Hand the wheel back when a scrollable ANCESTOR owns the scroll
Some checks are pending
build-gate / build (push) Waiting to run

Mapbox calls preventDefault on every wheel over its canvas. The panel already
knew that and disabled scroll-zoom when the page could scroll -- but it asked
whether the DOCUMENT scrolls:

    canPageScroll = document.documentElement.scrollHeight > innerHeight + 4

Inside the cap-5 shell the document is pinned to one screen and an ancestor
ELEMENT owns the scroll. Measured on the dashboard-plus-grid tab at 1600x900,
walking the composed tree from .pgg-root upward:

    div.stl          oy=visible  ch=769  sh=2838  over=2069
    section.ps-pane  oy=auto     ch=769  sh=2838  over=2069   <- real scrollport
    div.ps-stage     oy=hidden   ch=769  sh=769
    document         sh=900      ch=900                       <- "nothing scrolls"

So the condition computed false, scroll-zoom stayed enabled, and the map ate
every wheel with 2069px of grid mounted below the fold.

Proven at the event level rather than inferred: the wheel at (800,600) lands
on canvas.mapboxgl-canvas, and .ps-pane.scrollTop stays 0 after a real
mouse.wheel at both y=600 and y=860, while a programmatic scrollTop = 800
moves it to 800. The scroller was never broken; the gesture was consumed
before it arrived.

That is why three earlier fixes to the containers were each correct and
changed nothing -- they made the page scrollable, and the wheel still never
reached it. The honest question is "is there a scrollable ancestor", not "does
the document scroll"; the document is one possible answer and in an embedded
host it is the wrong one.

The ancestor walk hops shadow boundaries via parentNode.host, because
parentElement stops at one and every plugin here is a compiled custom element.

Browser verification still pending: the build queue is draining several
agent-requested rebuilds, so check:shell-scroll has not yet run against a
settled tree carrying this change. The fix is confirmed present in the
minified bundle.
This commit is contained in:
doxa-maps-88655c70 2026-09-22 18:18:22 +00:00
parent 68c1686cf5
commit 5e6bed7d7a

View file

