feat: improve causal graph readability — scale positions, bigger fonts, less overlap

- Scale node positions by 1.55x to spread graph vertically/horizontally
- Increase node size: 120x42 -> 130x48 (normal), 130x50 -> 140x54 (editor)
- Bigger label font: 11px -> 12px inside nodes
- Edge labels: 8.5px -> 9.5px, lag/decay: 8px -> 9px
- Better label stagger along bezier: lag at t=0.13, label at t=0.42, decay at t=0.84
- Larger perpendicular offset: 10px -> 16px (less overlap on diagonal edges)
- SVG height: 480 -> 560 (normal), 540 -> 620 (editor)
- Use scaledNodes array for node rendering to apply position transform

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-30 08:46:07 +02:00
parent 5e37cd81a2
commit 589faa633b

View File

@@ -149,18 +149,22 @@ export function GraphSVG({
const nodes = graph.nodes || []
const edges = graph.edges || []
const xs = nodes.map(n => n.x)
const ys = nodes.map(n => n.y)
const minX = (xs.length ? Math.min(...xs) : 0) - 60
const minY = (ys.length ? Math.min(...ys) : 0) - 40
const maxX = (xs.length ? Math.max(...xs) : 400) + 130
const maxY = (ys.length ? Math.max(...ys) : 400) + 70
// Scale positions to reduce overlap between nodes and edge labels
const POS_SCALE = compact ? 1 : 1.55
const scaledNodes = nodes.map(n => ({ ...n, x: n.x * POS_SCALE, y: n.y * POS_SCALE }))
const nodeMap = Object.fromEntries(scaledNodes.map(n => [n.id, n]))
const xs = scaledNodes.map(n => n.x)
const ys = scaledNodes.map(n => n.y)
const minX = (xs.length ? Math.min(...xs) : 0) - 80
const minY = (ys.length ? Math.min(...ys) : 0) - 50
const maxX = (xs.length ? Math.max(...xs) : 400) + 160
const maxY = (ys.length ? Math.max(...ys) : 400) + 90
const W = maxX - minX; const H = maxY - minY
const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n]))
// Nodes larger in editor mode to accommodate type badge
const NW = compact ? 92 : editorMode ? 130 : 120
const NH = compact ? 32 : editorMode ? 50 : 42
const NW = compact ? 92 : editorMode ? 140 : 130
const NH = compact ? 32 : editorMode ? 54 : 48
const statusColor = (s: string) =>
s === 'correct' ? '#10b981' : s === 'wrong' ? '#ef4444' : '#64748b'
@@ -188,25 +192,12 @@ export function GraphSVG({
const dx = x2 - x1; const dy = y2 - y1; const len = Math.sqrt(dx*dx + dy*dy) || 1
const bx = (1-t0)**3*x1 + 3*(1-t0)**2*t0*x1 + 3*(1-t0)*t0**2*x2 + t0**3*x2
const by = (1-t0)**3*y1 + 3*(1-t0)**2*t0*my + 3*(1-t0)*t0**2*my + t0**3*y2
return { x: bx + (-dy/len)*10, y: by + (dx/len)*10 }
return { x: bx + (-dy/len)*16, y: by + (dx/len)*16 }
}
// Label position: 1/3 of the way from source (avoids node centers)
// Label position along bezier path with perpendicular offset to avoid the line
function edgeLabelPos(e: CausalEdge): { x: number; y: number } | null {
const s = nodeMap[e.from]; const t = nodeMap[e.to]
if (!s || !t) return null
// Use 30% along the path to stay near source and avoid crowded midpoints
const t0 = 0.3
const x1 = s.x; const y1 = s.y + NH / 2
const x2 = t.x; const y2 = t.y - NH / 2
const my = (y1 + y2) / 2
// Cubic bezier at t: C(x1,my), C(x2,my)
const bx = (1-t0)**3*x1 + 3*(1-t0)**2*t0*x1 + 3*(1-t0)*t0**2*x2 + t0**3*x2
const by = (1-t0)**3*y1 + 3*(1-t0)**2*t0*my + 3*(1-t0)*t0**2*my + t0**3*y2
// Offset perpendicular to avoid sitting on the line
const dx = x2 - x1; const dy = y2 - y1
const len = Math.sqrt(dx*dx + dy*dy) || 1
return { x: bx + (-dy/len)*10, y: by + (dx/len)*10 }
return bezierPt(e, 0.42)
}
function handleSvgClick(e: React.MouseEvent<SVGSVGElement>) {
@@ -217,7 +208,7 @@ export function GraphSVG({
return (
<svg ref={svgRef} viewBox={`${minX} ${minY} ${W} ${H}`}
style={{ width: '100%', height: compact ? 280 : editorMode ? 540 : 480 }}
style={{ width: '100%', height: compact ? 280 : editorMode ? 620 : 560 }}
className={clsx('overflow-visible', editorMode && 'cursor-crosshair')}
onClick={handleSvgClick}>
<defs>
@@ -247,13 +238,13 @@ export function GraphSVG({
const sw = edgeWidth(e.strength)
const lpos = e.label ? edgeLabelPos(e) : null
const labelText = e.label ?? ''
const labelW = labelText.length * 5
const labelW = labelText.length * 5.8
const lagTxtMin = (!compact && e.lag_min) ? fmtLag(e.lag_min) : null
const lagTxtDay = (!compact && e.lag_days) ? `+${e.lag_days}j` : null
const lagTxt = lagTxtMin && lagTxtDay ? `${lagTxtMin}/${lagTxtDay}` : (lagTxtMin ?? lagTxtDay)
const decayTxt = (!compact && e.decay_days != null) ? fmtDecay(e.decay_days) : null
const lagPos = lagTxt ? bezierPt(e, 0.1) : null
const decayPos = decayTxt ? bezierPt(e, 0.88) : null
const lagPos = lagTxt ? bezierPt(e, 0.13) : null
const decayPos = decayTxt ? bezierPt(e, 0.84) : null
return (
<g key={i}>
<path d={edgePath(e)} fill="none"
@@ -262,24 +253,24 @@ export function GraphSVG({
markerEnd={`url(#arrow-${sign})`} opacity={0.85} />
{lpos && (
<g>
<rect x={lpos.x - 2} y={lpos.y - 9} width={labelW + 4} height={11}
rx={3} fill="#0c1220" opacity={0.82} />
<text x={lpos.x} y={lpos.y} fill={color} fontSize={8.5} fontWeight="500"
<rect x={lpos.x - 3} y={lpos.y - 10} width={labelW + 6} height={13}
rx={3} fill="#0c1220" opacity={0.88} />
<text x={lpos.x} y={lpos.y} fill={color} fontSize={9.5} fontWeight="500"
letterSpacing="0.2">{labelText}</text>
</g>
)}
{lagPos && lagTxt && (
<g>
<rect x={lagPos.x - 2} y={lagPos.y - 9} width={lagTxt.length * 5 + 4} height={11}
rx={3} fill="#0c1220" opacity={0.85} />
<text x={lagPos.x} y={lagPos.y} fill="#fbbf24" fontSize={8} fontWeight="600">{lagTxt}</text>
<rect x={lagPos.x - 3} y={lagPos.y - 10} width={lagTxt.length * 5.8 + 6} height={13}
rx={3} fill="#0c1220" opacity={0.88} />
<text x={lagPos.x} y={lagPos.y} fill="#fbbf24" fontSize={9} fontWeight="600">{lagTxt}</text>
</g>
)}
{decayPos && decayTxt && (
<g>
<rect x={decayPos.x - 2} y={decayPos.y - 9} width={decayTxt.length * 5 + 4} height={11}
rx={3} fill="#0c1220" opacity={0.85} />
<text x={decayPos.x} y={decayPos.y} fill="#94a3b8" fontSize={8} fontWeight="600">{decayTxt}</text>
<rect x={decayPos.x - 3} y={decayPos.y - 10} width={decayTxt.length * 5.8 + 6} height={13}
rx={3} fill="#0c1220" opacity={0.88} />
<text x={decayPos.x} y={decayPos.y} fill="#94a3b8" fontSize={9} fontWeight="600">{decayTxt}</text>
</g>
)}
</g>
@@ -287,15 +278,15 @@ export function GraphSVG({
})}
{/* Nodes */}
{nodes.map(n => {
{scaledNodes.map(n => {
const col = getNodeColor(n)
const val = nodeValues?.[n.id]
const isSel = selectedNodeId === n.id
const maxLen = editorMode ? 20 : 19
const lFS = compact ? 9 : 11
const maxLen = editorMode ? 22 : 21
const lFS = compact ? 9 : 12
// vertical layout inside node
const labelY = compact ? 12 : editorMode ? 15 : 14
const valY = compact ? 22 : 30
const labelY = compact ? 12 : editorMode ? 17 : 16
const valY = compact ? 22 : 34
const typeY = NH - 7 // type badge near bottom
return (
<g key={n.id} transform={`translate(${n.x - NW / 2},${n.y - NH / 2})`}