// Canvas: pan/zoom stage holding nodes + edges + groups + notes.
// Also renders:
//   - the corner-radius drag handle for the selected orth edge
//   - dotted "anchor" lines from each anchored note to its target
//   - per-node annotation chips (StayDuration + Cost)
const { useRef: useRefC, useState: useStateC, useEffect: useEffectC, useMemo: useMemoC } = React;

function Canvas({
  nodes, edges, groups, notes,
  defaultCurrency,
  whooshEdgeIds,
  showSeq, showNotes, showAnnotations,
  notePickMode, onNoteAnchorPickTarget, onCancelNotePick,
  edgePickMode, onEdgeEndpointPickTarget, onCancelEdgePick,
  selection, setSelection,
  updateNode, deleteNode, addEdge, updateEdge,
  updateNote, deleteNote,
  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);
  // (connectFrom / connectPos state removed: edges are auto-derived from
  // node times now — there's no manual "drag from + to make an arrow"
  // mode any more.)
  const [draggingEdgeId, setDraggingEdgeId] = useStateC(null);
  const edgeDragStart = useRefC(null);
  const [shiftHeld, setShiftHeld] = useStateC(false);
  const cornerDragRef = useRefC(null);

  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);
    };
  }, []);

  const clampView = (tx, ty, zoom) => {
    const el = stageRef.current;
    if (!el || nodes.length === 0) return { tx, ty };
    const rect = el.getBoundingClientRect();
    const margin = 200;
    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);
    });
    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)),
    };
  };

  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 edgeOffsets = useMemoC(()=>{
    const counts = {};
    return edges.map(e => {
      if (e.style === 'orth-h' || e.style === 'orth-v') return 0;
      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 step = 32;
      const spread = idx===0 ? 0 : (idx%2? 1: -1) * Math.ceil(idx/2) * step;
      return spread * dir;
    });
  }, [edges]);

  // ── Derived sequence numbering by time ─────────────────────────
  // The visible ① ② ③ … on each edge is derived from the times on its
  // endpoints — so inserting a new node mid-trip auto-shuffles numbers.
  // Edges with at least one timed endpoint sort by the chronologically
  // earlier endpoint. Edges with no times anywhere fall back to the
  // legacy edge.seq value (so the manual sequence-swap / ↑↓ UI still
  // works for purely-undated graphs).
  const derivedSeqMap = useMemoC(() => {
    const map = new Map();
    const nodeById = Object.fromEntries(nodes.map(n => [n.id, n]));
    const keyOf = (e) => {
      const f = nodeById[e.from], t = nodeById[e.to];
      const fromAt = f && (f.departure || f.arrival);
      const toAt   = t && (t.arrival   || t.departure);
      if (fromAt && toAt) return fromAt < toAt ? fromAt : toAt;
      return fromAt || toAt || null;
    };
    const timed = [], untimed = [];
    edges.forEach(e => {
      const k = keyOf(e);
      if (k) timed.push({ edge: e, k });
      else   untimed.push(e);
    });
    timed.sort((a, b) => {
      if (a.k !== b.k) return a.k < b.k ? -1 : 1;
      const sa = a.edge.seq, sb = b.edge.seq;
      if ((sa || 0) !== (sb || 0)) return (sa || 0) - (sb || 0);
      return edges.indexOf(a.edge) - edges.indexOf(b.edge);
    });
    untimed.sort((a, b) => (a.seq || 0) - (b.seq || 0));
    let n = 1;
    timed.forEach(({ edge }) => map.set(edge.id, n++));
    untimed.forEach(edge => map.set(edge.id, n++));
    return map;
  }, [edges, nodes]);

  const handleEdgeDragStart = (id, e) => {
    setDraggingEdgeId(id);
    const edge = edges.find(ed=>ed.id===id);
    if (!edge) return;
    if (edge.style === 'orth-h' || edge.style === 'orth-v') 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) => {
    if (e.target.closest('.node') || e.target.closest('.edge-label-g') || 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('.note') || e.target.closest('.corner-handle') || e.target.closest('.anno-chip') || e.target.closest('.node-anno-chip')) return;
    setSelection(null);
    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 (cornerDragRef.current) {
      const { edgeId, corner, ibx, iby, startR, startProj } = cornerDragRef.current;
      const w = screenToWorld(e.clientX, e.clientY);
      const dx = w.x - corner.x;
      const dy = w.y - corner.y;
      const proj = dx * ibx + dy * iby;
      const newR = Math.max(0, startR + (proj - startProj));
      const edge = edges.find(ed => ed.id === edgeId);
      if (edge){
        const g = getOrthGeom(edge, nodes);
        if (g) {
          const r = Math.min(newR, g.maxR);
          updateEdge(edgeId, { cornerR: r }, { skipHistory: true });
        }
      }
      return;
    }
    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 }));
    }
  };
  const onStageUp = (e) => {
    if (cornerDragRef.current) {
      const { edgeId } = cornerDragRef.current;
      const edge = edges.find(ed => ed.id === edgeId);
      if (edge && typeof edge.cornerR === 'number') {
        updateEdge(edgeId, { cornerR: edge.cornerR });
      }
      cornerDragRef.current = null;
    }
    if (draggingEdgeId) {
      setDraggingEdgeId(null);
      edgeDragStart.current = null;
    }
    setPanning(false);
    panStart.current = 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)));
    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);
  });

  // (doStartConnect / doFinishConnect removed: edges are auto-derived from
  // node times, the user no longer hand-draws them.)

  const selectedOrth = (() => {
    if (selection?.type !== 'edge') return null;
    const edge = edges.find(e => e.id === selection.id);
    if (!edge) return null;
    if (edge.style !== 'orth-h' && edge.style !== 'orth-v') return null;
    return getOrthGeom(edge, nodes);
  })();

  const onCornerDown = (e) => {
    if (!selectedOrth) return;
    e.stopPropagation();
    const w = screenToWorld(e.clientX, e.clientY);
    const dx = w.x - selectedOrth.corner.x;
    const dy = w.y - selectedOrth.corner.y;
    const proj = dx * selectedOrth.inwardBisector.x + dy * selectedOrth.inwardBisector.y;
    cornerDragRef.current = {
      edgeId: selectedOrth.edgeId,
      corner: selectedOrth.corner,
      ibx: selectedOrth.inwardBisector.x,
      iby: selectedOrth.inwardBisector.y,
      startR: selectedOrth.R,
      startProj: proj,
    };
    try { e.currentTarget.setPointerCapture(e.pointerId); } catch(_){}
  };

  // ── Edge selection click handler ──────────────────────────
  // Intercepts (in priority order): note-anchor pick mode → seq-swap mode →
  // shift+click z-order swap → ordinary selection.
  const handleEdgeSelect = (id, shiftKey) => {
    if (notePickMode && notePickMode.type === 'edge'){
      onNoteAnchorPickTarget && onNoteAnchorPickTarget(id);
      return;
    }
    if (notePickMode){
      if (flash) flash(notePickMode.type === 'node'
        ? 'Click a place (arrows are not pickable here)' : '');
      return;
    }
    // Shift+click on a DIFFERENT edge while an edge is already selected
    // → "pull" semantics: copy the clicked edge's settings (relation /
    // style / color / label / stamp / cost / customOffset / cornerR)
    // INTO the selected one. Same UX as the node-side time copy.
    if (shiftKey && selection?.type==='edge' && selection.id !== id && updateEdge){
      const src = edges.find(e => e.id === id);
      if (src){
        updateEdge(selection.id, {
          relation: src.relation,
          style: src.style,
          color: src.color,
          label: src.label,
          stamp: src.stamp,
          cost: src.cost || '',
          customOffset: src.customOffset,
          cornerR: src.cornerR,
        });
        if (flash) flash('Copied arrow settings');
      }
      return;
    }
    setSelection({type:'edge', id});
  };

  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,
        }}>

        <div className={`stage-bg ${bgPattern}`} style={{
          position:'absolute', left:-2000, top:-2000, width:8000, height:7000,
          backgroundColor:bgColor, pointerEvents:'none',
        }}/>

        {showTitleOnCanvas && (title || subtitle) && (() => {
          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: 20, transform:'translateX(-50%)'}}>
              {title ? <div className="t1">{title}</div> : null}
              {subtitle ? <div className="t2">{subtitle}</div> : null}
            </div>
          );
        })()}

        {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)=>{
                  if (composingGroupId) return;
                  setSelection({type:'group', id});
                }}
              />
            ))}
          </svg>
        )}

        <svg className="edges-svg" width="4000" height="3000" style={{position:'absolute',left:0,top:0}}>
          {/* Note-link lines drawn FIRST so they sit BEHIND the edges, edge
              labels, and node avatars at both ends. The visible portion is
              only the gap between the anchored target and the note card. */}
          {(showNotes !== false) && notes.map(n => {
            const anchor = window.resolveNoteAnchor && window.resolveNoteAnchor(n, nodes, edges, edgeOffsets);
            if (!anchor) return null;
            // Note pos is the top-left of the card; aim the line at the card's
            // visual centroid (approx 90px right, 22px down).
            const noteCenter = { x: n.x + 90, y: n.y + 22 };
            return (
              <line key={'nlink-'+n.id} className="note-link"
                    x1={anchor.x} y1={anchor.y}
                    x2={noteCenter.x} y2={noteCenter.y}
                    stroke="#64748B" strokeWidth={1.1} strokeDasharray="3 4"
                    opacity={0.75} pointerEvents="none"/>
            );
          })}

          {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}
              seqNumber={showSeq !== false ? (derivedSeqMap.get(e.id) || (i + 1)) : null}
              whoosh={whooshEdgeIds && whooshEdgeIds.has(e.id)}
              defaultCurrency={defaultCurrency}
              showAnnotations={showAnnotations}
              updateEdge={updateEdge}
              worldZoom={view.zoom}
              onSelect={handleEdgeSelect}
              onEdit={(id)=>onEditEdge(id)}
              onDragStart={handleEdgeDragStart}
            />
          ))}

          {/* Per-node annotation chips (StayDuration + Cost), rendered in the SVG
              layer so they sit alongside edge anno chips. Each is independently
              draggable via node.annoOffset. */}
          {(showAnnotations !== false) && nodes.map(n => {
            const items = [];
            const dt = window.tourDateTime;
            const timeText = dt ? dt.formatTimePair(n.arrival, n.departure) : '';
            if (timeText){
              items.push({ icon:'clock', text: timeText });
            }
            // Display normalisation: bare numbers ("300") get the trip's
            // default currency prefixed for the chip ("$300"). User input
            // with explicit symbols ("¥25", "300円") is preserved as-is.
            if (n.cost) {
              const fmt = window.formatCostDisplay || ((s) => s);
              items.push({ icon:'wallet', text: fmt(n.cost, defaultCurrency || '$') });
            }
            if (items.length === 0) return null;
            const ao = n.annoOffset || defaultNodeAnnoOffset(n);
            const linkVisible = Math.hypot(ao.dx, ao.dy - defaultNodeAnnoOffset(n).dy) > 36;
            return (
              <g key={'nanno-'+n.id} className="node-anno-group">
                {linkVisible && (
                  <line className="anno-link"
                        x1={n.x} y1={n.y}
                        x2={n.x + ao.dx} y2={n.y + ao.dy}
                        stroke="#94A3B8" strokeWidth={1} strokeDasharray="2 4"
                        opacity={0.55} pointerEvents="none"/>
                )}
                <NodeAnnoChip
                  items={items}
                  x={n.x + ao.dx} y={n.y + ao.dy}
                  selected={selection?.type==='node' && selection.id===n.id}
                  nodeId={n.id}
                  currentOffset={ao}
                  updateNode={updateNode}
                  worldZoom={view.zoom}
                  onSelect={(id)=>setSelection({type:'node', id})}/>
              </g>
            );
          })}

          {/* (in-progress drag line removed — manual edge drawing is gone.) */}

          {selectedOrth && (
            <g className="corner-handle"
               onPointerDown={onCornerDown}
               style={{cursor:'grab'}}>
              <line
                x1={selectedOrth.corner.x} y1={selectedOrth.corner.y}
                x2={selectedOrth.handlePos.x} y2={selectedOrth.handlePos.y}
                stroke="#10A37F" strokeWidth={1} strokeDasharray="2 3" opacity="0.5"/>
              <circle
                cx={selectedOrth.handlePos.x} cy={selectedOrth.handlePos.y}
                r={8}
                fill="#fff" stroke="#10A37F" strokeWidth={2}/>
              <circle
                cx={selectedOrth.handlePos.x} cy={selectedOrth.handlePos.y}
                r={3} fill="#10A37F"/>
              <title>corner roundness (drag to adjust)</title>
            </g>
          )}
        </svg>

        {/* Notes layer */}
        {(showNotes !== false) && notes.map(n => (
          <Note
            key={n.id}
            note={n}
            selected={selection?.type==='note' && selection.id===n.id}
            worldZoom={view.zoom}
            onSelect={(id)=>setSelection({type:'note', id})}
            onDrag={(id,x,y)=>{updateNote(id,{x,y},{skipHistory:true});}}
            onDragEnd={(id, moved)=>{
              if (moved){
                const n2 = notes.find(x=>x.id===id);
                if (n2) updateNote(id,{x:n2.x,y:n2.y});
              }
            }}
            onDelete={deleteNote}
          />
        ))}

        {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, opts)=>{
                const shift = !!(opts && opts.shiftKey);
                // Edge re-target pick mode: clicking a node sets that edge's
                // from / to endpoint to this node. Highest priority.
                if (edgePickMode){
                  onEdgeEndpointPickTarget && onEdgeEndpointPickTarget(id);
                  return;
                }
                // Note-anchor pick mode: clicking a node sets the anchor
                if (notePickMode && notePickMode.type === 'node'){
                  onNoteAnchorPickTarget && onNoteAnchorPickTarget(id);
                  return;
                }
                if (notePickMode && notePickMode.type === 'edge'){
                  if (flash) flash('Click an arrow (places are not pickable here)');
                  return;
                }
                if (composingGroupId && toggleGroupMember){
                  toggleGroupMember(composingGroupId, id);
                  return;
                }
                // Shift+click on a DIFFERENT node while a node is already
                // selected → "pull" semantics: copy the clicked node's
                // settings (icon / color / shape / highlight / arrival /
                // departure / cost / costCategory) INTO the selected one.
                // We deliberately skip name, subtitle, and position because
                // those are intrinsic identifiers/coordinates of the place.
                if (shift && selection?.type === 'node' && selection.id !== id){
                  const src = nodes.find(n => n.id === id);
                  if (src && updateNode){
                    const patch = {
                      icon: src.icon ?? null,
                      shape: src.shape ?? 'circle',
                      color: src.color ?? '#FFFFFF',
                      emoji: src.emoji ?? null,
                      image: src.image ?? null,
                      highlight: src.highlight ?? null,
                      arrival: src.arrival || '',
                      departure: src.departure || '',
                      cost: src.cost || '',
                      costCategory: src.costCategory ?? undefined,
                    };
                    updateNode(selection.id, patch);
                    if (flash) flash(`Copied settings from "${src.name || 'Untitled'}"`);
                  }
                  return; // don't change selection
                }
                setSelection({type:'node', id});
              }}
              onDrag={(id,x,y)=>{ setDraggingNodeId(id); updateNode(id,{x,y}, {skipHistory:true}); }}
              onDragEnd={(id, moved)=>{
                if (moved){
                  const n2 = nodes.find(x=>x.id===id);
                  if (n2) updateNode(id,{x:n2.x,y:n2.y});
                }
                setDraggingNodeId(null);
              }}
              onDelete={deleteNode}
            />
          );
        })}

        {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: zoom / Enter <b style={{color:'var(--green-deep)'}}>arrival &amp; departure times</b> — arrows auto-connect in time order / While selecting, <b style={{color:'var(--green-deep)'}}>Shift+click</b> another place or arrow to copy its settings</div>

      {shiftHeld && selection?.type === 'edge' && (
        <div className="shift-hint">🔀 Click another arrow to swap z-order (numbers preserved)</div>
      )}

      {/* Edge re-target pick-mode banner */}
      {edgePickMode && (() => {
        const e = edges.find(ed => ed.id === edgePickMode.edgeId);
        const otherId = e ? (edgePickMode.endpoint === 'from' ? e.to : e.from) : null;
        const other = nodes.find(n => n.id === otherId);
        return (
          <div className="compose-banner"
               onPointerDown={(ev)=>ev.stopPropagation()}
               onClick={(ev)=>ev.stopPropagation()}
               style={{borderColor:'#F59E0B', background:'#FFFBEB'}}>
            <span style={{display:'inline-flex', alignItems:'center', gap:6}}>
              <window.TourIcon name="route" size={14} stroke="#B45309"/>
              new<b>{edgePickMode.endpoint === 'from' ? 'Origin' : 'destination'}</b>. Click
              {other && <span style={{color:'var(--ink-muted)', fontWeight:500, marginLeft:4}}>
                ({edgePickMode.endpoint === 'from' ? '→ ' : '← '}{other.name})
              </span>}
            </span>
            <button className="btn sm" onClick={onCancelEdgePick} style={{padding:'4px 10px'}}>
              <window.TourIcon name="x" size={12}/> Cancel
            </button>
          </div>
        );
      })()}

      {/* Note-anchor pick-mode banner */}
      {notePickMode && (
        <div className="compose-banner"
             onPointerDown={(e)=>e.stopPropagation()}
             onClick={(e)=>e.stopPropagation()}
             style={{borderColor:'#7C3AED', background:'#FAF5FF'}}>
          <span style={{display:'inline-flex', alignItems:'center', gap:6}}>
            <window.TourIcon name={notePickMode.type === 'node' ? 'map-pin' : 'route'} size={14} stroke="#7C3AED"/>
            Note's link target: <b>{notePickMode.type === 'node' ? 'Place' : 'arrow'}</b>. Click
          </span>
          <button className="btn sm" onClick={onCancelNotePick} style={{padding:'4px 10px'}}>
            <window.TourIcon name="x" size={12}/> Cancel
          </button>
        </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()}>
            <span style={{display:'inline-flex', alignItems:'center', gap:6}}>
              <window.TourIcon name="calendar" size={14} stroke="currentColor"/>
              Editing group ({count} places). Click 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 10px'}}>Done</button>
          </div>
        );
      })()}
    </div>
  );
}

