phase 1: globe core — viewer, dual basemaps, clock, HUD, layer contract

- CesiumJS viewer (token-free, baseLayer:false), Carto dark + EOX Sentinel-2
- clock spans now +/-6h CLAMPED; built-in timeline/animation = the space-time slider
- HUD: brand, LIVE/SCRUBBED chip, Data/Photo mode toggle, layer rows, credits
- lighting is mode-aware: OFF in Data (high-contrast asset map), ON in Photo
  (realistic day/night terminator) — refines SPEC 6.1's global-lighting note
- main.js defines the layer-factory contract + dynamic loader; layers land next
- serve.py now forwards OpenSky X-Rate-Limit-* headers for the aircraft status line

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-13 12:06:05 +10:00
parent 0fec269512
commit 7356d62381
7 changed files with 639 additions and 0 deletions

214
css/style.css Normal file
View File

@ -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; }

36
index.html Normal file
View File

@ -0,0 +1,36 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GODSIGH ▸ World View</title>
<script>window.CESIUM_BASE_URL = 'https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/';</script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/Widgets/widgets.css" />
<link rel="stylesheet" href="css/style.css" />
<script src="https://cdn.jsdelivr.net/npm/cesium@1/Build/Cesium/Cesium.js"></script>
<script src="https://cdn.jsdelivr.net/npm/satellite.js@5/dist/satellite.min.js"></script>
</head>
<body>
<div id="cesiumContainer"></div>
<aside id="hud">
<header>
<div class="brand">GODSIGH <span>▸ WORLD VIEW</span></div>
<div id="live-chip" data-state="live">LIVE</div>
</header>
<div class="modes">
<button id="mode-data" class="active" type="button">Data</button>
<button id="mode-photo" type="button">Photo</button>
</div>
<div id="layers"></div>
<footer>
<div>Public OSINT feeds · Celestrak · OpenSky · EOX Sentinel-2 · CARTO/OSM</div>
<div class="demo">Ships &amp; events are <b>[DEMO]</b> data</div>
</footer>
</aside>
<div id="pick-overlay" hidden></div>
<script type="module" src="js/main.js"></script>
</body>
</html>

93
js/config.js Normal file
View File

@ -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',
},
};

71
js/lib.js Normal file
View File

@ -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);
}

150
js/main.js Normal file
View File

@ -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

62
js/ui.js Normal file
View File

@ -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 = `
<label>
<input type="checkbox" ${defaultOn ? 'checked' : ''} />
<span class="dot" data-state="${defaultOn ? 'ok' : 'off'}"></span>
<span class="lname"></span>
</label>
<div class="lstatus"></div>`;
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 =
`<div class="po-close" role="button" aria-label="close">×</div>` +
`<div class="po-title"></div>` +
rowPairs.map(() => `<div class="po-row"><span></span><span></span></div>`).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;
}

View File

@ -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)