The legend reaches the map immediately: the shader variant compiles at boot, not on the first click

The Driver: on Simple Research the research map is not connecting with the legend. It was connecting —
two seconds later, which a reader correctly reads as "nothing happened".

I was wrong about the cause twice, and both wrong answers were cheap to discard because they were
measured. First guess: a late echo through applyDimFilter's 80ms dedupe, whose toggle branch would read
the echo as a second click and clear the highlight. rega instrumented the pin layer every frame for 2 s:
ZERO 'legend:highlight' events on every click and no apply-then-revert anywhere. Second guess: the JS
filter scan in zoomToLegendRow, which walks 2,106 features evaluating ['in',['get','uniqueId'],
['literal', ~700 ids]] linearly. Benchmarked at the real shapes: 13.9 ms per full scan, 4.4 ms with a
memoised Set. Two orders of magnitude short, so nothing there was "optimised".

rega's CPU profile named it. ONE task of 3.2-3.5 s, self time getShaderParameter 3,583 ms +
getProgramParameter 551 ms: the GPU compiling a NEW SHADER VARIANT. The click was the first moment
circle-opacity stopped being a constant and became a per-feature expression, and a data-driven paint
property needs a different program. The second click on the same map costs 205 ms — the program is
cached — which is the signature of a one-time compile and not of anything in our logic. The legend's DOM
was never involved: 34 rows before the click, 34 after.

So the fix is not a smaller expression. It is that the first SELECTION must not be the moment the
variant is first needed. With nothing selected the value is now ['case',['has','uniqueId'], a, a] —
both branches identical, so not one pixel changes — and circle-opacity is data-driven from the first
paint. The program compiles during boot beside every other one, and a selection afterwards only
re-uploads attribute values.

The trade, stated rather than hidden: boot compiles one more program. On a real GPU that is tens of
milliseconds; rega measured seconds only because that box rasterises on the CPU through SwiftShader.
Paying it at load beats freezing the map mid-gesture, and the variant was going to be compiled on the
first interaction anyway.

Second finding from the same profile, fixed here too: circle-opacity was being rewritten 4-8 times for a
single click (+1.52, +1.57, +1.94, +5.17 s) because the selection handler, the glow sync and the slider
watcher all end up in this one function, and every write re-uploads the per-feature attribute. It now
writes only when the value actually differs — asked of the MAP rather than of a remembered key, so a
layer that gets torn down and re-added cannot leave the skip holding a dim that is no longer painted.

Verified: gates:pre 12/12 (check:setup-order included, since this touches module-scope state next to a
function that reads it). Needs eyes: rega, the same affinity click — expect the dim at the first frame
and one long task at BOOT instead.
This commit is contained in:
Doxa Maps 2026-09-28 02:08:30 +00:00
parent d690b1af41
commit 6eae46c002

View file

@ -1637,12 +1637,43 @@ function pinBaseAlpha() {
}
// matchExpr: an expression to dim by, null to clear dimming, undefined to re-apply what is current
// (which is what the slider's watcher needs — it changes the scale, not the selection).
// ★ THE UNDIMMED VALUE IS DATA-DRIVEN TOO, AND THAT IS THE WHOLE POINT (Driver bug: "the research map
// isn't connecting with the legend"; rega's CPU profile, 2026-09-28). A legend click on a big affinity
// row appeared to do nothing for ~2 s. It was not the echo guard, not the legend's DOM (34 rows before,
// 34 after) and not expression evaluation (the JS filter scan over 2,106 features against a 700-id list
// measures 14 ms). It was ONE task of 3.2-3.5 s whose self time was getShaderParameter + getProgram-
// Parameter: the GPU compiling a NEW SHADER VARIANT, because this was the first moment circle-opacity
// stopped being a constant and became a per-feature expression. The second click was cheap — the
// program was cached — which is exactly the signature of a one-time compile.
//
// So the fix is not to make the expression smaller; it is to stop the FIRST SELECTION being the moment
// the variant is needed. With no selection the value is now ['case', ['has','uniqueId'], a, a] — both
// branches identical, so not one pixel changes, but circle-opacity is data-driven from the first paint
// and the program compiles during boot alongside every other one. A selection then only re-uploads
// attribute values, which is the cheap path.
//
// The trade, stated: boot compiles one more program (tens of ms on a real GPU; rega measured seconds
// only because that box rasterises on the CPU via SwiftShader). Paying it at load beats freezing the
// map mid-gesture, and the variant was going to be compiled on first interaction anyway.
function applyPinOpacity(m, matchExpr) {
if (matchExpr !== undefined) _pinMatchExpr = matchExpr || null
if (!m || !m.getLayer?.('language-family-pins')) return
const a = pinBaseAlpha()
const value = _pinMatchExpr ? ['case', _pinMatchExpr, a, 0] : a
// ['has','uniqueId'] reads a feature property, which is what makes it data-driven; both outcomes are
// `a`, so a pin without the property renders exactly as before.
const value = _pinMatchExpr ? ['case', _pinMatchExpr, a, 0] : ['case', ['has', 'uniqueId'], a, a]
// ★ ONE WRITE PER DISTINCT VALUE. rega counted circle-opacity rewritten 4-8 times for a single click
// (+1.52, +1.57, +1.94, +5.17 s): the selection handler, the glow sync and the slider watcher all end
// up here, and each write re-uploads the per-feature attribute. Every writer already routes through
// this function, so the collapse belongs here and nowhere else. Compared as text because the value is
// an expression tree; at a few hundred ids that is microseconds against a re-upload.
try {
// Asked of the MAP, not of a remembered key. A remembered one would go stale the moment the layer
// is torn down and re-added (setStyle wipes custom layers — this file has done that before) and the
// skip would then hold a dim that is no longer painted. Reading the live value cannot be stale, and
// if an engine normalises the expression differently the compare simply misses and we write, which
// is the old behaviour rather than a wrong one.
if (JSON.stringify(m.getPaintProperty('language-family-pins', 'circle-opacity')) === JSON.stringify(value)) return
m.setPaintProperty('language-family-pins', 'circle-opacity', value)
m.setPaintProperty('language-family-pins', 'circle-stroke-opacity', value)
if (m.getLayer('language-family-pins-shadow')) {