Stage 8: world map view — backend endpoint, map component, map page

- BansByCountryResponse model added to ban.py
- bans_by_country() service: parallel geo lookup via asyncio.gather,
  aggregation by ISO alpha-2 country code (up to 2 000 bans)
- GET /api/dashboard/bans/by-country endpoint in dashboard router
- 290 tests pass (5 new), ruff + mypy clean (44 files)
- isoNumericToAlpha2.ts: 249-entry ISO numeric → alpha-2 static map
- types/map.ts, api/map.ts, hooks/useMapData.ts created
- WorldMap.tsx: react-simple-maps Mercator SVG map, per-country ban
  count overlay, colour intensity scaling, country click filtering,
  GeoLayer nested-component pattern for useGeographies context
- MapPage.tsx: time-range selector, WorldMap, country filter info bar,
  summary line, companion FluentUI Table with country filter
- Frontend tsc + ESLint clean (0 errors/warnings)
This commit is contained in:
2026-03-01 14:53:49 +01:00
parent 7f81f0614b
commit 54313fd3e0
13 changed files with 1343 additions and 20 deletions

View File

@@ -0,0 +1,190 @@
/**
* 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],
);
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" }}
onClick={(): void => {
if (cc) 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}>
<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>
);
}