GODSIGH/SPEC.md
jing 7da9eeaeb0 phase 7-8: adversarial-review fixes + README
Applied 4 confirmed findings from the multi-agent review (5th was a documented
deliberate deviation):
- aircraft: self-healing visibilitychange listener — the live feed no longer
  stalls permanently after tab hide->show while the clock is paused (MAJOR)
- SPEC §9: nginx proxy_pass with $is_args$args needs a resolver or prod 502s
  while nginx -t still passes — documented + hardened the deploy step (MAJOR)
- ships: live-AIS map now evicts true least-recently-updated (move-to-end),
  not oldest-inserted
- ships: corrected inverted Hormuz lane direction labels (tracks with
  decreasing longitude sail west/inbound, not east)
- README: full data-source + attribution table, real-vs-DEMO breakdown,
  architecture, roadmap

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:47:11 +10:00

24 KiB
Raw Blame History

GODSIGH — Build Spec & Execution Instructions

Audience: Claude Opus 4.8, executing autonomously in this repo. Author: Claude Fable 5, 2026-07-13, from a working session with John. Repo: ssh://git@100.71.119.27:222/monster/GODSIGH.git (branch main) Final destination: https://partly.party/godsigh/ (games VPS, forum-nginx container, Cloudflare-fronted) — deployment is the LAST phase and is gated on John.


0. Mission

Build a browser-based 3D/4D OSINT "world view" globe in the spirit of Bilawal Sidhu's God's Eye View: a CesiumJS digital-twin globe with a space-time slider along the bottom, where independent public sensor feeds — satellite orbits (Celestrak TLEs), live aircraft (OpenSky ADS-B), ship traffic (AIS), and static infrastructure vectors (pipelines, choke points, ports) — are all synced to one clock. Two rendering skins: a dark high-contrast Data Mode and a satellite-imagery Photo Mode.

Everything uses free, public, unclassified data. No API keys are required for the MVP (AIS live data optionally takes a free aisstream.io key later).

Done looks like: open http://127.0.0.1:8137, see a dark globe over the Persian Gulf with ~60 named satellites tracing glowing orbits, thousands of live aircraft, demo tanker traffic through the Strait of Hormuz (including one flagged "dark vessel" AIS gap), pipeline/choke-point overlays, and demo event markers that appear/disappear as you scrub the timeline ±6 hours. Then, on John's go-ahead, the same thing live at partly.party/godsigh.

1. Ground truth — already verified 2026-07-13 (do not re-litigate)

These endpoints were probed with browser-like Origin headers from this machine:

Feed URL Status CORS Consequence
Celestrak TLEs https://celestrak.org/NORAD/elements/gp.php?GROUP=stations&FORMAT=tle 200 ACAO: * Browser may fetch directly; we proxy anyway for safety
OpenSky ADS-B https://opensky-network.org/api/states/all 200 ACAO: pinned to their own origin Browser CANNOT fetch directly — must go through our proxy
EOX Sentinel-2 cloudless https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2023_3857/default/g/{z}/{y}/{x}.jpg 200 ACAO: * Photo Mode basemap, no key
Carto dark basemap https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png (subdomains ad) 200 ACAO: * Data Mode basemap, no key

OpenSky anonymous quota: ~400 credits/day; a global states/all call costs 4 credits (≈100 calls/day). The response carries x-rate-limit-remaining. Budget accordingly (see §6.3).

Classified US satellites (e.g. USA 234 / Topaz) are not in Celestrak's public catalog — do not waste time looking. Russian (Persona/Kosmos) and Chinese (Gaofen) military satellites are publicly tracked. Amateur "classfd" TLE lists are a roadmap item only.

