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

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

![timeline.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/684932/951a7282-7bf2-f1a0-1bed-b6cf17f87e76.png)

D3.jsを使ってチャートを描画することに加えてドラッグ&ドロップやズームを使って画面操作によってデータを変更できるようなものを作っていきます。
(QiitaにアップロードできるGIFのサイズだとわかりにくいですね。。。)
<img width="500" alt="ファイル名" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/684932/ab5fa506-580c-1630-f572-cd413a50f766.gif">

## タイムラインチャートの実装
### 描画の実装
描画するところまでを説明します。

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

```typescript
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軸を描画します。

```typescript
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/684932/ed0a8ad4-0b83-e991-5905-b12478a2f8dd.png)

```ts
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領域の幅とパディングで計算して求めます。

```ts
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/684932/a70858a5-5287-b1c0-666e-cd8c5224c84b.png)

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

```ts
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/684932/b9acea94-16ee-061b-b902-717936ecee60.png)

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

```ts
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


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

```ts
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/684932/292a576a-7c41-4dd0-601d-f97984caa285.png)

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

```ts
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/684932/0cf0e1b0-1cea-5794-391c-9f049995b84c.gif)

```ts
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もこれからよく使うので外だししておきます。ここは現段階では深く気にしないで良いです。

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

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

```ts
var dragStart = function (d) {
  d3.event.sourceEvent.stopPropagation()
  d3.select(this)
    .classed('dragging', true)
    .style('opacity', 0.7)
}
```
ドラッグの開始時のイベントリスナでは、ドラッグした要素のクラスを付与し、ドラッグ中であることがわかるようにするため`opacity`の値を変更しておきます。

```ts
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/684932/98e23379-8f31-18b9-f165-a5c22b68514d.png)


```ts
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分刻みで移動しているのがわかるようになります。

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

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

```ts
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/684932/873c4292-4e51-14fa-b6b5-59b097e7d02c.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のライブラリを追加します。

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

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

```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を使って、タイムラインチャートを表示する画面を作成します。

```sh
$ 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`にルーティングを追加します。

```typescript: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文を追加します。

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

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

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

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

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

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