Migrate WorldMap to d3-geo, fix TopoJSON country ID mappings and update tests

This commit is contained in:
2026-04-05 18:50:44 +02:00
parent bfe0daf754
commit 8e2bb5d3fb
7 changed files with 505 additions and 484 deletions

View File

@@ -1,32 +1,42 @@
/**
* 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.
* 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, useMemo, useState } from "react";
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
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 type { CSSProperties } from "react";
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 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: {
@@ -34,6 +44,25 @@ const useStyles = makeStyles({
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",
@@ -74,212 +103,21 @@ const useStyles = makeStyles({
},
});
// ---------------------------------------------------------------------------
// GeoLayer — must be rendered inside ComposableMap to access map context
// ---------------------------------------------------------------------------
type TopoJsonTopology = Topology & {
objects: {
countries: TopoGeometryCollection;
};
};
interface GeoLayerProps {
countries: Record<string, number>;
countryNames?: Record<string, string>;
selectedCountry: string | null;
onSelectCountry: (cc: string | null) => void;
thresholdLow: number;
thresholdMedium: number;
thresholdHigh: number;
}
type TooltipState = {
cc: string;
count: number;
name: string;
x: number;
y: number;
} | null;
function GeoLayer({
countries,
countryNames,
selectedCountry,
onSelectCountry,
thresholdLow,
thresholdMedium,
thresholdHigh,
}: GeoLayerProps): React.JSX.Element {
const styles = useStyles();
const { geographies, path } = useGeographies({ geography: GEO_URL });
const [tooltip, setTooltip] = useState<
| {
cc: string;
count: number;
name: string;
x: number;
y: number;
}
| null
>(null);
const handleClick = useCallback(
(cc: string | null): void => {
onSelectCountry(selectedCountry === cc ? null : cc);
},
[selectedCountry, onSelectCountry],
);
// Stable event handlers — shared across all Geography components so
// React.memo is not defeated by new function references each render.
const handleMouseEnter = useCallback(
(e: React.MouseEvent<SVGPathElement>): void => {
const cc = e.currentTarget.dataset.cc;
if (!cc) return;
const count = Number(e.currentTarget.dataset.count);
const name = e.currentTarget.dataset.name ?? cc;
setTooltip({ cc, count, name, x: e.clientX, y: e.clientY });
},
[],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent<SVGPathElement>): void => {
setTooltip((current) =>
current
? { ...current, x: e.clientX, y: e.clientY }
: current,
);
},
[],
);
const handleMouseLeave = useCallback((): void => {
setTooltip(null);
}, []);
// Memoize style objects so Geography's React.memo can skip re-renders
// when only the tooltip position changes.
type GeoStyle = { default: CSSProperties; hover: CSSProperties; pressed: CSSProperties };
const styleMap = useMemo(() => {
const map: Record<string, GeoStyle> = {};
for (const geo of geographies as { rsmKey: string; id: string | number }[]) {
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 fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
map[geo.rsmKey] = {
default: {
fill: isSelected ? tokens.colorBrandBackground : fillColor,
stroke: tokens.colorNeutralStroke2,
strokeWidth: 0.75,
outline: "none",
},
hover: {
fill: isSelected
? tokens.colorBrandBackgroundHover
: cc && count > 0
? tokens.colorNeutralBackground3
: fillColor,
stroke: tokens.colorNeutralStroke1,
strokeWidth: 1,
outline: "none",
},
pressed: {
fill: cc ? tokens.colorBrandBackgroundPressed : fillColor,
stroke: tokens.colorBrandStroke1,
strokeWidth: 1,
outline: "none",
},
};
}
return map;
}, [geographies, countries, selectedCountry, thresholdLow, thresholdMedium, thresholdHigh]);
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;
// 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 (
<g key={geo.rsmKey} style={{ cursor: cc ? "pointer" : "default" }}>
<Geography
geography={geo}
role={cc ? "button" : undefined}
tabIndex={cc ? 0 : undefined}
aria-label={
cc
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
isSelected ? " (selected)" : ""
}`
: undefined
}
aria-pressed={isSelected || undefined}
onClick={(): void => {
if (cc) handleClick(cc);
}}
onKeyDown={(e): void => {
if (cc && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
handleClick(cc);
}
}}
onMouseEnter={handleMouseEnter}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
data-cc={cc ?? undefined}
data-count={cc ? String(count) : undefined}
data-name={cc ? (countryNames?.[cc] ?? cc) : undefined}
style={styleMap[geo.rsmKey]}
/>
{count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
className={styles.countLabel}
>
{count}
</text>
)}
</g>
);
},
)}
{tooltip &&
createPortal(
<div
className={styles.tooltip}
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
role="tooltip"
aria-live="polite"
>
<span className={styles.tooltipCountry}>{tooltip.name}</span>
<span className={styles.tooltipCount}>
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
</span>
</div>,
document.body,
)}
</>
);
}
// ---------------------------------------------------------------------------
// WorldMap — public component
// ---------------------------------------------------------------------------
export interface WorldMapProps {
interface WorldMapProps {
/** ISO alpha-2 country code → ban count. */
countries: Record<string, number>;
/** Optional mapping from country code to display name. */
@@ -307,21 +145,143 @@ export function WorldMap({
}: WorldMapProps): React.JSX.Element {
const styles = useStyles();
const cardStyles = useCardStyles();
const [zoom, setZoom] = useState<number>(1);
const [zoom, setZoom] = useState<number>(MIN_ZOOM);
const [center, setCenter] = useState<[number, number]>([0, 0]);
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
const [tooltip, setTooltip] = useState<TooltipState>(null);
const handleZoomIn = (): void => {
setZoom((z) => Math.min(z + 0.5, 8));
};
const zoomRef = useRef<number>(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<boolean>(false);
const handleZoomOut = (): void => {
setZoom((z) => Math.max(z - 0.5, 1));
};
useEffect(() => {
zoomRef.current = zoom;
}, [zoom]);
const handleResetView = (): void => {
setZoom(1);
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<unknown, Feature<Geometry, GeoJsonProperties>>>(
() => 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<SVGSVGElement>) => {
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<SVGSVGElement>) => {
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<SVGSVGElement>) => {
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<SVGSVGElement>) => {
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 (
<div
@@ -329,13 +289,12 @@ export function WorldMap({
role="img"
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
>
{/* Zoom controls */}
<div className={styles.zoomControls}>
<Button
appearance="secondary"
size="small"
onClick={handleZoomIn}
disabled={zoom >= 8}
disabled={zoom >= MAX_ZOOM}
title="Zoom in"
aria-label="Zoom in"
>
@@ -345,7 +304,7 @@ export function WorldMap({
appearance="secondary"
size="small"
onClick={handleZoomOut}
disabled={zoom <= 1}
disabled={zoom <= MIN_ZOOM}
title="Zoom out"
aria-label="Zoom out"
>
@@ -355,7 +314,7 @@ export function WorldMap({
appearance="secondary"
size="small"
onClick={handleResetView}
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
title="Reset view"
aria-label="Reset view"
>
@@ -363,34 +322,126 @@ export function WorldMap({
</Button>
</div>
<ComposableMap
projection="geoMercator"
projectionConfig={{ scale: 130, center: [10, 20] }}
width={800}
height={400}
style={{ width: "100%", height: "auto" }}
<svg
className={styles.svg}
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
role="img"
aria-label="World map showing banned IP counts by country."
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onWheel={handleWheel}
>
<ZoomableGroup
zoom={zoom}
center={center}
onMoveEnd={({ zoom: newZoom, coordinates }): void => {
setZoom(newZoom);
setCenter(coordinates);
}}
minZoom={1}
maxZoom={8}
>
<GeoLayer
countries={countries}
countryNames={countryNames}
selectedCountry={selectedCountry}
onSelectCountry={onSelectCountry}
thresholdLow={thresholdLow}
thresholdMedium={thresholdMedium}
thresholdHigh={thresholdHigh}
/>
</ZoomableGroup>
</ComposableMap>
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
{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;
}
const centroid = pathGenerator.centroid(featureItem);
const [cx, cy] = centroid;
const isCentroidValid = Number.isFinite(cx) && Number.isFinite(cy);
return (
<g key={String(rawId)}>
<path
d={pathString}
role={cc ? "button" : undefined}
tabIndex={cc ? 0 : undefined}
aria-label={
cc
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
isSelected ? " (selected)" : ""
}`
: undefined
}
aria-pressed={isSelected || undefined}
className={`${styles.country} ${
isSelected ? styles.countrySelected : ""
} ${hoveredCountry === cc ? styles.countryHovered : ""}`}
style={
{
["--country-fill" as string]: fillColor,
["--country-hover-fill" as string]: isSelected
? tokens.colorBrandBackgroundHover
: tokens.colorBrandBackground2,
["--country-selected-fill" as string]: tokens.colorBrandBackground,
} as React.CSSProperties
}
onClick={(): void => {
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);
}}
/>
{count > 0 && isCentroidValid && (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
className={styles.countLabel}
>
{count}
</text>
)}
</g>
);
})}
</g>
</svg>
{tooltip &&
createPortal(
<div
className={styles.tooltip}
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
role="tooltip"
aria-live="polite"
>
<span className={styles.tooltipCountry}>{tooltip.name}</span>
<span className={styles.tooltipCount}>
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
</span>
</div>,
document.body,
)}
</div>
);
}