Reactで、2つの箱を線でつなぐ図をSVGで描く方法

​​React​Playwright,ResizeObserver,SVG,TypeScript

2つの箱を線でつなぐには、箱は HTML の要素として置き、同じ大きさの SVG を上に重ねて、箱の位置を測った座標で <path> を描きます。座標は SVG 自身の左上を原点にして測ると、ページのスクロールや図の枠線に左右されません。測り直すのは、親が再レンダーされたとき(useLayoutEffect)と、箱の大きさだけが変わったとき(ResizeObserver)の2つの場合です。

補足

検証環境は 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 を操作して、線の座標や画面の色を確かめました。Firefox と Safari、スマートフォンの実機、スクリーンリーダーでの読み上げは試していません。3つ以上の箱をつなぐ図や、線が箱をよけて通る道筋の計算は扱いません。

1回目は箱Bを右下へドラッグし、線の終点が箱Bの左の辺に付いたまま動く。2回目は箱Bを箱Aの下へ動かし、線が箱Aの下の辺と箱Bの上の辺を結ぶ形に変わる。3回目は箱Aの「詳しく」を押して箱Aが縦に伸び、線の始点が下へずれる。座標が変わった直後だけ数字がピンクになる
箱を動かしたり大きさを変えたりしたときの線の動き(丸はマウスの位置で、押している間とクリックの瞬間は濃くなる。丸と上の表示は GIF 用に重ねたもの)

箱は HTML で置き、線は上に重ねた SVG で描く

箱の中には文字やボタンが入るので、折り返しや余白を CSS に任せられる HTML の要素で作ります。線だけを SVG にし、図の全体を覆う大きさで箱の上に重ねます。

css
.diagram {
  position: relative; /* 箱と SVG の位置の基準 */
  height: 280px;
  margin-bottom: 16px;
  border: 1px solid #9aa5ab;
  background: #eefafd;
}
.box {
  position: absolute;
  width: 140px;
  padding: 8px 12px;
  border: 2px solid #067a98;
  border-radius: 8px;
  background: #fff;
}
.draggable { cursor: grab; touch-action: none; user-select: none; }
.connector {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none; /* 線の下にある箱やボタンを押せるようにする */
}

JSX では SVG を箱より後に書いているので、画面では SVG が箱の上に重なります。そのため pointer-events: none が欠かせません。検証では、これを auto に戻すと、箱の中のボタンの位置にある要素が svg.connector になり、クリックしてもボタンの文字が「詳しく」のまま変わりませんでした。none のときは同じ位置の要素が button になり、クリックで「閉じる」に変わりました。

