LoginSignup
17
18

More than 3 years have passed since last update.

D3.jsでタイムラインチャートを作る

Last updated at Posted at 2020-08-16

作ってるアプリにタイムラインチャートを組み込もうと思っても、案外グラフ系のオープンソースライブラリだと表現できないこととかありますよね。今回はD3.jsを使ってタイムラインチャートを実装する方法を説明していきます。

timeline.png

D3.jsを使ってチャートを描画することに加えてドラッグ&ドロップやズームを使って画面操作によってデータを変更できるようなものを作っていきます。
(QiitaにアップロードできるGIFのサイズだとわかりにくいですね。。。)
ファイル名

タイムラインチャートの実装

描画の実装

描画するところまでを説明します。

データセットの準備

今回は、1日のスケジュールを、時間軸を持ったタイムライチャートで表現します。
1日分のスケジュールを一つのチャートにする体で、各スケジュールごとに開始時間・終了時間を持つデータ構造とします。

var category = ['睡眠', '朝ご飯', '洗濯', 'テレビ', 'YouTube', 'ゲーム', '昼ごはん', 'ランニング', 'パソコン', '夜ご飯', 'お風呂']
var datasets = [
  {
    date: moment('2020/08/11'),
    schedule: [
      { categoryNo: 0, from: moment('2020/08/11 00:00:00'), to: moment('2020/08/11 07:00:00') },
      { categoryNo: 1, from: moment('2020/08/11 07:30:00'), to: moment('2020/08/11 08:30:00') },
      { categoryNo: 3, from: moment('2020/08/11 08:30:00'), to: moment('2020/08/11 10:00:00') },
    ]
  }
]

x軸の描画

下記のコードでSVG領域にx軸を描画します。

var width = 900, height = 200
var padding = { top: 10, right: 10, bottom: 10, left: 75 }
var dataset = datasets[0]
var timelineStartTime = moment(dataset.date.startOf('day'))
var timelineEndTime = moment(moment(dataset.date)).add(1, 'days').startOf('day')
var svg = d3.select('#timeline-chart')
  .append('svg')
  .attr('width', width)
  .attr('height', height)
var xScale = d3.scaleTime()
  .domain([timelineStartTime, timelineEndTime])
  .range([padding.left, width - padding.right])
var chartHeight = height - padding.top - padding.bottom
var xAxis = d3.axisBottom(xScale)
  .ticks(10)
  .tickSize(-chartHeight)
  .tickFormat(d3.timeFormat('%H:%M'))
var gX = svg.append('g')
  .attr('transform', 'translate(' + 0 + ',' + (height - padding.bottom) + ')')
  .call(xAxis)

timeline-x軸.png

var xScale = d3.scaleTime()
  .domain([timelineStartTime, timelineEndTime])
  .range([padding.left, width - padding.right])

時間軸を使う場合のスケールは、d3.scaleTime() https://github.com/d3/d3-scale/blob/v2.2.2/README.md#scaleTime を使用します。他のスケールのAPIでは、時間軸を等間隔に描画することができません。
time.domain()にはスケールのインプットを指定します。予め描画する時間幅を決めておいてその開始・終了の時間を入力します。
time.range()にはアウトプットの座標位置を指定します。SVG領域の幅とパディングで計算して求めます。

var chartHeight = height - padding.top - padding.bottom
var xAxis = d3.axisBottom(xScale)
  .ticks(10)
  .tickSize(-chartHeight)
  .tickFormat(d3.timeFormat('%H:%M'))

axis.tickSize()を使って軸のメモリの長さを指定しています。負の値を指定することで、軸に対して下方向にメモリを伸ばすのではなく、上方向に伸びるようにしています。

上記の変数の関係をまとめるとこんな感じになります。

timeline-x軸-位置関係.png

y軸の描画

今回は1日分のスケジュールを一つのチャートにするので、y軸にはその日のラベルを表示すれば十分ですね。

