feat: Wave 6 OSINT4ALL sweep — TfL JamCams, InciWeb incidents, Radio Garden
Three new layers from the OSINT4ALL directory triage: - jamcams (registry): ~800 live London traffic cameras via TfL open API (CORS-open, keyless). Click a cam dot -> live snapshot in the InfoBox, 5-min cache-busted refresh. New collapsed 'Regional - London' category. - inciweb (registry): named US wildfire incidents from InciWeb RSS. New spec.parse hook in the geojson-layer factory lets XML feeds supply their own parser; coordinates regex'd out of DMS text in <description>. - radio (bespoke): ~12k live radio stations from Radio Garden pinned to their cities (PointPrimitiveCollection). Click -> station list + live audio player (audio streams direct; JSON via new allowlisted serve.py proxy/rg/ with 12h/1h caches, SSRF-tested). Keeps playing while you pan. Deploy note: prod nginx needs a location for /godsigh/proxy/rg/ before the radio layer works live (mirror of serve.py RG_PATH_RE allowlist). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
92c6e0ebc3
commit
7ccb2214f9
@ -254,6 +254,47 @@ html, body {
|
||||
/* Keep Cesium's info box on-theme without fighting it */
|
||||
.cesium-infoBox { font-family: ui-monospace, monospace; }
|
||||
|
||||
/* ---- Radio Garden player ---- */
|
||||
#radio-player {
|
||||
position: absolute;
|
||||
bottom: 140px;
|
||||
left: 14px;
|
||||
width: 250px;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 11;
|
||||
padding: 12px;
|
||||
background: var(--panel-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 2px solid #7de3a0;
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
font: 11px ui-monospace, Menlo, monospace;
|
||||
color: var(--text);
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
#radio-player[hidden] { display: none; }
|
||||
#radio-player .rp-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
|
||||
#radio-player .rp-title { font-weight: 700; color: #fff; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#radio-player .rp-close { cursor: pointer; color: var(--dim); font-size: 15px; flex: none; }
|
||||
#radio-player .rp-close:hover { color: var(--text); }
|
||||
#radio-player .rp-list { overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
|
||||
#radio-player .rp-row { display: flex; align-items: center; gap: 8px; padding: 3px 2px; border-radius: 5px; }
|
||||
#radio-player .rp-row:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
#radio-player .rp-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#radio-player .rp-play {
|
||||
flex: none; width: 22px; height: 22px; padding: 0;
|
||||
font: 10px ui-monospace, monospace; color: #7de3a0;
|
||||
background: rgba(125, 227, 160, 0.1);
|
||||
border: 1px solid rgba(125, 227, 160, 0.35);
|
||||
border-radius: 5px; cursor: pointer;
|
||||
}
|
||||
#radio-player .rp-play:hover { background: rgba(125, 227, 160, 0.25); }
|
||||
#radio-player .rp-hint { color: var(--dim); padding: 4px 2px; }
|
||||
#radio-player .rp-foot { margin-top: 8px; padding-top: 6px; border-top: 1px solid var(--border); color: var(--dim); font-size: 9px; }
|
||||
|
||||
/* ---- Solar System mode ---- */
|
||||
#ss-btn {
|
||||
position: absolute;
|
||||
|
||||
@ -131,7 +131,10 @@ export function createGeoJsonLayer(ctx, spec) {
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = await res.json(); // never gate on Content-Type (EONET lies rss+xml)
|
||||
// spec.parse lets non-JSON feeds (e.g. InciWeb RSS/XML) supply their own
|
||||
// text → features parser; default path never gates on Content-Type
|
||||
// (EONET lies rss+xml but is JSON).
|
||||
json = spec.parse ? spec.parse(await res.text()) : await res.json();
|
||||
} catch {
|
||||
ui.setStatus(spec.id, 'bad response — retrying', 'err');
|
||||
return;
|
||||
|
||||
135
js/layers/radiogarden.js
Normal file
135
js/layers/radiogarden.js
Normal file
@ -0,0 +1,135 @@
|
||||
// Radio Garden layer (Wave 6) — ~8,000 live radio stations pinned to their real
|
||||
// cities. Click a dot → pick a station → it streams live through a small player.
|
||||
// Place/channel JSON comes via the same-origin proxy (proxy/rg/…, no CORS
|
||||
// upstream); the AUDIO itself streams straight from radio.garden — <audio> media
|
||||
// loads don't need CORS. Unofficial API: default-off, fails soft.
|
||||
|
||||
export default function create(ctx) {
|
||||
const { viewer, Cesium, lib, ui } = ctx;
|
||||
const pts = viewer.scene.primitives.add(new Cesium.PointPrimitiveCollection());
|
||||
pts.show = false;
|
||||
|
||||
let enabled = false;
|
||||
let loaded = false;
|
||||
const MINT = Cesium.Color.fromCssColorString('#7de3a0').withAlpha(0.85);
|
||||
const OUTLINE = Cesium.Color.BLACK;
|
||||
|
||||
ui.addLayer('radio', 'Radio Garden (live radio)', false, (on) => {
|
||||
enabled = on;
|
||||
pts.show = on;
|
||||
if (on && !loaded) load();
|
||||
if (!on) { stopAudio(); hidePlayer(); ui.setStatus('radio', '', 'ok'); }
|
||||
else if (loaded) ui.setStatus('radio', `${pts.length} stations · click to listen`, 'ok');
|
||||
});
|
||||
|
||||
async function load() {
|
||||
ui.setStatus('radio', 'loading stations…', 'warn');
|
||||
let res;
|
||||
try {
|
||||
res = await fetch('proxy/rg/ara/content/places');
|
||||
} catch {
|
||||
ui.setStatus('radio', 'network error', 'err'); return;
|
||||
}
|
||||
if (!res.ok) { ui.setStatus('radio', `HTTP ${res.status}`, 'err'); return; }
|
||||
let list;
|
||||
try {
|
||||
list = (await res.json()).data.list;
|
||||
} catch {
|
||||
ui.setStatus('radio', 'bad response', 'err'); return;
|
||||
}
|
||||
for (const p of list) {
|
||||
const g = p.geo; // [lon, lat]
|
||||
if (!Array.isArray(g) || !isFinite(g[0]) || !isFinite(g[1])) continue;
|
||||
pts.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(g[0], g[1]),
|
||||
pixelSize: 4,
|
||||
color: MINT,
|
||||
outlineColor: OUTLINE,
|
||||
outlineWidth: 1,
|
||||
disableDepthTestDistance: 50000,
|
||||
id: { layer: 'radio', placeId: p.url.split('/').pop(), title: p.title, country: p.country, size: p.size },
|
||||
});
|
||||
}
|
||||
loaded = true;
|
||||
if (enabled) ui.setStatus('radio', `${pts.length} stations · click to listen`, 'ok');
|
||||
}
|
||||
|
||||
// ---- player ----
|
||||
const audio = new Audio();
|
||||
audio.volume = 0.85;
|
||||
let panel = null;
|
||||
let playingBtn = null;
|
||||
|
||||
function ensurePanel() {
|
||||
if (panel) return panel;
|
||||
panel = document.createElement('aside');
|
||||
panel.id = 'radio-player';
|
||||
panel.hidden = true;
|
||||
panel.innerHTML = `<div class="rp-head"><span class="rp-title"></span><span class="rp-close" title="Close & stop">×</span></div>
|
||||
<div class="rp-list"></div>
|
||||
<div class="rp-foot">radio.garden · streams are live</div>`;
|
||||
panel.querySelector('.rp-close').addEventListener('click', () => { stopAudio(); hidePlayer(); });
|
||||
document.body.appendChild(panel);
|
||||
return panel;
|
||||
}
|
||||
|
||||
function stopAudio() {
|
||||
audio.pause();
|
||||
audio.removeAttribute('src');
|
||||
audio.load();
|
||||
if (playingBtn) { playingBtn.textContent = '▶'; playingBtn = null; }
|
||||
}
|
||||
function hidePlayer() { if (panel) panel.hidden = true; }
|
||||
|
||||
async function openPlayer(meta) {
|
||||
const p = ensurePanel();
|
||||
p.querySelector('.rp-title').textContent = `📻 ${meta.title}${meta.country ? ' · ' + meta.country : ''}`;
|
||||
const listEl = p.querySelector('.rp-list');
|
||||
listEl.innerHTML = '<div class="rp-hint">tuning…</div>';
|
||||
p.hidden = false;
|
||||
let items = [];
|
||||
try {
|
||||
const res = await fetch(`proxy/rg/ara/content/page/${encodeURIComponent(meta.placeId)}/channels`);
|
||||
const j = await res.json();
|
||||
for (const block of (j.data && j.data.content) || []) {
|
||||
for (const it of block.items || []) {
|
||||
const pg = it.page || it;
|
||||
if (pg && pg.url && pg.title) items.push({ id: pg.url.split('/').pop(), title: pg.title });
|
||||
}
|
||||
}
|
||||
} catch { /* fall through to empty-state */ }
|
||||
if (!items.length) { listEl.innerHTML = '<div class="rp-hint">no stations found</div>'; return; }
|
||||
listEl.innerHTML = '';
|
||||
for (const st of items.slice(0, 14)) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'rp-row';
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'rp-play';
|
||||
btn.textContent = '▶';
|
||||
const name = document.createElement('span');
|
||||
name.textContent = st.title; // textContent — no HTML injection possible
|
||||
btn.addEventListener('click', () => {
|
||||
if (playingBtn === btn) { stopAudio(); return; } // toggle off
|
||||
stopAudio();
|
||||
// The mp3 endpoint 302s to the real stream; <audio> follows it, no CORS needed.
|
||||
audio.src = `https://radio.garden/api/ara/content/listen/${st.id}/channel.mp3`;
|
||||
audio.play().catch(() => { btn.textContent = '✕'; });
|
||||
btn.textContent = '■';
|
||||
playingBtn = btn;
|
||||
});
|
||||
row.appendChild(btn);
|
||||
row.appendChild(name);
|
||||
listEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePick(picked) {
|
||||
if (!enabled || picked?.id?.layer !== 'radio') return false;
|
||||
openPlayer(picked.id);
|
||||
return true;
|
||||
}
|
||||
// NOTE: no clearPick — the radio keeps playing while you pan the globe; the
|
||||
// player's × is the way to stop. That's the point of ambient world radio.
|
||||
|
||||
return { id: 'radio', handlePick };
|
||||
}
|
||||
@ -183,6 +183,7 @@ const LAYER_MODULES = [
|
||||
'./layers/military.js',
|
||||
'./layers/radius.js',
|
||||
'./layers/celestial.js',
|
||||
'./layers/radiogarden.js',
|
||||
];
|
||||
const activeLayers = [];
|
||||
|
||||
|
||||
@ -206,8 +206,94 @@ export const REGISTRY = [
|
||||
},
|
||||
status: ({ shown }) => `${shown} NSW incidents`,
|
||||
},
|
||||
|
||||
// ──────────────────── Earth (Wave 6: OSINT4ALL sweep) ────────────────────
|
||||
{
|
||||
id: 'inciweb', name: 'US fire incidents (InciWeb)', category: 'Earth & hazards', defaultOn: false,
|
||||
url: 'https://inciweb.wildfire.gov/incidents/rss.xml',
|
||||
refreshMs: 900000, cap: 300,
|
||||
// RSS/XML feed → spec.parse turns it into plain feature objects. Coordinates
|
||||
// arrive as DMS text inside <description> ("Latitude: 42° 36 14 Longitude:
|
||||
// 123° 0 7"); InciWeb is US-only so longitude is always West (negated).
|
||||
parse: (text) => {
|
||||
const doc = new DOMParser().parseFromString(text, 'application/xml');
|
||||
const out = [];
|
||||
for (const it of doc.querySelectorAll('item')) {
|
||||
const get = (tag) => (it.querySelector(tag) ? it.querySelector(tag).textContent.trim() : '');
|
||||
const desc = get('description');
|
||||
const m = desc.match(/Latitude:\s*([\d.]+)°?\s+([\d.]+)\s*([\d.]+)?\s*Longitude:\s*([\d.]+)°?\s+([\d.]+)\s*([\d.]+)?/);
|
||||
if (!m) continue;
|
||||
const dms = (d, mn, s) => (+d) + (+mn) / 60 + (+(s || 0)) / 3600;
|
||||
const lat = dms(m[1], m[2], m[3]);
|
||||
const lon = -dms(m[4], m[5], m[6]); // US incl. AK/HI/PR → always West
|
||||
const state = (desc.match(/State:\s*([^-]+?)\s*---/) || [])[1] || '';
|
||||
const updated = (desc.match(/Last updated:\s*([\d-]+)/) || [])[1] || '';
|
||||
const overview = (desc.match(/Incident Overview:\s*(.+)$/s) || [])[1] || '';
|
||||
out.push({ title: get('title'), link: get('link'), lat, lon, state, updated, overview });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
adapt: (x) => x,
|
||||
usable: (f) => isFinite(f.lat) && isFinite(f.lon) && Math.abs(f.lat) <= 90 && !!f.title,
|
||||
featureId: (f) => f.link || f.title,
|
||||
sig: (f) => `${f.updated}|${f.overview.length}`,
|
||||
locate: (f) => ({ lon: f.lon, lat: f.lat }),
|
||||
style: (f, ctx) => ({
|
||||
billboard: { image: inciGlyph(ctx), width: 18, height: 18, disableDepthTestDistance: 50000 },
|
||||
label: { text: f.title.replace(/^[A-Z0-9]+\s+/, ''), fillColor: ctx.lib.cz('#ff8c5a'), fade: [4.0e5, 4.0e6] },
|
||||
}),
|
||||
description: (f, { lib }) => {
|
||||
const url = lib.safeUrl(f.link);
|
||||
return `<table class="cesium-infoBox-defaultTable"><tbody>` +
|
||||
`<tr><th>Incident</th><td>${lib.escapeHtml(f.title)}</td></tr>` +
|
||||
`<tr><th>State</th><td>${lib.escapeHtml(f.state || '—')}</td></tr>` +
|
||||
`<tr><th>Updated</th><td>${lib.escapeHtml(f.updated || '—')}</td></tr></tbody></table>` +
|
||||
`<p>${lib.escapeHtml(f.overview.replace(/\s+/g, ' ').trim().slice(0, 320))}…</p>` +
|
||||
(url ? `<p><a href="${lib.escapeHtml(url)}" target="_blank" rel="noopener">InciWeb incident page ↗</a></p>` : '');
|
||||
},
|
||||
status: ({ shown }) => `${shown} named incidents`,
|
||||
},
|
||||
|
||||
// ──────────────────── Regional — London ────────────────────
|
||||
{
|
||||
id: 'jamcams', name: 'Traffic cams (TfL JamCams)', category: 'Regional — London', defaultOn: false,
|
||||
url: 'https://api.tfl.gov.uk/Place/Type/JamCam', // CORS-open, no key
|
||||
refreshMs: 300000, cap: 1000,
|
||||
adapt: (j) => (Array.isArray(j) ? j : []),
|
||||
usable: (f) => isFinite(f.lat) && isFinite(f.lon) && jcProp(f, 'available') === 'true' && !!jcProp(f, 'imageUrl'),
|
||||
featureId: (f) => f.id,
|
||||
// Include a 5-min bucket so entities (and their snapshot URLs) refresh each
|
||||
// poll — the S3 image object updates in place, so the cache-buster matters.
|
||||
sig: (f) => `${jcProp(f, 'imageUrl')}|${Math.floor(Date.now() / 300000)}`,
|
||||
locate: (f) => ({ lon: f.lon, lat: f.lat }),
|
||||
style: (f, { Cesium, lib }) => ({
|
||||
point: { pixelSize: 5, color: lib.cz('#6fd3ff'), outlineColor: Cesium.Color.BLACK, outlineWidth: 1, disableDepthTestDistance: 50000 },
|
||||
}),
|
||||
description: (f, { lib }) => {
|
||||
const img = lib.safeUrl(jcProp(f, 'imageUrl'));
|
||||
const vid = lib.safeUrl(jcProp(f, 'videoUrl'));
|
||||
const cb = Math.floor(Date.now() / 300000); // aligns with sig bucket
|
||||
return `<p><b>${lib.escapeHtml(f.commonName || 'JamCam')}</b></p>` +
|
||||
(img ? `<p><img src="${lib.escapeHtml(img)}?cb=${cb}" style="width:100%;border-radius:6px" alt="live traffic cam"/></p>` : '') +
|
||||
`<p style="opacity:.7">Live snapshot · refreshes ~5 min · TfL Open Data</p>` +
|
||||
(vid ? `<p><a href="${lib.escapeHtml(vid)}" target="_blank" rel="noopener">MP4 clip ↗</a></p>` : '');
|
||||
},
|
||||
status: ({ shown }) => `${shown} live cams · click for view`,
|
||||
},
|
||||
];
|
||||
|
||||
// TfL Place additionalProperties: [{key, value}, …] → value lookup.
|
||||
function jcProp(f, key) {
|
||||
const arr = f && f.additionalProperties;
|
||||
if (!Array.isArray(arr)) return undefined;
|
||||
const hit = arr.find((p) => p && p.key === key);
|
||||
return hit && hit.value;
|
||||
}
|
||||
|
||||
// InciWeb flame glyph — separate cache from EONET's so the colors differ.
|
||||
let _inciGlyph = null;
|
||||
const inciGlyph = (ctx) => (_inciGlyph ||= ctx.lib.flameGlyph('#ff6a3c'));
|
||||
|
||||
// GDACS event-type codes → readable prefix.
|
||||
const TYPE = { EQ: '🌍 Quake', TC: '🌀 Cyclone', FL: '🌊 Flood', DR: '🏜 Drought', VO: '🌋 Volcano', WF: '🔥 Wildfire', TS: '🌊 Tsunami' };
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
// Cesium's globe is Earth-at-origin, so the mode HIDES the globe + all Earth
|
||||
// layers and places the Sun at the scene origin; exiting restores everything.
|
||||
|
||||
import { FACTS, MISSIONS } from './ssdata.js';
|
||||
import { FACTS, MISSIONS, MOONS } from './ssdata.js';
|
||||
|
||||
export default function initSolarSystem(ctx) {
|
||||
const { viewer, Cesium, lib } = ctx;
|
||||
@ -79,6 +79,71 @@ export default function initSolarSystem(ctx) {
|
||||
const sphereEntities = {}; // key → textured ellipsoid (frozen position, visible up close)
|
||||
const dotEntities = {}; // key → colored dot + label (animates along the orbit)
|
||||
const ringEntities = {}; // key → Saturn ring plane
|
||||
// NOTE: moons MUST live in this same datasource. Adding a second
|
||||
// CustomDataSource breaks the planets' static ellipsoid rendering outright
|
||||
// (DataSourceDisplay shares primitive collections — the same class of bug that
|
||||
// originally stopped this whole mode from drawing). They also must be BILLBOARDS,
|
||||
// not ellipsoids: an animated ellipsoid enters Cesium's async geometry pipeline
|
||||
// and stalls the planets' build, so the planet silently never renders.
|
||||
// Moons render as PRIMITIVES, not entities. Any extra entity in this datasource
|
||||
// starves Cesium's async static-geometry build and the textured PLANET silently
|
||||
// stops rendering (verified: remove the moons and the planet reappears). Primitive
|
||||
// collections bypass the entity/DataSourceDisplay pipeline entirely — the same
|
||||
// trick the aircraft layers use — so the planets' geometry batch is untouched.
|
||||
// They're also only built for the planet you're actually visiting.
|
||||
const moonBB = new Cesium.BillboardCollection();
|
||||
const moonLBL = new Cesium.LabelCollection();
|
||||
const moonPL = new Cesium.PolylineCollection();
|
||||
moonBB.show = moonLBL.show = moonPL.show = false;
|
||||
scene.primitives.add(moonBB);
|
||||
scene.primitives.add(moonLBL);
|
||||
scene.primitives.add(moonPL);
|
||||
|
||||
// A single shaded white sphere, drawn once and tinted per moon via billboard
|
||||
// colour. Billboards render immediately and — crucially — never enter Cesium's
|
||||
// async geometry pipeline, so animated moons can't stall the planets' build.
|
||||
// With sizeInMeters the width/height are WORLD units, so they scale exactly like
|
||||
// real 3D bodies (true relative size) while always facing the camera.
|
||||
let moonDiscCanvas = null;
|
||||
function moonDisc() {
|
||||
if (moonDiscCanvas) return moonDiscCanvas;
|
||||
const S = 64, R = S / 2, cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const g = cv.getContext('2d'), img = g.createImageData(S, S), d = img.data;
|
||||
const lx = -0.4, ly = 0.45, lz = 0.8; // key light, upper-left-front
|
||||
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
|
||||
const nx = (x - R) / R, ny = (R - y) / R, r2 = nx * nx + ny * ny, i = (y * S + x) * 4;
|
||||
if (r2 > 1) { d[i + 3] = 0; continue; }
|
||||
const nz = Math.sqrt(1 - r2);
|
||||
const v = Math.min(255, 255 * (Math.max(0.16, nx * lx + ny * ly + nz * lz) * 0.85 + 0.15));
|
||||
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
|
||||
}
|
||||
g.putImageData(img, 0, 0);
|
||||
moonDiscCanvas = cv;
|
||||
return cv;
|
||||
}
|
||||
|
||||
// A single point on a moon's orbit, in the planet's equatorial plane, offset from
|
||||
// wherever that planet currently sits.
|
||||
function orbitPoint(key, md, uAx, vAx, ang) {
|
||||
const c = posOf(key, simDay);
|
||||
const p = Cesium.Cartesian3.multiplyByScalar(uAx, md * Math.cos(ang), new Cesium.Cartesian3());
|
||||
Cesium.Cartesian3.add(p, Cesium.Cartesian3.multiplyByScalar(vAx, md * Math.sin(ang), new Cesium.Cartesian3()), p);
|
||||
return Cesium.Cartesian3.add(c, p, p);
|
||||
}
|
||||
|
||||
// One full moon-orbit circle in the planet's equatorial plane, around wherever
|
||||
// that planet currently sits. Plain array ⇒ Cesium treats it as constant.
|
||||
function traceRing(key, md, uAx, vAx) {
|
||||
const c = posOf(key, simDay), out = [];
|
||||
for (let i = 0; i <= 48; i++) {
|
||||
const a = (i / 48) * 2 * Math.PI;
|
||||
const p = Cesium.Cartesian3.multiplyByScalar(uAx, md * Math.cos(a), new Cesium.Cartesian3());
|
||||
Cesium.Cartesian3.add(p, Cesium.Cartesian3.multiplyByScalar(vAx, md * Math.sin(a), new Cesium.Cartesian3()), p);
|
||||
out.push(Cesium.Cartesian3.add(c, p, p));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Orbital time-lapse: `simDay` is the heliocentric day number driving the dots.
|
||||
// It advances in real time while `playing` (overview only) so the planets sweep
|
||||
@ -167,6 +232,23 @@ export default function initSolarSystem(ctx) {
|
||||
// orientation pushes the ellipsoid onto Cesium's dynamic-geometry path, which
|
||||
// only supports solid-colour materials and renders the planet blank white. A
|
||||
// static orientation keeps the image material; you still orbit planets by hand.
|
||||
// ---- moon scaling ----
|
||||
// True relative sizes/distances are unusable here (Phobos would be 0.3% of Mars;
|
||||
// the Moon would sit 60 planet-radii out, far outside the travel-to framing). So
|
||||
// compress both: radius by sqrt (keeps ordering, lifts the tiny ones off zero)
|
||||
// with a visibility floor, and orbit distance by a log map into a band that fits
|
||||
// the close-up view — pushed outside the rings for Saturn.
|
||||
const moonRadius = (planetR, moonKm, planetKm) =>
|
||||
planetR * Math.max(0.035, 0.45 * Math.sqrt(moonKm / planetKm));
|
||||
function moonDist(planetR, ratio, ringed) {
|
||||
const lo = Math.log(2.5), hi = Math.log(62);
|
||||
const t = Math.min(1, Math.max(0, (Math.log(ratio) - lo) / (hi - lo)));
|
||||
return planetR * (ringed ? 3.4 + 1.6 * t : 1.6 + 2.4 * t);
|
||||
}
|
||||
// Display period: sqrt-compressed so Phobos still whips round and Iapetus still
|
||||
// visibly moves, while preserving who-orbits-faster-than-whom.
|
||||
const moonPeriodSec = (P) => Math.max(2.0, 3.2 * Math.sqrt(Math.abs(P)));
|
||||
|
||||
const X_AXIS = new Cesium.Cartesian3(1, 0, 0);
|
||||
function tiltQuat(b) {
|
||||
return Cesium.Quaternion.fromAxisAngle(X_AXIS, (b.obliq || 0) * D2R);
|
||||
@ -328,6 +410,53 @@ export default function initSolarSystem(ctx) {
|
||||
scene.camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(pos));
|
||||
}
|
||||
|
||||
function clearMoons() {
|
||||
moonBB.removeAll(); moonLBL.removeAll(); moonPL.removeAll();
|
||||
}
|
||||
// Build the moon system for one planet, at its current (frozen) position.
|
||||
function buildMoonsFor(key) {
|
||||
clearMoons();
|
||||
const b = BODIES.find((x) => x.key === key);
|
||||
const moons = MOONS[key];
|
||||
if (!b || !moons || !moons.length) return;
|
||||
const r = bodyRadius(b.km, b.sun);
|
||||
const tilt = (b.obliq || 0) * D2R;
|
||||
const uAx = new Cesium.Cartesian3(1, 0, 0); // in-plane
|
||||
const vAx = new Cesium.Cartesian3(0, Math.cos(tilt), Math.sin(tilt)); // ⟂ u, in-plane
|
||||
const ddc = new Cesium.DistanceDisplayCondition(0.0, r * 30);
|
||||
moons.forEach((mn, idx) => {
|
||||
const mr = moonRadius(r, mn.km, b.km);
|
||||
const md = moonDist(r, mn.a / b.km, !!b.ring);
|
||||
const phase = idx * 1.7; // fan them around the orbit so they don't line up
|
||||
const pos = orbitPoint(key, md, uAx, vAx, phase);
|
||||
const col = Cesium.Color.fromCssColorString(mn.color);
|
||||
// sizeInMeters ⇒ width/height are WORLD units, so the orbs hold their true
|
||||
// relative size as you zoom, exactly like real bodies.
|
||||
moonBB.add({
|
||||
position: pos, image: moonDisc(), color: col,
|
||||
sizeInMeters: true, width: mr * 2, height: mr * 2,
|
||||
distanceDisplayCondition: ddc,
|
||||
});
|
||||
moonLBL.add({
|
||||
position: pos, text: mn.name,
|
||||
font: '10px "Segoe UI", system-ui, sans-serif',
|
||||
fillColor: col, outlineColor: Cesium.Color.fromCssColorString('#05080b'),
|
||||
outlineWidth: 3, style: Cesium.LabelStyle.FILL_AND_OUTLINE,
|
||||
verticalOrigin: Cesium.VerticalOrigin.TOP,
|
||||
pixelOffset: new Cesium.Cartesian2(0, 8),
|
||||
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, r * 14),
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
moonPL.add({
|
||||
positions: traceRing(key, md, uAx, vAx), width: 1,
|
||||
material: Cesium.Material.fromType('Color', {
|
||||
color: col.withAlpha(0.22),
|
||||
}),
|
||||
distanceDisplayCondition: ddc,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Snapshot ALL planet spheres+rings forward to the current sim time (constant
|
||||
// position keeps the image material). Freezing every body — not just the target —
|
||||
// keeps any sphere that drifts into view aligned with its animated dot.
|
||||
@ -369,6 +498,7 @@ export default function initSolarSystem(ctx) {
|
||||
};
|
||||
function goOverview() {
|
||||
hideCard();
|
||||
clearMoons();
|
||||
playing = !missionsOn; // resume the orrery (stay paused if browsing missions)
|
||||
showSpheres(false); // dots-only
|
||||
flyCamera(OVERVIEW.pos, OVERVIEW.dir, OVERVIEW.up, 2.0);
|
||||
@ -427,6 +557,7 @@ export default function initSolarSystem(ctx) {
|
||||
});
|
||||
|
||||
ds.show = true;
|
||||
moonBB.show = moonLBL.show = moonPL.show = true;
|
||||
document.body.classList.add('ss-active'); // CSS hides the OSINT timeline/animation chrome
|
||||
document.getElementById('hud').style.display = 'none';
|
||||
panel.style.display = 'block';
|
||||
@ -447,12 +578,13 @@ export default function initSolarSystem(ctx) {
|
||||
function exit() {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
closeMissions(false); hideCard();
|
||||
closeMissions(false); hideCard(); clearMoons();
|
||||
if (flightTick) { flightTick(); flightTick = null; } // stop any in-progress swoop
|
||||
stopAnim(); playing = false;
|
||||
scene.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); // clear planet orbit-lock before returning to Earth
|
||||
document.body.classList.remove('ss-active');
|
||||
ds.show = false;
|
||||
moonBB.show = moonLBL.show = moonPL.show = false;
|
||||
for (const [s, show] of saved.dataSources) s.show = show;
|
||||
for (const [p, show] of saved.primitives) p.show = show;
|
||||
scene.globe.show = saved.globe;
|
||||
@ -479,10 +611,8 @@ export default function initSolarSystem(ctx) {
|
||||
function travelTo(key) {
|
||||
const b = BODIES.find((x) => x.key === key);
|
||||
if (!b) return;
|
||||
if (key === 'earth') { // Earth = home; drop back to the OSINT globe
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
// Earth is a real destination like the rest (so you can see the Moon); the
|
||||
// "Return to Earth (OSINT)" button is the way back to the globe.
|
||||
if (missionsOn) closeMissions(false);
|
||||
// Pause the orrery, freeze every sphere to the current sim time and reveal them
|
||||
// (textured, aligned with their dots). The close-up is a constant-position
|
||||
@ -490,6 +620,7 @@ export default function initSolarSystem(ctx) {
|
||||
playing = false;
|
||||
freezeAll();
|
||||
showSpheres(true);
|
||||
buildMoonsFor(key); // only this planet's moons exist at a time
|
||||
showBodyCard(key); // infographic while you fly in
|
||||
const V = Cesium.Cartesian3;
|
||||
const pos = b.sun ? new V(0, 0, 0) : posOf(key, simDay);
|
||||
@ -527,7 +658,9 @@ export default function initSolarSystem(ctx) {
|
||||
function showBodyCard(key) {
|
||||
const f = FACTS[key]; if (!f) return hideCard();
|
||||
const b = BODIES.find((x) => x.key === key);
|
||||
fillCard(b.name, f.blurb, f.stats, '💡 ' + f.fact, b.color);
|
||||
const mn = MOONS[key] || [];
|
||||
const stats = mn.length ? [...f.stats, ['Moons shown', mn.map((m) => m.name).join(', ')]] : f.stats;
|
||||
fillCard(b.name, f.blurb, stats, '💡 ' + f.fact, b.color);
|
||||
}
|
||||
function showMissionCard(m) {
|
||||
const tgt = BODIES.find((b) => b.key === m.target);
|
||||
@ -562,7 +695,7 @@ export default function initSolarSystem(ctx) {
|
||||
}
|
||||
function openMissions() {
|
||||
missionsOn = true; missionsBtn.classList.add('active'); missionsWrap.style.display = 'block';
|
||||
playing = false; showSpheres(false); hideCard();
|
||||
playing = false; showSpheres(false); hideCard(); clearMoons();
|
||||
clearMissions(); drawMissions();
|
||||
scene.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||
flyCamera(OVERVIEW.pos, OVERVIEW.dir, OVERVIEW.up, 1.8);
|
||||
|
||||
35
js/ssdata.js
35
js/ssdata.js
@ -111,6 +111,41 @@ export const FACTS = {
|
||||
},
|
||||
};
|
||||
|
||||
// Notable moons per planet. `km` = mean radius, `a` = semi-major axis (km),
|
||||
// `P` = sidereal orbital period in days (negative = retrograde, e.g. Triton).
|
||||
// Real values — the renderer compresses size/distance so they read on screen.
|
||||
export const MOONS = {
|
||||
earth: [
|
||||
{ name: 'Moon', km: 1737.4, a: 384400, P: 27.322, color: '#c8c4bc' },
|
||||
],
|
||||
mars: [
|
||||
{ name: 'Phobos', km: 11.27, a: 9376, P: 0.31891, color: '#8a7f75' },
|
||||
{ name: 'Deimos', km: 6.2, a: 23463, P: 1.26244, color: '#9b9186' },
|
||||
],
|
||||
jupiter: [
|
||||
{ name: 'Io', km: 1821.6, a: 421700, P: 1.769, color: '#e8d16b' },
|
||||
{ name: 'Europa', km: 1560.8, a: 671100, P: 3.551, color: '#dcd6c8' },
|
||||
{ name: 'Ganymede', km: 2634.1, a: 1070400, P: 7.155, color: '#a89b8c' },
|
||||
{ name: 'Callisto', km: 2410.3, a: 1882700, P: 16.689, color: '#6e6459' },
|
||||
],
|
||||
saturn: [
|
||||
{ name: 'Enceladus', km: 252.1, a: 238040, P: 1.370, color: '#f2f7fa' },
|
||||
{ name: 'Rhea', km: 763.8, a: 527108, P: 4.518, color: '#cfc9bf' },
|
||||
{ name: 'Titan', km: 2574.7, a: 1221870, P: 15.945, color: '#e8a24a' },
|
||||
{ name: 'Iapetus', km: 734.5, a: 3560820, P: 79.322, color: '#9c8f7e' },
|
||||
],
|
||||
uranus: [
|
||||
{ name: 'Miranda', km: 235.8, a: 129900, P: 1.413, color: '#b9bfc2' },
|
||||
{ name: 'Ariel', km: 578.9, a: 190900, P: 2.520, color: '#c9ced0' },
|
||||
{ name: 'Titania', km: 788.4, a: 436300, P: 8.706, color: '#b0b6b8' },
|
||||
{ name: 'Oberon', km: 761.4, a: 583500, P: 13.463, color: '#9aa0a3' },
|
||||
],
|
||||
neptune: [
|
||||
{ name: 'Proteus', km: 210, a: 117647, P: 1.122, color: '#8e9296' },
|
||||
{ name: 'Triton', km: 1353.4, a: 354759, P: -5.877, color: '#d8cfc9' }, // retrograde
|
||||
],
|
||||
};
|
||||
|
||||
// Missions: waypoints[0] is launch (Earth); the rest are real flyby / arrival dates
|
||||
// at the named body. Dates are ISO (UTC). `target` is the headline destination.
|
||||
export const MISSIONS = [
|
||||
|
||||
7
js/ui.js
7
js/ui.js
@ -5,16 +5,17 @@ const rows = new Map();
|
||||
|
||||
// Category scheme is owned here so HUD order is stable regardless of layer
|
||||
// registration order. Categories collapsed-by-default are marked below.
|
||||
const CATEGORY_ORDER = ['Space', 'Air', 'Sea', 'Earth & hazards', 'Context', 'Regional — Australia'];
|
||||
const COLLAPSED_BY_DEFAULT = new Set(['Regional — Australia']);
|
||||
const CATEGORY_ORDER = ['Space', 'Air', 'Sea', 'Earth & hazards', 'Context', 'Regional — Australia', 'Regional — London'];
|
||||
const COLLAPSED_BY_DEFAULT = new Set(['Regional — Australia', 'Regional — London']);
|
||||
const CATEGORY_BY_ID = {
|
||||
satellites: 'Space', 'sat-paths': 'Space', launches: 'Space', celestial: 'Space',
|
||||
aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air',
|
||||
emergency: 'Air', 'rare-types': 'Air', shadow: 'Air', radius: 'Air',
|
||||
ships: 'Sea',
|
||||
quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards',
|
||||
infra: 'Context', events: 'Context',
|
||||
infra: 'Context', events: 'Context', radio: 'Context',
|
||||
rfs: 'Regional — Australia',
|
||||
jamcams: 'Regional — London', inciweb: 'Earth & hazards',
|
||||
};
|
||||
|
||||
const sections = new Map(); // category → { body }
|
||||
|
||||
36
serve.py
36
serve.py
@ -83,6 +83,16 @@ _adsb_cache = {} # path -> (body, ts)
|
||||
_adsb_lock = threading.Lock()
|
||||
_adsb_next = [0.0] # earliest wall-time the next upstream call may start
|
||||
|
||||
# ---- Radio Garden proxy (proxy/rg/<path>) ------------------------------------
|
||||
# Unofficial API, no CORS header → needs this same-origin hop. Only two shapes
|
||||
# are forwardable (the allowlist prevents open-proxy/SSRF): the global places
|
||||
# list (~1.8 MB, changes rarely → 12 h cache) and one place's channel list
|
||||
# (1 h cache). Audio streams do NOT go through here — the browser's <audio>
|
||||
# element plays radio.garden/…/channel.mp3 directly (media loads need no CORS).
|
||||
RG_PATH_RE = re.compile(r'^ara/content/(places|page/[A-Za-z0-9]{4,16}/channels)$')
|
||||
RG_CACHE_SEC = {"ara/content/places": 43200} # default for others: 3600
|
||||
_rg_cache = {} # path -> (body, ts)
|
||||
|
||||
# ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------
|
||||
# proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and
|
||||
# HARD-caches the /v2/mil/ response — the quota guard. The $10 BASIC tier bills
|
||||
@ -201,6 +211,8 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
return self._serve_mil()
|
||||
if name.startswith("adsb/"):
|
||||
return self._serve_adsb(name[len("adsb/"):])
|
||||
if name.startswith("rg/"):
|
||||
return self._serve_rg(name[len("rg/"):])
|
||||
if name == "adsbx-mil":
|
||||
return self._serve_adsbx_mil()
|
||||
base = UPSTREAMS.get(name)
|
||||
@ -365,6 +377,30 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
|
||||
return self._send_json(502, {"error": str(e)})
|
||||
|
||||
def _serve_rg(self, path):
|
||||
# Radio Garden proxy, allowlisted by RG_PATH_RE (places + channel lists).
|
||||
if not RG_PATH_RE.match(path):
|
||||
return self._send_json(400, {"error": "unsupported radio garden path"})
|
||||
hit = _rg_cache.get(path)
|
||||
ttl = RG_CACHE_SEC.get(path, 3600)
|
||||
if hit and time.time() - hit[1] < ttl:
|
||||
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "hit"})
|
||||
try:
|
||||
req = urllib.request.Request(f"https://radio.garden/api/{path}",
|
||||
headers={"User-Agent": "godsigh/1.0 (partly.party)"})
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
body = r.read()
|
||||
_rg_cache[path] = (body, time.time())
|
||||
return self._send(200, "application/json", body, {"X-Godsigh-Cache": "miss"})
|
||||
except urllib.error.HTTPError as e:
|
||||
if hit is not None: # stale-on-error keeps the layer alive
|
||||
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
|
||||
return self._send_json(e.code, {"error": f"radio.garden upstream {e.code}"})
|
||||
except Exception as e:
|
||||
if hit is not None:
|
||||
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
|
||||
return self._send_json(502, {"error": str(e)})
|
||||
|
||||
def _serve_adsbx_mil(self):
|
||||
host, key = load_adsbx_creds()
|
||||
if not host or not key:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user