Migrate WorldMap to d3-geo, fix TopoJSON country ID mappings and update tests
This commit is contained in:
190
frontend/package-lock.json
generated
190
frontend/package-lock.json
generated
@@ -1,30 +1,33 @@
|
||||
{
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.14",
|
||||
"version": "0.9.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bangui-frontend",
|
||||
"version": "0.9.14",
|
||||
"version": "0.9.15",
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"d3-geo": "^3.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"recharts": "^3.8.0"
|
||||
"recharts": "^3.8.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/node": "^25.3.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/topojson-client": "^3.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.13.0",
|
||||
"@typescript-eslint/parser": "^8.13.0",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
@@ -3565,23 +3568,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz",
|
||||
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz",
|
||||
"integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz",
|
||||
"integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
@@ -3597,12 +3592,6 @@
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz",
|
||||
"integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
@@ -3624,16 +3613,6 @@
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz",
|
||||
"integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "^2",
|
||||
"@types/d3-selection": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -3652,6 +3631,7 @@
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
@@ -3696,16 +3676,25 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-simple-maps": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz",
|
||||
"integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==",
|
||||
"node_modules/@types/topojson-client": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-geo": "^2",
|
||||
"@types/d3-zoom": "^2",
|
||||
"@types/geojson": "*",
|
||||
"@types/react": "*"
|
||||
"@types/topojson-specification": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/topojson-specification": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
|
||||
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
@@ -4476,28 +4465,6 @@
|
||||
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
|
||||
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
|
||||
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 2",
|
||||
"d3-selection": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
|
||||
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
@@ -4508,12 +4475,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
|
||||
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "^2.5.0"
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
@@ -4550,12 +4520,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
|
||||
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
@@ -4592,41 +4556,6 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
|
||||
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
|
||||
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 2",
|
||||
"d3-dispatch": "1 - 2",
|
||||
"d3-ease": "1 - 2",
|
||||
"d3-interpolate": "1 - 2",
|
||||
"d3-timer": "1 - 2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
|
||||
"integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 2",
|
||||
"d3-drag": "2",
|
||||
"d3-interpolate": "1 - 2",
|
||||
"d3-selection": "2",
|
||||
"d3-transition": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/data-urls": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||
@@ -5745,16 +5674,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
@@ -5982,18 +5901,6 @@
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -6110,23 +6017,6 @@
|
||||
"react-dom": ">=16.8"
|
||||
}
|
||||
},
|
||||
"node_modules/react-simple-maps": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
|
||||
"integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-geo": "^2.0.2",
|
||||
"d3-selection": "^2.0.0",
|
||||
"d3-zoom": "^2.0.0",
|
||||
"topojson-client": "^3.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.8.0 || 17.x || 18.x",
|
||||
"react-dom": "^16.8.0 || 17.x || 18.x"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.0",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz",
|
||||
@@ -7516,6 +7406,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/world-atlas": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
|
||||
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xml-name-validator": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||
|
||||
@@ -17,14 +17,17 @@
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.55.0",
|
||||
"@fluentui/react-icons": "^2.0.257",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"d3-geo": "^3.1.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2",
|
||||
"recharts": "^3.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-geo": "^3.1.0",
|
||||
"@types/topojson-client": "^3.0.0",
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
/**
|
||||
* WorldMap — SVG world map showing per-country ban counts.
|
||||
*
|
||||
* Uses react-simple-maps with the Natural Earth 110m TopoJSON data from
|
||||
* jsDelivr CDN. For each country that has bans in the selected time window,
|
||||
* the total count is displayed inside the country's borders. Clicking a
|
||||
* country filters the companion table.
|
||||
* Uses a local TopoJSON bundle and d3-geo for projection, path generation,
|
||||
* and native SVG pan/zoom behaviour.
|
||||
*/
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ComposableMap, ZoomableGroup, Geography, useGeographies } from "react-simple-maps";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button, makeStyles, tokens } from "@fluentui/react-components";
|
||||
import { geoMercator, geoPath, type GeoPath } from "d3-geo";
|
||||
import { feature } from "topojson-client";
|
||||
import type {
|
||||
Feature,
|
||||
FeatureCollection,
|
||||
GeoJsonProperties,
|
||||
Geometry,
|
||||
} from "geojson";
|
||||
import type {
|
||||
GeometryCollection as TopoGeometryCollection,
|
||||
Topology,
|
||||
} from "topojson-specification";
|
||||
import worldData from "world-atlas/countries-110m.json";
|
||||
import { useCardStyles } from "../theme/commonStyles";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { GeoPermissibleObjects } from "d3-geo";
|
||||
import { ISO_NUMERIC_TO_ALPHA2 } from "../data/isoNumericToAlpha2";
|
||||
import { getBanCountColor } from "../utils/mapColors";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static data URL — world-atlas 110m TopoJSON (No-fill, outline-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const GEO_URL =
|
||||
"https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Styles
|
||||
// ---------------------------------------------------------------------------
|
||||
const MAP_WIDTH = 800;
|
||||
const MAP_HEIGHT = 400;
|
||||
const MIN_ZOOM = 1;
|
||||
const MAX_ZOOM = 8;
|
||||
const ZOOM_STEP = 0.5;
|
||||
const PAN_THRESHOLD = 3;
|
||||
|
||||
const useStyles = makeStyles({
|
||||
mapWrapper: {
|
||||
@@ -34,6 +44,25 @@ const useStyles = makeStyles({
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
svg: {
|
||||
width: "100%",
|
||||
height: "auto",
|
||||
touchAction: "none",
|
||||
},
|
||||
country: {
|
||||
transition: "fill 150ms ease, stroke 150ms ease",
|
||||
stroke: tokens.colorNeutralStroke2,
|
||||
strokeWidth: 0.75,
|
||||
fill: "var(--country-fill)",
|
||||
outline: "none",
|
||||
cursor: "pointer",
|
||||
},
|
||||
countryHovered: {
|
||||
fill: "var(--country-hover-fill)",
|
||||
},
|
||||
countrySelected: {
|
||||
fill: "var(--country-selected-fill)",
|
||||
},
|
||||
countLabel: {
|
||||
fontSize: "9px",
|
||||
fontWeight: "600",
|
||||
@@ -74,212 +103,21 @@ const useStyles = makeStyles({
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GeoLayer — must be rendered inside ComposableMap to access map context
|
||||
// ---------------------------------------------------------------------------
|
||||
type TopoJsonTopology = Topology & {
|
||||
objects: {
|
||||
countries: TopoGeometryCollection;
|
||||
};
|
||||
};
|
||||
|
||||
interface GeoLayerProps {
|
||||
countries: Record<string, number>;
|
||||
countryNames?: Record<string, string>;
|
||||
selectedCountry: string | null;
|
||||
onSelectCountry: (cc: string | null) => void;
|
||||
thresholdLow: number;
|
||||
thresholdMedium: number;
|
||||
thresholdHigh: number;
|
||||
}
|
||||
type TooltipState = {
|
||||
cc: string;
|
||||
count: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null;
|
||||
|
||||
function GeoLayer({
|
||||
countries,
|
||||
countryNames,
|
||||
selectedCountry,
|
||||
onSelectCountry,
|
||||
thresholdLow,
|
||||
thresholdMedium,
|
||||
thresholdHigh,
|
||||
}: GeoLayerProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const { geographies, path } = useGeographies({ geography: GEO_URL });
|
||||
|
||||
const [tooltip, setTooltip] = useState<
|
||||
| {
|
||||
cc: string;
|
||||
count: number;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(cc: string | null): void => {
|
||||
onSelectCountry(selectedCountry === cc ? null : cc);
|
||||
},
|
||||
[selectedCountry, onSelectCountry],
|
||||
);
|
||||
|
||||
// Stable event handlers — shared across all Geography components so
|
||||
// React.memo is not defeated by new function references each render.
|
||||
const handleMouseEnter = useCallback(
|
||||
(e: React.MouseEvent<SVGPathElement>): void => {
|
||||
const cc = e.currentTarget.dataset.cc;
|
||||
if (!cc) return;
|
||||
const count = Number(e.currentTarget.dataset.count);
|
||||
const name = e.currentTarget.dataset.name ?? cc;
|
||||
setTooltip({ cc, count, name, x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent<SVGPathElement>): void => {
|
||||
setTooltip((current) =>
|
||||
current
|
||||
? { ...current, x: e.clientX, y: e.clientY }
|
||||
: current,
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
setTooltip(null);
|
||||
}, []);
|
||||
|
||||
// Memoize style objects so Geography's React.memo can skip re-renders
|
||||
// when only the tooltip position changes.
|
||||
type GeoStyle = { default: CSSProperties; hover: CSSProperties; pressed: CSSProperties };
|
||||
const styleMap = useMemo(() => {
|
||||
const map: Record<string, GeoStyle> = {};
|
||||
for (const geo of geographies as { rsmKey: string; id: string | number }[]) {
|
||||
const numericId = String(geo.id);
|
||||
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
|
||||
map[geo.rsmKey] = {
|
||||
default: {
|
||||
fill: isSelected ? tokens.colorBrandBackground : fillColor,
|
||||
stroke: tokens.colorNeutralStroke2,
|
||||
strokeWidth: 0.75,
|
||||
outline: "none",
|
||||
},
|
||||
hover: {
|
||||
fill: isSelected
|
||||
? tokens.colorBrandBackgroundHover
|
||||
: cc && count > 0
|
||||
? tokens.colorNeutralBackground3
|
||||
: fillColor,
|
||||
stroke: tokens.colorNeutralStroke1,
|
||||
strokeWidth: 1,
|
||||
outline: "none",
|
||||
},
|
||||
pressed: {
|
||||
fill: cc ? tokens.colorBrandBackgroundPressed : fillColor,
|
||||
stroke: tokens.colorBrandStroke1,
|
||||
strokeWidth: 1,
|
||||
outline: "none",
|
||||
},
|
||||
};
|
||||
}
|
||||
return map;
|
||||
}, [geographies, countries, selectedCountry, thresholdLow, thresholdMedium, thresholdHigh]);
|
||||
|
||||
if (geographies.length === 0) return <></>;
|
||||
|
||||
// react-simple-maps types declare path as always defined, but it can be null
|
||||
// during initial render before MapProvider context initializes. Cast to reflect
|
||||
// the true runtime type and allow safe null checking.
|
||||
const safePath = path as unknown as typeof path | null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(geographies as { rsmKey: string; id: string | number }[]).map(
|
||||
(geo) => {
|
||||
const numericId = String(geo.id);
|
||||
const cc: string | null = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count: number = cc !== null ? (countries[cc] ?? 0) : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
|
||||
// Only calculate centroid if path is available
|
||||
let cx: number | undefined;
|
||||
let cy: number | undefined;
|
||||
if (safePath != null) {
|
||||
const centroid = safePath.centroid(geo as unknown as GeoPermissibleObjects);
|
||||
[cx, cy] = centroid;
|
||||
}
|
||||
|
||||
return (
|
||||
<g key={geo.rsmKey} style={{ cursor: cc ? "pointer" : "default" }}>
|
||||
<Geography
|
||||
geography={geo}
|
||||
role={cc ? "button" : undefined}
|
||||
tabIndex={cc ? 0 : undefined}
|
||||
aria-label={
|
||||
cc
|
||||
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
|
||||
isSelected ? " (selected)" : ""
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
aria-pressed={isSelected || undefined}
|
||||
onClick={(): void => {
|
||||
if (cc) handleClick(cc);
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if (cc && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
handleClick(cc);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-cc={cc ?? undefined}
|
||||
data-count={cc ? String(count) : undefined}
|
||||
data-name={cc ? (countryNames?.[cc] ?? cc) : undefined}
|
||||
style={styleMap[geo.rsmKey]}
|
||||
/>
|
||||
{count > 0 && cx !== undefined && cy !== undefined && isFinite(cx) && isFinite(cy) && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className={styles.countLabel}
|
||||
>
|
||||
{count}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
},
|
||||
)}
|
||||
|
||||
{tooltip &&
|
||||
createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
||||
role="tooltip"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className={styles.tooltipCountry}>{tooltip.name}</span>
|
||||
<span className={styles.tooltipCount}>
|
||||
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WorldMap — public component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface WorldMapProps {
|
||||
interface WorldMapProps {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** Optional mapping from country code to display name. */
|
||||
@@ -307,21 +145,143 @@ export function WorldMap({
|
||||
}: WorldMapProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
const cardStyles = useCardStyles();
|
||||
const [zoom, setZoom] = useState<number>(1);
|
||||
const [zoom, setZoom] = useState<number>(MIN_ZOOM);
|
||||
const [center, setCenter] = useState<[number, number]>([0, 0]);
|
||||
const [hoveredCountry, setHoveredCountry] = useState<string | null>(null);
|
||||
const [tooltip, setTooltip] = useState<TooltipState>(null);
|
||||
|
||||
const handleZoomIn = (): void => {
|
||||
setZoom((z) => Math.min(z + 0.5, 8));
|
||||
};
|
||||
const zoomRef = useRef<number>(zoom);
|
||||
const centerRef = useRef<[number, number]>(center);
|
||||
const dragStateRef = useRef<{
|
||||
active: boolean;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startCenter: [number, number];
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const clickSuppressedRef = useRef<boolean>(false);
|
||||
|
||||
const handleZoomOut = (): void => {
|
||||
setZoom((z) => Math.max(z - 0.5, 1));
|
||||
};
|
||||
useEffect(() => {
|
||||
zoomRef.current = zoom;
|
||||
}, [zoom]);
|
||||
|
||||
const handleResetView = (): void => {
|
||||
setZoom(1);
|
||||
useEffect(() => {
|
||||
centerRef.current = center;
|
||||
}, [center]);
|
||||
|
||||
const topology = useMemo(() => worldData as unknown as TopoJsonTopology, []);
|
||||
|
||||
const geoJson = useMemo(
|
||||
() =>
|
||||
feature(topology, topology.objects.countries) as FeatureCollection<
|
||||
Geometry,
|
||||
GeoJsonProperties
|
||||
>,
|
||||
[topology],
|
||||
);
|
||||
|
||||
const projection = useMemo(
|
||||
() => geoMercator().fitSize([MAP_WIDTH, MAP_HEIGHT], geoJson),
|
||||
[geoJson],
|
||||
);
|
||||
|
||||
const pathGenerator = useMemo<GeoPath<unknown, Feature<Geometry, GeoJsonProperties>>>(
|
||||
() => geoPath().projection(projection),
|
||||
[projection],
|
||||
);
|
||||
|
||||
const countryFeatures = useMemo(
|
||||
() => geoJson.features.filter((feature) => feature.id != null && feature.geometry != null),
|
||||
[geoJson.features],
|
||||
);
|
||||
|
||||
const clampZoom = useCallback((value: number) => Math.min(Math.max(value, MIN_ZOOM), MAX_ZOOM), []);
|
||||
|
||||
const handleCountrySelect = useCallback(
|
||||
(cc: string | null): void => {
|
||||
if (clickSuppressedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelectCountry(selectedCountry === cc ? null : cc);
|
||||
},
|
||||
[onSelectCountry, selectedCountry],
|
||||
);
|
||||
|
||||
const handlePointerDown = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragStateRef.current = {
|
||||
active: true,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startCenter: centerRef.current,
|
||||
moved: false,
|
||||
};
|
||||
clickSuppressedRef.current = false;
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag?.active) return;
|
||||
|
||||
const dx = event.clientX - drag.startX;
|
||||
const dy = event.clientY - drag.startY;
|
||||
if (!drag.moved && Math.hypot(dx, dy) > PAN_THRESHOLD) {
|
||||
drag.moved = true;
|
||||
clickSuppressedRef.current = true;
|
||||
}
|
||||
|
||||
setCenter([drag.startCenter[0] + dx, drag.startCenter[1] + dy]);
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((event: React.PointerEvent<SVGSVGElement>) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag) return;
|
||||
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
dragStateRef.current = null;
|
||||
window.setTimeout(() => {
|
||||
clickSuppressedRef.current = false;
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
const handleWheel = useCallback((event: React.WheelEvent<SVGSVGElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const currentZoom = zoomRef.current;
|
||||
const desiredZoom = clampZoom(currentZoom + (event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));
|
||||
if (desiredZoom === currentZoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const svgX = (event.clientX - rect.left - centerRef.current[0]) / currentZoom;
|
||||
const svgY = (event.clientY - rect.top - centerRef.current[1]) / currentZoom;
|
||||
|
||||
setZoom(desiredZoom);
|
||||
setCenter([
|
||||
centerRef.current[0] - svgX * (desiredZoom - currentZoom),
|
||||
centerRef.current[1] - svgY * (desiredZoom - currentZoom),
|
||||
]);
|
||||
}, [clampZoom]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
setZoom((value) => clampZoom(value + ZOOM_STEP));
|
||||
}, [clampZoom]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
setZoom((value) => clampZoom(value - ZOOM_STEP));
|
||||
}, [clampZoom]);
|
||||
|
||||
const handleResetView = useCallback(() => {
|
||||
setZoom(MIN_ZOOM);
|
||||
setCenter([0, 0]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -329,13 +289,12 @@ export function WorldMap({
|
||||
role="img"
|
||||
aria-label="World map showing banned IP counts by country. Click a country to filter the table below."
|
||||
>
|
||||
{/* Zoom controls */}
|
||||
<div className={styles.zoomControls}>
|
||||
<Button
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleZoomIn}
|
||||
disabled={zoom >= 8}
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
>
|
||||
@@ -345,7 +304,7 @@ export function WorldMap({
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleZoomOut}
|
||||
disabled={zoom <= 1}
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
>
|
||||
@@ -355,7 +314,7 @@ export function WorldMap({
|
||||
appearance="secondary"
|
||||
size="small"
|
||||
onClick={handleResetView}
|
||||
disabled={zoom === 1 && center[0] === 0 && center[1] === 0}
|
||||
disabled={zoom === MIN_ZOOM && center[0] === 0 && center[1] === 0}
|
||||
title="Reset view"
|
||||
aria-label="Reset view"
|
||||
>
|
||||
@@ -363,34 +322,126 @@ export function WorldMap({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ComposableMap
|
||||
projection="geoMercator"
|
||||
projectionConfig={{ scale: 130, center: [10, 20] }}
|
||||
width={800}
|
||||
height={400}
|
||||
style={{ width: "100%", height: "auto" }}
|
||||
<svg
|
||||
className={styles.svg}
|
||||
viewBox={`0 0 ${MAP_WIDTH} ${MAP_HEIGHT}`}
|
||||
role="img"
|
||||
aria-label="World map showing banned IP counts by country."
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerLeave={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<ZoomableGroup
|
||||
zoom={zoom}
|
||||
center={center}
|
||||
onMoveEnd={({ zoom: newZoom, coordinates }): void => {
|
||||
setZoom(newZoom);
|
||||
setCenter(coordinates);
|
||||
}}
|
||||
minZoom={1}
|
||||
maxZoom={8}
|
||||
>
|
||||
<GeoLayer
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
selectedCountry={selectedCountry}
|
||||
onSelectCountry={onSelectCountry}
|
||||
thresholdLow={thresholdLow}
|
||||
thresholdMedium={thresholdMedium}
|
||||
thresholdHigh={thresholdHigh}
|
||||
/>
|
||||
</ZoomableGroup>
|
||||
</ComposableMap>
|
||||
<g transform={`translate(${center[0]} ${center[1]}) scale(${zoom})`}>
|
||||
{countryFeatures.map((featureItem) => {
|
||||
const rawId = featureItem.id;
|
||||
const numericId = String(Number(rawId));
|
||||
const cc = ISO_NUMERIC_TO_ALPHA2[numericId] ?? null;
|
||||
const count = cc !== null ? countries[cc] ?? 0 : 0;
|
||||
const isSelected = cc !== null && selectedCountry === cc;
|
||||
const fillColor = getBanCountColor(count, thresholdLow, thresholdMedium, thresholdHigh);
|
||||
const pathString = pathGenerator(featureItem) ?? "";
|
||||
if (!pathString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const centroid = pathGenerator.centroid(featureItem);
|
||||
const [cx, cy] = centroid;
|
||||
const isCentroidValid = Number.isFinite(cx) && Number.isFinite(cy);
|
||||
|
||||
return (
|
||||
<g key={String(rawId)}>
|
||||
<path
|
||||
d={pathString}
|
||||
role={cc ? "button" : undefined}
|
||||
tabIndex={cc ? 0 : undefined}
|
||||
aria-label={
|
||||
cc
|
||||
? `${cc}: ${String(count)} ban${count !== 1 ? "s" : ""}${
|
||||
isSelected ? " (selected)" : ""
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
aria-pressed={isSelected || undefined}
|
||||
className={`${styles.country} ${
|
||||
isSelected ? styles.countrySelected : ""
|
||||
} ${hoveredCountry === cc ? styles.countryHovered : ""}`}
|
||||
style={
|
||||
{
|
||||
["--country-fill" as string]: fillColor,
|
||||
["--country-hover-fill" as string]: isSelected
|
||||
? tokens.colorBrandBackgroundHover
|
||||
: tokens.colorBrandBackground2,
|
||||
["--country-selected-fill" as string]: tokens.colorBrandBackground,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onClick={(): void => {
|
||||
if (cc) {
|
||||
handleCountrySelect(cc);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event): void => {
|
||||
if (cc && (event.key === "Enter" || event.key === " ")) {
|
||||
event.preventDefault();
|
||||
handleCountrySelect(cc);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={(event): void => {
|
||||
if (!cc) return;
|
||||
setHoveredCountry(cc);
|
||||
setTooltip({
|
||||
cc,
|
||||
count,
|
||||
name: countryNames?.[cc] ?? cc,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
}}
|
||||
onMouseMove={(event): void => {
|
||||
setTooltip((current) =>
|
||||
current
|
||||
? { ...current, x: event.clientX, y: event.clientY }
|
||||
: current,
|
||||
);
|
||||
}}
|
||||
onMouseLeave={(): void => {
|
||||
setHoveredCountry(null);
|
||||
setTooltip(null);
|
||||
}}
|
||||
/>
|
||||
{count > 0 && isCentroidValid && (
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
className={styles.countLabel}
|
||||
>
|
||||
{count}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{tooltip &&
|
||||
createPortal(
|
||||
<div
|
||||
className={styles.tooltip}
|
||||
style={{ left: tooltip.x + 12, top: tooltip.y + 12 }}
|
||||
role="tooltip"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span className={styles.tooltipCountry}>{tooltip.name}</span>
|
||||
<span className={styles.tooltipCount}>
|
||||
{tooltip.count.toLocaleString()} ban{tooltip.count !== 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,16 +8,31 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
|
||||
|
||||
// Mock react-simple-maps to avoid fetching real TopoJSON and to control geometry.
|
||||
vi.mock("react-simple-maps", () => ({
|
||||
ComposableMap: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
ZoomableGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Geography: ({ children, ...props }: { children?: React.ReactNode } & Record<string, unknown>) => <g {...props}>{children}</g>,
|
||||
useGeographies: () => ({
|
||||
geographies: [{ rsmKey: "geo-1", id: 840 }],
|
||||
path: { centroid: () => [10, 10] },
|
||||
vi.mock(
|
||||
"world-atlas/countries-110m.json",
|
||||
() => ({
|
||||
default: {
|
||||
type: "Topology",
|
||||
objects: {
|
||||
countries: {
|
||||
type: "GeometryCollection",
|
||||
geometries: [
|
||||
{
|
||||
type: "Polygon",
|
||||
arcs: [[0]],
|
||||
id: "840",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
arcs: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]],
|
||||
transform: {
|
||||
scale: [1, 1],
|
||||
translate: [0, 0],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
);
|
||||
|
||||
import { WorldMap } from "../WorldMap";
|
||||
|
||||
@@ -34,19 +49,20 @@ describe("WorldMap", () => {
|
||||
</FluentProvider>,
|
||||
);
|
||||
|
||||
// Tooltip should not be present initially
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
// Country map area is exposed as an accessible button with an accurate label
|
||||
const countryButton = screen.getByRole("button", { name: "US: 42 bans" });
|
||||
expect(countryButton).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole("button", { name: /Zoom in/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Zoom out/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Reset view/i })).toBeInTheDocument();
|
||||
|
||||
fireEvent.mouseEnter(countryButton, { clientX: 10, clientY: 10 });
|
||||
|
||||
const tooltip = screen.getByRole("tooltip");
|
||||
expect(tooltip).toHaveTextContent("United States");
|
||||
expect(tooltip).toHaveTextContent("42 bans");
|
||||
expect(tooltip).toHaveStyle({ left: "22px", top: "22px" });
|
||||
|
||||
fireEvent.mouseLeave(countryButton);
|
||||
expect(screen.queryByRole("tooltip")).toBeNull();
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"4": "AF",
|
||||
"8": "AL",
|
||||
"10": "AQ",
|
||||
"12": "DZ",
|
||||
"16": "AS",
|
||||
"20": "AD",
|
||||
@@ -46,6 +47,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"148": "TD",
|
||||
"152": "CL",
|
||||
"156": "CN",
|
||||
"158": "TW",
|
||||
"162": "CX",
|
||||
"166": "CC",
|
||||
"170": "CO",
|
||||
@@ -76,6 +78,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"250": "FR",
|
||||
"254": "GF",
|
||||
"258": "PF",
|
||||
"260": "TF",
|
||||
"262": "DJ",
|
||||
"266": "GA",
|
||||
"268": "GE",
|
||||
@@ -107,6 +110,7 @@ export const ISO_NUMERIC_TO_ALPHA2: Record<string, string> = {
|
||||
"372": "IE",
|
||||
"376": "IL",
|
||||
"380": "IT",
|
||||
"384": "CI",
|
||||
"388": "JM",
|
||||
"392": "JP",
|
||||
"398": "KZ",
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
|
||||
Reference in New Issue
Block a user