/** * 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, useState } from "react"; import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps"; import { Button, makeStyles, tokens } from "@fluentui/react-components"; import type { GeoPermissibleObjects } from "d3-geo"; import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2"; import { getBanCountColor } from "../utils/mapColors"; // --------------------------------------------------------------------------- // 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%", position: "relative", 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", }, zoomControls: { position: "absolute", top: tokens.spacingVerticalM, right: tokens.spacingHorizontalM, display: "flex", flexDirection: "column", gap: tokens.spacingVerticalXS, zIndex: 10, }, }); // --------------------------------------------------------------------------- // GeoLayer — must be rendered inside ComposableMap to access map context // --------------------------------------------------------------------------- interface GeoLayerProps { countries: Record; selectedCountry: string | null; onSelectCountry: (cc: string | null) => void; thresholdLow: number; thresholdMedium: number; thresholdHigh: number; } function GeoLayer({ countries, selectedCountry, onSelectCountry, thresholdLow, thresholdMedium, thresholdHigh, }: 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], ); if (geographies.length === 0) return <>; // react-simple-maps types declare path as always defined, but it can be null // during initial render before MapProvider context initializes. Cast to reflect // the true runtime type and allow safe null checking. const safePath = path as unknown as typeof path | null; 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; // Compute the fill color based on ban count const fillColor = getBanCountColor( count, thresholdLow, thresholdMedium, thresholdHigh, ); // Only calculate centroid if path is available let cx: number | undefined; let cy: number | undefined; if (safePath != null) { const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects); [cx, cy] = centroid; } return ( { if (cc) handleClick(cc); }} onKeyDown={(e): void => { if (cc && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); handleClick(cc); } }} > 0 ? tokens.colorNeutralBackground3 : fillColor, stroke: tokens.colorNeutralStroke1, strokeWidth: 1, outline: "none", }, pressed: { fill: cc ? tokens.colorBrandBackgroundPressed : fillColor, stroke: tokens.colorBrandStroke1, strokeWidth: 1, outline: "none", }, }} /> {count > 0 && cx !== undefined && cy !== undefined && 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; /** Ban count threshold for green coloring (default: 20). */ thresholdLow?: number; /** Ban count threshold for yellow coloring (default: 50). */ thresholdMedium?: number; /** Ban count threshold for red coloring (default: 100). */ thresholdHigh?: number; } export function WorldMap({ countries, selectedCountry, onSelectCountry, thresholdLow = 20, thresholdMedium = 50, thresholdHigh = 100, }: WorldMapProps): React.JSX.Element { const styles = useStyles(); const [zoom, setZoom] = useState(1); const [center, setCenter] = useState<[number, number]>([0, 0]); const handleZoomIn = (): void => { setZoom((z) => Math.min(z + 0.5, 8)); }; const handleZoomOut = (): void => { setZoom((z) => Math.max(z - 0.5, 1)); }; const handleResetView = (): void => { setZoom(1); setCenter([0, 0]); }; return (
{/* Zoom controls */}
{ setZoom(newZoom); setCenter(coordinates); }} minZoom={1} maxZoom={8} >
); }