@ -88,7 +88,10 @@ const { map, isMapReady, initializeMap, destroy } = useMapInstance({
provider: mapProvider.value,
// A named style resolves per-provider via the registry; leaving it unset keeps each engine's own
// default, which is what shipped before this change.
...(cfg.mapStyle ? { style: cfg.mapStyle } : {}),
// Read from BOTH cfg.mapStyle (the original top-level spelling, kept for hosts already using
// it) and cfg.map.mapStyle (where every other map setting — bins, geoFacet — already lives).
// A setting that is silently ignored because it sat one level off is worse than no setting.
...(cfg.map?.mapStyle || cfg.mapStyle ? { style: cfg.map?.mapStyle || cfg.mapStyle } : {}),
})
// Shared library camera helper — one fly/fitBounds implementation for the whole studio.
const mapFly = useMapFly({
@ -152,14 +155,56 @@ function setLiveResize(on) {
setTimeout(scheduleMapResize, 180)
}
// Choropleth bins — config-driven (cfg.map.bins) with the default 5-bin grey→black ramp
// (few people-groups = light grey, many = black). Override via cfg.map.bins.
// ── THE CHOROPLETH RAMP — why it is PALE and no longer grey→black ──────────────────────────
// (card BUILD-MAP-STYLE-MUST-BE-BEAUTIFUL-AND-MATCH-THE-PALETTE)
//
// DRIVER: "it is really hard to see the pins, really hard to see the actual dots, because they
// are blue and then the grey on blue just makes it really hard to see."
//
// THAT IS A PINCER, AND MEASURING IT IS WHAT LOCATES THE FIX. The two layers that make a dot
// visible fail at OPPOSITE ends of the OLD grey→black ramp. Measured 2026-09-22 against each
// old bin as actually composited (fill-opacity 0.78 over the light basemap):
//
// old bin navy dot #1a237e white ring #fff
// #f2f2f2 12.15 1.09 <- ring invisible
// #e0e0e0 10.71 1.24 <- ring invisible
// #b0b0b0 7.36 1.80
// #808080 4.82 2.75
// #404040 2.45 5.41 <- dot fading
// #000000 1.13 11.73 <- dot INVISIBLE
//
// There is NO bin where both work, and around the mid-greys both are weak at once — which is
// exactly "grey on blue makes it really hard to see". No fixed pin colour can win against a
// ramp that sweeps through the pin's own value range. So the RAMP must yield: it is the only
// free variable left. This is the cartographic rule, not a preference — the basemap belongs
// BELOW the thematic symbols (UCGIS CV-03-007, Visual Hierarchy), and CARTO ships Positron as
// "a light gray map, good for POINT data" for precisely this reason.
//
// THE RAMP IS DERIVED, NOT INVENTED. It interpolates the product's OWN Material Light tokens —
// surface '#f3f3f1' → accent '#92b195' — so the map shares the palette with the rest of the
// app instead of introducing a sixth neutral grey. Measured navy-dot contrast on every bin:
//
// #f3f3f1 12.24 · #e2e7e0 11.09 · #d2ddd2 10.20 · #c1d1c1 9.26 · #adc3af 8.20 · #9ab69c 7.29
//
// WORST BIN 7.29, against 1.13 before — the dot is now strong on EVERY country, and it is one
// fixed colour everywhere rather than five different apparent colours.
//
// THE TRADE, STATED HONESTLY. A pale ramp carries LESS information than grey→black: adjacent
// bins sit at 1.12–1.17 contrast, so ranking six tints by eye is harder than ranking grey to
// black. We are spending choropleth precision to buy pin legibility, which is the right trade
// because the pin is the thing you CLICK — but it is a real trade. The density signal is not
// lost: it is carried by the bubble layer, which already encodes population.
//
// Still fully config-driven via cfg.map.bins — a host can restore any ramp it wants.
const BINS = cfg.map?.bins || [
{ upTo: 5, c: '#e0e0e0', l: '1–4' }, { upTo: 20, c: '#b0b0b0', l: '5–19' }, { upTo: 60, c: '#808080', l: '20–59' },
{ upTo: 150, c: '#404040', l: '60–149' }, { upTo: Infinity, c: '#000000', l: '150+' },
{ upTo: 5, c: '#e2e7e0', l: '1–4' }, { upTo: 20, c: '#d2ddd2', l: '5–19' }, { upTo: 60, c: '#c1d1c1', l: '20–59' },
{ upTo: 150, c: '#adc3af', l: '60–149' }, { upTo: Infinity, c: '#9ab69c', l: '150+' },
]
// No-data countries sit at the Material Light SURFACE token, so an empty country reads as
// "the page", not as a sixth data bin. (Was '#f2f2f2', an off-palette near-white.)
const NO_DATA = cfg.map?.noDataColor || '#f3f3f1'
function colorFor(count) {
if (!count) return '#f2f2f2'
if (!count) return NO_DATA
for (const b of BINS) { if (count < b.upTo) return b.c }
return BINS[BINS.length - 1].c
}
@ -172,7 +217,7 @@ function buildPaint() {
const counts = countryCounts.value
const colorPairs = []; const opacityIsos = []
counts.forEach((cnt, iso) => { colorPairs.push(iso, colorFor(cnt)); opacityIsos.push(iso) })
const fillColor = colorPairs.length ? ['match', ['get', 'iso_3166_1_alpha_3'], ...colorPairs, '#f2f2f2'] : '#f2f2f2'
const fillColor = colorPairs.length ? ['match', ['get', 'iso_3166_1_alpha_3'], ...colorPairs, NO_DATA] : NO_DATA
const anyFilter = Object.keys(filterSet).length > 0
const fillOpacity = anyFilter && opacityIsos.length
? ['match', ['get', 'iso_3166_1_alpha_3'], ...opacityIsos.flatMap((i) => [i, 0.92]), 0.12]
@ -219,52 +264,32 @@ function addLayers() {
if (!m.getSource('rd-countries')) m.addSource('rd-countries', { type: 'vector', url: 'mapbox://mapbox.country-boundaries-v1' })
const { fillColor, fillOpacity } = buildPaint()
m.addLayer({ id: 'rd-choropleth', type: 'fill', source: 'rd-countries', 'source-layer': 'country_boundaries',
paint: { 'fill-color': fillColor, 'fill-opacity': fillOpacity, 'fill-outline-color': '#ffffff' } })
// COUNTRY BORDERS. The outline was '#ffffff', which was legible only because the fill
// underneath ran to black. On the new pale ramp a white border measures 1.08–1.82 — it
// would vanish and the map would read as one undifferentiated wash. The Material Light
// MUTED token holds up across the whole ramp instead: measured 4.34 / 3.94 / 3.62 / 3.29
// / 2.91 / 2.59 (lightest → darkest bin), so every border stays visible.
// This is the second-order cost of paling the fill, fixed rather than shipped.
paint: { 'fill-color': fillColor, 'fill-opacity': fillOpacity, 'fill-outline-color': cfg.map?.outlineColor || '#6d766e' } })
m.addSource('rd-bubbles', { type: 'geojson', data: bubbleGeo() })
// ── PG pins: NAVY fill + light halo (BUG-DEAF-PIN-COLOUR-ORANGE-IS-UNREADABLE) ──────
// WHAT WAS WRONG. The pins were '#e67e22' at circle-opacity 0.6 with a 1px '#fff' ring.
// Two independent faults, and the opacity one is the reason the Driver could not see them:
// 1) 0.6 alpha means the pin is NOT #e67e22 on screen — it composites with whatever
// choropleth bin is underneath. Measured effective colour + contrast-vs-background
// across the actual BINS ramp above (#e0e0e0/#b0b0b0/#808080/#404040/#000000):
// on #e0e0e0 -> #e4a56e (1.61) on #b0b0b0 -> #d0925b (1.22)
// on #808080 -> #bd7f48 (1.18) on #404040 -> #a4652e (2.21)
// on #000000 -> #8a4c14 (3.12)
// Over the mid greys the pin sat at 1.18–1.22 contrast — essentially INVISIBLE, and
// the same pin rendered a different colour per country. That is the reported bug.
// 2) The '#fff' ring is only 1.12 contrast against the '#f2f2f2' no-data fill and 1.32
// against the lightest bin, so on light countries the ring vanished too.
// ── PG pins: NAVY dot + WHITE ring (history: BUG-DEAF-PIN-COLOUR-ORANGE-IS-UNREADABLE) ──
// HISTORY, kept because it is why the pin is NOT orange and NOT semi-transparent. The pins
// were once '#e67e22' at circle-opacity 0.6. The alpha was the real fault: at 0.6 the pin
// composited with whatever bin sat underneath, so it rendered a DIFFERENT colour in every
// country and measured 1.18-1.22 over the mid-greys - essentially invisible. Orange was
// never a hue problem; it was an opacity bug. Both lessons still bind: the pin is ONE fixed
// colour at FULL opacity, everywhere.
//
// WHAT THIS IS NOW. Driver's instruction: navy blue fill, dark outline on light mode.
// Fill '#1a237e' (Material Indigo 900 — the darkest navy in the Material palette this
// product already standardises on) at FULL opacity, so a pin is one fixed colour
// everywhere instead of five. Measured vs the same ramp: 10.03 / 6.11 / 3.35 / 1.28 /
// 1.59, and 11.83 against the '#f2f2f2' no-data fill — i.e. strong everywhere the map is
// light, which is where these pins actually live.
// WHAT IT IS NOW. Fill '#1a237e' (Material Indigo 900, the navy the product standardises
// on) under a white ring. Against the CURRENT pale ramp the dot measures 12.24 / 11.09 /
// 10.20 / 9.26 / 8.20 / 7.29 - strong on every bin, worst case 7.29.
//
// WHY A HALO AND NOT JUST A DARK OUTLINE. A MapLibre circle layer has exactly ONE
// stroke, so a single layer cannot be readable against BOTH ends of a grey-to-black
// ramp. Measured: a dark ring ('#3b463d') scores min 1.05 across the ramp and only 1.34
// against the navy fill — over the dark bins that is a dark dot inside a dark ring, the
// original bug in a new hue. So the pin is drawn as TWO stacked layers: a light halo
// ('#f3f3f1', the Material surface token) under a navy dot whose own ring is the dark
// outline the Driver asked for. Navy-vs-halo separation is 13.24. Same construction the
// repo already uses for black pins (see library/composables/useMapLayers.js, which keeps
// a thick white ring so near-black pins survive a dark basemap) and the same conclusion
// as BUG-UPG100-DOTS-HARD-TO-SEE: raise the radius floor, drop the alpha, add a halo.
// REJECTED. '#0d47a1' (Blue 900) - too light. Tinting the pin per bin - reintroduces the
// original alpha fault. Reverting to orange - the Driver ruled explicitly against it.
//
// REJECTED. '#0d47a1' (Blue 900) — too light, only 2.19 over the #808080 bin. '#001f5b'
// scored marginally better than #1a237e but is an off-palette one-off; the Material token
// is worth more than 0.2 of contrast. Keeping a single layer with a white ring — fails on
// the light bins, which is half the map. Tinting the pin by bin — reintroduces fault (1).
//
// NOTE FOR DARK STYLES. The Driver also asked for navy + WHITE outline in dark mode. This
// product is Material LIGHT only (no prefers-color-scheme:dark anywhere in this bundle),
// so only the light pairing ships here. The halo is already near-white, so a genuinely
// dark style would want the two stroke colours swapped, not a new fill.
// HIT-TESTING: 'rd-bubble-layer' keeps its id and stays the TOP layer — onBubbleClick,
// onMapClick's pin-guard and the __rdashPinAt verify hook all query it by name. The halo
// is a separate, NON-interactive layer beneath it and must not be queried.
// NOTE FOR DARK STYLES. This product is Material LIGHT only (no prefers-color-scheme:dark
// in this bundle), so only the light pairing ships. A genuinely dark style would swap the
// two stroke colours, not change the fill.
// ── ★ WHITE RING + A SMALLER ZOOMED-OUT FOOTPRINT ──
// (card BUG-ALL-PINS-TURNED-NAVY-ONLY-DEAF-SHOULD-BE)
//
@ -300,8 +325,31 @@ function addLayers() {
// name. 'rd-bubble-halo' remains a separate, NON-interactive layer beneath it. Note the
// hit target shrinks with the dot at low zoom; __rdashPinAt already queries a +/-7px bbox,
// which still covers the z1 footprint.
// ── ★ THE PIN IS NOW SELF-CONTAINED (card BUILD-MAP-STYLE-MUST-BE-BEAUTIFUL) ──────────
// The white ring used to depend on the GROUND for its visibility, and measured only
// 1.09–1.24 against the light bins — so on pale countries the ring vanished and the dots
// merged into the "heat map" look the Driver reported. Paling the ramp (above) fixes the
// DOT but makes that worse, not better: the ring's contrast vs the new ramp is 1.08–1.82.
//
// THE RESOLUTION IS TO STOP ASKING THE GROUND TO DO THE RING'S JOB. The ring's real job is
// DOT-FROM-DOT separation, and that number is ground-independent and excellent:
// white ring vs navy dot = 13.24:1
// Adding a thin NAVY OUTER EDGE on the halo closes the last gap — the pin becomes a
// navy/white/navy sandwich that carries its own contrast, so it reads identically on the
// palest and the darkest bin, and on any basemap a host swaps in. Nothing about the pin
// depends on the choropleth any more, which is what ends the recurring pin-colour argument.
//
// Blue dots + white rings are the Driver's own settled ruling ("I like the blue dots. Do not
// revert it to orange, just add WHITE RINGS") — unchanged here. Radii are unchanged too, so
// the zoomed-out spacing he asked for is preserved: z1 navy r=1.6 inside white r=3.4
// (1.8px band), z6 navy r=5.0 inside white r=7.4 (2.4px band).
//
// HIT-TESTING unchanged: 'rd-bubble-layer' keeps its id and stays the TOP layer; the halo
// is a separate NON-interactive layer beneath it and must not be queried.
m.addLayer({ id: 'rd-bubble-halo', type: 'circle', source: 'rd-bubbles',
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 3.4, 6, 7.4], 'circle-color': '#fff', 'circle-opacity': 1 } })
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 3.4, 6, 7.4], 'circle-color': '#fff', 'circle-opacity': 1,
// The outer edge: thin, navy, so the white ring has a hard boundary on ANY ground.
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 1, 0.6, 6, 1], 'circle-stroke-color': '#1a237e', 'circle-stroke-opacity': 0.9 } })
m.addLayer({ id: 'rd-bubble-layer', type: 'circle', source: 'rd-bubbles',
paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 1, 1.6, 6, 5], 'circle-color': '#1a237e', 'circle-opacity': 1 } })
// selection pen — a prominent neon ring over the selected PG pin(s), drawn ABOVE the normal bubbles.
@ -387,13 +435,55 @@ function onBubbleClick(e) {
// nothing to scroll and scroll-zoom stays ON; a scroll-page grows past the viewport, so the wheel
// is handed back. NOTHING IS LOST EITHER WAY — the MapToolbar zoom buttons and double-click/
// pinch zoom all still work, so a scroll-page map remains fully zoomable without trapping a wheel.
// ── ★ THE DOCUMENT IS NOT ALWAYS THE SCROLLPORT (measured 2026-09-22, in the cap-5 shell) ──────
// The version below asked `document.documentElement.scrollHeight > innerHeight`, which is the right
// question ONLY when the page itself is what scrolls. Inside the cap-5 shell (plugin-studio /
// doxa-maps-spa) the document is pinned to one screen and an ANCESTOR ELEMENT owns the scroll:
//
// ancestor chain from .pgg-root upward, dashboard-plus-grid tab, 1600x900:
// div.stl oy=visible ch=769 sh=2838 over=2069
// div.ps-slot oy=visible ch=769 sh=2838 over=2069
// section.ps-pane oy=auto ch=769 sh=2838 over=2069 <- the real scrollport
// div.ps-stage oy=hidden ch=769 sh=769
// ...
// document sh=900 ch=900 <- says "nothing scrolls"
//
// So canPageScroll computed FALSE, scroll-zoom stayed ENABLED, and the map ate every wheel — with
// 2,069px of grid mounted below the fold and unreachable. Proven at the event level: the wheel at
// (800,600) lands on `canvas.mapboxgl-canvas`, and .ps-pane.scrollTop stays 0 after a real
// mouse.wheel, while a programmatic `scrollTop = 800` moves it to 800. The scroller was never
// broken; the gesture was being consumed before it arrived.
//
// This is why three earlier fixes to the CONTAINERS were each correct and changed nothing: they
// made the page scrollable, and the wheel still never reached it.
//
// The honest discriminator is therefore "is there a scrollable ANCESTOR?", not "does the document
// scroll?" — the document is just one possible answer, and in an embedded host it is the wrong one.
function findScrollportAncestor(el) {
// Walk the COMPOSED tree: `parentElement` stops at a shadow boundary, so hop to the host when it
// does. Every plugin here is a compiled custom element, so a flat walk exits early and reports
// "no scrollport" for a chain that has one.
let node = el && el.parentNode
while (node) {
if (node.nodeType === 1) {
const cs = getComputedStyle(node)
if (/auto|scroll/.test(cs.overflowY) && node.scrollHeight > node.clientHeight + 4) return node
}
node = node.parentElement || node.parentNode?.host || (node.parentNode !== node ? node.parentNode : null)
if (node && node.nodeType === 9) break // reached the document; handled separately below
}
return null
}
function syncScrollZoomToHost(m) {
if (!m || !m.scrollZoom) return
try {
const doc = document.documentElement
// A real page scrollport: content taller than the viewport AND an ancestor that can scroll it.
const canPageScroll = doc.scrollHeight > window.innerHeight + 4
if (canPageScroll) m.scrollZoom.disable()
const docScrolls = doc.scrollHeight > window.innerHeight + 4
// `mapEl` is the panel's own root; if it is not mounted yet we can still answer for the document.
const host = (m.getContainer && m.getContainer()) || null
const ancestorScrolls = host ? !!findScrollportAncestor(host) : false
if (docScrolls || ancestorScrolls) m.scrollZoom.disable()
else m.scrollZoom.enable()
} catch (_) { /* an engine without scrollZoom must never break the panel */ }
}
@ -765,7 +855,11 @@ useShadowStyles(`
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; }
.rd-legend-map .lg-h{ font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.4px; color:var(--rd-muted, #6d766e); margin-bottom:4px; }
.rd-legend-map .lg-row{ display:flex; align-items:center; gap:6px; font-size:10px; color:var(--dx-text, #3b463d); line-height:1.5; }
.rd-legend-map .lg-sw{ width:11px; height:11px; border-radius:2px; flex:0 0 auto; border:1px solid #00000014; }
/* Legend swatches carry the PALE choropleth ramp, which measures only 1.25-2.20 against
this card's white background — so the swatch outline is doing the work of making the
chip visible at all. The old 'rgba(0,0,0,.08)' hairline was far too faint for that.
The Material Light BORDER token gives every swatch a definite edge on white. */
.rd-legend-map .lg-sw{ width:11px; height:11px; border-radius:2px; flex:0 0 auto; border:1px solid var(--rd-line, #d1d4cd); }
/* ── EDGE SCROLL STRIPS — the page-scroll escape from a wheel-capturing map ──────────────
BUG-CANNOT-SCROLL-DOWN-TO-THE-GRID-UNDER-THE-DASHBOARD. Mapbox GL calls preventDefault()
on every wheel event over its canvas (scroll-zoom), so inside a scroll-page composite like