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
+10 -1
View File
@@ -57,12 +57,21 @@ const {
try { try {
const { mount } = await import('../lib/reading-map.js'); const { mount } = await import('../lib/reading-map.js');
for (const el of containers) { for (const el of containers) {
mount(el, { const map = await mount(el, {
src: el.dataset.src!, src: el.dataset.src!,
// Fail closed, like the garden's other widgets: a missing payload or a dead GPU // Fail closed, like the garden's other widgets: a missing payload or a dead GPU
// leaves a plain page rather than an empty bordered box. // leaves a plain page rather than an empty bordered box.
onError: (err: unknown) => console.warn('reading map unavailable:', err), onError: (err: unknown) => console.warn('reading map unavailable:', err),
}); });
if (!map) continue;
// Ring the node the header's quote came from. The two are independent fetches on
// every page view and either can land first, so handle both orders: read the hash if
// it is already there, and listen in case it is not. `garden:quote` is dispatched by
// the header script in Base.astro.
const ring = (hash?: string) => hash && map.markQuoted(hash);
ring(window.__gardenQuoteHash);
addEventListener('garden:quote', (e) => ring((e as CustomEvent).detail?.text_hash));
} }
} catch (err) { } catch (err) {
/* chunk failed to load: the figure stays hidden */ /* chunk failed to load: the figure stays hidden */
+117
View File
@@ -0,0 +1,117 @@
---
/**
* "Currently reading" — the book Wes most recently made progress in.
*
* import ReadingNow from '../components/ReadingNow.astro';
* <ReadingNow />
*
* Data: api.c0smere.net/reading/current, which is kobo_archive on phlegethon by way of
* api.reading_current(). Date-only by design — the timestamp would publish when he is awake.
*
* ⚠️ **It says how long ago on purpose.** Six of the ten books on the currently-reading shelf
* have not been opened in months, so this widget will sit on one entry indefinitely the moment
* the Kobo goes down for a while. Without the relative date that reads as a broken widget
* rather than an honest one; past DORMANT_DAYS it also mutes itself and says "paused".
*
* No cover image: there is no source for one. `kobo.books.isbn` is a sideloader's UUID for most
* of the shelf and `kobo.storygraph_links` carries no cover URL. The remaining route is pulling
* it out of the epub at intake time, which is a library-rag change, not a garden one.
*/
---
<section class="reading-now" id="reading-now" hidden>
<h2 class="reading-now__label">Currently reading</h2>
<p class="reading-now__title" id="rn-title"></p>
<p class="reading-now__author" id="rn-author"></p>
<div class="reading-now__bar" id="rn-bar" role="img" aria-label="" aria-hidden="false">
<span class="reading-now__fill" id="rn-fill"></span>
</div>
<p class="reading-now__meta" id="rn-meta"></p>
</section>
<script>
// Fail closed, like the garden's other widgets: the section ships hidden and only a good
// response reveals it, so a 404 (nothing in progress) or a dead API leaves a plain page.
try {
const res = await fetch('https://api.c0smere.net/reading/current');
if (!res.ok) throw new Error(String(res.status));
const r = await res.json();
const DORMANT_DAYS = 45;
const days = Number(r.days_since);
const ago =
days <= 0 ? 'today'
: days === 1 ? 'yesterday'
: days < 30 ? `${days} days ago`
: days < 60 ? 'about a month ago'
: `${Math.round(days / 30)} months ago`;
document.getElementById('rn-title')!.textContent = r.title;
document.getElementById('rn-author')!.textContent = r.author ?? '';
const pct = Math.max(0, Math.min(100, Number(r.percent)));
const fill = document.getElementById('rn-fill')!;
fill.style.width = `${pct}%`;
// The bar is decorative; the number lives in text for anyone not seeing it. Pages are
// mentioned only when the book has a real count — 203 of 237 books have none, and a
// made-up denominator is worse than no denominator.
const pages = r.total_pages ? ` · ~${Math.round((pct / 100) * r.total_pages)} of ${r.total_pages} pp` : '';
document.getElementById('rn-bar')!.setAttribute('aria-label', `${pct}% read`);
document.getElementById('rn-meta')!.textContent = `${pct}%${pages} · read ${ago}`;
const root = document.getElementById('reading-now')!;
if (days >= DORMANT_DAYS) root.dataset.dormant = 'true';
root.hidden = false;
} catch {
/* API down, blocked, or nothing in progress: the slot stays hidden */
}
</script>
<style>
.reading-now {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid var(--hair);
font-size: 0.82rem;
line-height: 1.4;
}
.reading-now[hidden] { display: none; }
.reading-now__label {
margin: 0 0 0.5rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted);
}
.reading-now__title {
margin: 0;
font-weight: 600;
color: var(--fg);
/* Titles here run long ("Introduction to Linear Algebra (Ed 5) …") and the rail is 13rem */
overflow-wrap: anywhere;
}
.reading-now__author { margin: 0.1rem 0 0.6rem; color: var(--muted); }
.reading-now__bar {
height: 0.28rem;
border-radius: 999px;
background: var(--hair);
overflow: hidden;
}
.reading-now__fill {
display: block;
height: 100%;
width: 0;
border-radius: inherit;
background: var(--link-bright, #46c421);
}
.reading-now__meta { margin: 0.45rem 0 0; color: var(--muted); }
/* Long-dormant: still shown, visibly at rest. The widget's job is to be honest about when
it was last true, not to imply the reading is ongoing. */
.reading-now[data-dormant] { opacity: 0.72; }
.reading-now[data-dormant] .reading-now__fill { background: var(--muted); }
</style>
+18
View File
@@ -0,0 +1,18 @@
/// <reference types="astro/client" />
declare global {
interface Window {
/**
* `text_hash` of the reading highlight in the page header, set by Base.astro once
* api.c0smere.net/highlights/random answers. The reading map reads it to ring the node
* that passage came from.
*
* A global as well as a `garden:quote` event because the two fetches race: whichever
* lands second needs the other's result, and only one of the two mechanisms works in
* each order.
*/
__gardenQuoteHash?: string;
}
}
export {};
+19 -5
View File
@@ -5,6 +5,7 @@ import { buildNavTree } from '../lib/nav.mjs';
import { COMMIT, BUILT_AT } from '../lib/build-info.mjs'; import { COMMIT, BUILT_AT } from '../lib/build-info.mjs';
import NavTree from '../components/NavTree.astro'; import NavTree from '../components/NavTree.astro';
import Toc from '../components/Toc.astro'; import Toc from '../components/Toc.astro';
import ReadingNow from '../components/ReadingNow.astro';
import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs'; import { loadNotebooks, notebookUrl } from '../lib/notebooks.mjs';
interface Props { interface Props {
@@ -198,13 +199,13 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
</ul> </ul>
</div> </div>
</aside> </aside>
{ {/* The rail exists whenever EITHER thing wants it. It used to be rendered only when
showToc && ( there was a table of contents, which would have left the reading widget with no home
on any page of fewer than two headings — including the homepage. */}
<aside class="toc-col"> <aside class="toc-col">
<Toc headings={headings} /> {showToc && <Toc headings={headings} />}
<ReadingNow />
</aside> </aside>
)
}
</div> </div>
<script> <script>
@@ -221,6 +222,13 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
? `— ${q.author}, ${q.book}` ? `— ${q.author}, ${q.book}`
: `— ${q.book}`; : `— ${q.book}`;
document.getElementById('hq')!.hidden = false; document.getElementById('hq')!.hidden = false;
// Hand the passage's identity to anything that can locate it — on the homepage the
// reading map rings the node it came from. Published both ways because the map's
// payload fetch races this one: the global covers "map mounted second", the event
// covers "map mounted first". text_hash, never the text, so the lookup is exact.
window.__gardenQuoteHash = q.text_hash;
dispatchEvent(new CustomEvent('garden:quote', { detail: q }));
} catch { } catch {
/* API down or blocked: slot stays hidden */ /* API down or blocked: slot stays hidden */
} }
@@ -588,6 +596,12 @@ const pageUrl = new URL(Astro.url.pathname, Astro.site);
grid-template-columns: 17rem minmax(0, 1fr); grid-template-columns: 17rem minmax(0, 1fr);
max-width: 96rem; max-width: 96rem;
} }
/* ...and the rail must be hidden, not merely unstyled. It is now always rendered (it
carries the reading widget as well as the TOC), so in a two-column grid a third
child would wrap onto a second row and land under the sidebar. */
.layout-wide .toc-col {
display: none;
}
.toc-col { .toc-col {
display: block; display: block;
position: sticky; position: sticky;
+70
View File
@@ -198,6 +198,50 @@ export function build(container, data) {
head.renderOrder = 4; head.renderOrder = 4;
world.add(head); 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 ---------------------------------------------------------------------------- // --- overlays ----------------------------------------------------------------------------
const dateEl = document.createElement('div'); const dateEl = document.createElement('div');
dateEl.className = 'reading-map__date'; dateEl.className = 'reading-map__date';
@@ -327,6 +371,15 @@ export function build(container, data) {
shownIndex = drawn - 1; shownIndex = drawn - 1;
dateEl.textContent = fmtDate(data.trace[shownIndex].t); 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); renderer.render(scene, camera);
} }
@@ -344,10 +397,27 @@ export function build(container, data) {
io.disconnect(); ro.disconnect(); io.disconnect(); ro.disconnect();
renderer.dispose(); renderer.dispose();
nebulaGeo.dispose(); signalGeo.dispose(); traceGeo.dispose(); headGeo.dispose(); nebulaGeo.dispose(); signalGeo.dispose(); traceGeo.dispose(); headGeo.dispose();
newestRing.geometry.dispose(); newestRing.material.dispose();
quotedRing.geometry.dispose(); quotedRing.material.dispose();
sprite.dispose(); sprite.dispose();
container.querySelector('canvas')?.remove(); container.querySelector('canvas')?.remove();
dateEl.remove(); tipEl.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 }, stats: { nodes: nodeCount, highlights: total, chunks: signalNodes.length },
}; };
} }
+13
View File
@@ -36,6 +36,15 @@
--map-signal: #e8eef6; --map-signal: #e8eef6;
--map-trace: var(--link-bright, #46c421); --map-trace: var(--link-bright, #46c421);
/* --- the two standing markers ------------------------------------------------------------
Drawn as rings, so colour is not what tells them from the point cloud — outline-versus-dot
already does, the same form-carries-the-distinction argument the schemes below rest on.
These hues only have to separate the two markers from EACH OTHER, and amber↔fuchsia holds
up under protanopia (ΔE 18.7) where amber↔green would not.
--map-newest: the most recent highlight. --map-quoted: the passage in the page header. */
--map-newest: #f59e0b;
--map-quoted: #e879f9;
--map-ink: var(--fg, #ffffff); --map-ink: var(--fg, #ffffff);
--map-ink-soft: var(--muted, hsla(0, 0%, 100%, 0.64)); --map-ink-soft: var(--muted, hsla(0, 0%, 100%, 0.64));
--map-surface: rgba(16, 19, 27, 0.92); --map-surface: rgba(16, 19, 27, 0.92);
@@ -60,6 +69,10 @@
.reading-map--ember { .reading-map--ember {
--map-signal: #f59e0b; --map-signal: #f59e0b;
--map-trace: var(--link-bright, #46c421); --map-trace: var(--link-bright, #46c421);
/* The default newest-marker amber IS this scheme's signal colour, which would make the ring
look like a highlight that had merely grown a hole. Sky blue is the furthest thing from
both marks here. */
--map-newest: #38bdf8;
} }
/* --- scheme: frost -------------------------------------------------------------------------- /* --- scheme: frost --------------------------------------------------------------------------