doxa-maps/plugin-designs/cities-internet/connectivity-map.vue
Doxa Maps e3d25ff6db The people-group list is a universal prop: the same maps can show any project's list
Every map showed DOXA's people groups, and only those. The same maps must serve other projects'
lists: Project 7000 (unreached), 4700 (frontier), 700 (frontier of 500,000 or more), 2000 (DOXA).
The list is now chosen the way the engine is:
  profile-config `list` > ?list= > window.MAP_LIST > 'doxa' (exactly today's behaviour)
with ids or project aliases ('700', 'project-7000').

- library/lists/lists.json: each list is a FILTER over the IMB / peoplegroups.org dataset already
  in the repo (Lostness Priority, Population, Engagement Status), never a hand-kept roster.
- library/scripts/people-groups/build-lists.mjs (`bun run lists`): writes each list's DOXA members
  to data/people-groups/lists/<id>.json, joined the way the Vision Card funnel joins, so the
  counts agree with it: unreached 7,341 (2,082 DOXA), frontier 4,754 (1,745), frontier-500k 737
  (71), unengaged 2,982 (1,929).
- library/lists/peopleGroupList.js: resolvePeopleGroupList and applyPeopleGroupList(rows, cfg).
  The default list returns the same array, unfetched. A list whose membership cannot load shows no
  groups (fails closed) rather than DOXA's whole list under another list's name.
- Tier A, every production map: DataSourceManager applies it (and the maps that build one pass
  their profileConfig), and so does every direct people-group fetch: people-groups-grid, the
  Big Picture header, research-dashboard (data, CSV export; the DOXA-wide /statistics baselines
  are skipped under another list), Regions (list, dashboards, their table), UPG-100 (4 fetches +
  the poster wall), Gospel's roster, and the Experimental upg-neighbor and Wall. The composites
  thread `list` to their children like `provider`.

Gates: check:universal-props gains a `list` column (LIST-FETCH, LIST-DSM, LIST-KEYS; its
self-test now plants 9 bypasses) and passes across 18 bundles, with all-people-groups allowed:
it draws the IMB dataset, so its list is Tier B. New check:list-prop-unit (Node, 17 cases, in
gates:post); poisoning the ladder order or deleting a membership file fails it. Documented in
documentation/reference-universal-props.md section 5.

Headless: all 14 touched bundles build; check:composites-mount 20/20 and check:composite-interop
17 links pass on this tree; no-orphan-gates, script-flags, quoted-scripts, doc-scripts pass. Not
rendered in a browser.
2026-09-26 05:16:02 +00:00

467 lines
26 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
/**
* connectivity-map.vue — THE CITIES-WITH-INTERNET GLOBAL MAP.
*
* @description Every country shaded by measured internet penetration, with its named carriers and dominant
* social platform on tap — the connectivity view of where DX people groups can be reached online.
*
* Every country shaded by measured internet penetration, with its NAMED carriers
* and dominant social platform on tap. Brief: "all these are missing maps."
*
* CLONE of gospel-resources/resources-map.vue with the data and bands swapped —
* profiles duplicate, the pattern does not get re-derived. Same mobile-first
* rules: one WebGL context, tap-not-hover, legend strip, bottom sheet.
*
* MOBILE FIRST, deliberately. The brief asked for mobile-friendliness before this
* map existed, so it is built that way rather than retrofitted:
* - one WebGL context (the ~16-cap rule; this is the app's only map pane)
* - tap-to-select with a bottom SHEET, not a hover popup — hover does not exist
* on a phone, and a hover-only map is an unusable map
* - the legend is a BOUNDED panel (380px, left-anchored), clear of the control
* rail on the right. It was a full-width strip; that is what made it collide.
* - boundaries FETCHED at runtime (the IIFE inlines every import; 2.7MB of
* geojson in the bundle would block first paint)
*
* ═══ WHAT THE COLOUR MEANS, AND WHAT IT MUST NOT BE READ AS ═════════════════
* The band is NATIONAL internet penetration (World Bank, with its observation
* year). It is NOT a city measurement — per-city connectivity does not exist
* publicly at this scale, and shading a country then reading a city off it is the
* error this whole corpus refuses. The sheet says so on every country.
*
* CARRIER NAMES come from ASN registry holders (RIPEstat). That set includes ISPs
* 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 { useI18n } from 'vue-i18n'
import enConnectivity from '@map/i18n/locales/en/connectivity.json'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { addMapAttribution } from '@map/composables/useMapAttribution'
// Map toolbar — canonical modular control rail (MapToolbar slot-wrapper + button
// 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 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'
import { resolveAssetsBase } from '@map/utils/embedBase.js'
import { resolveMapProvider } from '@map/composables/useMapProvider.js'
import { ensureEngineScript, engineGlobal, ensureEngineCSS, engineStylesheetUrl } from '@map/composables/useMapInstance.js'
import { resolveNamedStyle } from '@map/styles/registry.js'
import { installRtlTextPlugin } from '@map/maps/rtl-text.js'
// People-group dots open the SAME detail panel as Prayer Progress (doxa-simple-map): the
// shared store selection, the shared GO-pin highlight, and the shared legend wrappers
// that host PeopleGroupDetail on desktop and in the phone sheet.
import { DataSourceManager } from '@map/utils/DataSourceManager.js'
import { useSelectedPin } from '@map/composables/useSelectedPin.js'
import { retryableAsyncComponent } from '@map/composables/retryableAsyncComponent.js'
import LegendDesktop from '@map/components/legends/LegendDesktop.vue'
const LegendMobile = retryableAsyncComponent(() => import('@map/components/legends/LegendMobile.vue'))
const profileConfig = inject('profileConfig', ref(null))
const isDark = computed(() => profileConfig.value?.isDark === true)
// The token through the library's channel (documentation/reference-universal-props.md): the
// profile-config tk, then ProfileLoader's injected mapboxToken (which carries the page-wide shared
// token, fed by window.MAP_TOKEN), then window.MAP_TOKEN. This read window.DOXA_TOKEN, which only a
// few dev pages set, so in the studio and on every built page the map ignored the page's token.
const injectedTk = inject('mapboxToken', null)
const mapboxToken = computed(() => profileConfig.value?.tk || injectedTk?.value
|| (typeof window !== 'undefined' && window.MAP_TOKEN) || '')
const assetsBase = computed(() => resolveAssetsBase(profileConfig.value))
const mapEl = ref(null)
const mapRef = shallowRef(null)
const rows = ref([])
const selected = ref(null)
const aiNoticeOpen = ref(true) // dismissible AI-research disclaimer in the detail sheet
const err = ref(null)
// ── People-group dots (Driver: "red dots on the map to show me where the people groups are")
// The DOXA list from pray-tools, the same list Prayer Progress draws. It carries the slug the
// detail panel fetches by. The shared pin table (tables/people-group-pins.json) has no slug,
// so its dots could not open that panel.
// A distinct red with a white ring: the "under 25%" fill is also red (#b42318), and a dot
// without the ring vanishes into it.
const PG_DOT = '#e11d48'
const uiStore = inject('uiStore', null)
const _lang = (() => { const v = inject('lang', null)?.value; return (v && v !== 'en') ? v : null })()
const dsm = new DataSourceManager({ locale: _lang, profileConfig })
const groups = shallowRef([])
const dotCount = ref(0)
const selectedPin = useSelectedPin({ getMap: () => mapRef.value, getActivePinColor: () => PG_DOT })
async function loadGroups() {
await dsm.init()
const raw = await dsm.setActiveSource('pray-tools')
groups.value = markRaw(raw || [])
}
// The properties PeopleGroupDetail reads (plus uniqueId for the shared highlight). Flat
// primitives only: Mapbox stringifies nested feature properties.
function groupFC() {
const feats = []
for (const pg of groups.value) {
const lng = parseFloat(pg.longitude ?? pg.lng), lat = parseFloat(pg.latitude ?? pg.lat)
if (!isFinite(lng) || !isFinite(lat)) continue
const n = pg._normalized || {}
feats.push({
type: 'Feature',
properties: {
uniqueId: pg.uniqueId, slug: pg.slug || '', name: pg.name,
language: pg.language, languageFamily: pg.languageFamily || n.languageFamily || '',
country: pg.country, countryIso: pg.countryIso, countryIsoLabel: pg.countryIsoLabel, countryName: pg.countryName,
religion: pg.religion, religionLabel: pg.religionLabel, religionName: pg.religionName,
population: pg.population, status: pg.status, description: pg.description || '', imageUrl: pg.imageUrl || '',
peoplePraying: Number(pg.peoplePraying ?? n.peoplePraying ?? 0),
},
geometry: { type: 'Point', coordinates: [lng, lat] },
})
}
return { type: 'FeatureCollection', features: feats }
}
function paintGroups(map) {
if (!groups.value.length || map.getSource('language-families')) return
const fc = groupFC()
dotCount.value = fc.features.length
// Named 'language-families' because the shared selected-pin highlight looks for it.
map.addSource('language-families', { type: 'geojson', data: fc, promoteId: 'uniqueId' })
// OPENING VIEW = the dots' own extent (gospel found this on its clone). A hard-coded zoom frames
// the world only at one canvas size: MEASURED at 390 px, zoom 1.3 showed 1,579 of the 2,107
// groups and cut off the Americas and the Pacific. Fitting the data means the first view cannot
// depend on the screen. Once, on first paint; the user owns the camera after that.
if (fc.features.length) {
let w = 180, s = 90, e = -180, n = -90
for (const f of fc.features) { const [x, y] = f.geometry.coordinates; if (x < w) w = x; if (x > e) e = x; if (y < s) s = y; if (y > n) n = y }
const pad = Math.round(Math.min(map.getContainer().clientWidth, map.getContainer().clientHeight) * 0.06) + 8
try {
const want = map.cameraForBounds([[w, s], [e, n]], { padding: pad })
map.fitBounds([[w, s], [e, n]], { padding: pad, duration: 0 })
// A PORTRAIT PHONE CANNOT FRAME THE WORLD IN MERCATOR: the engine will not zoom out past the
// point where the world stops filling the viewport vertically (MEASURED at 390x844: floor
// 0.72 while the fit wanted -0.62). When the fit is clamped like that, some dots must be off
// screen; put the visible window where the MOST dots are rather than at the bbox centre.
if (want && map.getZoom() > want.zoom + 0.05) {
const b = map.getBounds(); const span = Math.min(360, b.getEast() - b.getWest())
let bestLng = map.getCenter().lng, best = -1
for (let c = -180; c < 180; c += 5) {
let k = 0
for (const f of fc.features) { let d = f.geometry.coordinates[0] - c; d = ((d + 540) % 360) - 180; if (Math.abs(d) <= span / 2) k++ }
if (k > best) { best = k; bestLng = c }
}
map.jumpTo({ center: [bestLng, (s + n) / 2] })
}
} catch (_) {}
}
map.addLayer({
id: 'ci-pg-dots', type: 'circle', source: 'language-families',
paint: {
'circle-color': PG_DOT,
'circle-radius': ['interpolate', ['linear'], ['zoom'], 0, 2.6, 4, 3.6, 8, 6, 12, 8.5],
'circle-stroke-color': '#ffffff',
'circle-stroke-width': ['interpolate', ['linear'], ['zoom'], 0, 1, 6, 1.4, 12, 1.8],
'circle-opacity': 0.95,
},
})
map.on('click', 'ci-pg-dots', (e) => {
const f = e.features?.[0]
if (!f || !uiStore) return
selected.value = null // the carriers sheet and the group panel never stack
uiStore.selectPeopleGroup(f)
})
map.on('mouseenter', 'ci-pg-dots', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'ci-pg-dots', () => { map.getCanvas().style.cursor = '' })
// Verification handle, same contract as gospel-resources' __grState: the counts the layer holds.
if (typeof window !== 'undefined') {
window.__ciState = () => ({
groups: groups.value.length,
dots: dotCount.value,
hasLayer: !!map.getLayer('ci-pg-dots'),
rendered: map.queryRenderedFeatures({ layers: ['ci-pg-dots'] }).length,
selected: uiStore?.selectedPeopleGroup?.properties?.slug || null,
})
window.__ciMap = map
}
}
// ── UI text: library/i18n/locales/<lc>/connectivity.json, in all eleven locales ─────────
// i18n is OPTIONAL (the gospel-resources pattern): useI18n() throws on a host that never
// installed it, so English from the shipped catalog is the fallback, never a blank or a key.
// Carrier and platform names are data and are not translated.
const enLookup = (key) =>
String(key).replace(/^connectivity\./, '').split('.').reduce((o, k) => (o == null ? o : o[k]), enConnectivity)
const fill = (str, params) => (params ? String(str).replace(/\{(\w+)\}/g, (m, k) => (k in params ? params[k] : m)) : str)
let t = (key, params) => fill(enLookup(key) ?? String(key), params)
try {
const i18n = useI18n()
t = (key, params) => { const v = i18n.t(key, params || {}); return v === key ? fill(enLookup(key) ?? key, params) : v }
} catch (_) { /* no i18n on this host: the English catalog above is the fallback */ }
// Bands, not a continuous ramp: a reader can hold four categories, not a gradient.
const BANDS = [
{ id: 'yes', color: '#10b981' },
{ id: 'partial', color: '#e8c15a' },
{ id: 'no', color: '#b42318' },
{ id: 'unknown', color: '#9aa2ad' },
]
const bandColor = (b) => (BANDS.find((x) => x.id === b) || {}).color || '#9aa2ad'
const byIso = computed(() => {
const m = {}
for (const r of rows.value) m[r.iso3] = r
return m
})
async function load() {
const [data, adm0] = await Promise.all([
fetch(`${assetsBase.value}/tables/internet-by-country.json`).then((r) => r.json()),
// world-adm0 now INCLUDES India, so there is one ADM0 file and no patched
// variant. It used to omit IND (the 100-list splits India into 10 provincial
// zones, so its geometry shipped only as gb-IND-ADM1.geojson) — which left the
// LARGEST field in the dataset, 856 people groups, as a hole on every map. The
// review caught it as "India seems to have no internet coverage". The fix was a
// second file, world-adm0-plus.geojson; that file is gone and India is fetched
// at ADM0 like every other country (data/source-boundaries.mjs).
fetch(`${assetsBase.value}/boundaries/world-adm0.geojson`).then((r) => r.json()),
])
rows.value = data.countries || []
return adm0
}
function paint(map, adm0) {
const m = byIso.value
const feats = (adm0.features || []).map((f) => {
const iso = f.properties?.iso3
const d = m[iso]
return {
type: 'Feature',
properties: {
iso3: iso,
band: d?.band || 'none',
color: d ? bandColor(d.band) : 'rgba(0,0,0,0)',
hasData: !!d,
},
geometry: f.geometry,
}
})
const fc = { type: 'FeatureCollection', features: feats }
if (map.getSource('ci-src')) { map.getSource('ci-src').setData(fc); return }
map.addSource('ci-src', { type: 'geojson', data: fc })
map.addLayer({
id: 'ci-fill', type: 'fill', source: 'ci-src',
paint: { 'fill-color': ['get', 'color'], 'fill-opacity': ['case', ['get', 'hasData'], 0.72, 0] },
})
map.addLayer({
id: 'ci-line', type: 'line', source: 'ci-src',
paint: { 'line-color': 'rgba(255,255,255,0.5)', 'line-width': 0.5 },
})
// TAP, not hover. On a phone hover never fires, so a hover-only map is dead.
map.on('click', 'ci-fill', (e) => {
// A tap on a people-group dot belongs to the dot, not the country under it.
if (map.getLayer('ci-pg-dots') && map.queryRenderedFeatures(e.point, { layers: ['ci-pg-dots'] }).length) return
const iso = e.features?.[0]?.properties?.iso3
if (iso && byIso.value[iso]) { uiStore?.selectPeopleGroup(null); selected.value = byIso.value[iso] }
})
map.on('mouseenter', 'ci-fill', () => { map.getCanvas().style.cursor = 'pointer' })
map.on('mouseleave', 'ci-fill', () => { map.getCanvas().style.cursor = '' })
}
// ── HONOUR THE PROVIDER SWITCH: this profile builds its map RAW ─────────────
// Every bundle that goes through useMapInstance gets the switch for free. This one calls
// `new gl.Map` directly, so it was invisible to it — MEASURED by sweeping all 16 isMap leaves:
// at ?provider=maplibre this leaf still loaded Mapbox and made 18 requests to api.mapbox.com,
// identical to its mapbox run, while 12 of 16 siblings swapped. Same shape and same fix as
// my-upg-100-list; the engine loader is IMPORTED, never a second copy of the ENGINES table.
// No geocoder is mounted here, so the deferred-search-bar rule does not apply to this profile.
const ciProvider = resolveMapProvider(inject('profileConfig', null))
let unmounted = false // set on unmount; onMounted's awaits check it before building the map
onMounted(async () => {
try { await ensureEngineScript(ciProvider) } catch (_) { /* fall through to the notice below */ }
const gl = engineGlobal(ciProvider) || window.mapboxgl
if (unmounted) return
if (!gl || !mapEl.value) { err.value = 'map engine not loaded'; return }
// RTL SHAPING, before the first map exists. This raw map does not go through useMapInstance
// (which installs it), and the basemap draws each place in its OWN script: over the Middle East
// most labels are Arabic, Persian or Hebrew for EVERY viewer. An entry-time call cannot do this,
// because the engine is injected lazily, after the bundle has run.
installRtlTextPlugin(gl)
try {
// The dots are a second layer, not a precondition: if the list fails, the map still shades.
const groupsLoaded = loadGroups().catch((e) => { console.warn('[connectivity-map] people groups:', e?.message || e) })
const adm0 = await load()
// The pane can unmount during the awaits above (a tab switch mid-load). Building a map
// into a container that no longer exists throws; this is the raw-map twin of the
// useMapInstance guard (0c1a91cd), which this profile does not go through.
if (unmounted || !mapEl.value) return
// The engine's stylesheet must be INSIDE this shadow root; a document-level <link> never reaches
// it. Without it Mapbox's CSS canary fires and controls/markers lose their positioning rules.
const css = engineStylesheetUrl(ciProvider)
if (css) await ensureEngineCSS(mapEl.value, css)
if (unmounted || !mapEl.value) return
const map = new gl.Map({
container: mapEl.value,
// Paired through the registry so both engines show the same intent: 'ink' is the
// near-black entry (mapbox dark-v11 / OpenFreeMap dark) and 'paper' the near-white one
// (light-v11 / positron). Falls back to the literal if a name ever stops resolving.
style: resolveNamedStyle(isDark.value ? 'ink' : 'paper', ciProvider).style
|| (isDark.value ? 'mapbox://styles/mapbox/dark-v11' : 'mapbox://styles/mapbox/light-v11'),
accessToken: mapboxToken.value,
center: [42, 18], zoom: 1.3, projection: 'mercator', attributionControl: false,
})
mapRef.value = markRaw(map)
// LICENCE OBLIGATION, not decoration. attributionControl:false above kills
// Mapbox's built-in credit and nothing replaced it — this map was shipping with
// NO attribution at all. It SHADES geoBoundaries polygons, so the geoBoundaries
// credit + link back is required, and gbOpen is mixed-licence with several
// countries carrying a real attribution requirement (India CC BY 2.5 IN, etc).
addMapAttribution(map, { boundaries: true })
map.once('load', async () => {
paint(map, adm0)
selectedPin.initialize()
await groupsLoaded
paintGroups(map)
})
} catch (e) { err.value = String(e.message || e) }
})
onBeforeUnmount(() => {
unmounted = true
selectedPin.cleanup()
uiStore?.selectPeopleGroup(null)
try { mapRef.value?.remove() } catch (_) {}
})
const fmt = (n) => (Number(n) || 0).toLocaleString()
const groupOpen = computed(() => !!uiStore?.selectedPeopleGroup)
useShadowStyles(
'.gm{position:absolute;inset:0;overflow:hidden}' +
'.gm-map{position:absolute;inset:0}' +
// LEGEND: a BOUNDED, left-anchored panel — NOT a full-width strip.
// It used to be `left:8px;right:8px`, which is a strip spanning the whole map
// (MEASURED 1264px of a 1280px map, 98.75%). That collided with the floating
// control rail by construction: MapToolbar is inset-inline-end:3px with 32px
// buttons, so ANY box reaching right:8px intersects it at every viewport. The
// rail's z-index:10 beats this z-index:4, so the buttons stayed clickable and
// the defect read as "the legend underlaps the controls" — reported 3x.
// The fix imitates the shared SemanticTreeLegend, which has always done it
// correctly: `left:8px; width:var(--map-legend-width,380px)`. Bounded and
// left-anchored cannot reach the right rail, so the two can never overlap
// 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).
'.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;' +
"font:12px/1.35 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;box-shadow:0 2px 10px rgba(0,0,0,.16)}" +
// Narrow screens: the rail is still on the right, so the legend must stop short
// of it rather than run under it. 46px = 32px button + 3px inset + clearance.
'@media(max-width:480px){.gm-legend{width:auto;inset-inline-end:46px}}' +
'.is-dark .gm-legend{background:rgba(19,32,25,.95);color:#e5e7eb}' +
'.gm-title{font-weight:700;font-size:12.5px;margin-bottom:5px}' +
'.gm-keys{display:flex;flex-wrap:wrap;gap:5px 12px}' +
'.gm-key{display:flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}' +
'.gm-sw{width:12px;height:12px;border-radius:3px;flex:none}' +
'.gm-caveat{margin-top:5px;font-size:10.5px;opacity:.72;line-height:1.4}' +
// DETAIL: a bottom sheet on a phone, a corner card on desktop.
'.gm-sheet{position:absolute;left:0;right:0;bottom:0;z-index:6;max-height:62%;overflow-y:auto;' +
'background:#fff;color:#0f1419;border-radius:14px 14px 0 0;padding:14px 16px 22px;' +
"font:13px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;box-shadow:0 -6px 26px rgba(0,0,0,.28)}" +
'@media(min-width:700px){.gm-sheet{inset-inline-start:auto;inset-inline-end:14px;bottom:14px;width:330px;border-radius:12px}}' +
'.is-dark .gm-sheet{background:#132019;color:#e5e7eb}' +
'.gm-sh-head{display:flex;align-items:center;gap:10px;margin-bottom:8px}' +
'.gm-sh-name{font-weight:800;font-size:15px}' +
'.gm-ai{display:flex;align-items:flex-start;gap:8px;margin:0 0 10px;padding:7px 9px;border-radius:8px;font-size:11px;line-height:1.35;background:rgba(180,120,0,.12);color:#7a4f00;border:1px solid rgba(180,120,0,.25)}' +
'.is-dark .gm-ai{background:rgba(255,200,80,.10);color:#f0cf88;border-color:rgba(255,200,80,.22)}' +
'.gm-ai-x{margin-left:auto;flex:none;border:0;background:none;cursor:pointer;font-size:15px;line-height:1;color:inherit;opacity:.7;padding:0 2px}' +
'.gm-ai-x:hover{opacity:1}' +
'.gm-x{margin-left:auto;border:1px solid rgba(0,0,0,.16);background:transparent;color:inherit;' +
'font:600 12px inherit;padding:6px 12px;border-radius:8px;cursor:pointer;min-height:34px}' +
'.gm-stat{display:flex;justify-content:space-between;gap:12px;padding:4px 0;' +
'border-bottom:1px solid rgba(0,0,0,.07);font-variant-numeric:tabular-nums}' +
'.is-dark .gm-stat{border-bottom-color:rgba(255,255,255,.09)}' +
'.gm-stat b{font-weight:700}' +
'.gm-list{margin-top:9px}' +
'.gm-list-h{font-weight:700;font-size:12px;margin-bottom:3px}' +
'.gm-list ul{margin:0;padding-left:17px;font-size:12px;line-height:1.5}' +
'.gm-note{margin-top:9px;font-size:11px;line-height:1.5;opacity:.75}' +
'.gm-err{position:absolute;top:50%;left:0;right:0;text-align:center;font-size:13px;opacity:.7}' +
'.gm-dot{width:10px;height:10px;border-radius:50%;flex:none;box-sizing:border-box;border:1.5px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.25)}' +
// The group panel uses Prayer Progress's two wrappers, switched at the same breakpoint.
'.gm-pg-mobile{display:none}' +
'@media(max-width:767px){.gm-pg-mobile{display:block}.gm-pg-desktop{display:none}}',
'doxa-connectivity-map',
)
</script>
<template>
<div class="gm" :class="{ 'is-dark': isDark }">
<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. -->
<MapToolbar v-if="mapRef">
<ZoomInButton :map="mapRef" :is-dark="isDark" />
<ZoomOutButton :map="mapRef" :is-dark="isDark" />
<LocationButton :map="mapRef" :is-dark="isDark" />
<FullscreenButton :map-container="mapEl" :is-dark="isDark" />
<ShareButton :is-dark="isDark" :embed-path="'cities-internet/connectivity-map/index.html'" />
</MapToolbar>
<div v-show="!groupOpen" class="gm-legend">
<div class="gm-title">{{ t('connectivity.title') }}</div>
<div class="gm-keys">
<span v-for="b in BANDS" :key="b.id" class="gm-key">
<i class="gm-sw" :style="{ background: b.color }"></i>{{ t('connectivity.bands.' + b.id) }}
</span>
<span v-if="groups.length" class="gm-key">
<i class="gm-dot" :style="{ background: PG_DOT }"></i>{{ t('connectivity.peopleGroup') }} ({{ fmt(groups.length) }})
</span>
</div>
<div class="gm-caveat">
{{ t('connectivity.caveat') }}
</div>
</div>
<div v-if="err" class="gm-err">{{ err }}</div>
<!-- A tapped people-group dot: the same PeopleGroupDetail panel Prayer Progress opens. -->
<div v-if="groupOpen" class="gm-pg-desktop">
<LegendDesktop legend-type="prayer" popup-action="pray" :initially-collapsed="false" :is-dark="isDark" />
</div>
<div v-if="groupOpen" class="gm-pg-mobile">
<LegendMobile legend-type="prayer" popup-action="pray" :is-dark="isDark" />
</div>
<!-- Tap a country: bottom sheet on mobile, corner card on desktop. -->
<div v-if="selected" class="gm-sheet">
<div class="gm-sh-head">
<span class="gm-sh-name">{{ selected.country }}</span>
<button type="button" class="gm-x" @click="selected = null">{{ t('connectivity.sheet.close') }}</button>
</div>
<div v-if="aiNoticeOpen" class="gm-ai">
<span>{{ t('connectivity.sheet.ai') }}</span>
<button type="button" class="gm-ai-x" @click="aiNoticeOpen = false" :aria-label="t('connectivity.sheet.dismiss')">×</button>
</div>
<div class="gm-stat"><span>{{ t('connectivity.sheet.internetUsers') }}</span><b>{{ selected.internet_pct != null ? selected.internet_pct + '%' : t('connectivity.sheet.unknown') }}</b></div>
<div class="gm-stat"><span>{{ t('connectivity.sheet.measured') }}</span><b>{{ selected.internet_year || '—' }}</b></div>
<div class="gm-stat"><span>{{ t('connectivity.sheet.mobilePer100') }}</span><b>{{ selected.mobile_per_100 ?? '—' }}</b></div>
<div class="gm-stat"><span>{{ t('connectivity.sheet.peopleGroups') }}</span><b>{{ fmt(selected.people_groups) }}</b></div>
<div class="gm-stat"><span>{{ t('connectivity.sheet.topPlatform') }}</span><b>{{ selected.top_platform || t('connectivity.sheet.unknown') }}<template v-if="selected.top_platform_pct"> · {{ selected.top_platform_pct }}%</template></b></div>
<div v-if="selected.carriers && selected.carriers.length" class="gm-list">
<div class="gm-list-h">{{ t('connectivity.sheet.carriers', { n: selected.carrier_count }) }}</div>
<ul><li v-for="(c, i) in selected.carriers" :key="i">{{ c }}</li></ul>
</div>
<div v-if="selected.platforms && selected.platforms.length" class="gm-list">
<div class="gm-list-h">{{ t('connectivity.sheet.socialPlatforms') }}</div>
<ul><li v-for="(p, i) in selected.platforms" :key="i">{{ p.name }} · {{ p.pct }}%</li></ul>
</div>
<p class="gm-note">{{ t('connectivity.sheet.note') }}</p>
</div>
</div>
</template>