Spaces:
Runtime error
Runtime error
Download frontend/src/pages/TopicMap.tsx from firebird-technologies/NoteStack-Test: direct link, hf CLI and curl.
- Browser
- Download file 37.6 kB
-
https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/main/frontend/src/pages/TopicMap.tsx
- Command line
-
hf download hf://spaces/firebird-technologies/NoteStack-Test/frontend/src/pages/TopicMap.tsx
-
curl -L -o TopicMap.tsx https://huggingface.co/spaces/firebird-technologies/NoteStack-Test/resolve/main/frontend/src/pages/TopicMap.tsx
37.6 kB
| 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 { artifactsApi, notebooksApi, topicsApi } from "../api/endpoints"; | |
| import { SourcesNav } from "./Sources"; | |
| 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 PINS_KEY = "notestack.topicmap.pins"; | |
| type Pins = Record<string, { x: number; y: number }>; | |
| function readPins(ids: Set<string>): Pins { | |
| try { | |
| const all = JSON.parse(localStorage.getItem(PINS_KEY) || "{}") as Pins; | |
| return Object.fromEntries(Object.entries(all).filter(([id]) => ids.has(id))); | |
| } catch { | |
| return {}; | |
| } | |
| } | |
| function writePins(pins: Pins) { | |
| try { | |
| localStorage.setItem(PINS_KEY, JSON.stringify(pins)); | |
| } catch { | |
| // Storage blocked: positions just last for this visit. | |
| } | |
| } | |
| 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<string, P> { | |
| 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<number, { x: number; y: number; n: number }>(); | |
| 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<SVGSVGElement>) { | |
| const [view, setView] = useState<View>(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<SVGSVGElement>) => { | |
| 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<SVGSVGElement>) => { | |
| 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<SVGSVGElement>) => { | |
| drag.current = null; | |
| if (svgRef.current?.hasPointerCapture(e.pointerId)) svgRef.current.releasePointerCapture(e.pointerId); | |
| }; | |
| const onDoubleClick = (e: ReactMouseEvent<SVGSVGElement>) => { | |
| 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, | |
| toSvg, | |
| 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 ( | |
| <svg className="sparkline" viewBox={`0 0 100 ${height}`} preserveAspectRatio="none" aria-hidden="true"> | |
| {values.map((v, i) => { | |
| const h = v ? Math.max(2, (v / max) * (height - 2)) : 1; | |
| return ( | |
| <rect | |
| key={i} | |
| x={i * bw + bw * 0.15} | |
| y={height - h} | |
| width={bw * 0.7} | |
| height={h} | |
| rx={0.8} | |
| className={highlightFrom !== undefined && i >= highlightFrom ? "recent" : ""} | |
| /> | |
| ); | |
| })} | |
| </svg> | |
| ); | |
| } | |
| const STATUS_LABEL: Record<TopicNode["status"], string> = { | |
| 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 ( | |
| <g className="deep-field" aria-hidden="true"> | |
| {[140, 260, 390, 520].map((r) => ( | |
| <circle key={r} cx={W / 2} cy={H / 2} r={r} className="survey-ring" /> | |
| ))} | |
| <line x1={W / 2} y1={0} x2={W / 2} y2={H} className="survey-axis" /> | |
| <line x1={0} y1={H / 2} x2={W} y2={H / 2} className="survey-axis" /> | |
| {stars.map((s, i) => ( | |
| <circle key={i} cx={s.x} cy={s.y} r={s.r} fill="#ffffff" opacity={s.o} /> | |
| ))} | |
| </g> | |
| ); | |
| } | |
| export default function TopicMap() { | |
| const [data, setData] = useState<TopicMapData | null>(null); | |
| const [job, setJob] = useState<Job | null>(null); | |
| const [selected, setSelected] = useState<TopicDetail | null>(null); | |
| const [briefing, setBriefing] = useState(false); | |
| const [menu, setMenu] = useState(false); | |
| const [acting, setActing] = useState<string | null>(null); | |
| const [hover, setHover] = useState<string | null>(null); | |
| const [mode, setMode] = useState<ColorMode>("recency"); | |
| const [reading, setReading] = useState<string | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const navigate = useNavigate(); | |
| const svgRef = useRef<SVGSVGElement>(null); | |
| const { view, toSvg, zoomAt, pan, reset, focusOn, handlers } = useZoomPan(svgRef); | |
| const [pins, setPins] = useState<Pins>({}); | |
| const [dragging, setDragging] = useState(false); | |
| const justDragged = useRef(false); | |
| 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<string, P>()), [data]); | |
| const placed = useMemo(() => { | |
| const m = new Map<string, P>(); | |
| positions.forEach((p, id) => m.set(id, pins[id] ? { ...p, ...pins[id] } : p)); | |
| return m; | |
| }, [positions, pins]); | |
| useEffect(() => { | |
| if (data) setPins(readPins(new Set(data.nodes.map((n) => n.id)))); | |
| }, [data]); | |
| useEffect(() => { | |
| if (!dragging && data) writePins(pins); | |
| }, [pins, dragging, data]); | |
| const byId = useMemo(() => new Map((data?.nodes ?? []).map((n) => [n.id, n])), [data]); | |
| const neighbors = useMemo(() => { | |
| const m = new Map<string, Set<string>>(); | |
| 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) => placed.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, placed]); | |
| 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 { | |
| if (full) setPins({}); | |
| setJob(await topicsApi.rebuild(full)); | |
| } catch (e) { | |
| setError(errorMessage(e)); | |
| } | |
| }; | |
| const open = async (id: string, center = false) => { | |
| playClick(); | |
| if (center) { | |
| const p = placed.get(id); | |
| if (p) focusOn(p.x, p.y); | |
| } | |
| setSelected(await topicsApi.get(id)); | |
| }; | |
| const startDrag = (e: ReactPointerEvent, ids: string[]) => { | |
| if (e.button !== 0) return; | |
| e.stopPropagation(); | |
| justDragged.current = false; | |
| const v = view; | |
| const origin = toSvg(e.clientX, e.clientY, v); | |
| const start = Object.fromEntries(ids.map((id) => [id, placed.get(id)!]).filter(([, p]) => p)) as Record<string, P>; | |
| let moved = false; | |
| const move = (ev: PointerEvent) => { | |
| const p = toSvg(ev.clientX, ev.clientY, v); | |
| const dx = p.x - origin.x; | |
| const dy = p.y - origin.y; | |
| if (!moved) { | |
| if (Math.hypot(dx, dy) < 4 * (v.w / W)) return; | |
| moved = true; | |
| setDragging(true); | |
| } | |
| setPins((prev) => { | |
| const next = { ...prev }; | |
| for (const [id, s] of Object.entries(start)) next[id] = { x: s.x + dx, y: s.y + dy }; | |
| return next; | |
| }); | |
| }; | |
| const up = () => { | |
| window.removeEventListener("pointermove", move); | |
| window.removeEventListener("pointerup", up); | |
| window.removeEventListener("pointercancel", up); | |
| if (moved) { | |
| justDragged.current = true; | |
| setDragging(false); | |
| } | |
| }; | |
| window.addEventListener("pointermove", move); | |
| window.addEventListener("pointerup", up); | |
| window.addEventListener("pointercancel", up); | |
| }; | |
| /** A click that ends a drag is not a click. */ | |
| const wasDrag = () => { | |
| const d = justDragged.current; | |
| justDragged.current = false; | |
| return d; | |
| }; | |
| const resetPins = () => { | |
| playClick(); | |
| setPins({}); | |
| }; | |
| const hasPins = Object.keys(pins).length > 0; | |
| 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 ( | |
| <div className="page-wrap"> | |
| <SourcesNav /> | |
| <PageHeader eyebrow="Topic map" title="The constellations in your archive"> | |
| <Tabs<ColorMode> | |
| tabs={[ | |
| { id: "recency", label: "Recency" }, | |
| { id: "momentum", label: "Momentum" }, | |
| ]} | |
| value={mode} | |
| onChange={setMode} | |
| /> | |
| {data && data.nodes.length > 0 && ( | |
| <button className={`btn btn-small${briefing ? " btn-primary" : ""}`} onClick={() => setBriefing(!briefing)} aria-pressed={briefing}> | |
| Briefing | |
| </button> | |
| )} | |
| {data?.has_untagged_posts && ( | |
| <button className="btn btn-small" disabled={Boolean(running)} onClick={() => rebuild(false)}> | |
| Map new posts | |
| </button> | |
| )} | |
| <div className="card-menu map-menu"> | |
| <button type="button" className="icon-btn" aria-label="Map options" aria-expanded={menu} onClick={() => setMenu(!menu)}> | |
| ⋯ | |
| </button> | |
| {menu && ( | |
| <div className="card-menu-list" onMouseLeave={() => setMenu(false)}> | |
| <button | |
| type="button" | |
| disabled={Boolean(running)} | |
| onClick={() => { | |
| setMenu(false); | |
| rebuild(true); | |
| }} | |
| > | |
| Rebuild the whole map | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </PageHeader> | |
| {live && (running || live.status === "failed") && <JobProgress job={live} />} | |
| {error && <p className="error-text">{error}</p>} | |
| {!data && <Loading center="page" />} | |
| {data && data.nodes.length === 0 && !running && ( | |
| <EmptyState | |
| title="No constellations yet" | |
| body="Notestack reads each post and names what it is about, then links topics that appear together. New posts are mapped automatically after each sync." | |
| action={ | |
| <button className="btn btn-primary" onClick={() => rebuild(false)}> | |
| Map my archive | |
| </button> | |
| } | |
| /> | |
| )} | |
| {data && data.nodes.length > 0 && ( | |
| <div className={`map-layout${selected || briefing ? "" : " map-full"}`}> | |
| <div className="card map-canvas"> | |
| <div className="map-controls"> | |
| <button className="icon-btn" onClick={() => zoomAt(0.75)} aria-label="Zoom in" title="Zoom in"> | |
| + | |
| </button> | |
| <button className="icon-btn" onClick={() => zoomAt(1.33)} aria-label="Zoom out" title="Zoom out"> | |
| - | |
| </button> | |
| <button className="icon-btn mono" onClick={reset} aria-label="Reset view" title="Reset view"> | |
| 1:1 | |
| </button> | |
| {hasPins && ( | |
| <button className="icon-btn mono" onClick={resetPins} aria-label="Reset star positions" title="Reset star positions"> | |
| ↺ | |
| </button> | |
| )} | |
| </div> | |
| <div className="map-hud mono" aria-live="polite"> | |
| {hovered ? ( | |
| <> | |
| <span className="hud-title">{hovered.name}</span> | |
| <span> | |
| {hovered.post_count} posts · last {formatDate(hovered.last_at)} · {STATUS_LABEL[hovered.status]} | |
| </span> | |
| <Sparkline values={hovered.timeline} height={18} highlightFrom={recentBucket} /> | |
| </> | |
| ) : ( | |
| <span className="muted"> | |
| {data.nodes.length} stars · {galaxyCount} galaxies · scroll to zoom, drag space to pan, drag stars or galaxies to move them | |
| </span> | |
| )} | |
| </div> | |
| <details className="map-legend mono" aria-label="Legend"> | |
| <summary>Legend</summary> | |
| <span> | |
| <i className="lg-size" /> size = posts | |
| </span> | |
| {mode === "recency" ? ( | |
| <span> | |
| <i className="lg-bright" /> brighter = written about recently | |
| </span> | |
| ) : ( | |
| <> | |
| <span> | |
| <i className="lg-tail" /> rising | |
| </span> | |
| <span> | |
| <i className="lg-dormant" /> dormant | |
| </span> | |
| </> | |
| )} | |
| <span> | |
| <i className="lg-line" /> lines = shared posts | |
| </span> | |
| </details> | |
| <svg | |
| ref={svgRef} | |
| viewBox={`${view.x} ${view.y} ${view.w} ${view.h}`} | |
| role="img" | |
| aria-label="Topic constellation. Plus and minus zoom, arrow keys pan, 0 resets." | |
| tabIndex={0} | |
| className={`map-svg${dragging ? " dragging" : ""}`} | |
| style={{ aspectRatio: `${W} / ${H}` }} | |
| {...handlers} | |
| onKeyDown={(e) => { | |
| 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(); | |
| }} | |
| > | |
| <defs> | |
| <radialGradient id="star-glow" cx="50%" cy="50%" r="50%"> | |
| <stop offset="0%" stopColor="#ffffff" stopOpacity="0.9" /> | |
| <stop offset="30%" stopColor="#217cff" stopOpacity="0.55" /> | |
| <stop offset="100%" stopColor="#217cff" stopOpacity="0" /> | |
| </radialGradient> | |
| <radialGradient id="nebula" cx="50%" cy="50%" r="50%"> | |
| <stop offset="0%" stopColor="#217cff" stopOpacity="0.16" /> | |
| <stop offset="60%" stopColor="#217cff" stopOpacity="0.06" /> | |
| <stop offset="100%" stopColor="#217cff" stopOpacity="0" /> | |
| </radialGradient> | |
| <radialGradient id="galaxy-core" cx="50%" cy="50%" r="50%"> | |
| <stop offset="0%" stopColor="#ffffff" stopOpacity="0.7" /> | |
| <stop offset="12%" stopColor="#ffffff" stopOpacity="0.32" /> | |
| <stop offset="40%" stopColor="#ffffff" stopOpacity="0.08" /> | |
| <stop offset="70%" stopColor="#217cff" stopOpacity="0.05" /> | |
| <stop offset="100%" stopColor="#217cff" stopOpacity="0" /> | |
| </radialGradient> | |
| <linearGradient id="comet" x1="0" y1="0" x2="1" y2="0"> | |
| <stop offset="0%" stopColor="#ffffff" stopOpacity="0.85" /> | |
| <stop offset="100%" stopColor="#217cff" stopOpacity="0" /> | |
| </linearGradient> | |
| </defs> | |
| <DeepField /> | |
| {nebulae.map((g) => ( | |
| <g | |
| key={g.id} | |
| className="nebula" | |
| onPointerDown={(e) => startDrag(e, g.topic_ids)} | |
| onClick={() => !wasDrag() && flyToGalaxy(g.id)} | |
| > | |
| <ellipse cx={g.cx} cy={g.cy} rx={g.rx} ry={g.ry} fill="url(#nebula)" /> | |
| <circle cx={g.cx} cy={g.cy} r={Math.min(g.rx, g.ry) * 0.75} fill="url(#galaxy-core)" className="galaxy-core" /> | |
| <circle cx={g.cx} cy={g.cy} r={2.4 * scale} fill="#ffffff" className="galaxy-heart" /> | |
| <text | |
| x={g.cx} | |
| y={g.cy - g.ry + 18 * scale} | |
| textAnchor="middle" | |
| className="galaxy-label" | |
| style={{ fontSize: 11 * scale, letterSpacing: 3 * scale }} | |
| > | |
| {g.name.toUpperCase()} GALAXY | |
| </text> | |
| </g> | |
| ))} | |
| {data.edges.map((e) => { | |
| const a = placed.get(e.source); | |
| const b = placed.get(e.target); | |
| if (!a || !b) return null; | |
| const lit = focus && (e.source === focus || e.target === focus); | |
| const strength = e.weight / maxEdge; | |
| return ( | |
| <line | |
| key={`${e.source}-${e.target}`} | |
| x1={a.x} | |
| y1={a.y} | |
| x2={b.x} | |
| y2={b.y} | |
| className="constellation" | |
| stroke={lit ? "#217cff" : "#ffffff"} | |
| strokeOpacity={lit ? 0.95 : focus ? 0.04 : 0.08 + strength * 0.3} | |
| strokeWidth={(lit ? 1.4 + strength * 2 : 0.6 + strength * 1.8) * scale} | |
| strokeDasharray={strength < 0.25 && !lit ? `${3 * scale} ${4 * scale}` : undefined} | |
| /> | |
| ); | |
| })} | |
| {data.nodes.map((n) => { | |
| const p = placed.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 ( | |
| <g | |
| key={n.id} | |
| className="map-node" | |
| opacity={dim ? 0.2 : 1} | |
| onMouseEnter={() => setHover(n.id)} | |
| onMouseLeave={() => setHover(null)} | |
| onPointerDown={(e) => startDrag(e, [n.id])} | |
| onClick={() => !wasDrag() && 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 && ( | |
| <path | |
| d={`M ${p.x} ${p.y - p.r * 0.35} L ${p.x + tail} ${p.y - tail * 0.45} L ${p.x + p.r * 0.35} ${p.y + p.r * 0.1} Z`} | |
| fill="url(#comet)" | |
| opacity={0.8} | |
| /> | |
| )} | |
| <circle cx={p.x} cy={p.y} r={p.r * 2.1} fill="url(#star-glow)" opacity={active ? 1 : glow} /> | |
| {brightest.has(n.id) && ( | |
| <g className="spikes" stroke={color} strokeOpacity={0.55} strokeWidth={1 * scale}> | |
| <line x1={p.x - p.r * 2.4} y1={p.y} x2={p.x + p.r * 2.4} y2={p.y} /> | |
| <line x1={p.x} y1={p.y - p.r * 2.4} x2={p.x} y2={p.y + p.r * 2.4} /> | |
| </g> | |
| )} | |
| {mode === "momentum" && n.status === "dormant" && ( | |
| <circle | |
| cx={p.x} | |
| cy={p.y} | |
| r={p.r * 0.9 + 4} | |
| fill="none" | |
| stroke="#ffffff" | |
| strokeOpacity={0.5} | |
| strokeWidth={1 * scale} | |
| strokeDasharray={`${2 * scale} ${3 * scale}`} | |
| /> | |
| )} | |
| <circle cx={p.x} cy={p.y} r={Math.max(2.5, p.r * 0.42)} fill={color} /> | |
| {active && <circle cx={p.x} cy={p.y} r={p.r + 12} className="orbit-ring" strokeWidth={1.2 * scale} />} | |
| {showLabel && ( | |
| <text | |
| x={p.x} | |
| y={p.y + p.r + 18 * scale} | |
| textAnchor="middle" | |
| className="map-label" | |
| style={{ fontSize: 12.5 * scale, strokeWidth: 4 * scale }} | |
| > | |
| {n.name} | |
| {(zoomedIn || active) && ( | |
| <tspan className="map-count" dx={6 * scale}> | |
| {n.post_count} | |
| </tspan> | |
| )} | |
| </text> | |
| )} | |
| </g> | |
| ); | |
| })} | |
| </svg> | |
| </div> | |
| {(selected || briefing) && ( | |
| <aside className="card map-side"> | |
| {!selected && ( | |
| <> | |
| <p className="eyebrow">Mission briefing</p> | |
| <p className="muted small"> | |
| {data.archive.posts} posts from {formatDate(data.archive.start)} to {formatDate(data.archive.end)}. Recent means since{" "} | |
| {formatDate(data.archive.recent_from)}. | |
| </p> | |
| <section className="brief"> | |
| <h3>Galaxies</h3> | |
| <ul className="brief-list"> | |
| {data.galaxies | |
| .filter((g) => g.size > 1) | |
| .slice(0, 6) | |
| .map((g) => ( | |
| <li key={g.id}> | |
| <button className="link-btn" onClick={() => flyToGalaxy(g.id)}> | |
| {g.name} | |
| </button> | |
| <span className="mono muted"> | |
| {g.size} topics · {g.post_count} posts | |
| </span> | |
| </li> | |
| ))} | |
| {galaxyCount === 0 && <li className="muted small">No clusters yet. Map more posts.</li>} | |
| </ul> | |
| </section> | |
| <section className="brief"> | |
| <h3>Rising</h3> | |
| <ul className="brief-list"> | |
| {data.insights.rising.map((r) => ( | |
| <li key={r.id}> | |
| <button className="link-btn" onClick={() => open(r.id, true)}> | |
| {r.name} | |
| </button> | |
| <span className="mono brief-up">{r.momentum.toFixed(1)}x</span> | |
| </li> | |
| ))} | |
| {data.insights.rising.length === 0 && <li className="muted small">Nothing is picking up speed right now.</li>} | |
| </ul> | |
| </section> | |
| <section className="brief"> | |
| <h3>Dormant</h3> | |
| <ul className="brief-list"> | |
| {data.insights.dormant.map((d) => ( | |
| <li key={d.id}> | |
| <button className="link-btn" onClick={() => open(d.id, true)}> | |
| {d.name} | |
| </button> | |
| <span className="mono muted">since {formatDate(d.last_at)}</span> | |
| </li> | |
| ))} | |
| {data.insights.dormant.length === 0 && <li className="muted small">Every topic has been visited lately.</li>} | |
| </ul> | |
| {data.insights.dormant.length > 0 && ( | |
| <Link to="/app/launchpad#ideas" className="small-link mono"> | |
| Resurface old posts | |
| </Link> | |
| )} | |
| </section> | |
| <section className="brief"> | |
| <h3>Strongest pairings</h3> | |
| <ul className="brief-list"> | |
| {data.insights.pairs.map((p) => ( | |
| <li key={`${p.a_id}-${p.b_id}`}> | |
| <span> | |
| <button className="link-btn" onClick={() => open(p.a_id, true)}> | |
| {p.a} | |
| </button> | |
| <span className="muted"> + </span> | |
| <button className="link-btn" onClick={() => open(p.b_id, true)}> | |
| {p.b} | |
| </button> | |
| </span> | |
| <span className="mono muted">{p.posts} posts</span> | |
| </li> | |
| ))} | |
| </ul> | |
| </section> | |
| </> | |
| )} | |
| {selected && ( | |
| <> | |
| <button className="link-btn mono muted small" onClick={() => setSelected(null)}> | |
| {briefing ? "Back to briefing" : "Close"} | |
| </button> | |
| <div className="row between"> | |
| <h2>{selected.name}</h2> | |
| <span className={`pill mono status-${selected.status}`}>{STATUS_LABEL[selected.status]}</span> | |
| </div> | |
| {selected.summary && <p className="muted">{selected.summary}</p>} | |
| <dl className="star-stats"> | |
| <div> | |
| <dt>Posts</dt> | |
| <dd>{selected.post_count}</dd> | |
| </div> | |
| <div> | |
| <dt>Recent</dt> | |
| <dd>{selected.recent_posts}</dd> | |
| </div> | |
| <div> | |
| <dt>First</dt> | |
| <dd>{formatDate(selected.first_at)}</dd> | |
| </div> | |
| <div> | |
| <dt>Last</dt> | |
| <dd>{formatDate(selected.last_at)}</dd> | |
| </div> | |
| </dl> | |
| <p className="mono muted small">Momentum: {momentumText(selected.momentum)}</p> | |
| <div className="timeline"> | |
| <Sparkline values={selected.timeline} height={44} highlightFrom={recentBucket} /> | |
| <div className="row between mono muted small"> | |
| <span>{formatDate(selected.timeline_start)}</span> | |
| <span>{formatDate(selected.timeline_end)}</span> | |
| </div> | |
| </div> | |
| {selected.related.length > 0 && ( | |
| <div className="stack"> | |
| <span className="mono muted small">Travels with</span> | |
| <div className="chips"> | |
| {selected.related.map((r) => ( | |
| <button key={r.id} className="chip chip-btn" onClick={() => open(r.id, true)} title={`${r.shared_posts} shared posts`}> | |
| {r.name} <span className="mono">{r.shared_posts}</span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <div className="topic-actions"> | |
| <button | |
| className="btn btn-small btn-primary" | |
| disabled={acting !== null} | |
| onClick={async () => { | |
| setActing("audio"); | |
| try { | |
| const nb = await notebooksApi.fromTopic(selected.id); | |
| await artifactsApi.generate({ type: "audio_overview", notebook_id: nb.id, format: "deep_dive", minutes: 6 }); | |
| navigate(`/app/notebooks/${nb.id}`); | |
| } finally { | |
| setActing(null); | |
| } | |
| }} | |
| > | |
| {acting === "audio" ? "Starting..." : "Audio about this"} | |
| </button> | |
| {selected.posts[0] && ( | |
| <button className="btn btn-small" disabled={acting !== null} onClick={() => navigate(`/app/launchpad/kits?post=${selected.posts[0].id}`)}> | |
| Launch Kit for the latest post | |
| </button> | |
| )} | |
| <button | |
| className="btn btn-small" | |
| disabled={acting !== null} | |
| onClick={async () => { | |
| const nb = await notebooksApi.fromTopic(selected.id); | |
| navigate(`/app/notebooks/${nb.id}`); | |
| }} | |
| > | |
| Open as notebook | |
| </button> | |
| </div> | |
| <ul className="doc-list"> | |
| {selected.posts.map((d) => ( | |
| <li key={d.id} className="doc-row"> | |
| <button className="link-btn doc-title" onClick={() => setReading(d.id)}> | |
| {d.title} | |
| </button> | |
| <span className="mono muted">{formatDate(d.published_at)}</span> | |
| </li> | |
| ))} | |
| </ul> | |
| </> | |
| )} | |
| </aside> | |
| )} | |
| </div> | |
| )} | |
| {reading && <Reader documentId={reading} onClose={() => setReading(null)} />} | |
| </div> | |
| ); | |
| } | |