#フロントエンド#React#SVG#UIデザイン

レーダーチャートの頂点ドラッグ編集をスライダーと双方向同期する


多次元のパラメータを調整する UI で、レーダーチャート(クモの巣グラフ)はとても直感的です。8本のスライダーがずらりと並ぶより、8角形の面積と形で「今どんなバランスか」が一目でわかる。

でも多くの実装では、レーダーチャートは表示専用です。値はスライダーで入力し、レーダーはその結果を描くだけ。ここで一歩踏み込んで、「レーダーの頂点を直接つまんで動かせたら」を実現すると、操作の気持ちよさが段違いになります。しかもスライダーとレーダーが双方向に同期していれば、ユーザーは好きな方で操作できます。

この記事は、SVG レーダーの頂点ドラッグ編集と、N 本のスライダーとの双方向同期を、React で実装するパターンの記録です。座標変換とポインタイベントの扱いが要点になります。

設計方針:状態は1つ、UIは2つ

双方向同期と聞くと「レーダーの状態」と「スライダーの状態」を互いに反映し合う、という複雑な絵を想像しがちです。が、それは罠です。2つの状態を持つと、どちらが正か(source of truth)が曖昧になり、無限ループや不整合の温床になります。

正解はシンプルで、状態は1つだけ持つ。レーダーもスライダーも、その唯一の状態を読んで描画し、操作したら同じ更新関数を呼ぶ。ここでは軸キーごとの値を持つストア(sliders)と、更新関数(setSlider)を単一の真実とします。

export default function RadarChart({ axes, sliders }: { axes: Axis[]; sliders: Sliders }) {
  const setSlider = useCanvaStore((s) => s.setSlider);
  // sliders を読んで描画し、ドラッグしたら setSlider を呼ぶだけ
}

こうすると「双方向同期」は自動的に達成されます。スライダーを動かせば sliders が更新されてレーダーが再描画され、レーダーをドラッグすれば同じ setSlider を呼ぶのでスライダーも即座に追従する。同期ロジックは実質ゼロです。同期を頑張るのではなく、同期が要らない構造にする、というのがこの設計の肝です。

値から頂点座標へ(描画方向)

まず順方向、sliders の値から各頂点の座標を求めます。軸は等角に配置します。i 番目の軸の角度は、真上(-π/2)を起点に円周を N 等分した位置です。

const angleOf = (i: number) => (Math.PI * 2 * i) / n - Math.PI / 2;

値(0〜100)は中心からの距離に対応させます。値を 0〜1 に正規化し、半径 R を掛けて、その角度方向の点を極座標→直交座標で求めます。

const pts = axes.map((a, i) => {
  const ang = angleOf(i);
  const v = Math.max(0, Math.min(100, sliders[a.key] ?? 50)) / 100;
  return {
    x: cx + Math.cos(ang) * R * v,   // 頂点
    y: cy + Math.sin(ang) * R * v,
    lx: cx + Math.cos(ang) * (R + 26), // ラベル位置(外周の少し外)
    ly: cy + Math.sin(ang) * (R + 26),
    val: Math.round(sliders[a.key] ?? 50),
  };
});

この ptsuseMemosliders に依存させておけば、値が変わるたびに頂点が再計算され、ポリゴンとラベルが更新されます。値が未定義なら 50(中央)にフォールバックしておくと安全です。軸数 naxes.length から取るので、6軸でも8軸でも同じコードで動きます。

頂点座標から値へ(ドラッグ方向)

難しいのは逆方向、ポインタの位置から「その軸の値」を求める部分です。ここで2つの座標変換が必要になります。

(1) スクリーン座標 → SVG 座標。 SVG は viewBox で内部座標系を持ち、実際の描画サイズは CSS で変わります。ポインタの clientX/Y は画面ピクセルなので、要素の矩形(getBoundingClientRect)で割って viewBox のスケールに戻します。

(2) SVG 座標 → 軸方向の値。 ユーザーは必ずしも軸の直線上をなぞりません。斜めにずれた位置にポインタが来ます。そこで、中心からポインタへのベクトルを、その軸の方向ベクトルに射影します。内積を取れば軸方向の成分(符号付きの距離)が得られ、それを R で割れば 0〜1 の値になります。

