Migrate WorldMap to d3-geo, fix TopoJSON country ID mappings and update tests
This commit is contained in:
169
Docs/Tasks.md
169
Docs/Tasks.md
@@ -8,79 +8,128 @@ Reference: `Docs/Refactoring.md` for full analysis of each issue.
|
||||
|
||||
## Open Issues
|
||||
|
||||
### ~~WorldMap hover highlight: fill color sometimes does not change~~ ✅ Done
|
||||
### Replace `react-simple-maps` with `d3-geo` in WorldMap
|
||||
|
||||
**Status:** Completed.
|
||||
The current `WorldMap` component (`frontend/src/components/WorldMap.tsx`) uses the `react-simple-maps` library (`ComposableMap`, `ZoomableGroup`, `Geography`, `useGeographies`). This library wraps d3-geo but adds a heavy abstraction layer and fetches the TopoJSON geography file from a remote CDN at runtime. Replace it with direct d3-geo rendering, following the pattern demonstrated in the reference project at `/media/lukas/Volume/repo/worldmaptest/`.
|
||||
|
||||
**Summary:** Memoized Geography style objects via `useMemo` keyed by `geographies`, `countries`, `selectedCountry`, and threshold props. Stabilized `onMouseEnter`, `onMouseMove`, and `onMouseLeave` handlers with `useCallback` using `data-*` attributes to avoid per-Geography closures. This ensures `React.memo(Geography)` correctly skips re-renders when only tooltip position changes, eliminating the state-update race that caused hover fills to flash back to defaults.
|
||||
Reference: `Docs/Features.md` §4 (World Map View) for the full feature specification.
|
||||
|
||||
**Symptom:** On mouse-over, a country's stroke correctly becomes bold (`strokeWidth: 1`), but the background fill colour sometimes stays at the default value instead of switching to the hover colour.
|
||||
**All existing features must be preserved.** The component's public API (`WorldMapProps`) and behaviour must remain identical so that `MapPage.tsx`, `HistoryPage.tsx`, and the existing unit test continue to work after the migration.
|
||||
|
||||
**Root cause — `React.memo` is defeated by new style objects on every render:**
|
||||
---
|
||||
|
||||
`Geography.js` (`react-simple-maps-main/src/components/Geography.js`) is wrapped in `memo()`. It keeps an internal `isFocused` state that toggles between `style.default` and `style.hover`. This works correctly in isolation.
|
||||
#### Task 1 — Swap npm dependencies [DONE]
|
||||
|
||||
However, in `WorldMap.tsx` (`frontend/src/components/WorldMap.tsx`):
|
||||
Remove `react-simple-maps` and `@types/react-simple-maps` from `frontend/package.json`. Add the following packages that the new implementation requires:
|
||||
|
||||
1. **`onMouseMove`** calls `setTooltip(...)` on every pixel of mouse movement, triggering a GeoLayer re-render.
|
||||
2. Each GeoLayer render creates a **new `style` object literal** for every `<Geography>` (lines ~199–222). Because `memo()` does a shallow reference comparison, it sees a "new" `style` prop every time and **re-renders all ~200 Geography components** on every mouse-move event.
|
||||
3. During these rapid cascading re-renders, React's batched state updates can cause `Geography`'s internal `setFocus(true)` (fired in `handleMouseEnter`) and the parent's `setTooltip` to race. When the parent re-render is processed before `isFocused` is committed, Geography momentarily renders with `isFocused = false` and picks `style.default` — the fill stays at the default colour while the stroke (which changes less dramatically) appears correct.
|
||||
- `d3-geo` — geographic projection and SVG path generation.
|
||||
- `@types/d3-geo` — TypeScript definitions for d3-geo.
|
||||
- `topojson-client` — converts TopoJSON to GeoJSON `FeatureCollection`.
|
||||
- `@types/topojson-client` — TypeScript definitions for topojson-client.
|
||||
- `world-atlas` — provides the `countries-110m.json` TopoJSON file as a local npm asset (no more CDN fetch at runtime).
|
||||
|
||||
A secondary contributing factor: for countries with `count === 0` that are not selected, `style.hover.fill` equals `style.default.fill` (both resolve to `fillColor`), so there is intentionally no visible fill change on hover for those countries. This may overlap with the perceived bug if the user expects all countries to highlight.
|
||||
Run `npm install` and verify the lock file updates cleanly.
|
||||
|
||||
**Fix — memoize the style objects so `memo()` can skip re-renders:**
|
||||
---
|
||||
|
||||
In `WorldMap.tsx`, compute each Geography's style object with `useMemo` (or move it outside the `.map()` callback with stable references). Concretely:
|
||||
#### Task 2 — Rewrite `WorldMap.tsx` to use d3-geo directly [DONE]
|
||||
|
||||
1. **Create a `useMemo`-based style map** keyed by `geo.rsmKey` that only recomputes when `countries`, `selectedCountry`, or the threshold props change — not on every tooltip update.
|
||||
Rewrite the component so that it renders a plain `<svg>` with `<path>` elements generated by d3-geo instead of the react-simple-maps wrappers. The implementation should follow this approach (as seen in the reference project):
|
||||
|
||||
```tsx
|
||||
const styleMap = useMemo(() => {
|
||||
const map: Record<string, { default: CSSProperties; hover: CSSProperties; pressed: CSSProperties }> = {};
|
||||
for (const geo of geographies as { rsmKey: string; id: string | number }[]) {
|
||||
const numericId = String(geo.id);
|
||||
const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count = cc !== null ? (countries[cc] ?? 0) : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
|
||||
map[geo.rsmKey] = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}, [geographies, countries, selectedCountry, thresholdLow, thresholdMedium, thresholdHigh]);
|
||||
```
|
||||
1. **Import the TopoJSON locally** — `import worldData from "world-atlas/countries-110m.json"` instead of fetching from a CDN URL. Use `topojson-client`'s `feature()` to extract the GeoJSON `FeatureCollection` once (memoised).
|
||||
|
||||
2. **Pass the memoized style** in the `.map()` callback:
|
||||
```tsx
|
||||
<Geography style={styleMap[geo.rsmKey]} ... />
|
||||
```
|
||||
2. **Create a projection** — Use `geoMercator()` from d3-geo (matching the current Mercator projection) with `.fitSize([width, height], featureCollection)` to auto-scale. Memoise the projection so it is only recomputed when the geometry changes.
|
||||
|
||||
3. **Wrap event handlers in `useCallback`** (`onMouseEnter`, `onMouseMove`, `onMouseLeave`) or move them outside the `.map()` so they are stable references and do not defeat `memo()`. Consider passing `cc`, `count`, and `countryNames` as data attributes and reading them from `e.currentTarget` inside a single shared handler.
|
||||
3. **Create a path generator** — `geoPath().projection(projection)`. Memoise.
|
||||
|
||||
With stable `style` references, `memo(Geography)` will correctly skip re-renders for countries whose data has not changed, eliminating the state-update race condition and the unnecessary rendering of ~200 SVG paths on every mouse-move event.
|
||||
4. **Render countries** — Map over the GeoJSON features and render a `<path>` element for each country. Use the `ISO_NUMERIC_TO_ALPHA2` lookup (already exists in `frontend/src/data/isoNumericToAlpha2.ts`) to translate the numeric feature id to the alpha-2 code expected by the `countries` prop.
|
||||
|
||||
**Files to change:**
|
||||
- `frontend/src/components/WorldMap.tsx` — memoize style objects and stabilize event handlers.
|
||||
- No changes needed in `react-simple-maps-main/src/components/Geography.js`.
|
||||
5. **Preserve colour coding** — Continue using `getBanCountColor()` from `frontend/src/utils/mapColors.ts` to compute each country's fill colour based on its ban count and the three threshold props.
|
||||
|
||||
6. **Preserve ban-count labels** — For every country with `count > 0`, compute the centroid with `pathGenerator.centroid(feature)` and render a `<text>` element at that position showing the count. Countries with zero bans must remain blank and transparent (no fill, no label).
|
||||
|
||||
7. **Preserve country selection** — Clicking a country calls `onSelectCountry` with the alpha-2 code (or `null` to deselect). The selected country must receive a distinct brand fill colour, matching the current behaviour.
|
||||
|
||||
8. **Preserve hover tooltip** — On `mouseenter` / `mousemove` / `mouseleave`, show/hide a tooltip portal (`createPortal` into `document.body`) displaying the country name and ban count. Use the same Fluent UI styled tooltip div that the current implementation uses.
|
||||
|
||||
9. **Preserve keyboard accessibility** — Each country with a known alpha-2 code must have `role="button"`, `tabIndex={0}`, an `aria-label` (`"CC: N ban(s)"`), and `aria-pressed` when selected. `Enter` and `Space` must trigger selection/deselection.
|
||||
|
||||
10. **Use a `viewBox`-based responsive SVG** — Set `viewBox="0 0 {width} {height}"` and `style={{ width: "100%", height: "auto" }}` so the map scales with its container, matching the reference project's approach.
|
||||
|
||||
---
|
||||
|
||||
#### Task 3 — Implement zoom and pan without `react-simple-maps` [DONE]
|
||||
|
||||
The current implementation relies on `ZoomableGroup` from react-simple-maps for zoom/pan. Reimplement this using a `<g>` wrapper with an SVG `transform` attribute driven by React state:
|
||||
|
||||
1. **State:** Track `zoom` (number, 1–8) and `center` (translate offset `[x, y]`).
|
||||
|
||||
2. **Zoom controls:** Keep the three overlay buttons (Zoom In `+`, Zoom Out `−`, Reset `⟲`) in the top-right corner. Each button adjusts the `zoom` state by ±0.5, clamped to `[1, 8]`. Reset sets zoom to 1 and center to `[0, 0]`.
|
||||
|
||||
3. **Mouse-wheel zoom:** Attach a `wheel` event handler to the SVG that increments/decrements zoom on scroll, zooming toward the cursor position.
|
||||
|
||||
4. **Click-and-drag pan:** Track `mousedown` → `mousemove` → `mouseup` on the SVG to translate the `center` offset. Only pan when the drag exceeds a small threshold (e.g. 3 px) to avoid conflicting with country click events.
|
||||
|
||||
5. **Touch support (stretch goal):** Optionally support pinch-to-zoom and touch-drag for tablet users.
|
||||
|
||||
6. **Apply transform:** Wrap all `<path>` and `<text>` elements in a `<g transform="translate(tx, ty) scale(zoom)">` group. Alternatively, use `d3-zoom` if a more robust implementation is preferred, but keep React as the rendering layer (no d3 DOM manipulation).
|
||||
|
||||
---
|
||||
|
||||
#### Task 4 — Update hover and selection styles to use CSS transitions [DONE]
|
||||
|
||||
The reference project applies hover highlights via CSS classes (`.country`, `.country.hovered`) with CSS `transition` instead of the react-simple-maps `style={{ default, hover, pressed }}` object. Adopt the same approach:
|
||||
|
||||
- Define CSS classes (or Fluent UI `makeStyles` rules) for default, hovered, and selected states.
|
||||
- Apply the correct class based on component state (`isSelected`, `isHovered`).
|
||||
- Use a CSS `transition` on `fill` and `stroke` for a smooth 150 ms highlight effect.
|
||||
- This avoids the react-simple-maps per-geography style object entirely.
|
||||
|
||||
Ensure the selected state still uses `tokens.colorBrandBackground` / `tokens.colorBrandBackgroundHover` / `tokens.colorBrandBackgroundPressed` from Fluent UI so the map integrates visually with the rest of the application.
|
||||
|
||||
---
|
||||
|
||||
#### Task 5 — Update the WorldMap unit test [DONE]
|
||||
|
||||
The existing test at `frontend/src/components/__tests__/WorldMap.test.tsx` mocks `react-simple-maps`. After the migration those mocks are invalid. Update the test:
|
||||
|
||||
1. **Remove the `vi.mock("react-simple-maps", ...)` block.**
|
||||
|
||||
2. **Mock the TopoJSON data instead.** Since the new implementation imports `world-atlas/countries-110m.json` directly, mock that module to return a minimal TopoJSON object containing a single country feature (e.g. id `"840"` for the US). Use `topojson-client`'s `feature()` to verify the mock produces a valid GeoJSON feature.
|
||||
|
||||
3. **Keep the same assertions:** tooltip appears on hover with country name and ban count, tooltip disappears on mouse leave, country element has correct ARIA attributes (`role="button"`, `aria-label`, `aria-pressed`).
|
||||
|
||||
4. **Verify zoom controls render:** assert that the three zoom buttons (Zoom In, Zoom Out, Reset) are present and have the correct `aria-label` values.
|
||||
|
||||
5. Also verify that tests in `MapPage.test.tsx` and `HistoryPage.test.tsx` still pass (they mock `WorldMap` at the component level so they should be unaffected, but confirm).
|
||||
|
||||
---
|
||||
|
||||
#### Task 6 — Remove CDN dependency and verify offline capability [DONE]
|
||||
|
||||
The old implementation fetched geography data from `https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json` at runtime. The new implementation bundles the data via the `world-atlas` npm package, so:
|
||||
|
||||
1. Delete the `GEO_URL` constant.
|
||||
2. Confirm the TopoJSON file is included in the Vite bundle (imported as a JSON module).
|
||||
3. Verify the map renders correctly without any network request for geography data (check the browser network tab or write a test that asserts no fetch calls are made for the old CDN URL).
|
||||
|
||||
---
|
||||
|
||||
#### Task 7 — Final integration smoke test [DONE]
|
||||
|
||||
After all changes, manually verify the following against the feature specification in `Docs/Features.md` §4:
|
||||
|
||||
- Countries are colour-coded by ban count (transparent → green → yellow → red) using smooth interpolation.
|
||||
- Ban count numbers are displayed centred inside each country that has bans.
|
||||
- Countries with zero bans are transparent with no label.
|
||||
- Clicking a country filters the companion ban table below.
|
||||
- Clicking the same country again deselects it.
|
||||
- Zoom in / zoom out / reset buttons work correctly (range 1×–8×).
|
||||
- Mouse-wheel zoom and click-drag pan work.
|
||||
- Tooltip appears on hover showing country name and localised ban count.
|
||||
- Keyboard navigation works (Tab to focus, Enter/Space to toggle selection).
|
||||
- The map is responsive and scales with the container width.
|
||||
- Time-range selector on `MapPage` still updates the map data correctly.
|
||||
- Colour thresholds from settings are applied (thresholdLow, thresholdMedium, thresholdHigh props).
|
||||
- Run `npm run test` — all existing tests pass.
|
||||
- Run `npm run build` — production build succeeds with no errors or warnings.
|
||||
|
||||
190
frontend/package-lock.json
generated
190
frontend/package-lock.json
generated
@@ -1,30 +1,33 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.14",
|
||||
"version": "0.9.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.14",
|
||||
"version": "0.9.15",
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"d3-geo": "^3.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"recharts": "^3.8.0"
|
||||
"recharts": "^3.8.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/node": "^25.3.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/topojson-client": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
||||
"@typescript-eslint/parser": "^8.13.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
@@ -3565,23 +3568,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz",
|
||||
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz",
|
||||
"integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
@@ -3597,12 +3592,6 @@
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz",
|
||||
"integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
@@ -3624,16 +3613,6 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz",
|
||||
"integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "^2",
|
||||
"@types/d3-selection": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -3652,6 +3631,7 @@
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
@@ -3696,16 +3676,25 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-simple-maps": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz",
|
||||
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==",
|
||||
"node_modules/@types/topojson-client": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-geo": "^2",
|
||||
"@types/d3-zoom": "^2",
|
||||
"@types/geojson": "*",
|
||||
"@types/react": "*"
|
||||
"@types/topojson-specification": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/topojson-specification": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
|
||||
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
@@ -4476,28 +4465,6 @@
|
||||
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
|
||||
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
|
||||
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 2",
|
||||
"d3-selection": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
|
||||
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
@@ -4508,12 +4475,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
|
||||
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^2.5.0"
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
@@ -4550,12 +4520,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
|
||||
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
@@ -4592,41 +4556,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
|
||||
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
|
||||
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 2",
|
||||
"d3-dispatch": "1 - 2",
|
||||
"d3-ease": "1 - 2",
|
||||
"d3-interpolate": "1 - 2",
|
||||
"d3-timer": "1 - 2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
|
||||
"integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 2",
|
||||
"d3-drag": "2",
|
||||
"d3-interpolate": "1 - 2",
|
||||
"d3-selection": "2",
|
||||
"d3-transition": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
@@ -5745,16 +5674,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -5982,18 +5901,6 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -6110,23 +6017,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-simple-maps": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
|
||||
"integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-geo": "^2.0.2",
|
||||
"d3-selection": "^2.0.0",
|
||||
"d3-zoom": "^2.0.0",
|
||||
"topojson-client": "^3.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.8.0 || 17.x || 18.x",
|
||||
"react-dom": "^16.8.0 || 17.x || 18.x"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
@@ -7516,6 +7406,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/world-atlas": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
|
||||
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
@@ -17,14 +17,17 @@
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"d3-geo": "^3.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/topojson-client": "^3.0.0",
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
/**
|
||||
* 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.
|
||||
* Uses a local TopoJSON bundle and d3-geo for projection, path generation,
|
||||
* and native SVG pan/zoom behaviour.
|
||||
*/
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { geoMercator, geoPath, type GeoPath } from "d3-geo";
|
||||
import { feature } from "topojson-client";
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
GeoJsonProperties,
|
||||
Geometry,
|
||||
} from "geojson";
|
||||
import type {
|
||||
GeometryCollection as TopoGeometryCollection,
|
||||
Topology,
|
||||
} from "topojson-specification";
|
||||
import worldData from "world-atlas/countries-110m.json";
|
||||
import { useCardStyles } from "../theme/commonStyles";
|
||||
import type { CSSProperties } from "react";
|
||||
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 MAP_WIDTH = 800;
|
||||
const MAP_HEIGHT = 400;
|
||||
const MIN_ZOOM = 1;
|
||||
const MAX_ZOOM = 8;
|
||||
const ZOOM_STEP = 0.5;
|
||||
const PAN_THRESHOLD = 3;
|
||||
|
||||
const useStyles = makeStyles({
|
||||
mapWrapper: {
|
||||
@@ -34,6 +44,25 @@ const useStyles = makeStyles({
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
svg: {
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
touchAction: "none",
|
||||
},
|
||||
country: {
|
||||
transition: "fill 150ms ease, stroke 150ms ease",
|
||||
stroke: tokens.colorNeutralStroke2,
|
||||
strokeWidth: 0.75,
|
||||
fill: "var(--country-fill)",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
},
|
||||
countryHovered: {
|
||||
fill: "var(--country-hover-fill)",
|
||||
},
|
||||
countrySelected: {
|
||||
fill: "var(--country-selected-fill)",
|
||||
},
|
||||
countLabel: {
|
||||
fontSize: "9px",
|
||||
fontWeight: "600",
|
||||
@@ -74,212 +103,21 @@ const useStyles = makeStyles({
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GeoLayer — must be rendered inside ComposableMap to access map context
|
||||
// ---------------------------------------------------------------------------
|
||||
type TopoJsonTopology = Topology & {
|
||||
objects: {
|
||||
countries: TopoGeometryCollection;
|
||||
};
|
||||
};
|
||||
|
||||
interface GeoLayerProps {
|
||||
countries: Record<string, number>;
|
||||
countryNames?: Record<string, string>;
|
||||
selectedCountry: string | null;
|
||||
onSelectCountry: (cc: string | null) => void;
|
||||
thresholdLow: number;
|
||||
thresholdMedium: number;
|
||||
thresholdHigh: number;
|
||||
}
|
||||
type TooltipState = {
|
||||
cc: string;
|
||||
count: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
// Stable event handlers — shared across all Geography components so
|
||||
// React.memo is not defeated by new function references each render.
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<SVGPathElement>): void => {
|
||||
const cc = e.currentTarget.dataset.cc;
|
||||
if (!cc) return;
|
||||
const count = Number(e.currentTarget.dataset.count);
|
||||
const name = e.currentTarget.dataset.name ?? cc;
|
||||
setTooltip({ cc, count, name, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGPathElement>): void => {
|
||||
setTooltip((current) =>
|
||||
current
|
||||
? { ...current, x: e.clientX, y: e.clientY }
|
||||
: current,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
// Memoize style objects so Geography's React.memo can skip re-renders
|
||||
// when only the tooltip position changes.
|
||||
type GeoStyle = { default: CSSProperties; hover: CSSProperties; pressed: CSSProperties };
|
||||
const styleMap = useMemo(() => {
|
||||
const map: Record<string, GeoStyle> = {};
|
||||
for (const geo of geographies as { rsmKey: string; id: string | number }[]) {
|
||||
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 fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
|
||||
map[geo.rsmKey] = {
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}, [geographies, countries, selectedCountry, thresholdLow, thresholdMedium, thresholdHigh]);
|
||||
|
||||
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;
|
||||
|
||||
// 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" }}>
|
||||
<Geography
|
||||
geography={geo}
|
||||
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={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-cc={cc ?? undefined}
|
||||
data-count={cc ? String(count) : undefined}
|
||||
data-name={cc ? (countryNames?.[cc] ?? cc) : undefined}
|
||||
style={styleMap[geo.rsmKey]}
|
||||
/>
|
||||
{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 {
|
||||
interface WorldMapProps {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** Optional mapping from country code to display name. */
|
||||
@@ -307,21 +145,143 @@ export function WorldMap({
|
||||
}: WorldMapProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const cardStyles = useCardStyles();
|
||||
const [zoom, setZoom] = useState<number>(1);
|
||||
const [zoom, setZoom] = useState<number>(MIN_ZOOM);
|
||||
const [center, setCenter] = useState<[number, number]>([0, 0]);
|
||||
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState>(null);
|
||||
|
||||
const handleZoomIn = (): void => {
|
||||
setZoom((z) => Math.min(z + 0.5, 8));
|
||||
};
|
||||
const zoomRef = useRef<number>(zoom);
|
||||
const centerRef = useRef<[number, number]>(center);
|
||||
const dragStateRef = useRef<{
|
||||
active: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startCenter: [number, number];
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const clickSuppressedRef = useRef<boolean>(false);
|
||||
|
||||
const handleZoomOut = (): void => {
|
||||
setZoom((z) => Math.max(z - 0.5, 1));
|
||||
};
|
||||
useEffect(() => {
|
||||
zoomRef.current = zoom;
|
||||
}, [zoom]);
|
||||
|
||||
const handleResetView = (): void => {
|
||||
setZoom(1);
|
||||
useEffect(() => {
|
||||
centerRef.current = center;
|
||||
}, [center]);
|
||||
|
||||
const topology = useMemo(() => worldData as unknown as TopoJsonTopology, []);
|
||||
|
||||
const geoJson = useMemo(
|
||||
() =>
|
||||
feature(topology, topology.objects.countries) as FeatureCollection<
|
||||
Geometry,
|
||||
GeoJsonProperties
|
||||
>,
|
||||
[topology],
|
||||
);
|
||||
|
||||
const projection = useMemo(
|
||||
() => geoMercator().fitSize([MAP_WIDTH, MAP_HEIGHT], geoJson),
|
||||
[geoJson],
|
||||
);
|
||||
|
||||
const pathGenerator = useMemo<GeoPath<unknown, Feature<Geometry, GeoJsonProperties>>>(
|
||||
() => geoPath().projection(projection),
|
||||
[projection],
|
||||
);
|
||||
|
||||
const countryFeatures = useMemo(
|
||||
() => geoJson.features.filter((feature) => feature.id != null && feature.geometry != null),
|
||||
[geoJson.features],
|
||||
);
|
||||
|
||||
const clampZoom = useCallback((value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM), []);
|
||||
|
||||
const handleCountrySelect = useCallback(
|
||||
(cc: string | null): void => {
|
||||
if (clickSuppressedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectCountry(selectedCountry === cc ? null : cc);
|
||||
},
|
||||
[onSelectCountry, selectedCountry],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragStateRef.current = {
|
||||
active: true,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startCenter: centerRef.current,
|
||||
moved: false,
|
||||
};
|
||||
clickSuppressedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag?.active) return;
|
||||
|
||||
const dx = event.clientX - drag.startX;
|
||||
const dy = event.clientY - drag.startY;
|
||||
if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
|
||||
drag.moved = true;
|
||||
clickSuppressedRef.current = true;
|
||||
}
|
||||
|
||||
setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]);
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
window.setTimeout(() => {
|
||||
clickSuppressedRef.current = false;
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback((event: React.WheelEvent<SVGSVGElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const currentZoom = zoomRef.current;
|
||||
const desiredZoom = clampZoom(currentZoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));
|
||||
if (desiredZoom === currentZoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const svgX = (event.clientX - rect.left - centerRef.current[0]) / currentZoom;
|
||||
const svgY = (event.clientY - rect.top - centerRef.current[1]) / currentZoom;
|
||||
|
||||
setZoom(desiredZoom);
|
||||
setCenter([
|
||||
centerRef.current[0] - svgX * (desiredZoom - currentZoom),
|
||||
centerRef.current[1] - svgY * (desiredZoom - currentZoom),
|
||||
]);
|
||||
}, [clampZoom]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom((value) => clampZoom(value + ZOOM_STEP));
|
||||
}, [clampZoom]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom((value) => clampZoom(value - ZOOM_STEP));
|
||||
}, [clampZoom]);
|
||||
|
||||
const handleResetView = useCallback(() => {
|
||||
setZoom(MIN_ZOOM);
|
||||
setCenter([0, 0]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -329,13 +289,12 @@ export function WorldMap({
|
||||
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}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
@@ -345,7 +304,7 @@ export function WorldMap({
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
@@ -355,7 +314,7 @@ export function WorldMap({
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleResetView}
|
||||
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
|
||||
disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
|
||||
title="Reset view"
|
||||
aria-label="Reset view"
|
||||
>
|
||||
@@ -363,34 +322,126 @@ export function WorldMap({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{ scale: 130, center: [10, 20] }}
|
||||
width={800}
|
||||
height={400}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
<svg
|
||||
className={styles.svg}
|
||||
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
|
||||
role="img"
|
||||
aria-label="World map showing banned IP counts by country."
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<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>
|
||||
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
|
||||
{countryFeatures.map((featureItem) => {
|
||||
const rawId = featureItem.id;
|
||||
const numericId = String(Number(rawId));
|
||||
const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count = cc !== null ? countries[cc] ?? 0 : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
|
||||
const pathString = pathGenerator(featureItem) ?? "";
|
||||
if (!pathString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const centroid = pathGenerator.centroid(featureItem);
|
||||
const [cx, cy] = centroid;
|
||||
const isCentroidValid = Number.isFinite(cx) && Number.isFinite(cy);
|
||||
|
||||
return (
|
||||
<g key={String(rawId)}>
|
||||
<path
|
||||
d={pathString}
|
||||
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}
|
||||
className={`${styles.country} ${
|
||||
isSelected ? styles.countrySelected : ""
|
||||
} ${hoveredCountry === cc ? styles.countryHovered : ""}`}
|
||||
style={
|
||||
{
|
||||
["--country-fill" as string]: fillColor,
|
||||
["--country-hover-fill" as string]: isSelected
|
||||
? tokens.colorBrandBackgroundHover
|
||||
: tokens.colorBrandBackground2,
|
||||
["--country-selected-fill" as string]: tokens.colorBrandBackground,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={(): void => {
|
||||
if (cc) {
|
||||
handleCountrySelect(cc);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event): void => {
|
||||
if (cc && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
handleCountrySelect(cc);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(event): void => {
|
||||
if (!cc) return;
|
||||
setHoveredCountry(cc);
|
||||
setTooltip({
|
||||
cc,
|
||||
count,
|
||||
name: countryNames?.[cc] ?? cc,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
}}
|
||||
onMouseMove={(event): void => {
|
||||
setTooltip((current) =>
|
||||
current
|
||||
? { ...current, x: event.clientX, y: event.clientY }
|
||||
: current,
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHoveredCountry(null);
|
||||
setTooltip(null);
|
||||
}}
|
||||
/>
|
||||
{count > 0 && isCentroidValid && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className={styles.countLabel}
|
||||
>
|
||||
{count}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{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,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,16 +8,31 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
|
||||
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry.
|
||||
vi.mock("react-simple-maps", () => ({
|
||||
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Geography: ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => <g {...props}>{children}</g>,
|
||||
useGeographies: () => ({
|
||||
geographies: [{ rsmKey: "geo-1", id: 840 }],
|
||||
path: { centroid: () => [10, 10] },
|
||||
vi.mock(
|
||||
"world-atlas/countries-110m.json",
|
||||
() => ({
|
||||
default: {
|
||||
type: "Topology",
|
||||
objects: {
|
||||
countries: {
|
||||
type: "GeometryCollection",
|
||||
geometries: [
|
||||
{
|
||||
type: "Polygon",
|
||||
arcs: [[0]],
|
||||
id: "840",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
arcs: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]],
|
||||
transform: {
|
||||
scale: [1, 1],
|
||||
translate: [0, 0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
);
|
||||
|
||||
import { WorldMap } from "../WorldMap";
|
||||
|
||||
@@ -34,19 +49,20 @@ describe("WorldMap", () => {
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Tooltip should not be present initially
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
// Country map area is exposed as an accessible button with an accurate label
|
||||
const countryButton = screen.getByRole("button", { name: "US: 42 bans" });
|
||||
expect(countryButton).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole("button", { name: /Zoom in/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Zoom out/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Reset view/i })).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
|
||||
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent("United States");
|
||||
expect(tooltip).toHaveTextContent("42 bans");
|
||||
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
|
||||
|
||||
fireEvent.mouseLeave(countryButton);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"4": "AF",
|
||||
"8": "AL",
|
||||
"10": "AQ",
|
||||
"12": "DZ",
|
||||
"16": "AS",
|
||||
"20": "AD",
|
||||
@@ -46,6 +47,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"148": "TD",
|
||||
"152": "CL",
|
||||
"156": "CN",
|
||||
"158": "TW",
|
||||
"162": "CX",
|
||||
"166": "CC",
|
||||
"170": "CO",
|
||||
@@ -76,6 +78,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"250": "FR",
|
||||
"254": "GF",
|
||||
"258": "PF",
|
||||
"260": "TF",
|
||||
"262": "DJ",
|
||||
"266": "GA",
|
||||
"268": "GE",
|
||||
@@ -107,6 +110,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"372": "IE",
|
||||
"376": "IL",
|
||||
"380": "IT",
|
||||
"384": "CI",
|
||||
"388": "JM",
|
||||
"392": "JP",
|
||||
"398": "KZ",
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
|
||||
Reference in New Issue
Block a user