import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject } from "react"; import { useNavigate } from "react-router-dom"; import { notebooksApi, topicsApi } from "../api/endpoints"; import type { Job, TopicDetail, TopicMap as TopicMapData } from "../api/types"; import { Reader } from "../components/Reader"; import { EmptyState, errorMessage, formatDate, JobProgress, Loading, PageHeader } from "../components/ui"; import { useJob } from "../hooks/useJob"; import { playClick } from "../lib/sound"; const W = 900; const H = 620; type P = { id: string; x: number; y: number; vx: number; vy: number; r: number }; type View = { x: number; y: number; w: number; h: number }; const HOME: View = { x: 0, y: 0, w: W, h: H }; const MIN_W = W / 8; const MAX_W = W * 1.6; /** 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; // Keep the focus point fixed on screen while scaling. 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")) 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) => setView((v) => { const w = Math.min(v.w, W / 2); 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 deterministic force layout: repulsion between all nodes, springs along edges, pull to center. */ 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 / Math.max(1, data.nodes.length)) * Math.PI * 2 * 2.39996; const rad = 60 + (i % 7) * 30; return { id: n.id, x: W / 2 + Math.cos(a) * rad, y: H / 2 + Math.sin(a) * rad, vx: 0, vy: 0, r: 6 + 22 * Math.sqrt(n.post_count / maxCount) }; }); const byId = new Map(nodes.map((n) => [n.id, n])); const maxW = Math.max(1, ...data.edges.map((e) => e.weight)); for (let step = 0; step < 320; step++) { const cool = 1 - step / 320; 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 + 26; const f = (2400 / 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 = 110 - 40 * (e.weight / maxW); const f = (d - target) * 0.02 * (0.5 + e.weight / maxW); a.vx += (dx / d) * f; a.vy += (dy / d) * f; b.vx -= (dx / d) * f; b.vy -= (dy / d) * f; } for (const n of nodes) { n.vx += (W / 2 - n.x) * 0.004; n.vy += (H / 2 - n.y) * 0.004; n.x += Math.max(-12, Math.min(12, n.vx)) * cool; n.y += Math.max(-12, Math.min(12, n.vy)) * cool; n.x = Math.max(n.r + 10, Math.min(W - n.r - 10, n.x)); n.y = Math.max(n.r + 10, Math.min(H - n.r - 10, n.y)); n.vx *= 0.6; n.vy *= 0.6; } } return byId; } 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 [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 ? layout(data) : new Map()), [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]); const focus = hover ?? selected?.id ?? null; const running = live && (live.status === "queued" || live.status === "running"); 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 scale = view.w / W; // keeps labels and strokes a constant size on screen return (
{live && (running || live.status === "failed") && } {error &&

{error}

} {!data && } {data && data.nodes.length === 0 && !running && ( rebuild(false)}> Map my archive } /> )} {data && data.nodes.length > 0 && (

Scroll to zoom · drag to pan · double click to dive in

{ 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(); }} > {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); 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; 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`} onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && open(n.id)} > {n.name} ); })}
)} {reading && setReading(null)} />}
); }