// Shared time-series chart primitives — the Beszel-grade look on our tokens: // titled cards with a subtitle, soft area fills over a real time axis, // unit-aware Y ticks, a dotted tooltip sorted by value, and legend chips. import type { ReactNode } from "react"; import { Area, CartesianGrid, ComposedChart, Line, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { t } from "@/i18n"; /** One chart row: epoch ms plus named series values. */ export type ChartRow = { t: number } & Record; export interface Series { key: string; label: string; color: string; /** Shared value → stacked areas (container charts). */ stack?: string; /** Render as a bare line, no fill (temperatures). */ line?: boolean; } export const CHART_COLORS = [ "var(--chart-1)", "var(--chart-2)", "var(--chart-3)", "var(--chart-4)", "var(--chart-5)", "var(--chart-6)", "var(--chart-7)", "var(--chart-8)", ]; export const chartColor = (i: number) => CHART_COLORS[i % CHART_COLORS.length] ?? "var(--accent)"; const axisTick = { fontSize: 10, fill: "var(--text-faint)" }; /** Auto Y-domain with headroom, rounding to a readable ceiling. */ export function niceMax(dataMax: number): number { if (!Number.isFinite(dataMax) || dataMax <= 0) return 1; if (dataMax > 10) return Math.ceil(dataMax * 1.05); if (dataMax > 1) return Math.ceil(dataMax * 10.5) / 10; return Math.ceil(dataMax * 105) / 100; } /** Tick format by range: HH:mm up to 2 days, day+time to 2 weeks, then dates. */ export function xTick(spanMs: number): (v: number) => string { if (spanMs <= 48 * 3600_000) return (v) => new Date(v).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); if (spanMs <= 14 * 86400_000) return (v) => new Date(v).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); return (v) => new Date(v).toLocaleDateString([], { month: "short", day: "numeric" }); } const fullTime = (ms: number) => new Date(ms).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", }); interface TipItem { name?: string; value?: number | string; color?: string; stroke?: string; dataKey?: string | number; payload?: ChartRow; } /** Dotted tooltip: timestamp header, per-series rows sorted by value desc. */ function ChartTip({ active, payload, fmt, }: { active?: boolean; payload?: TipItem[]; fmt: (v: number) => string; }) { if (!active || !payload?.length) return null; const t0 = payload[0]?.payload?.t; const rows = payload .filter((p): p is TipItem & { value: number } => typeof p.value === "number") .sort((a, b) => b.value - a.value); if (!rows.length) return null; return (
{t0 !== undefined &&
{fullTime(t0)}
}
{rows.map((p) => (
{p.name} {fmt(p.value)}
))}
); } /** Area/line chart on a time-scaled X axis. Empty data renders a hint, not a blank box. */ export function MetricChart({ rows, series, fmt, domain, spanMs, }: { rows: ChartRow[]; series: Series[]; fmt: (v: number) => string; domain?: [number, number] | undefined; spanMs: number; }) { if (!rows.length) { return (
{t("chart.noData")}
); } return ( fmt(Number(v))} /> } cursor={{ stroke: "var(--border-strong)" }} /> {series.map((s) => s.line ? ( ) : ( ), )} ); } /** Titled chart card in the Beszel layout: title + subtitle on top, legend chips below. */ export function ChartCard({ title, hint, actions, series, height = 160, children, }: { title: string; hint?: string; actions?: ReactNode; series?: Series[]; height?: number; children: ReactNode; }) { return (

{title}

{hint ?

{hint}

: null}
{actions}
{children}
{series && series.length > 1 && (
{series.map((s) => ( {s.label} ))}
)}
); }