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:
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user