D3.jsでExcelの「ウィンドウ枠を固定」みたいな動きをつくる

## はじめに: やりたいこと

タイトルわかりますかね? 見てもらった方が早いので図を貼っておきます。こういうやつです。(こういう機能はなんて言えばいいんだろう?)

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154230/77196988-3d4c-dfdb-833e-e4256586595d.gif)

ひとつの SVG の中に複数の領域を作って、それぞれが drag できるようになっています。これの作り方について解説します。[Git リポジトリの情報可視化](https://qiita.com/corestate55/items/1642f1fa240b76f6585d) で作ったやつのいち機能ですね。

ちなみに、元ネタは [The D3 Graph Gallery](https://www.d3-graph-gallery.com/index.html) の [Zooming in d3\.js](https://www.d3-graph-gallery.com/graph/interactivity_zoom.html#axisZoom) です。そちらでは座標軸 (目盛り) 位置を固定して、プロットされている要素のズームやドラッグをやっています。ここではドラッグだけで、かつ x/y 軸部分の領域は縦または横のみに動く、という形です。やってることは大して違いがないので、これがわかれば同じような話に応用が利くはずです。

上のデモは CodePen で作ってあるのでそちらで触ってみてください。以下、このコードの内容を説明します。

<p class="codepen" data-height="265" data-theme-id="light" data-default-tab="js,result" data-user="corestate55" data-slug-hash="PowGbrM" style="height: 265px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;" data-pen-title="d3-multi-part-drag">
  <span>See the Pen <a href="https://codepen.io/corestate55/pen/PowGbrM">
  d3-multi-part-drag</a> by m.hagiwara (<a href="https://codepen.io/corestate55">@corestate55</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://static.codepen.io/assets/embed/ei.js"></script>

## 用語定義

以下のように表示領域に名前を付けています。

![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154230/83fb419d-d3a3-f205-9653-d083e64cb075.png)

## 使われている SVG の機能

D3.js で作る図は SVG で構成されているので、D3.js での実装はいったん置いておいて、まずは使われている SVG 要素の機能や使い方から押さえましょう。

### Group (`g`) と座標変換 (`transform`)

* [g \- SVG: Scalable Vector Graphics \| MDN](https://developer.mozilla.org/ja/docs/Web/SVG/Element/g)
* [transform \- SVG: Scalable Vector Graphics \| MDN](https://developer.mozilla.org/ja/docs/Web/SVG/Attribute/transform)

Group (`g`) はコンテナで、複数の要素をまとめられます。Group にたいして `transform` を設定すると、group の子要素になっている SVG 要素全体に対して座標系を設定する (座標変換を行う) ことができます。今回の場合、各表示領域の左上 (上の図で丸がついているところ) がその表示領域の原点になるように設定しています。単純な平行移動 (`translate(x, y)`)です。ここの「座標系の設定」がとても重要で、SVG 領域で見たときと、各領域 (group) で見たときに扱う座標系が異なってきます。

これを示すためにデモの方は各領域内での座標系と SVG 視点(青) で見たときの座標系で特徴的なポイントを表示しています。

![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154230/53e38dfc-d0f8-08bb-3cf7-756d1ca3506a.png)

たとえば SVG 座標系から見ると `[200, 100]` のところが以下のようになります。視点が違うので座標が異なりますが、「SVG 図上の位置」としては同じポイントを指しています。

* xAxis 座標系では `[0, 100]`
* yAxis 座標系では `[200, 0]`
* plotArea 座標系では `[0, 0]`

こうして座標系を設定することにより、表示領域が接する部分の座標(値)をそろえることができました。各領域内の要素をどう動かすかについては実装 (イベントハンドラ) の所ですね。

### 図形のクリッピング (`clipPath`)

さて、座標系を設定しましたがまだ「表示領域」が設定されていません、このままだと group の「外」にしたい所にオブジェクトを描画しても表示されます。SVG の表示領域内であればオブジェクトが描画できるのでまああたりまえですね。「特定の領域からはみ出したオブジェクトについては描画しない」を実現するために `clipPath` (クリッピング) を使用します。

* [クリッピングとマスキング \- SVG: Scalable Vector Graphics \| MDN](https://developer.mozilla.org/ja/docs/Web/SVG/Tutorial/Clipping_and_masking)

表示領域 =「この範囲内にだけオブジェクトを描きたい範囲」を SVG 要素として設定します。ここでは矩形 (`rect`) なので、各エリアそれぞれで `clipPath` として使う `rect` を設定します。表示領域設定用の `rect` は `defs` セクションの中に置いて、要素を再利用可能にします。それを group から参照するという形になります。表示領域用 `rect` を複数の要素に対して適用するためにさらに group を作ります。具体的には以下のような階層を各領域ごとに作ります。

```
+ SVG
  + Group (座標系の設定)
    + defs (再利用可能な要素の定義)
    | + clipPath
    |   + Rect (表示領域の設定)
    + Group (描画オブジェクトをまとめて clipPath を適用する) 
      + 描画対象のオブジェクト
```

上の階層からわかりますが、表示領域用 `rect` は座標系を設定した group の子要素になるので、各領域の座標系で位置・サイズを指定することになります。`clipPath` を適用したグループ内にある SVG 要素は、領域からはみ出した部分はクリッピングされて表示されなくなります。こうして「表示領域」が設定されます。

## d3.js での実装


### Group と clipPath

まずは各表示領域: `g`/`clipPath` のデータを作っていきます。コメント入れてありますが、どこから見た何の値なのかに気をつけてください。基準にする座標系が異なる設定値が混ざっているので、そこを間違うと上手くいきません。[^naming]

[^naming]: [Git リポジトリの情報可視化](https://qiita.com/corestate55/items/1642f1fa240b76f6585d) のコードを元にデモ用のサンプルコードをかいたので変数名とかプロパティ名がそれに引きずられていますが、もうちょっと名前を工夫するとか考えた方が良かった気がしますね…。

```javascript
const graphData = [
  {
    area: 'xAxis', // 領域名 (id とかに使う)
    px: px0,       // SVG 座標系から見たときの xAxis 座標系の位置 (transform する移動量)
    py: 0,
    clipPath: {    // 表示領域の設定: xAxis 座標系から見たときの clipPath の位置とサイズ
      x: 0,
      y: 0,
      width: width1,
      height: py0
    }
  },
  // 省略
]
```

このデータに基づいてそれぞれの SVG 要素を作っていきます。まず group の作成と座標系の設定から。

```javascript
const groups = svg.selectAll('g')
  .data(graphData)
  .enter()
  .append('g')
  .attr('id', d => `${d.area}-group`)
  .attr('transform', d => `translate(${d.px},${d.py})`) // SVG 座標系から見たときの領域座標系(原点)の位置
```

座標系を設定したらその下に `clipPath` と `clipPath` を適用する group (各領域の中で実際に描画するオブジェクトを格納するコンテナ) を作ります。

```javascript
groups
  .append('defs')
  .append('SVG:clipPath')        // clipPath の定義
  .attr('id', d => `${d.area}-clip`)
  .append('rect')                // clipPath として使う四角形 (rect) を設定
  .attr('x', d => d.clipPath.x)  // 各領域の下につくるので、その領域の座標系で位置とサイズを指定
  .attr('y', d => d.clipPath.y)
  .attr('width', d => d.clipPath.width)
  .attr('height', d => d.clipPath.height)
groups
  .append('g')
  .attr('class', 'clipped-group')
  .attr('clip-path', d => `url(#${d.area}-clip)`) // URLで clipPath 領域を参照
```

### 要素の描画

あとは、各表示領域ごとに `clipPath` が適用されている group の中に描画対象のオブジェクトを入れていきます。もちろんそれらも、各表示領域の座標系で位置を指定します。

### ドラッグしてオブジェクトを動かす

各領域ごとに `drag` イベントハンドラを設定しますが、個々のオブジェクトに設定するのではなく、表示領域全体でドラッグして動かせるようにするので、イベントをキャプチャするための要素 (`rect`) を追加します。また、イベントキャプチャ用の `rect` は、これ自体はドラッグの対象にしないので `clipPath` を適用する group の外に置きます。

```
+ SVG
  + Group (座標系の設定)
    + defs (再利用可能な要素の定義)
    | + clipPath
    |   + Rect (表示領域の設定)
    + Group (描画オブジェクトをまとめて clipPath を適用する) 
    | + 描画対象のオブジェクト
    + Rect (ポインタイベント)   <= 追加, イベントキャプチャのため最後(一番上)におく
```

```javascript
groups
  .append('rect')
  .attr('class', d => `${d.area} drag-handler`) // ポインタイベントキャプチャ用の rect
  .attr('x', d => d.clipPath.x)                 // 位置とサイズは clipPath rect とおなじ
  .attr('y', d => d.clipPath.y)
  .attr('width', d => d.clipPath.width)
  .attr('height', d => d.clipPath.height)
```

ここでポイントなのは CSS の方で `pointer-events: all` を設定しておくところです。デモでは各表示領域の範囲がわかりやすいように塗りつぶしアリにしているのですが、実際には `fill: none` (塗りつぶしナシ) に設定するでしょう。そうなるとデフォルトではイベントをつかんでくれなくなるので、見えなくてもこのオブジェクトでポインタイベントをつかんでね、と指定しておきます。

* [pointer\-events \- CSS: カスケーディングスタイルシート \| MDN](https://developer.mozilla.org/ja/docs/Web/CSS/pointer-events)

```scss
rect.drag-handler {
  pointer-events: all;
}
```

今回は、各領域に描画するオブジェクトに対して特にポインタイベントを設定しないので、このイベントハンドラ用 `rect` を最前面に持ってきています。が、もし個々のオブジェクトにもイベントハンドラを設定したい場合、こいつが一番上にあるとすべてのポインタイベントを奪ってしまうので動きません。オブジェクトの順序を変える (重なり方を変える) とかの工夫が必要になります。

あとはポインタイベント用 `rect` に対してイベントハンドラを設定してやるだけです。xAxis/yAxis では x/y いずれか片方の移動量だけを渡すようにしてあります。これは `dragAllMarker` は三つの領域すべての要素に対して指定された移動量でオブジェクトを動かすからですね。xAxis/yAxis では縦横どちらか片方向の成分を捨てないと狙った動きになりません。たとえば xAxis 領域内で縦にドラッグすると yAxis/plotArea 内の要素が縦に動く、という状況になります。

```javascript
const dragHandlerOf = {
  'xAxis': () => dragAllMarker(d3.event.dx, 0), // 横方向のみひろう
  'yAxis': () => dragAllMarker(0, d3.event.dy), // 縦方向のみひろう
  'plotArea': () => dragAllMarker(d3.event.dx, d3.event.dy)
}
Object.keys(dragHandlerOf).forEach(area => {
  selectGroup(area)
    .select('rect.drag-handler')
    .call(d3.drag().on('drag', dragHandlerOf[area]))
})
```

## Drag するのはオブジェクトか座標系か

今回のデモと同じような動作を実現させるときに、2 種類やり方があると思います。

* 座標系を固定してオブジェクトを動かす (今回のデモで実装しているのはこっち)
* オブジェクトを固定して座標系を動かす

前者が視点を固定してモノを動かす方法、後者はモノを固定して視点を動かすもの、という言い方でもよいかな。後者・座標系を動かす場合は、ドラッグイベントに対して各座標系の `transform` の値を変えていくことになります。個々のオブジェクトに対して位置計算をするのではなく、座標系の方をいじった方が簡単になるような気もしますが、合わせて `clipPath` やポインタイベント用 `rect` の位置も変えていかないといけないんですよね。考え方がちょっとややこしいのでやっていません。[^notice]

[^notice]: あと、ちょっと試してみたところ、SVG 座標系に対してどの位置に設定するかという操作をするためか、ドラッグした際にカクつくのがあったので。でも、そのときの自分のコードが悪かったかもしれない。あまりちゃんと調べられていないのでこのあたりはもうちょっと調べてみてもいいかな……。

## おわりに

複数の表示領域で、中身を個別に動かす・連動して動かす、というのをどうやるかについて説明しました。座標系と表示範囲の設定方法がわかれば、あとは通常のイベントハンドリングでできます。いくつか SVG の機能について知らないとできないので、SVG について知っているかどうかが結構効いてきますね。SVG ドキュメントの構造や座標系設定などに注意しましょう。このあたりの理屈がわかれば、表示領域を増やしたり、zoom を加えたり……みたいな応用はいろいろコントロールできるようになると思います。