Merge pull request 'feat(graph): navigation & UX — deep-link, depth, PNG, rich panel (PR D)' (#134) from worktree-graph-nav into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 44s

This commit was merged in pull request #134.
This commit is contained in:
2026-06-08 04:56:26 +00:00
5 changed files with 152 additions and 23 deletions

View File

@@ -1,3 +1,4 @@
import { Suspense } from "react";
import Link from "next/link"; import Link from "next/link";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
@@ -22,7 +23,15 @@ export default function GraphPage() {
</p> </p>
</header> </header>
<div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" /> <div className="h-[2px] bg-gradient-to-l from-transparent via-gold to-transparent" />
<GraphView /> <Suspense
fallback={
<div className="grid h-[560px] place-items-center text-sm text-ink-muted">
טוען גרף
</div>
}
>
<GraphView />
</Suspense>
</section> </section>
</AppShell> </AppShell>
); );

View File

@@ -2,7 +2,7 @@
import { use, useState } from "react"; import { use, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Pencil, Check, X } from "lucide-react"; import { Pencil, Check, X, Share2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { AppShell } from "@/components/app-shell"; import { AppShell } from "@/components/app-shell";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -88,9 +88,16 @@ export default function PrecedentDetailPage({
{data.case_number} {data.case_number}
</div> </div>
</div> </div>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}> <div className="flex items-center gap-2">
<Pencil className="w-3.5 h-3.5 me-1" /> ערוך פרטים <Button asChild variant="outline" size="sm">
</Button> <Link href={`/graph?focus=cl:${id}`}>
<Share2 className="w-3.5 h-3.5 me-1" /> הצג בגרף
</Link>
</Button>
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="w-3.5 h-3.5 me-1" /> ערוך פרטים
</Button>
</div>
</div> </div>
{/* Citation per Israeli unified citation rules. The LLM {/* Citation per Israeli unified citation rules. The LLM

View File

@@ -12,7 +12,9 @@ import { ExternalLink, X } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { GraphNode } from "@/lib/api/graph"; import type { GraphNode } from "@/lib/api/graph";
import { usePrecedent } from "@/lib/api/precedent-library";
const TYPE_LABELS: Record<string, string> = { const TYPE_LABELS: Record<string, string> = {
precedent: "פסיקה", precedent: "פסיקה",
@@ -54,6 +56,9 @@ export function GraphNodePanel({
const isPrecedentLike = node.type === "precedent" || node.type === "halacha"; const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
const isGap = node.type === "gap"; const isGap = node.type === "gap";
const isDigest = node.type === "digest"; const isDigest = node.type === "digest";
// Rich detail (summary/headnote) for precedents — reuses the library hook.
const detailId = node.type === "precedent" ? node.case_law_id : null;
const detail = usePrecedent(detailId);
return ( return (
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto"> <Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
<CardContent className="space-y-4 p-4"> <CardContent className="space-y-4 p-4">
@@ -119,6 +124,25 @@ export function GraphNodePanel({
)} )}
</dl> </dl>
{detailId && (
<div className="space-y-1.5 border-t border-rule pt-3">
{detail.isPending ? (
<>
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-4/5" />
</>
) : detail.error ? (
<p className="text-ink-muted text-xs m-0">לא ניתן לטעון תקציר.</p>
) : detail.data?.headnote || detail.data?.summary ? (
<p className="text-ink text-sm leading-relaxed m-0">
{detail.data.headnote || detail.data.summary}
</p>
) : (
<p className="text-ink-muted text-xs m-0">אין תקציר.</p>
)}
</div>
)}
{isPrecedentLike && node.case_law_id && ( {isPrecedentLike && node.case_law_id && (
<Button asChild variant="outline" className="w-full"> <Button asChild variant="outline" className="w-full">
<Link href={`/precedents/${node.case_law_id}`}> <Link href={`/precedents/${node.case_law_id}`}>

View File

@@ -12,7 +12,10 @@
* PageRank doesn't change just because you zoomed into it. * PageRank doesn't change just because you zoomed into it.
*/ */
import { useEffect, useMemo, useState } from "react"; 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 { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -71,6 +74,29 @@ export function GraphView() {
const facets = useGraphFacets().data; const facets = useGraphFacets().data;
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null); const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [focusNodeId, setFocusNodeId] = useState<string | 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>) => const onChange = (patch: Partial<GraphControls>) =>
setControls((c) => ({ ...c, ...patch })); setControls((c) => ({ ...c, ...patch }));
@@ -132,7 +158,7 @@ export function GraphView() {
const isFocused = !!focusNodeId; const isFocused = !!focusNodeId;
// Keep the full query alive when metrics are on so the overlay map stays warm. // Keep the full query alive when metrics are on so the overlay map stays warm.
const full = useCorpusGraph(filters, !isFocused || metricsOn); const full = useCorpusGraph(filters, !isFocused || metricsOn);
const neighborhood = useNodeNeighborhood(focusNodeId, 1, nodeTypes); const neighborhood = useNodeNeighborhood(focusNodeId, depth, nodeTypes);
const active = isFocused ? neighborhood : full; const active = isFocused ? neighborhood : full;
const error = active.error as Error | undefined; const error = active.error as Error | undefined;
@@ -178,6 +204,23 @@ export function GraphView() {
setSelectedNode(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; const showRanking = metricsOn && !selectedNode && (full.data?.nodes.length ?? 0) > 0;
return ( return (
@@ -186,18 +229,32 @@ export function GraphView() {
<span> <span>
{data ? `${data.nodes.length} נקודות · ${data.edges.length} קשרים` : "—"} {data ? `${data.nodes.length} נקודות · ${data.edges.length} קשרים` : "—"}
</span> </span>
{!isFocused && full.data?.truncated && ( <div className="flex items-center gap-3">
<span className="text-gold-deep"> {!isFocused && full.data?.truncated && (
מוצגות {full.data.nodes.length} הנקודות המצוטטות ביותר מתוך{" "} <span className="text-gold-deep">
{full.data.total_available} צמצמו את הסינון כדי לראות פחות מוצגות {full.data.nodes.length} מתוך {full.data.total_available}
</span> צמצמו את הסינון
)} </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>
<div className="flex gap-4 h-[calc(100vh-320px)] min-h-[560px]"> <div className="flex gap-4 h-[calc(100vh-320px)] min-h-[560px]">
<GraphFilterPanel controls={controls} onChange={onChange} facets={facets} /> <GraphFilterPanel controls={controls} onChange={onChange} facets={facets} />
<div className="relative flex-1 rounded-lg border border-rule bg-surface overflow-hidden"> <div
ref={canvasAreaRef}
className="relative flex-1 rounded-lg border border-rule bg-surface overflow-hidden"
>
{error ? ( {error ? (
<div className="grid h-full place-items-center p-6 text-center"> <div className="grid h-full place-items-center p-6 text-center">
<div className="space-y-2"> <div className="space-y-2">
@@ -227,14 +284,30 @@ export function GraphView() {
)} )}
{isFocused && ( {isFocused && (
<Button <div className="absolute top-3 start-3 flex items-center gap-2">
variant="outline" <Button
size="sm" variant="outline"
onClick={backToFull} size="sm"
className="absolute top-3 start-3 bg-surface/90 backdrop-blur" onClick={backToFull}
> className="bg-surface/90 backdrop-blur"
חזרה לגרף המלא >
</Button> חזרה לגרף המלא
</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} /> <Legend colorBy={controls.colorBy} />
@@ -352,6 +425,12 @@ const LEGENDS: Record<ColorBy, { color: string; label: string }[]> = {
], ],
}; };
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: "יומון מסכם" },
];
function Legend({ colorBy }: { colorBy: ColorBy }) { function Legend({ colorBy }: { colorBy: ColorBy }) {
const items = LEGENDS[colorBy] ?? LEGENDS.type; const items = LEGENDS[colorBy] ?? LEGENDS.type;
return ( return (
@@ -365,6 +444,16 @@ function Legend({ colorBy }: { colorBy: ColorBy }) {
{i.label} {i.label}
</div> </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> </div>
); );
} }

View File

@@ -527,7 +527,7 @@ async def build_node_neighborhood(
- ``pa:<token>`` — a practice-area hub; same as topic. - ``pa:<token>`` — a practice-area hub; same as topic.
""" """
types = normalize_node_types(node_types) types = normalize_node_types(node_types)
depth = max(1, min(int(depth), 2)) depth = max(1, min(int(depth), 3)) # BFS is still bounded by NODE_CAP_MAX
prefix, _, rest = node_id.partition(":") prefix, _, rest = node_id.partition(":")
rest = rest.strip() rest = rest.strip()
if prefix not in {"cl", "tag", "pa"} or not rest: if prefix not in {"cl", "tag", "pa"} or not rest: