From 6eae46c002bf5eb2b3072b0fde254400aa5bf5de Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 02:08:30 +0000 Subject: [PATCH] The legend reaches the map immediately: the shader variant compiles at boot, not on the first click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../doxa-research-map/research-map.vue | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 819390233..ed67c4a94 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -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')) {