PPLR data maps: every view loads under MapLibre, and the legend has accessible names

Under provider=maplibre (the keyless gallery) four of the seven map views never
finished loading. They built `new mapboxgl.NavigationControl` in the load
handler, and that global does not exist under MapLibre, so they sat on
"Loading…". Popups and the pin marker had the same fault. Every such call now
goes through glFor(map), the namespace of the engine that built the map. The
views' styles and the Dark/Satellite/Light switch now use registry names (ink,
hybrid, paper), which are the same Mapbox styles under Mapbox and OpenFreeMap
under MapLibre.

The search box loads its geocoder by engine, as GeocoderComponent does. Under
MapLibre it is MaplibreGeocoder over this map's own local index, so the keyless
gallery makes no request to api.mapbox.com at all.

The legend's row caret, clear chip and collapse/reopen buttons carry accessible
names and aria-expanded from legend.a11y.* and aria.* (optional i18n, English
fallback), the keys the library legend uses. check:shared-reached now counts
pplr-data-maps as carrying legend-a11y-keys.

window.__pplrMap() reports what the active view's map did (engine, load fired,
style layers, this bundle's data sources), so "loaded" is asserted, not inferred.

Measured on a served built tree, host page on another origin: all ten views
loaded under MapLibre and under Mapbox (load fired, style layers and data
sources present, canvas not blank, 0 errors), with 0 api.mapbox.com requests
under MapLibre. The same check on the parent build fails four map views under
MapLibre. A control with no provider makes 13 Mapbox requests.
This commit is contained in:
doxa-maps-88655c70 2026-09-25 09:57:17 +00:00
parent 3cd3e7a55f
commit 17931ed031
15 changed files with 190 additions and 84 deletions

View file

