// 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 (滞在時間 + 諸費用) 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 leaveAt (from-departure // → fallback from-arrival → fallback to-arrival). Edges with no times // anywhere fall back to the legacy edge.seq value (so the manual // 番号入替 / ↑↓ 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]; // Prefer the chronologically EARLIER endpoint as the leaveAt — covers // both the natural case (from=earlier, normal arrow) and the auto-flip // case (the user drew it backward; we sort by the actual earlier one). 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; // Stable tie-break: legacy seq, then array index. 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' ? '地点をクリックしてください(矢印は対象外)' : ''); 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('矢印の設定をコピーしました'); } 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 (滞在時間 + 諸費用), 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 && ( 角の丸み(ドラッグして調整) )} {/* 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('矢印をクリックしてください(地点は対象外)'); 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(`「${src.name || '名称未設定'}」の設定をコピーしました`); } 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)}%
ドラッグで移動 / ホイール:ズーム / 地点に到着・出発時刻を入れると矢印が自動で繋がる / 選択中にShift+クリックで他の地点・矢印の設定をコピー
{shiftHeld && selection?.type === 'edge' && (
🔀 そのまま別の矢印をクリックで重なり順を入れ替え(番号は変わりません)
)} {/* 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'}}> 新しい{edgePickMode.endpoint === 'from' ? '出発地点' : '行き先'}をクリック {other && ({edgePickMode.endpoint === 'from' ? '→ ' : '← '}{other.name}) }
); })()} {/* Note-anchor pick-mode banner */} {notePickMode && (
e.stopPropagation()} onClick={(e)=>e.stopPropagation()} style={{borderColor:'#7C3AED', background:'#FAF5FF'}}> メモの紐づけ先となる{notePickMode.type === 'node' ? '地点' : '矢印'}をクリック
)} {composingGroupId && (() => { const g = groups.find(x => x.id === composingGroupId); const count = g?.nodeIds?.length || 0; return (
e.stopPropagation()} onClick={(e)=>e.stopPropagation()}> グループ編集中({count}地点) ノードをクリックで追加/除外
); })()}
); } // ── 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;