diff --git a/web-ui/src/components/graph/graph-canvas.tsx b/web-ui/src/components/graph/graph-canvas.tsx index 7c3374e..ccb7c9f 100644 --- a/web-ui/src/components/graph/graph-canvas.tsx +++ b/web-ui/src/components/graph/graph-canvas.tsx @@ -130,6 +130,17 @@ export function GraphCanvas({ fgRef.current?.zoomToFit?.(400, 60); }, []); + // Spread nodes out so labels have room — stronger repulsion + longer links + // than the d3-force defaults (charge -30, link 30). Re-applied whenever the + // graph data changes (full ↔ neighborhood). + useEffect(() => { + const fg = fgRef.current; + if (!fg?.d3Force) return; + fg.d3Force("charge")?.strength?.(-220); + fg.d3Force("link")?.distance?.(60); + fg.d3ReheatSimulation?.(); + }, [graphData]); + const drawNode = useCallback( (node: FGNode, ctx: CanvasRenderingContext2D, globalScale: number) => { const r = nodeRadius(node); @@ -147,22 +158,39 @@ export function GraphCanvas({ ctx.stroke(); } - // Labels: hubs always; precedents when zoomed in, important, or active. - const isHub = node.type === "topic" || node.type === "practice_area"; + // Labels — gated by zoom so they don't pile up. At overview zoom only a + // few show (active node, the 3 practice-area hubs, the most-cited + // precedents); zooming in reveals the rest, by which point there is pixel + // room between nodes. Font is kept at a ~constant SCREEN size (px / + // globalScale) instead of growing as you zoom out — that growth was what + // made labels collide. + const isTopicHub = node.type === "topic"; + const isAreaHub = node.type === "practice_area"; const showLabel = !dimmed && - (isHub || node.id === activeId || node.size >= 3 || globalScale >= 1.6); + (node.id === activeId || + isAreaHub || + globalScale >= 1.5 || + (isTopicHub && globalScale >= 1.05) || + (!isTopicHub && node.size >= 4 && globalScale >= 0.9)); if (showLabel && node.label) { - const fontSize = Math.max(2.5, (isHub ? 4.5 : 3.6) / Math.sqrt(globalScale)) + - (isHub ? 1 : 0); - ctx.font = `${fontSize + 6}px Heebo, sans-serif`; + const fontPx = isTopicHub || isAreaHub ? 12 : 11; + const fontSize = fontPx / globalScale; // ~constant on screen + ctx.font = `${fontSize}px Heebo, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "top"; ctx.direction = "rtl"; - ctx.fillStyle = isHub ? "#7a5a26" : "#1a1a2e"; const label = - node.label.length > 28 ? `${node.label.slice(0, 27)}…` : node.label; - ctx.fillText(label, node.x ?? 0, (node.y ?? 0) + r + 1); + node.label.length > 22 ? `${node.label.slice(0, 21)}…` : node.label; + const lx = node.x ?? 0; + const ly = (node.y ?? 0) + r + 2 / globalScale; + // White halo so the text stays readable over edges and nearby nodes. + ctx.lineWidth = 3 / globalScale; + ctx.strokeStyle = "rgba(255,255,255,0.92)"; + ctx.lineJoin = "round"; + ctx.strokeText(label, lx, ly); + ctx.fillStyle = isTopicHub || isAreaHub ? "#7a5a26" : "#1a1a2e"; + ctx.fillText(label, lx, ly); } ctx.globalAlpha = 1; },