import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from "react"; import { createPortal } from "react-dom"; import { artifactsApi } from "../api/endpoints"; import type { Artifact, MindNode } from "../api/types"; import { useJob } from "../hooks/useJob"; import { ConfirmButton, formatDate, JobProgress, Modal, StatusPill } from "./ui"; import { Reader } from "./Reader"; /* ---------- Layout: a galaxy. The centre idea, branches orbiting it, sub-themes orbiting each branch, points orbiting each sub-theme. Every orbit is smaller than the one above, so zooming in always reveals a new ring. ---------- */ type Placed = { node: MindNode; level: number; x: number; y: number; parent: Placed | null; angle: number; branch: number }; const RADIUS = [46, 26, 13, 4.6]; // node radius per level, in world units const TOPIC_ORBIT = 150; const DETAIL_ORBIT = 62; const LABEL_PX = [24, 17, 13, 12]; // label size on screen at scale 1 const PANEL_W = 340; const MAX_SCALE = 14; const mindConstellationTitle = (title: string) => title.replace(/^(?:Mind map|Idea Galaxy|Idea Constellation):/, "Mind Constellation:"); /* Shapes. Maps open as a Figure and the explorer lets you change it. Orbit is a ring with spokes; the others are freer figures joined like constellations. The seed still varies each figure's exact outline from map to map. */ export type Shape = "orbit" | "spiral" | "constellation" | "cluster"; export const SHAPES: { id: Shape; label: string }[] = [ { id: "orbit", label: "Orbit" }, { id: "spiral", label: "Spiral" }, { id: "constellation", label: "Figure" }, { id: "cluster", label: "Cluster" }, ]; type Edge = { a: Placed; b: Placed; to: Placed; faint?: boolean }; const MIN_GAP = 470; // branches keep this far apart so their sub-themes and points never collide const MIN_ROOT = 400; function hashSeed(id: string) { let h = 0; for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0; return h % 997; } // Every map opens as a constellation figure; the switcher changes it, and the choice is remembered per map. export const shapeFor = (_id: string): Shape => "constellation"; function branchPoints(shape: Shape, n: number, seed: number): { x: number; y: number }[] { const r = (k: number) => seeded(seed * 7.13 + k); const orbit = orbitOf(n); const start = shape === "orbit" ? -Math.PI / 2 : r(1) * Math.PI * 2; let pts: { x: number; y: number }[] = []; if (shape === "orbit") { pts = Array.from({ length: n }, (_, i) => { const a = start + (2 * Math.PI * i) / Math.max(n, 1); return { x: Math.cos(a) * orbit, y: Math.sin(a) * orbit }; }); } else if (shape === "spiral") { const dir = r(2) > 0.5 ? 1 : -1; pts = Array.from({ length: n }, (_, i) => { const a = start + dir * i * 0.95; const rad = 300 + (n > 1 ? (i * (orbit + 120 - 300)) / (n - 1) : 0); return { x: Math.cos(a) * rad, y: Math.sin(a) * rad }; }); } else if (shape === "constellation") { if (seed % 2 === 0) { // a zigzag, like Cassiopeia pts = Array.from({ length: n }, (_, i) => ({ x: (i - (n - 1) / 2) * 380, y: (i % 2 ? 1 : -1) * (150 + r(10 + i) * 150) })); } else { // a sweeping arc with a ragged edge, like the Plough's handle pts = Array.from({ length: n }, (_, i) => { const t = n > 1 ? -1.15 + (2.3 * i) / (n - 1) : 0; const rad = orbit * (0.85 + r(10 + i) * 0.45); return { x: Math.sin(t) * rad, y: -Math.cos(t) * rad + orbit * 0.7 }; }); } const cx = pts.reduce((m, q) => m + q.x, 0) / Math.max(n, 1); const cy = pts.reduce((m, q) => m + q.y, 0) / Math.max(n, 1); pts = pts.map((q) => { const x = q.x - cx; const y = q.y - cy; return { x: x * Math.cos(start) - y * Math.sin(start), y: x * Math.sin(start) + y * Math.cos(start) }; }); } else { for (let i = 0; i < n; i++) { let best = { x: 0, y: 0 }; for (let t = 0; t < 300; t++) { const a = r(100 + i * 300 + t) * Math.PI * 2; const d = Math.sqrt(r(101 + i * 300 + t)) * orbit * 1.25; const q = { x: Math.cos(a) * d, y: Math.sin(a) * d }; best = q; if (Math.hypot(q.x, q.y) >= MIN_ROOT && pts.every((o) => Math.hypot(o.x - q.x, o.y - q.y) >= MIN_GAP)) break; } pts.push(best); } } // Push apart anything that landed too close. for (let it = 0; it < 200; it++) { for (let i = 0; i < pts.length; i++) { for (let j = i + 1; j < pts.length; j++) { const dx = pts[j].x - pts[i].x; const dy = pts[j].y - pts[i].y; const d = Math.hypot(dx, dy) || 1; if (d < MIN_GAP) { const push = (MIN_GAP - d) / 2; pts[i].x -= (dx / d) * push; pts[i].y -= (dy / d) * push; pts[j].x += (dx / d) * push; pts[j].y += (dy / d) * push; } } } // The centre keeps its own clear space (checked last, so nothing pushes a star back into it). for (const q of pts) { const d0 = Math.hypot(q.x, q.y) || 1; if (d0 < MIN_ROOT) { q.x = (q.x / d0) * MIN_ROOT; q.y = (q.y / d0) * MIN_ROOT; } } } return pts; } function place(root: MindNode, shape: Shape, seed: number): { nodes: Placed[]; edges: Edge[]; extent: number } { const jit = (k: number) => (shape === "orbit" ? 0 : seeded(seed * 3.7 + k) - 0.5); const nodes: Placed[] = []; const edges: Edge[] = []; const top: Placed = { node: root, level: 0, x: 0, y: 0, parent: null, angle: 0, branch: -1 }; nodes.push(top); const pts = branchPoints(shape, root.children.length, seed); const bps: Placed[] = []; root.children.forEach((b, i) => { const a = Math.atan2(pts[i].y, pts[i].x); const bp: Placed = { node: b, level: 1, x: pts[i].x, y: pts[i].y, parent: top, angle: a, branch: i }; nodes.push(bp); bps.push(bp); const topics: Placed[] = []; b.children.forEach((t, j) => { const ta = a + (j - (b.children.length - 1) / 2) * 0.95 + jit(i * 50 + j) * 0.35; const tr = TOPIC_ORBIT * (1 + jit(i * 50 + j + 7) * 0.4); const tp: Placed = { node: t, level: 2, x: bp.x + Math.cos(ta) * tr, y: bp.y + Math.sin(ta) * tr, parent: bp, angle: ta, branch: i }; nodes.push(tp); topics.push(tp); const details: Placed[] = []; t.children.forEach((d, k) => { const da = ta + (k - (t.children.length - 1) / 2) * 0.9 + jit(i * 500 + j * 50 + k) * 0.3; const dp: Placed = { node: d, level: 3, x: tp.x + Math.cos(da) * DETAIL_ORBIT, y: tp.y + Math.sin(da) * DETAIL_ORBIT, parent: tp, angle: da, branch: i }; nodes.push(dp); details.push(dp); }); link(edges, tp, details, shape); }); link(edges, bp, topics, shape); }); // Branch connections: spokes for an orbit, otherwise lines that join the stars into a figure. const near = (p: Placed, from: Placed[]) => [...from].sort((u, v) => Math.hypot(u.x - p.x, u.y - p.y) - Math.hypot(v.x - p.x, v.y - p.y)); if (shape === "orbit") bps.forEach((bp) => edges.push({ a: top, b: bp, to: bp })); else if (shape === "spiral") { if (bps[0]) edges.push({ a: top, b: bps[0], to: bps[0] }); for (let i = 1; i < bps.length; i++) edges.push({ a: bps[i - 1], b: bps[i], to: bps[i] }); } else if (shape === "constellation") { for (let i = 1; i < bps.length; i++) edges.push({ a: bps[i - 1], b: bps[i], to: bps[i] }); near(top, bps).slice(0, 2).forEach((bp) => edges.push({ a: top, b: bp, to: bp })); if (seed % 3 === 0 && bps.length > 3) edges.push({ a: bps[bps.length - 1], b: bps[0], to: bps[0] }); } else { // cluster: a minimum spanning tree over the centre and every branch (Prim) const inTree = [top]; const rest = [...bps]; while (rest.length) { let best: { from: Placed; to: Placed; d: number } | null = null; for (const q of rest) for (const f of inTree) { const d = Math.hypot(q.x - f.x, q.y - f.y); if (!best || d < best.d) best = { from: f, to: q, d }; } edges.push({ a: best!.from, b: best!.to, to: best!.to }); inTree.push(best!.to); rest.splice(rest.indexOf(best!.to), 1); } } // Every theme stays tied to the centre: the figure lines carry the eye, and these faint spokes keep it whole. if (shape !== "orbit") { const joined = new Set(edges.filter((e) => e.a === top || e.b === top).map((e) => (e.a === top ? e.b : e.a))); bps.filter((bp) => !joined.has(bp)).forEach((bp) => edges.push({ a: top, b: bp, to: bp, faint: true })); } const extent = Math.max(MIN_ROOT, ...bps.map((q) => Math.hypot(q.x, q.y))); return { nodes, edges, extent }; } /** An orbit fans its children out from the parent; the freer shapes thread them in a line like a constellation. */ function link(edges: Edge[], parent: Placed, kids: Placed[], shape: Shape) { if (shape === "orbit") kids.forEach((k) => edges.push({ a: parent, b: k, to: k })); else kids.forEach((k, i) => edges.push({ a: i === 0 ? parent : kids[i - 1], b: k, to: k })); } function wrap(text: string, max: number, maxLines = 3): string[] { const words = text.split(/\s+/).filter(Boolean); const lines: string[] = []; let cur = ""; for (const w of words) { if (cur && (cur + " " + w).length > max) { lines.push(cur); cur = w; } else cur = cur ? `${cur} ${w}` : w; } if (cur) lines.push(cur); if (lines.length > maxLines) { const kept = lines.slice(0, maxLines); kept[maxLines - 1] = kept[maxLines - 1].replace(/[\s.,;:]*$/, "") + "..."; return kept; } return lines; } const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)); const ramp = (z: number, a: number, b: number) => clamp((z - a) / (b - a), 0, 1); const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); function seeded(n: number) { const x = Math.sin(n * 12.9898) * 43758.5453; return x - Math.floor(x); } // Three star layers that drift at different speeds as the camera moves, which is what gives the depth. const LAYERS = [ { n: 90, parallax: 0.012, r: [0.35, 0.8], op: 0.45 }, { n: 50, parallax: 0.035, r: [0.6, 1.3], op: 0.7 }, { n: 16, parallax: 0.085, r: [1.2, 2.2], op: 0.95 }, ].map((l, li) => ({ ...l, stars: Array.from({ length: l.n }, (_, i) => { const k = li * 1000 + i; return { x: seeded(k + 1), y: seeded(k + 501), r: l.r[0] + seeded(k + 901) * (l.r[1] - l.r[0]), d: seeded(k + 1301) * 6, blue: seeded(k + 77) > 0.7 }; }), })); // Themes stay inside the app palette: different strengths of the one blue. The centre is pale starlight. const ACCENTS = ["#217cff", "#4a96ff", "#2f6fe0", "#6fb0ff", "#1f5fd0", "#8cc0ff", "#3b86f0"]; const CORE = "#bcd6ff"; const accentOf = (p: Placed) => (p.branch < 0 ? CORE : ACCENTS[p.branch % ACCENTS.length]); const orbitOf = (n: number) => Math.max(320, 82 * n); type Cam = { x: number; y: number; s: number }; const camTransform = (c: Cam, size: { w: number; h: number }) => `translate(${size.w / 2 - c.x * c.s} ${size.h / 2 - c.y * c.s}) scale(${c.s})`; /* ---------- The explorer ---------- */ export function MindMapExplorer({ artifact, onClose, warp = true, startAt = "root", embedded = false, onExpand }: { artifact: Artifact; onClose: () => void; warp?: boolean; startAt?: string; /** Shown inside the page (a report) in a frame instead of full screen; the wheel zooms only with Ctrl or Cmd held. */ embedded?: boolean; /** Embedded: the button that opens the full-screen explorer. */ onExpand?: () => void; }) { const root = artifact.content.root as MindNode | undefined; const storeKey = `ns_mindmap_shape_${artifact.id}`; const [shape, setShape] = useState(() => { try { const saved = localStorage.getItem(storeKey) as Shape | null; if (saved && SHAPES.some((x) => x.id === saved)) return saved; } catch { /* no storage: use the map's own shape */ } return shapeFor(artifact.id); }); const seed = useMemo(() => hashSeed(artifact.id), [artifact.id]); const layoutData = useMemo(() => (root ? place(root, shape, seed) : { nodes: [], edges: [], extent: MIN_ROOT }), [root, shape, seed]); const placed = layoutData.nodes; const edges = layoutData.edges; const byId = useMemo(() => new Map(placed.map((p) => [p.node.id, p])), [placed]); const wrapRef = useRef(null); const [size, setSize] = useState({ w: 1000, h: 700 }); const [focusId, setFocusId] = useState(startAt); // The camera lives in a ref and is written straight to the DOM every frame. React only re-renders when the zoom // crosses a small step (labels and which rings show depend on it), so flying and panning stay smooth. const camRef = useRef({ x: 0, y: 0, s: 0.1 }); const worldRef = useRef(null); const starRefs = useRef<(SVGGElement | null)[]>([]); const sizeRef = useRef(size); sizeRef.current = size; const bucket = useRef(0); const [, setStep] = useState(0); const applyCam = useCallback((c: Cam) => { camRef.current = c; worldRef.current?.setAttribute("transform", camTransform(c, sizeRef.current)); // Each star field is one 3x3-screen tile; the drift wraps around it (the tile is drawn again beside itself), // so the sky never runs out however far or deep the camera goes. const { w, h } = sizeRef.current; const wrap = (v: number, period: number) => ((v % period) + period) % period; starRefs.current.forEach((g, i) => g?.setAttribute("transform", `translate(${wrap(-c.x * c.s * LAYERS[i].parallax, w * 3)} ${wrap(-c.y * c.s * LAYERS[i].parallax, h * 3)})`), ); const b = Math.round(Math.log(c.s) * 25); if (b !== bucket.current) { bucket.current = b; setStep(b); } }, []); const anim = useRef(0); const drag = useRef<{ px: number; py: number; cam: Cam; moved: boolean } | null>(null); const [reading, setReading] = useState<{ id: string; start: number; end: number } | null>(null); const [panelOpen, setPanelOpen] = useState(true); const wide = size.w >= 820; const panelW = panelOpen && wide ? PANEL_W : 0; useEffect(() => { const el = wrapRef.current; if (!el) return; const measure = () => setSize({ w: el.clientWidth, h: el.clientHeight }); measure(); const ro = new ResizeObserver(measure); ro.observe(el); return () => ro.disconnect(); }, []); /** Where the camera should sit to frame a node and everything orbiting it. */ const targetFor = useCallback( (id: string): Cam => { const p = byId.get(id) ?? placed[0]; const members = id === "root" ? placed : placed.filter((q) => { for (let c: Placed | null = q; c; c = c.parent) if (c === p) return true; return false; }); const pad = id === "root" ? 70 : p.level === 3 ? 56 : 46; const minX = Math.min(...members.map((m) => m.x - RADIUS[m.level])); const maxX = Math.max(...members.map((m) => m.x + RADIUS[m.level])); const minY = Math.min(...members.map((m) => m.y - RADIUS[m.level])); const maxY = Math.max(...members.map((m) => m.y + RADIUS[m.level])); const halfW = Math.max((maxX - minX) / 2 + pad, 60); const halfH = Math.max((maxY - minY) / 2 + pad, 60); const freeW = size.w - panelW; const s = clamp(Math.min(freeW / (2 * halfW), size.h / (2 * halfH)), 0.05, MAX_SCALE); return { x: (minX + maxX) / 2 + panelW / 2 / s, y: (minY + maxY) / 2, s }; }, [byId, placed, size.w, size.h, panelW], ); const fitScale = useMemo(() => targetFor("root").s, [targetFor]); const flyTo = useCallback((to: Cam, ms = 950) => { cancelAnimationFrame(anim.current); const from = camRef.current; const t0 = performance.now(); const step = (now: number) => { const t = ease(clamp((now - t0) / ms, 0, 1)); // Scale moves geometrically so the zoom feels even; the centre follows. const s = from.s * Math.pow(to.s / from.s, t); applyCam({ s, x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t }); if (t < 1) anim.current = requestAnimationFrame(step); }; anim.current = requestAnimationFrame(step); }, [applyCam]); const focus = useCallback( (id: string, ms?: number) => { setFocusId(id); flyTo(targetFor(id), ms); }, [flyTo, targetFor], ); // Warp in: start far out and fly to the whole constellation. const entered = useRef(false); useEffect(() => { if (entered.current || !placed.length || size.w < 50) return; entered.current = true; const end = targetFor(startAt); if (!warp) { applyCam(end); return; } applyCam({ ...end, s: end.s * 0.25 }); flyTo(end, 1400); }, [placed.length, size.w, targetFor, flyTo, applyCam, warp, startAt]); // Reframe when the window or panel changes. const firstFrame = useRef(true); useEffect(() => { if (firstFrame.current) { firstFrame.current = false; return; } if (entered.current) flyTo(targetFor(focusId), 350); // eslint-disable-next-line react-hooks/exhaustive-deps }, [panelW, size.w, size.h]); // A new shape reshuffles every star, so fly back out to see the whole figure. const shapeSeen = useRef(shape); useEffect(() => { if (shapeSeen.current === shape) return; shapeSeen.current = shape; setFocusId("root"); flyTo(targetFor("root"), 900); try { localStorage.setItem(storeKey, shape); } catch { /* fine */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, [shape]); useEffect(() => () => cancelAnimationFrame(anim.current), []); const up = useCallback(() => { const p = byId.get(focusId); if (p?.parent) focus(p.parent.node.id); }, [byId, focus, focusId]); useEffect(() => { if (embedded) return; // in a page the keys belong to the page (scrolling), not the map const onKey = (e: KeyboardEvent) => { if (reading) return; if (e.key === "Escape") onClose(); else if (e.key === "Backspace" || e.key === "ArrowUp") { e.preventDefault(); up(); } }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [onClose, up, reading, embedded]); // Wheel zooms around the cursor (a native listener, so the page behind never scrolls). const svgRef = useRef(null); const zoomBy = useCallback((factor: number, sx: number, sy: number) => { cancelAnimationFrame(anim.current); const c = camRef.current; const s = clamp(c.s * factor, fitScale * 0.5, MAX_SCALE); // keep the world point under (sx, sy) fixed const wx = c.x + (sx - size.w / 2) / c.s; const wy = c.y + (sy - size.h / 2) / c.s; applyCam({ s, x: wx - (sx - size.w / 2) / s, y: wy - (sy - size.h / 2) / s }); }, [fitScale, size.w, size.h, applyCam]); useEffect(() => { const el = svgRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { if (embedded && !(e.ctrlKey || e.metaKey)) return; // let the page scroll past an embedded map e.preventDefault(); const r = el.getBoundingClientRect(); zoomBy(Math.exp(clamp(-e.deltaY * 0.0018, -0.35, 0.35)), e.clientX - r.left, e.clientY - r.top); }; el.addEventListener("wheel", onWheel, { passive: false }); return () => el.removeEventListener("wheel", onWheel); }, [zoomBy, embedded]); const onPointerDown = (e: ReactPointerEvent) => { cancelAnimationFrame(anim.current); drag.current = { px: e.clientX, py: e.clientY, cam: camRef.current, moved: false }; }; const onPointerMove = (e: ReactPointerEvent) => { const d = drag.current; if (!d) return; const dx = e.clientX - d.px; const dy = e.clientY - d.py; if (!d.moved && Math.abs(dx) + Math.abs(dy) > 4) { d.moved = true; // Capture only once it is a real drag: capturing on press would send the click to the canvas, not the star. svgRef.current?.setPointerCapture(e.pointerId); } if (d.moved) applyCam({ s: d.cam.s, x: d.cam.x - dx / d.cam.s, y: d.cam.y - dy / d.cam.s }); }; const onPointerUp = (e: ReactPointerEvent) => { if (svgRef.current?.hasPointerCapture(e.pointerId)) svgRef.current.releasePointerCapture(e.pointerId); window.setTimeout(() => (drag.current = null), 0); // lets the click handler see `moved` }; if (!root || !placed.length) return null; const cam = camRef.current; const z = cam.s / fitScale; const levelOp = [1, 1, ramp(z, 1.25, 1.8), ramp(z, 3.6, 5)]; const focused = byId.get(focusId) ?? placed[0]; const lineage = new Set(); for (let c: Placed | null = focused; c; c = c.parent) lineage.add(c.node.id); const inFocus = (p: Placed) => { if (focusId === "root") return true; if (lineage.has(p.node.id)) return true; for (let c: Placed | null = p; c; c = c.parent) if (c === focused) return true; return false; }; const labelWorld = (level: number) => (LABEL_PX[level] * Math.pow(cam.s, 0.3)) / cam.s; // Declutter: at any zoom, several labels can be visible at once and sit close together on screen. A bigger or more // central node (lower level first, then closer to the view centre) claims its space; one whose box would then // overlap an already-claimed box is hidden until zooming in spreads the nodes apart. const hiddenLabels = (() => { const centerX = size.w / 2 - cam.x * cam.s; const centerY = size.h / 2 - cam.y * cam.s; const toScreen = (p: Placed) => ({ x: centerX + p.x * cam.s, y: centerY + p.y * cam.s }); const candidates = placed .filter((p) => levelOp[p.level] * (inFocus(p) ? 1 : 0.28) >= 0.3) .map((p) => { const s = toScreen(p); const fsPx = labelWorld(p.level) * cam.s * (p.level === 0 ? 0.82 : 1); const text = p.level === 0 ? p.node.label.toUpperCase() : p.node.label; const w = Math.min(text.length, p.level >= 2 ? 16 : 14) * fsPx * 0.58; const r = RADIUS[p.level] * cam.s; const top = s.y + r * 1.35; return { p, box: { x1: s.x - w / 2, x2: s.x + w / 2, y1: top, y2: top + fsPx * 1.3 } }; }) .sort((a, b) => { if (a.p.level !== b.p.level) return a.p.level - b.p.level; // bigger levels (branches) win over small ones const da = (a.box.x1 + a.box.x2) ** 2 + (a.box.y1 + a.box.y2) ** 2; const db = (b.box.x1 + b.box.x2) ** 2 + (b.box.y1 + b.box.y2) ** 2; return da - db; // within a level, whichever sits nearer the view centre wins }); const kept: { x1: number; x2: number; y1: number; y2: number }[] = []; const hidden = new Set(); const overlaps = (a: typeof candidates[number]["box"], b: typeof candidates[number]["box"]) => a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1; for (const { p, box } of candidates) { if (p.node.id === focusId || kept.every((k) => !overlaps(box, k))) kept.push(box); else hidden.add(p.node.id); } return hidden; })(); const trail: Placed[] = []; for (let c: Placed | null = focused; c; c = c.parent) trail.unshift(c); const view = (
{ const r = svgRef.current!.getBoundingClientRect(); zoomBy(1.8, e.clientX - r.left, e.clientY - r.top); }} > {[...ACCENTS, CORE].map((c, i) => ( ))} {LAYERS.map((layer, li) => ( (starRefs.current[li] = el)} aria-hidden="true"> {layer.stars.map((st, i) => ( ))} ))} {/* nebula: a soft cloud of each theme's colour behind its cluster */} {placed .filter((p) => p.level === 1) .map((p) => ( ))} {/* orbits, with a small satellite drifting around the ones in view */} {placed .filter((p) => shape === "orbit" && p.node.children.length) .map((p) => { const r = p.level === 0 ? layoutData.extent : p.level === 1 ? TOPIC_ORBIT : DETAIL_ORBIT; const op = levelOp[p.level + 1] * (inFocus(p) ? 1 : 0.3); if (op < 0.03) return null; const c = accentOf(p.level === 0 ? p : p); return ( {p.level < 3 && ( )} ); })} {/* constellation lines; the path to what you are looking at flows */} {edges .filter((e) => levelOp[e.to.level] > 0.03) .map((e) => { const live = inFocus(e.to); const flowing = live && focusId !== "root"; // the whole map at rest is calm solid lines; a chosen path flows return ( ); })} {/* deepest first so the outer rings never hide the inner ones' labels */} {[...placed].reverse().map((p) => { const op = levelOp[p.level] * (inFocus(p) ? 1 : 0.28); if (op < 0.02) return null; const r = RADIUS[p.level]; const fs = labelWorld(p.level); const lines = wrap(p.level === 0 ? p.node.label.toUpperCase() : p.node.label, p.level >= 2 ? 16 : 14, 3); const hidden = p.node.children.length * (1 - (p.level < 3 ? levelOp[p.level + 1] : 1)); const showNote = p.level === 3 && ramp(z, 6, 8) > 0.05 && p.node.note; const noteFs = Math.min(fs * 0.82, 3.4); const gi = p.level === 0 ? 7 : p.branch % ACCENTS.length; const c = accentOf(p); const spike = r * (p.level === 0 ? 4.6 : p.level === 1 ? 3.2 : 3); return ( 0.3 ? "all" : "none", color: c }} transform={`translate(${p.x} ${p.y})`} onClick={() => { if (drag.current?.moved) return; focus(p.node.id); }} tabIndex={op > 0.3 ? 0 : -1} role="button" aria-label={p.node.label} onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && focus(p.node.id)} > {p.level < 2 && ( )} {p.level === 0 ? ( <> ) : ( <> )} {p.node.id === focusId && } {hidden > 0.2 && p.level > 0 && ( )} {!hiddenLabels.has(p.node.id) && ( {lines.map((l, i) => ( {l} ))} )} {showNote && ( {wrap(p.node.note, 30, 5).map((l, i) => ( {l} ))} )} ); })} ); return embedded ? view : createPortal(view, document.body); } /* ---------- The right-rail list: every mind map in this notebook, one row each ---------- */ export function MindMapRow({ artifact: initial, autoOpen, onOpen, onRemoved, }: { artifact: Artifact; autoOpen?: boolean; onOpen: (a: Artifact) => void; onRemoved: (id: string) => void; }) { const [artifact, setArtifact] = useState(initial); const job = useJob(artifact.job, () => { artifactsApi.get(artifact.id).then((a) => { setArtifact(a); if (autoOpen && a.status === "ready") onOpen(a); }); }); const running = job && (job.status === "queued" || job.status === "running"); const ready = artifact.status === "ready"; const failed = artifact.status === "failed" ? (artifact.content.error as string | undefined) ?? job?.error ?? "Could not build this map." : null; const nodes = artifact.content.node_count as number | undefined; const posts = artifact.content.post_count as number | undefined; return (
{!ready && !failed && } { await artifactsApi.remove(artifact.id); onRemoved(artifact.id); }} > Delete {artifact.title} {running && job && (
)} {failed &&

{failed}

}
); } /* ---------- The "what should the map be about" dialog ---------- */ export function MindMapDialog({ docs, busy, error, onClose, onCreate, }: { docs: { id: string; title: string }[]; busy: boolean; error: string | null; onClose: () => void; onCreate: (documentIds: string[], focus: string) => void; }) { const [picked, setPicked] = useState>(() => new Set(docs.map((d) => d.id))); const [focus, setFocus] = useState(""); const [open, setOpen] = useState(docs.length <= 6); const all = picked.size === docs.length; const toggle = (id: string) => setPicked((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; }); // Portaled to : inside the side pane, a transform or filter on an ancestor would centre "fixed" on the pane. return createPortal(

Sources

{open && (
{docs.map((d) => ( ))}
)}

What should the topic be?