Some checks failed
Build and Push Docker Images (Trusted) / build-and-push (., docker/caddy/Dockerfile, caddy) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/l4-port-manager/Dockerfile, l4-port-manager) (push) Has been cancelled
Build and Push Docker Images (Trusted) / build-and-push (., docker/web/Dockerfile, web) (push) Has been cancelled
Tests / test (push) Has been cancelled
364 lines
16 KiB
TypeScript
Executable File
364 lines
16 KiB
TypeScript
Executable File
'use client';
|
|
|
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import MapGL, { Layer, Popup, Source, type MapLayerMouseEvent } from 'react-map-gl/maplibre';
|
|
import { feature } from 'topojson-client';
|
|
import type { Topology, GeometryCollection } from 'topojson-specification';
|
|
import type { ExpressionSpecification, FillLayerSpecification, LineLayerSpecification } from 'maplibre-gl';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
|
|
const A2N: Record<string, string> = {
|
|
AF:'4',AL:'8',DZ:'12',AD:'20',AO:'24',AG:'28',AR:'32',AM:'51',
|
|
AU:'36',AT:'40',AZ:'31',BS:'44',BH:'48',BD:'50',BB:'52',BY:'112',
|
|
BE:'56',BZ:'84',BJ:'204',BT:'64',BO:'68',BA:'70',BW:'72',BR:'76',
|
|
BN:'96',BG:'100',BF:'854',BI:'108',CV:'132',KH:'116',CM:'120',
|
|
CA:'124',CF:'140',TD:'148',CL:'152',CN:'156',CO:'170',KM:'174',
|
|
CG:'178',CD:'180',CR:'188',CI:'384',HR:'191',CU:'192',CY:'196',
|
|
CZ:'203',DK:'208',DJ:'262',DM:'212',DO:'214',EC:'218',EG:'818',
|
|
SV:'222',GQ:'226',ER:'232',EE:'233',SZ:'748',ET:'231',FJ:'242',
|
|
FI:'246',FR:'250',GA:'266',GM:'270',GE:'268',DE:'276',GH:'288',
|
|
GR:'300',GD:'308',GT:'320',GN:'324',GW:'624',GY:'328',HT:'332',
|
|
HN:'340',HU:'348',IS:'352',IN:'356',ID:'360',IR:'364',IQ:'368',
|
|
IE:'372',IL:'376',IT:'380',JM:'388',JP:'392',JO:'400',KZ:'398',
|
|
KE:'404',KI:'296',KP:'408',KR:'410',KW:'414',KG:'417',LA:'418',
|
|
LV:'428',LB:'422',LS:'426',LR:'430',LY:'434',LI:'438',LT:'440',
|
|
LU:'442',MG:'450',MW:'454',MY:'458',MV:'462',ML:'466',MT:'470',
|
|
MH:'584',MR:'478',MU:'480',MX:'484',FM:'583',MD:'498',MC:'492',
|
|
MN:'496',ME:'499',MA:'504',MZ:'508',MM:'104',NA:'516',NR:'520',
|
|
NP:'524',NL:'528',NZ:'554',NI:'558',NE:'562',NG:'566',NO:'578',
|
|
OM:'512',PK:'586',PW:'585',PA:'591',PG:'598',PY:'600',PE:'604',
|
|
PH:'608',PL:'616',PT:'620',QA:'634',RO:'642',RU:'643',RW:'646',
|
|
KN:'659',LC:'662',VC:'670',WS:'882',SM:'674',ST:'678',SA:'682',
|
|
SN:'686',RS:'688',SC:'690',SL:'694',SG:'702',SK:'703',SI:'705',
|
|
SB:'90',SO:'706',ZA:'710',SS:'728',ES:'724',LK:'144',SD:'729',
|
|
SR:'740',SE:'752',CH:'756',SY:'760',TW:'158',TJ:'762',TZ:'834',
|
|
TH:'764',TL:'626',TG:'768',TO:'776',TT:'780',TN:'788',TR:'792',
|
|
TM:'795',TV:'798',UG:'800',UA:'804',AE:'784',GB:'826',US:'840',
|
|
UY:'858',UZ:'860',VU:'548',VE:'862',VN:'704',YE:'887',ZM:'894',
|
|
ZW:'716',PS:'275',
|
|
};
|
|
const N2A: Record<string, string> = Object.fromEntries(Object.entries(A2N).map(([a, n]) => [n, a]));
|
|
|
|
const NAMES: Record<string, string> = {
|
|
AF:'Afghanistan',AL:'Albania',DZ:'Algeria',AD:'Andorra',AO:'Angola',AG:'Antigua & Barbuda',
|
|
AR:'Argentina',AM:'Armenia',AU:'Australia',AT:'Austria',AZ:'Azerbaijan',BS:'Bahamas',
|
|
BH:'Bahrain',BD:'Bangladesh',BB:'Barbados',BY:'Belarus',BE:'Belgium',BZ:'Belize',
|
|
BJ:'Benin',BT:'Bhutan',BO:'Bolivia',BA:'Bosnia & Herzegovina',BW:'Botswana',BR:'Brazil',
|
|
BN:'Brunei',BG:'Bulgaria',BF:'Burkina Faso',BI:'Burundi',CV:'Cape Verde',KH:'Cambodia',
|
|
CM:'Cameroon',CA:'Canada',CF:'Central African Rep.',TD:'Chad',CL:'Chile',CN:'China',
|
|
CO:'Colombia',KM:'Comoros',CG:'Congo',CD:'DR Congo',CR:'Costa Rica',CI:"Côte d'Ivoire",
|
|
HR:'Croatia',CU:'Cuba',CY:'Cyprus',CZ:'Czech Republic',DK:'Denmark',DJ:'Djibouti',
|
|
DM:'Dominica',DO:'Dominican Republic',EC:'Ecuador',EG:'Egypt',SV:'El Salvador',
|
|
GQ:'Equatorial Guinea',ER:'Eritrea',EE:'Estonia',SZ:'Eswatini',ET:'Ethiopia',
|
|
FJ:'Fiji',FI:'Finland',FR:'France',GA:'Gabon',GM:'Gambia',GE:'Georgia',DE:'Germany',
|
|
GH:'Ghana',GR:'Greece',GD:'Grenada',GT:'Guatemala',GN:'Guinea',GW:'Guinea-Bissau',
|
|
GY:'Guyana',HT:'Haiti',HN:'Honduras',HU:'Hungary',IS:'Iceland',IN:'India',ID:'Indonesia',
|
|
IR:'Iran',IQ:'Iraq',IE:'Ireland',IL:'Israel',IT:'Italy',JM:'Jamaica',JP:'Japan',
|
|
JO:'Jordan',KZ:'Kazakhstan',KE:'Kenya',KI:'Kiribati',KP:'North Korea',KR:'South Korea',
|
|
KW:'Kuwait',KG:'Kyrgyzstan',LA:'Laos',LV:'Latvia',LB:'Lebanon',LS:'Lesotho',LR:'Liberia',
|
|
LY:'Libya',LI:'Liechtenstein',LT:'Lithuania',LU:'Luxembourg',MG:'Madagascar',MW:'Malawi',
|
|
MY:'Malaysia',MV:'Maldives',ML:'Mali',MT:'Malta',MH:'Marshall Islands',MR:'Mauritania',
|
|
MU:'Mauritius',MX:'Mexico',FM:'Micronesia',MD:'Moldova',MC:'Monaco',MN:'Mongolia',
|
|
ME:'Montenegro',MA:'Morocco',MZ:'Mozambique',MM:'Myanmar',NA:'Namibia',NR:'Nauru',
|
|
NP:'Nepal',NL:'Netherlands',NZ:'New Zealand',NI:'Nicaragua',NE:'Niger',NG:'Nigeria',
|
|
NO:'Norway',OM:'Oman',PK:'Pakistan',PW:'Palau',PA:'Panama',PG:'Papua New Guinea',
|
|
PY:'Paraguay',PE:'Peru',PH:'Philippines',PL:'Poland',PT:'Portugal',QA:'Qatar',
|
|
RO:'Romania',RU:'Russia',RW:'Rwanda',KN:'Saint Kitts & Nevis',LC:'Saint Lucia',
|
|
VC:'Saint Vincent',WS:'Samoa',SM:'San Marino',ST:'São Tomé & Príncipe',SA:'Saudi Arabia',
|
|
SN:'Senegal',RS:'Serbia',SC:'Seychelles',SL:'Sierra Leone',SG:'Singapore',SK:'Slovakia',
|
|
SI:'Slovenia',SB:'Solomon Islands',SO:'Somalia',ZA:'South Africa',SS:'South Sudan',
|
|
ES:'Spain',LK:'Sri Lanka',SD:'Sudan',SR:'Suriname',SE:'Sweden',CH:'Switzerland',
|
|
SY:'Syria',TW:'Taiwan',TJ:'Tajikistan',TZ:'Tanzania',TH:'Thailand',TL:'Timor-Leste',
|
|
TG:'Togo',TO:'Tonga',TT:'Trinidad & Tobago',TN:'Tunisia',TR:'Turkey',TM:'Turkmenistan',
|
|
TV:'Tuvalu',UG:'Uganda',UA:'Ukraine',AE:'United Arab Emirates',GB:'United Kingdom',
|
|
US:'United States',UY:'Uruguay',UZ:'Uzbekistan',VU:'Vanuatu',VE:'Venezuela',
|
|
VN:'Vietnam',YE:'Yemen',ZM:'Zambia',ZW:'Zimbabwe',PS:'Palestine',
|
|
};
|
|
|
|
function flag(code: string): string {
|
|
if (!code || code.length !== 2) return '🌐';
|
|
return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65));
|
|
}
|
|
|
|
// Unwrap polygon rings so consecutive vertices never jump more than 180° in longitude.
|
|
// This prevents MapLibre from drawing giant artifacts for countries crossing ±180° (Russia, Fiji, etc.).
|
|
// Coordinates outside [-180, 180] are intentional — MapLibre renders them via world-copy tiling.
|
|
function cutAntimeridian(fc: GeoJSON.FeatureCollection): GeoJSON.FeatureCollection {
|
|
function unwrapRing(ring: GeoJSON.Position[]): GeoJSON.Position[] {
|
|
if (ring.length === 0) return ring;
|
|
const out: GeoJSON.Position[] = [[ring[0][0], ring[0][1]]];
|
|
for (let i = 1; i < ring.length; i++) {
|
|
let lng = ring[i][0];
|
|
const prev = out[i - 1][0];
|
|
while (lng - prev > 180) lng -= 360;
|
|
while (prev - lng > 180) lng += 360;
|
|
out.push([lng, ring[i][1]]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function fixGeometry(geom: GeoJSON.Geometry): GeoJSON.Geometry {
|
|
if (geom.type === 'Polygon') {
|
|
return { ...geom, coordinates: geom.coordinates.map(unwrapRing) };
|
|
}
|
|
if (geom.type === 'MultiPolygon') {
|
|
return { ...geom, coordinates: geom.coordinates.map(p => p.map(unwrapRing)) };
|
|
}
|
|
return geom;
|
|
}
|
|
|
|
return {
|
|
...fc,
|
|
features: fc.features.map(f => f.geometry ? { ...f, geometry: fixGeometry(f.geometry) } : f),
|
|
};
|
|
}
|
|
|
|
const OCEAN = '#0a1628';
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const MAP_STYLE: any = {
|
|
version: 8,
|
|
name: 'blank',
|
|
sources: {},
|
|
layers: [{ id: 'bg', type: 'background', paint: { 'background-color': OCEAN } }],
|
|
};
|
|
|
|
const FILL_LAYER: Omit<FillLayerSpecification, 'source'> = {
|
|
id: 'countries-fill',
|
|
type: 'fill',
|
|
paint: {
|
|
'fill-color': [
|
|
'interpolate', ['linear'],
|
|
['coalesce', ['get', 'norm'], 0],
|
|
0, '#1e293b', // no traffic — slate-800, clearly distinct from ocean
|
|
0.001, '#1e3a8a', // any traffic
|
|
0.4, '#3b82f6',
|
|
1, '#93c5fd',
|
|
] as ExpressionSpecification,
|
|
'fill-opacity': 1,
|
|
},
|
|
};
|
|
|
|
const SELECTED_LAYER: Omit<FillLayerSpecification, 'source'> = {
|
|
id: 'countries-selected',
|
|
type: 'fill',
|
|
paint: {
|
|
'fill-color': '#7dd3fc',
|
|
'fill-opacity': 0.45,
|
|
},
|
|
};
|
|
|
|
const HOVER_LAYER: Omit<FillLayerSpecification, 'source'> = {
|
|
id: 'countries-hover',
|
|
type: 'fill',
|
|
paint: {
|
|
'fill-color': '#7dd3fc',
|
|
'fill-opacity': 0.30,
|
|
},
|
|
};
|
|
|
|
const OUTLINE_LAYER: Omit<LineLayerSpecification, 'source'> = {
|
|
id: 'countries-outline',
|
|
type: 'line',
|
|
paint: {
|
|
'line-color': 'rgba(148,163,184,0.18)',
|
|
'line-width': 0.6,
|
|
},
|
|
};
|
|
|
|
export interface CountryStats { countryCode: string; total: number; blocked: number; }
|
|
|
|
interface HoverInfo {
|
|
longitude: number;
|
|
latitude: number;
|
|
alpha2: string | null;
|
|
total: number;
|
|
blocked: number;
|
|
}
|
|
|
|
export default function WorldMapInner({ data, selectedCountry }: { data: CountryStats[]; selectedCountry?: string | null }) {
|
|
const [baseGeojson, setBaseGeojson] = useState<GeoJSON.FeatureCollection | null>(null);
|
|
const [hoverInfo, setHoverInfo] = useState<HoverInfo | null>(null);
|
|
|
|
const countMap = useMemo(() => new Map(data.map(d => [d.countryCode, d.total])), [data]);
|
|
const blockedMap = useMemo(() => new Map(data.map(d => [d.countryCode, d.blocked])), [data]);
|
|
const max = useMemo(() => data.reduce((m, d) => Math.max(m, d.total), 0), [data]);
|
|
|
|
useEffect(() => {
|
|
fetch('/geo/countries-50m.json')
|
|
.then(r => r.json())
|
|
.then((topo: Topology) => {
|
|
const fc = feature(topo, topo.objects.countries as GeometryCollection) as GeoJSON.FeatureCollection;
|
|
setBaseGeojson(cutAntimeridian(fc));
|
|
})
|
|
.catch(() => setBaseGeojson({ type: 'FeatureCollection', features: [] }));
|
|
}, []);
|
|
|
|
const geojson = useMemo<GeoJSON.FeatureCollection | null>(() => {
|
|
if (!baseGeojson) return null;
|
|
const safeMax = Math.max(max, 1);
|
|
return {
|
|
...baseGeojson,
|
|
features: baseGeojson.features.map(f => {
|
|
const alpha2 = N2A[String(Number(f.id ?? 0))] ?? null;
|
|
const total = alpha2 ? (countMap.get(alpha2) ?? 0) : 0;
|
|
const blocked = alpha2 ? (blockedMap.get(alpha2) ?? 0) : 0;
|
|
const isSelected = alpha2 !== null && alpha2 === selectedCountry;
|
|
return { ...f, properties: { ...f.properties, alpha2, total, blocked, norm: total / safeMax, isSelected } };
|
|
}),
|
|
};
|
|
}, [baseGeojson, countMap, blockedMap, max, selectedCountry]);
|
|
|
|
const onHover = useCallback((event: MapLayerMouseEvent) => {
|
|
const f = event.features?.[0];
|
|
if (!f) { setHoverInfo(null); return; }
|
|
const alpha2 = (f.properties?.alpha2 as string | null) ?? null;
|
|
setHoverInfo({
|
|
longitude: event.lngLat.lng,
|
|
latitude: event.lngLat.lat,
|
|
alpha2,
|
|
total: (f.properties?.total as number) ?? 0,
|
|
blocked: (f.properties?.blocked as number) ?? 0,
|
|
});
|
|
}, []);
|
|
|
|
// Hover filter: only tracks mouse position, changes on every mousemove
|
|
const hoverFilter = useMemo<ExpressionSpecification>(() => {
|
|
const a2 = hoverInfo?.alpha2 ?? null;
|
|
return a2 ? ['==', ['get', 'alpha2'], a2] : ['boolean', false];
|
|
}, [hoverInfo?.alpha2]);
|
|
|
|
// Centroid popup for the table-selected country (shown when not hovering)
|
|
const selectedInfo = useMemo(() => {
|
|
if (!selectedCountry || !geojson) return null;
|
|
const feat = geojson.features.find(f => f.properties?.alpha2 === selectedCountry);
|
|
if (!feat?.geometry) return null;
|
|
|
|
const positions: GeoJSON.Position[] = [];
|
|
const collect = (geom: GeoJSON.Geometry) => {
|
|
if (geom.type === 'Polygon') geom.coordinates.forEach(r => positions.push(...r));
|
|
else if (geom.type === 'MultiPolygon') geom.coordinates.forEach(p => p.forEach(r => positions.push(...r)));
|
|
};
|
|
collect(feat.geometry);
|
|
if (positions.length === 0) return null;
|
|
|
|
const lngs = positions.map(c => c[0]);
|
|
const lats = positions.map(c => c[1]);
|
|
// Clamp longitude to [-180, 180] for the popup anchor
|
|
const rawLng = (Math.min(...lngs) + Math.max(...lngs)) / 2;
|
|
const longitude = ((rawLng + 180) % 360 + 360) % 360 - 180;
|
|
const latitude = Math.max(-85, Math.min(85, (Math.min(...lats) + Math.max(...lats)) / 2));
|
|
|
|
return {
|
|
longitude,
|
|
latitude,
|
|
alpha2: selectedCountry,
|
|
total: (feat.properties?.total as number) ?? 0,
|
|
blocked: (feat.properties?.blocked as number) ?? 0,
|
|
};
|
|
}, [selectedCountry, geojson]);
|
|
|
|
if (!geojson) {
|
|
return (
|
|
<div className="flex justify-center items-center h-[300px]">
|
|
<Skeleton className="w-full h-[400px] rounded-lg" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="relative h-full flex flex-col">
|
|
{/* Override MapLibre popup chrome to match dark theme */}
|
|
<style>{`
|
|
.wm-popup .maplibregl-popup-content {
|
|
background: rgba(8,16,30,0.96) !important;
|
|
border: 1px solid rgba(148,163,184,0.15) !important;
|
|
border-radius: 10px !important;
|
|
padding: 10px 14px !important;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.6) !important;
|
|
backdrop-filter: blur(12px) !important;
|
|
min-width: 152px;
|
|
}
|
|
.wm-popup .maplibregl-popup-tip { display: none !important; }
|
|
`}</style>
|
|
|
|
<div className="rounded-lg overflow-hidden border border-white/[0.08] flex-1 min-h-[280px] min-w-[400px] w-full">
|
|
<MapGL
|
|
mapStyle={MAP_STYLE}
|
|
initialViewState={{
|
|
bounds: [[-168, -56], [168, 74]],
|
|
fitBoundsOptions: { padding: 4 },
|
|
}}
|
|
minZoom={0.5}
|
|
interactiveLayerIds={['countries-fill']}
|
|
onMouseMove={onHover}
|
|
onMouseLeave={() => setHoverInfo(null)}
|
|
style={{ width: '100%', height: '100%' }}
|
|
attributionControl={false}
|
|
dragRotate={false}
|
|
pitchWithRotate={false}
|
|
cursor={hoverInfo ? 'crosshair' : 'grab'}
|
|
>
|
|
<Source id="countries" type="geojson" data={geojson}>
|
|
<Layer {...FILL_LAYER} source="countries" />
|
|
{/* Selected: data-driven via isSelected property baked into geojson — reliable on click */}
|
|
<Layer {...SELECTED_LAYER} source="countries" filter={['==', ['get', 'isSelected'], true]} />
|
|
{/* Hover: filter-driven, changes on mousemove */}
|
|
<Layer {...HOVER_LAYER} source="countries" filter={hoverFilter} />
|
|
<Layer {...OUTLINE_LAYER} source="countries" />
|
|
</Source>
|
|
|
|
{/* Hover popup (takes precedence) or selected-country popup */}
|
|
{(hoverInfo ?? selectedInfo) && (() => {
|
|
const info = hoverInfo ?? selectedInfo!;
|
|
return (
|
|
<Popup
|
|
longitude={info.longitude}
|
|
latitude={info.latitude}
|
|
offset={[0, -6] as [number, number]}
|
|
closeButton={false}
|
|
closeOnClick={false}
|
|
anchor="bottom"
|
|
className="wm-popup"
|
|
>
|
|
<div style={{ color: '#f1f5f9', fontFamily: 'inherit', fontSize: 13 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 7, fontWeight: 600, fontSize: 14 }}>
|
|
<span style={{ fontSize: 20, lineHeight: 1 }}>{info.alpha2 ? flag(info.alpha2) : '🌐'}</span>
|
|
<span>{info.alpha2 ? (NAMES[info.alpha2] ?? info.alpha2) : 'Territory'}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20 }}>
|
|
<span style={{ color: '#94a3b8' }}>Requests</span>
|
|
<span style={{ color: '#60a5fa', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
|
{info.total.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
{info.blocked > 0 && (
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 20, marginTop: 3 }}>
|
|
<span style={{ color: '#94a3b8' }}>Blocked</span>
|
|
<span style={{ color: '#f87171', fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
|
|
{info.blocked.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{info.total === 0 && (
|
|
<div style={{ color: '#475569', marginTop: 3, fontSize: 12 }}>No traffic recorded</div>
|
|
)}
|
|
</div>
|
|
</Popup>
|
|
);
|
|
})()}
|
|
</MapGL>
|
|
</div>
|
|
|
|
{max > 0 && (
|
|
<div className="flex items-center gap-2 mt-1.5 px-0.5">
|
|
<p className="text-xs text-muted-foreground">Low</p>
|
|
<div className="flex-1 h-[5px] rounded-full" style={{ background: 'linear-gradient(to right, #1e3a8a, #3b82f6, #93c5fd)' }} />
|
|
<p className="text-xs text-muted-foreground">High</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|