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>
|
||||
);
|
||||
}
|
||||
95
frontend/src/hooks/useDashboardCountryData.ts
Normal file
95
frontend/src/hooks/useDashboardCountryData.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* `useDashboardCountryData` hook.
|
||||
*
|
||||
* Fetches ban-by-country aggregates for dashboard chart components. Unlike
|
||||
* `useMapData`, this hook has no debouncing or map-specific state.
|
||||
*
|
||||
* Re-fetches automatically when `timeRange` or `origin` changes.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { fetchBansByCountry } from "../api/map";
|
||||
import type { DashboardBanItem, BanOriginFilter, TimeRange } from "../types/ban";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Return value shape for {@link useDashboardCountryData}. */
|
||||
export interface UseDashboardCountryDataResult {
|
||||
/** ISO alpha-2 country code → ban count. */
|
||||
countries: Record<string, number>;
|
||||
/** ISO alpha-2 country code → human-readable country name. */
|
||||
countryNames: Record<string, string>;
|
||||
/** All ban records in the selected window. */
|
||||
bans: DashboardBanItem[];
|
||||
/** Total ban count in the window. */
|
||||
total: number;
|
||||
/** True while a fetch is in flight. */
|
||||
isLoading: boolean;
|
||||
/** Error message or `null`. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch and expose ban-by-country data for dashboard charts.
|
||||
*
|
||||
* @param timeRange - Time-range preset: `"24h"`, `"7d"`, `"30d"`, or `"365d"`.
|
||||
* @param origin - Origin filter: `"all"`, `"blocklist"`, or `"selfblock"`.
|
||||
* @returns Aggregated country data, ban list, loading state, and error.
|
||||
*/
|
||||
export function useDashboardCountryData(
|
||||
timeRange: TimeRange,
|
||||
origin: BanOriginFilter,
|
||||
): UseDashboardCountryDataResult {
|
||||
const [countries, setCountries] = useState<Record<string, number>>({});
|
||||
const [countryNames, setCountryNames] = useState<Record<string, string>>({});
|
||||
const [bans, setBans] = useState<DashboardBanItem[]>([]);
|
||||
const [total, setTotal] = useState<number>(0);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const load = useCallback((): void => {
|
||||
// Abort any in-flight request.
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchBansByCountry(timeRange, origin)
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setCountries(data.countries);
|
||||
setCountryNames(data.country_names);
|
||||
// MapBanItem and DashboardBanItem are structurally identical.
|
||||
setBans(data.bans as DashboardBanItem[]);
|
||||
setTotal(data.total);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch data");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
}, [timeRange, origin]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
return (): void => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
return { countries, countryNames, bans, total, isLoading, error };
|
||||
}
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
MessageBar,
|
||||
MessageBarBody,
|
||||
Spinner,
|
||||
Text,
|
||||
ToggleButton,
|
||||
Toolbar,
|
||||
@@ -16,6 +19,9 @@ import {
|
||||
} from "@fluentui/react-components";
|
||||
import { BanTable } from "../components/BanTable";
|
||||
import { ServerStatusBar } from "../components/ServerStatusBar";
|
||||
import { TopCountriesPieChart } from "../components/TopCountriesPieChart";
|
||||
import { TopCountriesBarChart } from "../components/TopCountriesBarChart";
|
||||
import { useDashboardCountryData } from "../hooks/useDashboardCountryData";
|
||||
import type { BanOriginFilter, TimeRange } from "../types/ban";
|
||||
import { BAN_ORIGIN_FILTER_LABELS, TIME_RANGE_LABELS } from "../types/ban";
|
||||
|
||||
@@ -64,6 +70,16 @@ const useStyles = makeStyles({
|
||||
tabContent: {
|
||||
paddingTop: tokens.spacingVerticalS,
|
||||
},
|
||||
chartsRow: {
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: tokens.spacingHorizontalL,
|
||||
flexWrap: "wrap",
|
||||
},
|
||||
chartCard: {
|
||||
flex: "1 1 300px",
|
||||
minWidth: "280px",
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,6 +107,9 @@ export function DashboardPage(): React.JSX.Element {
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
|
||||
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
|
||||
|
||||
const { countries, countryNames, isLoading: countryLoading, error: countryError } =
|
||||
useDashboardCountryData(timeRange, originFilter);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
@@ -98,6 +117,40 @@ export function DashboardPage(): React.JSX.Element {
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<ServerStatusBar />
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Charts section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<Text as="h2" size={500} weight="semibold">
|
||||
Top Countries
|
||||
</Text>
|
||||
</div>
|
||||
<div className={styles.tabContent}>
|
||||
{countryError != null && (
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{countryError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
)}
|
||||
{countryLoading && countryError == null ? (
|
||||
<Spinner label="Loading chart data…" />
|
||||
) : (
|
||||
<div className={styles.chartsRow}>
|
||||
<div className={styles.chartCard}>
|
||||
<TopCountriesPieChart
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
/>
|
||||
</div> <div className={styles.chartCard}>
|
||||
<TopCountriesBarChart
|
||||
countries={countries}
|
||||
countryNames={countryNames}
|
||||
/>
|
||||
</div> </div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
{/* Ban list section */}
|
||||
{/* ------------------------------------------------------------------ */}
|
||||
|
||||
72
frontend/src/utils/chartTheme.ts
Normal file
72
frontend/src/utils/chartTheme.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Chart theme utility — maps Fluent UI v9 design tokens to Recharts-compatible
|
||||
* CSS colour strings.
|
||||
*
|
||||
* Recharts renders SVG elements and sets colour values as SVG attributes, not
|
||||
* CSS properties. SVG attributes do not support CSS custom-property
|
||||
* references (`var(…)`), so token values must be resolved to their actual
|
||||
* colour strings at render time via `getComputedStyle`.
|
||||
*
|
||||
* Call `resolveFluentToken` inside a component (not at module level) so that
|
||||
* the resolved value reflects the theme that is active when the component
|
||||
* renders.
|
||||
*/
|
||||
|
||||
import { tokens } from "@fluentui/react-components";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime resolver
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolves a Fluent UI v9 token string (e.g. `tokens.colorNeutralForeground2`)
|
||||
* to the literal CSS colour value defined in the active theme.
|
||||
*
|
||||
* @param tokenValue - A Fluent v9 token string such as
|
||||
* `"var(--colorNeutralForeground2)"`.
|
||||
* @returns The resolved colour string (e.g. `"#605e5c"`), or the original
|
||||
* token value if resolution fails.
|
||||
*/
|
||||
export function resolveFluentToken(tokenValue: string): string {
|
||||
const match = /var\((--[^,)]+)/.exec(tokenValue);
|
||||
if (match == null || match[1] == null) return tokenValue;
|
||||
const resolved = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(match[1])
|
||||
.trim();
|
||||
return resolved !== "" ? resolved : tokenValue;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Categorical palette
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Five distinct categorical colours for pie/bar slices and line series,
|
||||
* expressed as Fluent UI v9 CSS custom-property references.
|
||||
*
|
||||
* Resolve at render time with `resolveFluentToken` before passing to
|
||||
* Recharts components.
|
||||
*/
|
||||
export const CHART_PALETTE: readonly string[] = [
|
||||
tokens.colorPaletteBlueBorderActive,
|
||||
tokens.colorPaletteRedBorderActive,
|
||||
tokens.colorPaletteGreenBorderActive,
|
||||
tokens.colorPaletteGoldBorderActive,
|
||||
tokens.colorPalettePurpleBorderActive,
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural colours
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Fluent token for axis labels and tick text — resolves per active theme. */
|
||||
export const CHART_AXIS_TEXT_TOKEN: string = tokens.colorNeutralForeground2;
|
||||
|
||||
/** Fluent token for CartesianGrid lines — resolves per active theme. */
|
||||
export const CHART_GRID_LINE_TOKEN: string = tokens.colorNeutralStroke2;
|
||||
|
||||
/** Fluent token for tooltip background — resolves per active theme. */
|
||||
export const CHART_TOOLTIP_BG_TOKEN: string = tokens.colorNeutralBackground1;
|
||||
|
||||
/** Fluent token for tooltip text — resolves per active theme. */
|
||||
export const CHART_TOOLTIP_TEXT_TOKEN: string = tokens.colorNeutralForeground1;
|
||||
Reference in New Issue
Block a user