PPLR data maps: keep the loading globe up until the pins are drawn, not merely added

Six views set dataReady (which removes the LoadingGlobe) straight after addSource(). The engine
then still tiles tens of thousands of points in its worker, so the globe vanished and the map sat
empty with a full legend. Measured on the DX-life gallery: 0 features for 20 s, then 19,376.
whenGeojsonDrawn(map) resolves once every GeoJSON source is loaded AND a frame has rendered
(not 'idle', which an animating map may never reach), with a 45 s cap so it can never hang.
Religion, pins, coverage, translations, language families and Rosetta cards await it before
dataReady. Plugin-local: pplrData.js has no consumer outside pplr-data-maps.
Headless proof: a fake-map unit test (already loaded / last source late / never loads -> false at
the cap / style mid-swap / no map), 9/9 pass; BUNDLE=pplr-data-maps build + tree + check:sri PASS.
This commit is contained in:
Doxa Maps 2026-09-25 23:24:26 +00:00
parent 8dcd407e29
commit 0de6a7c2f8
7 changed files with 41 additions and 6 deletions

View file

@ -63,6 +63,29 @@ export function ensureGeocoder(provider = 'mapbox') {
// `new mapboxgl.Popup` throws on the first pin click. Now the library's engineFor (one copy).
export { engineFor as glFor } from '@map/composables/useMapProvider.js'
// Resolves once every GeoJSON source on the map has LOADED and a frame has RENDERED after that,
// or after `timeoutMs` (a safety net, never a hang). The views flipped `dataReady` (which hides the
// LoadingGlobe) right after addSource(); the engine then still tiles tens of thousands of points in
// its worker, so the globe disappeared and the map sat EMPTY. Measured on the DX-life gallery: 0
// dots for 20 s, then 19,376 features from 30 s. Not 'idle': an animating map may never go idle.
export function whenGeojsonDrawn(map, timeoutMs = 45000) {
return new Promise((resolve) => {
if (!map) return resolve(false)
let done = false
const finish = (drawn) => { if (done) return; done = true; clearTimeout(timer); map.off('sourcedata', check); resolve(drawn) }
const timer = setTimeout(() => finish(false), timeoutMs)
const check = () => {
try {
const srcs = (map.getStyle() || {}).sources || {}
const ids = Object.keys(srcs).filter((k) => srcs[k].type === 'geojson')
if (ids.every((id) => map.isSourceLoaded(id))) { map.once('render', () => finish(true)); map.triggerRepaint() }
} catch (_) { /* style mid-swap: wait for the next sourcedata */ }
}
map.on('sourcedata', check)
check()
})
}
// 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 }

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
/**
* pplr-coverage.vue — Country choropleth: avg # of locales filled per PG in that country.
*
@ -118,6 +118,8 @@ onMounted(async () => {
.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)`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (e) {
status.value = `Error: ${e.message}`

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
/**
* pplr-language-families.vue — PG pins colored by PPLR ethnolinguistic family.
*
@ -490,6 +490,8 @@ onMounted(async () => {
const coverage = Math.round(100 * matched / fc.features.length)
status.value = `Ready · ${fc.features.length.toLocaleString()} pins · ${coverage}% family coverage · top-25 = ${Math.round(100 * inTop25 / fc.features.length)}%`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (err) {
status.value = `Error: ${err.message}`

View file

@ -5,7 +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 { glFor, exposeMap, whenGeojsonDrawn } 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'
@ -50,6 +50,8 @@ onMounted(async () => {
.addTo(map.value)
})
status.value = `Ready · ${fc.features.length.toLocaleString('en-US')} PG-in-country pins · click any pin`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (e) {
status.value = `Error: ${e.message}`

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
/**
* pplr-religion.vue — PG pins colored by primary religion.
*
@ -267,6 +267,8 @@ onMounted(async () => {
const dominant = RELIGION_GROUPS.reduce((a, b) => (counts[a.key] || 0) > (counts[b.key] || 0) ? a : b)
// Per user 2026-05-02: drop the "Ready" prefix — it was unclear and chewed up width.
status.value = `${pgData.value.features.length.toLocaleString()} pins · ${dominant.label} most common (${(counts[dominant.key]||0).toLocaleString()})`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (err) {
status.value = `Error: ${err.message}`

View file

@ -13,7 +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 { glFor, exposeMap, whenGeojsonDrawn } 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'
@ -141,6 +141,8 @@ onMounted(async () => {
map.value.on('mouseleave', 'rosetta-pin', () => map.value.getCanvas().style.cursor = '')
status.value = `Ready · ${pgFc.features.length} pins · ${rosKeys.size} Rosetta samples (gold ★)`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (e) {
status.value = `Error: ${e.message}`

View file

@ -1,5 +1,5 @@
<script setup>
import { dataUrl, glFor, exposeMap } from '../utils/pplrData.js'
import { dataUrl, glFor, exposeMap, whenGeojsonDrawn } from '../utils/pplrData.js'
/**
* pplr-translations.vue — PG name in all 17 locales.
*
@ -92,6 +92,8 @@ onMounted(async () => {
})
status.value = `${fc.features.length.toLocaleString()} pins · click any pin to see all 17 locale names`
// keep the LoadingGlobe up until the pins are actually drawn, not merely added
await whenGeojsonDrawn(map.value)
dataReady.value = true
} catch (err) {
status.value = `Error: ${err.message}`