Reading map: tint the nodes themselves instead of ringing them

Wes asked for a hue change on the node and got rings; the rings look like
chrome bolted onto the picture. This does what was asked: `signal` gains a
per-vertex colour attribute, and marking a node is three floats written into
the buffer it already draws. No extra object, no extra draw call, and the
billboarding the ring meshes needed goes away with them.

vertexColors MULTIPLIES material.color, so the material is white now — left at
the signal colour it would filter every tint through that hue.

The reason I had reached for rings was real, and needed handling rather than
avoiding: the trace head parks on the newest node under prefers-reduced-motion
at ~3x a signal point's size, which buried the amber tint completely — caught
by pixel-sampling the render, where amber came back ABSENT in that mode. The
head marks how far the draw has got, and under reduced motion there is no
draw, so it now hides itself. Both marks verified present in both modes.

Colour is doing the whole job now with no form difference to fall back on, so
the measured contrasts move from reassuring to load-bearing; they are recorded
in the CSS. Every pair that matters clears dE 30 worst-case across normal,
protan and deutan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wes
2026-08-14 09:05:48 -04:00
co-authored by Claude Opus 5
parent 51b4033541
commit b1a0192dd1
2 changed files with 75 additions and 64 deletions
+55 -48
View File
@@ -145,8 +145,23 @@ export function build(container, data) {
}); });
const signalGeo = new THREE.BufferGeometry(); const signalGeo = new THREE.BufferGeometry();
signalGeo.setAttribute('position', new THREE.BufferAttribute(signalPos, 3)); signalGeo.setAttribute('position', new THREE.BufferAttribute(signalPos, 3));
// Per-vertex colour, so a single highlight can be tinted without a second object over it.
// Every point starts at the scheme's signal colour and two of them get overwritten below.
// `vertexColors` MULTIPLIES material.color by the attribute, so the material must be white
// or every tint would be filtered through the signal hue.
const tmpColor = new THREE.Color();
const signalColor = new THREE.BufferAttribute(new Float32Array(signalNodes.length * 3), 3);
tmpColor.set(colors.signal);
for (let i = 0; i < signalNodes.length; i++) {
signalColor.setXYZ(i, tmpColor.r, tmpColor.g, tmpColor.b);
}
signalGeo.setAttribute('color', signalColor);
const signalColorAttr = signalGeo.getAttribute('color');
const signal = new THREE.Points(signalGeo, new THREE.PointsMaterial({ const signal = new THREE.Points(signalGeo, new THREE.PointsMaterial({
color: new THREE.Color(colors.signal), color: 0xffffff,
vertexColors: true,
map: sprite, map: sprite,
size: 0.062, size: 0.062,
sizeAttenuation: true, sizeAttenuation: true,
@@ -200,49 +215,45 @@ export function build(container, data) {
head.renderOrder = 4; head.renderOrder = 4;
world.add(head); world.add(head);
// --- two standing markers ---------------------------------------------------------------- // The head marks where the draw has got to. Under prefers-reduced-motion there is no draw —
// Rings, not brighter dots. The animated `head` above is already a bright point, and at the // the trace is complete from the first frame — so it marks nothing, and it parks permanently
// end of every draw cycle it comes to rest on exactly the newest node — under // on the last trace entry at nearly three times the size of a signal point. That is exactly
// prefers-reduced-motion it parks there permanently. A second filled dot at the same // the newest node, so leaving it on would bury the amber tint under a white blob for the one
// coordinate reads as a rendering fault, so these are drawn as outlines instead: same // audience least able to wait for it to move.
// position, unmistakably a different kind of thing. head.visible = !reduced;
function ring(colorKey, radius) {
const geo = new THREE.RingGeometry(radius, radius * 1.34, 32);
const mesh = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
color: new THREE.Color(cssVar(container, colorKey, colors.signal)),
transparent: true,
opacity: 0.95,
side: THREE.DoubleSide,
depthTest: false,
depthWrite: false,
fog: false,
}));
mesh.visible = false;
mesh.renderOrder = 5;
world.add(mesh);
return mesh;
}
const newestRing = ring('--map-newest', 0.045); // --- two marked nodes ---------------------------------------------------------------------
const quotedRing = ring('--map-quoted', 0.062); // The nodes themselves change hue. No extra object is drawn: `signalColor` above is a
// per-vertex colour attribute on the same one-draw-call Points, and marking a node is
const placeRing = (mesh, node) => { // writing three floats into it.
if (node == null) { mesh.visible = false; return; } //
mesh.position.set(xyz[node * 3], xyz[node * 3 + 1], xyz[node * 3 + 2]); // Note the animated `head` sweeps over the newest node at the very end of each draw cycle —
mesh.visible = true; // briefly the same mark rendered brighter, not a second object beside it. Under reduced
// motion it would sit there permanently instead, which is why it is hidden in that mode.
//
// Both marked nodes are guaranteed to be in this buffer — every marked node comes from a
// trace entry, and `signalNodes` is exactly the distinct trace nodes.
const paint = (node, hex) => {
const row = rowOfNode.get(node);
if (row === undefined) return false;
tmpColor.set(hex);
signalColor.setXYZ(row, tmpColor.r, tmpColor.g, tmpColor.b);
signalColorAttr.needsUpdate = true;
return true;
}; };
// The newest highlight is the LAST trace entry, never the max of `t`: the payload says // The newest highlight is the LAST trace entry, never the max of `t`: the payload says
// 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;
placeRing(newestRing, newestNode); if (newestNode != null) paint(newestNode, cssVar(container, '--map-newest', colors.signal));
// 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 billboard = new THREE.Quaternion(); const quotedHex = cssVar(container, '--map-quoted', colors.signal);
let quotedNode = null;
// --- overlays ---------------------------------------------------------------------------- // --- overlays ----------------------------------------------------------------------------
const dateEl = document.createElement('div'); const dateEl = document.createElement('div');
@@ -374,14 +385,6 @@ export function build(container, data) {
dateEl.textContent = fmtDate(data.trace[shownIndex].t); dateEl.textContent = fmtDate(data.trace[shownIndex].t);
} }
// Billboard the markers. They are flat ring geometry parented to `world`, so without this
// they turn edge-on — invisible — twice per revolution. Cancelling the world's rotation
// leaves them facing the camera, which never moves.
if (newestRing.visible || quotedRing.visible) {
billboard.copy(world.quaternion).invert();
newestRing.quaternion.copy(billboard);
quotedRing.quaternion.copy(billboard);
}
renderer.render(scene, camera); renderer.render(scene, camera);
} }
@@ -399,27 +402,31 @@ export function build(container, data) {
io.disconnect(); ro.disconnect(); io.disconnect(); ro.disconnect();
renderer.dispose(); renderer.dispose();
nebulaGeo.dispose(); signalGeo.dispose(); traceGeo.dispose(); headGeo.dispose(); nebulaGeo.dispose(); signalGeo.dispose(); traceGeo.dispose(); headGeo.dispose();
newestRing.geometry.dispose(); newestRing.material.dispose();
quotedRing.geometry.dispose(); quotedRing.material.dispose();
sprite.dispose(); sprite.dispose();
container.querySelector('canvas')?.remove(); container.querySelector('canvas')?.remove();
dateEl.remove(); tipEl.remove(); dateEl.remove(); tipEl.remove();
}, },
/** /**
* Ring the node a quoted passage came from. `textHash` is the `text_hash` that * Tint the node a quoted passage came from. `textHash` is the `text_hash` that
* api.c0smere.net/highlights/random returns beside the quote. * api.c0smere.net/highlights/random returns beside the quote.
* *
* Returns false when that passage has no node. Measured at 2 of the 230 quotes the header * Returns false when that passage has no node. Measured at 2 of the 230 quotes the header
* can draw (0.9%) — the endpoint already restricts itself to books library-rag has * can draw (0.9%) — the endpoint already restricts itself to books library-rag has
* indexed, so the only survivors are passages the export could not confirm inside the * indexed, so the only survivors are passages the export could not confirm inside the
* chunk they claim. A normal outcome, not an error: the caller does nothing and no ring * chunk they claim. A normal outcome, not an error: nothing is tinted.
* is drawn.
*/ */
markQuoted(textHash) { markQuoted(textHash) {
const node = nodeOfHash.get(textHash); const node = nodeOfHash.get(textHash) ?? null;
placeRing(quotedRing, node ?? null); // Repaint the previous one back to the scheme colour, so calling this twice does not
return node != null; // leave a trail of tinted nodes behind it.
if (quotedNode != null && quotedNode !== node && quotedNode !== newestNode) {
paint(quotedNode, colors.signal);
}
quotedNode = node;
// 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.
return node != null && paint(node, quotedHex);
}, },
stats: { nodes: nodeCount, highlights: total, chunks: signalNodes.length }, stats: { nodes: nodeCount, highlights: total, chunks: signalNodes.length },
}; };
+20 -16
View File
@@ -36,19 +36,23 @@
--map-signal: #e8eef6; --map-signal: #e8eef6;
--map-trace: var(--link-bright, #46c421); --map-trace: var(--link-bright, #46c421);
/* --- the two standing markers ------------------------------------------------------------ /* --- the two marked nodes -----------------------------------------------------------------
--map-newest: the most recent highlight. --map-quoted: the passage quoted in the header. --map-newest: the most recent highlight. --map-quoted: the passage quoted in the header.
Drawn as rings, so colour is not what separates them from the cloud — outline-versus-dot These re-tint the highlight points themselves rather than drawing anything over them, so
does, the same form-carries-the-distinction argument the schemes below rest on. unlike everywhere else in this file, **hue is the only channel carrying the distinction** —
there is no form difference to fall back on. That makes the measurements below load-bearing
rather than reassuring (colorspacious, CVD severity 100):
What colour DOES have to carry is telling the two markers apart, and it does that well: amber ↔ signal white ΔE 42.2 normal · 42.9 protan · 40.1 deutan
amber↔fuchsia is ΔE 52.9 normal, 59.6 protan, 56.5 deutan (colorspacious, severity 100). fuchsia ↔ signal white ΔE 38.9 normal · 37.8 protan · 30.4 deutan
amber ↔ fuchsia ΔE 52.9 normal · 59.6 protan · 56.5 deutan
⚠️ Measured honestly: amber↔trace-green is only **ΔE 4.8 under protanopia**. A protanope All three clear comfortably, including the one that matters most — telling the two marked
cannot tell the newest ring from the trace line by hue. Accepted on the same grounds the nodes apart from each other.
`ember` scheme below is accepted — nothing here is *encoded* by colour, a closed ring and a
polyline are different shapes, and no reader ever has to answer a question by telling those ⚠️ The one weak pair, recorded honestly: amber ↔ the trace green is **ΔE 4.8 under
two hues apart. Fuchsia has no such problem (ΔE 61.2 protan against the trace). */ protanopia**. That one keeps a form difference (a point versus a continuous line), which is
the same argument the `ember` scheme below rests on, and nothing is *encoded* by it. */
--map-newest: #f59e0b; --map-newest: #f59e0b;
--map-quoted: #e879f9; --map-quoted: #e879f9;
@@ -76,13 +80,13 @@
.reading-map--ember { .reading-map--ember {
--map-signal: #f59e0b; --map-signal: #f59e0b;
--map-trace: var(--link-bright, #46c421); --map-trace: var(--link-bright, #46c421);
/* The default newest-marker amber IS this scheme's signal colour here, which would make the /* The default newest-marker amber IS this scheme's signal colour, so a node tinted with it
ring look like a highlight that had merely grown a hole. would be indistinguishable from the 214 around it — the mark would simply vanish.
⚠️ Sky blue (#38bdf8) was the obvious substitute and is **wrong**: against the fuchsia ⚠️ Sky blue (#38bdf8) was the obvious substitute and is **wrong**: against the fuchsia
quoted-marker it measures ΔE 0.5 under deuteranopia — the two rings would be the same quoted node it measures ΔE 0.5 under deuteranopia. The two marked nodes would be the same
colour to a deuteranope, which is the one distinction these hues actually have to carry. colour to a deuteranope, which is the one distinction these hues have to carry. White is
White is the only candidate that clears all three of this scheme's marks (worst case the only candidate clearing all three of this scheme's marks (worst case ΔE 30.4 across
ΔE 30.4 across normal/protan/deutan); cyan managed 7.8 and lime 8.5. */ normal/protan/deutan); cyan managed 7.8 and lime 8.5. */
--map-newest: #e8eef6; --map-newest: #e8eef6;
} }