'use client'; import * as maplibregl from 'maplibre-gl'; import type { ExpressionSpecification, GeoJSONSource, StyleSpecification } from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useRef, useState } from 'react'; import { feature } from 'topojson-client'; import type { Topology, GeometryCollection } from 'topojson-specification'; import countries110 from 'world-atlas/countries-110m.json'; import { fmt, fmtDelta } from '@/lib/format'; import { greatCircle } from '@/lib/geo'; import { numericToAlpha2 } from '@/lib/iso-numeric-to-alpha2'; import { NEUTRAL, levelWord, pressureColor } from '@/lib/pressure'; import type { Country, Front, Incident, LatencyPair, LevelId, Probe, Region } from '@/lib/types'; import { MODES, lossToScale, type MapMode } from './modes'; const STYLE_URL = 'https://tiles.openfreemap.org/styles/dark'; /** Offline/blocked fallback: our own countries layer on a near-black plane — never a fake basemap. */ const FALLBACK_STYLE: StyleSpecification = { version: 8, name: 'ip-fallback', glyphs: 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf', sources: {}, layers: [{ id: 'bg', type: 'background', paint: { 'background-color': '#070A0F' } }] }; const DASH_FRAMES: number[][] = [ [0, 4, 3], [0.5, 4, 2.5], [1, 4, 2], [1.5, 4, 1.5], [2, 4, 1], [2.5, 4, 0.5], [3, 4, 0], [0, 0.5, 3, 3.5], [0, 1, 3, 3], [0, 1.5, 3, 2.5], [0, 2, 3, 2], [0, 2.5, 3, 1.5], [0, 3, 3, 1], [0, 3.5, 3, 0.5], ]; type FC = GeoJSON.FeatureCollection; const EMPTY_FC: FC = { type: 'FeatureCollection', features: [] }; interface Props { mode: MapMode; regions: Region[] | null; countries: Country[] | null; probes: Probe[] | null; fronts: Front[] | null; incidents: Incident[] | null; matrix: LatencyPair[] | null; } function scopeValue(mode: MapMode, components: Record | undefined, pressure: number, loss?: number | null): number | null { const def = MODES.find((m) => m.id === mode); if (mode === 'pressure' || mode === 'incidents' || mode === 'probes') return pressure; if (mode === 'loss') return lossToScale(loss); if (def?.component) return components?.[def.component] ?? null; return pressure; } export function WorldMap({ mode, regions, countries, probes, fronts, incidents, matrix }: Props) { const el = useRef(null); const mapRef = useRef(null); const [ready, setReady] = useState(false); const [tip, setTip] = useState<{ x: number; y: number; html: string } | null>(null); const router = useRouter(); // Base countries GeoJSON (computed once; ~180 features). const baseCountries = useMemo(() => { const topo = countries110 as unknown as Topology<{ countries: GeometryCollection }>; const fc = feature(topo, topo.objects.countries) as unknown as FC; for (const f of fc.features) { const cc = numericToAlpha2(f.id as string); f.properties = { ...(f.properties ?? {}), cc: cc ?? null }; } return fc; }, []); // Per-region loss from the latency matrix (source view) — used by the Packet-loss mode. const lossByRegion = useMemo(() => { const m = new Map(); for (const p of matrix ?? []) { const e = m.get(p.from) ?? { sum: 0, n: 0 }; e.sum += p.loss_pct; e.n++; m.set(p.from, e); } return new Map([...m].map(([k, v]) => [k, v.sum / v.n])); }, [matrix]); // ---- init map once useEffect(() => { if (!el.current || mapRef.current) return; // MapLibre 6 module worker: served from /public (scripts/copy-maplibre-worker.mjs) — the bundler cannot resolve it. maplibregl.setWorkerUrl('/maplibre/maplibre-gl-worker.mjs'); const map = new maplibregl.Map({ container: el.current, style: STYLE_URL, center: [12, 22], zoom: 1.15, minZoom: 0.7, maxZoom: 6, renderWorldCopies: false, attributionControl: { compact: true }, dragRotate: false, pitchWithRotate: false, touchPitch: false, canvasContextAttributes: { preserveDrawingBuffer: true, antialias: true }, }); map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right'); if (process.env.NODE_ENV !== 'production') (window as unknown as { __ipMap?: maplibregl.Map }).__ipMap = map; let fellBack = false; map.on('error', (e) => { // Style/tiles unreachable (offline, blocked): fall back to our own layers only. const msg = String((e as { error?: { message?: string } }).error?.message ?? ''); if (!fellBack && !map.isStyleLoaded() && /style|fetch|Failed|NetworkError|403|404|5\d\d/i.test(msg)) { fellBack = true; map.setStyle(FALLBACK_STYLE); } }); const onStyle = () => { if (map.getSource('countries')) return; // dim basemap labels/roads: keep only water/land/boundaries/country labels from the vendor style for (const layer of map.getStyle().layers ?? []) { if (/road|transit|building|poi|housenumber|aeroway|rail|ferry|path|place_(?!country|continent)|water_name|waterway/.test(layer.id)) { try { map.setLayoutProperty(layer.id, 'visibility', 'none'); } catch { /* ignore */ } } } map.addSource('countries', { type: 'geojson', data: EMPTY_FC }); map.addSource('regions', { type: 'geojson', data: EMPTY_FC }); map.addSource('probes', { type: 'geojson', data: EMPTY_FC }); map.addSource('fronts', { type: 'geojson', data: EMPTY_FC }); map.addSource('arcs', { type: 'geojson', data: EMPTY_FC }); map.addSource('incidents', { type: 'geojson', data: EMPTY_FC }); const firstSymbol = (map.getStyle().layers ?? []).find((l) => l.type === 'symbol')?.id; map.addLayer({ id: 'countries-fill', type: 'fill', source: 'countries', paint: { 'fill-color': ['get', 'color'] as ExpressionSpecification, 'fill-opacity': ['case', ['boolean', ['get', 'observed'], false], 0.42, 0.9] as ExpressionSpecification } }, firstSymbol); map.addLayer({ id: 'countries-line', type: 'line', source: 'countries', paint: { 'line-color': '#1B2430', 'line-width': 0.5 } }, firstSymbol); map.addLayer({ id: 'arcs', type: 'line', source: 'arcs', layout: { 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'] as ExpressionSpecification, 'line-width': ['get', 'width'] as ExpressionSpecification, 'line-opacity': 0.55 } }); map.addLayer({ id: 'fronts-glow', type: 'line', source: 'fronts', layout: { 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'] as ExpressionSpecification, 'line-width': ['+', ['get', 'width'], 6] as ExpressionSpecification, 'line-opacity': 0.12, 'line-blur': 4 } }); map.addLayer({ id: 'fronts', type: 'line', source: 'fronts', layout: { 'line-cap': 'round' }, paint: { 'line-color': ['get', 'color'] as ExpressionSpecification, 'line-width': ['get', 'width'] as ExpressionSpecification, 'line-opacity': 0.9, 'line-dasharray': [0, 4, 3] } }); map.addLayer({ id: 'fronts-arrow', type: 'symbol', source: 'fronts', layout: { 'symbol-placement': 'line', 'symbol-spacing': 140, 'text-field': '›', 'text-size': 16, 'text-font': ['Noto Sans Regular'], 'text-keep-upright': false, 'text-allow-overlap': true }, paint: { 'text-color': ['get', 'color'] as ExpressionSpecification } }); map.addLayer({ id: 'incidents-ring', type: 'circle', source: 'incidents', paint: { 'circle-radius': ['get', 'radius'] as ExpressionSpecification, 'circle-color': 'rgba(0,0,0,0)', 'circle-stroke-color': ['get', 'color'] as ExpressionSpecification, 'circle-stroke-width': 1.5, 'circle-stroke-opacity': 0.9 } }); map.addLayer({ id: 'incidents-dot', type: 'circle', source: 'incidents', paint: { 'circle-radius': 3, 'circle-color': ['get', 'color'] as ExpressionSpecification } }); map.addLayer({ id: 'regions-dot', type: 'circle', source: 'regions', paint: { 'circle-radius': ['interpolate', ['linear'], ['zoom'], 0.7, 9, 4, 16] as ExpressionSpecification, 'circle-color': ['get', 'color'] as ExpressionSpecification, 'circle-opacity': 0.95, 'circle-stroke-color': '#070A0F', 'circle-stroke-width': 1.5 } }); map.addLayer({ id: 'regions-label', type: 'symbol', source: 'regions', layout: { 'text-field': ['get', 'label'] as ExpressionSpecification, 'text-size': 10, 'text-font': ['Noto Sans Bold'], 'text-allow-overlap': true, 'text-ignore-placement': true }, paint: { 'text-color': '#070A0F' } }); map.addLayer({ id: 'regions-name', type: 'symbol', source: 'regions', minzoom: 2, layout: { 'text-field': ['get', 'name'] as ExpressionSpecification, 'text-size': 10.5, 'text-offset': [0, 1.6], 'text-anchor': 'top', 'text-font': ['Noto Sans Regular'] }, paint: { 'text-color': '#8B98A5', 'text-halo-color': '#070A0F', 'text-halo-width': 1 } }); map.addLayer({ id: 'probes', type: 'circle', source: 'probes', paint: { 'circle-radius': ['get', 'radius'] as ExpressionSpecification, 'circle-color': ['get', 'color'] as ExpressionSpecification, 'circle-opacity': 0.9, 'circle-stroke-color': '#070A0F', 'circle-stroke-width': 1 } }); setReady(true); }; map.on('style.load', onStyle); mapRef.current = map; return () => { map.remove(); mapRef.current = null; setReady(false); }; }, []); // ---- data → sources (recomputed only when data/mode change) useEffect(() => { const map = mapRef.current; if (!map || !ready) return; const isProbes = mode === 'probes'; const isIncidents = mode === 'incidents'; const byCc = new Map((countries ?? []).map((c) => [c.cc, c])); const countriesFc: FC = { type: 'FeatureCollection', features: baseCountries.features.map((f) => { const cc = f.properties?.cc as string | null; const c = cc ? byCc.get(cc) : undefined; const v = c ? scopeValue(mode, c.components as Record, c.pressure, lossByRegion.get(c.region) ?? null) : null; const observed = Boolean(c) && v != null && !isProbes && !isIncidents; return { ...f, properties: { ...f.properties, observed, color: observed ? pressureColor(v!) : NEUTRAL, value: v, pressure: c?.pressure ?? null, level: c?.level ?? null, delta: c?.delta_1h ?? null, name: c?.name ?? null, probes: c?.probes ?? 0, targets: c?.targets ?? 0 } }; }), }; (map.getSource('countries') as GeoJSONSource | undefined)?.setData(countriesFc); const regionsFc: FC = { type: 'FeatureCollection', features: (regions ?? []) .filter((r) => r.id !== 'global' && !isProbes && !isIncidents) .map((r) => { const v = scopeValue(mode, r.components as Record, r.pressure, lossByRegion.get(r.id) ?? null); return { type: 'Feature', geometry: { type: 'Point', coordinates: [r.lon, r.lat] }, properties: { id: r.id, name: r.name, label: v == null ? '·' : fmt(v, 0), color: v == null ? '#3A4756' : pressureColor(v), value: v, pressure: r.pressure, delta: r.delta_1h, level: r.level, probes: r.probes, targets: r.targets, incidents: r.incidents } }; }), }; (map.getSource('regions') as GeoJSONSource | undefined)?.setData(regionsFc); const probesFc: FC = { type: 'FeatureCollection', features: (probes ?? []).map((p) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [p.lon, p.lat] }, properties: { id: p.probe_id, name: p.name, status: p.status, radius: isProbes ? 4 + Math.min(6, p.measurements_1h / 1200) : 3.5, color: p.status === 'online' ? '#7FB77E' : p.status === 'stale' ? '#E9C46A' : p.status === 'offline' ? '#D62828' : '#8B98A5', provider: p.provider, asn: p.asn }, })), }; (map.getSource('probes') as GeoJSONSource | undefined)?.setData(probesFc); const frontsFc: FC = { type: 'FeatureCollection', features: isProbes || isIncidents ? [] : (fronts ?? []).map((f) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates: greatCircle([f.from.lon, f.from.lat], [f.to.lon, f.to.lat], 64) }, properties: { id: f.id, name: f.name, status: f.status, color: pressureColor(f.intensity), width: 1.5 + f.confidence * 2, intensity: f.intensity } })), }; (map.getSource('fronts') as GeoJSONSource | undefined)?.setData(frontsFc); // Probe-network mode: inter-region latency matrix as arcs (thickness = |z|) const centroid = new Map((regions ?? []).map((r) => [r.id, [r.lon, r.lat] as [number, number]])); const arcsFc: FC = { type: 'FeatureCollection', features: isProbes ? (matrix ?? []) .filter((m) => m.from !== m.to && centroid.has(m.from) && centroid.has(m.to)) .map((m) => ({ type: 'Feature', geometry: { type: 'LineString', coordinates: greatCircle(centroid.get(m.from)!, centroid.get(m.to)!, 48) }, properties: { color: m.z >= 3 ? '#E76F51' : m.z >= 1.5 ? '#E9C46A' : '#3A4756', width: 0.6 + Math.min(5, Math.abs(m.z)) * 0.8, from: m.from, to: m.to, rtt: m.rtt_ms, z: m.z } })) : [], }; (map.getSource('arcs') as GeoJSONSource | undefined)?.setData(arcsFc); const incFc: FC = { type: 'FeatureCollection', features: isIncidents ? (incidents ?? []) .map((i) => { const c = i.scope_type === 'region' ? centroid.get(i.scope_id ?? '') : i.scope_type === 'country' ? (byCc.get((i.scope_id ?? '').toUpperCase()) ? [byCc.get((i.scope_id ?? '').toUpperCase())!.lon, byCc.get((i.scope_id ?? '').toUpperCase())!.lat] : undefined) : undefined; if (!c) return null; return { type: 'Feature' as const, geometry: { type: 'Point' as const, coordinates: c }, properties: { slug: i.slug, title: i.title, status: i.status, color: pressureColor(i.current_pressure), radius: 8 + Math.min(20, i.affected_targets / 4), pressure: i.current_pressure } }; }) .filter((f): f is NonNullable => f != null) : [], }; (map.getSource('incidents') as GeoJSONSource | undefined)?.setData(incFc); }, [ready, mode, regions, countries, probes, fronts, incidents, matrix, baseCountries, lossByRegion]); // ---- animated dash offset ONLY while a front is developing/active (real state, not decoration) useEffect(() => { const map = mapRef.current; if (!map || !ready) return; const moving = mode !== 'probes' && mode !== 'incidents' && (fronts ?? []).some((f) => f.status === 'developing' || f.status === 'active'); if (!moving || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { try { map.setPaintProperty('fronts', 'line-dasharray', [0, 4, 3]); } catch { /* layer missing */ } return; } let step = 0; let raf = 0; let last = 0; const tick = (t: number) => { if (t - last > 70) { last = t; step = (step + 1) % DASH_FRAMES.length; try { map.setPaintProperty('fronts', 'line-dasharray', DASH_FRAMES[step]!); } catch { /* layer missing */ } } raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [ready, fronts, mode]); // ---- interactions useEffect(() => { const map = mapRef.current; if (!map || !ready) return; const layers = ['regions-dot', 'probes', 'incidents-dot', 'incidents-ring', 'countries-fill', 'arcs']; const onMove = (e: maplibregl.MapMouseEvent) => { const feats = map.queryRenderedFeatures(e.point, { layers: layers.filter((l) => map.getLayer(l)) }); const f = feats[0]; if (!f) { setTip(null); map.getCanvas().style.cursor = ''; return; } const p = f.properties as Record; let html = ''; if (f.layer.id === 'regions-dot') html = `${p.name}
pressure ${fmt(p.pressure as number)} ${levelWord(p.level as LevelId)} · Δ1h ${fmtDelta(p.delta as number)}
${p.probes} probes · ${p.targets} targets · ${p.incidents} incidents${mode !== 'pressure' ? `
${MODES.find((m) => m.id === mode)?.label}: ${p.value == null ? 'not observed' : fmt(p.value as number)}` : ''}`; else if (f.layer.id === 'probes') html = `${p.id} · ${p.status}
${p.name}
${p.provider} · AS${p.asn}`; else if (f.layer.id.startsWith('incidents')) html = `${p.title}
${p.status} · pressure ${fmt(p.pressure as number)}`; else if (f.layer.id === 'arcs') html = `${p.from} → ${p.to}
RTT ${fmt(p.rtt as number)} ms · z ${fmt(p.z as number)}`; else if (f.layer.id === 'countries-fill') { if (!p.name) { setTip(null); map.getCanvas().style.cursor = ''; return; } html = `${p.name} (${p.cc})
pressure ${fmt(p.pressure as number)} ${levelWord(p.level as LevelId)} · Δ1h ${fmtDelta(p.delta as number)}
${p.probes} probes · ${p.targets} targets${mode !== 'pressure' && mode !== 'probes' && mode !== 'incidents' ? `
${MODES.find((m) => m.id === mode)?.label}: ${p.value == null ? 'not observed' : fmt(p.value as number)}` : ''}`; } map.getCanvas().style.cursor = 'pointer'; const w = map.getContainer().clientWidth; setTip({ x: Math.min(e.point.x + 12, w - 270), y: e.point.y + 12, html }); }; const onLeave = () => setTip(null); const onClick = (e: maplibregl.MapMouseEvent) => { const feats = map.queryRenderedFeatures(e.point, { layers: ['regions-dot', 'incidents-dot', 'incidents-ring', 'countries-fill'].filter((l) => map.getLayer(l)) }); const f = feats[0]; if (!f) return; const p = f.properties as Record; if (f.layer.id === 'regions-dot') router.push(`/internet/${p.id}`); else if (f.layer.id.startsWith('incidents')) router.push(`/event/${p.slug}`); else if (f.layer.id === 'countries-fill' && p.name && p.cc) router.push(`/country/${String(p.cc).toLowerCase()}`); }; map.on('mousemove', onMove); map.on('mouseout', onLeave); map.on('click', onClick); return () => { map.off('mousemove', onMove); map.off('mouseout', onLeave); map.off('click', onClick); }; }, [ready, mode, router]); return (
{tip && (
)}
); }