overflow: visible は、箱が図の外へはみ出したときのためです。MDN の overflow 属性の説明にあるとおり、HTML の中に置いた <svg> は、ブラウザの既定のスタイルで外側を切り取られます。箱を図の下の枠より外へ動かして、枠の 8px 下の色を調べたところ、visible では線の縁の水色(#5ddcf0。線の色 #00c8e8 が背景と混ざった色)、hidden では背景の白(#ffffff)でした。

線を引く Connector コンポーネント

線を引く部分を、つなぐ2つの箱の ref を受け取るコンポーネントにしました。

tsx
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import type { RefObject } from 'react';

type Point = { x: number; y: number };

// SVG の左上を原点にした、el の位置と中心
function rectIn(el: HTMLElement, svg: SVGSVGElement) {
  const r = el.getBoundingClientRect();
  const o = svg.getBoundingClientRect();
  const left = r.left - o.left, right = r.right - o.left, top = r.top - o.top, bottom = r.bottom - o.top;
  return { left, right, top, bottom, cx: (left + right) / 2, cy: (top + bottom) / 2 };
}

// 2つの箱の、向かい合う辺の真ん中どうしを結ぶ曲線
function connectorPath(svg: SVGSVGElement, a: HTMLElement, b: HTMLElement) {
  const ra = rectIn(a, svg);
  const rb = rectIn(b, svg);
  const dx = rb.cx - ra.cx;
  const dy = rb.cy - ra.cy;
  let from: Point, to: Point, bend: Point;
  if (Math.abs(dx) >= Math.abs(dy)) {
    // 左右に離れているときは、左右の辺どうしをつなぐ
    const s = dx >= 0 ? 1 : -1;
    from = { x: s > 0 ? ra.right : ra.left, y: ra.cy };
    to = { x: s > 0 ? rb.left : rb.right, y: rb.cy };
    bend = { x: s * Math.max(40, Math.abs(to.x - from.x) / 2), y: 0 };
  } else {
    // 上下に離れているときは、上下の辺どうしをつなぐ
    const s = dy >= 0 ? 1 : -1;
    from = { x: ra.cx, y: s > 0 ? ra.bottom : ra.top };
    to = { x: rb.cx, y: s > 0 ? rb.top : rb.bottom };
    bend = { x: 0, y: s * Math.max(40, Math.abs(to.y - from.y) / 2) };
  }
  // 制御点を辺と垂直の向きにずらすと、辺からまっすぐ出て、まっすぐ入る線になる
  return `M ${from.x} ${from.y} C ${from.x + bend.x} ${from.y + bend.y} ${to.x - bend.x} ${to.y - bend.y} ${to.x} ${to.y}`;
}

type Props = {
  fromRef: RefObject<HTMLElement | null>;
  toRef: RefObject<HTMLElement | null>;
  color?: string;
};

export function Connector({ fromRef, toRef, color = '#00c8e8' }: Props) {
  const svgRef = useRef<SVGSVGElement>(null);
  const [d, setD] = useState('');
  const markerId = useId();

  function update() {
    const svg = svgRef.current, a = fromRef.current, b = toRef.current;
    if (svg && a && b) setD(connectorPath(svg, a, b)); // 同じ文字列なら再レンダーされない
  }

  // 親が再レンダーされるたびに(箱を動かしたときなど)、画面に出る前に測り直す
  useLayoutEffect(update);

  // 親が再レンダーされないまま、箱や図の大きさが変わったときも測り直す
  useEffect(() => {
    const ro = new ResizeObserver(update);
    for (const el of [svgRef.current, fromRef.current, toRef.current]) {
      if (el) ro.observe(el);
    }
    return () => ro.disconnect();
  }, []);

  return (
    <svg ref={svgRef} className="connector" aria-hidden="true">
      <defs>
        <marker
          id={markerId}
          viewBox="0 0 10 10"
          refX="10"
          refY="5"
          markerWidth="6"
          markerHeight="6"
          orient="auto-start-reverse"
        >
          <path d="M 0 0 L 10 5 L 0 10 z" fill={color} />
        </marker>
      </defs>
      {d && <path d={d} fill="none" stroke={color} strokeWidth="2" markerEnd={`url(#${markerId})`} />}
    </svg>
  );
}

使う側では、2つの箱に ref を付け、同じ親の中に Connector を置きます。検証に使った図では、箱 B をドラッグで動かせるようにし、箱 A には押すと説明が開いて縦に伸びる「詳しく」ボタンを入れました。

tsx
import { useRef, useState } from 'react';
import type { PointerEvent } from 'react';
import { Connector } from './Connector';

// 中の「詳しく」で高さが変わる箱。開閉の state は箱の中だけにある
function DetailBox({ title, detail }: { title: string; detail: string }) {
  const [open, setOpen] = useState(false);
  return (
    <>
      <strong>{title}</strong>
      {open && <p>{detail}</p>}
      <button type="button" onClick={() => setOpen((o) => !o)}>
        {open ? '閉じる' : '詳しく'}
      </button>
    </>
  );
}

type Pos = { x: number; y: number };

export function Diagram({ color }: { color?: string }) {
  const aRef = useRef<HTMLDivElement>(null);
  const bRef = useRef<HTMLDivElement>(null);
  const [posB, setPosB] = useState<Pos>({ x: 220, y: 150 });
  const drag = useRef<{ dx: number; dy: number } | null>(null);

  // 箱 B をドラッグで動かす(位置は state に持つ)
  function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = { dx: e.clientX - posB.x, dy: e.clientY - posB.y };
  }
  function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
    if (!drag.current) return;
    setPosB({ x: e.clientX - drag.current.dx, y: e.clientY - drag.current.dy });
  }
  function handlePointerUp() {
    drag.current = null;
  }

  return (
    <div className="diagram">
      <div className="box" ref={aRef} style={{ left: 16, top: 24 }}>
        <DetailBox title="注文を受ける" detail="在庫を確かめてから、受け付けます。" />
      </div>
      <div
        className="box draggable"
        ref={bRef}
        style={{ left: posB.x, top: posB.y }}
        onPointerDown={handlePointerDown}
        onPointerMove={handlePointerMove}
        onPointerUp={handlePointerUp}
      >
        <strong>発送する</strong>
      </div>
      <Connector fromRef={aRef} toRef={bRef} color={color} />
    </div>
  );
}

