diff --git a/js/config.js b/js/config.js index 4dfe173..63ad929 100644 --- a/js/config.js +++ b/js/config.js @@ -61,6 +61,31 @@ export const CONFIG = { // any large bbox). Or set {lamin, lomin, lamax, lomax}. bbox: null, pollMs: 90_000, + // Callsign prefixes flagged as military (highlighted + optionally filtered). + // Trimmed OpenSky callsigns are matched by prefix. Curated list of common + // NATO/allied air-transport & tanker call words; extend freely. + militaryPrefixes: ['RCH', 'REACH', 'LAGR', 'DUKE', 'POLO', 'CNV', 'RRR', + 'ASCOT', 'GAF', 'BAF', 'IAM', 'PLF', 'HKY', 'NATO', + 'CFC', 'MC', 'SAM', 'EVAC'], + }, + + // Earthquakes — USGS GeoJSON. Direct browser fetch (ACAO:*), no proxy needed. + // Full https URL by design (cross-origin feed, not a same-origin path). + quakes: { + url: 'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson', + refreshMs: 300_000, // 5 min + cap: 400, // hard ceiling on rendered quakes (drop smallest mag first) + labelMinMag: 4.5, // only label quakes at/above this magnitude (declutter) + magRamp: [2, 6], // color/size lerp domain + }, + + // Wildfires — NASA EONET. Direct fetch (ACAO:*). NOTE: EONET mislabels its + // Content-Type as application/rss+xml, but the body is JSON — never gate on + // the header; res.json() works regardless. + fires: { + url: 'https://eonet.gsfc.nasa.gov/api/v3/events?category=wildfires&status=open&limit=500', + refreshMs: 900_000, // 15 min + cap: 500, }, // Optional live AIS. Leave blank to use the demo fleet only (roadmap feature). @@ -82,6 +107,12 @@ export const CONFIG = { aircraftLow: '#ffb347', aircraftMid: '#ffe74d', aircraftHigh: '#4dd2ff', + aircraftMil: '#ff5964', // military-callsign tint (overrides altitude band) + // earthquakes (magnitude ramp: amber → red) + quakeMin: '#ffcf50', + quakeMax: '#ff3b30', + // wildfires + fire: '#ff7a1a', // ships shipNormal: '#7bff9d', shipToll: '#ffe74d', diff --git a/js/layers/quakes.js b/js/layers/quakes.js new file mode 100644 index 0000000..d9e30e1 --- /dev/null +++ b/js/layers/quakes.js @@ -0,0 +1,191 @@ +// Earthquakes layer (Wave 2) — REAL data from the USGS all_day GeoJSON feed. +// Time-anchored like the events layer, but with genuine events: a quake's +// availability starts at its own origin time, so scrubbing the slider back +// before it happened makes it vanish. Fetched directly (USGS sends ACAO:*), +// refreshed every 5 min, deduped by feature id across refreshes. + +export default function create(ctx) { + const { viewer, CONFIG, lib, ui, Cesium, start, stop } = ctx; + const Q = CONFIG.quakes; + + const ds = new Cesium.CustomDataSource('quakes'); + viewer.dataSources.add(ds); + + const amber = lib.cz(CONFIG.colors.quakeMin); + const red = lib.cz(CONFIG.colors.quakeMax); + const [magLo, magHi] = Q.magRamp; + + // id → entity, so refreshes update the delta instead of duplicating. + const byId = new Map(); + + let enabled = true; + let timer = null; + let inFlight = false; + + ui.addLayer('quakes', 'Earthquakes (USGS)', true, (on) => { + enabled = on; + ds.show = on; + }); + ui.setStatus('quakes', 'loading USGS feed…', 'warn'); + + // Magnitude → color (amber→red over magRamp) and base pixel size. + function magColor(mag) { + const t = Cesium.Math.clamp((mag - magLo) / (magHi - magLo), 0, 1); + return Cesium.Color.lerp(amber, red, t, new Cesium.Color()); + } + const baseSize = (mag) => Math.max(3, 4 + mag * 2.2); + + // Availability start clamped into the fixed clock window [start, stop]. + // Older-than-window quakes are visible the whole time; in-window ones appear + // as the slider crosses their origin time; (impossible) future ones clamp out. + function availabilityFor(timeMs) { + const jd = Cesium.JulianDate.fromDate(new Date(timeMs)); + let s = jd; + if (Cesium.JulianDate.lessThan(s, start)) s = start.clone(); + if (Cesium.JulianDate.greaterThan(s, stop)) s = stop.clone(); + return new Cesium.TimeIntervalCollection([ + new Cesium.TimeInterval({ start: s, stop }), + ]); + } + + function buildEntity(f) { + const p = f.properties || {}; + const g = f.geometry || {}; + const coords = g.coordinates || []; + const lon = coords[0]; + const lat = coords[1]; + const depth = coords[2]; + const mag = p.mag; + const timeMs = p.time; + const place = p.place || 'Unknown location'; + const size = baseSize(mag); + const color = magColor(mag); + const recentAtBuild = typeof timeMs === 'number' && (Date.now() - timeMs) < 3_600_000; + + // Quakes under an hour old pulse; the callback is age-aware so it settles + // on its own once the quake passes the 1 h mark (no rebuild needed). + const pixelSize = recentAtBuild + ? new Cesium.CallbackProperty(() => { + const fresh = typeof timeMs === 'number' && (Date.now() - timeMs) < 3_600_000; + return fresh ? size + 3 * Math.sin(Date.now() / 300) : size; + }, false) + : size; + + const ent = new Cesium.Entity({ + id: `quake:${f.id}`, + name: `M${mag.toFixed(1)} — ${place}`, + position: Cesium.Cartesian3.fromDegrees(lon, lat), + availability: availabilityFor(timeMs), + point: { + pixelSize, + color, + outlineColor: Cesium.Color.BLACK, + outlineWidth: 1, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + description: + `
| Magnitude | M${mag.toFixed(1)} |
|---|---|
| Place | ${place} |
| Depth | ${depth != null ? depth.toFixed(1) + ' km' : '—'} |
| Time (UTC) | ${timeMs != null ? new Date(timeMs).toISOString().replace('T', ' ').replace('.000Z', ' UTC') : '—'} |