Reading map: a legend under the figure

Five marks, and until now the only way to learn what any of them meant was to
hover a dot. The last row is the one worth having: it says out loud that the
fuchsia node is where the quote at the top of the page came from, which is
otherwise a connection nobody would guess was being made.

Three things it does not do:

Counts come from the payload at mount, not from the markup — they move every
time a book is indexed, and a hardcoded 8,424 would go stale on the next one.

Swatches take the palette the renderer resolved rather than re-reading the CSS
vars, so a scheme modifier on the container (`--ember` swaps the newest hue)
cannot leave the legend describing a different picture than the one on screen.

The quoted row follows markQuoted's return value, not the attempt. A quote
whose source book was never indexed leaves the map unmarked, and the legend
now stays quiet about it instead of pointing at a colour that is not there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-08-14 09:18:07 -04:00
co-authored by Claude Opus 5
parent b1a0192dd1
commit 6849d8ffe5
2 changed files with 91 additions and 9 deletions
+83 -6
View File
@@ -40,6 +40,17 @@ const {
<figure class="reading-map-figure"> <figure class="reading-map-figure">
<div class="reading-map" data-src={src}></div> <div class="reading-map" data-src={src}></div>
{caption && <figcaption>{caption}</figcaption>} {caption && <figcaption>{caption}</figcaption>}
{/* Counts are filled in from the payload at mount, not hardcoded — they move every time a
book is indexed. The `quoted` row stays hidden until markQuoted() actually finds a node,
so the legend never names a mark that is not on screen. */}
<ul class="reading-map-legend" hidden>
<li><i data-swatch="nebula"></i><b data-count="nodes"></b> passages read</li>
<li><i data-swatch="signal"></i><b data-count="highlights"></b> highlighted</li>
<li><i data-swatch="trace"></i>in the order I marked them</li>
<li><i data-swatch="newest"></i>most recent</li>
<li data-row="quoted" hidden><i data-swatch="quoted"></i>the quote at the top of this page</li>
</ul>
</figure> </figure>
<script> <script>
@@ -65,13 +76,38 @@ const {
}); });
if (!map) continue; if (!map) continue;
// Ring the node the header's quote came from. The two are independent fetches on const figure = el.closest('.reading-map-figure');
const legend = figure?.querySelector<HTMLElement>('.reading-map-legend');
if (legend) {
for (const b of legend.querySelectorAll<HTMLElement>('[data-count]')) {
const n = map.stats[b.dataset.count as keyof typeof map.stats];
b.textContent = n.toLocaleString();
}
// Swatches take the palette the renderer actually resolved, so a scheme modifier on
// the container (`--ember` swaps the newest colour) can never leave the legend
// describing a picture that is not the one on screen.
for (const i of legend.querySelectorAll<HTMLElement>('[data-swatch]')) {
i.style.background = map.colors[i.dataset.swatch as keyof typeof map.colors];
}
legend.hidden = false;
}
const quotedRow = legend?.querySelector<HTMLElement>('[data-row="quoted"]');
// Tint the node the header's quote came from. The two are independent fetches on
// every page view and either can land first, so handle both orders: read the hash if // every page view and either can land first, so handle both orders: read the hash if
// it is already there, and listen in case it is not. `garden:quote` is dispatched by // it is already there, and listen in case it is not. `garden:quote` is dispatched by
// the header script in Base.astro. // the header script in Base.astro.
const ring = (hash?: string) => hash && map.markQuoted(hash); //
ring(window.__gardenQuoteHash); // The legend row follows markQuoted's return value rather than the attempt, so a quote
addEventListener('garden:quote', (e) => ring((e as CustomEvent).detail?.text_hash)); // whose source book was never indexed leaves the map unmarked AND unexplained, instead
// of pointing at a colour that is not there.
const mark = (hash?: string) => {
if (!hash) return;
const found = map.markQuoted(hash);
if (quotedRow) quotedRow.hidden = !found;
};
mark(window.__gardenQuoteHash);
addEventListener('garden:quote', (e) => mark((e as CustomEvent).detail?.text_hash));
} }
} catch (err) { } catch (err) {
/* chunk failed to load: the figure stays hidden */ /* chunk failed to load: the figure stays hidden */
@@ -90,8 +126,49 @@ const {
line-height: 1.5; line-height: 1.5;
opacity: 0.72; opacity: 0.72;
} }
/* Hide the caption too when the map itself failed to mount. */ /* Hide the caption and legend too when the map itself failed to mount. */
.reading-map-figure:not(:has([data-ready])) figcaption { .reading-map-figure:not(:has([data-ready])) figcaption,
.reading-map-figure:not(:has([data-ready])) .reading-map-legend {
display: none; display: none;
} }
/* The legend is the one place colour is *allowed* to be the whole encoding: it is where the
mapping from hue to meaning is stated, rather than left to be inferred off the cloud. */
.reading-map-legend {
display: flex;
flex-wrap: wrap;
gap: 0.35rem 1.15rem;
margin: 0.7rem 0 0;
padding: 0;
list-style: none;
font-size: 0.76rem;
line-height: 1.5;
opacity: 0.72;
}
.reading-map-legend[hidden] { display: none; }
.reading-map-legend li {
display: flex;
align-items: baseline;
gap: 0.42rem;
}
.reading-map-legend b {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* Backgrounds are set from the resolved palette at mount — see the script above. The dot is
nudged down because `align-items: baseline` aligns it on its own box, not the text's. */
.reading-map-legend i {
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
transform: translateY(0.06rem);
}
/* The trace is a line in the picture, so it is a line here too rather than a third dot. */
.reading-map-legend i[data-swatch='trace'] {
width: 0.85rem;
height: 0.14rem;
border-radius: 999px;
}
</style> </style>
+8 -3
View File
@@ -74,6 +74,8 @@ export function build(container, data) {
signal: cssVar(container, '--map-signal', '#ea580c'), signal: cssVar(container, '--map-signal', '#ea580c'),
trace: cssVar(container, '--map-trace', '#2563eb'), trace: cssVar(container, '--map-trace', '#2563eb'),
fog: cssVar(container, '--map-fog', '#fcfcfb'), fog: cssVar(container, '--map-fog', '#fcfcfb'),
newest: cssVar(container, '--map-newest', '#f59e0b'),
quoted: cssVar(container, '--map-quoted', '#e879f9'),
}; };
// --- scene ------------------------------------------------------------------------------ // --- scene ------------------------------------------------------------------------------
@@ -246,13 +248,12 @@ export function build(container, data) {
// trace_order is chronological and pre-sorted, `t` is date-only, and many highlights share a // trace_order is chronological and pre-sorted, `t` is date-only, and many highlights share a
// day — sorting on it would quietly pick a different passage from the same date. // day — sorting on it would quietly pick a different passage from the same date.
const newestNode = data.trace.length ? data.trace[data.trace.length - 1].node : null; const newestNode = data.trace.length ? data.trace[data.trace.length - 1].node : null;
if (newestNode != null) paint(newestNode, cssVar(container, '--map-newest', colors.signal)); if (newestNode != null) paint(newestNode, colors.newest);
// Set by the page once the header's random quote has loaded; the two fetches are independent // Set by the page once the header's random quote has loaded; the two fetches are independent
// and either can win. `h` is the highlight's text_hash, which the API returns alongside the // and either can win. `h` is the highlight's text_hash, which the API returns alongside the
// quote — an exact key, not a text match. // quote — an exact key, not a text match.
const nodeOfHash = new Map(data.trace.map((t) => [t.h, t.node])); const nodeOfHash = new Map(data.trace.map((t) => [t.h, t.node]));
const quotedHex = cssVar(container, '--map-quoted', colors.signal);
let quotedNode = null; let quotedNode = null;
// --- overlays ---------------------------------------------------------------------------- // --- overlays ----------------------------------------------------------------------------
@@ -426,9 +427,13 @@ export function build(container, data) {
quotedNode = node; quotedNode = node;
// Quoted wins a tie: if the header happens to quote the newest highlight, the passage // Quoted wins a tie: if the header happens to quote the newest highlight, the passage
// the reader is actually looking at is the one worth pointing at. // the reader is actually looking at is the one worth pointing at.
return node != null && paint(node, quotedHex); return node != null && paint(node, colors.quoted);
}, },
stats: { nodes: nodeCount, highlights: total, chunks: signalNodes.length }, stats: { nodes: nodeCount, highlights: total, chunks: signalNodes.length },
// The resolved palette, so a legend can colour its swatches from the same values this
// 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,
}; };
} }