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();
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({
color: new THREE.Color(colors.signal),
color: 0xffffff,
vertexColors: true,
map: sprite,
size: 0.062,
sizeAttenuation: true,
@@ -200,49 +215,45 @@ export function build(container, data) {
head.renderOrder = 4;
world.add(head);
// --- two standing markers ----------------------------------------------------------------
// Rings, not brighter dots. The animated `head` above is already a bright point, and at the
// end of every draw cycle it comes to rest on exactly the newest node — under
// prefers-reduced-motion it parks there permanently. A second filled dot at the same
// coordinate reads as a rendering fault, so these are drawn as outlines instead: same
// position, unmistakably a different kind of thing.
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;
}
// The head marks where the draw has got to. Under prefers-reduced-motion there is no draw —
// the trace is complete from the first frame — so it marks nothing, and it parks permanently
// on the last trace entry at nearly three times the size of a signal point. That is exactly
// the newest node, so leaving it on would bury the amber tint under a white blob for the one
// audience least able to wait for it to move.
head.visible = !reduced;
const newestRing = ring('--map-newest', 0.045);
const quotedRing = ring('--map-quoted', 0.062);
const placeRing = (mesh, node) => {
if (node == null) { mesh.visible = false; return; }
mesh.position.set(xyz[node * 3], xyz[node * 3 + 1], xyz[node * 3 + 2]);
mesh.visible = true;
// --- two marked nodes ---------------------------------------------------------------------
// 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
// writing three floats into it.
//
// Note the animated `head` sweeps over the newest node at the very end of each draw cycle —
// 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
// 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.
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
// 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.
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 ----------------------------------------------------------------------------
const dateEl = document.createElement('div');
@@ -374,14 +385,6 @@ export function build(container, data) {
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);
}
@@ -399,27 +402,31 @@ export function build(container, data) {
io.disconnect(); ro.disconnect();
renderer.dispose();
nebulaGeo.dispose(); signalGeo.dispose(); traceGeo.dispose(); headGeo.dispose();
newestRing.geometry.dispose(); newestRing.material.dispose();
quotedRing.geometry.dispose(); quotedRing.material.dispose();
sprite.dispose();
container.querySelector('canvas')?.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.
*
* 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
* 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
* is drawn.
* chunk they claim. A normal outcome, not an error: nothing is tinted.
*/
markQuoted(textHash) {
const node = nodeOfHash.get(textHash);
placeRing(quotedRing, node ?? null);
return node != null;
const node = nodeOfHash.get(textHash) ?? null;
// Repaint the previous one back to the scheme colour, so calling this twice does not
// 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 },
};
+20 -16
View File
@@ -36,19 +36,23 @@
--map-signal: #e8eef6;
--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.
Drawn as rings, so colour is not what separates them from the cloud — outline-versus-dot
does, the same form-carries-the-distinction argument the schemes below rest on.
These re-tint the highlight points themselves rather than drawing anything over them, so
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↔fuchsia is ΔE 52.9 normal, 59.6 protan, 56.5 deutan (colorspacious, severity 100).
amber ↔ signal white ΔE 42.2 normal · 42.9 protan · 40.1 deutan
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
cannot tell the newest ring from the trace line by hue. Accepted on the same grounds the
`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
two hues apart. Fuchsia has no such problem (ΔE 61.2 protan against the trace). */
All three clear comfortably, including the one that matters most — telling the two marked
nodes apart from each other.
⚠️ The one weak pair, recorded honestly: amber ↔ the trace green is **ΔE 4.8 under
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-quoted: #e879f9;
@@ -76,13 +80,13 @@
.reading-map--ember {
--map-signal: #f59e0b;
--map-trace: var(--link-bright, #46c421);
/* The default newest-marker amber IS this scheme's signal colour here, which would make the
ring look like a highlight that had merely grown a hole.
/* The default newest-marker amber IS this scheme's signal colour, so a node tinted with it
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
quoted-marker it measures ΔE 0.5 under deuteranopia — the two rings would be the same
colour to a deuteranope, which is the one distinction these hues actually have to carry.
White is the only candidate that clears all three of this scheme's marks (worst case
ΔE 30.4 across normal/protan/deutan); cyan managed 7.8 and lime 8.5. */
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 have to carry. White is
the only candidate clearing all three of this scheme's marks (worst case ΔE 30.4 across
normal/protan/deutan); cyan managed 7.8 and lime 8.5. */
--map-newest: #e8eef6;
}