2. Locked architecture decisions

  1. No build step. Vanilla ES modules + CDN globals. No npm, no bundler, no framework. index.html loads CesiumJS and satellite.js as script-tag globals; our code is plain <script type="module"> files importing each other relatively.
    • Cesium: https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/Cesium.js + .../Build/Cesium/Widgets/widgets.css (global Cesium; the IIFE build self-detects its base URL so workers/assets load from the CDN).
    • satellite.js: https://cdn.jsdelivr.net/npm/satellite.js@5/dist/satellite.min.js (global satellite).
    • Before the deploy phase, pin both to the exact resolved versions (read Cesium.VERSION in the console) so prod doesn't drift.
  2. Subpath-safe URLs everywhere. The app must work at both http://127.0.0.1:8137/ and https://partly.party/godsigh/. Every fetch and asset reference is relative (proxy/opensky?..., css/style.css) — never a leading /.
  3. Dev server = serve.py (already in repo): stdlib-only static server + same-origin proxy at proxy/opensky and proxy/celestrak, port 8137. In production, nginx location blocks replace the proxy (§9.4) — serve.py is dev-only and must not ship into the web root.
  4. Cesium Entities vs Primitives split: satellites/ships/infra/events are Cesium entities (few hundred, get time-dynamics + the built-in InfoBox for free). Aircraft are a BillboardCollection primitive (thousands; entities would crawl) with a custom click overlay.
  5. Time model: Cesium clock spans now 6h → now +6h, CLAMPED, multiplier 1, starting at now, shouldAnimate: true. The built-in timeline + animation widgets ARE the space-time slider — do not build a custom one. Time-dynamic layers (satellites, demo ships, events) use SampledPositionProperty/availability so scrubbing "just works". Live-only layers (aircraft, live AIS) are hidden with a status note when the clock is scrubbed >10 min from wall-clock now (§6.6).
  6. No fake data masquerading as real. Demo/synthetic layers (ships, events) are labeled [DEMO] in entity names, in the HUD, and in the README.

