249 lines
13 KiB
Vue
249 lines
13 KiB
Vue
<script setup>
|
||
/**
|
||
* connectivity-map.vue — THE CITIES-WITH-INTERNET GLOBAL MAP.
|
||
*
|
||
* 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 horizontal strip, not a floating panel that eats the screen
|
||
* - 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'
|
||
|
||
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(() => String(profileConfig.value?.assetsBase || './assets').replace(/\/+$/, ''))
|
||
|
||
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 = '' })
|
||
}
|
||
|
||
onMounted(async () => {
|
||
const gl = window.mapboxgl
|
||
if (!gl || !mapEl.value) { err.value = 'mapbox-gl not loaded'; return }
|
||
try {
|
||
const adm0 = await load()
|
||
const map = new gl.Map({
|
||
container: mapEl.value,
|
||
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 horizontal strip pinned to the top, so it never eats a phone screen
|
||
// the way a floating panel does.
|
||
'.gm-legend{position:absolute;top:8px;left:8px;right:8px;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)}" +
|
||
'.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>
|