Skip to content

Latest commit

 

History

History
608 lines (467 loc) · 90.5 KB

File metadata and controls

608 lines (467 loc) · 90.5 KB

sat.trackr.live

Space situational awareness, legible.

A public, read-only, mobile-friendly 3D web app that visualizes everything humans have put into Earth orbit — plus the events shaping that environment (launches, reentries, conjunctions, space weather) — on a single time-scrubbable globe.

Part of the trackr.live family alongside trackr.live and cyber.trackr.live.


Status

Phase 11 complete. All 67 chunks across phases 1-11 are live. Phase 11 shipped a full production-grade hygiene pass: security-finding triage (docs/security-audit-2026-05-23.md — 0 critical / 0 high / 6 unique moderate, CodeQL + secret-scanning both clean); composer update symfony/yaml 8.0.11→8.0.12 patches CVE-2026-45304/45305/45133 (Billion Laughs / ReDoS / stack exhaustion); breaking vite 5→8 + vitest 2→4 upgrade closes vite path-traversal + esbuild dev-server CORS bypass + the transitive chain; bin/security-audit-gate.sh blocks on ≥-moderate findings with advisory variant wired into make ci per § II row 6 (30-day non-blocking rollout); Playwright flakiness audit (3/10 baseline → 0/10 verify-3 sweep) with 3 reusable harness helpers in tests/E2E/helpers/ + institutional-memory ledger at tests/E2E/FLAKINESS.md; full README overhaul (Prerequisites + Fresh-clone bootstrap + Environment variables 16-key reference table covering every EnvLoader::get + refreshed Makefile-targets table mirroring make help verbatim + curl-based verification checklists); Makefile help-regex bug fix (was excluding ingest-ll2 + test-e2e); pre-existing PHP-CS-Fixer drift auto-fixed; pre-existing PHPStan debt frozen into a baseline so make ci stays green for new code while the existing 36-error backlog is picked off in future cleanup chunks. 146 Playwright + 361 Vitest + 411 PHPUnit passing. docs/phase11.md retired.

Phase 11 detailed progress (kept as historical record while Phase 11 was in flight): all 6 chunks landed at the Phase 11 close. Chunk 1 — Audit + triage: ran all 5 security tools (composer audit, npm audit, Dependabot, CodeQL, secret-scanning) and captured every finding into docs/security-audit-2026-05-23.md (0 critical / 0 high / 6 unique moderate; CodeQL + secret-scanning both clean). Landed bin/security-audit-dump.sh re-runner + make security-audit-dump + 3 PHPUnit SecurityAuditDumpTest cases. Chunk 2 — zero work (no high/critical findings); rolled into chunk 3. Chunk 3 — Fix moderate findings + make security-audit gate: three sub-commits closed all 6 moderates — (A) composer update symfony/yaml 8.0.11→8.0.12 patches the CVE trio; (B) vite 5→8 + vitest 2→4 breaking dev-dep upgrade closes vite path-traversal + esbuild CORS bypass + transitive vitest advisories (plus adding @types/node, removing 3 now-unused suppressions); (C) bin/security-audit-gate.sh blocks on ≥-moderate findings, make security-audit + make security-audit-advisory Makefile targets, advisory variant wired into make ci (30-day non-blocking rollout). 5 new SecurityAuditGateTest cases. Honest deviation: spec said "wire into existing CI workflow" but no .github/workflows/ exists — advisory chains into make ci instead; future PR can add Actions wiring. Chunk 4 — Playwright flakiness audit: 10× baseline sweep showed 3/10 fails (notify-sw-idb SW→IDB race + notify-pass observer-state leakage); built tests/E2E/helpers/ with 3 reusable modules + helpers.spec.ts self-spec (5 cases); applied targeted fixes (15s post-arm IDB poll, expect.poll for prompt text, defensive cleanBrowserStorage, 5s→10s toHaveAttribute budget, 300ms→800ms gesture-wire wait); 3 sweep cycles iterated 3/10 → 4/10 → 2/10 → 0/10 — clears the ≤ 1/10 acceptance. No retries: bump (§ II decision 13). Full audit trail at tests/E2E/FLAKINESS.md. Chunk 5 — README deployment overhaul: new "Get started locally" + "Environment variables" 16-key reference table + refreshed Makefile-targets table (41 targets) + trimmed "Production deployment" pointing at docs/deploy.md. Found and fixed the Makefile help-regex bug. 5 new ReadmeConsistencyTest PHPUnit cases pin every contract (README ↔ make help, README ↔ EnvLoader::get, README ↔ docs/deploy.md). Chunk 6 — Doc consistency + Phase 11 close: audited every .md in docs/, fixed stale forward-looking prose in docs/phase7.md, brought docs/deploy.md's "Verified by" footer current with the in-repo verification record. Ran full make ci end-to-end — surfaced pre-existing PHP-CS-Fixer drift (auto-fixed via make lint-fix across ~100 files) and 39 pre-existing PHPStan errors (36 frozen into phpstan-baseline.neon for future cleanup, 3 dead @phpstan-ignore comments removed). Fresh-environment DreamHost / Fly walkthrough deferred to a future operator-driven exercise per § IV "DreamHost VPS verifier availability" risk mitigation. Final tally: PHP 411 (398 pre-Phase-11, +13) / Vitest 361 unchanged / Playwright 146 passing + 3 skipped (+5 helper self-spec). Bundle 242.73 KB / 67.56 KB gzipped main — unchanged.

Phase 10 complete. All 61 chunks across phases 1-10 are live. Phase 10 shipped a full ops & scale pass: server-side TLE propagation cache + GET /api/v1/satellites/{norad}/state-now endpoint (60 s APCu/SQLite cache, optional observer look-angles via WGS84 ENU); TLE ingest reject-rate alerting (per-ingester thresholds CelesTrak 1 % / SpaceTrack 5 % / LL2 10 % / SOCRATES 5 %, 3-band classifier, 30-min cooldown via storage/cache/ingest-alerts.json, Slack-compatible POST to INGEST_ALERT_WEBHOOK_URL); amateur SatNOGS ground-station layer (~2,000 violet 3 px dots from network.satnogs.org/api/stations/, qra_active 90-day window, dedicated tooltip with Maidenhead grid + SatNOGS deep-link); docs/deploy.md with DreamHost VPS Apache + Fly.io paths (sample fly.toml, Dockerfile, .fly.dockerignore, cron entry block, troubleshooting matrix); POST /webhooks/ingest/{source} push-based ingest receiver with bearer auth (WEBHOOK_SECRET), 8-source allowlist, per-(source, IP) 60 s rate limit, async proc_open dispatch. 141 Playwright + 361 Vitest + 398 PHPUnit passing. docs/phase10.md retired.

