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.
This commit is contained in:
Doxa Maps 2026-09-26 07:42:44 +00:00
parent 1c7104e90b
commit 3c69770422
13 changed files with 91 additions and 29 deletions

View file

@ -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 } : {}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {
}

View file

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

View file

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

View file

@ -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() }

View file

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

View file

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

View file

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

View file

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