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:
169
frontend/src/components/TopCountriesPieChart.tsx
Normal file
169
frontend/src/components/TopCountriesPieChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user