doxa-maps/plugin-designs/cities-internet/connectivity-map.vue

249 lines
13 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.
*
* 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 &amp; 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>