"use client";
/**
* Force-directed canvas for the corpus graph (Obsidian-graph-view-like).
*
* Uses react-force-graph-2d (HTML5 canvas + d3-force) — the live physics give
* the "alive" Obsidian feel, and `nodeCanvasObject` gives full control over
* node radius (size = citation count) and Hebrew/RTL label rendering. Loaded
* via next/dynamic with ssr:false because the canvas needs the DOM (Next 16).
*/
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CorpusGraph, GraphNode } from "@/lib/api/graph";
// react-force-graph-2d's default export is a forwardRef component; it touches
// `window` at module load, so it must be client-only. Cast to a loose type:
// next/dynamic's wrapper doesn't surface the lib's ref/prop types cleanly, and
// the node/link callbacks below are independently typed (FGNode/FGLink).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ForceGraph2D: any = dynamic(() => import("react-force-graph-2d"), {
ssr: false,
loading: () => (
טוען גרף…
),
});
// Internal shapes the canvas works with (force-graph mutates these).
type FGNode = GraphNode & { x?: number; y?: number };
type FGLink = {
source: string;
target: string;
type: string;
treatment: string | null;
};
export type ColorBy =
| "type"
| "practice_area"
| "community"
| "precedent_level"
| "recency";
export type SizeBy = "indegree" | "pagerank" | "betweenness";
const NODE_COLORS: Record = {
precedent: "#1e3a5f", // navy
halacha: "#b45309", // amber
topic: "#a97d3a", // gold — hubs stand out
practice_area: "#475569", // slate
gap: "#94a3b8", // faint slate — research gap (not in corpus)
};
const TREATMENT_COLORS: Record = {
overrule: "#b91c1c",
overruled: "#b91c1c",
distinguish: "#d97706",
distinguished: "#d97706",
};
// Distinct, cyclic palette for community (cluster) colouring.
export const COMMUNITY_PALETTE = [
"#1e3a5f", "#a97d3a", "#3b7a57", "#8c3b4a", "#5b4b8a", "#b06a2c",
"#2f6f7a", "#9a4f6a", "#46688a", "#7d6b3a", "#6b7280", "#4d7c5a",
];
// Authority hierarchy: עליון darkest → ועדת ערר lightest.
export const LEVEL_COLORS: Record = {
עליון: "#13294b",
מנהלי: "#3b6ea5",
ועדת_ערר_מחוזית: "#8fb0cf",
};
export const PA_COLORS: Record = {
rishuy_uvniya: "#1e3a5f",
betterment_levy: "#a97d3a",
compensation_197: "#3b7a57",
};
const FALLBACK_COLOR = "#94a3b8";
/** Old (slate) → recent (gold) gradient over 1994–2026. */
export function recencyColor(dateStr: string | null): string {
if (!dateStr) return FALLBACK_COLOR;
const y = Number(dateStr.slice(0, 4));
if (!y) return FALLBACK_COLOR;
const t = Math.max(0, Math.min(1, (y - 1994) / (2026 - 1994)));
const oldC = [100, 116, 139];
const newC = [169, 125, 58];
const c = oldC.map((o, i) => Math.round(o + (newC[i] - o) * t));
return `rgb(${c[0]},${c[1]},${c[2]})`;
}
export function colorForNode(n: GraphNode, colorBy: ColorBy): string {
// Hubs always keep their type colour — only precedents recolour.
if (n.type !== "precedent") return NODE_COLORS[n.type] ?? FALLBACK_COLOR;
switch (colorBy) {
case "practice_area":
return PA_COLORS[n.practice_area ?? ""] ?? FALLBACK_COLOR;
case "community":
return n.community != null
? COMMUNITY_PALETTE[n.community % COMMUNITY_PALETTE.length]
: FALLBACK_COLOR;
case "precedent_level":
return LEVEL_COLORS[n.precedent_level ?? ""] ?? FALLBACK_COLOR;
case "recency":
return recencyColor(n.date);
default:
return NODE_COLORS.precedent;
}
}
export function radiusForNode(n: GraphNode, sizeBy: SizeBy): number {
if (n.type === "topic" || n.type === "practice_area") return 5;
if (sizeBy === "pagerank" && n.pagerank != null) {
return 3 + Math.sqrt(n.pagerank) * 18;
}
if (sizeBy === "betweenness" && n.betweenness != null) {
return 3 + Math.sqrt(n.betweenness) * 18;
}
return Math.min(22, 3 + Math.sqrt(Math.max(0, n.size)) * 1.7);
}
function useElementSize() {
const ref = useRef(null);
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const el = ref.current;
if (!el) return;
const ro = new ResizeObserver((entries) => {
const r = entries[0]?.contentRect;
if (r) setSize({ width: Math.floor(r.width), height: Math.floor(r.height) });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
return { ref, size };
}
export function GraphCanvas({
data,
selectedId,
onNodeClick,
colorBy = "type",
sizeBy = "indegree",
}: {
data: CorpusGraph | undefined;
selectedId: string | null;
onNodeClick: (node: GraphNode) => void;
colorBy?: ColorBy;
sizeBy?: SizeBy;
}) {
const { ref, size } = useElementSize();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const fgRef = useRef(null);
const [hoverId, setHoverId] = useState(null);
// Fresh objects each time `data` changes so force-graph can attach x/y
// without mutating the TanStack Query cache.
const graphData = useMemo(() => {
if (!data) return { nodes: [] as FGNode[], links: [] as FGLink[] };
return {
nodes: data.nodes.map((n) => ({ ...n })) as FGNode[],
links: data.edges.map((e) => ({
source: e.source,
target: e.target,
type: e.type,
treatment: e.treatment,
})) as FGLink[],
};
}, [data]);
// Adjacency for hover/selection highlighting (computed once per data change).
const adjacency = useMemo(() => {
const map = new Map>();
for (const e of graphData.links) {
if (!map.has(e.source)) map.set(e.source, new Set());
if (!map.has(e.target)) map.set(e.target, new Set());
map.get(e.source)!.add(e.target);
map.get(e.target)!.add(e.source);
}
return map;
}, [graphData]);
const activeId = hoverId ?? selectedId;
const activeNeighbors = activeId ? adjacency.get(activeId) : undefined;
const isDimmed = useCallback(
(id: string) => {
if (!activeId) return false;
if (id === activeId) return false;
return !(activeNeighbors && activeNeighbors.has(id));
},
[activeId, activeNeighbors],
);
// Zoom-to-fit once physics settle.
const handleEngineStop = useCallback(() => {
fgRef.current?.zoomToFit?.(400, 60);
}, []);
// Spread nodes out so labels have room — stronger repulsion + longer links
// than the d3-force defaults (charge -30, link 30). Re-applied whenever the
// graph data changes (full ↔ neighborhood).
useEffect(() => {
const fg = fgRef.current;
if (!fg?.d3Force) return;
fg.d3Force("charge")?.strength?.(-220);
fg.d3Force("link")?.distance?.(60);
fg.d3ReheatSimulation?.();
}, [graphData]);
const drawNode = useCallback(
(node: FGNode, ctx: CanvasRenderingContext2D, globalScale: number) => {
const r = radiusForNode(node, sizeBy);
const dimmed = isDimmed(node.id);
const isGap = node.type === "gap";
const color = colorForNode(node, colorBy);
ctx.globalAlpha = dimmed ? 0.18 : isGap ? 0.55 : 1;
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, r, 0, 2 * Math.PI);
if (isGap) {
// Hollow dashed circle — a ruling cited but absent from the corpus.
ctx.setLineDash([3 / globalScale, 2 / globalScale]);
ctx.lineWidth = 1.3 / globalScale;
ctx.strokeStyle = NODE_COLORS.gap;
ctx.stroke();
ctx.setLineDash([]);
} else {
ctx.fillStyle = color;
ctx.fill();
}
if (node.id === activeId) {
ctx.lineWidth = 2 / globalScale;
ctx.strokeStyle = "#a97d3a";
ctx.stroke();
}
// Labels — gated by zoom so they don't pile up. At overview zoom only a
// few show (active node, the 3 practice-area hubs, the most-cited
// precedents); zooming in reveals the rest, by which point there is pixel
// room between nodes. Font is kept at a ~constant SCREEN size (px /
// globalScale) instead of growing as you zoom out — that growth was what
// made labels collide.
const isTopicHub = node.type === "topic";
const isAreaHub = node.type === "practice_area";
const showLabel =
!dimmed &&
(node.id === activeId ||
isAreaHub ||
globalScale >= 1.5 ||
(isTopicHub && globalScale >= 1.05) ||
(!isTopicHub && node.size >= 4 && globalScale >= 0.9));
if (showLabel && node.label) {
const fontPx = isTopicHub || isAreaHub ? 12 : 11;
const fontSize = fontPx / globalScale; // ~constant on screen
ctx.font = `${fontSize}px Heebo, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.direction = "rtl";
const label =
node.label.length > 22 ? `${node.label.slice(0, 21)}…` : node.label;
const lx = node.x ?? 0;
const ly = (node.y ?? 0) + r + 2 / globalScale;
// White halo so the text stays readable over edges and nearby nodes.
ctx.lineWidth = 3 / globalScale;
ctx.strokeStyle = "rgba(255,255,255,0.92)";
ctx.lineJoin = "round";
ctx.strokeText(label, lx, ly);
ctx.fillStyle = isTopicHub || isAreaHub ? "#7a5a26" : "#1a1a2e";
ctx.fillText(label, lx, ly);
}
ctx.globalAlpha = 1;
},
[activeId, isDimmed, colorBy, sizeBy],
);
const drawPointerArea = useCallback(
(node: FGNode, color: string, ctx: CanvasRenderingContext2D) => {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x ?? 0, node.y ?? 0, radiusForNode(node, sizeBy) + 2, 0, 2 * Math.PI);
ctx.fill();
},
[sizeBy],
);
const linkColor = useCallback(
(link: FGLink) => {
const s = typeof link.source === "object" ? (link.source as FGNode).id : link.source;
const t = typeof link.target === "object" ? (link.target as FGNode).id : link.target;
const active = activeId && (s === activeId || t === activeId);
if (active) return "rgba(169,125,58,0.85)";
if (activeId) return "rgba(120,130,150,0.06)";
if (link.treatment && TREATMENT_COLORS[link.treatment]) {
return TREATMENT_COLORS[link.treatment];
}
if (link.type === "tagged" || link.type === "in_area") {
return "rgba(169,125,58,0.16)";
}
return "rgba(80,90,110,0.22)";
},
[activeId],
);
if (!size.width && !data) {
return ;
}
return (
{size.width > 0 && (
n.label}
nodeCanvasObject={drawNode}
nodePointerAreaPaint={drawPointerArea}
linkColor={linkColor}
linkWidth={(l: FGLink) => {
const s = typeof l.source === "object" ? (l.source as FGNode).id : l.source;
const t = typeof l.target === "object" ? (l.target as FGNode).id : l.target;
return activeId && (s === activeId || t === activeId) ? 1.6 : 0.6;
}}
linkDirectionalArrowLength={(l: FGLink) => (l.type === "cites" ? 2.4 : 0)}
linkDirectionalArrowRelPos={1}
onNodeClick={(n: FGNode) => onNodeClick(n)}
onNodeHover={(n: FGNode | null) => setHoverId(n?.id ?? null)}
onEngineStop={handleEngineStop}
cooldownTicks={120}
warmupTicks={20}
/>
)}
);
}