var dateDispFormat = 'M月D日'
var chartCenterY = padding.top + chartHeight / 2
svg.append('text')
  .text(dataset.date.format(dateDispFormat))
  .attr('x', 10)
  .attr('y', chartCenterY)
  .attr('dy', '0.5rem')

text要素は指定したy座標の値の上に位置するように描画されるため、dyで半文字分下方向へシフトするように指定します。

timeline-y軸-シフト.png

データの描画

まずはデータセットをバインドするコードです。

var scheduleG = svg.selectAll()
  .data(dataset.schedule)
  .enter()
  .append('g')

こちらはデータセットをバインドする際の構文で、d3.selectAll()でDOMに存在していない要素を選択することで、空のセレクションを取得し、selection.data()でデータセットをバインドします。selection.enter()でエンター領域(これから要素を追加する領域)に対してselection.apeend()g要素を追加していきます。
データのバインド方法は公式ドキュメントも参考にしてください。https://github.com/d3/d3-selection/blob/v1.4.1/README.md#selection_data

次に、短形を描画するコードです。

var schedule = scheduleG.append('rect')
  .attr('x', function (d) { return xScale(d['from']) })
  .attr('y', chartCenterY - barHeight / 2)
  .attr('width', function (d) { return xScale(d['to']) - xScale(d['from']) })
  .attr('height', barHeight)
  .attr('fill', function (d) { return d3.schemeCategory10[d.categoryNo % 10] })

ラベルと同様、rect要素もグラフ領域の真ん中に描画します。fill属性にはデータを表す短形の色を指定しますが、d3.schemeCategory10の配列を使って、スケジュールのカテゴリNO.を使って配色を決定します。現状、10個以上のカテゴリがある場合は、色が被ってしまいますがひとまずよしとします(D3.jsのv4にはd3.schemeCategory20なんてのがありましたが、今は無くなってしまいました)。

ここまでのコードで、下記のチャートが作成できます。

timeline-データ描画.png

Draggingの実装

画面からスケジュールデータの時間を変更するためにDraggingを実装していきます。下記のコードでスケジュールの短形をドラッグ&ドロップで移動できます。

var zoomedXScale = xScale
var calcScheduleX = function (d) { return zoomedXScale(d['from']) }
var calcScheduleWidth = function (d) { return zoomedXScale(d['to']) - zoomedXScale(d['from']) }

var makeRoundTime = function (time) {
  var roundMinutesStr = ('0' + String(Math.round(time.minute() / 5) * 5)).slice(-2)
  if (roundMinutesStr === '60') {
    roundMinutesStr = '00'
    time.add(1, 'hours')
  }
  return moment(time.format('YYYY/MM/DD HH:' + roundMinutesStr.slice(-2) + ':00'))
}
var dragStart = function (d) {
  d3.event.sourceEvent.stopPropagation()
  d3.select(this)
    .classed('dragging', true)
    .style('opacity', 0.7)
}
var dragEnd = function (d) {
  d['from'] = makeRoundTime(d['from'])
  d['to'] = makeRoundTime(d['to'])
  d3.select(this)
    .classed('dragging', false)
    .style('opacity', 1)
    .attr('x', calcScheduleX)
    .attr('width', calcScheduleWidth)
}
var scheduleDrag = d3.drag()
  .on('start', dragStart)
  .on('drag', function (d) {
    var between = d['to'].diff(d['from'], 'minutes')
    var fromTime = moment(zoomedXScale.invert(zoomedXScale(d['from']) + d3.event.dx))
    var toTime = moment(fromTime).add(between, 'minutes')

    if (timelineStartTime.diff(fromTime) > 0) return;
    else if (timelineEndTime.diff(toTime) < 0) return;

    d['from'] = fromTime
    d['to'] = toTime
    d3.select(this).attr('x', calcScheduleX)
  })
  .on('end', dragEnd)
schedule.style('cursor', 'move')
  .style('mix-blend-mode', 'multiply')
  .call(scheduleDrag)

