diff --git a/.gitignore b/.gitignore
index 9b5b325..61cae5b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,10 @@ public/nb/
# cyrion whenever the corpus or its located highlights move. It was tracked until 2026-08-14,
# which is why automating the export needed this line first.
public/reading_umap.json
+# the /reading explorer's payload — same generator, same timer, different projection
+# (`export.py --explorer`, 15/0.1 plus the kind and section layers). Tracking it would dirty
+# the tree exactly as above.
+public/reading_explorer.json
.build_state
.build.lock
.notebooks.lock
diff --git a/deploy/auto-build.sh b/deploy/auto-build.sh
index 60e290e..6d6072b 100755
--- a/deploy/auto-build.sh
+++ b/deploy/auto-build.sh
@@ -31,8 +31,11 @@ sig=$({
find "$VAULT" -name .obsidian -prune -o -type f -printf '%T@ %p\n' | sort
find "$REPO/notebooks-export" -type f -printf '%T@ %p\n' 2>/dev/null | sort
# same deal for the reading map: gitignored, written by library-rag-export.service on its
- # own timer, so git status/diff above cannot see it change
- find "$REPO/public/reading_umap.json" -printf '%T@ %p\n' 2>/dev/null | sort
+ # own timer, so git status/diff above cannot see it change. Both payloads — the banner's and
+ # the /reading explorer's — or a corpus change would rebuild one page and leave the other
+ # serving yesterday's cloud.
+ find "$REPO/public/reading_umap.json" "$REPO/public/reading_explorer.json" \
+ -printf '%T@ %p\n' 2>/dev/null | sort
} | sha256sum | cut -d' ' -f1)
if [ "$FORCE" -eq 0 ] && [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then
diff --git a/src/components/ReadingExplorer.astro b/src/components/ReadingExplorer.astro
new file mode 100644
index 0000000..0dde016
--- /dev/null
+++ b/src/components/ReadingExplorer.astro
@@ -0,0 +1,145 @@
+---
+/**
+ * The reading explorer — the same cloud as the banner, made legible.
+ *
+ * import ReadingExplorer from '../components/ReadingExplorer.astro';
+ *
+ *
+ * Pieces:
+ * src/components/ReadingExplorer.astro ← this file
+ * src/lib/reading-map.js ← the renderer, SHARED with the banner (explorer: true)
+ * src/styles/reading-map.css ← canvas, tooltip, every --map-* token
+ * src/styles/reading-explorer.css ← the legend and picker below
+ * public/reading_explorer.json ← generated; NOT in git
+ *
+ * ⚠️ **A different payload from the banner, and a different projection.** The banner is
+ * `export.py` at 50/0.8 (`airy`); this is `export.py --explorer` at 15/0.1 plus per-node `kind`
+ * and `section` layers. The two are not interchangeable — 50/0.8 was chosen for the banner
+ * *because* it shows book territory less clearly, which is the one thing this page exists to
+ * show. See library-rag/lab/PLAN.md §0.
+ *
+ * ⚠️ **The payload is gitignored and generated on cyrion**, exactly like the banner's. A fresh
+ * checkout has no map until the export runs; anywhere that is not cyrion, that is never, and the
+ * figure stays hidden. Fetch garden.c0smere.net/reading_explorer.json, or run
+ * `uv run umap/export.py --explorer -o /public/reading_explorer.json` from library-rag/.
+ */
+import '../styles/reading-map.css';
+import '../styles/reading-explorer.css';
+
+interface Props {
+ /** Payload URL. Same-origin: a static file, not an API call. */
+ src?: string;
+ caption?: string | null;
+}
+
+const {
+ src = '/reading_explorer.json',
+ caption = 'Every passage of every book, placed by what it is about and coloured by what kind of book it came from. Pick a title to see where it sits.',
+} = Astro.props;
+---
+
+
+
+ {caption && {caption}}
+
+ {/* Filled from the payload at mount — counts and swatches both. Neither is hardcoded: a kind's
+ share moves every time a book is indexed, and two sources for one colour is how a legend
+ ends up describing a picture that is no longer on screen. */}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/reading-map.js b/src/lib/reading-map.js
index 3358371..cf2e1c0 100644
--- a/src/lib/reading-map.js
+++ b/src/lib/reading-map.js
@@ -22,6 +22,15 @@ 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)');
+/**
+ * Explorer only: how much a chunk outside the isolated book is knocked back.
+ *
+ * A dim, not a hide. Isolating a book against an empty frame answers "where are its chunks" and
+ * destroys the only question worth asking — "where does it sit *relative to everything else*."
+ * The rest of the corpus has to stay legible as context or the feature is pointless.
+ */
+const ISOLATE_DIM = 0.085;
+
/** 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();
@@ -71,11 +80,11 @@ function perVertexSize(shader) {
shader.vertexShader.replace(target, 'gl_PointSize = size * aScale;');
}
-export async function mount(container, { src, onError } = {}) {
+export async function mount(container, { src, onError, explorer = false } = {}) {
try {
const res = await fetch(src);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
- return build(container, await res.json());
+ return build(container, await res.json(), { explorer });
} 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.
@@ -84,7 +93,7 @@ export async function mount(container, { src, onError } = {}) {
}
}
-export function build(container, data) {
+export function build(container, data, { explorer = false } = {}) {
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const colors = {
@@ -96,6 +105,24 @@ export function build(container, data) {
quoted: cssVar(container, '--map-quoted', '#e879f9'),
};
+ // Kind colouring is on only when the payload actually CARRIES the layer, not merely when the
+ // caller asked for it. `export.py` and `export.py --explorer` write different files, and a
+ // page pointed at the wrong one should degrade to the flat cloud rather than throw.
+ const byKind = explorer && Array.isArray(data.nodes.kind) && Array.isArray(data.meta?.kinds);
+ if (byKind) {
+ // Slots 1–3 of the validated categorical palette, in fixed order: blue, orange, aqua. These
+ // three clear the ALL-PAIRS CVD and normal-vision floors against this exact surface
+ // (#0b0d12), which the adjacent-pairs default does not guarantee — a point cloud puts every
+ // category next to every other one, so all-pairs is the right test for it.
+ //
+ // ⚠️ Fixed order, never cycled. A fourth kind does not get a generated hue; it stays
+ // `unclassified` grey until someone decides what it is. See library-rag/umap/kinds.py.
+ data.meta.kinds.forEach((k, i) => {
+ colors[`kind-${i}`] = cssVar(container, `--map-kind-${k}`,
+ ['#3987e5', '#d95926', '#199e70'][i] || '#6b7280');
+ });
+ }
+
// --- scene ------------------------------------------------------------------------------
const scene = new THREE.Scene();
@@ -125,16 +152,41 @@ export function build(container, data) {
// --- the nebula: every chunk, one draw call ---------------------------------------------
const nebulaGeo = new THREE.BufferGeometry();
nebulaGeo.setAttribute('position', new THREE.BufferAttribute(xyz, 3));
+
+ // Per-vertex colour, and the untouched per-vertex BASE kept alongside it. Isolation rewrites
+ // the live attribute in place, so without a pristine copy to restore from, dimming twice
+ // would compound and the cloud would fade a notch on every hover.
+ let nebulaBase = null;
+ if (byKind) {
+ const rgb = new Float32Array(nodeCount * 3);
+ const swatch = data.meta.kinds.map((_, i) => new THREE.Color(colors[`kind-${i}`]));
+ for (let i = 0; i < nodeCount; i++) {
+ const c = swatch[data.nodes.kind[i]] || swatch[0];
+ rgb[i * 3] = c.r; rgb[i * 3 + 1] = c.g; rgb[i * 3 + 2] = c.b;
+ }
+ nebulaGeo.setAttribute('color', new THREE.BufferAttribute(rgb, 3));
+ nebulaBase = Float32Array.from(rgb);
+ }
+
// 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.
+ //
+ // ⚠️ The explorer runs SMALLER AND DENSER, and that pairing is not cosmetic. Three hues
+ // accumulating in one volume at the banner's 0.055/0.3 average toward a single grey-brown —
+ // soft overlapping sprites blend toward neutral, which is invisible with one colour and fatal
+ // with three. Smaller points overlap less; more opacity keeps each one's hue.
+ //
+ // `vertexColors` MULTIPLIES material.color by the attribute, so the material must be white or
+ // every kind would be filtered through the nebula hue. Same trap the signal layer documents.
const nebula = new THREE.Points(nebulaGeo, new THREE.PointsMaterial({
- color: new THREE.Color(colors.nebula),
+ color: byKind ? 0xffffff : new THREE.Color(colors.nebula),
+ vertexColors: byKind,
map: sprite,
- size: 0.055,
+ size: byKind ? 0.032 : 0.055,
sizeAttenuation: true,
transparent: true,
- opacity: 0.3,
+ opacity: byKind ? 0.62 : 0.3,
depthWrite: false,
fog: true,
}));
@@ -420,6 +472,39 @@ export function build(container, data) {
});
}
+ // --- isolation ----------------------------------------------------------------------------
+ /**
+ * Knock every chunk outside one book back to a whisper. `null` restores the whole cloud.
+ *
+ * Scales each dimmed vertex toward black rather than lerping toward the fog colour. The garden
+ * is fixed dark and `--map-fog` IS `--bg` (#0b0d12), so multiplying down lands where a real
+ * alpha fade would — and `PointsMaterial` has no per-vertex alpha, which is what this would
+ * otherwise need. ⚠️ On a light surface this must become a lerp toward fog instead; multiplying
+ * toward black there would make dimmed points the most prominent thing on screen.
+ *
+ * Always restores from `nebulaBase`, never from the live attribute — see its declaration.
+ */
+ let isolated = null;
+ function isolateBook(bookIndex) {
+ if (!byKind || !nebulaBase) return null;
+ const attr = nebulaGeo.getAttribute('color');
+ const rgb = attr.array;
+ if (bookIndex === null || bookIndex === undefined) {
+ rgb.set(nebulaBase);
+ isolated = null;
+ } else {
+ for (let i = 0; i < nodeCount; i++) {
+ const k = data.nodes.book[i] === bookIndex ? 1 : ISOLATE_DIM;
+ rgb[i * 3] = nebulaBase[i * 3] * k;
+ rgb[i * 3 + 1] = nebulaBase[i * 3 + 1] * k;
+ rgb[i * 3 + 2] = nebulaBase[i * 3 + 2] * k;
+ }
+ isolated = bookIndex;
+ }
+ attr.needsUpdate = true;
+ return isolated;
+ }
+
// --- 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
@@ -527,6 +612,32 @@ export function build(container, data) {
// renderer drew with — including whichever `--map-*` scheme modifier is on the container.
// Two sources for one colour is how a legend ends up quietly lying about the picture.
colors,
+
+ /** Explorer only. A no-op returning null when the payload carries no kind layer. */
+ isolateBook,
+ get isolatedBook() { return isolated; },
+
+ /**
+ * What a legend needs, COUNTED FROM THE PAYLOAD rather than hardcoded. A kind's share moves
+ * every time a book is indexed, and the same rule the swatches follow applies to the counts:
+ * a legend that disagrees with the picture is worse than no legend at all.
+ *
+ * Empty when there is no kind layer, so a caller can branch on `.length`.
+ */
+ kinds: byKind
+ ? data.meta.kinds.map((name, i) => ({
+ name,
+ color: colors[`kind-${i}`],
+ chunks: data.nodes.kind.reduce((n, k) => n + (k === i), 0),
+ // `{ title, index }`, not bare titles: `index` is the position in `data.books`, which
+ // is exactly what `isolateBook()` takes. Handing back titles alone would make every
+ // caller re-derive the index by searching on a string — and two books could share one.
+ books: data.books
+ .map((b, index) => ({ title: b.title, index, kind: b.kind }))
+ .filter((b) => b.kind === i)
+ .map(({ title, index }) => ({ title, index })),
+ }))
+ : [],
};
}
diff --git a/src/pages/reading.astro b/src/pages/reading.astro
new file mode 100644
index 0000000..bae52e2
--- /dev/null
+++ b/src/pages/reading.astro
@@ -0,0 +1,66 @@
+---
+import Base from '../layouts/Base.astro';
+import ReadingExplorer from '../components/ReadingExplorer.astro';
+---
+
+
+
The reading map
+
+
+ The homepage banner draws this same cloud in a single colour, arranged to look like
+ weather. This is the version arranged to be read: same corpus, a tighter
+ projection, and every passage coloured by what kind of book it came from.
+
+
+
+
+
What you are looking at
+
+
+ Every book is split into passages of roughly 500 words, and each passage is turned into
+ 768 numbers by an embedding model — a position in a space where things that mean similar
+ things end up near each other. That space is then squashed down to three dimensions so it
+ fits in a browser. Nearness is meaning; the axes themselves mean nothing at all.
+
+
+
+ The obvious question is whether this is a map of ideas or just a map of
+ books, and it is measurably the latter: 82% of a passage's fifteen nearest
+ neighbours come from the same book, against 6% if they were drawn at random. Prose style
+ is a much louder signal than subject matter. So each book is an island — and the honest
+ interesting part is how the islands are arranged.
+
+
+
+ Cluster the map without telling it anything about the books, and it splits the Warhammer
+ 40,000 novels from everything else with not one book straddling the line. Split it further
+ and the treatises — Aurelius, Seneca, Aristotle, Beauvoir — pull away from the narratives,
+ with Nausea, The Stranger and Notes From Underground landing
+ beside Franklin and Lovecraft rather than beside the philosophy they are usually shelved
+ with. Nobody labelled that. It fell out of the prose.
+
+
+
+ Two honest caveats. Narrative is one colour occupying two separate regions, because
+ Empire of the Vampire is large enough to hold a territory of its own — that is book
+ size, not a fourth kind of book. And Notes From Underground genuinely refuses to sit
+ still: only 68% of it lands with its own group, which is about right for a book that is half
+ confession and half argument.
+
+
+
+ The passages themselves are not in the file this page downloads — only coordinates, which
+ cannot be turned back into text. The quotes that appear when you hover a highlighted point
+ are ones already published under Quotes.
+
+
+
+
diff --git a/src/styles/reading-explorer.css b/src/styles/reading-explorer.css
new file mode 100644
index 0000000..c3ee341
--- /dev/null
+++ b/src/styles/reading-explorer.css
@@ -0,0 +1,112 @@
+/*
+ * Chrome for the reading EXPLORER — the /reading page.
+ *
+ * Loads on top of `reading-map.css`, which owns the canvas, the tooltip and every `--map-*`
+ * token including the kind colours. This file is only the controls the banner does not have:
+ * the kind legend and the book picker.
+ *
+ * ⚠️ Same rule as its sibling: the garden is fixed dark, so nothing here keys off
+ * `prefers-color-scheme`. The palette follows the SITE.
+ */
+
+/* Taller than the banner. This is the page's subject rather than a header ornament, and book
+ territory is what the reader is here to look at — at the banner's height, the isolated book's
+ chunks are too few pixels to locate. */
+.reading-map--explorer {
+ height: clamp(26rem, 78vh, 52rem);
+}
+
+/* --- the kind legend ------------------------------------------------------------------------
+ Colour is doing real encoding work here, unlike on the banner, so the legend is not optional
+ chrome: it is where hue is bound to meaning. It stays in the DOM and unhidden even before the
+ map mounts, because it is also the page's only statement of what the three groups ARE. */
+.reading-legend {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem 1.3rem;
+ margin: 0.85rem 0 0;
+ padding: 0;
+ list-style: none;
+ font-size: 0.8rem;
+ line-height: 1.5;
+}
+
+.reading-legend li {
+ display: flex;
+ align-items: baseline;
+ gap: 0.45rem;
+}
+
+/* Swatches are painted from the palette the RENDERER resolved, not from CSS — see the component
+ script. One source for a colour, or the legend eventually describes a picture that changed. */
+.reading-legend i {
+ flex: none;
+ width: 0.62rem;
+ height: 0.62rem;
+ border-radius: 50%;
+ transform: translateY(0.06rem);
+}
+
+.reading-legend b {
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.reading-legend .reading-legend__count {
+ color: var(--muted, hsla(0, 0%, 100%, 0.64));
+ font-weight: 400;
+}
+
+/* --- the book picker ------------------------------------------------------------------------ */
+.reading-picker {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.55rem;
+ margin: 1rem 0 0;
+ font-size: 0.82rem;
+}
+
+.reading-picker label {
+ color: var(--muted, hsla(0, 0%, 100%, 0.64));
+}
+
+.reading-picker select {
+ flex: 1 1 16rem;
+ max-width: 28rem;
+ padding: 0.34rem 0.5rem;
+ font: inherit;
+ color: var(--fg, #fff);
+ background: var(--map-surface, rgba(16, 19, 27, 0.92));
+ border: 1px solid var(--hair, hsla(0, 0%, 100%, 0.14));
+ border-radius: 0.3rem;
+}
+
+/* The native menu renders in the OS palette on most platforms; these keep it legible on the
+ ones that honour author colours for options. */
+.reading-picker select optgroup,
+.reading-picker select option {
+ color: var(--fg, #fff);
+ background: #12151d;
+}
+
+.reading-picker button {
+ padding: 0.34rem 0.7rem;
+ font: inherit;
+ color: var(--fg, #fff);
+ background: transparent;
+ border: 1px solid var(--hair, hsla(0, 0%, 100%, 0.14));
+ border-radius: 0.3rem;
+ cursor: pointer;
+}
+.reading-picker button:hover { border-color: var(--link-bright, #46c421); }
+.reading-picker button[hidden] { display: none; }
+
+/* Hide the controls entirely when the map failed to mount — the fail-closed rule the banner
+ already follows. A picker that drives nothing is worse than no picker. */
+.reading-explorer:not(:has([data-ready])) .reading-picker,
+.reading-explorer:not(:has([data-ready])) .reading-legend { display: none; }
+
+@media (prefers-reduced-motion: reduce) {
+ .reading-picker select, .reading-picker button { transition: none; }
+}
diff --git a/src/styles/reading-map.css b/src/styles/reading-map.css
index e1332cd..1860e19 100644
--- a/src/styles/reading-map.css
+++ b/src/styles/reading-map.css
@@ -29,6 +29,27 @@
and it is 7,625 points against the signal's 206 — any real saturation here shouts. */
--map-nebula: #46536b;
+ /* --- kind colours: the EXPLORER only ------------------------------------------------------
+ The banner draws one flat `--map-nebula`; /reading colours each chunk by what kind of book
+ it came from. Slots 1–3 of the validated categorical palette, in fixed order.
+
+ ⚠️ Validated ALL-PAIRS, not adjacent-pairs, against this file's `--map-fog` (#0b0d12). A
+ point cloud has no series order — every kind touches every other kind somewhere in the
+ volume — so the adjacent-pairs default that suits stacks and lines is the wrong test here.
+ Measured (OKLab ×100): worst pair CVD ΔE 9.4 (deutan, aqua↔orange), worst normal-vision
+ ΔE 20.9 (aqua↔blue), all three ≥3:1 contrast on the surface.
+
+ ⚠️ `--map-kind-narrative` is aqua and `--map-trace` is the garden green. They are close in
+ hue, and that is tolerable ONLY because form separates them absolutely: the kinds are a
+ diffuse cloud of points, the trace is one continuous line. Nothing is encoded by their
+ similarity. If the trace ever becomes points, this pair has to be re-measured.
+
+ A fourth kind is NOT given a generated hue — see umap/kinds.py. It falls back to grey. */
+ --map-kind-warhammer: #3987e5;
+ --map-kind-narrative: #d95926;
+ --map-kind-treatise: #199e70;
+ --map-kind-unclassified: #6b7280;
+
/* --- the chosen scheme: `frost` ---------------------------------------------------------
White-hot highlights over a cool cloud, with the garden's green as the thread. Picked
2026-08-12. It is also the only one of the three whose two marks are far enough apart