// Edge rendering for the tour/transit tool. // // Features: // - Curve / rail / wavy / orth styles // - Lucide-icon "stamp" inside the label pill (no emoji) // - L-shape labels stay at the polyline geometric midpoint regardless of R // - Annotation chip (duration / distance / cost) is rendered as a SEPARATE // element with its own offset (edge.annoOffset). It's draggable via // pointerdown and connected back to its anchor with a dotted line whenever // it's been pulled away from the default position. // Estimate rendered text width per character — CJK glyphs render about 1.15× // font size wide, Latin (Inter) ~0.6×. Used as the INITIAL estimate before // getBBox() refines (and as a fallback if getBBox doesn't fire / returns 0). function estimateTextWidth(text, fontSize){ if (!text) return 0; const s = String(text); const fs = fontSize || 11; let w = 0; for (let i = 0; i < s.length; i++) { const code = s.charCodeAt(i); if (code >= 0x2E80 && code <= 0x9FFF) w += fs * 1.15; else if (code >= 0xFF00 && code <= 0xFFEF) w += fs * 1.05; else if (code >= 0x3000 && code <= 0x303F) w += fs * 1.05; else w += fs * 0.62; } return Math.ceil(w); } // ── Curve helpers ──────────────────────────────────────────────── function edgePointOnCircle(cx,cy,tx,ty,r){ const dx = tx-cx, dy = ty-cy; const L = Math.hypot(dx,dy) || 1; return { x: cx + dx/L*r, y: cy + dy/L*r }; } function buildCurve(p1, p2, offset){ const mx = (p1.x+p2.x)/2, my = (p1.y+p2.y)/2; const dx = p2.x-p1.x, dy = p2.y-p1.y; const L = Math.hypot(dx,dy) || 1; const nx = -dy/L, ny = dx/L; const cx = mx + nx*offset; const cy = my + ny*offset; return { d: `M ${p1.x} ${p1.y} Q ${cx} ${cy} ${p2.x} ${p2.y}`, cx, cy }; } function sampleQuad(p1, c, p2, n=60){ const pts=[]; for(let i=0;i<=n;i++){ const t=i/n, it=1-t; pts.push({ x: it*it*p1.x + 2*it*t*c.x + t*t*p2.x, y: it*it*p1.y + 2*it*t*c.y + t*t*p2.y, t, }); } return pts; } function polyToPath(pts){ return pts.map((p,i)=> (i?'L':'M') + p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' '); } function wavePathAlong(p1, c, p2, amp=7, freq=0.09){ const samples = sampleQuad(p1,c,p2,120); const out=[]; for(let i=0;i 0 ? R * Math.PI / 2 : 0; const total = legA + arc + legB; if (total <= 0) return { x: corner.x, y: corner.y }; const half = total / 2; if (half <= legA){ // Midpoint sits on the first straight leg. if (horizFirst) return { x: p1.x + sx * half, y: p1.y }; return { x: p1.x, y: p1.y + sy * half }; } if (half <= legA + arc){ // Midpoint sits on the rounded corner. Sample the same quadratic Bezier // we drew — that way the label tracks the visible curve, not an idealised // arc that diverges from it slightly. const t = (half - legA) / arc; // 0..1 progress through arc let P0, P1, P2; if (horizFirst){ P0 = { x: corner.x - sx*R, y: corner.y }; P1 = corner; P2 = { x: corner.x, y: corner.y + sy*R }; } else { P0 = { x: corner.x, y: corner.y - sy*R }; P1 = corner; P2 = { x: corner.x + sx*R, y: corner.y }; } const it = 1 - t; return { x: it*it*P0.x + 2*it*t*P1.x + t*t*P2.x, y: it*it*P0.y + 2*it*t*P1.y + t*t*P2.y, }; } // Midpoint sits on the second straight leg. const past = half - legA - arc; if (horizFirst) return { x: corner.x, y: corner.y + sy * (R + past) }; return { x: corner.x + sx * (R + past), y: corner.y }; } // Resolve where an edge's LABEL pill is rendered, given the same `nodes` // dictionary Edge.jsx receives. Used by Note.jsx to anchor a memo's dotted // link line at (or just below) the label, not the geometric midpoint of the // from/to chord. `labelOffsetY` shifts the returned point downward so notes // can attach to the BOTTOM of the pill (the user's preferred direction). function computeEdgeLabelPosition(edge, nodesById, edgeOffset, labelOffsetY){ const rawA = nodesById[edge.from]; const rawB = nodesById[edge.to]; if (!rawA || !rawB) return { x: 0, y: 0 }; // Mirror Edge.jsx's auto-flip so note anchors track the flipped curve. let a = rawA, b = rawB; let flipped = false; const aT = rawA.departure || rawA.arrival; const bT = rawB.departure || rawB.arrival; if (aT && bT && aT > bT) { a = rawB; b = rawA; flipped = true; } const offY = (typeof labelOffsetY === 'number') ? labelOffsetY : 0; const style = edge.style || 'oneway'; const nrA = 40, nrB = 40; if (style === 'orth-h' || style === 'orth-v'){ let p1, p2; if (style === 'orth-h'){ const sx = Math.sign(b.x - a.x) || 1; const sy = Math.sign(b.y - a.y) || 1; p1 = { x: a.x + sx * nrA, y: a.y }; p2 = { x: b.x, y: b.y - sy * nrB }; } else { const sx = Math.sign(b.x - a.x) || 1; const sy = Math.sign(b.y - a.y) || 1; 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 = buildOrthogonalPath(p1, p2, style, cornerR); const m = orthMidpoint({ horizFirst: orth.horizFirst, p1, p2, corner: orth.corner, R: orth.R, sx: orth.sx, sy: orth.sy, }); return { x: m.x, y: m.y + offY }; } // Free curve / rail / wavy — same formula Edge.jsx uses (mirror the flip). const baseOffset = edgeOffset || 0; const actualOffset = edge.customOffset !== undefined ? (flipped ? -edge.customOffset : edge.customOffset) : (flipped ? -baseOffset : baseOffset); const c0 = buildCurve({x:a.x,y:a.y}, {x:b.x,y:b.y}, actualOffset); const p1 = edgePointOnCircle(a.x, a.y, c0.cx, c0.cy, nrA); const p2 = edgePointOnCircle(b.x, b.y, c0.cx, c0.cy, nrB); const cv = buildCurve(p1, p2, actualOffset * 0.6); return { x: 0.25*p1.x + 0.5*cv.cx + 0.25*p2.x, y: 0.25*p1.y + 0.5*cv.cy + 0.25*p2.y + offY, }; } // ── Stamp icon helpers (Lucide rendered inside the label pill) ─── function stampSvgChildren(name, ink){ if (!name || !window.tourIconAsSvgChildren) return null; return window.tourIconAsSvgChildren(name, { stroke: ink, strokeWidth: 2.4 }); } // ── Label pill ─────────────────────────────────────────────────── // Width is measured from the rendered via getBBox so long labels // (esp. CJK) don't visually spill out of the rounded rect. The Lucide stamp // icon (if any) sits inside the pill, on the left, color-matched to the text. function EdgeLabelBox({ text, stamp, x, y, color, selected, onSelect, onEdit, onDragStart }){ const textRef = React.useRef(null); const hasIcon = !!stamp; const ICON = 13; const PAD_L = 10; const PAD_R = 12; const GAP = 5; // Smart per-char initial estimate (CJK vs Latin). getBBox refines after // render. The smarter estimate avoids wide right padding on English labels // (which were getting ~7px/char overestimate at the initial state). const [tw, setTw] = React.useState(() => Math.max(20, estimateTextWidth(text, 11))); React.useLayoutEffect(()=>{ if (textRef.current) { try { const bb = textRef.current.getBBox(); if (bb.width > 0.5 && Math.abs(bb.width - tw) > 0.5) setTw(bb.width); } catch(_){} } }, [text]); const w = Math.max(36, PAD_L + (hasIcon ? ICON + GAP : 0) + tw + PAD_R); const ink = darkenForWhitePill(color); const textX = -w/2 + PAD_L + (hasIcon ? ICON + GAP : 0); return ( {e.stopPropagation(); onSelect && onSelect(e.shiftKey);}} onDoubleClick={(e)=>{e.stopPropagation(); onEdit && onEdit();}} onPointerDown={(e)=>{ e.stopPropagation(); if (e.shiftKey) return; onSelect && onSelect(false); if(onDragStart) onDragStart(e); }} style={{cursor:'pointer'}} > {hasIcon && ( {stampSvgChildren(stamp, ink)} )} {text} ); } // Slightly darken a hex color so it reads against a white pill. function darkenForWhitePill(hex){ if (!hex || typeof hex !== 'string') return '#10A37F'; const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); if (!m) return hex; const r = parseInt(m[1].slice(0,2),16); const g = parseInt(m[1].slice(2,4),16); const b = parseInt(m[1].slice(4,6),16); const lum = (0.2126*r + 0.7152*g + 0.0722*b) / 255; if (lum <= 0.55) return hex; const mix = ((lum - 0.55) / 0.45) * 0.45; const blend = c => Math.round(c * (1 - mix)); const toHex = c => blend(c).toString(16).padStart(2,'0'); return '#' + toHex(r) + toHex(g) + toHex(b); } // Sequence-number badge — small filled circle drawn near the edge's source. function SeqBadge({ x, y, n, color }){ if (!n && n !== 0) return null; const r = 11; return ( {n} ); } // ── Annotation chip (duration / cost / etc.) ──────────────────── // Stand-alone draggable chip rendered as a sibling of the edge label. Items // are { icon, text } pairs — icon is a Lucide name. The chip's own offset // (edge.annoOffset / node.annoOffset) is updated live while dragging. // // Width is computed with getBBox so CJK + punctuation like "1h30m" // or "¥18,000" don't get clipped or run together — the previous // `text.length * 7` approximation overlapped on dense entries. function AnnoChip({ items, anchorX, anchorY, offsetX, offsetY, onPointerDown, selected }){ if (!items?.length) return null; const ICON = 11; const GAP_ICON_TEXT = 3; const GAP_BETWEEN_ITEMS = 10; // includes the · separator's room const PAD_X = 9; const FONT = 10.5; const H = 18; // Initial estimate (before first measure) uses the per-char CJK / Latin // helper; getBBox refines after layout. const initialEst = items.map(it => Math.max(18, estimateTextWidth(it.text || '', FONT))); const [textWidths, setTextWidths] = React.useState(initialEst); const refs = React.useRef([]); React.useLayoutEffect(()=>{ const next = items.map((it, i) => { try { const bb = refs.current[i]?.getBBox(); return bb ? Math.max(8, bb.width) : initialEst[i]; } catch(_){ return initialEst[i]; } }); if (next.some((w, i) => Math.abs(w - (textWidths[i] || 0)) > 0.5)){ setTextWidths(next); } }, [items.map(i => i.text + '|' + i.icon).join('\n')]); // Total width = sum of (icon + gap + measured text) + separators + padding. const itemWidths = items.map((_, i) => ICON + GAP_ICON_TEXT + (textWidths[i] || 0)); const totalW = itemWidths.reduce((a,b)=>a+b, 0) + Math.max(0, items.length - 1) * GAP_BETWEEN_ITEMS + PAD_X * 2; const x = anchorX + offsetX; const y = anchorY + offsetY; // Layout cursor — walk left → right placing icon, then text, then separator dot. let cur = -totalW/2 + PAD_X; const children = []; items.forEach((it, i) => { const tw = textWidths[i] || initialEst[i]; children.push( {window.tourIconAsSvgChildren(it.icon, { stroke:'#475569', strokeWidth: 2.5 })} ); cur += ICON + GAP_ICON_TEXT; children.push( { refs.current[i] = el; }} x={cur} y={1} fontSize={FONT} fontWeight={600} fill="#334155" dominantBaseline="middle" textAnchor="start" fontFamily='"Noto Sans JP","Inter",system-ui,sans-serif' style={{pointerEvents:'none', whiteSpace:'pre'}}>{it.text} ); cur += tw; if (i < items.length - 1){ children.push( · ); cur += GAP_BETWEEN_ITEMS; } }); return ( {children} ); } // ── Edge ───────────────────────────────────────────────────────── function Edge({ edge, nodes, offset, selected, onSelect, onEdit, onDragStart, seqNumber, showAnnotations, updateEdge, worldZoom, whoosh, defaultCurrency }){ const rawA = nodes[edge.from]; const rawB = nodes[edge.to]; if (!rawA || !rawB) return null; // Auto-flip rendering direction by time: // If the from-node's departure is LATER than the to-node's arrival, the // user drew the arrow "backward in time". We render it flipped (so the // arrowhead points in the time-flow direction) — but the underlying // edge.from/edge.to data stays untouched, so re-targeting and other // graph operations are unaffected. The perpendicular curve offset // negates so the bulge stays on the same world-space side after flip. let a = rawA, b = rawB; let visualOffset = offset; const aT = rawA.departure || rawA.arrival; const bT = rawB.departure || rawB.arrival; if (aT && bT && aT > bT){ a = rawB; b = rawA; visualOffset = -(offset || 0); } // Effective offset that feeds the rest of the curve math. const offsetForCurve = visualOffset; const rel = RELATIONS.find(r => r.id === edge.relation) || RELATIONS[0]; const color = edge.color || rel.color; const style = edge.style || rel.defaultStyle || 'oneway'; // Tiered stroke widths: // - Shinkansen (rail-double): 8px — visually distinct as the "fastest/biggest" route // - oneway-thick / air : 6px // - other : 3.5px const stroke = (style === 'rail-double') ? 8 : (style === 'oneway-thick' || style === 'air') ? 6 : 3.5; const isThick = stroke >= 6; const nrA = 40, nrB = 40; // Build the anno items. // ⏱ MoveDuration — auto-derived from the from-node's departure → to-node's // arrival (datetime model). Read-only; the user fills these on the // node inspector. The chip simply omits the time if either side // isn't filled in. // 💴 Travel cost — stored on the edge. const annoItems = []; const dt = window.tourDateTime; if (dt && a.departure && b.arrival){ const m = dt.minutesBetween(a.departure, b.arrival); if (m != null && m > 0){ annoItems.push({ icon:'clock', text: dt.formatDuration(m) }); } } // Display normalisation: bare numbers ("300") get the trip's default // currency prefixed for the chip ("$300"). Inputs already containing // a currency marker are preserved as-is. if (edge.cost) { const fmtDisp = window.formatCostDisplay || ((s) => s); annoItems.push({ icon:'banknote', text: fmtDisp(edge.cost, defaultCurrency || '$') }); } const hasAnno = annoItems.length > 0 && (showAnnotations !== false); // ── ORTHOGONAL (L-shape) ──────────────────────────────────── if (style === 'orth-h' || style === 'orth-v'){ let p1, p2; if (style === 'orth-h'){ const sx = Math.sign(b.x - a.x) || 1; const sy = Math.sign(b.y - a.y) || 1; p1 = { x: a.x + sx * nrA, y: a.y }; p2 = { x: b.x, y: b.y - sy * nrB }; } else { const sx = Math.sign(b.x - a.x) || 1; const sy = Math.sign(b.y - a.y) || 1; 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 = buildOrthogonalPath(p1, p2, style, cornerR); // Arrow head along the final segment direction. let arrDx, arrDy; if (style === 'orth-h'){ const sy = Math.sign(p2.y - orth.corner.y) || 1; arrDx = 0; arrDy = sy; } else { const sx = Math.sign(p2.x - orth.corner.x) || 1; arrDx = sx; arrDy = 0; } const arrowSize = (style === 'rail-double') ? 22 : (isThick ? 18 : 12); const endArrow = buildArrowheadFromDir(p2, arrDx, arrDy, arrowSize, stroke); // Path geometric midpoint — independent of R, so the label stays glued // to the actual line even when the corner is heavily rounded. const labelPos = orthMidpoint({ horizFirst: orth.horizFirst, p1, p2, corner: orth.corner, R: orth.R, sx: orth.sx, sy: orth.sy, }); // Sequence badge a bit into the first leg. const seqAt = (style === 'orth-h') ? { x: p1.x + Math.sign(p2.x-p1.x)*22, y: p1.y } : { x: p1.x, y: p1.y + Math.sign(p2.y-p1.y)*22 }; const labelText = (edge.label ?? rel.label) || ''; const stamp = (edge.stamp !== undefined) ? edge.stamp : rel.stamp; // Anno chip default offset depends on which leg the label is on. // Place it perpendicular to the path so it doesn't collide with the line. const ao = edge.annoOffset || defaultAnnoOffsetForOrth(style, labelPos, p1, p2, orth.corner); const linkVisible = Math.hypot(ao.dx, ao.dy) > 28; return ( onSelect(edge.id, e.shiftKey)} onDoubleClick={()=>onEdit(edge.id)} onPointerDown={(e)=>{ if(onDragStart && !e.shiftKey) onDragStart(edge.id, e); }}/> {/* Anno-link rendered BEFORE the label so the label pill (white fill) and chip rect (light fill) cover the line at each end — only the gap between them is visible. Without this ordering the dotted line paints ON TOP of the label, making text hard to read. */} {hasAnno && linkVisible && ( )} {endArrow && } {(seqNumber !== undefined && seqNumber !== null) && ( )} {(labelText || stamp) ? ( onSelect(edge.id, shiftKey)} onEdit={()=>onEdit(edge.id)} onDragStart={onDragStart ? (e)=>onDragStart(edge.id, e) : null} /> ) : null} {hasAnno && ( annoChipDrag(e, edge, ao, updateEdge, worldZoom, onSelect)}/> )} ); } // ── FREE / RAIL / WAVY paths ──────────────────────────────── const p1full = { x:a.x, y:a.y }; const p2full = { x:b.x, y:b.y }; // When the edge is rendered flipped (auto-flip above), the customOffset // also negates so the perpendicular curve bulges on the same world-space // side after the flip. Note: rawA/rawB are the data-side endpoints. const flipped = a !== rawA; const actualOffset = edge.customOffset !== undefined ? (flipped ? -edge.customOffset : edge.customOffset) : offsetForCurve; const { cx, cy } = buildCurve(p1full, p2full, actualOffset); const p1 = edgePointOnCircle(a.x,a.y, cx, cy, nrA); const p2 = edgePointOnCircle(b.x,b.y, cx, cy, nrB); const curve = buildCurve(p1, p2, actualOffset * 0.6); let pathD = curve.d; let dash; if (style === 'dotted') dash = '0.1 8'; else if (style === 'dashed') dash = '10 6'; // Air route — slightly wider gap so the dashes read as "intermittent", // suggesting a flight path rather than a continuous line. else if (style === 'air') dash = '14 10'; else if (style === 'wavy') pathD = wavePathAlong(p1, {x:curve.cx,y:curve.cy}, p2, 6, 0.12); if (style === 'air' && (edge.customOffset === undefined)){ const airCurve = buildCurve(p1, p2, (offset || 0) + Math.min(80, Math.hypot(p2.x-p1.x, p2.y-p1.y) * 0.18)); pathD = airCurve.d; } const arrowSize = (style === 'rail-double') ? 22 : (isThick ? 18 : 14); const needEnd = (style !== 'rail-stripe'); const needStart = (style === 'twoway'); const endArrow = needEnd ? buildArrowhead(p1, {x:curve.cx,y:curve.cy}, p2, 1.0, arrowSize, stroke) : null; const startArrow = needStart ? buildArrowhead(p1, {x:curve.cx,y:curve.cy}, p2, 0.0, arrowSize, stroke) : null; const labelX = 0.25*p1.x + 0.5*curve.cx + 0.25*p2.x; const labelY = 0.25*p1.y + 0.5*curve.cy + 0.25*p2.y; const labelText = (edge.label ?? rel.label) || ''; const stamp = (edge.stamp !== undefined) ? edge.stamp : rel.stamp; const seqAt = sampleAtT(p1, {x:curve.cx,y:curve.cy}, p2, 0.15); const railDouble = (style === 'rail-double'); const railStripe = (style === 'rail-stripe'); let stripes = null; if (railStripe){ const samples = sampleQuad(p1, {x:curve.cx,y:curve.cy}, p2, 30); const out = []; const tickHalf = 6; for (let i = 2; i < samples.length - 2; i += 3){ const s = samples[i]; const prev = samples[i-1]; const tx = s.x - prev.x, ty = s.y - prev.y; const tL = Math.hypot(tx, ty) || 1; const nx = -ty/tL, ny = tx/tL; out.push({ x1: s.x - nx*tickHalf, y1: s.y - ny*tickHalf, x2: s.x + nx*tickHalf, y2: s.y + ny*tickHalf, }); } stripes = out; } // Anno chip default offset = below the label pill on the perpendicular side. const ao = edge.annoOffset || { dx: 0, dy: 24 }; const linkVisible = Math.hypot(ao.dx, ao.dy) > 28; return ( onSelect(edge.id, e.shiftKey)} onDoubleClick={()=>onEdit(edge.id)} onPointerDown={(e)=>{ if(onDragStart && !e.shiftKey) onDragStart(edge.id, e); }}/> {railDouble && ( )} {stripes && stripes.map((s, i)=>( ))} {endArrow && } {startArrow && } {/* Anno-link drawn BEFORE the label/chip so it sits underneath both rects (white pill + light chip). The line only shows in the gap. */} {hasAnno && linkVisible && ( )} {(seqNumber !== undefined && seqNumber !== null) && ( )} {(labelText || stamp) ? ( onSelect(edge.id, shiftKey)} onEdit={()=>onEdit(edge.id)} onDragStart={onDragStart ? (e)=>onDragStart(edge.id, e) : null} /> ) : null} {hasAnno && ( annoChipDrag(e, edge, ao, updateEdge, worldZoom, onSelect)}/> )} ); } // Pointerdown handler attached to anno chips. Each chip captures the pointer // and patches edge.annoOffset live (skipHistory) until release, then commits. function annoChipDrag(e, edge, ao, updateEdge, worldZoom, onSelect){ e.stopPropagation(); if (onSelect) onSelect(edge.id, false); const startX = e.clientX, startY = e.clientY; const ox = ao.dx || 0, oy = ao.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 }; updateEdge(edge.id, { 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) updateEdge(edge.id, { annoOffset: last }); }; target.addEventListener('pointermove', onMove); target.addEventListener('pointerup', onUp); target.addEventListener('pointercancel', onUp); } // Choose a sensible default chip offset for orth edges so it sits on the // outside of the L (away from the inward bisector). function defaultAnnoOffsetForOrth(style, labelPos, p1, p2, corner){ // If the label is on the horizontal leg, place chip below; on vertical leg, // place chip to the right. const onHorizLeg = (Math.abs(labelPos.y - p1.y) < 1) || (style === 'orth-h' && Math.abs(labelPos.y - p1.y) < 1); if (onHorizLeg) return { dx: 0, dy: 24 }; return { dx: 24, dy: 0 }; } function sampleAtT(p1, c, p2, t){ const it = 1 - t; return { x: it*it*p1.x + 2*it*t*c.x + t*t*p2.x, y: it*it*p1.y + 2*it*t*c.y + t*t*p2.y, }; } function buildArrowhead(p1, c, p2, tPos, size, strokeW){ size = size || 14; strokeW = strokeW || 4; const eps = 0.02; let tA, tB; if (tPos >= 1.0) { tA = 1.0 - eps; tB = 1.0; } else { tA = 0.0; tB = eps; } const evalBez = (t) => sampleAtT(p1, c, p2, t); const pA = evalBez(tA); const pB = evalBez(tB); let dx = pB.x - pA.x, dy = pB.y - pA.y; const L = Math.hypot(dx, dy) || 1; dx /= L; dy /= L; if (tPos < 0.5) { dx = -dx; dy = -dy; } const tip = evalBez(tPos); const nx = -dy, ny = dx; const halfW = size * 0.55; const backDist = size; const tipOffset = Math.max(4, strokeW * 1.0); tip.x += dx * tipOffset; tip.y += dy * tipOffset; const bx = tip.x - dx * backDist; const by = tip.y - dy * backDist; return `${tip.x},${tip.y} ${bx + nx*halfW},${by + ny*halfW} ${bx - nx*halfW},${by - ny*halfW}`; } function buildArrowheadFromDir(tip, dx, dy, size, strokeW){ size = size || 14; strokeW = strokeW || 4; const L = Math.hypot(dx, dy) || 1; dx /= L; dy /= L; const nx = -dy, ny = dx; const halfW = size * 0.55; const backDist = size; const tipOffset = Math.max(4, strokeW * 1.0); const t = { x: tip.x + dx * tipOffset, y: tip.y + dy * tipOffset }; const bx = t.x - dx * backDist; const by = t.y - dy * backDist; return `${t.x},${t.y} ${bx + nx*halfW},${by + ny*halfW} ${bx - nx*halfW},${by - ny*halfW}`; } window.Edge = Edge; window.AnnoChip = AnnoChip; window.buildOrthogonalPath = buildOrthogonalPath; window.computeEdgeLabelPosition = computeEdgeLabelPosition;