197 lines
5.6 KiB
TypeScript
197 lines
5.6 KiB
TypeScript
/**
|
|
* 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<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 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<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;
|
|
const fullName = (entry.payload as BarEntry).fullName;
|
|
return (
|
|
<ChartTooltip active={active}>
|
|
<strong>{fullName}</strong>
|
|
<br />
|
|
{String(entry.value)} ban{entry.value === 1 ? "" : "s"}
|
|
</ChartTooltip>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 (
|
|
<div className={styles.emptyWrapper}>
|
|
<Text className={styles.emptyText}>No bans in this time range.</Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
|
|
|
|
return (
|
|
<div className={styles.wrapper} style={{ height: chartHeight }}>
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart
|
|
layout="vertical"
|
|
data={entries}
|
|
margin={CHART_MARGIN}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
|
|
<XAxis
|
|
type="number"
|
|
tick={{ fill: axisColour, fontSize: CHART_TICK_FONT_SIZE }}
|
|
axisLine={{ stroke: gridColour }}
|
|
tickLine={false}
|
|
/>
|
|
<YAxis
|
|
type="category"
|
|
dataKey="name"
|
|
width={Y_AXIS_WIDTH}
|
|
tick={{ fill: axisColour, fontSize: CHART_TICK_FONT_SIZE }}
|
|
axisLine={false}
|
|
tickLine={false}
|
|
/>
|
|
<Tooltip content={BarTooltip} cursor={{ fill: "transparent" }} />
|
|
<Bar dataKey="value" fill={primaryColour} radius={[0, 3, 3, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
);
|
|
}
|