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,76 @@
/**
* `useMapData` hook — fetches and manages ban-by-country data.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBansByCountry } from "../api/map";
import type { BansByCountryResponse, MapBanItem, TimeRange } from "../types/map";
// ---------------------------------------------------------------------------
// Return type
// ---------------------------------------------------------------------------
export interface UseMapDataResult {
/** Per-country ban counts (ISO alpha-2 → count). */
countries: Record<string, number>;
/** ISO alpha-2 → country name mapping. */
countryNames: Record<string, string>;
/** All ban records in the selected window. */
bans: MapBanItem[];
/** Total ban count. */
total: number;
/** True while a fetch is in flight. */
loading: boolean;
/** Error message or null. */
error: string | null;
/** Trigger a manual re-fetch. */
refresh: () => void;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useMapData(range: TimeRange = "24h"): UseMapDataResult {
const [data, setData] = useState<BansByCountryResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
abortRef.current?.abort();
abortRef.current = new AbortController();
setLoading(true);
setError(null);
fetchBansByCountry(range)
.then((resp) => {
setData(resp);
})
.catch((err: unknown) => {
if (err instanceof Error && err.name !== "AbortError") {
setError(err.message);
}
})
.finally((): void => {
setLoading(false);
});
}, [range]);
useEffect((): (() => void) => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return {
countries: data?.countries ?? {},
countryNames: data?.country_names ?? {},
bans: data?.bans ?? [],
total: data?.total ?? 0,
loading,
error,
refresh: load,
};
}