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