feat: Wave 6.2 — swap paywalled Windy for free 511-network road cams

Windy moved its Webcams API behind a paid tier (only the in-site Plugins
API is free), so the windycams layer is gone before ever going live. In
its place: roadcams — official public traffic cameras from keyless 511
APIs (a North-America-standard platform, one adapter covers many regions).
Launching with Alberta (367) + Ontario (946); more regions are one entry
in REGIONS + serve.py CAMS_REGIONS + an nginx location each (several 511s
just need free keys). InfoBox descriptions are CallbackProperties, so the
snapshot cache-buster is computed at click time — always-fresh images with
zero entity rebuilds. Verified: 1,302 cams, live Toronto snapshot in-app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-29 15:01:19 +10:00
parent a435997a3d
commit 30b1381a8b
5 changed files with 109 additions and 123 deletions

81
js/layers/roadcams.js Normal file
View File

@ -0,0 +1,81 @@
// Road cams layer (Wave 6.2) — official public traffic cameras from keyless
// government APIs. The "511" platform is a North-America standard, so one
// adapter covers many regions; today: Alberta + Ontario (~1,100 cams). London
// has its own dedicated TfL layer. JSON comes via proxy/cams/<region> (no CORS
// upstream); snapshot IMAGES load directly in the InfoBox <img> (no CORS needed).
//
// Add a region = one entry in REGIONS here + serve.py CAMS_REGIONS + a prod
// nginx location. Several more 511s (Georgia, Nevada, New England…) just need
// their free API keys.
const REGIONS = [
{ id: 'alberta', name: 'Alberta' },
{ id: 'ontario', name: 'Ontario' },
];
export default function create(ctx) {
const { viewer, Cesium, lib, ui } = ctx;
const ds = new Cesium.CustomDataSource('roadcams');
ds.show = false;
viewer.dataSources.add(ds);
let enabled = false;
let loaded = false;
ui.addLayer('roadcams', 'Road cams (511 network)', false, (on) => {
enabled = on;
ds.show = on;
if (on && !loaded) load();
else if (on) ui.setStatus('roadcams', statusText(), 'ok');
else ui.setStatus('roadcams', '', 'ok');
}, 'Context');
const okRegions = [];
function statusText() {
return `${ds.entities.values.length} cams · ${okRegions.join(' · ') || '—'}`;
}
async function load() {
ui.setStatus('roadcams', 'loading cameras…', 'warn');
const results = await Promise.allSettled(REGIONS.map(async (r) => {
const res = await fetch(`proxy/cams/${r.id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return { region: r, cams: await res.json() };
}));
for (const rr of results) {
if (rr.status !== 'fulfilled') continue;
const { region, cams } = rr.value;
let n = 0;
for (const c of Array.isArray(cams) ? cams : []) {
if (!isFinite(c.Latitude) || !isFinite(c.Longitude)) continue;
const views = (c.Views || []).filter((v) => v && v.Url && v.Status !== 'Disabled');
if (!views.length) continue;
ds.entities.add({
position: Cesium.Cartesian3.fromDegrees(c.Longitude, c.Latitude),
point: {
pixelSize: 5, color: lib.cz('#ffb84d'),
outlineColor: Cesium.Color.BLACK, outlineWidth: 1,
disableDepthTestDistance: 50000,
},
// CallbackProperty: HTML is built the moment the InfoBox opens, so the
// snapshot cache-buster is always current — no stale images, no rebuilds.
description: new Cesium.CallbackProperty(() => {
const cb = Math.floor(Date.now() / 120000); // 2-min buckets
const imgs = views.slice(0, 2).map((v) =>
`<p><img src="${lib.escapeHtml(lib.safeUrl(v.Url))}?godsigh=${cb}" style="width:100%;border-radius:6px" alt="traffic cam"/>` +
(v.Description ? `<br/><span style="opacity:.7">${lib.escapeHtml(v.Description)}</span>` : '') + `</p>`).join('');
return `<p><b>${lib.escapeHtml(c.Location || c.Roadway || 'Camera')}</b></p>` + imgs +
`<p style="opacity:.7">${lib.escapeHtml([c.Roadway, region.name].filter(Boolean).join(' · '))} · 511 open data</p>`;
}, false),
});
n++;
}
if (n) okRegions.push(region.name);
}
loaded = true;
const anyFail = results.some((r) => r.status !== 'fulfilled');
if (enabled) ui.setStatus('roadcams', statusText() + (anyFail ? ' · some regions down' : ''), anyFail ? 'warn' : 'ok');
}
return { id: 'roadcams' };
}

View File

@ -1,87 +0,0 @@
// 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' };
}

View File

@ -185,7 +185,7 @@ const LAYER_MODULES = [
'./layers/celestial.js',
'./layers/radiogarden.js',
'./layers/firms.js',
'./layers/windycams.js',
'./layers/roadcams.js',
];
const activeLayers = [];

View File

@ -13,7 +13,7 @@ const CATEGORY_BY_ID = {
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', radio: 'Context', windycams: 'Context',
infra: 'Context', events: 'Context', radio: 'Context', roadcams: 'Context',
rfs: 'Regional — Australia',
jamcams: 'Regional — London', inciweb: 'Earth & hazards', firms: 'Earth & hazards',
};

View File

@ -93,7 +93,7 @@ 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)
# ---- Keyed upstreams: NASA FIRMS + Windy webcams -----------------------------
# ---- Keyed upstreams: NASA FIRMS --------------------------------------------
# Keys live in gitignored files at repo root and are injected SERVER-SIDE only
# (never reach the browser). Files are re-read on every request (they're tiny),
# so dropping in a fixed key needs no server restart. The loader is tolerant:
@ -117,9 +117,18 @@ def read_key_file(path):
FIRMS_CACHE_SEC = 3600 # FIRMS refreshes ~3-hourly; 1h cache is generous
_firms_cache = [None, 0.0] # (body, ts)
WINDY_NEARBY_RE = re.compile(r'^-?\d{1,2}(\.\d+)?,-?\d{1,3}(\.\d+)?,\d{1,3}$')
WINDY_CACHE_SEC = 600
_windy_cache = {} # rounded-nearby -> (body, ts)
# ---- Road cams (511-network et al.) ------------------------------------------
# Official public traffic-camera APIs with no key. The 511 platform is a
# North-America standard, so one adapter covers many regions; add entries here
# (and a matching prod nginx location) to light up more. JSON needs this proxy
# (no ACAO upstream); the snapshot IMAGES load directly in <img> (no CORS needed).
CAMS_REGIONS = {
"alberta": "https://511.alberta.ca/api/v2/get/cameras",
"ontario": "https://511on.ca/api/v2/get/cameras",
}
CAMS_CACHE_SEC = 1800 # camera LISTS are near-static; images are fetched live
_cams_cache = {} # region -> (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
@ -243,8 +252,8 @@ class Handler(SimpleHTTPRequestHandler):
return self._serve_rg(name[len("rg/"):])
if name == "firms":
return self._serve_firms()
if name == "windy":
return self._serve_windy(query)
if name.startswith("cams/"):
return self._serve_cams(name[len("cams/"):])
if name == "adsbx-mil":
return self._serve_adsbx_mil()
base = UPSTREAMS.get(name)
@ -455,41 +464,24 @@ class Handler(SimpleHTTPRequestHandler):
code = getattr(e, "code", 502)
return self._send_json(code if isinstance(code, int) else 502, {"error": "FIRMS upstream failed"})
def _serve_windy(self, query):
# Windy Webcams v3: cams near a point. Query is nearby=lat,lon,radius_km
# (validated), key goes in the x-windy-api-key HEADER server-side. Cache
# keyed on a coarsened nearby so panning nearby reuses entries.
params = urllib.parse.parse_qs(query)
nearby = (params.get("nearby") or [""])[0]
if not WINDY_NEARBY_RE.match(nearby):
return self._send_json(400, {"error": "bad nearby=lat,lon,radius_km"})
lat, lon, rad = nearby.split(",")
ck = f"{round(float(lat) * 4) / 4},{round(float(lon) * 4) / 4},{rad}"
hit = _windy_cache.get(ck)
if hit and time.time() - hit[1] < WINDY_CACHE_SEC:
def _serve_cams(self, region):
# Road cams: allowlisted official public feeds (see CAMS_REGIONS).
base = CAMS_REGIONS.get(region)
if not base:
return self._send_json(400, {"error": f"unknown cams region {region!r}"})
hit = _cams_cache.get(region)
if hit and time.time() - hit[1] < CAMS_CACHE_SEC:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "hit"})
key = read_key_file("windycredentials.json")
if not key:
return self._send_json(503, {"error": "no Windy key (windycredentials.json)"})
try:
url = ("https://api.windy.com/webcams/api/v3/webcams?"
f"nearby={nearby}&limit=50&include=location,images,player,urls")
req = urllib.request.Request(url, headers={
"User-Agent": "godsigh/1.0 (partly.party)", "x-windy-api-key": key})
with urllib.request.urlopen(req, timeout=15) as r:
req = urllib.request.Request(base, headers={"User-Agent": "godsigh/1.0 (partly.party)"})
with urllib.request.urlopen(req, timeout=20) as r:
data = r.read()
_windy_cache[ck] = (data, time.time())
_cams_cache[region] = (data, time.time())
return self._send(200, "application/json", data, {"X-Godsigh-Cache": "miss"})
except urllib.error.HTTPError as e:
if hit is not None:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
if e.code == 401:
return self._send_json(401, {"error": "Windy key rejected — needs a WEBCAMS API key (api.windy.com/webcams)"})
return self._send_json(e.code, {"error": f"windy upstream {e.code}"})
except Exception:
if hit is not None:
return self._send(200, "application/json", hit[0], {"X-Godsigh-Cache": "stale"})
return self._send_json(502, {"error": "windy upstream failed"})
return self._send_json(502, {"error": f"cams upstream {region} failed"})
def _serve_adsbx_mil(self):
host, key = load_adsbx_creds()