// 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 (