// 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 (
{stageRef.current=el; if(canvasRef)canvasRef.current=el;}} className={`stage ${panning?'panning':''}`} style={{backgroundColor:bgColor}} onPointerDown={onStageDown} onPointerMove={onStageMove} onPointerUp={onStageUp} onPointerCancel={onStageUp} >
{showTitleOnCanvas && (title || subtitle) && (() => { const cx = nodes.length > 0 ? nodes.reduce((s,n) => s + n.x, 0) / nodes.length : 500; return (
{title ?
{title}
: null} {subtitle ?
{subtitle}
: null}
); })()} {groups.length > 0 && ( {groups.map(g => ( { if (composingGroupId) return; setSelection({type:'group', id}); }} /> ))} )} {/* 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 ( ); })} {edges.map((e, i)=>( [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 ( {linkVisible && ( )} setSelection({type:'node', id})}/> ); })} {/* (in-progress drag line removed — manual edge drawing is gone.) */} {selectedOrth && ( corner roundness (drag to adjust) )} {/* Notes layer */} {(showNotes !== false) && notes.map(n => ( 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 ( { 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 && ( {groups.map(g => ( ))} )}
{Math.round(view.zoom*100)}%
Drag to pan / Wheel: zoom / Enter arrival & departure times — arrows auto-connect in time order / While selecting, Shift+click another place or arrow to copy its settings
{shiftHeld && selection?.type === 'edge' && (
🔀 Click another arrow to swap z-order (numbers preserved)
)} {/* 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 (
ev.stopPropagation()} onClick={(ev)=>ev.stopPropagation()} style={{borderColor:'#F59E0B', background:'#FFFBEB'}}> new{edgePickMode.endpoint === 'from' ? 'Origin' : 'destination'}. Click {other && ({edgePickMode.endpoint === 'from' ? '→ ' : '← '}{other.name}) }
); })()} {/* Note-anchor pick-mode banner */} {notePickMode && (
e.stopPropagation()} onClick={(e)=>e.stopPropagation()} style={{borderColor:'#7C3AED', background:'#FAF5FF'}}> Note's link target: {notePickMode.type === 'node' ? 'Place' : 'arrow'}. Click
)} {composingGroupId && (() => { const g = groups.find(x => x.id === composingGroupId); const count = g?.nodeIds?.length || 0; return (
e.stopPropagation()} onClick={(e)=>e.stopPropagation()}> Editing group ({count} places). Click to add/remove
); })()}
); } // ── 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 ( {window.AnnoChip && ( {}} /> )} ); } // 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;