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