// Canvas: pan/zoom stage that holds nodes + edges and handles connect-drag
const { useRef: useRefC, useState: useStateC, useEffect: useEffectC, useMemo: useMemoC } = React;

function Canvas({
  nodes, edges, groups, writings,
  selection, setSelection,
  updateNode, deleteNode, addEdge, updateEdge,
  updateWriting, deleteWriting,
  swapEdgeZ, flash,
  composingGroupId, setComposingGroupId, toggleGroupMember,
  updateGroup,
  setView, view,
  canvasRef, bgPattern, bgColor,
  title, subtitle, showTitleOnCanvas,
  onEditEdge,
}){
  groups = groups || [];
  writings = writings || [];
  const stageRef = useRefC(null);
  const [panning, setPanning] = useStateC(false);
  const panStart = useRefC(null);
  const [draggingNodeId, setDraggingNodeId] = useStateC(null);
  const [connectFrom, setConnectFrom] = useStateC(null);
  const [connectPos, setConnectPos] = useStateC(null);
  const [draggingEdgeId, setDraggingEdgeId] = useStateC(null);
  const edgeDragStart = useRefC(null);
  const [shiftHeld, setShiftHeld] = useStateC(false);
  // Writing-specific transient state. Mirrors the existing connectFrom /
  // edgeDragStart patterns so behavior stays consistent with edges.
  const [draggingWritingId, setDraggingWritingId] = useStateC(null);
  const [writingConnectFrom, setWritingConnectFrom] = useStateC(null);
  const [writingConnectPos, setWritingConnectPos] = useStateC(null);
  const [draggingWritingLineId, setDraggingWritingLineId] = useStateC(null);
  const writingLineDragStart = useRefC(null);
  // Measured bboxes reported by Writing components — used by writingShrink to
  // start the connection line exactly outside the visible text. Estimation is
  // a fallback for the very first frame before the first measurement lands.
  const [writingBox, setWritingBox] = useStateC({});
  const onMeasureWriting = React.useCallback((id, box) => {
    setWritingBox(prev => {
      const cur = prev[id];
      if (cur && cur.w === box.w && cur.h === box.h) return prev;
      return { ...prev, [id]: box };
    });
  }, []);

  // Track shift key so we can show a contextual "click another arrow to swap z-order" hint.
  // Ignore shift while a text input is focused so capital-letter typing doesn't flash the guide.
  useEffectC(()=>{
    const isInputFocused = (target) => {
      const tag = (target?.tagName || '').toLowerCase();
      return tag === 'input' || tag === 'textarea';
    };
    const onDown = (e)=>{
      if (e.key !== 'Shift') return;
      if (isInputFocused(e.target)) return;
      setShiftHeld(true);
    };
    const onUp   = (e)=>{ if (e.key === 'Shift') setShiftHeld(false); };
    const onBlur = ()=> setShiftHeld(false);
    window.addEventListener('keydown', onDown);
    window.addEventListener('keyup', onUp);
    window.addEventListener('blur', onBlur);
    return ()=>{
      window.removeEventListener('keydown', onDown);
      window.removeEventListener('keyup', onUp);
      window.removeEventListener('blur', onBlur);
    };
  }, []);

  // Clamp pan so content never fully leaves the viewport
  const clampView = (tx, ty, zoom) => {
    const el = stageRef.current;
    if (!el || nodes.length === 0) return { tx, ty };
    const rect = el.getBoundingClientRect();
    const margin = 200; // always keep at least 200px of content area visible
    // content bounding box in world coords
    let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
    nodes.forEach(n => {
      minX = Math.min(minX, n.x - 60);
      minY = Math.min(minY, n.y - 60);
      maxX = Math.max(maxX, n.x + 60);
      maxY = Math.max(maxY, n.y + 60);
    });
    // in screen coords: worldX * zoom + tx = screenX
    // We want: at least some part of [minX..maxX] visible in [0..rect.width]
    // maxX * zoom + tx >= margin  =>  tx >= margin - maxX * zoom
    // minX * zoom + tx <= rect.width - margin  =>  tx <= rect.width - margin - minX * zoom
    const txMin = margin - maxX * zoom;
    const txMax = rect.width - margin - minX * zoom;
    const tyMin = margin - maxY * zoom;
    const tyMax = rect.height - margin - minY * zoom;
    return {
      tx: Math.max(txMin, Math.min(txMax, tx)),
      ty: Math.max(tyMin, Math.min(tyMax, ty)),
    };
  };

  // Screen -> world coords
  const screenToWorld = (sx, sy) => {
    const rect = stageRef.current.getBoundingClientRect();
    return {
      x: (sx - rect.left - view.tx) / view.zoom,
      y: (sy - rect.top - view.ty) / view.zoom,
    };
  };

  const handleEdgeDragStart = (id, e) => {
    setDraggingEdgeId(id);
    const edge = edges.find(ed=>ed.id===id);
    if (!edge) return;
    const a = nodes.find(n=>n.id===edge.from);
    const b = nodes.find(n=>n.id===edge.to);
    if (!a || !b) return;

    const dx = b.x - a.x;
    const dy = b.y - a.y;
    const len = Math.hypot(dx, dy) || 1;
    const nx = -dy / len;
    const ny = dx / len;
    
    const i = edges.indexOf(edge);
    const baseOffset = i >= 0 ? edgeOffsets[i] : 0;
    const currentOffset = edge.customOffset !== undefined ? edge.customOffset : baseOffset;
    
    edgeDragStart.current = {
      startX: e.clientX,
      startY: e.clientY,
      startOffset: currentOffset,
      nx, ny,
    };
    e.target.setPointerCapture(e.pointerId);
  };

  // Resolve one of a writing's links to a world-space target point. Returns
  // null if the target node/edge is gone. Edge targets pick up the bezier
  // midpoint with the same offset logic as the Edge component, so the line
  // tracks the visible center of the curve even after the edge is bent.
  const resolveLinkTarget = (link) => {
    if (!link) return null;
    if (link.type === 'node') {
      const n = nodes.find(nn => nn.id === link.id);
      return n ? { x: n.x, y: n.y, type: 'node' } : null;
    }
    if (link.type === 'edge') {
      const i = edges.findIndex(ee => ee.id === link.id);
      if (i < 0) return null;
      const ed = edges[i];
      const a = nodes.find(nn => nn.id === ed.from);
      const b = nodes.find(nn => nn.id === ed.to);
      if (!a || !b) return null;
      const off = ed.customOffset !== undefined ? ed.customOffset : edgeOffsets[i];
      const { cx, cy } = window.buildCurve({x:a.x,y:a.y}, {x:b.x,y:b.y}, off);
      return {
        x: 0.25*a.x + 0.5*cx + 0.25*b.x,
        y: 0.25*a.y + 0.5*cy + 0.25*b.y,
        type: 'edge',
      };
    }
    return null;
  };

  // The line starts OUTSIDE the writing's text (so it doesn't overlap the
  // visible glyphs) but extends ALL THE WAY to the target's center. Visual
  // tidiness comes from z-order instead of geometry: the writing-lines svg
  // is drawn beneath the edges/nodes layers, so node avatars and edge labels
  // naturally cover the inside of the line.
  const writingShrink = (w, dirX, dirY) => {
    const measured = writingBox[w.id];
    let halfW, halfH;
    if (measured) {
      // Tight margin — the measured box already includes the writing's CSS
      // padding, so 4px outside that is enough to keep the dotted line clear
      // of the visible text strokes.
      halfW = measured.w / 2 + 4;
      halfH = measured.h / 2 + 4;
    } else {
      // Fallback: rough character-width estimate. 0.7em is intentionally
      // generous so a horizontal line never crosses CJK glyphs even before
      // the real measurement arrives.
      const fs = w.fontSize || 28;
      const lines = (w.text || '').split('\n');
      const maxChars = Math.max(1, ...lines.map(l => l.length || 1));
      halfW = (maxChars * fs * 0.7) / 2 + 12;
      halfH = (lines.length * fs * 1.25) / 2 + 6;
    }
    // Rotate the outward unit vector into the writing's local frame.
    const rot = -((w.rotation || 0) * Math.PI / 180);
    const c = Math.cos(rot), s = Math.sin(rot);
    const rx = dirX * c - dirY * s;
    const ry = dirX * s + dirY * c;
    let dist = Infinity;
    if (Math.abs(rx) > 1e-6) dist = Math.min(dist, halfW / Math.abs(rx));
    if (Math.abs(ry) > 1e-6) dist = Math.min(dist, halfH / Math.abs(ry));
    return Number.isFinite(dist) ? dist : Math.max(halfW, halfH);
  };

  // Build a curved path from outside the writing all the way to the target
  // center. The wl-path is drawn under edges/nodes so the avatar and edge
  // label hide the inner portion automatically. Returns null when the writing
  // is essentially on top of the target (line would have nowhere to go).
  const buildWritingLinePath = (w, link) => {
    const target = resolveLinkTarget(link);
    if (!target) return null;
    const cx = w.x, cy = w.y;
    let dx = target.x - cx, dy = target.y - cy;
    const L = Math.hypot(dx, dy) || 1;
    const ux = dx / L, uy = dy / L;
    const startShrink = writingShrink(w, ux, uy);
    if (startShrink >= L - 4) return null;
    const p1 = { x: cx + ux * startShrink, y: cy + uy * startShrink };
    const p2 = { x: target.x, y: target.y };
    const { d } = window.buildCurve(p1, p2, link.lineCurve || 0);
    return { d, p1, p2 };
  };

  const handleWritingLineDragStart = (writingId, linkIdx, e) => {
    e.stopPropagation();
    const w = writings.find(x => x.id === writingId);
    if (!w) return;
    const link = (w.links || [])[linkIdx];
    if (!link) return;
    const target = resolveLinkTarget(link);
    if (!target) return;
    setDraggingWritingLineId({ writingId, linkIdx });
    setSelection({ type: 'writing', id: writingId });
    const dx = target.x - w.x;
    const dy = target.y - w.y;
    const len = Math.hypot(dx, dy) || 1;
    const nx = -dy / len;
    const ny = dx / len;
    writingLineDragStart.current = {
      startX: e.clientX,
      startY: e.clientY,
      startCurve: link.lineCurve || 0,
      nx, ny,
    };
    e.target.setPointerCapture(e.pointerId);
  };

  const doStartWritingConnect = (id, e) => {
    setWritingConnectFrom(id);
    const w = writings.find(x => x.id === id);
    if (w) setWritingConnectPos({ x: w.x, y: w.y });
  };

  const onStageDown = (e) => {
    if (e.target.closest('.node') || e.target.closest('.edge-label') || e.target.closest('.edge-hit') || e.target.closest('.pan-ui') || e.target.closest('.hint') || e.target.closest('.compose-banner') || e.target.closest('.group-labels-svg') || e.target.closest('.writing') || e.target.closest('.wl-hit')) return;
    setSelection(null);
    // start panning
    setPanning(true);
    panStart.current = { x: e.clientX, y: e.clientY, tx: view.tx, ty: view.ty };
    e.currentTarget.setPointerCapture(e.pointerId);
  };
  const onStageMove = (e) => {
    if (draggingEdgeId && edgeDragStart.current) {
      const state = edgeDragStart.current;
      const dxScreen = (e.clientX - state.startX) / view.zoom;
      const dyScreen = (e.clientY - state.startY) / view.zoom;
      const diff = dxScreen * state.nx + dyScreen * state.ny;
      const newOffset = state.startOffset + diff;
      if (updateEdge) updateEdge(draggingEdgeId, { customOffset: newOffset }, { skipHistory: true });
      return;
    }
    if (draggingWritingLineId && writingLineDragStart.current) {
      const st = writingLineDragStart.current;
      const dxScreen = (e.clientX - st.startX) / view.zoom;
      const dyScreen = (e.clientY - st.startY) / view.zoom;
      const diff = dxScreen * st.nx + dyScreen * st.ny;
      const { writingId, linkIdx } = draggingWritingLineId;
      const w = writings.find(x => x.id === writingId);
      if (w && updateWriting) {
        const links = (w.links || []).map((l, i) => i === linkIdx ? { ...l, lineCurve: st.startCurve + diff } : l);
        updateWriting(writingId, { links }, { skipHistory: true });
      }
      return;
    }
    if (panning && panStart.current){
      const rawTx = panStart.current.tx + (e.clientX - panStart.current.x);
      const rawTy = panStart.current.ty + (e.clientY - panStart.current.y);
      const clamped = clampView(rawTx, rawTy, view.zoom);
      setView(v => ({ ...v, tx: clamped.tx, ty: clamped.ty }));
    }
    if (connectFrom){
      setConnectPos(screenToWorld(e.clientX, e.clientY));
    }
    if (writingConnectFrom){
      setWritingConnectPos(screenToWorld(e.clientX, e.clientY));
    }
  };
  const onStageUp = (e) => {
    if (draggingEdgeId) {
      setDraggingEdgeId(null);
      edgeDragStart.current = null;
    }
    if (draggingWritingLineId) {
      // Commit a final history entry now that the user released the line.
      const { writingId } = draggingWritingLineId;
      const w = writings.find(x => x.id === writingId);
      if (w && updateWriting) updateWriting(writingId, { links: w.links || [] });
      setDraggingWritingLineId(null);
      writingLineDragStart.current = null;
    }
    setPanning(false);
    panStart.current = null;
    if (writingConnectFrom){
      // Hit-test the release point against nodes (world-space proximity) and
      // edges (DOM elementFromPoint via .edge-group[data-edge-id]). First match
      // wins; nodes take priority since their hit-radius is generous.
      let target = null;
      const rect = stageRef.current?.getBoundingClientRect();
      if (rect) {
        const wx = (e.clientX - rect.left - view.tx) / view.zoom;
        const wy = (e.clientY - rect.top - view.ty) / view.zoom;
        const hitR = 60;
        const n = nodes.find(nn => Math.hypot(nn.x - wx, nn.y - wy) <= hitR);
        if (n) target = { type: 'node', id: n.id };
      }
      if (!target) {
        const el = document.elementFromPoint(e.clientX, e.clientY);
        if (el) {
          const eg = el.closest('.edge-group');
          if (eg && eg.dataset && eg.dataset.edgeId) {
            target = { type: 'edge', id: eg.dataset.edgeId };
          }
        }
      }
      if (target && updateWriting) {
        const w = writings.find(x => x.id === writingConnectFrom);
        if (w) {
          const links = w.links || [];
          // Avoid stacking duplicate links onto the same target.
          const exists = links.some(l => l.type === target.type && l.id === target.id);
          if (!exists) {
            updateWriting(writingConnectFrom, { links: [...links, { type: target.type, id: target.id, lineCurve: 0 }] });
          }
        }
      }
      setWritingConnectFrom(null);
      setWritingConnectPos(null);
    }
    if (connectFrom){
      // iPad / touch fix: Safari's implicit pointer capture keeps pointerup
      // on the source +handle, so the target avatar's onPointerUp never fires.
      // For touch/pen, hit-test the release point against node centers in world
      // space. Mouse input keeps the original path (handleFinishUp on avatar).
      if (e.pointerType && e.pointerType !== 'mouse') {
        const rect = stageRef.current?.getBoundingClientRect();
        if (rect) {
          const worldX = (e.clientX - rect.left - view.tx) / view.zoom;
          const worldY = (e.clientY - rect.top - view.ty) / view.zoom;
          const hitR = 60;
          const target = nodes.find(n => {
            if (n.id === connectFrom) return false;
            return Math.hypot(n.x - worldX, n.y - worldY) <= hitR;
          });
          if (target) addEdge(connectFrom, target.id);
        }
      }
      setConnectFrom(null);
      setConnectPos(null);
    }
  };

  const onWheel = (e) => {
    e.preventDefault();
    const rect = stageRef.current.getBoundingClientRect();
    const sx = e.clientX - rect.left;
    const sy = e.clientY - rect.top;
    const delta = -e.deltaY * 0.0015;
    const nextZoom = Math.max(0.25, Math.min(2.5, view.zoom * (1+delta)));
    // keep cursor point steady
    const wx = (sx - view.tx) / view.zoom;
    const wy = (sy - view.ty) / view.zoom;
    const tx = sx - wx*nextZoom;
    const ty = sy - wy*nextZoom;
    const clamped = clampView(tx, ty, nextZoom);
    setView({ zoom: nextZoom, tx: clamped.tx, ty: clamped.ty });
  };

  useEffectC(()=>{
    const el = stageRef.current;
    if(!el) return;
    const wheel = (e)=> onWheel(e);
    el.addEventListener('wheel', wheel, { passive:false });
    return ()=> el.removeEventListener('wheel', wheel);
  });

  // Edge offset map: if multiple edges between same pair, stagger them
  const edgeOffsets = useMemoC(()=>{
    const counts = {};
    return edges.map(e => {
      const key = [e.from, e.to].sort().join('--');
      const idx = (counts[key] = (counts[key]||0));
      counts[key]++;
      const dir = e.from < e.to ? 1 : -1;
      const base = 0;
      // spread: 0, +28, -28, +56, -56 ...
      const step = 32;
      const spread = idx===0 ? base : (idx%2? 1: -1) * Math.ceil(idx/2) * step;
      return spread * dir;
    });
  }, [edges]);

  const doStartConnect = (nodeId) => {
    setConnectFrom(nodeId);
    const n = nodes.find(n=>n.id===nodeId);
    if (n) setConnectPos({x:n.x,y:n.y});
  };
  const doFinishConnect = (nodeId) => {
    if (connectFrom && connectFrom !== nodeId){
      addEdge(connectFrom, nodeId);
    }
    setConnectFrom(null);
    setConnectPos(null);
  };

  return (
    <div
      ref={(el)=>{stageRef.current=el; if(canvasRef)canvasRef.current=el;}}
      className={`stage ${panning?'panning':''}`}
      style={{backgroundColor:bgColor}}
      onPointerDown={onStageDown}
      onPointerMove={onStageMove}
      onPointerUp={onStageUp}
      onPointerCancel={onStageUp}
    >
      <div id="capture-root" className="world" style={{
          transform:`translate(${view.tx}px, ${view.ty}px) scale(${view.zoom})`,
          width: 4000, height: 3000,
        }}>

        {/* Background layer inside the scaled world — this is what gets captured on export.
            Extra-wide/tall so export-time content bounds never extend past it. */}
        <div className={`stage-bg ${bgPattern}`} style={{
          position:'absolute', left:-2000, top:-2000, width:8000, height:7000,
          backgroundColor:bgColor, pointerEvents:'none',
        }}/>

        {showTitleOnCanvas && (title || subtitle) && (() => {
          // Center title over actual node content, not the arbitrary world width
          const cx = nodes.length > 0
            ? nodes.reduce((s,n) => s + n.x, 0) / nodes.length
            : 500;
          return (
            <div className="canvas-title" style={{position:'absolute', left: cx, top: 24, transform:'translateX(-50%)'}}>
              {title ? <div className="t1">{title}</div> : null}
              {subtitle ? <div className="t2">{subtitle}</div> : null}
            </div>
          );
        })()}

        {/* Group blob layer: drawn between background and edges, so edges/nodes sit on top. */}
        {groups.length > 0 && (
          <svg className="groups-svg" width="4000" height="3000" style={{position:'absolute',left:0,top:0,overflow:'visible'}}>
            {groups.map(g => (
              <GroupBlob
                key={g.id}
                group={g}
                nodes={nodes}
                selected={selection?.type==='group' && selection.id===g.id}
                composing={composingGroupId===g.id}
                onSelect={(id)=>{
                  // Don't steal selection while composing — clicks then go to nodes (toggle membership)
                  if (composingGroupId) return;
                  setSelection({type:'group', id});
                }}
              />
            ))}
          </svg>
        )}

        {/* Writing connection lines — drawn BENEATH edges and nodes so node
            avatars and edge labels naturally cover the inside of each line.
            The dotted writing→target line "tucks under" labels with no gap.
            One <g> per (writing, link) pair. */}
        {writings.length > 0 && (
          <svg className="writing-lines-svg" width="4000" height="3000" style={{position:'absolute',left:0,top:0,overflow:'visible'}}>
            {writings.flatMap(w => (w.links || []).map((link, idx) => {
              const path = buildWritingLinePath(w, link);
              if (!path) return null;
              const sel = selection?.type === 'writing' && selection.id === w.id;
              const stroke = w.color || '#F56A92';
              const key = `${w.id}__${idx}`;
              return (
                <g key={key} className={`wl-group${sel ? ' selected' : ''}`}>
                  <path className="wl-hit" d={path.d}
                        onClick={(e)=>{ e.stopPropagation(); setSelection({ type:'writing', id: w.id }); }}
                        onPointerDown={(e)=>{
                          if (e.shiftKey) return;
                          handleWritingLineDragStart(w.id, idx, e);
                        }}/>
                  <path className="wl-path" d={path.d}
                        stroke={stroke}
                        strokeWidth={2}
                        strokeOpacity={0.85}/>
                </g>
              );
            }))}
            {writingConnectFrom && writingConnectPos ? (() => {
              const from = writings.find(x => x.id === writingConnectFrom);
              if (!from) return null;
              // Start the in-progress line outside the writing's text box too,
              // so the user sees consistent geometry while dragging.
              const dx = writingConnectPos.x - from.x;
              const dy = writingConnectPos.y - from.y;
              const L = Math.hypot(dx, dy) || 1;
              const ux = dx / L, uy = dy / L;
              const sh = writingShrink(from, ux, uy);
              if (sh >= L) return null;
              return (
                <line className="in-progress-line writing"
                      x1={from.x + ux * sh} y1={from.y + uy * sh}
                      x2={writingConnectPos.x} y2={writingConnectPos.y}/>
              );
            })() : null}
          </svg>
        )}

        <svg className="edges-svg" width="4000" height="3000" style={{position:'absolute',left:0,top:0}}>
          {edges.map((e, i)=>(
            <Edge
              key={e.id}
              edge={e}
              nodes={Object.fromEntries(nodes.map(n=>[n.id,n]))}
              offset={edgeOffsets[i]}
              selected={selection?.type==='edge' && selection.id===e.id}
              onSelect={(id, shiftKey)=>{
                if (shiftKey && selection?.type==='edge' && selection.id !== id && swapEdgeZ){
                  swapEdgeZ(selection.id, id);
                  setSelection(null);
                  if (flash) flash('矢印の重なり順を入れ替えたよ');
                  return;
                }
                setSelection({type:'edge', id});
              }}
              onEdit={(id)=>onEditEdge(id)}
              onDragStart={handleEdgeDragStart}
            />
          ))}
          {connectFrom && connectPos ? (()=>{
            const from = nodes.find(n=>n.id===connectFrom);
            if(!from) return null;
            return (
              <line className="in-progress-line"
                    x1={from.x} y1={from.y}
                    x2={connectPos.x} y2={connectPos.y}/>
            );
          })() : null}
        </svg>

        {nodes.map(n => {
          const composingGroup = composingGroupId ? groups.find(g=>g.id===composingGroupId) : null;
          const isMemberOfComposing = composingGroup ? (composingGroup.nodeIds||[]).includes(n.id) : false;
          return (
            <Node
              key={n.id}
              node={n}
              selected={(selection?.type==='node' && selection.id===n.id) || isMemberOfComposing}
              dragging={draggingNodeId===n.id}
              worldZoom={view.zoom}
              onSelect={(id)=>{
                // In compose mode: clicks toggle membership instead of selecting
                if (composingGroupId && toggleGroupMember){
                  toggleGroupMember(composingGroupId, id);
                  return;
                }
                setSelection({type:'node', id});
              }}
              onDrag={(id,x,y)=>{ setDraggingNodeId(id); updateNode(id,{x,y}, {skipHistory:true}); }}
              onDragEnd={(id, moved)=>{
                if (moved){
                  // commit a history entry by resetting last position via no-op change
                  const n2 = nodes.find(x=>x.id===id);
                  if (n2) updateNode(id,{x:n2.x,y:n2.y});
                }
                setDraggingNodeId(null);
              }}
              onStartConnect={doStartConnect}
              onFinishConnect={doFinishConnect}
              onDelete={deleteNode}
            />
          );
        })}

        {/* Writings (annotation text) — drawn ABOVE nodes/edges per spec. */}
        {writings.map(w => (
          <Writing
            key={w.id}
            writing={w}
            selected={selection?.type === 'writing' && selection.id === w.id}
            dragging={draggingWritingId === w.id}
            worldZoom={view.zoom}
            onSelect={(id)=> setSelection({ type:'writing', id })}
            onDrag={(id, x, y)=>{ setDraggingWritingId(id); updateWriting(id, { x, y }, { skipHistory: true }); }}
            onDragEnd={(id, moved)=>{
              if (moved) {
                const w2 = writings.find(x => x.id === id);
                if (w2) updateWriting(id, { x: w2.x, y: w2.y });
              }
              setDraggingWritingId(null);
            }}
            onStartConnect={doStartWritingConnect}
            onDelete={deleteWriting}
            onTextChange={(id, text)=> updateWriting(id, { text })}
            onMeasure={onMeasureWriting}
          />
        ))}

        {/* Group name pill layer — drawn AFTER nodes so labels sit on top of
            everything. pointer-events:none keeps clicks flowing through to
            the nodes/edges beneath, so the label never blocks selection. */}
        {groups.length > 0 && (
          <svg className="group-labels-svg" width="4000" height="3000"
               style={{position:'absolute',left:0,top:0,overflow:'visible',pointerEvents:'none'}}>
            {groups.map(g => (
              <GroupLabel key={g.id} group={g} nodes={nodes}
                          updateGroup={updateGroup} worldZoom={view.zoom}/>
            ))}
          </svg>
        )}
      </div>

      <div className="pan-ui">
        <button className="btn icon sm" onClick={()=>{
          const z = Math.max(0.25, view.zoom - 0.1);
          const c = clampView(view.tx, view.ty, z);
          setView({ zoom: z, tx: c.tx, ty: c.ty });
        }}>−</button>
        <div className="zoom-val">{Math.round(view.zoom*100)}%</div>
        <button className="btn icon sm" onClick={()=>{
          const z = Math.min(2.5, view.zoom + 0.1);
          const c = clampView(view.tx, view.ty, z);
          setView({ zoom: z, tx: c.tx, ty: c.ty });
        }}>＋</button>
        <button className="btn sm" onClick={()=>{
          const c = clampView(0, 0, 1);
          setView({ zoom: 1, tx: c.tx, ty: c.ty });
        }}>リセット</button>
      </div>
      <div className="hint">ドラッグで移動 / ホイール：ズーム / ノードの <b style={{color:'#7DD8B4'}}>＋</b> で関係作成 / Shift+クリックで矢印の重なり順を入替</div>

      {shiftHeld && selection?.type === 'edge' && (
        <div style={{
          position:'absolute', left:'50%', top:20, transform:'translateX(-50%)',
          background:'var(--mint)', color:'#fff', fontWeight:800, fontSize:13,
          padding:'8px 16px', borderRadius:999,
          boxShadow:'0 4px 14px rgba(120,76,110,.25)',
          zIndex:8, pointerEvents:'none', whiteSpace:'nowrap',
        }}>
          🔀 そのまま別の矢印をクリックで重なり順を入れ替え
        </div>
      )}

      {composingGroupId && (() => {
        const g = groups.find(x => x.id === composingGroupId);
        const count = g?.nodeIds?.length || 0;
        return (
          <div
            className="compose-banner"
            onPointerDown={(e)=>e.stopPropagation()}
            onClick={(e)=>e.stopPropagation()}
            style={{
              position:'absolute', left:'50%', top:20, transform:'translateX(-50%)',
              background:'var(--paper)', color:'var(--ink)',
              fontWeight:800, fontSize:13,
              padding:'10px 16px', borderRadius:14,
              border:`2px solid ${g?.color || '#FFB3C7'}`,
              boxShadow:'0 4px 14px rgba(120,76,110,.25)',
              zIndex:8, display:'flex', alignItems:'center', gap:10, whiteSpace:'nowrap',
            }}>
            <span>🫧 グループ編集中（{count} 人） ノードをクリックで追加/除外</span>
            <button className="btn sm primary"
                    onPointerDown={(e)=>e.stopPropagation()}
                    onClick={(e)=>{ e.stopPropagation(); setComposingGroupId(null); setSelection({type:'group', id:composingGroupId}); }}
                    style={{padding:'4px 12px'}}>完了</button>
          </div>
        );
      })()}
    </div>
  );
}

window.Canvas = Canvas;
