From 7356d623812e97dedf8b0f0487c4c64578b3ae37 Mon Sep 17 00:00:00 2001 From: jing Date: Mon, 13 Jul 2026 12:06:05 +1000 Subject: [PATCH] =?UTF-8?q?phase=201:=20globe=20core=20=E2=80=94=20viewer,?= =?UTF-8?q?=20dual=20basemaps,=20clock,=20HUD,=20layer=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- css/style.css | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 36 +++++++++ js/config.js | 93 ++++++++++++++++++++++ js/lib.js | 71 +++++++++++++++++ js/main.js | 150 +++++++++++++++++++++++++++++++++++ js/ui.js | 62 +++++++++++++++ serve.py | 13 +++ 7 files changed, 639 insertions(+) create mode 100644 css/style.css create mode 100644 index.html create mode 100644 js/config.js create mode 100644 js/lib.js create mode 100644 js/main.js create mode 100644 js/ui.js 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)