Files
garden-astro/src/lib/reading-map.js
T
wes bd6910a5dd Reading map: ring the newest highlight and the one quoted in the header
Two standing markers, drawn as rings rather than brighter dots. The animated
trace head is already a bright point and comes to rest on exactly the newest
node at the end of every draw cycle — under prefers-reduced-motion it parks
there permanently — so a second filled dot at that coordinate would read as a
rendering fault. Outlines say "different kind of thing" without competing.

The newest highlight is the LAST trace entry, never the max of `t`: the
payload is pre-sorted, `t` is date-only, and many highlights share a day.

The quoted one is looked up by text_hash, which /highlights/random now
returns beside the quote. The two fetches race on every page view, so the
hash is published both as a global and as a `garden:quote` event — one covers
each order. About one highlight in fifteen has no node, because its source
book was never indexed; markQuoted returns false and nothing is drawn.

Rings are flat geometry parented to the rotating world, so they are
billboarded each frame or they would vanish edge-on twice a revolution.

Also adds the "Currently reading" rail widget. It says how long ago on
purpose: six of the ten books on that shelf have not been opened in months,
and without a relative date the thing looks broken rather than honest. Past
45 days it mutes itself. No cover — kobo.books.isbn is a sideloader's UUID
for most of the shelf and storygraph_links has no cover URL.

The TOC rail is now always rendered, since it carries the widget too; it was
conditional on having two headings, which would have hidden the widget on the
homepage. Hidden explicitly on wide notebook pages, where a third grid child
would wrap under the sidebar.
2026-08-14 08:45:12 -04:00

430 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
? `<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;
// `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) => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
));
}