import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject, } from "react"; import { Link, useNavigate } from "react-router-dom"; import { notebooksApi, topicsApi } from "../api/endpoints"; import type { Job, TopicDetail, TopicMap as TopicMapData, TopicNode } from "../api/types"; import { Reader } from "../components/Reader"; import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader, Tabs } from "../components/ui"; import { useJob } from "../hooks/useJob"; import { playClick } from "../lib/sound"; // Virtual canvas. The layout runs in open space and is then fitted into this box with a margin. const W = 1200; const H = 780; const PAD = 90; type P = { id: string; x: number; y: number; vx: number; vy: number; r: number; galaxy: number }; type View = { x: number; y: number; w: number; h: number }; type ColorMode = "recency" | "momentum"; const HOME: View = { x: 0, y: 0, w: W, h: H }; const MIN_W = W / 8; const MAX_W = W * 1.6; // Deterministic pseudo random, so the map looks the same on every visit. function seeded(n: number) { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x); } /** Force layout with generous spacing: strong repulsion (room for labels), long constellation lines, * a pull toward each galaxy's centre so related topics cluster, then fitted to the canvas. */ function layout(data: TopicMapData): Map { const maxCount = Math.max(1, ...data.nodes.map((n) => n.post_count)); const nodes: P[] = data.nodes.map((n, i) => { const a = i * 2.39996; const rad = 80 + Math.sqrt(i) * 70; return { id: n.id, x: Math.cos(a) * rad, y: Math.sin(a) * rad, vx: 0, vy: 0, r: 5 + 20 * Math.sqrt(n.post_count / maxCount), galaxy: n.galaxy, }; }); const byId = new Map(nodes.map((n) => [n.id, n])); const maxW = Math.max(1, ...data.edges.map((e) => e.weight)); const STEPS = 420; for (let step = 0; step < STEPS; step++) { const cool = 1 - step / STEPS; for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const a = nodes[i]; const b = nodes[j]; let dx = a.x - b.x; let dy = a.y - b.y; let d2 = dx * dx + dy * dy; if (d2 < 0.01) { dx = 0.1; dy = 0.1; d2 = 0.02; } const minD = a.r + b.r + 90; // room for two labels side by side const k = a.galaxy === b.galaxy ? 7000 : 11000; // other galaxies push harder, leaving voids between them const f = (k / d2) * (d2 < minD * minD ? 3 : 1); const d = Math.sqrt(d2); a.vx += (dx / d) * f; a.vy += (dy / d) * f; b.vx -= (dx / d) * f; b.vy -= (dy / d) * f; } } for (const e of data.edges) { const a = byId.get(e.source); const b = byId.get(e.target); if (!a || !b) continue; const dx = b.x - a.x; const dy = b.y - a.y; const d = Math.sqrt(dx * dx + dy * dy) || 1; const target = 190 - 70 * (e.weight / maxW); const f = (d - target) * 0.012 * (0.4 + e.weight / maxW); a.vx += (dx / d) * f; a.vy += (dy / d) * f; b.vx -= (dx / d) * f; b.vy -= (dy / d) * f; } // Galaxy cohesion. const centers = new Map(); for (const n of nodes) { const c = centers.get(n.galaxy) ?? { x: 0, y: 0, n: 0 }; c.x += n.x; c.y += n.y; c.n += 1; centers.set(n.galaxy, c); } for (const n of nodes) { const c = centers.get(n.galaxy)!; if (c.n > 1) { n.vx += (c.x / c.n - n.x) * 0.006; n.vy += (c.y / c.n - n.y) * 0.006; } n.vx += -n.x * 0.0012; // gentle gravity toward the origin n.vy += -n.y * 0.0012; n.x += Math.max(-16, Math.min(16, n.vx)) * cool; n.y += Math.max(-16, Math.min(16, n.vy)) * cool; n.vx *= 0.6; n.vy *= 0.6; } } // Fit into the canvas with a margin, keeping proportions; never enlarge a small map past 1.4x. const xs = nodes.map((n) => n.x); const ys = nodes.map((n) => n.y); const minX = Math.min(...xs); const maxX = Math.max(...xs); const minY = Math.min(...ys); const maxY = Math.max(...ys); const s = Math.min((W - PAD * 2) / Math.max(1, maxX - minX), (H - PAD * 2) / Math.max(1, maxY - minY), 1.4); const ox = (W - (maxX - minX) * s) / 2 - minX * s; const oy = (H - (maxY - minY) * s) / 2 - minY * s; for (const n of nodes) { n.x = n.x * s + ox; n.y = n.y * s + oy; } return byId; } /** Wheel to zoom around the cursor, drag to pan, buttons and keys for the rest. */ function useZoomPan(svgRef: RefObject) { const [view, setView] = useState(HOME); const drag = useRef<{ px: number; py: number; view: View; moved: boolean } | null>(null); const toSvg = useCallback( (clientX: number, clientY: number, v: View) => { const rect = svgRef.current!.getBoundingClientRect(); return { x: v.x + ((clientX - rect.left) / rect.width) * v.w, y: v.y + ((clientY - rect.top) / rect.height) * v.h }; }, [svgRef], ); const scaleView = (v: View, factor: number, fx: number, fy: number): View => { const w = Math.max(MIN_W, Math.min(MAX_W, v.w * factor)); const h = (w / W) * H; return { x: fx - ((fx - v.x) * w) / v.w, y: fy - ((fy - v.y) * h) / v.h, w, h }; }; const zoomAt = useCallback((factor: number, cx?: number, cy?: number) => { setView((v) => scaleView(v, factor, cx ?? v.x + v.w / 2, cy ?? v.y + v.h / 2)); }, []); useEffect(() => { const el = svgRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); setView((v) => { const p = toSvg(e.clientX, e.clientY, v); return scaleView(v, Math.exp(Math.max(-0.4, Math.min(0.4, e.deltaY * 0.0015))), p.x, p.y); }); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }); const onPointerDown = (e: ReactPointerEvent) => { if ((e.target as Element).closest(".map-node, .nebula")) return; drag.current = { px: e.clientX, py: e.clientY, view, moved: false }; svgRef.current?.setPointerCapture(e.pointerId); }; const onPointerMove = (e: ReactPointerEvent) => { const d = drag.current; if (!d || !svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); const dx = ((e.clientX - d.px) / rect.width) * d.view.w; const dy = ((e.clientY - d.py) / rect.height) * d.view.h; if (Math.abs(dx) + Math.abs(dy) > 2) d.moved = true; setView({ ...d.view, x: d.view.x - dx, y: d.view.y - dy }); }; const onPointerUp = (e: ReactPointerEvent) => { drag.current = null; if (svgRef.current?.hasPointerCapture(e.pointerId)) svgRef.current.releasePointerCapture(e.pointerId); }; const onDoubleClick = (e: ReactMouseEvent) => { if ((e.target as Element).closest(".map-node")) return; const p = toSvg(e.clientX, e.clientY, view); zoomAt(0.6, p.x, p.y); }; const pan = (dx: number, dy: number) => setView((v) => ({ ...v, x: v.x + dx * v.w, y: v.y + dy * v.h })); const reset = () => setView(HOME); const focusOn = (x: number, y: number, width = W / 2) => setView(() => { const w = Math.max(MIN_W, Math.min(W, width)); const h = (w / W) * H; return { x: x - w / 2, y: y - h / 2, w, h }; }); return { view, zoomAt, pan, reset, focusOn, handlers: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel: onPointerUp, onDoubleClick }, }; } // Small pieces function Sparkline({ values, height = 36, highlightFrom }: { values: number[]; height?: number; highlightFrom?: number }) { const max = Math.max(1, ...values); const bw = 100 / values.length; return ( ); } const STATUS_LABEL: Record = { rising: "Rising", steady: "Steady orbit", dormant: "Dormant", }; function momentumText(m: number) { if (!Number.isFinite(m) || m === 0) return "no recent posts"; return m >= 1 ? `${m.toFixed(1)}x its usual share lately` : `${Math.round(m * 100)}% of its usual share lately`; } /** Deep field: faint background stars plus survey rings, drawn once. */ function DeepField() { const stars = useMemo( () => Array.from({ length: 260 }, (_, i) => ({ x: seeded(i + 1) * W, y: seeded(i + 1000) * H, r: 0.3 + seeded(i + 2000) * 1.1, o: 0.15 + seeded(i + 3000) * 0.5, })), [], ); return ( ); } export default function TopicMap() { const [data, setData] = useState(null); const [job, setJob] = useState(null); const [selected, setSelected] = useState(null); const [hover, setHover] = useState(null); const [mode, setMode] = useState("recency"); const [reading, setReading] = useState(null); const [error, setError] = useState(null); const navigate = useNavigate(); const svgRef = useRef(null); const { view, zoomAt, pan, reset, focusOn, handlers } = useZoomPan(svgRef); const load = () => topicsApi.map().then(setData, () => setError("Could not load the topic map.")); useEffect(() => { load(); }, []); const live = useJob(job, (j) => { if (j.status === "done") load(); }); const positions = useMemo(() => (data?.nodes.length ? layout(data) : new Map()), [data]); const byId = useMemo(() => new Map((data?.nodes ?? []).map((n) => [n.id, n])), [data]); const neighbors = useMemo(() => { const m = new Map>(); data?.edges.forEach((e) => { if (!m.has(e.source)) m.set(e.source, new Set()); if (!m.has(e.target)) m.set(e.target, new Set()); m.get(e.source)!.add(e.target); m.get(e.target)!.add(e.source); }); return m; }, [data]); // Nebula for each galaxy with more than one star: centred on its members, sized to cover them. const nebulae = useMemo(() => { if (!data) return []; return data.galaxies .filter((g) => g.size > 1) .map((g) => { const pts = g.topic_ids.map((id) => positions.get(id)).filter(Boolean) as P[]; const cx = pts.reduce((s, p) => s + p.x, 0) / Math.max(pts.length, 1); const cy = pts.reduce((s, p) => s + p.y, 0) / Math.max(pts.length, 1); const rx = Math.max(90, ...pts.map((p) => Math.abs(p.x - cx) + p.r)) + 50; const ry = Math.max(80, ...pts.map((p) => Math.abs(p.y - cy) + p.r)) + 45; return { ...g, cx, cy, rx, ry }; }); }, [data, positions]); const brightest = useMemo( () => new Set((data?.nodes ?? []).slice().sort((a, b) => b.post_count - a.post_count).slice(0, 3).map((n) => n.id)), [data], ); const maxEdge = Math.max(1, ...(data?.edges ?? []).map((e) => e.weight)); const recentBucket = useMemo(() => { const a = data?.archive; if (!a?.start || !a.end || !a.recent_from) return undefined; const span = +new Date(a.end) - +new Date(a.start) || 1; return Math.floor(((+new Date(a.recent_from) - +new Date(a.start)) / span) * 12); }, [data]); const focus = hover ?? selected?.id ?? null; const running = live && (live.status === "queued" || live.status === "running"); const scale = view.w / W; // keeps labels and strokes a constant size on screen const zoomedIn = scale < 0.75; const hovered = hover ? byId.get(hover) : null; const rebuild = async (full: boolean) => { setError(null); try { setJob(await topicsApi.rebuild(full)); } catch (e) { setError(errorMessage(e)); } }; const open = async (id: string, center = false) => { playClick(); if (center) { const p = positions.get(id); if (p) focusOn(p.x, p.y); } setSelected(await topicsApi.get(id)); }; const flyToGalaxy = (gid: number) => { const n = nebulae.find((g) => g.id === gid); if (n) focusOn(n.cx, n.cy, Math.max(n.rx * 2.6, (n.ry * 2.6 * W) / H)); }; const starColor = (n: TopicNode) => { if (mode === "momentum") return n.status === "rising" ? "#ffffff" : n.status === "dormant" ? "rgba(255,255,255,0.45)" : "#217cff"; // recency: fresh topics burn white, old ones cool toward dim blue return n.recency > 0.75 ? "#ffffff" : n.recency > 0.4 ? "#9cc4ff" : "#217cff"; }; const starGlow = (n: TopicNode) => mode === "momentum" ? (n.status === "rising" ? 1 : n.status === "dormant" ? 0.25 : 0.55) : 0.25 + 0.75 * n.recency; const galaxyCount = data ? data.galaxies.filter((g) => g.size > 1).length : 0; return (
tabs={[ { id: "recency", label: "Recency" }, { id: "momentum", label: "Momentum" }, ]} value={mode} onChange={setMode} /> {live && (running || live.status === "failed") && } {error &&

{error}

} {!data && } {data && data.nodes.length === 0 && !running && ( rebuild(false)}> Map my archive } /> )} {data && data.nodes.length > 0 && (
{hovered ? ( <> {hovered.name} {hovered.post_count} posts · last {formatDate(hovered.last_at)} · {STATUS_LABEL[hovered.status]} ) : ( {data.nodes.length} stars · {galaxyCount} galaxies · scroll to zoom, drag to pan )}
size = posts {mode === "recency" ? ( brighter = written about recently ) : ( <> rising dormant )} lines = shared posts
{ const step = 0.12; if (e.key === "+" || e.key === "=") zoomAt(0.8); else if (e.key === "-") zoomAt(1.25); else if (e.key === "0") reset(); else if (e.key === "ArrowLeft") pan(-step, 0); else if (e.key === "ArrowRight") pan(step, 0); else if (e.key === "ArrowUp") pan(0, -step); else if (e.key === "ArrowDown") pan(0, step); else return; e.preventDefault(); }} > {nebulae.map((g) => ( flyToGalaxy(g.id)}> {g.name.toUpperCase()} GALAXY ))} {data.edges.map((e) => { const a = positions.get(e.source); const b = positions.get(e.target); if (!a || !b) return null; const lit = focus && (e.source === focus || e.target === focus); const strength = e.weight / maxEdge; return ( ); })} {data.nodes.map((n) => { const p = positions.get(n.id)!; const dim = focus && focus !== n.id && !neighbors.get(focus)?.has(n.id); const active = selected?.id === n.id; const color = starColor(n); const glow = starGlow(n); const showLabel = zoomedIn || brightest.has(n.id) || n.post_count >= 3 || focus === n.id || active || Boolean(focus && neighbors.get(focus)?.has(n.id)); const tail = mode === "momentum" && n.status === "rising" ? Math.min(90, 30 + n.momentum * 18) : 0; return ( setHover(n.id)} onMouseLeave={() => setHover(null)} onClick={() => open(n.id)} onDoubleClick={() => open(n.id, true)} tabIndex={0} role="button" aria-label={`${n.name}, ${n.post_count} posts, ${STATUS_LABEL[n.status]}`} onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && open(n.id)} > {tail > 0 && ( )} {brightest.has(n.id) && ( )} {mode === "momentum" && n.status === "dormant" && ( )} {active && } {showLabel && ( {n.name} {(zoomedIn || active) && ( {n.post_count} )} )} ); })}
)} {reading && setReading(null)} />}
); }