diff --git a/src/lib/reading-map.js b/src/lib/reading-map.js index 04b3b1b..3358371 100644 --- a/src/lib/reading-map.js +++ b/src/lib/reading-map.js @@ -53,6 +53,24 @@ function dotTexture() { return tex; } +/** + * `PointsMaterial` has one `size` for the whole object. This patches its vertex shader to take a + * per-vertex `aScale` multiplier, so a single point in a shared buffer can draw larger. + * + * Assigned to `material.onBeforeCompile`. + */ +function perVertexSize(shader) { + const target = 'gl_PointSize = size;'; + // If three.js ever rewrites this line the marks quietly stop standing out and nothing else + // breaks — exactly the kind of regression that survives a release unnoticed. + if (!shader.vertexShader.includes(target)) { + console.warn('reading map: point-size hook not found; marked nodes will not be enlarged'); + return; + } + shader.vertexShader = 'attribute float aScale;\n' + + shader.vertexShader.replace(target, 'gl_PointSize = size * aScale;'); +} + export async function mount(container, { src, onError } = {}) { try { const res = await fetch(src); @@ -161,7 +179,21 @@ export function build(container, data) { signalGeo.setAttribute('color', signalColor); const signalColorAttr = signalGeo.getAttribute('color'); - const signal = new THREE.Points(signalGeo, new THREE.PointsMaterial({ + // Per-vertex SIZE, alongside per-vertex colour. + // + // Hue on its own was not enough to find a marked node inside a dense cluster — the philosophy + // knot is dozens of overlapping sprites accumulating to near-white, and a same-size dot of a + // different colour just joins the pile. That is an occlusion problem, not a contrast one, and + // no choice of hue fixes it. Scale does. + // + // `PointsMaterial` has no per-vertex size, so the vertex shader gets patched. The alternative + // was a second Points object holding one point, which is how `head` works — but that is an + // object drawn *over* the node rather than the node itself, and looking like a marker stuck on + // top is the thing this whole approach exists to avoid. + const signalScale = new THREE.BufferAttribute(new Float32Array(signalNodes.length).fill(1), 1); + signalGeo.setAttribute('aScale', signalScale); + + const signalMat = new THREE.PointsMaterial({ color: 0xffffff, vertexColors: true, map: sprite, @@ -174,7 +206,10 @@ export function build(container, data) { depthTest: false, depthWrite: false, fog: false, - })); + }); + signalMat.onBeforeCompile = perVertexSize; + + const signal = new THREE.Points(signalGeo, signalMat); signal.renderOrder = 2; world.add(signal); @@ -235,12 +270,65 @@ export function build(container, data) { // // 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) => { + // How much larger a marked node draws than an ordinary highlight. The quoted one is bigger + // than the newest because it is the one a reader goes looking for — they have just read the + // passage in the header and want to know where it sits. The newest is ambient; nobody hunts + // for it. Both were set by eye against a mark landing inside the philosophy cluster, which is + // the hardest case in the picture and the one that prompted this. + const NEWEST_SCALE = 1.5; + const QUOTED_SCALE = 2.1; + const SLOT = { newest: 0, quoted: 1 }; + + // ⚠️ Size alone did NOT make a marked node findable inside the philosophy cluster, and the + // reason is draw order, not contrast. Every highlight lives in one transparent Points object, + // so vertices draw in buffer order — a node marked early in the trace sits *under* the two + // dozen white sprites and the green trace lines that come after it, and no amount of scale or + // hue survives being blended over. + // + // So the two marked nodes are ALSO drawn into a two-point buffer with a renderOrder above the + // trace. Same position, same sprite, same colour as the tint underneath — it is the same mark + // rendered again where nothing can cover it, not a badge stuck on top of one. + const markPos = new THREE.BufferAttribute(new Float32Array(6), 3); + const markColor = new THREE.BufferAttribute(new Float32Array(6), 3); + // Scale 0 collapses gl_PointSize to nothing, which is how an unused slot hides. Cheaper and + // less stateful than juggling draw ranges for two points. + const markScale = new THREE.BufferAttribute(new Float32Array(2), 1); + const markGeo = new THREE.BufferGeometry(); + markGeo.setAttribute('position', markPos); + markGeo.setAttribute('color', markColor); + markGeo.setAttribute('aScale', markScale); + const markMat = new THREE.PointsMaterial({ + color: 0xffffff, + vertexColors: true, + map: sprite, + size: 0.062, + sizeAttenuation: true, + transparent: true, + opacity: 1, + depthTest: false, + depthWrite: false, + fog: false, + }); + markMat.onBeforeCompile = perVertexSize; + const marks = new THREE.Points(markGeo, markMat); + marks.renderOrder = 5; // above the trace (3) and the head (4) + world.add(marks); + + const paint = (node, hex, scale = 1, slot = null) => { 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; + signalScale.setX(row, scale); + signalScale.needsUpdate = true; + + if (slot !== null) { + markPos.setXYZ(slot, xyz[node * 3], xyz[node * 3 + 1], xyz[node * 3 + 2]); + markColor.setXYZ(slot, tmpColor.r, tmpColor.g, tmpColor.b); + markScale.setX(slot, scale); + markPos.needsUpdate = markColor.needsUpdate = markScale.needsUpdate = true; + } return true; }; @@ -248,7 +336,7 @@ export function build(container, data) { // 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; - if (newestNode != null) paint(newestNode, colors.newest); + if (newestNode != null) paint(newestNode, colors.newest, NEWEST_SCALE, SLOT.newest); // 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 @@ -403,6 +491,7 @@ export function build(container, data) { io.disconnect(); ro.disconnect(); renderer.dispose(); nebulaGeo.dispose(); signalGeo.dispose(); traceGeo.dispose(); headGeo.dispose(); + markGeo.dispose(); markMat.dispose(); sprite.dispose(); container.querySelector('canvas')?.remove(); dateEl.remove(); tipEl.remove(); @@ -419,15 +508,19 @@ export function build(container, data) { */ markQuoted(textHash) { 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); + // Reset the previous one — colour AND size — so calling this twice does not leave a trail + // of marked nodes. The newest node is restored to its own mark, not to plain signal. + if (quotedNode != null && quotedNode !== node) { + quotedNode === newestNode + ? paint(quotedNode, colors.newest, NEWEST_SCALE, SLOT.newest) + : paint(quotedNode, colors.signal, 1); + markScale.setX(SLOT.quoted, 0); // vacate the slot until a new node claims it + markScale.needsUpdate = true; } 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, colors.quoted); + return node != null && paint(node, colors.quoted, QUOTED_SCALE, SLOT.quoted); }, stats: { nodes: nodeCount, highlights: total, chunks: signalNodes.length }, // The resolved palette, so a legend can colour its swatches from the same values this