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:
Generated
+7
@@ -17,6 +17,7 @@
|
||||
"rehype-katex": "^7.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"satori": "^0.28.2",
|
||||
"three": "^0.185.1",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
}
|
||||
},
|
||||
@@ -6018,6 +6019,12 @@
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.185.1",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz",
|
||||
"integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"rehype-katex": "^7.0.0",
|
||||
"remark-math": "^6.0.0",
|
||||
"satori": "^0.28.2",
|
||||
"three": "^0.185.1",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"allowScripts": {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* The reading map — an orbiting point cloud of everything read, traced by everything highlighted.
|
||||
*
|
||||
* Framework-agnostic on purpose: one `mount()` that takes a container and a payload URL. The
|
||||
* Astro wrapper (`ReadingMap.astro`) is thin, and the standalone preview uses the same module,
|
||||
* so what gets reviewed is what ships.
|
||||
*
|
||||
* Payload contract is `umap/PLAN.md` §2, and two parts of it are load-bearing:
|
||||
*
|
||||
* • `trace` is pre-sorted chronologically and MUST NOT be re-sorted. `t` is date-only and
|
||||
* many highlights share a day, so array order is the only thing carrying the sequence.
|
||||
* • There is no `has_highlight` flag. The signal set is the distinct `trace[].node` values —
|
||||
* 262 highlights over 206 chunks, because one passage can be highlighted more than once.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
const DRAW_SECONDS = 45; // one full pass of the trace
|
||||
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)');
|
||||
|
||||
/** 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();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* A soft radial dot, drawn once and shared by every point material.
|
||||
*
|
||||
* The default `PointsMaterial` splat is a hard-edged square. At the sizes this scene uses that
|
||||
* reads as grit — thousands of tiny tiles — rather than as a cloud. A gaussian-ish falloff with
|
||||
* no hard rim is the whole difference between "scatter plot" and "nebula": overlapping sprites
|
||||
* accumulate into something that looks continuous instead of stippled.
|
||||
*/
|
||||
function dotTexture() {
|
||||
const S = 64;
|
||||
const c = document.createElement('canvas');
|
||||
c.width = c.height = S;
|
||||
const ctx = c.getContext('2d');
|
||||
const g = ctx.createRadialGradient(S / 2, S / 2, 0, S / 2, S / 2, S / 2);
|
||||
g.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
g.addColorStop(0.35, 'rgba(255,255,255,0.62)');
|
||||
g.addColorStop(0.72, 'rgba(255,255,255,0.13)');
|
||||
g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, S, S);
|
||||
const tex = new THREE.CanvasTexture(c);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
}
|
||||
|
||||
export async function mount(container, { src, onError } = {}) {
|
||||
try {
|
||||
const res = await fetch(src);
|
||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||
return build(container, await res.json());
|
||||
} 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.
|
||||
onError?.(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function build(container, data) {
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const colors = {
|
||||
nebula: cssVar(container, '--map-nebula', '#9aa4b2'),
|
||||
signal: cssVar(container, '--map-signal', '#ea580c'),
|
||||
trace: cssVar(container, '--map-trace', '#2563eb'),
|
||||
fog: cssVar(container, '--map-fog', '#fcfcfb'),
|
||||
};
|
||||
|
||||
// --- scene ------------------------------------------------------------------------------
|
||||
const scene = new THREE.Scene();
|
||||
|
||||
// Depth cue. Without it a point cloud is a flat spray of dots that happens to rotate; with
|
||||
// it, the far side recedes into the page colour and the thing reads as a volume. This is the
|
||||
// single biggest lever on whether it looks like a cloud, and it costs one line.
|
||||
scene.fog = new THREE.FogExp2(new THREE.Color(colors.fog), 0.42);
|
||||
|
||||
// Closer, with a narrower lens: the cloud is normalised to roughly unit radius, so filling
|
||||
// the frame means being near it rather than zoomed out looking at a speck.
|
||||
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
|
||||
camera.position.set(0, 0, 2.35);
|
||||
|
||||
const sprite = dotTexture();
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
container.appendChild(renderer.domElement);
|
||||
|
||||
// Everything rotates together, so the trace stays welded to the cloud it runs through.
|
||||
const world = new THREE.Group();
|
||||
scene.add(world);
|
||||
|
||||
const xyz = Float32Array.from(data.nodes.xyz);
|
||||
const nodeCount = xyz.length / 3;
|
||||
|
||||
// --- the nebula: every chunk, one draw call ---------------------------------------------
|
||||
const nebulaGeo = new THREE.BufferGeometry();
|
||||
nebulaGeo.setAttribute('position', new THREE.BufferAttribute(xyz, 3));
|
||||
// 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.
|
||||
const nebula = new THREE.Points(nebulaGeo, new THREE.PointsMaterial({
|
||||
color: new THREE.Color(colors.nebula),
|
||||
map: sprite,
|
||||
size: 0.055,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
depthWrite: false,
|
||||
fog: true,
|
||||
}));
|
||||
world.add(nebula);
|
||||
|
||||
// --- the signal: chunks holding a highlight ----------------------------------------------
|
||||
// Distinct nodes, in first-appearance order, so `signalRow` can map a raycast hit back to
|
||||
// the trace entries that live there.
|
||||
const signalNodes = [];
|
||||
const rowOfNode = new Map();
|
||||
for (const t of data.trace) {
|
||||
if (!rowOfNode.has(t.node)) {
|
||||
rowOfNode.set(t.node, signalNodes.length);
|
||||
signalNodes.push(t.node);
|
||||
}
|
||||
}
|
||||
const entriesAtNode = new Map();
|
||||
for (const t of data.trace) {
|
||||
if (!entriesAtNode.has(t.node)) entriesAtNode.set(t.node, []);
|
||||
entriesAtNode.get(t.node).push(t);
|
||||
}
|
||||
|
||||
const signalPos = new Float32Array(signalNodes.length * 3);
|
||||
signalNodes.forEach((n, i) => {
|
||||
signalPos[i * 3] = xyz[n * 3];
|
||||
signalPos[i * 3 + 1] = xyz[n * 3 + 1];
|
||||
signalPos[i * 3 + 2] = xyz[n * 3 + 2];
|
||||
});
|
||||
const signalGeo = new THREE.BufferGeometry();
|
||||
signalGeo.setAttribute('position', new THREE.BufferAttribute(signalPos, 3));
|
||||
const signal = new THREE.Points(signalGeo, new THREE.PointsMaterial({
|
||||
color: new THREE.Color(colors.signal),
|
||||
map: sprite,
|
||||
size: 0.062,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
// Highlights are the point of the picture; never let the nebula bury one. They also stay
|
||||
// out of the fog, so a highlight on the far side is still findable.
|
||||
depthTest: false,
|
||||
depthWrite: false,
|
||||
fog: false,
|
||||
}));
|
||||
signal.renderOrder = 2;
|
||||
world.add(signal);
|
||||
|
||||
// --- the trace: highlights in the order they were made -----------------------------------
|
||||
// Plain THREE.Line rather than Line2. Line2 gives real thickness but its geometry is
|
||||
// instanced, so `setDrawRange` does not clip it — the progressive draw would need
|
||||
// instanceCount juggling for a line that is 1px on most displays anyway.
|
||||
const tracePos = new Float32Array(data.trace.length * 3);
|
||||
data.trace.forEach((t, i) => {
|
||||
tracePos[i * 3] = xyz[t.node * 3];
|
||||
tracePos[i * 3 + 1] = xyz[t.node * 3 + 1];
|
||||
tracePos[i * 3 + 2] = xyz[t.node * 3 + 2];
|
||||
});
|
||||
const traceGeo = new THREE.BufferGeometry();
|
||||
traceGeo.setAttribute('position', new THREE.BufferAttribute(tracePos, 3));
|
||||
traceGeo.setDrawRange(0, 1);
|
||||
const trace = new THREE.Line(traceGeo, new THREE.LineBasicMaterial({
|
||||
color: new THREE.Color(colors.trace),
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
depthTest: false,
|
||||
fog: false,
|
||||
}));
|
||||
trace.renderOrder = 3;
|
||||
world.add(trace);
|
||||
|
||||
// The leading edge, so the eye has something to follow.
|
||||
const headGeo = new THREE.BufferGeometry();
|
||||
headGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(3), 3));
|
||||
const head = new THREE.Points(headGeo, new THREE.PointsMaterial({
|
||||
color: new THREE.Color(colors.signal),
|
||||
map: sprite,
|
||||
size: 0.17,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
depthTest: false,
|
||||
fog: false,
|
||||
}));
|
||||
head.renderOrder = 4;
|
||||
world.add(head);
|
||||
|
||||
// --- overlays ----------------------------------------------------------------------------
|
||||
const dateEl = document.createElement('div');
|
||||
dateEl.className = 'reading-map__date';
|
||||
container.appendChild(dateEl);
|
||||
|
||||
const tipEl = document.createElement('figure');
|
||||
tipEl.className = 'reading-map__tip';
|
||||
tipEl.hidden = true;
|
||||
container.appendChild(tipEl);
|
||||
|
||||
const fmtDate = (iso) => {
|
||||
const [y, m] = iso.split('-');
|
||||
return new Date(Date.UTC(+y, +m - 1, 1))
|
||||
.toLocaleDateString(undefined, { month: 'long', year: 'numeric', timeZone: 'UTC' });
|
||||
};
|
||||
|
||||
function showTip(node, clientX, clientY) {
|
||||
const entries = entriesAtNode.get(node);
|
||||
if (!entries) return;
|
||||
const book = data.books[data.nodes.book[node]];
|
||||
const e = entries[0];
|
||||
const more = entries.length > 1
|
||||
? `<span class="reading-map__more">+${entries.length - 1} more here</span>` : '';
|
||||
// The attribution is COALESCE(attributed_to, book_author) — an introduction is credited to
|
||||
// whoever wrote it, not to whoever wrote the book around it.
|
||||
const byline = [e.attribution, e.editorial ? 'introduction' : null]
|
||||
.filter(Boolean).join(' · ');
|
||||
const voice = e.spoken_by
|
||||
? `<span class="reading-map__voice">spoken by ${escapeHtml(e.spoken_by)}</span>` : '';
|
||||
tipEl.innerHTML =
|
||||
`<blockquote>${escapeHtml(e.text)}</blockquote>` +
|
||||
`<figcaption><strong>${escapeHtml(book.title)}</strong>` +
|
||||
`<span>${escapeHtml(byline)}</span>${voice}` +
|
||||
`<time>${fmtDate(e.t)}</time>${more}</figcaption>`;
|
||||
tipEl.hidden = false;
|
||||
|
||||
const r = container.getBoundingClientRect();
|
||||
const w = tipEl.offsetWidth, h = tipEl.offsetHeight;
|
||||
let x = clientX - r.left + 16, y = clientY - r.top + 16;
|
||||
if (x + w > r.width) x = clientX - r.left - w - 16;
|
||||
if (y + h > r.height) y = clientY - r.top - h - 16;
|
||||
tipEl.style.transform = `translate(${Math.max(8, x)}px, ${Math.max(8, y)}px)`;
|
||||
}
|
||||
|
||||
// --- picking ------------------------------------------------------------------------------
|
||||
const raycaster = new THREE.Raycaster();
|
||||
raycaster.params.Points.threshold = 0.045;
|
||||
const pointer = new THREE.Vector2();
|
||||
let hoverNode = null;
|
||||
let paused = false;
|
||||
|
||||
function onMove(ev) {
|
||||
const r = renderer.domElement.getBoundingClientRect();
|
||||
pointer.x = ((ev.clientX - r.left) / r.width) * 2 - 1;
|
||||
pointer.y = -((ev.clientY - r.top) / r.height) * 2 + 1;
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const hit = raycaster.intersectObject(signal, false)[0];
|
||||
if (hit) {
|
||||
hoverNode = signalNodes[hit.index];
|
||||
paused = true; // hold the orbit still while reading
|
||||
showTip(hoverNode, ev.clientX, ev.clientY);
|
||||
renderer.domElement.style.cursor = 'pointer';
|
||||
} else if (hoverNode !== null) {
|
||||
hoverNode = null;
|
||||
paused = false;
|
||||
tipEl.hidden = true;
|
||||
renderer.domElement.style.cursor = '';
|
||||
}
|
||||
}
|
||||
if (POINTER_FINE.matches) {
|
||||
renderer.domElement.addEventListener('pointermove', onMove);
|
||||
renderer.domElement.addEventListener('pointerleave', () => {
|
||||
hoverNode = null; paused = false; tipEl.hidden = true;
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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
|
||||
// three's 300×150 default, and the scene renders correct geometry into the wrong box. It
|
||||
// recovers on the next ResizeObserver callback, which makes the bug intermittent rather than
|
||||
// absent — the worst kind. Reveal, then measure.
|
||||
container.dataset.ready = 'true';
|
||||
|
||||
const resize = () => {
|
||||
const { clientWidth: w, clientHeight: h } = container;
|
||||
if (!w || !h) return;
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
const ro = new ResizeObserver(resize);
|
||||
ro.observe(container);
|
||||
resize();
|
||||
|
||||
// --- loop ---------------------------------------------------------------------------------
|
||||
// Constant INDEX rate, not constant time rate. 188 of 293 highlights fall in July–August
|
||||
// 2025 and several months are empty; pacing by wall-clock date would park the head in one
|
||||
// cluster for most of the loop and then crawl through dead air. The date readout carries
|
||||
// the unevenness instead, which is the honest place for it.
|
||||
let raf = 0, last = performance.now(), elapsed = 0, shownIndex = -1;
|
||||
const total = data.trace.length;
|
||||
|
||||
function frame(now) {
|
||||
raf = requestAnimationFrame(frame);
|
||||
const dt = Math.min((now - last) / 1000, 0.05);
|
||||
last = now;
|
||||
|
||||
if (!paused) world.rotation.y += ORBIT_RATE * dt;
|
||||
|
||||
elapsed += dt;
|
||||
const cycle = DRAW_SECONDS + HOLD_SECONDS;
|
||||
if (elapsed > cycle) elapsed -= cycle;
|
||||
const drawn = reduced
|
||||
? total
|
||||
: Math.max(1, Math.min(total, Math.round((elapsed / DRAW_SECONDS) * total)));
|
||||
|
||||
traceGeo.setDrawRange(0, drawn);
|
||||
const h = (drawn - 1) * 3;
|
||||
head.geometry.attributes.position.setXYZ(0, tracePos[h], tracePos[h + 1], tracePos[h + 2]);
|
||||
head.geometry.attributes.position.needsUpdate = true;
|
||||
|
||||
if (drawn - 1 !== shownIndex) {
|
||||
shownIndex = drawn - 1;
|
||||
dateEl.textContent = fmtDate(data.trace[shownIndex].t);
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
// Nothing runs until the banner is actually on screen — an orbiting WebGL scene has no
|
||||
// business burning a frame budget while the reader is three screens down.
|
||||
const io = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting && !raf) { last = performance.now(); raf = requestAnimationFrame(frame); }
|
||||
else if (!entry.isIntersecting && raf) { cancelAnimationFrame(raf); raf = 0; }
|
||||
}, { threshold: 0.01 });
|
||||
io.observe(container);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
cancelAnimationFrame(raf);
|
||||
io.disconnect(); ro.disconnect();
|
||||
renderer.dispose();
|
||||
nebulaGeo.dispose(); signalGeo.dispose(); traceGeo.dispose(); headGeo.dispose();
|
||||
sprite.dispose();
|
||||
container.querySelector('canvas')?.remove();
|
||||
dateEl.remove(); tipEl.remove();
|
||||
},
|
||||
stats: { nodes: nodeCount, highlights: total, chunks: signalNodes.length },
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
|
||||
));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import Base from '../layouts/Base.astro';
|
||||
import ReadingMap from '../components/ReadingMap.astro';
|
||||
|
||||
const entries = await getCollection('garden');
|
||||
const home = entries.find((e) => e.id === 'index');
|
||||
@@ -14,5 +15,6 @@ const title = home?.data.title ?? 'Wesley Ray';
|
||||
description={home?.data.description}
|
||||
headings={rendered?.headings ?? []}
|
||||
>
|
||||
<ReadingMap />
|
||||
{Content && <Content />}
|
||||
</Base>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* Chrome for the reading map.
|
||||
*
|
||||
* ⚠️ **Garden-first, and deliberately NOT `prefers-color-scheme`.** garden.c0smere.net is fixed
|
||||
* dark — `--bg: #0b0d12`, white text, no light mode. Keying this widget off the *viewer's* OS
|
||||
* preference would hand a light palette to anyone browsing with light-mode set, on a page that
|
||||
* is black regardless: distant points would fade toward near-white against a near-black ground,
|
||||
* which reads as smeared dirt rather than as depth. The palette follows the SITE.
|
||||
*
|
||||
* Everything inherits `--bg` / `--fg` / `--accent` from the garden when present, with the
|
||||
* garden's own values as fallbacks so the standalone preview matches what ships.
|
||||
*
|
||||
* Three schemes ship as modifier classes on the container. The renderer reads its colours once
|
||||
* at mount, so switching scheme means re-mounting, not repainting.
|
||||
*/
|
||||
|
||||
.reading-map {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: clamp(22rem, 68vh, 44rem);
|
||||
overflow: hidden;
|
||||
|
||||
/* ⚠️ Must be the page background behind the canvas. The canvas is transparent, so distant
|
||||
points fade toward THIS colour to give depth — set it wrong and the far side of the cloud
|
||||
fades toward something the reader cannot see. */
|
||||
--map-fog: var(--bg, #0b0d12);
|
||||
|
||||
/* The ground. Low chroma and only a little above the background: it is texture, not subject,
|
||||
and it is 7,625 points against the signal's 206 — any real saturation here shouts. */
|
||||
--map-nebula: #46536b;
|
||||
|
||||
/* --- 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
|
||||
(ΔE 31.9 normal, 25.5 protan) that their distinction does not rest on dots-versus-line. */
|
||||
--map-signal: #e8eef6;
|
||||
--map-trace: var(--link-bright, #46c421);
|
||||
|
||||
--map-ink: var(--fg, #ffffff);
|
||||
--map-ink-soft: var(--muted, hsla(0, 0%, 100%, 0.64));
|
||||
--map-surface: rgba(16, 19, 27, 0.92);
|
||||
--map-border: var(--hair, hsla(0, 0%, 100%, 0.14));
|
||||
}
|
||||
|
||||
/* --- scheme: brand -------------------------------------------------------------------------
|
||||
The garden's own green doing both jobs. Signal and trace sit 9.2 ΔE apart, which would be
|
||||
too close for two chart series told apart by colour alone — here they never are: one is a
|
||||
field of dots, the other a continuous line. Form carries the distinction, colour carries the
|
||||
identity. */
|
||||
.reading-map--brand {
|
||||
--map-signal: var(--link-bright, #46c421);
|
||||
--map-trace: var(--link, #2fa50f);
|
||||
}
|
||||
|
||||
/* --- scheme: ember --------------------------------------------------------------------------
|
||||
Warm subject, cool ground, brand green as the thread. The most "sky at night" of the three.
|
||||
⚠️ Amber↔green is ΔE 4.2 under protanopia — the two are nearly identical to a protanope, so
|
||||
this scheme leans entirely on dots-versus-line. Acceptable because nothing here is encoded by
|
||||
colour: no reader ever has to answer a question by telling these two hues apart. */
|
||||
.reading-map--ember {
|
||||
--map-signal: #f59e0b;
|
||||
--map-trace: var(--link-bright, #46c421);
|
||||
}
|
||||
|
||||
/* --- scheme: frost --------------------------------------------------------------------------
|
||||
The shipping default, spelled out above on `.reading-map`. Kept as a modifier so the preview
|
||||
can name it alongside the others and so a page can ask for it explicitly. */
|
||||
.reading-map--frost {
|
||||
--map-signal: #e8eef6;
|
||||
--map-trace: var(--link-bright, #46c421);
|
||||
}
|
||||
|
||||
/* Fails closed: the markup ships hidden and only a successful mount reveals it, so a missing
|
||||
payload degrades to a plain page rather than an empty bordered box. */
|
||||
.reading-map:not([data-ready]) { display: none; }
|
||||
|
||||
.reading-map canvas { display: block; width: 100%; height: 100%; }
|
||||
|
||||
.reading-map__date {
|
||||
position: absolute;
|
||||
left: 0.25rem;
|
||||
bottom: 0.5rem;
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--map-ink-soft);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.reading-map__tip {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
max-width: min(28rem, 82%);
|
||||
padding: 0.85rem 0.95rem;
|
||||
background: var(--map-surface);
|
||||
border: 1px solid var(--map-border);
|
||||
border-radius: 0.25rem;
|
||||
backdrop-filter: blur(8px);
|
||||
color: var(--map-ink);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* The passage is the one place on this page that is prose rather than instrumentation, and the
|
||||
garden already has a face for prose. */
|
||||
.reading-map__tip blockquote {
|
||||
margin: 0 0 0.6rem;
|
||||
font-family: var(--serif, Georgia, serif);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
/* 800-character highlights exist, and a tooltip is not where anyone should read one. The
|
||||
Quotes pages already carry every passage in full. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 6;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reading-map__tip figcaption {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.15rem 0.6rem;
|
||||
font-family: var(--mono, ui-monospace, monospace);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.5;
|
||||
color: var(--map-ink-soft);
|
||||
border-top: 1px solid var(--map-border);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.reading-map__tip figcaption strong { color: var(--map-ink); font-weight: 700; }
|
||||
.reading-map__voice { color: var(--map-signal); }
|
||||
.reading-map__more { opacity: 0.7; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* The loop already draws the trace complete rather than animating it, so the date readout
|
||||
would sit on a value that never changes. Retire it. */
|
||||
.reading-map__date { display: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user