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