feat(graph): navigation & UX — deep-link, depth, PNG, rich panel (PR D)
Final corpus-graph PR. Connects the graph to the chair's workflow and rounds out the Obsidian-grade interactions. Backend (web/graph_api.py): neighborhood depth cap 2 → 3 (still bounded by NODE_CAP_MAX). Frontend: - URL deep-link: /graph?focus=cl:<id> is read on mount and written on focus change (router.replace, scroll:false). GraphView wrapped in <Suspense> per Next 16's useSearchParams requirement. - "הצג בגרף" button on the precedent detail page → /graph?focus=cl:<id>. - Depth slider (1–3) in the focused overlay → useNodeNeighborhood(id, depth). - Export PNG: grabs the rendered <canvas> from the area ref → toDataURL → download; failures surface a toast (UI4). - Rich node panel: precedent nodes fetch headnote/summary via the existing usePrecedent hook (Skeleton while pending, error surfaced — UI4). - Edge-type legend (ציטוט / נושא-תחום / יומון) added under the node legend. Deferred (noted for a later pass): expand-in-place merge, search→camera-center. web-ui build + lint pass. Invariants: G2 (depth change is read-only), UI4 (PNG + detail errors surfaced, not swallowed). api:types post-deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user