Phase 10 detailed progress (kept as historical record while Phase 10 was in flight): chunks 1 + 2 + 3 of 4 landed at the Phase 10 close. Chunk 1 — server-side TLE propagation cache + /state-now endpoint. New bin/sgp4-passes.mjs mode: 'state' branch returns single-point ECEF + velocity + geodetic via satellite.js. New StateNowService shells out to Node, caches the per-NORAD result behind a 60 s StateNowCacheInterface (APCu primary, state_now_cache SQLite-table fallback for shared hosting per § IV). New LookAngles PHP pure-math helper computes WGS84 observer ECEF + ENU az/el/range so look-angles are observer-agnostic and the cache stays per-NORAD. GET /api/v1/satellites/{norad}/state-now with optional ?lat&lon&alt returns { epoch_ms, ecef_m, ecef_velocity_m_s, geodetic, tle_age_hours, observer_look_angles? } with Cache-Control: max-age=60, stale-while-revalidate=120. Chunk 2 — TLE ingest reject-rate alerting: new IngestAlertService evaluates the rejection ratio against per-ingester thresholds (CelesTrak 1 %, SpaceTrack 5 %, LL2 10 %, SOCRATES 5 %), classifies into 3 bands ([1-5%, 5-20%, 20%+]), dedupes via a 30-min cooldown stored in storage/cache/ingest-alerts.json (file-based because PHP CLI processes don't share APCu across cron runs), POSTs a Slack-compatible payload ({ text, ingester, parsed, rejected, ratio, first_error_sample, timestamp }) to INGEST_ALERT_WEBHOOK_URL when all guards pass. Wired into the 4 CLI ingest commands that have rejection counters (CelesTrak / SpaceTrack-TIP / LL2 / SOCRATES); the 3 atomic ingesters (Ovation / SWPC / SatNOGS) ingest-or-fail without per-record rejection so they're not wired — documented in phase10.md. Chunk 3 — Amateur SatNOGS ground-station layer. New amateur_ground_stations migration; SatnogsStationsClient paginates network.satnogs.org/api/stations/ (note: db.satnogs.org serves transmitters from Phase 5 chunk 1; the Network host serves stations); SatnogsStationsIngester computes qra_active = 1 for stations seen within 90 days per § II row 6, defensive against malformed rows; make ingest-satnogs-stations + weekly cron; GET /api/v1/amateur-stations?page&per_page&include_inactive (active-only default, max 1000/page, 1-hour cache); new Cesium AmateurStationsLayer renders ≤2,000 violet #b366ff 3 px dots (distinct from the 41-station curated 6 px catalog); new <sat-amateur-station-tooltip> shows name + Maidenhead grid + a "View on SatNOGS Network →" deep-link; § overlays → 📡 Amateur ground stations toggle (default off). 137 Playwright + 361 Vitest + 386 PHPUnit passing. Chunk 4 still pending (deploy paths + push-based ingest).

Phase 9 complete. All 57 chunks across phases 1-9 are live. Phase 9 shipped a full visual-depth pass: context-aware zoom button cycling three states (close-up over Earth / context view above sat / recenter on sat) with prefers-reduced-motion-honoring Cesium camera flights; GNSS constellation color overlays for GPS / Galileo / GLONASS / BeiDou (locked palette #ffb700/#5fa8d3/#7cc282/#d77a3a, default off, 50 ms debounced recolor); sun-synchronous ground-track ribbons for Landsat 8/9 + Sentinel-1/2/3/5P (soft-green dim ribbons, ±0.5 orbits, 30 s clock-time throttle, hard 20-sat cap with console warning); decay-event historical timelapse rendering up to 50 traces of the last ~24 h pre-decay arc for the past 30 days of TIP-message-predicted reentries (fading red #ff3860, client-side propagation via satellite.js, click routes to /text/satellite/{norad}); marquee glTF infrastructure for Hubble + Tiangong + Soyuz (four of seven roster slots now ready for licensing-clean binaries via make fetch-models; Dragon/Cygnus/Starlink stay procedural with explicit § II row 9 fallback comments). 124 Playwright + 361 Vitest + 337 PHPUnit passing (3 marquee-glTF specs skip pending operator sourcing). docs/phase9.md retired.

Phase 9 detailed progress (kept as historical record while Phase 9 was in flight): chunks 1 + 2 + 3 + 4 of 5 landed at the Phase 9 close. Chunk 1 — context-aware <sat-zoom-button> topbar element: new resources/js/globe/ZoomController.ts (altitude constants + chooseZoomAction pure-math + three Cesium flyTo wrappers honoring prefers-reduced-motion); button cycles three states (⊕ Zoom in 1,500 km close-up over Earth when no satellite is selected, ⊖ Zoom out 10,000 km context view when a satellite is selected and the camera is within 5,000 km of it, ⊙ Recenter 800 km close-up when a satellite is selected and the camera has wandered further out); hidden in sky view, disabled in /conjunction/{p}/{s} replay route; lives between <sat-share-button> and <sat-settings> in the topbar action cluster; collapses to icon-only below 1300 px. Chunk 2 — GNSS constellation color overlays: new GET /api/v1/gnss/membership returns the four constellations' NORAD lists in one fetch (10-min browser cache); <sat-overlays-menu> gains a § GNSS constellations subsection with four toggle rows (GPS amber #ffb700, Galileo blue #5fa8d3, GLONASS green #7cc282, BeiDou red-amber #d77a3a) — color swatches double as the inline legend; PointPrimitiveLayer re-colors members on overlay flip (50 ms debounced) without touching other dots; default off so the existing cyan PAYLOAD story doesn't change on first load. Chunk 3 — sun-synchronous ground-track ribbons: new resources/data/sun_synch.json curated 9-sat roster (Landsat 8/9 + Sentinel-1A/1C/2A/2B/3A/3B/5P); new MarqueeRibbonLayer extends the Phase-3 ribbon pattern to render N always-on dim ground tracks (±0.5 orbits, soft-green #7cc282, past-dimmer-than-future alpha falloff peaking at 0.4); satrecs built lazily from PointPrimitiveLayer.getTle on the first frame after TLEs land; re-render throttled to once per 30 s of clock time; OverlayService + <sat-overlays-menu> grow a ⤳ Sun-synchronous ribbons row (default off); hard 20-sat cap with console warning if the JSON config tries to add more. Chunk 4 — decay-event historical timelapse: new GET /api/v1/reentries/traces?days=30 joins reentries × satellites × tle_current (with tle_history fallback) returning up to 50 reentry records ordered by soonest-decay-first; client-side DecayTraceLayer propagates the final 24 h pre-decay arc with satellite.js (no server-side SGP4 round-trip) and renders each as a fading red polyline (#ff3860, peak alpha 0.45 at the decay moment, floors post-decay since TIP messages don't predict beyond the event); <sat-overlays-menu> grows a ⌫ Decay trails row (default off, tooltip notes Space-Track TIP coverage dep); clicking a trace dispatches a decay-pick window event the App routes to /text/satellite/{norad}. 123 Playwright + 353 Vitest + 337 PHPUnit passing. Chunk 5 still pending (marquee glTFs).

Phase 8 complete. All 52 chunks across phases 1-8 are live. Phase 8 shipped a full engagement-and-accessibility pass: prefers-reduced-motion respect across the selection pulse / share-flash / Cesium flyTo / sky-view entry; sortable column headers on every /text/* table view (server-side ?sort=col&dir=asc|desc plus a 0.92 KB-gz progressive-enhancement hijack); ICS calendar export at GET /api/v1/satellites/{norad}/passes.ics?lat&lon&alt&days (RFC 5545, 14 days, 50 events, stable UIDs, naked-eye magnitude callouts) with 📅 Add to calendar links on both the SPA detail panel and the /text/satellite/{norad} §Visibility table; touch-optimized bottom-sheet detail panel at ≤ 700 px with a 36×4 px drag handle, three snap points (peek 25 % / mid 50 % / full 75 %), swipe-down dismiss, and backdrop dim at full state; new <sat-settings> gear ⚙ topbar element hosting notification lead-time (2/5/10/15 min) + analytics opt-out + reduced-motion indicator + 🔔 Test notification + Forget-my-prefs; per-pass 🔔 notify button in §Visibility with in-page setTimeout firing while open and service-worker IndexedDB persistence (sat-trackr-passes store + 60-s heartbeat + notificationclick deep-link) for when the tab is closed; cookieless Plausible analytics integration (PLAUSIBLE_DOMAIN + PLAUSIBLE_SCRIPT_URL env vars, three-layered opt-out via env / DNT / localStorage); server-rendered /text/satellite/{norad} §Visibility passes table with URL-param observer (?lat&lon&alt), TableSort allowlist (rise_at asc, max_elevation_deg desc, duration_seconds desc, magnitude asc-NULL-last), inline observer prompt form with [data-geolocation] navigator.geolocation.getCurrentPosition progressive enhancement, and the chunk-2 sort-hijack working unchanged. 102 Playwright + 298 Vitest + 320 PHPUnit passing. docs/phase8.md retired. Phases 9-11 still drafted in docs/phase{9,10,11}.md (visual depth + zoom button, ops & scale, security hardening + Playwright flakiness stabilization) for whenever appetite returns. Note for the Phase 11 audit: 10× back-to-back make test-e2e sweeps at Phase 8 close produced ~3-in-4 runs with one different spec failing (each fail passes immediately when re-run alone — strong "shared state across specs" signal: SW registration carrying into next test, IDB persistence, Cesium init races). Captured as Phase 11 chunk 4 (tests/E2E/FLAKINESS.md ledger + per-test isolation fixes).

Phase 7 complete. All 45 chunks across phases 1-7 are live. Phase 7 shipped a full sky-view "stargazer" mode: tap the ↑ Sky view topbar toggle (gated on observer location) to flip the Cesium camera from globe view to a zenith-aimed pose at the observer's location, with eye-height lift + a ground horizon ring so the Earth/sky boundary stays unambiguous, world-space N/E/S/W cardinal labels + a zenith reticle, a floating ↓ Back to globe button + 8-direction compass HUD strip showing live camera heading, auto-hide of surface-painting overlays with 🔒 lock cue, predicted pass-arc rendering for the marquee roster + RCS-estimated naked-eye candidates (capped at 100 arcs), and selection-driven camera rotation so picking a satellite via search aims the camera at where it actually is. URL deep-link via ?view=sky&lat=…&lon=…. Search in sky view is restricted to NORADs currently above the horizon. Project is in maintenance mode after Phase 7 close. Phases 8-11 are drafted in docs/phase{8,9,10,11}.md for whenever appetite returns — engagement & accessibility (sortable /text/* headers, ICS export, touch-optimized detail panel, Notifications, Plausible), visual depth + zoom button (GNSS layers, sun-synch ribbons, decay timelapse, remaining marquee glTFs), ops & scale (server-side TLE prop cache, alerting, amateur SatNOGS stations, multi-cloud deploy), security hardening + README deployment overhaul. Phases 8-11 also planned in docs/: engagement & accessibility (Phase 8 — sortable /text/* headers, ICS export, touch-optimized detail panel, Notifications, Plausible), visual depth + zoom button (Phase 9), ops & scale (Phase 10), security hardening + README deployment overhaul (Phase 11). Foundation + data depth + showcase visuals (phases 1-3): full SPA with globe + detail panel + time scrubbing + text-only fallback at /text; SATCAT/LL2/Space-Track/CelesTrak-OMM ingest with Alpha-5 NORAD encoding; observer pill + Node-SGP4 pass predictions; Cesium lighting + BSC5 stars + fading orbit ribbons + marquee 3D shapes + ground-station layer + VIIRS night-lights overlay + Overlays topbar menu. Situational awareness (Phase 4): SOCRATES conjunctions ingest (~145 K rows) + paginated JSON API + /text/conjunctions; NOAA SWPC space-weather snapshots every 5 min + ☼ Kp topbar pill with SVG trend popover + /text/space-weather; OVATION aurora overlay (15-min cron); /stats dashboard with operator/country/type/year breakdowns; /text/events + /events.atom Atom 1.0 syndication; click-station tooltip; N2YO visual-magnitude enrichment with quota guard; glTF swap-in path for marquee satellites + structural-assertion Playwright suite. Polish & ecosystem (Phase 5): SatNOGS amateur-radio enrichment, PWA manifest + offline /text service worker, OpenAPI 3.1 spec + Swagger UI, OG image generation, sitemap + canonical + JSON-LD, deep-link sharing + Web Share button, ISS glTF model. Conjunction-replay showcase (Phase 6): dedicated /conjunction/{p}/{s} route with chase camera + HUD + TCA pulse + replay entry points.

Phase 1 — Foundation MVP (✅ complete)

Chunk Status What it adds
1. Bootstrap + chrome ✅ done Repo skeleton, build tooling, Slim front controller, SPA shell, dark/light/high-contrast themes, Cesium globe with OSM imagery, search input + ⌘K, theme switcher
1.5. WebGL fallback gate ✅ done hasWebGL() probes for webgl2 / webgl / experimental-webgl (try/catch for restricted contexts); <sat-app> renders <sat-no-webgl> notice with CTA → /text when absent
2. Schema + migrations ✅ done bin/console (Symfony Console) with migrate / rollback / migrate:status / make:migration / health; satellites, satellites_fts (with sync triggers), tle_current, tle_history, satellite_purposes tables
3. CelesTrak GP ingester ✅ done make ingest populates ~15.6K distinct satellites from CelesTrak's 38 GP groups in ~40s; idempotent re-runs honor CelesTrak's 403 "not modified" signal
4. JSON API ✅ done 8 endpoints under /api/v1/: satellites/groups/search/autocomplete; CORS + ETag + JSON middleware; group_membership table powers /groups/{slug}/tles
5. Globe rendering ✅ done ~15K satellites as Cesium.PointPrimitiveCollection, color-coded by type; SGP4 in a Web Worker @ 4Hz; click-to-select via Cesium.Scene.pick
6. Detail panel + search ✅ done Right-rail panel with Identity / Current state / Orbital elements / Raw data; functional <sat-search> with autocomplete + camera fly-to
7. Time scrubbing ✅ done <sat-timeline> with ±7d slider + yellow bands beyond ±48h, play/pause, 5 speed buttons, Now reset; Clock facade drives both worker + UI
8. Text-only catalog /text ✅ done 4 PHP routes (catalog list / satellite detail / groups / search) — server-rendered HTML, no JS required, sitemap-friendly. Self-contained inline CSS. SPA top-bar links to it.

Phase 2 — Data depth (✅ complete)

Chunk Status What it adds
1. SATCAT ingester + Phase-2 schema ✅ done make ingest-satcat enriches ~28.8K records (98.5% of catalog) with operator/country/launch_date/launch_site_code/RCS/status/decayed_at in 30s; rebuilds satellite_purposes from group_membership (12,757 rows). 5 new migrations land the chunks-3-6 tables (launch_sites, launches, reentries, pass_cache + a column on satellites). 31 new PHPUnit cases, 108 total tests passing.
2. Detail panel reads enriched fields ✅ done DECAYED satellites filtered out of /groups/{slug}/tles (per phase2.md decision 9); launch_site_code surfaced in JSON detail + SPA panel + /text/satellite/{norad}; "⚠ Reentered" callout when decayed_at set; new Status column on /text catalog list; make health reports SATCAT enrichment % (currently 98.5%) and counts all 9 app tables. No new tests (controller polish reuses existing test bed).
3. Launch Library 2 ingester + launches view ✅ done make ingest-ll2 pulls 50 upcoming + 100 previous launches from ll.thespacedevs.com in ~4s, populating 51 pads + 150 launch records (idempotent UPSERT, FK-safe). Four JSON endpoints: GET /api/v1/launches/{upcoming,recent,{id}} + GET /api/v1/launch-sites. Server-rendered text views at /text/launches (countdowns), /text/launches/recent, /text/launches/{uuid}. Topbar's § launches placeholder is now a real link. 92 PHP / 31 JS passing.
4. Space-Track ingester + reentries view ✅ done make ingest-spacetrack pulls TIP messages from www.space-track.org via cookie-jar session in ~1.2s; UPSERT keyed on (norad_id, source); TIPs for objects we don't catalog are skipped (44/50 in the first real run). Two JSON endpoints — GET /api/v1/reentries/upcoming?within_hours=N (default 168, max 720) + GET /api/v1/reentries/{norad} — and a server-rendered /text/decays mirror with countdowns + tri-color risk badge. SPA topbar + text nav grow § decays. 110 PHP / 31 JS passing.
5. Observer location handling ✅ done New <sat-observer-pill> lives in the topbar between search and the theme switcher; collapses to 📍 set location when unset, 📍 short-label (lat°, lon°) once chosen. Three input modes: 🛰 use my location (geolocation), 🌍 city search (Nominatim, debounced 350ms + rate-limited 1 req/s), ⌖ manual lat/lon. Persisted to localStorage as sat:observer; survives reload + discards malformed JSON cleanly. Subscriber-friendly so chunk 6 can react. 110 PHP / 44 JS passing (+13 new vitest specs).
6. Pass predictions (calc + UI) ✅ done Pure-function pass detector (resources/js/passes/computePasses.ts) walks SGP4 elevation curves and refines rise/peak/set with 12-step bisection. Mirrored in bin/sgp4-passes.mjs Node CLI; PHP PassCalculator shells out via proc_open with a 15s timeout. PassCache (6h TTL keyed on NORAD + observer-3dp + day) keeps repeats under ~30ms. GET /api/v1/satellites/{norad}/passes?lat&lon (cache 5min + swr=10min) and a new § Visibility from observer section in the detail panel that fetches the next 5 passes when the 📍 pill has a location set. make pass-cache-prune sweeps expired rows. 124 PHP / 49 JS passing. Deferred to chunk 7: N2YO magnitude enrichment + browser-worker compute path.
7. CelesTrak FORMAT=JSON migration + Phase 2 polish ✅ done NoradId::encode/decode Alpha-5 helper (A0000Z9999 = 100000–339999, I and O skipped) so TleParser keeps parsing once CelesTrak hits 6-digit NORAD IDs (~mid-2026). New OmmJsonParser consumes CelesTrak FORMAT=JSON records and produces the same ParsedTle value object the TLE path emits — including byte-perfect synthesized line1/line2 strings via TleEmitter so satellite.js, the SPA worker, and the copy-to-clipboard panel keep working unchanged. bin/console ingest:celestrak --format=json flips the source format end-to-end (TLE remains the default while we cut over). Phase 3 outline lands at docs/phase3.md. 149 PHP / 49 JS passing. Deferred to Phase 3 / 4: N2YO magnitude enrichment, browser-worker compute_passes path, "above horizon now?" line in §Visibility — see docs/phase3.md § V.

Phase 3 — Showcase visuals (✅ complete)

Chunk Status What it adds
1. Terminator + sun/moon/stars ✅ done bin/build-skybox.php fetches the Bright Star Catalog 5 (~9100 stars), projects them onto an inertial-frame cubemap, and emits 6 magnitude-graded PNG faces at public/textures/skybox/ (~120KB total). Globe.ts replaces Cesium's default skybox with that BSC5 cubemap and explicitly enables sun + moon. Terminator already moved with the time-scrub from Phase 1's enableLighting = true; this chunk delivers the visible night-side starfield. Bundle: 89.17 → 89.46 KB gzipped main (skybox is asset-loaded at runtime). 149 PHP / 49 JS passing.
2. Selected-object orbit ribbons ✅ done New resources/js/passes/computeGroundTrack.ts walks SGP4 over pastOrbits + futureOrbits revolutions and returns timestamped sub-satellite points via gstime + eciToGeodetic. OrbitRibbonLayer (Cesium PolylineCollection) renders a fading ground-track ribbon for the currently-selected satellite — past dim, future bright, gradient via 24 short-segment polylines. <sat-detail-panel> grew a Ribbon: ½ · 1 · 2 · 3 orbits toggle. Refreshes ~every 1/30 of the satellite's period as the user scrubs time (~3min for ISS at 1× speed). Bundle: 89.5 → 115.3 KB gzipped main (+12.5 KB from twoline2satrec/gstime/eciToGeodetic joining the main bundle). 149 PHP / 56 JS passing.
3. 3D models (ISS / Tiangong / Hubble / Dragon / Cygnus / Soyuz / Starlink) ✅ done (stand-in shapes) New marqueeRegistry (7 entries: 3 stations/telescopes + 3 cargo capsules + a Starlink stand-in matched by name prefix). MarqueeShapeLayer renders a colored Cesium BoxGeometry/CylinderGeometry primitive at the satellite's ECEF position, oriented to local east-north-up, when (a) the selected satellite is in the roster AND (b) the camera is within 5,000km of it. Visual scale exaggerated (×120 ISS, ×900 Starlink) so the primitive is visible at LOD threshold. Honest tradeoff vs spec: ships procedural primitives instead of self-hosted glTF for chunk 3 — the LOD swap, scaling, color-coding, and host wiring all work the same; swapping in real glTF is a one-method change in MarqueeShapeLayer.buildPrimitive (extend MarqueeSpec with a gltfUri field + call Cesium.Model.fromGltfAsync). Bundle: 115.3 → 119.2 KB gzipped main. 149 PHP / 64 JS passing.
4. Ground stations + sensor cones ✅ done New resources/data/ground_stations.json (41 sites: 6 NEN + 3 DSN + 9 ESTRACK + 4 JAXA + 5 ISRO + 8 KSAT + 5 AWS + 1 ATLAS). GroundStationLayer renders each as a network-colored 6px PointPrimitive + a 5°-half-angle CylinderGeometry cone (apex at the station, 1,000 km tall, base in the sky). New <sat-overlays-menu> topbar dropdown sits between the 📍 pill and the theme switcher with four checkboxes (orbit ribbon / 3D shapes / ground stations / light pollution). State persists to localStorage as sat:overlays; partial JSON merges into defaults. Globe subscribes — toggling rebuilds visibility for ribbons + marquee + stations layers. Bundle: 119.2 → 133.7 KB gzipped main. 149 PHP / 76 JS passing.
5. Light pollution overlay ✅ done NASA's 2012 VIIRS Earth-at-Night composite (3600×1800 JPG, 794 KB committed at public/textures/earth-at-night.jpg) — much lighter than the 40 MB budget. LightPollutionLayer adds a Cesium.SingleTileImageryProvider on top of the base imagery with dayAlpha=0 + nightAlpha=0.85 so city lights show only on the dark side, composing naturally with the chunk-1 terminator. Genuinely lazy-loaded: the JPG isn't requested until the user toggles "Light pollution" on for the first time. Bundle: 133.7 → 134.8 KB gzipped main (+0.3 KB — layer file only). 149 PHP / 76 JS passing.
6. Polish + Playwright + Phase 4 outline ✅ done "Above horizon now?" line in § Visibility (live elevation/azimuth + compass octant, accent-colored Yes/No verdict, updated every 500ms via the existing tickLive() loop). Bundle audit: 41.87 KB gzipped main, well under the 250 KB target. Lazy-load verified: VIIRS JPG isn't requested until "Light pollution" is toggled on for the first time; ground-station catalog + marquee registry inlined. Playwright + chromium installed; 3 smoke specs (tests/E2E/smoke.spec.ts) pass in 9.8s — make test-e2e runs them. Visual-diff baselines for the Cesium globe deferred to Phase 4 chunk 6 polish where the new HTML surfaces are easier to baseline. docs/phase4.md outlines situational-awareness chunks. 149 PHP / 76 JS / 3 e2e passing.

Phase 4 — Situational awareness (✅ complete)

Chunk Status What it adds
1. Conjunctions ingest + schema ✅ done New conjunctions table (migration 12) holds CelesTrak SOCRATES Plus close-approach predictions (TCA, miss distance, relative speed, max probability, dilution). SocratesClient fetches the canonical sort-minRange.csv; SocratesCsvParser (7 unit specs, fixture-driven) decodes the 11-column shape and normalizes TCAs to ISO-with-Z; SocratesIngester (5 feature specs) wraps fetch+parse in a single transaction with UPSERT on (primary, secondary, TCA) for re-run idempotency. --max-tca-hours=N window trims to the user-visible horizon (default 168h). bin/console ingest:socrates + make ingest-socrates wraps it. Plan swerve, explicit: docs/phase4.md §II row 2 said "HTML scrape" but the live site exposes a clean CSV — going with CSV, no info lost. Real run: 145,392 conjunctions ingested in 11.36s. Top of the table is realistic — Starlink × Starlink @ 21m / p=0.45, Starlink × CZ-2C rocket body @ 23m / p=0.16. 161 PHP / 76 JS passing.
2. Conjunctions API + text view ⏳ pending /api/v1/conjunctions/{upcoming,{primary}/{secondary}} + /text/conjunctions + topbar § conjunctions
3. Space weather ingest + widget ⏳ pending NOAA SWPC ingester + <sat-space-weather-pill> + 24h trend popover
4. Aurora overlay ✅ done OvationClient fetches the NOAA SWPC 1°×1° aurora-probability grid (~65 K cells, refreshed every ~15 min). AuroraRasterGenerator (PHP GD) bakes it into a 720×360 equirectangular RGBA PNG with the standard auroral color ramp (faint green → orange → red-orange) and alpha-scaling, dropping <5% cells as noise. bin/console ingest:ovation + make ingest-ovation overwrite public/textures/aurora-latest.png + a sidecar aurora-latest.json with the observation/forecast timestamps. Real run: 11,488 painted cells in 4.7 KB (way under the budgeted 250 KB — PNG compresses transparent regions to almost nothing). Client side: new AuroraOverlayLayer mirrors LightPollutionLayer (Cesium SingleTileImageryProvider, lazy on first toggle, ?v=now cache-buster per toggle-on so refreshed rasters land cleanly); OverlayService gains an aurora key (default off); § overlays menu grows ✦ Aurora forecast. Bundle: 144.7 → 145.8 KB gzipped main. 180 PHP / 76 JS passing.
5. Stats dashboard ✅ done New StatsController dispatches on /api/v1/stats/{summary,operators,countries,types,launch-years} — all pure GROUP BY aggregations over the existing satellites table, no new ingest. summary returns one dashboard object (totals by type/status/orbit-class + mass + top-5 operators/countries). /text/stats mirrors with bar-chart-like ASCII bars per row. Live against the production 15,665-sat catalog: PAYLOAD 98.49%, US dominates (11,606 of 15,665), launches/year shows the Starlink ramp from 463 in 2020 to 4,315 in 2025. Operator data is empty (SATCAT doesn't ship it — flagged in the empty-state copy for Phase 5+ ingest). Cache 15min + swr=1h. § stats link added to both navs. 188 PHP / 76 JS passing.
6. Events feed + Atom + station tooltip ✅ done New EventsAggregator merges launches / reentries / significant conjunctions (prob ≥ 1e-4) / noteworthy storms (R/S/G ≥ 2 or X-ray ≥ M) into one time-ordered stream; per-kind LIMIT 50 caps the Atom feed at ~32 KB (without it the prod conjunction firehose was 2.5 MB). AtomGenerator emits RFC-4287 Atom 1.0 with stable urn:sat.trackr.live:event:… IDs + deep-link alternates; SimpleXML round-trip in tests. New GET /events.atom (10min cache) + /text/events server-rendered chronological view with kind-colored badges + relative timestamps. The Phase 3 chunk 4 station-tooltip deferral lands: SelectionController recognizes the chunk-4A StationPick id shape and routes through Globe.countSatellitesAboveStation() (iterates ~15 K cached ECEF positions in <50 ms via an ENU-frame elevation check); <sat-station-tooltip> Lit element pops over the cursor with "Tracking N satellites ≥ 5°" and dismisses on Esc / outside-click / 8 s timeout. Both navs' § events placeholder becomes a real link. Bundle: 145.95 → 150.14 KB gzipped main. 198 PHP / 76 JS passing.
7. N2YO magnitude enrichment ✅ done The Phase 2 chunk 6 deferral lands. N2YOClient wraps api.n2yo.com/rest/v1/satellite/visualpasses/ with a daily quota guard backed by storage/cache/n2yo-quota.json (mirrors N2YO's own info.transactionscount so the cap stays accurate across processes; capped at 800/day with 200 headroom under the published 1000/day informal limit). PassMagnitudeEnricher runs after the chunk-6 Node SGP4 compute; merges per-pass mag by closest peak-time match within 60 s; defaults every magnitude to null first so the UI shape is stable when the key is missing / quota is exhausted / N2YO 4xx's. SatellitePassesController wires the enricher before cache.put, so the 6h cache holds the enriched payload. <sat-detail-panel> §Visibility table grows a Mag column with 1-decimal precision; magnitudes <3 (naked-eye threshold) render in accent color. Verified live: fresh ISS-over-NYC fetch returned mag=0.1/-0.3/0.5 matching N2YO's own data exactly. Bundle: 150.14 → 150.59 KB gzipped main. 209 PHP / 76 JS passing.
8. glTF swap-in path + Playwright structural specs + Phase 5 outline ✅ done MarqueeSpec grew an optional gltfUri; MarqueeShapeLayer loads via Cesium.Model.fromGltfAsync() when set (with loadToken race-safety against re-selection mid-load), falls back to the chunk-3A procedural primitive when unset or on load failure. public/models/CREDITS.md documents the file-format expectation + licensing checklist for contributors. Honest framing: no real glTF files were sourced this chunk — that's a Phase 5 chunk 7 deliberate task. 5 new Playwright structural specs (tests/E2E/visual.spec.ts) cover /text/conjunctions, /text/space-weather, /text/stats, /text/events, /events.atom. Pixel-perfect baselines deferred to Phase 5 (needs a fixture-data layer; live data drifts every cron run). docs/phase5.md outlines polish + ecosystem work. Bundle: 150.59 → 151.14 KB gzipped main. 209 PHP / 76 JS / 8 e2e passing.

Phase 5 — Polish & ecosystem (✅ complete)

Chunk Status Notes
1. SatNOGS amateur-radio enrichment ✅ done New satellite_radio table (UUID PK, NORAD + indexes) populated from db.satnogs.org/api/transmitters/ — the catalog returns 4,861 transmitters as one un-paginated 3.4 MB JSON array; no API key, no pagination dance. SatnogsClient + SatnogsIngester + ingest:satnogs (idempotent UPSERT-by-UUID, orphan filter skips ~57% of rows whose NORAD isn't in our catalog → 2,115 transmitters linked to satellites on first live run). New endpoint GET /api/v1/satellites/{norad}/radio returns alive-first-then-name-sorted transmitter rows. Detail panel + /text/satellite/{norad} both gained a § Radio section with mode / description / downlink / uplink columns, MHz/GHz auto-formatted, inactive rows greyed. Weekly cron entry added below. 220 PHP / 76 JS / 8 e2e passing.
2. PWA manifest + service worker + offline /text ✅ done public/manifest.webmanifest (id/scope /, display=standalone, theme + bg #0a0e27, three shortcuts) + public/icons/{192,512,512-maskable}.png + public/apple-touch-icon.png (180×180), all generated by bin/build-pwa-icons.php (PHP GD, no asset deps). Both shell.php and /text/layout.php link the manifest + Apple PWA meta. Vanilla public/sw.js (no Workbox dep) uses cache-first for /build/* (Vite content-hashed → safe forever within a version) + network-first with offline fallback for /text/*; install pre-caches /offline.html + icons + manifest; activate purges old CACHE_VERSION buckets; skipWaiting + clients.claim make new SWs take over on the next navigation. Registration via resources/js/pwa/register-sw.ts (from main.ts) + an inline 11-line script in the /text layout, both gated to skip localhost unless localStorage.pwaEnableInDev='1' (which the Playwright suite sets). 4 new Playwright specs cover manifest validity, icon HTTP, SW activation, and offline fallback. Bundle: 151.14 → 154.52 KB gzipped main. 220 PHP / 76 JS / 12 e2e passing.
3. OpenAPI 3.1 spec + Swagger UI ✅ done New zircote/swagger-php 6.1 dep (no runtime weight — attributes are compile-time metadata). All 21 Phase-1..4 endpoints annotated with #[OA\Get(path, summary, tags, parameters, responses)]; 9 #[OA\Tag] blocks (Catalog, Groups, Launches, Reentries, Conjunctions, Space weather, Stats, Search, Radio) and 11 shared #[OA\Schema] components (SatelliteSummary / SatelliteDetail / TleCurrent / PaginationMeta / PaginationLinks / ErrorResponse / GroupSummary / Launch / Conjunction / Pass / RadioTransmitter) live in src/Http/Docs/ so per-endpoint annotations stay tight. New OpenApiGenerator runs the swagger-php reflection scan; GET /api/v1/openapi.json serves the live spec (~75 KB, ETag-cached); GET /api/v1/docs renders Swagger UI from jsDelivr (no vendored ~1 MB UI bundle, CDN-failure fallback message included). make openapi-dump / bin/console openapi:dump emits a static public/openapi.json for prod-side caching. 5 PHP tests + 2 Playwright specs cover schema/path completeness, JSON round-trip, and Swagger UI rendering. 225 PHP / 76 JS / 14 e2e passing.
4. OG image generator + per-page meta ✅ done New OgImageGenerator (PHP GD, no extra deps) emits 1200×630 PNGs in the trackr.live family aesthetic — dark navy bg, accent cyan rule + brand glyph (favicon's stroked circle + crosshair), DejaVu Sans Mono via imagettftext with a GD-bitmap fallback if the font's missing. Three card templates: renderSatellite (name + NORAD/intl-designator + type/orbit/country badges), renderLaunch (provider/vehicle/pad/NET), renderEvents (top-5 conjunctions table). New OgImageController mounts at /og/satellite/{norad}.png, /og/launch/{id}.png, /og/events.png — outside the JSON middleware group; serves cached PNGs from storage/cache/og/ for 6 h with Cache-Control: public, max-age=21600, stale-while-revalidate=86400; events card bucketed per UTC day so the cache key ages itself. Meta tags wired into both surfaces: shell.php picks satellite/{selectedNorad} when one is selected else events; /text/satellite/{norad} → satellite card; /text/launches/{id} → launch card; everything else in /text/* defaults to the events card via the new ogImage param on TextRenderer::renderPage. 9 new PHP tests (4 generator + 5 controller) + 4 Playwright specs cover PNG validity, cache reuse, 404 paths, and og:image / twitter:image presence on /text/satellite + /text/conjunctions. 234 PHP / 76 JS / 18 e2e passing.
5. Sitemap + canonical + JSON-LD ✅ done New SitemapBuilder walks the catalog + launches table and emits a sitemap_index.xml-style top-level public/sitemap.xml plus chunked public/sitemap-{n}.xml files at 10 000 URLs per chunk (sitemaps.org allows 50K, but smaller chunks keep single files well under the 50 MB cap and play nicely with future Alpha-5 growth). First live run: 15,824 URLs across 2 chunks (~2.6 MB total). bin/console sitemap:build / make sitemap-build + daily cron entry at 04:45. TextRenderer::renderPage grew ?canonicalPath + ?jsonLd params; both surfaces (SPA shell, /text layout) emit <link rel="canonical"> per page, and /text/satellite/{norad} ships schema.org Thing, /text/launches/{id} ships Event (with organizer when provider known), and /text/conjunctions ships CollectionPage JSON-LD. 5 new PHP tests + 5 Playwright specs cover index structure, urlset structure, 10K chunking, satellite/launch URL presence, base-URL slash handling, robots.txt sitemap line, and per-page canonical/JSON-LD presence. 239 PHP / 76 JS / 23 e2e passing.
6. Deep-link sharing + Web Share ✅ done New resources/js/share/shareUrl.ts — pure parse/build helpers for ?sat=&lat=&lon=&alt=&t= URLs. Strict validation: NORADs 1..9999999, lat ±90, lon ±180, ISO-8601 timestamps; malformed values dropped silently so a bad link still loads something usable. Lat+lon are emitted/parsed only as a pair — never a half-set observer. Coords rounded to 4 dp (~11 m) so URLs stay short. <sat-app>.connectedCallback parses the URL and restores: satellite selection (deferred to camera-fly until clock-ready because the globe mounts later), observer (only when none already in localStorage — saved obs always wins), and clock time (queued for clock-ready). New <sat-share-button> topbar element reads current state at click time, calls navigator.share() when present, falls back to navigator.clipboard.writeText, flashes a "Copied" badge for 1.5 s. Uses a shadow-DOM-aware ancestor walker since Element.closest() won't cross shadow boundaries between sat-share-buttonsat-top-barsat-app. 18 new Vitest cases + 3 Playwright specs (deep-link selection works, bogus ?sat loads gracefully, button is wired and clickable). Bundle: 154.52 → 158.93 KB gzipped main. 239 PHP / 94 JS / 26 e2e passing.
7. Real glTF for marquee + Phase 6 outline ✅ done One real glTF wired (ISS, NASA Solar System Exploration, ~44.5 MB binary, public-domain under NASA's image-use guidelines). Binaries are gitignored — added bin/fetch-marquee-models.sh + make fetch-models to populate public/models/ on clone or deploy. MarqueeSpec for NORAD 25544 grew gltfUri: '/models/iss.glb' with visualScale: 120 matching the legacy procedural primitive so the on-screen size is consistent. Phase 4 chunk 8A's MarqueeShapeLayer fallback is unchanged — when the file is absent, the procedural panel renders as before. Honest framing: licensing-clean glTF for the other six marquee slots (Tiangong, Hubble, Dragon, Cygnus, Soyuz, Starlink) is genuinely scarce; they stay procedural until contributors find sources. public/models/CREDITS.md documents the ISS source + license + the contributor pattern. New docs/phase6.md outlines optional follow-up work (conjunction-replay scene, GNSS constellations, ICS export, WebGPU dots, multi-region deploy) without committing to any of it. 2 new Playwright specs verify glTF HTTP serving + graceful fallback. 239 PHP / 94 JS / 28 e2e passing.

See docs/phase5.md for the locked Phase 5 plan + decisions. Phase 6 is also closed — see docs/phase6.md for the locked plan (now retired into status-banner form). Project is in maintenance mode; any future phase would be planned fresh.

Phase 6 — Conjunction-replay showcase (✅ complete)

Chunk Status Notes
1. Routing + replay scene scaffold ✅ done New /conjunction/{primary:[0-9]+}/{secondary:[0-9]+} SPA route resolves the soonest TCA for the pair via SpaShellController (order-insensitive lookup against the conjunctions table); embeds the row as a <script id="sat-replay-context" type="application/json"> blob alongside a replay-mode="conjunction" attribute on <sat-app>. New resources/js/replay/replayContext.ts is a pure parse/validate helper with the locked window constants (REPLAY_WINDOW_MS = ±5 min, REPLAY_START_OFFSET_MS = -2 min). App.ts.connectedCallback reads the blob; handleClockReady fires globe.enterConjunctionReplay(ctx) once Cesium is up. New resources/js/globe/ConjunctionScene.ts fetches both sats' TLEs in parallel, rewrites Cesium's clock window to TCA ± 5min (CLAMPED), parks the cursor at TCA−2min paused, and frames the chase camera on a BoundingSphere around the midpoint of both ECEF positions (×6 margin, 200 km floor) — re-framed every tick so the camera tracks the convergence. Clock.cesium getter added so the scene can manipulate startTime/stopTime/clockRange without ballooning the Clock facade. Globe.viewer made public so the scene can issue viewBoundingSphere. Disposal restores the prior clock window. Chunk-1 explicitly defers: both-sat ribbons, both-sat marquee shapes, dimming the catalog (PointPrimitiveLayer needs a setOverallAlpha), HUD overlay, replay timeline controls, TCA-moment visual highlight — all chunk-2 work. 9 new Vitest cases + 3 PHP tests + 2 Playwright specs cover SSR contract, JSON validation, window math, and order-insensitive lookup. Bundle: 158.93 → 163.21 KB gzipped main. 242 PHP / 103 JS / 30 e2e passing.
2. HUD overlay + dual ribbons/marquee + TCA pulse ✅ done All four chunk-1 deferrals landed. PointPrimitiveLayer grew setOverallAlpha(0..1)ConjunctionScene calls setOverallAlpha(0) on activate (catalog blanked, replay subjects own the stage) and restores 1 on dispose. OrbitRibbonLayer + MarqueeShapeLayer each gained a secondary slot (showSecondary / hideSecondary + matching update/render) — independent of the primary slot, independent loadToken on the marquee so async glTF loads don't cross-contaminate. Secondary ribbon tints accent-orange (#ffb700) so the two arcs distinguish on the globe. New <sat-conjunction-hud> Lit element — top-center card with both names + NORADs (color-swatch matches ribbon hue), live miss distance (computed every tick from main-thread SGP4 ECEF positions; falls back to the SOCRATES tca_range_km until propagation seeds), TCA countdown (T±MM:SS), relative velocity, Foster probability, ▶ Play/❚❚ Pause button + back link. New pure helpers formatTcaCountdown, formatMissDistance, liveMissKm keep the HUD render-tick math testable in Node. TCA-moment pulse — accent-cyan Cesium.Entity point that fades in/out within ±500 ms of TCA so the closest-approach instant is visually marked; auto-removed when out of window or on dispose. 10 new Vitest cases (countdown formatting, miss formatting, Euclidean live miss) + 1 Playwright spec (HUD attached, Play button present, Miss/TCA labels rendered). Bundle: 163.21 → 175.44 KB gzipped main. 242 PHP / 113 JS / 31 e2e passing.
3. Entry points + sharing ✅ done Every row in /text/conjunctions gained a ▶ Replay cell (new column header + <a class="replay-link" href="/conjunction/{p}/{s}">); empty-state rendering unchanged. Atom event-feed conjunction entries now <link rel="alternate" href="/conjunction/{p}/{s}"> instead of the JSON API URL they used to point at — feed readers and crawlers land on a human page (the SOCRATES row id is preserved in the entry URN for stable dedup). Existing chunk-6 Share button + chunk-5 deep-link sharing reuse unchanged: the replay URL itself is the deep link, copy-paste works, navigator.share() works. 3 new PHP tests (empty-state has no links, populated state generates correct /conjunction/{p}/{s} per row, Atom conjunction links match ^/conjunction/\d+/\d+$) + 2 Playwright specs (/text/conjunctions → ▶ click → HUD attached; old /api/v1/conjunctions/{p}/{s} link pattern absent from Atom feed). 245 PHP / 113 JS / 33 e2e passing.
4. Tests + Phase 6 close ✅ done 5 new Vitest cases in tests/Js/replayWindow.test.ts covering edge cases the chunk-1B suite didn't: fractional-second ISO timestamps, explicit +00:00 offsets, locked constants (REPLAY_WINDOW_MS = 300000, REPLAY_START_OFFSET_MS = -120000), window-containment invariants, DST-boundary TCA. 2 new Playwright specs in conjunction-replay-flow.spec.ts covering the marquee user journey: navigate → HUD attached → click ▶ Play → countdown advances → click ❚❚ Pause → countdown freezes; and the HUD back link returning to /text/conjunctions. Honest framing: Vitest-mocking the activate/dispose state machine for a Cesium-bound class doesn't pay for itself; the integration test gives real confidence the scene transitions don't leak state. Playwright global timeout bumped from 30 s → 60 s to absorb Cesium init under sustained back-to-back e2e load (individual long specs already use test.slow() for extra headroom). README closes Phase 6; docs/phase6.md retires. 245 PHP / 118 JS / 35 e2e passing.

Phase 7 — Sky view / stargazer mode (✅ complete)

Chunk Status Notes
1. Mode + camera + topbar toggle ✅ done Sub-chunk 1A added a view: 'globe' | 'sky' field to the share-URL system (buildShareUrl emits view=sky only as non-default; parseShareParams accepts both for round-trip purity) + a @state() viewMode field on <sat-app> with restore-on-connect (graceful-ignore when no observer is set). 8 Vitest. Sub-chunk 1B added resources/js/globe/skyCameraMath.ts (Cesium-free constants — pitch +75°, heading 0°, FOV 75°, fly duration 1.5 s, FOV bounds 30°–110°) + resources/js/globe/SkyCamera.ts (Cesium-using snapshotCamera / setSkyView / setGlobeView). Globe.enterSkyView / exitSkyView / isInSkyView orchestrate atmosphere suppression + terrain-collision floor with full save/restore. 17 Vitest. Sub-chunk 1C wired the topbar: new <sat-view-toggle> immediately right of <sat-observer-pill> — disabled with explanatory tooltip when no observer or in /conjunction/{p}/{s} replay route; click calls new <sat-app>.requestViewMode() which fires Globe.enterSkyView/exitSkyView + emits view-mode-changed. Shadow-DOM walker extracted from ShareButton to resources/js/util/shadowDom.ts. 5 Playwright e2e covering: disabled-no-observer, click round-trip, ?view=sky deep-link with observer, graceful-fallback without observer, replay-route disabled. 245 PHP / 143 JS / 40 e2e passing.
2. UI polish + back-to-globe + overlay auto-hide ✅ done Sub-chunk 2A added OverlayService.suspend(keys[]) / restore() / isSuspended(key) / suspendedKeys() — runtime-only (no localStorage write), pre-call values captured + restored exactly, setEnabled is a write-lock no-op on suspended keys, always notifies subscribers so UI cues re-render. Globe.enterSkyView suspends ground-stations + light-pollution + aurora; exitSkyView restores. <sat-overlays-menu> renders suspended rows with data-suspended='true', 0.45 opacity, not-allowed cursor, 🔒 lock glyph, "Hidden in sky view" tooltip, aria-disabled='true'. 9 Vitest. Sub-chunk 2B added resources/js/globe/skyHorizonConstants.ts (Cesium-free constants — 1 km horizon-label offset, 5 km zenith reticle altitude, 20px monospace font, cardinal bearings + ENU offset math) + resources/js/globe/SkyHorizonLayer.ts (Cesium-using LabelCollection for N/E/S/W in the observer's local ENU frame + PointPrimitiveCollection with one transparent-fill / accent-cyan-outline reticle 5 km above). Attached on sky entry, detached on exit, viewer-destroy-safe. 12 Vitest. Sub-chunk 2C added resources/js/ui/compass.ts (Cesium-free 8-direction segmenting with NaN/∞ defenses + 22.5°-boundary semantics) + new <sat-sky-overlay> Lit element in .globe-area (hidden via data-hidden attr in globe mode; top-left ↓ Back to globe button with accent border + blur backdrop; horizontal compass strip with all 8 directions + degree-rounded bearing readout). Globe dispatches sky-camera-heading window events on camera.changed, debounced to 0.5° deltas; attached on sky entry, detached on exit. Back-to-globe click walks to <sat-app> via the shadow-DOM helper + calls requestViewMode('globe'). 12 Vitest + 4 Playwright e2e. 245 PHP / 176 JS / 44 e2e passing.
3. Pass-arc layer (selected + marquee) ✅ done Sub-chunk 3A added /api/v1/sky-view/passes?lat&lon&within_hours&include=marquee — aggregate-pass endpoint backed by Phase 2's PassCalculator + PassCache. Marquee roster is a 6-NORAD canonical mirror of MARQUEE_SPECS in src/Sky/MarqueeRoster.php (Starlink excluded — namePrefix matcher, ~7000 sats; chunk 4 handles bright-pass discovery). Validates lat/lon range, clamps within_hours to [1, 168], rejects unknown include, sorts by peak_at, silently skips NORADs without TLE. 7 PHPUnit. Sub-chunk 3B added PassArcLayer — Cesium PolylineCollection + LabelCollection. attach(viewer) + setMarquee/setSelected/setPasses(records[]) + detach(). For each record: parses TLE via twoline2satrec, propagates 30 sub-samples via satellite.js, converts ECI → geodetic → ECEF Cartesian3, builds polyline + peak label "{name} · HH:MM". Globe.enterSkyView fetches sky-view passes async (token-rev'd against rapid enter/exit); exitSkyView tears down. ~600 propagations per session, under 50ms, no worker. 13 Vitest. Sub-chunk 3C refactored PassArcLayer to track two slots (marquee + selected) with (norad, peak_at) dedupe. New Globe.refreshSelectedPassArc fetches via the existing /api/v1/satellites/{norad}/passes endpoint when selection changes in sky view; activateSkyView seeds it on initial entry too. 3 Playwright e2e for the new endpoint + 400 paths. 252 PHP / 199 JS / 46 e2e passing.
4. Bright-pass discovery + tests + Phase 7 close ✅ done Sub-chunk 4A added src/Sky/MagnitudeEstimator.php (rough base = 5 − 2.5·log10(rcs) + (90−elev)/30 honest ~±1.5 mag, calibrated to ISS / Hubble / Starlink; defensive against zero / NaN / Infinity inputs) + src/Sky/BrightPassDiscoverer.php (top 30 candidates by rcs_meters ≥ 1.5 m² excluding marquee, runs PassCalculator per-candidate reusing the 6h PassCache, filters to passes peaking in [now, now+window] AND peak_el ≥ 20°, prefers Phase 4 chunk 7's N2YO mag when present, drops mag > 4.5, sorts brightest-first, caps to caller's maxResults). SkyViewPassesController extended to accept include=bright: marquee aggregate + discovery merged, capped at MAX_TOTAL_ARCS = 100; response meta gains include + bright_count + max_arcs. 11 PHPUnit (9 estimator + 2 endpoint). Sub-chunk 4B Globe.refreshPassArcs now requests include=bright so sky-view entry receives marquee + discovery in one call (~3-5 s first-time-from-this-location, <100 ms warm). API-client types updated. New make sky-cache-prune target (alias of pass-cache-prune since the discovery reuses the same pass_cache table — no new schema). Sub-chunk 4C 3 new Playwright e2e: include=bright envelope shape, unsupported-include 400, full entry-flow (observer set → toggle → overlay visible → back-to-globe). docs/phase7.md flipped to ✅ Closed; this README header replaced with the Phase 7 complete block. 263 PHP / 214 JS / 49 e2e passing.

See docs/phase7.md for the full locked plan; docs/phase8.md, docs/phase9.md, docs/phase10.md, docs/phase11.md for the planned follow-on phases (engagement & accessibility, visual depth + zoom button, ops & scale, security + README deployment overhaul).

See docs/phase4.md for the locked plan, decisions, dependencies, and risk.

See docs/phase3.md for the locked Phase 3 plan, decisions, dependencies, and risk.

See docs/phase1.md and docs/phase2.md for design details, and docs/req_spec.md for the long-form vision (sections §1–§30).


What's testable today

From the browser or your phone — public JSON API

The catalog API is live at http://localhost:8000/api/v1/... (or the LAN URL). Try any of these in a browser, with curl, or from your phone:

# Catalog
curl http://localhost:8000/api/v1/satellites?limit=5
curl http://localhost:8000/api/v1/satellites?country=US&type=PAYLOAD&limit=10
curl http://localhost:8000/api/v1/satellites?q=hubble        # FTS5 fuzzy
curl http://localhost:8000/api/v1/satellites/25544           # ISS detail (TLE inlined)
curl http://localhost:8000/api/v1/satellites/25544/tle       # ISS current TLE only

# Groups (38 CelesTrak groups configured)
curl http://localhost:8000/api/v1/groups
curl http://localhost:8000/api/v1/groups/stations            # 27 NORAD IDs
curl http://localhost:8000/api/v1/groups/starlink/tles       # bulk: 10K Starlinks

# Search
curl 'http://localhost:8000/api/v1/search?q=ISS'             # ISS family modules
curl 'http://localhost:8000/api/v1/search?q=1998-067A'       # by intl designator
curl 'http://localhost:8000/api/v1/autocomplete?q=star'      # typeahead

# Launches (Phase 2 chunk 3 — populated by `make ingest-ll2`)
curl 'http://localhost:8000/api/v1/launches/upcoming?limit=5'  # next launches by NET
curl 'http://localhost:8000/api/v1/launches/recent?days=30'    # last 30 days
curl 'http://localhost:8000/api/v1/launches/{uuid}'            # detail incl. pad + cataloged objects
curl http://localhost:8000/api/v1/launch-sites                 # all 51 pads alphabetical

# Reentries (Phase 2 chunk 4 — populated by `make ingest-spacetrack`)
curl 'http://localhost:8000/api/v1/reentries/upcoming?within_hours=720'  # next 30 days
curl http://localhost:8000/api/v1/reentries/54837                        # one prediction by NORAD

# Pass predictions (Phase 2 chunk 6 — Node SGP4 + 6h SQLite cache)
curl 'http://localhost:8000/api/v1/satellites/25544/passes?lat=51.5072&lon=-0.1276&days=2'  # ISS over London, next 2 days

Every response carries an ETag; pass it back via If-None-Match to get a 304 Not Modified. CORS is fully open (Access-Control-Allow-Origin: *) and OPTIONS preflight returns 204 in <5ms.

From any browser — text-only catalog at /text (no JS / no WebGL required)

For environments without WebGL (older browsers, restricted IT, GPU disabled, headless tools, or JS fully off), the same data is browseable as plain HTML:

http://localhost:8000/text                      # paginated catalog with filter form
http://localhost:8000/text?q=ISS&type=PAYLOAD   # filtered (FTS5 q + type/country/status/orbit)
http://localhost:8000/text/satellite/25544      # full §10 detail page for ISS
http://localhost:8000/text/groups               # all 38 CelesTrak groups + counts
http://localhost:8000/text/groups/stations      # group members
http://localhost:8000/text/search?q=hubble      # search form + results

Self-contained — inline dark-theme CSS in the layout, no external assets, sitemap-friendly. The SPA's <sat-no-webgl> notice (auto-shown when hasWebGL() returns false) links here as the primary CTA.

In the browser

Open http://localhost:8000 (or the LAN URL printed by make). You should see:

  • Top bar: ⊕ sat.trackr.live wordmark, Space situational awareness, _legible_ tagline, § catalog · § launches · § decays · § conjunctions · § stats · § events nav (all link to text views), search input with ⌘K shortcut hint, 📍 observer-location pill (Phase 2 chunk 5), ☼ Kp space-weather pill (Phase 4 chunk 3, opens popover with 24h trend), § overlays menu with five toggles — Orbit ribbon / 3D shapes / Ground stations / Light pollution / Aurora forecast (Phase 3 chunk 4 + Phase 4 chunk 4), theme switcher button. Click any ground station on the globe → <sat-station-tooltip> (Phase 4 chunk 6) shows "Tracking N satellites ≥ 5°".
  • Cesium globe with ~15,000 satellites rendered as point primitives, color-coded by object_type (cyan = payloads + unknown, amber = rocket bodies, red = debris, gray = TBA). SGP4 propagation runs in a Web Worker at 4Hz; you should see the ISS marching across the planet, Starlink trains in formation, and ~10K LEO objects in slow-motion swarm. Drag to rotate, pinch/scroll to zoom. OpenStreetMap imagery (no Cesium ion token needed yet).
  • Click any dot → it turns white + 9px and the right-rail detail panel slides in with four § sections:
    • § Identity — type/status/orbit-class badges + 6-cell grid (operator, country, launch date, launch vehicle, mass, RCS). After Phase 2 chunk 1 (SATCAT), object_type/status/country/launch_date/launch_site_code/RCS now populated for ~98.5% of objects (operator + mass + dimensions remain empty until later sources). External links: N2YO, Heavens-Above, Gunter, Wikipedia.
    • § Current state — live latitude / longitude / altitude (km) updated 2× per second from the propagator worker.
    • § Orbital elements — epoch with <sat-freshness-badge> (FRESH/STALE/AGED/OLD), period, inclination, eccentricity, mean motion, perigee, apogee, semi-major axis, B*, RAAN, arg perigee, mean anomaly, rev number.
    • § Raw data — clickable 3-line TLE (click to copy) + JSON detail/TLE links.
  • Search the catalog in the top-right input (⌘K focuses it). Type to get a debounced autocomplete dropdown of up to 10 matches; ↑/↓ navigates, Enter or click selects. On selection the camera flies to the satellite and the detail panel opens.
  • Close the panel via the × button, the Esc key, or by clicking empty space on the globe.
  • Status pill in the bottom-left corner shows the load progression: "Loading satellite catalog…" → "Parsing 15,665 TLEs…" → "Tracking 15,665 satellites" (then fades to half-opacity).
  • Bottom timeline spans now-7d → now+7d. The yellow shaded bands beyond ±48h mark the "extrapolated" zone (Phase 1 doesn't have historical TLE backfill yet). Drag the slider to scrub time — the swarm jumps immediately to the new positions. Play/pause + speed buttons (0.5×–600×) animate forward (or back) at the chosen multiplier; the live state in the detail panel reflects the scrubbed time. "Now" button snaps back to wall-clock present.
  • Theme switcher: click the toggle (top right) to cycle Dark / Light / High contrast. Choice persists in localStorage.

URL shapes already wired:

  • / → SPA shell with full globe
  • /satellite/{norad} → SPA shell with the NORAD ID set as initial selection so the detail panel opens for that satellite on page load (camera doesn't auto-fly — that's only triggered by the search picker)

From the CLI

# Schema management
make migrate                          # apply migrations (14 total: 6 Phase 1 + 5 Phase 2 chunk 1 + 2 Phase 4 chunks 1+3 + 1 Phase 5 chunk 1)
make migrate-status                   # show what's applied vs pending
make rollback                         # reverse the most recent batch
make make-migration NAME=add_foo      # scaffold a new migration file

# Catalog ingest
make ingest                           # CelesTrak GP — TLE data for ~15.6K satellites in ~40s (chunk 3)
make ingest-group GROUP=stations      # just one GP group
make ingest-satcat                    # CelesTrak SATCAT — operator/country/launch_date/RCS/status enrichment in ~30s (Phase 2 chunk 1)
make ingest-satcat-group GROUP=starlink  # just one SATCAT group
make ingest-ll2                       # Launch Library 2 — 50 upcoming + 100 previous launches in ~4s (Phase 2 chunk 3)
make ingest-spacetrack                # Space-Track TIP — predicted reentries in ~1.2s (Phase 2 chunk 4)
make ingest-socrates                  # CelesTrak SOCRATES — ~145k close-approach predictions in ~12s (Phase 4 chunk 1)
make ingest-swpc                      # NOAA SWPC snapshot of Kp + X-ray + R/S/G in <1s (Phase 4 chunk 3, */5min cron)
make ingest-ovation                   # NOAA OVATION aurora-forecast raster in <1s (Phase 4 chunk 4, */15min cron)
make ingest-satnogs                   # SatNOGS DB transmitter catalog → satellite_radio table (Phase 5 chunk 1, weekly cron)
make fetch-models                     # Pull marquee-satellite glTFs into public/models/ (Phase 5 chunk 7, idempotent ~45MB total)
make pass-cache-prune                 # Sweep expired pass-cache rows (Phase 2 chunk 6)
make build-skybox                     # Regenerate BSC5 starfield cubemap into public/textures/skybox/ (Phase 3 chunk 1)
make health                           # PHP / pdo_sqlite / DB / per-table row counts

# Quality gates
make test                             # 209 PHP + 76 JS = 285 cases passing
make test-e2e                         # 8 Playwright structural specs (Phase 3 chunk 6 + Phase 4 chunk 8) — needs `npx playwright install chromium` once
make lint / make analyze / make typecheck / make ci

After make ingest, the database holds ~15.6K distinct satellites (deduplicated across overlapping CelesTrak groups), ~15.6K current TLEs, and a history row per (norad_id, epoch) pair — typically 1 per object on the first run, growing by the number of objects with refreshed epochs on each subsequent run.

The schema after make migrate matches docs/phase1.md § V exactly:

Table Purpose Notes
satellites Catalog row per object CHECK constraints on object_type, status, orbit_class, size_class; 6 indexes. CelesTrak GP populates name + intl_designator; CelesTrak SATCAT (Phase 2 chunk 1) fills object_type/status/country/launch_date/launch_site_code/decayed_at/rcs_meters. Operator/mass/dimensions still empty pending later sources.
satellites_fts FTS5 virtual table for fuzzy search Auto-synced via insert/update/delete triggers
tle_current One TLE per active object FK to satellites, ON DELETE CASCADE; mean motion + eccentricity + inclination + RAAN + arg perigee + mean anomaly + BSTAR + rev number, plus derived period / perigee / apogee / semi-major axis
tle_history Append-only TLE archive Composite PK (norad_id, epoch); INSERT OR IGNORE makes re-ingests cheap
satellite_purposes Join table for §5 SET-style purpose Populated by SATCAT ingester via group_membership heuristic (Phase 2 chunk 1); 12,757 rows after first run
group_membership Join table tracking which CelesTrak group(s) include each satellite Composite PK (norad_id, group_slug) + last_seen_at; populated by the ingester on each pass; powers /api/v1/groups/{slug}*
launch_sites LL2 launch pads Populated by make ingest-ll2 (Phase 2 chunk 3); ~51 rows on first run
launches LL2 launch records Populated by make ingest-ll2 (Phase 2 chunk 3); 150 rows (50 upcoming + 100 previous)
reentries Predicted decays from Space-Track TIP + CelesTrak SATCAT Populated by make ingest-spacetrack (Phase 2 chunk 4); UPSERT keyed on (norad_id, source) so re-runs refresh predictions in place
pass_cache Server-side pass-prediction cache (6h TTL) Populated by PassCache::put() whenever the chunk-6 controller spawns a fresh Node subprocess; key = {norad}:{lat-3dp}:{lon-3dp}:{day}. Sweep with make pass-cache-prune.
migrations Auto-created by Migrator Tracks applied filename + batch + timestamp

API endpoint reference (chunk 4)

Method + path Returns Notes
GET /api/v1/satellites Paginated list {data, meta, links} Filters: country, type, status, orbit_class (multi via comma); operator (substring); launched_after/launched_before (ISO date); q (FTS5); page, limit (max 500)
GET /api/v1/satellites/{norad} Full detail with tle_current inlined Includes freshness label (FRESH/STALE/AGED/OLD per §11) and epoch_age_seconds; 404 on unknown NORAD
GET /api/v1/satellites/{norad}/tle Current TLE only For clients that already have catalog metadata
GET /api/v1/groups All 38 CelesTrak groups + counts 1h cacheable
GET /api/v1/groups/{slug} Group + ordered NORAD IDs 5min cacheable
GET /api/v1/groups/{slug}/tles Bulk TLE blob {group, count, tles: [...]} The hot SPA endpoint; full keys (norad_id, name, line1, line2, object_type) for readability
GET /api/v1/search?q= Up to 50 results, each with match_type NORAD ID exact > intl designator exact > FTS5 fuzzy
GET /api/v1/autocomplete?q= Up to 10 typeahead results NORAD ID prefix + FTS5 prefix; 5min cacheable
GET /api/v1/launches/upcoming Next launches by NET, with pad block limit (default 50, max 100); cache 5min + swr=10min
GET /api/v1/launches/recent Past N days of launches, most-recent first limit (default 100, max 200), days (default 90, max 365); cache 1h
GET /api/v1/launches/{uuid} Single launch with full detail + pad + decoded associated_norad_ids 404 on unknown UUID
GET /api/v1/launch-sites All ~51 pads alphabetical Cache 24h
GET /api/v1/reentries/upcoming Predicted reentries within within_hours (default 168, max 720) Joined with satellite name + object_type; cache 10min + swr=15min
GET /api/v1/reentries/{norad} Most-recently-updated prediction for a NORAD; raw TIP message decoded; nested satellite block 404 on no prediction; cache 5min + swr=10min
GET /api/v1/satellites/{norad}/passes Up to 14 days of pass predictions for an observer; required lat+lon, optional alt/days/min_elevation_deg. Each pass is rise/peak/set ISO + duration + max elevation + 3 azimuths. meta.from_cache flags hits Cold ~250ms (Node spawn), warm ~30ms; cache 5min + swr=10min
GET /api/v1/conjunctions/upcoming Top close-approaches (paginated). Filters: within_hours (1-720, default 24), min_probability (default 0), limit (1-500, default 50) + page, sort (probability|tca|range, default probability DESC) Joined with both satellites' object_type + country; cache 10min + swr=15min
GET /api/v1/conjunctions/{primary}/{secondary} Every active prediction for a satellite pair, order-insensitive, sorted by TCA 404 on no predictions; cache 5min + swr=10min
GET /api/v1/space-weather/now Latest NOAA SWPC sample (Kp + X-ray flux/class + R/S/G storm scales) 404 if no samples ingested; cache 5min + swr=10min
GET /api/v1/space-weather/24h All samples in trailing 24h, sorted ASC cache 5min + swr=10min
GET /api/v1/stats/{breakdown} Catalog aggregations. Breakdowns: summary (one-call dashboard), operators / countries (top-N by count, ?limit default 50 max 200), types (with percent), launch-years (per-year counts, ?since=YYYY default 1957) 404 on unknown breakdown; cache 15min + swr=1h
GET /events.atom Atom 1.0 syndication feed merging the trailing 7d + leading 7d of launches / reentries / significant conjunctions / noteworthy storms. Per-kind limit 50 keeps the feed ~32 KB application/atom+xml; cache 10min + swr=30min

Default response headers: Content-Type: application/json; charset=utf-8, Cache-Control: public, max-age=60, stale-while-revalidate=120 (controllers override per-route — bulk-TLE uses 300s, group lists use 3600s), ETag: W/"<sha1-of-body>" plus open CORS (*). If-None-Match → 304.

CelesTrak ingest details

  • Format: the ingester defaults to FORMAT=TLE (3-line sets) but accepts --format=json to consume FORMAT=JSON (OMM) records via OmmJsonParser. When NORAD IDs cross 6 digits (~mid-2026) the JSON path stays valid; the TLE path also keeps working via NoradId::encode / NoradId::decode Alpha-5 encoding (A0000Z9999 = 100000–339999). Both paths produce the same ParsedTle shape; OmmJsonParser synthesizes byte-perfect line1/line2 strings via TleEmitter so the rest of the system (satellite.js, copy-to-clipboard, raw-data panel) doesn't care which path served the data.
  • Group list: 38 groups configured in src/Ingest/CelesTrakGroups.php covering Special-Interest, Weather/Earth-Obs, Communications, Navigation, Scientific, and Miscellaneous. Many objects appear in multiple groups; the upsert-by-norad_id logic dedupes naturally.
  • Idempotency: CelesTrak returns HTTP 403 with body "GP data has not updated since…" when you re-fetch a group it considers unchanged. We treat that as a polite skip — group counted but no records processed. INSERT OR IGNORE on tle_history ensures re-ingesting the same TLE adds no row.
  • Cron: the schedule lands on prod once you wire DreamHost cron to cd ~/sat.trackr.live && make ingest >> storage/logs/cron.log 2>&1 every 6 hours per docs/req_spec.md §23.
  • SATCAT preservation: CelesTrak's basic GP feed only carries name + intl designator + orbital elements. The upsert deliberately does not touch operator, country, mass, or other satellites-table columns on conflict, so the future SATCAT ingester can populate those without being clobbered.

Get started locally

Walks a contributor with PHP/JS basics — but zero project context — from a fresh git clone to a running local app + a non-empty globe. If anything in this section doesn't work as described, that's a bug in the README; please open an issue.

Prerequisites

Tool Min version Verify with Why
PHP 8.4 php -v The codebase uses readonly + property hooks heavily
PHP extensions php -m | grep -E 'pdo_sqlite|curl|gd|mbstring|json' pdo_sqlite (persistence), curl (Guzzle), gd (OG images), mbstring + json (typically built-in)
APCu recommended php -m | grep apcu Phase 10 chunk 1's state-now cache; falls back to a SQLite table if missing
Composer 2.x composer --version PHP dependency manager
Node 20+ node --version Vite build + bin/sgp4-passes.mjs SGP4 propagation
npm 10+ npm --version Ships with Node 20+
sqlite3 CLI 3.40+ sqlite3 --version Schema uses RETURNING clauses; useful for inspection
make any make --version Single user-facing entry point for every command
git any git --version Cloning + bin/fetch-marquee-models.sh

Verification: running every command in the right column above should print a version number, not an error. If any are missing, install them via your OS package manager (Debian/Ubuntu: apt, macOS: brew, etc.) before continuing.

Fresh-clone bootstrap

# 1. Clone the repo.
git clone git@github.com:CyberSecDef/sat.trackr.live.git
cd sat.trackr.live

# 2. Copy env templates. Real .env files are gitignored.
cp .env.example     .env       # base config (APP_ENV, APP_NAME, APP_URL, LOG_LEVEL)
cp .env.dev.example .env.dev   # dev overlay (DB_PATH, VITE_DEV_ORIGIN, optional API creds)

# 3. Install PHP + JS dependencies, then build the SPA bundle.
make install                   # composer install + npm install (~30 s)
make build                     # vite build → public/build/ (~5 s)

# 4. Create the SQLite schema (~14 migrations).
make migrate                   # idempotent; safe to re-run

# 5. Populate the catalog. The globe is empty without at least `make ingest`.
make ingest                    # CelesTrak GP — ~15.6K satellites, ~40 s (REQUIRED)
make ingest-satcat             # operator / country / launch-date enrichment (optional, ~30 s)
make ingest-ll2                # upcoming + recent launches (optional, ~4 s)
make ingest-socrates           # close-approach predictions (optional, ~12 s)
make ingest-swpc               # space-weather snapshot (optional, < 1 s)
make ingest-ovation            # aurora-forecast overlay raster (optional, < 1 s)
make ingest-satnogs            # amateur-radio transmitter catalog (optional)
make ingest-satnogs-stations   # amateur ground-station catalog (optional)
# `make ingest-spacetrack` needs SPACE_TRACK_USER + SPACE_TRACK_PASS in .env.dev.

Verification after step 5: make health should report row counts ≥ 15,000 for the satellites table; if it reports 0, ingest didn't run.

Run locally

Both dev modes bind to 0.0.0.0 so the app is reachable from your phone or another device on the same LAN.

make serve     # PHP server on 0.0.0.0:8000 — serves the production-built SPA from public/build/
# or
make dev       # PHP (0.0.0.0:8000) + Vite (0.0.0.0:5173) in parallel; HMR enabled

Open http://localhost:8000 on this machine, or http://<lan-ip>:8000 from another device. make (no args) prints the full target list and the detected LAN URL.

Override the bind address or ports on the command line:

make serve HOST=127.0.0.1          # loopback only
make serve PORT=9000               # different port
make dev   HOST=0.0.0.0 VITE_PORT=5174

When testing the Vite HMR client from another device, set VITE_DEV_ORIGIN="http://<your-lan-ip>:5173" in .env.dev so the injected HMR URL points at the right host.

Verification after make serve:

curl -s http://localhost:8000/api/v1/satellites?limit=1 | head -c 200
# → JSON envelope with at least one satellite row
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/text
# → 200
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/
# → 200 (SPA shell)

Browser smoke: load http://localhost:8000/, wait for the globe to render with ~15K cyan dots. Use the 🔍 search to look up ISS; the detail panel should open with NORAD 25544.


Environment variables

Every key referenced by EnvLoader::get() in PHP. The base .env file holds environment-agnostic values; .env.{dev,prod} overlays the environment-specific ones (DB paths, credentials, etc). Templates: .env.example, .env.dev.example, .env.prod.example. Real .env* files are gitignored.

Key Required? Default Which feature Where it's read
APP_ENV no dev Selects which overlay file (.env.dev or .env.prod) loads after .env src/App/EnvLoader.php, src/App/Container.php
APP_NAME no sat.trackr.live <title>, OG meta tags, footer credit src/App/Container.php
APP_URL recommended in prod http://localhost:8000 Absolute URLs in OG meta, sitemap, Atom feed entries src/App/Container.php, src/Http/Controllers/AtomEventsController.php
LOG_LEVEL no info Monolog verbosity (debug / info / notice / warning / error / critical) src/App/Container.php
VITE_DEV_ORIGIN no http://localhost:5173 Origin of the Vite dev server; injected into the HMR client URL during make dev src/App/Container.php
DB_PATH yes (in prod) data/sat.db SQLite database file location. Use an absolute path in prod src/App/Container.php
CESIUM_ION_TOKEN optional empty Cesium ion-hosted imagery + terrain. Empty falls back to OpenStreetMap src/App/Container.php
NODE_BINARY optional node Absolute path to the Node binary bin/sgp4-passes.mjs shells out to. Set when node isn't on the cron $PATH src/App/Container.php
SPACE_TRACK_USER optional empty Space-Track.org username for TIP decay-message ingest (Phase 2 chunk 4) src/App/Container.php
SPACE_TRACK_PASS optional empty Space-Track.org password — paired with SPACE_TRACK_USER src/App/Container.php
N2YO_API_KEY optional empty N2YO API key for pass-prediction visual-magnitude enrichment (Phase 4 chunk 7) src/App/Container.php
LL2_API_TOKEN optional empty Launch Library 2 paid-tier token. Free tier (15 req/hr) works without one src/App/Container.php
PLAUSIBLE_DOMAIN optional empty Cookieless Plausible analytics data-domain attribute. Empty omits the script entirely (Phase 8 chunk 6) src/App/Container.php
PLAUSIBLE_SCRIPT_URL optional https://plausible.io/js/script.js Plausible script source — change for self-hosted instances src/App/Container.php
INGEST_ALERT_WEBHOOK_URL optional empty Slack/Discord/Mattermost-compatible incoming webhook for TLE reject-rate alerts (Phase 10 chunk 2) src/App/Container.php
WEBHOOK_SECRET optional empty Bearer-token shared secret for POST /webhooks/ingest/{source}. Empty → endpoint returns 403 always (Phase 10 chunk 4) src/App/Container.php

Notes on "required":

  • DB_PATH is technically not required in dev (defaults to data/sat.db) but should always be set explicitly in prod to an absolute path so cron jobs find the right database.
  • Every "optional" key disables the corresponding feature gracefully when empty (no errors). For example, WEBHOOK_SECRET="" makes the push-based ingest endpoint return 403 on every request without crashing.
  • The Vite client at build time reads only import.meta.env.DEV (a Vite built-in indicating dev vs prod mode). No .env keys are exposed to client JS by design.

Verification: make health confirms APP_ENV loaded successfully + PHP extensions are present + the SQLite DB at DB_PATH is reachable. If the app boots without errors, EnvLoader::load() resolved your .env* chain.


Project layout

sat.trackr.live/
├── .env / .env.dev / .env.prod      gitignored; see .env*.example for shape
├── Makefile                         single user-facing entry point — run `make`
├── composer.json   php deps
├── package.json    js deps
├── vite.config.ts  Vite + vite-plugin-cesium
├── tsconfig.json   strict + experimentalDecorators (Lit)
├── docs/
│   └── phase1.md   Full Phase 1 design
├── public/                         ← Apache DocumentRoot
│   ├── index.php   Slim front controller
│   ├── .htaccess   SPA rewrites + cache headers
│   ├── favicon.svg
│   ├── robots.txt
│   ├── build/      Vite output (hashed JS/CSS) — gitignored
│   └── cesium/     Cesium runtime assets — gitignored
├── src/                            PHP, namespace SatTrackr\
│   ├── App/        EnvLoader, Container, Kernel
│   ├── Http/       Controllers + Middleware
│   └── Services/   ViteAssetResolver
├── resources/                      Frontend source
│   ├── js/         TypeScript: main.ts, App.ts, ui/, globe/, types/
│   ├── css/        main.css + themes/{dark,light,high-contrast}.css
│   └── views/      shell.php (SPA shell template)
├── data/           sat.db lives here (gitignored)
├── storage/        logs/, cache/ (gitignored)
└── tests/
    ├── Php/        PHPUnit
    └── Js/         Vitest

Makefile targets

Every operator-facing command goes through make. Run make (no args) to print the full list at any time; the same list appears below. The 1-line descriptions here mirror make help verbatim — tests/Php/Feature/ReadmeConsistencyTest.php asserts they stay in sync.

Setup + run

Target What it does
make help Show the full list (default target)
make install Install PHP and JS dependencies
make install-prod Install for production (no dev deps, optimized autoloader)
make clean Remove build artifacts, vendor dirs, caches
make dev Start PHP + Vite dev servers in parallel (Ctrl-C kills both)
make serve Start PHP server only (serves the production build from public/)
make build Production build of the SPA into public/build/
make build-skybox Regenerate BSC5 starfield cubemap into public/textures/skybox/ (Phase 3 chunk 1)

Database

Target What it does
make migrate Apply pending migrations
make rollback Roll back the most recent migration batch
make migrate-status Show applied vs pending migrations
make make-migration NAME=add_foo Generate a new migration skeleton

Ingest (data refresh)

Target What it does
make ingest Run CelesTrak GP ingester for all configured groups (TLE data)
make ingest-group GROUP=starlink Ingest a single CelesTrak group
make ingest-satcat Run CelesTrak SATCAT ingester (operator/country/launch_date/RCS/status enrichment)
make ingest-satcat-group GROUP=starlink Ingest a single SATCAT group
make ingest-ll2 Run Launch Library 2 ingester (MODE=upcoming|previous|both, default both)
make ingest-spacetrack Run Space-Track TIP ingester for predicted reentries (LIMIT=N, default 100)
make ingest-socrates Run SOCRATES conjunction ingester (HOURS=N TCA window, default 168)
make ingest-swpc Snapshot NOAA SWPC space-weather indicators (Phase 4 chunk 3)
make ingest-ovation Refresh the NOAA OVATION aurora-forecast raster (Phase 4 chunk 4)
make ingest-satnogs Refresh amateur-radio transmitter catalog from SatNOGS DB (Phase 5 chunk 1)
make ingest-satnogs-stations Refresh amateur ground-station catalog from SatNOGS Network (Phase 10 chunk 3)

Asset + cache maintenance

Target What it does
make openapi-dump Regenerate public/openapi.json from controller attributes (Phase 5 chunk 3)
make sitemap-build Regenerate sitemap.xml + chunked sitemap-{n}.xml from the catalog (Phase 5 chunk 5)
make fetch-models Pull marquee-satellite glTFs into public/models/ (Phase 5 chunk 7; idempotent, ~45 MB total)
make pass-cache-prune Sweep expired rows from pass_cache (Phase 2 chunk 6)
make sky-cache-prune Sweep stale sky-view pass-cache rows — alias of pass-cache-prune (Phase 7 chunk 4B)

Quality gate

Target What it does
make health Run the health CLI command (DB ping, row counts, last ingest)
make lint / make lint-fix Run all linters (PHP-CS-Fixer + ESLint) / auto-fix all linters
make lint-php / make lint-js PHP-CS-Fixer in dry-run mode / ESLint over resources/js
make analyze PHPStan static analysis
make typecheck TypeScript typecheck (no emit)
make test Run all tests (PHPUnit + Vitest)
make test-php / make test-js PHPUnit only / Vitest only
make test-e2e Playwright smoke specs (needs npx playwright install chromium once)
make ci Full quality gate (lint + analyze + typecheck + test + advisory security-audit)
make deploy-check Sanity-check deploy prerequisites (env files, .htaccess, build artifacts)
make security-audit-dump Re-run all 5 security tools and print a verbose summary (always exits 0)
make security-audit Audit gate: exit non-zero if any finding ≥ moderate (composer + npm + Dependabot)
make security-audit-advisory Advisory variant: runs the gate but never returns non-zero — used by make ci during the 30-day rollout per docs/phase11.md § II row 6

Servers bind to 0.0.0.0 so the app is reachable from other devices on the LAN. Override with make serve HOST=127.0.0.1 if you want loopback-only.


Tech stack

  • PHP 8.4 + Slim Framework 4 + PHP-DI 7 + Eloquent (illuminate/database 11) + illuminate/console 11 for migrations + Monolog 3 + Guzzle 7 + vlucas/phpdotenv 5
  • SQLite (file-based, via pdo_sqlite)
  • Lit 3 (UI as LitElement custom elements with sat- prefix) + Cesium.js 1.121 + satellite.js (SGP4 in a Web Worker, browser-side)
  • Vite 8 + TypeScript 5.6 (strict) + ESLint 9 (flat config) + typescript-eslint + Prettier 3 + Vitest 4
  • PHPUnit 11, PHPStan level 6, PHP-CS-Fixer 3 (PSR-12 + declare(strict_types=1))

Production deployment

👉 The authoritative deployment doc is docs/deploy.md. It walks two production paths end-to-end:

  1. DreamHost VPS (Apache + cron) — the original prod target. Apache vhost config, PHP-FPM selection, document-root pointing, cron entries with MAILTO, log rotation.
  2. Fly.io (single machine + scheduled cron) — Phase 10 alternative. Sample fly.toml, Dockerfile, .fly.dockerignore, troubleshooting matrix.

Pick one. The same .env.prod works on both; the runtime shape (Apache PHP module vs Fly machine PHP-FPM) is what differs.

Quick reference (DreamHost VPS)

If you've deployed this before and just need the cheat sheet:

# On the VPS, as a non-root user:
ssh user@example.com
cd ~/example.com           # or wherever the domain's docroot lives
git clone https://github.com/CyberSecDef/sat.trackr.live.git app
cd app

# 1. Install (no dev deps).
composer install --no-dev --optimize-autoloader
npm ci && npm run build

# 2. Configure: copy templates, edit .env + .env.prod with real values.
cp .env.example .env                 # set APP_ENV=prod
cp .env.prod.example .env.prod       # fill in DB_PATH, CESIUM_ION_TOKEN, creds

# 3. Schema + seed.
mkdir -p data storage/logs storage/cache
make migrate
make ingest                          # ~40 s — globe is empty without this

# 4. Apache: point DocumentRoot at <repo>/app/public/. .htaccess handles
#    SPA rewrites + cache headers + security headers.

# 5. Cron (drop the canonical block from docs/deploy.md "Step 5 — cron").
crontab -e

Verification checklist (run on the prod host after step 5):

curl -s -o /dev/null -w "%{http_code}\n" https://example.com/                    # → 200
curl -s https://example.com/api/v1/satellites?limit=1 | head -c 200              # → JSON envelope
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/text                # → 200
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/events.atom         # → 200
make health                                                                       # → all rows OK

If any check fails, dig into docs/deploy.md § "Troubleshooting" — every failure mode seen during Phase 10 chunk 4's verification pass is documented there.

Cron schedule (canonical)

The full cron block lives in docs/deploy.md "Step 5 — cron". Excerpt of the most important entries:

MAILTO=ops@example.com
0 */6 * * *   cd ~/example.com/app && make ingest               >> storage/logs/cron.log 2>&1
0 4 * * *     cd ~/example.com/app && make ingest-satcat        >> storage/logs/cron.log 2>&1
0 * * * *     cd ~/example.com/app && make ingest-ll2 MODE=upcoming   >> storage/logs/cron.log 2>&1
0 */12 * * *  cd ~/example.com/app && make ingest-spacetrack    >> storage/logs/cron.log 2>&1
*/5 * * * *   cd ~/example.com/app && make ingest-swpc          >> storage/logs/cron.log 2>&1
30 4 * * *    cd ~/example.com/app && make pass-cache-prune     >> storage/logs/cron.log 2>&1

Operator-driven verification

Per docs/phase11.md § II row 8, a teammate (or yourself on a fresh DreamHost VPS) is expected to follow this README + docs/deploy.md literally on a clean instance and report back what didn't work. The verifier's name + date appears at the bottom of docs/deploy.md once that run is captured.


Contributing

This project is AGPL-3.0-or-later. If you run a modified version on a public service, you must offer the modified source to your users.

Issues and PRs welcome at https://github.com/CyberSecDef/sat.trackr.live.


License

AGPL-3.0-or-later