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>
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
"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 1994–2026. */
|
||
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>
|
||
);
|
||
}
|