/** * 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)}
` + `
${escapeHtml(book.title)}` + `${escapeHtml(byline)}${voice}` + `${more}
`; tipEl.hidden = false; const r = container.getBoundingClientRect(); const w = tipEl.offsetWidth, h = tipEl.offsetHeight; let x = clientX - r.left + 16, y = clientY - r.top + 16; if (x + w > r.width) x = clientX - r.left - w - 16; if (y + h > r.height) y = clientY - r.top - h - 16; tipEl.style.transform = `translate(${Math.max(8, x)}px, ${Math.max(8, y)}px)`; } // --- picking ------------------------------------------------------------------------------ const raycaster = new THREE.Raycaster(); raycaster.params.Points.threshold = 0.045; const pointer = new THREE.Vector2(); let hoverNode = null; let paused = false; function onMove(ev) { const r = renderer.domElement.getBoundingClientRect(); pointer.x = ((ev.clientX - r.left) / r.width) * 2 - 1; pointer.y = -((ev.clientY - r.top) / r.height) * 2 + 1; raycaster.setFromCamera(pointer, camera); const hit = raycaster.intersectObject(signal, false)[0]; if (hit) { hoverNode = signalNodes[hit.index]; paused = true; // hold the orbit still while reading showTip(hoverNode, ev.clientX, ev.clientY); renderer.domElement.style.cursor = 'pointer'; } else if (hoverNode !== null) { hoverNode = null; paused = false; tipEl.hidden = true; renderer.domElement.style.cursor = ''; } } if (POINTER_FINE.matches) { renderer.domElement.addEventListener('pointermove', onMove); renderer.domElement.addEventListener('pointerleave', () => { hoverNode = null; paused = false; tipEl.hidden = true; }); } // --- resize ------------------------------------------------------------------------------- // ⚠️ Mark ready BEFORE measuring. The fail-closed CSS hides the container until `data-ready` // is set, so a container measured first reports 0×0, the early return leaves the canvas at // three's 300×150 default, and the scene renders correct geometry into the wrong box. It // recovers on the next ResizeObserver callback, which makes the bug intermittent rather than // absent — the worst kind. Reveal, then measure. container.dataset.ready = 'true'; const resize = () => { const { clientWidth: w, clientHeight: h } = container; if (!w || !h) return; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); }; const ro = new ResizeObserver(resize); ro.observe(container); resize(); // --- loop --------------------------------------------------------------------------------- // Constant INDEX rate, not constant time rate. 188 of 293 highlights fall in July–August // 2025 and several months are empty; pacing by wall-clock date would park the head in one // cluster for most of the loop and then crawl through dead air. The date readout carries // the unevenness instead, which is the honest place for it. let raf = 0, last = performance.now(), elapsed = 0, shownIndex = -1; const total = data.trace.length; function frame(now) { raf = requestAnimationFrame(frame); const dt = Math.min((now - last) / 1000, 0.05); last = now; // `reduced` stops the orbit outright, not just the trace draw. This sits on a public // homepage, and a continuously rotating scene is exactly what a reader who asked for // reduced motion is asking not to get. The cloud still renders — it just holds still. if (!paused && !reduced) world.rotation.y += ORBIT_RATE * dt; elapsed += dt; const cycle = DRAW_SECONDS + HOLD_SECONDS; if (elapsed > cycle) elapsed -= cycle; const drawn = reduced ? total : Math.max(1, Math.min(total, Math.round((elapsed / DRAW_SECONDS) * total))); traceGeo.setDrawRange(0, drawn); const h = (drawn - 1) * 3; head.geometry.attributes.position.setXYZ(0, tracePos[h], tracePos[h + 1], tracePos[h + 2]); head.geometry.attributes.position.needsUpdate = true; if (drawn - 1 !== shownIndex) { shownIndex = drawn - 1; dateEl.textContent = fmtDate(data.trace[shownIndex].t); } // Billboard the markers. They are flat ring geometry parented to `world`, so without this // they turn edge-on — invisible — twice per revolution. Cancelling the world's rotation // leaves them facing the camera, which never moves. if (newestRing.visible || quotedRing.visible) { billboard.copy(world.quaternion).invert(); newestRing.quaternion.copy(billboard); quotedRing.quaternion.copy(billboard); } renderer.render(scene, camera); } // Nothing runs until the banner is actually on screen — an orbiting WebGL scene has no // business burning a frame budget while the reader is three screens down. const io = new IntersectionObserver(([entry]) => { if (entry.isIntersecting && !raf) { last = performance.now(); raf = requestAnimationFrame(frame); } else if (!entry.isIntersecting && raf) { cancelAnimationFrame(raf); raf = 0; } }, { threshold: 0.01 }); io.observe(container); return { destroy() { cancelAnimationFrame(raf); io.disconnect(); ro.disconnect(); renderer.dispose(); nebulaGeo.dispose(); signalGeo.dispose(); traceGeo.dispose(); headGeo.dispose(); newestRing.geometry.dispose(); newestRing.material.dispose(); quotedRing.geometry.dispose(); quotedRing.material.dispose(); sprite.dispose(); container.querySelector('canvas')?.remove(); dateEl.remove(); tipEl.remove(); }, /** * Ring the node a quoted passage came from. `textHash` is the `text_hash` that * api.c0smere.net/highlights/random returns beside the quote. * * Returns false when that passage has no node — about 1 highlight in 15, because its * source book has never been indexed into library-rag and so was never placed. That is a * normal outcome, not an error: the caller does nothing and the map simply shows no ring. * The set shrinks as those sources get indexed. */ markQuoted(textHash) { const node = nodeOfHash.get(textHash); placeRing(quotedRing, node ?? null); return node != null; }, stats: { nodes: nodeCount, highlights: total, chunks: signalNodes.length }, }; } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] )); }