/** * TopCountriesPieChart — shows the top 4 countries by ban count plus * an "Other" slice aggregating all remaining countries. */ import { Cell, Legend, Pie, PieChart, ResponsiveContainer, Tooltip, } from "recharts"; import type { PieLabelRenderProps } from "recharts"; import type { LegendPayload } from "recharts/types/component/DefaultLegendContent"; import type { TooltipContentProps } from "recharts/types/component/Tooltip"; import { tokens, makeStyles, Text } 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; /** ISO alpha-2 country code → human-readable country name. */ countryNames: Record; } interface SliceData { name: string; value: number; /** Resolved fill colour for this slice. */ fill: string; } // --------------------------------------------------------------------------- // Styles // --------------------------------------------------------------------------- const useStyles = makeStyles({ wrapper: { width: "100%", minHeight: "280px", }, emptyWrapper: { width: "100%", minHeight: "280px", display: "flex", alignItems: "center", justifyContent: "center", }, emptyText: { color: tokens.colorNeutralForeground3, }, }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Build the 5-slice dataset from raw country maps, with resolved colours. */ function buildSlices( countries: Record, countryNames: Record, 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 (
{displayName}
{banCount != null ? `${String(banCount)} ban${banCount === 1 ? "" : "s"}` : ""}
); } // --------------------------------------------------------------------------- // 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); if (slices.length === 0) { return (
No bans in this time range.
); } /** Format legend entries as "Country Name (xx%)" and colour them to match their slice. */ const legendFormatter = ( value: string, entry: LegendPayload, ): React.ReactNode => { 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 (
{ const name = labelProps.name ?? ""; const percent = labelProps.percent ?? 0; return `${name}: ${(percent * 100).toFixed(0)}%`; }} labelLine={false} > {slices.map((slice, index) => ( // eslint-disable-next-line @typescript-eslint/no-deprecated ))}
); }