// 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,
  notes, showNotes, updateNote,
  showFlow,
  branchNodeMode, addBranchNodeOnEdge, addBranchNodeOnGroup,
  selection, setSelection,
  updateNode, deleteNode, addEdge, updateEdge,
  swapEdgeZ, flash,
  composingGroupId, setComposingGroupId, toggleGroupMember,
  updateGroup,
  setView, view,
  canvasRef, bgPattern, bgColor,
  title, subtitle, showTitleOnCanvas,
  onEditEdge,
}){
  groups = groups || [];
  notes = notes || [];
  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);
  // In branch mode, track which group's blob the pointer is currently over so
  // we can highlight a valid drop target. Reset to null when over empty space
  // or when branch mode exits.
  const [branchHoverGroupId, setBranchHoverGroupId] = useStateC(null);

  // 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);
  };

  const onStageDown = (e) => {
    // Branch-node placement mode: click on an edge → snap to the nearest
    // point on that edge's bezier and attach the branch there. The branch
    // stays magnet-locked to that arrow (slides but never detaches by drag).
    // Shift-click falls through so the existing z-order-swap interaction still
    // works on branch-related arrows without having to exit branch mode.
    if (branchNodeMode && !e.shiftKey) {
      const rect = stageRef.current?.getBoundingClientRect();
      const worldX = rect ? (e.clientX - rect.left - view.tx) / view.zoom : 0;
      const worldY = rect ? (e.clientY - rect.top  - view.ty) / view.zoom : 0;

      // Priority 1: edge-attach (if click hits an arrow's hit-area).
      const hit = e.target.closest('.edge-hit');
      if (hit) {
        const edgeId = hit.getAttribute('data-edge-id');
        const edge = edges.find(ed => ed.id === edgeId);
        if (edge && rect && addBranchNodeOnEdge) {
          const from = nodes.find(n => n.id === edge.from);
          const to   = nodes.find(n => n.id === edge.to);
          if (from && to) {
            const geom = computeEdgeBezier(edge, from, to, edgeOffsetById[edge.id] || 0);
            const pt = closestPointOnBezier(geom.p1, {x:geom.cx, y:geom.cy}, geom.p2, worldX, worldY);
            addBranchNodeOnEdge(edge.id, pt.t, pt.x, pt.y);
          }
        }
        e.stopPropagation();
        return;
      }
      // Priority 2: group-attach (if click is inside a group's *visible blob
      // shape*). Walk groups in render order; if shapes overlap, the last
      // (top-most) one wins. The branch becomes a regular member of that
      // group, so the blob naturally extends around it without any projection.
      let target = null;
      groups.forEach(g => {
        const members = (g.nodeIds || [])
          .map(nid => nodes.find(n => n.id === nid))
          .filter(Boolean);
        if (members.length === 0) return;
        if (!isPointInBlobShape(worldX, worldY, members)) return;
        target = g;
      });
      if (target && addBranchNodeOnGroup) {
        addBranchNodeOnGroup(target.id, worldX, worldY);
        e.stopPropagation();
        return;
      }
      // Clicked empty space while in branch mode — gentle hint, no pan.
      if (flash) flash('Click an arrow or zone blob to place a branch node');
      e.stopPropagation();
      return;
    }
    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')) 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 (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));
    }
    // Branch-mode blob hover: highlight the blob the pointer is currently
    // over so the user gets visual feedback for valid drop targets. Cheap
    // enough to recompute every move (groups are typically <10).
    if (branchNodeMode){
      const w = screenToWorld(e.clientX, e.clientY);
      let hit = null;
      for (const g of groups){
        const members = (g.nodeIds || [])
          .map(nid => nodes.find(n => n.id === nid))
          .filter(Boolean);
        if (members.length > 0 && isPointInBlobShape(w.x, w.y, members)) {
          hit = g.id; // last wins → matches click priority
        }
      }
      if (hit !== branchHoverGroupId) setBranchHoverGroupId(hit);
    } else if (branchHoverGroupId){
      setBranchHoverGroupId(null);
    }
  };
  const onStageUp = (e) => {
    if (draggingEdgeId) {
      setDraggingEdgeId(null);
      edgeDragStart.current = null;
    }
    setPanning(false);
    panStart.current = 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]);
  // Keyed by edge.id for lookups outside the main edges.map iteration.
  const edgeOffsetById = useMemoC(()=>{
    const m = {};
    edges.forEach((e,i)=>{ m[e.id] = edgeOffsets[i]; });
    return m;
  }, [edges, edgeOffsets]);

  // Resolve branch-node positions: only edge-attached branches need
  // re-positioning each frame (snap to bezier at parameter t). Group-member
  // branches are regular members with free (x, y), included naturally in the
  // metaball blob as the user drags them around.
  const resolvedNodes = useMemoC(()=>{
    return nodes.map(n => {
      if (n.variant !== 'branch' || !n.attachedEdgeId) return n;
      const edge = edges.find(e => e.id === n.attachedEdgeId);
      if (!edge) return n;
      const from = nodes.find(nn => nn.id === edge.from);
      const to = nodes.find(nn => nn.id === edge.to);
      if (!from || !to) return n;
      const geom = computeEdgeBezier(edge, from, to, edgeOffsetById[edge.id] || 0);
      const pt = pointOnBezier(geom.p1, {x:geom.cx, y:geom.cy}, geom.p2, n.t ?? 0.5);
      return { ...n, x: pt.x, y: pt.y };
    });
  }, [nodes, edges, edgeOffsetById]);
  const nodeById = React.useMemo(
    () => Object.fromEntries(resolvedNodes.map(n => [n.id, n])),
    [resolvedNodes]
  );

  // Resolve every note's anchor point — node center for nodeId notes, edge
  // midpoint for targetEdgeId notes. NoteBox / NoteConnector consume {x,y}
  // directly without caring which kind of target it is.
  const noteAnchors = useMemoC(()=>{
    const map = {};
    notes.forEach(n => {
      if (n.targetEdgeId) {
        const edge = edges.find(e => e.id === n.targetEdgeId);
        if (!edge) return;
        const from = nodeById[edge.from];
        const to = nodeById[edge.to];
        if (!from || !to) return;
        const geom = computeEdgeBezier(edge, from, to, edgeOffsetById[edge.id] || 0);
        const pt = pointOnBezier(geom.p1, {x:geom.cx, y:geom.cy}, geom.p2, 0.5);
        map[n.id] = { x: pt.x, y: pt.y };
      } else if (n.nodeId) {
        const node = nodeById[n.nodeId];
        if (node) map[n.id] = { x: node.x, y: node.y };
      }
    });
    return map;
  }, [notes, edges, nodeById, edgeOffsetById]);

  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':''}${branchNodeMode?' branch-mode':''}`}
      style={{backgroundColor:bgColor}}
      onPointerDown={onStageDown}
      onPointerMove={onStageMove}
      onPointerUp={onStageUp}
      onPointerCancel={onStageUp}
    >
      <div id="capture-root" className={`world${showFlow?'':' flow-hidden'}`} 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}
                branchHover={branchNodeMode && branchHoverGroupId===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>
        )}

        <svg className="edges-svg" width="4000" height="3000" style={{position:'absolute',left:0,top:0}}>
          {/* Edge-note connector lines render FIRST inside this SVG, so the
              arrow paths drawn after naturally overlay them — labels and the
              edge body cover the connector where they cross, no extra
              avoidance logic needed. Hidden when the global notes toggle is
              off (matches node-note behavior). */}
          {showNotes && notes.filter(n => n.targetEdgeId).map(note => (
            <NoteConnector key={'edge-'+note.id} note={note} anchor={noteAnchors[note.id]}/>
          ))}
          {edges.map((e, i)=>(
            <Edge
              key={e.id}
              edge={e}
              nodes={nodeById}
              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('Arrow stacking order swapped');
                  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>

        {/* Edge label layer — drawn in a separate SVG AFTER every edge path so
            that a later edge crossing over an earlier edge's label doesn't
            cover the pill background. Fixes "name pill looks transparent when
            another arrow passes behind it". */}
        <svg className="edge-labels-svg" width="4000" height="3000"
             style={{position:'absolute',left:0,top:0,overflow:'visible',pointerEvents:'none'}}>
          {edges.map((e, i)=>(
            <g key={e.id} style={{pointerEvents:'auto'}}>
              <EdgeLabel
                edge={e}
                nodes={nodeById}
                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('Arrow stacking order swapped');
                    return;
                  }
                  setSelection({type:'edge', id});
                }}
                onEdit={(id)=>onEditEdge(id)}
                onDragStart={handleEdgeDragStart}
              />
            </g>
          ))}
        </svg>

        {resolvedNodes.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);
                const raw = nodes.find(nn => nn.id === id);
                // Edge-attached branches slide along the bezier — the drag
                // position projects to the nearest t on the curve, never
                // updating x/y directly.
                if (raw && raw.variant === 'branch' && raw.attachedEdgeId){
                  const edge = edges.find(e => e.id === raw.attachedEdgeId);
                  if (edge){
                    const from = nodes.find(nn => nn.id === edge.from);
                    const to   = nodes.find(nn => nn.id === edge.to);
                    if (from && to){
                      const geom = computeEdgeBezier(edge, from, to, edgeOffsetById[edge.id] || 0);
                      const proj = closestPointOnBezier(geom.p1, {x:geom.cx,y:geom.cy}, geom.p2, x, y);
                      updateNode(id, { t: proj.t, x: proj.x, y: proj.y }, {skipHistory:true});
                      return;
                    }
                  }
                }
                // Everything else (regular cards, group-member branches): free
                // drag. Group-member branches are just regular members — the
                // metaball blob naturally re-shapes around them.
                updateNode(id, {x,y}, {skipHistory:true});
              }}
              onDragEnd={(id, moved)=>{
                if (moved){
                  const n2 = nodes.find(x=>x.id===id);
                  if (n2) {
                    if (n2.variant === 'branch' && n2.attachedEdgeId !== undefined){
                      updateNode(id, { t: n2.t });  // commit history w/ latest t
                    } else {
                      updateNode(id, { x:n2.x, y:n2.y });
                    }
                  }
                }
                setDraggingNodeId(null);
              }}
              onStartConnect={doStartConnect}
              onFinishConnect={doFinishConnect}
              onDelete={deleteNode}
            />
          );
        })}

        {/* Card / arrow annotation layer — only NODE-attached connectors live
            here (edge-attached connectors render inside edges-svg above so
            labels can occlude them). All boxes render here so they sit above
            edges and nodes. Wrapped in `.notes-hidden` when the global
            showNotes toggle is off so CSS hides both layers in one shot
            (clean screenshot export too). */}
        {notes.length > 0 && (
          <div className={showNotes ? '' : 'notes-hidden'}>
            <svg className="notes-svg" width="4000" height="3000" style={{overflow:'visible'}}>
              {notes.filter(n => !n.targetEdgeId).map(note => (
                <NoteConnector key={note.id} note={note} anchor={noteAnchors[note.id]}/>
              ))}
            </svg>
            {notes.map(note => (
              <NoteBox
                key={note.id}
                note={note}
                anchor={noteAnchors[note.id]}
                selected={selection?.type==='note' && selection.id===note.id}
                worldZoom={view.zoom}
                onSelect={(id)=>setSelection({type:'note', id})}
                onDrag={(id, offset)=> updateNote(id, { offset }, { skipHistory:true })}
                onDragEnd={(id, moved)=>{
                  if (moved){
                    // commit a history entry
                    const n2 = notes.find(x=>x.id===id);
                    if (n2) updateNote(id, { offset: n2.offset });
                  }
                }}
              />
            ))}
          </div>
        )}

        {/* 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 });
        }}>Reset</button>
      </div>
      <div className="hint">Drag to pan · Wheel to zoom · Drag the <b style={{color:'#7FE3A9'}}>＋</b> on a card to draw an arrow · Shift-click to swap arrow stacking order</div>

      {shiftHeld && selection?.type === 'edge' && (
        <div style={{
          position:'absolute', left:'50%', top:20, transform:'translateX(-50%)',
          background:'var(--bg-card)', color:'var(--gold)', fontWeight:800, fontSize:13,
          padding:'8px 16px', borderRadius:999,
          border:'1px solid var(--gold-deep)',
          boxShadow:'0 0 16px rgba(233,196,106,.25), 0 4px 14px rgba(0,0,0,.5)',
          letterSpacing:'.02em',
          zIndex:8, pointerEvents:'none', whiteSpace:'nowrap',
        }}>
          🔀 Click another arrow to swap their stacking order
        </div>
      )}

      {branchNodeMode && (
        <div style={{
          // bottom:120 keeps the pill clear of the right-bottom credit overlay
          // (which sits at bottom:70, ~22px tall) and the pan-ui / hint row at
          // bottom:14.
          position:'absolute', left:'50%', bottom:120, transform:'translateX(-50%)',
          background:'var(--bg-card)', color:'var(--gold)', fontWeight:800, fontSize:13,
          padding:'8px 16px', borderRadius:999,
          border:'1px solid var(--gold-deep)',
          boxShadow:'0 0 16px rgba(233,196,106,.25), 0 4px 14px rgba(0,0,0,.5)',
          letterSpacing:'.02em',
          zIndex:8, pointerEvents:'none', whiteSpace:'nowrap',
        }}>
          ● Click an arrow or zone blob to place a branch node · <span style={{color:'var(--ink-soft)', fontWeight:600}}>Click "Branch node" again or press Esc to finish</span>
        </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:`1px solid ${g?.color || '#E9C46A'}`,
              boxShadow:'0 4px 14px rgba(120,76,110,.25)',
              zIndex:8, display:'flex', alignItems:'center', gap:10, whiteSpace:'nowrap',
            }}>
            <span>🫧 Editing zone ({count} card{count===1?'':'s'}) — click a card to add/remove</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'}}>Done</button>
          </div>
        );
      })()}
    </div>
  );
}

window.Canvas = Canvas;
