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

@@ -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>
);
}