feat(graph): in-app corpus citation graph (/graph) — Phase 1
Native, Obsidian-graph-view-like network of the precedent corpus, rendered
in web-ui from a read-only projection of the live DB. Replaces the idea of
exporting to an external Obsidian vault (which would be a parallel, drifting
copy of the corpus — the exact root cause G2 forbids).
The graph edges already existed in the data model; this only surfaces them:
nodes = precedents (case_law) + synthesized topic/practice-area hubs;
edges = cites (precedent_internal_citations) + same_chain (case_law_relations)
+ tagged/in_area (subject_tags / practice_area membership). Node size =
incoming-citation count (index-backed GROUP BY on idx_pic_target). Click a
node → local-graph neighborhood focus; panel deep-links to /precedents/[id].
Backend (read-only, SELECT only — G2):
- web/graph_api.py — Pydantic models (CorpusGraph/GraphNode/GraphEdge, so
OpenAPI emits real types — UI2) + SQL assembly over the shared db.get_pool().
- web/app.py — GET /api/graph/corpus, GET /api/graph/node/{id}/neighborhood,
both with explicit response_model. practice_area validated against the
closed enum (G5); both endpoints write nothing.
Frontend:
- react-force-graph-2d (canvas/d3-force), loaded via next/dynamic ssr:false.
- /graph page + nav entry; graph.ts TanStack hooks; filter panel (practice_area
/ source / min-citations / search / node-type toggles), node detail panel,
hover+selection neighborhood highlight. Explicit error handling (UI4).
Not a retrieval path (03-retrieval): returns graph topology, never ranked
search results. Halacha nodes + corroboration/equivalence edges are Phase 2,
already gated behind the node_types param (no contract change needed).
SQL validated read-only against the live DB (142 precedents, 85 resolved
citations, JSONB tag expansion, ANY(uuid[]) edge + BFS queries). web-ui lint
+ build pass; /graph in the route table.
Invariants: keeps G2 (single source of truth — live projection, no parallel
store), G5 (corpus separation filtered server-side), UI2 (response models),
UI4 (no swallowed UI errors).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
233
web-ui/src/components/graph/graph-canvas.tsx
Normal file
233
web-ui/src/components/graph/graph-canvas.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"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;
|
||||
};
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
precedent: "#1e3a5f", // navy
|
||||
halacha: "#b45309", // amber
|
||||
topic: "#a97d3a", // gold — hubs stand out
|
||||
practice_area: "#475569", // slate
|
||||
};
|
||||
|
||||
const TREATMENT_COLORS: Record<string, string> = {
|
||||
overrule: "#b91c1c",
|
||||
overruled: "#b91c1c",
|
||||
distinguish: "#d97706",
|
||||
distinguished: "#d97706",
|
||||
};
|
||||
|
||||
function nodeRadius(n: GraphNode): number {
|
||||
if (n.type === "topic" || n.type === "practice_area") return 5;
|
||||
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,
|
||||
}: {
|
||||
data: CorpusGraph | undefined;
|
||||
selectedId: string | null;
|
||||
onNodeClick: (node: GraphNode) => void;
|
||||
}) {
|
||||
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);
|
||||
}, []);
|
||||
|
||||
const drawNode = useCallback(
|
||||
(node: FGNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const r = nodeRadius(node);
|
||||
const dimmed = isDimmed(node.id);
|
||||
const color = NODE_COLORS[node.type] ?? "#64748b";
|
||||
ctx.globalAlpha = dimmed ? 0.18 : 1;
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
if (node.id === activeId) {
|
||||
ctx.lineWidth = 2 / globalScale;
|
||||
ctx.strokeStyle = "#a97d3a";
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Labels: hubs always; precedents when zoomed in, important, or active.
|
||||
const isHub = node.type === "topic" || node.type === "practice_area";
|
||||
const showLabel =
|
||||
!dimmed &&
|
||||
(isHub || node.id === activeId || node.size >= 3 || globalScale >= 1.6);
|
||||
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`;
|
||||
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);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
},
|
||||
[activeId, isDimmed],
|
||||
);
|
||||
|
||||
const drawPointerArea = useCallback(
|
||||
(node: FGNode, color: string, ctx: CanvasRenderingContext2D) => {
|
||||
ctx.fillStyle = color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(node.x ?? 0, node.y ?? 0, nodeRadius(node) + 2, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user