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