@ -18,8 +18,9 @@
*
* The `/` keyboard shortcut from the original PplrGeocoder is preserved.
*/
import { ref, watch, onMounted, onBeforeUnmount, computed } from 'vue'
import { ensureGeocoder } from '../utils/pplrData.js'
import { ref, watch, onMounted, onBeforeUnmount, computed, inject } from 'vue'
import { ensureGeocoder, GEOCODER } from '../utils/pplrData.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({
@ -44,6 +45,10 @@ const wrapEl = ref(null)
const slotEl = ref(null)
let geocoder = null
// Same engine the map uses. Under maplibre (the keyless gallery) search runs on MaplibreGeocoder
// over this map's own local index only, so it makes no Mapbox request at all.
const provider = resolveMapProvider(inject('profileConfig', null))
const isMaplibre = provider === 'maplibre'
// Build the localGeocoder callback. Mapbox-gl-geocoder calls this on every
// keystroke; we return Carmen-GeoJSON-shaped features for matching local
@ -116,7 +121,7 @@ function renderSuggestion(item) {
// Shadow DOM is sealed from regular-document styles, so we must inject the same
// stylesheet into each shadow root that hosts a geocoder. Idempotent — checks
// if already present.
const GEOCODER_CSS_URL = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css'
const GEOCODER_CSS_URL = (GEOCODER[provider] || GEOCODER.mapbox).css
function ensureGeocoderCss(root) {
if (!root || root === document) return
if (root.querySelector('link[data-pplr-geocoder-css]')) return
@ -134,6 +139,22 @@ function setup() {
// from mounting. Per user 2026-05-02: map went completely blank when
// the geocoder threw on shadow-DOM addTo().
try {
if (isMaplibre) {
if (!window.MaplibreGeocoder || !window.maplibregl) {
console.warn('[MapboxGeocoder] maplibre-gl-geocoder or maplibre-gl not on window — search disabled')
return
}
// Local-only: the PG/family index this map already holds. No place search, no key.
geocoder = new window.MaplibreGeocoder({ forwardGeocode: async () => ({ type: 'FeatureCollection', features: [] }) }, {
maplibregl: window.maplibregl,
placeholder: props.placeholder,
localGeocoder: buildLocalGeocoder(),
localGeocoderOnly: true,
minLength: props.minChars,
showResultsWhileTyping: true,
render: renderSuggestion,
})
} else {
if (!window.MapboxGeocoder || !window.mapboxgl) {
console.warn('[MapboxGeocoder] mapbox-gl-geocoder or mapbox-gl not on window — check CDN <script> tags')
return
@ -152,6 +173,7 @@ function setup() {
limit: 5,
render: renderSuggestion,
})
}
geocoder.on('result', (ev) => {
const f = ev.result
@ -222,7 +244,7 @@ function onGlobalKey(e) {
const isCmdK = (e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')
if (isSlash || isCmdK) {
e.preventDefault()
const input = slotEl.value?.querySelector('input.mapboxgl-ctrl-geocoder--input')
const input = slotEl.value?.querySelector('input.mapboxgl-ctrl-geocoder--input, input.maplibregl-ctrl-geocoder--input')
input?.focus()
input?.select?.()
}
@ -230,7 +252,7 @@ function onGlobalKey(e) {
onMounted(() => {
// setup() swallows its own errors; a failed script load leaves the map working without search.
ensureGeocoder().then(setup, (e) => console.warn('[MapboxGeocoder]', e.message))
ensureGeocoder(provider).then(setup, (e) => console.warn('[MapboxGeocoder]', e.message))
document.addEventListener('keydown', onGlobalKey)
})
onBeforeUnmount(() => {

View file

@ -21,6 +21,22 @@
* (one search per map cluster, per QA "unified geocoder" decision).
*/
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
// Accessible names from the shared catalog (legend.a11y.*, aria.*), the keys the library legend
// uses, so they are translated in all eleven locales. i18n is OPTIONAL: on a host without it, the
// English fallback below is used, never a raw key.
const A11Y_EN = {
'legend.a11y.expandRow': 'Expand {label}', 'legend.a11y.collapseRow': 'Collapse {label}',
'legend.a11y.clearSelection': 'Clear selection',
'aria.expandLegend': 'Expand legend', 'aria.collapseLegend': 'Collapse legend',
}
const fillA11y = (str, p) => String(str).replace(/\{(\w+)\}/g, (m, k) => (p && k in p ? p[k] : m))
let ta = (key, p) => fillA11y(A11Y_EN[key] ?? key, p)
try {
const i18n = useI18n()
ta = (key, p) => { const v = i18n.t(key, p || {}); return v === key ? fillA11y(A11Y_EN[key] ?? key, p) : v }
} catch (_) { /* no i18n on this host */ }
import { useInstance } from '../composables/usePplrInstance.js'
const props = defineProps({
@ -373,6 +389,8 @@ function rowClasses(row) {
class="stl-collapse-btn"
:class="{ closed: !panelOpen }"
:title="panelOpen ? 'Collapse legend' : 'Expand legend'"
:aria-label="panelOpen ? ta('aria.collapseLegend') : ta('aria.expandLegend')"
:aria-expanded="String(panelOpen)"
@click="panelOpen = !panelOpen">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.8"
@ -442,6 +460,8 @@ function rowClasses(row) {
class="stl-trunk" :class="`stl-trunk-${seg}`"></span>
<button v-if="row.node.children?.length"
class="stl-caret" :class="{ open: expandedIds.has(row.node.id) }"
:aria-expanded="expandedIds.has(row.node.id) ? 'true' : 'false'"
:aria-label="ta(expandedIds.has(row.node.id) ? 'legend.a11y.collapseRow' : 'legend.a11y.expandRow', { label: row.node.label })"
@click.stop="toggleExpand(row.node.id, $event)">
<svg width="9" height="9" viewBox="0 0 12 12" fill="none">
<path d="M4.5 3L7.5 6L4.5 9" stroke="currentColor" stroke-width="1.6"
@ -456,7 +476,7 @@ function rowClasses(row) {
<span v-if="hasPop" class="stl-num">{{ fmtPop(row.node.pop) }}</span>
<span class="stl-x-slot">
<button v-if="selectedId === row.node.id"
class="stl-x-chip" title="Clear selection"
class="stl-x-chip" :title="ta('legend.a11y.clearSelection')" :aria-label="ta('legend.a11y.clearSelection')"
@click.stop="deselect($event)">
<svg width="8" height="8" viewBox="0 0 12 12" fill="none">
<path d="M2 2L10 10M10 2L2 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
@ -485,7 +505,9 @@ function rowClasses(row) {
<button v-if="!panelOpen"
class="stl-reopen"
:data-theme="instance?.theme.value || 'dark'"
title="Expand legend"
:title="ta('aria.expandLegend')"
:aria-label="ta('aria.expandLegend')"
aria-expanded="false"
@click="panelOpen = true">
<svg class="stl-reopen-icon" width="11" height="11" viewBox="0 0 12 12" fill="none">
<path d="M2 4h8M2 6h8M2 8h5" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>

View file

@ -33,17 +33,58 @@ export function loadNames() {
return namesPromise
}
// mapbox-gl-geocoder is a page global too (the original page carried a <script> tag). Load it
// once, on first need, so the search box works wherever the bundle is embedded.
const GEOCODER_JS = 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js'
let geocoderPromise = null
export function ensureGeocoder() {
if (typeof window === 'undefined' || window.MapboxGeocoder) return Promise.resolve()
if (!geocoderPromise) geocoderPromise = new Promise((res, rej) => {
// The geocoder is a page global too (the original page carried a <script> tag). Load it once, on
// first need, and pick it BY ENGINE, as library/components/map-controls/GeocoderComponent.vue does:
// the Mapbox plugin under provider=mapbox, MapLibre's under provider=maplibre. A hard-coded Mapbox
// script made this the only request to api.mapbox.com in the keyless (maplibre) gallery.
export const GEOCODER = {
mapbox: { js: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js',
css: 'https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css',
global: 'MapboxGeocoder' },
maplibre: { js: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.min.js',
css: 'https://cdn.jsdelivr.net/npm/@maplibre/maplibre-gl-geocoder@1/dist/maplibre-gl-geocoder.css',
global: 'MaplibreGeocoder' },
}
const geocoderPromises = {}
export function ensureGeocoder(provider = 'mapbox') {
const g = GEOCODER[provider] || GEOCODER.mapbox
if (typeof window === 'undefined' || window[g.global]) return Promise.resolve()
if (!geocoderPromises[provider]) geocoderPromises[provider] = new Promise((res, rej) => {
const s = document.createElement('script')
s.src = GEOCODER_JS
s.onload = () => res(); s.onerror = () => { geocoderPromise = null; rej(new Error('geocoder failed to load')) }
s.src = g.js
s.onload = () => res(); s.onerror = () => { delete geocoderPromises[provider]; rej(new Error('geocoder failed to load')) }
document.head.appendChild(s)
})
return geocoderPromise
return geocoderPromises[provider]
}
// The engine namespace that built THIS map (Popup, Marker, NavigationControl…). The views were
// written against the Mapbox global; under provider=maplibre that global does not exist and a
// `new mapboxgl.Popup` throws on the first pin click.
export function glFor(map) {
if (typeof window === 'undefined') return undefined
const ml = window.maplibregl
if (ml && map && ml.Map && map instanceof ml.Map) return ml
return window.mapboxgl || ml
}
// Verification handle (the gospel-resources __grState contract): what the ACTIVE view's map
// actually did, so a check can assert "loaded" rather than inferring it from missing requests.
// window.__pplrMap() -> { engine, loaded, styleLayers, pplrLayers }
export function exposeMap(map) {
if (typeof window === 'undefined' || !map) return
let loaded = false
map.once('load', () => { loaded = true })
window.__pplrMap = () => {
let layers = []
try { layers = map.getStyle()?.layers || [] } catch (_) {}
return {
engine: window.maplibregl && window.maplibregl.Map && map instanceof window.maplibregl.Map ? 'maplibre' : 'mapbox',
// 'load' may already have fired before this listener was attached; a loaded style says so too
loaded: loaded || !!(map.isStyleLoaded && map.isStyleLoaded()),
styleLayers: layers.length,
// sources this bundle added (not the basemap's): proof the view's own data reached the map
dataSources: (() => { try { return Object.keys(map.getStyle()?.sources || {}).filter((k) => !/^(composite|mapbox|openmaptiles|natural_earth|ne2_shaded|maplibre)/i.test(k)).length } catch (_) { return 0 } })(),
}
}
}

View file

@ -29,7 +29,7 @@ export const LOCALE_NAMES = {
}
// ── PPLR FeatureServer — country polygons (live, not bundled) ────────────────
import { dataUrl } from './pplrData.js'
import { dataUrl, glFor } from './pplrData.js'
export const COUNTRY_FS_URL = 'https://services2.arcgis.com/S4ydGgujXcif36k3/arcgis/rest/services/aCountry/FeatureServer/0/query'
export const COUNTRY_FS_FIELDS = ['GENCalpha3', 'ISOalpha2', 'CtryNm', 'ctryNmDisp']
@ -282,7 +282,7 @@ export function makePinIndicator() {
if (!map || !lngLat) return
ensureEl(color)
if (!marker) {
marker = new mapboxgl.Marker({ element: el, anchor: 'bottom' })
marker = new (glFor(map)).Marker({ element: el, anchor: 'bottom' })
}
marker.setLngLat(lngLat).addTo(map)
},

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
/**
* pplr-coverage.vue — Country choropleth: avg # of locales filled per PG in that country.
*
@ -31,7 +31,7 @@ function reload() { window.location.reload() }
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [25, 18],
zoom: 1.6
})
@ -50,6 +50,7 @@ async function streamCoverage() {
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
map.value.on('load', async () => {
try {
status.value = 'Loading per-country coverage…'
@ -105,7 +106,7 @@ onMounted(async () => {
<b>${p.avgFilled != null ? p.avgFilled.toFixed(1) : '—'}</b> / 17</td></tr>
</table>
</div>`
new mapboxgl.Popup({ maxWidth: '300px' })
new (glFor(map.value)).Popup({ maxWidth: '300px' })
.setLngLat(e.lngLat).setHTML(html).addTo(map.value)
})
@ -113,7 +114,7 @@ onMounted(async () => {
const { pinLayerId } = await addPGPoints(map.value, { circleColor: '#fef3c7' })
map.value.on('click', pinLayerId, e => {
const f = e.features[0]
new mapboxgl.Popup({ maxWidth: '320px' })
new (glFor(map.value)).Popup({ maxWidth: '320px' })
.setLngLat(f.geometry.coordinates).setHTML(popupForPG(f.properties)).addTo(map.value)
})
status.value = `Ready · ${countryStats.size} countries colored by avg translation locales / 17 (all ${[...countryStats.values()].reduce((n, c) => n + c.pgCount, 0).toLocaleString('en-US')} PG-in-country rows)`

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
/**
* pplr-language-families.vue — PG pins colored by PPLR ethnolinguistic family.
*
@ -12,6 +12,8 @@ import { dataUrl } from '../utils/pplrData.js'
* Each depth level = one tab. Filter expressions are pre-built into each tree node.
*/
import { inject, onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
@ -23,6 +25,8 @@ import { createPplrInstance, provideInstance } from '../composables/usePplrInsta
const pplrInstance = createPplrInstance('language-families')
provideInstance(pplrInstance)
// Registry names resolve per engine, so the Dark/Satellite/Light switch works under MapLibre too.
const mapProvider = resolveMapProvider(inject('profileConfig', null))
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const profileRoot = ref(null) // FullscreenControl target — fullscreens .profile so chrome stays visible
@ -32,7 +36,7 @@ const PIN_LAYER = 'lf-pins'
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [25, 18],
zoom: 1.6,
projection: 'mercator' // pinned at construct time — no globe→mercator reproject flash
@ -100,9 +104,9 @@ const projection = ref('mercator') // default mercator per user spec
const showBasemap = ref(false)
const currentBasemap = ref('dark')
const BASEMAPS = [
{ key: 'dark', label: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ key: 'satellite', label: 'Satellite', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
{ key: 'light', label: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ key: 'dark', label: 'Dark', url: 'ink' },
{ key: 'satellite', label: 'Satellite', url: 'hybrid' },
{ key: 'light', label: 'Light', url: 'paper' },
]
const pgData = ref(null)
@ -370,7 +374,7 @@ function setBasemap(key) {
map.value.once('style.load', () => { setupLayers(); map.value.setProjection(projection.value) })
// diff:false suppresses Mapbox's "Unimplemented: setSprite" warning between
// dark-v11 and light-v11. We re-add layers in setupLayers anyway.
map.value.setStyle(style, { diff: false })
map.value.setStyle(resolveNamedStyle(style, mapProvider).style, { diff: false })
}
function toggleTheme() {
@ -422,8 +426,9 @@ function popupHtml(p) {
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new (glFor(map.value)).NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {

View file

@ -5,6 +5,7 @@
* Individual pins at all zoom levels; click pin for full Rosetta popup; hover country for highlight.
*/
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { glFor, exposeMap } from '../utils/pplrData.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, addPGPoints, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
@ -26,13 +27,14 @@ const DATA_SOURCES = [
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [25, 18],
zoom: 1.6
})
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
map.value.on('load', async () => {
try {
status.value = 'Adding country polygons…'
@ -42,7 +44,7 @@ onMounted(async () => {
map.value.on('click', pinLayerId, e => {
const f = e.features[0]
new mapboxgl.Popup({ maxWidth: '340px' })
new (glFor(map.value)).Popup({ maxWidth: '340px' })
.setLngLat(f.geometry.coordinates)
.setHTML(popupForPG(f.properties))
.addTo(map.value)

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
/**
* pplr-religion.vue — PG pins colored by primary religion.
*
@ -8,6 +8,8 @@ import { dataUrl } from '../utils/pplrData.js'
* Uses the `rg` field pre-computed in pg-points.geojson.
*/
import { inject, onMounted, onBeforeUnmount, ref, computed, watch } from 'vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import MapboxGeocoder from '../components/MapboxGeocoder.vue'
@ -19,6 +21,8 @@ import { createPplrInstance, provideInstance } from '../composables/usePplrInsta
const pplrInstance = createPplrInstance('religion')
provideInstance(pplrInstance)
// Registry names resolve per engine, so the Dark/Satellite/Light switch works under MapLibre too.
const mapProvider = resolveMapProvider(inject('profileConfig', null))
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const profileRoot = ref(null) // FullscreenControl target — fullscreens .profile so chrome stays visible
@ -30,9 +34,9 @@ const projection = ref('mercator')
const showBasemap = ref(false)
const currentBasemap = ref('dark')
const BASEMAPS = [
{ key: 'dark', label: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ key: 'satellite', label: 'Satellite', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
{ key: 'light', label: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ key: 'dark', label: 'Dark', url: 'ink' },
{ key: 'satellite', label: 'Satellite', url: 'hybrid' },
{ key: 'light', label: 'Light', url: 'paper' },
]
const pgData = ref(null)
const dataReady = ref(false)
@ -57,7 +61,7 @@ function setBasemap(key) {
map.value.once('style.load', () => { setupLayers(); map.value.setProjection(projection.value) })
// diff:false suppresses Mapbox's "Unimplemented: setSprite" warning between
// dark-v11 and light-v11. We re-add layers in setupLayers anyway.
map.value.setStyle(style, { diff: false })
map.value.setStyle(resolveNamedStyle(style, mapProvider).style, { diff: false })
}
function setupLayers() {
@ -108,7 +112,7 @@ const DATA_SOURCES = [
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [25, 18],
zoom: 1.6,
projection: 'mercator' // pinned at construct time — no globe→mercator reproject flash
@ -240,8 +244,9 @@ function popupHtml(p) {
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new (glFor(map.value)).NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
/**
* pplr-rop-tree.vue — ROP hierarchy: Affinity Bloc → Cluster → People Group → PGIC.
*
@ -8,6 +8,8 @@ import { dataUrl } from '../utils/pplrData.js'
* Pins are colored by Affinity Bloc. Each level sorts by pin count desc.
*/
import { inject, onMounted, onBeforeUnmount, ref, computed, shallowRef, watch } from 'vue'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, setCountryPolygonTheme, POPUP_CSS, COUNTRY_FS_URL, COUNTRY_FS_FIELDS, FullscreenToggleControl, makePinIndicator, bindFuzzyPinClick } from '../utils/pplrMap.js'
import SemanticTreeLegend from '../components/SemanticTreeLegend.vue'
@ -20,6 +22,8 @@ import { createPplrInstance, provideInstance } from '../composables/usePplrInsta
const pplrInstance = createPplrInstance('rop-tree')
provideInstance(pplrInstance)
// Registry names resolve per engine, so the Dark/Satellite/Light switch works under MapLibre too.
const mapProvider = resolveMapProvider(inject('profileConfig', null))
const mapboxToken = inject('mapboxToken')
const mapContainer = ref(null)
const profileRoot = ref(null) // FullscreenControl target — fullscreens the .profile so chrome stays visible
@ -29,7 +33,7 @@ const PIN_LAYER = 'rop-pins'
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [25, 18],
zoom: 1.6,
projection: 'mercator' // pinned at construct time — see useMapInstance projection arg
@ -78,9 +82,9 @@ const projection = ref('mercator') // default mercator per user spec
const showBasemap = ref(false)
const currentBasemap = ref('dark')
const BASEMAPS = [
{ key: 'dark', label: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
{ key: 'satellite', label: 'Satellite', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
{ key: 'light', label: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
{ key: 'dark', label: 'Dark', url: 'ink' },
{ key: 'satellite', label: 'Satellite', url: 'hybrid' },
{ key: 'light', label: 'Light', url: 'paper' },
]
// ── ROP tree (4 generations: Affinity Bloc → People Cluster → ROP25 → ROP3byCountry)
@ -309,7 +313,7 @@ function setBasemap(key) {
// "Unimplemented: setSprite" warning whenever sprite sheets differ between
// dark-v11 and light-v11. We re-add all our layers in setupLayers anyway,
// so a full rebuild is correct and avoids the noisy warning.
map.value.setStyle(style, { diff: false })
map.value.setStyle(resolveNamedStyle(style, mapProvider).style, { diff: false })
}
// Quick light/dark toggle — flips between dark-v11 and light-v11 directly
@ -389,12 +393,13 @@ function popupHtml(p) {
// ── Lifecycle ─────────────────────────────────────────────────────────────────
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
// Navigation: zoom +/- (Mapbox built-in) + custom fullscreen toggle.
// See FullscreenToggleControl in pplrMap.js for why we don't use
// mapboxgl.FullscreenControl with a non-map container.
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new (glFor(map.value)).NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}

View file

@ -13,6 +13,7 @@
* Click any pin → popup with full Rosetta table.
*/
import { inject, onMounted, onBeforeUnmount, ref } from 'vue'
import { glFor, exposeMap } from '../utils/pplrData.js'
import { useMapInstance } from '@map/composables/useMapInstance.js'
import { addCountryPolygons, loadOnce, popupForPG, POPUP_CSS } from '../utils/pplrMap.js'
import LoadingGlobe from '../components/LoadingGlobe.vue'
@ -34,13 +35,14 @@ function reload() { window.location.reload() }
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [50, 22],
zoom: 1.8
})
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
map.value.on('load', async () => {
try {
status.value = 'Loading PG data…'
@ -126,7 +128,7 @@ onMounted(async () => {
${missingIds.join(', ')} ID${missingIds.length > 1 ? 's' : ''} not yet cross-mapped in PPLR — empty rows are a data gap, not an error.
</td></tr>`
: ''
new mapboxgl.Popup({ maxWidth: '360px' })
new (glFor(map.value)).Popup({ maxWidth: '360px' })
.setLngLat(f.geometry.coordinates)
.setHTML(popupForPG(p, rosettaRow + missingNote))
.addTo(map.value)

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
/**
* pplr-translations.vue — PG name in all 17 locales.
*
@ -26,7 +26,7 @@ const PIN_LAYER = 'trans-pins'
const { map, isMapReady, initializeMap, destroy } = useMapInstance({
containerRef: mapContainer,
accessToken: mapboxToken.value,
style: 'mapbox://styles/mapbox/dark-v11',
style: 'ink', // named: dark-v11 under Mapbox, OpenFreeMap dark under MapLibre
center: [70, 22],
zoom: 2,
projection: 'mercator',
@ -56,8 +56,9 @@ const DATA_SOURCES = [
onMounted(async () => {
await initializeMap()
exposeMap(map.value)
if (map.value) {
map.value.addControl(new mapboxgl.NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new (glFor(map.value)).NavigationControl({ showCompass: false, visualizePitch: false }), 'bottom-right')
map.value.addControl(new FullscreenToggleControl(profileRoot.value), 'bottom-right')
}
map.value.on('load', async () => {

File diff suppressed because one or more lines are too long

View file

@ -837,7 +837,7 @@
"bundle": "pplr-data-maps",
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"output": "doxa-maps/pplr-data-maps/pplr-data-maps.js",
"integrity": "sha384-u04GVRZqNXyBqfkyvCtm9IT6/RWbJTNTxTpv6ScsnE/5za+UIlTjAoZKIn9k2Zp3",
"integrity": "sha384-RHtStBg9uYgRi/QxaFbnrR9Q3wKUebRcm+gi2eza6tSYsI3t7YsjayWiCwU4fCKU",
"elements": [
"pplr-data-maps"
],
@ -853,7 +853,7 @@
"scriptUrl": "./pplr-data-maps/pplr-data-maps.js",
"embed": {
"iframe": "<iframe src=\"./pplr-data-maps/pplr-data-maps/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-u04GVRZqNXyBqfkyvCtm9IT6/RWbJTNTxTpv6ScsnE/5za+UIlTjAoZKIn9k2Zp3\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./pplr-data-maps/pplr-data-maps.js\" integrity=\"sha384-RHtStBg9uYgRi/QxaFbnrR9Q3wKUebRcm+gi2eza6tSYsI3t7YsjayWiCwU4fCKU\" crossorigin=\"anonymous\"></script>\n<pplr-data-maps profile-config='{\"profile\":\"pplr-data-maps\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></pplr-data-maps>"
},
"bundle": "pplr-data-maps"
}

File diff suppressed because one or more lines are too long

View file

@ -29,7 +29,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <pplr-data-maps> custom element. -->
<script src="../pplr-data-maps.js" integrity="sha384-u04GVRZqNXyBqfkyvCtm9IT6/RWbJTNTxTpv6ScsnE/5za+UIlTjAoZKIn9k2Zp3" crossorigin="anonymous"></script>
<script src="../pplr-data-maps.js" integrity="sha384-RHtStBg9uYgRi/QxaFbnrR9Q3wKUebRcm+gi2eza6tSYsI3t7YsjayWiCwU4fCKU" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: