Defense & Aerospace

Satellite Pass Tracker

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.

Install

npx shadcn@latest add @paragon/sat-tracker

sat-tracker.tsx

"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>
  );
}