// Edge rendering: oneway/twoway/energy/cracked/dotted/wavy between two nodes. // Also handles multi-edge offset between same pair so they don't overlap. // Intersection: find point on circle (cx,cy,r) on the line from (cx,cy) toward (tx,ty) 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 }; } // Build a quadratic-bezier path between two points with a perpendicular offset. 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 }; } // Sample points along a quadratic bezier function sampleQuad(p1, c, p2, n=60){ const pts=[]; for(let i=0;i<=n;i++){ const t=i/n; const it=1-t; const x = it*it*p1.x + 2*it*t*c.x + t*t*p2.x; const y = it*it*p1.y + 2*it*t*c.y + t*t*p2.y; pts.push({x,y,t}); } return pts; } // Convert a polyline into an SVG path d function polyToPath(pts){ return pts.map((p,i)=> (i?'L':'M') + p.x.toFixed(1) + ' ' + p.y.toFixed(1)).join(' '); } // Build a "wavy" path along the curve function wavePathAlong(p1, c, p2, amp=7, freq=0.09){ const samples = sampleQuad(p1,c,p2,120); const out=[]; for(let i=0;i= THRESHOLD) return hex; const mix = ((THRESHOLD - lum) / THRESHOLD) * MAX_MIX; const blend = c => Math.round(c + (255 - c) * mix); const toHex = c => blend(c).toString(16).padStart(2, '0'); return '#' + toHex(r) + toHex(g) + toHex(b); } // Pill-shaped label whose width is measured from the rendered via getBBox, // so long labels (especially CJK) don't visually spill past the white rounded rect. function EdgeLabelBox({ text, x, y, color, selected, onSelect, onEdit, onDragStart }){ const textRef = React.useRef(null); const [w, setW] = React.useState(() => Math.max(36, text.length * 12 + 24)); React.useLayoutEffect(()=>{ if (textRef.current) { try { const bb = textRef.current.getBBox(); const next = Math.max(36, bb.width + 20); if (Math.abs(next - w) > 0.5) setW(next); } catch(_){} } }, [text]); return ( {e.stopPropagation(); onSelect && onSelect(e.shiftKey);}} onDoubleClick={(e)=>{e.stopPropagation(); onEdit && onEdit();}} onPointerDown={(e)=>{ e.stopPropagation(); if (e.shiftKey) return; // click handler will process shift+click (swap z-order) onSelect && onSelect(false); if(onDragStart) onDragStart(e); }} style={{cursor:'pointer'}} > {text} ); } function Edge({ edge, nodes, offset, selected, onSelect, onEdit, onDragStart }){ const a = nodes[edge.from]; const b = nodes[edge.to]; if (!a || !b) return null; const rel = RELATIONS.find(r => r.id === edge.relation) || RELATIONS[0]; const color = edge.color || rel.color; const style = edge.style || 'oneway'; // Thickness: 'normal' (default) or 'thick'. Scales the base stroke, the flow // overlay stroke (via CSS .edge-flow.thick) and the arrowhead polygon size. const isThick = edge.thickness === 'thick'; const stroke = isThick ? 10 : 4; // Shrink the path to the node edge. For thick arrows the arrowhead tip is // pushed forward by a larger tipOffset (to contain the bigger line cap), so // we also shrink the path MORE so the final tip position stays consistent // with normal arrows (= tip only grazes into the card by ~3px). // Normal: tipOffset=6, nr=48, tip lands ~42 from node center (card edge ~45). // Thick : tipOffset=11, nr=53, tip lands ~42 from center (same depth). const tipOffset = Math.max(6, stroke * 1.1); const nr = 48 + Math.max(0, tipOffset - 6); const p1full = { x:a.x, y:a.y }; const p2full = { x:b.x, y:b.y }; const actualOffset = edge.customOffset !== undefined ? edge.customOffset : offset; const { cx, cy } = buildCurve(p1full, p2full, actualOffset); // shrink endpoints to node boundary using control point direction const p1 = edgePointOnCircle(a.x,a.y, cx, cy, nr); const p2 = edgePointOnCircle(b.x,b.y, cx, cy, nr); const curve = buildCurve(p1, p2, actualOffset * 0.6); let pathD = curve.d; let dash = ''; if (style === 'dotted') dash = '0.1 12'; if (style === 'wavy') pathD = wavePathAlong(p1, {x:curve.cx,y:curve.cy}, p2, 6, 0.12); if (style === 'cracked') pathD = crackPathAlong(p1, {x:curve.cx,y:curve.cy}, p2, 10); // Build arrowhead polygons instead of SVG markers (html-to-image breaks markers) const arrowSize = isThick ? 32 : 18; const needEnd = (style === 'oneway' || style === 'twoway' || style === 'dotted' || style === 'wavy'); 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; // label position: mid of curve 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 || ''; return ( onSelect(edge.id, e.shiftKey)} onDoubleClick={()=>onEdit(edge.id)} onPointerDown={(e)=>{ if(onDragStart && !e.shiftKey) onDragStart(edge.id, e); }}/> {/* Flow overlay: bright pulse that travels along the curve from source → target. Gives every arrow a clear directional flow. */} {/* Two-way arrows get a second flow going the opposite direction so the pulse appears to emanate from BOTH endpoints — both sides "push" toward each other. */} {style === 'twoway' && ( )} {endArrow && } {startArrow && } ); } // Renders ONLY the label (pill) for an edge. Used by Canvas to paint all labels // in a separate SVG pass AFTER every edge path has been drawn, so a later edge // crossing over an earlier edge's label doesn't cover the pill background. function EdgeLabel({ edge, nodes, offset, selected, onSelect, onEdit, onDragStart }){ const a = nodes[edge.from]; const b = nodes[edge.to]; if (!a || !b) return null; // Must match the nr used in Edge() above — including the thick-arrow bump — // otherwise labels drift off the midpoint of the real drawn path. const stroke = edge.thickness === 'thick' ? 10 : 4; const tipOffset = Math.max(6, stroke * 1.1); const nr = 48 + Math.max(0, tipOffset - 6); const p1full = { x:a.x, y:a.y }; const p2full = { x:b.x, y:b.y }; const actualOffset = edge.customOffset !== undefined ? edge.customOffset : offset; const { cx, cy } = buildCurve(p1full, p2full, actualOffset); const p1 = edgePointOnCircle(a.x,a.y, cx, cy, nr); const p2 = edgePointOnCircle(b.x,b.y, cx, cy, nr); const curve = buildCurve(p1, p2, actualOffset * 0.6); const rel = RELATIONS.find(r => r.id === edge.relation) || RELATIONS[0]; const color = edge.color || rel.color; 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 || ''; if (!labelText && !stamp) return null; return ( onSelect(edge.id, shiftKey)} onEdit={()=>onEdit(edge.id)} onDragStart={onDragStart ? (e)=>onDragStart(edge.id, e) : null} /> ); } // Build an arrowhead polygon at a point along a quadratic bezier. // tPos: 0.0 = start, 1.0 = end // strokeW: the base stroke width of the path the arrow terminates — we push the // arrow tip forward enough that the path's round line-cap (radius = strokeW/2) // sits fully inside the triangle instead of peeking past its narrow tip. function buildArrowhead(p1, c, p2, tPos, size, strokeW){ size = size || 10; strokeW = strokeW || 4; // Sample the tangent near the target point 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) => { 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 }; }; const pA = evalBez(tA); const pB = evalBez(tB); // tangent direction (pointing from A toward B) let dx = pB.x - pA.x, dy = pB.y - pA.y; const L = Math.hypot(dx, dy) || 1; dx /= L; dy /= L; // For start arrow, reverse direction if (tPos < 0.5) { dx = -dx; dy = -dy; } const tip = evalBez(tPos); // Two base points perpendicular to the tangent const nx = -dy, ny = dx; const halfW = size * 0.6; // Slightly wider base (0.6 instead of 0.5) const backDist = size; // Push the arrow tip forward so the path's round line-cap (radius = strokeW/2) // fits inside the triangle. Triangle width at distance d from the tip is // `1.2 * d` (given halfW=0.6*size), so we need d ≥ strokeW / 1.2 to fully // contain a cap of diameter strokeW. Add a small safety margin. const tipOffset = Math.max(6, strokeW * 1.1); tip.x += dx * tipOffset; tip.y += dy * tipOffset; const bx = tip.x - dx * backDist; const by = tip.y - dy * backDist; const x1 = bx + nx * halfW; const y1 = by + ny * halfW; const x2 = bx - nx * halfW; const y2 = by - ny * halfW; return `${tip.x},${tip.y} ${x1},${y1} ${x2},${y2}`; } window.Edge = Edge; window.EdgeLabel = EdgeLabel;