"use client"; import { useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { motion, useReducedMotion } from "framer-motion"; import { cn } from "../lib/utils"; /** * Charts for apps: line, area, bar, sparkline, and donut charts drawn * as SVG. Strict input (one category key, ≤ 6 numeric series), host-owned * styling, animated entry, a hover readout, and an accessible data table * for screen readers. */ type Tone = "primary" | "positive" | "warning" | "danger" | "muted" | "accent"; export interface ChartProps { kind: "line" | "area" | "bar" | "sparkline" | "donut"; data: Array>; xKey: string; series: Array<{ key: string; label?: string | undefined; tone?: Tone | undefined }>; title?: string | undefined; height?: number | undefined; valueFormat?: "number" | "percent" | "currency" | "compact" | undefined; currency?: string | undefined; stacked?: boolean | undefined; compact?: boolean | undefined; } // Host theme tokens, so charts follow the workspace's palette and dark mode. const TONE_COLORS: Record = { primary: "var(--action)", positive: "var(--status-positive)", warning: "var(--status-attention)", danger: "var(--status-risk)", muted: "var(--muted-foreground)", accent: "var(--chart-4)", }; const DEFAULT_ORDER: Tone[] = ["primary", "accent", "positive", "warning", "danger", "muted"]; function formatter(format: ChartProps["valueFormat"], currency?: string) { switch (format) { case "percent": return new Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: 1 }); case "currency": return new Intl.NumberFormat(undefined, { style: "currency", currency: currency ?? "USD", maximumFractionDigits: 0, }); case "compact": return new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }); default: return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }); } } /** * Axis ticks from 0 (or a negative minimum) to a round maximum, about four * intervals. Whole-number data (counts) never gets fractional ticks. */ function niceScale(min: number, max: number, integersOnly: boolean): { min: number; max: number; ticks: number[] } { const span = Math.max(max - min, integersOnly ? 1 : Number.EPSILON); const rough = span / 4; const magnitude = 10 ** Math.floor(Math.log10(rough)); const candidates = [1, 2, 2.5, 5, 10].map((factor) => factor * magnitude); let step = candidates.find((candidate) => candidate >= rough) ?? 10 * magnitude; if (integersOnly) step = Math.max(1, Math.ceil(step)); const low = Math.floor(min / step) * step; const high = Math.max(low + step, Math.ceil(max / step) * step); const ticks: number[] = []; for (let value = low; value <= high + step / 2; value += step) ticks.push(Number(value.toFixed(10))); return { min: low, max: high, ticks }; } /** A bar with rounded top corners and a square baseline. */ function barPath(left: number, top: number, width: number, height: number, radius: number): string { const r = Math.max(0, Math.min(radius, width / 2, height)); const right = left + width; const bottom = top + height; return `M${left},${bottom} V${top + r} Q${left},${top} ${left + r},${top} H${right - r} Q${right},${top} ${right},${top + r} V${bottom} Z`; } function useWidth() { const ref = useRef(null); const [width, setWidth] = useState(0); useLayoutEffect(() => { const element = ref.current; if (!element) return; setWidth(element.clientWidth); const observer = new ResizeObserver(([entry]) => { if (entry) setWidth(entry.contentRect.width); }); observer.observe(element); return () => observer.disconnect(); }, []); return [ref, width] as const; } export function Chart(props: ChartProps) { const reduceMotion = useReducedMotion(); const gradientId = useId(); const [containerRef, width] = useWidth(); const [hover, setHover] = useState(null); const format = useMemo(() => formatter(props.valueFormat, props.currency), [props.valueFormat, props.currency]); const series = props.series.map((entry, index) => ({ ...entry, color: TONE_COLORS[entry.tone ?? DEFAULT_ORDER[index % DEFAULT_ORDER.length]!], label: entry.label ?? entry.key, })); const numeric = (row: Record, key: string) => { const value = row[key]; return typeof value === "number" && Number.isFinite(value) ? value : 0; }; const isSpark = props.kind === "sparkline"; const height = props.height ?? (isSpark ? 40 : props.kind === "donut" ? 200 : props.compact ? 160 : 240); const padding = isSpark ? { top: 4, right: 2, bottom: 4, left: 2 } : { top: 12, right: 12, bottom: 26, left: 44 }; if (props.data.length === 0) { return (
No data yet
); } const table = ( {props.title ? : null} {series.map((entry) => ( ))} {props.data.map((row, index) => ( {series.map((entry) => ( ))} ))}
{props.title}
{props.xKey}{entry.label}
{String(row[props.xKey] ?? "")}{format.format(numeric(row, entry.key))}
); if (props.kind === "donut") { const first = series[0]!; const total = props.data.reduce((sum, row) => sum + Math.max(0, numeric(row, first.key)), 0) || 1; const radius = 70; const circumference = 2 * Math.PI * radius; let offset = 0; const slices = props.data.map((row, index) => { const value = Math.max(0, numeric(row, first.key)); const length = (value / total) * circumference; const slice = { label: String(row[props.xKey] ?? ""), value, dash: length, offset, color: TONE_COLORS[DEFAULT_ORDER[index % DEFAULT_ORDER.length]!], }; offset += length; return slice; }); const active = hover !== null ? slices[hover] : null; return (
{props.title ?
{props.title}
: null}
{slices.map((slice, index) => ( setHover(index)} onMouseLeave={() => setHover(null)} className="cursor-default transition-[stroke-width]" /> ))}

{format.format(active ? active.value : total)}

{active ? active.label : props.series[0]?.label ? `Total ${props.series[0].label.toLowerCase()}` : "Total"}

    {slices.map((slice, index) => (
  • setHover(index)} onMouseLeave={() => setHover(null)} className="flex items-center gap-2 text-xs text-muted-foreground" > {slice.label} {Math.round((slice.value / total) * 100)}%
  • ))}
{table}
); } const innerWidth = Math.max(0, width - padding.left - padding.right); const innerHeight = height - padding.top - padding.bottom; const count = props.data.length; const stackedTotals = props.data.map((row) => series.reduce((sum, entry) => sum + numeric(row, entry.key), 0)); const rawMax = props.stacked ? Math.max(...stackedTotals, 0) : Math.max(...props.data.flatMap((row) => series.map((entry) => numeric(row, entry.key))), 0); const rawMin = props.stacked ? 0 : Math.min(0, ...props.data.flatMap((row) => series.map((entry) => numeric(row, entry.key)))); const integersOnly = props.data.every((row) => series.every((entry) => Number.isInteger(numeric(row, entry.key)))); const scale = isSpark ? { min: rawMin, max: rawMax || 1, ticks: [] as number[] } : niceScale(rawMin, rawMax, integersOnly); const max = scale.max; const min = scale.min; const y = (value: number) => padding.top + innerHeight - ((value - min) / (max - min || 1)) * innerHeight; const bandWidth = innerWidth / count; const x = (index: number) => props.kind === "bar" ? padding.left + bandWidth * index + bandWidth / 2 : padding.left + (count === 1 ? innerWidth / 2 : (innerWidth / (count - 1)) * index); const ticks = scale.ticks; const labelEvery = Math.max(1, Math.ceil(count / Math.max(1, Math.floor(innerWidth / 72)))); function pathFor(key: string, offsets: number[] | null) { return props.data .map((row, index) => { const base = offsets ? offsets[index]! : 0; return `${index === 0 ? "M" : "L"}${x(index).toFixed(2)},${y(base + numeric(row, key)).toFixed(2)}`; }) .join(" "); } const cumulative = props.data.map(() => 0); const layers = series.map((entry) => { const offsets = props.stacked ? [...cumulative] : null; const line = pathFor(entry.key, offsets); if (props.stacked) props.data.forEach((row, index) => (cumulative[index]! += numeric(row, entry.key))); return { entry, offsets, line }; }); const hovered = hover !== null ? props.data[hover] : null; return (
{props.title || (!isSpark && series.length > 1) ? (
{props.title ?
{props.title}
: } {series.length > 1 && !isSpark ? (
    {series.map((entry) => (
  • {entry.label}
  • ))}
) : null}
) : null}
{width > 0 ? ( setHover(null)} onMouseMove={(event) => { const box = event.currentTarget.getBoundingClientRect(); const offset = event.clientX - box.left - padding.left; const index = props.kind === "bar" ? Math.floor(offset / bandWidth) : Math.round(offset / (innerWidth / Math.max(1, count - 1))); setHover(index >= 0 && index < count ? index : null); }} > {series.map((entry, index) => ( ))} {ticks.map((tick, index) => ( {formatter(props.valueFormat === "currency" ? "compact" : props.valueFormat, props.currency).format(tick)} ))} {!isSpark ? props.data.map((row, index) => index % labelEvery === 0 ? ( {String(row[props.xKey] ?? "").slice(0, 12)} ) : null, ) : null} {props.kind === "bar" ? layers.map(({ entry, offsets }, seriesIndex) => { const groupWidth = Math.min(bandWidth * 0.62, 44); const barWidth = props.stacked ? groupWidth : groupWidth / series.length; return props.data.map((row, index) => { const value = numeric(row, entry.key); const base = offsets ? offsets[index]! : 0; const top = y(base + value); const bottom = y(base); const left = x(index) - groupWidth / 2 + (props.stacked ? 0 : barWidth * seriesIndex); return ( ); }); }) : layers.map(({ entry, offsets, line }, seriesIndex) => { const areaBase = props.data .map((_, index) => `L${x(count - 1 - index).toFixed(2)},${y(offsets ? offsets[count - 1 - index]! : Math.max(min, 0)).toFixed(2)}`) .join(" "); return ( {props.kind === "area" || isSpark ? ( ) : null} ); })} {hover !== null && props.kind !== "bar" && !isSpark ? ( {layers.map(({ entry }) => { const row = props.data[hover]!; const stackedValue = props.stacked ? series .slice(0, series.findIndex((candidate) => candidate.key === entry.key) + 1) .reduce((sum, candidate) => sum + numeric(row, candidate.key), 0) : numeric(row, entry.key); return ( ); })} ) : null} ) : null} {hovered && !isSpark ? (

{String(hovered[props.xKey] ?? "")}

{series.map((entry) => (

{entry.label} {format.format(numeric(hovered, entry.key))}

))}
) : null}
{table}
); } type SeriesInput = ChartProps["series"]; /** Line chart over a category key. */ export function LineChart(props: Omit) { return ; } /** Filled area chart; pass `stacked` to stack series. */ export function AreaChart(props: Omit) { return ; } /** Grouped or stacked bars. */ export function BarChart(props: Omit) { return ; } /** One proportion per category; uses the first series. */ export function DonutChart(props: Omit) { return ; } /** A tiny trend line for stats and table cells. */ export function Sparkline({ values, tone = "primary", height = 32, className, }: { values: number[]; tone?: NonNullable; height?: number; className?: string; }) { return (
({ x: index, value }))} xKey="x" series={[{ key: "value", tone }]} height={height} />
); }