// Edge rendering: arrow/heart/crack/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 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 nr = 48; // visual radius of node avatar (approx) 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); const rel = RELATIONS.find(r => r.id === edge.relation) || RELATIONS[0]; const color = edge.color || rel.color; const stroke = 4; const style = edge.style || 'oneway'; 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 = 18; // Increased from 10 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) : null; const startArrow = needStart ? buildArrowhead(p1, {x:curve.cx,y:curve.cy}, p2, 0.0, arrowSize) : 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; // hearts along curve (for heart style) const hearts = []; if (style === 'heart'){ for (let t of [0.25,0.5,0.75]){ const it=1-t; const x = it*it*p1.x + 2*it*t*curve.cx + t*t*p2.x; const y = it*it*p1.y + 2*it*t*curve.cy + t*t*p2.y; hearts.push({x,y,t}); } } 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); }}/> {endArrow && } {startArrow && } {hearts.map((h,i)=>( ))} {labelText || stamp ? ( onSelect(edge.id, shiftKey)} onEdit={()=>onEdit(edge.id)} onDragStart={onDragStart ? (e)=>onDragStart(edge.id, e) : null} /> ) : null} ); } // Build an arrowhead polygon at a point along a quadratic bezier. // tPos: 0.0 = start, 1.0 = end function buildArrowhead(p1, c, p2, tPos, size){ size = size || 10; // 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; // 線の端の丸み(ラウンドキャップ)が矢印の先端からはみ出ないように、矢印自体を6px前方にずらす tip.x += dx * 6; tip.y += dy * 6; 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}`; } function HeartShape({color}){ return ( ); } window.Edge = Edge; // Expose helpers reused by Writing connection lines (Canvas.jsx) — they share // the exact same quadratic-bezier math as edges so behavior stays consistent. window.buildCurve = buildCurve;