const updateFromEvent = (e: React.PointerEvent) => {
  const i = dragRef.current;
  if (i == null || !svgRef.current) return;
  const rect = svgRef.current.getBoundingClientRect();
  const px = ((e.clientX - rect.left) / rect.width) * size;   // (1) → SVG座標
  const py = ((e.clientY - rect.top) / rect.height) * size;
  const ang = angleOf(i);
  const proj = (px - cx) * Math.cos(ang) + (py - cy) * Math.sin(ang); // (2) 軸方向へ射影
  const v = Math.max(0, Math.min(1, proj / R));
  setSlider(axes[i].key, Math.round(v * 100));
};

射影を使うことで、ユーザーは「だいたいその軸の方へ引っ張る」だけでよく、軸線ちょうどをなぞる必要がありません。値は 0〜1 にクランプして範囲外を防ぎます。そして最後に setSlider を呼ぶ——ここで唯一の状態が更新され、レーダーもスライダーも同時に追従します。

ポインタイベント:capture と当たり判定

ドラッグ操作を破綻なく成立させるには、2つの細工が要ります。

Pointer Capture。 頂点でドラッグを始めた後、ポインタが素早く動いて頂点や SVG の外へ出ても、イベントを取り続けたい。setPointerCapture を使うと、指定した要素がそのポインタのイベントを独占できます。ドラッグ開始時に SVG 要素へキャプチャを設定し、どの軸をつかんでいるかを dragRef に記録します。

const onVertexDown = (i: number, e: React.PointerEvent) => {
  e.preventDefault();
  dragRef.current = i;                          // つかんだ軸を記録
  svgRef.current?.setPointerCapture(e.pointerId);
  updateFromEvent(e);                           // 押した瞬間にも反映
};

移動は SVG 全体の onPointerMove で受け、dragRef が立っているときだけ updateFromEvent を回します。終了時は dragRef をクリアし、キャプチャを解放します。マウスの mousedown/move/up を個別に扱うより、Pointer Events に一本化するとタッチ・ペンも同じコードでカバーできます。

当たり判定の拡大。 頂点の見た目の丸(半径5px 程度)は、指でつまむには小さすぎます。そこで各頂点に、見た目の丸とは別に透明な大きい円(半径16px)を重ねて、そちらにポインタダウンを付けます。見た目は繊細なまま、掴める範囲だけ広げるテクニックです。

<circle cx={p.x} cy={p.y} r={16} fill="transparent"
        style={{ cursor: 'grab' }}
        onPointerDown={(e) => onVertexDown(i, e)} />
<circle cx={p.x} cy={p.y} r={5} fill="#b9a7ff" pointerEvents="none" />

見た目の丸には pointerEvents="none" を付けて、当たり判定を透明円だけに集約するのを忘れずに。

落とし穴・学び

  • タッチのスクロールを止める。SVG に touchAction: 'none' を指定しないと、モバイルでドラッグしたつもりがページがスクロールしてしまいます。ドラッグ UI には必須です。
  • 状態を2つ持たない。レーダー用とスライダー用に別々の state を持つと同期地獄になります。単一の真実を両方が読む構造にすれば、同期コードそのものが消えます。
  • 射影で「軸線ちょうど」を要求しない。頂点の x,y をそのまま値に使うのではなく、軸方向への内積射影に落とすことで、多少ずれた操作でも自然に効きます。
  • capture 解放は防御的にreleasePointerCapture は状況によって例外を投げることがあるので try/catch で包み、ドラッグ状態のクリア自体は必ず行うようにします。
  • 見た目と当たり判定を分離する。小さな見た目の頂点と、大きな透明ヒットエリアを分けることで、精度と操作性を両立できます。

まとめ

  • レーダーチャートを表示専用から編集可能にすると、多次元パラメータ調整の操作感が大きく向上する
  • 双方向同期は単一の状態を両 UI が共有することで、同期ロジックなしに達成する
  • 順方向は極座標で値→頂点、逆方向は中心→ポインタのベクトルを軸方向へ内積射影して頂点→値に変換する
  • ドラッグは Pointer Events + setPointerCapture で一本化し、touchAction: 'none' でスクロールを抑止する
  • 透明な大きい当たり判定を見た目の頂点に重ね、繊細な見た目と掴みやすさを両立する
← 記事一覧へ