Refactor quakes/fires onto a data-driven registry, then seed ~6 verified new real feeds (adsb.lol military [needs proxy], NWS alerts, GDACS disasters, Launch Library, NOAA aurora, NSW RFS bushfires) under a categorized collapsible HUD, plus NASA GIBS time-dimensioned imagery. GDELT news is investigate-first. CORS/shape ground truth for all 8 candidates probed and tabled in §1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
17 KiB
GODSIGH — Wave 3 Build Spec (the layer registry)
Audience: Claude Opus 4.8, executing autonomously in this repo.
Author: Claude Fable 5, 2026-07-13, after reviewing the completed Wave 1 + Wave 2 builds.
Prerequisite: Waves 1–2 are COMPLETE at 403884e. Read SPEC.md §2/§7/§10 and SPEC2.md §1/§8/§9 first; every rule there still binds (relative URLs, no build step, entities-vs-primitives, [DEMO] labeling, per-phase verify-then-commit). Deployment (SPEC.md §9) is unchanged and still gated on John.
0. Mission & the core idea
Wave 2 proved that most new layers are the same shape: fetch GeoJSON-ish data on an interval, drop styled entities on the globe, report status. quakes.js and fires.js are 90% identical boilerplate. That doesn't scale to the 15+ feeds we want.
Wave 3 makes adding a layer a data-entry task, not a coding task. Build a data-source registry + a generic GeoJSON layer factory, refactor the two Wave 2 real-data layers onto it to prove parity, then seed the registry with a batch of verified new feeds and a collapsible categorized HUD. The bespoke layers (satellites, aircraft, ships, infra, events) stay exactly as they are — they have real per-layer logic that a generic factory shouldn't swallow.
Done looks like: a js/registry.js manifest where one object literal = one working layer; quakes/fires rebuilt as registry entries with no behavior change; ~6 new real layers live (military aircraft, severe-weather alerts, global disasters, rocket launches, aurora, NSW bushfires) under a categorized, collapsible HUD; and a NASA GIBS time-dimensioned imagery option. Two feeds (GDELT news, adsb.lol proxying) are investigate-first.
1. Ground truth — verified 2026-07-13 by Fable (do not re-probe)
Probed with a browser-like Origin header from this machine:
| Feed | URL | CORS | Geometry / shape | Verdict |
|---|---|---|---|---|
| adsb.lol military | https://api.adsb.lol/v2/mil |
NO ACAO — needs proxy | {ac:[{hex,flight,lat,lon,alt_baro,gs,track,...}]}, ~251 ac |
proxy it |
| NWS alerts (US) | https://api.weather.gov/alerts/active?severity=Severe,Extreme |
ACAO: * |
GeoJSON FeatureCollection; features may have null geometry (skip those) |
direct |
| GDACS disasters | https://www.gdacs.org/gdacsapi/api/events/geteventlist/MAP |
ACAO: * |
GeoJSON FeatureCollection, Point geometry | direct |
| Launch Library 2 | https://ll.thespacedevs.com/2.2.0/launch/upcoming/?limit=30&mode=list |
ACAO: * |
JSON {results:[{name,net,pad:{latitude,longitude,location}}]} — NOT GeoJSON |
direct, custom adapter |
| NOAA aurora | https://services.swpc.noaa.gov/json/ovation_aurora_latest.json |
ACAO: * |
{coordinates:[[lon,lat,aurora%],...]} ~920 KB grid — NOT points |
direct, special layer |
| NSW RFS bushfires | https://www.rfs.nsw.gov.au/feeds/majorIncidents.json |
ACAO: * |
GeoJSON; features carry GeometryCollection (Point + Polygon) |
direct |
| NASA GIBS | https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/{Layer}/default/{Time}/{TileMatrixSet}/{z}/{y}/{x}.jpg |
ACAO: * |
WMTS raster tiles, time-dimensioned | direct, imagery |
| GDELT geo | https://api.gdeltproject.org/api/v2/geo/geo?query=…&format=GeoJSON |
404 on my query formats | GeoJSON when the query is right, but params are finicky | investigate-first |
adsb.lol has no rate limit or key (community ADS-B). GIBS/NWS/GDACS/Launch Library/NOAA are all free, keyless, public.
2. Locked design decisions
- Registry, not inheritance.
js/registry.jsdefault-exports an array of plain-object layer specs.main.jsiterates it and instantiates each via the generic factory. Bespoke layers stay as their own modules inLAYER_MODULES— the registry is additive, both lists load. - Two generic factories, not one.
js/layers/geojson-layer.js(entity layers from point/polygon GeoJSON) andjs/layers/imagery-layer.js(WMTS/tile overlays like GIBS). A feed that's neither (aurora grid, Launch Library's non-GeoJSON JSON) either gets a smalladapt()function in its registry entry that returns a normalized feature array, or stays a bespoke module — decide per feed (§5). - Everything relative or full-
https://. Same rule as always: same-origin paths relative (proxy/…), cross-origin feeds are fullhttps://URLs living only in the registry. The subpath audit (grep -rnE "fetch\(['\"]/|src=['\"]/" js/) must stay empty. - No behavior regressions. After refactoring quakes/fires onto the factory, the app must look and behave identically — same counts, colors, time-anchoring, labels, statuses. Verify side-by-side against the current build.
- Categories + collapse. At 12+ layers a flat HUD is unusable. Group rows by category, collapsible, most new layers default OFF to keep first paint clean and quota/clutter down.
[DEMO]rule still holds — but note almost everything in this wave is REAL. Only label demo data.
3. Phase 1 — the GeoJSON layer factory
New file js/layers/geojson-layer.js. Export createGeoJsonLayer(ctx, spec) returning the standard { id, onClockTick?, ... } contract object. It must express everything quakes/fires need, driven by spec:
// A registry entry consumed by createGeoJsonLayer.
{
id: 'quakes',
name: 'Earthquakes (USGS)',
category: 'Earth',
defaultOn: true,
url: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson',
refreshMs: 300000,
// Turn a raw response into an array of normalized features. Default assumes
// GeoJSON FeatureCollection; override for odd shapes.
adapt: (json) => json.features,
// Pull geometry → {lon, lat} from one feature (handles Point, Polygon-centroid,
// GeometryCollection — factor the fires.js/quakes.js logic here as the default).
// Return null to skip a feature.
locate: (f) => ({ lon, lat }),
// Stable id for dedupe across refreshes; return null to rebuild-wholesale each time.
featureId: (f) => f.id,
// Optional: availability start for time-anchoring (return a Date/ms, or null for always-on).
timeAt: (f) => f.properties.time,
// Visual spec per feature.
style: (f) => ({
color: '#ff3b30', pixelSize: 8, // OR glyph: sharedCanvas / billboard opts
label: 'M5.2 …' | null, // null = no label (declutter)
labelFade: [2.0e6, 1.2e7], // NearFarScalar near/far, optional
pulseIf: (f) => boolean, // recent-event pulse, optional
}),
description: (f) => '<table>…</table>', // InfoBox HTML
cap: 400, // max entities; drop by a sort key
sortKey: (f) => f.properties.mag, // for the cap (keep highest)
status: ({shown, extra}) => `${shown} quakes${extra}`, // HUD status text
}
Requirements the factory must honor (all already solved in quakes.js/fires.js — lift the logic):
Promise-safe polling loop (self-schedulingsetTimeout,inFlightguard, keeps polling regardless of scrub since these are historical/live-record feeds).- Dedupe-by-id with revision detection (rebuild an entity when its content signature changes — quakes.js already does this; make it the default when
featureIdis provided). - Time-anchoring via
timeAt(clamp intervals to[ctx.start, ctx.stop], exactly quakes.js'savailabilityFor). - Cap by
sortKey(drop smallest/least-important first). - Never trust
Content-Type(EONET lies — §SPEC2). Guard null geometry, null coords, missing props everywhere. - Full error→status reporting (
ok/warn/err), no silent failures. - Register its HUD row + category via the new UI API (§6).
Do not build any real feed yet — just the factory + a trivial inline test spec, verified to render.
4. Phase 2 — refactor quakes & fires onto the factory (parity proof)
Rebuild quakes and fires as registry entries (move them into registry.js), delete js/layers/quakes.js and js/layers/fires.js, and remove them from LAYER_MODULES. This is the acid test that the factory is expressive enough.
- Quakes entry:
timeAt= origin time (keeps the appears-as-slider-crosses behavior), amber→redmagColorramp instyle, labels for M≥4.5, revision-aware dedupe, cap 400 by magnitude, the pulse-if-<1h. - Fires entry: no
timeAt(wholesale rebuild), flame glyph billboard, aggressive label fade, cap 500. - Verify parity in the browser: counts, colors, labels, time-anchoring (scrub back → quakes vanish), statuses must match the pre-refactor build exactly. Screenshot both before/after if unsure. Commit only when identical.
5. Phase 3 — seed the registry with new real layers
Add these as registry entries (or bespoke where noted). Each gets a category, defaultOn: false unless noted, verify each renders, commit per feed or in small batches.
Category "Air":
- Military aircraft (
adsb.lol/v2/mil) — needs the proxy: addmiltoserve.py'sUPSTREAMS(https://api.adsb.lol/v2/mil) and an nginx block in SPEC.md §9's list (same resolver caveat — document it). Fetchproxy/mil. This is a BillboardCollection primitive like civil aircraft, NOT entities (could be hundreds) — so it may be a bespoke module reusing aircraft.js's rendering, or a factory "primitive mode." Judge which is cleaner; if bespoke, factor the shared billboard build out of aircraft.js. Tint military-red#ff5964, glyph rotated bytrack, click overlay withflight/hex/alt/speed. Poll ~60 s (no quota limit). This supersedes Wave 2's callsign-guess military filter — keep that filter for civil OpenSky, but real mil data is separate and better.
Category "Human":
- Severe weather alerts (
api.weather.gov/alerts/active?severity=Severe,Extreme) — GeoJSON factory. Many features have polygon geometry AND some havenullgeometry (skip null). Style: amber translucent polygons (PolygonHierarchyfrom the ring) + a point+label at the centroid;timeAt=properties.onsetorsent(time-anchored). US-only — note that in the description.defaultOn: false. - Global disasters (
gdacs.org/…/geteventlist/MAP) — GeoJSON factory, Point geometry. Icon/color bypropertiesevent type (EQ/TC/FL/VO/DR/WF) and alert level (green/orange/red). Label the event name. Global.timeAtif a timestamp is present.
Category "Space":
- Rocket launches (Launch Library 2) — NOT GeoJSON, so
adapt: (j) => j.results+locate: (r) => ({lon: r.pad.latitude, lon…})readingpad.latitude/longitude. Time-anchor tonet(the launch window) so upcoming launches appear as the slider reaches them — but note mostnetvalues are in the FUTURE beyond the +6h window, so also render them statically at their pad with a "T-…" countdown in the label/description. A rising rocket glyph.defaultOn: false. - Aurora oval (NOAA OVATION) — NOT points, it's a ~920 KB
[[lon,lat,prob],...]grid. This is a bespoke module, not a factory layer: sample the grid (every Nth point, prob>10), render as a translucent green point cloud or a coarse polygon band near both poles, alpha ∝ probability. Refresh ~5 min.defaultOn: false. Gorgeous in Photo mode.
Category "Regional — Australia" (collapsed by default):
- NSW RFS bushfires (
rfs.nsw.gov.au/feeds/majorIncidents.json) — GeoJSON factory. Features carryGeometryCollection(Point + Polygon) —locateuses the Point; optionally draw the polygon fire-ground. Color bypropertiesalert level (advice/watch-act/emergency). Label the incident name.timeAtfrom the pubDate if present. This is John's home-turf detail pack; a stub for adding VIC/QLD equivalents later.
6. Phase 4 — categorized collapsible HUD
Extend js/ui.js:
addLayer(id, name, defaultOn, onToggle, category)— new optionalcategory(default'Core'). Rows render grouped under a collapsible category header (▸/▾), with a per-category count and a category-level show/hide-all toggle. Preserve the existing flat API for the bespoke layers (they land in'Core').- Categories in display order: Core, Space, Air, Sea, Earth, Human, Regional — Australia. Bespoke Wave 1 layers map to Core/Space/Air/Sea; quakes→Earth, fires→Earth (or a "Land" category — your call, keep it sensible).
getLayerIds/getLayerChecked/setLayerCheckedmust keep working (URL-state depends on them). Collapsed state is UI-only — do NOT serialize it into the hash (keep the hash stable).- Keep the HUD scrollable and within its existing max-height; categories collapsed-by-default for Regional and any all-off category, expanded for Core.
- CSS: match the existing dark HUD idiom (the category header styled like a quiet section divider). Don't fight Cesium widget CSS.
7. Phase 5 — NASA GIBS imagery option
New file js/layers/imagery-layer.js + registry support for kind: 'imagery' entries. GIBS is time-dimensioned raster — a third basemap-ish skin:
- Add a GIBS
UrlTemplateImageryProviderwhose{Time}is driven by the Cesium clock (the current day,YYYY-MM-DD; MODIS true-color is daily). Start withMODIS_Terra_CorrectedReflectance_TrueColor(GoogleMapsCompatible_Level9,.jpg), andVIIRS_SNPP_Thermal_Anomalies_375m_Allas a fire overlay option. - This is NOT a HUD entity layer — it's an imagery layer with an opacity slider, sitting above the Photo basemap. A small "Imagery" section in the HUD (or a mode addition): off by default; when on, add the layer to
viewer.imageryLayerswithalpha ~0.85. - When the clock is scrubbed to a past day, refetch tiles for that date (GIBS has years of history) — a genuine "satellite imagery time machine." Debounce on day-change, not every tick.
- Attribution: "NASA EOSDIS GIBS" — add to the credits.
- If wiring the clock→time dimension cleanly proves fiddly, ship it fixed to "yesterday" (today's mosaic is often incomplete) with a note, rather than half-working scrubbing.
8. Phase 6 — investigate-first: GDELT geocoded news
Time-boxed, same discipline as the Wave 2 GPS-jam stretch:
- GDELT's GEO 2.0 API returned 404 on my
?query=protest&format=GeoJSONattempts — the query grammar is particular. Investigate the correct params (it wants a specificquery=DSL andformat=GeoJSON; checkhttps://blog.gdeltproject.orgGEO 2.0 docs). Confirm a working URL that returns GeoJSON points withACAO. - If it works: a GeoJSON-factory layer, category "Human",
defaultOn: false, points at article locations, label = the location name, description links the source articles,timeAtfrom the article time if present. Note GDELT's terms/attribution. - If the query grammar stays ambiguous or CORS blocks it: document findings in the README roadmap and stop. Don't scrape around it.
9. Verification & workflow
- Per phase: SPEC.md §7 protocol (clean console on fresh load, network 200s, screenshots, HUD statuses, toggle checks) + the phase's own checks.
node --checkevery touched JS file. Subpath audit stays empty. - Parity is the headline check for Phase 2 — the refactor must be behavior-invisible.
- The preview tab backgrounds itself (
document.hidden), pausing live polls and rAF — Waves 1–2 documented the workarounds (driveonClockTickdirectly;viewer.render()beforetoDataURL; overridedocument.hiddento force a poll). Reuse them. - Commit per phase (
wave3 phase N: …), push after each.Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>. - Update README per phase: the layers table grows a lot — consider grouping it by category to match the HUD. Mark which feeds are REAL (nearly all) vs DEMO (ships/events only). Add attributions for every new source (adsb.lol, NWS, GDACS, Launch Library/The Space Devs, NOAA SWPC, NSW RFS, NASA GIBS).
- Run an adversarial-review workflow over the new code before the final commit — the Waves 1–2 recipe (reviewers by dimension → verify each finding → apply confirmed). Focus dimensions: factory correctness/expressiveness, parity of the refactored layers, CORS/subpath safety, HUD state + URL-state interaction, quota/perf (poll cadences, the 920 KB aurora grid, hundreds of mil billboards).
10. Deployment note (for whenever John runs SPEC.md §9)
- New same-origin proxy upstream:
mil→https://api.adsb.lol/v2/mil. Add the nginxlocationblock alongside opensky/celestrak, with the resolver directive (the §9 gotcha — a variable inproxy_passforces request-time DNS; no resolver ⇒ 502 whilenginx -tpasses). All other Wave 3 feeds are directhttps://and need zero prod config. - Everything else deploys as plain static files. The registry is just JS.
11. Out of scope for Wave 3
GPS-jam (still gated on licensing per Wave 2), oil-futures panel, Cesium ion terrain, VPS-side recorder, accounts/auth, live AIS by default, VIC/QLD/other regional packs beyond the NSW stub. Roadmap only.