Reactで、折れ線グラフに乗せると値が出るツールチップを作る方法

​​React​CSS,Playwright,SVG,TypeScript

折れ線グラフのツールチップは、ポインターの横の位置から「いちばん近い点」を求めれば作れます。気を付けるのは、画面の px と SVG の viewBox の座標が、グラフを縮めると一致しなくなることです。getScreenCTM() で座標を直してから点を選べば、スマートフォンの幅でも、乗せた点の値が出ます。

ライブラリを使わずに、LineChart というコンポーネントを作りました。グラフの上でポインターを動かすと、いちばん近い月に縦の点線と丸が付き、その月の値がツールチップに出ます。データは架空の数字です。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。タッチは Playwright のタッチ端末の設定と、Chrome DevTools Protocol で送ったタッチ操作で試したもので、実機のスマートフォンでは試していません。Firefox と Safari、ペン入力、スクリーンリーダーでの読み上げも試していません。点が等間隔に並んでいないデータ(日付が飛び飛びの時系列など)は扱っていません。

月ごとの来店数の折れ線グラフ。上の帯に「操作 1/2:グラフを左から右へなぞる」と出て、灰色の丸のカーソルが左から右へ動く。カーソルが進むにつれて、縦の点線と白い丸が 1月、2月…12月 の点へ順に移り、ツールチップの値が「320人」「280人」…と変わる。値が変わった直後だけ、値の文字がピンクになる。7月からはツールチップが点線の左側に出る。続けて帯が「操作 2/2:グラフの外へ出す」に変わり、カーソルが下へ出ると、点線と丸とツールチップが消える
マウスでグラフをなぞって、外へ出したときの様子(上の帯、灰色の丸のカーソル、値の文字のピンクは GIF 用に重ねたもの)

ポインターの位置を、viewBox の座標に直す

座標の計算は、1つのファイルにまとめました。点の x は、左の余白から右の余白までを、点の数より1つ少ない数で等分した位置です。y は、値が 0 なら下の端、max なら上の端になるように割り当てます。

ts
export type Point = { label: string; value: number };

// グラフの大きさ(viewBox の単位)と、軸の文字のための余白
export const W = 640;
export const H = 300;
export const PAD = { top: 16, right: 16, bottom: 32, left: 48 };
export const PLOT_W = W - PAD.left - PAD.right;
export const PLOT_H = H - PAD.top - PAD.bottom;

// i 番目の点の x と、値 v の y(どちらも viewBox の単位)
export const xAt = (i: number, n: number) => PAD.left + (n > 1 ? (PLOT_W * i) / (n - 1) : PLOT_W / 2);
export const yAt = (v: number, max: number) => PAD.top + PLOT_H * (1 - v / max);

// 画面上のポインターの位置(clientX, clientY)を、viewBox の座標に直す
export function toSvgPoint(svg: SVGSVGElement, clientX: number, clientY: number): DOMPoint | null {
  const m = svg.getScreenCTM();
  if (!m) return null;
  return new DOMPoint(clientX, clientY).matrixTransform(m.inverse());
}

// viewBox の x に、いちばん近い点の番号(点は等間隔に並んでいる)
export function nearestIndex(x: number, n: number): number {
  if (n <= 1) return 0;
  const i = Math.round(((x - PAD.left) / PLOT_W) * (n - 1));
  return Math.min(n - 1, Math.max(0, i));
}

toSvgPoint の中で使っている getScreenCTM() は、SVG 2 の仕様の getScreenCTM の項によると、要素の座標を、ブラウザの表示領域(ビューポート)の座標に変える行列を返します。この行列には viewBox による拡大・縮小や、ページの中での <svg> の位置も含まれます。名前には Screen と付いていますが、基準はポインターの clientX・clientY と同じビューポートで、仕様の注記にも getClientCTM と名付けた方が適切だったと書かれています。逆行列(inverse())を、ポインターの clientX・clientY の点に matrixTransform() で掛けると、viewBox の座標に戻せます。

offsetX を使っても、<svg> そのものの上で受け取れば、<svg> の左端からの距離は分かります。ただし、単位は画面の px です。viewBox の幅は 640 なので、グラフが 640px で表示されているうちは、px と viewBox の座標が同じになり、違いに気付きません。

幅 375px の画面では、グラフの幅が 343px になりました。カーソルを各月の x に乗せて比べると、getScreenCTM() で直した場合はどれも正しい月が出ました。offsetX をそのまま viewBox の座標として使った場合は、次のようにずれました。

