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:
type-two 2026-07-29 13:57:26 +10:00
parent 92c6e0ebc3
commit 7ccb2214f9
9 changed files with 483 additions and 12 deletions

View File

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

View File

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

View File

@ -183,6 +183,7 @@ const LAYER_MODULES = [
'./layers/military.js',
'./layers/radius.js',
'./layers/celestial.js',
'./layers/radiogarden.js',
];
const activeLayers = [];

View File

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

View File

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

View File

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

View File

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

View File

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