Tooltipの位置をSVG要素の位置を元に決める

## はじめに

SVG 図を描いたときにマウスオーバーでツールチップを表示して付加的な情報を出したい、というのはよくありますよね。ツールチップも、[SVG 要素に `title` を設定する簡易的な方法](http://bl.ocks.org/ilyabo/1339996) からツールチップ用の `div` に対して visibility と position を設定する汎用的なものまであります。今回は後者の汎用的なものの話です。

D3.js で汎用的なツールチップを作る場合、ツールチップの位置を指定するのにマウスポインタの位置 (`d3.event.page[XY]`) を使う方法がよく紹介されています。たとえば [Simple d3.js tooltips](https://bl.ocks.org/d3noob/a22c42db65eb00d4e369) とか。でも、マウスポインタ位置でツールチップの位置を指定する方法だと、マウスオーバーするオブジェクトに対してツールチップの表示位置が都度変わってしまうんですよね。オブジェクトの右側にマウスを乗せれば右側に寄るし、左側にマウスを乗せれば左側による。マウスポインタ位置を使う以上どうしてもそうなる。

ここでは、マウスオーバーに対して、マウスポインタ位置に依存せずいつも決まった位置にツールチップを出したい = オブジェクトに対してツールチップ位置を固定する方法についてとりあげます。とはいってもこれもすでに整理してくれてる人がいるわけで、[Positioning a Tooltip on an SVG](https://codepen.io/billdwhite/pen/rgEbc) がとても参考[^tooltip-ref] になります。ツールチップ作るだけならここのコードを元に適当にコピペするだけでも動くのですが、その中身で何をやっているのかを見てみましょう。

[^tooltip-ref]: この CodePen のデモは  [javascript - D3.js: Position tooltips using element position, not mouse position? - Stack Overflow](https://stackoverflow.com/questions/16256454/d3-js-position-tooltips-using-element-position-not-mouse-position) で紹介されていたものです。

## 実例

[Gitリポジトリの情報可視化](https://qiita.com/corestate55/items/1642f1fa240b76f6585d) で実際に使っているので、これを元に話を進めます。こんな形で、`rect` の位置を元に、(マウスポインタ位置によらず) `rect` の真下にツールチップを出しています。

![01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154230/05317625-62d8-7d3c-2e96-ad3a53c9b748.png)

このツールチップをこの位置で出すためにどういう処理をしているのかを解説してみます。[関係するコード](https://github.com/corestate55/gh-file-grass/blob/develop/src/js/gh_file_grass/operator.js)を見てみましょう。

```javascript
  _positionMatrixOfId(id) {
    const target = document.getElementById(id)
    // attribute .[xy] are only for rect and text.
    return target
      .getScreenCTM()
      .translate(+target.getAttribute('x'), +target.getAttribute('y'))
  }

  _enableTooltip(htmlStr, id) {
    const matrix = this._positionMatrixOfId(id)
    let yMargin = id.match(/commit.*label/) ? 1 : 2
    select('div#stat-tooltip')
      .style('visibility', 'visible')
      .style('left', `${window.pageXOffset + matrix.e - this.lc * 1.25}px`)
      .style('top', `${window.pageYOffset + matrix.f + this.lc * yMargin}px`)
      .html(htmlStr)
  }
```

`_enableTooltip()` はマウスオーバーで呼ばれる関数で、(1)ツールチップを visible にする (2)ツールチップの位置を設定する (3)ツールチップの中身(HTML)を設定する 処理をしています。そして (2)位置設定 では `_positionMatrixOfId()` が返す変換行列が使用されています。キモになるのはこの中にある `getScreenCTM()` ですね。

## 座標系の話

さて。`getScreenCTM()` の話をする前に、SVG 要素の位置情報を元にツールチップを出すために何をしなければいけないのかをおさらいしましょう。まずはマウスポインタ位置を使う方法について考えてみます。マウスポインタ位置を使う場合は具体的に「どこから見た」マウスポインタ位置をとっていたのか?

* [d3-selection/README.md at v1.4.1 · d3/d3-selection · GitHub](https://github.com/d3/d3-selection/blob/v1.4.1/README.md#event)
  * [UIEvent.pageX - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/pageX)
  * [UIEvent.pageY - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/pageY)

`pageY` についてみると

>  integer value in pixels for the y-coordinate of the mouse pointer, relative to the whole document

とあります。"ドキュメント全体" に対するマウスポインタの位置。つまり「ドキュメント全体の座標系で見て」どの位置にツールチップを出すべきかを決める必要がある。まあ図を見た方が早いので見てしまいましょう。上のコードではこういう座標変換をやっています。

![02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154230/d14944bc-147e-cea7-e740-78b6f1d96767.png)

具体的にやってみた方がわかりやすいので、[Gitリポジトリの情報可視化](https://qiita.com/corestate55/items/1642f1fa240b76f6585d) の実例をもとに座標値をとって上の図に入れています。順に見てみましょう。まず、ツールチップを表示させたい SVG 要素 (`rect#stat190`) は `(32,16)` の位置にあります。

![03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154230/2aad7cae-77ea-fd8d-9d6f-c134dac783f6.png)

このオブジェクトは SVG 左上を原点とする座標系から見て `(259, 120)` の位置に平行移動された座標系 (`g#stat-group`) の中にあります。

![04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154230/a58c445f-6ece-884c-947f-33f209cb9be7.png)

SVG 要素からわかるのはここまで。マウスオーバーが発生するのは `rect` ですが、オブジェクトにあるのは直近の座標系 (`g#stats-group`) から見たときの位置だけなので、どうにかしてこれを「ドキュメント全体の座標系」に変換してやらないといけないわけですね。

## Screen Current Transformation Matrix (Screen CTM)

もう上の図でバラしてしまっていますが、`getScreenCTM()` はスクリーン (ディスプレイ上に見えている範囲) の原点を基準にして、指定した要素の属する座標系までの変換行列を出してくれます。

参照:

* [Coordinate Systems, Transformations and Units – SVG 1\.1 \(Second Edition\)](https://www.w3.org/TR/2011/REC-SVG11-20110816/coords.html#NestedTransformations)
    * CTMに関する説明はここ
    * [座標系, 変換, 単位 – SVG 1\.1 （第２版）](https://triple-underscore.github.io/SVG11/coords.html#NestedTransformations)
* [Basic Data Types and Interfaces – SVG 1\.1 \(Second Edition\)](https://www.w3.org/TR/2011/REC-SVG11-20110816/types.html#__svg__SVGLocatable__getScreenCTM)
    * `getScreenCTM()` についての説明はここ
    * [基本データ型と基本インタフェース – SVG 1\.1 （第２版）](https://triple-underscore.github.io/SVG11/types.html#__svg__SVGLocatable__getScreenCTM)
* [SVGGraphicsElement - Web APIs | MDN](https://developer.mozilla.org/en-US/docs/Web/API/SVGGraphicsElement)
* [svg要素の基本的な使い方まとめ](http://defghi1977.html.xdomain.jp/tech/svgMemo/svgMemo_04.htm)

上の例で実際に計算してみましょう。

![05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154230/c9e77d5c-ad1c-fdd9-1218-79cfb050b0a6.png)

```math
\begin{bmatrix}
a & c & e \\
b & d & f \\
0 & 0 & 1
\end{bmatrix}
\begin{bmatrix}
x \\
y \\
1
\end{bmatrix}
=
\begin{bmatrix}
1 & 0 & 267 \\
0 & 1 & 128 \\
0 & 0 & 1
\end{bmatrix}
\begin{bmatrix}
x \\
y \\
1
\end{bmatrix}
=
\begin{bmatrix}
x + 267 \\
y + 128 \\
1
\end{bmatrix}
```

`getScreenCTM()` は上で書いたような座標変換計算のための変換行列を 出しています。平行移動しかないので x/y にかかる係数 $a, b, c, d$ は使われておらず、使うのはオフセット部分 $e, f$ だけですね。さて。あとは `svg` の位置がどこになるかですが、

* スクロールしていないのでドキュメント座標系とスクリーン座標系は一致 (`window.page[XY]Offset = 0`)
* ドキュメント内の余白 (`margin`/`padding`) は `body` で `margin: 8px` が設定されているだけ

![06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154230/c5bb733e-e605-3f94-0806-abaa480f86e8.png)

という状態。後は足し算をしてやればよい。

* pageXOffset + margin-left (body) + stats.transform.x = 0 + 8 + 259 = 267 = ScreenCTM.e
* pageYOffset + margin-top (body) + stats.transform.y = 0 + 8 + 120 = 128 = ScreenCTM.f

`rect` の所属する座標系の原点までの変換行列 (平行移動なので x/y の移動量) が求められていることがわかります。実装上は

```javascript
    return target
      .getScreenCTM() // screen 座養鶏で見た target (SVG要素) のある座標系の位置
      .translate(+target.getAttribute('x'), +target.getAttribute('y')) // target 位置まで平行移動
```
と、`getScreenCTM` のあとで `translate` して `rect` の位置を足し込んでいます。ここで返している変換行列はスクリーン座標系での `rect` の位置になるわけです。(それを下のコードでは `matrix` として受けている。)

```javascript
    select('div#stat-tooltip')
      .style('visibility', 'visible')
      .style('left', `${window.pageXOffset + matrix.e - this.lc * 1.25}px`)
      .style('top', `${window.pageYOffset + matrix.f + this.lc * yMargin}px`)
    //               ^^^^^^^^^^^^^^^^^^^^ 
    //               ドキュメント座標系で見た スクリーン座標系の原点
    //               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //               ドキュメント座標系で見た 対象 SVG 要素 (rect) の位置
```

ドキュメント原点 → スクリーン原点 (`page[XY]Offset`) → 対象オブジェクト位置 ( `matrix.[ef]`) が求められています。それを起点にしているので、 **ドキュメント全体の座標系で見たときの SVG 要素 (`rect`) 位置に対して** どこにツールチップを表示するかを計算している……ということです。

### おわりに

ツールチップの話でした。これも、どこから見た何の位置を求めるべきなのか、というのがちょっとわかりにくいですよね。まあグラフィクス関連は座標系の話からは逃れられないですが……。CTM についての説明はすごく単純な状況の計算だけ取り上げているので厳密にはちょっと違うとか説明が足りないとかはあるかも。変なところがあったら教えてください。