timeline-dragging_small.gif

var zoomedXScale = xScale
var calcScheduleX = function (d) { return zoomedXScale(d['from']) }
var calcScheduleWidth = function (d) { return zoomedXScale(d['to']) - zoomedXScale(d['from']) }

zoomedXScale(スケールのfunction)はあとでZoomingを実装した際にスケールが変わったものを再代入するために、変数を用意しておきます。短形の位置を求めるfunctionもこれからよく使うので外だししておきます。ここは現段階では深く気にしないで良いです。

d3.drag()
  .on('start', function(d){})
  .on('drag', function(d){})
  .on('end', function(d){})

D3.jsのドラッグは上記の構文で実装することを念頭においてもらって、

var dragStart = function (d) {
  d3.event.sourceEvent.stopPropagation()
  d3.select(this)
    .classed('dragging', true)
    .style('opacity', 0.7)
}

ドラッグの開始時のイベントリスナでは、ドラッグした要素のクラスを付与し、ドラッグ中であることがわかるようにするためopacityの値を変更しておきます。

var scheduleDrag = d3.drag()
  .on('start', dragStart)
  .on('drag', function (d) {
    var between = d['to'].diff(d['from'], 'minutes')
    var fromTime = moment(zoomedXScale.invert(zoomedXScale(d['from']) + d3.event.dx))
    var toTime = moment(fromTime).add(between, 'minutes')

    if (timelineStartTime.diff(fromTime) > 0) return;
    else if (timelineEndTime.diff(toTime) < 0) return;

    d['from'] = fromTime
    d['to'] = toTime
    d3.select(this).attr('x', calcScheduleX)
  })
  .on('end', dragEnd)

イベントリスナのd3.event.dxでx軸方向の移動量が取得できるので、これを使って移動後の各値を計算していきます。計算方法は以下の図のような感じになっています。シンプルにtoにdxを足してあげてもできたかもしれません。。。

timeline-dragging-位置計算.png

var dragEnd = function (d) {
  d['from'] = makeRoundTime(d['from'])
  d['to'] = makeRoundTime(d['to'])
  d3.select(this)
    .classed('dragging', false)
    .style('opacity', 1)
    .attr('x', calcScheduleX)
    .attr('width', calcScheduleWidth)
}

ドラッグの終了時のイベントリスナで、再度from-toの値を更新します。これは、makeRoundTimeというfunctionでドラッグ後の時間を5分刻みの値に繰り上げもしくは繰り下げしています。こうすることで例えば20:56の位置にドロップしても20:55に直したりできます。今のスケールでは分単位で値を参照することはできませんが、あとでZoomingを実装するとメッシュを変更できるようになるので、5分刻みで移動しているのがわかるようになります。

schedule.style('cursor', 'move')
  .style('mix-blend-mode', 'multiply')
  .call(scheduleDrag)

cursorクラスをmoveに変更し、ドラッグ&ドロップできることを見た目で表現します。次に、mix-blend-modeで短形が重なった場合にその状態を見た目で表現します。ここは本当は、ドラッグ中に隣り合う短形のオブジェクトどぶつかる場合には、ぶつかった短形のオブジェクトも一緒に移動するようなUIがベストであるとは思います。最後に、作成したドラッグのfunctionをバインドして、Draggingの実装は終わりです。

Zoomingの実装

今、画面の初期表示時に24時間のスケジュールを表示している状態です。Zoomingを実装することでチャートのスケールを変更できるようにし、分単位の時間まで確認できるように実装していきます。

var zoomed = function () {
  zoomedXScale = d3.event.transform.rescaleX(xScale)
  gX.call(xAxis.scale(zoomedXScale))
  schedule.attr('x', calcScheduleX)
    .attr('width', calcScheduleWidth)
}
var zoom = d3.zoom()
  .scaleExtent([1, 20])
  .translateExtent([[0, 0], [width, 0]])
  .on('zoom', zoomed)
