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

379 lines
12 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 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>
);
}