Reading map: make a marked node findable inside a dense cluster
A quote landing in the philosophy knot was effectively invisible. Two causes, and only the second one was obvious. Size. `PointsMaterial` has one size for the whole object, so the vertex shader is patched to take a per-vertex `aScale`. Quoted draws 2.1x, newest 1.5x — quoted is larger because it is the one a reader goes hunting for, having just read the passage in the header; the newest is ambient and nobody looks for it. Draw order, which was the real problem and which no hue or size would have fixed. Every highlight is 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 gets blended away. Measured on the worst case in the picture — the most crowded Meditations node, 25 neighbours within 0.05 — the mark rendered at 20 saturated pixels, peaking at (209,114,225) against a target of (232,121,249). So the two marked nodes are drawn a second time into their own two-point buffer above the trace. Same position, same sprite, same colour as the tint underneath: the same mark rendered where nothing can cover it, not a badge stuck on top. That is 54 pixels peaking at (229,120,245) — essentially the pure colour, and visible at a glance in the zoom. An unused slot hides by holding scale 0, which collapses gl_PointSize, rather than by juggling draw ranges for two points. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+102
-9
@@ -53,6 +53,24 @@ function dotTexture() {
|
|||||||
return tex;
|
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 } = {}) {
|
export async function mount(container, { src, onError } = {}) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(src);
|
const res = await fetch(src);
|
||||||
@@ -161,7 +179,21 @@ export function build(container, data) {
|
|||||||
signalGeo.setAttribute('color', signalColor);
|
signalGeo.setAttribute('color', signalColor);
|
||||||
const signalColorAttr = signalGeo.getAttribute('color');
|
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,
|
color: 0xffffff,
|
||||||
vertexColors: true,
|
vertexColors: true,
|
||||||
map: sprite,
|
map: sprite,
|
||||||
@@ -174,7 +206,10 @@ export function build(container, data) {
|
|||||||
depthTest: false,
|
depthTest: false,
|
||||||
depthWrite: false,
|
depthWrite: false,
|
||||||
fog: false,
|
fog: false,
|
||||||
}));
|
});
|
||||||
|
signalMat.onBeforeCompile = perVertexSize;
|
||||||
|
|
||||||
|
const signal = new THREE.Points(signalGeo, signalMat);
|
||||||
signal.renderOrder = 2;
|
signal.renderOrder = 2;
|
||||||
world.add(signal);
|
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
|
// 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.
|
// 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);
|
const row = rowOfNode.get(node);
|
||||||
if (row === undefined) return false;
|
if (row === undefined) return false;
|
||||||
tmpColor.set(hex);
|
tmpColor.set(hex);
|
||||||
signalColor.setXYZ(row, tmpColor.r, tmpColor.g, tmpColor.b);
|
signalColor.setXYZ(row, tmpColor.r, tmpColor.g, tmpColor.b);
|
||||||
signalColorAttr.needsUpdate = true;
|
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;
|
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
|
// 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, 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
|
// 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
|
||||||
@@ -403,6 +491,7 @@ 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();
|
||||||
|
markGeo.dispose(); markMat.dispose();
|
||||||
sprite.dispose();
|
sprite.dispose();
|
||||||
container.querySelector('canvas')?.remove();
|
container.querySelector('canvas')?.remove();
|
||||||
dateEl.remove(); tipEl.remove();
|
dateEl.remove(); tipEl.remove();
|
||||||
@@ -419,15 +508,19 @@ export function build(container, data) {
|
|||||||
*/
|
*/
|
||||||
markQuoted(textHash) {
|
markQuoted(textHash) {
|
||||||
const node = nodeOfHash.get(textHash) ?? null;
|
const node = nodeOfHash.get(textHash) ?? null;
|
||||||
// Repaint the previous one back to the scheme colour, so calling this twice does not
|
// Reset the previous one — colour AND size — so calling this twice does not leave a trail
|
||||||
// leave a trail of tinted nodes behind it.
|
// of marked nodes. The newest node is restored to its own mark, not to plain signal.
|
||||||
if (quotedNode != null && quotedNode !== node && quotedNode !== newestNode) {
|
if (quotedNode != null && quotedNode !== node) {
|
||||||
paint(quotedNode, colors.signal);
|
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;
|
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, colors.quoted);
|
return node != null && paint(node, colors.quoted, QUOTED_SCALE, SLOT.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
|
// The resolved palette, so a legend can colour its swatches from the same values this
|
||||||
|
|||||||
Reference in New Issue
Block a user