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:
parent
0fec269512
commit
7356d62381
214
css/style.css
Normal file
214
css/style.css
Normal 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
36
index.html
Normal 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 & 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
93
js/config.js
Normal 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
71
js/lib.js
Normal 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
150
js/main.js
Normal 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
62
js/ui.js
Normal 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;
|
||||
}
|
||||
13
serve.py
13
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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user