/** * WorldMap — SVG world map showing per-country ban counts. * * Uses a local TopoJSON bundle and d3-geo for projection, path generation, * and native SVG pan/zoom behaviour. */ import { createPortal } from "react-dom"; import { useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { Button, makeStyles, tokens } from "@fluentui/react-components"; import { geoMercator, geoPath, type GeoPath } from "d3-geo"; import { feature } from "topojson-client"; import type { Feature, FeatureCollection, GeoJsonProperties, Geometry, } from "geojson"; import type { GeometryCollection as TopoGeometryCollection, Topology, } from "topojson-specification"; import worldData from "world-atlas/countries-110m.json"; import { useCardStyles } from "../theme/commonStyles"; import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2"; import { getBanCountColor } from "../utils/mapColors"; const MAP_WIDTH = 800; const MAP_HEIGHT = 400; const MIN_ZOOM = 1; const MAX_ZOOM = 8; const ZOOM_STEP = 0.5; const PAN_THRESHOLD = 3; const useStyles = makeStyles({ mapWrapper: { width: "100%", position: "relative", overflow: "hidden", }, svg: { width: "100%", height: "auto", touchAction: "none", }, country: { transition: "fill 150ms ease, stroke 150ms ease", stroke: tokens.colorNeutralStroke2, strokeWidth: 0.75, fill: "var(--country-fill)", outline: "none", cursor: "pointer", }, countryHovered: { fill: "var(--country-hover-fill)", }, countrySelected: { fill: "var(--country-selected-fill)", }, 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, }, tooltip: { position: "fixed", zIndex: 9999, pointerEvents: "none", backgroundColor: tokens.colorNeutralBackground1, border: `1px solid ${tokens.colorNeutralStroke2}`, borderRadius: tokens.borderRadiusSmall, padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, display: "flex", flexDirection: "column", gap: tokens.spacingVerticalXXS, boxShadow: tokens.shadow4, }, tooltipCountry: { fontSize: tokens.fontSizeBase200, fontWeight: tokens.fontWeightSemibold, color: tokens.colorNeutralForeground1, }, tooltipCount: { fontSize: tokens.fontSizeBase200, color: tokens.colorNeutralForeground2, }, }); type TopoJsonTopology = Topology & { objects: { countries: TopoGeometryCollection; }; }; type TooltipState = { cc: string; count: number; name: string; x: number; y: number; } | null; interface WorldMapProps { /** ISO alpha-2 country code → ban count. */ countries: Record; /** Optional mapping from country code to display name. */ countryNames?: 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, countryNames, selectedCountry, onSelectCountry, thresholdLow = 20, thresholdMedium = 50, thresholdHigh = 100, }: WorldMapProps): React.JSX.Element { const styles = useStyles(); const cardStyles = useCardStyles(); const [zoom, setZoom] = useState(MIN_ZOOM); const [center, setCenter] = useState<[number, number]>([0, 0]); const [hoveredCountry, setHoveredCountry] = useState(null); const [tooltip, setTooltip] = useState(null); const zoomRef = useRef(zoom); const centerRef = useRef<[number, number]>(center); const dragStateRef = useRef<{ active: boolean; startX: number; startY: number; startCenter: [number, number]; moved: boolean; } | null>(null); const clickSuppressedRef = useRef(false); useEffect(() => { zoomRef.current = zoom; }, [zoom]); useEffect(() => { centerRef.current = center; }, [center]); const topology = useMemo(() => worldData as unknown as TopoJsonTopology, []); const geoJson = useMemo( () => feature(topology, topology.objects.countries) as FeatureCollection< Geometry, GeoJsonProperties >, [topology], ); const projection = useMemo( () => geoMercator().fitSize([MAP_WIDTH, MAP_HEIGHT], geoJson), [geoJson], ); const pathGenerator = useMemo>>( () => geoPath().projection(projection), [projection], ); const countryFeatures = useMemo( () => geoJson.features.filter((feature) => feature.id != null && feature.geometry != null), [geoJson.features], ); const clampZoom = useCallback((value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM), []); const handleCountrySelect = useCallback( (cc: string | null): void => { if (clickSuppressedRef.current) { return; } onSelectCountry(selectedCountry === cc ? null : cc); }, [onSelectCountry, selectedCountry], ); const handlePointerDown = useCallback((event: React.PointerEvent) => { if (event.button !== 0) return; event.currentTarget.setPointerCapture(event.pointerId); dragStateRef.current = { active: true, startX: event.clientX, startY: event.clientY, startCenter: centerRef.current, moved: false, }; clickSuppressedRef.current = false; }, []); const handlePointerMove = useCallback((event: React.PointerEvent) => { const drag = dragStateRef.current; if (!drag?.active) return; const dx = event.clientX - drag.startX; const dy = event.clientY - drag.startY; if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) { drag.moved = true; clickSuppressedRef.current = true; } setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]); }, []); const handlePointerUp = useCallback((event: React.PointerEvent) => { const drag = dragStateRef.current; if (!drag) return; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } dragStateRef.current = null; window.setTimeout(() => { clickSuppressedRef.current = false; }, 0); }, []); const handleWheel = useCallback((event: React.WheelEvent) => { event.preventDefault(); const currentZoom = zoomRef.current; const desiredZoom = clampZoom(currentZoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)); if (desiredZoom === currentZoom) { return; } const rect = event.currentTarget.getBoundingClientRect(); const svgX = (event.clientX - rect.left - centerRef.current[0]) / currentZoom; const svgY = (event.clientY - rect.top - centerRef.current[1]) / currentZoom; setZoom(desiredZoom); setCenter([ centerRef.current[0] - svgX * (desiredZoom - currentZoom), centerRef.current[1] - svgY * (desiredZoom - currentZoom), ]); }, [clampZoom]); const handleZoomIn = useCallback(() => { setZoom((value) => clampZoom(value + ZOOM_STEP)); }, [clampZoom]); const handleZoomOut = useCallback(() => { setZoom((value) => clampZoom(value - ZOOM_STEP)); }, [clampZoom]); const handleResetView = useCallback(() => { setZoom(MIN_ZOOM); setCenter([0, 0]); }, []); return (
{countryFeatures.map((featureItem) => { const rawId = featureItem.id; const numericId = String(Number(rawId)); const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null; const count = cc !== null ? countries[cc] ?? 0 : 0; const isSelected = cc !== null && selectedCountry === cc; const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh); const pathString = pathGenerator(featureItem) ?? ""; if (!pathString) { return null; } return ( { if (cc) { handleCountrySelect(cc); } }} onKeyDown={(event): void => { if (cc && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); handleCountrySelect(cc); } }} onMouseEnter={(event): void => { if (!cc) return; setHoveredCountry(cc); setTooltip({ cc, count, name: countryNames?.[cc] ?? cc, x: event.clientX, y: event.clientY, }); }} onMouseMove={(event): void => { setTooltip((current) => current ? { ...current, x: event.clientX, y: event.clientY } : current, ); }} onMouseLeave={(): void => { setHoveredCountry(null); setTooltip(null); }} /> ); })} {tooltip && createPortal(
{tooltip.name} {tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
, document.body, )}
); }