379 lines
12 KiB
TypeScript
379 lines
12 KiB
TypeScript
/**
|
||
* 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 { createPortal } from "react-dom";
|
||
import { useCallback, useState } from "react";
|
||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
||
import { useCardStyles } from "../theme/commonStyles";
|
||
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",
|
||
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,
|
||
},
|
||
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,
|
||
},
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// GeoLayer — must be rendered inside ComposableMap to access map context
|
||
// ---------------------------------------------------------------------------
|
||
|
||
interface GeoLayerProps {
|
||
countries: Record<string, number>;
|
||
countryNames?: Record<string, string>;
|
||
selectedCountry: string | null;
|
||
onSelectCountry: (cc: string | null) => void;
|
||
thresholdLow: number;
|
||
thresholdMedium: number;
|
||
thresholdHigh: number;
|
||
}
|
||
|
||
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],
|
||
);
|
||
|
||
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 (
|
||
<g
|
||
key={geo.rsmKey}
|
||
style={{ cursor: cc ? "pointer" : "default" }}
|
||
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={(e): void => {
|
||
if (!cc) return;
|
||
setTooltip({
|
||
cc,
|
||
count,
|
||
name: countryNames?.[cc] ?? cc,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
});
|
||
}}
|
||
onMouseMove={(e): void => {
|
||
setTooltip((current) =>
|
||
current
|
||
? {
|
||
...current,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
}
|
||
: current,
|
||
);
|
||
}}
|
||
onMouseLeave={(): void => {
|
||
setTooltip(null);
|
||
}}
|
||
>
|
||
<Geography
|
||
geography={geo}
|
||
style={{
|
||
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",
|
||
},
|
||
}}
|
||
/>
|
||
{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 {
|
||
/** 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>(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 (
|
||
<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."
|
||
>
|
||
{/* Zoom controls */}
|
||
<div className={styles.zoomControls}>
|
||
<Button
|
||
appearance="secondary"
|
||
size="small"
|
||
onClick={handleZoomIn}
|
||
disabled={zoom >= 8}
|
||
title="Zoom in"
|
||
aria-label="Zoom in"
|
||
>
|
||
+
|
||
</Button>
|
||
<Button
|
||
appearance="secondary"
|
||
size="small"
|
||
onClick={handleZoomOut}
|
||
disabled={zoom <= 1}
|
||
title="Zoom out"
|
||
aria-label="Zoom out"
|
||
>
|
||
−
|
||
</Button>
|
||
<Button
|
||
appearance="secondary"
|
||
size="small"
|
||
onClick={handleResetView}
|
||
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
|
||
title="Reset view"
|
||
aria-label="Reset view"
|
||
>
|
||
⟲
|
||
</Button>
|
||
</div>
|
||
|
||
<ComposableMap
|
||
projection="geoMercator"
|
||
projectionConfig={{ scale: 130, center: [10, 20] }}
|
||
width={800}
|
||
height={400}
|
||
style={{ width: "100%", height: "auto" }}
|
||
>
|
||
<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>
|
||
</div>
|
||
);
|
||
}
|