// ── Node anno chip — wrapper around AnnoChip with node.annoOffset drag ──
function NodeAnnoChip({ items, x, y, selected, nodeId, currentOffset, updateNode, worldZoom, onSelect }){
  const onPointerDown = (e) => {
    e.stopPropagation();
    if (onSelect) onSelect(nodeId);
    const startX = e.clientX, startY = e.clientY;
    const ox = currentOffset.dx || 0, oy = currentOffset.dy || 0;
    let last = { dx: ox, dy: oy };
    let moved = false;
    const z = worldZoom || 1;
    const target = e.currentTarget;
    try { target.setPointerCapture(e.pointerId); } catch(_){}
    const onMove = (ev) => {
      const dx = (ev.clientX - startX) / z;
      const dy = (ev.clientY - startY) / z;
      if (Math.abs(dx)+Math.abs(dy) > 2) moved = true;
      last = { dx: ox + dx, dy: oy + dy };
      updateNode(nodeId, { annoOffset: last }, { skipHistory: true });
    };
    const onUp = (ev) => {
      target.removeEventListener('pointermove', onMove);
      target.removeEventListener('pointerup', onUp);
      target.removeEventListener('pointercancel', onUp);
      try { target.releasePointerCapture(ev.pointerId); } catch(_){}
      if (moved) updateNode(nodeId, { annoOffset: last });
    };
    target.addEventListener('pointermove', onMove);
    target.addEventListener('pointerup', onUp);
    target.addEventListener('pointercancel', onUp);
  };
  return (
    <g className={`node-anno-chip${selected?' selected':''}`} onPointerDown={onPointerDown} style={{cursor:'grab'}}>
      {window.AnnoChip && (
        <window.AnnoChip
          items={items}
          anchorX={x} anchorY={y}
          offsetX={0} offsetY={0}
          selected={selected}
          onPointerDown={()=>{}} />
      )}
    </g>
  );
}

