"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(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({ 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(null); const [focusNodeId, setFocusNodeId] = useState(null); const [depth, setDepth] = useState(1); const canvasAreaRef = useRef(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) => 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 (
{data ? `${data.nodes.length} נקודות · ${data.edges.length} קשרים` : "—"}
{!isFocused && full.data?.truncated && ( מוצגות {full.data.nodes.length} מתוך {full.data.total_available} — צמצמו את הסינון )}
{error ? (

שגיאה בטעינת הגרף

{error.message}

) : active.isLoading && !data ? (
טוען גרף…
) : data && data.nodes.length === 0 ? (
אין נקודות התואמות לסינון.
) : ( )} {isFocused && (
עומק setDepth(Number(e.target.value))} className="w-16 accent-gold" aria-label="עומק שכנים" /> {depth}
)}
{selectedNode ? ( setSelectedNode(null)} /> ) : showRanking ? ( ) : null}
); } 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 ( המשפיעות גשרים ); } function RankList({ items, metric, onPick, }: { items: GraphNode[]; metric: "pagerank" | "betweenness"; onPick: (n: GraphNode) => void; }) { if (items.length === 0) { return

אין נתונים.

; } return (
    {items.map((n, i) => (
  1. ))}
); } const LEGENDS: Record = { 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 (
{items.map((i) => (
{i.label}
))}
{EDGE_LEGEND.map((e) => (
{e.label}
))}
); }