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:
parent
d690b1af41
commit
6eae46c002
1 changed files with 32 additions and 1 deletions
|
|
@ -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')) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue