/** * @file MiniChart.tsx * @description The Administration page's small single-series charts: a bar * chart (a user's daily spend) and a line chart (System Health's CPU and * memory history), plain SVG in the app's theme tokens. One series each — the * card's title names it, so there is no legend — thin marks, rounded bar ends * on the baseline, a recessive grid with a max label, and a hover tooltip * (crosshair for the line, per bar for the bars) whose text is the caller's. * @author Nguyễn Ngọc Trí Vĩ */ import { useRef, useState, type MouseEvent } from "react"; export type ChartPoint = { x: string; y: number }; const H = 120; const PAD_TOP = 14; const PAD_BOTTOM = 18; /** * @param props.points - In x order; `x` is the axis/tooltip label. * @param props.kind - `bar` or `line`. * @param props.format - Formats a value for the max label and the tooltip. * @param props.max - A fixed top (e.g. 100 for a percentage); else the data's max. * @param props.label - The chart's accessible name. */ export function MiniChart({ points, kind, format, max, label, emptyText, }: { points: ChartPoint[]; kind: "bar" | "line"; format: (v: number) => string; max?: number; label: string; emptyText?: string; }) { const ref = useRef(null); const [hover, setHover] = useState(null); if (points.length === 0) return (
{emptyText ?? "—"}
); const W = 600; const top = Math.max(max ?? 0, ...points.map((p) => p.y), 1e-9); const plotH = H - PAD_TOP - PAD_BOTTOM; const yOf = (v: number) => PAD_TOP + plotH - (v / top) * plotH; const step = W / points.length; const xOf = (i: number) => kind === "bar" ? i * step + step / 2 : (i / Math.max(1, points.length - 1)) * W; const onMove = (e: MouseEvent) => { const r = ref.current?.getBoundingClientRect(); if (!r || r.width === 0) return; const fx = ((e.clientX - r.left) / r.width) * W; const i = kind === "bar" ? Math.floor(fx / step) : Math.round((fx / W) * Math.max(1, points.length - 1)); setHover(Math.max(0, Math.min(points.length - 1, i))); }; const h = hover != null ? points[hover] : null; const barW = Math.max(2, Math.min(24, step - 2)); const line = points .map((p, i) => `${i ? "L" : "M"}${xOf(i).toFixed(1)},${yOf(p.y).toFixed(1)}`) .join(""); return (
setHover(null)} > {[0, 0.5, 1].map((f) => ( ))} {kind === "bar" ? ( points.map((p, i) => { const y = yOf(p.y); const bh = Math.max(0, PAD_TOP + plotH - y); const r = Math.min(4, barW / 2, bh); const x = xOf(i) - barW / 2; const base = PAD_TOP + plotH; // Rounded top only; the base sits flat on the axis. const d = bh <= 0 ? "" : `M${x},${base}V${y + r}Q${x},${y} ${x + r},${y}H${x + barW - r}Q${x + barW},${y} ${x + barW},${y + r}V${base}Z`; return ( ); }) ) : ( <> {hover != null && ( )} )} {format(top)}
{points[0]!.x} {points[points.length - 1]!.x}
{h && hover != null && (
{h.x}
{format(h.y)}
)}
); }