Files
BanGUI/frontend/src/components/WorldMap.tsx

448 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
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>
);
}