Merge pull request 'feat(graph): in-app corpus citation graph (/graph) — Phase 1' (#113) from worktree-corpus-graph into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m44s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 3m44s
This commit was merged in pull request #113.
This commit is contained in:
@@ -50,6 +50,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
id: "knowledge",
|
||||
items: [
|
||||
{ href: "/precedents", label: "ספריית פסיקה" },
|
||||
{ href: "/graph", label: "מפת הקורפוס" },
|
||||
{ href: "/digests", label: "יומונים" },
|
||||
{ href: "/missing-precedents", label: "פסיקה חסרה" },
|
||||
{ href: "/goldset", label: "מדגם-זהב" },
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
178
web-ui/src/components/graph/graph-filter-panel.tsx
Normal file
178
web-ui/src/components/graph/graph-filter-panel.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Filter sidebar for the corpus graph. Controlled — all state lives in
|
||||
* GraphView. node_types toggles let the chair thin the graph (precedent is
|
||||
* always on; halacha is Phase 2 and shown disabled to telegraph the roadmap).
|
||||
*/
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export type GraphControls = {
|
||||
practiceArea: string;
|
||||
source: string;
|
||||
minCitations: number;
|
||||
q: string;
|
||||
showTopics: boolean;
|
||||
showPracticeAreas: boolean;
|
||||
showHalachot: boolean;
|
||||
};
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
const PRACTICE_AREAS: { value: string; label: string }[] = [
|
||||
{ value: "rishuy_uvniya", label: "רישוי ובנייה" },
|
||||
{ value: "betterment_levy", label: "היטל השבחה" },
|
||||
{ value: "compensation_197", label: "פיצויים (ס׳ 197)" },
|
||||
];
|
||||
|
||||
const SOURCES: { value: string; label: string }[] = [
|
||||
{ value: "external_upload", label: "פסיקה חיצונית" },
|
||||
{ value: "internal_committee", label: "החלטות ועדה" },
|
||||
{ value: "cited_only", label: "מוזכר בלבד" },
|
||||
];
|
||||
|
||||
const MIN_CITATIONS = [0, 1, 2, 3, 5];
|
||||
|
||||
export function GraphFilterPanel({
|
||||
controls,
|
||||
onChange,
|
||||
}: {
|
||||
controls: GraphControls;
|
||||
onChange: (patch: Partial<GraphControls>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm w-72 shrink-0 overflow-y-auto">
|
||||
<CardContent className="space-y-5 p-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="graph-search" className="text-xs text-ink-muted">
|
||||
חיפוש פסיקה
|
||||
</Label>
|
||||
<Input
|
||||
id="graph-search"
|
||||
value={controls.q}
|
||||
placeholder="מספר תיק או שם…"
|
||||
onChange={(e) => onChange({ q: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-ink-muted">תחום</Label>
|
||||
<Select
|
||||
value={controls.practiceArea || ALL}
|
||||
onValueChange={(v) => onChange({ practiceArea: v === ALL ? "" : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>כל התחומים</SelectItem>
|
||||
{PRACTICE_AREAS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-ink-muted">מקור</Label>
|
||||
<Select
|
||||
value={controls.source || ALL}
|
||||
onValueChange={(v) => onChange({ source: v === ALL ? "" : v })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>כל המקורות</SelectItem>
|
||||
{SOURCES.map((s) => (
|
||||
<SelectItem key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-ink-muted">מינימום ציטוטים נכנסים</Label>
|
||||
<Select
|
||||
value={String(controls.minCitations)}
|
||||
onValueChange={(v) => onChange({ minCitations: Number(v) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MIN_CITATIONS.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n === 0 ? "הצג הכל" : `${n}+`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs text-ink-muted">סוגי נקודות</Label>
|
||||
<ToggleRow
|
||||
label="נקודות-נושא"
|
||||
checked={controls.showTopics}
|
||||
onCheckedChange={(v) => onChange({ showTopics: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="נקודות-תחום"
|
||||
checked={controls.showPracticeAreas}
|
||||
onCheckedChange={(v) => onChange({ showPracticeAreas: v })}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="הלכות (שלב ב׳)"
|
||||
checked={controls.showHalachot}
|
||||
onCheckedChange={(v) => onChange({ showHalachot: v })}
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleRow({
|
||||
label,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onCheckedChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm ${disabled ? "text-ink-muted/50" : "text-ink"}`}>
|
||||
{label}
|
||||
</span>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
web-ui/src/components/graph/graph-node-panel.tsx
Normal file
105
web-ui/src/components/graph/graph-node-panel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Side panel shown when a node is selected. For precedent/halacha nodes it
|
||||
* deep-links into the existing precedent library (/precedents/[id]) so the
|
||||
* graph is a navigation surface, not a dead-end visualization.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import { ExternalLink, X } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { GraphNode } from "@/lib/api/graph";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
precedent: "פסיקה",
|
||||
halacha: "הלכה",
|
||||
topic: "נושא",
|
||||
practice_area: "תחום",
|
||||
};
|
||||
|
||||
const PA_LABELS: Record<string, string> = {
|
||||
rishuy_uvniya: "רישוי ובנייה",
|
||||
betterment_levy: "היטל השבחה",
|
||||
compensation_197: "פיצויים (ס׳ 197)",
|
||||
appeals_committee: "ועדת ערר",
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<string, string> = {
|
||||
external_upload: "פסיקה חיצונית",
|
||||
internal_committee: "החלטת ועדה",
|
||||
cited_only: "מוזכר בלבד",
|
||||
nevo_seed: "נבו",
|
||||
};
|
||||
|
||||
export function GraphNodePanel({
|
||||
node,
|
||||
onClose,
|
||||
}: {
|
||||
node: GraphNode;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
|
||||
return (
|
||||
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1">
|
||||
<Badge variant="outline" className="text-[0.65rem]">
|
||||
{TYPE_LABELS[node.type] ?? node.type}
|
||||
</Badge>
|
||||
<h2 className="text-navy text-base leading-snug m-0">{node.label}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
aria-label="סגור"
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<dl className="space-y-2 text-sm">
|
||||
{isPrecedentLike && (
|
||||
<Row label="ציטוטים נכנסים" value={String(node.size)} />
|
||||
)}
|
||||
{node.practice_area && (
|
||||
<Row label="תחום" value={PA_LABELS[node.practice_area] ?? node.practice_area} />
|
||||
)}
|
||||
{node.source_kind && (
|
||||
<Row label="מקור" value={SOURCE_LABELS[node.source_kind] ?? node.source_kind} />
|
||||
)}
|
||||
{node.precedent_level && <Row label="דרגה" value={node.precedent_level} />}
|
||||
{!isPrecedentLike && (
|
||||
<p className="text-ink-muted text-xs leading-relaxed m-0">
|
||||
לחיצה על נקודה זו מתמקדת בשכניה — כל הפסיקות המשויכות אליה.
|
||||
</p>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{isPrecedentLike && node.case_law_id && (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/precedents/${node.case_law_id}`}>
|
||||
<ExternalLink className="size-4 me-2" />
|
||||
פתח בספריית הפסיקה
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<dt className="text-ink-muted text-xs shrink-0">{label}</dt>
|
||||
<dd className="text-ink text-end m-0">{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
179
web-ui/src/components/graph/graph-view.tsx
Normal file
179
web-ui/src/components/graph/graph-view.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
"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, and node detail panel.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
type CorpusGraph,
|
||||
type GraphNode,
|
||||
useCorpusGraph,
|
||||
useNodeNeighborhood,
|
||||
} from "@/lib/api/graph";
|
||||
import {
|
||||
type GraphControls,
|
||||
GraphFilterPanel,
|
||||
} from "@/components/graph/graph-filter-panel";
|
||||
import { GraphCanvas } 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: "",
|
||||
showTopics: true,
|
||||
showPracticeAreas: true,
|
||||
showHalachot: false,
|
||||
});
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
||||
const [focusNodeId, setFocusNodeId] = useState<string | null>(null);
|
||||
|
||||
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");
|
||||
return t.join(",");
|
||||
}, [controls.showTopics, controls.showPracticeAreas, controls.showHalachot]);
|
||||
|
||||
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,
|
||||
}),
|
||||
[controls.practiceArea, controls.source, controls.minCitations, nodeTypes, debouncedQ],
|
||||
);
|
||||
|
||||
const isFocused = !!focusNodeId;
|
||||
const full = useCorpusGraph(filters, !isFocused);
|
||||
const neighborhood = useNodeNeighborhood(focusNodeId, 1, nodeTypes);
|
||||
|
||||
const active = isFocused ? neighborhood : full;
|
||||
const data: CorpusGraph | undefined = active.data;
|
||||
const error = active.error as Error | undefined;
|
||||
|
||||
const handleNodeClick = (node: GraphNode) => {
|
||||
setSelectedNode(node);
|
||||
setFocusNodeId(node.id);
|
||||
};
|
||||
|
||||
const backToFull = () => {
|
||||
setFocusNodeId(null);
|
||||
setSelectedNode(null);
|
||||
};
|
||||
|
||||
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>
|
||||
{!isFocused && full.data?.truncated && (
|
||||
<span className="text-gold-deep">
|
||||
מוצגות {full.data.nodes.length} הנקודות המצוטטות ביותר מתוך{" "}
|
||||
{full.data.total_available} — צמצמו את הסינון כדי לראות פחות
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 h-[calc(100vh-320px)] min-h-[560px]">
|
||||
<GraphFilterPanel controls={controls} onChange={onChange} />
|
||||
|
||||
<div 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFocused && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={backToFull}
|
||||
className="absolute top-3 start-3 bg-surface/90 backdrop-blur"
|
||||
>
|
||||
← חזרה לגרף המלא
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Legend />
|
||||
</div>
|
||||
|
||||
{selectedNode && (
|
||||
<GraphNodePanel node={selectedNode} onClose={() => setSelectedNode(null)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend() {
|
||||
const items = [
|
||||
{ color: "#1e3a5f", label: "פסיקה" },
|
||||
{ color: "#a97d3a", label: "נושא" },
|
||||
{ color: "#475569", label: "תחום" },
|
||||
];
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user