Files
legal-ai/web-ui/src/components/graph/graph-view.tsx
Chaim 8258f09228 feat(graph): metadata filters + facets (corpus graph PR A)
Adds legal-metadata filtering and the payload to color by it (foundation for
the color-by selector in the analytics PR).

Backend (web/graph_api.py, web/app.py) — read-only, G2:
- GraphNode += court, date (ISO) — precedents carry them for filter/color-by.
- build_corpus_graph += server-side WHERE filters (G5): court, precedent_level,
  chair, district, year_from, year_to (EXTRACT(YEAR FROM date)). Neighborhood
  query also selects court/date.
- New GET /api/graph/facets (response_model GraphFacets, UI2) → distinct
  courts/levels/chairs/districts so the UI doesn't hardcode Hebrew strings.

Frontend:
- graph.ts: GraphNode += court/date; GraphFilters += the six params;
  buildParams; useGraphFacets() hook.
- graph-filter-panel: an "advanced" Accordion with court/precedent_level/chair/
  district Selects (from facets) + year-from/year-to Selects.
- graph-view: new controls wired into filters; facets fetched and passed down.

Verified read-only against the live DB (precedent_level=עליון&year_from=2015
filters correctly; facets populated: 36 courts / 3 levels / 19 chairs / 4
districts). web-ui build + lint pass.

Invariants: G2 (SELECT-only via db.get_pool), G5 (filters server-side),
UI2 (explicit response_models). api:types to be regenerated post-deploy.

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

206 lines
6.2 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, and node detail panel.
*/
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import {
type CorpusGraph,
type GraphNode,
useCorpusGraph,
useGraphFacets,
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: "",
court: "",
precedentLevel: "",
chair: "",
district: "",
yearFrom: 0,
yearTo: 0,
showTopics: true,
showPracticeAreas: true,
showHalachot: false,
});
const facets = useGraphFacets().data;
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,
court: controls.court,
precedent_level: controls.precedentLevel,
chair: controls.chair,
district: controls.district,
year_from: controls.yearFrom,
year_to: controls.yearTo,
}),
[
controls.practiceArea,
controls.source,
controls.minCitations,
controls.court,
controls.precedentLevel,
controls.chair,
controls.district,
controls.yearFrom,
controls.yearTo,
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} facets={facets} />
<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>
);
}