mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Merge pull request #1528 from dylanmaniatakes/codex/s2-ghostmaps-age-filter
feat: add adjustable age filtering across S2 GhostMaps Thanks @dylanmaniatakes. Maintainer review fixes (separate commits): age-window narrowing/restore consistency, card kept across same-report reloads, date-only events cover their whole UTC day, and the age choice as a labelled Event age switch.
This commit is contained in:
18 files changed
+1011
-118
No files matched your search
@@ -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).
|
||||||
+30
-30
@@ -250,7 +250,6 @@ style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
|
|||||||
selection and card placement. Deterministic operation checks run in `npm test`;
|
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.
|
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
|
||||||
|
|
||||||
|
|
||||||
## Prohibited & Restricted Airspace — FAA special-use airspace
|
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||||
|
|
||||||
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||||
@@ -4348,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
|
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
||||||
its criteria cannot be silently ignored.
|
its criteria cannot be silently ignored.
|
||||||
|
|
||||||
| Layer | Source | File | Proxy | Update Interval |
|
| Layer | Source | File | Proxy | Update Interval |
|
||||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||||
| Dams ▰ | OpenInfraMap/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 |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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 |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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
|
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;
|
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
||||||
@@ -6014,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.
|
- **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.
|
- **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.
|
- **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.
|
- 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.
|
- Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit.
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
|
* (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
|
* seen from over the Indian Ocean no pin clearly behind the planet is
|
||||||
* shown; back over North America the near-side pins show again.
|
* 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).
|
* 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);
|
record('LIVE: the /api/ghostmaps/events stream is open', stream);
|
||||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
|
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 ----------------------------------------------------------
|
// ---- (ii) PICK ----------------------------------------------------------
|
||||||
const target = await page.evaluate((id) => {
|
const target = await page.evaluate((id) => {
|
||||||
const gev = window.__godsEyeView;
|
const gev = window.__godsEyeView;
|
||||||
|
|||||||
@@ -3466,5 +3466,12 @@
|
|||||||
"Open {name} on Acartia": "فتح {name} على Acartia",
|
"Open {name} on Acartia": "فتح {name} على Acartia",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "مساهمو 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} يوم · الاحتفاظ بغير المؤرخ"
|
||||||
}
|
}
|
||||||
@@ -3166,5 +3166,12 @@
|
|||||||
"Open {name} on Acartia": "{name} auf Acartia öffnen",
|
"Open {name} on Acartia": "{name} auf Acartia öffnen",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3241,5 +3241,12 @@
|
|||||||
"Open {name} on Acartia": "Abrir {name} en Acartia",
|
"Open {name} on Acartia": "Abrir {name} en Acartia",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3241,5 +3241,12 @@
|
|||||||
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3241,5 +3241,12 @@
|
|||||||
"Open {name} on Acartia": "Apri {name} su Acartia",
|
"Open {name} on Acartia": "Apri {name} su Acartia",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3091,5 +3091,12 @@
|
|||||||
"Open {name} on Acartia": "Acartiaで{name}を開く",
|
"Open {name} on Acartia": "Acartiaで{name}を開く",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "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}日以内 · 日付なしは保持"
|
||||||
}
|
}
|
||||||
@@ -3241,5 +3241,12 @@
|
|||||||
"Open {name} on Acartia": "Abrir {name} no Acartia",
|
"Open {name} on Acartia": "Abrir {name} no Acartia",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3316,5 +3316,12 @@
|
|||||||
"Open {name} on Acartia": "Открыть {name} в Acartia",
|
"Open {name} on Acartia": "Открыть {name} в Acartia",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "Участники 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} дн. · метки без даты сохранены"
|
||||||
}
|
}
|
||||||
@@ -3316,5 +3316,12 @@
|
|||||||
"Open {name} on Acartia": "Відкрити {name} в Acartia",
|
"Open {name} on Acartia": "Відкрити {name} в Acartia",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "Учасники 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} дн. · позначки без дати збережено"
|
||||||
}
|
}
|
||||||
@@ -3091,5 +3091,12 @@
|
|||||||
"Open {name} on Acartia": "在 Acartia 上打开 {name}",
|
"Open {name} on Acartia": "在 Acartia 上打开 {name}",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "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}天内 · 保留无日期标记"
|
||||||
}
|
}
|
||||||
@@ -3091,5 +3091,12 @@
|
|||||||
"Open {name} on Acartia": "在 Acartia 上打開 {name}",
|
"Open {name} on Acartia": "在 Acartia 上打開 {name}",
|
||||||
"iNaturalist": "iNaturalist",
|
"iNaturalist": "iNaturalist",
|
||||||
"iNaturalist contributors · licences per record": "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}天內 · 保留無日期標記"
|
||||||
}
|
}
|
||||||
+190
-57
@@ -16,7 +16,15 @@ import {
|
|||||||
} from '../../i18n/index.js';
|
} from '../../i18n/index.js';
|
||||||
import { createHorizonCull } from '../streetLevel/view.js';
|
import { createHorizonCull } from '../streetLevel/view.js';
|
||||||
import { readGhostMapsKml } from './kml.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 './catalog.js';
|
||||||
export * from './records.js';
|
export * from './records.js';
|
||||||
export { readGhostMapsKml } from './kml.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';
|
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
|
||||||
const PICK_PREFIX = 'ghostmaps:';
|
const PICK_PREFIX = 'ghostmaps:';
|
||||||
const STORAGE_KEY = 'gev.ghostmaps.products';
|
const STORAGE_KEY = 'gev.ghostmaps.products';
|
||||||
|
const AGE_STORAGE_KEY = 'gev.ghostmaps.ageDays';
|
||||||
const ACCENT = '#c8ff00';
|
const ACCENT = '#c8ff00';
|
||||||
const MAX_ICON_BYTES = 512 * 1024;
|
const MAX_ICON_BYTES = 512 * 1024;
|
||||||
const CARD_HOST_OPTIONS = Object.freeze({
|
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. */
|
/** A round marker for placemarks whose icon is missing or remote. */
|
||||||
const markerCache = new Map();
|
const markerCache = new Map();
|
||||||
function markerImage(color) {
|
function markerImage(color) {
|
||||||
@@ -137,6 +159,7 @@ export function createGhostMapsLayer({
|
|||||||
pointer = null,
|
pointer = null,
|
||||||
openExternal = null,
|
openExternal = null,
|
||||||
storage = defaultStorage(),
|
storage = defaultStorage(),
|
||||||
|
now = () => Date.now(),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (typeof source?.getCatalog !== 'function')
|
if (typeof source?.getCatalog !== 'function')
|
||||||
throw new TypeError('GhostMaps layer requires a catalog source');
|
throw new TypeError('GhostMaps layer requires a catalog source');
|
||||||
@@ -154,7 +177,9 @@ export function createGhostMapsLayer({
|
|||||||
let _selectedLink = null;
|
let _selectedLink = null;
|
||||||
let _lifetime = new AbortController();
|
let _lifetime = new AbortController();
|
||||||
let _selection = readStoredSelection(storage);
|
let _selection = readStoredSelection(storage);
|
||||||
/** productId → {version, dataSource, urls, count, features} */
|
let _ageDays = readStoredAge(storage);
|
||||||
|
let _expiryTimer = null;
|
||||||
|
/** productId → {version, dataSource, urls, features: Map<index, record>, pins} */
|
||||||
const _loaded = new Map();
|
const _loaded = new Map();
|
||||||
/** productId → in-flight version */
|
/** productId → in-flight version */
|
||||||
const _loading = new Map();
|
const _loading = new Map();
|
||||||
@@ -183,17 +208,40 @@ export function createGhostMapsLayer({
|
|||||||
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
|
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unload(id) {
|
function unload(id, keepSelection = false) {
|
||||||
const item = _loaded.get(id);
|
const item = _loaded.get(id);
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
_loaded.delete(id);
|
_loaded.delete(id);
|
||||||
_viewer?.dataSources?.remove(item.dataSource, true);
|
_viewer?.dataSources?.remove(item.dataSource, true);
|
||||||
item.urls.forEach((url) => URL.revokeObjectURL(url));
|
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. */
|
/** Build one product's data source off-scene, then swap it in. */
|
||||||
async function load(product, signal) {
|
async function load(product, signal) {
|
||||||
|
const ageDays = _ageDays;
|
||||||
const bytes = await source.getFile(product.id, product.version, { signal });
|
const bytes = await source.getFile(product.id, product.version, { signal });
|
||||||
signal.throwIfAborted();
|
signal.throwIfAborted();
|
||||||
const entries = await readZipEntries(bytes);
|
const entries = await readZipEntries(bytes);
|
||||||
@@ -223,12 +271,16 @@ export function createGhostMapsLayer({
|
|||||||
const dataSource = new Cesium.CustomDataSource(
|
const dataSource = new Cesium.CustomDataSource(
|
||||||
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
|
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
|
||||||
);
|
);
|
||||||
const features = [];
|
const features = new Map();
|
||||||
const pins = [];
|
const pins = [];
|
||||||
|
const at = now();
|
||||||
dataSource.entities.suspendEvents();
|
dataSource.entities.suspendEvents();
|
||||||
parsed.features.forEach((feature, index) => {
|
parsed.features.forEach((feature, index) => {
|
||||||
|
const dateMs = ghostMapsEventDate(feature.description);
|
||||||
|
if (isGhostMapsEventExpired(dateMs, at, ageDays)) return;
|
||||||
const key = `${product.id}:${index}`;
|
const key = `${product.id}:${index}`;
|
||||||
const { style } = feature;
|
const { style } = feature;
|
||||||
|
const entities = [];
|
||||||
feature.geometries.forEach((geometry, part) => {
|
feature.geometries.forEach((geometry, part) => {
|
||||||
const id = `${PICK_PREFIX}${key}:${part}`;
|
const id = `${PICK_PREFIX}${key}:${part}`;
|
||||||
if (geometry.type === 'point') {
|
if (geometry.type === 'point') {
|
||||||
@@ -255,7 +307,9 @@ export function createGhostMapsLayer({
|
|||||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
entities.push(entity);
|
||||||
pins.push({
|
pins.push({
|
||||||
|
index,
|
||||||
position,
|
position,
|
||||||
get show() {
|
get show() {
|
||||||
return entity.show;
|
return entity.show;
|
||||||
@@ -265,61 +319,76 @@ export function createGhostMapsLayer({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (geometry.type === 'line') {
|
} else if (geometry.type === 'line') {
|
||||||
dataSource.entities.add({
|
entities.push(
|
||||||
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)
|
|
||||||
dataSource.entities.add({
|
dataSource.entities.add({
|
||||||
id: `${id}:outline`,
|
id,
|
||||||
polyline: {
|
polyline: {
|
||||||
positions: ring([...geometry.outer, geometry.outer[0]]),
|
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||||
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
|
geometry.positions.flat(),
|
||||||
|
),
|
||||||
|
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
|
||||||
material: cesiumColor(style.lineColor, color),
|
material: cesiumColor(style.lineColor, color),
|
||||||
clampToGround: true,
|
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,
|
name: feature.name,
|
||||||
folder: feature.folder,
|
folder: feature.folder,
|
||||||
description: feature.description,
|
description: feature.description,
|
||||||
|
dateMs,
|
||||||
|
entities,
|
||||||
anchor: anchorOf(feature.geometries[0]),
|
anchor: anchorOf(feature.geometries[0]),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
dataSource.entities.resumeEvents();
|
dataSource.entities.resumeEvents();
|
||||||
return { version: product.version, dataSource, urls, features, pins };
|
return {
|
||||||
|
version: product.version,
|
||||||
|
ageDays,
|
||||||
|
dataSource,
|
||||||
|
urls,
|
||||||
|
features,
|
||||||
|
pins,
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
throw error;
|
throw error;
|
||||||
@@ -330,6 +399,7 @@ export function createGhostMapsLayer({
|
|||||||
function sync() {
|
function sync() {
|
||||||
const run = _queue.then(async () => {
|
const run = _queue.then(async () => {
|
||||||
if (!_enabled || !_viewer || !_catalog) return;
|
if (!_enabled || !_viewer || !_catalog) return;
|
||||||
|
expireEvents();
|
||||||
const signal = _lifetime.signal;
|
const signal = _lifetime.signal;
|
||||||
const products = new Map(_catalog.products.map((p) => [p.id, p]));
|
const products = new Map(_catalog.products.map((p) => [p.id, p]));
|
||||||
for (const id of [..._loaded.keys()]) {
|
for (const id of [..._loaded.keys()]) {
|
||||||
@@ -338,21 +408,33 @@ export function createGhostMapsLayer({
|
|||||||
}
|
}
|
||||||
for (const product of _catalog.products) {
|
for (const product of _catalog.products) {
|
||||||
if (!wanted(product) || signal.aborted) continue;
|
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);
|
_loading.set(product.id, product.version);
|
||||||
_failed.delete(product.id);
|
_failed.delete(product.id);
|
||||||
notifyRow();
|
notifyRow();
|
||||||
try {
|
try {
|
||||||
const next = await load(product, signal);
|
const next = await load(product, signal);
|
||||||
if (signal.aborted || !_enabled) {
|
if (signal.aborted || !_enabled || next.ageDays !== _ageDays) {
|
||||||
next.urls.forEach((url) => URL.revokeObjectURL(url));
|
next.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Cull before the first frame so far-side pins never flash.
|
// Cull before the first frame so far-side pins never flash.
|
||||||
_horizon.update(next.pins);
|
_horizon.update(next.pins);
|
||||||
await _viewer.dataSources.add(next.dataSource);
|
await _viewer.dataSources.add(next.dataSource);
|
||||||
// The new edition is on the globe before the old one leaves.
|
// The new edition is on the globe before the old one leaves. The card
|
||||||
unload(product.id);
|
// 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);
|
_loaded.set(product.id, next);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
@@ -391,12 +473,38 @@ export function createGhostMapsLayer({
|
|||||||
void sync();
|
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 ----------------------------------------------------
|
// ---- selection card ----------------------------------------------------
|
||||||
function featureFor(key) {
|
function featureFor(key) {
|
||||||
const [productId, index] = key.split(':');
|
const [productId, index] = key.split(':');
|
||||||
return {
|
return {
|
||||||
product: _catalog?.products.find((p) => p.id === productId) || null,
|
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))
|
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||||
return null;
|
return null;
|
||||||
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
|
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}`
|
? `${productId}:${index}`
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
@@ -542,7 +650,7 @@ export function createGhostMapsLayer({
|
|||||||
|
|
||||||
function featureCount() {
|
function featureCount() {
|
||||||
let count = 0;
|
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;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,6 +671,8 @@ export function createGhostMapsLayer({
|
|||||||
enable() {
|
enable() {
|
||||||
_enabled = true;
|
_enabled = true;
|
||||||
_lifetime = new AbortController();
|
_lifetime = new AbortController();
|
||||||
|
if (_expiryTimer === null)
|
||||||
|
_expiryTimer = setInterval(expireEvents, 60_000);
|
||||||
for (const item of _loaded.values()) item.dataSource.show = true;
|
for (const item of _loaded.values()) item.dataSource.show = true;
|
||||||
installClickHandler();
|
installClickHandler();
|
||||||
if (!_unsubscribe && typeof source.subscribe === 'function')
|
if (!_unsubscribe && typeof source.subscribe === 'function')
|
||||||
@@ -580,6 +690,8 @@ export function createGhostMapsLayer({
|
|||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
_enabled = false;
|
_enabled = false;
|
||||||
|
clearInterval(_expiryTimer);
|
||||||
|
_expiryTimer = null;
|
||||||
_lifetime.abort();
|
_lifetime.abort();
|
||||||
_unsubscribe?.();
|
_unsubscribe?.();
|
||||||
_unsubscribe = null;
|
_unsubscribe = null;
|
||||||
@@ -593,6 +705,7 @@ export function createGhostMapsLayer({
|
|||||||
|
|
||||||
async update(_viewerArg, { signal } = {}) {
|
async update(_viewerArg, { signal } = {}) {
|
||||||
if (!_enabled || signal?.aborted) return false;
|
if (!_enabled || signal?.aborted) return false;
|
||||||
|
expireEvents();
|
||||||
try {
|
try {
|
||||||
const catalog = await source.getCatalog({ signal });
|
const catalog = await source.getCatalog({ signal });
|
||||||
if (!_enabled) return false;
|
if (!_enabled) return false;
|
||||||
@@ -637,6 +750,25 @@ export function createGhostMapsLayer({
|
|||||||
})
|
})
|
||||||
: t('Connecting to the S2 GhostMaps repository…');
|
: t('Connecting to the S2 GhostMaps repository…');
|
||||||
return {
|
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) => {
|
chips: products.map((product) => {
|
||||||
const loaded = _loaded.get(product.id);
|
const loaded = _loaded.get(product.id);
|
||||||
const failed = _failed.get(product.id);
|
const failed = _failed.get(product.id);
|
||||||
@@ -651,11 +783,11 @@ export function createGhostMapsLayer({
|
|||||||
product.fileTitle,
|
product.fileTitle,
|
||||||
loaded
|
loaded
|
||||||
? tn(
|
? tn(
|
||||||
loaded.features.length,
|
loaded.features.size,
|
||||||
'{count} placemark',
|
'{count} placemark',
|
||||||
'{count} placemarks',
|
'{count} placemarks',
|
||||||
{
|
{
|
||||||
count: formatNumber(loaded.features.length),
|
count: formatNumber(loaded.features.size),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
@@ -667,7 +799,7 @@ export function createGhostMapsLayer({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
legend: [],
|
legend: [],
|
||||||
info,
|
info: `${_ageDays === null ? t('All markers') : t('Event age: {days}d · undated kept', { days: formatNumber(_ageDays) })} · ${info}`,
|
||||||
infoTitle: t(
|
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.',
|
'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() {
|
getStats() {
|
||||||
return {
|
return {
|
||||||
count: featureCount(),
|
count: featureCount(),
|
||||||
|
ageDays: _ageDays,
|
||||||
products: [..._loaded.keys()],
|
products: [..._loaded.keys()],
|
||||||
commit: _catalog?.commit || null,
|
commit: _catalog?.commit || null,
|
||||||
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
|
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
|
||||||
@@ -693,7 +826,7 @@ export function createGhostMapsLayer({
|
|||||||
if (!_enabled) return [];
|
if (!_enabled) return [];
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const [productId, item] of _loaded) {
|
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;
|
if (out.length >= maxCount) return out;
|
||||||
out.push({
|
out.push({
|
||||||
product: productId,
|
product: productId,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import * as Cesium from 'cesium';
|
import * as Cesium from 'cesium';
|
||||||
import { createGhostMapsLayer } from './index.js';
|
import { createGhostMapsLayer } from './index.js';
|
||||||
|
import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js';
|
||||||
|
|
||||||
function element(localName, children = [], value = '', attributes = {}) {
|
function element(localName, children = [], value = '', attributes = {}) {
|
||||||
const node = {
|
const node = {
|
||||||
@@ -22,7 +23,7 @@ function element(localName, children = [], value = '', attributes = {}) {
|
|||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function documentFixture() {
|
function documentFixture(reports = [{ name: 'Test report' }]) {
|
||||||
return element('document', [
|
return element('document', [
|
||||||
element('kml', [
|
element('kml', [
|
||||||
element('Document', [
|
element('Document', [
|
||||||
@@ -36,11 +37,14 @@ function documentFixture() {
|
|||||||
'',
|
'',
|
||||||
{ id: 'pin' },
|
{ id: 'pin' },
|
||||||
),
|
),
|
||||||
element('Placemark', [
|
...reports.map(({ name, description = '', geometry }) =>
|
||||||
element('name', [], 'Test report'),
|
element('Placemark', [
|
||||||
element('styleUrl', [], '#pin'),
|
element('name', [], name),
|
||||||
element('Point', [element('coordinates', [], '12,34')]),
|
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]));
|
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');
|
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
|
||||||
globalThis.DOMParser = class {
|
globalThis.DOMParser = class {
|
||||||
parseFromString() {
|
parseFromString() {
|
||||||
return documentFixture();
|
return documentFixture(reports);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
t.after(() => {
|
t.after(() => {
|
||||||
@@ -119,9 +133,13 @@ function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
|||||||
};
|
};
|
||||||
const layer = createGhostMapsLayer({
|
const layer = createGhostMapsLayer({
|
||||||
storage: null,
|
storage: null,
|
||||||
|
now,
|
||||||
|
...controls,
|
||||||
source: {
|
source: {
|
||||||
getCatalog: async () => ({
|
getCatalog: async () => ({
|
||||||
products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }],
|
products: products ?? [
|
||||||
|
{ id: 'cip', version: 'a'.repeat(40), defaultOn: true },
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
getFile: async () => {
|
getFile: async () => {
|
||||||
beforeFileReturns?.(layer);
|
beforeFileReturns?.(layer);
|
||||||
@@ -178,3 +196,416 @@ test('successful load keeps icons until the product is disabled', async (t) => {
|
|||||||
assert.deepEqual(revoked, created);
|
assert.deepEqual(revoked, created);
|
||||||
assert.equal(mounted.size, 0);
|
assert.equal(mounted.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dated = (name, value, label = 'Date') => ({
|
||||||
|
name,
|
||||||
|
description: `<table><tr><td>${label}</td><td>${value}</td></tr></table>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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:
|
||||||
|
'<table><tr><td>Date of Event</td><td>10/1/2026</td></tr><tr><td>Date Of Report</td><td>11/2/2026</td></tr></table>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
+103
-11
@@ -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
|
// KML colors, and card text wrapping. Strings in, data out — description
|
||||||
// markup is only ever read as text, never inserted into the page.
|
// 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_VALUE = 600;
|
||||||
const MAX_LINKS = 6;
|
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(
|
||||||
|
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
||||||
|
)) {
|
||||||
|
const cells = [
|
||||||
|
...row[1].matchAll(/<t[dh]\b[^>]*>([\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
|
* Fields and links from a placemark description. ArcGIS "popup" exports are a
|
||||||
* two-column table (label, value); anything else becomes one unlabelled field.
|
* 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,
|
// Innermost rows only: ArcGIS nests the field table inside an outer row,
|
||||||
// and only leaf rows with exactly two cells are label/value pairs.
|
// and only leaf rows with exactly two cells are label/value pairs.
|
||||||
for (const row of source.matchAll(
|
for (const { label, value, markup } of descriptionRows(source)) {
|
||||||
/<tr\b[^>]*>((?:(?!<tr\b)[\s\S])*?)<\/tr\s*>/gi,
|
|
||||||
)) {
|
|
||||||
const cells = [
|
|
||||||
...row[1].matchAll(/<t[dh]\b[^>]*>([\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]);
|
|
||||||
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
|
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
|
||||||
if (EMPTY_VALUES.has(value.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
|
// A bare research link is carried by `links`; the card does not need
|
||||||
// the URL twice.
|
// the URL twice.
|
||||||
if (/^https?:\/\/\S+$/i.test(value)) continue;
|
if (/^https?:\/\/\S+$/i.test(value)) continue;
|
||||||
|
|||||||
@@ -7,8 +7,83 @@ import {
|
|||||||
kmlColor,
|
kmlColor,
|
||||||
parseDescriptionFields,
|
parseDescriptionFields,
|
||||||
wrapCardText,
|
wrapCardText,
|
||||||
|
ghostMapsEventDate,
|
||||||
|
isGhostMapsEventExpired,
|
||||||
|
parseGhostMapsEventDate,
|
||||||
} from './records.js';
|
} 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 [
|
||||||
|
'',
|
||||||
|
'<Null>',
|
||||||
|
'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) => `<tr><td>${label}</td><td>${value}</td></tr>`;
|
||||||
|
const html = `<table><tr><td><table>${
|
||||||
|
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')
|
||||||
|
}</table></td></tr></table>`;
|
||||||
|
assert.equal(ghostMapsEventDate(html), Date.UTC(2026, 9, 29) - 1);
|
||||||
|
assert.equal(
|
||||||
|
ghostMapsEventDate(
|
||||||
|
`<table>${row('Date', '<Null>')}${row('Date Of Report', '11/2/2026')}</table>`,
|
||||||
|
),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
ghostMapsEventDate(`<table>${row('Date_Of_Event', '10/28/2026')}</table>`),
|
||||||
|
Date.UTC(2026, 9, 29) - 1,
|
||||||
|
);
|
||||||
|
assert.equal(ghostMapsEventDate('Occurred 10/28/2026'), null);
|
||||||
|
assert.equal(
|
||||||
|
ghostMapsEventDate(
|
||||||
|
`<script><table>${row('Date', '1/1/2020')}</table></script>`,
|
||||||
|
),
|
||||||
|
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.
|
// The shape ArcGIS Pro writes into every S2 placemark description.
|
||||||
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
||||||
<META http-equiv="Content-Type" content="text/html"></head>
|
<META http-equiv="Content-Type" content="text/html"></head>
|
||||||
@@ -27,6 +102,14 @@ const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><head>
|
|||||||
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
|
function changeImage(a, b) { document.getElementById('x').innerHTML = b; }
|
||||||
</script></html>`;
|
</script></html>`;
|
||||||
|
|
||||||
|
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', () => {
|
test('ArcGIS popup tables become plain label/value fields and http links', () => {
|
||||||
const { fields, links } = parseDescriptionFields(ARCGIS);
|
const { fields, links } = parseDescriptionFields(ARCGIS);
|
||||||
assert.deepEqual(fields, [
|
assert.deepEqual(fields, [
|
||||||
|
|||||||
Reference in new issue
Block a user