svg.call(zoom)

まず、zoomedと名付けたイベントリスナにd3.event.transform.rescaleX()で取得できる新しいスケールでオブジェクトを更新し、さらにx軸のオブジェクトであるgXにそのスケールをバインドしておきます。さらに、ズーム後のスケールで短形の位置を更新します。

d3.zoom()ではzoom.scaleExtent()で倍率を、zoom.translate()でズーム領域を指定します(https://github.com/d3/d3/blob/master/API.md#zooming-d3-zoom )。今回はy軸方向にはズームする必要がないため、yの値には0を設定しておきます。最後にsvgのオブエクトにzoomイベントをバインドしておきます。

timeline-zooming.gif

GIFだとわかりにくですが、ズームした後に短形をドラッグすると5分刻みの軸にピタッと寄ることがわかします。以上でZoomingの実装は終わりです。

付録

開発環境

  • macOS Caralina 10.15.6
  • Node.js 12.18.3
  • Angular CLI 10.0.5
  • Angular 10.0.8
  • Visual Studio Code
  • D3.js 5.16.0
  • Moment.js 2.27.0

Angularの準備

ライブラリの追加

AngularのプロジェクトにMomnet.jsとD3.jsのライブラリを追加します。

$ npm install --save moment
$ npm install --save d3
$ npm install --save-dev @types/d3

ライブラリを追加すると、package.jsonは以下のようになります。

{
  "name": "client",
  "version": "0.0.0",
  "scripts": {
    ...
  },
  "private": true,
  "dependencies": {
    ...
    "d3": "^5.16.0",
    "moment": "^2.27.0",
    ...
  },
  "devDependencies": {
    ...
    "@types/d3": "^5.7.2",
    ...
  }
}

開発用の画面を追加

コマンドラインからAngular-CLIを使って、タイムラインチャートを表示する画面を作成します。

$ ng generate component d3-timeline-chart
CREATE src/app/d3-timeline-chart/d3-timeline-chart.component.scss (0 bytes)
CREATE src/app/d3-timeline-chart/d3-timeline-chart.component.html (32 bytes)
CREATE src/app/d3-timeline-chart/d3-timeline-chart.component.spec.ts (693 bytes)
CREATE src/app/d3-timeline-chart/d3-timeline-chart.component.ts (318 bytes)
UPDATE src/app/app.module.ts (515 bytes)

コンポーネントを作成すると、app-routing-module.tsにルーティングを追加します。

app-routing-module.ts
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { D3TimelineChartComponent } from './d3-timeline-chart/d3-timeline-chart.component'; //追加

const routes: Routes = [
  { path: 'd3-timeline-chart', component: D3TimelineChartComponent } //追加
];

@NgModule({
  imports: [RouterModule.forRoot(routes)],
  exports: [RouterModule]
})
export class AppRoutingModule { }

ルーティングを追加したので、開発時にはブラウザでhttp://localhost:4200/d3-timeline-chartにアクセスして、動作確認をしていきます。

import文の追加

追加した画面のTypeScriptファイルであるd3-timeline-chart.component.tsを開いて、Moment.jsとD3.jsのimport文を追加します。

d3-timeline-chart.component.ts
import * as moment from 'moment';
import * as d3 from 'd3';

次に、同じく追加した画面のHTMLファイルであるd3-timeline-chart.component.htmlを開いて、タイムラインチャートをバインドするHTMLタグを追加しておきます。

<div id="timeline-chart"></div>

動作確認

無事にライブラリが追加できているか確認します。d3-timeline-chart.component.tsに下記のコードを書いて、ブラウザでhttp://localhost:4200/d3-timeline-chartにアクセスし、Hello!と表示されることを確認します。

d3.select('#timeline-chart').append('d').text('Hello!')

以上でD3.jsでタイムラインチャートを作成するための、Angularプロジェクトの設定は完了です。

17
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
18