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:
@@ -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 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;
|
||||
|
||||
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) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
Reference in New Issue
Block a user