Files
legal-ai/web-ui/src/components/graph/graph-view.tsx
Chaim ef21cb93e5 feat(graph): halacha (rule) layer (corpus graph — closes Phase 2)
Enables the previously-disabled "הלכות" toggle. Each approved/published halacha
of a displayed precedent becomes a hal:<id> node linked to its parent
precedent (extracted_from); two cross-rule edges when both endpoints are in
view: corroborates (a later ruling cites the rule —
halacha_citation_corroboration) and equivalent (same principle from another
committee — equivalent_halachot). Node size = corroboration in-degree.

Backend (web/graph_api.py — read-only, G2):
- _halacha_nodes_and_edges(): halachot WHERE case_law_id in view AND
  review_status IN (approved, published), LIMIT 600; rule_type carried in the
  source_kind slot, rule_statement in note. Wired into both build functions
  (gated via node_types). Metrics still exclude halacha edges (only cites/
  precedent-typed feed PageRank). Validated: 185 halachot on the top-30
  precedents; 20 corroboration + 5 equivalent edges in the corpus.

Frontend:
- graph.ts: GraphEdgeType += extracted_from.
- graph-filter-panel: "הלכות" toggle enabled (was disabled "שלב ב׳").
- graph-canvas: amber halacha nodes; edge colours — extracted_from (faint
  amber), corroborates (amber), equivalent (violet).
- graph-node-panel: halacha branch — אזכורים + סוג כלל + rule text; "open in
  library" deep-links to the parent precedent.
- graph-view: halacha added to node + edge legends.

web-ui build + lint pass. Invariants: G2 (SELECT-only), UI2 (no model change —
reuses note/source_kind/case_law_id slots).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 05:13:09 +00:00

