作ってるアプリにタイムラインチャートを組み込もうと思っても、案外グラフ系のオープンソースライブラリだと表現できないこととかありますよね。今回はD3.jsを使ってタイムラインチャートを実装する方法を説明していきます。
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)
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()
を使って軸のメモリの長さを指定しています。負の値を指定することで、軸に対して下方向にメモリを伸ばすのではなく、上方向に伸びるようにしています。
上記の変数の関係をまとめるとこんな感じになります。
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
で半文字分下方向へシフトするように指定します。
データの描画
まずはデータセットをバインドするコードです。
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なんてのがありましたが、今は無くなってしまいました)。
ここまでのコードで、下記のチャートが作成できます。
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)
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を足してあげてもできたかもしれません。。。
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イベントをバインドしておきます。
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
にルーティングを追加します。
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文を追加します。
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プロジェクトの設定は完了です。