乗せた月 offsetX offsetX をそのまま使ったときに出る月
4月 109.9 2月
9月 250.2 5月
12月 334.4 6月
上下2つの折れ線グラフ。どちらも 9月 の点の下に灰色の丸のカーソルがある。上は offsetX をそのまま使った場合で、縦の点線と丸が 5月 の点に付き、ツールチップに「5月 390人」と出ている。下は getScreenCTM で viewBox の座標に直した場合で、点線と丸が 9月 の点に付き、「9月 430人」と出ている
幅 375px の画面で 9月 に乗せたとき。offsetX をそのまま使った場合(上)と、viewBox の座標に直した場合(下)

画面の px は viewBox の座標より小さい数になるので、右へ行くほど、乗せた月より左の月が出ます。12月に乗せても6月までしか届きません。

いちばん近い点を選ぶ

nearestIndex は、viewBox の x を「何番目の点か」の小数に直し、Math.round で四捨五入しています。点と点の真ん中を境に、近い方の点が選ばれます。幅 800px の画面で、3月と4月のちょうど真ん中から viewBox の単位で 2 だけ左に乗せると「3月 410人」、2 だけ右に乗せると「4月 460人」が出ました。

カーソルを左右の余白に乗せたときは、番号が 0 より小さくなったり、点の数以上になったりしないように、Math.min と Math.max で端の点に収めています。グラフの左の余白(x が 10 の位置)に乗せると、1月の点に丸が付き、その中心は (48, 167.2) でした。1月の値 320 の位置です。

縦の位置は見ていないので、点の真上でなくても、x が同じなら、どの高さに乗せても同じ点が選ばれます。6月の x で、横軸の文字の高さに乗せたときも「6月 350人」が出ました。折れ線グラフでは、線のすぐそばを狙わなくても値が読めるので、この方が扱いやすいと筆者は考えています。この計算は、点が等間隔に並んでいることを前提にしています。

LineChart のコード

ポインターの処理、キーボードの処理、ツールチップをまとめたコンポーネントです。

tsx
import { useState, type KeyboardEvent, type PointerEvent } from 'react';
import { H, PAD, PLOT_H, W, nearestIndex, toSvgPoint, xAt, yAt, type Point } from './chart';

type Props = {
  data: Point[];
  title: string;
  max: number; // 縦軸のいちばん上の値
  unit?: string;
};

export function LineChart({ data, title, max, unit = '' }: Props) {
  const [active, setActive] = useState<number | null>(null);
  const n = data.length;
  const points = data.map((d, i) => `${xAt(i, n)},${yAt(d.value, max)}`).join(' ');

  const pick = (e: PointerEvent<SVGSVGElement>) => {
    const p = toSvgPoint(e.currentTarget, e.clientX, e.clientY);
    if (p) setActive(nearestIndex(p.x, n));
  };
  // 指を離したときにも pointerleave が来るので、マウスのときだけ消す
  const leave = (e: PointerEvent<SVGSVGElement>) => {
    if (e.pointerType === 'mouse') setActive(null);
  };
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const next: Record<string, number> = {
      ArrowRight: active === null ? 0 : Math.min(n - 1, active + 1),
      ArrowLeft: active === null ? n - 1 : Math.max(0, active - 1),
      Home: 0,
      End: n - 1,
    };
    if (e.key in next) {
      e.preventDefault();
      setActive(next[e.key]);
    } else if (e.key === 'Escape') {
      setActive(null);
    }
  };

  const sel = active === null ? null : data[active];
  const selX = active === null ? 0 : xAt(active, n);
  const text = sel ? `${sel.label} ${sel.value}${unit}` : '';

  return (
    <div
      className="line-chart"
      tabIndex={0}
      role="group"
      aria-label={`${title}(左右の矢印キーで値を読む)`}
      onKeyDown={onKeyDown}
      onBlur={() => setActive(null)}
    >
      <svg
        viewBox={`0 0 ${W} ${H}`}
        aria-hidden="true"
        onPointerMove={pick}
        onPointerDown={pick}
        onPointerLeave={leave}
      >
        {[0, 0.5, 1].map((t) => (
          <g key={t}>
            <line x1={PAD.left} x2={W - PAD.right} y1={yAt(max * t, max)} y2={yAt(max * t, max)} stroke="#d5dde0" />
            <text x={PAD.left - 8} y={yAt(max * t, max) + 4} textAnchor="end" fontSize={12} fill="#5b6870">
              {max * t}
            </text>
          </g>
        ))}
        {data.map((d, i) => (
          <text key={d.label} x={xAt(i, n)} y={H - 10} textAnchor="middle" fontSize={12} fill="#5b6870">
            {d.label}
          </text>
        ))}
        <polyline points={points} fill="none" stroke="#00c8e8" strokeWidth={3} strokeLinejoin="round" />
        {sel && (
          <>
            <line className="guide" x1={selX} x2={selX} y1={PAD.top} y2={PAD.top + PLOT_H} stroke="#9aa5ab" strokeDasharray="4 4" />
            <circle className="dot" cx={selX} cy={yAt(sel.value, max)} r={6} fill="#fff" stroke="#067a98" strokeWidth={3} />
          </>
        )}
      </svg>
      {sel && (
        <div
          className="tip"
          aria-hidden="true"
          style={{
            left: `${(selX / W) * 100}%`,
            top: `${(PAD.top / H) * 100}%`,
            // 右半分の点では、線の左側に出す
            transform: active! > (n - 1) / 2 ? 'translateX(calc(-100% - 10px))' : 'translateX(10px)',
          }}
        >
          <span className="tip-label">{sel.label}</span>
          <strong>
            {sel.value}
            {unit}
          </strong>
        </div>
      )}
      <p className="visually-hidden" aria-live="polite">
        {text}
      </p>
    </div>
  );
}

