/** * The reading map — an orbiting point cloud of everything read, traced by everything highlighted. * * Framework-agnostic on purpose: one `mount()` that takes a container and a payload URL. The * Astro wrapper (`ReadingMap.astro`) is thin, and the standalone preview uses the same module, * so what gets reviewed is what ships. * * Payload contract is `umap/PLAN.md` §2, and two parts of it are load-bearing: * * • `trace` is pre-sorted chronologically and MUST NOT be re-sorted. `t` is date-only and * many highlights share a day, so array order is the only thing carrying the sequence. * • There is no `has_highlight` flag. The signal set is the distinct `trace[].node` values — * 262 highlights over 206 chunks, because one passage can be highlighted more than once. */ import * as THREE from 'three'; const DRAW_SECONDS = 45; // one full pass of the trace const HOLD_SECONDS = 4; // beat at the end before looping const ORBIT_RATE = 0.035; // radians/sec — slow enough to read text over const POINTER_FINE = window.matchMedia('(pointer: fine)'); /** Read a CSS custom property off the container, with a fallback. Keeps theming in CSS. */ function cssVar(el, name, fallback) { const v = getComputedStyle(el).getPropertyValue(name).trim(); return v || fallback; } /** * A soft radial dot, drawn once and shared by every point material. * * The default `PointsMaterial` splat is a hard-edged square. At the sizes this scene uses that * reads as grit — thousands of tiny tiles — rather than as a cloud. A gaussian-ish falloff with * no hard rim is the whole difference between "scatter plot" and "nebula": overlapping sprites * accumulate into something that looks continuous instead of stippled. */ function dotTexture() { const S = 64; const c = document.createElement('canvas'); c.width = c.height = S; const ctx = c.getContext('2d'); const g = ctx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2); g.addColorStop(0, 'rgba(255,255,255,1)'); g.addColorStop(0.35, 'rgba(255,255,255,0.62)'); g.addColorStop(0.72, 'rgba(255,255,255,0.13)'); g.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = g; ctx.fillRect(0, 0, S, S); const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; return tex; } export async function mount(container, { src, onError } = {}) { try { const res = await fetch(src); if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); return build(container, await res.json()); } catch (err) { // Fail closed, the way the garden's other widgets do: leave the container hidden rather // than showing a broken box. A missing payload should degrade to a plain page. onError?.(err); return null; } } export function build(container, data) { const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; const colors = { nebula: cssVar(container, '--map-nebula', '#9aa4b2'), signal: cssVar(container, '--map-signal', '#ea580c'), trace: cssVar(container, '--map-trace', '#2563eb'), fog: cssVar(container, '--map-fog', '#fcfcfb'), }; // --- scene ------------------------------------------------------------------------------ const scene = new THREE.Scene(); // Depth cue. Without it a point cloud is a flat spray of dots that happens to rotate; with // it, the far side recedes into the page colour and the thing reads as a volume. This is the // single biggest lever on whether it looks like a cloud, and it costs one line. scene.fog = new THREE.FogExp2(new THREE.Color(colors.fog), 0.42); // Closer, with a narrower lens: the cloud is normalised to roughly unit radius, so filling // the frame means being near it rather than zoomed out looking at a speck. const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100); camera.position.set(0, 0, 2.35); const sprite = dotTexture(); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); container.appendChild(renderer.domElement); // Everything rotates together, so the trace stays welded to the cloud it runs through. const world = new THREE.Group(); scene.add(world); const xyz = Float32Array.from(data.nodes.xyz); const nodeCount = xyz.length / 3; // --- the nebula: every chunk, one draw call --------------------------------------------- const nebulaGeo = new THREE.BufferGeometry(); nebulaGeo.setAttribute('position', new THREE.BufferAttribute(xyz, 3)); // Big and faint rather than small and solid. Individual chunks are not the subject — the // density is — so overlapping soft sprites are allowed to pile into brighter regions where // the corpus is thick, which is exactly where a book's territory sits. const nebula = new THREE.Points(nebulaGeo, new THREE.PointsMaterial({ color: new THREE.Color(colors.nebula), map: sprite, size: 0.055, sizeAttenuation: true, transparent: true, opacity: 0.3, depthWrite: false, fog: true, })); world.add(nebula); // --- the signal: chunks holding a highlight ---------------------------------------------- // Distinct nodes, in first-appearance order, so `signalRow` can map a raycast hit back to // the trace entries that live there. const signalNodes = []; const rowOfNode = new Map(); for (const t of data.trace) { if (!rowOfNode.has(t.node)) { rowOfNode.set(t.node, signalNodes.length); signalNodes.push(t.node); } } const entriesAtNode = new Map(); for (const t of data.trace) { if (!entriesAtNode.has(t.node)) entriesAtNode.set(t.node, []); entriesAtNode.get(t.node).push(t); } const signalPos = new Float32Array(signalNodes.length * 3); signalNodes.forEach((n, i) => { signalPos[i * 3] = xyz[n * 3]; signalPos[i * 3 + 1] = xyz[n * 3 + 1]; signalPos[i * 3 + 2] = xyz[n * 3 + 2]; }); const signalGeo = new THREE.BufferGeometry(); signalGeo.setAttribute('position', new THREE.BufferAttribute(signalPos, 3)); const signal = new THREE.Points(signalGeo, new THREE.PointsMaterial({ color: new THREE.Color(colors.signal), map: sprite, size: 0.062, sizeAttenuation: true, transparent: true, opacity: 1, // Highlights are the point of the picture; never let the nebula bury one. They also stay // out of the fog, so a highlight on the far side is still findable. depthTest: false, depthWrite: false, fog: false, })); signal.renderOrder = 2; world.add(signal); // --- the trace: highlights in the order they were made ----------------------------------- // Plain THREE.Line rather than Line2. Line2 gives real thickness but its geometry is // instanced, so `setDrawRange` does not clip it — the progressive draw would need // instanceCount juggling for a line that is 1px on most displays anyway. const tracePos = new Float32Array(data.trace.length * 3); data.trace.forEach((t, i) => { tracePos[i * 3] = xyz[t.node * 3]; tracePos[i * 3 + 1] = xyz[t.node * 3 + 1]; tracePos[i * 3 + 2] = xyz[t.node * 3 + 2]; }); const traceGeo = new THREE.BufferGeometry(); traceGeo.setAttribute('position', new THREE.BufferAttribute(tracePos, 3)); traceGeo.setDrawRange(0, 1); const trace = new THREE.Line(traceGeo, new THREE.LineBasicMaterial({ color: new THREE.Color(colors.trace), transparent: true, opacity: 0.6, depthTest: false, fog: false, })); trace.renderOrder = 3; world.add(trace); // The leading edge, so the eye has something to follow. const headGeo = new THREE.BufferGeometry(); headGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3)); const head = new THREE.Points(headGeo, new THREE.PointsMaterial({ color: new THREE.Color(colors.signal), map: sprite, size: 0.17, sizeAttenuation: true, transparent: true, opacity: 1, depthTest: false, fog: false, })); head.renderOrder = 4; world.add(head); // --- two standing markers ---------------------------------------------------------------- // Rings, not brighter dots. The animated `head` above is already a bright point, and at the // end of every draw cycle it comes to rest on exactly the newest node — under // prefers-reduced-motion it parks there permanently. A second filled dot at the same // coordinate reads as a rendering fault, so these are drawn as outlines instead: same // position, unmistakably a different kind of thing. function ring(colorKey, radius) { const geo = new THREE.RingGeometry(radius, radius * 1.34, 32); const mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({ color: new THREE.Color(cssVar(container, colorKey, colors.signal)), transparent: true, opacity: 0.95, side: THREE.DoubleSide, depthTest: false, depthWrite: false, fog: false, })); mesh.visible = false; mesh.renderOrder = 5; world.add(mesh); return mesh; } const newestRing = ring('--map-newest', 0.045); const quotedRing = ring('--map-quoted', 0.062); const placeRing = (mesh, node) => { if (node == null) { mesh.visible = false; return; } mesh.position.set(xyz[node * 3], xyz[node * 3 + 1], xyz[node * 3 + 2]); mesh.visible = true; }; // The newest highlight is the LAST trace entry, never the max of `t`: the payload says // trace_order is chronological and pre-sorted, `t` is date-only, and many highlights share a // day — sorting on it would quietly pick a different passage from the same date. const newestNode = data.trace.length ? data.trace[data.trace.length - 1].node : null; placeRing(newestRing, newestNode); // Set by the page once the header's random quote has loaded; the two fetches are independent // and either can win. `h` is the highlight's text_hash, which the API returns alongside the // quote — an exact key, not a text match. const nodeOfHash = new Map(data.trace.map((t) => [t.h, t.node])); const billboard = new THREE.Quaternion(); // --- overlays ---------------------------------------------------------------------------- const dateEl = document.createElement('div'); dateEl.className = 'reading-map__date'; container.appendChild(dateEl); const tipEl = document.createElement('figure'); tipEl.className = 'reading-map__tip'; tipEl.hidden = true; container.appendChild(tipEl); const fmtDate = (iso) => { const [y, m] = iso.split('-'); return new Date(Date.UTC(+y, +m - 1, 1)) .toLocaleDateString(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' }); }; function showTip(node, clientX, clientY) { const entries = entriesAtNode.get(node); if (!entries) return; const book = data.books[data.nodes.book[node]]; const e = entries[0]; const more = entries.length > 1 ? `+${entries.length - 1} more here` : ''; // The attribution is COALESCE(attributed_to, book_author) — an introduction is credited to // whoever wrote it, not to whoever wrote the book around it. const byline = [e.attribution, e.editorial ? 'introduction' : null] .filter(Boolean).join(' · '); const voice = e.spoken_by ? `spoken by ${escapeHtml(e.spoken_by)}` : ''; tipEl.innerHTML = `
${escapeHtml(e.text)}` + `