433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
/**
|
||
* 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<string, number>;
|
||
/** Optional mapping from country code to display name. */
|
||
countryNames?: Record<string, string>;
|
||
/** 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<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 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);
|
||
|
||
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<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
|
||
className={`${cardStyles.card} ${styles.mapWrapper}`}
|
||
role="img"
|
||
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
|
||
>
|
||
<div className={styles.zoomControls}>
|
||
<Button
|
||
appearance="secondary"
|
||
size="small"
|
||
onClick={handleZoomIn}
|
||
disabled={zoom >= MAX_ZOOM}
|
||
title="Zoom in"
|
||
aria-label="Zoom in"
|
||
>
|
||
+
|
||
</Button>
|
||
<Button
|
||
appearance="secondary"
|
||
size="small"
|
||
onClick={handleZoomOut}
|
||
disabled={zoom <= MIN_ZOOM}
|
||
title="Zoom out"
|
||
aria-label="Zoom out"
|
||
>
|
||
−
|
||
</Button>
|
||
<Button
|
||
appearance="secondary"
|
||
size="small"
|
||
onClick={handleResetView}
|
||
disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
|
||
title="Reset view"
|
||
aria-label="Reset view"
|
||
>
|
||
⟲
|
||
</Button>
|
||
</div>
|
||
|
||
<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}
|
||
>
|
||
<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;
|
||
}
|
||
|
||
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);
|
||
}}
|
||
/>
|
||
</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>
|
||
);
|
||
}
|