Three maps asked the window which engine drew them; now they ask the map

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.
This commit is contained in:
Doxa Maps 2026-09-26 16:55:33 +00:00
parent 50a29b463d
commit 5d82899d18
4 changed files with 42 additions and 6 deletions

View file

@ -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

View file

@ -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
? `<a href="https://pray.doxa.life/${encodeURIComponent(doxaRec.slug)}?source=doxalife&utm_source=${utmSource()}" target="_blank" rel="noopener">${esc(tr('pray'))}</a>`
: ''
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(`<div class="apg-pop">

View file

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

View file

@ -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,