Files
BanGUI/frontend/src/components/WorldMap.tsx
Lukas f06fea699f Fix WorldMap crash when path is undefined during early render
react-simple-maps types declare path as always non-null, but it is
undefined during the initial render before the MapProvider context
has fully initialised. Guard with an early return after all hooks
have been called, casting through unknown to reflect the true runtime
type without triggering the @typescript-eslint/no-unnecessary-condition
rule.
2026-03-01 20:27:50 +01:00

215 lines
7.1 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 { useCallback } from "react";
import { ComposableMap, Geography, useGeographies } from "react-simple-maps";
import { makeStyles, tokens } from "@fluentui/react-components";
import type { GeoPermissibleObjects } from "d3-geo";
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
// ---------------------------------------------------------------------------
// 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%",
backgroundColor: tokens.colorNeutralBackground2,
borderRadius: tokens.borderRadiusMedium,
border: `1px solid ${tokens.colorNeutralStroke1}`,
overflow: "hidden",
},
countLabel: {
fontSize: "9px",
fontWeight: "600",
fill: tokens.colorNeutralForeground1,
pointerEvents: "none",
userSelect: "none",
},
});
// ---------------------------------------------------------------------------
// Colour utilities
// ---------------------------------------------------------------------------
/** Map a ban count to a fill colour intensity. */
function getFill(count: number, maxCount: number): string {
if (count === 0 || maxCount === 0) return "#E8E8E8";
const intensity = count / maxCount;
// Interpolate from light amber to deep red
const r = Math.round(220 + (220 - 220) * intensity);
const g = Math.round(200 - 180 * intensity);
const b = Math.round(160 - 160 * intensity);
return `rgb(${String(r)},${String(g)},${String(b)})`;
}
// ---------------------------------------------------------------------------
// GeoLayer — must be rendered inside ComposableMap to access map context
// ---------------------------------------------------------------------------
interface GeoLayerProps {
countries: Record<string, number>;
maxCount: number;
selectedCountry: string | null;
onSelectCountry: (cc: string | null) => void;
}
function GeoLayer({
countries,
maxCount,
selectedCountry,
onSelectCountry,
}: GeoLayerProps): React.JSX.Element {
const styles = useStyles();
const { geographies, path } = useGeographies({ geography: GEO_URL });
const handleClick = useCallback(
(cc: string | null): void => {
onSelectCountry(selectedCountry === cc ? null : cc);
},
[selectedCountry, onSelectCountry],
);
// react-simple-maps types declare `path` as always defined, but it is
// undefined during early renders before the MapProvider context initialises.
// Cast through unknown to reflect the true runtime type and guard safely.
const safePath = path as unknown as typeof path | null;
if (safePath == null) return <></>;
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;
const centroid = path.centroid(geo as unknown as GeoPermissibleObjects);
const [cx, cy] = centroid;
const fill = isSelected
? tokens.colorBrandBackground
: getFill(count, maxCount);
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);
}
}}
>
<Geography
geography={geo}
style={{
default: {
fill,
stroke: tokens.colorNeutralBackground1,
strokeWidth: 0.5,
outline: "none",
},
hover: {
fill: cc ? tokens.colorBrandBackgroundHover : fill,
stroke: tokens.colorNeutralBackground1,
strokeWidth: 0.5,
outline: "none",
},
pressed: {
fill: tokens.colorBrandBackgroundPressed,
stroke: tokens.colorNeutralBackground1,
strokeWidth: 0.5,
outline: "none",
},
}}
/>
{count > 0 && isFinite(cx) && isFinite(cy) && (
<text
x={cx}
y={cy}
textAnchor="middle"
dominantBaseline="central"
className={styles.countLabel}
>
{count}
</text>
)}
</g>
);
},
)}
</>
);
}
// ---------------------------------------------------------------------------
// WorldMap — public component
// ---------------------------------------------------------------------------
export interface WorldMapProps {
/** ISO alpha-2 country code → ban count. */
countries: Record<string, number>;
/** 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;
}
export function WorldMap({
countries,
selectedCountry,
onSelectCountry,
}: WorldMapProps): React.JSX.Element {
const styles = useStyles();
const maxCount = Math.max(0, ...Object.values(countries));
return (
<div
className={styles.mapWrapper}
role="img"
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
>
<ComposableMap
projection="geoMercator"
projectionConfig={{ scale: 130, center: [10, 20] }}
width={800}
height={400}
style={{ width: "100%", height: "auto" }}
>
<GeoLayer
countries={countries}
maxCount={maxCount}
selectedCountry={selectedCountry}
onSelectCountry={onSelectCountry}
/>
</ComposableMap>
</div>
);
}