3. Repo workflow

  • Work on main (John's solo repo; no PR ceremony). Commit per phase with messages like phase 2: satellite layer — celestrak TLEs + SGP4 sampling.
  • End commit messages with: Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  • Push after each phase so John can watch progress in Gitea.
  • Already in repo when you start: serve.py, SPEC.md (this file), README.md (stub), .claude/launch.json (preview server config), .gitignore.

4. File map (target state)

GODSIGH/
├── SPEC.md                  # this file
├── README.md                # user-facing docs; finish in phase 8
├── serve.py                 # dev server + CORS proxy (exists; don't ship to web root)
├── deploy.sh                # phase 9 only — mirrors GAMES/* deploy doctrine
├── .claude/launch.json      # preview config: python3 serve.py 8137 (exists)
├── index.html               # CDN script tags, HUD skeleton, cesiumContainer
├── css/style.css            # dark HUD styling
└── js/
    ├── config.js            # all URLs, keys, poll intervals, theater camera, colors
    ├── main.js              # viewer bootstrap, basemap modes, clock, live-gating, picking
    ├── ui.js                # HUD panel: layer toggles, per-feed status lines, LIVE/SCRUB chip
    └── layers/
        ├── satellites.js    # Celestrak TLE → SGP4 → SampledPositionProperty entities
        ├── aircraft.js      # OpenSky poll → BillboardCollection
        ├── ships.js         # demo AIS fleet + dark-vessel gap + optional aisstream.io live
        ├── infra.js         # static pipelines, choke points, facilities
        └── events.js        # [DEMO] time-anchored event markers

5. Phase plan

Execute in order. Verify each phase in the browser before committing (§7 protocol). Phases 18 need no input from John. Phase 9 is gated.

  • Phase 0 — bootstrap: clone repo, python3 serve.py 8137 (or preview_start with the provided launch.json), confirm the empty page serves and proxy/opensky?lamin=24&lomin=50&lamax=27&lomax=57 returns JSON through it.
  • Phase 1 — globe core: viewer + dual basemaps + clock/timeline + camera + HUD skeleton (§6.1).
  • Phase 2 — satellites (§6.2).
  • Phase 3 — aircraft (§6.3).
  • Phase 4 — ships (§6.4).
  • Phase 5 — infrastructure + events (§6.5).
  • Phase 6 — interaction polish: mode toggle, live-scrub gating, picking overlay, status lines (§6.6).
  • Phase 7 — full verification pass (§7).
  • Phase 8 — README (§8).
  • Phase 9 — deploy to partly.party/godsigh (§9) — stop and confirm with John first.

6. Detailed component specs

6.1 Globe core (main.js, index.html, css/style.css)

const now   = Cesium.JulianDate.now();
const start = Cesium.JulianDate.addHours(now, -6, new Cesium.JulianDate());
const stop  = Cesium.JulianDate.addHours(now,  6, new Cesium.JulianDate());

const viewer = new Cesium.Viewer('cesiumContainer', {
  baseLayer: false,            // we manage imagery ourselves — avoids any Cesium ion token
  baseLayerPicker: false, geocoder: false, sceneModePicker: false,
  navigationHelpButton: false, homeButton: false, fullscreenButton: false,
  timeline: true, animation: true, infoBox: true, selectionIndicator: true,
  shouldAnimate: true,
});
  • Basemaps: add two UrlTemplateImageryProvider layers — Carto dark_all (Data Mode, subdomains:['a','b','c','d'], credit "© OpenStreetMap contributors © CARTO") and EOX s2cloudless-2023_3857 (Photo Mode, credit "Sentinel-2 cloudless by EOX — modified Copernicus Sentinel data 2023"). Photo layer starts show = false. Mode toggle flips the two show flags. Keep both credits — attribution is a license requirement for Carto, OSM and EOX.
  • Clock: set startTime/stopTime/currentTime, clockRange: Cesium.ClockRange.CLAMPED, multiplier: 1, then viewer.timeline.zoomTo(start, stop).
  • Atmosphere/lighting: viewer.scene.globe.enableLighting = true (live day/night terminator — the single cheapest "wow"). Default sky box/atmosphere are fine.
  • Camera: viewer.camera.setView({ destination: Cesium.Cartesian3.fromDegrees(53.0, 25.5, 2_800_000) }) — opens on the Persian Gulf theater.
  • HUD: fixed left panel (<aside id="hud">) over the canvas: title GODSIGH ▸ WORLD VIEW, mode toggle buttons, one row per layer (checkbox + name + colored status dot + status text), a LIVE / SCRUBBED chip, and a footer crediting data sources. Styling: near-black translucent panels (rgba(10,14,18,.82) + backdrop-filter: blur(8px)), 1px rgba(255,255,255,.08) borders, ui-monospace font, cyan #35e0ff accents. Don't fight Cesium's widget CSS; the default timeline/animation widgets already look the part.

6.2 Satellites (layers/satellites.js)

Fetch (via proxy/celestrak?..., dedupe by NORAD number, cap ~100 sats):

Query Purpose
GROUP=resource&FORMAT=tle Commercial/civil EO constellation — filter names by keywords: WORLDVIEW, LEGION, PLEIADES, CAPELLA, GAOFEN, SKYSAT, ICEYE, UMBRA, LANDSAT, SENTINEL-1, SENTINEL-2, GEOEYE, KOMPSAT, CARTOSAT, RESURS
GROUP=stations&FORMAT=tle Keep only ISS (ZARYA) and CSS (TIANHE)
NAME=GAOFEN&FORMAT=tle Chinese military EO series incl. Gaofen-11 (may 404/empty — tolerate)
NAME=COSMOS 2486&FORMAT=tle, NAME=COSMOS 2506&FORMAT=tle Russian Persona-class recon (URL-encode the space)

TLE text parses as name/line1/line2 triplets. A failed source must not sink the layer — Promise.allSettled, report per-source counts in the status line.

Propagation: for each sat, satellite.twoline2satrec(l1, l2), then build a SampledPositionProperty sampled every 60 s across the full clock window (721 samples/sat):

const date = Cesium.JulianDate.toDate(t);
const pv   = satellite.propagate(satrec, date);
if (!pv || !pv.position) continue;              // decayed/bad TLEs — skip sample
const gmst = satellite.gstime(date);
const geo  = satellite.eciToGeodetic(pv.position, gmst);   // radians + km
posProp.addSample(t, Cesium.Cartesian3.fromRadians(geo.longitude, geo.latitude, geo.height * 1000));

Set Lagrange interpolation (degree 5). ~100 sats × 721 samples ≈ 72k SGP4 calls — chunk the loop (e.g. 10 sats per setTimeout(0) tick) so first paint isn't blocked; update the status line as chunks land.

Entity per sat: point (pixelSize 6, color-coded), label (name, 10px ui-monospace, offset above, translucencyByDistance so labels fade at distance), path (width 1, resolution 120, PolylineGlowMaterialProperty at ~0.35 alpha, leadTime = trailTime = periodSec / 2 where periodSec = (2π / satrec.no) * 60satrec.no is rad/min), description HTML: NORAD id, source query, period min, inclination satrec.inclo (rad→deg), apogee/perigee from a = (398600.4418 / n²)^(1/3) km with n = satrec.no/60 rad/s, e = satrec.ecco.

Colors: GAOFEN #ff4d4d · COSMOS #ff9f40 · WORLDVIEW/LEGION #4dd2ff · CAPELLA #9d7bff · PLEIADES #7bff9d · SENTINEL #ffd24d · ISS/CSS #ffffff · default #66ffcc.

Separate HUD toggles for "Satellites" and "Orbit paths" (paths ON by default).

6.3 Aircraft (layers/aircraft.js)

  • Fetch proxy/opensky (optionally with ?lamin=&lomin=&lamax=&lomax= from config.js; default global — same 4-credit cost as any bbox > 400 deg², and a fully lit planet is the demo). Response: { time, states: [...] }; state array indices: 0 icao24, 1 callsign, 2 origin_country, 5 lon, 6 lat, 7 baro_altitude, 8 on_ground, 9 velocity(m/s), 10 true_track(deg). Skip null lon/lat and on_ground.
  • Render: one shared canvas-drawn triangle glyph (32×32, white — draw (16,2)→(28,30)→(16,23)→(4,30), close, fill) in a BillboardCollection; per-billboard color tint by altitude band (< 3000 m #ffb347, < 9000 m #ffe74d, else #4dd2ff), rotation: -Cesium.Math.toRadians(track || 0), position at baro_altitude meters, scale ~0.55. Set id: { layer:'aircraft', icao24, callsign, country, alt, vel, track } for picking. Rebuild the collection wholesale each poll (8k billboards rebuilds fine).
  • Poll: every 90 s while the layer is enabled AND the clock is live (§6.6) AND !document.hidden — that's ~40% of the anonymous daily budget for a long session, acceptable. On HTTP 429, back off ×4 and show rate-limited — anonymous OpenSky quota in the status line. Show count + data timestamp + x-rate-limit-remaining when available.
  • Picking: ScreenSpaceEventHandler LEFT_CLICK → scene.pick; if picked?.id?.layer === 'aircraft', fill a small fixed overlay div (callsign, country, altitude m/ft, speed kt, heading); otherwise hide it and let the entity InfoBox handle sats/ships.

6.4 Ships (layers/ships.js)

Demo fleet (always available, everything named … [DEMO]): ~8 vessels crossing the clock window as SampledPositionProperty waypoint tracks ([lon, lat, hourOffset], linear interpolation, height 0), rendered as diamond-glyph billboards (shared canvas, like aircraft) with fixed rotation from the great-circle bearing of the track (θ = atan2(sinΔλ·cosφ₂, cosφ₁·sinφ₂ sinφ₁·cosφ₂·cosΔλ), rotation = -toRadians(θ)), plus name label and InfoBox description (flag, type, speed, route note). Include:

  1. 23 eastbound + 2 westbound tankers/LNG through the Hormuz deep channel, e.g. [[56.9,25.4,-6],[56.4,26.2,-2.5],[55.6,26.55,0],[54.5,26.2,3],[53.5,25.7,6]] and reverses. Green #7bff9d.
  2. One "toll-route" LPG carrier hugging the northern (Iranian-waters) coastline: waypoints tracking ~27.026.5 N between 54.556.5 E. Yellow #ffe74d, description notes the escort-route storyline.
  3. One dark vessel #ff4d4d: availability = two TimeIntervals — [start, now2h] and [now+1h, stop]. Between its last-seen and reacquired positions draw a red dashed polyline (PolylineDashMaterialProperty) with a mid-track label AIS DARK GAP [DEMO]. This demos the dark-vessel-detection concept while scrubbing.
  4. 56 stationary gray tankers clustered at Fujairah anchorage (~25.1025.25 N, 56.556.7 E) — the "floating parking lot".

Live AIS (optional, later): if CONFIG.AISSTREAM_API_KEY is set, open wss://stream.aisstream.io/v0/stream, on open send { APIKey, BoundingBoxes: [[[10,30],[35,70]]], FilterMessageTypes: ["PositionReport"] } (boxes are [lat,lon] pairs, SW/NE), upsert one entity per MMSI (cap ~500, evict stalest). Free key from aisstream.io. Verify the actual message shape at runtime before trusting field names. WebSockets are not CORS-blocked, so no proxy needed. Demo fleet hides when live AIS is on.

6.5 Infrastructure + events (layers/infra.js, layers/events.js)

All coordinates approximate — say so in each description. Static entities, no time dynamics (except events' availability).

Choke points — red ring (ellipse ~80 km semi-axes, outline + rgba(255,0,60,.06) fill) + label: Strait of Hormuz (56.5, 26.6), Bab el-Mandeb (43.33, 12.58), Suez (32.35, 30.45), Malacca (101.0, 2.5).

Pipelines — amber #ffbf40 dashed polylines (clampToGround: true) + midpoint label:

  • EastWest Petroline: (49.68,25.94) → (46.7,24.6) → (44.5,24.3) → (41.5,24.2) → (38.06,24.09) (Abqaiq → Yanbu)
  • HabshanFujairah: (53.61,23.75) → (54.6,24.2) → (55.5,24.8) → (56.33,25.12)

Facilities — small square points + labels: Ras Tanura oil terminal (50.16,26.64), Abqaiq processing (49.68,25.94), Yanbu port (38.06,24.09), Kharg Island terminal (50.32,29.23), Fujairah oil terminal (56.37,25.18), Jebel Ali desalination (55.02,25.01), Ras Al-Khair desalination (49.19,27.55), Al Udeid Air Base (51.32,25.12), Haifa refinery (35.05,32.79).

Events [DEMO] — 34 pulsing markers (point pixelSize via CallbackProperty sine ~8±4) whose availability starts at their event time, so they appear when the slider crosses them: e.g. now3h strike marker inland Iran (51.4, 32.6), now2h "satellite pass correlation" note at the same spot, now+1h tanker-incident marker in the Strait. Descriptions state clearly these are illustrative placeholders for a future real event feed.

6.6 Interaction glue (main.js, ui.js)

  • Live-scrub gating: on clock.onTick (throttled to ~1/s), compute |currentTime JulianDate.now()|. If > 600 s: hide aircraft + live-AIS collections, set chip to SCRUBBED — live layers paused, and skip OpenSky polls (saves quota). Back within 600 s: restore. The timeline's rightmost 6 h are future — satellites keep predicting there (correct behavior — orbits are deterministic), live layers stay dark.
  • ui.js API: addLayer(id, name, defaultOn, onToggle) and setStatus(id, text, state) where state ∈ ok/warn/err → green/amber/red dot. Every layer reports load counts and failures here — no silent failures.
  • config.js: every URL, poll interval, bbox, camera start, color table, and the empty AISSTREAM_API_KEY slot in one file, commented.

7. Verification protocol (phase 7, and after every phase)

Use the preview tools (.claude/launch.json defines server godsighpython3 serve.py 8137):

  1. preview_start, then preview_console_logs (level error) — zero uncaught errors allowed.
  2. preview_network — confirm 200s: Carto tiles, EOX tiles (toggle Photo Mode first), proxy/celestrak… ×45, proxy/opensky…. 429 from OpenSky is acceptable if the HUD shows the rate-limit status.
  3. preview_screenshot in both modes; preview_snapshot to confirm HUD text/toggles.
  4. Behavior checks: click a satellite → InfoBox with orbital params; click an aircraft → overlay; scrub timeline back 3 h → aircraft vanish + chip flips + demo ships/satellites move + now3h event marker pops in; drag to future → satellites keep orbiting; toggle every layer on/off; toggle both basemap modes.
  5. Acceptance checklist: ☐ ≥40 satellites with orbit paths ☐ aircraft count in status (or explicit rate-limit message) ☐ dark-vessel gap renders with dashed red line ☐ all infra labels ☐ events appear/disappear with slider ☐ both basemaps ☐ zero console errors ☐ every fetch path relative (grep for fetch('/ and src="/ — must be empty).

8. README (phase 8)

Rewrite README.md for a visitor: what it is, screenshot, quickstart (python3 serve.py 8137), data-source table with attribution (OpenSky CC terms — non-commercial; Carto/OSM attribution; EOX Sentinel-2 attribution; Celestrak), explicit list of what is DEMO data, and the roadmap: live AIS via aisstream key · GPS-jamming layer (gpsjam.org daily H3 data) · historical recording (cron job appending OpenSky/AIS snapshots to JSONL for true time-travel — see the jobs skill heartbeat convention if built) · classified-sat amateur TLEs · oil-futures side panel · Cesium ion token for terrain/photorealistic 3D tiles · agentic event ingestion.

9. Phase 9 — Deployment (FINAL — gated)

Stop. Confirm with John before starting this phase. He will generate a fresh Cloudflare API token when ready. Two prerequisites from the local machine doctrine:

  1. Read ~/Documents/GAMES/CLAUDE.md and one existing deploy script (e.g. GAMES/cratewars/deploy.sh) and mirror the doctrine: bundle → rsync to /tmp staging on humanjing@100.71.119.27docker cp into forum-nginx web root under godsigh/ → curl verify. Ignore the stale ~/Documents/VPS_DEPLOYMENT_GUIDE.md / DEPLOYMENT_STATUS.md.
  2. Run /ship-check before exposing anything (the proxy endpoint is the only dynamic surface — upstream is pinned to OpenSky so there's no open-proxy/SSRF hole, but let ship-check confirm).

Deployment specifics:

  • Ship only index.html, css/, js/ (+ README). Never serve.py, SPEC.md, .claude/.

  • Pin CDN versions (§2.1) before shipping.

  • Proxy in prod: add an nginx location to the forum-nginx config (find it via docker exec forum-nginx nginx -T; check whether the conf is a host bind-mount or container-local — if container-local, a restart wipes it, so persist it the same way the existing site config persists).

    CRITICAL — resolver required. A proxy_pass whose value contains a variable (here $is_args$args, and the set $ups_* host below) makes nginx resolve the upstream at request time, not config-load time. Without a resolver directive in scope, every proxied request returns HTTP 502 "no resolver defined to resolve …" — and nginx -t still PASSES, so the config test gives false confidence. Confirm one exists (docker exec forum-nginx nginx -T | grep -i resolver); if not, add one at http/server scope:

resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;   # add once if absent

location = /godsigh/proxy/opensky {
    set $ups_opensky opensky-network.org;
    proxy_pass https://$ups_opensky/api/states/all$is_args$args;
    proxy_ssl_server_name on;
    proxy_set_header Host opensky-network.org;
    proxy_set_header User-Agent "godsigh/1.0 (partly.party)";
    add_header Cache-Control "no-store" always;
}
location = /godsigh/proxy/celestrak {
    set $ups_celestrak celestrak.org;
    proxy_pass https://$ups_celestrak/NORAD/elements/gp.php$is_args$args;
    proxy_ssl_server_name on;
    proxy_set_header Host celestrak.org;
    proxy_set_header User-Agent "godsigh/1.0 (partly.party)";
    add_header Cache-Control "max-age=3600" always;
}

then docker exec forum-nginx nginx -t && docker exec forum-nginx nginx -s reload. nginx -t passing is NOT sufficient — it does not catch a missing resolver. The proxy curl below (must return JSON, not a 502 or HTML) is the hard gate.

  • Cloudflare: ask John for the fresh token at this point; purge partly.party/godsigh/* after deploy. Beware Cloudflare caching the proxy endpoints — the no-store header above matters; if aircraft data looks frozen, check cf-cache-status.
  • Verify like the deploy-map says — data endpoints, not just the index: curl -sI https://partly.party/godsigh/ (200), curl -s 'https://partly.party/godsigh/proxy/opensky?lamin=24&lomin=50&lamax=27&lomax=57' | head -c 300 (JSON, not HTML), one Celestrak proxy call, and a browser load checking tiles + console. Report exact curl results to John. Non-2xx = not deployed; say so plainly.

10. Known pitfalls

  • Viewer({ baseLayer: false }) is the modern (≥1.104) way to get a token-free viewer; do not pass imageryProvider (removed) and do not touch Cesium ion.
  • satellite.propagate returns falsy/NaN positions for decayed or malformed TLEs — guard every sample; skip sats with < 50 valid samples.
  • Billboard rotation is screen-space CCW; headings are CW-from-north → always negate. Looks right top-down, slightly off at oblique tilt — accepted.
  • OpenSky callsigns are 8-char space-padded — .trim().
  • Celestrak NAME= queries can return No GP data found as plain text — detect (line1[0] !== '1') and treat as empty, not fatal.
  • Don't enable requestRenderMode — the animating clock needs continuous render.
  • Keep the Cesium credit container visible (attribution is a license condition, not decoration).
  • If jsdelivr's cesium@1 alias ever serves a breaking change mid-build, pin to the last known-good exact version and note it in the README.

11. Explicitly out of scope for this build

GPS-jamming heatmap, real strike/event feeds, oil-futures charts, historical data recording, AI agent ingestion, Cesium ion terrain/3D tiles, accounts/auth. List them in the README roadmap; build none of them.