TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23/**4 * Recharts wrappers tuned to the Spinza tokens: one gold accent, muted greys,5 * hairline solid grid, 2px lines, thin rounded bars, tooltips in a glass card.6 * Text never wears the series colour — identity comes from the swatch beside it.7 */8import * as React from "react";9import { Bar, BarChart, CartesianGrid, Cell, Line, LineChart, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";10import { cn } from "@/lib/utils";1112export const CHART = {13 accent: "#c9a961",14 accent2: "#e8cf8f",15 grey: "#7b8090",16 grey2: "#4f5464",17 info: "#6ea8ff",18 success: "#3ddc97",19 danger: "#ff5c7a",20 grid: "rgba(255,255,255,0.07)",21 axis: "rgba(255,255,255,0.12)",22 tick: "#7b8090",23} as const;2425export type Row = Record<string, unknown>;2627export interface Series {28 key: string;29 label: string;30 color: string;31 /** Stacked bars share a stackId. */32 stackId?: string;33}3435const tickStyle = { fill: CHART.tick, fontSize: 11 } as const;3637export type Formatter = (v: number) => string;3839const defaultFmt: Formatter = (v) => (Math.abs(v) >= 1_000_000 ? `${(v / 1_000_000).toFixed(1).replace(/\.0$/, "")}M` : Math.abs(v) >= 1000 ? `${(v / 1000).toFixed(1).replace(/\.0$/, "")}K` : v.toLocaleString("en-US", { maximumFractionDigits: 2 }));4041/* ----------------------------------------------------------- tooltip */4243interface TooltipEntry {44 name?: string | number;45 value?: number | string | ReadonlyArray<number | string>;46 color?: string;47 fill?: string;48 dataKey?: string | number;49 payload?: Record<string, unknown>;50}5152interface AdminTooltipProps {53 active?: boolean;54 payload?: ReadonlyArray<TooltipEntry>;55 label?: unknown;56 labelFormatter?: (label: unknown, row?: Record<string, unknown>) => string;57 valueFormatter?: Formatter;58 series?: Series[];59}6061export function AdminTooltip({ active, payload, label, labelFormatter, valueFormatter = defaultFmt, series }: AdminTooltipProps) {62 if (!active || !payload || payload.length === 0) return null;63 const row = payload[0]?.payload;64 const title = labelFormatter ? labelFormatter(label, row) : label === undefined ? "" : String(label);65 return (66 <div className="glass min-w-[140px] rounded-sm px-3 py-2 text-[12px] shadow-2xl">67 {title ? <div className="mb-1 font-semibold text-fg">{title}</div> : null}68 <div className="space-y-0.5">69 {payload.map((p, i) => {70 const s = series?.find((x) => x.key === p.dataKey);71 const v = typeof p.value === "number" ? valueFormatter(p.value) : String(p.value ?? "—");72 return (73 <div key={i} className="flex items-center justify-between gap-4">74 <span className="inline-flex items-center gap-1.5 text-fg-3">75 <span className="inline-block h-2 w-2 rounded-[2px]" style={{ background: s?.color ?? p.color ?? p.fill ?? CHART.accent }} />76 {s?.label ?? String(p.name ?? p.dataKey ?? "")}77 </span>78 <span className="tabular font-medium text-fg">{v}</span>79 </div>80 );81 })}82 </div>83 </div>84 );85}8687/* ------------------------------------------------------------ legend */8889export function ChartLegend({ series, className }: { series: Series[]; className?: string }) {90 if (series.length < 2) return null;91 return (92 <div className={cn("flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] text-fg-3", className)}>93 {series.map((s) => (94 <span key={s.key} className="inline-flex items-center gap-1.5">95 <span className="inline-block h-2 w-2 rounded-[2px]" style={{ background: s.color }} />96 {s.label}97 </span>98 ))}99 </div>100 );101}102103/* ------------------------------------------------------------- frame */104105export function ChartFrame({ title, subtitle, series, children, height = 220, right, className }: { title?: React.ReactNode; subtitle?: React.ReactNode; series?: Series[]; children: React.ReactNode; height?: number; right?: React.ReactNode; className?: string }) {106 return (107 <div className={cn("min-w-0", className)}>108 {title || right ? (109 <div className="mb-2 flex flex-wrap items-start justify-between gap-2">110 <div>111 {title ? <div className="text-[13px] font-semibold tracking-tight">{title}</div> : null}112 {subtitle ? <div className="text-[12px] text-fg-3">{subtitle}</div> : null}113 </div>114 {right}115 </div>116 ) : null}117 {series ? <ChartLegend series={series} className="mb-2" /> : null}118 <div style={{ height }} className="w-full">119 {children}120 </div>121 </div>122 );123}124125export function ChartEmpty({ message = "No data for this period." }: { message?: string }) {126 return <div className="grid h-full place-items-center rounded-sm border border-dashed border-line text-[12px] text-fg-4">{message}</div>;127}128129/* ------------------------------------------------------------- bars */130131export function Bars({ data, x, series, xFormat, yFormat = defaultFmt, stacked, barSize = 18, tooltipLabel, yDomain, layout = "horizontal", height, hideXTicks, referenceY }: { data: Row[]; x: string; series: Series[]; xFormat?: (v: unknown) => string; yFormat?: Formatter; stacked?: boolean; barSize?: number; tooltipLabel?: (label: unknown, row?: Record<string, unknown>) => string; yDomain?: [number | "auto" | "dataMin" | "dataMax", number | "auto" | "dataMin" | "dataMax"]; layout?: "horizontal" | "vertical"; height?: number; hideXTicks?: boolean; referenceY?: { y: number; label?: string } }) {132 if (data.length === 0) return <ChartEmpty />;133 const vertical = layout === "vertical";134 return (135 <ResponsiveContainer width="100%" height={height ?? "100%"}>136 <BarChart data={data} layout={layout} stackOffset={stacked ? "sign" : "none"} margin={{ top: 6, right: 8, bottom: 0, left: vertical ? 8 : -12 }} barGap={2} barCategoryGap={vertical ? 6 : "24%"}>137 <CartesianGrid stroke={CHART.grid} vertical={vertical} horizontal={!vertical} />138 {vertical ? (139 <>140 <XAxis type="number" tick={tickStyle} tickFormatter={yFormat} axisLine={{ stroke: CHART.axis }} tickLine={false} domain={yDomain} allowDecimals={false} />141 <YAxis type="category" dataKey={x} tick={tickStyle} tickFormatter={xFormat} axisLine={false} tickLine={false} width={120} interval={0} />142 </>143 ) : (144 <>145 <XAxis dataKey={x} tick={hideXTicks ? false : tickStyle} tickFormatter={xFormat} axisLine={{ stroke: CHART.axis }} tickLine={false} minTickGap={18} />146 <YAxis tick={tickStyle} tickFormatter={yFormat} axisLine={false} tickLine={false} width={48} domain={yDomain} allowDecimals={false} />147 </>148 )}149 <Tooltip cursor={{ fill: "rgba(255,255,255,0.04)" }} content={<AdminTooltip series={series} valueFormatter={yFormat} labelFormatter={tooltipLabel ?? (xFormat ? (l) => xFormat(l) : undefined)} />} />150 {referenceY ? <ReferenceLine y={referenceY.y} stroke={CHART.grey} strokeDasharray="4 4" label={referenceY.label ? { value: referenceY.label, fill: CHART.tick, fontSize: 10, position: "insideTopRight" } : undefined} /> : null}151 {series.map((s, i) => {152 const last = i === series.length - 1;153 return <Bar key={s.key} dataKey={s.key} name={s.label} fill={s.color} stackId={stacked ? (s.stackId ?? "a") : s.stackId} barSize={barSize} radius={!stacked || last ? (vertical ? [0, 4, 4, 0] : [4, 4, 0, 0]) : 0} stroke={stacked ? "#0c0e14" : undefined} strokeWidth={stacked ? 1 : 0} isAnimationActive={false} />;154 })}155 </BarChart>156 </ResponsiveContainer>157 );158}159160/** Single-series histogram with optional highlighted bucket. */161export function Histogram({ data, x, y, xFormat, yFormat = defaultFmt, logScale, highlight, color = CHART.accent, height }: { data: Row[]; x: string; y: string; xFormat?: (v: unknown) => string; yFormat?: Formatter; logScale?: boolean; highlight?: (row: Row) => boolean; color?: string; height?: number }) {162 if (data.length === 0) return <ChartEmpty />;163 const rows = logScale ? data.map((r) => ({ ...r, [y]: (r[y] as number) > 0 ? (r[y] as number) : null })) : data;164 return (165 <ResponsiveContainer width="100%" height={height ?? "100%"}>166 <BarChart data={rows} margin={{ top: 6, right: 8, bottom: 0, left: -8 }} barCategoryGap="20%">167 <CartesianGrid stroke={CHART.grid} vertical={false} />168 <XAxis dataKey={x} tick={tickStyle} tickFormatter={xFormat} axisLine={{ stroke: CHART.axis }} tickLine={false} interval={0} angle={data.length > 8 ? -30 : 0} textAnchor={data.length > 8 ? "end" : "middle"} height={data.length > 8 ? 42 : 24} />169 <YAxis tick={tickStyle} tickFormatter={yFormat} axisLine={false} tickLine={false} width={52} scale={logScale ? "log" : "auto"} domain={logScale ? [1, "auto"] : [0, "auto"]} allowDataOverflow={logScale} allowDecimals={false} />170 <Tooltip cursor={{ fill: "rgba(255,255,255,0.04)" }} content={<AdminTooltip valueFormatter={yFormat} labelFormatter={xFormat ? (l) => xFormat(l) : undefined} />} />171 <Bar dataKey={y} name={String(y)} fill={color} radius={[4, 4, 0, 0]} barSize={22} isAnimationActive={false}>172 {highlight ? data.map((r, i) => <Cell key={i} fill={highlight(r) ? CHART.accent2 : color} />) : null}173 </Bar>174 </BarChart>175 </ResponsiveContainer>176 );177}178179/* ------------------------------------------------------------- lines */180181export function Lines({ data, x, series, xFormat, yFormat = defaultFmt, yDomain, reference, tooltipLabel, height, dots }: { data: Row[]; x: string; series: Series[]; xFormat?: (v: unknown) => string; yFormat?: Formatter; yDomain?: [number | "auto" | "dataMin" | "dataMax", number | "auto" | "dataMin" | "dataMax"]; reference?: { y: number; label?: string }; tooltipLabel?: (label: unknown, row?: Record<string, unknown>) => string; height?: number; dots?: boolean }) {182 if (data.length === 0) return <ChartEmpty />;183 return (184 <ResponsiveContainer width="100%" height={height ?? "100%"}>185 <LineChart data={data} margin={{ top: 6, right: 12, bottom: 0, left: -8 }}>186 <CartesianGrid stroke={CHART.grid} vertical={false} />187 <XAxis dataKey={x} tick={tickStyle} tickFormatter={xFormat} axisLine={{ stroke: CHART.axis }} tickLine={false} minTickGap={24} />188 <YAxis tick={tickStyle} tickFormatter={yFormat} axisLine={false} tickLine={false} width={56} domain={yDomain ?? ["auto", "auto"]} />189 <Tooltip cursor={{ stroke: CHART.axis }} content={<AdminTooltip series={series} valueFormatter={yFormat} labelFormatter={tooltipLabel ?? (xFormat ? (l) => xFormat(l) : undefined)} />} />190 {reference ? <ReferenceLine y={reference.y} stroke={CHART.grey} strokeDasharray="4 4" label={reference.label ? { value: reference.label, fill: CHART.tick, fontSize: 10, position: "insideTopRight" } : undefined} /> : null}191 {series.map((s) => (192 <Line key={s.key} type="monotone" dataKey={s.key} name={s.label} stroke={s.color} strokeWidth={2} dot={dots ? { r: 3, fill: s.color, stroke: "#0c0e14", strokeWidth: 2 } : false} activeDot={{ r: 4, fill: s.color, stroke: "#0c0e14", strokeWidth: 2 }} isAnimationActive={false} connectNulls />193 ))}194 </LineChart>195 </ResponsiveContainer>196 );197}198199/* ------------------------------------------------------- sparkline */200201export function Sparkline({ values, color = CHART.accent, height = 28, width = 96 }: { values: number[]; color?: string; height?: number; width?: number }) {202 if (values.length < 2) return <span className="text-fg-4">—</span>;203 const min = Math.min(...values);204 const max = Math.max(...values);205 const span = max - min || 1;206 const pts = values.map((v, i) => `${(i / (values.length - 1)) * width},${height - ((v - min) / span) * (height - 4) - 2}`).join(" ");207 return (208 <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden>209 <polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} strokeLinejoin="round" strokeLinecap="round" />210 </svg>211 );212}213