"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) digest: "#2f6f7a", // teal — daily-digest (יומון) discovery layer }; 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 (n.type === "digest") return 4; 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)"; } if (link.type === "covers") { return "rgba(47,111,122,0.45)"; // teal — digest → ruling } if (link.type === "extracted_from") { return "rgba(180,83,9,0.22)"; // faint amber — rule → its precedent } if (link.type === "corroborates") { return "rgba(180,83,9,0.55)"; // amber — ruling cites this rule } if (link.type === "equivalent") { return "rgba(124,58,173,0.45)"; // violet — same principle, parallel } 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} /> )}
); }