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. 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 ## 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 | | 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. | | **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. | | **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. | | **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. | | **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 ### Basemaps
- **Data Mode:** © OpenStreetMap contributors © CARTO (dark_all raster tiles). - **Data Mode:** © OpenStreetMap contributors © CARTO (dark_all raster tiles).
- **Photo Mode:** Sentinel-2 cloudless by [EOX](https://s2maps.eu) — modified Copernicus Sentinel data 2023. - **Photo Mode:** Sentinel-2 cloudless by [EOX](https://s2maps.eu) — modified Copernicus Sentinel data 2023.
## What is real vs. DEMO ## 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. **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). 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 ## Architecture
No framework, no bundler — vanilla ES modules plus two CDN globals (CesiumJS, satellite.js). 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/config.js all URLs, intervals, camera, colors, keys — one file
js/lib.js shared helpers (glyphs, bearing, color bands) js/lib.js shared helpers (glyphs, bearing, color bands)
js/ui.js HUD: layer rows, status lines, LIVE/SCRUBBED chip js/ui.js HUD: layer rows, status lines, LIVE/SCRUBBED chip
js/main.js viewer bootstrap, basemaps, clock, gating, picking, loader 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) js/layers/*.js one module per layer (satellites, aircraft, ships, infra, events, quakes, fires)
serve.py dev server + same-origin proxy (proxy/opensky, proxy/celestrak) 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/`. **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 ## Roadmap
- **Live AIS** by default (aisstream.io key) — implemented, opt-in today. - **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. - **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. - **Classified-sat amateur TLEs** (community "classfd" catalogs) for birds Celestrak omits.
- **Economic side panel** — Brent / WTI oil futures synced to the timeline. - **Economic side panel** — Brent / WTI oil futures synced to the timeline.
- **Cesium ion terrain & photorealistic 3D tiles** (needs a token). - **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(); 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} ---- // ---- normalized plane shape: {icao24, callsign, country, lon, lat, alt, vel, track} ----
function stateToPlane(s) { function stateToPlane(s) {
return { return {
@ -47,17 +63,27 @@ export default function create(ctx) {
} }
// Factored build loop — rebuilds the whole collection from a plane array. // 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) { function renderPlanes(planes) {
lastPlanes = planes; // remembered so toggling the filter re-renders instantly
bc.removeAll(); bc.removeAll();
let count = 0; let total = 0;
let mil = 0;
for (const p of planes) { for (const p of planes) {
if (p.lon == null || p.lat == null) continue; if (p.lon == null || p.lat == null) continue;
total++;
const military = isMilitary(p.callsign);
if (military) mil++;
if (militaryOnly && !military) continue;
bc.add({ bc.add({
position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt || 0), position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.alt || 0),
image: glyph, 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 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: { id: {
layer: 'aircraft', layer: 'aircraft',
icao24: p.icao24, icao24: p.icao24,
@ -66,11 +92,12 @@ export default function create(ctx) {
alt: p.alt, alt: p.alt,
vel: p.vel, vel: p.vel,
track: p.track, track: p.track,
military,
}, },
}); });
count++;
} }
return count; ui.setStatus('aircraft-mil', `${mil} military of ${total}`, militaryOnly ? 'ok' : 'off');
return total;
} }
// ---- live poll loop ------------------------------------------------------- // ---- live poll loop -------------------------------------------------------
@ -228,7 +255,8 @@ export default function create(ctx) {
function handlePick(picked) { function handlePick(picked) {
if (picked?.id?.layer !== 'aircraft') return false; if (picked?.id?.layer !== 'aircraft') return false;
const id = picked.id; 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 || '—'], ['Country', id.country || '—'],
['Altitude', `${Math.round(id.alt || 0)} m / ${Math.round((id.alt || 0) * 3.28084)} ft`], ['Altitude', `${Math.round(id.alt || 0)} m / ${Math.round((id.alt || 0) * 3.28084)} ft`],
['Speed', `${Math.round((id.vel || 0) * 1.94384)} kt`], ['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('satellites', 'loading TLEs…', 'warn');
ui.setStatus('sat-paths', '', 'ok'); 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. --- // --- Parse raw TLE text into {name, l1, l2} triplets. ---
function parseTLE(text) { function parseTLE(text) {
const lines = text.split(/\r?\n/).map((s) => s.replace(/\s+$/, '')).filter((s) => s.length); 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>` + `<tr><th>Perigee</th><td>${perigee.toFixed(0)} km</td></tr>` +
`</tbody></table>`; `</tbody></table>`;
return new Cesium.Entity({ const entity = new Cesium.Entity({
name, name,
position: posProp, position: posProp,
point: { point: {
@ -220,6 +264,9 @@ export default function create(ctx) {
}, },
description, description,
}); });
// Stash what the ground-track overlay needs to recompute on selection.
satMeta.set(entity.id, { satrec, hex, periodSec });
return entity;
} }
run().catch((err) => { run().catch((err) => {

View File

@ -56,8 +56,10 @@ const photoLayer = imagery.addImageryProvider(new Cesium.UrlTemplateImageryProvi
})); }));
photoLayer.show = false; photoLayer.show = false;
let currentMode = 'data';
function setMode(mode) { function setMode(mode) {
const photo = mode === 'photo'; const photo = mode === 'photo';
currentMode = photo ? 'photo' : 'data';
photoLayer.show = photo; photoLayer.show = photo;
darkLayer.show = !photo; darkLayer.show = !photo;
// Data Mode = flat high-contrast asset map (always readable). // Data Mode = flat high-contrast asset map (always readable).
@ -78,10 +80,82 @@ viewer.clock.clockRange = Cesium.ClockRange.CLAMPED;
viewer.clock.multiplier = 1; viewer.clock.multiplier = 1;
viewer.timeline.zoomTo(start, stop); viewer.timeline.zoomTo(start, stop);
// ---- camera opens on the Gulf ---- // ---- shareable URL state (#c=lon,lat,height,heading,pitch&m=d|p&L=on,ids&t=offsetSec) ----
viewer.camera.setView({ function clampToWindow(jd) {
destination: Cesium.Cartesian3.fromDegrees(CONFIG.camera.lon, CONFIG.camera.lat, CONFIG.camera.height), 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 ---- // ---- context handed to every layer factory ----
const ctx = { viewer, CONFIG, lib, ui, Cesium, start, stop, now }; const ctx = { viewer, CONFIG, lib, ui, Cesium, start, stop, now };
@ -145,8 +219,15 @@ clickHandler.setInputAction((movement) => {
} }
}, Cesium.ScreenSpaceEventType.LEFT_CLICK); }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
setMode('data'); if (!hashState.modeApplied) setMode('data');
ui.setLiveChip(true); 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 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; } }; 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' // state ∈ 'ok' | 'warn' | 'err' | 'off'
export function setStatus(id, text, state = 'ok') { export function setStatus(id, text, state = 'ok') {
const row = rows.get(id); 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 without racing each other into the anonymous daily quota. The contract is
mirrored verbatim in godstrument's brief — keep the two in sync. mirrored verbatim in godstrument's brief — keep the two in sync.
""" """
import base64
import json import json
import os import os
import re
import sqlite3 import sqlite3
import sys import sys
import tempfile import tempfile
@ -173,6 +175,34 @@ class Handler(SimpleHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) 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): def _send_json(self, status, obj, extra=None):
self._send(status, "application/json", json.dumps(obj).encode(), extra) self._send(status, "application/json", json.dumps(obj).encode(), extra)