doxa-maps/plugin-designs/demo-map/demo-map.vue
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

264 lines
13 KiB
Vue

<script setup>
/**
* demo-map.vue — Application Profile: Demo Map
*
* @description Simple two-tab demo map. Tab 1 colors pins by religion (shared
* library strategy); Tab 2 paints all pins one custom purple
* (#8b5cf6) via this bundle's local `single-color.js` strategy.
* @profile demo-map
* @bundle demo-map
* @element demo-map
*
* Built by composing reusable @map/* library pieces (see documentation/reference.md):
* useMapInstance — Mapbox map boot/teardown
* useMapData — data load via DataSourceManager
* useMapLayers — the 'language-family-pins' circle layer + popups
* useShadowStyles — Shadow-DOM-safe CSS injection
* Everything demo-map-specific (tabs, mini legend, the purple strategy) lives
* in this bundle's own folder — it modifies nothing in library/ or internal/.
* For colors it imports only the registry seam from @map (colors/_registry.js)
* and defines its purple strategy locally (single-color.js) — the intended way
* to use the seam.
*/
import { inject, provide, ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useMapInstance } from '@map/composables/useMapInstance.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'
import { DataSourceManager } from '@map/utils/DataSourceManager.js'
import { getLanguageFamilyColor } from '@map/colors/language-family.js'
import { buildColorExpression, getColorStrategy, COLOR_MODES } from '@map/colors/_registry.js'
// Map toolbar — canonical modular control rail (MapToolbar slot-wrapper + button
// children). ThemeToggleButton is omitted: this profile has no theme-toggle
// handler; isDark defaults to false (profile-config may still flip it).
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import ZoomInButton from '@map/components/map-controls/ZoomInButton.vue'
import ZoomOutButton from '@map/components/map-controls/ZoomOutButton.vue'
import LocationButton from '@map/components/map-controls/LocationButton.vue'
import FullscreenButton from '@map/components/map-controls/FullscreenButton.vue'
import ShareButton from '@map/components/map-controls/ShareButton.vue'
// ─── Shadow DOM style injection ──────────────────────────────────────────────
useShadowStyles(`
.dm-root { position:absolute;inset:0;display:flex;flex-direction:column;overflow:hidden;
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; }
.dm-tab-bar { flex:0 0 auto;display:flex;align-items:center;background:#fff;
border-bottom:2px solid #e0e0e0;box-shadow:0 2px 4px rgba(0,0,0,.08);height:48px;
padding:0 12px;gap:4px;z-index:10; }
.dm-tab { background:none;border:none;padding:0 20px;height:100%;font-size:14px;
font-weight:500;color:#555;cursor:pointer;border-bottom:3px solid transparent;
transition:color .15s,border-color .15s,background .15s;white-space:nowrap; }
.dm-tab:hover { background:rgba(139,92,246,.06);color:#8b5cf6; }
.dm-tab.active { color:#8b5cf6;border-bottom-color:#8b5cf6;background:rgba(139,92,246,.06); }
.dm-map-area { flex:1 1 0;position:relative;overflow:hidden; }
.dm-map-canvas { position:absolute;inset:0; }
.dm-loading { position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:#f8f9fa;color:#555;font-size:14px;z-index:5; }
/* Failure state, NOT a cover: modelled on cities-internet's .gm-err so anything the
map did manage to render stays visible around it. A spinner that outlives the
failure reads as "slow" and the user just waits; this says the map is gone. */
.dm-error { position:absolute;top:50%;left:12px;right:12px;z-index:7;transform:translateY(-50%);
text-align:center;font-size:13px;line-height:1.5;color:#7f1d1d;background:rgba(255,255,255,.94);
border:1px solid rgba(127,29,29,.25);border-radius:10px;padding:12px 14px;
box-shadow:0 2px 10px rgba(0,0,0,.16); }
.dm-legend { position:absolute;left:10px;bottom:26px;z-index:6;background:rgba(255,255,255,.92);
border:1px solid #e5e7eb;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.12);
padding:10px 14px;max-width:220px; }
.dm-legend-title { font-size:12px;font-weight:700;color:#374151;margin-bottom:6px;
text-transform:uppercase;letter-spacing:.04em; }
.dm-legend-row { display:flex;align-items:center;gap:8px;font-size:12px;color:#4b5563;
line-height:1.7; }
.dm-dot { flex:none;width:10px;height:10px;border-radius:50%;
border:1px solid rgba(0,0,0,.25); }
`, 'demo-map')
// ─── Config from ProfileLoader ────────────────────────────────────────────────
const profileConfig = inject('profileConfig')
const mapboxToken = inject('mapboxToken')
const dataSource = inject('dataSource')
// Read-only theme flag for the control rail — no theme-toggle handler in this
// profile, so ThemeToggleButton is omitted; defaults false unless profile-config
// passes isDark.
const isDark = computed(() => profileConfig?.value?.isDark === true)
// ─── Tabs ─────────────────────────────────────────────────────────────────────
// Two fixed tabs (a host page may still override via profile-config.tabs,
// mirroring the doxa-simple-map pattern):
// religion — shared library strategy (COLOR_MODES.RELIGION)
// purple — this bundle's local single-color strategy (all pins #8b5cf6)
const DEFAULT_TABS = [
{ id: 'religion', label: 'By Religion', colorStrategy: COLOR_MODES.RELIGION },
{ id: 'purple', label: 'Purple Pins', colorStrategy: COLOR_MODES.SINGLE_COLOR },
]
const tabs = computed(() => profileConfig?.value?.tabs ?? DEFAULT_TABS)
const activeTabId = ref(null)
const activeTab = computed(() => tabs.value.find(t => t.id === activeTabId.value) ?? tabs.value[0])
function switchTab(tabId) {
if (tabId === activeTabId.value) return
activeTabId.value = tabId
const tab = tabs.value.find(t => t.id === tabId)
const m = map.value
if (!tab || !m || !m.getLayer('language-family-pins')) return
m.setPaintProperty('language-family-pins', 'circle-color',
buildColorExpression(tab.colorStrategy))
}
// ─── Mini legend (local to this profile — values come from the strategies) ───
const legendRows = computed(() => {
const strat = getColorStrategy(activeTab.value?.colorStrategy)
if (!strat) return []
// Religion strategy exposes `families` (letter → label) + `palette` (letter → hex)
if (strat.families) {
return Object.entries(strat.families).map(([letter, label]) => ({
label,
color: strat.palette?.[letter] ?? strat.fallback,
}))
}
// Single-color strategy: one swatch for everything
return [{ label: 'All people groups', color: strat.palette?.pin ?? strat.fallback }]
})
const legendTitle = computed(() =>
getColorStrategy(activeTab.value?.colorStrategy)?.name ?? 'Legend')
// ─── Map ID ───────────────────────────────────────────────────────────────────
const injectedInstanceId = inject('instanceId', null)
const mapId = (injectedInstanceId?.value || injectedInstanceId) ||
('demo-map-' + Math.random().toString(36).slice(2, 7))
provide('mapId', mapId)
// ─── Stores (provided by ProfileLoader) ──────────────────────────────────────
const mapStore = inject('mapStore')
const dataStore = inject('dataStore')
const uiStore = inject('uiStore')
// ─── Data + map + layers (all reused from @map) ──────────────────────────────
const mapContainer = ref(null)
const appReady = ref(false)
// Set when map init concludes it has failed. appReady alone cannot express this:
// it is false both while loading AND forever after a failure, so the loading
// overlay outlived every failure. Same `err` shape cities-internet already uses.
const mapError = ref(null)
const dsm = new DataSourceManager()
const { map, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
// Engine per profile-config.provider ('mapbox' default | 'maplibre' token-free).
// Under maplibre, useMapInstance swaps this mapbox:// style for its free basemap.
provider: resolveMapProvider(profileConfig),
style: 'mapbox://styles/mapbox/light-v11',
center: [20, 10],
zoom: 1.8,
})
const mapData = useMapData({
mapId,
dataSourceId: dataSource.value,
dataSourceManager: dsm,
dataStore,
markRaw: (v) => v,
})
const mapLayers = useMapLayers({
getMap: () => map.value,
mapId,
getLanguageFamilyColor,
getNormalizedPeopleGroups: () => mapData.normalizedPeopleGroups.value || [],
})
// ─── Lifecycle ────────────────────────────────────────────────────────────────
onMounted(async () => {
activeTabId.value = tabs.value[0]?.id ?? null
uiStore?.updateBreakpoint?.(window.innerWidth)
// 1. Start the data load immediately (parallel with map init)
const dataPromise = mapData.loadData().catch((err) => {
console.error('[demo-map] Data load failed:', err)
return { normalizedPeopleGroups: [] }
})
// 2. Boot the map
try {
await initializeMap()
} catch (err) {
// `new mapboxgl.Map()` throws outright when the device has no WebGL context.
console.error('[demo-map] Map failed to initialize —', err)
mapError.value = String(err?.message || err)
return
}
if (!map.value) {
console.error('[demo-map] Map failed to initialize — check token and container')
mapError.value = 'Map failed to initialize — check token and container'
return
}
// 3. After style.load: add the pins colored by the ACTIVE tab's strategy
map.value.once('style.load', async () => {
try {
const { normalizedPeopleGroups } = await dataPromise
mapStore?.registerMap?.(mapId, map.value)
setProjectionOn(map.value, 'mercator') // per-engine shape: library/useMapProvider (C2)
if (normalizedPeopleGroups?.length) {
mapLayers.addLanguageFamilyLayer(normalizedPeopleGroups, activeTab.value?.colorStrategy)
}
mapStore?.setMapReady?.(mapId)
appReady.value = true
} catch (err) {
console.error('[demo-map] Map ready failed:', err)
mapError.value = String(err?.message || err)
}
})
})
onBeforeUnmount(() => {
mapStore?.unregisterMap?.(mapId)
destroy()
})
</script>
<template>
<div class="dm-root">
<!-- Two-tab bar: religion coloring vs single custom purple -->
<div class="dm-tab-bar">
<button
v-for="tab in tabs"
:key="tab.id"
class="dm-tab"
:class="{ active: activeTab?.id === tab.id }"
@click="switchTab(tab.id)"
>
{{ tab.label }}
</button>
</div>
<!-- Map area -->
<div class="dm-map-area">
<div v-if="!appReady && !mapError" class="dm-loading">Loading map…</div>
<div v-if="mapError" class="dm-error">The map could not be loaded. {{ mapError }}</div>
<div ref="mapContainer" class="dm-map-canvas" />
<!-- Shared control rail — MapToolbar self-positions top-right. Zoom/Location/
Fullscreen gate on the live map instance; ShareButton needs neither and
always renders so the map is embeddable. -->
<MapToolbar v-if="appReady && map">
<ZoomInButton :map="map" :is-dark="isDark" />
<ZoomOutButton :map="map" :is-dark="isDark" />
<LocationButton :map="map" :is-dark="isDark" />
<FullscreenButton :map-container="mapContainer" :is-dark="isDark" />
<ShareButton :is-dark="isDark" :embed-path="'demo-map/demo-map/index.html'" />
</MapToolbar>
<!-- Mini legend, driven by the active tab's strategy values -->
<div v-if="appReady" class="dm-legend">
<div class="dm-legend-title">{{ legendTitle }}</div>
<div v-for="row in legendRows" :key="row.label" class="dm-legend-row">
<span class="dm-dot" :style="{ background: row.color }"></span>{{ row.label }}
</div>
</div>
</div>
</div>
</template>