- firms (bespoke): raw VIIRS S-NPP fire detections, world, 24h. CSV via new serve.py proxy/firms (key from gitignored firmscredentials.json, injected server-side, 1h cache + stale-on-error). Points colored/sized by fire radiative power; click -> FRP/brightness/time/satellite overlay. Verified live: 1412 detections, hottest 88 MW seen 00:35 UTC today. - windycams (bespoke): viewport-driven Windy Webcams v3 — nearest ~50 cams wherever the camera stops, entities with live preview img in InfoBox. proxy/windy validates nearby=lat,lon,km, injects x-windy-api-key header, 10min coarsened cache. Degrades cleanly: 'key rejected' status until a real WEBCAMS-product key lands in windycredentials.json (re-read per request, no restart needed). - key loader tolerant of JSON, brace-less fragments, or raw pasted tokens. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
88 lines
3.6 KiB
JavaScript
88 lines
3.6 KiB
JavaScript
// Windy webcams layer (Wave 6.1) — live webcams near wherever you're looking,
|
|
// worldwide. Viewport-driven like the radius layer: each time the camera stops
|
|
// moving we ask proxy/windy for the ~50 nearest cams (key injected server-side,
|
|
// 10 min cache, coarsened cache key so nearby pans reuse entries). Cams render
|
|
// as entities so the InfoBox shows the live preview image + a player link.
|
|
|
|
export default function create(ctx) {
|
|
const { viewer, Cesium, lib, ui } = ctx;
|
|
const ds = new Cesium.CustomDataSource('windycams');
|
|
ds.show = false;
|
|
viewer.dataSources.add(ds);
|
|
|
|
let enabled = false;
|
|
let inFlight = false;
|
|
let debounce = null;
|
|
let moveUnsub = null;
|
|
|
|
ui.addLayer('windycams', 'Webcams (Windy)', false, (on) => {
|
|
enabled = on;
|
|
ds.show = on;
|
|
if (on) {
|
|
moveUnsub = viewer.camera.moveEnd.addEventListener(scheduleRefresh);
|
|
refresh();
|
|
} else {
|
|
if (moveUnsub) { moveUnsub(); moveUnsub = null; }
|
|
if (debounce) { clearTimeout(debounce); debounce = null; }
|
|
ui.setStatus('windycams', '', 'ok');
|
|
}
|
|
}, 'Context');
|
|
|
|
function scheduleRefresh() {
|
|
if (!enabled) return;
|
|
if (debounce) clearTimeout(debounce);
|
|
debounce = setTimeout(refresh, 400);
|
|
}
|
|
|
|
function viewCenter() {
|
|
const c = Cesium.Cartographic.fromCartesian(viewer.camera.positionWC);
|
|
const km = Math.min(250, Math.max(15, Math.round(c.height / 2000)));
|
|
return { lat: Cesium.Math.toDegrees(c.latitude), lon: Cesium.Math.toDegrees(c.longitude), km };
|
|
}
|
|
|
|
async function refresh() {
|
|
if (!enabled || inFlight) return;
|
|
inFlight = true;
|
|
try {
|
|
const { lat, lon, km } = viewCenter();
|
|
let res;
|
|
try {
|
|
res = await fetch(`proxy/windy?nearby=${lat.toFixed(2)},${lon.toFixed(2)},${km}`);
|
|
} catch {
|
|
ui.setStatus('windycams', 'network error', 'warn'); return;
|
|
}
|
|
if (res.status === 401) { ui.setStatus('windycams', 'key rejected — needs a Webcams API key', 'err'); return; }
|
|
if (res.status === 503) { ui.setStatus('windycams', 'no key — add windycredentials.json', 'err'); return; }
|
|
if (!res.ok) { ui.setStatus('windycams', `HTTP ${res.status}`, 'warn'); return; }
|
|
let j;
|
|
try { j = await res.json(); } catch { ui.setStatus('windycams', 'bad response', 'err'); return; }
|
|
const cams = j.webcams || [];
|
|
ds.entities.removeAll();
|
|
for (const w of cams) {
|
|
const loc = w.location || {};
|
|
if (!isFinite(loc.latitude) || !isFinite(loc.longitude)) continue;
|
|
const img = lib.safeUrl(((w.images || {}).current || {}).preview);
|
|
const detail = lib.safeUrl((w.urls || {}).detail || ((w.player || {}).day));
|
|
ds.entities.add({
|
|
position: Cesium.Cartesian3.fromDegrees(loc.longitude, loc.latitude),
|
|
point: {
|
|
pixelSize: 6, color: lib.cz('#ffd166'),
|
|
outlineColor: Cesium.Color.BLACK, outlineWidth: 1,
|
|
disableDepthTestDistance: 50000,
|
|
},
|
|
description:
|
|
`<p><b>${lib.escapeHtml(w.title || 'Webcam')}</b></p>` +
|
|
(img ? `<p><img src="${lib.escapeHtml(img)}" style="width:100%;border-radius:6px" alt="webcam preview"/></p>` : '') +
|
|
`<p style="opacity:.7">${lib.escapeHtml([loc.city, loc.country].filter(Boolean).join(' · '))}</p>` +
|
|
(detail ? `<p><a href="${lib.escapeHtml(detail)}" target="_blank" rel="noopener">Open live player ↗</a></p>` : ''),
|
|
});
|
|
}
|
|
ui.setStatus('windycams', `${ds.entities.values.length} cams near view${j.total ? ` · ${j.total} in area` : ''}`, 'ok');
|
|
} finally {
|
|
inFlight = false;
|
|
}
|
|
}
|
|
|
|
return { id: 'windycams' };
|
|
}
|