A ground-station pass tracker with a parametric elevation-over-azimuth sky arc, a vehicle glyph sampled along the path, a live AOS countdown, and an elevation readout.
npx shadcn@latest add @paragon/sat-tracker"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export interface SatPass {
/** Satellite / vehicle identifier. */
id: string;
/** Peak elevation of the pass in degrees, 0–90. */
maxElevation: number;
/** Azimuth the pass starts at, degrees. */
aosAzimuth: number;
/** Azimuth the pass sets at, degrees. */
losAzimuth: number;
/** Seconds until acquisition of signal. */
etaSeconds: number;
/** Total visible duration of the pass in seconds. */
durationSeconds: number;
}
export interface SatTrackerProps extends React.ComponentProps<"div"> {
pass?: SatPass;
/** Ground-station name. */
station?: string;
static?: boolean;
}
const DEFAULT_PASS: SatPass = {
id: "PARAGON-4B (DEMO)",
maxElevation: 71,
aosAzimuth: 218,
losAzimuth: 34,
etaSeconds: 194,
durationSeconds: 612,
};
function fmt(s: number) {
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
}
/**
* A satellite-pass tracker. The sky track is a real elevation-over-azimuth
* arc: a quadratic Bézier whose apex height is derived from the pass's peak
* elevation, sampled by `getPointAtLength` so the moving vehicle glyph and
* the elevation plot share one coordinate space. A countdown ticks down to
* acquisition of signal in tabular figures. Reduced motion parks the vehicle
* at apex and freezes the counter.
*/
export function SatTracker({
pass = DEFAULT_PASS,
station = "GS-ALPHA / DEMO",
static: isStatic = false,
className,
...props
}: SatTrackerProps) {
const reduced = useReducedMotion();
const arcRef = React.useRef<SVGPathElement>(null);
const [eta, setEta] = React.useState(pass.etaSeconds);
const [t, setT] = React.useState(reduced || isStatic ? 0.5 : 0);
const [dot, setDot] = React.useState<{ x: number; y: number } | null>(null);
// Sky dome: viewBox 0..200 x, 0..120 y. Horizon at y=100, zenith at y=8.
// Apex height scales with peak elevation (90° => zenith).
const apexY = React.useMemo(() => {
const frac = Math.max(0, Math.min(1, pass.maxElevation / 90));
return 100 - frac * 92;
}, [pass.maxElevation]);
const arcD = `M 20 100 Q 100 ${2 * apexY - 100} 180 100`;
// Countdown to AOS.
React.useEffect(() => {
if (reduced || isStatic || eta <= 0) return;
const timer = setInterval(() => setEta((e) => Math.max(0, e - 1)), 1000);
return () => clearInterval(timer);
}, [reduced, isStatic, eta]);
// Vehicle glyph travels the arc while the pass is "visible".
React.useEffect(() => {
if (reduced || isStatic) return;
let raf = 0;
let last = performance.now();
const step = (now: number) => {
const dt = (now - last) / 1000;
last = now;
setT((prev) => {
const next = prev + dt / Math.max(30, pass.durationSeconds / 12);
return next > 1 ? 0 : next;
});
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [reduced, isStatic, pass.durationSeconds]);
// Sample the path at t → exact point on the arc.
React.useEffect(() => {
const el = arcRef.current;
if (!el) return;
const len = el.getTotalLength();
const p = el.getPointAtLength(len * t);
setDot({ x: p.x, y: p.y });
}, [t, arcD]);
const elevationNow = Math.round(((100 - (dot?.y ?? apexY)) / 92) * 90);
return (
<div
className={cn(
"w-full max-w-sm rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-baseline justify-between">
<div className="min-w-0">
<p className="truncate font-mono text-sm font-semibold">{pass.id}</p>
<p className="text-xs text-muted-foreground">{station}</p>
</div>
<div className="shrink-0 text-right">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
AOS in
</p>
<p className="font-mono text-lg font-semibold tabular-nums text-primary">
{fmt(eta)}
</p>
</div>
</div>
<div className="relative mt-3 overflow-hidden rounded-lg bg-secondary/60 p-2">
<svg viewBox="0 0 200 112" className="h-auto w-full">
{/* horizon */}
<line
x1={12}
y1={100}
x2={188}
y2={100}
stroke="var(--color-border)"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
{/* elevation guide rings at 30/60 deg — arcs computed */}
{[30, 60].map((e) => {
const y = 100 - (e / 90) * 92;
const a = `M 20 100 Q 100 ${2 * y - 100} 180 100`;
return (
<path
key={e}
d={a}
fill="none"
stroke="var(--color-border)"
strokeOpacity={0.6}
strokeDasharray="2 3"
strokeWidth={0.8}
vectorEffect="non-scaling-stroke"
/>
);
})}
{/* the pass track */}
<path
ref={arcRef}
d={arcD}
fill="none"
stroke="var(--color-primary)"
strokeWidth={1.5}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
/>
{/* AOS / LOS endpoints */}
<circle cx={20} cy={100} r={2.5} fill="var(--color-muted-foreground)" />
<circle cx={180} cy={100} r={2.5} fill="var(--color-muted-foreground)" />
{/* vehicle */}
{dot && (
<g transform={`translate(${dot.x} ${dot.y})`}>
<circle r={5} fill="var(--color-primary)" fillOpacity={0.18} />
<circle
r={2.6}
fill="var(--color-primary)"
stroke="var(--color-card)"
strokeWidth={0.8}
/>
</g>
)}
<text x={20} y={110} textAnchor="middle" fontSize={6} fill="var(--color-muted-foreground)" className="font-mono">
AOS {pass.aosAzimuth}°
</text>
<text x={180} y={110} textAnchor="middle" fontSize={6} fill="var(--color-muted-foreground)" className="font-mono">
LOS {pass.losAzimuth}°
</text>
</svg>
</div>
<dl className="mt-3 grid grid-cols-3 gap-2 text-center">
{[
{ k: "Max El", v: `${pass.maxElevation}°` },
{ k: "El now", v: `${elevationNow}°` },
{ k: "Duration", v: fmt(pass.durationSeconds) },
].map((s) => (
<div key={s.k} className="rounded-md bg-secondary/50 px-2 py-1.5">
<dt className="text-[10px] uppercase tracking-wider text-muted-foreground">
{s.k}
</dt>
<dd className="font-mono text-sm font-medium tabular-nums">{s.v}</dd>
</div>
))}
</dl>
</div>
);
}