diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index f2b002428..03747265d 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -217,7 +217,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | Dataset | Folder | License | Commercial use? | Attribution | | --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- | -| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | +| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) | | **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map | | **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation | | **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" | diff --git a/README.md b/README.md index bce41b190..c9eb6e52f 100644 --- a/README.md +++ b/README.md @@ -473,7 +473,7 @@ In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Ev _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._ -**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712). +**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,352), Dams (704), and Submarine Cables (712). ![Diving into the Bahamas and revealing labeled submarine cable routes beneath the globe](docs/media/09-undersea-cables.gif) diff --git a/changelog.d/1528-s2-ghostmaps-age-filter.md b/changelog.d/1528-s2-ghostmaps-age-filter.md new file mode 100644 index 000000000..2ba019286 --- /dev/null +++ b/changelog.d/1528-s2-ghostmaps-age-filter.md @@ -0,0 +1,4 @@ +- Add an adjustable event-age filter across S2 GhostMaps products, defaulting + to seven days with 1-, 3-, 14- and 30-day choices and an All markers option. + Preserve undated features, save the selected window and expire old events + while the map stays open. Thanks @dylanmaniatakes (#1528). diff --git a/changelog.d/1585-keyless-unicode-search.md b/changelog.d/1585-keyless-unicode-search.md new file mode 100644 index 000000000..1f8b3af41 --- /dev/null +++ b/changelog.d/1585-keyless-unicode-search.md @@ -0,0 +1,3 @@ +- Keep keyless place search on a nearby exact match for names written in + non-Latin scripts instead of discarding the match and retrying without its + proximity ranking (mertkayacs, #1585). diff --git a/changelog.d/847-cyclone-shared-selection.md b/changelog.d/847-cyclone-shared-selection.md new file mode 100644 index 000000000..150295823 --- /dev/null +++ b/changelog.d/847-cyclone-shared-selection.md @@ -0,0 +1,3 @@ +- Let the voice assistant read the storm you select in Cyclone advisories: + its official advisory, position, intensity and movement. Clearing the + selection and the storm leaving the feed are reported apart (kvnloo, #847). diff --git a/changelog.d/870-hidden-contact-dr.md b/changelog.d/870-hidden-contact-dr.md new file mode 100644 index 000000000..aab55229e --- /dev/null +++ b/changelog.d/870-hidden-contact-dr.md @@ -0,0 +1,4 @@ +- Reduce dead-reckoning work for flights already hidden behind the horizon. + Hidden contacts update on a staggered one-in-13-ticks rotation (about once a + second) while visible contacts and contacts with fleet models retain the full + update path (Wilmer Alzate, #870). diff --git a/changelog.d/953-datacenters-norway-refresh.md b/changelog.d/953-datacenters-norway-refresh.md new file mode 100644 index 000000000..d60d4ac00 --- /dev/null +++ b/changelog.d/953-datacenters-norway-refresh.md @@ -0,0 +1,2 @@ +- Add Troll Housing to the Datacenters layer (4,352 features) by refreshing + Norway from OpenStreetMap data of 2026-10-05 (trygvehhh, #953). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8ad23493a..3540c3096 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -250,7 +250,6 @@ style listeners. Synthetic fixtures cover privacy, acquisition, rendering, selection and card placement. Deterministic operation checks run in `npm test`; wall-clock budgets run in `npm run test:perf`. All 11 locales are supported. - ## Prohibited & Restricted Airspace — FAA special-use airspace The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`, @@ -742,6 +741,12 @@ geographic seams and releases on disable; forecast animation is not implied. NHC movement speed is converted from miles per hour to rounded knots by the proxy; advisory wind intensity is already in knots and is passed through. Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source. +Explicitly selecting a storm publishes one shared `weather-cyclones` record: +advisory provenance, source position, the English classification name and, +while the proxy serves its cache, the cache fetch time. The automatic row +selection does not claim shared context; refreshes do not re-announce it. +Clear or disable reports `deliberate`; a storm leaving the feed reports +`evicted`, with no fallback. Consult the linked official advisory for safety decisions. Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state. @@ -4342,35 +4347,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra requests use Play; a qualified Play-shaped tool call is normalized to Select so its criteria cannot be silently ignored. -| Layer | Source | File | Proxy | Update Interval | -| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- | -| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s | -| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s | -| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) | -| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | -| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | -| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | -| 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 | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | -| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | -| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | -| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | -| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | -| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | -| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | -| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | -| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | -| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | -| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | -| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | -| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | -| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | -| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | -| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | -| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | -| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | -| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) | +| Layer | Source | File | Proxy | Update Interval | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s | +| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s | +| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) | +| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | +| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | +| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | +| 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 | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | +| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | +| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | +| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | +| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | +| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | +| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | +| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | +| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | +| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | +| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | +| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | +| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | +| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | +| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | +| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | +| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | +| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | +| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) | GNSS Integrity samples a 250 nm circle around the Earth point under the screen centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; @@ -5350,7 +5355,7 @@ silently demoting every later lookup for the session. ### Motion & Symbology Correctness (June 10, 2026) -- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” +- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.” - **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder. - **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them. - **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode. @@ -5881,8 +5886,11 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total deadline, with Photon requests capped at six seconds each. Caller/application cancellation stops retries and late cache writes. Replacing a location search cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five -candidates, and an unbiased retry for name mismatches. A "place, region" query -prefers a candidate named exactly for the place whose city, county, state, +candidates, and an unbiased retry for name mismatches. Name comparison folds +Latin accents while preserving letters and marks in other scripts, so a +non-Latin name spelled exactly, marks included, keeps its proximity ranking. A +leading "the" counts: "The Hague" does not take a nearby "Hague". A "place, region" query prefers a +candidate named exactly for the place whose city, county, state, country or country code (or USA/UK) contains every word of the region, and a settlement or administrative area over a POI when both fit. Nearby results that fail that check are retried unbiased. After the region checks of both passes, a @@ -6005,6 +6013,7 @@ request viewbox and continues searching without that geographic preference. - **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked. - **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere. +- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All** (every marker). The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown. - **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. diff --git a/docs/INFRASTRUCTURE-LAYERS.md b/docs/INFRASTRUCTURE-LAYERS.md index 31d3cb4cb..aa8cd0558 100644 --- a/docs/INFRASTRUCTURE-LAYERS.md +++ b/docs/INFRASTRUCTURE-LAYERS.md @@ -57,6 +57,6 @@ Consumers use the package exports, which do not import standalone application globals. Dataset files and source/license notices remain under `src/data/local_data/`. -Their input counts are 4,351 datacenter features and 704 dam features. Cesium may +Their input counts are 4,352 datacenter features and 704 dam features. Cesium may expand multipart geometries into multiple entities; entity and stem counts are different measurements. This change does not refresh or relicense the datasets. diff --git a/scripts/format-scope.json b/scripts/format-scope.json index e5b46f7b3..f0d2a744e 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -347,6 +347,7 @@ "src/layers/flights/tracking.js", "src/layers/flights/rendering.js", "src/layers/flights/testing.js", + "src/layers/flights/hiddenContactRotation.test.mjs", "src/layers/flights/evidence.js", "src/layers/flights/ingestion.js", "src/layers/flights/queries.js", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 20f6c0b85..8a8b79283 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -572,6 +572,7 @@ "src/search/placeSearch.js", "src/search/google.js", "src/keylessGeocoder.js", + "src/data/placeNames.js", "src/search/geospatial.js", "src/search/http.js", "src/search/defaults.js", @@ -2335,6 +2336,7 @@ "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/app/layers/cyclones.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", @@ -3379,6 +3381,7 @@ "src/app/layers/alprCameras.js", "src/app/layers/bikeshare.js", "src/app/layers/cctv.js", + "src/app/layers/cyclones.js", "src/app/layers/earthquakes.js", "src/app/layers/firms.js", "src/app/layers/flights.js", diff --git a/scripts/qa-ghostmaps.mjs b/scripts/qa-ghostmaps.mjs index 83dc8ed97..3c3f0b880 100644 --- a/scripts/qa-ghostmaps.mjs +++ b/scripts/qa-ghostmaps.mjs @@ -28,6 +28,8 @@ * (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled: * seen from over the Indian Ocean no pin clearly behind the planet is * shown; back over North America the near-side pins show again. + * (viii) AGE — seven days is the default; shorter/longer windows and All + * markers update the globe, and the age choice survives a reload. * * Screenshots go to qa-shots/ghostmaps-*.png (gitignored). * @@ -224,6 +226,77 @@ async function main() { record('LIVE: the /api/ghostmaps/events stream is open', stream); await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') }); + const ageChips = await page.evaluate( + (id) => + [ + ...document.querySelectorAll( + `[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`, + ), + ].map((option) => ({ + id: option.dataset.chipId, + active: option.getAttribute('aria-checked') === 'true', + })), + LAYER_ID, + ); + record( + 'AGE: seven days is the default with an All markers option', + live.ageDays === 7 && + ageChips.length === 6 && + ageChips + .filter((chip) => chip.active) + .map((chip) => chip.id) + .join() === 'age-7' && + ageChips.some((chip) => chip.id === 'age-all'), + ); + const selectAge = async (days) => { + await showRow(page); + const selector = `[data-layer-id="${LAYER_ID}"] [data-chip-id="age-${days ?? 'all'}"]`; + await page.waitForSelector(selector, { timeout: 15000 }); + // Dispatch through the rendered button, matching the product-chip gate. + await page.$eval(selector, (button) => button.click()); + await page.waitForFunction( + (id, age) => { + const s = window.__godsEyeView.dataManager.layers + .get(id) + .module.getStats(); + return s.ageDays === age && !s.loading; + }, + { timeout: TIMEOUT_MS }, + LAYER_ID, + days, + ); + return stats(page); + }; + const all = await selectAge(null); + record( + 'AGE: All markers restores old events without duplicate data sources', + all.count > live.count && + (await sources(page)).length === live.products.length, + `${live.count} → ${all.count}`, + ); + await page.reload({ waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => window.__godsEyeView?.dataManager, { + timeout: TIMEOUT_MS, + }); + await enableAndSettle(page); + const persistedAge = await stats(page); + record( + 'AGE: All markers survives a reload', + persistedAge.ageDays === null && persistedAge.count === all.count, + ); + const thirty = await selectAge(30); + const one = await selectAge(1); + const seven = await selectAge(7); + record( + 'AGE: counts follow the window order and seven days restores the default', + one.count <= live.count && + thirty.count >= live.count && + thirty.count <= all.count && + seven.count === live.count && + seven.ageDays === 7, + `1d=${one.count}, 7d=${seven.count}, 30d=${thirty.count}, all=${all.count}`, + ); + // ---- (ii) PICK ---------------------------------------------------------- const target = await page.evaluate((id) => { const gev = window.__godsEyeView; diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 0946b6ceb..f368d260e 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -1,7 +1,7 @@ import { resolveCatalogSources } from './sourceComposition.js'; import { createWeatherClock } from '../layers/weather/clock.js'; import { createWeatherLayer } from '../layers/weather/index.js'; -import { createCyclonesLayer } from '../layers/cyclones/index.js'; +import { createApplicationCyclones } from './layers/cyclones.js'; import { createWindLayer } from '../layers/wind/index.js'; import { createLayerCatalog } from './catalog.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; @@ -182,7 +182,7 @@ export function createApplicationCatalog({ id: 'weather-lightning', clock: weatherClock, }), - createCyclonesLayer({ feed: sources.cyclones }), + createApplicationCyclones({ feed: sources.cyclones }), ...createOsmOverlayLayers(), ...createInfrastructureLayers(localGeoJsonServices), createApplicationCables({ source: sources.cables }), diff --git a/src/app/layers/cyclones.js b/src/app/layers/cyclones.js new file mode 100644 index 000000000..e0cd508b0 --- /dev/null +++ b/src/app/layers/cyclones.js @@ -0,0 +1,10 @@ +import { createCyclonesLayer } from '../../layers/cyclones/index.js'; +import * as context from '../../data/contextStore.js'; + +/** Wire cyclone selection to the application shared context. */ +export function createApplicationCyclones(options) { + return createCyclonesLayer({ + context: typeof window !== 'undefined' ? context : null, + ...options, + }); +} diff --git a/src/data/fixtures/README.md b/src/data/fixtures/README.md index e9dd55f50..257eaf8a5 100644 --- a/src/data/fixtures/README.md +++ b/src/data/fixtures/README.md @@ -1,5 +1,10 @@ # Test fixtures +- `photon-moscow-search.json` - Photon responses for `Москва`, captured + 2026-11-05 with and without a proximity bias near the Tajikistan namesake. + The fixture is trimmed to the fields the keyless geocoder consumes and is + used only by offline search tests. Data from OpenStreetMap contributors, + ODbL 1.0 (https://www.openstreetmap.org/copyright). - `tomtom-flow-austin-12-935-1686.pbf` — one real TomTom traffic-flow vector tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin z12 x935 y1686, captured 2026-07-16 from diff --git a/src/data/fixtures/photon-moscow-search.json b/src/data/fixtures/photon-moscow-search.json new file mode 100644 index 000000000..7b1a4d25a --- /dev/null +++ b/src/data/fixtures/photon-moscow-search.json @@ -0,0 +1,110 @@ +{ + "biased": { + "features": [ + { + "geometry": { + "type": "Point", + "coordinates": [71.8339931, 38.948412] + }, + "properties": { + "name": "Москва", + "country": "Тоҷикистон" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [37.6063916, 55.625578] + }, + "properties": { + "name": "Москва", + "country": "Россия" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [37.6174782, 55.7505412] + }, + "properties": { + "name": "Москва", + "country": "Россия" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [37.0536908, 55.7098009] + }, + "properties": { + "name": "Москва", + "country": "Россия" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [32.163334, 56.917778] + }, + "properties": { + "name": "Москва", + "country": "Россия" + } + } + ] + }, + "unbiased": { + "features": [ + { + "geometry": { + "type": "Point", + "coordinates": [37.6063916, 55.625578] + }, + "properties": { + "name": "Москва", + "country": "Россия" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [37.6174782, 55.7505412] + }, + "properties": { + "name": "Москва", + "country": "Россия" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [37.0536908, 55.7098009] + }, + "properties": { + "name": "Москва", + "country": "Россия" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [32.163334, 56.917778] + }, + "properties": { + "name": "Москва", + "country": "Россия" + } + }, + { + "geometry": { + "type": "Point", + "coordinates": [71.8339931, 38.948412] + }, + "properties": { + "name": "Москва", + "country": "Тоҷикистон" + } + } + ] + } +} diff --git a/src/data/infrastructure.test.mjs b/src/data/infrastructure.test.mjs index 45fb3d317..c7f1b5a23 100644 --- a/src/data/infrastructure.test.mjs +++ b/src/data/infrastructure.test.mjs @@ -82,7 +82,7 @@ test('infrastructure factory preserves identity and creates independent state wi test('dataset URLs still name the complete bundled sources', () => { for (const [file, count] of [ - ['datacenters', 4351], + ['datacenters', 4352], ['dams', 704], ]) { const lines = readFileSync( diff --git a/src/data/local_data/datacenters/README.md b/src/data/local_data/datacenters/README.md index 454a15007..a89894fdb 100644 --- a/src/data/local_data/datacenters/README.md +++ b/src/data/local_data/datacenters/README.md @@ -5,7 +5,7 @@ layer. - Source: OpenStreetMap contributors - License: Open Database License (ODbL) 1.0 -- Feature count: 4,351 +- Feature count: 4,352 - Runtime file: `datacenters.geojsonl` The public-release snapshot removes contact-oriented tags such as email, phone, @@ -16,3 +16,12 @@ ODbL 1.0 with the required OpenStreetMap attribution. The original extraction date and query were not recorded alongside this snapshot. Future refreshes should record both before replacing the file. + +## Norway refresh + +Features inside Geofabrik's Norway boundary (`norway.poly`, with Svalbard) come +from a newer extract than the rest of the file. Contact tags are dropped as above. + +- Extract: Geofabrik `norway-261005.osm.pbf`, OSM data + 2026-10-05T20:21:35Z, MD5 `1d2cfdeb98980fce11b8891d0f9146c0` +- Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center` diff --git a/src/data/local_data/datacenters/datacenters.geojsonl b/src/data/local_data/datacenters/datacenters.geojsonl index f7f5fc89f..b02bd57b3 100644 --- a/src/data/local_data/datacenters/datacenters.geojsonl +++ b/src/data/local_data/datacenters/datacenters.geojsonl @@ -3770,15 +3770,15 @@ {"id":939340882,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021364183,52.315373019],[21.02232433,52.315435883],[21.022363641,52.315209656],[21.021403578,52.315146875],[21.021364183,52.315373019]]]},"properties":{"id":11407,"tags":{"name":"T-Mobile Warszawa-Szlachecka","telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":939340882}} {"id":1005973518,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021325375,52.315595978],[21.022285857,52.315658423],[21.02232433,52.315435883],[21.021364183,52.315373019],[21.021325375,52.315595978]]]},"properties":{"id":11976,"tags":{"telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":1005973518}} {"id":1156823776,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.115130857,52.235385942],[21.115189866,52.236349777],[21.115232027,52.236667954],[21.115818174,52.237174556],[21.116175578,52.237468677],[21.117262124,52.236985628],[21.11728157,52.236976576],[21.117262795,52.236960985],[21.117340495,52.236926033],[21.117360276,52.2369433],[21.117447532,52.236904491],[21.11804097,52.236654459],[21.118030996,52.236645742],[21.118041054,52.236641719],[21.116603055,52.235624072],[21.116518984,52.235676375],[21.115986231,52.235696659],[21.115954966,52.235361551],[21.115130857,52.235385942]]]},"properties":{"id":15342,"tags":{"name":"Atman Data Center Warsaw-1","telecom":"data_center","website":"https://www.atman.pl"},"type":"data_center","osm_id":1156823776}} -{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.598350034,58.860111293]},"properties":{"id":24849,"tags":{"name":"Maan Group","telecom":"data_center","operator":"Privat"},"type":"data_center","osm_id":7405586142}} +{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.5983501,58.8601113]},"properties":{"id":24849,"tags":{"name":"Maan Group","opening_hours":"24/7","operator":"Privat","telecom":"data_center"},"type":"data_center","osm_id":7405586142}} {"id":481615983,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.572510149,56.481777951],[9.574069938,56.484921751],[9.57465357,56.48483349],[9.574639572,56.484805243],[9.574700676,56.484795939],[9.574714925,56.484824857],[9.575298557,56.484736595],[9.57454888,56.483225422],[9.574609229,56.483216369],[9.574510155,56.483016629],[9.574449722,56.483025849],[9.573738769,56.481592795],[9.573155137,56.481680972],[9.573170643,56.481712321],[9.573109539,56.481721625],[9.573093781,56.48168969],[9.572510149,56.481777951]]]},"properties":{"id":7881,"tags":{"telecom":"data_center","building":"data_center","operator":"Apple","wikidata":"Q19721053","wikipedia":"en:Foulum Data Center","description":"Apple Datacenter"},"type":"data_center","osm_id":481615983}} {"id":618756435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.500360092,57.318668738],[10.500399236,57.318671672],[10.500382891,57.318734871],[10.500552038,57.318744175],[10.500545249,57.318787677],[10.500501495,57.318785666],[10.500458999,57.318957746],[10.500970295,57.31899647],[10.501009187,57.318847189],[10.501084959,57.318852888],[10.50111966,57.318719197],[10.500843896,57.318698159],[10.500865521,57.318615178],[10.500859235,57.318614759],[10.500869461,57.318575699],[10.500954537,57.318582153],[10.501022347,57.31832223],[10.500736691,57.318300437],[10.500669133,57.318559689],[10.500674162,57.318560025],[10.500664272,57.318598498],[10.500383981,57.318577124],[10.500360092,57.318668738]]]},"properties":{"id":8842,"tags":{"ref":"AAL01-01","name":"Prime Data Centers - Saeby Campus AAL01","telecom":"data_center","website":"https://primedatacenters.com/locations/saeby/","building":"industrial","operator":"Prime Data Centers"},"type":"data_center","osm_id":618756435}} -{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002243225,59.633471864],[11.003903261,59.633446551],[11.007046726,59.633399696],[11.006983443,59.632420941],[11.006982772,59.63227057],[11.006911023,59.632265289],[11.006852936,59.632253973],[11.006765848,59.632228576],[11.00671782,59.632205275],[11.006119687,59.632213573],[11.006119352,59.632202592],[11.006118933,59.63219245],[11.005930424,59.632194378],[11.005923635,59.632120869],[11.005980799,59.632120031],[11.005979542,59.632104524],[11.005977027,59.63206865],[11.00597225,59.631998242],[11.005412171,59.632035541],[11.005286945,59.632043839],[11.00527848,59.632044342],[11.003707962,59.632147272],[11.003107483,59.63219178],[11.002741864,59.632235533],[11.002729375,59.63223788],[11.002345987,59.632309294],[11.002308352,59.632316251],[11.002302066,59.632477351],[11.002277088,59.632913881],[11.002243225,59.633471864]]]},"properties":{"id":107,"tags":{"name":"STACK Infrastructure OSL04","type":"multipolygon","telecom":"data_center","website":"https://www.stackinfra.com","operator":"Stack Infrastructure","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":-14367602}} +{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.0022433,59.6334719],[11.0022771,59.6329139],[11.0023021,59.6324774],[11.0023084,59.6323163],[11.002346,59.6323093],[11.0027294,59.6322379],[11.0027419,59.6322356],[11.0031075,59.6321918],[11.003708,59.6321473],[11.0052785,59.6320444],[11.005287,59.6320439],[11.0054122,59.6320356],[11.0059723,59.6319983],[11.0059771,59.6320687],[11.0059796,59.6321046],[11.0059808,59.6321201],[11.0059237,59.6321209],[11.0059305,59.6321944],[11.006119,59.6321925],[11.0061194,59.6322026],[11.0061197,59.6322136],[11.0067179,59.6322053],[11.0067659,59.6322286],[11.006853,59.632254],[11.0069111,59.6322653],[11.0069828,59.6322706],[11.0069835,59.632421],[11.0070468,59.6333997],[11.0039033,59.6334466],[11.0022433,59.6334719]]]},"properties":{"id":107,"tags":{"landuse":"industrial","name":"STACK Infrastructure OSL04","operator":"Stack Infrastructure","operator:wikidata":"Q115477173","telecom":"data_center","website":"https://www.stackinfra.com","type":"multipolygon"},"type":"data_center","osm_id":-14367602}} {"id":1028687251,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.004111886,59.632578437],[11.004142983,59.633037262],[11.004275166,59.633034999],[11.004274998,59.63303173],[11.00505133,59.633019157],[11.005056443,59.633099372],[11.005224416,59.63309669],[11.005190637,59.632562344],[11.005060885,59.632564439],[11.005060969,59.632567457],[11.004587978,59.632575419],[11.00458538,59.632536527],[11.004459819,59.632538623],[11.004462418,59.632576844],[11.004242476,59.6325807],[11.004242141,59.632576174],[11.004111886,59.632578437]]]},"properties":{"id":12226,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1028687251}} {"id":1164840308,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002600126,59.632599392],[11.002631223,59.633058217],[11.00276349,59.633055954],[11.002763238,59.633052685],[11.00353957,59.633040196],[11.003544683,59.633120327],[11.00371274,59.633117645],[11.003678961,59.632583382],[11.003549042,59.632585478],[11.003549293,59.632588495],[11.003076135,59.632596458],[11.00307362,59.632557566],[11.002948143,59.632559661],[11.002950657,59.632597883],[11.002730716,59.632601571],[11.002730465,59.632597129],[11.002600126,59.632599392]]]},"properties":{"id":15459,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840308}} {"id":1164840309,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.005613672,59.632723444],[11.005644769,59.633182269],[11.005776951,59.633179922],[11.005776784,59.633176737],[11.006553199,59.633164164],[11.006558229,59.633244463],[11.006726286,59.633241781],[11.006692423,59.632707351],[11.006562671,59.632709446],[11.006562839,59.632712464],[11.006089764,59.632720426],[11.006087166,59.632681534],[11.005961772,59.632683797],[11.005964287,59.632721851],[11.005744262,59.632725623],[11.005743927,59.632721181],[11.005613672,59.632723444]]]},"properties":{"id":15460,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840309}} -{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.991467366,59.759024056],[10.992780978,59.759406942],[10.992773937,59.759423957],[10.992948197,59.759474248],[10.993073842,59.759363859],[10.993022293,59.759348939],[10.993291688,59.759112653],[10.993128827,59.759065547],[10.993181885,59.759019027],[10.993083062,59.758990445],[10.992976947,59.759083484],[10.992845519,59.759045346],[10.992813584,59.759073342],[10.9927876,59.759065798],[10.993409789,59.758524243],[10.99263002,59.758297094],[10.991938848,59.758898663],[10.991693594,59.758827165],[10.991467366,59.759024056]]]},"properties":{"id":11377,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"warehouse","ref:bygningsnr":"300752944"},"type":"data_center","osm_id":935092287}} -{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.992826324,59.757448761],[10.994530281,59.757601396],[10.994634888,59.757303168],[10.992928332,59.757152545],[10.992826324,59.757448761]]]},"properties":{"id":15426,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"industrial"},"type":"data_center","osm_id":1161764012}} +{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9914674,59.7590241],[10.9916936,59.7588272],[10.9919389,59.7588987],[10.9926301,59.7582971],[10.9934098,59.7585243],[10.9927876,59.7590658],[10.9928136,59.7590734],[10.9928456,59.7590454],[10.992977,59.7590835],[10.9930831,59.7589905],[10.9931819,59.7590191],[10.9931289,59.7590656],[10.9932917,59.7591127],[10.9930223,59.759349],[10.9930739,59.7593639],[10.9929482,59.7594743],[10.992774,59.759424],[10.992781,59.759407],[10.9914674,59.7590241]]]},"properties":{"id":11377,"tags":{"building":"warehouse","name":"Green Mountain Ytre Enebakk","ref:bygningsnr":"300752944","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":935092287}} +{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9928264,59.7574488],[10.9929284,59.7571526],[10.9946349,59.7573032],[10.9945303,59.7576014],[10.9928264,59.7574488]]]},"properties":{"id":15426,"tags":{"building":"industrial","name":"Green Mountain Ytre Enebakk","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":1161764012}} {"id":30724580,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.808790995,59.924984985],[10.808893925,59.925031923],[10.80893793,59.92500594],[10.808982941,59.925025972],[10.809042955,59.92501298],[10.809083943,59.925031923],[10.809012948,59.925077856],[10.80926583,59.925189755],[10.80955115,59.925025972],[10.809676543,59.925081293],[10.81032899,59.924701928],[10.809890365,59.924508138],[10.80982155,59.924515347],[10.809691631,59.924457847],[10.808820667,59.924557592],[10.809099365,59.924699581],[10.808806418,59.924865291],[10.808918149,59.924914828],[10.808875988,59.924937962],[10.808790995,59.924984985]]]},"properties":{"id":261,"tags":{"name":"Stack Infrastructure OSLO1A","telecom":"data_center","building":"office","operator":"Stack Infrastructure","ref:bygningsnr":"80877293","building:levels":"4","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":30724580}} {"id":80871917,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.83408892,59.938588981],[10.834305927,59.938720996],[10.834575992,59.938720996],[10.834574987,59.938736],[10.834581943,59.938736922],[10.834579932,59.938771958],[10.834736925,59.938771958],[10.834734997,59.93893792],[10.836717988,59.938944961],[10.836717988,59.938983937],[10.836817984,59.938983937],[10.83681899,59.938944961],[10.836844974,59.938944961],[10.836846985,59.938774976],[10.836841956,59.938774976],[10.836842962,59.938744968],[10.836849919,59.938744968],[10.836851931,59.938653941],[10.836683957,59.938652935],[10.836684125,59.938640278],[10.836686975,59.938414973],[10.83490297,59.938406926],[10.834899953,59.938646984],[10.834812949,59.938645978],[10.83481496,59.938551933],[10.834579932,59.938550927],[10.834580938,59.938564925],[10.834567946,59.938564925],[10.834567946,59.938551933],[10.834089926,59.938549922],[10.83408892,59.938588981]]]},"properties":{"id":1464,"tags":{"name":"Bulk Data Centers Oslo Internet Exchange","telecom":"data_center","website":"https://bulkinfrastructure.com/data-centers/locations/os-ix","building":"industrial","ref:bygningsnr":"80709943"},"type":"data_center","osm_id":80871917}} {"id":12158409495,"type":"Feature","geometry":{"type":"Point","coordinates":[9.689363435,62.59439936]},"properties":{"id":28723,"tags":{"name":"VisBook AS","telecom":"data_center","website":"https://visbook.com/"},"type":"data_center","osm_id":12158409495}} @@ -4349,3 +4349,4 @@ {"id":540895898,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.71747909,56.303855963],[101.71782015,56.304420065],[101.721113819,56.303806929],[101.720772759,56.303242994],[101.71747909,56.303855963]]]},"properties":{"id":8271,"tags":{"name":"Битривер Рус","name:en":"Bitriver Rus","name:ru":"Битривер Рус","telecom":"data_center","website":"https://bitriver.farm/","building":"data_center"},"type":"data_center","osm_id":540895898}} {"id":1082803064,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[123.948021193,47.340264299],[123.948354625,47.340268239],[123.948490663,47.340269831],[123.948488568,47.340349292],[123.948809762,47.340353064],[123.948811942,47.340267485],[123.948918643,47.340268658],[123.948922918,47.340137817],[123.948023959,47.340126082],[123.948021193,47.340264299]]]},"properties":{"id":13377,"tags":{"name":"联通云数据中心·齐齐哈尔","name:en":"Unicom Data Center of Qiqihar","name:zh":"联通云数据中心·齐齐哈尔","telecom":"data_center","building":"yes","name:zh-Hans":"联通云数据中心·齐齐哈尔","name:zh-Hant":"聯通雲數據中心·齊齊哈爾"},"type":"data_center","osm_id":1082803064}} {"id":442479059,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[142.76019226,46.910096898],[142.760192427,46.910325724],[142.760447656,46.910328155],[142.760460397,46.910102263],[142.76019226,46.910096898]]]},"properties":{"id":7471,"tags":{"telecom":"data_center","building":"yes"},"type":"data_center","osm_id":442479059}} +{"id":1551029155,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.1069147,62.8627625],[7.1071207,62.8621617],[7.1075917,62.8616616],[7.1088647,62.8620476],[7.1106308,62.862645],[7.1098655,62.8632827],[7.1089604,62.8630042],[7.1086734,62.8632156],[7.1069147,62.8627625]]]},"properties":{"id":29471,"tags":{"layer":"-1","loc_name":"Troll Housing Klempertåsen","name":"Troll Housing","operator":"Troll Housing AS","telecom":"data_center","website":"https://www.trollhousing.no/"},"type":"data_center","osm_id":1551029155}} diff --git a/src/data/placeNames.js b/src/data/placeNames.js index 300d09209..90b458dd3 100644 --- a/src/data/placeNames.js +++ b/src/data/placeNames.js @@ -11,17 +11,16 @@ const FOLD = { }; /** - * Comparable form of a place name, shared by the bundled packs, the - * OpenFreeMap street and grounds matchers and the Nominatim server: case - * folded, accents removed from Latin letters only, punctuation and symbols - * collapsed to single spaces, and a leading "the" dropped. Other scripts keep - * their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while "São Paulo", - * "sao paulo" and "SÃO PAULO!" meet. + * Script-aware fold of a place name: case folded, accents removed from Latin + * letters only, punctuation and symbols collapsed to single spaces. Other + * scripts keep their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while + * "São Paulo", "sao paulo" and "SÃO PAULO!" meet. Every word is kept: "The + * Hague" is not "Hague". * * @param {unknown} value * @returns {string} */ -export function foldPlaceName(value) { +export function foldPlaceText(value) { return String(value ?? '') .toLowerCase() .replace(/[ßøæłđıœþ]/g, (c) => FOLD[c]) @@ -29,8 +28,19 @@ export function foldPlaceName(value) { .replace(/(\p{Script=Latin})\p{M}+/gu, '$1') .normalize('NFKC') .replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ') - .trim() - .replace(/^the /, ''); + .trim(); +} + +/** + * Comparable form of a place name, shared by the bundled packs, the + * OpenFreeMap street and grounds matchers and the Nominatim server: + * `foldPlaceText` with a leading "the" dropped. + * + * @param {unknown} value + * @returns {string} + */ +export function foldPlaceName(value) { + return foldPlaceText(value).replace(/^the /, ''); } /** diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 8a2b81e20..f1d9903ed 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3466,5 +3466,12 @@ "Open {name} on Acartia": "فتح {name} على Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل", + "{days}d": "{days}ي", + "All markers": "كل العلامات", + "Show markers of every age": "عرض العلامات من جميع الأعمار", + "Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة", + "event age filter|All": "الكل", + "Event age": "عمر الحدث", + "Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 837a87471..50715c99f 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3166,5 +3166,12 @@ "Open {name} on Acartia": "{name} auf Acartia öffnen", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag", + "{days}d": "{days}T", + "All markers": "Alle Markierungen", + "Show markers of every age": "Markierungen jeden Alters anzeigen", + "Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben", + "event age filter|All": "Alle", + "Event age": "Ereignisalter", + "Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 57aa09eb7..6adb40da9 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3241,5 +3241,12 @@ "Open {name} on Acartia": "Abrir {name} en Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro", + "{days}d": "{days}d", + "All markers": "Todos los marcadores", + "Show markers of every age": "Mostrar marcadores de cualquier antigüedad", + "Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha", + "event age filter|All": "Todos", + "Event age": "Antigüedad", + "Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index bfd2a5975..788210f89 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3241,5 +3241,12 @@ "Open {name} on Acartia": "Ouvrir {name} sur Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation", + "{days}d": "{days}j", + "All markers": "Tous les marqueurs", + "Show markers of every age": "Afficher les marqueurs de tout âge", + "Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés", + "event age filter|All": "Tous", + "Event age": "Ancienneté", + "Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 74c0d01aa..567c3c6ed 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3241,5 +3241,12 @@ "Open {name} on Acartia": "Apri {name} su Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione", + "{days}d": "{days}g", + "All markers": "Tutti gli indicatori", + "Show markers of every age": "Mostra gli indicatori di qualsiasi età", + "Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti", + "event age filter|All": "Tutti", + "Event age": "Età degli eventi", + "Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 932d851e2..5b96cb3a8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3091,5 +3091,12 @@ "Open {name} on Acartia": "Acartiaで{name}を開く", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと", + "{days}d": "{days}日", + "All markers": "すべてのマーカー", + "Show markers of every age": "日付にかかわらずすべてのマーカーを表示", + "Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持", + "event age filter|All": "すべて", + "Event age": "経過日数", + "Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index e71f144d5..20bbea5aa 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3241,5 +3241,12 @@ "Open {name} on Acartia": "Abrir {name} no Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro", + "{days}d": "{days}d", + "All markers": "Todos os marcadores", + "Show markers of every age": "Mostrar marcadores de qualquer idade", + "Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos", + "event age filter|All": "Todos", + "Event age": "Idade do evento", + "Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 6573ad11b..ca4813a5a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3316,5 +3316,12 @@ "Open {name} on Acartia": "Открыть {name} в Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи", + "{days}d": "{days}д", + "All markers": "Все метки", + "Show markers of every age": "Показать метки любого возраста", + "Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены", + "event age filter|All": "Все", + "Event age": "Возраст события", + "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 18ea8dc4f..9b7ae1f61 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3316,5 +3316,12 @@ "Open {name} on Acartia": "Відкрити {name} в Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису", + "{days}d": "{days}д", + "All markers": "Усі позначки", + "Show markers of every age": "Показати позначки будь-якого віку", + "Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено", + "event age filter|All": "Усі", + "Event age": "Вік події", + "Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 8d09b74a3..9b17dc66f 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3091,5 +3091,12 @@ "Open {name} on Acartia": "在 Acartia 上打开 {name}", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权", + "{days}d": "{days}天", + "All markers": "所有标记", + "Show markers of every age": "显示所有时间的标记", + "Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记", + "event age filter|All": "全部", + "Event age": "事件时间", + "Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 30801f0f9..e3d9b716c 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3091,5 +3091,12 @@ "Open {name} on Acartia": "在 Acartia 上打開 {name}", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權" + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權", + "{days}d": "{days}天", + "All markers": "所有標記", + "Show markers of every age": "顯示所有時間的標記", + "Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記", + "event age filter|All": "全部", + "Event age": "事件時間", + "Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記" } diff --git a/src/keylessGeocoder.js b/src/keylessGeocoder.js index 4e01f1c74..3a5cd0035 100644 --- a/src/keylessGeocoder.js +++ b/src/keylessGeocoder.js @@ -1,3 +1,5 @@ +import { foldPlaceText } from './data/placeNames.js'; + /** Photon/OpenStreetMap place-search adapter. */ const PHOTON_ENDPOINT = 'https://photon.komoot.io/api/'; @@ -233,19 +235,15 @@ export function photonSearchUrl( } /** - * Place name reduced to comparable form: unaccented, lowercased, punctuation - * collapsed to single spaces. "Huế" and "Hue" have to compare equal, because a - * user typing either means the same city. + * Place name reduced to comparable form: lowercased, Latin accents removed, + * other scripts preserved, and punctuation collapsed to single spaces. "Huế" + * and "Hue" have to compare equal, because a user typing either means the same + * city. * @param {string} text * @returns {string} */ export function normalizeToponym(text) { - return String(text ?? '') - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .toLowerCase() - .replace(/[^a-z0-9]+/g, ' ') - .trim(); + return foldPlaceText(text); } /** diff --git a/src/keylessGeocoder.test.mjs b/src/keylessGeocoder.test.mjs index 95098a10c..7f50ebc2e 100644 --- a/src/keylessGeocoder.test.mjs +++ b/src/keylessGeocoder.test.mjs @@ -35,6 +35,12 @@ import { import { geocodeNavigationMode } from './locations.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const MOSCOW_RESPONSES = JSON.parse( + fs.readFileSync( + path.join(ROOT, 'src/data/fixtures/photon-moscow-search.json'), + 'utf8', + ), +); /** Photon's real answer for "Hanoi", trimmed to the fields we consume. */ const HANOI = { @@ -249,6 +255,58 @@ test('a biased near-miss is refused, and the unbiased answer is taken instead', assert.equal(fetchImpl.urls.length, 2, 'the refused bias costs exactly one extra request'); }); +test('a non-Latin exact match keeps the nearby namesake', async () => { + const fetchImpl = photonPair( + MOSCOW_RESPONSES.biased.features, + MOSCOW_RESPONSES.unbiased.features, + ); + + const result = await geocodeKeyless('Москва', { + bias: '38.90,71.80|39.00,71.90', + fetchImpl, + }); + + assert.equal(result.label, 'Москва, Тоҷикистон'); + assert.equal(fetchImpl.urls.length, 1); +}); + +test('toponym folding preserves non-Latin scripts', () => { + const cases = [ + ['Huế', 'hue'], + ['Москва', 'москва'], + ['Αθήνα', 'αθήνα'], + ['القاهرة', 'القاهرة'], + ['東京', '東京'], + ['São 東京', 'sao 東京'], + ['Straße', 'strasse'], + ['Tromsø', 'tromso'], + ['Łódź', 'lodz'], + ['The Hague', 'the hague'], + ]; + + for (const [input, expected] of cases) { + assert.equal(normalizeToponym(input), expected, input); + } +}); + +test('a leading "the" is part of the name a nearby match must equal', async () => { + // "The Hague" over upstate New York means the Dutch city, not Hague, NY. + const hagueNy = { + geometry: { type: 'Point', coordinates: [-73.5004, 43.6967] }, + properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Hague', country: 'United States' }, + }; + const theHague = { + geometry: { type: 'Point', coordinates: [4.3113, 52.0799] }, + properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'The Hague', country: 'Nederland' }, + }; + const fetchImpl = photonPair([hagueNy], [theHague]); + + const result = await geocodeKeyless('The Hague', { bias: '43.65,-73.55|43.75,-73.45', fetchImpl }); + + assert.equal(Math.round(result.lat), 52, `got ${result.label}`); + assert.equal(fetchImpl.urls.length, 2); +}); + test('a biased match that leads with the name is taken without a second request', async () => { // The other half of the rule, and the reason bias exists at all: "Sixth // Street" over Austin must stay Austin's. diff --git a/src/layers/cyclones/index.js b/src/layers/cyclones/index.js index 9138e8830..90936edf3 100644 --- a/src/layers/cyclones/index.js +++ b/src/layers/cyclones/index.js @@ -67,6 +67,7 @@ export function createCyclonesLayer({ feed, hitTestOverlay = hitTestWorldOverlay, overlayHost, + context = null, cesium = Cesium, createRendering = createCycloneRendering, matchMedia = globalThis.matchMedia?.bind(globalThis), @@ -82,6 +83,7 @@ export function createCyclonesLayer({ selectedId = null, selectionIntent = 'auto', navigationGeneration = 0, + selectedContextStormId = null, clickHandler = null, removeClickCapture = null; let enabled = false, @@ -92,6 +94,62 @@ export function createCyclonesLayer({ const notify = () => listener?.(); const selected = () => snapshot?.storms.find((storm) => storm.id === selectedId) || null; + function clearSelectedContext({ evicted = false } = {}) { + if (selectedContextStormId) { + if (!evicted) + context.clearSelectedEntityContextForLayer('weather-cyclones'); + context.removeEntityContextsForLayer('weather-cyclones'); + } + selectedContextStormId = null; + } + + // Callers publish only while selectedId names a storm in the snapshot. + function publishSelectedContext({ announce = false } = {}) { + const storm = selected(); + if (!context) return; + const entity = {}; + const contextId = `cyclone:${storm.id}`; + context.registerEntityContext(entity, { + id: contextId, + layerId: 'weather-cyclones', + layerName: 'Cyclone advisories', + source: 'NOAA NHC / CPHC', + label: storm.name, + latitude: storm.position.latitude, + longitude: storm.position.longitude, + // At most 12 properties reach the voice summary; keep the list short. + properties: { + classification: storm.classification, + classificationName: Object.hasOwn( + CLASSIFICATION_NAMES, + storm.classification, + ) + ? CLASSIFICATION_NAMES[storm.classification] + : undefined, + basin: storm.basin, + advisoryNumber: storm.advisoryNumber, + issuedAt: storm.issuedAt, + positionAt: storm.positionAt, + windKt: storm.windKt, + pressureHpa: storm.pressureHpa, + // Flat scalars: the voice context summary drops nested objects. + movementDirectionDegrees: storm.movement?.directionDegrees, + movementSpeedKt: storm.movement?.speedKt, + advisoryUrl: storm.advisoryUrl, + // Only while the proxy serves its cache because NHC is unreachable. + staleCacheFetchedAt: + snapshot.stale && Number.isFinite(snapshot.fetchedAt) + ? new Date(snapshot.fetchedAt).toISOString() + : undefined, + }, + }); + selectedContextStormId = storm.id; + if (announce) context.selectEntityContext(entity); + context.removeEntityContextsForLayer('weather-cyclones', { + retainIds: new Set([contextId]), + }); + } + function select(id) { if (selectedId !== id) ++navigationGeneration; selectedId = id; @@ -236,6 +294,7 @@ export function createCyclonesLayer({ selectedId = null; selectionIntent = 'auto'; ++navigationGeneration; + clearSelectedContext(); rendering?.clear(); }, async update(_viewer, { signal } = {}) { @@ -259,6 +318,7 @@ export function createCyclonesLayer({ snapshot = next; selectedId = null; ++navigationGeneration; + clearSelectedContext({ evicted: true }); error = next.reason || ADVISORIES_UNAVAILABLE; return true; } @@ -281,6 +341,10 @@ export function createCyclonesLayer({ : snapshot.storms[0]?.id || null, ); else rendering.setSelection(selectedId); + if (selectedContextStormId) { + if (selectedId === selectedContextStormId) publishSelectedContext(); + else clearSelectedContext({ evicted: true }); + } return true; } catch (cause) { if (controller.signal.aborted || request !== controller) return false; @@ -290,6 +354,7 @@ export function createCyclonesLayer({ snapshot = null; selectedId = null; ++navigationGeneration; + clearSelectedContext({ evicted: true }); return true; } finally { signal?.removeEventListener('abort', abort); @@ -305,6 +370,7 @@ export function createCyclonesLayer({ if (params.clear === true || params.stormId === null) { selectionIntent = 'cleared'; select(null); + clearSelectedContext(); notify(); } else if ( typeof params.stormId === 'string' && @@ -312,6 +378,7 @@ export function createCyclonesLayer({ ) { selectionIntent = 'user'; select(params.stormId); + publishSelectedContext({ announce: true }); notify(); } const storm = selected(); diff --git a/src/layers/cyclones/index.test.mjs b/src/layers/cyclones/index.test.mjs index aff654659..e826f392e 100644 --- a/src/layers/cyclones/index.test.mjs +++ b/src/layers/cyclones/index.test.mjs @@ -7,6 +7,9 @@ import { isPointerFree, } from '../../data/inputOwnership.js'; import { isOwnedByOtherLayer } from '../../data/pickRegistry.js'; +import * as contextStore from '../../data/contextStore.js'; +import { createGevActionRunner } from '../../voice/gevActions.js'; +import * as Cesium from 'cesium'; const time = '2026-09-16T03:00:00.000Z'; const storm = (id = 'ep152026') => ({ @@ -338,7 +341,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick }); function harness( feed = { getSnapshot: async () => snapshot() }, - { reducedMotion = false } = {}, + { reducedMotion = false, context = null } = {}, ) { const applied = [], navigation = [], @@ -368,6 +371,7 @@ function harness( }; const layer = createCyclonesLayer({ feed, + context, createRendering: () => rendering, matchMedia: () => ({ matches: reducedMotion }), openLink: (url) => opened.push(url), @@ -401,6 +405,256 @@ function harness( }, }; } +// Real shared-context store on a stand-in window; the voice runner reads it. +async function withSharedContext(t, body) { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const host = new EventTarget(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: host, + }); + const layers = []; + t.after(() => { + try { + for (const layer of layers) layer.destroy(); + } finally { + if (previousWindow) + Object.defineProperty(globalThis, 'window', previousWindow); + else delete globalThis.window; + } + }); + const events = []; + host.addEventListener('gev:entity-selected', ({ detail }) => + events.push(['selected', detail.id]), + ); + host.addEventListener('gev:entity-selection-cleared', ({ detail }) => + events.push(['cleared', detail.layerId, detail.reason]), + ); + // The voice runner bounds its basemap wait with the window timers. + Object.assign(host, { setTimeout, clearTimeout }); + const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000); + const runner = createGevActionRunner({ + viewer: { + clock: { onTick: { addEventListener: () => () => {} } }, + scene: { canvas: { addEventListener() {}, removeEventListener() {} } }, + camera: { + moveEnd: { addEventListener() {} }, + positionWC: position, + positionCartographic: Cesium.Cartographic.fromCartesian(position), + heading: 0, + pitch: 0, + }, + }, + styleManager: {}, + dataManager: { + layers: new Map(), + isEnabled: (id) => id === 'weather-cyclones', + getAll: () => [], + }, + }); + const selectedProperties = async () => { + const result = await runner('get_entity_context', { scope: 'selected' }); + assert.equal(result.scope, 'selected'); + return result.selected.properties; + }; + const store = contextStore.getContextStore(); + const open = (feed) => { + const h = harness(feed, { context: contextStore }); + layers.push(h.layer); + return h; + }; + await body({ events, selectedProperties, store, open }); +} + +test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => { + await withSharedContext( + t, + async ({ events, selectedProperties, store, open }) => { + let next = snapshot([ + { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, + { + ...storm('ep162026'), + movement: { directionDegrees: 0, speedKt: 10 }, + }, + ]); + const h = open({ getSnapshot: async () => next }); + h.layer.enable(); + await h.layer.update(); + assert.deepEqual( + events, + [], + 'the automatic first-storm row selection must not claim shared context', + ); + assert.equal(store.entities.size, 0); + + h.layer.setParams({ stormId: 'ep152026' }); + assert.equal(store.entities.get('cyclone:ep152026').label, 'Fifteen-E'); + let properties = await selectedProperties(); + assert.equal(properties.classification, 'PTC'); + assert.equal(properties.classificationName, 'Post-tropical cyclone'); + assert.equal(properties.advisoryNumber, '10'); + assert.equal( + properties.movementDirectionDegrees, + '315', + 'selected storm movement should survive voice summarization', + ); + assert.equal(properties.movementSpeedKt, '12'); + assert.equal( + properties.advisoryUrl, + 'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml', + ); + assert.equal(properties.staleCacheFetchedAt, undefined); + + h.layer.setParams({ stormId: 'ep162026' }); + properties = await selectedProperties(); + assert.equal( + properties.movementDirectionDegrees, + '0', + 'a due-north heading is a value, not a missing one', + ); + assert.equal(properties.movementSpeedKt, '10'); + assert.deepEqual(events, [ + ['selected', 'cyclone:ep152026'], + ['selected', 'cyclone:ep162026'], + ]); + assert.deepEqual([...store.entities.keys()], ['cyclone:ep162026']); + + next = snapshot([ + { ...storm(), movement: { directionDegrees: 315, speedKt: 12 } }, + { ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } }, + ]); + await h.layer.update(); + assert.equal(events.length, 2, 'refresh must not announce selection'); + properties = await selectedProperties(); + assert.equal( + properties.movementSpeedKt, + '0', + 'stationary is a value, refreshed into the selected record', + ); + + h.layer.setParams({ clear: true }); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'deliberate', + ]); + assert.equal(store.entities.size, 0); + + h.layer.setParams({ stormId: 'ep162026' }); + next = snapshot([storm()]); + await h.layer.update(); + assert.deepEqual( + events.at(-1), + ['cleared', 'weather-cyclones', 'evicted'], + 'a vanished user-selected storm must clear instead of transferring context to the fallback row', + ); + assert.equal(store.entities.size, 0); + assert.equal(store.selectedEntityId, null); + + h.layer.setParams({ stormId: 'ep152026' }); + h.layer.disable(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'deliberate', + ]); + assert.equal(store.entities.size, 0); + }, + ); +}); + +test('shared cyclone context is evicted when the source fails and marks a stale cache', async (t) => { + await withSharedContext( + t, + async ({ events, selectedProperties, store, open }) => { + let next = snapshot([{ ...storm(), classification: 'XX' }]); + let fail = false; + const h = open({ + getSnapshot: async () => { + if (fail) throw new Error('Network unavailable'); + return next; + }, + }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + let properties = await selectedProperties(); + assert.equal(properties.classification, 'XX'); + assert.equal( + properties.classificationName, + undefined, + 'an unknown code is not given a guessed meaning', + ); + + next = { ...snapshot(), stale: true }; + await h.layer.update(); + properties = await selectedProperties(); + assert.equal(properties.staleCacheFetchedAt, time); + assert.equal(events.length, 1, 'refresh must not announce selection'); + + next = { + ...snapshot([]), + unavailable: true, + reason: 'Cyclone data unavailable', + }; + await h.layer.update(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'evicted', + ]); + assert.equal(store.entities.size, 0); + + next = snapshot(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + assert.equal(store.selectedEntityId, 'cyclone:ep152026'); + fail = true; + await h.layer.update(); + assert.deepEqual(events.at(-1), [ + 'cleared', + 'weather-cyclones', + 'evicted', + ]); + assert.equal(store.entities.size, 0); + assert.equal(store.selectedEntityId, null); + }, + ); +}); + +test('cyclone clears leave another layer selection and its records alone', async (t) => { + await withSharedContext(t, async ({ events, store, open }) => { + const h = open({ getSnapshot: async () => snapshot() }); + h.layer.enable(); + await h.layer.update(); + h.layer.setParams({ stormId: 'ep152026' }); + const foreign = {}; + contextStore.registerEntityContext(foreign, { + id: 'flight:abc123', + layerId: 'flights', + label: 'Foreign', + }); + contextStore.selectEntityContext(foreign); + const before = events.length; + + h.layer.setParams({ clear: true }); + assert.equal(store.selectedEntityId, 'flight:abc123'); + assert.ok(store.entities.has('flight:abc123')); + assert.equal(store.entities.has('cyclone:ep152026'), false); + assert.equal( + events.length, + before, + 'no clear is reported for a foreign selection', + ); + + // A cyclone clear does not report a dangling foreign selection as evicted. + store.entities.delete('flight:abc123'); + h.layer.disable(); + assert.equal(store.selectedEntityId, 'flight:abc123'); + assert.equal(events.length, before); + }); +}); + test('refresh preserves selection intent, including explicit clears and missing storms', async () => { let next = snapshot([storm(), storm('ep162026')]); const h = harness({ getSnapshot: async () => next }); diff --git a/src/layers/flights/hiddenContactRotation.test.mjs b/src/layers/flights/hiddenContactRotation.test.mjs new file mode 100644 index 000000000..d410eb5d4 --- /dev/null +++ b/src/layers/flights/hiddenContactRotation.test.mjs @@ -0,0 +1,163 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { createFlightState } from './state.js'; +import { createRendering } from './rendering.js'; +import { + FLEET_DR_INTERVAL_MS, + HIDDEN_CONTACT_DR_INTERVAL_MS, + HIDDEN_CONTACT_DR_TICKS, + hiddenContactDrPhase, +} from './policy.js'; + +// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation +// instead of every tick. Their positions still feed hidden-inclusive proximity +// queries, so the rotation must reach every contact about once a second, and +// it must spread them across ticks so no single tick carries the whole fleet. + +const icao = (n) => n.toString(16).padStart(6, '0'); + +test('the rotation revisits each hidden contact about once a second', () => { + const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS; + assert.ok(HIDDEN_CONTACT_DR_TICKS > 1); + assert.ok( + Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS, + ); +}); + +test('a contact keeps the same slot, inside the rotation', () => { + for (let n = 0; n < 500; n++) { + const phase = hiddenContactDrPhase(icao(n * 7919)); + assert.ok(Number.isInteger(phase)); + assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS); + assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase); + } +}); + +test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => { + const fleet = 13000; + const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0); + // Real ICAO24 addresses cluster by national block; sample several blocks. + for (let n = 0; n < fleet; n++) + perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++; + const fair = fleet / HIDDEN_CONTACT_DR_TICKS; + for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`); +}); + +/** Real fleet tick and horizon culling, with motion and presentation injected. */ +function fleetFixture() { + let now = 1000; + let reckonings = 0; + const services = { + groundSnap: { createGroundSnap: () => ({ clear() {} }) }, + militaryRegistry: { isMilitaryIcao: () => false }, + aircraftPresentation: { isTr3b: () => false }, + camera: {}, + focus: { + focusNowMs: () => now, + getFocusTarget: () => null, + nearFarScalarValueAtDistance: () => 1, + advanceProjectedSpriteFocus: () => ({ factor: 1 }), + }, + recession: { + applyAircraftBillboardTreatment: () => ({ + factors: { scale: 1 }, + alpha: 1, + }), + }, + }; + const flightState = createFlightState({ services }); + const camera = { + positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100), + positionCartographic: { height: 100 }, + heading: 0, + pitch: 0, + roll: 0, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; + const id = 'abc123'; + // 111 km away at ground level is beyond a 100 m camera's horizon, but + // inside the model keep radius. No WebGL scene is needed for the culler. + const billboard = { + show: false, + position: Cesium.Cartesian3.fromDegrees(1, 0, 0), + }; + flightState._viewer = { camera, scene: {} }; + flightState._billboardCollection = { show: true }; + flightState._billboards.set(id, billboard); + flightState.records.data.set(id, {}); + flightState._models3dEnabled = false; + // Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free. + flightState._cockpitContactMode = true; + const parts = { + tracking: { _refreshCockpitNearContacts() {} }, + motion: { + _deadReckon() { + reckonings++; + return billboard.position; + }, + _floorGroundedDisplayPosition(id, info, position) { + return position; + }, + }, + }; + const rendering = createRendering({ flightState, services, parts }); + return { + flightState, + camera, + billboard, + id, + reckonings: () => reckonings, + tick() { + now += FLEET_DR_INTERVAL_MS; + rendering._fleetTick(); + }, + offTurn() { + flightState._fleetTickSeq = + (hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS; + }, + }; +} + +test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => { + const f = fleetFixture(); + for (let i = 0; i < HIDDEN_CONTACT_DR_TICKS; i++) f.tick(); + assert.equal(f.reckonings(), 1); + assert.equal(f.billboard.show, false); +}); + +test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => { + const f = fleetFixture(); + f.offTurn(); + f.tick(); + assert.equal(f.reckonings(), 0); + f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100); + f.offTurn(); + f.tick(); + assert.equal(f.reckonings(), 1); + assert.equal(f.billboard.show, true); +}); + +test('a hidden contact with a retained fleet model is never rotation-skipped', () => { + const f = fleetFixture(); + f.flightState._models3dEnabled = true; + const model = { ready: true, show: true }; + f.flightState._models.set(f.id, model); + f.offTurn(); + f.tick(); + assert.equal(f.reckonings(), 1); + assert.equal( + f.flightState._models.get(f.id), + model, + 'model remains in keep range', + ); + assert.equal( + model.show, + false, + 'the full pass hides its model beyond the horizon', + ); +}); diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 2ecb0a8a4..9f0e810bd 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -198,6 +198,37 @@ export const FLEET_DR_INTERVAL_MS = 80; export const ROTATION_REFRESH_MS = 1000; +/** + * @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the + * horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows + * well under 1% of them; the rest still need positions for hidden-inclusive + * proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the + * 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in + * every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all. + */ + +export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000; + +export const HIDDEN_CONTACT_DR_TICKS = Math.max( + 1, + Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS), +); + +/** + * Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden + * contact re-reckons on the same tick of every rotation. + * @param {string} id Contact id (ICAO24). + * @returns {number} + */ + +export function hiddenContactDrPhase(id) { + const text = String(id); + let hash = 0; + for (let i = 0; i < text.length; i++) + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + return hash % HIDDEN_CONTACT_DR_TICKS; +} + /** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary * steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */ diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index d4075a565..6709f9647 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -36,6 +36,8 @@ import { COURSE_SLEW_DT_MAX_SEC, ROTATION_REFRESH_MS, COURSE_MAX_DPS, + HIDDEN_CONTACT_DR_TICKS, + hiddenContactDrPhase, } from './policy.js'; import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; @@ -839,6 +841,12 @@ export function createRendering({ } } + /** The contact's hidden-rotation slot, cached on its billboard. */ + function _hiddenDrPhase(bb, icao24) { + bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24); + return bb._gevHiddenDrPhase; + } + function _fleetTick() { if ( !flightState._viewer || @@ -965,9 +973,25 @@ export function createRendering({ for (const icao of toRelease) _releaseModel(icao); } + const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS; + for (const [icao24, bb] of flightState._billboards) { if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion + // A contact still behind the horizon at its last position would only be + // re-reckoned to be hidden again, so off its turn it keeps that position + // (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view, + // and any contact with a fleet model, takes the full pass below at once. + if ( + !bb.show && + _hiddenDrPhase(bb, icao24) !== hiddenDrPhase && + !flightState._models.has(icao24) && + !occluder.isPointVisible( + flightState._cullPositions.get(icao24) || bb.position, + ) + ) + continue; + const info = flightState.records.data.get(icao24); const dr = parts.motion._deadReckon( diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index b297d5a9d..25d1c1aa3 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -204,6 +204,10 @@ export function createFlightState({ source, services }) { flightState._lastFleetTickMs = 0; + /** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */ + + flightState._fleetTickSeq = 0; + /** @type {string} Camera pose signature at the last rotation pass */ flightState._lastCamPoseSig = ''; diff --git a/src/layers/ghostMaps/index.js b/src/layers/ghostMaps/index.js index 01d564d05..79971c715 100644 --- a/src/layers/ghostMaps/index.js +++ b/src/layers/ghostMaps/index.js @@ -16,7 +16,15 @@ import { } from '../../i18n/index.js'; import { createHorizonCull } from '../streetLevel/view.js'; import { readGhostMapsKml } from './kml.js'; -import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js'; +import { + fieldLabel, + GHOSTMAPS_AGE_OPTIONS, + GHOSTMAPS_DEFAULT_AGE_DAYS, + ghostMapsEventDate, + isGhostMapsEventExpired, + parseDescriptionFields, + wrapCardText, +} from './records.js'; export * from './catalog.js'; export * from './records.js'; export { readGhostMapsKml } from './kml.js'; @@ -26,6 +34,7 @@ export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps'; export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps'; const PICK_PREFIX = 'ghostmaps:'; const STORAGE_KEY = 'gev.ghostmaps.products'; +const AGE_STORAGE_KEY = 'gev.ghostmaps.ageDays'; const ACCENT = '#c8ff00'; const MAX_ICON_BYTES = 512 * 1024; const CARD_HOST_OPTIONS = Object.freeze({ @@ -82,6 +91,19 @@ function writeStoredSelection(storage, selected) { } } +function readStoredAge(storage) { + try { + const raw = storage?.getItem(AGE_STORAGE_KEY); + if (raw !== null && raw !== undefined) { + const value = JSON.parse(raw); + if (GHOSTMAPS_AGE_OPTIONS.includes(value)) return value; + } + } catch { + /* unavailable or invalid storage uses the default */ + } + return GHOSTMAPS_DEFAULT_AGE_DAYS; +} + /** A round marker for placemarks whose icon is missing or remote. */ const markerCache = new Map(); function markerImage(color) { @@ -137,6 +159,7 @@ export function createGhostMapsLayer({ pointer = null, openExternal = null, storage = defaultStorage(), + now = () => Date.now(), } = {}) { if (typeof source?.getCatalog !== 'function') throw new TypeError('GhostMaps layer requires a catalog source'); @@ -154,7 +177,9 @@ export function createGhostMapsLayer({ let _selectedLink = null; let _lifetime = new AbortController(); let _selection = readStoredSelection(storage); - /** productId → {version, dataSource, urls, count, features} */ + let _ageDays = readStoredAge(storage); + let _expiryTimer = null; + /** productId → {version, dataSource, urls, features: Map, pins} */ const _loaded = new Map(); /** productId → in-flight version */ const _loading = new Map(); @@ -183,17 +208,40 @@ export function createGhostMapsLayer({ return (_catalog?.products || []).filter(wanted).map(({ id }) => id); } - function unload(id) { + function unload(id, keepSelection = false) { const item = _loaded.get(id); if (!item) return; _loaded.delete(id); _viewer?.dataSources?.remove(item.dataSource, true); item.urls.forEach((url) => URL.revokeObjectURL(url)); - if (_selectedKey?.startsWith(`${id}:`)) clearSelection(); + if (!keepSelection && _selectedKey?.startsWith(`${id}:`)) clearSelection(); + } + + // Remove expired entities, rather than hiding them: horizon culling must + // never make an old pin visible again. Map keys keep surviving picks stable. + function expireEvents() { + const at = now(); + let changed = false; + for (const [productId, item] of _loaded) { + for (const [index, feature] of item.features) { + if (!isGhostMapsEventExpired(feature.dateMs, at, _ageDays)) continue; + for (const entity of feature.entities) + item.dataSource.entities.remove(entity); + item.features.delete(index); + if (_selectedKey === `${productId}:${index}`) clearSelection(); + changed = true; + } + item.pins = item.pins.filter((pin) => item.features.has(pin.index)); + } + if (changed) { + notifyRow(); + render(); + } } /** Build one product's data source off-scene, then swap it in. */ async function load(product, signal) { + const ageDays = _ageDays; const bytes = await source.getFile(product.id, product.version, { signal }); signal.throwIfAborted(); const entries = await readZipEntries(bytes); @@ -223,12 +271,16 @@ export function createGhostMapsLayer({ const dataSource = new Cesium.CustomDataSource( `${GHOSTMAPS_LAYER_ID}:${product.id}`, ); - const features = []; + const features = new Map(); const pins = []; + const at = now(); dataSource.entities.suspendEvents(); parsed.features.forEach((feature, index) => { + const dateMs = ghostMapsEventDate(feature.description); + if (isGhostMapsEventExpired(dateMs, at, ageDays)) return; const key = `${product.id}:${index}`; const { style } = feature; + const entities = []; feature.geometries.forEach((geometry, part) => { const id = `${PICK_PREFIX}${key}:${part}`; if (geometry.type === 'point') { @@ -255,7 +307,9 @@ export function createGhostMapsLayer({ disableDepthTestDistance: Number.POSITIVE_INFINITY, }, }); + entities.push(entity); pins.push({ + index, position, get show() { return entity.show; @@ -265,61 +319,76 @@ export function createGhostMapsLayer({ }, }); } else if (geometry.type === 'line') { - dataSource.entities.add({ - id, - polyline: { - positions: Cesium.Cartesian3.fromDegreesArray( - geometry.positions.flat(), - ), - width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), - material: cesiumColor(style.lineColor, color), - clampToGround: true, - }, - }); - } else { - const ring = (points) => - Cesium.Cartesian3.fromDegreesArray(points.flat()); - dataSource.entities.add({ - id, - polygon: { - hierarchy: new Cesium.PolygonHierarchy( - ring(geometry.outer), - geometry.holes.map( - (hole) => new Cesium.PolygonHierarchy(ring(hole)), - ), - ), - material: - style.fill === false - ? Cesium.Color.TRANSPARENT - : cesiumColor( - style.polyColor, - color, - style.polyColor ? undefined : 0.25, - ), - classificationType: Cesium.ClassificationType.BOTH, - }, - }); - if (style.outline !== false) + entities.push( dataSource.entities.add({ - id: `${id}:outline`, + id, polyline: { - positions: ring([...geometry.outer, geometry.outer[0]]), - width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + positions: Cesium.Cartesian3.fromDegreesArray( + geometry.positions.flat(), + ), + width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)), material: cesiumColor(style.lineColor, color), clampToGround: true, }, - }); + }), + ); + } else { + const ring = (points) => + Cesium.Cartesian3.fromDegreesArray(points.flat()); + entities.push( + dataSource.entities.add({ + id, + polygon: { + hierarchy: new Cesium.PolygonHierarchy( + ring(geometry.outer), + geometry.holes.map( + (hole) => new Cesium.PolygonHierarchy(ring(hole)), + ), + ), + material: + style.fill === false + ? Cesium.Color.TRANSPARENT + : cesiumColor( + style.polyColor, + color, + style.polyColor ? undefined : 0.25, + ), + classificationType: Cesium.ClassificationType.BOTH, + }, + }), + ); + if (style.outline !== false) + entities.push( + dataSource.entities.add({ + id: `${id}:outline`, + polyline: { + positions: ring([...geometry.outer, geometry.outer[0]]), + width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)), + material: cesiumColor(style.lineColor, color), + clampToGround: true, + }, + }), + ); } }); - features.push({ + features.set(index, { name: feature.name, folder: feature.folder, description: feature.description, + dateMs, + entities, anchor: anchorOf(feature.geometries[0]), }); }); dataSource.entities.resumeEvents(); - return { version: product.version, dataSource, urls, features, pins }; + return { + version: product.version, + ageDays, + dataSource, + urls, + features, + pins, + }; } catch (error) { urls.forEach((url) => URL.revokeObjectURL(url)); throw error; @@ -330,6 +399,7 @@ export function createGhostMapsLayer({ function sync() { const run = _queue.then(async () => { if (!_enabled || !_viewer || !_catalog) return; + expireEvents(); const signal = _lifetime.signal; const products = new Map(_catalog.products.map((p) => [p.id, p])); for (const id of [..._loaded.keys()]) { @@ -338,21 +408,33 @@ export function createGhostMapsLayer({ } for (const product of _catalog.products) { if (!wanted(product) || signal.aborted) continue; - if (_loaded.get(product.id)?.version === product.version) continue; + const loaded = _loaded.get(product.id); + if (loaded?.version === product.version && loaded.ageDays === _ageDays) + continue; _loading.set(product.id, product.version); _failed.delete(product.id); notifyRow(); try { const next = await load(product, signal); - if (signal.aborted || !_enabled) { + if (signal.aborted || !_enabled || next.ageDays !== _ageDays) { next.urls.forEach((url) => URL.revokeObjectURL(url)); return; } // Cull before the first frame so far-side pins never flash. _horizon.update(next.pins); await _viewer.dataSources.add(next.dataSource); - // The new edition is on the globe before the old one leaves. - unload(product.id); + // The new edition is on the globe before the old one leaves. The card + // stays only if its index still holds the same report: the server + // may answer a pinned version with newer bytes. + const index = Number(_selectedKey?.split(':')[1]); + const before = loaded?.features.get(index); + const after = next.features.get(index); + unload( + product.id, + Boolean(before && after) && + before.name === after.name && + before.description === after.description, + ); _loaded.set(product.id, next); } catch (error) { if (signal.aborted) return; @@ -391,12 +473,38 @@ export function createGhostMapsLayer({ void sync(); } + function setAgeDays(ageDays) { + if (!GHOSTMAPS_AGE_OPTIONS.includes(ageDays) || ageDays === _ageDays) + return; + _ageDays = ageDays; + try { + storage?.setItem(AGE_STORAGE_KEY, JSON.stringify(ageDays)); + } catch { + /* private window or blocked storage: keep the session choice */ + } + expireEvents(); + // Narrowing only removes markers. A product loaded with a narrower window + // keeps its old ageDays, so sync() restores omitted placemarks from the + // cached KMZ even though its upstream version has not changed. + for (const [id, item] of _loaded) + if ( + item.ageDays === null || + (ageDays !== null && item.ageDays >= ageDays) + ) { + item.ageDays = ageDays; + // A failed wider reload no longer matters; a stale edition retries. + _failed.delete(id); + } + notifyRow(); + return sync(); + } + // ---- selection card ---------------------------------------------------- function featureFor(key) { const [productId, index] = key.split(':'); return { product: _catalog?.products.find((p) => p.id === productId) || null, - feature: _loaded.get(productId)?.features[Number(index)] || null, + feature: _loaded.get(productId)?.features.get(Number(index)) || null, }; } @@ -490,7 +598,7 @@ export function createGhostMapsLayer({ if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX)) return null; const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':'); - return _loaded.get(productId)?.features[Number(index)] + return _loaded.get(productId)?.features.has(Number(index)) ? `${productId}:${index}` : null; } @@ -542,7 +650,7 @@ export function createGhostMapsLayer({ function featureCount() { let count = 0; - for (const item of _loaded.values()) count += item.features.length; + for (const item of _loaded.values()) count += item.features.size; return count; } @@ -563,6 +671,8 @@ export function createGhostMapsLayer({ enable() { _enabled = true; _lifetime = new AbortController(); + if (_expiryTimer === null) + _expiryTimer = setInterval(expireEvents, 60_000); for (const item of _loaded.values()) item.dataSource.show = true; installClickHandler(); if (!_unsubscribe && typeof source.subscribe === 'function') @@ -580,6 +690,8 @@ export function createGhostMapsLayer({ disable() { _enabled = false; + clearInterval(_expiryTimer); + _expiryTimer = null; _lifetime.abort(); _unsubscribe?.(); _unsubscribe = null; @@ -593,6 +705,7 @@ export function createGhostMapsLayer({ async update(_viewerArg, { signal } = {}) { if (!_enabled || signal?.aborted) return false; + expireEvents(); try { const catalog = await source.getCatalog({ signal }); if (!_enabled) return false; @@ -637,6 +750,25 @@ export function createGhostMapsLayer({ }) : t('Connecting to the S2 GhostMaps repository…'); return { + segmented: { + id: 'age', + label: t('Event age'), + options: GHOSTMAPS_AGE_OPTIONS.map((days) => ({ + id: `age-${days ?? 'all'}`, + label: + days === null + ? t('All', { context: 'event age filter' }) + : t('{days}d', { days: formatNumber(days) }), + title: + days === null + ? t('Show markers of every age') + : t('Maximum event age: {days}d; undated markers are kept', { + days: formatNumber(days), + }), + active: days === _ageDays, + onClick: () => setAgeDays(days), + })), + }, chips: products.map((product) => { const loaded = _loaded.get(product.id); const failed = _failed.get(product.id); @@ -651,11 +783,11 @@ export function createGhostMapsLayer({ product.fileTitle, loaded ? tn( - loaded.features.length, + loaded.features.size, '{count} placemark', '{count} placemarks', { - count: formatNumber(loaded.features.length), + count: formatNumber(loaded.features.size), }, ) : null, @@ -667,7 +799,7 @@ export function createGhostMapsLayer({ }; }), legend: [], - info, + info: `${_ageDays === null ? t('All markers') : t('Event age: {days}d · undated kept', { days: formatNumber(_ageDays) })} · ${info}`, infoTitle: t( 'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.', ), @@ -677,6 +809,7 @@ export function createGhostMapsLayer({ getStats() { return { count: featureCount(), + ageDays: _ageDays, products: [..._loaded.keys()], commit: _catalog?.commit || null, lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null, @@ -693,7 +826,7 @@ export function createGhostMapsLayer({ if (!_enabled) return []; const out = []; for (const [productId, item] of _loaded) { - for (const feature of item.features) { + for (const feature of item.features.values()) { if (out.length >= maxCount) return out; out.push({ product: productId, diff --git a/src/layers/ghostMaps/lifecycle.test.mjs b/src/layers/ghostMaps/lifecycle.test.mjs index 51d546017..ae9f63911 100644 --- a/src/layers/ghostMaps/lifecycle.test.mjs +++ b/src/layers/ghostMaps/lifecycle.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import * as Cesium from 'cesium'; import { createGhostMapsLayer } from './index.js'; +import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js'; function element(localName, children = [], value = '', attributes = {}) { const node = { @@ -22,7 +23,7 @@ function element(localName, children = [], value = '', attributes = {}) { return node; } -function documentFixture() { +function documentFixture(reports = [{ name: 'Test report' }]) { return element('document', [ element('kml', [ element('Document', [ @@ -36,11 +37,14 @@ function documentFixture() { '', { id: 'pin' }, ), - element('Placemark', [ - element('name', [], 'Test report'), - element('styleUrl', [], '#pin'), - element('Point', [element('coordinates', [], '12,34')]), - ]), + ...reports.map(({ name, description = '', geometry }) => + element('Placemark', [ + element('name', [], name), + element('description', [], description), + element('styleUrl', [], '#pin'), + geometry || element('Point', [element('coordinates', [], '12,34')]), + ]), + ), ]), ]), ]); @@ -83,11 +87,21 @@ function fixtureKmz() { return new Uint8Array(Buffer.concat([...local, directory, end])); } -function fixture(t, { beforeFileReturns, onCreateUrl } = {}) { +function fixture( + t, + { + beforeFileReturns, + onCreateUrl, + reports, + now, + products, + controls = {}, + } = {}, +) { const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser'); globalThis.DOMParser = class { parseFromString() { - return documentFixture(); + return documentFixture(reports); } }; t.after(() => { @@ -119,9 +133,13 @@ function fixture(t, { beforeFileReturns, onCreateUrl } = {}) { }; const layer = createGhostMapsLayer({ storage: null, + now, + ...controls, source: { getCatalog: async () => ({ - products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }], + products: products ?? [ + { id: 'cip', version: 'a'.repeat(40), defaultOn: true }, + ], }), getFile: async () => { beforeFileReturns?.(layer); @@ -178,3 +196,416 @@ test('successful load keeps icons until the product is disabled', async (t) => { assert.deepEqual(revoked, created); assert.equal(mounted.size, 0); }); + +const dated = (name, value, label = 'Date') => ({ + name, + description: `
${label}${value}
`, +}); + +const ageChip = (layer, days) => + layer + .getRowControls() + .segmented.options.find(({ id }) => id === `age-${days ?? 'all'}`); + +test('age controls default to seven days and widening restores filtered and expired reports', async (t) => { + let at = Date.UTC(2026, 10, 3, 12); + const { layer, viewer, mounted } = fixture(t, { + now: () => at, + reports: [ + dated('Old', '10/1/2026'), + dated('Ten days', '10/24/2026'), + dated('Recent', '11/2/2026'), + { name: 'Undated' }, + ], + }); + await layer.update(viewer); + assert.equal(layer.getStats().ageDays, 7); + assert.equal(ageChip(layer, 7).active, true); + assert.equal(layer.getStats().count, 2); + await ageChip(layer, 14).onClick(); + assert.equal(layer.getStats().count, 3); + await ageChip(layer, null).onClick(); + assert.equal(layer.getStats().count, 4); + at += 100 * 86400_000; + await layer.update(viewer); + assert.equal(layer.getStats().count, 4, 'all markers never ages out'); + await ageChip(layer, 1).onClick(); + assert.equal(layer.getStats().count, 1); + assert.equal(layer.getAnalystRecords()[0].name, 'Undated'); + await ageChip(layer, null).onClick(); + assert.equal(layer.getStats().count, 4); + assert.equal(mounted.size, 1, 'filter changes never duplicate data sources'); + const controls = layer.getRowControls(); + assert.equal( + controls.segmented.options.filter(({ active }) => active).length, + 1, + ); + assert.equal( + controls.chips.some(({ id }) => id.startsWith('age-')), + false, + 'age options are one switch, not product chips', + ); + assert.equal(ageChip(layer, null).active, true); +}); + +test('age preferences persist independently of product choices and reject invalid storage', async (t) => { + const values = new Map([['gev.ghostmaps.products', '["cip"]']]); + const storage = { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, value), + }; + await t.test('save All markers without changing products', async (t) => { + const { layer, viewer } = fixture(t, { controls: { storage } }); + await layer.update(viewer); + await ageChip(layer, null).onClick(); + assert.equal(values.get('gev.ghostmaps.ageDays'), 'null'); + assert.equal(values.get('gev.ghostmaps.products'), '["cip"]'); + }); + await t.test('restore All markers and save thirty days', async (t) => { + const { layer } = fixture(t, { controls: { storage } }); + assert.equal(layer.getStats().ageDays, null); + await ageChip(layer, 30).onClick(); + assert.equal(values.get('gev.ghostmaps.ageDays'), '30'); + }); + await t.test('restore thirty days', (t) => { + const { layer } = fixture(t, { controls: { storage } }); + assert.equal(layer.getStats().ageDays, 30); + }); + for (const value of ['garbage', '"7"', '-1', '0', '2', '{}']) { + await t.test(`reject ${value}`, (t) => { + values.set('gev.ghostmaps.ageDays', value); + const { layer } = fixture(t, { controls: { storage } }); + assert.equal(layer.getStats().ageDays, 7); + }); + } +}); + +test('blocked storage still permits changing the age within the session', async (t) => { + const storage = { + getItem() { + throw new Error('blocked'); + }, + setItem() { + throw new Error('blocked'); + }, + }; + const { layer, viewer } = fixture(t, { controls: { storage } }); + await layer.update(viewer); + assert.equal(layer.getStats().ageDays, 7); + await ageChip(layer, null).onClick(); + assert.equal(layer.getStats().ageDays, null); +}); + +test('a filter changed during a pending load never installs the superseded filter', async (t) => { + let changed = false; + const { layer, viewer, mounted, revoked, created } = fixture(t, { + now: () => Date.UTC(2026, 10, 3), + reports: [dated('Old', '10/1/2026')], + beforeFileReturns: (layer) => { + if (changed) return; + changed = true; + ageChip(layer, null).onClick(); + }, + }); + await layer.update(viewer); + await layer.update(viewer); + assert.equal(layer.getStats().count, 1); + assert.equal(layer.getStats().ageDays, null); + assert.equal(mounted.size, 1); + assert.equal( + revoked.length, + created.length - 1, + 'discarded load releases icons', + ); +}); + +test('age filtering and All markers apply to every conflict-map product, including optional maps', async (t) => { + for (const product of [...GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP]) { + await t.test(product.id, async (t) => { + const { layer, viewer, mounted } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + products: [{ ...product, version: 'a'.repeat(40) }], + reports: [ + dated( + 'Old event', + '10/1/2026', + product.id === 'cip' ? 'Date of Event' : 'Date', + ), + dated('Recent event', '11/2/2026'), + { name: 'Undated reference feature' }, + ], + }); + await layer.update(viewer); + if (!product.defaultOn) { + layer + .getRowControls() + .chips.find(({ id }) => id === product.id) + .onClick(); + await layer.update(viewer); + } + assert.deepEqual(layer.getStats().products, [product.id]); + assert.equal(layer.getStats().count, 2); + assert.deepEqual( + layer.getAnalystRecords().map(({ name }) => name), + ['Recent event', 'Undated reference feature'], + ); + await ageChip(layer, null).onClick(); + assert.equal( + layer.getStats().count, + 3, + "All markers restores this product's old events", + ); + await ageChip(layer, 7).onClick(); + assert.equal(layer.getStats().count, 2); + assert.equal(mounted.size, 1); + }); + } +}); + +test('default products filter old event dates while keeping recent, future and undated markers', async (t) => { + const { layer, viewer, mounted } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + reports: [ + dated('Old event', '10/20/2026'), + dated('Recent event', '11/2/2026'), + dated('At cutoff', '10/27/2026 12:00:00 PM'), + dated('Before cutoff', '10/27/2026 11:59:59 AM'), + dated('Future event', '11/4/2026'), + { name: 'Undated site' }, + dated('Blank date', '<Null>'), + dated('Invalid date', '2/30/2026'), + dated('Only a report date', '10/1/2026', 'Date Of Report'), + { + name: 'Old event reported recently', + description: + '
Date of Event10/1/2026
Date Of Report11/2/2026
', + }, + ], + }); + await layer.update(viewer); + assert.deepEqual(layer.getStats().products, ['cip']); + assert.deepEqual( + layer.getAnalystRecords().map((r) => r.name), + [ + 'Recent event', + 'At cutoff', + 'Future event', + 'Undated site', + 'Blank date', + 'Invalid date', + 'Only a report date', + ], + ); + assert.equal(layer.getStats().count, 7); + assert.equal([...mounted][0].entities.values.length, 7); + // Filtering cannot shift pick ids onto a different report. + assert.ok([...mounted][0].entities.getById('ghostmaps:cip:1:0')); + assert.equal( + [...mounted][0].entities.getById('ghostmaps:cip:0:0'), + undefined, + ); +}); + +test('unchanged cached products age out on the timer, clear selected cards and keep surviving picks', async (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + let at = Date.UTC(2026, 10, 3, 12); + let click; + const cards = []; + const { layer, viewer, mounted } = fixture(t, { + now: () => at, + reports: [ + dated('Expiring', '10/27/2026 12:00:00 PM'), + dated('Surviving', '11/2/2026'), + ], + controls: { + overlayHost: { setEntries: (_id, entries) => cards.push(entries) }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction: (fn) => { + click = fn; + }, + destroy() {}, + }), + picking: { resolvePickId: (picked) => picked }, + }, + }); + await layer.update(viewer); + const ds = [...mounted][0]; + viewer.scene.pick = () => 'ghostmaps:cip:0:0'; + click({ position: { x: 0, y: 0 } }); + assert.equal(cards.at(-1).length, 1); + at += 60_000; + t.mock.timers.tick(60_000); + assert.equal(layer.getStats().count, 1); + assert.equal(ds.entities.values.length, 1); + assert.equal(ds.entities.getById('ghostmaps:cip:0:0'), undefined); + assert.ok(ds.entities.getById('ghostmaps:cip:1:0')); + assert.deepEqual(cards.at(-1), []); + await layer.update(viewer); + assert.equal([...mounted][0], ds, 'same-version KMZ is not reloaded'); + viewer.scene.pick = () => 'ghostmaps:cip:1:0'; + click({ position: { x: 0, y: 0 } }); + assert.match(cards.at(-1)[0].title, /Surviving/); + layer.disable(); + at += 14 * 86400_000; + t.mock.timers.tick(60_000); + assert.equal(mounted.size, 0); + layer.enable(); + await layer.update(viewer); + assert.equal(layer.getStats().count, 0); +}); + +test('expiry removes every geometry and polygon outline belonging to an old report', async (t) => { + t.mock.timers.enable({ apis: ['setInterval'] }); + // 10/27 is a whole UTC day: at 7d it expires when 11/3 ends. + let at = Date.UTC(2026, 10, 4) - 30_000; + const polygon = element('Polygon', [ + element('outerBoundaryIs', [ + element('LinearRing', [ + element('coordinates', [], '12,34 13,34 13,35 12,34'), + ]), + ]), + ]); + const { layer, viewer, mounted } = fixture(t, { + now: () => at, + reports: [ + { + ...dated('Multi-part', '10/27/2026'), + geometry: element('MultiGeometry', [ + element('Point', [element('coordinates', [], '12,34')]), + element('LineString', [element('coordinates', [], '12,34 13,35')]), + polygon, + ]), + }, + ], + }); + await layer.update(viewer); + const ds = [...mounted][0]; + assert.equal(ds.entities.values.length, 4); + at += 60_000; + t.mock.timers.tick(60_000); + assert.equal(ds.entities.values.length, 0); + assert.equal(layer.getStats().count, 0); +}); + +test('narrowing then returning to the previous window restores its reports without a duplicate source', async (t) => { + const { layer, viewer, mounted } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')], + }); + await layer.update(viewer); + assert.equal(layer.getStats().count, 2); + await ageChip(layer, 1).onClick(); + assert.equal(layer.getStats().count, 1); + await ageChip(layer, 7).onClick(); + await layer.update(viewer); + assert.equal(layer.getStats().count, 2, '7d must show both reports again'); + assert.equal(mounted.size, 1); +}); + +test('two quick clicks, 1d then 7d, restore the report', async (t) => { + const at = Date.UTC(2026, 10, 3, 12); + const { layer, viewer } = fixture(t, { + now: () => at, + reports: [dated('Today', '11/3/2026'), dated('Three days', '10/31/2026')], + }); + await layer.update(viewer); + const a = ageChip(layer, 1).onClick(); + const b = ageChip(layer, 7).onClick(); + await a; + await b; + await layer.update(viewer); + assert.equal(layer.getStats().count, 2); +}); + +test('narrowing the window keeps the open card of a surviving marker', async (t) => { + let click; + const cards = []; + const { layer, viewer } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')], + controls: { + overlayHost: { setEntries: (_id, entries) => cards.push(entries) }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction: (fn) => { + click = fn; + }, + destroy() {}, + }), + picking: { resolvePickId: (picked) => picked }, + }, + }); + await layer.update(viewer); + await ageChip(layer, 30).onClick(); + assert.equal(layer.getStats().count, 2); + viewer.scene.pick = () => 'ghostmaps:cip:0:0'; + click({ position: { x: 0, y: 0 } }); + assert.equal(cards.at(-1).length, 1); + await ageChip(layer, 3).onClick(); + assert.equal(layer.getStats().count, 1); + assert.equal( + cards.at(-1).length, + 1, + 'card of the surviving marker stays open', + ); +}); + +test('a report dated yesterday is shown by 1d one hour into today (UTC)', async (t) => { + const { layer, viewer } = fixture(t, { + now: () => Date.UTC(2026, 10, 6, 1), + reports: [dated('Yesterday', '11/5/2026')], + }); + await layer.update(viewer); + await ageChip(layer, 1).onClick(); + assert.equal(layer.getStats().count, 1); +}); + +test('a widened reload that returns a different report at the selected index closes its card', async (t) => { + let click; + const cards = []; + const reports = [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')]; + const { layer, viewer } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + reports, + controls: { + overlayHost: { setEntries: (_id, entries) => cards.push(entries) }, + screenSpaceEventHandlerFactory: () => ({ + setInputAction: (fn) => { + click = fn; + }, + destroy() {}, + }), + picking: { resolvePickId: (picked) => picked }, + }, + }); + await layer.update(viewer); + viewer.scene.pick = () => 'ghostmaps:cip:0:0'; + click({ position: { x: 0, y: 0 } }); + assert.equal(cards.at(-1).length, 1); + // The server answers the pinned version with a newer edition's bytes. + reports[0] = dated('Replacement', '11/2/2026'); + await ageChip(layer, 30).onClick(); + assert.equal(layer.getStats().count, 2); + assert.equal( + cards.at(-1).length, + 0, + 'a different report must not keep the old card', + ); +}); + +test('narrowing to a window the loaded data covers clears a failed wider reload', async (t) => { + let fail = false; + const { layer, viewer } = fixture(t, { + now: () => Date.UTC(2026, 10, 3, 12), + reports: [dated('Recent', '11/2/2026'), dated('Older', '10/20/2026')], + beforeFileReturns: () => { + if (fail) throw new Error('network down'); + }, + }); + await layer.update(viewer); + fail = true; + await ageChip(layer, 30).onClick(); + assert.ok(layer.getStats().error, 'the wider reload failed'); + await ageChip(layer, 3).onClick(); + assert.equal(layer.getStats().error, null); + assert.equal(layer.getStats().count, 1); +}); diff --git a/src/layers/ghostMaps/records.js b/src/layers/ghostMaps/records.js index 7c4eb58bf..62e48d3b3 100644 --- a/src/layers/ghostMaps/records.js +++ b/src/layers/ghostMaps/records.js @@ -1,4 +1,4 @@ -// Pure helpers for GhostMaps placemarks: description tables to plain fields, +// Pure helpers for GhostMaps placemarks: report fields and event dates, // KML colors, and card text wrapping. Strings in, data out — description // markup is only ever read as text, never inserted into the page. @@ -51,6 +51,106 @@ const MAX_FIELDS = 24; const MAX_VALUE = 600; const MAX_LINKS = 6; +// Use the same leaf rows for report cards and event dates, without the card's +// field-count/value-length limits affecting the age filter. +function* descriptionRows(source) { + for (const row of source.matchAll( + /]*>((?:(?!/gi, + )) { + const cells = [ + ...row[1].matchAll(/]*>([\s\S]*?)<\/t[dh]\s*>/gi), + ].map((cell) => cell[1]); + if (cells.length === 2) + yield { + label: htmlToText(cells[0]), + value: htmlToText(cells[1]), + markup: cells[1], + }; + } +} + +export const GHOSTMAPS_DEFAULT_AGE_DAYS = 7; +export const GHOSTMAPS_AGE_OPTIONS = Object.freeze([1, 3, 7, 14, 30, null]); + +/** ArcGIS US dates (optionally with a time) and ISO dates, interpreted in UTC + * when no zone is supplied. A date without a time means the whole UTC day, so + * it resolves to the day's last millisecond: "1d" still shows yesterday's + * reports. Reject calendar rollovers instead of guessing. */ +export function parseGhostMapsEventDate(value) { + const input = String(value ?? '').trim(); + const us = + /^(\d{1,2})\/(\d{1,2})\/(\d{4})(?:\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM))?$/i.exec( + input, + ); + const iso = + /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/.exec( + input, + ); + if (!us && !iso) return null; + const year = Number(us ? us[3] : iso[1]); + const month = Number(us ? us[1] : iso[2]); + const day = Number(us ? us[2] : iso[3]); + let hour = Number(us?.[4] ?? iso?.[4] ?? 0); + const minute = Number(us?.[5] ?? iso?.[5] ?? 0); + const second = Number(us?.[6] ?? iso?.[6] ?? 0); + if (us?.[7]) { + if (hour < 1 || hour > 12) return null; + hour = (hour % 12) + (us[7].toUpperCase() === 'PM' ? 12 : 0); + } + const at = Date.UTC(year, month - 1, day, hour, minute, second); + const date = new Date(at); + if ( + year < 1900 || + month < 1 || + month > 12 || + day < 1 || + date.getUTCDate() !== day || + hour > 23 || + minute > 59 || + second > 59 + ) + return null; + if (iso?.[4]) { + const zoned = /(?:Z|[+-]\d{2}:\d{2})$/.test(input) ? input : `${input}Z`; + const parsed = Date.parse(zoned); + return Number.isFinite(parsed) ? parsed : null; + } + return us?.[4] ? at : at + 86400_000 - 1; +} + +/** The event's date, never its report/upload date or dates mentioned in prose. */ +export function ghostMapsEventDate(description) { + const rows = [ + ...descriptionRows( + String(description ?? '').replace( + /<(script|style|head)\b[\s\S]*?<\/\1\s*>/gi, + ' ', + ), + ), + ]; + for (const name of ['dateofevent', 'eventdate', 'date']) { + for (const { label, value } of rows) { + if (label.toLowerCase().replace(/[\s_]+/g, '') !== name) continue; + const at = parseGhostMapsEventDate(value); + if (at !== null) return at; + } + } + return null; +} + +/** Unknown and future dates stay visible; only a confirmed old event expires. */ +export function isGhostMapsEventExpired( + dateMs, + now = Date.now(), + ageDays = GHOSTMAPS_DEFAULT_AGE_DAYS, +) { + return ( + ageDays !== null && + Number.isFinite(dateMs) && + dateMs < now - ageDays * 86400_000 + ); +} + /** * Fields and links from a placemark description. ArcGIS "popup" exports are a * two-column table (label, value); anything else becomes one unlabelled field. @@ -73,18 +173,10 @@ export function parseDescriptionFields(html) { }; // Innermost rows only: ArcGIS nests the field table inside an outer row, // and only leaf rows with exactly two cells are label/value pairs. - for (const row of source.matchAll( - /]*>((?:(?!/gi, - )) { - const cells = [ - ...row[1].matchAll(/]*>([\s\S]*?)<\/t[dh]\s*>/gi), - ].map((cell) => cell[1]); - if (cells.length !== 2) continue; - const label = htmlToText(cells[0]); - const value = htmlToText(cells[1]); + for (const { label, value, markup } of descriptionRows(source)) { if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue; if (EMPTY_VALUES.has(value.toLowerCase())) continue; - addLinks(cells[1]); + addLinks(markup); // A bare research link is carried by `links`; the card does not need // the URL twice. if (/^https?:\/\/\S+$/i.test(value)) continue; diff --git a/src/layers/ghostMaps/records.test.mjs b/src/layers/ghostMaps/records.test.mjs index 86fecb52b..f674fb1cb 100644 --- a/src/layers/ghostMaps/records.test.mjs +++ b/src/layers/ghostMaps/records.test.mjs @@ -7,8 +7,83 @@ import { kmlColor, parseDescriptionFields, wrapCardText, + ghostMapsEventDate, + isGhostMapsEventExpired, + parseGhostMapsEventDate, } from './records.js'; +test('event dates parse ArcGIS calendar dates and timestamps consistently in UTC', () => { + for (const [value, iso] of [ + ['10/28/2026', '2026-10-28T23:59:59.999Z'], + ['10/28/2026 6:12:00 PM', '2026-10-28T18:12:00Z'], + ['1/17/2026 9:46:41 AM', '2026-01-17T09:46:41Z'], + ['10/28/2026 12:00 AM', '2026-10-28T00:00:00Z'], + ['10/28/2026 12:00 PM', '2026-10-28T12:00:00Z'], + ['2026-10-28', '2026-10-28T23:59:59.999Z'], + ['2026-10-28T12:34:56', '2026-10-28T12:34:56Z'], + ['2026-10-28T12:34:56.123-04:00', '2026-10-28T16:34:56.123Z'], + ['2/29/2024', '2024-02-29T23:59:59.999Z'], + ]) + assert.equal(parseGhostMapsEventDate(value), Date.parse(iso), value); + for (const value of [ + '', + '', + 'N/A', + 'unknown', + '2/29/2026', + '9/31/2026', + '2026-02-30', + '13/1/2026', + '1/0/2026', + '10/28/2026 13:00 PM', + '2026-10-28T24:00:00Z', + '2026-10-28T12:60:00Z', + ]) { + assert.equal(parseGhostMapsEventDate(value), null, value); + } +}); + +test('event date fields take precedence over report dates and prose, even after long report tables', () => { + const row = (label, value) => `${label}${value}`; + const html = `
${ + row('Date Of Report', '11/2/2026') + + row('Description', 'Incident mentioned on 11/1/2026') + + Array.from({ length: 30 }, (_, i) => row(`Field${i}`, 'value')).join('') + + row('Date', '10/30/2026') + + row('Date of Event', '10/28/2026') + }
`; + assert.equal(ghostMapsEventDate(html), Date.UTC(2026, 9, 29) - 1); + assert.equal( + ghostMapsEventDate( + `${row('Date', '<Null>')}${row('Date Of Report', '11/2/2026')}
`, + ), + null, + ); + assert.equal( + ghostMapsEventDate(`${row('Date_Of_Event', '10/28/2026')}
`), + Date.UTC(2026, 9, 29) - 1, + ); + assert.equal(ghostMapsEventDate('Occurred 10/28/2026'), null); + assert.equal( + ghostMapsEventDate( + ``, + ), + null, + ); +}); + +test('seven-day expiry keeps the exact boundary, future dates and unknown dates', () => { + const at = Date.UTC(2026, 10, 3, 12); + assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12), at), false); + assert.equal( + isGhostMapsEventExpired(Date.UTC(2026, 9, 27, 12) - 1, at), + true, + ); + assert.equal(isGhostMapsEventExpired(Date.UTC(2026, 10, 4), at), false); + for (const value of [null, undefined, NaN]) + assert.equal(isGhostMapsEventExpired(value, at), false); +}); + // The shape ArcGIS Pro writes into every S2 placemark description. const ARCGIS = ` @@ -27,6 +102,14 @@ const ARCGIS = ` function changeImage(a, b) { document.getElementById('x').innerHTML = b; } `; +test('custom ages change the cutoff and all markers disables expiry', () => { + const at = Date.UTC(2026, 10, 3, 12); + const event = at - 10 * 86400_000; + assert.equal(isGhostMapsEventExpired(event, at, 7), true); + assert.equal(isGhostMapsEventExpired(event, at, 14), false); + assert.equal(isGhostMapsEventExpired(event, at, null), false); +}); + test('ArcGIS popup tables become plain label/value fields and http links', () => { const { fields, links } = parseDescriptionFields(ARCGIS); assert.deepEqual(fields, [