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