GODSIGH/js/layers/geojson-layer.js
jing 941f5032e0 wave3: layer registry + generic GeoJSON factory (+ 4 new free layers)
- js/layers/geojson-layer.js: one factory expressing fetch/dedupe+revision/
  time-anchoring/cap/style/status — adding a GeoJSON feed is now data entry
- js/registry.js: quakes & fires refactored onto it (verified parity — same
  counts/colors/time-anchoring), plus 4 new REAL layers, all default-off:
  GDACS disasters (93), NWS severe weather US (13), Launch Library (30),
  NSW RFS bushfires (21)
- deleted js/layers/quakes.js + fires.js (now registry entries); removed dead
  CONFIG.quakes/fires blocks
- factory honors default-off (ds.show synced to enabled); zero console errors

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:26:13 +10:00

191 lines
7.3 KiB
JavaScript

// Generic GeoJSON-ish layer factory (Wave 3). One function that expresses what
// quakes.js and fires.js used to hand-roll, driven entirely by a `spec` object
// from js/registry.js — so adding a feed is data entry, not new code.
//
// A spec (all but id/name/url optional; sensible defaults shown in registry.js):
// { id, name, category, defaultOn, url, refreshMs,
// adapt(json) -> raw feature array (default: json.features)
// usable(f) -> bool (default: finite lon/lat)
// locate(f) -> {lon,lat} | null (default: GeoJSON geometry)
// featureId(f) -> string | null (null => rebuild wholesale)
// sig(f) -> string (revision key; default props+coords)
// timeAt(f) -> ms|Date|null (non-null => time-anchored availability)
// sortKey(f) -> number (cap keeps highest; default 0)
// cap -> number
// style(f, ctx) -> { point?, billboard?, label? } Cesium graphics options
// description(f, ctx) -> html
// status({shown, features}) -> string }
export function createGeoJsonLayer(ctx, spec) {
const { viewer, Cesium, lib, ui, start, stop } = ctx;
const ds = new Cesium.CustomDataSource(spec.id);
viewer.dataSources.add(ds);
const refreshMs = spec.refreshMs || 300000;
const adapt = spec.adapt || ((json) => (json && json.features) || []);
const locate = spec.locate || defaultLocate;
const usable = spec.usable || ((f) => { const p = locate(f); return !!p && isFinite(p.lon) && isFinite(p.lat); });
const sig = spec.sig || ((f) => JSON.stringify([f.properties, f.geometry && f.geometry.coordinates]));
const status = spec.status || (({ shown }) => `${shown} features`);
// id → { entity, sig } for dedupe + USGS-style revision detection.
const byId = spec.featureId ? new Map() : null;
let enabled = spec.defaultOn !== false;
let timer = null;
let inFlight = false;
ds.show = enabled; // datasources default to visible — honor a default-off layer
ui.addLayer(spec.id, spec.name, enabled, (on) => { enabled = on; ds.show = on; }, spec.category);
ui.setStatus(spec.id, 'loading…', 'warn');
// Build one Cesium.Entity from a feature via the spec's style/description.
function buildEntity(f, id) {
const p = locate(f);
const s = (spec.style && spec.style(f, ctx)) || {};
const opts = {
position: Cesium.Cartesian3.fromDegrees(p.lon, p.lat, p.height || 0),
};
if (id != null) opts.id = `${spec.id}:${id}`;
if (spec.timeAt) {
const when = spec.timeAt(f);
if (when != null) opts.availability = availabilityFor(when);
}
if (s.point) opts.point = s.point;
if (s.billboard) opts.billboard = s.billboard;
if (s.label) {
opts.label = {
font: '11px "Segoe UI", system-ui, sans-serif',
fillColor: Cesium.Color.WHITE,
outlineColor: Cesium.Color.fromCssColorString('#0a0f14'),
outlineWidth: 3,
style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: Cesium.VerticalOrigin.BOTTOM,
pixelOffset: new Cesium.Cartesian2(0, -10),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
...s.label,
};
if (s.label.fade) {
const [near, far] = s.label.fade;
opts.label.translucencyByDistance = new Cesium.NearFarScalar(near, 1.0, far, 0.0);
delete opts.label.fade;
}
}
if (spec.description) opts.description = spec.description(f, ctx);
return new Cesium.Entity(opts);
}
// Availability start clamped into the fixed clock window [start, stop], so a
// real event appears exactly as the slider crosses its moment.
function availabilityFor(when) {
const jd = Cesium.JulianDate.fromDate(when instanceof Date ? when : new Date(when));
let sJd = jd;
if (Cesium.JulianDate.lessThan(sJd, start)) sJd = start.clone();
if (Cesium.JulianDate.greaterThan(sJd, stop)) sJd = stop.clone();
return new Cesium.TimeIntervalCollection([new Cesium.TimeInterval({ start: sJd, stop })]);
}
function apply(rawFeatures) {
let features = (rawFeatures || []).filter(usable);
if (spec.sortKey) features.sort((a, b) => spec.sortKey(b) - spec.sortKey(a));
if (spec.cap) features = features.slice(0, spec.cap);
if (!byId) {
// Wholesale rebuild (feeds with no stable id, e.g. fires).
ds.entities.removeAll();
for (const f of features) ds.entities.add(buildEntity(f, null));
ui.setStatus(spec.id, status({ shown: features.length, features }), 'ok');
return;
}
// Dedupe by id + rebuild only revised entities (e.g. USGS magnitude updates).
const keep = new Map(features.map((f) => [`${spec.id}:${spec.featureId(f)}`, f]));
for (const [key, rec] of byId) {
if (!keep.has(key)) { ds.entities.remove(rec.entity); byId.delete(key); }
}
for (const [key, f] of keep) {
const s = sig(f);
const existing = byId.get(key);
if (existing) {
if (existing.sig === s) continue;
ds.entities.remove(existing.entity);
}
const ent = buildEntity(f, spec.featureId(f));
ds.entities.add(ent);
byId.set(key, { entity: ent, sig: s });
}
ui.setStatus(spec.id, status({ shown: byId.size, features }), 'ok');
}
async function poll() {
let res;
try {
res = await fetch(spec.url);
} catch {
ui.setStatus(spec.id, 'network error — retrying', 'warn');
return;
}
if (!res.ok) {
ui.setStatus(spec.id, `HTTP ${res.status} — retrying`, 'warn');
return;
}
let json;
try {
json = await res.json(); // never gate on Content-Type (EONET lies rss+xml)
} catch {
ui.setStatus(spec.id, 'bad response — retrying', 'err');
return;
}
try {
apply(adapt(json));
} catch (err) {
console.error(`[godsigh] ${spec.id} apply failed:`, err);
ui.setStatus(spec.id, 'render error', 'err');
}
}
// Self-scheduling loop; keeps polling regardless of scrub (historical feeds).
async function tick() {
timer = null;
if (inFlight) return;
inFlight = true;
try { await poll(); } finally { inFlight = false; }
timer = setTimeout(tick, refreshMs);
}
tick();
return { id: spec.id };
}
// Default geometry → {lon, lat}: Point, Polygon (ring centroid), or a
// GeometryCollection (first usable Point, else first Polygon centroid).
export function defaultLocate(f) {
const g = f && f.geometry;
if (!g) return null;
return fromGeometry(g);
}
function fromGeometry(g) {
if (!g) return null;
if (g.type === 'Point' && Array.isArray(g.coordinates)) {
const [lon, lat] = g.coordinates;
return isFinite(lon) && isFinite(lat) ? { lon, lat } : null;
}
if (g.type === 'Polygon' && Array.isArray(g.coordinates) && Array.isArray(g.coordinates[0])) {
return centroid(g.coordinates[0]);
}
if (g.type === 'GeometryCollection' && Array.isArray(g.geometries)) {
for (const sub of g.geometries) if (sub.type === 'Point') { const p = fromGeometry(sub); if (p) return p; }
for (const sub of g.geometries) { const p = fromGeometry(sub); if (p) return p; }
}
return null;
}
function centroid(ring) {
let sx = 0, sy = 0, n = 0;
for (const pt of ring) {
if (Array.isArray(pt) && isFinite(pt[0]) && isFinite(pt[1])) { sx += pt[0]; sy += pt[1]; n++; }
}
return n ? { lon: sx / n, lat: sy / n } : null;
}