座標は SVG 自身の左上を原点にして測る

getBoundingClientRect() が返す位置は、表示領域の左上を原点にした値です。そのままでは SVG の中の座標にならないので、箱の値から SVG 自身の getBoundingClientRect() を引きます。どちらもスクロールで同じだけずれるため、差はスクロールに左右されません。検証では、2つ目の図の「詳しく」を押して箱 A を伸ばした後の線を、スクロールしないときと、ページを 200px スクロールしたときで比べました。どちらも (184,82) → (220,172) で、同じでした。

SVG は図の枠線の内側いっぱいに広がっているので、SVG の左上は、箱の left・top の基準と同じ点です。最初の表示では、箱 A の右の辺が x = 184、上下の真ん中が y = 60 で、線の始点も (184,60) になりました。図には 1px の枠線がありますが、その幅を足したり引いたりする計算はいりませんでした。また、SVG に viewBox を付けていないこの書き方で、path の数値どおりの位置に線と矢印が描かれることを、その位置の画面の色で確かめました。

原点を SVG の ref で取るのには、もう1つ理由があります。比べるために、図の外側の div に ref を付けて Connector に渡し、その div を原点にする版も作りました。どちらも ResizeObserver を外して最初の表示を調べると、div を原点にする版では線が描かれませんでした。Connector の useLayoutEffect が最初に動いた時点で、箱の ref には要素が入っていたのに、外側の div の ref は空だったためです。

text
原点を SVG の ref で取る:
  console.log: useLayoutEffect: svgRef true fromRef true toRef true
  console.log: useLayoutEffect: svgRef true fromRef true toRef true
  console.log: useLayoutEffect: svgRef true fromRef true toRef true
  console.log: useLayoutEffect: svgRef true fromRef true toRef true
  1つ目の図: 箱A 16,24〜184,96 / 箱B 220,150〜388,194 / 線 (184,60) → (220,172)
原点を親の div の ref で取る:
  console.log: useLayoutEffect: containerRef false fromRef true toRef true
  console.log: useLayoutEffect: containerRef false fromRef true toRef true
  1つ目の図: 線なし

ページには図を2つ置いているので、ログは図ごとに出ています。

つまり、Connector の中の SVG と、Connector より前に並ぶ箱の ref には、最初の useLayoutEffect の時点で要素が入っていましたが、Connector を囲む親の div の ref には入っていませんでした。div を原点にする版に ResizeObserver を付けたときの動きは試していません。

向かい合う辺の真ん中どうしを曲線で結ぶ

線の両端は、箱の中心どうしの横の差と縦の差を比べて決めます。横に離れているときは左右の辺の真ん中を、縦に離れているときは上下の辺の真ん中を結びます。箱 B を箱 A の下へ動かすと、線は (100,96) → (104,220) になり、箱 A の下の辺から箱 B の上の辺へつながりました。

