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.0 KB · 49 lines tsx
Raw Blame History
1import { scaleLinear } from 'd3-scale';2import { curveMonotoneX, line as d3line } from 'd3-shape';3import { fmtDate, fmtInt, num } from '@/lib/format';4import type { SatelliteHistory } from '@/lib/types';56/**7 * Perigee / apogee over time (dual-line, SVG, server-safe). Local variant of the shared AreaChart which is single-series.8 * Renders only with ≥ 2 daily points — the caller shows an honest note otherwise.9 */10export function AltitudeChart({ series, height = 180 }: { series: SatelliteHistory['altitude_series']; height?: number }) {11  const rows = series12    .map((r) => ({ day: r.day, perigee: num(r.perigee_km), apogee: num(r.apogee_km) }))13    .filter((r): r is { day: string; perigee: number; apogee: number } => r.perigee !== null && r.apogee !== null);14  if (rows.length < 2) return null;15  const W = 640;16  const H = height;17  const pad = { l: 44, r: 10, t: 10, b: 24 };18  const x = scaleLinear().domain([0, rows.length - 1]).range([pad.l, W - pad.r]);19  const lo = Math.min(...rows.map((r) => r.perigee));20  const hi = Math.max(...rows.map((r) => r.apogee));21  const padY = (hi - lo) * 0.15 || 2;22  const y = scaleLinear().domain([lo - padY, hi + padY]).nice().range([H - pad.b, pad.t]);23  const mk = (key: 'perigee' | 'apogee') => d3line<(typeof rows)[number]>().x((_, i) => x(i)).y((d) => y(d[key])).curve(curveMonotoneX)(rows) ?? '';24  const every = Math.max(1, Math.ceil(rows.length / 6));25  return (26    <div>27      <svg viewBox={`0 0 ${W} ${H}`} className="h-auto w-full" role="img" aria-label="Perigee and apogee altitude over time" preserveAspectRatio="none">28        <title>Perigee and apogee altitude over time</title>29        {y.ticks(4).map((t) => (30          <g key={t}>31            <line x1={pad.l} x2={W - pad.r} y1={y(t)} y2={y(t)} stroke="var(--rule)" />32            <text x={pad.l - 6} y={y(t)} dy="0.32em" textAnchor="end" fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{fmtInt(t)}</text>33          </g>34        ))}35        <path d={mk('apogee')} fill="none" stroke="var(--series-2)" strokeWidth={1.6} />36        <path d={mk('perigee')} fill="none" stroke="var(--series-1)" strokeWidth={1.6} />37        {rows.map((r, i) => (i % every === 0 || i === rows.length - 1) && (38          <text key={r.day} x={x(i)} y={H - 6} textAnchor={i === rows.length - 1 ? 'end' : i === 0 ? 'start' : 'middle'} fontSize="10" fill="var(--ink-3)" fontFamily="var(--font-mono)">{r.day.slice(0, 10)}</text>39        ))}40      </svg>41      <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-ink-2">42        <li className="inline-flex items-center gap-1.5"><span className="inline-block h-0.5 w-4" style={{ background: 'var(--series-2)' }} /> Apogee (km)</li>43        <li className="inline-flex items-center gap-1.5"><span className="inline-block h-0.5 w-4" style={{ background: 'var(--series-1)' }} /> Perigee (km)</li>44        <li className="text-ink-3">{rows.length} daily points · {fmtDate(rows[0]!.day)} → {fmtDate(rows[rows.length - 1]!.day)}</li>45      </ul>46    </div>47  );48}49