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:
parent
767884146d
commit
2e061b2cdf
43
README.md
43
README.md
@ -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.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 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 East–West Petroline & Habshan–Fujairah 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:
|
||||
|
||||

|
||||
|
||||
### 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
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
BIN
docs/screenshot-photo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@ -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`],
|
||||
|
||||
@ -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) => {
|
||||
|
||||
93
js/main.js
93
js/main.js
@ -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
|
||||
|
||||
23
js/ui.js
23
js/ui.js
@ -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);
|
||||
|
||||
30
serve.py
30
serve.py
@ -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)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user