doxa-maps/library/composables/useMapProvider.js
Doxa Maps 3b5a86aaef Auto resolves to MapLibre; a token no longer picks the engine
The Driver wants MapLibre as the default everywhere, on cost and on how it
looks, with Mapbox supported as an explicit choice.

One line decided it. resolveMapProvider read
  explicit || (token present ? 'mapbox' : 'maplibre')
so merely HOLDING a Mapbox token selected Mapbox. That conflated two different
things: having a token is a capability, wanting Mapbox is a decision, and only
the decision belongs in this function. It now reads explicit || 'maplibre'.

The token is still honoured for what it is — with provider:'mapbox' it
authenticates the map, and the token argument stays in the signature because
callers pass one they hold outside the config. It simply no longer votes.

The migration surface is exactly one shape: a host that pasted a real token into
a published embed snippet and set no provider. All 36 shipped snippets carry a
tk placeholder and no provider key, so any host who filled one in is in it. An
unfilled snippet was already MapLibre, because hasMapToken rejects YOUR_
placeholders, and the DX Life branches pass no token at all. Such a host keeps
Mapbox by adding "provider":"mapbox"; ?provider= and window.MAP_PROVIDER are
unchanged.

The gate's four token-selects-Mapbox assertions were the old specification, so
they are inverted rather than deleted — the diff should show what changed for
whom. Two are added: the opt-in still works with a token in hand, which is the
path a migrating host takes, and a real token is still SEEN, asserted apart from
the engine because otherwise the engine could be right while authentication
broke silently.

check:map-props-unit was not in gates:pre, so it proved nothing on a normal run.
It is now, and the suite reads 32 of 32 with it named.
2026-09-28 17:53:38 +00:00

237 lines
13 KiB
JavaScript

