Every marker used disableDepthTestDistance: Infinity, so points/billboards on the FAR side of the Earth drew on top of the near hemisphere instead of being occluded — reading as 'dots stuck, not moving with the globe' when you rotate. Changed to 50000 (50km) across all layers: the globe now occludes far-side markers, while you can still zoom right up to one without it clipping into the surface. Also made the billboard layers (aircraft/military/adsb/radius) explicit for consistent behavior. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
191 lines
7.3 KiB
JavaScript
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: 50000,
|
|
...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;
|
|
}
|