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
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:
@@ -1,3 +1,4 @@
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AppShell } from "@/components/app-shell";
|
||||
@@ -22,7 +23,15 @@ export default function GraphPage() {
|
||||
</p>
|
||||
</header>
|
||||
<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>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { use, useState } from "react";
|
||||
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 { AppShell } from "@/components/app-shell";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -88,9 +88,16 @@ export default function PrecedentDetailPage({
|
||||
{data.case_number}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
<Pencil className="w-3.5 h-3.5 me-1" /> ערוך פרטים
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<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>
|
||||
|
||||
{/* Citation per Israeli unified citation rules. The LLM
|
||||
|
||||
@@ -12,7 +12,9 @@ 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 { Skeleton } from "@/components/ui/skeleton";
|
||||
import type { GraphNode } from "@/lib/api/graph";
|
||||
import { usePrecedent } from "@/lib/api/precedent-library";
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
precedent: "פסיקה",
|
||||
@@ -54,6 +56,9 @@ export function GraphNodePanel({
|
||||
const isPrecedentLike = node.type === "precedent" || node.type === "halacha";
|
||||
const isGap = node.type === "gap";
|
||||
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 (
|
||||
<Card className="bg-surface border-rule shadow-sm w-80 shrink-0 overflow-y-auto">
|
||||
<CardContent className="space-y-4 p-4">
|
||||
@@ -119,6 +124,25 @@ export function GraphNodePanel({
|
||||
)}
|
||||
</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 && (
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/precedents/${node.case_law_id}`}>
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
* 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 { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -71,6 +74,29 @@ export function GraphView() {
|
||||
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 }));
|
||||
@@ -132,7 +158,7 @@ export function GraphView() {
|
||||
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, 1, nodeTypes);
|
||||
const neighborhood = useNodeNeighborhood(focusNodeId, depth, nodeTypes);
|
||||
|
||||
const active = isFocused ? neighborhood : full;
|
||||
const error = active.error as Error | undefined;
|
||||
@@ -178,6 +204,23 @@ export function GraphView() {
|
||||
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 (
|
||||
@@ -186,18 +229,32 @@ export function GraphView() {
|
||||
<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 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-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">
|
||||
<div
|
||||
ref={canvasAreaRef}
|
||||
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">
|
||||
@@ -227,14 +284,30 @@ export function GraphView() {
|
||||
)}
|
||||
|
||||
{isFocused && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={backToFull}
|
||||
className="absolute top-3 start-3 bg-surface/90 backdrop-blur"
|
||||
>
|
||||
← חזרה לגרף המלא
|
||||
</Button>
|
||||
<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} />
|
||||
@@ -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 }) {
|
||||
const items = LEGENDS[colorBy] ?? LEGENDS.type;
|
||||
return (
|
||||
@@ -365,6 +444,16 @@ function Legend({ colorBy }: { colorBy: ColorBy }) {
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -527,7 +527,7 @@ async def build_node_neighborhood(
|
||||
- ``pa:<token>`` — a practice-area hub; same as topic.
|
||||
"""
|
||||
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(":")
|
||||
rest = rest.strip()
|
||||
if prefix not in {"cl", "tag", "pa"} or not rest:
|
||||
|
||||
Reference in New Issue
Block a user