SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
13.8 KB · 254 lines tsx
Raw Blame History
1/**2 * Small, dependency-light SVG charts (server-component friendly, no client JS). One visual system:3 * hairline axes, tabular numbers, series colours from the design tokens, dark surfaces.4 * Every chart accepts `title` (visually hidden <title>) for accessibility and renders an "Unavailable" state for empty data.5 */6import { max as d3max } from 'd3-array';7import { scaleBand, scaleLinear } from 'd3-scale';8import { area as d3area, curveMonotoneX, line as d3line } from 'd3-shape';9import { cn } from '@/lib/cn';10import { fmtCompact, fmtInt } from '@/lib/format';1112export const SERIES = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)', 'var(--series-4)', 'var(--series-5)', 'var(--series-6)', 'var(--series-7)', 'var(--series-8)'];1314function Empty({ className, h }: { className?: string; h: number }) {15  return (16    <div className={cn('flex items-center justify-center rounded-md border border-dashed border-rule text-xs text-ink-3', className)} style={{ height: h }} role="img" aria-label="Chart unavailable">17      Unavailable18    </div>19  );20}2122export interface BarDatum {23  label: string;24  value: number;25  color?: string;26  href?: string;27}2829/** Horizontal bars with labels — ranking lists. */30export function HBars({ data, className, max, valueFormat = fmtInt, barHeight = 26, showValue = true }: { data: BarDatum[]; className?: string; max?: number; valueFormat?: (v: number) => string; barHeight?: number; showValue?: boolean }) {31  if (!data.length) return <Empty className={className} h={120} />;32  const m = max ?? d3max(data, (d) => d.value) ?? 1;33  return (34    <ul className={cn('space-y-1.5', className)}>35      {data.map((d, i) => (36        <li key={`${d.label}-${i}`} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-sm" style={{ minHeight: barHeight }}>37          <div className="min-w-0">38            <div className="flex items-baseline justify-between gap-2">39              {d.href ? <a href={d.href} className="truncate text-ink hover:text-accent hover:underline">{d.label}</a> : <span className="truncate text-ink">{d.label}</span>}40              {showValue && <span className="tnum shrink-0 text-xs text-ink-2">{valueFormat(d.value)}</span>}41            </div>42            <div className="mt-1 h-[6px] w-full overflow-hidden rounded-full bg-plane-2">43              <div className="h-full rounded-full" style={{ width: `${Math.max(1, (d.value / m) * 100)}%`, background: d.color ?? 'var(--series-1)' }} />44            </div>45          </div>46        </li>47      ))}48    </ul>49  );50}5152export interface SeriesPoint {53  x: number | string;54  y: number;55}5657/** Vertical bar chart (time series by year/month). */58export function Bars({ data, className, height = 160, color = 'var(--series-1)', title, xTicks = 6, yFormat = fmtCompact, highlightLast = false }: { data: SeriesPoint[]; className?: string; height?: number; color?: string; title: string; xTicks?: number; yFormat?: (v: number) => string; highlightLast?: boolean }) {59  if (!data.length) return <Empty className={className} h={height} />;60  const W = 640;61  const H = height;62  const pad = { l: 36, r: 8, t: 8, b: 22 };63  const x = scaleBand<string>().domain(data.map((d) => String(d.x))).range([pad.l, W - pad.r]).paddingInner(0.25);64  const ymax = d3max(data, (d) => d.y) ?? 1;65  const y = scaleLinear().domain([0, ymax || 1]).nice().range([H - pad.b, pad.t]);66  const ticks = y.ticks(4);67  const every = Math.max(1, Math.ceil(data.length / xTicks));68  return (69    <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={title} preserveAspectRatio="none">70      <title>{title}</title>71      {ticks.map((t) => (72        <g key={t}>73          <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />74          <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{yFormat(t)}</text>75        </g>76      ))}77      {data.map((d, i) => (78        <rect key={String(d.x)} x={x(String(d.x))} y={y(d.y)} width={x.bandwidth()} height={Math.max(0, H - pad.b - y(d.y))} fill={highlightLast && i === data.length - 1 ? 'var(--accent)' : color} opacity={0.9} rx={1} />79      ))}80      {data.map((d, i) => (i % every === 0 || i === data.length - 1) && (81        <text key={`t${String(d.x)}`} x={(x(String(d.x)) ?? 0) + x.bandwidth() / 2} y={H - 6} textAnchor="middle" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{String(d.x).slice(0, 7)}</text>82      ))}83    </svg>84  );85}8687/** Stacked vertical bars (e.g. launches by year by region). `keys` order = stack order; colours from SERIES. */88export function StackedBars({ data, keys, labels, className, height = 200, title, xTicks = 8 }: { data: Record<string, number | string>[]; keys: string[]; labels?: Record<string, string>; className?: string; height?: number; title: string; xTicks?: number }) {89  if (!data.length) return <Empty className={className} h={height} />;90  const W = 640;91  const H = height;92  const pad = { l: 36, r: 8, t: 8, b: 22 };93  const xs = data.map((d) => String(d.x));94  const x = scaleBand<string>().domain(xs).range([pad.l, W - pad.r]).paddingInner(0.2);95  const totals = data.map((d) => keys.reduce((s, k) => s + (Number(d[k]) || 0), 0));96  const y = scaleLinear().domain([0, d3max(totals) || 1]).nice().range([H - pad.b, pad.t]);97  const every = Math.max(1, Math.ceil(data.length / xTicks));98  return (99    <div className={className}>100      <svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img" aria-label={title} preserveAspectRatio="none">101        <title>{title}</title>102        {y.ticks(4).map((t) => (103          <g key={t}>104            <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />105            <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtCompact(t)}</text>106          </g>107        ))}108        {data.map((d, i) => {109          let acc = 0;110          return keys.map((k, ki) => {111            const v = Number(d[k]) || 0;112            const y0 = y(acc);113            acc += v;114            const y1 = y(acc);115            return <rect key={`${i}-${k}`} x={x(String(d.x))} y={y1} width={x.bandwidth()} height={Math.max(0, y0 - y1)} fill={SERIES[ki % SERIES.length]} opacity={0.9} />;116          });117        })}118        {xs.map((lab, i) => (i % every === 0 || i === xs.length - 1) && (119          <text key={lab} x={(x(lab) ?? 0) + x.bandwidth() / 2} y={H - 6} textAnchor="middle" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{lab.slice(0, 7)}</text>120        ))}121      </svg>122      <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2">123        {keys.map((k, i) => (124          <li key={k} className="inline-flex items-center gap-1.5">125            <span className="inline-block size-2.5 rounded-sm" style={{ background: SERIES[i % SERIES.length] }} /> {labels?.[k] ?? k}126          </li>127        ))}128      </ul>129    </div>130  );131}132133/** Area/line chart for continuous series. */134export function AreaChart({ data, className, height = 160, color = 'var(--series-1)', title, yFormat = fmtCompact, xLabel, fill = true, yDomain }: { data: SeriesPoint[]; className?: string; height?: number; color?: string; title: string; yFormat?: (v: number) => string; xLabel?: (x: number | string) => string; fill?: boolean; yDomain?: [number, number] }) {135  if (data.length < 2) return <Empty className={className} h={height} />;136  const W = 640;137  const H = height;138  const pad = { l: 40, r: 8, t: 8, b: 22 };139  const x = scaleLinear().domain([0, data.length - 1]).range([pad.l, W - pad.r]);140  const ys = data.map((d) => d.y);141  const lo = yDomain ? yDomain[0] : Math.min(...ys);142  const hi = yDomain ? yDomain[1] : Math.max(...ys);143  const padY = (hi - lo) * 0.1 || 1;144  const y = scaleLinear().domain([yDomain ? lo : lo - padY, yDomain ? hi : hi + padY]).nice().range([H - pad.b, pad.t]);145  const ln = d3line<SeriesPoint>().x((_, i) => x(i)).y((d) => y(d.y)).curve(curveMonotoneX);146  const ar = d3area<SeriesPoint>().x((_, i) => x(i)).y0(H - pad.b).y1((d) => y(d.y)).curve(curveMonotoneX);147  const every = Math.max(1, Math.ceil(data.length / 6));148  const gid = `g${Math.abs(hashStr(title))}`;149  return (150    <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={title} preserveAspectRatio="none">151      <title>{title}</title>152      <defs>153        <linearGradient id={gid} x1="0" x2="0" y1="0" y2="1">154          <stop offset="0" stopColor={color} stopOpacity="0.35" />155          <stop offset="1" stopColor={color} stopOpacity="0" />156        </linearGradient>157      </defs>158      {y.ticks(4).map((t) => (159        <g key={t}>160          <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />161          <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{yFormat(t)}</text>162        </g>163      ))}164      {fill && <path d={ar(data) ?? ''} fill={`url(#${gid})`} />}165      <path d={ln(data) ?? ''} fill="none" stroke={color} strokeWidth={1.8} />166      {data.map((d, i) => (i % every === 0 || i === data.length - 1) && (167        <text key={i} x={x(i)} y={H - 6} textAnchor={i === data.length - 1 ? 'end' : i === 0 ? 'start' : 'middle'} fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{xLabel ? xLabel(d.x) : String(d.x).slice(0, 7)}</text>168      ))}169    </svg>170  );171}172173/** Donut with legend — status/orbit distributions. */174export function Donut({ data, className, size = 140, title, total }: { data: BarDatum[]; className?: string; size?: number; title: string; total?: number }) {175  const sum = data.reduce((s, d) => s + d.value, 0);176  if (!sum) return <Empty className={className} h={size} />;177  const r = size / 2;178  const stroke = size * 0.16;179  const c = 2 * Math.PI * (r - stroke / 2);180  let acc = 0;181  return (182    <div className={cn('flex items-center gap-5', className)}>183      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} role="img" aria-label={title} className="shrink-0">184        <title>{title}</title>185        <circle cx={r} cy={r} r={r - stroke / 2} fill="none" stroke="var(--plane-2)" strokeWidth={stroke} />186        {data.map((d, i) => {187          const frac = d.value / sum;188          const el = <circle key={i} cx={r} cy={r} r={r - stroke / 2} fill="none" stroke={d.color ?? SERIES[i % SERIES.length]} strokeWidth={stroke} strokeDasharray={`${frac * c} ${c}`} strokeDashoffset={-acc * c} transform={`rotate(-90 ${r} ${r})`} />;189          acc += frac;190          return el;191        })}192        <text x={r} y={r} dy="0.35em" textAnchor="middle" fontSize={size * 0.16} fontWeight={600} fill="var(--ink)" fontFamily="var(--font-mono)">{fmtCompact(total ?? sum)}</text>193      </svg>194      <ul className="min-w-0 flex-1 space-y-1 text-sm">195        {data.map((d, i) => (196          <li key={i} className="flex items-center justify-between gap-3">197            <span className="inline-flex min-w-0 items-center gap-2 text-ink-2"><span className="inline-block size-2.5 shrink-0 rounded-sm" style={{ background: d.color ?? SERIES[i % SERIES.length] }} /><span className="truncate">{d.label}</span></span>198            <span className="tnum shrink-0 text-ink">{fmtInt(d.value)} <span className="text-ink-3">· {((d.value / sum) * 100).toFixed(0)}%</span></span>199          </li>200        ))}201      </ul>202    </div>203  );204}205206/** Tiny inline sparkline. */207export function Sparkline({ values, width = 90, height = 24, color = 'var(--accent)' }: { values: number[]; width?: number; height?: number; color?: string }) {208  if (values.length < 2) return <span className="text-xs text-ink-3">—</span>;209  const x = scaleLinear().domain([0, values.length - 1]).range([1, width - 1]);210  const y = scaleLinear().domain([Math.min(...values), Math.max(...values) || 1]).range([height - 2, 2]);211  const d = d3line<number>().x((_, i) => x(i)).y((v) => y(v)).curve(curveMonotoneX)(values) ?? '';212  return (213    <svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} aria-hidden className="inline-block align-middle">214      <path d={d} fill="none" stroke={color} strokeWidth={1.5} />215    </svg>216  );217}218219/** Histogram (altitude/inclination profiles): values already bucketed. */220export function Histogram({ data, className, height = 140, color = 'var(--series-1)', title, unit = '', xTicks = 8 }: { data: { bin: number; value: number }[]; className?: string; height?: number; color?: string; title: string; unit?: string; xTicks?: number }) {221  if (!data.length) return <Empty className={className} h={height} />;222  const W = 640;223  const H = height;224  const pad = { l: 36, r: 8, t: 6, b: 22 };225  const bins = data.map((d) => d.bin);226  const step = bins.length > 1 ? Math.min(...bins.slice(1).map((b, i) => b - (bins[i] ?? 0)).filter((v) => v > 0)) || 1 : 1;227  const x = scaleLinear().domain([Math.min(...bins), Math.max(...bins) + step]).range([pad.l, W - pad.r]);228  const y = scaleLinear().domain([0, d3max(data, (d) => d.value) || 1]).nice().range([H - pad.b, pad.t]);229  const bw = Math.max(1, x(step) - x(0) - 1);230  return (231    <svg viewBox={`0 0 ${W} ${H}`} className={cn('h-auto w-full', className)} role="img" aria-label={title} preserveAspectRatio="none">232      <title>{title}</title>233      {y.ticks(3).map((t) => (234        <g key={t}>235          <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />236          <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtCompact(t)}</text>237        </g>238      ))}239      {data.map((d) => (240        <rect key={d.bin} x={x(d.bin)} y={y(d.value)} width={bw} height={Math.max(0, H - pad.b - y(d.value))} fill={color} opacity={0.9} />241      ))}242      {x.ticks(xTicks).map((t) => (243        <text key={t} x={x(t)} y={H - 6} textAnchor="middle" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtCompact(t)}{unit}</text>244      ))}245    </svg>246  );247}248249function hashStr(s: string): number {250  let h = 0;251  for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;252  return h;253}254