diff --git a/src/lib/reading-map.js b/src/lib/reading-map.js index 3a9701e..1973193 100644 --- a/src/lib/reading-map.js +++ b/src/lib/reading-map.js @@ -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 }, }; diff --git a/src/styles/reading-map.css b/src/styles/reading-map.css index e98c102..e1332cd 100644 --- a/src/styles/reading-map.css +++ b/src/styles/reading-map.css @@ -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; }