曲線には、path の C コマンド(3次ベジェ曲線)を使います。2つの制御点を、始点と終点から辺と垂直な向きにずらすと、線は辺からまっすぐ出て、まっすぐ入ります。ずらす量を 40 以上にしているのは、箱どうしが近いときでも曲がり方が分かるようにするためです。

測り直すタイミングは2つある

箱 B のドラッグでは、親の Diagram の state(posB)が変わって再レンダーされます。依存配列を書かない useLayoutEffect(update) は、React の useLayoutEffect のリファレンスにあるとおり、コミットのたびに、ブラウザが画面を描き直す前に動きます。依存配列を [] にすると、最初の1回しか測りません。

text
useLayoutEffect(update): 右下へ: 箱A 16,24〜184,96 / 箱B 300,190〜468,234 / 線 (184,60) → (300,212)
useLayoutEffect(update): 箱Aの下へ: 箱A 16,24〜184,96 / 箱B 20,220〜188,264 / 線 (100,96) → (104,220)
useLayoutEffect(update, []): 右下へ: 箱A 16,24〜184,96 / 箱B 300,190〜468,234 / 線 (184,60) → (220,172)
useLayoutEffect(update, []): 箱Aの下へ: 箱A 16,24〜184,96 / 箱B 20,220〜188,264 / 線 (184,60) → (220,172)

[] のほうは、箱 B が動いても線の終点が (220,172) に残りました。箱の位置が変わっても大きさは変わらないので、ResizeObserver も知らせてくれません。

測るたびに setD を呼んでも、繰り返し再レンダーされることはありません。d を文字列で持っているので、座標が変わらなければ前と同じ値になり、useState のリファレンスにあるとおり、React は再レンダーを省きます。上のログでも、SVG の ref で取る版の useLayoutEffect は、最初の表示で2つの図を合わせて4回動いただけで、動き続けることはありませんでした。

一方、箱 A の「詳しく」は、開閉の state が箱の中の DetailBox にしかありません。押しても Diagram と Connector は再レンダーされず、useLayoutEffect は動きません。箱の大きさの変化を知らせる ResizeObserver で測り直します。

text
ResizeObserver あり: 箱A 16,24〜184,140 / 箱B 220,150〜388,194 / 線 (184,82) → (220,172)
ResizeObserver なし: 箱A 16,24〜184,140 / 箱B 220,150〜388,194 / 線 (184,60) → (220,172)

箱 A の下の辺が 96 から 140 へ伸び、上下の真ん中は 82 になりました。ResizeObserver がない版では、線の始点が箱 A の真ん中より上の 60 に残りました。コードでは SVG 自身も監視していますが、図の大きさを変えたときの動きは試していません。

矢印は marker で付け、id は useId で作る

矢印の形は <marker> に描き、線の marker-end から url(#id) で指します。orient 属性の説明(英語)にあるとおり、auto-start-reverse を指定すると、矢印が線の向きに合わせて回ります。marker-end で使う分には auto と同じで、同じ矢印を marker-start にも付けたときに、始点では向きが逆になって外を向きます(MDN の説明によるもので、この記事では試していません)。marker 要素の refX="10" と refY="5" は、三角の先の点 (10,5) を線の終点に合わせる指定です。

この id を 'arrow' のような決まった文字列にすると、同じ図を2つ置いたときに id が重なります。検証では、2つ目の図の線をピンクにしたところ、線はピンク(#d85878)なのに、矢印は1つ目の図の水色(#00c8e8)で描かれました。useId で作ると、id は _r_0_ と _r_1_ に分かれ、2つ目の図の矢印もピンクになりました。

この図では、線を見た目のための飾りとして扱い、SVG に aria-hidden="true" を付けて、アクセシビリティツリーから外しています。そのため、箱どうしのつながりを読み手に伝えたいときは、線とは別に、文章や一覧でも書いておく必要があります。

​Posted by 86