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:
@@ -149,18 +149,22 @@ export function GraphSVG({
|
|||||||
const nodes = graph.nodes || []
|
const nodes = graph.nodes || []
|
||||||
const edges = graph.edges || []
|
const edges = graph.edges || []
|
||||||
|
|
||||||
const xs = nodes.map(n => n.x)
|
// Scale positions to reduce overlap between nodes and edge labels
|
||||||
const ys = nodes.map(n => n.y)
|
const POS_SCALE = compact ? 1 : 1.55
|
||||||
const minX = (xs.length ? Math.min(...xs) : 0) - 60
|
const scaledNodes = nodes.map(n => ({ ...n, x: n.x * POS_SCALE, y: n.y * POS_SCALE }))
|
||||||
const minY = (ys.length ? Math.min(...ys) : 0) - 40
|
const nodeMap = Object.fromEntries(scaledNodes.map(n => [n.id, n]))
|
||||||
const maxX = (xs.length ? Math.max(...xs) : 400) + 130
|
|
||||||
const maxY = (ys.length ? Math.max(...ys) : 400) + 70
|
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 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
|
// Nodes larger in editor mode to accommodate type badge
|
||||||
const NW = compact ? 92 : editorMode ? 130 : 120
|
const NW = compact ? 92 : editorMode ? 140 : 130
|
||||||
const NH = compact ? 32 : editorMode ? 50 : 42
|
const NH = compact ? 32 : editorMode ? 54 : 48
|
||||||
|
|
||||||
const statusColor = (s: string) =>
|
const statusColor = (s: string) =>
|
||||||
s === 'correct' ? '#10b981' : s === 'wrong' ? '#ef4444' : '#64748b'
|
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 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 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
|
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 {
|
function edgeLabelPos(e: CausalEdge): { x: number; y: number } | null {
|
||||||
const s = nodeMap[e.from]; const t = nodeMap[e.to]
|
return bezierPt(e, 0.42)
|
||||||
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 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSvgClick(e: React.MouseEvent<SVGSVGElement>) {
|
function handleSvgClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
@@ -217,7 +208,7 @@ export function GraphSVG({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<svg ref={svgRef} viewBox={`${minX} ${minY} ${W} ${H}`}
|
<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')}
|
className={clsx('overflow-visible', editorMode && 'cursor-crosshair')}
|
||||||
onClick={handleSvgClick}>
|
onClick={handleSvgClick}>
|
||||||
<defs>
|
<defs>
|
||||||
@@ -247,13 +238,13 @@ export function GraphSVG({
|
|||||||
const sw = edgeWidth(e.strength)
|
const sw = edgeWidth(e.strength)
|
||||||
const lpos = e.label ? edgeLabelPos(e) : null
|
const lpos = e.label ? edgeLabelPos(e) : null
|
||||||
const labelText = e.label ?? ''
|
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 lagTxtMin = (!compact && e.lag_min) ? fmtLag(e.lag_min) : null
|
||||||
const lagTxtDay = (!compact && e.lag_days) ? `+${e.lag_days}j` : null
|
const lagTxtDay = (!compact && e.lag_days) ? `+${e.lag_days}j` : null
|
||||||
const lagTxt = lagTxtMin && lagTxtDay ? `${lagTxtMin}/${lagTxtDay}` : (lagTxtMin ?? lagTxtDay)
|
const lagTxt = lagTxtMin && lagTxtDay ? `${lagTxtMin}/${lagTxtDay}` : (lagTxtMin ?? lagTxtDay)
|
||||||
const decayTxt = (!compact && e.decay_days != null) ? fmtDecay(e.decay_days) : null
|
const decayTxt = (!compact && e.decay_days != null) ? fmtDecay(e.decay_days) : null
|
||||||
const lagPos = lagTxt ? bezierPt(e, 0.1) : null
|
const lagPos = lagTxt ? bezierPt(e, 0.13) : null
|
||||||
const decayPos = decayTxt ? bezierPt(e, 0.88) : null
|
const decayPos = decayTxt ? bezierPt(e, 0.84) : null
|
||||||
return (
|
return (
|
||||||
<g key={i}>
|
<g key={i}>
|
||||||
<path d={edgePath(e)} fill="none"
|
<path d={edgePath(e)} fill="none"
|
||||||
@@ -262,24 +253,24 @@ export function GraphSVG({
|
|||||||
markerEnd={`url(#arrow-${sign})`} opacity={0.85} />
|
markerEnd={`url(#arrow-${sign})`} opacity={0.85} />
|
||||||
{lpos && (
|
{lpos && (
|
||||||
<g>
|
<g>
|
||||||
<rect x={lpos.x - 2} y={lpos.y - 9} width={labelW + 4} height={11}
|
<rect x={lpos.x - 3} y={lpos.y - 10} width={labelW + 6} height={13}
|
||||||
rx={3} fill="#0c1220" opacity={0.82} />
|
rx={3} fill="#0c1220" opacity={0.88} />
|
||||||
<text x={lpos.x} y={lpos.y} fill={color} fontSize={8.5} fontWeight="500"
|
<text x={lpos.x} y={lpos.y} fill={color} fontSize={9.5} fontWeight="500"
|
||||||
letterSpacing="0.2">{labelText}</text>
|
letterSpacing="0.2">{labelText}</text>
|
||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
{lagPos && lagTxt && (
|
{lagPos && lagTxt && (
|
||||||
<g>
|
<g>
|
||||||
<rect x={lagPos.x - 2} y={lagPos.y - 9} width={lagTxt.length * 5 + 4} height={11}
|
<rect x={lagPos.x - 3} y={lagPos.y - 10} width={lagTxt.length * 5.8 + 6} height={13}
|
||||||
rx={3} fill="#0c1220" opacity={0.85} />
|
rx={3} fill="#0c1220" opacity={0.88} />
|
||||||
<text x={lagPos.x} y={lagPos.y} fill="#fbbf24" fontSize={8} fontWeight="600">{lagTxt}</text>
|
<text x={lagPos.x} y={lagPos.y} fill="#fbbf24" fontSize={9} fontWeight="600">{lagTxt}</text>
|
||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
{decayPos && decayTxt && (
|
{decayPos && decayTxt && (
|
||||||
<g>
|
<g>
|
||||||
<rect x={decayPos.x - 2} y={decayPos.y - 9} width={decayTxt.length * 5 + 4} height={11}
|
<rect x={decayPos.x - 3} y={decayPos.y - 10} width={decayTxt.length * 5.8 + 6} height={13}
|
||||||
rx={3} fill="#0c1220" opacity={0.85} />
|
rx={3} fill="#0c1220" opacity={0.88} />
|
||||||
<text x={decayPos.x} y={decayPos.y} fill="#94a3b8" fontSize={8} fontWeight="600">{decayTxt}</text>
|
<text x={decayPos.x} y={decayPos.y} fill="#94a3b8" fontSize={9} fontWeight="600">{decayTxt}</text>
|
||||||
</g>
|
</g>
|
||||||
)}
|
)}
|
||||||
</g>
|
</g>
|
||||||
@@ -287,15 +278,15 @@ export function GraphSVG({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Nodes */}
|
{/* Nodes */}
|
||||||
{nodes.map(n => {
|
{scaledNodes.map(n => {
|
||||||
const col = getNodeColor(n)
|
const col = getNodeColor(n)
|
||||||
const val = nodeValues?.[n.id]
|
const val = nodeValues?.[n.id]
|
||||||
const isSel = selectedNodeId === n.id
|
const isSel = selectedNodeId === n.id
|
||||||
const maxLen = editorMode ? 20 : 19
|
const maxLen = editorMode ? 22 : 21
|
||||||
const lFS = compact ? 9 : 11
|
const lFS = compact ? 9 : 12
|
||||||
// vertical layout inside node
|
// vertical layout inside node
|
||||||
const labelY = compact ? 12 : editorMode ? 15 : 14
|
const labelY = compact ? 12 : editorMode ? 17 : 16
|
||||||
const valY = compact ? 22 : 30
|
const valY = compact ? 22 : 34
|
||||||
const typeY = NH - 7 // type badge near bottom
|
const typeY = NH - 7 // type badge near bottom
|
||||||
return (
|
return (
|
||||||
<g key={n.id} transform={`translate(${n.x - NW / 2},${n.y - NH / 2})`}
|
<g key={n.id} transform={`translate(${n.x - NW / 2},${n.y - NH / 2})`}
|
||||||
|
|||||||
Reference in New Issue
Block a user