Files
legal-ai/web-ui/src/components/graph/graph-view.tsx
Chaim f3b075d282
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 6s
feat(ui): IA redesign → production · יישום נאמן של 16 הדפים הנותרים למוקאפים
תיקון הגישה: יישום מלא ונאמן של עיצוב-המוקאפים המאושרים (Claude Design) על כל
הדפים — שינוי-הרכב אמיתי פר-מוקאפ, לא ליטוש-טוקנים. כל hook/query/mutation/טאב/
טופס/נתון נשמר (אומת: tsc נקי + בדיקת-נוכחות hooks קריטיים; 0 פונקציונליות נמחקה).

דפים (← מוקאפ):
- בית — לוח: KPI + "תיקים לפי סטטוס" (bars) + כרטיס-אישורים + CTA כפול.
- ארכיון — filter-bar שטוח + טבלה נקייה + צ'יפי-סוג/תוצאה.
- הערות יו״ר — פריסה דו-טורית + טופס-הוספה חי + כרטיסי-הערה.
- ספריית-פסיקה — tabs קו-תחתון + כרטיסי-תוצאה halacha/קטע + AuthorityBadge.
- דף-תקדים — באנר-meta parchment + דו-טורי + provenance pills.
- פסיקה-חסרה — pill פתוחים + צ'יפי-סטטוס + CTA העלאה.
- יומונים — אזור-העלאה מקווקו + כרטיסי-digest + "ממתין" כתווית פסיבית.
- גרף — פאנל-צד שכבות/אנליטיקה + canvas parchment.
- אימון-סגנון — פורטרט: banner + KPI + אנטומיה + ביטויי-חתימה.
- מתודולוגיה — עורך-צ'קליסט + "חל על:" + canon chip.
- מיומנויות/סקריפטים — טבלאות אמיתיות + צ'יפי-סטטוס.
- הגדרות — sidenav דו-טורי + env-rows עם "ממתין ל-redeploy".
- דף-תיק — באנר-תיק parchment + tabs + timeline + "פתח עורך החלטה".
- תפעול — SectionHeaders + טבלת-שירותים + כרטיסי-שער gold-wash.
- compose — באנר-תיק + SOT pill + פריסה דו-טורית + "השלמה והעברה".

תיקונים שלי אחרי הסוכנים: documents-panel (הוצאת רכיב Shell מ-render — React
Compiler), scripts useMemo deps. /approvals כבר נבנה מחדש נאמנה (commit קודם).

בדיקות: npx tsc --noEmit ✓ · eslint ✓ (לבד מ-learning-panel:109 קיים-מראש).
שימור-פונקציונליות אומת. CI Docker build = שער סופי לפני deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 23:00:25 +00:00

482 lines
16 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, 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 className="inline-flex items-center gap-2 rounded-full border border-rule bg-surface px-3 py-1 tabular-nums">
{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-5 h-[calc(100vh-320px)] min-h-[560px] items-start">
<GraphFilterPanel controls={controls} onChange={onChange} facets={facets} />
<div
ref={canvasAreaRef}
className="relative flex-1 h-full rounded-lg border border-rule bg-gradient-to-b from-[#f3ecda] to-[#efe6cf] shadow-sm 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} />
{data ? (
<div className="absolute bottom-3 start-3 rounded-full bg-surface/70 backdrop-blur px-2.5 py-1 text-[0.72rem] text-ink-muted tabular-nums">
{data.nodes.length} צמתים מוצגים
</div>
) : null}
</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);
const communities = new Set(
nodes.map((n) => n.community).filter((c) => c != null),
).size;
return (
<Card className="bg-surface border-rule shadow-sm w-[300px] shrink-0 max-h-full overflow-y-auto">
<CardContent className="p-4 space-y-4">
<div className="text-[0.8rem] font-semibold text-navy border-b border-rule-soft pb-2">
אנליטיקה
</div>
<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>
{communities > 0 ? (
<div className="flex items-center gap-2 border-t border-rule-soft pt-3 text-sm text-ink-soft">
אשכולות:
<b className="text-navy text-lg tabular-nums">{communities}</b>
</div>
) : null}
</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">
{items.map((n, i) => (
<li key={n.id} className="border-b border-rule-soft last:border-b-0">
<button
type="button"
onClick={() => onPick(n)}
className="flex w-full items-baseline gap-2 px-1 py-1.5 text-start text-sm hover:bg-gold-wash rounded"
>
<span className="w-4 shrink-0 text-ink-muted text-xs tabular-nums">
{i + 1}
</span>
<span className="truncate text-ink-soft">{n.label}</span>
<span className="ms-auto text-gold-deep font-semibold 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>
);
}