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%
3.6 KB · 79 lines tsx
Raw Blame History
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