462 lines
15 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";
/**
* Corpus graph orchestrator. Owns filter + selection state, decides whether to
* render the full graph or a focused node neighborhood (the Obsidian "local
* graph"), and wires the filter sidebar, canvas, ranking panel, and node panel.
*
* Analytics (PR B): when color-by=community or size-by=pagerank/betweenness,
* `metrics=true` is requested on the FULL graph (global importance). The
* neighborhood endpoint does not compute metrics — instead we overlay the
* cached full-graph metrics onto neighborhood nodes by id, so a node's
* PageRank doesn't change just because you zoomed into it.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ImageDown } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
type CorpusGraph,
type GraphNode,
useCorpusGraph,
useGraphFacets,
useNodeNeighborhood,
} from "@/lib/api/graph";
import {
type GraphControls,
GraphFilterPanel,
} from "@/components/graph/graph-filter-panel";
import {
type ColorBy,
COMMUNITY_PALETTE,
GraphCanvas,
LEVEL_COLORS,
PA_COLORS,
} from "@/components/graph/graph-canvas";
import { GraphNodePanel } from "@/components/graph/graph-node-panel";
const NODE_LIMIT = 400;
function useDebouncedValue<T>(value: T, ms: number): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return debounced;
}
export function GraphView() {
const [controls, setControls] = useState<GraphControls>({
practiceArea: "",
source: "",
minCitations: 0,
q: "",
court: "",
precedentLevel: "",
chair: "",
district: "",
yearFrom: 0,
yearTo: 0,
colorBy: "type",
sizeBy: "indegree",
showTopics: true,
showPracticeAreas: true,
showHalachot: false,
showGaps: false,
showDigests: false,
});
const facets = useGraphFacets().data;
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
const [depth, setDepth] = useState(1);
const canvasAreaRef = useRef<HTMLDivElement>(null);
// ── URL deep-link (?focus=) — read once on mount, write on focus change ──
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const didInit = useRef(false);
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
const f = searchParams.get("focus");
if (f) setFocusNodeId(f);
}, [searchParams]);
useEffect(() => {
if (!didInit.current) return;
const params = new URLSearchParams(searchParams.toString());
if (focusNodeId) params.set("focus", focusNodeId);
else params.delete("focus");
const qs = params.toString();
router.replace(qs ? `${pathname}?${qs}` : pathname, { scroll: false });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focusNodeId]);
const onChange = (patch: Partial<GraphControls>) =>
setControls((c) => ({ ...c, ...patch }));
const nodeTypes = useMemo(() => {
const t = ["precedent"];
if (controls.showTopics) t.push("topic");
if (controls.showPracticeAreas) t.push("practice_area");
if (controls.showHalachot) t.push("halacha");
if (controls.showGaps) t.push("gap");
if (controls.showDigests) t.push("digest");
return t.join(",");
}, [
controls.showTopics,
controls.showPracticeAreas,
controls.showHalachot,
controls.showGaps,
controls.showDigests,
]);
// Metrics are needed when colouring by cluster or sizing by a centrality.
const metricsOn =
controls.sizeBy !== "indegree" || controls.colorBy === "community";
const debouncedQ = useDebouncedValue(controls.q, 350);
const filters = useMemo(
() => ({
practice_area: controls.practiceArea,
source: controls.source,
min_citations: controls.minCitations,
node_types: nodeTypes,
limit: NODE_LIMIT,
q: debouncedQ,
court: controls.court,
precedent_level: controls.precedentLevel,
chair: controls.chair,
district: controls.district,
year_from: controls.yearFrom,
year_to: controls.yearTo,
metrics: metricsOn,
}),
[
controls.practiceArea,
controls.source,
controls.minCitations,
controls.court,
controls.precedentLevel,
controls.chair,
controls.district,
controls.yearFrom,
controls.yearTo,
metricsOn,
nodeTypes,
debouncedQ,
],
);
const isFocused = !!focusNodeId;
// Keep the full query alive when metrics are on so the overlay map stays warm.
const full = useCorpusGraph(filters, !isFocused || metricsOn);
const neighborhood = useNodeNeighborhood(focusNodeId, depth, nodeTypes);
const active = isFocused ? neighborhood : full;
const error = active.error as Error | undefined;
// Cache of global metrics by node id, overlaid onto neighborhood nodes.
const metricsMap = useMemo(() => {
const m = new Map<
string,
{ pagerank: number | null; betweenness: number | null; community: number | null }
>();
for (const n of full.data?.nodes ?? []) {
if (n.pagerank != null || n.community != null) {
m.set(n.id, {
pagerank: n.pagerank,
betweenness: n.betweenness,
community: n.community,
});
}
}
return m;
}, [full.data]);
const data: CorpusGraph | undefined = useMemo(() => {
if (!isFocused) return full.data;
if (!neighborhood.data) return undefined;
if (metricsMap.size === 0) return neighborhood.data;
return {
...neighborhood.data,
nodes: neighborhood.data.nodes.map((n) => {
const mv = metricsMap.get(n.id);
return mv ? { ...n, ...mv } : n;
}),
};
}, [isFocused, full.data, neighborhood.data, metricsMap]);
const handleNodeClick = (node: GraphNode) => {
setSelectedNode(node);
setFocusNodeId(node.id);
};
const backToFull = () => {
setFocusNodeId(null);
setSelectedNode(null);
};
const exportPng = useCallback(() => {
const canvas = canvasAreaRef.current?.querySelector("canvas");
if (!canvas) {
toast.error("הגרף עדיין נטען");
return;
}
try {
const url = canvas.toDataURL("image/png");
const a = document.createElement("a");
a.href = url;
a.download = "corpus-graph.png";
a.click();
} catch {
toast.error("ייצוא ה-PNG נכשל");
}
}, []);
const showRanking = metricsOn && !selectedNode && (full.data?.nodes.length ?? 0) > 0;
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3 text-xs text-ink-muted">
<span>
{data ? `${data.nodes.length} נקודות · ${data.edges.length} קשרים` : "—"}
</span>
<div className="flex items-center gap-3">
{!isFocused && full.data?.truncated && (
<span className="text-gold-deep">
מוצגות {full.data.nodes.length} מתוך {full.data.total_available}
צמצמו את הסינון
</span>
)}
<Button
variant="ghost"
size="sm"
onClick={exportPng}
className="h-7 gap-1.5 text-xs"
>
<ImageDown className="size-3.5" />
ייצוא PNG
</Button>
</div>
</div>
<div className="flex gap-4 h-[calc(100vh-320px)] min-h-[560px]">
<GraphFilterPanel controls={controls} onChange={onChange} facets={facets} />
<div
ref={canvasAreaRef}
className="relative flex-1 rounded-lg border border-rule bg-surface overflow-hidden"
>
{error ? (
<div className="grid h-full place-items-center p-6 text-center">
<div className="space-y-2">
<p className="text-red-700 font-medium m-0">שגיאה בטעינת הגרף</p>
<p className="text-ink-muted text-sm m-0">{error.message}</p>
<Button variant="outline" size="sm" onClick={() => active.refetch()}>
נסה שוב
</Button>
</div>
</div>
) : active.isLoading && !data ? (
<div className="grid h-full place-items-center text-sm text-ink-muted">
טוען גרף
</div>
) : data && data.nodes.length === 0 ? (
<div className="grid h-full place-items-center text-sm text-ink-muted">
אין נקודות התואמות לסינון.
</div>
) : (
<GraphCanvas
data={data}
selectedId={selectedNode?.id ?? null}
onNodeClick={handleNodeClick}
colorBy={controls.colorBy}
sizeBy={controls.sizeBy}
/>
)}
{isFocused && (
<div className="absolute top-3 start-3 flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={backToFull}
className="bg-surface/90 backdrop-blur"
>
חזרה לגרף המלא
</Button>
<div className="flex items-center gap-1.5 rounded-md bg-surface/90 backdrop-blur px-2 py-1 text-xs text-ink-muted">
<span>עומק</span>
<input
type="range"
min={1}
max={3}
step={1}
value={depth}
onChange={(e) => setDepth(Number(e.target.value))}
className="w-16 accent-gold"
aria-label="עומק שכנים"
/>
<span className="tabular-nums">{depth}</span>
</div>
</div>
)}
<Legend colorBy={controls.colorBy} />
</div>
{selectedNode ? (
<GraphNodePanel node={selectedNode} onClose={() => setSelectedNode(null)} />
) : showRanking ? (
<RankingPanel nodes={full.data!.nodes} onPick={handleNodeClick} />
) : null}
</div>
</div>
);
}
function RankingPanel({
nodes,
onPick,
}: {
nodes: GraphNode[];
onPick: (n: GraphNode) => void;
}) {
const precedents = nodes.filter((n) => n.type === "precedent");
const byPagerank = [...precedents]
.filter((n) => n.pagerank != null)
.sort((a, b) => (b.pagerank ?? 0) - (a.pagerank ?? 0))
.slice(0, 12);
const byBetweenness = [...precedents]
.filter((n) => n.betweenness != null)
.sort((a, b) => (b.betweenness ?? 0) - (a.betweenness ?? 0))
.slice(0, 12);
return (
<Card className="bg-surface border-rule shadow-sm w-72 shrink-0 overflow-y-auto">
<CardContent className="p-4">
<Tabs defaultValue="pagerank">
<TabsList className="w-full">
<TabsTrigger value="pagerank" className="flex-1">
המשפיעות
</TabsTrigger>
<TabsTrigger value="betweenness" className="flex-1">
גשרים
</TabsTrigger>
</TabsList>
<TabsContent value="pagerank">
<RankList items={byPagerank} metric="pagerank" onPick={onPick} />
</TabsContent>
<TabsContent value="betweenness">
<RankList items={byBetweenness} metric="betweenness" onPick={onPick} />
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
}
function RankList({
items,
metric,
onPick,
}: {
items: GraphNode[];
metric: "pagerank" | "betweenness";
onPick: (n: GraphNode) => void;
}) {
if (items.length === 0) {
return <p className="text-ink-muted text-xs mt-3">אין נתונים.</p>;
}
return (
<ol className="mt-2 space-y-1">
{items.map((n, i) => (
<li key={n.id}>
<button
type="button"
onClick={() => onPick(n)}
className="flex w-full items-baseline justify-between gap-2 rounded px-2 py-1 text-start text-sm hover:bg-gold-wash"
>
<span className="truncate">
<span className="text-ink-muted text-xs">{i + 1}.</span> {n.label}
</span>
<span className="text-ink-muted text-xs tabular-nums shrink-0">
{((n[metric] ?? 0) * 100).toFixed(0)}
</span>
</button>
</li>
))}
</ol>
);
}
const LEGENDS: Record<ColorBy, { color: string; label: string }[]> = {
type: [
{ color: "#1e3a5f", label: "פסיקה" },
{ color: "#b45309", label: "הלכה" },
{ color: "#a97d3a", label: "נושא" },
{ color: "#475569", label: "תחום" },
],
practice_area: [
{ color: PA_COLORS.rishuy_uvniya, label: "רישוי ובנייה" },
{ color: PA_COLORS.betterment_levy, label: "היטל השבחה" },
{ color: PA_COLORS.compensation_197, label: "פיצויים" },
],
precedent_level: [
{ color: LEVEL_COLORS["עליון"], label: "עליון" },
{ color: LEVEL_COLORS["מנהלי"], label: "מנהלי" },
{ color: LEVEL_COLORS["ועדת_ערר_מחוזית"], label: "ועדת ערר" },
],
community: [
{ color: COMMUNITY_PALETTE[0], label: "אשכול עיקרי" },
{ color: COMMUNITY_PALETTE[1], label: "אשכול נוסף" },
{ color: COMMUNITY_PALETTE[2], label: "אשכול נוסף" },
],
recency: [
{ color: "rgb(100,116,139)", label: "ישן (1994)" },
{ color: "rgb(169,125,58)", label: "עדכני (2026)" },
],
};
const EDGE_LEGEND: { color: string; label: string }[] = [
{ color: "rgba(80,90,110,0.7)", label: "ציטוט" },
{ color: "rgba(169,125,58,0.5)", label: "נושא/תחום" },
{ color: "rgba(47,111,122,0.7)", label: "יומון מסכם" },
{ color: "rgba(180,83,9,0.6)", label: "כלל/אזכור" },
];
function Legend({ colorBy }: { colorBy: ColorBy }) {
const items = LEGENDS[colorBy] ?? LEGENDS.type;
return (
<div className="absolute bottom-3 end-3 flex flex-col gap-1 rounded-md bg-surface/85 backdrop-blur px-3 py-2 text-xs text-ink-muted">
{items.map((i) => (
<div key={i.label} className="flex items-center gap-2">
<span
className="inline-block size-2.5 rounded-full"
style={{ backgroundColor: i.color }}
/>
{i.label}
</div>
))}
<div className="my-0.5 h-px bg-rule" />
{EDGE_LEGEND.map((e) => (
<div key={e.label} className="flex items-center gap-2">
<span
className="inline-block w-3.5 h-px"
style={{ backgroundColor: e.color, height: 2 }}
/>
{e.label}
</div>
))}
</div>
);
}