wave2 phase 5: polish pack (military, ground tracks, URL state, screenshots)

1. Military callsigns (aircraft.js): callsigns matching CONFIG.aircraft.
   militaryPrefixes tint red regardless of altitude band and bump larger. New
   "Military filter" HUD row (default off) shows only military aircraft when on;
   status "N military of M". Pick overlay shows Military/Civil class.
2. Satellite ground track (satellites.js): selecting a satellite draws its
   sub-satellite path (height 0, ±half orbit around now) as a dashed polyline in
   the sat's colour; deselect hides it; one track at a time. Recomputed on
   demand from the satrec (no upfront precompute of 97 tracks).
3. Shareable URL state (main.js + ui.js): camera / mode / layer toggles / clock
   offset serialize to location.hash (replaceState, 1 s cadence, only on change)
   and are restored on boot; malformed hashes ignored silently.
4. Screenshot endpoint (serve.py): dev-only POST snap?name= writes a base64 PNG
   body to docs/<name>.png (name sanitized to [a-z0-9-], can't escape docs/).
   Captured docs/screenshot-data.png + screenshot-photo.png (render() called
   synchronously before toDataURL — the preserveDrawingBuffer pitfall).

README rewritten for Wave 2: quakes/fires layer rows (marked REAL), replay,
shared cache, shareable links, ground tracks, military, screenshots, updated
architecture + roadmap.

Verified in browser: 11 military of 6062 + filter; ground track 180 pts on
select / hidden on deselect; URL round-trips camera+mode+layers+clock; both
screenshots written as valid 1280x720 PNGs; subpath audit clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-13 16:53:05 +10:00
parent 767884146d
commit 2e061b2cdf
8 changed files with 259 additions and 19 deletions

View File

@ -4,6 +4,8 @@ A browser-based **3D/4D OSINT world view** — a CesiumJS digital-twin globe tha
Everything runs client-side from free, public, **unclassified** data. No API keys are required. No Cesium ion token. No build step.
![Photo Mode — Sentinel-2 imagery over the Persian Gulf with the recon-satellite fleet, live aircraft, quakes and fires](docs/screenshot-photo.png)
---
## Quickstart
@ -29,18 +31,24 @@ The HUD lists every layer with a live status line and an on/off toggle, plus a `
| Layer | Source | Live? | Notes |
|---|---|---|---|
| **Satellites** (~97) | [Celestrak](https://celestrak.org) GP/TLE | ✅ deterministic | SGP4-propagated orbits with glowing paths; EO/recon birds incl. WorldView, Pléiades, Capella, SkySat, plus Chinese **Gaofen** and Russian **Cosmos** (Persona) military satellites. Click one for orbital elements. |
| **Aircraft** | [OpenSky Network](https://opensky-network.org) ADS-B | ✅ live | Thousands of live flights, colored by altitude band; click one for callsign/country/altitude/speed/heading. |
| **Aircraft** | [OpenSky Network](https://opensky-network.org) ADS-B | ✅ live | Thousands of live flights, colored by altitude band; **military callsigns highlighted** and filterable; click one for callsign/country/altitude/speed/heading. Scrub off-live and it **replays recorded traffic** (see below). |
| **Earthquakes** | [USGS](https://earthquake.usgs.gov) GeoJSON | ✅ real | Last 24 h of quakes, time-anchored so each **appears as the slider crosses its origin time**; magnitude ramps amber→red, recent quakes pulse, M≥4.5 labeled. Click one for magnitude/depth/time and the USGS event link. |
| **Wildfires** | [NASA EONET](https://eonet.gsfc.nasa.gov) | ✅ real | Active wildfire events worldwide as flame glyphs; click one for the event details and EONET link. |
| **Ships** `[DEMO]` | synthetic | — | Illustrative tanker traffic through the Strait of Hormuz, incl. a "toll-route" carrier, a **dark-vessel AIS gap**, and the Fujairah idle anchorage. Optional live AIS — see below. |
| **Infrastructure** | static vectors | — | Choke points (Hormuz, Bab el-Mandeb, Suez, Malacca), the EastWest Petroline & HabshanFujairah pipelines, and key oil/desal/base facilities. Coordinates approximate. |
| **Events** `[DEMO]` | synthetic | — | Time-anchored markers that appear as the slider crosses their moment — illustrative placeholders for a future real event feed. |
Select a satellite and its **sub-satellite ground track** draws under it. In **Data Mode**, the whole picture reads as orbits + markers on a dark globe:
![Data Mode — satellite orbit paths, the recon fleet, quakes and event markers](docs/screenshot-data.png)
### Basemaps
- **Data Mode:** © OpenStreetMap contributors © CARTO (dark_all raster tiles).
- **Photo Mode:** Sentinel-2 cloudless by [EOX](https://s2maps.eu) — modified Copernicus Sentinel data 2023.
## What is real vs. DEMO
**Real, live, public data:** satellites (Celestrak TLEs) and aircraft (OpenSky ADS-B). Satellite positions are computed live via SGP4; aircraft are polled from OpenSky.
**Real, live, public data:** satellites (Celestrak TLEs), aircraft (OpenSky ADS-B), **earthquakes** (USGS), and **wildfires** (NASA EONET). Satellite positions are computed live via SGP4; aircraft are polled from OpenSky; quakes and fires are fetched directly from their public feeds.
**Synthetic `[DEMO]` data:** ships and events. These are hand-authored to illustrate the *kind* of analysis the platform is built for (dark-vessel detection, choke-point pressure, strike/pass correlation) without claiming to depict a real event. Every demo entity is labeled `[DEMO]` in its name, the HUD, and its info panel.
@ -56,6 +64,28 @@ AISSTREAM_API_KEY: 'your-key-here',
On the first live position report the demo fleet hides and real vessels stream in over a WebSocket (no proxy needed).
## Time-travel: aircraft replay
Orbits, demo ships, quakes and events are all time-aware, so scrubbing the timeline "just works" for them. Live aircraft are different — you can't compute where a plane *was* an hour ago. So an optional local recorder, **`record.py`**, snapshots live traffic every 3 minutes into a small SQLite db (`data/history.db`, gitignored, 72 h retention). Scrub the clock off wall-clock-now and the aircraft layer fetches the nearest recorded snapshot and **replays the actual traffic** for that moment — the aircraft status turns amber (`replay · <time> · N aircraft`). Where no recorder ran (e.g. production), the history endpoint returns 404 and the layer simply falls back to hiding aircraft when scrubbed.
```sh
python3 record.py # run alongside serve.py; heartbeat at ~/.jobs/godsigh-recorder.status
```
The recorder fetches through the same proxy the app uses, so it shares the OpenSky quota rather than competing for it (see below).
## Shared OpenSky cache
`serve.py` caches the global OpenSky `/states/all` response at `~/.cache/godverse/opensky-states.json` for 120 s. Any global request within that window is served from cache (`X-Godsigh-Cache: hit`) instead of spending quota, and on a 429 the last-known snapshot is served stale (`X-Godsigh-Cache: stale`) so the app keeps working through quota exhaustion. This lets GODSIGH, its recorder, and the sibling **godstrument** project all poll from one machine without racing into the small anonymous daily quota.
## Shareable links
The current view is continuously serialized into the URL hash — camera, render mode, which layers are on, and the timeline position. Copy the URL and share it, and it reopens at exactly that camera, mode, layer set, and clock offset. Example:
```
#c=53.000,25.500,2800000,0.000,-90.000&m=d&L=satellites,sat-paths,infra,events,quakes,fires,ships,aircraft&t=0
```
## Architecture
No framework, no bundler — vanilla ES modules plus two CDN globals (CesiumJS, satellite.js).
@ -66,9 +96,10 @@ css/style.css dark HUD styling
js/config.js all URLs, intervals, camera, colors, keys — one file
js/lib.js shared helpers (glyphs, bearing, color bands)
js/ui.js HUD: layer rows, status lines, LIVE/SCRUBBED chip
js/main.js viewer bootstrap, basemaps, clock, gating, picking, loader
js/layers/*.js one module per layer (satellites, aircraft, ships, infra, events)
serve.py dev server + same-origin proxy (proxy/opensky, proxy/celestrak)
js/main.js viewer bootstrap, basemaps, clock, gating, picking, loader, URL-state sync
js/layers/*.js one module per layer (satellites, aircraft, ships, infra, events, quakes, fires)
serve.py dev server + same-origin proxy, shared OpenSky cache, history + snap endpoints
record.py optional dev daemon: snapshots live aircraft into data/history.db for replay
```
**Why the proxy?** OpenSky pins its CORS header to its own domain, so a browser can't fetch it cross-origin. `serve.py` (dev) and nginx (prod) forward `proxy/opensky` and `proxy/celestrak` from the same origin. Every URL in the app is **relative**, so it works unchanged at `/` and at `/godsigh/`.
@ -78,8 +109,8 @@ serve.py dev server + same-origin proxy (proxy/opensky, proxy/celes
## Roadmap
- **Live AIS** by default (aisstream.io key) — implemented, opt-in today.
- **Historical recording** — implemented locally via `record.py` (see *Time-travel* above); a VPS-side recorder for replay in production is a future step.
- **GPS-jamming heatmap** from [gpsjam.org](https://gpsjam.org) daily H3 tiles.
- **Historical recording** — a cron job appending OpenSky/AIS snapshots to JSONL for true time-travel beyond the live ±6h window.
- **Classified-sat amateur TLEs** (community "classfd" catalogs) for birds Celestrak omits.
- **Economic side panel** — Brent / WTI oil futures synced to the timeline.
- **Cesium ion terrain & photorealistic 3D tiles** (needs a token).

BIN
docs/screenshot-data.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
docs/screenshot-photo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -31,6 +31,22 @@ export default function create(ctx) {
if (on && isLive) kick();
});
// Military-callsign filter (default off): when on, only military aircraft
// render. Military callsigns are always tinted, filter on or off.
let militaryOnly = false;
let lastPlanes = [];
ui.addLayer('aircraft-mil', 'Military filter', false, (on) => {
militaryOnly = on;
renderPlanes(lastPlanes); // instant re-render of the current frame's planes
});
const MIL_PREFIXES = CONFIG.aircraft.militaryPrefixes || [];
function isMilitary(callsign) {
if (!callsign) return false;
const cs = callsign.toUpperCase();
return MIL_PREFIXES.some((p) => cs.startsWith(p));
}
// ---- normalized plane shape: {icao24, callsign, country, lon, lat, alt, vel, track} ----
function stateToPlane(s) {
return {
@ -47,17 +63,27 @@ export default function create(ctx) {
}
// Factored build loop — rebuilds the whole collection from a plane array.
// Shared by the live poll and by replay. Returns the feed total (the aircraft
// row shows the total; the Military-filter row shows "N military of M"), so
// the aircraft count stays stable whether or not the filter is engaged.
function renderPlanes(planes) {
lastPlanes = planes; // remembered so toggling the filter re-renders instantly
bc.removeAll();
let count = 0;
let total = 0;
let mil = 0;
for (const p of planes) {
if (p.lon == null || p.lat == null) continue;
total++;
const military = isMilitary(p.callsign);
if (military) mil++;
if (militaryOnly && !military) continue;
bc.add({
position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt || 0),
image: glyph,
color: lib.cz(lib.altitudeColor(CONFIG, p.alt)),
// Military callsigns tint red regardless of altitude band; civil use the band.
color: lib.cz(military ? CONFIG.colors.aircraftMil : lib.altitudeColor(CONFIG, p.alt)),
rotation: lib.headingToRotation(p.track), // lib negates — do NOT negate again
scale: 0.55,
scale: military ? 0.72 : 0.55, // bump military so it stands out
id: {
layer: 'aircraft',
icao24: p.icao24,
@ -66,11 +92,12 @@ export default function create(ctx) {
alt: p.alt,
vel: p.vel,
track: p.track,
military,
},
});
count++;
}
return count;
ui.setStatus('aircraft-mil', `${mil} military of ${total}`, militaryOnly ? 'ok' : 'off');
return total;
}
// ---- live poll loop -------------------------------------------------------
@ -228,7 +255,8 @@ export default function create(ctx) {
function handlePick(picked) {
if (picked?.id?.layer !== 'aircraft') return false;
const id = picked.id;
ui.showPickOverlay('✈ ' + (id.callsign || id.icao24), [
ui.showPickOverlay((id.military ? '✈⬤ ' : '✈ ') + (id.callsign || id.icao24), [
['Class', id.military ? 'Military (callsign)' : 'Civil'],
['Country', id.country || '—'],
['Altitude', `${Math.round(id.alt || 0)} m / ${Math.round((id.alt || 0) * 3.28084)} ft`],
['Speed', `${Math.round((id.vel || 0) * 1.94384)} kt`],

View File

@ -35,6 +35,50 @@ export default function create(ctx) {
ui.setStatus('satellites', 'loading TLEs…', 'warn');
ui.setStatus('sat-paths', '', 'ok');
// ---- ground track on selection (Wave 2 polish) ----
// One reusable dashed polyline showing the selected sat's sub-satellite path
// (height 0) over ±half an orbit around now. Recomputed on demand from the
// satrec so we don't precompute 97 tracks up front.
const satMeta = new Map(); // entity.id → { satrec, hex, periodSec }
const groundTrack = ds.entities.add({
id: 'sat-ground-track',
show: false,
polyline: {
positions: [],
width: 1.5,
material: new Cesium.PolylineDashMaterialProperty({ color: Cesium.Color.WHITE }),
},
});
function groundTrackPositions(satrec, periodSec) {
const positions = [];
const half = periodSec / 2;
const step = Math.max(20, periodSec / 180); // ~one orbit in ~180 samples
const nowJd = Cesium.JulianDate.now();
for (let dt = -half; dt <= half; dt += step) {
const date = Cesium.JulianDate.toDate(
Cesium.JulianDate.addSeconds(nowJd, dt, new Cesium.JulianDate()));
let pv;
try { pv = satellite.propagate(satrec, date); } catch { pv = null; }
if (!pv || !pv.position) continue;
const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(date));
if (!isFinite(geo.longitude) || !isFinite(geo.latitude)) continue;
// Cartesian3 positions → the 3D polyline hugs the globe, no antimeridian streak.
positions.push(Cesium.Cartesian3.fromRadians(geo.longitude, geo.latitude, 0));
}
return positions;
}
// selectedEntityChanged fires with undefined on deselect — handle it.
viewer.selectedEntityChanged.addEventListener((sel) => {
const meta = sel && satMeta.get(sel.id);
if (!meta) { groundTrack.show = false; return; }
groundTrack.polyline.positions = groundTrackPositions(meta.satrec, meta.periodSec);
groundTrack.polyline.material =
new Cesium.PolylineDashMaterialProperty({ color: lib.cz(meta.hex) });
groundTrack.show = true;
});
// --- Parse raw TLE text into {name, l1, l2} triplets. ---
function parseTLE(text) {
const lines = text.split(/\r?\n/).map((s) => s.replace(/\s+$/, '')).filter((s) => s.length);
@ -191,7 +235,7 @@ export default function create(ctx) {
`<tr><th>Perigee</th><td>${perigee.toFixed(0)} km</td></tr>` +
`</tbody></table>`;
return new Cesium.Entity({
const entity = new Cesium.Entity({
name,
position: posProp,
point: {
@ -220,6 +264,9 @@ export default function create(ctx) {
},
description,
});
// Stash what the ground-track overlay needs to recompute on selection.
satMeta.set(entity.id, { satrec, hex, periodSec });
return entity;
}
run().catch((err) => {

View File

@ -56,8 +56,10 @@ const photoLayer = imagery.addImageryProvider(new Cesium.UrlTemplateImageryProvi
}));
photoLayer.show = false;
let currentMode = 'data';
function setMode(mode) {
const photo = mode === 'photo';
currentMode = photo ? 'photo' : 'data';
photoLayer.show = photo;
darkLayer.show = !photo;
// Data Mode = flat high-contrast asset map (always readable).
@ -78,10 +80,82 @@ viewer.clock.clockRange = Cesium.ClockRange.CLAMPED;
viewer.clock.multiplier = 1;
viewer.timeline.zoomTo(start, stop);
// ---- camera opens on the Gulf ----
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(CONFIG.camera.lon, CONFIG.camera.lat, CONFIG.camera.height),
});
// ---- shareable URL state (#c=lon,lat,height,heading,pitch&m=d|p&L=on,ids&t=offsetSec) ----
function clampToWindow(jd) {
if (Cesium.JulianDate.lessThan(jd, start)) return start.clone();
if (Cesium.JulianDate.greaterThan(jd, stop)) return stop.clone();
return jd;
}
let pendingLayerList = null; // ON-layer ids from the hash, applied after layers load
function applyHashState() {
const state = { cameraApplied: false, modeApplied: false };
const raw = location.hash.replace(/^#/, '');
if (!raw) return state;
const params = new URLSearchParams(raw);
const c = params.get('c');
if (c) {
const p = c.split(',').map(Number);
if (p.length === 5 && p.every(Number.isFinite)) {
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(p[0], p[1], p[2]),
orientation: { heading: Cesium.Math.toRadians(p[3]), pitch: Cesium.Math.toRadians(p[4]), roll: 0 },
});
state.cameraApplied = true;
}
}
const m = params.get('m');
if (m === 'p') { setMode('photo'); state.modeApplied = true; }
else if (m === 'd') { setMode('data'); state.modeApplied = true; }
const t = params.get('t');
if (t !== null && t !== '' && Number.isFinite(Number(t))) {
viewer.clock.currentTime = clampToWindow(
Cesium.JulianDate.addSeconds(now, Number(t), new Cesium.JulianDate()));
}
if (params.has('L')) pendingLayerList = params.get('L');
return state;
}
// Parse once at boot; malformed hashes are ignored silently.
let hashState = { cameraApplied: false, modeApplied: false };
try { hashState = applyHashState(); } catch (err) { /* ignore malformed hash */ }
// Default camera opens on the Gulf — only when the hash didn't provide one.
if (!hashState.cameraApplied) {
viewer.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(CONFIG.camera.lon, CONFIG.camera.lat, CONFIG.camera.height),
});
}
function serializeState() {
const cam = viewer.camera;
const carto = cam.positionCartographic;
const lon = Cesium.Math.toDegrees(carto.longitude).toFixed(3);
const lat = Cesium.Math.toDegrees(carto.latitude).toFixed(3);
const height = Math.round(carto.height);
const heading = (((Cesium.Math.toDegrees(cam.heading) % 360) + 360) % 360).toFixed(3);
const pitch = Cesium.Math.toDegrees(cam.pitch).toFixed(3);
const mode = currentMode === 'photo' ? 'p' : 'd';
const onLayers = ui.getLayerIds().filter((id) => ui.getLayerChecked(id));
const curMs = Cesium.JulianDate.toDate(viewer.clock.currentTime).getTime();
const offset = Math.round((curMs - Date.now()) / 1000);
const t = Math.abs(offset) <= CONFIG.liveThresholdSec ? 0 : offset; // 0 = live
return `#c=${lon},${lat},${height},${heading},${pitch}&m=${mode}&L=${onLayers.join(',')}&t=${t}`;
}
function startHashSync() {
// Poll once a second; rewrite only when the serialized state actually changed
// (replaceState keeps it out of history). Covers camera, mode, layers, scrub.
setInterval(() => {
const hash = serializeState();
if (hash !== location.hash) history.replaceState(null, '', hash);
}, 1000);
}
// ---- context handed to every layer factory ----
const ctx = { viewer, CONFIG, lib, ui, Cesium, start, stop, now };
@ -145,8 +219,15 @@ clickHandler.setInputAction((movement) => {
}
}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
setMode('data');
if (!hashState.modeApplied) setMode('data');
ui.setLiveChip(true);
loadLayers();
loadLayers().then(() => {
// Restore layer on/off from the hash once every layer has registered its row.
if (pendingLayerList !== null) {
const wanted = new Set(pendingLayerList.split(',').filter(Boolean));
for (const id of ui.getLayerIds()) ui.setLayerChecked(id, wanted.has(id));
}
startHashSync();
});
window.__godsigh = { viewer, ctx, activeLayers }; // debug handle

View File

@ -21,6 +21,29 @@ export function addLayer(id, name, defaultOn, onToggle) {
return { setChecked: (v) => { cb.checked = v; } };
}
// Registered layer ids, in registration order (for URL-state serialization).
export function getLayerIds() {
return [...rows.keys()];
}
// Is a layer's checkbox currently checked?
export function getLayerChecked(id) {
const row = rows.get(id);
return row ? row.querySelector('input').checked : false;
}
// Set a layer's checkbox AND fire its onToggle (replays a real user toggle),
// so restoring URL state actually shows/hides the layer. No-op if unchanged.
export function setLayerChecked(id, on) {
const row = rows.get(id);
if (!row) return;
const cb = row.querySelector('input');
if (cb.checked !== on) {
cb.checked = on;
cb.dispatchEvent(new Event('change'));
}
}
// state ∈ 'ok' | 'warn' | 'err' | 'off'
export function setStatus(id, text, state = 'ok') {
const row = rows.get(id);

View File

@ -6,8 +6,10 @@ lets GODSIGH and its sibling project godstrument poll OpenSky from the same IP
without racing each other into the anonymous daily quota. The contract is
mirrored verbatim in godstrument's brief — keep the two in sync.
"""
import base64
import json
import os
import re
import sqlite3
import sys
import tempfile
@ -173,6 +175,34 @@ class Handler(SimpleHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def do_POST(self):
# Dev-only screenshot sink: POST snap?name=<name> with a base64 PNG (or a
# full data:image/png;base64,... URL) body → writes docs/<name>.png. The
# server binds 127.0.0.1 only, and the name is sanitized to [a-z0-9-] so
# the write can never escape docs/. serve.py is never deployed.
if not (self.path == "/snap" or self.path.startswith("/snap?")):
return self.send_error(404, "not found")
_, _, query = self.path.partition("?")
raw_name = (urllib.parse.parse_qs(query).get("name") or ["screenshot"])[0]
name = re.sub(r"[^a-z0-9-]", "", raw_name.lower()) or "screenshot"
length = int(self.headers.get("Content-Length") or 0)
text = self.rfile.read(length).decode("utf-8", "replace") if length else ""
if text.strip().startswith("data:") and "," in text:
text = text.split(",", 1)[1]
try:
png = base64.b64decode(text, validate=False)
except Exception:
return self._send_json(400, {"error": "body must be base64 PNG (optionally a data: URL)"})
if not png:
return self._send_json(400, {"error": "empty image"})
docs = (Path(__file__).resolve().parent / "docs")
docs.mkdir(parents=True, exist_ok=True)
out = (docs / f"{name}.png").resolve()
if out.parent != docs.resolve():
return self._send_json(400, {"error": "invalid name"})
out.write_bytes(png)
return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)})
def _send_json(self, status, obj, extra=None):
self._send(status, "application/json", json.dumps(obj).encode(), extra)