Add dashboard country charts (Stages 1–3)

- Install Recharts v3 as the project charting library
- Add chartTheme utility with Fluent UI v9 token resolution helper
  and a 5-colour categorical palette (resolves CSS vars at runtime)
- Add TopCountriesPieChart: top-4 + Other slice, Tooltip, Legend
- Add TopCountriesBarChart: horizontal top-20 bar chart
- Add useDashboardCountryData hook (wraps /api/dashboard/bans/by-country)
- Integrate both charts into DashboardPage in a responsive chartsRow
  (side-by-side on wide screens, stacked on narrow)
- All tsc --noEmit and eslint checks pass with zero warnings
This commit is contained in:
2026-03-11 16:06:24 +01:00
parent d931e8c6a3
commit 2ddfddfbbb
8 changed files with 1356 additions and 166 deletions

View File

@@ -14,7 +14,8 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"react-simple-maps": "^3.0.0"
"react-simple-maps": "^3.0.0",
"recharts": "^3.8.0"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
@@ -2667,6 +2668,42 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -3032,6 +3069,18 @@
"win32"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.19",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.19.tgz",
@@ -3086,12 +3135,24 @@
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz",
"integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"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",
@@ -3110,12 +3171,48 @@
"@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",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@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",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"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",
@@ -3193,6 +3290,12 @@
"@types/react": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.56.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz",
@@ -3628,6 +3731,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -3726,6 +3838,15 @@
"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",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-geo": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
@@ -3744,12 +3865,73 @@
"d3-color": "1 - 2"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"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",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
@@ -3803,6 +3985,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3841,6 +4029,16 @@
"embla-carousel": "8.6.0"
}
},
"node_modules/es-toolkit": {
"version": "1.45.1",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -4150,6 +4348,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -4311,6 +4515,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4812,6 +5026,29 @@
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -4871,6 +5108,57 @@
"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",
"integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -5047,6 +5335,12 @@
"@rollup/rollup-linux-x64-gnu": "4.53.3"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -5205,6 +5499,79 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/victory-vendor/node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/victory-vendor/node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/victory-vendor/node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/victory-vendor/node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/victory-vendor/node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@@ -19,7 +19,8 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"react-simple-maps": "^3.0.0"
"react-simple-maps": "^3.0.0",
"recharts": "^3.8.0"
},
"devDependencies": {
"@eslint/js": "^9.13.0",

View File

@@ -0,0 +1,178 @@
/**
* TopCountriesBarChart — horizontal bar chart showing the top 20 countries
* by ban count, sorted descending.
*/
import {
Bar,
BarChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { tokens, makeStyles } from "@fluentui/react-components";
import {
CHART_PALETTE,
CHART_AXIS_TEXT_TOKEN,
CHART_GRID_LINE_TOKEN,
resolveFluentToken,
} from "../utils/chartTheme";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TOP_N = 20;
/** Maximum chars before truncating a country name on the Y-axis. */
const MAX_LABEL_LENGTH = 20;
/** Height per bar row in pixels. */
const BAR_HEIGHT_PX = 36;
/** Minimum chart height in pixels. */
const MIN_CHART_HEIGHT = 180;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TopCountriesBarChartProps {
/** ISO alpha-2 country code → ban count. */
countries: Record<string, number>;
/** ISO alpha-2 country code → human-readable country name. */
countryNames: Record<string, string>;
}
interface BarEntry {
/** Full country name used by Tooltip. */
fullName: string;
/** Truncated name displayed on the Y-axis. */
name: string;
/** Ban count. */
value: number;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
wrapper: {
width: "100%",
overflowX: "hidden",
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build the sorted top-N dataset from raw country maps. */
function buildEntries(
countries: Record<string, number>,
countryNames: Record<string, string>,
): BarEntry[] {
return Object.entries(countries)
.sort(([, a], [, b]) => b - a)
.slice(0, TOP_N)
.map(([code, count]) => {
const full = countryNames[code] ?? code;
return {
fullName: full,
name:
full.length > MAX_LABEL_LENGTH
? `${full.slice(0, MAX_LABEL_LENGTH)}`
: full,
value: count,
};
});
}
// ---------------------------------------------------------------------------
// Custom tooltip
// ---------------------------------------------------------------------------
function BarTooltip(
props: TooltipContentProps,
): React.JSX.Element | null {
const { active, payload } = props;
if (!active || payload.length === 0) return null;
const entry = payload[0];
if (entry == null) return null;
// `fullName` is stored as an extra field on the payload item.
const fullName = (entry.payload as BarEntry).fullName;
return (
<div
style={{
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
borderRadius: "4px",
padding: "8px 12px",
color: resolveFluentToken(tokens.colorNeutralForeground1),
fontSize: "13px",
}}
>
<strong>{fullName}</strong>
<br />
{String(entry.value)} ban{entry.value === 1 ? "" : "s"}
</div>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Horizontal bar chart showing the top 20 countries by ban count.
*
* @param props - `countries` map and `countryNames` map from the
* `/api/dashboard/bans/by-country` response.
*/
export function TopCountriesBarChart({
countries,
countryNames,
}: TopCountriesBarChartProps): React.JSX.Element {
const styles = useStyles();
const entries = buildEntries(countries, countryNames);
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN);
return (
<div className={styles.wrapper} style={{ height: chartHeight }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
layout="vertical"
data={entries}
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
>
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
<XAxis
type="number"
tick={{ fill: axisColour, fontSize: 12 }}
axisLine={{ stroke: gridColour }}
tickLine={false}
/>
<YAxis
type="category"
dataKey="name"
width={140}
tick={{ fill: axisColour, fontSize: 12 }}
axisLine={false}
tickLine={false}
/>
<Tooltip content={BarTooltip} cursor={{ fill: "transparent" }} />
<Bar dataKey="value" fill={primaryColour} radius={[0, 3, 3, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,169 @@
/**
* TopCountriesPieChart — shows the top 4 countries by ban count plus
* an "Other" slice aggregating all remaining countries.
*/
import {
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
} from "recharts";
import type { PieLabelRenderProps } from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { tokens, makeStyles } from "@fluentui/react-components";
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const TOP_N = 4;
const OTHER_LABEL = "Other";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TopCountriesPieChartProps {
/** ISO alpha-2 country code → ban count. */
countries: Record<string, number>;
/** ISO alpha-2 country code → human-readable country name. */
countryNames: Record<string, string>;
}
interface SliceData {
name: string;
value: number;
/** Resolved fill colour for this slice. */
fill: string;
}
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
wrapper: {
width: "100%",
minHeight: "280px",
},
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build the 5-slice dataset from raw country maps, with resolved colours. */
function buildSlices(
countries: Record<string, number>,
countryNames: Record<string, string>,
palette: readonly string[],
): SliceData[] {
const entries = Object.entries(countries).sort(([, a], [, b]) => b - a);
const top = entries.slice(0, TOP_N);
const rest = entries.slice(TOP_N);
const slices: SliceData[] = top.map(([code, count], index) => ({
name: countryNames[code] ?? code,
value: count,
fill: palette[index % palette.length] ?? "",
}));
if (rest.length > 0) {
const otherTotal = rest.reduce((sum, [, c]) => sum + c, 0);
slices.push({
name: OTHER_LABEL,
value: otherTotal,
fill: palette[slices.length % palette.length] ?? "",
});
}
return slices;
}
// ---------------------------------------------------------------------------
// Custom tooltip
// ---------------------------------------------------------------------------
function PieTooltip(props: TooltipContentProps): React.JSX.Element | null {
const { active, payload } = props;
if (!active || payload.length === 0) return null;
const entry = payload[0];
if (entry == null) return null;
const banCount = entry.value;
const displayName: string = entry.name?.toString() ?? "";
return (
<div
style={{
backgroundColor: resolveFluentToken(tokens.colorNeutralBackground1),
border: `1px solid ${resolveFluentToken(tokens.colorNeutralStroke2)}`,
borderRadius: "4px",
padding: "8px 12px",
color: resolveFluentToken(tokens.colorNeutralForeground1),
fontSize: "13px",
}}
>
<strong>{displayName}</strong>
<br />
{banCount != null
? `${String(banCount)} ban${banCount === 1 ? "" : "s"}`
: ""}
</div>
);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Pie chart showing the top 4 countries by ban count plus an "Other" slice.
*
* @param props - `countries` map and `countryNames` map from the
* `/api/dashboard/bans/by-country` response.
*/
export function TopCountriesPieChart({
countries,
countryNames,
}: TopCountriesPieChartProps): React.JSX.Element {
const styles = useStyles();
const resolvedPalette = CHART_PALETTE.map(resolveFluentToken);
const slices = buildSlices(countries, countryNames, resolvedPalette);
const total = slices.reduce((sum, s) => sum + s.value, 0);
/** Format legend entries as "Country Name (xx%)" */
const legendFormatter = (value: string): string => {
const slice = slices.find((s) => s.name === value);
if (slice == null || total === 0) return value;
const pct = ((slice.value / total) * 100).toFixed(1);
return `${value} (${pct}%)`;
};
return (
<div className={styles.wrapper}>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={slices}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={90}
label={(labelProps: PieLabelRenderProps): string => {
const name = labelProps.name ?? "";
const percent = labelProps.percent ?? 0;
return `${name}: ${(percent * 100).toFixed(0)}%`;
}}
labelLine={false}
/>
<Tooltip content={PieTooltip} />
<Legend formatter={legendFormatter} />
</PieChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,95 @@
/**
* `useDashboardCountryData` hook.
*
* Fetches ban-by-country aggregates for dashboard chart components. Unlike
* `useMapData`, this hook has no debouncing or map-specific state.
*
* Re-fetches automatically when `timeRange` or `origin` changes.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchBansByCountry } from "../api/map";
import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban";
// ---------------------------------------------------------------------------
// Return type
// ---------------------------------------------------------------------------
/** Return value shape for {@link useDashboardCountryData}. */
export interface UseDashboardCountryDataResult {
/** ISO alpha-2 country code → ban count. */
countries: Record<string, number>;
/** ISO alpha-2 country code → human-readable country name. */
countryNames: Record<string, string>;
/** All ban records in the selected window. */
bans: DashboardBanItem[];
/** Total ban count in the window. */
total: number;
/** True while a fetch is in flight. */
isLoading: boolean;
/** Error message or `null`. */
error: string | null;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
/**
* Fetch and expose ban-by-country data for dashboard charts.
*
* @param timeRange - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
* @param origin - Origin filter: `"all"`, `"blocklist"`, or `"selfblock"`.
* @returns Aggregated country data, ban list, loading state, and error.
*/
export function useDashboardCountryData(
timeRange: TimeRange,
origin: BanOriginFilter,
): UseDashboardCountryDataResult {
const [countries, setCountries] = useState<Record<string, number>>({});
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
const [bans, setBans] = useState<DashboardBanItem[]>([]);
const [total, setTotal] = useState<number>(0);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback((): void => {
// Abort any in-flight request.
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setIsLoading(true);
setError(null);
fetchBansByCountry(timeRange, origin)
.then((data) => {
if (controller.signal.aborted) return;
setCountries(data.countries);
setCountryNames(data.country_names);
// MapBanItem and DashboardBanItem are structurally identical.
setBans(data.bans as DashboardBanItem[]);
setTotal(data.total);
})
.catch((err: unknown) => {
if (controller.signal.aborted) return;
setError(err instanceof Error ? err.message : "Failed to fetch data");
})
.finally(() => {
if (!controller.signal.aborted) {
setIsLoading(false);
}
});
}, [timeRange, origin]);
useEffect(() => {
load();
return (): void => {
abortRef.current?.abort();
};
}, [load]);
return { countries, countryNames, bans, total, isLoading, error };
}

View File

@@ -8,6 +8,9 @@
import { useState } from "react";
import {
MessageBar,
MessageBarBody,
Spinner,
Text,
ToggleButton,
Toolbar,
@@ -16,6 +19,9 @@ import {
} from "@fluentui/react-components";
import { BanTable } from "../components/BanTable";
import { ServerStatusBar } from "../components/ServerStatusBar";
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
import { useDashboardCountryData } from "../hooks/useDashboardCountryData";
import type { BanOriginFilter, TimeRange } from "../types/ban";
import { BAN_ORIGIN_FILTER_LABELS, TIME_RANGE_LABELS } from "../types/ban";
@@ -64,6 +70,16 @@ const useStyles = makeStyles({
tabContent: {
paddingTop: tokens.spacingVerticalS,
},
chartsRow: {
display: "flex",
flexDirection: "row",
gap: tokens.spacingHorizontalL,
flexWrap: "wrap",
},
chartCard: {
flex: "1 1 300px",
minWidth: "280px",
},
});
// ---------------------------------------------------------------------------
@@ -91,6 +107,9 @@ export function DashboardPage(): React.JSX.Element {
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const { countries, countryNames, isLoading: countryLoading, error: countryError } =
useDashboardCountryData(timeRange, originFilter);
return (
<div className={styles.root}>
{/* ------------------------------------------------------------------ */}
@@ -98,6 +117,40 @@ export function DashboardPage(): React.JSX.Element {
{/* ------------------------------------------------------------------ */}
<ServerStatusBar />
{/* ------------------------------------------------------------------ */}
{/* Charts section */}
{/* ------------------------------------------------------------------ */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<Text as="h2" size={500} weight="semibold">
Top Countries
</Text>
</div>
<div className={styles.tabContent}>
{countryError != null && (
<MessageBar intent="error">
<MessageBarBody>{countryError}</MessageBarBody>
</MessageBar>
)}
{countryLoading && countryError == null ? (
<Spinner label="Loading chart data…" />
) : (
<div className={styles.chartsRow}>
<div className={styles.chartCard}>
<TopCountriesPieChart
countries={countries}
countryNames={countryNames}
/>
</div> <div className={styles.chartCard}>
<TopCountriesBarChart
countries={countries}
countryNames={countryNames}
/>
</div> </div>
)}
</div>
</div>
{/* ------------------------------------------------------------------ */}
{/* Ban list section */}
{/* ------------------------------------------------------------------ */}

View File

@@ -0,0 +1,72 @@
/**
* Chart theme utility — maps Fluent UI v9 design tokens to Recharts-compatible
* CSS colour strings.
*
* Recharts renders SVG elements and sets colour values as SVG attributes, not
* CSS properties. SVG attributes do not support CSS custom-property
* references (`var(…)`), so token values must be resolved to their actual
* colour strings at render time via `getComputedStyle`.
*
* Call `resolveFluentToken` inside a component (not at module level) so that
* the resolved value reflects the theme that is active when the component
* renders.
*/
import { tokens } from "@fluentui/react-components";
// ---------------------------------------------------------------------------
// Runtime resolver
// ---------------------------------------------------------------------------
/**
* Resolves a Fluent UI v9 token string (e.g. `tokens.colorNeutralForeground2`)
* to the literal CSS colour value defined in the active theme.
*
* @param tokenValue - A Fluent v9 token string such as
* `"var(--colorNeutralForeground2)"`.
* @returns The resolved colour string (e.g. `"#605e5c"`), or the original
* token value if resolution fails.
*/
export function resolveFluentToken(tokenValue: string): string {
const match = /var\((--[^,)]+)/.exec(tokenValue);
if (match == null || match[1] == null) return tokenValue;
const resolved = getComputedStyle(document.documentElement)
.getPropertyValue(match[1])
.trim();
return resolved !== "" ? resolved : tokenValue;
}
// ---------------------------------------------------------------------------
// Categorical palette
// ---------------------------------------------------------------------------
/**
* Five distinct categorical colours for pie/bar slices and line series,
* expressed as Fluent UI v9 CSS custom-property references.
*
* Resolve at render time with `resolveFluentToken` before passing to
* Recharts components.
*/
export const CHART_PALETTE: readonly string[] = [
tokens.colorPaletteBlueBorderActive,
tokens.colorPaletteRedBorderActive,
tokens.colorPaletteGreenBorderActive,
tokens.colorPaletteGoldBorderActive,
tokens.colorPalettePurpleBorderActive,
] as const;
// ---------------------------------------------------------------------------
// Structural colours
// ---------------------------------------------------------------------------
/** Fluent token for axis labels and tick text — resolves per active theme. */
export const CHART_AXIS_TEXT_TOKEN: string = tokens.colorNeutralForeground2;
/** Fluent token for CartesianGrid lines — resolves per active theme. */
export const CHART_GRID_LINE_TOKEN: string = tokens.colorNeutralStroke2;
/** Fluent token for tooltip background — resolves per active theme. */
export const CHART_TOOLTIP_BG_TOKEN: string = tokens.colorNeutralBackground1;
/** Fluent token for tooltip text — resolves per active theme. */
export const CHART_TOOLTIP_TEXT_TOKEN: string = tokens.colorNeutralForeground1;