SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
11.8 KB · 213 lines tsx
Raw Blame History
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