SPB Git
7.2 KB · 61 lines tsx
1"use client";2import { useEffect, useMemo, useRef, useState } from "react";3import { useRouter } from "next/navigation";4import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, type SimulationLinkDatum, type SimulationNodeDatum } from "d3-force";5import { href, t, type Locale } from "@/lib/i18n";67export type GNode = { slug: string; identifier: string; title: string; year: number | null; domain: string | null; accent: string | null; views: number };8export type GEdge = { source: string; target: string; kind: string; weight: number; inferred: boolean; explanation: string | null; explanation_fr: string | null };9type N = GNode & SimulationNodeDatum; type L = SimulationLinkDatum<N> & { kind: string; weight: number; inferred: boolean; explanation: string | null; explanation_fr: string | null };10const KIND_COLOR: Record<string, string> = { topic: "#1f3a5f", method: "#2d4a3e", dataset: "#6b4e16", author: "#5c4b6b", semantic: "#6b2737", lexical: "#8a6d7a", curated: "#9b2c2c" };11/** Deterministic pseudo-random for stable initial positions (same layout on every load). */12function 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; }1314export function Constellation({ locale, nodes, edges, height = 520, compact = false, kinds: initialKinds }: { locale: Locale; nodes: GNode[]; edges: GEdge[]; height?: number; compact?: boolean; kinds?: string[] }) {15  const d = t(locale); const router = useRouter(); const ref = useRef<SVGSVGElement>(null);16  const allKinds = useMemo(() => [...new Set(edges.map((e) => e.kind))], [edges]);17  const [kinds, setKinds] = useState<Set<string>>(new Set(initialKinds ?? allKinds));18  const [pos, setPos] = useState<{ nodes: N[]; links: L[] } | null>(null);19  const [hover, setHover] = useState<string | null>(null); const [sel, setSel] = useState<L | null>(null);20  const [view, setView] = useState({ x: 0, y: 0, k: 1 }); const drag = useRef<{ x: number; y: number; vx: number; vy: number } | null>(null);21  const W = 960, H = height;22  useEffect(() => {23    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 }));24    const idx = new Map(ns.map((n) => [n.slug, n]));25    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 }));26    const sim = forceSimulation(ns).force("link", forceLink<N, L>(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))27      .force("charge", forceManyBody().strength(-220)).force("center", forceCenter(W / 2, H / 2)).force("collide", forceCollide(22)).stop();28    for (let i = 0; i < 300; i++) sim.tick();29    setPos({ nodes: ns, links: ls });30  }, [nodes, edges, kinds, H]);31  const neighbours = useMemo(() => { const m = new Map<string, Set<string>>(); 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]);32  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))) })); };33  const onDown = (e: React.PointerEvent) => { drag.current = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y }; };34  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) })); };35  const onUp = () => { drag.current = null; };36  return (37    <div className="card overflow-hidden">38      {!compact && (39        <div className="flex flex-wrap items-center gap-2 p-3 border-b border-line text-xs">40          <span className="label mr-1">{d.map.filter}</span>41          {allKinds.map((k) => <label key={k} className="chip cursor-pointer select-none"><input type="checkbox" className="mr-1 accent-[var(--navy)]" checked={kinds.has(k)} onChange={(e) => setKinds((s) => { const n = new Set(s); if (e.target.checked) n.add(k); else n.delete(k); return n; })} /><span className="inline-block w-2.5 h-2.5 rounded-full mr-1" style={{ background: KIND_COLOR[k] ?? "#888" }} />{(d.map as Record<string, string>)[k] ?? k}</label>)}42          <span className="ml-auto text-fg-3">{nodes.length} {d.map.nodes} · {pos?.links.length ?? 0} {d.map.edges}</span>43          <button type="button" className="btn btn-sm" onClick={() => setView({ x: 0, y: 0, k: 1 })}>{d.map.reset}</button>44        </div>45      )}46      <svg ref={ref} viewBox={`0 0 ${W} ${H}`} role="img" aria-label={d.map.title} className="w-full touch-none select-none bg-bg-2" style={{ height: compact ? 420 : "min(70vh, 640px)" }} onWheel={onWheel} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={onUp} onPointerLeave={onUp}>47        <g transform={`translate(${view.x},${view.y}) scale(${view.k})`}>48          {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 <line key={i} x1={s.x} y1={s.y} x2={tt.x} y2={tt.y} stroke={KIND_COLOR[l.kind] ?? "#888"} strokeOpacity={dim ? 0.08 : 0.35 + Math.min(1, l.weight) * 0.4} strokeWidth={0.8 + Math.min(1, l.weight) * 1.6} strokeDasharray={l.inferred ? "4 3" : undefined} onClick={() => setSel(l)} className="cursor-pointer" />; })}49          {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 (50            <g key={n.slug} transform={`translate(${n.x},${n.y})`} opacity={dim ? 0.25 : 1} className="cursor-pointer" onMouseEnter={() => 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}`)); }}>51              <circle r={r} fill={n.accent ?? "#1f3a5f"} stroke="var(--bg-2)" strokeWidth={1.5} />52              <text y={r + 11} textAnchor="middle" fontSize={10} fill="var(--fg-2)" fontFamily="var(--font-mono)">{n.identifier}</text>53              {hover === n.slug && <text y={-r - 6} textAnchor="middle" fontSize={11} fill="var(--fg)" style={{ paintOrder: "stroke", stroke: "var(--bg-2)", strokeWidth: 3 }}>{n.title.length > 70 ? n.title.slice(0, 68) + "…" : n.title}</text>}54            </g>); })}55        </g>56      </svg>57      {sel && <div className="p-3 border-t border-line text-sm flex gap-3 items-start"><span className="inline-block mt-1 w-2.5 h-2.5 rounded-full shrink-0" style={{ background: KIND_COLOR[sel.kind] }} /><span className="text-fg-2">{(sel.source as N).identifier} ↔ {(sel.target as N).identifier} — {locale === "fr" ? sel.explanation_fr ?? sel.explanation : sel.explanation}{sel.inferred ? ` (${d.paper.inferred})` : ""}</span><button type="button" className="ml-auto btn btn-sm" onClick={() => setSel(null)}>×</button></div>}58    </div>59  );60}61
Files · ba542514eac709a3e76ac3b3f88404c61356d594
Menu

Theme