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.
430 lines
18 KiB
JavaScript
430 lines
18 KiB
JavaScript
/**
|
||
* 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 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]
|
||
));
|
||
}
|