"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from "d3-force"; import { href, t, type Locale } from "@/lib/i18n"; export type GNode = { slug: string; identifier: string; title: string; year: number | null; domain: string | null; accent: string | null; views: number }; export type GEdge = { source: string; target: string; kind: string; weight: number; inferred: boolean; explanation: string | null; explanation_fr: string | null }; type N = GNode & SimulationNodeDatum; type L = SimulationLinkDatum & { kind: string; weight: number; inferred: boolean; explanation: string | null; explanation_fr: string | null }; const KIND_COLOR: Record = { topic: "#1f3a5f", method: "#2d4a3e", dataset: "#6b4e16", author: "#5c4b6b", semantic: "#6b2737", lexical: "#8a6d7a", curated: "#9b2c2c" }; /** Deterministic pseudo-random for stable initial positions (same layout on every load). */ function seeded(s: string) { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return (h % 10000) / 10000; } export function Constellation({ locale, nodes, edges, height = 520, compact = false, kinds: initialKinds }: { locale: Locale; nodes: GNode[]; edges: GEdge[]; height?: number; compact?: boolean; kinds?: string[] }) { const d = t(locale); const router = useRouter(); const ref = useRef(null); const allKinds = useMemo(() => [...new Set(edges.map((e) => e.kind))], [edges]); const [kinds, setKinds] = useState>(new Set(initialKinds ?? allKinds)); const [pos, setPos] = useState<{ nodes: N[]; links: L[] } | null>(null); const [hover, setHover] = useState(null); const [sel, setSel] = useState(null); const [view, setView] = useState({ x: 0, y: 0, k: 1 }); const drag = useRef<{ x: number; y: number; vx: number; vy: number } | null>(null); const W = 960, H = height; useEffect(() => { const ns: N[] = nodes.map((n) => ({ ...n, x: W / 2 + (seeded(n.slug) - 0.5) * W * 0.8, y: H / 2 + (seeded(n.slug + "y") - 0.5) * H * 0.8 })); const idx = new Map(ns.map((n) => [n.slug, n])); const ls: L[] = edges.filter((e) => kinds.has(e.kind) && idx.has(e.source) && idx.has(e.target)).map((e) => ({ source: idx.get(e.source)!, target: idx.get(e.target)!, kind: e.kind, weight: e.weight, inferred: e.inferred, explanation: e.explanation, explanation_fr: e.explanation_fr })); const sim = forceSimulation(ns).force("link", forceLink(ls).id((n) => n.slug).distance((l) => 70 + (1 - Math.min(1, l.weight)) * 90).strength((l) => 0.2 + Math.min(1, l.weight) * 0.5)) .force("charge", forceManyBody().strength(-220)).force("center", forceCenter(W / 2, H / 2)).force("collide", forceCollide(22)).stop(); for (let i = 0; i < 300; i++) sim.tick(); setPos({ nodes: ns, links: ls }); }, [nodes, edges, kinds, H]); const neighbours = useMemo(() => { const m = new Map>(); pos?.links.forEach((l) => { const s = (l.source as N).slug, tt = (l.target as N).slug; (m.get(s) ?? m.set(s, new Set()).get(s)!).add(tt); (m.get(tt) ?? m.set(tt, new Set()).get(tt)!).add(s); }); return m; }, [pos]); const onWheel = (e: React.WheelEvent) => { e.preventDefault(); setView((v) => ({ ...v, k: Math.min(4, Math.max(0.4, v.k * (e.deltaY < 0 ? 1.1 : 0.9))) })); }; const onDown = (e: React.PointerEvent) => { drag.current = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y }; }; const onMove = (e: React.PointerEvent) => { if (!drag.current) return; setView((v) => ({ ...v, x: drag.current!.vx + (e.clientX - drag.current!.x), y: drag.current!.vy + (e.clientY - drag.current!.y) })); }; const onUp = () => { drag.current = null; }; return (
{!compact && (
{d.map.filter} {allKinds.map((k) => )} {nodes.length} {d.map.nodes} · {pos?.links.length ?? 0} {d.map.edges}
)} {pos?.links.map((l, i) => { const s = l.source as N, tt = l.target as N; const dim = hover && hover !== s.slug && hover !== tt.slug; return setSel(l)} className="cursor-pointer" />; })} {pos?.nodes.map((n) => { const dim = hover && hover !== n.slug && !neighbours.get(hover)?.has(n.slug); const r = 7 + Math.min(6, Math.log1p(n.views)); return ( setHover(n.slug)} onMouseLeave={() => setHover(null)} onClick={() => router.push(href(locale, `/papers/${n.slug}`))} tabIndex={0} role="link" aria-label={`${n.identifier}: ${n.title}`} onKeyDown={(e) => { if (e.key === "Enter") router.push(href(locale, `/papers/${n.slug}`)); }}> {n.identifier} {hover === n.slug && {n.title.length > 70 ? n.title.slice(0, 68) + "…" : n.title}} ); })} {sel &&
{(sel.source as N).identifier} ↔ {(sel.target as N).identifier} — {locale === "fr" ? sel.explanation_fr ?? sel.explanation : sel.explanation}{sel.inferred ? ` (${d.paper.inferred})` : ""}
}
); }