engineFor(map): build markers, popups and poster maps with the map's OWN engine

Every generated leaf <head> preloaded mapbox-gl.js whatever the provider, so code that used the
bare `mapboxgl` global always found one, even on a MapLibre map. Removing that peer (it sends
every visitor's IP to Mapbox under provider=maplibre) turns each such site into an error.
engineFor(map), next to isMapboxMap in useMapProvider.js, asks the map which engine built it:
  - useSelectedPin: the selected-pin Marker
  - useMapEvents: the pin-fallback and region Popups
  - useMapPoster: main already builds the hidden map with the source map's engine (1cbfb3aa);
    its choice now goes through this one helper instead of a second inline sniff
  - pplr-data-maps: glFor is now this helper; the geocoder awaits ensureEngineScript first
  - key-cities: ensureEngineScript('mapbox') before reading the global
Measured with every api.mapbox.com tag stripped from the served leaf (no peer at all for
key-cities), before -> after:
  pin select under MapLibre, on cities-internet, country-posters, doxa-research-map and
  doxa-simple-map: "reading 'Marker'" -> marker drawn; country-posters region popup: none ->
  popup; PPLR search: no input -> suggestions (both engines); key-cities: "mapbox-gl not
  loaded" -> map loaded. Mapbox rows unchanged (they passed before and after).
The poster UI could not be driven: PosterDialog never opens in country-posters or
doxa-research-map (they pass props it does not declare and no modelValue/getMap), reported
separately. useMapPoster itself was rendered from a real map with only its engine on the page:
MapLibre "mapboxgl global not found" on the pre-1cbfb3aa library -> a 400x300 PNG with 88 colours.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 18:01:49 +00:00
parent 966c44fcc0
commit 056942b33f
7 changed files with 44 additions and 16 deletions

View file

@ -33,6 +33,7 @@
import { ref, readonly } from 'vue'; import { ref, readonly } from 'vue';
import { computeLayout } from './usePosterLayout.js'; import { computeLayout } from './usePosterLayout.js';
import { engineFor } from '../useMapProvider.js';
const MAX_SINGLE_CANVAS = 4096; // WebGL safe ceiling for most hardware const MAX_SINGLE_CANVAS = 4096; // WebGL safe ceiling for most hardware
const TILE_PX = 2048; // tile size for stitch path const TILE_PX = 2048; // tile size for stitch path
@ -370,7 +371,7 @@ async function _spawnHiddenMap(sourceMap, w, h, opts = {}) {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
const W = (typeof window !== 'undefined' ? window : {}); const W = (typeof window !== 'undefined' ? window : {});
const isMaplibre = !!W.maplibregl && sourceMap instanceof W.maplibregl.Map; const isMaplibre = !!W.maplibregl && sourceMap instanceof W.maplibregl.Map;
const gl = isMaplibre ? W.maplibregl : W.mapboxgl; const gl = engineFor(sourceMap); // the one per-instance helper (useMapProvider.js)
if (!gl) throw new Error('useMapPoster: ' + (isMaplibre ? 'maplibregl' : 'mapboxgl') + ' global not found'); if (!gl) throw new Error('useMapPoster: ' + (isMaplibre ? 'maplibregl' : 'mapboxgl') + ' global not found');
const styleObj = sourceMap.getStyle(); const styleObj = sourceMap.getStyle();

View file

@ -5,6 +5,7 @@
// Import Vue functions from NPM package // Import Vue functions from NPM package
import { ref, inject } from 'vue'; import { ref, inject } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { engineFor } from './useMapProvider.js';
/** /**
* useMapEvents composable - manages Mapbox layer event handlers * useMapEvents composable - manages Mapbox layer event handlers
@ -245,7 +246,7 @@ export function useMapEvents(options = {}) {
} }
// Fallback: show popup (for non-prayer legend types) // Fallback: show popup (for non-prayer legend types)
new mapboxgl.Popup({ new (engineFor(map).Popup)({
maxWidth: '320px', maxWidth: '320px',
className: 'people-group-popup' className: 'people-group-popup'
}) })
@ -290,7 +291,7 @@ export function useMapEvents(options = {}) {
if (e.features && e.features.length > 0) { if (e.features && e.features.length > 0) {
const properties = e.features[0].properties; const properties = e.features[0].properties;
new mapboxgl.Popup() new (engineFor(map).Popup)()
.setLngLat(e.lngLat) .setLngLat(e.lngLat)
.setHTML(generateRegionPopupContent(properties)) .setHTML(generateRegionPopupContent(properties))
.addTo(map); .addTo(map);

View file

@ -61,6 +61,28 @@ export function isMapboxMap(map) {
return typeof window !== 'undefined' && !!window.mapboxgl && map instanceof window.mapboxgl.Map return typeof window !== 'undefined' && !!window.mapboxgl && map instanceof window.mapboxgl.Map
} }
/**
* The engine namespace that built THIS map: window.maplibregl or window.mapboxgl. Use it for
* everything constructed alongside a map (Marker, Popup, a second Map, controls) instead of
* the bare `mapboxgl` global.
*
* ★ WHY. Every generated leaf <head> used to load mapbox-gl.js statically, whatever the provider,
* so a bare `new mapboxgl.Popup()` always found a global, even on a MapLibre map (where it
* built a MAPBOX popup and attached it to a MapLibre map). Removing that static peer (a visitor-IP
* leak to api.mapbox.com under MapLibre) turns each of those into a ReferenceError on the first
* pin select or click, and the poster's hidden map into 'mapboxgl global not found'.
* Asking the MAP which engine it is removes the dependency on what else the page loaded.
* @param {object} map the live map instance
* @returns {object|undefined} the engine namespace, or undefined when neither is loaded
*/
export function engineFor(map) {
if (typeof window === 'undefined') return undefined
const ml = window.maplibregl, mb = window.mapboxgl
if (ml && ml.Map && map instanceof ml.Map) return ml
if (mb && mb.Map && map instanceof mb.Map) return mb
return mb || ml
}
/** Mapbox-Standard-only lighting paint props, per engine. MapLibre rejects layers whose /** Mapbox-Standard-only lighting paint props, per engine. MapLibre rejects layers whose
* paint carries unknown properties (the whole layer is dropped → invisible pins), so * paint carries unknown properties (the whole layer is dropped → invisible pins), so
* spread this instead of writing '<kind>-emissive-strength' literally. * spread this instead of writing '<kind>-emissive-strength' literally.

View file

@ -3,7 +3,7 @@
// floats an animated "GO" location pin above it with a pulsing light glow. // floats an animated "GO" location pin above it with a pulsing light glow.
import { watch, inject } from 'vue'; import { watch, inject } from 'vue';
import { emissivePaint, isMapboxMap } from './useMapProvider.js'; import { emissivePaint, isMapboxMap, engineFor } from './useMapProvider.js';
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
// Constants // Constants
@ -151,7 +151,8 @@ export function useSelectedPin(options = {}) {
// anchor:'bottom' pins the marker's bottom edge to the lat/lng. // anchor:'bottom' pins the marker's bottom edge to the lat/lng.
// After the wrapper resize to match the SVG (43px), the bottom edge // After the wrapper resize to match the SVG (43px), the bottom edge
// IS the pin's tip — no offset compensation needed. // IS the pin's tip — no offset compensation needed.
_marker = new mapboxgl.Marker({ // engineFor, not the bare `mapboxgl` global: under MapLibre there may be no Mapbox global
_marker = new (engineFor(map).Marker)({
element : _markerEl, element : _markerEl,
anchor : 'bottom', anchor : 'bottom',
offset : [0, 0] offset : [0, 0]

View file

@ -36,6 +36,7 @@ import { labelFont } from '@map/maps/label-font.js'
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue' import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution' import { addMapAttribution } from '@map/composables/useMapAttribution'
import { ensureEngineScript, engineGlobal } from '@map/composables/useMapInstance.js'
import { resolveAssetsBase } from '@map/utils/embedBase.js' import { resolveAssetsBase } from '@map/utils/embedBase.js'
const profileConfig = inject('profileConfig', ref(null)) const profileConfig = inject('profileConfig', ref(null))
@ -131,7 +132,10 @@ function paint(map) {
} }
onMounted(async () => { onMounted(async () => {
const gl = window.mapboxgl // Load the engine rather than assume the page did: leaf <head>s no longer preload mapbox-gl.js
// (it leaked every visitor's IP to Mapbox under provider=maplibre). This map is Mapbox-only.
try { await ensureEngineScript('mapbox') } catch (_) {}
const gl = engineGlobal('mapbox')
if (!gl || !mapEl.value) { err.value = 'mapbox-gl not loaded'; return } if (!gl || !mapEl.value) { err.value = 'mapbox-gl not loaded'; return }
try { try {
await load() await load()

View file

@ -21,6 +21,7 @@
import { ref, watch, onMounted, onBeforeUnmount, computed, inject } from 'vue' import { ref, watch, onMounted, onBeforeUnmount, computed, inject } from 'vue'
import { ensureGeocoder, GEOCODER } from '../utils/pplrData.js' import { ensureGeocoder, GEOCODER } from '../utils/pplrData.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js' import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal } from '@map/composables/useMapInstance.js'
import { useInstance } from '../composables/usePplrInstance.js' import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({ const props = defineProps({
@ -155,7 +156,7 @@ function setup() {
render: renderSuggestion, render: renderSuggestion,
}) })
} else { } else {
if (!window.MapboxGeocoder || !window.mapboxgl) { if (!window.MapboxGeocoder || !engineGlobal('mapbox')) {
console.warn('[MapboxGeocoder] mapbox-gl-geocoder or mapbox-gl not on window — check CDN <script> tags') console.warn('[MapboxGeocoder] mapbox-gl-geocoder or mapbox-gl not on window — check CDN <script> tags')
return return
} }
@ -164,7 +165,7 @@ function setup() {
} }
geocoder = new window.MapboxGeocoder({ geocoder = new window.MapboxGeocoder({
accessToken: props.mapboxToken, accessToken: props.mapboxToken,
mapboxgl: window.mapboxgl, mapboxgl: engineGlobal('mapbox'),
placeholder: props.placeholder, placeholder: props.placeholder,
localGeocoder: buildLocalGeocoder(), localGeocoder: buildLocalGeocoder(),
localGeocoderOnly: false, localGeocoderOnly: false,
@ -252,7 +253,10 @@ function onGlobalKey(e) {
onMounted(() => { onMounted(() => {
// setup() swallows its own errors; a failed script load leaves the map working without search. // setup() swallows its own errors; a failed script load leaves the map working without search.
ensureGeocoder(provider).then(setup, (e) => console.warn('[MapboxGeocoder]', e.message)) // The ENGINE first, then its geocoder: the Mapbox path hands mapboxgl to the geocoder, and it
// used to find it only because every leaf <head> preloaded mapbox-gl.js. ensureEngineScript is
// a no-op when the map already loaded it.
ensureEngineScript(provider).then(() => ensureGeocoder(provider)).then(setup, (e) => console.warn('[MapboxGeocoder]', e.message))
document.addEventListener('keydown', onGlobalKey) document.addEventListener('keydown', onGlobalKey)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {

View file

@ -60,13 +60,8 @@ export function ensureGeocoder(provider = 'mapbox') {
// The engine namespace that built THIS map (Popup, Marker, NavigationControl…). The views were // The engine namespace that built THIS map (Popup, Marker, NavigationControl…). The views were
// written against the Mapbox global; under provider=maplibre that global does not exist and a // written against the Mapbox global; under provider=maplibre that global does not exist and a
// `new mapboxgl.Popup` throws on the first pin click. // `new mapboxgl.Popup` throws on the first pin click. Now the library's engineFor (one copy).
export function glFor(map) { export { engineFor as glFor } from '@map/composables/useMapProvider.js'
if (typeof window === 'undefined') return undefined
const ml = window.maplibregl
if (ml && map && ml.Map && map instanceof ml.Map) return ml
return window.mapboxgl || ml
}
// Verification handle (the gospel-resources __grState contract): what the ACTIVE view's map // Verification handle (the gospel-resources __grState contract): what the ACTIVE view's map
// actually did, so a check can assert "loaded" rather than inferring it from missing requests. // actually did, so a check can assert "loaded" rather than inferring it from missing requests.