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 */
|
/* Keep Cesium's info box on-theme without fighting it */
|
||||||
.cesium-infoBox { font-family: ui-monospace, monospace; }
|
.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 ---- */
|
/* ---- Solar System mode ---- */
|
||||||
#ss-btn {
|
#ss-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -131,7 +131,10 @@ export function createGeoJsonLayer(ctx, spec) {
|
|||||||
}
|
}
|
||||||
let json;
|
let json;
|
||||||
try {
|
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 {
|
} catch {
|
||||||
ui.setStatus(spec.id, 'bad response — retrying', 'err');
|
ui.setStatus(spec.id, 'bad response — retrying', 'err');
|
||||||
return;
|
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/military.js',
|
||||||
'./layers/radius.js',
|
'./layers/radius.js',
|
||||||
'./layers/celestial.js',
|
'./layers/celestial.js',
|
||||||
|
'./layers/radiogarden.js',
|
||||||
];
|
];
|
||||||
const activeLayers = [];
|
const activeLayers = [];
|
||||||
|
|
||||||
|
|||||||
@ -206,8 +206,94 @@ export const REGISTRY = [
|
|||||||
},
|
},
|
||||||
status: ({ shown }) => `${shown} NSW incidents`,
|
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.
|
// GDACS event-type codes → readable prefix.
|
||||||
const TYPE = { EQ: '🌍 Quake', TC: '🌀 Cyclone', FL: '🌊 Flood', DR: '🏜 Drought', VO: '🌋 Volcano', WF: '🔥 Wildfire', TS: '🌊 Tsunami' };
|
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
|
// 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.
|
// 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) {
|
export default function initSolarSystem(ctx) {
|
||||||
const { viewer, Cesium, lib } = 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 sphereEntities = {}; // key → textured ellipsoid (frozen position, visible up close)
|
||||||
const dotEntities = {}; // key → colored dot + label (animates along the orbit)
|
const dotEntities = {}; // key → colored dot + label (animates along the orbit)
|
||||||
const ringEntities = {}; // key → Saturn ring plane
|
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.
|
// 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
|
// 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
|
// orientation pushes the ellipsoid onto Cesium's dynamic-geometry path, which
|
||||||
// only supports solid-colour materials and renders the planet blank white. A
|
// only supports solid-colour materials and renders the planet blank white. A
|
||||||
// static orientation keeps the image material; you still orbit planets by hand.
|
// 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);
|
const X_AXIS = new Cesium.Cartesian3(1, 0, 0);
|
||||||
function tiltQuat(b) {
|
function tiltQuat(b) {
|
||||||
return Cesium.Quaternion.fromAxisAngle(X_AXIS, (b.obliq || 0) * D2R);
|
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));
|
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
|
// Snapshot ALL planet spheres+rings forward to the current sim time (constant
|
||||||
// position keeps the image material). Freezing every body — not just the target —
|
// position keeps the image material). Freezing every body — not just the target —
|
||||||
// keeps any sphere that drifts into view aligned with its animated dot.
|
// keeps any sphere that drifts into view aligned with its animated dot.
|
||||||
@ -369,6 +498,7 @@ export default function initSolarSystem(ctx) {
|
|||||||
};
|
};
|
||||||
function goOverview() {
|
function goOverview() {
|
||||||
hideCard();
|
hideCard();
|
||||||
|
clearMoons();
|
||||||
playing = !missionsOn; // resume the orrery (stay paused if browsing missions)
|
playing = !missionsOn; // resume the orrery (stay paused if browsing missions)
|
||||||
showSpheres(false); // dots-only
|
showSpheres(false); // dots-only
|
||||||
flyCamera(OVERVIEW.pos, OVERVIEW.dir, OVERVIEW.up, 2.0);
|
flyCamera(OVERVIEW.pos, OVERVIEW.dir, OVERVIEW.up, 2.0);
|
||||||
@ -427,6 +557,7 @@ export default function initSolarSystem(ctx) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ds.show = true;
|
ds.show = true;
|
||||||
|
moonBB.show = moonLBL.show = moonPL.show = true;
|
||||||
document.body.classList.add('ss-active'); // CSS hides the OSINT timeline/animation chrome
|
document.body.classList.add('ss-active'); // CSS hides the OSINT timeline/animation chrome
|
||||||
document.getElementById('hud').style.display = 'none';
|
document.getElementById('hud').style.display = 'none';
|
||||||
panel.style.display = 'block';
|
panel.style.display = 'block';
|
||||||
@ -447,12 +578,13 @@ export default function initSolarSystem(ctx) {
|
|||||||
function exit() {
|
function exit() {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
active = false;
|
active = false;
|
||||||
closeMissions(false); hideCard();
|
closeMissions(false); hideCard(); clearMoons();
|
||||||
if (flightTick) { flightTick(); flightTick = null; } // stop any in-progress swoop
|
if (flightTick) { flightTick(); flightTick = null; } // stop any in-progress swoop
|
||||||
stopAnim(); playing = false;
|
stopAnim(); playing = false;
|
||||||
scene.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); // clear planet orbit-lock before returning to Earth
|
scene.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); // clear planet orbit-lock before returning to Earth
|
||||||
document.body.classList.remove('ss-active');
|
document.body.classList.remove('ss-active');
|
||||||
ds.show = false;
|
ds.show = false;
|
||||||
|
moonBB.show = moonLBL.show = moonPL.show = false;
|
||||||
for (const [s, show] of saved.dataSources) s.show = show;
|
for (const [s, show] of saved.dataSources) s.show = show;
|
||||||
for (const [p, show] of saved.primitives) p.show = show;
|
for (const [p, show] of saved.primitives) p.show = show;
|
||||||
scene.globe.show = saved.globe;
|
scene.globe.show = saved.globe;
|
||||||
@ -479,10 +611,8 @@ export default function initSolarSystem(ctx) {
|
|||||||
function travelTo(key) {
|
function travelTo(key) {
|
||||||
const b = BODIES.find((x) => x.key === key);
|
const b = BODIES.find((x) => x.key === key);
|
||||||
if (!b) return;
|
if (!b) return;
|
||||||
if (key === 'earth') { // Earth = home; drop back to the OSINT globe
|
// Earth is a real destination like the rest (so you can see the Moon); the
|
||||||
exit();
|
// "Return to Earth (OSINT)" button is the way back to the globe.
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (missionsOn) closeMissions(false);
|
if (missionsOn) closeMissions(false);
|
||||||
// Pause the orrery, freeze every sphere to the current sim time and reveal them
|
// 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
|
// (textured, aligned with their dots). The close-up is a constant-position
|
||||||
@ -490,6 +620,7 @@ export default function initSolarSystem(ctx) {
|
|||||||
playing = false;
|
playing = false;
|
||||||
freezeAll();
|
freezeAll();
|
||||||
showSpheres(true);
|
showSpheres(true);
|
||||||
|
buildMoonsFor(key); // only this planet's moons exist at a time
|
||||||
showBodyCard(key); // infographic while you fly in
|
showBodyCard(key); // infographic while you fly in
|
||||||
const V = Cesium.Cartesian3;
|
const V = Cesium.Cartesian3;
|
||||||
const pos = b.sun ? new V(0, 0, 0) : posOf(key, simDay);
|
const pos = b.sun ? new V(0, 0, 0) : posOf(key, simDay);
|
||||||
@ -527,7 +658,9 @@ export default function initSolarSystem(ctx) {
|
|||||||
function showBodyCard(key) {
|
function showBodyCard(key) {
|
||||||
const f = FACTS[key]; if (!f) return hideCard();
|
const f = FACTS[key]; if (!f) return hideCard();
|
||||||
const b = BODIES.find((x) => x.key === key);
|
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) {
|
function showMissionCard(m) {
|
||||||
const tgt = BODIES.find((b) => b.key === m.target);
|
const tgt = BODIES.find((b) => b.key === m.target);
|
||||||
@ -562,7 +695,7 @@ export default function initSolarSystem(ctx) {
|
|||||||
}
|
}
|
||||||
function openMissions() {
|
function openMissions() {
|
||||||
missionsOn = true; missionsBtn.classList.add('active'); missionsWrap.style.display = 'block';
|
missionsOn = true; missionsBtn.classList.add('active'); missionsWrap.style.display = 'block';
|
||||||
playing = false; showSpheres(false); hideCard();
|
playing = false; showSpheres(false); hideCard(); clearMoons();
|
||||||
clearMissions(); drawMissions();
|
clearMissions(); drawMissions();
|
||||||
scene.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
scene.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
|
||||||
flyCamera(OVERVIEW.pos, OVERVIEW.dir, OVERVIEW.up, 1.8);
|
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
|
// 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.
|
// at the named body. Dates are ISO (UTC). `target` is the headline destination.
|
||||||
export const MISSIONS = [
|
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
|
// Category scheme is owned here so HUD order is stable regardless of layer
|
||||||
// registration order. Categories collapsed-by-default are marked below.
|
// registration order. Categories collapsed-by-default are marked below.
|
||||||
const CATEGORY_ORDER = ['Space', 'Air', 'Sea', 'Earth & hazards', 'Context', 'Regional — Australia'];
|
const CATEGORY_ORDER = ['Space', 'Air', 'Sea', 'Earth & hazards', 'Context', 'Regional — Australia', 'Regional — London'];
|
||||||
const COLLAPSED_BY_DEFAULT = new Set(['Regional — Australia']);
|
const COLLAPSED_BY_DEFAULT = new Set(['Regional — Australia', 'Regional — London']);
|
||||||
const CATEGORY_BY_ID = {
|
const CATEGORY_BY_ID = {
|
||||||
satellites: 'Space', 'sat-paths': 'Space', launches: 'Space', celestial: 'Space',
|
satellites: 'Space', 'sat-paths': 'Space', launches: 'Space', celestial: 'Space',
|
||||||
aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air',
|
aircraft: 'Air', military: 'Air', 'aircraft-mil': 'Air',
|
||||||
emergency: 'Air', 'rare-types': 'Air', shadow: 'Air', radius: 'Air',
|
emergency: 'Air', 'rare-types': 'Air', shadow: 'Air', radius: 'Air',
|
||||||
ships: 'Sea',
|
ships: 'Sea',
|
||||||
quakes: 'Earth & hazards', fires: 'Earth & hazards', gdacs: 'Earth & hazards', nws: 'Earth & hazards',
|
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',
|
rfs: 'Regional — Australia',
|
||||||
|
jamcams: 'Regional — London', inciweb: 'Earth & hazards',
|
||||||
};
|
};
|
||||||
|
|
||||||
const sections = new Map(); // category → { body }
|
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_lock = threading.Lock()
|
||||||
_adsb_next = [0.0] # earliest wall-time the next upstream call may start
|
_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) ------------
|
# ---- ADS-B Exchange military feed (paid RapidAPI, ~10k req/month) ------------
|
||||||
# proxy/adsbx-mil injects the RapidAPI key server-side (never in the browser) and
|
# 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
|
# 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()
|
return self._serve_mil()
|
||||||
if name.startswith("adsb/"):
|
if name.startswith("adsb/"):
|
||||||
return self._serve_adsb(name[len("adsb/"):])
|
return self._serve_adsb(name[len("adsb/"):])
|
||||||
|
if name.startswith("rg/"):
|
||||||
|
return self._serve_rg(name[len("rg/"):])
|
||||||
if name == "adsbx-mil":
|
if name == "adsbx-mil":
|
||||||
return self._serve_adsbx_mil()
|
return self._serve_adsbx_mil()
|
||||||
base = UPSTREAMS.get(name)
|
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(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
|
||||||
return self._send_json(502, {"error": str(e)})
|
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):
|
def _serve_adsbx_mil(self):
|
||||||
host, key = load_adsbx_creds()
|
host, key = load_adsbx_creds()
|
||||||
if not host or not key:
|
if not host or not key:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user