Files
BanGUI/frontend/src/components/TopCountriesPieChart.tsx

194 lines
5.7 KiB
TypeScript

/**
* 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";
import { ChartTooltip } from "./ChartTooltip";
// ---------------------------------------------------------------------------
// 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",
},
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<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 (
<ChartTooltip active={active}>
<strong>{displayName}</strong>
<br />
{banCount != null
? `${String(banCount)} ban${banCount === 1 ? "" : "s"}`
: ""}
</ChartTooltip>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className={styles.emptyWrapper}>
<Text className={styles.emptyText}>No bans in this time range.</Text>
</div>
);
}
/** 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 (
<span style={{ color: entry.color }}>
{value} ({pct}%)
</span>
);
};
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}
>
{slices.map((slice, index) => (
// eslint-disable-next-line @typescript-eslint/no-deprecated
<Cell key={index} fill={slice.fill} />
))}
</Pie>
<Tooltip content={PieTooltip} />
<Legend formatter={legendFormatter} />
</PieChart>
</ResponsiveContainer>
</div>
);
}