reading map: a second page, arranged to be read rather than to look like weather
The banner draws all 9,194 chunks in one flat colour. `nodes.book` was in the
payload but only ever reached the tooltip, so nothing on screen said which book
or which region you were looking at.
This adds /reading: the same corpus at the pre-flight's 15/0.1 instead of the
banner's 50/0.8, coloured by kind of book, with any single title isolatable.
The two projections are NOT interchangeable, and that is why this is a separate
page rather than a mode of the banner. 50/0.8 was chosen *because* it blurs book
territory into something that reads as a cloud; showing that territory is the one
thing this page exists to do.
Renderer notes:
• Explorer mode turns on only when the payload actually carries a `kind`
layer, not when the caller asks for it — a page pointed at the banner's
file degrades to the flat cloud instead of throwing.
• Smaller and denser points (0.032/0.62 vs 0.055/0.3). Three hues piling up
in one volume average toward grey; that pairing is what keeps each one's
hue, and it is not cosmetic.
• Isolation dims to 8.5% rather than hiding. Against an empty frame you learn
where a book's chunks are and lose the only question worth asking, which is
where they sit relative to everything else.
• `nebulaBase` keeps a pristine copy of the vertex colours; dimming from an
already-dimmed buffer compounds and the cloud would fade on every hover.
Kind colours are slots 1-3 of the validated categorical palette, checked
ALL-PAIRS against this file's own #0b0d12 — a point cloud has no series order,
so the adjacent-pairs default that suits stacks and lines is the wrong test.
Worst pair CVD dE 9.4, worst normal-vision dE 20.9.
⚠️ `--map-kind-narrative` (aqua) sits near `--map-trace` (the garden green).
Tolerable only because form separates them absolutely — diffuse points versus
one continuous line — and nothing is encoded by the similarity. If the trace
ever becomes points, re-measure that pair.
The payload is gitignored for the same reason the banner's is: a tracked
generated file dirties the tree and breaks the `git pull` at the top of every
build tick. auto-build.sh's signature now watches both, or a corpus change
would rebuild one page and leave the other on yesterday's cloud.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,10 @@ public/nb/
|
|||||||
# cyrion whenever the corpus or its located highlights move. It was tracked until 2026-08-14,
|
# 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.
|
# which is why automating the export needed this line first.
|
||||||
public/reading_umap.json
|
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_state
|
||||||
.build.lock
|
.build.lock
|
||||||
.notebooks.lock
|
.notebooks.lock
|
||||||
|
|||||||
@@ -31,8 +31,11 @@ sig=$({
|
|||||||
find "$VAULT" -name .obsidian -prune -o -type f -printf '%T@ %p\n' | sort
|
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
|
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
|
# 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
|
# own timer, so git status/diff above cannot see it change. Both payloads — the banner's and
|
||||||
find "$REPO/public/reading_umap.json" -printf '%T@ %p\n' 2>/dev/null | sort
|
# 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)
|
} | sha256sum | cut -d' ' -f1)
|
||||||
|
|
||||||
if [ "$FORCE" -eq 0 ] && [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then
|
if [ "$FORCE" -eq 0 ] && [ -f "$STATE" ] && [ "$(cat "$STATE")" = "$sig" ]; then
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* The reading explorer — the same cloud as the banner, made legible.
|
||||||
|
*
|
||||||
|
* import ReadingExplorer from '../components/ReadingExplorer.astro';
|
||||||
|
* <ReadingExplorer />
|
||||||
|
*
|
||||||
|
* 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 <checkout>/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;
|
||||||
|
---
|
||||||
|
|
||||||
|
<figure class="reading-explorer">
|
||||||
|
<div class="reading-map reading-map--explorer" data-src={src}></div>
|
||||||
|
{caption && <figcaption>{caption}</figcaption>}
|
||||||
|
|
||||||
|
{/* 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. */}
|
||||||
|
<ul class="reading-legend" data-legend></ul>
|
||||||
|
|
||||||
|
<div class="reading-picker">
|
||||||
|
<label for="reading-book">Isolate a book</label>
|
||||||
|
<select id="reading-book" data-picker>
|
||||||
|
<option value="">All {' '}books</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" data-clear hidden>Show all</button>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const figure = document.querySelector<HTMLElement>('.reading-explorer');
|
||||||
|
const container = figure?.querySelector<HTMLElement>('.reading-map');
|
||||||
|
|
||||||
|
if (container) {
|
||||||
|
try {
|
||||||
|
// Dynamic import so `three` lands in its own chunk. Same reasoning as the banner, and the
|
||||||
|
// same ⚠️: do NOT gate this on IntersectionObserver. `.reading-map:not([data-ready])` is
|
||||||
|
// `display: none`, a display-none element never intersects, and load-on-visible deadlocks.
|
||||||
|
const { mount } = await import('../lib/reading-map.js');
|
||||||
|
const map = await mount(container, {
|
||||||
|
src: container.dataset.src!,
|
||||||
|
explorer: true,
|
||||||
|
onError: (err: unknown) => console.warn('reading explorer unavailable:', err),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
const legend = figure!.querySelector<HTMLElement>('[data-legend]');
|
||||||
|
const picker = figure!.querySelector<HTMLSelectElement>('[data-picker]');
|
||||||
|
const clear = figure!.querySelector<HTMLButtonElement>('[data-clear]');
|
||||||
|
|
||||||
|
// --- legend ---------------------------------------------------------------------
|
||||||
|
// `map.kinds` is empty when the payload has no kind layer (i.e. someone pointed this at
|
||||||
|
// the banner's file). Then there is nothing to explain and the legend stays empty rather
|
||||||
|
// than inventing categories.
|
||||||
|
if (legend && map.kinds.length) {
|
||||||
|
for (const k of map.kinds) {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const dot = document.createElement('i');
|
||||||
|
dot.style.background = k.color;
|
||||||
|
const name = document.createElement('b');
|
||||||
|
name.textContent = k.name;
|
||||||
|
const count = document.createElement('span');
|
||||||
|
count.className = 'reading-legend__count';
|
||||||
|
count.textContent = `${k.chunks.toLocaleString()} passages · ${k.books.length} books`;
|
||||||
|
li.append(dot, name, count);
|
||||||
|
legend.append(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- picker ---------------------------------------------------------------------
|
||||||
|
// Grouped by kind so the menu carries the same structure the colours do, and sorted
|
||||||
|
// by title inside each group. The option VALUE is the book's index in `data.books`,
|
||||||
|
// which is exactly what isolateBook() takes.
|
||||||
|
if (picker && map.kinds.length) {
|
||||||
|
for (const k of map.kinds) {
|
||||||
|
const group = document.createElement('optgroup');
|
||||||
|
group.label = k.name;
|
||||||
|
const books = [...k.books].sort((a, b) => a.title.localeCompare(b.title));
|
||||||
|
for (const { title, index } of books) {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = String(index);
|
||||||
|
opt.textContent = title;
|
||||||
|
group.append(opt);
|
||||||
|
}
|
||||||
|
if (group.childElementCount) picker.append(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
picker.addEventListener('change', () => {
|
||||||
|
const v = picker.value;
|
||||||
|
map.isolateBook(v === '' ? null : Number(v));
|
||||||
|
if (clear) clear.hidden = v === '';
|
||||||
|
});
|
||||||
|
clear?.addEventListener('click', () => {
|
||||||
|
picker.value = '';
|
||||||
|
map.isolateBook(null);
|
||||||
|
clear.hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
/* chunk failed to load: the figure stays hidden by the fail-closed CSS */
|
||||||
|
console.warn('reading explorer unavailable:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reading-explorer { margin: 0 0 2rem; }
|
||||||
|
.reading-explorer figcaption {
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
opacity: 0.74;
|
||||||
|
}
|
||||||
|
.reading-explorer:not(:has([data-ready])) figcaption { display: none; }
|
||||||
|
</style>
|
||||||
+117
-6
@@ -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 ORBIT_RATE = 0.035; // radians/sec — slow enough to read text over
|
||||||
const POINTER_FINE = window.matchMedia('(pointer: fine)');
|
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. */
|
/** Read a CSS custom property off the container, with a fallback. Keeps theming in CSS. */
|
||||||
function cssVar(el, name, fallback) {
|
function cssVar(el, name, fallback) {
|
||||||
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
const v = getComputedStyle(el).getPropertyValue(name).trim();
|
||||||
@@ -71,11 +80,11 @@ function perVertexSize(shader) {
|
|||||||
shader.vertexShader.replace(target, 'gl_PointSize = size * aScale;');
|
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 {
|
try {
|
||||||
const res = await fetch(src);
|
const res = await fetch(src);
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
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) {
|
} catch (err) {
|
||||||
// Fail closed, the way the garden's other widgets do: leave the container hidden rather
|
// 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.
|
// 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 reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
const colors = {
|
const colors = {
|
||||||
@@ -96,6 +105,24 @@ export function build(container, data) {
|
|||||||
quoted: cssVar(container, '--map-quoted', '#e879f9'),
|
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 ------------------------------------------------------------------------------
|
// --- scene ------------------------------------------------------------------------------
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
|
|
||||||
@@ -125,16 +152,41 @@ export function build(container, data) {
|
|||||||
// --- the nebula: every chunk, one draw call ---------------------------------------------
|
// --- the nebula: every chunk, one draw call ---------------------------------------------
|
||||||
const nebulaGeo = new THREE.BufferGeometry();
|
const nebulaGeo = new THREE.BufferGeometry();
|
||||||
nebulaGeo.setAttribute('position', new THREE.BufferAttribute(xyz, 3));
|
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
|
// 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
|
// 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 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({
|
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,
|
map: sprite,
|
||||||
size: 0.055,
|
size: byKind ? 0.032 : 0.055,
|
||||||
sizeAttenuation: true,
|
sizeAttenuation: true,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
opacity: 0.3,
|
opacity: byKind ? 0.62 : 0.3,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
fog: true,
|
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 -------------------------------------------------------------------------------
|
// --- resize -------------------------------------------------------------------------------
|
||||||
// ⚠️ Mark ready BEFORE measuring. The fail-closed CSS hides the container until `data-ready`
|
// ⚠️ 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
|
// 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.
|
// 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.
|
// Two sources for one colour is how a legend ends up quietly lying about the picture.
|
||||||
colors,
|
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 })),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
import Base from '../layouts/Base.astro';
|
||||||
|
import ReadingExplorer from '../components/ReadingExplorer.astro';
|
||||||
|
---
|
||||||
|
|
||||||
|
<Base
|
||||||
|
title="The reading map"
|
||||||
|
description="Every passage of every book I have read, projected into three dimensions by what it is about — coloured by kind of book, and searchable one title at a time."
|
||||||
|
>
|
||||||
|
<h1>The reading map</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The homepage banner draws this same cloud in a single colour, arranged to look like
|
||||||
|
weather. This is the version arranged to be <em>read</em>: same corpus, a tighter
|
||||||
|
projection, and every passage coloured by what kind of book it came from.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ReadingExplorer />
|
||||||
|
|
||||||
|
<h2>What you are looking at</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
The obvious question is whether this is a map of <em>ideas</em> or just a map of
|
||||||
|
<em>books</em>, 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 <em>arranged</em>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
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 <em>Nausea</em>, <em>The Stranger</em> and <em>Notes From Underground</em> landing
|
||||||
|
beside Franklin and Lovecraft rather than beside the philosophy they are usually shelved
|
||||||
|
with. Nobody labelled that. It fell out of the prose.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="reading-note">
|
||||||
|
Two honest caveats. <em>Narrative</em> is one colour occupying two separate regions, because
|
||||||
|
<em>Empire of the Vampire</em> is large enough to hold a territory of its own — that is book
|
||||||
|
size, not a fourth kind of book. And <em>Notes From Underground</em> 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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="reading-note">
|
||||||
|
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 <a href="/Quotes/">Quotes</a>.
|
||||||
|
</p>
|
||||||
|
</Base>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.reading-note {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -29,6 +29,27 @@
|
|||||||
and it is 7,625 points against the signal's 206 — any real saturation here shouts. */
|
and it is 7,625 points against the signal's 206 — any real saturation here shouts. */
|
||||||
--map-nebula: #46536b;
|
--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` ---------------------------------------------------------
|
/* --- the chosen scheme: `frost` ---------------------------------------------------------
|
||||||
White-hot highlights over a cool cloud, with the garden's green as the thread. Picked
|
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
|
2026-08-12. It is also the only one of the three whose two marks are far enough apart
|
||||||
|
|||||||
Reference in New Issue
Block a user