使う側では、横軸の名前と値の組み、縦軸のいちばん上の値を渡します。

tsx
import { LineChart } from './LineChart';
import type { Point } from './chart';

// 例のデータ(架空の数字)
const visitors: Point[] = [
  { label: '1月', value: 320 },
  { label: '2月', value: 280 },
  { label: '3月', value: 410 },
  { label: '4月', value: 460 },
  { label: '5月', value: 390 },
  { label: '6月', value: 350 },
  { label: '7月', value: 520 },
  { label: '8月', value: 580 },
  { label: '9月', value: 430 },
  { label: '10月', value: 470 },
  { label: '11月', value: 500 },
  { label: '12月', value: 610 },
];

export function App() {
  return <LineChart data={visitors} title="月ごとの来店数" max={800} unit="人" />;
}

選んでいる点の番号を active に持ち、null なら何も出しません。onPointerMove で、ポインターが動くたびに番号を求め直し、マウスがグラフの外に出たら(onPointerLeave)、null に戻します。公式リファレンスの共通の props の説明にあるとおり、onPointerMove などのハンドラーは、ブラウザに組み込みの要素すべてに付けられます。React は、組み込みの SVG の要素もすべて扱えるので、<svg> にもそのまま付けられます。グラフの上にある線や文字は <svg> の子なので、どこに乗せても <svg> のハンドラーが呼ばれます。外に出したあとは、ツールチップも丸も消えました。

ツールチップと縦線の CSS は、次のとおりです。

