spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1/**2 * Local chart variants for the stats/debris/reentries pages (pure SVG, server-safe).3 * Same visual system as `@/components/charts/charts` — hairline axes, mono tick labels, token colours.4 */5import { max as d3max } from 'd3-array';6import { scaleLinear } from 'd3-scale';7import { cn } from '@/lib/cn';8import { fmtCompact } from '@/lib/format';910export interface MultiBin {11 bin: number;12 values: number[];13}1415/**16 * Grouped histogram: several series drawn side-by-side inside each bin (e.g. active payloads vs debris per 25 km).17 * `series` = labels + colours in the same order as `values`.18 */19export function GroupedHistogram({ data, series, className, height = 170, title, unit = '', xTicks = 8 }: { data: MultiBin[]; series: { label: string; color: string }[]; className?: string; height?: number; title: string; unit?: string; xTicks?: number }) {20 if (!data.length || !series.length) {21 return (22 <div className={cn('flex items-center justify-center rounded-md border border-dashed border-rule text-xs text-ink-3', className)} style={{ height }} role="img" aria-label="Chart unavailable">23 Unavailable24 </div>25 );26 }27 const W = 640;28 const H = height;29 const pad = { l: 36, r: 8, t: 6, b: 22 };30 const bins = data.map((d) => d.bin);31 const step = bins.length > 1 ? Math.min(...bins.slice(1).map((b, i) => b - (bins[i] ?? 0)).filter((v) => v > 0)) || 1 : 1;32 const x = scaleLinear().domain([Math.min(...bins), Math.max(...bins) + step]).range([pad.l, W - pad.r]);33 const ymaxRaw = d3max(data, (d) => Math.max(...d.values)) || 1;34 const y = scaleLinear().domain([0, ymaxRaw]).nice().range([H - pad.b, pad.t]);35 const slot = Math.max(1, x(step) - x(0) - 1);36 const bw = Math.max(0.8, slot / series.length);37 return (38 <div className={className}>39 <svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img" aria-label={title} preserveAspectRatio="none">40 <title>{title}</title>41 {y.ticks(3).map((t) => (42 <g key={t}>43 <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />44 <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>45 </g>46 ))}47 {data.map((d) =>48 d.values.map((v, si) => (49 <rect key={`${d.bin}-${si}`} x={x(d.bin) + si * bw} y={y(v)} width={bw} height={Math.max(0, H - pad.b - y(v))} fill={series[si]?.color ?? 'var(--series-1)'} opacity={0.9} />50 )),51 )}52 {x.ticks(xTicks).map((t) => (53 <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>54 ))}55 </svg>56 <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2">57 {series.map((s) => (58 <li key={s.label} className="inline-flex items-center gap-1.5">59 <span className="inline-block size-2.5 rounded-sm" style={{ background: s.color }} /> {s.label}60 </li>61 ))}62 </ul>63 </div>64 );65}6667/** Legend-only helper for charts that need an external key (e.g. two donuts sharing colours). */68export function Legend({ items, className }: { items: { label: string; color: string }[]; className?: string }) {69 return (70 <ul className={cn('flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2', className)}>71 {items.map((s) => (72 <li key={s.label} className="inline-flex items-center gap-1.5">73 <span className="inline-block size-2.5 rounded-sm" style={{ background: s.color }} /> {s.label}74 </li>75 ))}76 </ul>77 );78}79