doxa-maps/library/composables/useMapProvider.js
Doxa Maps 3c69770422 C2: one library helper for per-engine projection, across 12 call sites in 9 plugins
SPEC-LIBRARY-CURATION.md's first migration, in the order the shepherd approved.
Source only; studio rebuilds (rule 5B — library/ plus eight other owners' plugins).

THE ASYMMETRY, which is why 12 sites each solved it or failed at it separately:
    Mapbox    getProjection() -> { name }               setProjection('mercator')
    MapLibre  getProjection() -> { type } | undefined   setProjection({ type: 'mercator' })
Passing the Mapbox shape to MapLibre THROWS. Measured on regions-dashboard, where
the throw aborted the camera fly so a region dashboard never framed its region.

WHAT THE 12 SITES WERE DOING:
  · NINE passed a bare string, so on MapLibre the projection silently never changed.
    Eight swallowed the throw in a try/catch; doxa-simple-map.vue:1244 did NOT, so
    there it threw uncaught.
  · TWO had solved it independently and differently: pplr-data-maps'
    setProjectionFor() (instance-tested — the correct one, and the shape adopted)
    and regions-dashboard's local 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 on throw.

The helper reads the engine from the INSTANCE, never from config, because a page can
hold a map built by the other engine. Both functions return something observable —
setProjectionOn returns whether it applied, projectionOf returns null rather than
throwing — on upg100-plugin's argument that a precondition which bails without
recording it is indistinguishable from the request never arriving.
pplrData.setProjectionFor stays as a delegating named export so its three views need
no change.

GATE: _chk-c2-projection-helper-equivalence.mjs, 20 assertions. It runs all THREE old
patterns and the helper against the same stub maps and compares outcomes, rather than
testing the helper alone. Controls prove pattern A really does fail on MapLibre and
leave the projection untouched, and that the bare string really throws — so the
"equivalence" claim has something to be equivalent to. A NEG control catches a helper
that reports success it did not achieve.

★ I DESTROYED 984 LINES OF ANOTHER TEAM'S CODE GETTING HERE, and only the build caught
it. My import-inserting script did `s[:at] + IMPORT` and never appended `s[at:]`, so
every file where the import was ADDED rather than extended was truncated at its last
import line: MapCell.vue -458, FlowerClusters.vue -334, lang-cluster.vue -192.
check:no-free-identifiers PASSED on all three, because a truncated script block is
still valid script. Restored from HEAD and redone with an assertion that a migration
must change the line count by exactly +1 and never remove a line — which is the check
that would have caught it in the first second.

Verified after: all 9 affected bundles build, both gates green, and a per-file line
delta on all 13 changed files with the only negative being the 2 lines I deliberately
deleted from RegionCountryMap when its local pair became an import.
2026-09-26 08:39:53 +00:00

190 lines
10 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)
return explicitMapProvider(cfg) || (hasMapToken(cfg) || hasMapToken({ tk: token }) ? 'mapbox' : '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
*/
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 } : {}
}