From 5d82899d183dd0272678f78c0ca27a4b5ddb96f3 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 16:55:33 +0000 Subject: [PATCH] Three maps asked the window which engine drew them; now they ask the map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C3, buckets A and B. Each site was sniffing `window.maplibregl.Map` directly, which breaks the moment a leaf stops loading a static engine peer: the global is simply absent and the sniff answers about whatever else the page happened to load. A needed a helper that did not exist. Both A sites want a provider NAME from a live map. engineFor() returns the engine NAMESPACE, and resolveMapProvider() reads a CONFIG rather than a map, so on a dual-engine page it answers about the wrong thing. Hence providerFor(map) -> 'mapbox' | 'maplibre' | undefined, with two named consumers on arrival. ★ WHY providerFor RETURNS undefined INSTEAD OF A DEFAULT. The obvious rewrite was in terms of the existing isMapboxMap(). It is wrong in a way no per-engine test catches: both originals test for MAPLIBRE and fall through to mapbox/false, so an isMapboxMap() version inverts the fall-through to maplibre/true. On a stub, a destroyed map, or a page where neither global loaded, that changes what a diagnostic field reports AND which attribution a poster prints -- while agreeing on both real engines. Returning undefined forces each call site to keep its own default visible: pplrData.js engine: providerFor(map) || 'mapbox' research-map.vue const onMaplibre = providerFor(map.value) === 'maplibre' Changing a default is not a curation migration. B is engineGlobal(mapProvider), which already defaults to Mapbox for anything unrecognised -- exactly what the ternary's else branch did. All three files now carry zero raw window.mapboxgl|maplibregl references in code. Verified by _qa-cycle/02-builder-cycle/_chk-c3-provider-helper-equivalence.mjs: 27 assertions, each old pattern against its replacement on both engines and on {}, null and undefined, plus an executable proof of the isMapboxMap near-miss and three negative controls. --- library/composables/useMapProvider.js | 30 +++++++++++++++++++ .../all-people-groups/all-people-groups.vue | 6 ++-- .../doxa-research-map/research-map.vue | 6 ++-- .../pplr-data-maps/src/utils/pplrData.js | 6 ++-- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/library/composables/useMapProvider.js b/library/composables/useMapProvider.js index 26790a2df..c499e1cfe 100644 --- a/library/composables/useMapProvider.js +++ b/library/composables/useMapProvider.js @@ -106,6 +106,36 @@ export function isMapboxMap(map) { * @param {object} map the live map instance * @returns {object|undefined} the engine namespace, or undefined when neither is loaded */ +/** + * The provider NAME that built THIS map: 'mapbox', 'maplibre', or undefined when neither global is + * loaded or `map` is not a map at all. + * + * ★ WHY THIS EXISTS, given engineFor() is right above it. engineFor returns the engine NAMESPACE, + * which is what you want for constructing things (Marker, Popup, a second Map). Two call sites want + * the opposite: a NAME, to put in a diagnostic field and to choose an attribution string. They were + * each doing their own `map instanceof window.maplibregl.Map` sniff, which is the raw-global coupling + * C3 exists to remove — and `resolveMapProvider()` cannot serve them because it reads a CONFIG, not a + * live map, so on a dual-engine page it answers about the wrong thing. + * + * ★ THE AMBIGUOUS CASE IS THE WHOLE REASON THIS RETURNS undefined RATHER THAN A DEFAULT. Both call + * sites tested for MAPLIBRE and fell through to 'mapbox', so a stub, a destroyed map or a page where + * neither global loaded read as 'mapbox'. A rewrite in terms of the existing isMapboxMap() would have + * inverted that — falling through to 'maplibre' — and silently changed what a diagnostic reports and + * which attribution a poster prints. Returning undefined forces each caller to keep its OWN default + * visible at the call site, so the migration cannot quietly move a default. Changing a default is not + * a curation migration. + * + * @param {object} map the live map instance + * @returns {'mapbox'|'maplibre'|undefined} + */ +export function providerFor(map) { + if (typeof window === 'undefined') return undefined + const ml = window.maplibregl, mb = window.mapboxgl + if (ml && ml.Map && map instanceof ml.Map) return 'maplibre' + if (mb && mb.Map && map instanceof mb.Map) return 'mapbox' + return undefined +} + export function engineFor(map) { if (typeof window === 'undefined') return undefined const ml = window.maplibregl, mb = window.mapboxgl diff --git a/plugin-designs/all-people-groups/all-people-groups.vue b/plugin-designs/all-people-groups/all-people-groups.vue index 805464fd5..42b9c9ca8 100644 --- a/plugin-designs/all-people-groups/all-people-groups.vue +++ b/plugin-designs/all-people-groups/all-people-groups.vue @@ -31,7 +31,7 @@ */ import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue' import { useI18n } from 'vue-i18n' -import { useMapInstance } from '@map/composables/useMapInstance.js' +import { useMapInstance, engineGlobal } from '@map/composables/useMapInstance.js' import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js' import { useMapData } from '@map/composables/useMapData.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' @@ -295,7 +295,9 @@ function addLayer(fc) { const pray = doxaRec && doxaRec.slug ? `${esc(tr('pray'))}` : '' - const NS = mapProvider === 'maplibre' ? window.maplibregl : window.mapboxgl + // C3: engineGlobal() resolves a provider NAME to its namespace and already defaults to Mapbox + // for anything unrecognised, which is exactly what this ternary's else branch did. + const NS = engineGlobal(mapProvider) new NS.Popup({ closeButton: true, maxWidth: '290px' }) .setLngLat(e.lngLat) .setHTML(`
diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index 330dc6c89..cbcf3a13d 100644 --- a/plugin-designs/doxa-research-map/research-map.vue +++ b/plugin-designs/doxa-research-map/research-map.vue @@ -41,7 +41,7 @@ import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponen // ─── Composables (framework) ───────────────────────────────────────────────── import { useMapInstance } from '@map/composables/useMapInstance.js' -import { resolveMapProvider, providerLabel, setProjectionOn } from '@map/composables/useMapProvider.js' +import { resolveMapProvider, providerLabel, providerFor, setProjectionOn } from '@map/composables/useMapProvider.js' import { useMapLayers, PIN_STROKE_WIDTH_LIGHT, buildDarkRingWidth } from '@map/composables/useMapLayers.js' import { useMapTheme, THEME_TOGGLE, FIXED_BASEMAP } from '@map/composables/useMapTheme.js' import { useMapEvents } from '@map/composables/useMapEvents.js' @@ -1319,7 +1319,9 @@ async function openPoster() { spec.slots.scaleBar = false spec.slots.north = false // Credit the tiles actually drawn: OpenFreeMap on MapLibre, Mapbox otherwise. - const onMaplibre = !!(window.maplibregl && map.value instanceof window.maplibregl.Map) + // C3: ask the library, not the globals. The explicit === 'maplibre' keeps the original FALSE for + // a map that is neither, so the Mapbox attribution stays the fall-through it always was. + const onMaplibre = providerFor(map.value) === 'maplibre' spec.slots.footer = { ...spec.slots.footer, left: 'pray.doxa.life', right: new Date().toISOString().slice(0, 10), attribution: onMaplibre ? '© OpenFreeMap © OpenMapTiles © OpenStreetMap' : '© Mapbox © OpenStreetMap' } const blob = await exporter.render(spec, 'png') diff --git a/plugin-designs/pplr-data-maps/src/utils/pplrData.js b/plugin-designs/pplr-data-maps/src/utils/pplrData.js index f19741be7..c65c9a130 100644 --- a/plugin-designs/pplr-data-maps/src/utils/pplrData.js +++ b/plugin-designs/pplr-data-maps/src/utils/pplrData.js @@ -8,7 +8,7 @@ */ import { CDN, pinElement } from '@map/constants/cdn.js' import { loadPinnedUmd } from '@map/utils/loadPinnedUmd.js' -import { setProjectionOn } from '@map/composables/useMapProvider.js' +import { providerFor, setProjectionOn } from '@map/composables/useMapProvider.js' let base = './assets' export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') } export function dataUrl(file) { return base + '/pplr/' + file } @@ -112,7 +112,9 @@ export function exposeMap(map) { let layers = [] try { layers = map.getStyle()?.layers || [] } catch (_) {} return { - engine: window.maplibregl && window.maplibregl.Map && map instanceof window.maplibregl.Map ? 'maplibre' : 'mapbox', + // C3: ask the library, not the globals. `|| 'mapbox'` keeps the ORIGINAL fall-through for a map + // that is neither (stub, destroyed, neither global loaded) -- see providerFor's own note. + engine: providerFor(map) || 'mapbox', // 'load' may already have fired before this listener was attached; a loaded style says so too loaded: loaded || !!(map.isStyleLoaded && map.isStyleLoaded()), styleLayers: layers.length,