Reading map: a 3D hero of everything read, traced by what I highlighted

An orbiting point cloud of all 8,424 indexed passages, with the 262 highlighted
ones lit and threaded in the order they were marked. Data comes from library-rag
(chunks + embeddings) projected to 3D with UMAP; the page never touches Postgres.

Projection is `airy` (n_neighbors=50, min_dist=0.8) and the palette is `frost` --
both chosen by eye against the alternatives. Note that airy shows the underlying
book-territory structure LESS than the tight projection the pre-flight measured,
so the 0.805 k-NN purity figure describes that one, not this.

Page weight: the wrapper adds 321 B gzip to the homepage's blocking load. `three`
(127 KB gz) and the payload (56 KB gz) are a dynamic chunk fetched after render.
It fails closed -- no WebGL, no payload, or a dead fetch leaves a plain page.

The payload is a static snapshot; the 5-minute auto-build rebuilds the site, not
the data. New reading reaches this map only when export.py is re-run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-08-13 08:30:45 -04:00
co-authored by Claude Opus 5
parent 9da9a6cba2
commit 9519c46fe2
7 changed files with 593 additions and 0 deletions
+356
View File
@@ -0,0 +1,356 @@
/**
* 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);
// --- 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
? `<span class="reading-map__more">+${entries.length - 1} more here</span>` : '';
// 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
? `<span class="reading-map__voice">spoken by ${escapeHtml(e.spoken_by)}</span>` : '';
tipEl.innerHTML =
`<blockquote>${escapeHtml(e.text)}</blockquote>` +
`<figcaption><strong>${escapeHtml(book.title)}</strong>` +
`<span>${escapeHtml(byline)}</span>${voice}` +
`<time>${fmtDate(e.t)}</time>${more}</figcaption>`;
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 JulyAugust
// 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;
if (!paused) 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);
}
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();
sprite.dispose();
container.querySelector('canvas')?.remove();
dateEl.remove(); tipEl.remove();
},
stats: { nodes: nodeCount, highlights: total, chunks: signalNodes.length },
};
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}