0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

0
Posted at

📝 この記事は forge.workstyle.tech に掲載した記事の転載です。

多次元のパラメータを調整する 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' でスクロールを抑止する
  • 透明な大きい当たり判定を見た目の頂点に重ね、繊細な見た目と掴みやすさを両立する

元記事: https://forge.workstyle.tech/blog/draggable-radar-chart-slider-sync/?utm_source=qiita&utm_medium=crosspost&utm_campaign=draggable-radar-chart-slider-sync

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?