Files
legal-ai/web-ui/src/components/graph/graph-canvas.tsx
Chaim 9a126f7c36 feat(graph): research-gap (ghost) nodes (corpus graph PR C)
Turns the graph into a gap-finder: the 247 unresolved internal citations
(a corpus precedent cites a ruling NOT in the corpus) collapse to 230 distinct
"gap" nodes — each sized by how many corpus precedents cite it, i.e. the
most-wanted missing precedent.

Backend (web/graph_api.py — read-only, G2):
- "gap" added to VALID_NODE_TYPES (NOT default → off unless requested).
- New _gap_nodes_and_edges(): gap:<normalized citation> nodes from
  precedent_internal_citations WHERE cited_case_law_id IS NULL, sized by global
  in-degree; cites edges only from precedents present in the view (dangling-edge
  invariant holds). Best-effort enrichment from missing_precedents via exact
  normalized-citation match → gap_status + missing_precedent_id. Validated:
  230 gaps, top ע"א 3213/97 (cited 5×), 230/230 matched to missing_precedents.
- GraphNode += gap_status, missing_precedent_id. Metrics correctly exclude gap
  edges (target not a precedent). No app.py change (gated via node_types).

Frontend:
- graph.ts: GraphNodeType += "gap"; node fields.
- graph-filter-panel: toggle "חוסרי מחקר (פסיקה חסרה)" (off by default).
- graph-canvas: gaps render as faint hollow dashed circles, never recoloured
  by color-by; sized by citation count.
- graph-node-panel: gap branch — "מצוטטת ע״י N פסיקות" + status badge + link
  to /missing-precedents.

web-ui build + lint pass. Invariants: G2 (SELECT-only), UI2 (model grows on
explicit Pydantic). api:types post-deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:21:53 +00:00

