D3.js -- 睡眠時間をグラフにプロットする

概要

  • 睡眠時間をタイムテーブル(時間割のイメージ)にプロットしたい

結論

  • 作ろうと思えば作れないものはない

想定しているデータ形式

自環境ではDjangoからJSON形式でデータを取得している。

let sleepList = [
  [
    new Date("2018-02-14 23:55:55"),    // 睡眠開始時刻
    new Date("2018-02-15 05:55:55"),    // 起床時刻
  ],
  [
    new Date("2018-02-16 00:10:00"),
    new Date("2018-02-16 00:06:15"),
  ],
];

日付変更線をまたぐデータを分割する

SVG の rect 要素を使ってバーを表示したいので、グラフが 00:00:00 から開始していると
データを分割する必要が出てくる(詳しくはこちら)。

(何はなくとも)スケールを用意する

/* x 方向のスケールを作成する */
let nDates = 7;    // 一週間分のデータを表示する
let x = d3.scaleLinear()
  .domain([0, nDates])
  .range([margin.left, width - margin.right]);

y軸を 00:00:00 ~ 24:00:00 としたいので、

/* y 方向のスケールを作成する */
let y = d3.scaleLinear()
  .domain([0, 24])    // 00:00:00 ~ 24:00:00
  .range([margin.top, height - margin.bottom]);

で足りる。

睡眠データを取得する

/* 睡眠データ(JSON)を取得する */
d3.json("/events/sleeps-json-week.json/", function (error, data) {

  /* エラー時の処理 */
  if (error !== null) {
    console.log(error);
    return;   
  }

  /* JSONデータの取得に成功した場合 */
  let sleepList = [];
  for (let id in data) {
    let record = data[id];    // レコード 1 件
    sleepList.push(
      [new Date(record.start), new Date(record.finish)]
    );
  }

  /* 日付変更線をまたぐデータを分割する */
  let divided = divideByDateLine(sleepList);

  // (続く..)

});    // end of d3.json() ...

日付文字列のリストを用意する

以下のようにする。

/* 日付文字列のリストを取得する */
let tod = new Date();  
let dateStrings = [];
let tmpDate = null;
for (let i = 0; i < nDates; i++) {
  tmpDate = new Date();
  tmpDate.setDate(tod.getDate() - i);
  let ymd = tmpDate.toLocaleString().split(" ")[0];
  let md = ymd.substring(5);    // 年を取り除く
  dateStrings.push(md);
}
dateStrings.reverse();
  • x軸に日付を表示する
  • 睡眠時間(rect要素)をプロットする

ために、一週間前 ~ 現在時刻の日付文字列のリストを用意する。
特に、後者について日付文字列のリストが必要になるのは、
一日に取る睡眠の回数が一回とは限らないからである。
同じ日に複数回睡眠を取った場合のrect要素のx座標は同じであるべきなので、
日付文字列のリスト.indexOf(睡眠を取った日時)を利用する(このようにすれば、同一日に取った睡眠はに対しては同じインデックスが返る)。

軸と目盛りを作成する

この辺りは通常通り。

/* x 軸と目盛りを作成する */
let xAxis = svg.append("g")
  .attr("class", "x_axis")
  .attr(
    "transform",
    "translate(" + 
      [
        0,
        height - margin.bottom
      ].join(",") + ")"
  )
  .call(
    d3.axisBottom(x)
      .ticks(nDates)
      .tickSize(-height + margin.top + margin.bottom)
      .tickFormat(function (d, i) {
        return dateStrings[i];
      })
  );

/* y 軸と目盛りを作成する */
svg.append("g")
  .attr("class", "y_axis")
  .attr(
    "transform",
    "translate(" +
      [
        margin.left,
        0
      ].join(",") + ")"
  )
  .call(
    d3.axisLeft(y)
      .ticks(24)
      .tickSize(-width + margin.left + margin.right)
      .tickFormat(function (d) {
        return d + ":00";
      })
  );

rect要素で睡眠時間をプロットする

rect要素の座標とサイズについて、

  • x座標 → xスケール( 睡眠日時のインデックス )
  • y座標 → yスケール( 睡眠開始時刻 )
  • 横幅 → 一週間分のデータをプロットするので、描画領域の横幅 / 7
  • 高さ → (睡眠終了時刻 - 睡眠開始時刻) / 1日トータルのミリ秒 x 描画領域の高さ

で描画できる。
rect要素の高さでミリ秒を使用しているのは、
JavaScriptにおいて、Date(睡眠終了時刻) - Date(睡眠開始時刻) でミリ秒が返るためである。

/* バーを描画する */
let innerWidth = width - margin.left - margin.right;    // 内側の表示部分 (横幅)
let innerHeight = height - margin.bottom - margin.top;    // 内側の表示部分 (高さ)
let barWidth = innerWidth / nDates;    // バーの横幅
let bars = svg.selectAll("rect")
  .data(divided)
  .enter()
  .append("rect")
  .attr("x", function (d, i) {
    let start = d[0];
    let startYmd = start.toLocaleString().split(" ")[0];
    let startMd = startYmd.substring(5);    // 2018/02/15 → 02-15
    return x(dateStrings.indexOf(startMd));
  })
  .attr("y", function (d, i) {
    let start = d[0];
    return y(toHour(start));
  })
  .attr("width", barWidth)
  .attr("height", function (d, i) {
    let milliSecInDay = 86400000;    // Date - Date でミリ秒が返るため
    let start = d[0];
    let finish = d[1];
    return innerHeight * (finish - start) / milliSecInDay;
  })
  .attr("fill", "steelblue")
  .style("opacity", 0.6);

/* その日の始めから何秒経過したかを返す */
function toSec(d) {
  let elapsed = d.getHours() * 60 * 60 + 
    d.getMinutes() * 60 +
    d.getSeconds();
  return elapsed;
}

/* その日の始めから何時間経過したかを返す */
function toHour(d) {
  let sec = toSec(d);    // 経過秒
  let secInDay = 86400;    // 24 * 60 * 60
  return sec / secInDay * 24;
}

x軸の日付の位置を修正する

ここでも barWidth が利用できる。

/* 目盛りの日付文字列の位置を調整する */
xAxis.selectAll("g.tick")
  .selectAll("text")
  .attr(
    "transform",
    "translate(" + 
      [
        barWidth / 2,
        0
      ].join(",") + ")"
  );

動作イメージ

以下のようなテストデータを投入してみると、

let sleepList = [
  [
    new Date("2018-02-09 21:00:00"),
    new Date("2018-02-10 03:00:00"),
  ],
  [
    new Date("2018-02-10 22:00:00"),
    new Date("2018-02-11 04:00:00"),
  ],
  [
    new Date("2018-02-11 23:00:00"),
    new Date("2018-02-12 05:00:00"),
  ],
  [
    new Date("2018-02-12 22:59:30"),
    new Date("2018-02-13 05:05:14"),
  ],
  [
    new Date("2018-02-13 12:45:00"),    // ギリギリまで
    new Date("2018-02-13 12:59:59"),    // 昼寝
  ],
  [
    new Date("2018-02-14 00:00:30"),
    new Date("2018-02-14 06:00:54"),
  ],
  [
    new Date("2018-02-14 23:55:55"),    // 睡眠開始時刻
    new Date("2018-02-15 05:55:55"),    // 起床時刻
  ],
  [
    new Date("2018-02-16 00:10:00"),
    new Date("2018-02-16 00:06:15"),
  ],
];

このようになる。
f:id:zdassen:20180215131603j:plain