diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue index eb87fd189..0b4a8363a 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue @@ -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