diff --git a/CHANGELOG.md b/CHANGELOG.md index 21544fa..023c4d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md ## [Unreleased] +### Added + +- Global Conflict Events layer (`warscope-events`): WarScope/GDELT points via + same-origin `/api/warscope/events` only (fixed upstream host, cache + rate + limit). Zoomed-in cards show title and an outbound publisher link — no + Open Graph scrape or image proxy. + ### Fixed - Mapped-site outages show their scheduled retry countdown and distinguish diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index faa351d..74cf7ba 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -24,6 +24,7 @@ How to read this: | **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) | | **Esri World Imagery** (ArcGIS Online tile service) | The keyless satellite basemap — the default landing when no Google/ion credential is configured, and the "Esri Satellite" map stack | [Esri Master Agreement](https://www.esri.com/en-us/legal/terms/full-master-agreement): the public World Imagery service is usable in public-facing apps with attribution; no key is required for this classic endpoint, but Esri governs and can change access — an app at scale should review current ArcGIS Location Platform terms | "Powered by Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community" (provider carries the service's own credit line) | | **USGS** | Earthquakes | U.S. public domain | "Data courtesy of the U.S. Geological Survey" | +| **WarScope** (`/api/events`) | Global conflict event points (GDELT-backed aggregation) | [WarScope API docs](https://warscope.net/api-docs): free tier 60 req/min per IP; verify events against primary sources | "WarScope" + underlying GDELT/article attribution where shown | | **OpenStreetMap (Overpass API)** | Road geometry for traffic | ODbL 1.0 | "© OpenStreetMap contributors" | | **TomTom Traffic API** (flow vector tiles) | Live congestion coloring for the traffic layer (optional, BYOK) | [TomTom for Developers terms](https://developer.tomtom.com) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | | **OpenStreetMap (Overpass API)** | Viewport-bounded mapped installation context for Global Context | ODbL 1.0 | "© OpenStreetMap contributors" (incomplete mapped context) | @@ -51,6 +52,7 @@ How to read this: - **Global Context installation context.** `/api/military-installations` queries only an allow-listed subset of OSM `military=*` and `landuse=military` features inside a maximum 10° non-dateline viewport. It caches and may serve stale mapped context, but it is neither a global installation database nor evidence of capability, activity, or absence. User-requested Google Places results remain separately sourced candidates unless their returned types explicitly establish military classification; generic offices, museums, and similarly ambiguous matches are excluded from military proximity counts. - **Cockpit regional briefing.** `/api/regional-brief` rounds aircraft coordinates into 0.1° cache cells, caches results for five minutes, and serializes Nominatim calls at no more than one request per second. Google News RSS is queried with the resolved locality/region first; GDELT is used only when that RSS query fails or is empty. Google's published Google News terms restrict that source to personal, noncommercial use, so commercial deployments must disable/replace it or obtain separate permission; GDELT permits commercial dataset use with citation. The Data attribution popover identifies the active headline sources; article links retain publisher attribution. Headlines are location-query matches, not verified incidents, risk rankings, or evidence that a location is safe. Empty, partial, stale, and unavailable source states remain distinct. Open-Meteo supplies current conditions independently of the news source. `WX OFF` disables cockpit weather rendering only; the Local Info briefing still fetches its source-backed weather values and displays the required linked Open-Meteo credit. - **Dynamic weather presentation.** While cockpit mode is active, `/api/weather-effects` requests current Open-Meteo observations for the aircraft/camera location, rounds coordinates into 0.1° cache cells, caches results for five minutes, and may retain a stale observation for up to 30 minutes during a transient outage. WMO condition code selects the visual family; observed cloud cover, precipitation, visibility, wind speed, and wind direction bound its strength and motion. Missing or expired weather renders no synthetic atmospheric effect, and normal globe view never renders the weather overlay. +- **WarScope.** `/api/warscope/events` proxies WarScope's GDELT-backed conflict event feed (default 7-day / 400-event global window, ~10 min memory cache, serve-stale on failure, per-IP rate limit). Fixed upstream host only — no client-supplied URL fetch and no Open Graph / image preview proxies. When zoomed in, events show collapsible text cards with title and an outbound publisher source link. Reported events may be delayed, miscategorized, or duplicated; linked articles retain publisher terms. Respect WarScope's 60 requests/minute per IP limit — the app stays well under it via server-side caching. --- @@ -129,4 +131,4 @@ Douglas-Peucker simplification, 6-decimal rounding). ## In-app attribution -The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. +The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, WarScope, City of Austin, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1f4d861..b1e69ba 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1,6 +1,15 @@ # God's Eye View Current State -Updated: August 24, 2026 +Updated: September 8, 2026 + +## WarScope conflict events + +- `warscope-events` is a Data Layers toggle for global GDELT-backed conflict + points. The Vite middleware exposes only `/api/warscope/events` against the + fixed host `https://warscope.net` (clamped query params, ~10 min memory + cache, serve-stale, per-IP rate limit). There is no `/api/warscope/preview` + or `/api/warscope/image`. Zoomed-in overlay cards expand to title, WarScope + metadata, and an outbound publisher source link. ## Installations and map-source guidance @@ -1615,6 +1624,7 @@ its criteria cannot be silently ignored. | Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/ais-live` | 60s (+800ms visibility pass) | | Mapped Installations ⌖ | OpenStreetMap mapped context; on-demand Google Maps Places supplement | `src/data/militaryInstallations.js` | `/api/military-installations`, `/api/google/text-search` | viewport-driven + user search; while unavailable, auto-retry 30 s → 240 s backoff | | Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | +| Global Conflict Events | WarScope / GDELT | `src/data/warscopeEvents.js` | `/api/warscope/events` | 10 min | | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Traffic | OSM Overpass (+ optional TomTom live flow) | `src/data/traffic.js` | `/api/overpass` + `/api/tomtom` | viewport-driven | diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index b35da9e..cec0050 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -66,6 +66,13 @@ export const DATA_CREDITS = [ key: 'usgs', html: 'Earthquakes: Data courtesy of the U.S. Geological Survey', }, + { + key: 'warscope', + html: + 'Global conflict events: ' + + 'WarScope ' + + '(GDELT-backed reported events — verify with primary sources)', + }, { key: 'overpass', html: diff --git a/src/data/layerState.js b/src/data/layerState.js index eb68c14..7ac71f4 100644 --- a/src/data/layerState.js +++ b/src/data/layerState.js @@ -291,6 +291,7 @@ export const LAYER_STATE_REGISTRY = Object.freeze([ Object.freeze({ id: 'satellites', token: 's', disposition: 'enabled+options', optionOwner: 'satellites' }), Object.freeze({ id: 'telegeography-submarine-cables', token: 'u', disposition: 'enabled-only' }), Object.freeze({ id: 'traffic', token: 't', disposition: 'enabled-only' }), + Object.freeze({ id: 'warscope-events', token: 'o', disposition: 'enabled-only' }), ]); export const REGISTERED_LAYER_IDS = Object.freeze(LAYER_STATE_REGISTRY.map((entry) => entry.id)); diff --git a/src/data/layerState.test.mjs b/src/data/layerState.test.mjs index 2c282e7..2cb4ed2 100644 --- a/src/data/layerState.test.mjs +++ b/src/data/layerState.test.mjs @@ -155,8 +155,8 @@ function encode(state) { test('production registry is exact, canonical, and rejects incomplete contracts', async () => { assert.equal(validateLayerStateRegistry(), true); - assert.equal(REGISTERED_LAYER_IDS.length, 16); - assert.equal(new Set(REGISTERED_LAYER_IDS).size, 16); + assert.equal(REGISTERED_LAYER_IDS.length, 17); + assert.equal(new Set(REGISTERED_LAYER_IDS).size, 17); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.throws( () => validateLayerStateRegistry([...LAYER_STATE_REGISTRY, LAYER_STATE_REGISTRY[0]]), diff --git a/src/data/warscopeCards.js b/src/data/warscopeCards.js new file mode 100644 index 0000000..208ef0f --- /dev/null +++ b/src/data/warscopeCards.js @@ -0,0 +1,178 @@ +/** + * WarScope conflict-event cards — collapsed/expanded text summaries with an + * outbound source link (no scraped article thumbnails). + */ + +export const WARSCOPE_OVERLAY_SOURCE_ID = 'warscope-events'; +export const WARSCOPE_OVERLAY_COHORT_LIMIT = 48; +export const WARSCOPE_OVERLAY_COLLISION_CAPACITY = 24; + +/** Cards appear when zoomed in; fade out toward global overview altitudes. */ +export const WARSCOPE_CARD_FADE_START_M = 350_000; +export const WARSCOPE_CARD_FADE_END_M = 1_600_000; + +const EVENT_COLORS = Object.freeze({ + battles: '#ff3344', + 'explosions/remote violence': '#ff7722', + protests: '#ffcc33', + riots: '#ff9933', + 'strategic developments': '#66aaff', +}); + +/** @param {string} eventType */ +export function eventAccentColor(eventType) { + const key = String(eventType || '').trim().toLowerCase(); + return EVENT_COLORS[key] || '#c8d0dc'; +} + +/** @param {string} title @param {number} [max=42] */ +export function compactEventTitle(title, max = 42) { + const text = String(title || 'Conflict event').trim(); + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 1)).trim()}…`; +} + +/** + * Compact a publisher URL for card detail text (hostname + short path). + * @param {string} sourceUrl + * @param {number} [max=56] + * @returns {string} + */ +export function compactSourceLink(sourceUrl, max = 56) { + const raw = String(sourceUrl || '').trim(); + if (!raw) return ''; + try { + const url = new URL(raw); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return ''; + const path = url.pathname === '/' ? '' : url.pathname; + const text = `${url.hostname}${path}`; + if (text.length <= max) return text; + return `${text.slice(0, Math.max(0, max - 1)).trim()}…`; + } catch { + if (raw.length <= max) return raw; + return `${raw.slice(0, Math.max(0, max - 1)).trim()}…`; + } +} + +/** + * @param {object} event + * @param {boolean} expanded + * @returns {string[]} + */ +export function buildWarscopeCardDetails(event, expanded) { + const lines = []; + const typeLine = [event.eventType, event.subEventType].filter(Boolean).join(' · '); + if (typeLine) lines.push(typeLine); + const place = [event.region, event.country].filter(Boolean).join(', '); + if (place) lines.push(place); + if (event.date) lines.push(event.date); + if (Number(event.fatalities) > 0) lines.push(`${event.fatalities} reported fatalities`); + if (event.source) lines.push(`Source: ${event.source}`); + if (!expanded) { + lines.push('Click card to expand'); + return lines; + } + const notes = String(event.notes || '').trim(); + if (notes) { + lines.push(notes.length > 120 ? `${notes.slice(0, 119).trim()}…` : notes); + } + const link = compactSourceLink(event.sourceUrl); + if (link) lines.push(`Open source: ${link}`); + lines.push('Click card to collapse'); + return lines; +} + +/** + * @param {object} event + * @param {boolean} [expanded=false] + * @returns {number} + */ +export function eventOverlayPriority(event, expanded = false) { + const fatalities = Math.max(0, Number(event?.fatalities) || 0); + const quality = Number(event?.quality) || 0; + let priority = Math.round(fatalities * 1000 + quality * 100); + if (expanded) priority += 1_000_000; + return priority; +} + +/** + * Keep expanded cards and the highest-priority ambient cards within the cap. + * @param {Array<{id:string,priority:number}>} entries + * @param {Set|Array} expandedIds + * @param {number} [limit=WARSCOPE_OVERLAY_COHORT_LIMIT] + */ +export function selectWarscopeOverlayCohort( + entries, + expandedIds, + limit = WARSCOPE_OVERLAY_COHORT_LIMIT, +) { + const expanded = expandedIds instanceof Set + ? expandedIds + : new Set(Array.isArray(expandedIds) ? expandedIds : []); + const cap = Math.max(0, Math.min( + WARSCOPE_OVERLAY_COHORT_LIMIT, + Math.floor(Number(limit) || 0), + )); + if (!Array.isArray(entries) || cap === 0) return []; + const pinned = []; + const ambient = []; + for (const entry of entries) { + if (expanded.has(entry.id)) pinned.push(entry); + else ambient.push(entry); + } + ambient.sort((a, b) => b.priority - a.priority || String(a.id).localeCompare(String(b.id))); + const ambientCap = Math.max(0, cap - pinned.length); + return [...pinned, ...ambient.slice(0, ambientCap)]; +} + +/** + * @param {object} input + * @param {object} input.event Normalized WarScope event. + * @param {object} input.position Cesium Cartesian3 anchor. + * @param {boolean} [input.expanded=false] + */ +export function createWarscopeOverlayEntry({ + event, + position, + expanded = false, +} = {}) { + if (!event?.id || !position) return null; + const accent = eventAccentColor(event.eventType); + const priority = eventOverlayPriority(event, expanded); + const title = compactEventTitle(event.title); + const sourceUrl = String(event.sourceUrl || '').trim(); + const activate = sourceUrl && /^https?:\/\//i.test(sourceUrl) + ? () => { + try { + window.open(sourceUrl, '_blank', 'noopener,noreferrer'); + return true; + } catch { + return false; + } + } + : null; + + return { + id: String(event.id), + position, + accent, + priority, + interactive: true, + collisionGroup: 'warscope-event', + edgeFade: 'keyhole', + horizonCull: true, + terrainOcclusion: false, + verticalOnly: true, + placement: 'above', + gapPx: 10, + altitudeFadeStart: WARSCOPE_CARD_FADE_START_M, + altitudeFadeEnd: WARSCOPE_CARD_FADE_END_M, + maxDistance: 4_000_000, + accessibilityLabel: `${title}, ${event.country || 'global event'}`, + activate, + variant: 'card', + paintLane: 'ambient-card', + title, + details: buildWarscopeCardDetails(event, expanded), + }; +} diff --git a/src/data/warscopeCards.test.mjs b/src/data/warscopeCards.test.mjs new file mode 100644 index 0000000..6fe9f38 --- /dev/null +++ b/src/data/warscopeCards.test.mjs @@ -0,0 +1,76 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildWarscopeCardDetails, + compactEventTitle, + compactSourceLink, + createWarscopeOverlayEntry, + eventOverlayPriority, + selectWarscopeOverlayCohort, +} from './warscopeCards.js'; + +const SAMPLE_EVENT = Object.freeze({ + id: 'gdelt-1', + title: 'Battles in Ukraine', + eventType: 'Battles', + subEventType: 'Armed clash', + country: 'Ukraine', + region: 'Donetsk', + date: '2026-08-29', + notes: 'Reported clash near the contact line.', + source: 'GDELT Project', + sourceUrl: 'https://example.com/story', + fatalities: 2, + quality: 0.82, +}); + +test('warscope card helpers format collapsed and expanded detail lines', () => { + const collapsed = buildWarscopeCardDetails(SAMPLE_EVENT, false); + assert.match(collapsed.join('|'), /Click card to expand/); + assert.doesNotMatch(collapsed.join('|'), /Open source/); + + const expanded = buildWarscopeCardDetails(SAMPLE_EVENT, true); + assert.match(expanded.join('|'), /Open source: example\.com\/story/); + assert.match(expanded.join('|'), /Click card to collapse/); + assert.match(expanded.join('|'), /Reported clash/); +}); + +test('warscope overlay entry stays a text card with optional outbound activate', () => { + const position = { x: 1, y: 2, z: 3 }; + const collapsed = createWarscopeOverlayEntry({ + event: SAMPLE_EVENT, + position, + expanded: false, + }); + assert.equal(collapsed.variant, 'card'); + assert.equal(collapsed.interactive, true); + assert.equal(typeof collapsed.activate, 'function'); + assert.equal(collapsed.image, undefined); + + const expanded = createWarscopeOverlayEntry({ + event: SAMPLE_EVENT, + position, + expanded: true, + }); + assert.equal(expanded.variant, 'card'); + assert.match(expanded.details.join('|'), /Open source/); +}); + +test('warscope cohort keeps expanded cards and highest-priority ambient cards', () => { + const entries = [ + { id: 'a', priority: 10 }, + { id: 'b', priority: 900 }, + { id: 'c', priority: 800 }, + { id: 'd', priority: 50 }, + ]; + const cohort = selectWarscopeOverlayCohort(entries, new Set(['a']), 3); + assert.deepEqual(cohort.map((entry) => entry.id), ['a', 'b', 'c']); +}); + +test('warscope overlay priority boosts expanded cards above ambient peers', () => { + const base = eventOverlayPriority(SAMPLE_EVENT, false); + const expanded = eventOverlayPriority(SAMPLE_EVENT, true); + assert.ok(expanded > base); + assert.equal(compactEventTitle('x'.repeat(80), 10).length, 10); + assert.equal(compactSourceLink('https://news.example/path/to/article'), 'news.example/path/to/article'); +}); diff --git a/src/data/warscopeEvents.js b/src/data/warscopeEvents.js new file mode 100644 index 0000000..791b1c7 --- /dev/null +++ b/src/data/warscopeEvents.js @@ -0,0 +1,312 @@ +/** + * WarScope — global conflict events (GDELT-backed, proxied same-origin). + * Data: https://warscope.net/api-docs + * + * Zoomed out: colored event dots. + * Zoomed in: collapsible text cards with title + outbound source link + * (no article-preview scrape or image proxy). + */ + +import * as Cesium from 'cesium'; +import { + clearOverlaySource, + hitTestWorldOverlay, + setOverlayEntries, + setOverlaySourceVisible, +} from '../overlays/worldOverlay.js'; +import { governorRequestRender } from '../renderGovernor.js'; +import { + WARSCOPE_OVERLAY_COLLISION_CAPACITY, + WARSCOPE_OVERLAY_COHORT_LIMIT, + WARSCOPE_OVERLAY_SOURCE_ID, + createWarscopeOverlayEntry, + eventAccentColor, + selectWarscopeOverlayCohort, +} from './warscopeCards.js'; + +const API_URL = '/api/warscope/events'; +const UPDATE_MS = 600000; + +const EVENT_COLORS = Object.freeze({ + battles: '#ff3344', + 'explosions/remote violence': '#ff7722', + protests: '#ffcc33', + riots: '#ff9933', + 'strategic developments': '#66aaff', +}); + +const DEFAULT_OVERLAY_HOST = Object.freeze({ + setEntries: setOverlayEntries, + setVisible: setOverlaySourceVisible, + clearSource: clearOverlaySource, + hitTest: hitTestWorldOverlay, +}); + +function eventColor(eventType) { + return Cesium.Color.fromCssColorString(eventAccentColor(eventType)); +} + +function normalizeEvent(raw) { + const lat = Number(raw?.latitude); + const lon = Number(raw?.longitude); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; + const fatalities = Number(raw?.fatalities); + const quality = Number(raw?.quality?.overall); + return { + id: String(raw?.id || ''), + title: String(raw?.title || raw?.event_type || 'Conflict event').trim(), + eventType: String(raw?.event_type || '').trim(), + subEventType: String(raw?.sub_event_type || '').trim(), + country: String(raw?.country || '').trim(), + region: String(raw?.region || '').trim(), + date: String(raw?.event_date || raw?.date || '').trim(), + notes: String(raw?.notes || '').trim(), + source: String(raw?.source || 'WarScope').trim(), + sourceUrl: String(raw?.source_url || '').trim(), + lat, + lon, + fatalities: Number.isFinite(fatalities) ? fatalities : 0, + quality: Number.isFinite(quality) ? quality : 0, + }; +} + +export function createWarscopeEventsLayer({ + overlayHost = DEFAULT_OVERLAY_HOST, + screenSpaceEventHandlerFactory = (viewer) => ( + new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas) + ), +} = {}) { + let _viewer = null; + let _dataSource = null; + let _count = 0; + let _lastUpdate = null; + let _lastError = null; + let _stale = false; + let _enabled = false; + let _theater = 'global'; + /** @type {Map} */ + let _eventsById = new Map(); + /** @type {Map} */ + let _positionsById = new Map(); + /** @type {Set} */ + let _expandedIds = new Set(); + let _clickHandler = null; + + function syncOverlayEntries() { + if (!_enabled) return; + const entries = []; + for (const [id, event] of _eventsById) { + const position = _positionsById.get(id); + if (!position) continue; + const entry = createWarscopeOverlayEntry({ + event, + position, + expanded: _expandedIds.has(id), + }); + if (entry) entries.push(entry); + } + overlayHost.setEntries( + WARSCOPE_OVERLAY_SOURCE_ID, + selectWarscopeOverlayCohort(entries, _expandedIds), + { + cohortLimit: WARSCOPE_OVERLAY_COHORT_LIMIT, + collisionCapacity: WARSCOPE_OVERLAY_COLLISION_CAPACITY, + moving: false, + }, + ); + } + + function toggleExpanded(eventId) { + if (!eventId || !_eventsById.has(eventId)) return false; + if (_expandedIds.has(eventId)) _expandedIds.delete(eventId); + else _expandedIds.add(eventId); + syncOverlayEntries(); + if (_enabled) governorRequestRender('warscope-expand'); + return true; + } + + function installClickHandler() { + if (_clickHandler || !_viewer) return; + _clickHandler = screenSpaceEventHandlerFactory(_viewer); + _clickHandler.setInputAction((click) => { + const hit = overlayHost.hitTest?.( + click.position?.x, + click.position?.y, + { sourceId: WARSCOPE_OVERLAY_SOURCE_ID }, + ); + if (!hit?.entryId) return; + toggleExpanded(hit.entryId); + }, Cesium.ScreenSpaceEventType.LEFT_CLICK); + } + + function removeClickHandler() { + if (_clickHandler) { + _clickHandler.destroy(); + _clickHandler = null; + } + } + + const layer = { + id: 'warscope-events', + name: 'Global Conflict Events', + icon: '⚔️', + source: 'WarScope / GDELT', + updateInterval: UPDATE_MS, + + init(viewer) { + _viewer = viewer; + _dataSource = new Cesium.CustomDataSource('warscope-events'); + _dataSource.show = false; + viewer.dataSources.add(_dataSource); + _count = 0; + _lastUpdate = null; + _lastError = null; + _stale = false; + _enabled = false; + _theater = 'global'; + _eventsById = new Map(); + _positionsById = new Map(); + _expandedIds = new Set(); + overlayHost.setVisible(WARSCOPE_OVERLAY_SOURCE_ID, false); + }, + + enable() { + _enabled = true; + if (_dataSource) _dataSource.show = true; + overlayHost.setVisible(WARSCOPE_OVERLAY_SOURCE_ID, true); + syncOverlayEntries(); + installClickHandler(); + }, + + disable() { + _enabled = false; + if (_dataSource) _dataSource.show = false; + overlayHost.clearSource(WARSCOPE_OVERLAY_SOURCE_ID); + overlayHost.setVisible(WARSCOPE_OVERLAY_SOURCE_ID, false); + removeClickHandler(); + }, + + async update() { + if (!_dataSource) return false; + try { + const response = await fetch(API_URL, { cache: 'no-store' }); + const payload = await response.json().catch(() => null); + if (!response.ok || !payload) { + _lastError = payload?.error || `WarScope HTTP ${response.status}`; + return false; + } + _stale = Boolean(payload.stale); + _theater = String(payload.theater || 'global'); + const events = Array.isArray(payload.events) ? payload.events : []; + + _dataSource.entities.removeAll(); + _eventsById = new Map(); + _positionsById = new Map(); + const liveIds = new Set(); + let rendered = 0; + + for (const raw of events) { + const event = normalizeEvent(raw); + if (!event || !event.id) continue; + + liveIds.add(event.id); + _eventsById.set(event.id, event); + const position = Cesium.Cartesian3.fromDegrees(event.lon, event.lat); + _positionsById.set(event.id, position); + + const color = eventColor(event.eventType); + const magScale = Math.min(14, 6 + Math.sqrt(Math.max(0, event.fatalities)) * 2); + + _dataSource.entities.add({ + id: `warscope:${event.id}`, + position, + point: { + pixelSize: magScale, + color: color.withAlpha(0.88), + outlineColor: Cesium.Color.BLACK.withAlpha(0.45), + outlineWidth: 1, + disableDepthTestDistance: Number.POSITIVE_INFINITY, + }, + properties: { + title: event.title, + eventType: event.eventType, + subEventType: event.subEventType, + country: event.country, + region: event.region, + date: event.date, + notes: event.notes, + source: event.source, + sourceUrl: event.sourceUrl, + fatalities: event.fatalities, + }, + }); + rendered += 1; + } + + for (const id of [..._expandedIds]) { + if (!liveIds.has(id)) _expandedIds.delete(id); + } + + _count = rendered; + _lastUpdate = Number(payload.fetchedAt) || Date.now(); + _lastError = null; + syncOverlayEntries(); + if (_enabled) governorRequestRender('warscope-events-update'); + return true; + } catch (error) { + _lastError = 'WarScope network error'; + console.warn('[Data:WarScope]', error); + return false; + } + }, + + destroy(viewer) { + _enabled = false; + removeClickHandler(); + overlayHost.clearSource(WARSCOPE_OVERLAY_SOURCE_ID); + overlayHost.setVisible(WARSCOPE_OVERLAY_SOURCE_ID, false); + if (_dataSource) { + viewer.dataSources.remove(_dataSource, true); + _dataSource = null; + } + _viewer = null; + _count = 0; + _lastUpdate = null; + _lastError = null; + _stale = false; + _theater = 'global'; + _eventsById = new Map(); + _positionsById = new Map(); + _expandedIds = new Set(); + }, + + getRowControls() { + return { + legend: [ + { color: EVENT_COLORS.battles, label: 'Battles' }, + { color: EVENT_COLORS['explosions/remote violence'], label: 'Explosions / remote violence' }, + { color: EVENT_COLORS.protests, label: 'Protests' }, + { color: '#c8d0dc', label: 'Other reported events' }, + { color: '#9eb6d4', label: 'Zoom in for cards · click to expand' }, + ], + }; + }, + + getStats() { + return { + count: _count, + lastUpdate: _lastUpdate, + error: _lastError, + stale: _stale, + loadingLabel: _theater !== 'global' ? _theater : '7d global', + }; + }, + }; + + return layer; +} + +const warscopeEventsLayer = createWarscopeEventsLayer(); + +export default warscopeEventsLayer; diff --git a/src/main.js b/src/main.js index 84fbf12..5e7d810 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,7 @@ import { DataLayerManager } from './data/manager.js'; import flightsLayer from './data/flights.js'; import militaryFlightsLayer from './data/militaryFlights.js'; import earthquakesLayer from './data/earthquakes.js'; +import warscopeEventsLayer from './data/warscopeEvents.js'; import satellitesLayer from './data/satellites.js'; import rocketLaunchesLayer from './data/rocketLaunches.js'; import trafficLayer from './data/traffic.js'; @@ -210,6 +211,7 @@ async function init() { dataManager.register(flightsLayer); dataManager.register(militaryFlightsLayer); dataManager.register(earthquakesLayer); + dataManager.register(warscopeEventsLayer); dataManager.register(satellitesLayer); dataManager.register(rocketLaunchesLayer); rocketLaunchesLayer.attachDataManager(dataManager); diff --git a/vite.config.js b/vite.config.js index 34dac52..facafd3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1654,6 +1654,9 @@ function celestrakProxy() { export const LL2_CACHE_TTL_MS = 15 * 60_000; +/** WarScope events feed memory-cache TTL (stay well under the 60 req/min free tier). */ +export const WARSCOPE_CACHE_TTL_MS = 10 * 60_000; + /** Build LL2 request headers without exposing its optional token client-side. */ export function launchLibraryRequestHeaders(token = process.env.LL2_API_TOKEN) { const normalized = String(token || '').trim(); @@ -1776,6 +1779,132 @@ function rocketLaunchesProxy() { }; } +/** + * Same-origin WarScope conflict-events proxy. + * + * Fixed upstream host only (`https://warscope.net/api/events`). Query params are + * clamped server-side; the client never supplies an arbitrary fetch URL (no + * preview/image scrape endpoints). Memory cache + coalesce + per-IP rate limit + * keep us under WarScope's 60 req/min free tier. + * + * @returns {import('vite').Plugin} + */ +function warscopeEventsProxy() { + const ttlMs = WARSCOPE_CACHE_TTL_MS; + const maxResponseBytes = 8 * 1024 * 1024; + const rateLimiter = makeRateLimiter({ windowMs: 60_000, max: 30, globalMax: 60 }); + /** @type {{ at: number, body: string, key: string } | null} */ + let warscopeCache = null; + const warscopeInflight = new Map(); + + function sendJson(res, status, body, cacheState) { + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': status === 200 ? 'public, max-age=60' : 'no-store', + 'X-GEV-Cache': cacheState, + }); + res.end(body); + } + + /** + * @param {URLSearchParams} searchParams + * @returns {Promise<{ at: number, body: string, key: string }>} + */ + async function fetchWarscopeEvents(searchParams) { + const days = Math.max(1, Math.min(90, Number.parseInt(searchParams.get('days') || '7', 10) || 7)); + const limit = Math.max(1, Math.min(500, Number.parseInt(searchParams.get('limit') || '400', 10) || 400)); + const theater = String(searchParams.get('theater') || '').trim(); + const url = new URL('https://warscope.net/api/events'); + url.searchParams.set('days', String(days)); + url.searchParams.set('limit', String(limit)); + if (theater) url.searchParams.set('theater', theater); + + const upstream = await fetch(url, { + signal: AbortSignal.timeout(20000), + headers: { Accept: 'application/json' }, + }); + const text = await readResponseTextCapped(upstream, maxResponseBytes); + if (!upstream.ok) { + const error = new Error(`upstream HTTP ${upstream.status}`); + error.upstreamStatus = upstream.status; + error.upstreamBody = text; + throw error; + } + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== 'object') throw new Error('malformed WarScope response'); + const normalized = { + fetchedAt: Date.now(), + stale: false, + theater: parsed.theater || theater || 'global', + days, + count: parsed.count ?? parsed.events?.length ?? 0, + source: parsed.source || 'live', + meta: parsed.meta || null, + events: Array.isArray(parsed.events) ? parsed.events : [], + }; + const body = JSON.stringify(normalized); + const key = `${days}:${limit}:${theater || 'global'}`; + warscopeCache = { at: Date.now(), body, key }; + return warscopeCache; + } + + function install(middlewares) { + middlewares.use('/api/warscope/events', async (req, res) => { + if (req.method !== 'GET') { + sendJson(res, 405, JSON.stringify({ error: 'Method Not Allowed' }), 'NONE'); + return; + } + if (!rateLimiter(clientKey(req))) { + sendJson(res, 429, JSON.stringify({ error: 'Rate limit exceeded' }), 'NONE'); + return; + } + const url = new URL(req.url, 'http://localhost'); + const key = `${url.searchParams.get('days') || '7'}:${url.searchParams.get('limit') || '400'}:${url.searchParams.get('theater') || 'global'}`; + const now = Date.now(); + if (warscopeCache && warscopeCache.key === key && now - warscopeCache.at < ttlMs) { + sendJson(res, 200, warscopeCache.body, 'HIT'); + return; + } + const stale = warscopeCache?.key === key ? warscopeCache : null; + const request = coalesceProxyRequest(warscopeInflight, key, () => fetchWarscopeEvents(url.searchParams)); + try { + const fresh = await request.promise; + sendJson(res, 200, fresh.body, request.shared ? 'INFLIGHT' : 'MISS'); + } catch (error) { + if (stale) { + if (!request.shared) { + console.warn(`[/api/warscope/events] refresh failed (${error?.message || error}) — serving stale cache`); + } + try { + const parsed = JSON.parse(stale.body); + parsed.stale = true; + sendJson(res, 200, JSON.stringify(parsed), 'STALE-ERROR'); + } catch { + sendJson(res, 200, stale.body, 'STALE-ERROR'); + } + return; + } + sendJson( + res, + Number.isInteger(error?.upstreamStatus) ? error.upstreamStatus : 502, + error?.upstreamBody || JSON.stringify({ error: 'WarScope unavailable' }), + 'NONE', + ); + } + }); + } + + return { + name: 'warscope-events-proxy', + configureServer(server) { + install(server.middlewares); + }, + configurePreviewServer(server) { + install(server.middlewares); + }, + }; +} + /** * TomTom traffic-flow vector-tile proxy with a daily budget governor. * @@ -7745,6 +7874,7 @@ export default defineConfig(({ mode }) => { tomtomProxy(), firmsProxy(), rocketLaunchesProxy(), + warscopeEventsProxy(), terrainHeightsProxy(), adsbdbProxy(), overpassProxy(),