343 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* Force-directed canvas for the corpus graph (Obsidian-graph-view-like).
*
* Uses react-force-graph-2d (HTML5 canvas + d3-force) — the live physics give
* the "alive" Obsidian feel, and `nodeCanvasObject` gives full control over
* node radius (size = citation count) and Hebrew/RTL label rendering. Loaded
* via next/dynamic with ssr:false because the canvas needs the DOM (Next 16).
*/
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CorpusGraph, GraphNode } from "@/lib/api/graph";
// react-force-graph-2d's default export is a forwardRef component; it touches
// `window` at module load, so it must be client-only. Cast to a loose type:
// next/dynamic's wrapper doesn't surface the lib's ref/prop types cleanly, and
// the node/link callbacks below are independently typed (FGNode/FGLink).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ForceGraph2D: any = dynamic(() => import("react-force-graph-2d"), {
ssr: false,
loading: () => (
<div className="grid h-full w-full place-items-center text-sm text-ink-muted">
טוען גרף
</div>
),
});
// Internal shapes the canvas works with (force-graph mutates these).
type FGNode = GraphNode & { x?: number; y?: number };
type FGLink = {
source: string;
target: string;
type: string;
treatment: string | null;
};
export type ColorBy =
| "type"
| "practice_area"
| "community"
| "precedent_level"
| "recency";
export type SizeBy = "indegree" | "pagerank" | "betweenness";
const NODE_COLORS: Record<string, string> = {
precedent: "#1e3a5f", // navy
halacha: "#b45309", // amber
topic: "#a97d3a", // gold — hubs stand out
practice_area: "#475569", // slate
gap: "#94a3b8", // faint slate — research gap (not in corpus)
};
const TREATMENT_COLORS: Record<string, string> = {
overrule: "#b91c1c",
overruled: "#b91c1c",
distinguish: "#d97706",
distinguished: "#d97706",
};
// Distinct, cyclic palette for community (cluster) colouring.
export const COMMUNITY_PALETTE = [
"#1e3a5f", "#a97d3a", "#3b7a57", "#8c3b4a", "#5b4b8a", "#b06a2c",
"#2f6f7a", "#9a4f6a", "#46688a", "#7d6b3a", "#6b7280", "#4d7c5a",
];
// Authority hierarchy: עליון darkest → ועדת ערר lightest.
export const LEVEL_COLORS: Record<string, string> = {
עליון: "#13294b",
מנהלי: "#3b6ea5",
ועדת_ערר_מחוזית: "#8fb0cf",
};
export const PA_COLORS: Record<string, string> = {
rishuy_uvniya: "#1e3a5f",
betterment_levy: "#a97d3a",
compensation_197: "#3b7a57",
};
const FALLBACK_COLOR = "#94a3b8";
/** Old (slate) → recent (gold) gradient over 19942026. */
export function recencyColor(dateStr: string | null): string {
if (!dateStr) return FALLBACK_COLOR;
const y = Number(dateStr.slice(0, 4));
if (!y) return FALLBACK_COLOR;
const t = Math.max(0, Math.min(1, (y - 1994) / (2026 - 1994)));
const oldC = [100, 116, 139];
const newC = [169, 125, 58];
const c = oldC.map((o, i) => Math.round(o + (newC[i] - o) * t));
return `rgb(${c[0]},${c[1]},${c[2]})`;
}
export function colorForNode(n: GraphNode, colorBy: ColorBy): string {
// Hubs always keep their type colour — only precedents recolour.
if (n.type !== "precedent") return NODE_COLORS[n.type] ?? FALLBACK_COLOR;
switch (colorBy) {
case "practice_area":
return PA_COLORS[n.practice_area ?? ""] ?? FALLBACK_COLOR;
case "community":
return n.community != null
? COMMUNITY_PALETTE[n.community % COMMUNITY_PALETTE.length]
: FALLBACK_COLOR;
case "precedent_level":
return LEVEL_COLORS[n.precedent_level ?? ""] ?? FALLBACK_COLOR;
case "recency":
return recencyColor(n.date);
default:
return NODE_COLORS.precedent;
}
}
export function radiusForNode(n: GraphNode, sizeBy: SizeBy): number {
if (n.type === "topic" || n.type === "practice_area") return 5;
if (sizeBy === "pagerank" && n.pagerank != null) {
return 3 + Math.sqrt(n.pagerank) * 18;
}
if (sizeBy === "betweenness" && n.betweenness != null) {
return 3 + Math.sqrt(n.betweenness) * 18;
}
return Math.min(22, 3 + Math.sqrt(Math.max(0, n.size)) * 1.7);
}
function useElementSize<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const r = entries[0]?.contentRect;
if (r) setSize({ width: Math.floor(r.width), height: Math.floor(r.height) });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return { ref, size };
}
export function GraphCanvas({
data,
selectedId,
onNodeClick,
colorBy = "type",
sizeBy = "indegree",
}: {
data: CorpusGraph | undefined;
selectedId: string | null;
onNodeClick: (node: GraphNode) => void;
colorBy?: ColorBy;
sizeBy?: SizeBy;
}) {
const { ref, size } = useElementSize<HTMLDivElement>();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fgRef = useRef<any>(null);
const [hoverId, setHoverId] = useState<string | null>(null);
// Fresh objects each time `data` changes so force-graph can attach x/y
// without mutating the TanStack Query cache.
const graphData = useMemo(() => {
if (!data) return { nodes: [] as FGNode[], links: [] as FGLink[] };
return {
nodes: data.nodes.map((n) => ({ ...n })) as FGNode[],
links: data.edges.map((e) => ({
source: e.source,
target: e.target,
type: e.type,
treatment: e.treatment,
})) as FGLink[],
};
}, [data]);
// Adjacency for hover/selection highlighting (computed once per data change).
const adjacency = useMemo(() => {
const map = new Map<string, Set<string>>();
for (const e of graphData.links) {
if (!map.has(e.source)) map.set(e.source, new Set());
if (!map.has(e.target)) map.set(e.target, new Set());
map.get(e.source)!.add(e.target);
map.get(e.target)!.add(e.source);
}
return map;
}, [graphData]);
const activeId = hoverId ?? selectedId;
const activeNeighbors = activeId ? adjacency.get(activeId) : undefined;
const isDimmed = useCallback(
(id: string) => {
if (!activeId) return false;
if (id === activeId) return false;
return !(activeNeighbors && activeNeighbors.has(id));
},
[activeId, activeNeighbors],
);
// Zoom-to-fit once physics settle.
const handleEngineStop = useCallback(() => {
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 = radiusForNode(node, sizeBy);
const dimmed = isDimmed(node.id);
const isGap = node.type === "gap";
const color = colorForNode(node, colorBy);
ctx.globalAlpha = dimmed ? 0.18 : isGap ? 0.55 : 1;
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI);
if (isGap) {
// Hollow dashed circle — a ruling cited but absent from the corpus.
ctx.setLineDash([3 / globalScale, 2 / globalScale]);
ctx.lineWidth = 1.3 / globalScale;
ctx.strokeStyle = NODE_COLORS.gap;
ctx.stroke();
ctx.setLineDash([]);
} else {
ctx.fillStyle = color;
ctx.fill();
}
if (node.id === activeId) {
ctx.lineWidth = 2 / globalScale;
ctx.strokeStyle = "#a97d3a";
ctx.stroke();
}
// 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 &&
(node.id === activeId ||
isAreaHub ||
globalScale >= 1.5 ||
(isTopicHub && globalScale >= 1.05) ||
(!isTopicHub && node.size >= 4 && globalScale >= 0.9));
if (showLabel && node.label) {
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";
const label =
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;
},
[activeId, isDimmed, colorBy, sizeBy],
);
const drawPointerArea = useCallback(
(node: FGNode, color: string, ctx: CanvasRenderingContext2D) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, radiusForNode(node, sizeBy) + 2, 0, 2 * Math.PI);
ctx.fill();
},
[sizeBy],
);
const linkColor = useCallback(
(link: FGLink) => {
const s = typeof link.source === "object" ? (link.source as FGNode).id : link.source;
const t = typeof link.target === "object" ? (link.target as FGNode).id : link.target;
const active = activeId && (s === activeId || t === activeId);
if (active) return "rgba(169,125,58,0.85)";
if (activeId) return "rgba(120,130,150,0.06)";
if (link.treatment && TREATMENT_COLORS[link.treatment]) {
return TREATMENT_COLORS[link.treatment];
}
if (link.type === "tagged" || link.type === "in_area") {
return "rgba(169,125,58,0.16)";
}
return "rgba(80,90,110,0.22)";
},
[activeId],
);
if (!size.width && !data) {
return <div ref={ref} className="h-full w-full" />;
}
return (
<div ref={ref} className="h-full w-full">
{size.width > 0 && (
<ForceGraph2D
ref={fgRef}
width={size.width}
height={size.height}
graphData={graphData}
nodeId="id"
nodeLabel={(n: FGNode) => n.label}
nodeCanvasObject={drawNode}
nodePointerAreaPaint={drawPointerArea}
linkColor={linkColor}
linkWidth={(l: FGLink) => {
const s = typeof l.source === "object" ? (l.source as FGNode).id : l.source;
const t = typeof l.target === "object" ? (l.target as FGNode).id : l.target;
return activeId && (s === activeId || t === activeId) ? 1.6 : 0.6;
}}
linkDirectionalArrowLength={(l: FGLink) => (l.type === "cites" ? 2.4 : 0)}
linkDirectionalArrowRelPos={1}
onNodeClick={(n: FGNode) => onNodeClick(n)}
onNodeHover={(n: FGNode | null) => setHoverId(n?.id ?? null)}
onEngineStop={handleEngineStop}
cooldownTicks={120}
warmupTicks={20}
/>
)}
</div>
);
}