レーダーチャートの頂点ドラッグ編集をスライダーと双方向同期する
多次元のパラメータを調整する 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),
};
});
この pts を useMemo で sliders に依存させておけば、値が変わるたびに頂点が再計算され、ポリゴンとラベルが更新されます。値が未定義なら 50(中央)にフォールバックしておくと安全です。軸数 n は axes.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'でスクロールを抑止する - 透明な大きい当たり判定を見た目の頂点に重ね、繊細な見た目と掴みやすさを両立する