Reading map: a 3D hero of everything read, traced by what I highlighted

An orbiting point cloud of all 8,424 indexed passages, with the 262 highlighted
ones lit and threaded in the order they were marked. Data comes from library-rag
(chunks + embeddings) projected to 3D with UMAP; the page never touches Postgres.

Projection is `airy` (n_neighbors=50, min_dist=0.8) and the palette is `frost` --
both chosen by eye against the alternatives. Note that airy shows the underlying
book-territory structure LESS than the tight projection the pre-flight measured,
so the 0.805 k-NN purity figure describes that one, not this.

Page weight: the wrapper adds 321 B gzip to the homepage's blocking load. `three`
(127 KB gz) and the payload (56 KB gz) are a dynamic chunk fetched after render.
It fails closed -- no WebGL, no payload, or a dead fetch leaves a plain page.

The payload is a static snapshot; the 5-minute auto-build rebuilds the site, not
the data. New reading reaches this map only when export.py is re-run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-08-13 08:30:45 -04:00
co-authored by Claude Opus 5
parent 9da9a6cba2
commit 9519c46fe2
7 changed files with 593 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
---
/**
* The reading map — an orbiting point cloud of every passage read, traced in the
* order it was highlighted.
*
* import ReadingMap from '../components/ReadingMap.astro';
* <ReadingMap />
*
* Pieces:
* src/components/ReadingMap.astro ← this file
* src/lib/reading-map.js ← the renderer (shared with the standalone preview
* in c0smere_devops/library-rag/umap/web)
* src/styles/reading-map.css ← the chrome
* public/reading_umap.json ← `uv run umap/export.py -o …`
*
* ⚠️ The payload is a STATIC SNAPSHOT. Nothing on cyrion regenerates it — the 5-minute
* auto-build rebuilds the site, not the data. New reading only reaches this map when
* someone re-runs `export.py` and commits the result. See library-rag/umap/PLAN.md §7.
*/
import '../styles/reading-map.css';
interface Props {
/** Payload URL. Same-origin: a static file, not an API call. */
src?: string;
/** Caption under the canvas. Pass `null` to omit. */
caption?: string | null;
}
const {
src = '/reading_umap.json',
caption = 'Every passage I have read, arranged by what it is about — traced in the order I highlighted it.',
} = Astro.props;
---
<figure class="reading-map-figure">
<div class="reading-map" data-src={src}></div>
{caption && <figcaption>{caption}</figcaption>}
</figure>
<script>
// Dynamic import so `three` lands in its own chunk rather than the page bundle, and so a
// browser that never gets here (no WebGL, blocked script) pays nothing for it.
//
// ⚠️ Do NOT gate this on IntersectionObserver. `.reading-map:not([data-ready])` is
// `display: none` — that is the fail-closed rule — and a `display: none` element has no
// box, so it never intersects and the observer would never fire. Load-on-visible here
// deadlocks: hidden until mounted, never mounted because hidden. The renderer runs its
// own IntersectionObserver internally to park the animation loop offscreen, which is the
// part that actually matters for cost.
const containers = document.querySelectorAll<HTMLElement>('.reading-map');
if (containers.length) {
try {
const { mount } = await import('../lib/reading-map.js');
for (const el of containers) {
mount(el, {
src: el.dataset.src!,
// 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.
onError: (err: unknown) => console.warn('reading map unavailable:', err),
});
}
} catch (err) {
/* chunk failed to load: the figure stays hidden */
console.warn('reading map unavailable:', err);
}
}
</script>
<style>
.reading-map-figure {
margin: 0 0 2rem;
}
.reading-map-figure figcaption {
margin-top: 0.6rem;
font-size: 0.8rem;
line-height: 1.5;
opacity: 0.72;
}
/* Hide the caption too when the map itself failed to mount. */
.reading-map-figure:not(:has([data-ready])) figcaption {
display: none;
}
</style>