Merge origin/development into exp-shepherd/pinhint-s3

# Conflicts:
#	public/dx-maps/cities-internet/connectivity-list/index.html
#	public/dx-maps/cities-internet/connectivity-map/index.html
#	public/dx-maps/cities-internet/data-tables/index.html
#	public/dx-maps/doxa-maps-spa/manifest.json
#	public/dx-maps/gospel-resources/charts/index.html
#	public/dx-maps/gospel-resources/data-tables/index.html
#	public/dx-maps/gospel-resources/gospel-dashboard/index.html
#	public/dx-maps/gospel-resources/resources-list/index.html
#	public/dx-maps/gospel-resources/resources-map/index.html
#	public/dx-maps/manifest.json
This commit is contained in:
Doxa Maps 2026-09-30 05:48:47 +00:00
commit e96ae2c133
8 changed files with 160 additions and 60 deletions

View file

@ -39,7 +39,7 @@ function setGlobal(name, value) {
* and mobile carriers but ALSO banks, universities and hosting resellers; the
* carrier flag is a NAME-PATTERN HEURISTIC, not a verified classification.
*/
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount, watch } from 'vue'
import { useOptionalT, fillParams } from '@map/composables/useOptionalT.js'
import { tStatic, formatList } from '@map/i18n/index.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
@ -49,6 +49,7 @@ import { addMapAttribution } from '@map/composables/useMapAttribution'
// children). ThemeToggleButton is omitted: this profile's isDark is a read-only
// computed off profile-config with no theme-toggle handler to wire it to.
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import MapInfoButton from '@map/components/map-controls/MapInfoButton.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'
@ -90,6 +91,42 @@ const err = ref(null)
// measured 7-30s to the first data paint, and for that whole window the map was an empty basemap with
// nothing to say it was still working — indistinguishable from a broken embed.
const loading = ref(true)
// ─── "What can this map do?" is on its way (Prayer Progress's announcement) ────────────────────
// The control column, with its ⓘ on top, renders only once the map exists, after the data, which can
// take tens of seconds on a slow phone. So at mount this map tells the gallery's MapInfo a control is
// COMING, and MapInfo waits for the map instead of a clock rather than flashing its floating ⓘ first.
// A failure (err) or an unmount before the map exists withdraws it, so the floating ⓘ shows at once.
// A standalone page has no MapInfo: nothing answers and nothing changes.
function announceInfoPending(pending) {
const el = mapEl.value
if (!el) return
try { el.dispatchEvent(new CustomEvent('dxmaps:mapinfo-pending', { bubbles: true, composed: true, detail: { pending } })) } catch (_) {}
}
// A MapInfo that mounts after this map asked into the void announces itself; answer it again.
const reannounceInfoPending = () => { if (!mapRef.value && !err.value) announceInfoPending(true) }
watch(err, (e) => { if (e) announceInfoPending(false) })
// ─── On a phone the control column starts BELOW the folded legend ─────────────────────────────────
// The ⓘ is the column's top control, and its one-time pointer runs sideways from it across the map's
// top band, which on a phone is this legend's. MapToolbar's phone rule puts the column at top:72px,
// inside that band, so the pointer lay over the legend's title and buttons. The column now starts 8px
// below the FOLDED legend's measured bottom, whatever its height in each language. Measured only while
// folded, and before the toolbar exists, so opening the legend never moves the controls (MapInfo places
// its pointer when the control appears and would not follow a later move). Desktop is unchanged.
const legendEl = ref(null)
const toolbarTop = ref(0)
let legendRO = null
function measureLegend() {
const el = legendEl.value
if (!el || !narrow.value || legendOpen.value) return
const b = el.offsetTop + el.offsetHeight
if (b > 0) toolbarTop.value = b + 8
}
function watchLegend() {
try { legendRO = new ResizeObserver(measureLegend); if (legendEl.value) legendRO.observe(legendEl.value) } catch (_) { legendRO = null }
measureLegend()
}
// India by state (data/tables/internet-by-adm1.json, NFHS-5): the one country with sub-national data.
const adm1 = shallowRef(null) // { meta, byArea: { [adm1]: { name, women: {total,urban,rural}, men: {…} } } }
const adm1Sex = ref('women') // which half of the survey the fill shows; both are in the sheet
@ -409,6 +446,9 @@ const ciProvider = resolveMapProvider(inject('profileConfig', null))
let unmounted = false // set on unmount; onMounted's awaits check it before building the map
onMounted(async () => {
announceInfoPending(true)
window.addEventListener('dxmaps:mapinfo-host', reannounceInfoPending)
watchLegend()
try { await ensureEngineScript(ciProvider) } catch (_) { /* fall through to the notice below */ }
// THE ENGINE THE PAGE ASKED FOR, OR NONE. This read `engineGlobal(ciProvider) || window.mapboxgl`:
// engineGlobal already answers for Mapbox, so the fallback only fired when MapLibre was asked for and
@ -485,6 +525,9 @@ onMounted(async () => {
} catch (e) { err.value = String(e.message || e); loading.value = false }
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined') window.removeEventListener('dxmaps:mapinfo-host', reannounceInfoPending)
try { legendRO?.disconnect() } catch (_) {}
if (!mapRef.value) announceInfoPending(false)
if (typeof window !== 'undefined') window.removeEventListener('resize', syncNarrow)
// ★ RELEASE THE TEST HOOKS. Each pins whatever it closes over — a map instance pins its
// canvas, its sources, and through them the whole app — so every mount that leaves one behind
@ -538,6 +581,7 @@ useShadowStyles(
// again — a geometric guarantee, not a tuned z-index.
// LOGICAL, not physical: the control rail sits at inset-inline-end, so in Arabic it moves to the
// LEFT; a legend pinned left:8px then sat under the zoom buttons (measured in the ar gallery tab).
'@media(max-width:699px){.gm .map-toolbar.map-toolbar{top:var(--gm-toolbar-top,72px)}}' +
'.gm-legend{position:absolute;top:8px;inset-inline-start:8px;width:var(--map-legend-width,380px);' +
'max-width:calc(100% - 16px);box-sizing:border-box;z-index:4;padding:8px 10px;' +
'border-radius:10px;background:rgba(255,255,255,.94);color:#0f1419;' +
@ -600,13 +644,17 @@ useShadowStyles(
</script>
<template>
<div class="gm" :class="{ 'is-dark': isDark }">
<div class="gm" :class="{ 'is-dark': isDark }" :style="narrow && toolbarTop ? { '--gm-toolbar-top': toolbarTop + 'px' } : null">
<div ref="mapEl" class="gm-map"></div>
<!-- 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. -->
<!-- The TOP control is "What can this map do?": it opens the gallery's MapInfo for this tab, which then
hides its own floating button. It renders only where a MapInfo answers it, so a standalone page
has no dead button. Round and spaced, the library default. -->
<MapToolbar v-if="mapRef">
<MapInfoButton :is-dark="isDark" />
<ZoomInButton :map="mapRef" :is-dark="isDark" />
<ZoomOutButton :map="mapRef" :is-dark="isDark" />
<LocationButton :map="mapRef" :is-dark="isDark" />
@ -614,7 +662,7 @@ useShadowStyles(
<ShareButton :is-dark="isDark" :embed-path="'cities-internet/connectivity-map/index.html'" />
</MapToolbar>
<div v-show="!groupOpen" class="gm-legend">
<div v-show="!groupOpen" ref="legendEl" class="gm-legend">
<div class="gm-title">{{ t('connectivity.title') }}</div>
<button v-if="narrow" type="button" class="gm-fold" @click="toggleLegend"
:aria-expanded="legendOpen ? 'true' : 'false'">

View file

@ -124,6 +124,7 @@ export const TAB_INFO = {
},
'cities-internet': {
storageKey: 'cities-internet',
trigger: 'control',
pos: { side: '56px', narrow: { side: '4px' } },
leadKey: 'mapInfo.tab.cities-internet.lead',
takeawayKey: 'mapInfo.tab.cities-internet.takeaway',
@ -131,6 +132,7 @@ export const TAB_INFO = {
},
'gospel-resources': {
storageKey: 'gospel-resources',
trigger: 'control',
pos: { side: '56px', narrow: { side: '4px' } },
leadKey: 'mapInfo.tab.gospel-resources.lead',
takeawayKey: 'mapInfo.tab.gospel-resources.takeaway',

View file

@ -77,7 +77,7 @@ function setGlobal(name, value) {
* boundaries. Verified against the corpus's own `country_count`: 0 of 2,106 rows
* disagree, and the 5-country string above parses to exactly its 5 real names.
*/
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount } from 'vue'
import { computed, inject, ref, shallowRef, markRaw, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useOptionalT } from '@map/composables/useOptionalT.js' // vue-i18n stays: two guarded LOCALE reads below
import { formatList } from '@map/i18n/index.js'
@ -90,6 +90,7 @@ import { engineUiLocale } from '@map/composables/useMapInstance.js'
// children). ThemeToggleButton is omitted: this profile's isDark is a read-only
// computed off profile-config with no theme-toggle handler to wire it to.
import MapToolbar from '@map/components/map-controls/MapToolbar.vue'
import MapInfoButton from '@map/components/map-controls/MapInfoButton.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'
@ -129,6 +130,42 @@ const rows = ref([])
const selected = ref(null)
const err = ref(null)
// ─── "What can this map do?" is on its way (Prayer Progress's announcement) ────────────────────
// The control column, with its ⓘ on top, renders only once the map exists, after the data, which can
// take tens of seconds on a slow phone. So at mount this map tells the gallery's MapInfo a control is
// COMING, and MapInfo waits for the map instead of a clock rather than flashing its floating ⓘ first.
// A failure (err) or an unmount before the map exists withdraws it, so the floating ⓘ shows at once.
// A standalone page has no MapInfo: nothing answers and nothing changes.
function announceInfoPending(pending) {
const el = mapEl.value
if (!el) return
try { el.dispatchEvent(new CustomEvent('dxmaps:mapinfo-pending', { bubbles: true, composed: true, detail: { pending } })) } catch (_) {}
}
// A MapInfo that mounts after this map asked into the void announces itself; answer it again.
const reannounceInfoPending = () => { if (!cardMode.value && !mapRef.value && !err.value) announceInfoPending(true) }
watch(err, (e) => { if (e) announceInfoPending(false) })
// ─── On a phone the control column starts BELOW the folded legend ─────────────────────────────────
// The ⓘ is the column's top control, and its one-time pointer runs sideways from it across the map's
// top band, which on a phone is this legend's. MapToolbar's phone rule puts the column at top:72px,
// inside that band, so the pointer lay over the legend's title and buttons. The column now starts 8px
// below the FOLDED legend's measured bottom, whatever its height in each language. Measured only while
// folded, and before the toolbar exists, so opening the legend never moves the controls (MapInfo places
// its pointer when the control appears and would not follow a later move). Desktop is unchanged.
const legendEl = ref(null)
const toolbarTop = ref(0)
let legendRO = null
function measureLegend() {
const el = legendEl.value
if (!el || !narrow.value || legendOpen.value) return
const b = el.offsetTop + el.offsetHeight
if (b > 0) toolbarTop.value = b + 8
}
function watchLegend() {
try { legendRO = new ResizeObserver(measureLegend); if (legendEl.value) legendRO.observe(legendEl.value) } catch (_) { legendRO = null }
measureLegend()
}
// GAP = what is missing (default). COVERAGE = what was found. See the header note.
const mode = ref('gap')
@ -1638,6 +1675,11 @@ function openPin(i) {
* NOT YET LOADED, a case the local copy would have skipped straight past.
*/
onMounted(async () => {
if (!cardMode.value) {
announceInfoPending(true)
window.addEventListener('dxmaps:mapinfo-host', reannounceInfoPending)
watchLegend()
}
/* ⚠ CARD MODE RETURNS BEFORE THE ENGINE. Everything below this block — the engine script, its
stylesheet, the boundaries, the pin index — is what makes the card expensive, so none of it
runs. One fetch, then render. */
@ -1801,6 +1843,9 @@ onMounted(async () => {
} catch (e) { err.value = String(e.message || e) }
})
onBeforeUnmount(() => {
if (typeof window !== 'undefined') window.removeEventListener('dxmaps:mapinfo-host', reannounceInfoPending)
try { legendRO?.disconnect() } catch (_) {}
if (!mapRef.value) announceInfoPending(false)
// ★ RELEASE THE TEST HOOKS. Each pins whatever it closes over — a map instance pins its
// canvas, its sources, and through them the whole app — so every mount that leaves one behind
// leaks another copy, measured at roughly 10 MB each.
@ -1987,6 +2032,7 @@ useShadowStyles(
'.gm-sheet-full{top:0;max-height:100%;border-radius:0}' +
'@media(min-width:700px){.gm-sheet-full{inset:0;width:auto;border-radius:0}}' +
'.gm-card-wait{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:15px;color:#6d766e;padding:24px;text-align:center}' +
'@media(max-width:699px){.gm .map-toolbar.map-toolbar{top:var(--gm-toolbar-top,72px)}}' +
'.gm-legend{position:absolute;top:8px;inset-inline-start:8px;width:var(--map-legend-width,380px);' +
'max-height:calc(100% - 16px);overflow-y:auto;overscroll-behavior:contain;' +
'max-width:calc(100% - 16px);box-sizing:border-box;z-index:4;padding:8px 10px;' +
@ -2214,7 +2260,7 @@ useShadowStyles(
override is gone. What stays LTR is each block of English EXPLANATORY PROSE, marked
where it sits: those are still English by decision, and English inside an RTL box is
the defect this replaced. -->
<div class="gm" :class="{ 'is-dark': isDark, 'has-sheet': !!(selected || showGapList) }">
<div class="gm" :class="{ 'is-dark': isDark, 'has-sheet': !!(selected || showGapList) }" :style="narrow && toolbarTop ? { '--gm-toolbar-top': toolbarTop + 'px' } : null">
<div v-show="!cardMode" ref="mapEl" class="gm-map"></div>
<!-- ⚠ CARD MODE RENDERED BLANK TWICE. First because the sheet read selected.country when card
mode had no country; then because the sheet mounted on cardMode ALONE, before the shard
@ -2228,7 +2274,11 @@ useShadowStyles(
<!-- 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. -->
<!-- The TOP control is "What can this map do?": it opens the gallery's MapInfo for this tab, which then
hides its own floating button. It renders only where a MapInfo answers it, so a standalone page
has no dead button. Round and spaced, the library default. -->
<MapToolbar v-if="mapRef">
<MapInfoButton :is-dark="isDark" />
<ZoomInButton :map="mapRef" :is-dark="isDark" />
<ZoomOutButton :map="mapRef" :is-dark="isDark" />
<LocationButton :map="mapRef" :is-dark="isDark" />
@ -2236,7 +2286,7 @@ useShadowStyles(
<ShareButton :is-dark="isDark" :embed-path="shareEmbedPath" />
</MapToolbar>
<div v-if="!cardMode" class="gm-legend" :class="{ closed: !legendOpen && !narrow }">
<div v-if="!cardMode" ref="legendEl" class="gm-legend" :class="{ closed: !legendOpen && !narrow }">
<!-- ═══ SOMEBODY ELSE'S LIST, SAID PLAINLY ════════════════════════════════════════
First thing in the legend, because it is the first thing that is different about
this map: the reader is looking at a subset somebody chose. Two numbers and one

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -61,7 +61,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../../doxa-maps-spa.js" integrity="sha384-GroDOm6hlQtsW1ATYE7OtUoJoT1kv8bw1UNS00Z2tuLlj0OSA1dIUvmk2Z+XPOPZ" crossorigin="anonymous"></script>
<script src="../../doxa-maps-spa.js" integrity="sha384-1u+pox1NZIUdGV3Zm085Le7CJvxDl2xSPx4mdD4GbQExILhQvBmD5+27t3yV8kM7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -61,7 +61,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <doxa-maps-spa> custom element. -->
<script src="../doxa-maps-spa.js" integrity="sha384-GroDOm6hlQtsW1ATYE7OtUoJoT1kv8bw1UNS00Z2tuLlj0OSA1dIUvmk2Z+XPOPZ" crossorigin="anonymous"></script>
<script src="../doxa-maps-spa.js" integrity="sha384-1u+pox1NZIUdGV3Zm085Le7CJvxDl2xSPx4mdD4GbQExILhQvBmD5+27t3yV8kM7" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long