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')) {