/** * WorldMap — SVG world map showing per-country ban counts. * * Uses react-simple-maps with the Natural Earth 110m TopoJSON data from * jsDelivr CDN. For each country that has bans in the selected time window, * the total count is displayed inside the country's borders. Clicking a * country filters the companion table. */ import { useCallback } from "react"; import { ComposableMap, Geography, useGeographies } from "react-simple-maps"; import { makeStyles, tokens } from "@fluentui/react-components"; import type { GeoPermissibleObjects } from "d3-geo"; import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2"; // --------------------------------------------------------------------------- // Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only) // --------------------------------------------------------------------------- const GEO_URL = "https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json"; // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const useStyles = makeStyles({ mapWrapper: { width: "100%", backgroundColor: tokens.colorNeutralBackground2, borderRadius: tokens.borderRadiusMedium, border: `1px solid ${tokens.colorNeutralStroke1}`, overflow: "hidden", }, countLabel: { fontSize: "9px", fontWeight: "600", fill: tokens.colorNeutralForeground1, pointerEvents: "none", userSelect: "none", }, }); // --------------------------------------------------------------------------- // Colour utilities // --------------------------------------------------------------------------- /** Map a ban count to a fill colour intensity. */ function getFill(count: number, maxCount: number): string { if (count === 0 || maxCount === 0) return "#E8E8E8"; const intensity = count / maxCount; // Interpolate from light amber to deep red const r = Math.round(220 + (220 - 220) * intensity); const g = Math.round(200 - 180 * intensity); const b = Math.round(160 - 160 * intensity); return `rgb(${String(r)},${String(g)},${String(b)})`; } // --------------------------------------------------------------------------- // GeoLayer — must be rendered inside ComposableMap to access map context // --------------------------------------------------------------------------- interface GeoLayerProps { countries: Record; maxCount: number; selectedCountry: string | null; onSelectCountry: (cc: string | null) => void; } function GeoLayer({ countries, maxCount, selectedCountry, onSelectCountry, }: GeoLayerProps): React.JSX.Element { const styles = useStyles(); const { geographies, path } = useGeographies({ geography: GEO_URL }); const handleClick = useCallback( (cc: string | null): void => { onSelectCountry(selectedCountry === cc ? null : cc); }, [selectedCountry, onSelectCountry], ); // react-simple-maps types declare `path` as always defined, but it is // undefined during early renders before the MapProvider context initialises. // Cast through unknown to reflect the true runtime type and guard safely. const safePath = path as unknown as typeof path | null; if (safePath == null) return <>; return ( <> {(geographies as { rsmKey: string; id: string | number }[]).map( (geo) => { const numericId = String(geo.id); const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null; const count: number = cc !== null ? (countries[cc] ?? 0) : 0; const isSelected = cc !== null && selectedCountry === cc; const centroid = path.centroid(geo as unknown as GeoPermissibleObjects); const [cx, cy] = centroid; const fill = isSelected ? tokens.colorBrandBackground : getFill(count, maxCount); return ( { if (cc) handleClick(cc); }} onKeyDown={(e): void => { if (cc && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); handleClick(cc); } }} > {count > 0 && isFinite(cx) && isFinite(cy) && ( {count} )} ); }, )} ); } // --------------------------------------------------------------------------- // WorldMap — public component // --------------------------------------------------------------------------- export interface WorldMapProps { /** ISO alpha-2 country code → ban count. */ countries: Record; /** Currently selected country filter (null means no filter). */ selectedCountry: string | null; /** Called when the user clicks a country or deselects. */ onSelectCountry: (cc: string | null) => void; } export function WorldMap({ countries, selectedCountry, onSelectCountry, }: WorldMapProps): React.JSX.Element { const styles = useStyles(); const maxCount = Math.max(0, ...Object.values(countries)); return (
); }