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.
This commit is contained in:
wes
2026-08-14 08:45:12 -04:00
parent d0f56e4881
commit bd6910a5dd
6 changed files with 249 additions and 8 deletions
+70
View File
@@ -198,6 +198,50 @@ export function build(container, data) {
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';
@@ -327,6 +371,15 @@ export function build(container, data) {
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);
}
@@ -344,10 +397,27 @@ export function build(container, data) {
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 },
};
}