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>
462 lines
15 KiB
TypeScript
462 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|