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:
190
frontend/src/components/WorldMap.tsx
Normal file
190
frontend/src/components/WorldMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user