From 3c697704222c0a16bbfd391e49bbc00d6b49d7d8 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Sat, 26 Sep 2026 07:42:44 +0000 Subject: [PATCH] C2: one library helper for per-engine projection, across 12 call sites in 9 plugins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- library/composables/useMapProvider.js | 55 +++++++++++++++++++ .../all-people-groups/all-people-groups.vue | 4 +- .../country-posters/countries-map.vue | 4 +- plugin-designs/demo-map/demo-map.vue | 4 +- .../doxa-research-map/research-map.vue | 4 +- .../doxa-simple-map/doxa-simple-map.vue | 4 +- .../pplr-data-maps/src/utils/pplrData.js | 12 ++-- .../src/RegionCountryMap.vue | 12 ++-- .../dashboards/DashboardMapPanel.vue | 9 +-- .../src/cockpit/FlowerClusters.vue | 3 +- .../upg-neighbor/src/cockpit/MapCell.vue | 3 +- .../upg-neighbor/src/lang-cluster.vue | 3 +- plugin-designs/upg-neighbor/src/send-map.vue | 3 +- 13 files changed, 91 insertions(+), 29 deletions(-) diff --git a/library/composables/useMapProvider.js b/library/composables/useMapProvider.js index cfd99d974..26790a2df 100644 --- a/library/composables/useMapProvider.js +++ b/library/composables/useMapProvider.js @@ -130,6 +130,61 @@ export function engineFor(map) { * @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 } : {} } diff --git a/plugin-designs/all-people-groups/all-people-groups.vue b/plugin-designs/all-people-groups/all-people-groups.vue index 943168055..805464fd5 100644 --- a/plugin-designs/all-people-groups/all-people-groups.vue +++ b/plugin-designs/all-people-groups/all-people-groups.vue @@ -32,7 +32,7 @@ import { inject, ref, computed, onMounted, onBeforeUnmount } from 'vue' import { useI18n } from 'vue-i18n' import { useMapInstance } from '@map/composables/useMapInstance.js' -import { resolveMapProvider } from '@map/composables/useMapProvider.js' +import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js' import { useMapData } from '@map/composables/useMapData.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { DataSourceManager } from '@map/utils/DataSourceManager.js' @@ -348,7 +348,7 @@ onMounted(async () => { try { const rows = await rowsPromise if (!rows || !map.value) return - try { map.value.setProjection('mercator') } catch (_) {} + setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2) addLayer(buildFeatures(rows, new Set())) // every pin, by GSEC — the first paint mapStore?.registerMap?.('all-people-groups', map.value) appReady.value = true diff --git a/plugin-designs/country-posters/countries-map.vue b/plugin-designs/country-posters/countries-map.vue index 05821dea3..8f4ff196a 100644 --- a/plugin-designs/country-posters/countries-map.vue +++ b/plugin-designs/country-posters/countries-map.vue @@ -143,7 +143,7 @@ import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue import ThemeToggleButton from '@map/components/map-controls/ThemeToggleButton.vue' import HamburgerButton from '@map/components/map-controls/HamburgerButton.vue' import GeocoderComponent from '@map/components/map-controls/GeocoderComponent.vue' -import { resolveMapProvider } from '@map/composables/useMapProvider.js' +import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js' import ShareButton from '@map/components/map-controls/ShareButton.vue' // PERF: SideMenuDrawer is unused in the current research-map template (the @@ -1978,7 +1978,7 @@ watch(() => mapStore.showClusters, async (on) => { // ─── Lifecycle ─────────────────────────────────────────────────────────────── async function onMapReady(normalizedPeopleGroups) { mapStore.registerMap(mapId, map.value) - try { map.value.setProjection('mercator') } catch (_) { /* style may not support */ } + setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2) // Apply the persisted theme's lightPreset now the Standard style has loaded, so a // reload while dark was selected boots the NIGHT basemap (no light-under-dark desync). // (bug-ghost-rings-standard-style — Option B.) diff --git a/plugin-designs/demo-map/demo-map.vue b/plugin-designs/demo-map/demo-map.vue index b007fe92c..2658bb83e 100644 --- a/plugin-designs/demo-map/demo-map.vue +++ b/plugin-designs/demo-map/demo-map.vue @@ -23,7 +23,7 @@ import { inject, provide, ref, computed, onMounted, onBeforeUnmount } from 'vue' import { useMapInstance } from '@map/composables/useMapInstance.js' -import { resolveMapProvider } from '@map/composables/useMapProvider.js' +import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js' import { useMapData } from '@map/composables/useMapData.js' import { useMapLayers } from '@map/composables/useMapLayers.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' @@ -201,7 +201,7 @@ onMounted(async () => { try { const { normalizedPeopleGroups } = await dataPromise mapStore?.registerMap?.(mapId, map.value) - try { map.value.setProjection('mercator') } catch (e) { /* optional */ } + setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2) if (normalizedPeopleGroups?.length) { mapLayers.addLanguageFamilyLayer(normalizedPeopleGroups, activeTab.value?.colorStrategy) } diff --git a/plugin-designs/doxa-research-map/research-map.vue b/plugin-designs/doxa-research-map/research-map.vue index f0b8a474e..647c85ffd 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 } from '@map/composables/useMapProvider.js' +import { resolveMapProvider, providerLabel, 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' @@ -2394,7 +2394,7 @@ watch(() => mapStore.clusteringMode, async () => { // ─── Lifecycle ─────────────────────────────────────────────────────────────── async function onMapReady(normalizedPeopleGroups) { mapStore.registerMap(mapId, map.value) - try { map.value.setProjection('mercator') } catch (_) { /* style may not support */ } + setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2) // Apply the persisted theme's lightPreset now the Standard style has loaded, so a // reload while dark was selected boots the NIGHT basemap (no light-under-dark desync). // (bug-ghost-rings-standard-style — Option B.) diff --git a/plugin-designs/doxa-simple-map/doxa-simple-map.vue b/plugin-designs/doxa-simple-map/doxa-simple-map.vue index 0b86cf44f..389ca43b4 100644 --- a/plugin-designs/doxa-simple-map/doxa-simple-map.vue +++ b/plugin-designs/doxa-simple-map/doxa-simple-map.vue @@ -34,6 +34,7 @@ import { createPplrInstance, provideInstance } from '@map/composables/usePplrIns import { useLegendData as useFlatLegendData } from '@map/composables/legends/useLegendData.js' import { useMapFly } from '@map/composables/useMapFly.js' import { useCountryOutline, resolveCountryIso } from '@map/composables/useCountryOutline.js' +import { setProjectionOn } from '@map/composables/useMapProvider.js' import SideMenuDrawer from '@map/components/SideMenuDrawer.vue' // ─── Map control components ─────────────────────────────────────────────────── // Each button is imported individually so the profile can compose any subset @@ -1241,7 +1242,8 @@ async function onMapReady(normalizedPeopleGroups) { // Projection — safe guard try { - map.value.setProjection('mercator') + // ★ WAS UNGUARDED: a bare string THROWS on MapLibre, so this line could error the page. + setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2) } catch (e) { } diff --git a/plugin-designs/pplr-data-maps/src/utils/pplrData.js b/plugin-designs/pplr-data-maps/src/utils/pplrData.js index 320ceebf8..f19741be7 100644 --- a/plugin-designs/pplr-data-maps/src/utils/pplrData.js +++ b/plugin-designs/pplr-data-maps/src/utils/pplrData.js @@ -8,6 +8,7 @@ */ import { CDN, pinElement } from '@map/constants/cdn.js' import { loadPinnedUmd } from '@map/utils/loadPinnedUmd.js' +import { 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 } @@ -91,12 +92,13 @@ export function whenGeojsonDrawn(map, timeoutMs = 45000) { // ('globe'), MapLibre 5 takes a specification object ({ type: 'globe' }). The views passed the bare // string, so under provider=maplibre the Globe/Mercator toggle did nothing (or threw inside a // style.load handler). Guarded: a style that cannot take the projection must not break the view. +// ★ NOW A DELEGATE. This function was the REFERENCE implementation -- the only one of the three +// patterns in the tree that was correct on both engines -- and library/useMapProvider's +// setProjectionOn is it, lifted verbatim (C2). Kept as a named export so this plugin's three views +// (pplr-religion, pplr-language-families, pplr-rop-tree) need no change at all, and so the name they +// already read stays meaningful. Delete it when those views import the library helper directly. export function setProjectionFor(map, name) { - if (!map || !name) return - try { - const ml = typeof window !== 'undefined' ? window.maplibregl : null - map.setProjection(ml && ml.Map && map instanceof ml.Map ? { type: name } : name) - } catch (_) { /* the style does not support it: keep the current projection */ } + return setProjectionOn(map, name) } // Verification handle (the gospel-resources __grState contract): what the ACTIVE view's map diff --git a/plugin-designs/regions-dashboard/src/RegionCountryMap.vue b/plugin-designs/regions-dashboard/src/RegionCountryMap.vue index 024f6461a..be0289c45 100644 --- a/plugin-designs/regions-dashboard/src/RegionCountryMap.vue +++ b/plugin-designs/regions-dashboard/src/RegionCountryMap.vue @@ -34,7 +34,7 @@ import { useMapInstance } from '@map/composables/useMapInstance.js' // the expression is gone too. Three implementations were compared before swapping (the deleted // copy, my interim expression, and this) and they decide identically in every engine combination: // _qa-cycle/02-builder-cycle/_chk-glfor-equivalence.mjs. -import { engineFor } from '@map/composables/useMapProvider.js' +import { engineFor, projectionOf, setProjectionOn } from '@map/composables/useMapProvider.js' import { resolveMapProvider } from '@map/composables/useMapProvider.js' import { resolveAssetsBase } from '@map/utils/embedBase.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' @@ -549,14 +549,12 @@ function chooserZoom(m) { // fitTheirWay, escaped applySubject() — of which it is the last statement — and the camera was // never framed. Opening a region dashboard under MapLibre therefore left the map wherever it was // instead of flying to the region, which is exactly what the Driver reported. -const projectionOf = (m) => { - try { const p = m.getProjection && m.getProjection(); return (p && (p.name || p.type)) || null } catch (_) { return null } -} +// ★ BOTH OF THESE NOW COME FROM library/useMapProvider (C2). Mine read the engine from CONFIG +// (isMaplibreProvider), which is weaker than reading it from the INSTANCE: a page configured for one +// engine can hold a map built by the other. The library version instance-tests, which is what +// pplr-data-maps had been doing correctly all along. // Mapbox takes the bare string; MapLibre v5 takes { type }. Writing the wrong shape is not a // no-op — maplibre reads e?.type and would set undefined. -const setProjectionOn = (m, name) => { - try { m.setProjection(isMaplibreProvider ? { type: name } : name) } catch (_) {} -} function fitTheirWay(m, bounds) { let cam = null diff --git a/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue b/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue index cade95dbe..616b4ef05 100644 --- a/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue +++ b/plugin-designs/research-dashboard/src/components/dashboards/DashboardMapPanel.vue @@ -8,7 +8,7 @@ */ import { inject, ref, computed, onMounted, onBeforeUnmount, watch, unref } from 'vue' import { useMapInstance } from '@map/composables/useMapInstance.js' -import { resolveMapProvider } from '@map/composables/useMapProvider.js' +import { resolveMapProvider, setProjectionOn } from '@map/composables/useMapProvider.js' import { useMapFly } from '@map/composables/useMapFly.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { tStatic } from '@map/i18n/index.js' @@ -1375,9 +1375,10 @@ const projection = ref('globe') // but it now catches a genuine unsupported-engine case instead of masking a shape mismatch. function applyProjection() { const m = map.value - if (!m || typeof m.setProjection !== 'function') return - try { m.setProjection({ type: projection.value }); return } catch (_) { /* not the object form */ } - try { m.setProjection(projection.value) } catch (_) { /* engine has no projection support */ } + // ★ WAS PROBING BY EXCEPTION: it tried the object form, let it THROW on Mapbox, then fell back to + // the string. That reached the right end state but by throwing on every Mapbox call, and it asked + // the engine by trial instead of by identity. The helper instance-tests instead (C2). + setProjectionOn(m, projection.value) } function toggleProjection() { projection.value = projection.value === 'globe' ? 'mercator' : 'globe'; applyProjection() } diff --git a/plugin-designs/upg-neighbor/src/cockpit/FlowerClusters.vue b/plugin-designs/upg-neighbor/src/cockpit/FlowerClusters.vue index 3601fdfb7..776e19f54 100644 --- a/plugin-designs/upg-neighbor/src/cockpit/FlowerClusters.vue +++ b/plugin-designs/upg-neighbor/src/cockpit/FlowerClusters.vue @@ -32,6 +32,7 @@ import { useLanguageFamilyLegendData } from '@map/composables/legends/useLanguag import langFamilyByLanguage from '@map/data/langFamilyByLanguage.json' import bakedReached from '../data/reached-neighbors.json' import { useNeighborT } from '../composables/useNeighborT.js' +import { setProjectionOn } from '@map/composables/useMapProvider.js' const nt = useNeighborT() const C_RED = '#e11d48', C_RED_DK = '#9f1239', C_NAVY = '#002e6e', C_GOLD = '#f59e0b' @@ -278,7 +279,7 @@ async function fetchDoxa() { onMounted(async () => { await initializeMap() const m = map.value - try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver) + setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper try { doxaFeatures.value = await fetchDoxa() } catch (e) { statusMsg.value = 'Live doxa API unreachable — ' + e.message; appReady.value = true; return } appReady.value = true diff --git a/plugin-designs/upg-neighbor/src/cockpit/MapCell.vue b/plugin-designs/upg-neighbor/src/cockpit/MapCell.vue index 22d9cf7a6..75e58b847 100644 --- a/plugin-designs/upg-neighbor/src/cockpit/MapCell.vue +++ b/plugin-designs/upg-neighbor/src/cockpit/MapCell.vue @@ -22,6 +22,7 @@ import { inject, ref, computed, watch, onMounted, onBeforeUnmount, nextTick } fr import { useMapInstance } from '@map/composables/useMapInstance.js' import { useShadowStyles } from '@map/composables/useShadowStyles.js' import { useCockpitStore, COLORS, redColor, reachedColor, logRadius, fmtPop } from './useCockpit.js' +import { setProjectionOn } from '@map/composables/useMapProvider.js' const store = useCockpitStore() @@ -364,7 +365,7 @@ onMounted(async () => { await initializeMap() const m = map.value if (!m) { return } // no/invalid token — overlay shows the hint, store still drives the rest - try { m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver) + setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper // Gated test hook (inert in production): lets a harness use the live map.project(). if (cfg.value.debugExpose && typeof window !== 'undefined') { diff --git a/plugin-designs/upg-neighbor/src/lang-cluster.vue b/plugin-designs/upg-neighbor/src/lang-cluster.vue index 7fca37f8f..65814014c 100644 --- a/plugin-designs/upg-neighbor/src/lang-cluster.vue +++ b/plugin-designs/upg-neighbor/src/lang-cluster.vue @@ -388,6 +388,7 @@ function onMove(ev) { if (tip.value.show) { tip.value.x = ev.clientX; tip.value. // adminUrl/dataUrl/dataBase still override live (loadJSON below). import bakedReached from './data/reached-neighbors.json' import bakedAdmin1 from './data/admin1-membership.json' +import { setProjectionOn } from '@map/composables/useMapProvider.js' async function loadJSON(relCandidates) { for (const url of relCandidates) { try { const r = await fetch(url); if (r.ok) return await r.json() } catch (_) {} @@ -410,7 +411,7 @@ async function fetchDoxa() { onMounted(async () => { await initializeMap() const m = map.value - try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver) + setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper // baked data (own bundle dir). `base` lets the host say where data/ is served from // (platform deploy serves data/ alongside the bundle, like send-map/flower-mesh). diff --git a/plugin-designs/upg-neighbor/src/send-map.vue b/plugin-designs/upg-neighbor/src/send-map.vue index b367ffd27..c45de648c 100644 --- a/plugin-designs/upg-neighbor/src/send-map.vue +++ b/plugin-designs/upg-neighbor/src/send-map.vue @@ -401,6 +401,7 @@ function flyToSender(n) { // the profile data/ folder, so fetch('./data/...') 404s in the REAL platform (worked in dev only). // profile-config `dataUrl` still overrides live. import bakedReached from './data/reached-neighbors.json' +import { setProjectionOn } from '@map/composables/useMapProvider.js' async function loadDataset() { const cfgUrl = profileConfig?.value?.dataUrl if (cfgUrl) { @@ -412,7 +413,7 @@ async function loadDataset() { onMounted(async () => { await initializeMap() const m = map.value - try { m && m.setProjection('mercator') } catch (_) {} // flat mercator, never globe (Driver) + setProjectionOn(m, 'mercator') // flat mercator, never globe (Driver); per-engine shape via C2 helper // Test-only hook (inert in production — platform never sets debugExpose). Lets an // automated harness use the live map.project() to click exact circle positions.