diff --git a/css/style.css b/css/style.css
new file mode 100644
index 0000000..0809380
--- /dev/null
+++ b/css/style.css
@@ -0,0 +1,214 @@
+:root {
+ --panel: rgba(10, 14, 18, 0.82);
+ --panel-solid: rgba(10, 14, 18, 0.95);
+ --border: rgba(255, 255, 255, 0.08);
+ --accent: #35e0ff;
+ --text: #d6e2ea;
+ --dim: #7c8b97;
+ --ok: #46e08a;
+ --warn: #ffcf50;
+ --err: #ff5964;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ height: 100%;
+ overflow: hidden;
+ background: #05080b;
+ color: var(--text);
+ font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
+}
+
+#cesiumContainer {
+ position: absolute;
+ inset: 0;
+}
+
+/* ---- HUD ---- */
+#hud {
+ position: absolute;
+ top: 14px;
+ left: 14px;
+ width: 288px;
+ max-height: calc(100vh - 28px);
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 14px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ z-index: 10;
+ font-size: 12px;
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
+}
+
+#hud header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.brand {
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ font-size: 14px;
+ color: #fff;
+}
+.brand span {
+ color: var(--accent);
+ font-weight: 500;
+ font-size: 11px;
+}
+
+#live-chip {
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.5px;
+ padding: 3px 8px;
+ border-radius: 999px;
+ white-space: nowrap;
+ border: 1px solid transparent;
+}
+#live-chip[data-state="live"] {
+ color: var(--ok);
+ border-color: rgba(70, 224, 138, 0.4);
+ background: rgba(70, 224, 138, 0.1);
+}
+#live-chip[data-state="scrub"] {
+ color: var(--warn);
+ border-color: rgba(255, 207, 80, 0.4);
+ background: rgba(255, 207, 80, 0.1);
+ font-size: 9px;
+}
+
+.modes {
+ display: flex;
+ gap: 6px;
+}
+.modes button {
+ flex: 1;
+ padding: 6px 8px;
+ font: inherit;
+ font-size: 11px;
+ color: var(--dim);
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.15s;
+}
+.modes button:hover { color: var(--text); }
+.modes button.active {
+ color: #04121a;
+ background: var(--accent);
+ border-color: var(--accent);
+ font-weight: 700;
+}
+
+/* ---- layer rows ---- */
+#layers {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ overflow-y: auto;
+}
+.layer-row {
+ padding: 5px 4px;
+ border-radius: 6px;
+}
+.layer-row:hover { background: rgba(255, 255, 255, 0.03); }
+.layer-row label {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ cursor: pointer;
+ user-select: none;
+}
+.layer-row input[type="checkbox"] {
+ accent-color: var(--accent);
+ margin: 0;
+ cursor: pointer;
+}
+.layer-row .lname {
+ flex: 1;
+ color: var(--text);
+}
+.dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ flex: none;
+ background: var(--dim);
+ transition: background 0.2s;
+}
+.dot[data-state="ok"] { background: var(--ok); box-shadow: 0 0 6px rgba(70, 224, 138, 0.6); }
+.dot[data-state="warn"] { background: var(--warn); box-shadow: 0 0 6px rgba(255, 207, 80, 0.6); }
+.dot[data-state="err"] { background: var(--err); box-shadow: 0 0 6px rgba(255, 89, 100, 0.6); }
+.dot[data-state="off"] { background: var(--dim); }
+.lstatus {
+ padding: 0 0 0 24px;
+ color: var(--dim);
+ font-size: 10px;
+ line-height: 1.3;
+ min-height: 0;
+}
+.lstatus:empty { display: none; }
+
+#hud footer {
+ border-top: 1px solid var(--border);
+ padding-top: 8px;
+ color: var(--dim);
+ font-size: 9.5px;
+ line-height: 1.4;
+}
+#hud footer .demo { color: var(--warn); }
+
+/* ---- aircraft pick overlay ---- */
+#pick-overlay {
+ position: absolute;
+ bottom: 130px;
+ right: 14px;
+ width: 220px;
+ padding: 12px 14px;
+ background: var(--panel-solid);
+ border: 1px solid var(--border);
+ border-left: 2px solid var(--accent);
+ border-radius: 10px;
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ z-index: 10;
+ font-size: 11px;
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
+}
+#pick-overlay[hidden] { display: none; }
+#pick-overlay .po-title {
+ font-weight: 700;
+ color: #fff;
+ font-size: 13px;
+ margin-bottom: 6px;
+}
+#pick-overlay .po-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ padding: 1px 0;
+}
+#pick-overlay .po-row span:first-child { color: var(--dim); }
+#pick-overlay .po-close {
+ position: absolute;
+ top: 8px;
+ right: 10px;
+ cursor: pointer;
+ color: var(--dim);
+ font-size: 14px;
+}
+#pick-overlay .po-close:hover { color: var(--text); }
+
+/* Keep Cesium's info box on-theme without fighting it */
+.cesium-infoBox { font-family: ui-monospace, monospace; }
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..75191a5
--- /dev/null
+++ b/index.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ GODSIGH ▸ World View
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/js/config.js b/js/config.js
new file mode 100644
index 0000000..8d8f7cd
--- /dev/null
+++ b/js/config.js
@@ -0,0 +1,93 @@
+// GODSIGH configuration — every URL, interval, and tunable in one place.
+// All feed paths are RELATIVE so the app works identically at "/" (dev) and
+// "/godsigh/" (prod). Never introduce a leading-slash path.
+
+export const CONFIG = {
+ // Same-origin proxy endpoints (serve.py in dev; nginx location blocks in prod).
+ proxy: {
+ opensky: 'proxy/opensky',
+ celestrak: 'proxy/celestrak',
+ },
+
+ basemaps: {
+ // Data Mode — dark high-contrast raster.
+ dark: {
+ url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png',
+ subdomains: ['a', 'b', 'c', 'd'],
+ credit: '© OpenStreetMap contributors © CARTO',
+ maximumLevel: 19,
+ },
+ // Photo Mode — Sentinel-2 cloudless mosaic (no key).
+ photo: {
+ url: 'https://tiles.maps.eox.at/wmts/1.0.0/s2cloudless-2023_3857/default/g/{z}/{y}/{x}.jpg',
+ credit: 'Sentinel-2 cloudless by EOX — modified Copernicus Sentinel data 2023',
+ maximumLevel: 14,
+ },
+ },
+
+ // Timeline spans now ± windowHours.
+ windowHours: 6,
+ // Live-only layers (aircraft, live AIS) pause when the clock is scrubbed
+ // further than this from wall-clock now.
+ liveThresholdSec: 600,
+
+ // Camera opens on the Persian Gulf theater.
+ camera: { lon: 53.0, lat: 25.5, height: 2_800_000 },
+
+ satellites: {
+ sampleStepSec: 60, // one SGP4 sample per minute across the window
+ cap: 120, // hard ceiling on tracked satellites
+ // Each source is one Celestrak query. `filter` = keep only names containing
+ // one of these tokens; `keepOnly` = keep only these exact names.
+ sources: [
+ {
+ q: 'GROUP=resource&FORMAT=tle',
+ filter: ['WORLDVIEW', 'LEGION', 'PLEIADES', 'CAPELLA', 'GAOFEN', 'SKYSAT',
+ 'ICEYE', 'UMBRA', 'LANDSAT', 'SENTINEL-1', 'SENTINEL-2',
+ 'GEOEYE', 'KOMPSAT', 'CARTOSAT', 'RESURS'],
+ },
+ { q: 'GROUP=stations&FORMAT=tle', keepOnly: ['ISS (ZARYA)', 'CSS (TIANHE)'] },
+ { q: 'NAME=GAOFEN&FORMAT=tle' },
+ { q: 'NAME=COSMOS%202486&FORMAT=tle' },
+ { q: 'NAME=COSMOS%202506&FORMAT=tle' },
+ ],
+ },
+
+ aircraft: {
+ // null bbox = global (a global states/all call costs the same 4 credits as
+ // any large bbox). Or set {lamin, lomin, lamax, lomax}.
+ bbox: null,
+ pollMs: 90_000,
+ },
+
+ // Optional live AIS. Leave blank to use the demo fleet only (roadmap feature).
+ AISSTREAM_API_KEY: '',
+
+ colors: {
+ // satellites (by name token)
+ GAOFEN: '#ff4d4d',
+ COSMOS: '#ff9f40',
+ WORLDVIEW: '#4dd2ff',
+ LEGION: '#4dd2ff',
+ CAPELLA: '#9d7bff',
+ PLEIADES: '#7bff9d',
+ SENTINEL: '#ffd24d',
+ ISS: '#ffffff',
+ CSS: '#ffffff',
+ satDefault: '#66ffcc',
+ // aircraft (by altitude band)
+ aircraftLow: '#ffb347',
+ aircraftMid: '#ffe74d',
+ aircraftHigh: '#4dd2ff',
+ // ships
+ shipNormal: '#7bff9d',
+ shipToll: '#ffe74d',
+ shipDark: '#ff4d4d',
+ shipIdle: '#8a97a3',
+ // infra
+ chokepoint: '#ff003c',
+ pipeline: '#ffbf40',
+ facility: '#35e0ff',
+ event: '#ff4d4d',
+ },
+};
diff --git a/js/lib.js b/js/lib.js
new file mode 100644
index 0000000..e355c33
--- /dev/null
+++ b/js/lib.js
@@ -0,0 +1,71 @@
+// Shared helpers used across layers. Cesium and satellite are CDN globals.
+
+const Cesium = window.Cesium;
+
+// Build an offscreen canvas glyph for a BillboardCollection.
+export function makeGlyphCanvas(size, drawFn) {
+ const c = document.createElement('canvas');
+ c.width = size;
+ c.height = size;
+ drawFn(c.getContext('2d'), size);
+ return c;
+}
+
+// White upward-pointing triangle (aircraft). Tinted per-billboard via .color.
+export function aircraftGlyph() {
+ return makeGlyphCanvas(32, (g) => {
+ g.fillStyle = '#ffffff';
+ g.beginPath();
+ g.moveTo(16, 2);
+ g.lineTo(28, 30);
+ g.lineTo(16, 23);
+ g.lineTo(4, 30);
+ g.closePath();
+ g.fill();
+ });
+}
+
+// White diamond (ships).
+export function shipGlyph() {
+ return makeGlyphCanvas(28, (g) => {
+ g.fillStyle = '#ffffff';
+ g.beginPath();
+ g.moveTo(14, 2);
+ g.lineTo(26, 14);
+ g.lineTo(14, 26);
+ g.lineTo(2, 14);
+ g.closePath();
+ g.fill();
+ });
+}
+
+// Great-circle initial bearing, degrees (0 = north, clockwise).
+export function bearingDeg(lat1, lon1, lat2, lon2) {
+ const toR = Math.PI / 180;
+ const p1 = lat1 * toR;
+ const p2 = lat2 * toR;
+ const dl = (lon2 - lon1) * toR;
+ const y = Math.sin(dl) * Math.cos(p2);
+ const x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl);
+ return (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
+}
+
+// Aircraft altitude → band color.
+export function altitudeColor(CONFIG, altMeters) {
+ const c = CONFIG.colors;
+ if (altMeters == null) return c.aircraftHigh;
+ if (altMeters < 3000) return c.aircraftLow;
+ if (altMeters < 9000) return c.aircraftMid;
+ return c.aircraftHigh;
+}
+
+// CSS hex → Cesium.Color, optional alpha.
+export function cz(hex, alpha) {
+ const col = Cesium.Color.fromCssColorString(hex);
+ return alpha == null ? col : col.withAlpha(alpha);
+}
+
+// Billboard screen-space rotation (CCW) for a compass heading (CW from north).
+export function headingToRotation(headingDeg) {
+ return -Cesium.Math.toRadians(headingDeg || 0);
+}
diff --git a/js/main.js b/js/main.js
new file mode 100644
index 0000000..2452aa5
--- /dev/null
+++ b/js/main.js
@@ -0,0 +1,150 @@
+// GODSIGH bootstrap: viewer, basemaps, clock, live/scrub gating, picking, and
+// dynamic layer loading.
+//
+// LAYER CONTRACT — each module in js/layers/*.js default-exports a factory:
+//
+// export default function create(ctx) {
+// const { viewer, CONFIG, lib, ui, Cesium, start, stop, now } = ctx;
+// // register HUD row(s) with ui.addLayer(id, name, defaultOn, onToggle)
+// // report progress/failure with ui.setStatus(id, text, 'ok'|'warn'|'err')
+// return {
+// id: 'x', // string
+// onClockTick(currentTime, isLive) {}, // optional; called ~1/s
+// handlePick(picked) { return false; }, // optional; true if it consumed the click
+// clearPick() {}, // optional; hide any custom overlay
+// };
+// }
+
+import { CONFIG } from './config.js';
+import * as lib from './lib.js';
+import * as ui from './ui.js';
+
+const Cesium = window.Cesium;
+
+// ---- clock window: now-6h .. now+6h ----
+const now = Cesium.JulianDate.now();
+const start = Cesium.JulianDate.addHours(now, -CONFIG.windowHours, new Cesium.JulianDate());
+const stop = Cesium.JulianDate.addHours(now, CONFIG.windowHours, new Cesium.JulianDate());
+
+const viewer = new Cesium.Viewer('cesiumContainer', {
+ baseLayer: false, // we manage imagery ourselves — no Cesium ion token
+ baseLayerPicker: false,
+ geocoder: false,
+ sceneModePicker: false,
+ navigationHelpButton: false,
+ homeButton: false,
+ fullscreenButton: false,
+ timeline: true,
+ animation: true,
+ infoBox: true,
+ selectionIndicator: true,
+ shouldAnimate: true,
+});
+
+// ---- basemaps ----
+const imagery = viewer.imageryLayers;
+const darkLayer = imagery.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
+ url: CONFIG.basemaps.dark.url,
+ subdomains: CONFIG.basemaps.dark.subdomains,
+ credit: CONFIG.basemaps.dark.credit,
+ maximumLevel: CONFIG.basemaps.dark.maximumLevel,
+}));
+const photoLayer = imagery.addImageryProvider(new Cesium.UrlTemplateImageryProvider({
+ url: CONFIG.basemaps.photo.url,
+ credit: CONFIG.basemaps.photo.credit,
+ maximumLevel: CONFIG.basemaps.photo.maximumLevel,
+}));
+photoLayer.show = false;
+
+function setMode(mode) {
+ const photo = mode === 'photo';
+ photoLayer.show = photo;
+ darkLayer.show = !photo;
+ // Data Mode = flat high-contrast asset map (always readable).
+ // Photo Mode = realistic imagery with a live day/night terminator.
+ viewer.scene.globe.enableLighting = photo;
+ viewer.scene.globe.showGroundAtmosphere = photo;
+ document.getElementById('mode-data').classList.toggle('active', !photo);
+ document.getElementById('mode-photo').classList.toggle('active', photo);
+}
+document.getElementById('mode-data').addEventListener('click', () => setMode('data'));
+document.getElementById('mode-photo').addEventListener('click', () => setMode('photo'));
+
+// ---- clock configuration ----
+viewer.clock.startTime = start.clone();
+viewer.clock.stopTime = stop.clone();
+viewer.clock.currentTime = now.clone();
+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),
+});
+
+// ---- context handed to every layer factory ----
+const ctx = { viewer, CONFIG, lib, ui, Cesium, start, stop, now };
+
+// ---- dynamic layer loading (order = draw/registration order in the HUD) ----
+const LAYER_MODULES = [
+ './layers/satellites.js',
+ './layers/infra.js',
+ './layers/events.js',
+ './layers/ships.js',
+ './layers/aircraft.js',
+];
+const activeLayers = [];
+
+async function loadLayers() {
+ const results = await Promise.allSettled(LAYER_MODULES.map((p) => import(p)));
+ results.forEach((r, i) => {
+ if (r.status === 'rejected') {
+ console.error(`[godsigh] layer ${LAYER_MODULES[i]} failed to load:`, r.reason);
+ return;
+ }
+ const factory = r.value.default;
+ if (typeof factory !== 'function') {
+ console.warn(`[godsigh] ${LAYER_MODULES[i]} has no default export factory yet`);
+ return;
+ }
+ try {
+ const layer = factory(ctx);
+ if (layer) activeLayers.push(layer);
+ } catch (err) {
+ console.error(`[godsigh] layer ${LAYER_MODULES[i]} threw during init:`, err);
+ }
+ });
+}
+
+// ---- live/scrub gating (throttled ~1/s) ----
+let lastTickMs = 0;
+viewer.clock.onTick.addEventListener((clock) => {
+ const curMs = Cesium.JulianDate.toDate(clock.currentTime).getTime();
+ if (lastTickMs && Math.abs(curMs - lastTickMs) < 1000) return;
+ lastTickMs = curMs;
+ const isLive = Math.abs(curMs - Date.now()) <= CONFIG.liveThresholdSec * 1000;
+ ui.setLiveChip(isLive);
+ for (const l of activeLayers) l.onClockTick?.(clock.currentTime, isLive);
+});
+
+// ---- picking: aircraft (primitives) get a custom overlay; entities use InfoBox ----
+const clickHandler = new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas);
+clickHandler.setInputAction((movement) => {
+ const picked = viewer.scene.pick(movement.position);
+ let consumed = false;
+ for (const l of activeLayers) {
+ if (l.handlePick?.(picked)) { consumed = true; break; }
+ }
+ if (consumed) {
+ viewer.selectedEntity = undefined; // suppress InfoBox when a layer handled it
+ } else {
+ for (const l of activeLayers) l.clearPick?.();
+ }
+}, Cesium.ScreenSpaceEventType.LEFT_CLICK);
+
+setMode('data');
+ui.setLiveChip(true);
+loadLayers();
+
+window.__godsigh = { viewer, ctx, activeLayers }; // debug handle
diff --git a/js/ui.js b/js/ui.js
new file mode 100644
index 0000000..bfc40cb
--- /dev/null
+++ b/js/ui.js
@@ -0,0 +1,62 @@
+// HUD controller: layer registry, per-feed status lines, LIVE/SCRUBBED chip.
+
+const rows = new Map();
+
+// Register a layer row. Returns a handle so callers can flip the checkbox.
+export function addLayer(id, name, defaultOn, onToggle) {
+ const row = document.createElement('div');
+ row.className = 'layer-row';
+ row.innerHTML = `
+
+ `;
+ row.querySelector('.lname').textContent = name;
+ const cb = row.querySelector('input');
+ cb.addEventListener('change', () => onToggle(cb.checked));
+ document.getElementById('layers').appendChild(row);
+ rows.set(id, row);
+ return { setChecked: (v) => { cb.checked = v; } };
+}
+
+// state ∈ 'ok' | 'warn' | 'err' | 'off'
+export function setStatus(id, text, state = 'ok') {
+ const row = rows.get(id);
+ if (!row) return;
+ row.querySelector('.lstatus').textContent = text;
+ row.querySelector('.dot').dataset.state = state;
+}
+
+export function setLiveChip(isLive) {
+ const chip = document.getElementById('live-chip');
+ if (!chip) return;
+ chip.textContent = isLive ? 'LIVE' : 'SCRUBBED';
+ chip.title = isLive ? 'Clock is at wall-clock now' : 'Live layers paused — scrubbed off now';
+ chip.dataset.state = isLive ? 'live' : 'scrub';
+}
+
+// Small fixed overlay for primitive picks (aircraft), which have no Cesium InfoBox.
+export function showPickOverlay(title, rowPairs) {
+ const el = document.getElementById('pick-overlay');
+ if (!el) return;
+ el.innerHTML =
+ `×
` +
+ `` +
+ rowPairs.map(() => `
`).join('');
+ el.querySelector('.po-title').textContent = title;
+ const rowEls = el.querySelectorAll('.po-row');
+ rowPairs.forEach(([k, v], i) => {
+ const spans = rowEls[i].querySelectorAll('span');
+ spans[0].textContent = k;
+ spans[1].textContent = v;
+ });
+ el.querySelector('.po-close').addEventListener('click', hidePickOverlay);
+ el.hidden = false;
+}
+
+export function hidePickOverlay() {
+ const el = document.getElementById('pick-overlay');
+ if (el) el.hidden = true;
+}
diff --git a/serve.py b/serve.py
index 803c1f8..41b6684 100644
--- a/serve.py
+++ b/serve.py
@@ -23,20 +23,33 @@ class Handler(SimpleHTTPRequestHandler):
if not base:
return self.send_error(404, f"unknown upstream {name!r}")
url = base + ("?" + query if query else "")
+ # Forward these upstream headers so the client can show quota state.
+ passthrough = {}
+
+ def grab(hdrs):
+ for k in ("X-Rate-Limit-Remaining", "X-Rate-Limit-Limit", "X-Expires-After"):
+ if hdrs.get(k) is not None:
+ passthrough[k] = hdrs.get(k)
+
try:
req = urllib.request.Request(url, headers={"User-Agent": "godsigh-dev/0.1"})
with urllib.request.urlopen(req, timeout=45) as r:
body = r.read()
status, ctype = r.status, r.headers.get("Content-Type", "text/plain")
+ grab(r.headers)
except urllib.error.HTTPError as e:
body = e.read() or str(e).encode()
status, ctype = e.code, "text/plain"
+ grab(e.headers)
except Exception as e:
body, status, ctype = str(e).encode(), 502, "text/plain"
self.send_response(status)
self.send_header("Content-Type", ctype)
self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Expose-Headers", "X-Rate-Limit-Remaining, X-Rate-Limit-Limit, X-Expires-After")
self.send_header("Cache-Control", "no-store")
+ for k, v in passthrough.items():
+ self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)