diff --git a/plugin-designs/cities-internet/connectivity-map.vue b/plugin-designs/cities-internet/connectivity-map.vue index a42a2eab7..d206886c8 100644 --- a/plugin-designs/cities-internet/connectivity-map.vue +++ b/plugin-designs/cities-internet/connectivity-map.vue @@ -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(