Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ From the repo root, `make test-web` runs `npm test` here. It is not part of
## Sprints

- 12: scaffold
- 13: CelesTrak TLEs + SGP4 (this branch)
- 14: Nairobi contact windows
- 13: CelesTrak TLEs + SGP4
- 14: Nairobi contact windows (this branch)
- 15: Cesium globe, picker, credits, Heavens-Above/N2YO check

`npm run dev` proxies `/api/celestrak` to CelesTrak so the browser can fetch
Expand Down
81 changes: 71 additions & 10 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import { useEffect, useState } from "react";
import { SATELLITES } from "./orbit/catalog";
import { useEffect, useMemo, useState } from "react";
import { tleCache, type CachedTle } from "./orbit/cache";
import { SATELLITES } from "./orbit/catalog";
import { countdownSeconds, formatCountdown } from "./orbit/countdown";
import { fetchAndCacheTle } from "./orbit/fetchTle";
import { nextPass, type Pass } from "./orbit/pass";
import { DEFAULT_ELEVATION_MASK_DEG, NAIROBI } from "./orbit/station";
import { V2_STATUS } from "./v2";

function utc(d: Date): string {
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}

export default function App() {
const [cached, setCached] = useState<Record<number, CachedTle>>({});
const [selected, setSelected] = useState(SATELLITES[0].catnr);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<number | null>(null);
const [now, setNow] = useState(() => new Date());

useEffect(() => {
const cache = tleCache();
Expand All @@ -21,37 +30,62 @@ export default function App() {
setCached(next);
}, []);

useEffect(() => {
const id = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(id);
}, []);

async function load(catnr: number) {
setBusy(catnr);
setError(null);
try {
const entry = await fetchAndCacheTle(catnr);
setCached((prev) => ({ ...prev, [catnr]: entry }));
setSelected(catnr);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(null);
}
}

const tle = cached[selected];
const passBucket = Math.floor(now.getTime() / 15_000);
const pass: Pass | null = useMemo(() => {
if (!tle) {
return null;
}
return nextPass(tle, NAIROBI, new Date(), DEFAULT_ELEVATION_MASK_DEG);
}, [tle, passBucket]);

const inPass =
pass !== null &&
now.getTime() >= pass.rise.getTime() &&
now.getTime() < pass.set.getTime();

return (
<main>
<p className="kicker">OrbitLog</p>
<h1>Ride along with a real satellite and watch it think.</h1>
<p>
A TLE is a snapshot of an orbit. SGP4 is the standard model that
turns it into a position. Fetch times below are when this browser
last pulled CelesTrak; the globe comes in a later sprint.
A <strong>contact window</strong> is the few minutes a satellite is
above {DEFAULT_ELEVATION_MASK_DEG}° elevation at {NAIROBI.name}.{" "}
<strong>Look angles</strong> are azimuth (compass direction) and
elevation (angle above the horizon). The globe comes next.
</p>
<ul className="sats">
{SATELLITES.map((sat) => {
const hit = cached[sat.catnr];
const active = sat.catnr === selected;
return (
<li key={sat.catnr}>
<span>
{sat.name}{" "}
<code>CATNR {sat.catnr}</code>
</span>
<li key={sat.catnr} className={active ? "active" : undefined}>
<button
type="button"
className="pick"
onClick={() => setSelected(sat.catnr)}
>
{sat.name} <code>CATNR {sat.catnr}</code>
</button>
<span className="meta">
{hit ? `TLE fetched ${hit.fetchedAt}` : "TLE not fetched"}
</span>
Expand All @@ -67,6 +101,33 @@ export default function App() {
})}
</ul>
{error ? <p className="error">{error}</p> : null}
<section className="pass" aria-live="polite">
<h2>
Next pass over {NAIROBI.name} (mask {DEFAULT_ELEVATION_MASK_DEG}°)
</h2>
{!tle ? (
<p>Fetch a TLE to predict the next window.</p>
) : !pass ? (
<p>No pass in the next 36 hours for this TLE.</p>
) : (
<dl>
<dt>Countdown</dt>
<dd>
{inPass
? `In contact · sets in ${formatCountdown(countdownSeconds(pass.set, now))}`
: formatCountdown(countdownSeconds(pass.rise, now))}
</dd>
<dt>Rise</dt>
<dd>{utc(pass.rise)}</dd>
<dt>Max elevation</dt>
<dd>
{pass.maxElDeg.toFixed(1)}° at {utc(pass.maxElAt)}
</dd>
<dt>Set</dt>
<dd>{utc(pass.set)}</dd>
</dl>
)}
</section>
<p>
v2, the onboard diagnosis story, is <strong>{V2_STATUS}</strong> until
the benchmark writes files under <code>results/</code>. This page will
Expand Down
39 changes: 39 additions & 0 deletions web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,45 @@ p {
border-top: 1px solid #2a2e35;
}

.sats li.active {
background: #1a1d22;
}

.pick {
text-align: left;
background: none;
border: 0;
color: inherit;
padding: 0;
cursor: pointer;
}

dl {
display: grid;
grid-template-columns: 10rem 1fr;
gap: 0.35rem 1rem;
margin: 0;
}

dt {
color: #9aa0a6;
}

dd {
margin: 0;
}

.pass h2 {
font-size: 1rem;
font-weight: 600;
margin: 0 0 0.75rem;
}

.pass {
margin: 0 0 1.5rem;
padding-top: 0.5rem;
}

.sats li .meta {
grid-column: 1;
font-size: 0.85rem;
Expand Down
26 changes: 26 additions & 0 deletions web/src/orbit/countdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { countdownSeconds, formatCountdown } from "./countdown";

describe("countdownSeconds", () => {
it("clamps at zero", () => {
const to = new Date("2026-10-19T12:00:00.000Z");
const now = new Date("2026-10-19T12:00:10.000Z");
expect(countdownSeconds(to, now)).toBe(0);
});

it("counts whole seconds remaining", () => {
const to = new Date("2026-10-19T12:01:30.000Z");
const now = new Date("2026-10-19T12:00:00.000Z");
expect(countdownSeconds(to, now)).toBe(90);
});
});

describe("formatCountdown", () => {
it("formats mm:ss under an hour", () => {
expect(formatCountdown(90)).toBe("01:30");
});

it("formats h:mm:ss over an hour", () => {
expect(formatCountdown(3661)).toBe("1:01:01");
});
});
16 changes: 16 additions & 0 deletions web/src/orbit/countdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Seconds remaining until `to`, never negative. */
export function countdownSeconds(to: Date, now: Date): number {
return Math.max(0, Math.floor((to.getTime() - now.getTime()) / 1000));
}

export function formatCountdown(totalSeconds: number): string {
const seconds = Math.max(0, Math.floor(totalSeconds));
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const two = (n: number) => String(n).padStart(2, "0");
if (h > 0) {
return `${h}:${two(m)}:${two(s)}`;
}
return `${two(m)}:${two(s)}`;
}
57 changes: 57 additions & 0 deletions web/src/orbit/look.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Look angles are how a ground station sees a satellite: azimuth (compass
* direction, 0° = north), elevation (angle above the horizon), and range.
*
* A contact window is the stretch of time elevation stays above the mask.
*/
import * as satellite from "satellite.js";
import type { SatRec } from "satellite.js";
import type { Station } from "./station";
import type { ParsedTle } from "./tle";

export type LookAngles = {
azimuthDeg: number;
elevationDeg: number;
rangeKm: number;
};

export function satrecFromTle(tle: ParsedTle): SatRec {
return satellite.twoline2satrec(tle.line1, tle.line2);
}

export function lookAnglesAt(
satrec: SatRec,
station: Station,
when: Date,
): LookAngles {
const result = satellite.propagate(satrec, when);
const position = result?.position;
if (
position === undefined ||
position === null ||
typeof position.x !== "number"
) {
throw new Error("SGP4 did not return a position");
}
const gmst = satellite.gstime(when);
const ecf = satellite.eciToEcf(position, gmst);
const observer = {
latitude: satellite.degreesToRadians(station.latitudeDeg),
longitude: satellite.degreesToRadians(station.longitudeDeg),
height: station.heightKm,
};
const look = satellite.ecfToLookAngles(observer, ecf);
return {
azimuthDeg: satellite.radiansToDegrees(look.azimuth),
elevationDeg: satellite.radiansToDegrees(look.elevation),
rangeKm: look.rangeSat,
};
}

export function lookAngles(
tle: ParsedTle,
station: Station,
when: Date,
): LookAngles {
return lookAnglesAt(satrecFromTle(tle), station, when);
}
68 changes: 68 additions & 0 deletions web/src/orbit/pass.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import issTle from "./fixtures/iss.tle?raw";
import { lookAngles } from "./look";
import { nextPass } from "./pass";
import { DEFAULT_ELEVATION_MASK_DEG, NAIROBI } from "./station";
import { parseTle } from "./tle";

const tle = parseTle(issTle);
const from = new Date(Date.UTC(2026, 9, 19, 10, 26, 22));

describe("lookAngles", () => {
it("returns finite azimuth and elevation from Nairobi", () => {
const look = lookAngles(tle, NAIROBI, from);
expect(Number.isFinite(look.azimuthDeg)).toBe(true);
expect(Number.isFinite(look.elevationDeg)).toBe(true);
expect(look.azimuthDeg).toBeGreaterThanOrEqual(0);
expect(look.azimuthDeg).toBeLessThan(360);
expect(look.elevationDeg).toBeGreaterThan(-90);
expect(look.elevationDeg).toBeLessThan(90);
});
});

describe("nextPass", () => {
it("finds a Nairobi ISS pass with rise, max elevation and set", () => {
const pass = nextPass(tle, NAIROBI, from, DEFAULT_ELEVATION_MASK_DEG);
expect(pass).not.toBeNull();
if (!pass) {
return;
}
expect(pass.rise.getTime()).toBeLessThanOrEqual(pass.maxElAt.getTime());
expect(pass.maxElAt.getTime()).toBeLessThanOrEqual(pass.set.getTime());
expect(pass.set.getTime()).toBeGreaterThan(pass.rise.getTime());
expect(pass.maxElDeg).toBeGreaterThanOrEqual(DEFAULT_ELEVATION_MASK_DEG);
const minutes = (pass.set.getTime() - pass.rise.getTime()) / 60_000;
expect(minutes).toBeGreaterThan(1);
expect(minutes).toBeLessThan(20);
expect(lookAngles(tle, NAIROBI, pass.rise).elevationDeg).toBeGreaterThanOrEqual(
DEFAULT_ELEVATION_MASK_DEG - 0.5,
);
expect(
lookAngles(tle, NAIROBI, new Date(pass.rise.getTime() - 2000)).elevationDeg,
).toBeLessThan(DEFAULT_ELEVATION_MASK_DEG);
expect(
lookAngles(tle, NAIROBI, new Date(pass.set.getTime() + 2000)).elevationDeg,
).toBeLessThan(DEFAULT_ELEVATION_MASK_DEG);
});

it("treats 0° as a lower mask than 10° at the same 10° pass edges", () => {
const high = nextPass(tle, NAIROBI, from, 10);
expect(high).not.toBeNull();
if (!high) {
return;
}
const before = lookAngles(
tle,
NAIROBI,
new Date(high.rise.getTime() - 2000),
).elevationDeg;
const after = lookAngles(
tle,
NAIROBI,
new Date(high.set.getTime() + 2000),
).elevationDeg;
expect(before).toBeLessThan(10);
expect(after).toBeLessThan(10);
expect(before).toBeGreaterThan(-90);
});
});
Loading
Loading