spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
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