SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
18.4 KB · 329 lines tsx
Raw Blame History
1'use client';23import * as maplibregl from 'maplibre-gl';4import type { ExpressionSpecification, GeoJSONSource, StyleSpecification } from 'maplibre-gl';5import 'maplibre-gl/dist/maplibre-gl.css';6import { useRouter } from 'next/navigation';7import { useEffect, useMemo, useRef, useState } from 'react';8import { feature } from 'topojson-client';9import type { Topology, GeometryCollection } from 'topojson-specification';10import countries110 from 'world-atlas/countries-110m.json';11import { fmt, fmtDelta } from '@/lib/format';12import { greatCircle } from '@/lib/geo';13import { numericToAlpha2 } from '@/lib/iso-numeric-to-alpha2';14import { NEUTRAL, levelWord, pressureColor } from '@/lib/pressure';15import type { Country, Front, Incident, LatencyPair, LevelId, Probe, Region } from '@/lib/types';16import { MODES, lossToScale, type MapMode } from './modes';1718const STYLE_URL = 'https://tiles.openfreemap.org/styles/dark';19/** Offline/blocked fallback: our own countries layer on a near-black plane — never a fake basemap. */20const 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' } }] };21const DASH_FRAMES: number[][] = [22  [0, 4, 3],23  [0.5, 4, 2.5],24  [1, 4, 2],25  [1.5, 4, 1.5],26  [2, 4, 1],27  [2.5, 4, 0.5],28  [3, 4, 0],29  [0, 0.5, 3, 3.5],30  [0, 1, 3, 3],31  [0, 1.5, 3, 2.5],32  [0, 2, 3, 2],33  [0, 2.5, 3, 1.5],34  [0, 3, 3, 1],35  [0, 3.5, 3, 0.5],36];3738type FC = GeoJSON.FeatureCollection;39const EMPTY_FC: FC = { type: 'FeatureCollection', features: [] };4041interface Props {42  mode: MapMode;43  regions: Region[] | null;44  countries: Country[] | null;45  probes: Probe[] | null;46  fronts: Front[] | null;47  incidents: Incident[] | null;48  matrix: LatencyPair[] | null;49}5051function scopeValue(mode: MapMode, components: Record<string, number | null | undefined> | undefined, pressure: number, loss?: number | null): number | null {52  const def = MODES.find((m) => m.id === mode);53  if (mode === 'pressure' || mode === 'incidents' || mode === 'probes') return pressure;54  if (mode === 'loss') return lossToScale(loss);55  if (def?.component) return components?.[def.component] ?? null;56  return pressure;57}5859export function WorldMap({ mode, regions, countries, probes, fronts, incidents, matrix }: Props) {60  const el = useRef<HTMLDivElement>(null);61  const mapRef = useRef<maplibregl.Map | null>(null);62  const [ready, setReady] = useState(false);63  const [tip, setTip] = useState<{ x: number; y: number; html: string } | null>(null);64  const router = useRouter();6566  // Base countries GeoJSON (computed once; ~180 features).67  const baseCountries = useMemo<FC>(() => {68    const topo = countries110 as unknown as Topology<{ countries: GeometryCollection }>;69    const fc = feature(topo, topo.objects.countries) as unknown as FC;70    for (const f of fc.features) {71      const cc = numericToAlpha2(f.id as string);72      f.properties = { ...(f.properties ?? {}), cc: cc ?? null };73    }74    return fc;75  }, []);7677  // Per-region loss from the latency matrix (source view) — used by the Packet-loss mode.78  const lossByRegion = useMemo(() => {79    const m = new Map<string, { sum: number; n: number }>();80    for (const p of matrix ?? []) {81      const e = m.get(p.from) ?? { sum: 0, n: 0 };82      e.sum += p.loss_pct;83      e.n++;84      m.set(p.from, e);85    }86    return new Map([...m].map(([k, v]) => [k, v.sum / v.n]));87  }, [matrix]);8889  // ---- init map once90  useEffect(() => {91    if (!el.current || mapRef.current) return;92    // MapLibre 6 module worker: served from /public (scripts/copy-maplibre-worker.mjs) — the bundler cannot resolve it.93    maplibregl.setWorkerUrl('/maplibre/maplibre-gl-worker.mjs');94    const map = new maplibregl.Map({95      container: el.current,96      style: STYLE_URL,97      center: [12, 22],98      zoom: 1.15,99      minZoom: 0.7,100      maxZoom: 6,101      renderWorldCopies: false,102      attributionControl: { compact: true },103      dragRotate: false,104      pitchWithRotate: false,105      touchPitch: false,106      canvasContextAttributes: { preserveDrawingBuffer: true, antialias: true },107    });108    map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');109    if (process.env.NODE_ENV !== 'production') (window as unknown as { __ipMap?: maplibregl.Map }).__ipMap = map;110    let fellBack = false;111    map.on('error', (e) => {112      // Style/tiles unreachable (offline, blocked): fall back to our own layers only.113      const msg = String((e as { error?: { message?: string } }).error?.message ?? '');114      if (!fellBack && !map.isStyleLoaded() && /style|fetch|Failed|NetworkError|403|404|5\d\d/i.test(msg)) {115        fellBack = true;116        map.setStyle(FALLBACK_STYLE);117      }118    });119    const onStyle = () => {120      if (map.getSource('countries')) return;121      // dim basemap labels/roads: keep only water/land/boundaries/country labels from the vendor style122      for (const layer of map.getStyle().layers ?? []) {123        if (/road|transit|building|poi|housenumber|aeroway|rail|ferry|path|place_(?!country|continent)|water_name|waterway/.test(layer.id)) {124          try {125            map.setLayoutProperty(layer.id, 'visibility', 'none');126          } catch {127            /* ignore */128          }129        }130      }131      map.addSource('countries', { type: 'geojson', data: EMPTY_FC });132      map.addSource('regions', { type: 'geojson', data: EMPTY_FC });133      map.addSource('probes', { type: 'geojson', data: EMPTY_FC });134      map.addSource('fronts', { type: 'geojson', data: EMPTY_FC });135      map.addSource('arcs', { type: 'geojson', data: EMPTY_FC });136      map.addSource('incidents', { type: 'geojson', data: EMPTY_FC });137138      const firstSymbol = (map.getStyle().layers ?? []).find((l) => l.type === 'symbol')?.id;139      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);140      map.addLayer({ id: 'countries-line', type: 'line', source: 'countries', paint: { 'line-color': '#1B2430', 'line-width': 0.5 } }, firstSymbol);141      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 } });142      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 } });143      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] } });144      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 } });145      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 } });146      map.addLayer({ id: 'incidents-dot', type: 'circle', source: 'incidents', paint: { 'circle-radius': 3, 'circle-color': ['get', 'color'] as ExpressionSpecification } });147      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 } });148      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' } });149      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 } });150      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 } });151      setReady(true);152    };153    map.on('style.load', onStyle);154    mapRef.current = map;155    return () => {156      map.remove();157      mapRef.current = null;158      setReady(false);159    };160  }, []);161162  // ---- data → sources (recomputed only when data/mode change)163  useEffect(() => {164    const map = mapRef.current;165    if (!map || !ready) return;166    const isProbes = mode === 'probes';167    const isIncidents = mode === 'incidents';168    const byCc = new Map((countries ?? []).map((c) => [c.cc, c]));169    const countriesFc: FC = {170      type: 'FeatureCollection',171      features: baseCountries.features.map((f) => {172        const cc = f.properties?.cc as string | null;173        const c = cc ? byCc.get(cc) : undefined;174        const v = c ? scopeValue(mode, c.components as Record<string, number | null>, c.pressure, lossByRegion.get(c.region) ?? null) : null;175        const observed = Boolean(c) && v != null && !isProbes && !isIncidents;176        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 } };177      }),178    };179    (map.getSource('countries') as GeoJSONSource | undefined)?.setData(countriesFc);180181    const regionsFc: FC = {182      type: 'FeatureCollection',183      features: (regions ?? [])184        .filter((r) => r.id !== 'global' && !isProbes && !isIncidents)185        .map((r) => {186          const v = scopeValue(mode, r.components as Record<string, number | null>, r.pressure, lossByRegion.get(r.id) ?? null);187          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 } };188        }),189    };190    (map.getSource('regions') as GeoJSONSource | undefined)?.setData(regionsFc);191192    const probesFc: FC = {193      type: 'FeatureCollection',194      features: (probes ?? []).map((p) => ({195        type: 'Feature',196        geometry: { type: 'Point', coordinates: [p.lon, p.lat] },197        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 },198      })),199    };200    (map.getSource('probes') as GeoJSONSource | undefined)?.setData(probesFc);201202    const frontsFc: FC = {203      type: 'FeatureCollection',204      features: isProbes || isIncidents205        ? []206        : (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 } })),207    };208    (map.getSource('fronts') as GeoJSONSource | undefined)?.setData(frontsFc);209210    // Probe-network mode: inter-region latency matrix as arcs (thickness = |z|)211    const centroid = new Map((regions ?? []).map((r) => [r.id, [r.lon, r.lat] as [number, number]]));212    const arcsFc: FC = {213      type: 'FeatureCollection',214      features: isProbes215        ? (matrix ?? [])216            .filter((m) => m.from !== m.to && centroid.has(m.from) && centroid.has(m.to))217            .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 } }))218        : [],219    };220    (map.getSource('arcs') as GeoJSONSource | undefined)?.setData(arcsFc);221222    const incFc: FC = {223      type: 'FeatureCollection',224      features: isIncidents225        ? (incidents ?? [])226            .map((i) => {227              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;228              if (!c) return null;229              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 } };230            })231            .filter((f): f is NonNullable<typeof f> => f != null)232        : [],233    };234    (map.getSource('incidents') as GeoJSONSource | undefined)?.setData(incFc);235  }, [ready, mode, regions, countries, probes, fronts, incidents, matrix, baseCountries, lossByRegion]);236237  // ---- animated dash offset ONLY while a front is developing/active (real state, not decoration)238  useEffect(() => {239    const map = mapRef.current;240    if (!map || !ready) return;241    const moving = mode !== 'probes' && mode !== 'incidents' && (fronts ?? []).some((f) => f.status === 'developing' || f.status === 'active');242    if (!moving || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) {243      try {244        map.setPaintProperty('fronts', 'line-dasharray', [0, 4, 3]);245      } catch {246        /* layer missing */247      }248      return;249    }250    let step = 0;251    let raf = 0;252    let last = 0;253    const tick = (t: number) => {254      if (t - last > 70) {255        last = t;256        step = (step + 1) % DASH_FRAMES.length;257        try {258          map.setPaintProperty('fronts', 'line-dasharray', DASH_FRAMES[step]!);259        } catch {260          /* layer missing */261        }262      }263      raf = requestAnimationFrame(tick);264    };265    raf = requestAnimationFrame(tick);266    return () => cancelAnimationFrame(raf);267  }, [ready, fronts, mode]);268269  // ---- interactions270  useEffect(() => {271    const map = mapRef.current;272    if (!map || !ready) return;273    const layers = ['regions-dot', 'probes', 'incidents-dot', 'incidents-ring', 'countries-fill', 'arcs'];274    const onMove = (e: maplibregl.MapMouseEvent) => {275      const feats = map.queryRenderedFeatures(e.point, { layers: layers.filter((l) => map.getLayer(l)) });276      const f = feats[0];277      if (!f) {278        setTip(null);279        map.getCanvas().style.cursor = '';280        return;281      }282      const p = f.properties as Record<string, unknown>;283      let html = '';284      if (f.layer.id === 'regions-dot') html = `<b>${p.name}</b><br>pressure <b>${fmt(p.pressure as number)}</b> ${levelWord(p.level as LevelId)} · Δ1h ${fmtDelta(p.delta as number)}<br>${p.probes} probes · ${p.targets} targets · ${p.incidents} incidents${mode !== 'pressure' ? `<br>${MODES.find((m) => m.id === mode)?.label}: <b>${p.value == null ? 'not observed' : fmt(p.value as number)}</b>` : ''}`;285      else if (f.layer.id === 'probes') html = `<b>${p.id}</b> · ${p.status}<br>${p.name}<br>${p.provider} · AS${p.asn}`;286      else if (f.layer.id.startsWith('incidents')) html = `<b>${p.title}</b><br>${p.status} · pressure ${fmt(p.pressure as number)}`;287      else if (f.layer.id === 'arcs') html = `<b>${p.from} → ${p.to}</b><br>RTT ${fmt(p.rtt as number)} ms · z ${fmt(p.z as number)}`;288      else if (f.layer.id === 'countries-fill') {289        if (!p.name) {290          setTip(null);291          map.getCanvas().style.cursor = '';292          return;293        }294        html = `<b>${p.name}</b> (${p.cc})<br>pressure <b>${fmt(p.pressure as number)}</b> ${levelWord(p.level as LevelId)} · Δ1h ${fmtDelta(p.delta as number)}<br>${p.probes} probes · ${p.targets} targets${mode !== 'pressure' && mode !== 'probes' && mode !== 'incidents' ? `<br>${MODES.find((m) => m.id === mode)?.label}: <b>${p.value == null ? 'not observed' : fmt(p.value as number)}</b>` : ''}`;295      }296      map.getCanvas().style.cursor = 'pointer';297      const w = map.getContainer().clientWidth;298      setTip({ x: Math.min(e.point.x + 12, w - 270), y: e.point.y + 12, html });299    };300    const onLeave = () => setTip(null);301    const onClick = (e: maplibregl.MapMouseEvent) => {302      const feats = map.queryRenderedFeatures(e.point, { layers: ['regions-dot', 'incidents-dot', 'incidents-ring', 'countries-fill'].filter((l) => map.getLayer(l)) });303      const f = feats[0];304      if (!f) return;305      const p = f.properties as Record<string, unknown>;306      if (f.layer.id === 'regions-dot') router.push(`/internet/${p.id}`);307      else if (f.layer.id.startsWith('incidents')) router.push(`/event/${p.slug}`);308      else if (f.layer.id === 'countries-fill' && p.name && p.cc) router.push(`/country/${String(p.cc).toLowerCase()}`);309    };310    map.on('mousemove', onMove);311    map.on('mouseout', onLeave);312    map.on('click', onClick);313    return () => {314      map.off('mousemove', onMove);315      map.off('mouseout', onLeave);316      map.off('click', onClick);317    };318  }, [ready, mode, router]);319320  return (321    <div className="relative h-full w-full">322      <div ref={el} className="h-full w-full" aria-label="World map of Internet pressure" role="application" />323      {tip && (324        <div className="pointer-events-none absolute z-10 max-w-[260px] rounded-[3px] border border-line bg-panel px-2.5 py-1.5 text-[11.5px] leading-snug text-ink" style={{ left: tip.x, top: tip.y }} dangerouslySetInnerHTML={{ __html: tip.html }} />325      )}326    </div>327  );328}329