diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 90e7088..d43185d 100644 --- a/Docs/Tasks.md +++ b/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 `` (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 `` with `` 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 = {}; - 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 - - ``` +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 `` 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 `` 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 `` 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 `` and `` elements in a `` 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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 35bb71d..2aba3b3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 27ebe1e..68e61d8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/WorldMap.tsx b/frontend/src/components/WorldMap.tsx index d9b4871..ab4952e 100644 --- a/frontend/src/components/WorldMap.tsx +++ b/frontend/src/components/WorldMap.tsx @@ -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; - countryNames?: Record; - 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): 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): 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 = {}; - 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 ( - - { - 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) && ( - - {count} - - )} - - ); - }, - )} - - {tooltip && - createPortal( -
- {tooltip.name} - - {tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""} - -
, - document.body, - )} - - ); -} - -// --------------------------------------------------------------------------- -// WorldMap — public component -// --------------------------------------------------------------------------- - -export interface WorldMapProps { +interface WorldMapProps { /** ISO alpha-2 country code → ban count. */ countries: Record; /** 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(1); + const [zoom, setZoom] = useState(MIN_ZOOM); const [center, setCenter] = useState<[number, number]>([0, 0]); + const [hoveredCountry, setHoveredCountry] = useState(null); + const [tooltip, setTooltip] = useState(null); - const handleZoomIn = (): void => { - setZoom((z) => Math.min(z + 0.5, 8)); - }; + const zoomRef = useRef(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(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().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) => { + 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) => { + 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) => { + 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) => { + 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 (
- {/* Zoom controls */}
- - { - setZoom(newZoom); - setCenter(coordinates); - }} - minZoom={1} - maxZoom={8} - > - - - + + {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 ( + + { + 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 && ( + + {count} + + )} + + ); + })} + + + + {tooltip && + createPortal( +
+ {tooltip.name} + + {tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""} + +
, + document.body, + )}
); } diff --git a/frontend/src/components/__tests__/WorldMap.test.tsx b/frontend/src/components/__tests__/WorldMap.test.tsx index 4d70cda..9980b76 100644 --- a/frontend/src/components/__tests__/WorldMap.test.tsx +++ b/frontend/src/components/__tests__/WorldMap.test.tsx @@ -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 }) =>
{children}
, - ZoomableGroup: ({ children }: { children: React.ReactNode }) =>
{children}
, - Geography: ({ children, ...props }: { children?: React.ReactNode } & Record) => {children}, - 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", () => { , ); - // 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(); diff --git a/frontend/src/data/isoNumericToAlpha2.ts b/frontend/src/data/isoNumericToAlpha2.ts index cca5dc2..b623bfa 100644 --- a/frontend/src/data/isoNumericToAlpha2.ts +++ b/frontend/src/data/isoNumericToAlpha2.ts @@ -8,6 +8,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record = { "4": "AF", "8": "AL", + "10": "AQ", "12": "DZ", "16": "AS", "20": "AD", @@ -46,6 +47,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record = { "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 = { "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 = { "372": "IE", "376": "IL", "380": "IT", + "384": "CI", "388": "JM", "392": "JP", "398": "KZ", diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 0235e1d..04823da 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -8,6 +8,8 @@ /* Bundler mode */ "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force",