GODSIGH/js/layers/radiogarden.js
type-two 7ccb2214f9 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>
2026-07-29 13:57:26 +10:00

136 lines
5.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 };
}