/** * TopCountriesBarChart — horizontal bar chart showing the top 20 countries * by ban count, sorted descending. */ import { useMemo } from "react"; import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import type { TooltipContentProps } from "recharts/types/component/Tooltip"; import { tokens, makeStyles, Text } from "@fluentui/react-components"; import { CHART_PALETTE, CHART_AXIS_TEXT_TOKEN, CHART_GRID_LINE_TOKEN, resolveFluentToken, } from "../utils/chartTheme"; import { ChartTooltip } from "./ChartTooltip"; // --------------------------------------------------------------------------- // 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; /** ISO alpha-2 country code → human-readable country name. */ countryNames: Record; } 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 CHART_TICK_FONT_SIZE = 12; const CHART_MARGIN = { top: 4, right: 16, bottom: 4, left: 8 }; const Y_AXIS_WIDTH = 140; const useStyles = makeStyles({ wrapper: { width: "100%", overflowX: "hidden", }, emptyWrapper: { width: "100%", minHeight: `${String(MIN_CHART_HEIGHT)}px`, display: "flex", alignItems: "center", justifyContent: "center", }, emptyText: { color: tokens.colorNeutralForeground3, }, }); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Build the sorted top-N dataset from raw country maps. */ function buildEntries( countries: Record, countryNames: Record, ): 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; const fullName = (entry.payload as BarEntry).fullName; return ( {fullName}
{String(entry.value)} ban{entry.value === 1 ? "" : "s"}
); } // --------------------------------------------------------------------------- // 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 { primaryColour, axisColour, gridColour } = useMemo( () => ({ primaryColour: resolveFluentToken(CHART_PALETTE[0] ?? ""), axisColour: resolveFluentToken(CHART_AXIS_TEXT_TOKEN), gridColour: resolveFluentToken(CHART_GRID_LINE_TOKEN), }), [], ); if (entries.length === 0) { return (
No bans in this time range.
); } const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT); return (
); }