css
.line-chart { position: relative; max-width: 640px; }
.line-chart svg { display: block; width: 100%; height: auto; touch-action: pan-y; }
.line-chart:focus-visible { outline: 2px solid #067a98; outline-offset: 4px; }
.tip { position: absolute; pointer-events: none; white-space: nowrap; padding: 6px 10px; border-radius: 6px; background: #1f2a30; color: #fff; font-size: 14px; line-height: 1.4; }
.tip-label { display: block; font-size: 12px; color: #c9d3d8; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; margin: 0; }

縦軸と横軸の文字は、viewBox の単位で 12 の大きさにしています。グラフを 343px に縮めると、文字も同じ割合で小さくなります。画面の幅に合わせると文字の大きさまで変わることと、その対策は、このブログの「Reactで、ライブラリを使わずにSVGで棒グラフを描く方法」で扱っています。

ツールチップは HTML で重ね、位置は割合で指定する

ツールチップは、SVG の <text> ではなく、グラフに重ねた HTML の <div> にしました。HTML なら、文字の大きさが画面の px で決まり、グラフを縮めても小さくなりません。幅 375px の画面では、どの月でもツールチップの幅は 58.8px でした。背景や角の丸みも CSS で指定できます。

置く位置は、viewBox の座標を、グラフの幅と高さに対する割合(%)に直して指定しています。left は「点の x ÷ 640」、top は「上の余白 ÷ 300」です。外側の <div> を position: relative にしておけば、グラフが何 px で表示されていても、割合が同じなので縦線の横に来ます。画面の上でのグラフの大きさを測り直す必要がありません。

右の端の点でツールチップを右側に出すと、グラフからはみ出します。そこで、右半分の点(7月から12月)では、translateX(calc(-100% - 10px)) で、ツールチップを自分の幅の分だけ左へずらし、縦線の左側に出しています。幅 375px の画面(グラフの左端 16、右端 359)で測った位置は、次のとおりでした。

月 縦線の x ツールチップの左端〜右端 出た側
1月 41.5 51.7〜110.5 右側
6月 181.8 192.0〜250.8 右側
7月 209.8 141.3〜200.1 左側
12月 350.2 281.6〜340.4 左側

12月でも右側に出すように書き換えると、ツールチップの右端は 419.2 になり、画面の幅(375)を越えました。ツールチップは pointer-events: none にしています。これがないと、ツールチップは <svg> の子ではなく隣に重ねた要素なので、カーソルが乗ったときに <svg> から出たことになります。pointer-events: none を外した版も、幅 800px の画面で試しました。3月を選んだ状態でカーソルをツールチップに乗せると、<svg> に pointerleave が来て、ツールチップが消えました。消えるとカーソルの下は <svg> に戻り、pointerenter が来るので、次に動かすとまた出ます。そのままツールチップの上で右へ 1px ずつ 6 回動かすと、出ては消えるのを3回繰り返しました。pointer-events: none を付けた版では、同じように動かしても「3月 410人」が出たままでした。ツールチップはグラフの上端に置いているので、8月や9月を選んだときのように、ツールチップの下に値の大きい部分(7月〜8月あたり)の線があると、その線が隠れます。

この記事では、どちら側に出すかを点の位置だけで決めています。ツールチップの幅を測って、入りきるかどうかで決める方法は、このブログの「Reactで、ツールチップを画面の端からはみ出さずに出す方法」で扱っています。

タッチでは、指を離しても消さない

スマートフォンでは、指でグラフに触れると pointerdown が来ます。onPointerDown でも同じ処理を呼んでいるので、タップした点の値が出ます。

困るのは、指を離したときです。Playwright でタッチ端末として 3月をタップすると、イベントは pointerdown、pointerup、pointerleave の順に来ました。どれも pointerType は touch です。MDN のポインターイベントの説明にも、指のようにホバーができない入力では、pointerup のあとに pointerout(要素から出たことを知らせるイベント)が来ると書かれています。検証では、同じように pointerleave も来ました。pointerleave でいつも消すように書き換えた版では、タップしても、指を離すとすぐにツールチップが消えました。

そこで、pointerType が mouse のときだけ消すようにしています。この版では、3月をタップしたあとも「3月 410人」が出たままでした。pointerType が pen のときも、ツールチップは消えないコードになっています。

指でグラフを横になぞって、値を順に読めるようにするには、<svg> に touch-action: pan-y を付けます。MDN の touch-action の説明によると、標準では、指でのスクロールやズームはブラウザが受け持ち、ブラウザが受け持ち始めた時点でページには pointercancel が来ます。pan-y は、縦のスクロールだけをブラウザに任せる指定です。

Chrome DevTools Protocol で、1月から12月まで指を22回に分けて動かすタッチ操作を送って比べました。

touch-action pointermove pointercancel ツールチップに出た月
pan-y 22 回 0 回 1月から12月まで、すべて
auto(指定なし) 2 回 1 回 1月、2月

指定がないと、2回動かしたところでブラウザがスクロールの操作として受け持ち、pointercancel が来て、そのあとの動きはページに届きませんでした。pan-y を付けても、グラフの上で指を縦に 150px なぞると、ページはスクロールしました(scrollY は 174)。touch-action: none にすると、グラフの上では縦のスクロールもできなくなるので、グラフが画面の大部分を占めるページでは使いにくくなります。

キーボードでも値を読めるようにする

マウスもタッチも使わない人のために、グラフ全体を tabIndex={0} の <div> で包み、左右の矢印キーで点を選べるようにしました。Home で最初の点、End で最後の点、Escape で選択を外します。フォーカスが外れたときも(onBlur)、選択を外します。

Tab キーでグラフにフォーカスを移し、続けてキーを押したときのツールチップは、次のように変わりました。

押したキー ツールチップ
→ 1月 320人
→ 2月 280人
End 12月 610人
← 11月 500人
Home 1月 320人
Escape (消える)

ツールチップの <div> は aria-hidden="true" にして、読み上げには使っていません。代わりに、画面には見えない <p> に同じ文を入れ、aria-live を polite にしています。MDN によると、aria-live="polite" を付けた要素の中身が変わると、支援技術はふつう、利用者の今の作業を遮らずに、変わったことを伝えます。検証では、矢印キーを押すたびに、この <p> の中身がツールチップと同じ「1月 320人」などに変わりました。

Playwright で取り出した Chromium のアクセシビリティツリーでは、グラフは名前が「月ごとの来店数(左右の矢印キーで値を読む)」のグループ(group)になっていました。線や軸の文字を描いた <svg> は aria-hidden="true" にしているので、ツリーには出てきません。キーで値を読む操作があることを、名前の中で伝えています。

​Posted by 86