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