fix(graph): stop corpus-graph labels overlapping

Labels piled on top of each other (esp. in the neighborhood view) for two
reasons, both fixed in graph-canvas.tsx:

1. Font grew as you zoomed OUT (size was divided by sqrt(globalScale) and had
   a +6 floor), so at overview zoom labels became huge and collided. Now the
   label font is a ~constant SCREEN size (fontPx / globalScale).

2. Every node drew its label at once. Now labels are zoom-gated: at overview
   zoom only the active node, the 3 practice-area hubs, and the most-cited
   precedents (size>=4) are labeled; topic hubs appear at >=1.05 and the rest
   at >=1.5 — by which point there is pixel room between nodes.

Also: a white halo (strokeText) behind each label for legibility over edges
and nearby nodes, and stronger d3 forces (charge -220, link distance 60) so
nodes spread out and labels have more room.

web-ui build passes; /graph in the route table.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:07:27 +00:00
parent 635dc98492
commit f3e99a14ca

View File

@@ -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;
},