Found by verifying the GOAL rather than the checklist. The rollout table only ever listed plugins with a search bar, so a sweep of all 16 isMap leaves was run under both providers: 12 of 16 swapped, and this leaf made 18 requests to api.mapbox.com under ?provider=maplibre — identical to its mapbox run, because it builds its map RAW (new gl.Map on window.mapboxgl) and never reaches the resolution useMapInstance does for everyone else. Same shape and same fix as my-upg-100-list, minus the token gate this file never had: the engine comes from the shared ensureEngineScript/engineGlobal rather than a hardcoded global, and the two hardcoded mapbox:// style URLs are paired through the registry — 'ink' (dark-v11 / OpenFreeMap dark) and 'paper' (light-v11 / positron) — so both engines show one intent. The engine loader is imported, never re-declared; a second ENGINES table in a profile is how two copies of one fact drift apart. No geocoder is mounted here, so the deferred-search-bar rule does not apply and nothing about the search behaviour changed. MEASURED on the built tree: mapbox canvas=1 18 mapbox requests (unchanged) maplibre canvas=1 4 mapbox requests maplibregl loaded Screenshot read: positron basemap, connectivity choropleth, legend and controls all intact. Also recorded from the sweep: poster-wall and regions-list draw NO canvas on either engine — they are the already-filed isMap misclassification, not gaps, and must not be "fixed". 7/7 pre, 18/18 post.
284 lines
16 KiB
Vue
284 lines
16 KiB
Vue
<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 { 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 } from '@map/composables/useMapInstance.js'
|
||
import { resolveNamedStyle } from '@map/styles/registry.js'
|
||
|
||
const profileConfig = inject('profileConfig', ref(null))
|
||
const isDark = computed(() => profileConfig.value?.isDark === true)
|
||
const mapboxToken = computed(() => profileConfig.value?.tk || window.DOXA_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)
|
||
|
||
// Bands, not a continuous ramp: a reader can hold four categories, not a gradient.
|
||
const BANDS = [
|
||
{ id: 'yes', label: 'connected 60%+', color: '#10b981' },
|
||
{ id: 'partial', label: 'partial 25–59%', color: '#e8c15a' },
|
||
{ id: 'no', label: 'low, under 25%', color: '#b42318' },
|
||
{ id: 'unknown', label: 'not measured', 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) => {
|
||
const iso = e.features?.[0]?.properties?.iso3
|
||
if (iso && byIso.value[iso]) 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))
|
||
|
||
onMounted(async () => {
|
||
try { await ensureEngineScript(ciProvider) } catch (_) { /* fall through to the notice below */ }
|
||
const gl = engineGlobal(ciProvider) || window.mapboxgl
|
||
if (!gl || !mapEl.value) { err.value = 'map engine not loaded'; return }
|
||
try {
|
||
const adm0 = await load()
|
||
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', () => paint(map, adm0))
|
||
} catch (e) { err.value = String(e.message || e) }
|
||
})
|
||
onBeforeUnmount(() => { try { mapRef.value?.remove() } catch (_) {} })
|
||
|
||
const fmt = (n) => (Number(n) || 0).toLocaleString()
|
||
|
||
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.
|
||
'.gm-legend{position:absolute;top:8px;left: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;right: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{left:auto;right: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}',
|
||
'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 class="gm-legend">
|
||
<div class="gm-title">Internet access where unengaged people groups live</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>{{ b.label }}
|
||
</span>
|
||
</div>
|
||
<div class="gm-caveat">
|
||
NATIONAL figures (World Bank). Per-city connectivity is not publicly available — do not read a city off a country.
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="err" class="gm-err">{{ err }}</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">Close</button>
|
||
</div>
|
||
<div v-if="aiNoticeOpen" class="gm-ai">
|
||
<span>Data was found by AI deep research — it is subject to false information.</span>
|
||
<button type="button" class="gm-ai-x" @click="aiNoticeOpen = false" aria-label="Dismiss">×</button>
|
||
</div>
|
||
<div class="gm-stat"><span>Internet users</span><b>{{ selected.internet_pct != null ? selected.internet_pct + '%' : 'unknown' }}</b></div>
|
||
<div class="gm-stat"><span>Measured</span><b>{{ selected.internet_year || '—' }}</b></div>
|
||
<div class="gm-stat"><span>Mobile per 100</span><b>{{ selected.mobile_per_100 ?? '—' }}</b></div>
|
||
<div class="gm-stat"><span>People groups</span><b>{{ fmt(selected.people_groups) }}</b></div>
|
||
<div class="gm-stat"><span>Top platform</span><b>{{ selected.top_platform || '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">Carriers & ISPs ({{ 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">Social platforms</div>
|
||
<ul><li v-for="(p, i) in selected.platforms" :key="i">{{ p.name }} · {{ p.pct }}%</li></ul>
|
||
</div>
|
||
<p class="gm-note">
|
||
National figures, not city measurements. Carrier names are ASN registry holders — that set includes
|
||
banks and universities as well as ISPs, so the carrier flag is a name heuristic, not a verified classification.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|