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
|
## 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.
|
- `d3-geo` — geographic projection and SVG path generation.
|
||||||
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.
|
- `@types/d3-geo` — TypeScript definitions for d3-geo.
|
||||||
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.
|
- `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
|
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).
|
||||||
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]);
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Pass the memoized style** in the `.map()` callback:
|
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.
|
||||||
```tsx
|
|
||||||
<Geography style={styleMap[geo.rsmKey]} ... />
|
|
||||||
```
|
|
||||||
|
|
||||||
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:**
|
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.
|
||||||
- `frontend/src/components/WorldMap.tsx` — memoize style objects and stabilize event handlers.
|
|
||||||
- No changes needed in `react-simple-maps-main/src/components/Geography.js`.
|
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",
|
"name": "bangui-frontend",
|
||||||
"version": "0.9.14",
|
"version": "0.9.15",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "bangui-frontend",
|
"name": "bangui-frontend",
|
||||||
"version": "0.9.14",
|
"version": "0.9.15",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react-components": "^9.55.0",
|
"@fluentui/react-components": "^9.55.0",
|
||||||
"@fluentui/react-icons": "^2.0.257",
|
"@fluentui/react-icons": "^2.0.257",
|
||||||
"@types/react-simple-maps": "^3.0.6",
|
"d3-geo": "^3.1.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.27.0",
|
"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": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.13.0",
|
"@eslint/js": "^9.13.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
|
"@types/d3-geo": "^3.1.0",
|
||||||
"@types/node": "^25.3.2",
|
"@types/node": "^25.3.2",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@types/topojson-client": "^3.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
||||||
"@typescript-eslint/parser": "^8.13.0",
|
"@typescript-eslint/parser": "^8.13.0",
|
||||||
"@vitejs/plugin-react": "^4.3.3",
|
"@vitejs/plugin-react": "^4.3.3",
|
||||||
@@ -3565,23 +3568,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/d3-geo": {
|
"node_modules/@types/d3-geo": {
|
||||||
"version": "2.0.7",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||||
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==",
|
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/geojson": "*"
|
"@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": {
|
"node_modules/@types/d3-path": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
@@ -3597,12 +3592,6 @@
|
|||||||
"@types/d3-time": "*"
|
"@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": {
|
"node_modules/@types/d3-shape": {
|
||||||
"version": "3.1.8",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
@@ -3624,16 +3613,6 @@
|
|||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -3652,6 +3631,7 @@
|
|||||||
"version": "7946.0.16",
|
"version": "7946.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
@@ -3696,16 +3676,25 @@
|
|||||||
"@types/react": "^18.0.0"
|
"@types/react": "^18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react-simple-maps": {
|
"node_modules/@types/topojson-client": {
|
||||||
"version": "3.0.6",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||||
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==",
|
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3-geo": "^2",
|
|
||||||
"@types/d3-zoom": "^2",
|
|
||||||
"@types/geojson": "*",
|
"@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": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
@@ -4476,28 +4465,6 @@
|
|||||||
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/d3-format": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
@@ -4508,12 +4475,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-geo": {
|
"node_modules/d3-geo": {
|
||||||
"version": "2.0.2",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||||
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
|
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3-array": "^2.5.0"
|
"d3-array": "2.5.0 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/d3-interpolate": {
|
"node_modules/d3-interpolate": {
|
||||||
@@ -4550,12 +4520,6 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/d3-shape": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
@@ -4592,41 +4556,6 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
@@ -5745,16 +5674,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/obug": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||||
@@ -5982,18 +5901,6 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -6110,23 +6017,6 @@
|
|||||||
"react-dom": ">=16.8"
|
"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": {
|
"node_modules/recharts": {
|
||||||
"version": "3.8.0",
|
"version": "3.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||||
@@ -7516,6 +7406,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|||||||
@@ -17,14 +17,17 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fluentui/react-components": "^9.55.0",
|
"@fluentui/react-components": "^9.55.0",
|
||||||
"@fluentui/react-icons": "^2.0.257",
|
"@fluentui/react-icons": "^2.0.257",
|
||||||
"@types/react-simple-maps": "^3.0.6",
|
"d3-geo": "^3.1.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.27.0",
|
"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"
|
"recharts": "^3.8.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/d3-geo": "^3.1.0",
|
||||||
|
"@types/topojson-client": "^3.0.0",
|
||||||
"@eslint/js": "^9.13.0",
|
"@eslint/js": "^9.13.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
|
|||||||
@@ -1,32 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* WorldMap — SVG world map showing per-country ban counts.
|
* WorldMap — SVG world map showing per-country ban counts.
|
||||||
*
|
*
|
||||||
* Uses react-simple-maps with the Natural Earth 110m TopoJSON data from
|
* Uses a local TopoJSON bundle and d3-geo for projection, path generation,
|
||||||
* jsDelivr CDN. For each country that has bans in the selected time window,
|
* and native SVG pan/zoom behaviour.
|
||||||
* the total count is displayed inside the country's borders. Clicking a
|
|
||||||
* country filters the companion table.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import {
|
||||||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
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 { useCardStyles } from "../theme/commonStyles";
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import type { GeoPermissibleObjects } from "d3-geo";
|
|
||||||
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
|
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
|
||||||
import { getBanCountColor } from "../utils/mapColors";
|
import { getBanCountColor } from "../utils/mapColors";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
const MAP_WIDTH = 800;
|
||||||
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
|
const MAP_HEIGHT = 400;
|
||||||
// ---------------------------------------------------------------------------
|
const MIN_ZOOM = 1;
|
||||||
|
const MAX_ZOOM = 8;
|
||||||
const GEO_URL =
|
const ZOOM_STEP = 0.5;
|
||||||
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
const PAN_THRESHOLD = 3;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Styles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const useStyles = makeStyles({
|
const useStyles = makeStyles({
|
||||||
mapWrapper: {
|
mapWrapper: {
|
||||||
@@ -34,6 +44,25 @@ const useStyles = makeStyles({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
overflow: "hidden",
|
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: {
|
countLabel: {
|
||||||
fontSize: "9px",
|
fontSize: "9px",
|
||||||
fontWeight: "600",
|
fontWeight: "600",
|
||||||
@@ -74,212 +103,21 @@ const useStyles = makeStyles({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
type TopoJsonTopology = Topology & {
|
||||||
// GeoLayer — must be rendered inside ComposableMap to access map context
|
objects: {
|
||||||
// ---------------------------------------------------------------------------
|
countries: TopoGeometryCollection;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface GeoLayerProps {
|
type TooltipState = {
|
||||||
countries: Record<string, number>;
|
|
||||||
countryNames?: Record<string, string>;
|
|
||||||
selectedCountry: string | null;
|
|
||||||
onSelectCountry: (cc: string | null) => void;
|
|
||||||
thresholdLow: number;
|
|
||||||
thresholdMedium: number;
|
|
||||||
thresholdHigh: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
cc: string;
|
||||||
count: number;
|
count: number;
|
||||||
name: string;
|
name: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
} | null;
|
||||||
| null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const handleClick = useCallback(
|
interface WorldMapProps {
|
||||||
(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 {
|
|
||||||
/** ISO alpha-2 country code → ban count. */
|
/** ISO alpha-2 country code → ban count. */
|
||||||
countries: Record<string, number>;
|
countries: Record<string, number>;
|
||||||
/** Optional mapping from country code to display name. */
|
/** Optional mapping from country code to display name. */
|
||||||
@@ -307,21 +145,143 @@ export function WorldMap({
|
|||||||
}: WorldMapProps): React.JSX.Element {
|
}: WorldMapProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const cardStyles = useCardStyles();
|
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 [center, setCenter] = useState<[number, number]>([0, 0]);
|
||||||
|
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
|
||||||
|
const [tooltip, setTooltip] = useState<TooltipState>(null);
|
||||||
|
|
||||||
const handleZoomIn = (): void => {
|
const zoomRef = useRef<number>(zoom);
|
||||||
setZoom((z) => Math.min(z + 0.5, 8));
|
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);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
zoomRef.current = zoom;
|
||||||
|
}, [zoom]);
|
||||||
|
|
||||||
|
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 handleZoomOut = (): void => {
|
const handlePointerMove = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||||
setZoom((z) => Math.max(z - 0.5, 1));
|
const drag = dragStateRef.current;
|
||||||
};
|
if (!drag?.active) return;
|
||||||
|
|
||||||
const handleResetView = (): void => {
|
const dx = event.clientX - drag.startX;
|
||||||
setZoom(1);
|
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]);
|
setCenter([0, 0]);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -329,13 +289,12 @@ export function WorldMap({
|
|||||||
role="img"
|
role="img"
|
||||||
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
|
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
|
||||||
>
|
>
|
||||||
{/* Zoom controls */}
|
|
||||||
<div className={styles.zoomControls}>
|
<div className={styles.zoomControls}>
|
||||||
<Button
|
<Button
|
||||||
appearance="secondary"
|
appearance="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleZoomIn}
|
onClick={handleZoomIn}
|
||||||
disabled={zoom >= 8}
|
disabled={zoom >= MAX_ZOOM}
|
||||||
title="Zoom in"
|
title="Zoom in"
|
||||||
aria-label="Zoom in"
|
aria-label="Zoom in"
|
||||||
>
|
>
|
||||||
@@ -345,7 +304,7 @@ export function WorldMap({
|
|||||||
appearance="secondary"
|
appearance="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleZoomOut}
|
onClick={handleZoomOut}
|
||||||
disabled={zoom <= 1}
|
disabled={zoom <= MIN_ZOOM}
|
||||||
title="Zoom out"
|
title="Zoom out"
|
||||||
aria-label="Zoom out"
|
aria-label="Zoom out"
|
||||||
>
|
>
|
||||||
@@ -355,7 +314,7 @@ export function WorldMap({
|
|||||||
appearance="secondary"
|
appearance="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={handleResetView}
|
onClick={handleResetView}
|
||||||
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
|
disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
|
||||||
title="Reset view"
|
title="Reset view"
|
||||||
aria-label="Reset view"
|
aria-label="Reset view"
|
||||||
>
|
>
|
||||||
@@ -363,34 +322,126 @@ export function WorldMap({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ComposableMap
|
<svg
|
||||||
projection="geoMercator"
|
className={styles.svg}
|
||||||
projectionConfig={{ scale: 130, center: [10, 20] }}
|
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
|
||||||
width={800}
|
role="img"
|
||||||
height={400}
|
aria-label="World map showing banned IP counts by country."
|
||||||
style={{ width: "100%", height: "auto" }}
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerLeave={handlePointerUp}
|
||||||
|
onWheel={handleWheel}
|
||||||
>
|
>
|
||||||
<ZoomableGroup
|
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
|
||||||
zoom={zoom}
|
{countryFeatures.map((featureItem) => {
|
||||||
center={center}
|
const rawId = featureItem.id;
|
||||||
onMoveEnd={({ zoom: newZoom, coordinates }): void => {
|
const numericId = String(Number(rawId));
|
||||||
setZoom(newZoom);
|
const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||||
setCenter(coordinates);
|
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);
|
||||||
}}
|
}}
|
||||||
minZoom={1}
|
|
||||||
maxZoom={8}
|
|
||||||
>
|
|
||||||
<GeoLayer
|
|
||||||
countries={countries}
|
|
||||||
countryNames={countryNames}
|
|
||||||
selectedCountry={selectedCountry}
|
|
||||||
onSelectCountry={onSelectCountry}
|
|
||||||
thresholdLow={thresholdLow}
|
|
||||||
thresholdMedium={thresholdMedium}
|
|
||||||
thresholdHigh={thresholdHigh}
|
|
||||||
/>
|
/>
|
||||||
</ZoomableGroup>
|
{count > 0 && isCentroidValid && (
|
||||||
</ComposableMap>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,31 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||||
|
|
||||||
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry.
|
vi.mock(
|
||||||
vi.mock("react-simple-maps", () => ({
|
"world-atlas/countries-110m.json",
|
||||||
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
() => ({
|
||||||
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
default: {
|
||||||
Geography: ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => <g {...props}>{children}</g>,
|
type: "Topology",
|
||||||
useGeographies: () => ({
|
objects: {
|
||||||
geographies: [{ rsmKey: "geo-1", id: 840 }],
|
countries: {
|
||||||
path: { centroid: () => [10, 10] },
|
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";
|
import { WorldMap } from "../WorldMap";
|
||||||
|
|
||||||
@@ -34,19 +49,20 @@ describe("WorldMap", () => {
|
|||||||
</FluentProvider>,
|
</FluentProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tooltip should not be present initially
|
|
||||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
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" });
|
const countryButton = screen.getByRole("button", { name: "US: 42 bans" });
|
||||||
expect(countryButton).toBeInTheDocument();
|
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 });
|
fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
|
||||||
|
|
||||||
const tooltip = screen.getByRole("tooltip");
|
const tooltip = screen.getByRole("tooltip");
|
||||||
expect(tooltip).toHaveTextContent("United States");
|
expect(tooltip).toHaveTextContent("United States");
|
||||||
expect(tooltip).toHaveTextContent("42 bans");
|
expect(tooltip).toHaveTextContent("42 bans");
|
||||||
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
|
|
||||||
|
|
||||||
fireEvent.mouseLeave(countryButton);
|
fireEvent.mouseLeave(countryButton);
|
||||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||||
"4": "AF",
|
"4": "AF",
|
||||||
"8": "AL",
|
"8": "AL",
|
||||||
|
"10": "AQ",
|
||||||
"12": "DZ",
|
"12": "DZ",
|
||||||
"16": "AS",
|
"16": "AS",
|
||||||
"20": "AD",
|
"20": "AD",
|
||||||
@@ -46,6 +47,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
|||||||
"148": "TD",
|
"148": "TD",
|
||||||
"152": "CL",
|
"152": "CL",
|
||||||
"156": "CN",
|
"156": "CN",
|
||||||
|
"158": "TW",
|
||||||
"162": "CX",
|
"162": "CX",
|
||||||
"166": "CC",
|
"166": "CC",
|
||||||
"170": "CO",
|
"170": "CO",
|
||||||
@@ -76,6 +78,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
|||||||
"250": "FR",
|
"250": "FR",
|
||||||
"254": "GF",
|
"254": "GF",
|
||||||
"258": "PF",
|
"258": "PF",
|
||||||
|
"260": "TF",
|
||||||
"262": "DJ",
|
"262": "DJ",
|
||||||
"266": "GA",
|
"266": "GA",
|
||||||
"268": "GE",
|
"268": "GE",
|
||||||
@@ -107,6 +110,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
|||||||
"372": "IE",
|
"372": "IE",
|
||||||
"376": "IL",
|
"376": "IL",
|
||||||
"380": "IT",
|
"380": "IT",
|
||||||
|
"384": "CI",
|
||||||
"388": "JM",
|
"388": "JM",
|
||||||
"392": "JP",
|
"392": "JP",
|
||||||
"398": "KZ",
|
"398": "KZ",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
|
|||||||
Reference in New Issue
Block a user