/**
* useMapProvider.js — resolve the active map engine ("provider").
*
* Resolution order (first defined wins) — matches resolveMapProvider below EXACTLY:
* 1. profile-config `provider` — per-map, set in the profile-config JSON
* 2. ?provider= in the page URL — read here directly, so any generated leaf page can be
* flipped from the URL without the host doing anything
* 3. window.MAP_PROVIDER — host global, for a host that sets it programmatically
* 4. 'auto' — the default: Mapbox WHEN A TOKEN IS AVAILABLE, else MapLibre
*
* Valid values: 'mapbox' | 'maplibre' | 'auto'. Anything else is treated as 'auto'.
*
* ★ 'auto' (2026-09-25, by project decision). The public repo carries NO Mapbox token, so a default
* of 'mapbox' gave every keyless visitor a map that could not draw and a paste-a-token prompt.
* 'auto' asks the token ladder instead: a token in this element's profile-config (`tk`), or one
* published page-wide (window.MAP_TOKEN — the generated leaf's ?tk= / pasted / baked / fetched rungs,
* the studio's ⚙ Settings, and any element handed a `tk` all land there, see useSharedMapToken)
* means Mapbox, exactly as before. No token anywhere means MapLibre, keyless, with zero requests to
* Mapbox. An explicit provider (rungs 1-3) always wins.
* The decision is taken when the map is created: a token that arrives LATER does not swap a map
* that already drew with MapLibre (a host that fetches its token should set it before mounting, or
* set provider:'mapbox' explicitly).
*
* documentation/how-to.md ("Swap the map engine") carries the same order; keep the two in sync, and
* treat THIS function as the authority when they disagree.
*/
import { inject } from 'vue'
export const MAP_PROVIDERS = ['mapbox', 'maplibre']
export const DEFAULT_PROVIDER = 'auto'
/** Unwrap a ref/computed to its config object; pass plain objects through. */
function unwrapConfig(profileConfig) {
if (profileConfig && typeof profileConfig === 'object'
&& 'value' in profileConfig && !('provider' in profileConfig) && !('profile' in profileConfig)) {
return profileConfig.value
}
return profileConfig
}
/**
* Is a Mapbox token available to this map? The element's own `tk`, or the page-wide token
* (window.MAP_TOKEN, which useSharedMapToken bridges to every element's published tk).
*/
export function hasMapToken(cfg) {
// The embed snippets ship tk:"YOUR_MAPBOX_TOKEN" as a placeholder; an unfilled one is not a token.
const real = (v) => typeof v === 'string' && !!v.trim() && !/^YOUR_/.test(v.trim())
if (real(cfg?.tk)) return true
// A token service to ask (profile-config.tokenUrl) counts: the element fetches it before the map starts.
if (/^https?:\/\//i.test(String(cfg?.tokenUrl || ''))) return true
if (typeof window === 'undefined') return false
return real(window.MAP_TOKEN) || real(window.__doxaMapToken) || !!window.__doxaMapTokenPending
}
/**
* Resolve the provider from a profile-config (object or ref) + window fallback.
* Always returns a concrete engine ('mapbox' | 'maplibre'); 'auto' is decided here. `token` is a
* token the caller holds outside the config (useMapInstance's accessToken); it counts as available.
* Pure — safe to call outside a Vue setup().
*/
export function resolveMapProvider(profileConfig, { token } = {}) {
const cfg = unwrapConfig(profileConfig)
// ★ AUTO IS MAPLIBRE. A TOKEN NO LONGER CHOOSES AN ENGINE (Driver, 2026-09-28: MapLibre is the
// default everywhere, on cost and on how it looks; Mapbox stays supported as an explicit choice).
//
// It used to read: explicit || (token present ? 'mapbox' : 'maplibre') — so merely HOLDING a
// Mapbox token selected Mapbox. That conflated two different things: "I have a token" is a
// capability, "I want Mapbox" is a decision, and only the second belongs in this function.
//
// The token is still honoured for what it IS: with provider:'mapbox' it authenticates the map,
// and the `token` argument stays in the signature because callers pass one they hold outside the
// config. It simply no longer votes on the engine.
//
// WHO CHANGES: a host that pasted a real token into a published embed snippet and set no
// provider. All 36 shipped snippets carry a tk placeholder and NO provider key, so that is the
// whole migration surface. An UNFILLED snippet was already MapLibre — hasMapToken rejects
// YOUR_-prefixed placeholders — and the DX Life branches pass no token at all.
// To keep Mapbox, such a host adds "provider":"mapbox" to profile-config; ?provider=mapbox and
// window.MAP_PROVIDER are unchanged.
return explicitMapProvider(cfg) || 'maplibre'
}
/** The engine someone ASKED for (rungs 1-3), or '' when the default ('auto') decides. */
export function explicitMapProvider(profileConfig) {
const cfg = unwrapConfig(profileConfig)
const fromConfig = cfg?.provider
const fromUrl = typeof location !== 'undefined'
? new URLSearchParams(location.search).get('provider') : undefined
const fromWindow = typeof window !== 'undefined' ? window.MAP_PROVIDER : undefined
const raw = String(fromConfig || fromUrl || fromWindow || DEFAULT_PROVIDER).toLowerCase().trim()
return MAP_PROVIDERS.includes(raw) ? raw : ''
}
/** Human-readable engine label for a badge / UI. */
export function providerLabel(provider) {
return provider === 'maplibre' ? 'MapLibre' : 'Mapbox'
}
/** Convenience for use inside a Vue setup() — injects the provided profileConfig. */
export function useMapProvider() {
const profileConfig = inject('profileConfig', null)
return resolveMapProvider(profileConfig)
}
/** True when this map instance is a Mapbox GL map (vs MapLibre). Per-instance runtime
* sniff — works even on dual-engine pages where both globals are loaded. */
export function isMapboxMap(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
*/
/**
* 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
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
* paint carries unknown properties (the whole layer is dropped → invisible pins), so
* spread this instead of writing '<kind>-emissive-strength' literally.
*
* ★ `kind` EXISTS BECAUSE THE CIRCLE CASE WAS NOT THE ONLY ONE. This helper was hardcoded to
* 'circle', while fill, line and clustering layers still wrote the literal — so those layers kept
* being dropped by MapLibre for exactly the reason written above. MEASURED on countries-map by
* listening to the map's own `error` event, which is the ONLY place it surfaces:
* layers.regions-fill.paint.fill-emissive-strength: unknown property
* layers.regions-border.paint.line-emissive-strength: unknown property
* addLayer() does NOT throw for this — it accepts the call, emits an error event, and silently
* omits the layer. getLayer() then returns undefined and the map simply renders without it, which
* is why it read as "the fills do not paint" rather than as an error.
* @param {object} map the live map instance
* @param {'circle'|'fill'|'line'} [kind='circle']
*/
/**
* ── PROJECTION, PER ENGINE ───────────────────────────────────────────────────────────────────────
* ONE place for an API that is shaped differently on each engine, gathered from 12 call sites across
* 9 plugins (SPEC-LIBRARY-CURATION.md, candidate C2). The asymmetry:
*
* Mapbox getProjection() -> { name } setProjection('mercator')
* MapLibre getProjection() -> { type } | undefined setProjection({ type: 'mercator' })
*
* Passing the Mapbox shape to MapLibre THROWS. That is not hypothetical: it was measured on
* regions-dashboard, where the throw aborted the camera fly so opening a region dashboard never
* framed its region (rega confirmed the fix by eye, rows 13/14).
*
* WHAT THE 12 SITES DID BEFORE, and why one helper is worth it:
* · NINE passed a bare string, so on MapLibre the projection silently never changed. Eight wrapped
* it in try/catch and swallowed the throw; doxa-simple-map did NOT, so there it threw uncaught.
* · TWO had already solved it independently and differently — pplr-data-maps' setProjectionFor()
* (instance-tested, the shape adopted here) and regions-dashboard's pair (config-derived, which
* is weaker: it trusts what the page was configured with rather than what the map IS).
* · ONE probed by exception, trying the object form and falling back to the string on throw.
*
* THE ENGINE IS READ FROM THE INSTANCE, never from config. A page can be configured for one engine
* and hold a map built by the other (a fallback, a second map, an auto-engine decision), and the
* object-vs-string question is a fact about the INSTANCE.
*
* BOTH RETURN SOMETHING OBSERVABLE, on upg100-plugin's argument: a precondition that bails without
* recording that it bailed is indistinguishable, at the rendered layer, from the request never
* arriving. setProjectionOn returns whether it applied; projectionOf returns null rather than
* throwing. Callers that do not care may ignore it, and all 12 existing sites do.
*/
/** The current projection NAME on either engine, or null. Handles {name}, {type} and undefined. */
export function projectionOf(map) {
try {
const p = map && typeof map.getProjection === 'function' ? map.getProjection() : null
return (p && (p.name || p.type)) || null
} catch (_) { return null }
}
/**
* Set the projection on either engine. Returns true when it applied, false when it could not —
* a style that does not support the projection is a legitimate false, not an error.
*/
export function setProjectionOn(map, name) {
if (!map || !name || typeof map.setProjection !== 'function') return false
const ml = typeof window !== 'undefined' ? window.maplibregl : null
const wantsObject = !!(ml && ml.Map && map instanceof ml.Map)
try {
map.setProjection(wantsObject ? { type: name } : name)
return true
} catch (_) {
// The style cannot take it (Mapbox Standard pins some projections): keep the current one.
return false
}
}
export function emissivePaint(map, kind = 'circle') {
return isMapboxMap(map) ? { [`${kind}-emissive-strength`]: 1 } : {}
}