// Pick a default position for a node's anno chip that clears the name and
// subtitle pills. Without this nodes with both name + subtitle had their
// chip overlap the subtitle pill; bumping dy by line count keeps each label
// element visible at first render. Users can then drag the chip anywhere.
function defaultNodeAnnoOffset(node){
  const lp = node.labelPos || 'bottom';
  if (lp === 'top') return { dx: 0, dy: 56 };
  if (lp === 'left' || lp === 'right') return { dx: 0, dy: 56 };
  // Bottom labels — make room for name (≈22px) and subtitle (≈18px) below avatar.
  const lines = (node.name ? 1 : 0) + (node.subtitle ? 1 : 0);
  return { dx: 0, dy: 56 + lines * 22 };
}

function getOrthGeom(edge, nodes){
  if (!edge) return null;
  if (edge.style !== 'orth-h' && edge.style !== 'orth-v') return null;
  const a = nodes.find(n => n.id === edge.from);
  const b = nodes.find(n => n.id === edge.to);
  if (!a || !b) return null;
  const nrA = 40, nrB = 40;  // node radius shrink — uniform now that branch nodes are gone
  const sx = Math.sign(b.x - a.x) || 1;
  const sy = Math.sign(b.y - a.y) || 1;
  let p1, p2;
  if (edge.style === 'orth-h'){
    p1 = { x: a.x + sx * nrA, y: a.y };
    p2 = { x: b.x,            y: b.y - sy * nrB };
  } else {
    p1 = { x: a.x,            y: a.y + sy * nrA };
    p2 = { x: b.x - sx * nrB, y: b.y };
  }
  const cornerR = (typeof edge.cornerR === 'number') ? edge.cornerR : 8;
  const orth = window.buildOrthogonalPath(p1, p2, edge.style, cornerR);
  const handleDist = Math.max(orth.R, 16);
  return {
    edgeId: edge.id,
    corner: orth.corner,
    R: orth.R,
    maxR: orth.maxR,
    inwardBisector: orth.inwardBisector,
    handlePos: {
      x: orth.corner.x + orth.inwardBisector.x * handleDist,
      y: orth.corner.y + orth.inwardBisector.y * handleDist,
    },
  };
}

window.Canvas = Canvas;
