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>
206 lines
6.2 KiB
TypeScript
206 lines
6.2 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, 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>
|
||
);
|
||
}
|