mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-07 20:04:00 +00:00
Merge remote-tracking branch 'origin/main' into review/pr-1578
This commit is contained in:
commit
3cd7ed68aa
33 files changed
+1730
-373
No files matched your search
@@ -485,9 +485,9 @@ _The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED
|
||||
|
||||
> Street-level photos from any provider in one panel. This build ships **Mapillary** (a free client token), **Google Street View** (your Google Maps key), **KartaView** and **Panoramax** (no key).
|
||||
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it the panel reads **KEY REQUIRED**. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
**Setup.** At [mapillary.com/dashboard/developers](https://www.mapillary.com/dashboard/developers), register an application with **Read** access and copy its **Client Token** (`MLY|…`). Paste it into **POWER UP → Mapillary**, or set `MAPILLARY_CLIENT_TOKEN` in `.env` and restart. Without it Street Level starts on **KartaView** instead, and the Mapillary chip says which key to add. **Google Street View** uses `GOOGLE_MAPS_API_KEY` with the **Maps JavaScript API** enabled for it in Google Cloud; its chip starts off, since every panorama is billed to the key. **KartaView** and **Panoramax** need nothing: their public APIs are keyless.
|
||||
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
**Use.** Turn on **Street Level** under DATA LAYERS → Cameras, or with the pill in the STREET LEVEL panel header. Each provider has a chip in its own colour (Mapillary green), and one is lit at a time: lighting one switches the others off. Mapillary, which is free, is lit by default when its token is set; otherwise KartaView is. Zoom in to street level (below 60 km) for the coverage lines; from orbit the panel says to zoom in. The **IMAGERY** (ALL / 360° / FLAT) and **SINCE** filters apply to Mapillary, KartaView and Panoramax; Street View panoramas are all 360°, so with Street View lit the filters are hidden. Click a coverage line for its image cones and a cone to open the photo. Light **KARTAVIEW** (purple) and zoom in below about 6 km: its lines are the streets KartaView has photos of, and a click on one opens the photo nearest the click, flat or 360° (drag to look around), with cones along its drive; **‹ ›** or ← → step through the drive (Shift+← →, ↑ ↓ and + − look around a 360° photo; pinch zooms on a phone). Photos KartaView has archived are skipped, and where all are archived the panel says so. For KartaView, **SINCE** goes by upload date, as KartaView's own map does (it can filter by nothing else), and the panel notes it beside the SINCE value (ANY DATE (by upload date)); KartaView filters its lines on its own server, and a filtered area KartaView has not drawn before can take several seconds. Light **PANORAMAX** (orange) for the open, federated commons (strongest in France and Belgium): below about 60 km its lines are sequences. A click on a line opens that sequence's picture nearest the click without moving the camera, flat or 360°, with cones along the sequence around it; **‹ ›** or ← → step through it, and both filters apply. Its caption names the picture's authors (or the instance holding it) and licence (mostly CC BY-SA 4.0; pictures on the IGN instance are Licence Ouverte 2.0). On the **Google 3D** map (Google's terms keep Street View off other maps, where it does not appear), light **GOOGLE STREET VIEW** and click a street from below 3 km, or press **OPEN STREET VIEW** for the view centre, to open Google's panorama there; step along its arrows. Above the photo: **EXPAND** (full screen; Esc leaves) and **FIT / FILL** (Mapillary, and flat KartaView and Panoramax photos). The caption links to the image on the provider's site, and the globe credits each provider that is on ("© Mapillary contributors, CC BY-SA 4.0", "Street View imagery © Google", "© KartaView contributors, CC BY-SA 4.0", "© Panoramax contributors, licence per picture"). KartaView's own photo pages fail for signed-out visitors, so its link opens the full-size photo.
|
||||
|
||||
Share links carry the provider switches and filters (`0.m.0` Mapillary off, `0.m.0_0.r.google-1` Street View on, `0.m.0_0.r.google-0*kartaview-1*panoramax-0` KartaView on and `0.m.0_0.r.google-0*kartaview-0*panoramax-1` Panoramax on, as the app writes them (`0.m.0_0.r.kartaview-1` and `0.m.0_0.r.panoramax-1` work too; a link that leaves out `m` keeps Mapillary, the default, lit), `0.p.p` panoramas only, `0.s.365` the last year). To add a provider, implement the contract in `src/layers/streetLevel/registry.js` and register it in `src/app/layers/streetLevel.js`.
|
||||
|
||||
|
||||
@@ -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).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Fix the translations of the voice area cards, the fire confidence filter,
|
||||
the drone airspace layer and Sea Marks: one word for "landmark" per
|
||||
language, the airspace layer names the airspace again, and Traditional
|
||||
Chinese follows Simplified Chinese again (German, @gekh, #1581).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Without a Mapillary token, Street Level starts on the first keyless source
|
||||
(KartaView) instead of opening on Mapillary's `KEY REQUIRED` with nothing on
|
||||
the globe. A share link that turns a source off is respected
|
||||
(asturksever, #1601).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Let the voice assistant read the storm you select in Cyclone advisories:
|
||||
its official advisory, position, intensity and movement. Clearing the
|
||||
selection and the storm leaving the feed are reported apart (kvnloo, #847).
|
||||
+41
-33
@@ -250,7 +250,6 @@ style listeners. Synthetic fixtures cover privacy, acquisition, rendering,
|
||||
selection and card placement. Deterministic operation checks run in `npm test`;
|
||||
wall-clock budgets run in `npm run test:perf`. All 11 locales are supported.
|
||||
|
||||
|
||||
## Prohibited & Restricted Airspace — FAA special-use airspace
|
||||
|
||||
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
|
||||
@@ -742,6 +741,12 @@ geographic seams and releases on disable; forecast animation is not implied.
|
||||
NHC movement speed is converted from miles per hour to rounded knots by the
|
||||
proxy; advisory wind intensity is already in knots and is passed through.
|
||||
Cyclone markers, labels, tracks and cones are hidden beyond the horizon on every map source.
|
||||
Explicitly selecting a storm publishes one shared `weather-cyclones` record:
|
||||
advisory provenance, source position, the English classification name and,
|
||||
while the proxy serves its cache, the cache fetch time. The automatic row
|
||||
selection does not claim shared context; refreshes do not re-announce it.
|
||||
Clear or disable reports `deliberate`; a storm leaving the feed reports
|
||||
`evicted`, with no fallback.
|
||||
Consult the linked official advisory for safety decisions.
|
||||
|
||||
Voice and HUD snapshots reuse the existing feedState classifier. Analyst follow-ups retain their original data provenance; current-view results append provenance without replacing legacy fields. HUD context and deterministic telemetry include non-nominal feed state.
|
||||
@@ -4342,35 +4347,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
|
||||
requests use Play; a qualified Play-shaped tool call is normalized to Select so
|
||||
its criteria cannot be silently ignored.
|
||||
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
|
||||
| Layer | Source | File | Proxy | Update Interval |
|
||||
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
|
||||
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
|
||||
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
|
||||
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
|
||||
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
|
||||
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
|
||||
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
|
||||
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
|
||||
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
|
||||
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
|
||||
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
|
||||
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
|
||||
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
|
||||
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
|
||||
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
|
||||
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
|
||||
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
|
||||
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
|
||||
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
|
||||
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
|
||||
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
|
||||
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
|
||||
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
|
||||
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
|
||||
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
|
||||
|
||||
GNSS Integrity samples a 250 nm circle around the Earth point under the screen
|
||||
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
|
||||
@@ -4424,8 +4429,10 @@ provider draws in one colour (`PROVIDER_COLORS` in
|
||||
sequence is GEV cyan. Share options: `m` (Mapillary), `r` (the other providers' switches by id,
|
||||
e.g. `google-1` for Street View), `p` (`a`/`p`/`f` panoramas), `s` (since, days). Mapillary, Google
|
||||
Street View, KartaView and Panoramax are registered. Without
|
||||
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are
|
||||
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
`MAPILLARY_CLIENT_TOKEN` (and no Graph proxy) Street Level starts on the first
|
||||
keyless provider (KartaView), including a restored "Mapillary on" unless the link
|
||||
switches the keyless providers off; filter-only updates never change the source.
|
||||
With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14
|
||||
tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
|
||||
requests for a tile.
|
||||
|
||||
@@ -4439,7 +4446,7 @@ the camera, nearest first, at most 9 (the halves of a view across
|
||||
the date line count a shared tile once). Lines are draped
|
||||
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
|
||||
and Google 3D tiles alike; cones and the photo marker are ground-clamped
|
||||
billboards with a horizon cull. Without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
billboards with a horizon cull. When Mapillary is lit without `MAPILLARY_CLIENT_TOKEN` the panel
|
||||
reads KEY REQUIRED, says which key to add and disables the filters; a rejected
|
||||
key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
|
||||
pauses them for its Retry-After.
|
||||
@@ -6008,6 +6015,7 @@ request viewbox and continues searching without that geographic preference.
|
||||
|
||||
- **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
|
||||
- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere.
|
||||
- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All** (every marker). The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown.
|
||||
- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub.
|
||||
- It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min.
|
||||
- Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit.
|
||||
|
||||
@@ -2336,6 +2336,7 @@
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/cyclones.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/flights.js",
|
||||
@@ -3380,6 +3381,7 @@
|
||||
"src/app/layers/alprCameras.js",
|
||||
"src/app/layers/bikeshare.js",
|
||||
"src/app/layers/cctv.js",
|
||||
"src/app/layers/cyclones.js",
|
||||
"src/app/layers/earthquakes.js",
|
||||
"src/app/layers/firms.js",
|
||||
"src/app/layers/flights.js",
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
|
||||
* seen from over the Indian Ocean no pin clearly behind the planet is
|
||||
* shown; back over North America the near-side pins show again.
|
||||
* (viii) AGE — seven days is the default; shorter/longer windows and All
|
||||
* markers update the globe, and the age choice survives a reload.
|
||||
*
|
||||
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
|
||||
*
|
||||
@@ -224,6 +226,77 @@ async function main() {
|
||||
record('LIVE: the /api/ghostmaps/events stream is open', stream);
|
||||
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
|
||||
|
||||
const ageChips = await page.evaluate(
|
||||
(id) =>
|
||||
[
|
||||
...document.querySelectorAll(
|
||||
`[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`,
|
||||
),
|
||||
].map((option) => ({
|
||||
id: option.dataset.chipId,
|
||||
active: option.getAttribute('aria-checked') === 'true',
|
||||
})),
|
||||
LAYER_ID,
|
||||
);
|
||||
record(
|
||||
'AGE: seven days is the default with an All markers option',
|
||||
live.ageDays === 7 &&
|
||||
ageChips.length === 6 &&
|
||||
ageChips
|
||||
.filter((chip) => chip.active)
|
||||
.map((chip) => chip.id)
|
||||
.join() === 'age-7' &&
|
||||
ageChips.some((chip) => chip.id === 'age-all'),
|
||||
);
|
||||
const selectAge = async (days) => {
|
||||
await showRow(page);
|
||||
const selector = `[data-layer-id="${LAYER_ID}"] [data-chip-id="age-${days ?? 'all'}"]`;
|
||||
await page.waitForSelector(selector, { timeout: 15000 });
|
||||
// Dispatch through the rendered button, matching the product-chip gate.
|
||||
await page.$eval(selector, (button) => button.click());
|
||||
await page.waitForFunction(
|
||||
(id, age) => {
|
||||
const s = window.__godsEyeView.dataManager.layers
|
||||
.get(id)
|
||||
.module.getStats();
|
||||
return s.ageDays === age && !s.loading;
|
||||
},
|
||||
{ timeout: TIMEOUT_MS },
|
||||
LAYER_ID,
|
||||
days,
|
||||
);
|
||||
return stats(page);
|
||||
};
|
||||
const all = await selectAge(null);
|
||||
record(
|
||||
'AGE: All markers restores old events without duplicate data sources',
|
||||
all.count > live.count &&
|
||||
(await sources(page)).length === live.products.length,
|
||||
`${live.count} → ${all.count}`,
|
||||
);
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
|
||||
timeout: TIMEOUT_MS,
|
||||
});
|
||||
await enableAndSettle(page);
|
||||
const persistedAge = await stats(page);
|
||||
record(
|
||||
'AGE: All markers survives a reload',
|
||||
persistedAge.ageDays === null && persistedAge.count === all.count,
|
||||
);
|
||||
const thirty = await selectAge(30);
|
||||
const one = await selectAge(1);
|
||||
const seven = await selectAge(7);
|
||||
record(
|
||||
'AGE: counts follow the window order and seven days restores the default',
|
||||
one.count <= live.count &&
|
||||
thirty.count >= live.count &&
|
||||
thirty.count <= all.count &&
|
||||
seven.count === live.count &&
|
||||
seven.ageDays === 7,
|
||||
`1d=${one.count}, 7d=${seven.count}, 30d=${thirty.count}, all=${all.count}`,
|
||||
);
|
||||
|
||||
// ---- (ii) PICK ----------------------------------------------------------
|
||||
const target = await page.evaluate((id) => {
|
||||
const gev = window.__godsEyeView;
|
||||
|
||||
@@ -296,6 +296,22 @@ async function expandPanel(page) {
|
||||
);
|
||||
}
|
||||
|
||||
/** A keyless bundle opens on the first keyless source with live controls. */
|
||||
async function assertKeylessDefault(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
origin: 'user',
|
||||
}),
|
||||
);
|
||||
await uiUntil(page, litAlone, 'kartaview');
|
||||
const shown = await page.evaluate(() => ({
|
||||
status: document.getElementById('sl-status').textContent,
|
||||
disabled: document.getElementById('sl-controls').disabled,
|
||||
}));
|
||||
assert.notEqual(shown.status, 'KEY REQUIRED');
|
||||
assert.equal(shown.disabled, false, 'controls stay live');
|
||||
}
|
||||
|
||||
async function assertKeyRequired(page) {
|
||||
await page.evaluate(() =>
|
||||
window.__godsEyeView.dataManager.setEnabled('street-level', true, {
|
||||
@@ -528,11 +544,25 @@ async function main() {
|
||||
);
|
||||
|
||||
await expandPanel(page);
|
||||
if (!configured) {
|
||||
if (!configured && litAlone(await getUI(page), 'mapillary')) {
|
||||
// A keyed bundle behind a server without the token keeps Mapillary.
|
||||
await step('keyless server: KEY REQUIRED gates the controls', () =>
|
||||
assertKeyRequired(page),
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else if (!configured) {
|
||||
await step('keyless server: opens on KartaView, not KEY REQUIRED', () =>
|
||||
assertKeylessDefault(page),
|
||||
);
|
||||
await step(
|
||||
'keyless server: the MAPILLARY chip explains its key instead of switching',
|
||||
async () => {
|
||||
await press(page, '#sl-provider-chips [data-chip-id="mapillary"]');
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
assert.ok(litAlone(await getUI(page), 'kartaview'));
|
||||
},
|
||||
);
|
||||
skip('no Mapillary key', 'the keyed steps');
|
||||
} else {
|
||||
await park(page);
|
||||
await step(
|
||||
@@ -751,6 +781,8 @@ async function main() {
|
||||
await park(page);
|
||||
|
||||
await fixtureStep(
|
||||
// The fixture bundle carries a token, so this covers a keyless
|
||||
// server behind a keyed bundle; the keyless default is checked above.
|
||||
'keyless server (second page): KEY REQUIRED gates the controls',
|
||||
async () => {
|
||||
const keyless = await open(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { resolveCatalogSources } from './sourceComposition.js';
|
||||
import { createWeatherClock } from '../layers/weather/clock.js';
|
||||
import { createWeatherLayer } from '../layers/weather/index.js';
|
||||
import { createCyclonesLayer } from '../layers/cyclones/index.js';
|
||||
import { createApplicationCyclones } from './layers/cyclones.js';
|
||||
import { createWindLayer } from '../layers/wind/index.js';
|
||||
import { createLayerCatalog } from './catalog.js';
|
||||
import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
|
||||
@@ -182,7 +182,7 @@ export function createApplicationCatalog({
|
||||
id: 'weather-lightning',
|
||||
clock: weatherClock,
|
||||
}),
|
||||
createCyclonesLayer({ feed: sources.cyclones }),
|
||||
createApplicationCyclones({ feed: sources.cyclones }),
|
||||
...createOsmOverlayLayers(),
|
||||
...createInfrastructureLayers(localGeoJsonServices),
|
||||
createApplicationCables({ source: sources.cables }),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createCyclonesLayer } from '../../layers/cyclones/index.js';
|
||||
import * as context from '../../data/contextStore.js';
|
||||
|
||||
/** Wire cyclone selection to the application shared context. */
|
||||
export function createApplicationCyclones(options) {
|
||||
return createCyclonesLayer({
|
||||
context: typeof window !== 'undefined' ? context : null,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
+27
-20
@@ -3227,7 +3227,7 @@
|
||||
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
|
||||
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
|
||||
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
|
||||
"Fire intensity (FRP)": "الشدة (FRP)",
|
||||
"Fire intensity (FRP)": "شدة الحريق (FRP)",
|
||||
"≥ {value} MW": "{value} MW على الأقل",
|
||||
"Confidence": "الثقة",
|
||||
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
|
||||
@@ -3245,15 +3245,15 @@
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
|
||||
"Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)",
|
||||
"OSM Places": "أماكن OSM",
|
||||
"state or province": "ولاية أو مقاطعة",
|
||||
"county or district": "محافظة أو قضاء",
|
||||
"state or province": "ولاية أو إقليم",
|
||||
"county or district": "مقاطعة أو قضاء",
|
||||
"neighbourhood": "حي",
|
||||
"natural region": "منطقة طبيعية",
|
||||
"Which {place}?": "أي {place} تقصد؟",
|
||||
"Which place?": "أي مكان تقصد؟",
|
||||
"Not drawn: {parts}": "لم يُرسم: {parts}",
|
||||
"Not drawn: {parts}": "لم يُرسم المخطط: {parts}",
|
||||
"approximate": "تقريبي",
|
||||
"{pct}% cloud": "غيوم {pct}%",
|
||||
"{pct}% cloud": "الغيوم {pct}%",
|
||||
"Over {area}": "فوق {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"few": "الصور المطابقة: {count} من {total}",
|
||||
@@ -3264,12 +3264,12 @@
|
||||
"other": "الصور المطابقة: {count} من {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"few": "الصور التي جرى البحث فيها: {n}",
|
||||
"many": "الصور التي جرى البحث فيها: {n}",
|
||||
"one": "الصور التي جرى البحث فيها: {n}",
|
||||
"two": "الصور التي جرى البحث فيها: {n}",
|
||||
"zero": "الصور التي جرى البحث فيها: {n}",
|
||||
"other": "الصور التي جرى البحث فيها: {n}"
|
||||
"zero": "الصور المفحوصة: {n}",
|
||||
"one": "الصور المفحوصة: {n}",
|
||||
"two": "الصور المفحوصة: {n}",
|
||||
"few": "الصور المفحوصة: {n}",
|
||||
"many": "الصور المفحوصة: {n}",
|
||||
"other": "الصور المفحوصة: {n}"
|
||||
},
|
||||
"No matching imagery": "لا توجد صور مطابقة",
|
||||
"Unknown number of {kind}": "{kind}: العدد غير معروف",
|
||||
@@ -3277,10 +3277,10 @@
|
||||
"At least {count} {kind}": "{kind}: {count} على الأقل",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "في {area}",
|
||||
"Area around {place}": "المنطقة المحيطة بـ{place}",
|
||||
"Area around landmark": "المنطقة المحيطة بمعلم",
|
||||
"Outline {place}": "تحديد مخطط {place}",
|
||||
"Outline area": "تحديد مخطط منطقة",
|
||||
"Area around {place}": "المنطقة حول {place}",
|
||||
"Area around landmark": "المنطقة حول المعلم",
|
||||
"Outline {place}": "رسم مخطط {place}",
|
||||
"Outline area": "رسم مخطط منطقة",
|
||||
"Find {place}": "البحث عن {place}",
|
||||
"Find area": "البحث عن منطقة",
|
||||
"Find recent imagery": "البحث عن صور حديثة",
|
||||
@@ -3323,9 +3323,9 @@
|
||||
"zero": "التكرارات المدمجة: {count}"
|
||||
},
|
||||
"listed the first {limit}": "عُرض أول {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "عُدّ ضمن المستطيل المحيط ({area})، لا ضمن الحدود",
|
||||
"counted over the bounding box of {area}, not its outline": "جرى العدّ ضمن المستطيل المحيط بمنطقة {area}، لا ضمن حدودها الفعلية",
|
||||
"Search cancelled": "أُلغي البحث",
|
||||
"Sea Marks": "علامات بحرية",
|
||||
"Sea Marks": "العلامات البحرية",
|
||||
"Ski Pistes": "مسارات التزلج",
|
||||
"Fly to Tokyo": "انتقل إلى طوكيو",
|
||||
"Show live flights": "اعرض تتبّع الرحلات المباشر",
|
||||
@@ -3400,7 +3400,7 @@
|
||||
"Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. أعد المحاولة بعد {seconds} ث.",
|
||||
"The provider refused access for this key. Check the key's project, region or permissions.": "رفض المزوّد الوصول باستخدام هذا المفتاح. تحقّق من مشروع المفتاح أو منطقته أو أذوناته.",
|
||||
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.",
|
||||
"{lower} to {upper}": "{lower} إلى {upper}",
|
||||
"{lower} to {upper}": "من {lower} إلى {upper}",
|
||||
"Up to {upper}": "حتى {upper}",
|
||||
"From {lower}": "من {lower}",
|
||||
"Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات",
|
||||
@@ -3408,7 +3408,7 @@
|
||||
"Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح",
|
||||
"Prohibited (P)": "محظور (P)",
|
||||
"Restricted (R)": "مقيّد (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي الخاص المحظور والمقيّد لدى FAA فقط، ولا يشمل UAS Facility Map أو قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "المجال الجوي المحظور والمقيّد ذو الاستخدام الخاص لدى FAA فقط، ولا يشمل UAS Facility Map ولا قيود طيران UAS. للعلم فقط — ولا يغني عن الأدوات الرسمية لتخطيط رحلات UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "محظور",
|
||||
"Restricted": "مقيّد",
|
||||
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
|
||||
@@ -3467,5 +3467,12 @@
|
||||
"Open {name} on Acartia": "فتح {name} على Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل",
|
||||
"{days}d": "{days}ي",
|
||||
"All markers": "كل العلامات",
|
||||
"Show markers of every age": "عرض العلامات من جميع الأعمار",
|
||||
"Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة",
|
||||
"event age filter|All": "الكل",
|
||||
"Event age": "عمر الحدث",
|
||||
"Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ"
|
||||
}
|
||||
+25
-18
@@ -2919,7 +2919,7 @@
|
||||
"From {lower}": "Ab {lower}",
|
||||
"Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung",
|
||||
"Unnamed zone": "Unbenannte Zone",
|
||||
"Restricted airspace source unavailable": "Quelle für Sperrgebiete nicht verfügbar",
|
||||
"Restricted airspace source unavailable": "Quelle für Sperr- und Flugbeschränkungsgebiete nicht verfügbar",
|
||||
"Prohibited (P)": "Sperrgebiet (P)",
|
||||
"Restricted (R)": "Flugbeschränkungsgebiet (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Nur Sperr- und Flugbeschränkungsgebiete der FAA (Luftraum mit besonderer Nutzung), nicht die UAS Facility Map oder UAS-Flugbeschränkungen. Nur zur Information – kein Ersatz für offizielle UAS-Flugplanungstools (B4UFLY / LAANC).",
|
||||
@@ -2956,13 +2956,13 @@
|
||||
"fire confidence filter|All": "Alle",
|
||||
"fire confidence filter|Nominal+": "Mittel+",
|
||||
"fire confidence filter|High": "Hoch",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen — viele der stärksten Brände haben eine niedrige Konfidenz",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Alle Erkennungen – viele der stärksten Brände haben eine niedrige Konfidenz",
|
||||
"Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz",
|
||||
"High confidence detections only": "Nur Erkennungen mit hoher Konfidenz",
|
||||
"Fire intensity (FRP)": "Intensität (FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "Konfidenz",
|
||||
"Minimum fire radiative power, megawatts": "Minimale Strahlungsleistung des Brandes in Megawatt",
|
||||
"Minimum fire radiative power, megawatts": "Mindestwert der Strahlungsleistung des Brandes in Megawatt",
|
||||
"fire intensity filter|All": "Alle",
|
||||
"Detection confidence": "Konfidenz der Erkennung",
|
||||
"{shown} of {total} shown": "{shown} von {total} angezeigt",
|
||||
@@ -3002,9 +3002,9 @@
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "Gebiet: {area}",
|
||||
"Area around {place}": "Gebiet um {place}",
|
||||
"Area around landmark": "Gebiet um ein Wahrzeichen",
|
||||
"Outline {place}": "{place} umreißen",
|
||||
"Outline area": "Gebiet umreißen",
|
||||
"Area around landmark": "Gebiet um einen Ort",
|
||||
"Outline {place}": "Umriss von {place} zeichnen",
|
||||
"Outline area": "Umriss zeichnen",
|
||||
"Find {place}": "{place} finden",
|
||||
"Find area": "Gebiet finden",
|
||||
"Find recent imagery": "Aktuelle Bilddaten finden",
|
||||
@@ -3015,27 +3015,27 @@
|
||||
"Loading imagery": "Bilddaten werden geladen",
|
||||
"Searching OpenStreetMap": "OpenStreetMap wird durchsucht",
|
||||
"voice area|Area": "Gebiet",
|
||||
"buffer sized to the place": "Puffer in Größe des Ortes",
|
||||
"buffer sized to the place": "Puffer entsprechend der Ortsgröße",
|
||||
"enclosing mapped area": "umschließendes kartiertes Gebiet",
|
||||
"the map was cleared while looking it up": "die Karte wurde während der Suche geleert",
|
||||
"OpenStreetMap coverage varies by place": "Die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
|
||||
"the map was cleared while looking it up": "die Zeichenfläche wurde während der Suche geleert",
|
||||
"OpenStreetMap coverage varies by place": "die Abdeckung von OpenStreetMap ist je nach Ort unterschiedlich",
|
||||
"the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage",
|
||||
"there are more": "es gibt weitere",
|
||||
"{radius} m around the landmark": "{radius} m um das Wahrzeichen",
|
||||
"{radius} m around the landmark": "{radius} m um den Ort",
|
||||
"{count} parts": {
|
||||
"one": "Teile: {count}",
|
||||
"other": "Teile: {count}"
|
||||
"one": "{count} Teil",
|
||||
"other": "{count} Teile"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"one": "Gezeichnete Teile: {drawn} von {total}",
|
||||
"other": "Gezeichnete Teile: {drawn} von {total}"
|
||||
"one": "{drawn} von {total} Teil gezeichnet",
|
||||
"other": "{drawn} von {total} Teilen gezeichnet"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"one": "Zusammengeführte Doppelkartierungen: {count}",
|
||||
"other": "Zusammengeführte Doppelkartierungen: {count}"
|
||||
"one": "{count} Duplikat zusammengeführt",
|
||||
"other": "{count} Duplikate zusammengeführt"
|
||||
},
|
||||
"listed the first {limit}": "die ersten {limit} aufgelistet",
|
||||
"counted over the bounding box of {area}, not its outline": "über das umgebende Rechteck gezählt ({area}), nicht über den Umriss",
|
||||
"counted over the bounding box of {area}, not its outline": "im umgebenden Rechteck von {area} gezählt, nicht innerhalb des Umrisses",
|
||||
"Search cancelled": "Suche abgebrochen",
|
||||
"Sea Marks": "Seezeichen",
|
||||
"Ski Pistes": "Skipisten",
|
||||
@@ -3167,5 +3167,12 @@
|
||||
"Open {name} on Acartia": "{name} auf Acartia öffnen",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag",
|
||||
"{days}d": "{days}T",
|
||||
"All markers": "Alle Markierungen",
|
||||
"Show markers of every age": "Markierungen jeden Alters anzeigen",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben",
|
||||
"event age filter|All": "Alle",
|
||||
"Event age": "Ereignisalter",
|
||||
"Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben"
|
||||
}
|
||||
+22
-15
@@ -2993,7 +2993,7 @@
|
||||
"fire confidence filter|All": "Todas",
|
||||
"fire confidence filter|Nominal+": "Nominal+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos tienen confianza baja",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Todas las detecciones — muchos de los incendios más intensos se clasifican con confianza baja",
|
||||
"Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
|
||||
"High confidence detections only": "Solo detecciones de confianza alta",
|
||||
"Fire intensity (FRP)": "Intensidad (FRP)",
|
||||
@@ -3002,8 +3002,8 @@
|
||||
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
|
||||
"fire intensity filter|All": "Todo",
|
||||
"Detection confidence": "Confianza de la detección",
|
||||
"{shown} of {total} shown": "{shown} de {total} mostrados",
|
||||
"{lower} to {upper}": "{lower} a {upper}",
|
||||
"{shown} of {total} shown": "Mostrando {shown} de {total}",
|
||||
"{lower} to {upper}": "De {lower} a {upper}",
|
||||
"Up to {upper}": "Hasta {upper}",
|
||||
"From {lower}": "Desde {lower}",
|
||||
"Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos",
|
||||
@@ -3011,9 +3011,9 @@
|
||||
"Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible",
|
||||
"Prohibited (P)": "Prohibido (P)",
|
||||
"Restricted (R)": "Restringido (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA, sin el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo: no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Prohibido",
|
||||
"Restricted": "Restringido",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo espacio aéreo de uso especial prohibido y restringido de la FAA; no incluye el UAS Facility Map ni las restricciones de vuelo de UAS. Solo informativo — no sustituye a las herramientas oficiales de planificación de vuelos de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona prohibida",
|
||||
"Restricted": "Zona restringida",
|
||||
"Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido",
|
||||
"routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
|
||||
"route proxy error": "error del proxy de rutas",
|
||||
@@ -3051,11 +3051,11 @@
|
||||
"county or district": "condado o distrito",
|
||||
"neighbourhood": "barrio",
|
||||
"natural region": "región natural",
|
||||
"Which {place}?": "¿Cuál {place}?",
|
||||
"Which {place}?": "¿Qué {place}?",
|
||||
"Which place?": "¿Qué lugar?",
|
||||
"Not drawn: {parts}": "Sin dibujar: {parts}",
|
||||
"approximate": "aproximado",
|
||||
"{pct}% cloud": "{pct} % de nubes",
|
||||
"approximate": "aproximada",
|
||||
"{pct}% cloud": "{pct}% de nubes",
|
||||
"Over {area}": "Zona: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Adquisiciones coincidentes: {count} de {total}",
|
||||
@@ -3069,7 +3069,7 @@
|
||||
},
|
||||
"No matching imagery": "No hay imágenes que coincidan",
|
||||
"Unknown number of {kind}": "{kind}: número desconocido",
|
||||
"About {count} {kind}": "{kind}: unos {count}",
|
||||
"About {count} {kind}": "{kind}: aproximadamente {count}",
|
||||
"At least {count} {kind}": "{kind}: al menos {count}",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "En {area}",
|
||||
@@ -3088,12 +3088,12 @@
|
||||
"Searching OpenStreetMap": "Buscando en OpenStreetMap",
|
||||
"voice area|Area": "Área",
|
||||
"buffer sized to the place": "zona aproximada del tamaño del lugar",
|
||||
"enclosing mapped area": "zona cartografiada que lo rodea",
|
||||
"enclosing mapped area": "zona mapeada que contiene el punto de interés",
|
||||
"the map was cleared while looking it up": "el mapa se borró durante la búsqueda",
|
||||
"OpenStreetMap coverage varies by place": "La cobertura de OpenStreetMap varía según el lugar",
|
||||
"OpenStreetMap coverage varies by place": "la cobertura de OpenStreetMap varía según el lugar",
|
||||
"the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días",
|
||||
"there are more": "hay más",
|
||||
"{radius} m around the landmark": "{radius} m alrededor del lugar",
|
||||
"{radius} m around the landmark": "{radius} m alrededor del punto de interés",
|
||||
"{count} parts": {
|
||||
"many": "Partes: {count}",
|
||||
"one": "Partes: {count}",
|
||||
@@ -3110,7 +3110,7 @@
|
||||
"other": "Duplicados fusionados: {count}"
|
||||
},
|
||||
"listed the first {limit}": "se listaron los primeros {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contado en el rectángulo que lo rodea ({area}), no en su contorno",
|
||||
"counted over the bounding box of {area}, not its outline": "se contó en el rectángulo que abarca {area}, no dentro de su contorno",
|
||||
"Search cancelled": "Búsqueda cancelada",
|
||||
"Sea Marks": "Señales marítimas",
|
||||
"Ski Pistes": "Pistas de esquí",
|
||||
@@ -3242,5 +3242,12 @@
|
||||
"Open {name} on Acartia": "Abrir {name} en Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro",
|
||||
"{days}d": "{days}d",
|
||||
"All markers": "Todos los marcadores",
|
||||
"Show markers of every age": "Mostrar marcadores de cualquier antigüedad",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Antigüedad",
|
||||
"Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha"
|
||||
}
|
||||
+35
-28
@@ -2984,18 +2984,18 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Astuce : le bouton GEV MIC du dock permet de parler à la carte.",
|
||||
"Malformed cyclone snapshot": "Instantané cyclonique mal formé",
|
||||
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
|
||||
"{lower} to {upper}": "{lower} à {upper}",
|
||||
"Up to {upper}": "Jusqu’à {upper}",
|
||||
"{lower} to {upper}": "De {lower} à {upper}",
|
||||
"Up to {upper}": "Jusqu'à {upper}",
|
||||
"From {lower}": "À partir de {lower}",
|
||||
"Informational only · not for flight planning": "À titre informatif uniquement · ne pas utiliser pour la planification de vol",
|
||||
"Unnamed zone": "Zone sans nom",
|
||||
"Restricted airspace source unavailable": "Source de l’espace aérien réglementé indisponible",
|
||||
"Informational only · not for flight planning": "À titre indicatif · ne pas utiliser pour la préparation des vols",
|
||||
"Unnamed zone": "Sans nom",
|
||||
"Restricted airspace source unavailable": "Source de l'espace aérien réglementé indisponible",
|
||||
"Prohibited (P)": "Zone interdite (P)",
|
||||
"Restricted (R)": "Zone réglementée (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre informatif uniquement — ne remplace pas les outils officiels de planification de vol des drones (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Uniquement les zones interdites et réglementées de la FAA (espace aérien à usage spécial), sans la UAS Facility Map ni les restrictions de vol des drones. À titre indicatif uniquement — ne remplace pas les outils officiels de préparation des vols de drones (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zone interdite",
|
||||
"Restricted": "Zone réglementée",
|
||||
"Prohibited & Restricted Airspace": "Zones interdites et réglementées",
|
||||
"Prohibited & Restricted Airspace": "Espaces aériens interdits et réglementés",
|
||||
"scene|PLAY": "LIRE",
|
||||
"AUTHORING": "CRÉATION",
|
||||
"Create and edit scenes": "Créer et modifier des scènes",
|
||||
@@ -3026,7 +3026,7 @@
|
||||
"fire confidence filter|All": "Toutes",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Élevée",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — beaucoup des feux les plus intenses ont une confiance faible",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Toutes les détections — de nombreux feux parmi les plus intenses sont classés en confiance faible",
|
||||
"Nominal and high confidence detections": "Détections de confiance nominale et élevée",
|
||||
"High confidence detections only": "Détections de confiance élevée uniquement",
|
||||
"Fire intensity (FRP)": "Intensité (FRP)",
|
||||
@@ -3035,7 +3035,7 @@
|
||||
"Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts",
|
||||
"fire intensity filter|All": "Toutes",
|
||||
"Detection confidence": "Confiance de la détection",
|
||||
"{shown} of {total} shown": "{shown} sur {total} affichées",
|
||||
"{shown} of {total} shown": "Affichés : {shown} sur {total}",
|
||||
"Name of {name}": "Nom de {name}",
|
||||
"Change the colour of {name}": "Changer la couleur de {name}",
|
||||
"Colour of {name}": "Couleur de {name}",
|
||||
@@ -3045,7 +3045,7 @@
|
||||
"UTILITIES": "OUTILS",
|
||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
|
||||
"Voice place search (needs an Overpass server)": "Recherche de lieux par la voix (nécessite un serveur Overpass)",
|
||||
"Voice place search (needs an Overpass server)": "Recherche vocale de lieux (nécessite un serveur Overpass)",
|
||||
"OSM Places": "Lieux OSM",
|
||||
"state or province": "État ou province",
|
||||
"county or district": "comté ou district",
|
||||
@@ -3053,9 +3053,9 @@
|
||||
"natural region": "région naturelle",
|
||||
"Which {place}?": "Quel lieu nommé {place} ?",
|
||||
"Which place?": "Quel lieu ?",
|
||||
"Not drawn: {parts}": "Non dessiné : {parts}",
|
||||
"Not drawn: {parts}": "Contour non tracé : {parts}",
|
||||
"approximate": "approximatif",
|
||||
"{pct}% cloud": "{pct} % de nuages",
|
||||
"{pct}% cloud": "{pct} % de nuages",
|
||||
"Over {area}": "Zone : {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Acquisitions correspondantes : {count} sur {total}",
|
||||
@@ -3074,43 +3074,43 @@
|
||||
"{count} {kind}": "{kind} : {count}",
|
||||
"In {area}": "Zone : {area}",
|
||||
"Area around {place}": "Environs : {place}",
|
||||
"Area around landmark": "Environs d'un repère",
|
||||
"Area around landmark": "Environs d'un lieu",
|
||||
"Outline {place}": "Délimiter {place}",
|
||||
"Outline area": "Délimiter une zone",
|
||||
"Find {place}": "Trouver {place}",
|
||||
"Find area": "Trouver une zone",
|
||||
"Find recent imagery": "Trouver des images récentes",
|
||||
"Find {what}": "Trouver {what}",
|
||||
"Find {what}": "Trouver : {what}",
|
||||
"Find places": "Trouver des lieux",
|
||||
"Drawing outline": "Dessin du contour",
|
||||
"Searching imagery catalog": "Recherche dans le catalogue d'images",
|
||||
"Loading imagery": "Chargement des images",
|
||||
"Searching OpenStreetMap": "Recherche dans OpenStreetMap",
|
||||
"voice area|Area": "Zone",
|
||||
"buffer sized to the place": "zone tampon à la taille du lieu",
|
||||
"buffer sized to the place": "zone tampon adaptée à la taille du lieu",
|
||||
"enclosing mapped area": "zone cartographiée englobante",
|
||||
"the map was cleared while looking it up": "la carte a été effacée pendant la recherche",
|
||||
"OpenStreetMap coverage varies by place": "La couverture OpenStreetMap varie selon les lieux",
|
||||
"OpenStreetMap coverage varies by place": "la couverture OpenStreetMap varie selon les lieux",
|
||||
"the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours",
|
||||
"there are more": "il y en a d'autres",
|
||||
"{radius} m around the landmark": "{radius} m autour du lieu",
|
||||
"{count} parts": {
|
||||
"many": "Parties : {count}",
|
||||
"one": "Parties : {count}",
|
||||
"other": "Parties : {count}"
|
||||
"many": "Parties : {count}",
|
||||
"one": "Parties : {count}",
|
||||
"other": "Parties : {count}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"many": "Parties tracées : {drawn} sur {total}",
|
||||
"one": "Parties tracées : {drawn} sur {total}",
|
||||
"other": "Parties tracées : {drawn} sur {total}"
|
||||
"many": "Parties tracées : {drawn} sur {total}",
|
||||
"one": "Parties tracées : {drawn} sur {total}",
|
||||
"other": "Parties tracées : {drawn} sur {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"many": "Doublons fusionnés : {count}",
|
||||
"one": "Doublons fusionnés : {count}",
|
||||
"other": "Doublons fusionnés : {count}"
|
||||
"many": "Doublons fusionnés : {count}",
|
||||
"one": "Doublons fusionnés : {count}",
|
||||
"other": "Doublons fusionnés : {count}"
|
||||
},
|
||||
"listed the first {limit}": "les {limit} premiers sont listés",
|
||||
"counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour",
|
||||
"listed the first {limit}": "liste limitée à {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "compté dans le rectangle englobant ({area}), pas dans son contour exact",
|
||||
"Search cancelled": "Recherche annulée",
|
||||
"Sea Marks": "Balisage maritime",
|
||||
"Ski Pistes": "Pistes de ski",
|
||||
@@ -3242,5 +3242,12 @@
|
||||
"Open {name} on Acartia": "Ouvrir {name} sur Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributeurs iNaturalist · licences par observation",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributeurs iNaturalist · Acartia {count} (mer des Salish) · licences par observation",
|
||||
"{days}d": "{days}j",
|
||||
"All markers": "Tous les marqueurs",
|
||||
"Show markers of every age": "Afficher les marqueurs de tout âge",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Ancienneté maximale : {days} j ; marqueurs sans date conservés",
|
||||
"event age filter|All": "Tous",
|
||||
"Event age": "Ancienneté",
|
||||
"Event age: {days}d · undated kept": "Ancienneté : {days} j · marqueurs sans date conservés"
|
||||
}
|
||||
+28
-21
@@ -2984,18 +2984,18 @@
|
||||
"Tip: the GEV MIC button in the dock lets you talk to the map.": "Suggerimento: il pulsante GEV MIC nel dock ti permette di parlare con la mappa.",
|
||||
"Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
|
||||
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout",
|
||||
"{lower} to {upper}": "da {lower} a {upper}",
|
||||
"{lower} to {upper}": "Da {lower} a {upper}",
|
||||
"Up to {upper}": "Fino a {upper}",
|
||||
"From {lower}": "Da {lower}",
|
||||
"Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo",
|
||||
"Unnamed zone": "Zona senza nome",
|
||||
"Unnamed zone": "Senza nome",
|
||||
"Restricted airspace source unavailable": "Fonte dello spazio aereo regolamentato non disponibile",
|
||||
"Prohibited (P)": "Zona proibita (P)",
|
||||
"Prohibited (P)": "Zona vietata (P)",
|
||||
"Restricted (R)": "Zona regolamentata (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone proibite e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona proibita",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Solo zone vietate e regolamentate della FAA (spazio aereo a uso speciale), non la UAS Facility Map né le restrizioni di volo UAS. Solo a scopo informativo — non sostituisce gli strumenti ufficiali di pianificazione del volo UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Zona vietata",
|
||||
"Restricted": "Zona regolamentata",
|
||||
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate",
|
||||
"Prohibited & Restricted Airspace": "Spazio aereo vietato e regolamentato",
|
||||
"scene|PLAY": "AVVIA",
|
||||
"AUTHORING": "CREAZIONE",
|
||||
"Create and edit scenes": "Crea e modifica le scene",
|
||||
@@ -3026,7 +3026,7 @@
|
||||
"fire confidence filter|All": "Tutti",
|
||||
"fire confidence filter|Nominal+": "Nominale+",
|
||||
"fire confidence filter|High": "Alta",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti — molti degli incendi più intensi hanno confidenza bassa",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Tutti i rilevamenti: molti degli incendi più intensi hanno confidenza bassa",
|
||||
"Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta",
|
||||
"High confidence detections only": "Solo rilevamenti con confidenza alta",
|
||||
"Fire intensity (FRP)": "Intensità (FRP)",
|
||||
@@ -3035,7 +3035,7 @@
|
||||
"Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt",
|
||||
"fire intensity filter|All": "Tutti",
|
||||
"Detection confidence": "Confidenza del rilevamento",
|
||||
"{shown} of {total} shown": "{shown} di {total} mostrati",
|
||||
"{shown} of {total} shown": "Mostrati: {shown} su {total}",
|
||||
"Name of {name}": "Nome di {name}",
|
||||
"Change the colour of {name}": "Cambia il colore di {name}",
|
||||
"Colour of {name}": "Colore di {name}",
|
||||
@@ -3053,10 +3053,10 @@
|
||||
"natural region": "regione naturale",
|
||||
"Which {place}?": "Quale {place}?",
|
||||
"Which place?": "Quale luogo?",
|
||||
"Not drawn: {parts}": "Non disegnati: {parts}",
|
||||
"approximate": "approssimativo",
|
||||
"Not drawn: {parts}": "Contorno non disegnato: {parts}",
|
||||
"approximate": "approssimata",
|
||||
"{pct}% cloud": "{pct}% di nuvole",
|
||||
"Over {area}": "Zona: {area}",
|
||||
"Over {area}": "Area: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Acquisizioni corrispondenti: {count} su {total}",
|
||||
"one": "Acquisizioni corrispondenti: {count} su {total}",
|
||||
@@ -3075,8 +3075,8 @@
|
||||
"In {area}": "Area: {area}",
|
||||
"Area around {place}": "Area intorno a {place}",
|
||||
"Area around landmark": "Area intorno a un punto di interesse",
|
||||
"Outline {place}": "Traccia contorno di {place}",
|
||||
"Outline area": "Traccia contorno area",
|
||||
"Outline {place}": "Traccia il contorno di {place}",
|
||||
"Outline area": "Traccia il contorno dell'area",
|
||||
"Find {place}": "Trova {place}",
|
||||
"Find area": "Trova area",
|
||||
"Find recent imagery": "Trova immagini recenti",
|
||||
@@ -3084,16 +3084,16 @@
|
||||
"Find places": "Trova luoghi",
|
||||
"Drawing outline": "Disegno del contorno",
|
||||
"Searching imagery catalog": "Ricerca nel catalogo immagini",
|
||||
"Loading imagery": "Caricamento immagini",
|
||||
"Loading imagery": "Caricamento delle immagini",
|
||||
"Searching OpenStreetMap": "Ricerca in OpenStreetMap",
|
||||
"voice area|Area": "Area",
|
||||
"buffer sized to the place": "zona tampone delle dimensioni del luogo",
|
||||
"enclosing mapped area": "area mappata circostante",
|
||||
"the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca",
|
||||
"OpenStreetMap coverage varies by place": "La copertura di OpenStreetMap varia da luogo a luogo",
|
||||
"enclosing mapped area": "area mappata che contiene il punto di interesse",
|
||||
"the map was cleared while looking it up": "la mappa è stata ripulita durante la ricerca",
|
||||
"OpenStreetMap coverage varies by place": "la copertura di OpenStreetMap varia da luogo a luogo",
|
||||
"the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni",
|
||||
"there are more": "ce ne sono altri",
|
||||
"{radius} m around the landmark": "{radius} m attorno al luogo",
|
||||
"{radius} m around the landmark": "{radius} m intorno al punto di interesse",
|
||||
"{count} parts": {
|
||||
"many": "Parti: {count}",
|
||||
"one": "Parti: {count}",
|
||||
@@ -3109,8 +3109,8 @@
|
||||
"one": "Duplicati uniti: {count}",
|
||||
"other": "Duplicati uniti: {count}"
|
||||
},
|
||||
"listed the first {limit}": "elencati i primi {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno",
|
||||
"listed the first {limit}": "elenco limitato ai primi risultati: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "conteggio nel riquadro di delimitazione di {area}, non entro il suo contorno",
|
||||
"Search cancelled": "Ricerca annullata",
|
||||
"Sea Marks": "Segnalamenti marittimi",
|
||||
"Ski Pistes": "Piste da sci",
|
||||
@@ -3242,5 +3242,12 @@
|
||||
"Open {name} on Acartia": "Apri {name} su Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione",
|
||||
"{days}d": "{days}g",
|
||||
"All markers": "Tutti gli indicatori",
|
||||
"Show markers of every age": "Mostra gli indicatori di qualsiasi età",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti",
|
||||
"event age filter|All": "Tutti",
|
||||
"Event age": "Età degli eventi",
|
||||
"Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti"
|
||||
}
|
||||
+42
-35
@@ -2844,18 +2844,18 @@
|
||||
"route proxy error": "ルート検索プロキシでエラーが発生しました",
|
||||
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
|
||||
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
|
||||
"{lower} to {upper}": "{lower}~{upper}",
|
||||
"{lower} to {upper}": "{lower}–{upper}",
|
||||
"Up to {upper}": "{upper}まで",
|
||||
"From {lower}": "{lower}から",
|
||||
"Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください",
|
||||
"Unnamed zone": "名称なしのゾーン",
|
||||
"Unnamed zone": "名称不明の区域",
|
||||
"Restricted airspace source unavailable": "制限空域のデータソースを利用できません",
|
||||
"Prohibited (P)": "飛行禁止区域 (P)",
|
||||
"Restricted (R)": "制限区域 (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの飛行禁止区域と制限区域(特別使用空域)のみで、UAS Facility MapやUAS飛行制限は含みません。参考情報のみ — 公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
|
||||
"Prohibited (P)": "飛行禁止区域(P)",
|
||||
"Restricted (R)": "飛行制限区域(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "FAAの特別使用空域のうち、飛行禁止区域と飛行制限区域のみです。UAS Facility MapやUASの飛行制限は含みません。参考情報であり、公式のUAS飛行計画ツール(B4UFLY / LAANC)の代わりにはなりません。",
|
||||
"Prohibited": "飛行禁止区域",
|
||||
"Restricted": "制限区域",
|
||||
"Prohibited & Restricted Airspace": "飛行禁止区域と制限区域",
|
||||
"Restricted": "飛行制限区域",
|
||||
"Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域",
|
||||
"scene|PLAY": "再生",
|
||||
"AUTHORING": "作成",
|
||||
"Create and edit scenes": "シーンを作成・編集",
|
||||
@@ -2886,10 +2886,10 @@
|
||||
"fire confidence filter|All": "すべて",
|
||||
"fire confidence filter|Nominal+": "中以上",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています",
|
||||
"Nominal and high confidence detections": "信頼度が中・高の検知",
|
||||
"High confidence detections only": "信頼度が高の検知のみ",
|
||||
"Fire intensity (FRP)": "強度(FRP)",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "すべての検知。最も強い火災の多くは信頼度「低」と判定されています",
|
||||
"Nominal and high confidence detections": "信頼度「中」以上の検知",
|
||||
"High confidence detections only": "信頼度「高」の検知のみ",
|
||||
"Fire intensity (FRP)": "火災強度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "信頼度",
|
||||
"Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)",
|
||||
@@ -2907,16 +2907,16 @@
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||
"Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)",
|
||||
"OSM Places": "OSMの場所",
|
||||
"state or province": "州・省",
|
||||
"state or province": "州・県",
|
||||
"county or district": "郡・地区",
|
||||
"neighbourhood": "近隣地区",
|
||||
"natural region": "自然地域",
|
||||
"Which {place}?": "どの{place}ですか?",
|
||||
"Which {place}?": "どの「{place}」ですか?",
|
||||
"Which place?": "どの場所ですか?",
|
||||
"Not drawn: {parts}": "未描画:{parts}",
|
||||
"approximate": "概算",
|
||||
"{pct}% cloud": "雲量{pct}%",
|
||||
"Over {area}": "{area}上空",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "雲量 {pct}%",
|
||||
"Over {area}": "範囲:{area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "一致した画像:{count}/{total}件"
|
||||
},
|
||||
@@ -2925,45 +2925,45 @@
|
||||
},
|
||||
"No matching imagery": "一致する画像はありません",
|
||||
"Unknown number of {kind}": "{kind}:件数不明",
|
||||
"About {count} {kind}": "{kind}:約{count}件",
|
||||
"At least {count} {kind}": "{kind}:{count}件以上",
|
||||
"{count} {kind}": "{kind}:{count}件",
|
||||
"About {count} {kind}": "{kind}:約{count}か所",
|
||||
"At least {count} {kind}": "{kind}:{count}か所以上",
|
||||
"{count} {kind}": "{kind}:{count}か所",
|
||||
"In {area}": "{area}内",
|
||||
"Area around {place}": "{place}周辺のエリア",
|
||||
"Area around landmark": "ランドマーク周辺のエリア",
|
||||
"Outline {place}": "{place}の輪郭を描く",
|
||||
"Outline area": "エリアの輪郭を描く",
|
||||
"Outline {place}": "{place}の輪郭を描画",
|
||||
"Outline area": "エリアの輪郭を描画",
|
||||
"Find {place}": "{place}を検索",
|
||||
"Find area": "エリアを検索",
|
||||
"Find recent imagery": "最近の衛星画像を検索",
|
||||
"Find {what}": "{what}を検索",
|
||||
"Find places": "場所を検索",
|
||||
"Drawing outline": "輪郭を作図中",
|
||||
"Drawing outline": "輪郭を地図に描画中",
|
||||
"Searching imagery catalog": "画像カタログを検索中",
|
||||
"Loading imagery": "画像を読み込み中",
|
||||
"Searching OpenStreetMap": "OpenStreetMapを検索中",
|
||||
"voice area|Area": "エリア",
|
||||
"buffer sized to the place": "場所の大きさに合わせたバッファ",
|
||||
"enclosing mapped area": "周囲の地図上のエリア",
|
||||
"the map was cleared while looking it up": "検索中に地図がクリアされました",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap の網羅度は場所によって異なります",
|
||||
"the catalog covers the last 30 days": "カタログは過去 30 日分です",
|
||||
"buffer sized to the place": "場所の大きさに合わせたバッファー",
|
||||
"enclosing mapped area": "ランドマークを含むマップ済みのエリア",
|
||||
"the map was cleared while looking it up": "検索中に地図が消去されました",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMapの網羅度は場所によって異なります",
|
||||
"the catalog covers the last 30 days": "カタログの対象は過去30日間です",
|
||||
"there are more": "ほかにもあります",
|
||||
"{radius} m around the landmark": "ランドマークの周囲 {radius} m",
|
||||
"{radius} m around the landmark": "ランドマークから半径{radius} m",
|
||||
"{count} parts": {
|
||||
"other": "パーツ数: {count}"
|
||||
"other": "パーツ数:{count}"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "描画したパーツ: {drawn}/{total}"
|
||||
"other": "描画したパーツ:{drawn}/{total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "統合した重複: {count}"
|
||||
"other": "重複登録{count}件を統合"
|
||||
},
|
||||
"listed the first {limit}": "最初の {limit} 件を表示",
|
||||
"counted over the bounding box of {area}, not its outline": "外接矩形 ({area}) で数えた結果で、輪郭ではありません",
|
||||
"listed the first {limit}": "最初の{limit}件を表示",
|
||||
"counted over the bounding box of {area}, not its outline": "{area}の輪郭ではなく外接矩形の範囲で数えています",
|
||||
"Search cancelled": "検索をキャンセルしました",
|
||||
"Sea Marks": "航路標識",
|
||||
"Ski Pistes": "スキーゲレンデ",
|
||||
"Ski Pistes": "スキーコース",
|
||||
"Fly to Tokyo": "東京へ移動して",
|
||||
"Show live flights": "ライブフライトを表示して",
|
||||
"Reset to globe view": "地球全体ビューに戻して",
|
||||
@@ -3092,5 +3092,12 @@
|
||||
"Open {name} on Acartia": "Acartiaで{name}を開く",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと",
|
||||
"{days}d": "{days}日",
|
||||
"All markers": "すべてのマーカー",
|
||||
"Show markers of every age": "日付にかかわらずすべてのマーカーを表示",
|
||||
"Maximum event age: {days}d; undated markers are kept": "イベントの最大経過日数:{days}日。日付のないマーカーは保持",
|
||||
"event age filter|All": "すべて",
|
||||
"Event age": "経過日数",
|
||||
"Event age: {days}d · undated kept": "経過日数:{days}日以内 · 日付なしは保持"
|
||||
}
|
||||
+30
-23
@@ -2988,14 +2988,14 @@
|
||||
"Up to {upper}": "Até {upper}",
|
||||
"From {lower}": "A partir de {lower}",
|
||||
"Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo",
|
||||
"Unnamed zone": "Zona sem nome",
|
||||
"Unnamed zone": "Sem nome",
|
||||
"Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível",
|
||||
"Prohibited (P)": "Área proibida (P)",
|
||||
"Restricted (R)": "Área restrita (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Apenas áreas proibidas e restritas da FAA (espaço aéreo de uso especial), sem o UAS Facility Map ou as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Somente o espaço aéreo de uso especial proibido e restrito da FAA; não inclui o UAS Facility Map nem as restrições de voo de UAS. Apenas informativo — não substitui as ferramentas oficiais de planejamento de voo de UAS (B4UFLY / LAANC).",
|
||||
"Prohibited": "Área proibida",
|
||||
"Restricted": "Área restrita",
|
||||
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas",
|
||||
"Prohibited & Restricted Airspace": "Espaço aéreo proibido e restrito",
|
||||
"scene|PLAY": "INICIAR",
|
||||
"AUTHORING": "CRIAÇÃO",
|
||||
"Create and edit scenes": "Criar e editar cenas",
|
||||
@@ -3035,7 +3035,7 @@
|
||||
"Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts",
|
||||
"fire intensity filter|All": "Todas",
|
||||
"Detection confidence": "Confiança da detecção",
|
||||
"{shown} of {total} shown": "{shown} de {total} exibidas",
|
||||
"{shown} of {total} shown": "Detecções exibidas: {shown} de {total}",
|
||||
"Name of {name}": "Nome de {name}",
|
||||
"Change the colour of {name}": "Mudar a cor de {name}",
|
||||
"Colour of {name}": "Cor de {name}",
|
||||
@@ -3054,18 +3054,18 @@
|
||||
"Which {place}?": "Qual {place}?",
|
||||
"Which place?": "Qual lugar?",
|
||||
"Not drawn: {parts}": "Não desenhado: {parts}",
|
||||
"approximate": "aproximado",
|
||||
"approximate": "aproximada",
|
||||
"{pct}% cloud": "{pct}% de nuvens",
|
||||
"Over {area}": "Área: {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"many": "Aquisições correspondentes: {count} de {total}",
|
||||
"one": "Aquisições correspondentes: {count} de {total}",
|
||||
"other": "Aquisições correspondentes: {count} de {total}"
|
||||
"one": "Imagens correspondentes: {count} de {total}",
|
||||
"many": "Imagens correspondentes: {count} de {total}",
|
||||
"other": "Imagens correspondentes: {count} de {total}"
|
||||
},
|
||||
"{n} acquisitions searched": {
|
||||
"many": "Aquisições pesquisadas: {n}",
|
||||
"one": "Aquisições pesquisadas: {n}",
|
||||
"other": "Aquisições pesquisadas: {n}"
|
||||
"one": "Imagens pesquisadas: {n}",
|
||||
"many": "Imagens pesquisadas: {n}",
|
||||
"other": "Imagens pesquisadas: {n}"
|
||||
},
|
||||
"No matching imagery": "Nenhuma imagem correspondente",
|
||||
"Unknown number of {kind}": "{kind}: número desconhecido",
|
||||
@@ -3077,23 +3077,23 @@
|
||||
"Area around landmark": "Área ao redor de um ponto de referência",
|
||||
"Outline {place}": "Traçar contorno de {place}",
|
||||
"Outline area": "Traçar contorno da área",
|
||||
"Find {place}": "Encontrar {place}",
|
||||
"Find area": "Encontrar área",
|
||||
"Find recent imagery": "Encontrar imagens recentes",
|
||||
"Find {what}": "Encontrar {what}",
|
||||
"Find places": "Encontrar lugares",
|
||||
"Find {place}": "Buscar {place}",
|
||||
"Find area": "Buscar área",
|
||||
"Find recent imagery": "Buscar imagens recentes",
|
||||
"Find {what}": "Buscar {what}",
|
||||
"Find places": "Buscar lugares",
|
||||
"Drawing outline": "Desenhando contorno",
|
||||
"Searching imagery catalog": "Buscando no catálogo de imagens",
|
||||
"Loading imagery": "Carregando imagens",
|
||||
"Searching OpenStreetMap": "Buscando no OpenStreetMap",
|
||||
"voice area|Area": "Área",
|
||||
"buffer sized to the place": "zona de margem do tamanho do local",
|
||||
"enclosing mapped area": "área mapeada envolvente",
|
||||
"buffer sized to the place": "círculo do tamanho aproximado do lugar",
|
||||
"enclosing mapped area": "área mapeada que contém o ponto de referência",
|
||||
"the map was cleared while looking it up": "o mapa foi limpo durante a busca",
|
||||
"OpenStreetMap coverage varies by place": "A cobertura do OpenStreetMap varia de lugar para lugar",
|
||||
"the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias",
|
||||
"there are more": "há mais",
|
||||
"{radius} m around the landmark": "{radius} m ao redor do local",
|
||||
"{radius} m around the landmark": "{radius} m ao redor do ponto de referência",
|
||||
"{count} parts": {
|
||||
"many": "Partes: {count}",
|
||||
"one": "Partes: {count}",
|
||||
@@ -3109,10 +3109,10 @@
|
||||
"one": "Duplicatas mescladas: {count}",
|
||||
"other": "Duplicatas mescladas: {count}"
|
||||
},
|
||||
"listed the first {limit}": "listados os primeiros {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno",
|
||||
"listed the first {limit}": "lista limitada a {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "contagem na caixa delimitadora de {area}, não no contorno",
|
||||
"Search cancelled": "Busca cancelada",
|
||||
"Sea Marks": "Sinais marítimos",
|
||||
"Sea Marks": "Sinalização náutica",
|
||||
"Ski Pistes": "Pistas de esqui",
|
||||
"Fly to Tokyo": "Voe para Tóquio",
|
||||
"Show live flights": "Mostre os voos ao vivo",
|
||||
@@ -3242,5 +3242,12 @@
|
||||
"Open {name} on Acartia": "Abrir {name} no Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro",
|
||||
"{days}d": "{days}d",
|
||||
"All markers": "Todos os marcadores",
|
||||
"Show markers of every age": "Mostrar marcadores de qualquer idade",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos",
|
||||
"event age filter|All": "Todos",
|
||||
"Event age": "Idade do evento",
|
||||
"Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos"
|
||||
}
|
||||
+28
-21
@@ -128,7 +128,7 @@
|
||||
"SRC {value}": "ИСТ. {value}",
|
||||
"{value} CONTEXT": "КОНТЕКСТ {value}",
|
||||
"Focus fire detection {title}, {details}": "Фокус на очаге пожара {title}, {details}",
|
||||
"{value} conf": "увер.: {value}",
|
||||
"{value} conf": "дост.: {value}",
|
||||
"{value} ago": "{value} назад",
|
||||
"sensor n/a": "сенсор н/д",
|
||||
"FIRE · {frp} MW": "ПОЖАР · {frp} МВт",
|
||||
@@ -3063,27 +3063,27 @@
|
||||
"fire confidence filter|All": "Все",
|
||||
"fire confidence filter|Nominal+": "Средняя+",
|
||||
"fire confidence filter|High": "Высокая",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая",
|
||||
"Nominal and high confidence detections": "Детекции средней и высокой достоверности",
|
||||
"High confidence detections only": "Только детекции высокой достоверности",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "Все очаги — многим сильнейшим пожарам присвоена низкая достоверность",
|
||||
"Nominal and high confidence detections": "Очаги средней и высокой достоверности",
|
||||
"High confidence detections only": "Только очаги высокой достоверности",
|
||||
"Fire intensity (FRP)": "Интенсивность (FRP)",
|
||||
"≥ {value} MW": "≥ {value} МВт",
|
||||
"Confidence": "Достоверность",
|
||||
"Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты",
|
||||
"fire intensity filter|All": "Все",
|
||||
"Detection confidence": "Достоверность детекции",
|
||||
"{shown} of {total} shown": "показано {shown} из {total}",
|
||||
"{lower} to {upper}": "от {lower} до {upper}",
|
||||
"Detection confidence": "Достоверность обнаружения",
|
||||
"{shown} of {total} shown": "Показано {shown} из {total}",
|
||||
"{lower} to {upper}": "От {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "От {lower}",
|
||||
"Informational only · not for flight planning": "Только для информации · не для планирования полётов",
|
||||
"Unnamed zone": "Зона без названия",
|
||||
"Restricted airspace source unavailable": "Источник данных об ограниченном воздушном пространстве недоступен",
|
||||
"Unnamed zone": "Без названия",
|
||||
"Restricted airspace source unavailable": "Источник данных о зонах ограничения полётов недоступен",
|
||||
"Prohibited (P)": "Запретная зона (P)",
|
||||
"Restricted (R)": "Зона ограничения (R)",
|
||||
"Restricted (R)": "Зона ограничения полётов (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Только запретные зоны и зоны ограничения полётов FAA (воздушное пространство особого использования), без UAS Facility Map и ограничений полётов БАС. Только для информации — не заменяет официальные инструменты планирования полётов БАС (B4UFLY / LAANC).",
|
||||
"Prohibited": "Запретная зона",
|
||||
"Restricted": "Зона ограничения",
|
||||
"Restricted": "Зона ограничения полётов",
|
||||
"Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения",
|
||||
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
|
||||
"route proxy error": "ошибка прокси маршрутов",
|
||||
@@ -3117,13 +3117,13 @@
|
||||
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
|
||||
"Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)",
|
||||
"OSM Places": "Места OSM",
|
||||
"state or province": "штат или провинция",
|
||||
"state or province": "штат, провинция или область",
|
||||
"county or district": "округ или район",
|
||||
"neighbourhood": "район города",
|
||||
"natural region": "природный регион",
|
||||
"Which {place}?": "Какой из вариантов «{place}»?",
|
||||
"Which place?": "Какое место?",
|
||||
"Not drawn: {parts}": "Не нарисовано: {parts}",
|
||||
"Not drawn: {parts}": "Контур не нарисован: {parts}",
|
||||
"approximate": "приблизительно",
|
||||
"{pct}% cloud": "Облачность {pct}%",
|
||||
"Over {area}": "Область: {area}",
|
||||
@@ -3144,7 +3144,7 @@
|
||||
"About {count} {kind}": "{kind}: около {count}",
|
||||
"At least {count} {kind}": "{kind}: не менее {count}",
|
||||
"{count} {kind}": "{kind}: {count}",
|
||||
"In {area}": "Территория: {area}",
|
||||
"In {area}": "Область: {area}",
|
||||
"Area around {place}": "Окрестности: {place}",
|
||||
"Area around landmark": "Окрестности ориентира",
|
||||
"Outline {place}": "Контур: {place}",
|
||||
@@ -3160,12 +3160,12 @@
|
||||
"Searching OpenStreetMap": "Поиск в OpenStreetMap",
|
||||
"voice area|Area": "Область",
|
||||
"buffer sized to the place": "буферная зона по размеру места",
|
||||
"enclosing mapped area": "охватывающая область на карте",
|
||||
"enclosing mapped area": "область на карте, включающая ориентир",
|
||||
"the map was cleared while looking it up": "карта была очищена во время поиска",
|
||||
"OpenStreetMap coverage varies by place": "Покрытие OpenStreetMap различается в разных местах",
|
||||
"the catalog covers the last 30 days": "каталог охватывает последние 30 дней",
|
||||
"there are more": "есть ещё",
|
||||
"{radius} m around the landmark": "{radius} м вокруг объекта",
|
||||
"there are more": "есть и другие",
|
||||
"{radius} m around the landmark": "{radius} м вокруг ориентира",
|
||||
"{count} parts": {
|
||||
"few": "Частей: {count}",
|
||||
"many": "Частей: {count}",
|
||||
@@ -3184,11 +3184,11 @@
|
||||
"one": "Объединено дублей: {count}",
|
||||
"other": "Объединено дублей: {count}"
|
||||
},
|
||||
"listed the first {limit}": "показаны первые {limit}",
|
||||
"listed the first {limit}": "показаны первые результаты: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "подсчитано по ограничивающему прямоугольнику ({area}), а не по контуру",
|
||||
"Search cancelled": "Поиск отменён",
|
||||
"Sea Marks": "Морские знаки",
|
||||
"Ski Pistes": "Горнолыжные трассы",
|
||||
"Sea Marks": "Навигационные знаки",
|
||||
"Ski Pistes": "Лыжные трассы",
|
||||
"Fly to Tokyo": "Перелети в Токио",
|
||||
"Show live flights": "Покажи рейсы в реальном времени",
|
||||
"Reset to globe view": "Вернись к виду всего глобуса",
|
||||
@@ -3317,5 +3317,12 @@
|
||||
"Open {name} on Acartia": "Открыть {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи",
|
||||
"{days}d": "{days}д",
|
||||
"All markers": "Все метки",
|
||||
"Show markers of every age": "Показать метки любого возраста",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены",
|
||||
"event age filter|All": "Все",
|
||||
"Event age": "Возраст события",
|
||||
"Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены"
|
||||
}
|
||||
+29
-22
@@ -3064,19 +3064,19 @@
|
||||
"Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати",
|
||||
"fire intensity filter|All": "Усі",
|
||||
"Detection confidence": "Достовірність виявлення",
|
||||
"{shown} of {total} shown": "показано {shown} з {total}",
|
||||
"{lower} to {upper}": "від {lower} до {upper}",
|
||||
"{shown} of {total} shown": "Показано {shown} з {total}",
|
||||
"{lower} to {upper}": "Від {lower} до {upper}",
|
||||
"Up to {upper}": "До {upper}",
|
||||
"From {lower}": "Від {lower}",
|
||||
"Informational only · not for flight planning": "Лише для довідки · не для планування польотів",
|
||||
"Unnamed zone": "Зона без назви",
|
||||
"Restricted airspace source unavailable": "Джерело даних про обмежений повітряний простір недоступне",
|
||||
"Unnamed zone": "Без назви",
|
||||
"Restricted airspace source unavailable": "Джерело даних про зони обмеження польотів недоступне",
|
||||
"Prohibited (P)": "Заборонена зона (P)",
|
||||
"Restricted (R)": "Зона обмеження (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Лише заборонені зони та зони обмеження польотів FAA (повітряний простір особливого використання), без UAS Facility Map і обмежень польотів БпЛА. Лише для довідки — не замінює офіційні інструменти планування польотів БпЛА (B4UFLY / LAANC).",
|
||||
"Prohibited": "Заборонена зона",
|
||||
"Restricted": "Зона обмеження",
|
||||
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження",
|
||||
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження польотів",
|
||||
"Malformed cyclone snapshot": "Некоректний знімок циклонів",
|
||||
"Cyclone request timed out": "Минув час очікування запиту циклонів",
|
||||
"scene|PLAY": "СТАРТ",
|
||||
@@ -3117,7 +3117,7 @@
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||
"Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)",
|
||||
"OSM Places": "Місця OSM",
|
||||
"state or province": "штат або провінція",
|
||||
"state or province": "область, штат або провінція",
|
||||
"county or district": "округ або район",
|
||||
"neighbourhood": "район міста",
|
||||
"natural region": "природний регіон",
|
||||
@@ -3148,9 +3148,9 @@
|
||||
"Area around {place}": "Околиці: {place}",
|
||||
"Area around landmark": "Околиці орієнтира",
|
||||
"Outline {place}": "Контур: {place}",
|
||||
"Outline area": "Контур області",
|
||||
"Outline area": "Контур території",
|
||||
"Find {place}": "Пошук: {place}",
|
||||
"Find area": "Знайти область",
|
||||
"Find area": "Знайти територію",
|
||||
"Find recent imagery": "Знайти свіжі знімки",
|
||||
"Find {what}": "Пошук: {what}",
|
||||
"Find places": "Знайти місця",
|
||||
@@ -3158,14 +3158,14 @@
|
||||
"Searching imagery catalog": "Пошук у каталозі знімків",
|
||||
"Loading imagery": "Завантаження знімків",
|
||||
"Searching OpenStreetMap": "Пошук в OpenStreetMap",
|
||||
"voice area|Area": "Область",
|
||||
"voice area|Area": "Територія",
|
||||
"buffer sized to the place": "буферна зона за розміром місця",
|
||||
"enclosing mapped area": "охопна область на карті",
|
||||
"enclosing mapped area": "нанесений контур, що охоплює орієнтир",
|
||||
"the map was cleared while looking it up": "карту було очищено під час пошуку",
|
||||
"OpenStreetMap coverage varies by place": "Покриття OpenStreetMap відрізняється залежно від місця",
|
||||
"OpenStreetMap coverage varies by place": "покриття OpenStreetMap різниться залежно від місця",
|
||||
"the catalog covers the last 30 days": "каталог охоплює останні 30 днів",
|
||||
"there are more": "є ще",
|
||||
"{radius} m around the landmark": "{radius} м навколо об’єкта",
|
||||
"there are more": "є й інші",
|
||||
"{radius} m around the landmark": "{radius} м навколо орієнтира",
|
||||
"{count} parts": {
|
||||
"few": "Частин: {count}",
|
||||
"many": "Частин: {count}",
|
||||
@@ -3179,16 +3179,16 @@
|
||||
"other": "Намальовано частин: {drawn} з {total}"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"few": "Об’єднано дублікатів: {count}",
|
||||
"many": "Об’єднано дублікатів: {count}",
|
||||
"one": "Об’єднано дублікатів: {count}",
|
||||
"other": "Об’єднано дублікатів: {count}"
|
||||
"one": "Обʼєднано дублікатів: {count}",
|
||||
"few": "Обʼєднано дублікатів: {count}",
|
||||
"many": "Обʼєднано дублікатів: {count}",
|
||||
"other": "Обʼєднано дублікатів: {count}"
|
||||
},
|
||||
"listed the first {limit}": "показано перші {limit}",
|
||||
"listed the first {limit}": "показано перші результати: {limit}",
|
||||
"counted over the bounding box of {area}, not its outline": "підраховано за обмежувальним прямокутником ({area}), а не за контуром",
|
||||
"Search cancelled": "Пошук скасовано",
|
||||
"Sea Marks": "Морські знаки",
|
||||
"Ski Pistes": "Гірськолижні траси",
|
||||
"Sea Marks": "Навігаційні знаки",
|
||||
"Ski Pistes": "Лижні траси",
|
||||
"Fly to Tokyo": "Полети до Токіо",
|
||||
"Show live flights": "Покажи рейси онлайн",
|
||||
"Reset to globe view": "Поверни вигляд глобуса",
|
||||
@@ -3317,5 +3317,12 @@
|
||||
"Open {name} on Acartia": "Відкрити {name} в Acartia",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису",
|
||||
"{days}d": "{days}д",
|
||||
"All markers": "Усі позначки",
|
||||
"Show markers of every age": "Показати позначки будь-якого віку",
|
||||
"Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено",
|
||||
"event age filter|All": "Усі",
|
||||
"Event age": "Вік події",
|
||||
"Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено"
|
||||
}
|
||||
+28
-21
@@ -2851,26 +2851,26 @@
|
||||
},
|
||||
"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 在 GitHub 发布这些 KMZ 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "标称+",
|
||||
"fire confidence filter|Nominal+": "中+",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低",
|
||||
"Nominal and high confidence detections": "标称和高置信度的检测",
|
||||
"High confidence detections only": "仅高置信度的检测",
|
||||
"Fire intensity (FRP)": "火势强度 (FRP)",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有火点探测——许多强度最高的火点被评为低置信度",
|
||||
"Nominal and high confidence detections": "中、高置信度的火点探测",
|
||||
"High confidence detections only": "仅高置信度的火点探测",
|
||||
"Fire intensity (FRP)": "火势强度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "置信度",
|
||||
"Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)",
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "检测置信度",
|
||||
"Detection confidence": "探测置信度",
|
||||
"{shown} of {total} shown": "显示 {shown} / {total}",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
"From {lower}": "最低 {lower}",
|
||||
"Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划",
|
||||
"Unnamed zone": "未命名区域",
|
||||
"Restricted airspace source unavailable": "受限空域数据源不可用",
|
||||
"Prohibited (P)": "禁区 (P)",
|
||||
"Restricted (R)": "限制区 (R)",
|
||||
"Unnamed zone": "未命名空域",
|
||||
"Restricted airspace source unavailable": "禁区与限制区空域数据源不可用",
|
||||
"Prohibited (P)": "禁区(P)",
|
||||
"Restricted (R)": "限制区(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "仅包含 FAA 禁区和限制区特殊用途空域,不含 UAS Facility Map 和无人机飞行限制。仅供参考,不能替代官方无人机飞行规划工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁区",
|
||||
"Restricted": "限制区",
|
||||
@@ -2916,7 +2916,7 @@
|
||||
"Not drawn: {parts}": "未绘制:{parts}",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "云量 {pct}%",
|
||||
"Over {area}": "{area} 上空",
|
||||
"Over {area}": "覆盖 {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "匹配影像:{count}/{total}"
|
||||
},
|
||||
@@ -2931,36 +2931,36 @@
|
||||
"In {area}": "位于 {area}",
|
||||
"Area around {place}": "{place} 周边区域",
|
||||
"Area around landmark": "地标周边区域",
|
||||
"Outline {place}": "勾勒 {place} 轮廓",
|
||||
"Outline area": "勾勒区域轮廓",
|
||||
"Outline {place}": "绘制 {place} 轮廓",
|
||||
"Outline area": "绘制区域轮廓",
|
||||
"Find {place}": "查找 {place}",
|
||||
"Find area": "查找区域",
|
||||
"Find recent imagery": "查找近期影像",
|
||||
"Find {what}": "查找 {what}",
|
||||
"Find places": "查找地点",
|
||||
"Drawing outline": "正在勾勒轮廓",
|
||||
"Drawing outline": "正在地图上绘制轮廓",
|
||||
"Searching imagery catalog": "正在搜索影像目录",
|
||||
"Loading imagery": "正在加载影像",
|
||||
"Searching OpenStreetMap": "正在搜索 OpenStreetMap",
|
||||
"voice area|Area": "区域",
|
||||
"buffer sized to the place": "按地点大小设置的缓冲区",
|
||||
"enclosing mapped area": "包含该地点的地图区域",
|
||||
"enclosing mapped area": "包含该地标的地图标注区域",
|
||||
"the map was cleared while looking it up": "查找期间地图已清空",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆盖程度因地而异",
|
||||
"the catalog covers the last 30 days": "目录涵盖最近 30 天",
|
||||
"there are more": "还有更多",
|
||||
"{radius} m around the landmark": "地标周围 {radius} 米",
|
||||
"{radius} m around the landmark": "地标周围 {radius} m",
|
||||
"{count} parts": {
|
||||
"other": "部分数:{count}"
|
||||
"other": "{count} 个部分"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "已绘制部分:{drawn}/{total}"
|
||||
"other": "已绘制 {drawn}/{total} 个部分"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "已合并重复项:{count}"
|
||||
},
|
||||
"listed the first {limit}": "已列出前 {limit} 个",
|
||||
"counted over the bounding box of {area}, not its outline": "按外接矩形({area})计数,而非其轮廓",
|
||||
"counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形计数,而非其轮廓",
|
||||
"Search cancelled": "搜索已取消",
|
||||
"Sea Marks": "航标",
|
||||
"Ski Pistes": "滑雪道",
|
||||
@@ -3092,5 +3092,12 @@
|
||||
"Open {name} on Acartia": "在 Acartia 上打开 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权",
|
||||
"{days}d": "{days}天",
|
||||
"All markers": "所有标记",
|
||||
"Show markers of every age": "显示所有时间的标记",
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件时间",
|
||||
"Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记"
|
||||
}
|
||||
@@ -2854,16 +2854,16 @@
|
||||
"route proxy error": "路線代理出錯",
|
||||
"{lower} to {upper}": "{lower} 至 {upper}",
|
||||
"Up to {upper}": "最高 {upper}",
|
||||
"From {lower}": "自 {lower} 起",
|
||||
"From {lower}": "最低 {lower}",
|
||||
"Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃",
|
||||
"Unnamed zone": "未命名區域",
|
||||
"Restricted airspace source unavailable": "限制空域資料來源無法使用",
|
||||
"Prohibited (P)": "禁航區 (P)",
|
||||
"Restricted (R)": "限航區 (R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁航區與限航區特殊用途空域,不含 UAS Facility Map 與無人機飛行限制。僅供參考,不能取代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁航區",
|
||||
"Restricted": "限航區",
|
||||
"Prohibited & Restricted Airspace": "禁航區與限航區空域",
|
||||
"Unnamed zone": "未命名空域",
|
||||
"Restricted airspace source unavailable": "禁區與限制區空域數據源不可用",
|
||||
"Prohibited (P)": "禁區(P)",
|
||||
"Restricted (R)": "限制區(R)",
|
||||
"FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "僅包含 FAA 禁區和限制區特殊用途空域,不含 UAS Facility Map 和無人機飛行限制。僅供參考,不能替代官方無人機飛行規劃工具(B4UFLY / LAANC)。",
|
||||
"Prohibited": "禁區",
|
||||
"Restricted": "限制區",
|
||||
"Prohibited & Restricted Airspace": "禁區與限制區空域",
|
||||
"AUTHORING": "創作",
|
||||
"Create and edit scenes": "創建和編輯場景",
|
||||
"SCENE": "場景",
|
||||
@@ -2890,17 +2890,17 @@
|
||||
"Left panels": "左側面板",
|
||||
"Right panels": "右側面板",
|
||||
"fire confidence filter|All": "全部",
|
||||
"fire confidence filter|Nominal+": "標稱+",
|
||||
"fire confidence filter|Nominal+": "中+",
|
||||
"fire confidence filter|High": "高",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低",
|
||||
"Nominal and high confidence detections": "標稱和高置信度的檢測",
|
||||
"High confidence detections only": "僅高置信度的檢測",
|
||||
"Fire intensity (FRP)": "火勢強度 (FRP)",
|
||||
"Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度",
|
||||
"Nominal and high confidence detections": "中、高置信度的火點探測",
|
||||
"High confidence detections only": "僅高置信度的火點探測",
|
||||
"Fire intensity (FRP)": "火勢強度(FRP)",
|
||||
"≥ {value} MW": "≥ {value} MW",
|
||||
"Confidence": "置信度",
|
||||
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
|
||||
"fire intensity filter|All": "全部",
|
||||
"Detection confidence": "檢測置信度",
|
||||
"Detection confidence": "探測置信度",
|
||||
"{shown} of {total} shown": "顯示 {shown} / {total}",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
@@ -2916,7 +2916,7 @@
|
||||
"Not drawn: {parts}": "未繪製:{parts}",
|
||||
"approximate": "近似",
|
||||
"{pct}% cloud": "雲量 {pct}%",
|
||||
"Over {area}": "{area} 上空",
|
||||
"Over {area}": "覆蓋 {area}",
|
||||
"{count} of {total} acquisitions match": {
|
||||
"other": "匹配影像:{count}/{total}"
|
||||
},
|
||||
@@ -2931,36 +2931,36 @@
|
||||
"In {area}": "位於 {area}",
|
||||
"Area around {place}": "{place} 周邊區域",
|
||||
"Area around landmark": "地標周邊區域",
|
||||
"Outline {place}": "勾勒 {place} 輪廓",
|
||||
"Outline area": "勾勒區域輪廓",
|
||||
"Outline {place}": "繪製 {place} 輪廓",
|
||||
"Outline area": "繪製區域輪廓",
|
||||
"Find {place}": "查找 {place}",
|
||||
"Find area": "查找區域",
|
||||
"Find recent imagery": "查找近期影像",
|
||||
"Find {what}": "查找 {what}",
|
||||
"Find places": "查找地點",
|
||||
"Drawing outline": "正在勾勒輪廓",
|
||||
"Drawing outline": "正在地圖上繪製輪廓",
|
||||
"Searching imagery catalog": "正在搜索影像目錄",
|
||||
"Loading imagery": "正在加載影像",
|
||||
"Searching OpenStreetMap": "正在搜索 OpenStreetMap",
|
||||
"voice area|Area": "區域",
|
||||
"buffer sized to the place": "按地點大小設置的緩衝區",
|
||||
"enclosing mapped area": "包含該地點的地圖區域",
|
||||
"enclosing mapped area": "包含該地標的地圖標註區域",
|
||||
"the map was cleared while looking it up": "查找期間地圖已清空",
|
||||
"OpenStreetMap coverage varies by place": "OpenStreetMap 的覆蓋程度因地而異",
|
||||
"the catalog covers the last 30 days": "目錄涵蓋最近 30 天",
|
||||
"there are more": "還有更多",
|
||||
"{radius} m around the landmark": "地標周圍 {radius} 米",
|
||||
"{radius} m around the landmark": "地標周圍 {radius} m",
|
||||
"{count} parts": {
|
||||
"other": "部分數:{count}"
|
||||
"other": "{count} 個部分"
|
||||
},
|
||||
"{drawn} of {total} parts drawn": {
|
||||
"other": "已繪製部分:{drawn}/{total}"
|
||||
"other": "已繪製 {drawn}/{total} 個部分"
|
||||
},
|
||||
"{count} duplicate mappings merged": {
|
||||
"other": "已合併重複項:{count}"
|
||||
},
|
||||
"listed the first {limit}": "已列出前 {limit} 個",
|
||||
"counted over the bounding box of {area}, not its outline": "按外接矩形({area})計數,而非其輪廓",
|
||||
"counted over the bounding box of {area}, not its outline": "按 {area} 的外接矩形計數,而非其輪廓",
|
||||
"Search cancelled": "搜索已取消",
|
||||
"Sea Marks": "航標",
|
||||
"Ski Pistes": "滑雪道",
|
||||
@@ -3092,5 +3092,12 @@
|
||||
"Open {name} on Acartia": "在 Acartia 上打開 {name}",
|
||||
"iNaturalist": "iNaturalist",
|
||||
"iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權",
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權"
|
||||
"iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權",
|
||||
"{days}d": "{days}天",
|
||||
"All markers": "所有標記",
|
||||
"Show markers of every age": "顯示所有時間的標記",
|
||||
"Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記",
|
||||
"event age filter|All": "全部",
|
||||
"Event age": "事件時間",
|
||||
"Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記"
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export function createCyclonesLayer({
|
||||
feed,
|
||||
hitTestOverlay = hitTestWorldOverlay,
|
||||
overlayHost,
|
||||
context = null,
|
||||
cesium = Cesium,
|
||||
createRendering = createCycloneRendering,
|
||||
matchMedia = globalThis.matchMedia?.bind(globalThis),
|
||||
@@ -82,6 +83,7 @@ export function createCyclonesLayer({
|
||||
selectedId = null,
|
||||
selectionIntent = 'auto',
|
||||
navigationGeneration = 0,
|
||||
selectedContextStormId = null,
|
||||
clickHandler = null,
|
||||
removeClickCapture = null;
|
||||
let enabled = false,
|
||||
@@ -92,6 +94,62 @@ export function createCyclonesLayer({
|
||||
const notify = () => listener?.();
|
||||
const selected = () =>
|
||||
snapshot?.storms.find((storm) => storm.id === selectedId) || null;
|
||||
function clearSelectedContext({ evicted = false } = {}) {
|
||||
if (selectedContextStormId) {
|
||||
if (!evicted)
|
||||
context.clearSelectedEntityContextForLayer('weather-cyclones');
|
||||
context.removeEntityContextsForLayer('weather-cyclones');
|
||||
}
|
||||
selectedContextStormId = null;
|
||||
}
|
||||
|
||||
// Callers publish only while selectedId names a storm in the snapshot.
|
||||
function publishSelectedContext({ announce = false } = {}) {
|
||||
const storm = selected();
|
||||
if (!context) return;
|
||||
const entity = {};
|
||||
const contextId = `cyclone:${storm.id}`;
|
||||
context.registerEntityContext(entity, {
|
||||
id: contextId,
|
||||
layerId: 'weather-cyclones',
|
||||
layerName: 'Cyclone advisories',
|
||||
source: 'NOAA NHC / CPHC',
|
||||
label: storm.name,
|
||||
latitude: storm.position.latitude,
|
||||
longitude: storm.position.longitude,
|
||||
// At most 12 properties reach the voice summary; keep the list short.
|
||||
properties: {
|
||||
classification: storm.classification,
|
||||
classificationName: Object.hasOwn(
|
||||
CLASSIFICATION_NAMES,
|
||||
storm.classification,
|
||||
)
|
||||
? CLASSIFICATION_NAMES[storm.classification]
|
||||
: undefined,
|
||||
basin: storm.basin,
|
||||
advisoryNumber: storm.advisoryNumber,
|
||||
issuedAt: storm.issuedAt,
|
||||
positionAt: storm.positionAt,
|
||||
windKt: storm.windKt,
|
||||
pressureHpa: storm.pressureHpa,
|
||||
// Flat scalars: the voice context summary drops nested objects.
|
||||
movementDirectionDegrees: storm.movement?.directionDegrees,
|
||||
movementSpeedKt: storm.movement?.speedKt,
|
||||
advisoryUrl: storm.advisoryUrl,
|
||||
// Only while the proxy serves its cache because NHC is unreachable.
|
||||
staleCacheFetchedAt:
|
||||
snapshot.stale && Number.isFinite(snapshot.fetchedAt)
|
||||
? new Date(snapshot.fetchedAt).toISOString()
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
selectedContextStormId = storm.id;
|
||||
if (announce) context.selectEntityContext(entity);
|
||||
context.removeEntityContextsForLayer('weather-cyclones', {
|
||||
retainIds: new Set([contextId]),
|
||||
});
|
||||
}
|
||||
|
||||
function select(id) {
|
||||
if (selectedId !== id) ++navigationGeneration;
|
||||
selectedId = id;
|
||||
@@ -236,6 +294,7 @@ export function createCyclonesLayer({
|
||||
selectedId = null;
|
||||
selectionIntent = 'auto';
|
||||
++navigationGeneration;
|
||||
clearSelectedContext();
|
||||
rendering?.clear();
|
||||
},
|
||||
async update(_viewer, { signal } = {}) {
|
||||
@@ -259,6 +318,7 @@ export function createCyclonesLayer({
|
||||
snapshot = next;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
clearSelectedContext({ evicted: true });
|
||||
error = next.reason || ADVISORIES_UNAVAILABLE;
|
||||
return true;
|
||||
}
|
||||
@@ -281,6 +341,10 @@ export function createCyclonesLayer({
|
||||
: snapshot.storms[0]?.id || null,
|
||||
);
|
||||
else rendering.setSelection(selectedId);
|
||||
if (selectedContextStormId) {
|
||||
if (selectedId === selectedContextStormId) publishSelectedContext();
|
||||
else clearSelectedContext({ evicted: true });
|
||||
}
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (controller.signal.aborted || request !== controller) return false;
|
||||
@@ -290,6 +354,7 @@ export function createCyclonesLayer({
|
||||
snapshot = null;
|
||||
selectedId = null;
|
||||
++navigationGeneration;
|
||||
clearSelectedContext({ evicted: true });
|
||||
return true;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
@@ -305,6 +370,7 @@ export function createCyclonesLayer({
|
||||
if (params.clear === true || params.stormId === null) {
|
||||
selectionIntent = 'cleared';
|
||||
select(null);
|
||||
clearSelectedContext();
|
||||
notify();
|
||||
} else if (
|
||||
typeof params.stormId === 'string' &&
|
||||
@@ -312,6 +378,7 @@ export function createCyclonesLayer({
|
||||
) {
|
||||
selectionIntent = 'user';
|
||||
select(params.stormId);
|
||||
publishSelectedContext({ announce: true });
|
||||
notify();
|
||||
}
|
||||
const storm = selected();
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
isPointerFree,
|
||||
} from '../../data/inputOwnership.js';
|
||||
import { isOwnedByOtherLayer } from '../../data/pickRegistry.js';
|
||||
import * as contextStore from '../../data/contextStore.js';
|
||||
import { createGevActionRunner } from '../../voice/gevActions.js';
|
||||
import * as Cesium from 'cesium';
|
||||
|
||||
const time = '2026-09-16T03:00:00.000Z';
|
||||
const storm = (id = 'ep152026') => ({
|
||||
@@ -338,7 +341,7 @@ test('a click on a storm card or lead-hour label selects that storm without pick
|
||||
});
|
||||
function harness(
|
||||
feed = { getSnapshot: async () => snapshot() },
|
||||
{ reducedMotion = false } = {},
|
||||
{ reducedMotion = false, context = null } = {},
|
||||
) {
|
||||
const applied = [],
|
||||
navigation = [],
|
||||
@@ -368,6 +371,7 @@ function harness(
|
||||
};
|
||||
const layer = createCyclonesLayer({
|
||||
feed,
|
||||
context,
|
||||
createRendering: () => rendering,
|
||||
matchMedia: () => ({ matches: reducedMotion }),
|
||||
openLink: (url) => opened.push(url),
|
||||
@@ -401,6 +405,256 @@ function harness(
|
||||
},
|
||||
};
|
||||
}
|
||||
// Real shared-context store on a stand-in window; the voice runner reads it.
|
||||
async function withSharedContext(t, body) {
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const host = new EventTarget();
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: host,
|
||||
});
|
||||
const layers = [];
|
||||
t.after(() => {
|
||||
try {
|
||||
for (const layer of layers) layer.destroy();
|
||||
} finally {
|
||||
if (previousWindow)
|
||||
Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else delete globalThis.window;
|
||||
}
|
||||
});
|
||||
const events = [];
|
||||
host.addEventListener('gev:entity-selected', ({ detail }) =>
|
||||
events.push(['selected', detail.id]),
|
||||
);
|
||||
host.addEventListener('gev:entity-selection-cleared', ({ detail }) =>
|
||||
events.push(['cleared', detail.layerId, detail.reason]),
|
||||
);
|
||||
// The voice runner bounds its basemap wait with the window timers.
|
||||
Object.assign(host, { setTimeout, clearTimeout });
|
||||
const position = Cesium.Cartesian3.fromDegrees(-125.8, 15.5, 5_000_000);
|
||||
const runner = createGevActionRunner({
|
||||
viewer: {
|
||||
clock: { onTick: { addEventListener: () => () => {} } },
|
||||
scene: { canvas: { addEventListener() {}, removeEventListener() {} } },
|
||||
camera: {
|
||||
moveEnd: { addEventListener() {} },
|
||||
positionWC: position,
|
||||
positionCartographic: Cesium.Cartographic.fromCartesian(position),
|
||||
heading: 0,
|
||||
pitch: 0,
|
||||
},
|
||||
},
|
||||
styleManager: {},
|
||||
dataManager: {
|
||||
layers: new Map(),
|
||||
isEnabled: (id) => id === 'weather-cyclones',
|
||||
getAll: () => [],
|
||||
},
|
||||
});
|
||||
const selectedProperties = async () => {
|
||||
const result = await runner('get_entity_context', { scope: 'selected' });
|
||||
assert.equal(result.scope, 'selected');
|
||||
return result.selected.properties;
|
||||
};
|
||||
const store = contextStore.getContextStore();
|
||||
const open = (feed) => {
|
||||
const h = harness(feed, { context: contextStore });
|
||||
layers.push(h.layer);
|
||||
return h;
|
||||
};
|
||||
await body({ events, selectedProperties, store, open });
|
||||
}
|
||||
|
||||
test('shared cyclone context distinguishes switches, deliberate clears and feed eviction', async (t) => {
|
||||
await withSharedContext(
|
||||
t,
|
||||
async ({ events, selectedProperties, store, open }) => {
|
||||
let next = snapshot([
|
||||
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
|
||||
{
|
||||
...storm('ep162026'),
|
||||
movement: { directionDegrees: 0, speedKt: 10 },
|
||||
},
|
||||
]);
|
||||
const h = open({ getSnapshot: async () => next });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
assert.deepEqual(
|
||||
events,
|
||||
[],
|
||||
'the automatic first-storm row selection must not claim shared context',
|
||||
);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
assert.equal(store.entities.get('cyclone:ep152026').label, 'Fifteen-E');
|
||||
let properties = await selectedProperties();
|
||||
assert.equal(properties.classification, 'PTC');
|
||||
assert.equal(properties.classificationName, 'Post-tropical cyclone');
|
||||
assert.equal(properties.advisoryNumber, '10');
|
||||
assert.equal(
|
||||
properties.movementDirectionDegrees,
|
||||
'315',
|
||||
'selected storm movement should survive voice summarization',
|
||||
);
|
||||
assert.equal(properties.movementSpeedKt, '12');
|
||||
assert.equal(
|
||||
properties.advisoryUrl,
|
||||
'https://www.nhc.noaa.gov/text/MIATCMEP5.shtml',
|
||||
);
|
||||
assert.equal(properties.staleCacheFetchedAt, undefined);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
properties = await selectedProperties();
|
||||
assert.equal(
|
||||
properties.movementDirectionDegrees,
|
||||
'0',
|
||||
'a due-north heading is a value, not a missing one',
|
||||
);
|
||||
assert.equal(properties.movementSpeedKt, '10');
|
||||
assert.deepEqual(events, [
|
||||
['selected', 'cyclone:ep152026'],
|
||||
['selected', 'cyclone:ep162026'],
|
||||
]);
|
||||
assert.deepEqual([...store.entities.keys()], ['cyclone:ep162026']);
|
||||
|
||||
next = snapshot([
|
||||
{ ...storm(), movement: { directionDegrees: 315, speedKt: 12 } },
|
||||
{ ...storm('ep162026'), movement: { directionDegrees: 0, speedKt: 0 } },
|
||||
]);
|
||||
await h.layer.update();
|
||||
assert.equal(events.length, 2, 'refresh must not announce selection');
|
||||
properties = await selectedProperties();
|
||||
assert.equal(
|
||||
properties.movementSpeedKt,
|
||||
'0',
|
||||
'stationary is a value, refreshed into the selected record',
|
||||
);
|
||||
|
||||
h.layer.setParams({ clear: true });
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'deliberate',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep162026' });
|
||||
next = snapshot([storm()]);
|
||||
await h.layer.update();
|
||||
assert.deepEqual(
|
||||
events.at(-1),
|
||||
['cleared', 'weather-cyclones', 'evicted'],
|
||||
'a vanished user-selected storm must clear instead of transferring context to the fallback row',
|
||||
);
|
||||
assert.equal(store.entities.size, 0);
|
||||
assert.equal(store.selectedEntityId, null);
|
||||
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
h.layer.disable();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'deliberate',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('shared cyclone context is evicted when the source fails and marks a stale cache', async (t) => {
|
||||
await withSharedContext(
|
||||
t,
|
||||
async ({ events, selectedProperties, store, open }) => {
|
||||
let next = snapshot([{ ...storm(), classification: 'XX' }]);
|
||||
let fail = false;
|
||||
const h = open({
|
||||
getSnapshot: async () => {
|
||||
if (fail) throw new Error('Network unavailable');
|
||||
return next;
|
||||
},
|
||||
});
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
let properties = await selectedProperties();
|
||||
assert.equal(properties.classification, 'XX');
|
||||
assert.equal(
|
||||
properties.classificationName,
|
||||
undefined,
|
||||
'an unknown code is not given a guessed meaning',
|
||||
);
|
||||
|
||||
next = { ...snapshot(), stale: true };
|
||||
await h.layer.update();
|
||||
properties = await selectedProperties();
|
||||
assert.equal(properties.staleCacheFetchedAt, time);
|
||||
assert.equal(events.length, 1, 'refresh must not announce selection');
|
||||
|
||||
next = {
|
||||
...snapshot([]),
|
||||
unavailable: true,
|
||||
reason: 'Cyclone data unavailable',
|
||||
};
|
||||
await h.layer.update();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'evicted',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
|
||||
next = snapshot();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
assert.equal(store.selectedEntityId, 'cyclone:ep152026');
|
||||
fail = true;
|
||||
await h.layer.update();
|
||||
assert.deepEqual(events.at(-1), [
|
||||
'cleared',
|
||||
'weather-cyclones',
|
||||
'evicted',
|
||||
]);
|
||||
assert.equal(store.entities.size, 0);
|
||||
assert.equal(store.selectedEntityId, null);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('cyclone clears leave another layer selection and its records alone', async (t) => {
|
||||
await withSharedContext(t, async ({ events, store, open }) => {
|
||||
const h = open({ getSnapshot: async () => snapshot() });
|
||||
h.layer.enable();
|
||||
await h.layer.update();
|
||||
h.layer.setParams({ stormId: 'ep152026' });
|
||||
const foreign = {};
|
||||
contextStore.registerEntityContext(foreign, {
|
||||
id: 'flight:abc123',
|
||||
layerId: 'flights',
|
||||
label: 'Foreign',
|
||||
});
|
||||
contextStore.selectEntityContext(foreign);
|
||||
const before = events.length;
|
||||
|
||||
h.layer.setParams({ clear: true });
|
||||
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||
assert.ok(store.entities.has('flight:abc123'));
|
||||
assert.equal(store.entities.has('cyclone:ep152026'), false);
|
||||
assert.equal(
|
||||
events.length,
|
||||
before,
|
||||
'no clear is reported for a foreign selection',
|
||||
);
|
||||
|
||||
// A cyclone clear does not report a dangling foreign selection as evicted.
|
||||
store.entities.delete('flight:abc123');
|
||||
h.layer.disable();
|
||||
assert.equal(store.selectedEntityId, 'flight:abc123');
|
||||
assert.equal(events.length, before);
|
||||
});
|
||||
});
|
||||
|
||||
test('refresh preserves selection intent, including explicit clears and missing storms', async () => {
|
||||
let next = snapshot([storm(), storm('ep162026')]);
|
||||
const h = harness({ getSnapshot: async () => next });
|
||||
|
||||
+190
-57
@@ -16,7 +16,15 @@ import {
|
||||
} from '../../i18n/index.js';
|
||||
import { createHorizonCull } from '../streetLevel/view.js';
|
||||
import { readGhostMapsKml } from './kml.js';
|
||||
import { fieldLabel, parseDescriptionFields, wrapCardText } from './records.js';
|
||||
import {
|
||||
fieldLabel,
|
||||
GHOSTMAPS_AGE_OPTIONS,
|
||||
GHOSTMAPS_DEFAULT_AGE_DAYS,
|
||||
ghostMapsEventDate,
|
||||
isGhostMapsEventExpired,
|
||||
parseDescriptionFields,
|
||||
wrapCardText,
|
||||
} from './records.js';
|
||||
export * from './catalog.js';
|
||||
export * from './records.js';
|
||||
export { readGhostMapsKml } from './kml.js';
|
||||
@@ -26,6 +34,7 @@ export const GHOSTMAPS_LAYER_ID = 's2-ghostmaps';
|
||||
export const GHOSTMAPS_OVERLAY_SOURCE_ID = 's2-ghostmaps';
|
||||
const PICK_PREFIX = 'ghostmaps:';
|
||||
const STORAGE_KEY = 'gev.ghostmaps.products';
|
||||
const AGE_STORAGE_KEY = 'gev.ghostmaps.ageDays';
|
||||
const ACCENT = '#c8ff00';
|
||||
const MAX_ICON_BYTES = 512 * 1024;
|
||||
const CARD_HOST_OPTIONS = Object.freeze({
|
||||
@@ -82,6 +91,19 @@ function writeStoredSelection(storage, selected) {
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredAge(storage) {
|
||||
try {
|
||||
const raw = storage?.getItem(AGE_STORAGE_KEY);
|
||||
if (raw !== null && raw !== undefined) {
|
||||
const value = JSON.parse(raw);
|
||||
if (GHOSTMAPS_AGE_OPTIONS.includes(value)) return value;
|
||||
}
|
||||
} catch {
|
||||
/* unavailable or invalid storage uses the default */
|
||||
}
|
||||
return GHOSTMAPS_DEFAULT_AGE_DAYS;
|
||||
}
|
||||
|
||||
/** A round marker for placemarks whose icon is missing or remote. */
|
||||
const markerCache = new Map();
|
||||
function markerImage(color) {
|
||||
@@ -137,6 +159,7 @@ export function createGhostMapsLayer({
|
||||
pointer = null,
|
||||
openExternal = null,
|
||||
storage = defaultStorage(),
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
if (typeof source?.getCatalog !== 'function')
|
||||
throw new TypeError('GhostMaps layer requires a catalog source');
|
||||
@@ -154,7 +177,9 @@ export function createGhostMapsLayer({
|
||||
let _selectedLink = null;
|
||||
let _lifetime = new AbortController();
|
||||
let _selection = readStoredSelection(storage);
|
||||
/** productId → {version, dataSource, urls, count, features} */
|
||||
let _ageDays = readStoredAge(storage);
|
||||
let _expiryTimer = null;
|
||||
/** productId → {version, dataSource, urls, features: Map<index, record>, pins} */
|
||||
const _loaded = new Map();
|
||||
/** productId → in-flight version */
|
||||
const _loading = new Map();
|
||||
@@ -183,17 +208,40 @@ export function createGhostMapsLayer({
|
||||
return (_catalog?.products || []).filter(wanted).map(({ id }) => id);
|
||||
}
|
||||
|
||||
function unload(id) {
|
||||
function unload(id, keepSelection = false) {
|
||||
const item = _loaded.get(id);
|
||||
if (!item) return;
|
||||
_loaded.delete(id);
|
||||
_viewer?.dataSources?.remove(item.dataSource, true);
|
||||
item.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
if (_selectedKey?.startsWith(`${id}:`)) clearSelection();
|
||||
if (!keepSelection && _selectedKey?.startsWith(`${id}:`)) clearSelection();
|
||||
}
|
||||
|
||||
// Remove expired entities, rather than hiding them: horizon culling must
|
||||
// never make an old pin visible again. Map keys keep surviving picks stable.
|
||||
function expireEvents() {
|
||||
const at = now();
|
||||
let changed = false;
|
||||
for (const [productId, item] of _loaded) {
|
||||
for (const [index, feature] of item.features) {
|
||||
if (!isGhostMapsEventExpired(feature.dateMs, at, _ageDays)) continue;
|
||||
for (const entity of feature.entities)
|
||||
item.dataSource.entities.remove(entity);
|
||||
item.features.delete(index);
|
||||
if (_selectedKey === `${productId}:${index}`) clearSelection();
|
||||
changed = true;
|
||||
}
|
||||
item.pins = item.pins.filter((pin) => item.features.has(pin.index));
|
||||
}
|
||||
if (changed) {
|
||||
notifyRow();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one product's data source off-scene, then swap it in. */
|
||||
async function load(product, signal) {
|
||||
const ageDays = _ageDays;
|
||||
const bytes = await source.getFile(product.id, product.version, { signal });
|
||||
signal.throwIfAborted();
|
||||
const entries = await readZipEntries(bytes);
|
||||
@@ -223,12 +271,16 @@ export function createGhostMapsLayer({
|
||||
const dataSource = new Cesium.CustomDataSource(
|
||||
`${GHOSTMAPS_LAYER_ID}:${product.id}`,
|
||||
);
|
||||
const features = [];
|
||||
const features = new Map();
|
||||
const pins = [];
|
||||
const at = now();
|
||||
dataSource.entities.suspendEvents();
|
||||
parsed.features.forEach((feature, index) => {
|
||||
const dateMs = ghostMapsEventDate(feature.description);
|
||||
if (isGhostMapsEventExpired(dateMs, at, ageDays)) return;
|
||||
const key = `${product.id}:${index}`;
|
||||
const { style } = feature;
|
||||
const entities = [];
|
||||
feature.geometries.forEach((geometry, part) => {
|
||||
const id = `${PICK_PREFIX}${key}:${part}`;
|
||||
if (geometry.type === 'point') {
|
||||
@@ -255,7 +307,9 @@ export function createGhostMapsLayer({
|
||||
disableDepthTestDistance: Number.POSITIVE_INFINITY,
|
||||
},
|
||||
});
|
||||
entities.push(entity);
|
||||
pins.push({
|
||||
index,
|
||||
position,
|
||||
get show() {
|
||||
return entity.show;
|
||||
@@ -265,61 +319,76 @@ export function createGhostMapsLayer({
|
||||
},
|
||||
});
|
||||
} else if (geometry.type === 'line') {
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polyline: {
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
geometry.positions.flat(),
|
||||
),
|
||||
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const ring = (points) =>
|
||||
Cesium.Cartesian3.fromDegreesArray(points.flat());
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
ring(geometry.outer),
|
||||
geometry.holes.map(
|
||||
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
|
||||
),
|
||||
),
|
||||
material:
|
||||
style.fill === false
|
||||
? Cesium.Color.TRANSPARENT
|
||||
: cesiumColor(
|
||||
style.polyColor,
|
||||
color,
|
||||
style.polyColor ? undefined : 0.25,
|
||||
),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
});
|
||||
if (style.outline !== false)
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id: `${id}:outline`,
|
||||
id,
|
||||
polyline: {
|
||||
positions: ring([...geometry.outer, geometry.outer[0]]),
|
||||
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
positions: Cesium.Cartesian3.fromDegreesArray(
|
||||
geometry.positions.flat(),
|
||||
),
|
||||
width: Math.min(8, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const ring = (points) =>
|
||||
Cesium.Cartesian3.fromDegreesArray(points.flat());
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id,
|
||||
polygon: {
|
||||
hierarchy: new Cesium.PolygonHierarchy(
|
||||
ring(geometry.outer),
|
||||
geometry.holes.map(
|
||||
(hole) => new Cesium.PolygonHierarchy(ring(hole)),
|
||||
),
|
||||
),
|
||||
material:
|
||||
style.fill === false
|
||||
? Cesium.Color.TRANSPARENT
|
||||
: cesiumColor(
|
||||
style.polyColor,
|
||||
color,
|
||||
style.polyColor ? undefined : 0.25,
|
||||
),
|
||||
classificationType: Cesium.ClassificationType.BOTH,
|
||||
},
|
||||
}),
|
||||
);
|
||||
if (style.outline !== false)
|
||||
entities.push(
|
||||
dataSource.entities.add({
|
||||
id: `${id}:outline`,
|
||||
polyline: {
|
||||
positions: ring([...geometry.outer, geometry.outer[0]]),
|
||||
width: Math.min(6, Math.max(1.5, style.lineWidth ?? 2)),
|
||||
material: cesiumColor(style.lineColor, color),
|
||||
clampToGround: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
features.push({
|
||||
features.set(index, {
|
||||
name: feature.name,
|
||||
folder: feature.folder,
|
||||
description: feature.description,
|
||||
dateMs,
|
||||
entities,
|
||||
anchor: anchorOf(feature.geometries[0]),
|
||||
});
|
||||
});
|
||||
dataSource.entities.resumeEvents();
|
||||
return { version: product.version, dataSource, urls, features, pins };
|
||||
return {
|
||||
version: product.version,
|
||||
ageDays,
|
||||
dataSource,
|
||||
urls,
|
||||
features,
|
||||
pins,
|
||||
};
|
||||
} catch (error) {
|
||||
urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
throw error;
|
||||
@@ -330,6 +399,7 @@ export function createGhostMapsLayer({
|
||||
function sync() {
|
||||
const run = _queue.then(async () => {
|
||||
if (!_enabled || !_viewer || !_catalog) return;
|
||||
expireEvents();
|
||||
const signal = _lifetime.signal;
|
||||
const products = new Map(_catalog.products.map((p) => [p.id, p]));
|
||||
for (const id of [..._loaded.keys()]) {
|
||||
@@ -338,21 +408,33 @@ export function createGhostMapsLayer({
|
||||
}
|
||||
for (const product of _catalog.products) {
|
||||
if (!wanted(product) || signal.aborted) continue;
|
||||
if (_loaded.get(product.id)?.version === product.version) continue;
|
||||
const loaded = _loaded.get(product.id);
|
||||
if (loaded?.version === product.version && loaded.ageDays === _ageDays)
|
||||
continue;
|
||||
_loading.set(product.id, product.version);
|
||||
_failed.delete(product.id);
|
||||
notifyRow();
|
||||
try {
|
||||
const next = await load(product, signal);
|
||||
if (signal.aborted || !_enabled) {
|
||||
if (signal.aborted || !_enabled || next.ageDays !== _ageDays) {
|
||||
next.urls.forEach((url) => URL.revokeObjectURL(url));
|
||||
return;
|
||||
}
|
||||
// Cull before the first frame so far-side pins never flash.
|
||||
_horizon.update(next.pins);
|
||||
await _viewer.dataSources.add(next.dataSource);
|
||||
// The new edition is on the globe before the old one leaves.
|
||||
unload(product.id);
|
||||
// The new edition is on the globe before the old one leaves. The card
|
||||
// stays only if its index still holds the same report: the server
|
||||
// may answer a pinned version with newer bytes.
|
||||
const index = Number(_selectedKey?.split(':')[1]);
|
||||
const before = loaded?.features.get(index);
|
||||
const after = next.features.get(index);
|
||||
unload(
|
||||
product.id,
|
||||
Boolean(before && after) &&
|
||||
before.name === after.name &&
|
||||
before.description === after.description,
|
||||
);
|
||||
_loaded.set(product.id, next);
|
||||
} catch (error) {
|
||||
if (signal.aborted) return;
|
||||
@@ -391,12 +473,38 @@ export function createGhostMapsLayer({
|
||||
void sync();
|
||||
}
|
||||
|
||||
function setAgeDays(ageDays) {
|
||||
if (!GHOSTMAPS_AGE_OPTIONS.includes(ageDays) || ageDays === _ageDays)
|
||||
return;
|
||||
_ageDays = ageDays;
|
||||
try {
|
||||
storage?.setItem(AGE_STORAGE_KEY, JSON.stringify(ageDays));
|
||||
} catch {
|
||||
/* private window or blocked storage: keep the session choice */
|
||||
}
|
||||
expireEvents();
|
||||
// Narrowing only removes markers. A product loaded with a narrower window
|
||||
// keeps its old ageDays, so sync() restores omitted placemarks from the
|
||||
// cached KMZ even though its upstream version has not changed.
|
||||
for (const [id, item] of _loaded)
|
||||
if (
|
||||
item.ageDays === null ||
|
||||
(ageDays !== null && item.ageDays >= ageDays)
|
||||
) {
|
||||
item.ageDays = ageDays;
|
||||
// A failed wider reload no longer matters; a stale edition retries.
|
||||
_failed.delete(id);
|
||||
}
|
||||
notifyRow();
|
||||
return sync();
|
||||
}
|
||||
|
||||
// ---- selection card ----------------------------------------------------
|
||||
function featureFor(key) {
|
||||
const [productId, index] = key.split(':');
|
||||
return {
|
||||
product: _catalog?.products.find((p) => p.id === productId) || null,
|
||||
feature: _loaded.get(productId)?.features[Number(index)] || null,
|
||||
feature: _loaded.get(productId)?.features.get(Number(index)) || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -490,7 +598,7 @@ export function createGhostMapsLayer({
|
||||
if (typeof pickId !== 'string' || !pickId.startsWith(PICK_PREFIX))
|
||||
return null;
|
||||
const [productId, index] = pickId.slice(PICK_PREFIX.length).split(':');
|
||||
return _loaded.get(productId)?.features[Number(index)]
|
||||
return _loaded.get(productId)?.features.has(Number(index))
|
||||
? `${productId}:${index}`
|
||||
: null;
|
||||
}
|
||||
@@ -542,7 +650,7 @@ export function createGhostMapsLayer({
|
||||
|
||||
function featureCount() {
|
||||
let count = 0;
|
||||
for (const item of _loaded.values()) count += item.features.length;
|
||||
for (const item of _loaded.values()) count += item.features.size;
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -563,6 +671,8 @@ export function createGhostMapsLayer({
|
||||
enable() {
|
||||
_enabled = true;
|
||||
_lifetime = new AbortController();
|
||||
if (_expiryTimer === null)
|
||||
_expiryTimer = setInterval(expireEvents, 60_000);
|
||||
for (const item of _loaded.values()) item.dataSource.show = true;
|
||||
installClickHandler();
|
||||
if (!_unsubscribe && typeof source.subscribe === 'function')
|
||||
@@ -580,6 +690,8 @@ export function createGhostMapsLayer({
|
||||
|
||||
disable() {
|
||||
_enabled = false;
|
||||
clearInterval(_expiryTimer);
|
||||
_expiryTimer = null;
|
||||
_lifetime.abort();
|
||||
_unsubscribe?.();
|
||||
_unsubscribe = null;
|
||||
@@ -593,6 +705,7 @@ export function createGhostMapsLayer({
|
||||
|
||||
async update(_viewerArg, { signal } = {}) {
|
||||
if (!_enabled || signal?.aborted) return false;
|
||||
expireEvents();
|
||||
try {
|
||||
const catalog = await source.getCatalog({ signal });
|
||||
if (!_enabled) return false;
|
||||
@@ -637,6 +750,25 @@ export function createGhostMapsLayer({
|
||||
})
|
||||
: t('Connecting to the S2 GhostMaps repository…');
|
||||
return {
|
||||
segmented: {
|
||||
id: 'age',
|
||||
label: t('Event age'),
|
||||
options: GHOSTMAPS_AGE_OPTIONS.map((days) => ({
|
||||
id: `age-${days ?? 'all'}`,
|
||||
label:
|
||||
days === null
|
||||
? t('All', { context: 'event age filter' })
|
||||
: t('{days}d', { days: formatNumber(days) }),
|
||||
title:
|
||||
days === null
|
||||
? t('Show markers of every age')
|
||||
: t('Maximum event age: {days}d; undated markers are kept', {
|
||||
days: formatNumber(days),
|
||||
}),
|
||||
active: days === _ageDays,
|
||||
onClick: () => setAgeDays(days),
|
||||
})),
|
||||
},
|
||||
chips: products.map((product) => {
|
||||
const loaded = _loaded.get(product.id);
|
||||
const failed = _failed.get(product.id);
|
||||
@@ -651,11 +783,11 @@ export function createGhostMapsLayer({
|
||||
product.fileTitle,
|
||||
loaded
|
||||
? tn(
|
||||
loaded.features.length,
|
||||
loaded.features.size,
|
||||
'{count} placemark',
|
||||
'{count} placemarks',
|
||||
{
|
||||
count: formatNumber(loaded.features.length),
|
||||
count: formatNumber(loaded.features.size),
|
||||
},
|
||||
)
|
||||
: null,
|
||||
@@ -667,7 +799,7 @@ export function createGhostMapsLayer({
|
||||
};
|
||||
}),
|
||||
legend: [],
|
||||
info,
|
||||
info: `${_ageDays === null ? t('All markers') : t('Event age: {days}d · undated kept', { days: formatNumber(_ageDays) })} · ${info}`,
|
||||
infoTitle: t(
|
||||
'S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.',
|
||||
),
|
||||
@@ -677,6 +809,7 @@ export function createGhostMapsLayer({
|
||||
getStats() {
|
||||
return {
|
||||
count: featureCount(),
|
||||
ageDays: _ageDays,
|
||||
products: [..._loaded.keys()],
|
||||
commit: _catalog?.commit || null,
|
||||
lastUpdate: _catalog?.committedAt || _catalog?.changedAt || null,
|
||||
@@ -693,7 +826,7 @@ export function createGhostMapsLayer({
|
||||
if (!_enabled) return [];
|
||||
const out = [];
|
||||
for (const [productId, item] of _loaded) {
|
||||
for (const feature of item.features) {
|
||||
for (const feature of item.features.values()) {
|
||||
if (out.length >= maxCount) return out;
|
||||
out.push({
|
||||
product: productId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import * as Cesium from 'cesium';
|
||||
import { createGhostMapsLayer } from './index.js';
|
||||
import { GHOSTMAPS_PRODUCTS, GHOSTMAPS_LATEST_DROP } from './catalog.js';
|
||||
|
||||
function element(localName, children = [], value = '', attributes = {}) {
|
||||
const node = {
|
||||
@@ -22,7 +23,7 @@ function element(localName, children = [], value = '', attributes = {}) {
|
||||
return node;
|
||||
}
|
||||
|
||||
function documentFixture() {
|
||||
function documentFixture(reports = [{ name: 'Test report' }]) {
|
||||
return element('document', [
|
||||
element('kml', [
|
||||
element('Document', [
|
||||
@@ -36,11 +37,14 @@ function documentFixture() {
|
||||
'',
|
||||
{ id: 'pin' },
|
||||
),
|
||||
element('Placemark', [
|
||||
element('name', [], 'Test report'),
|
||||
element('styleUrl', [], '#pin'),
|
||||
element('Point', [element('coordinates', [], '12,34')]),
|
||||
]),
|
||||
...reports.map(({ name, description = '', geometry }) =>
|
||||
element('Placemark', [
|
||||
element('name', [], name),
|
||||
element('description', [], description),
|
||||
element('styleUrl', [], '#pin'),
|
||||
geometry || element('Point', [element('coordinates', [], '12,34')]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
]);
|
||||
@@ -83,11 +87,21 @@ function fixtureKmz() {
|
||||
return new Uint8Array(Buffer.concat([...local, directory, end]));
|
||||
}
|
||||
|
||||
function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
||||
function fixture(
|
||||
t,
|
||||
{
|
||||
beforeFileReturns,
|
||||
onCreateUrl,
|
||||
reports,
|
||||
now,
|
||||
products,
|
||||
controls = {},
|
||||
} = {},
|
||||
) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'DOMParser');
|
||||
globalThis.DOMParser = class {
|
||||
parseFromString() {
|
||||
return documentFixture();
|
||||
return documentFixture(reports);
|
||||
}
|
||||
};
|
||||
t.after(() => {
|
||||
@@ -119,9 +133,13 @@ function fixture(t, { beforeFileReturns, onCreateUrl } = {}) {
|
||||
};
|
||||
const layer = createGhostMapsLayer({
|
||||
storage: null,
|
||||
now,
|
||||
...controls,
|
||||
source: {
|
||||
getCatalog: async () => ({
|
||||
products: [{ id: 'cip', version: 'a'.repeat(40), defaultOn: true }],
|
||||
products: products ?? [
|
||||
{ id: 'cip', version: 'a'.repeat(40), defaultOn: true },
|
||||
],
|
||||
}),
|
||||
getFile: async () => {
|
||||
beforeFileReturns?.(layer);
|
||||
@@ -178,3 +196,416 @@ test('successful load keeps icons until the product is disabled', async (t) => {
|
||||
assert.deepEqual(revoked, created);
|
||||
assert.equal(mounted.size, 0);
|
||||
});
|
||||
|
||||
const dated = (name, value, label = 'Date') => ({
|
||||
name,
|
||||
description: `<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
|
||||
// markup is only ever read as text, never inserted into the page.
|
||||
|
||||
@@ -51,6 +51,106 @@ const MAX_FIELDS = 24;
|
||||
const MAX_VALUE = 600;
|
||||
const MAX_LINKS = 6;
|
||||
|
||||
// Use the same leaf rows for report cards and event dates, without the card's
|
||||
// field-count/value-length limits affecting the age filter.
|
||||
function* descriptionRows(source) {
|
||||
for (const row of source.matchAll(
|
||||
/<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
|
||||
* two-column table (label, value); anything else becomes one unlabelled field.
|
||||
@@ -73,18 +173,10 @@ export function parseDescriptionFields(html) {
|
||||
};
|
||||
// Innermost rows only: ArcGIS nests the field table inside an outer row,
|
||||
// and only leaf rows with exactly two cells are label/value pairs.
|
||||
for (const row of source.matchAll(
|
||||
/<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]);
|
||||
for (const { label, value, markup } of descriptionRows(source)) {
|
||||
if (!label || SKIPPED_LABELS.has(label.toLowerCase())) continue;
|
||||
if (EMPTY_VALUES.has(value.toLowerCase())) continue;
|
||||
addLinks(cells[1]);
|
||||
addLinks(markup);
|
||||
// A bare research link is carried by `links`; the card does not need
|
||||
// the URL twice.
|
||||
if (/^https?:\/\/\S+$/i.test(value)) continue;
|
||||
|
||||
@@ -7,8 +7,83 @@ import {
|
||||
kmlColor,
|
||||
parseDescriptionFields,
|
||||
wrapCardText,
|
||||
ghostMapsEventDate,
|
||||
isGhostMapsEventExpired,
|
||||
parseGhostMapsEventDate,
|
||||
} from './records.js';
|
||||
|
||||
test('event dates parse ArcGIS calendar dates and timestamps consistently in UTC', () => {
|
||||
for (const [value, iso] of [
|
||||
['10/28/2026', '2026-10-28T23:59:59.999Z'],
|
||||
['10/28/2026 6:12:00 PM', '2026-10-28T18:12:00Z'],
|
||||
['1/17/2026 9:46:41 AM', '2026-01-17T09:46:41Z'],
|
||||
['10/28/2026 12:00 AM', '2026-10-28T00:00:00Z'],
|
||||
['10/28/2026 12:00 PM', '2026-10-28T12:00:00Z'],
|
||||
['2026-10-28', '2026-10-28T23:59:59.999Z'],
|
||||
['2026-10-28T12:34:56', '2026-10-28T12:34:56Z'],
|
||||
['2026-10-28T12:34:56.123-04:00', '2026-10-28T16:34:56.123Z'],
|
||||
['2/29/2024', '2024-02-29T23:59:59.999Z'],
|
||||
])
|
||||
assert.equal(parseGhostMapsEventDate(value), Date.parse(iso), value);
|
||||
for (const value of [
|
||||
'',
|
||||
'<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.
|
||||
const ARCGIS = `<html xmlns:fo="http://www.w3.org/1999/XSL/Format"><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; }
|
||||
</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', () => {
|
||||
const { fields, links } = parseDescriptionFields(ARCGIS);
|
||||
assert.deepEqual(fields, [
|
||||
|
||||
@@ -126,8 +126,17 @@ export function createStreetLevelLayer({
|
||||
});
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on.
|
||||
const startsOn = defs.find((def) => def.defaultOn !== false);
|
||||
/** `def` if its key is ready, else the first keyless provider not refused. */
|
||||
function keylessInstead(def, refused = () => false) {
|
||||
if (def.keyReady?.() !== false) return def;
|
||||
return defs.find((d) => !d.requiresKeyId && !refused(d.id)) || def;
|
||||
}
|
||||
|
||||
// One source at a time: the first provider that defaults on. Without its
|
||||
// key, the first keyless source instead, so a fresh keyless install opens
|
||||
// on coverage rather than KEY REQUIRED.
|
||||
const preferred = defs.find((def) => def.defaultOn !== false);
|
||||
const startsOn = preferred && keylessInstead(preferred);
|
||||
for (const def of defs) {
|
||||
const entry = {
|
||||
def,
|
||||
@@ -585,7 +594,17 @@ export function createStreetLevelLayer({
|
||||
providerIds: state.providers.keys(),
|
||||
filter: state.filter,
|
||||
});
|
||||
const lit = litByParams(decoded.providers);
|
||||
// A restored "Mapillary on" (the codec default) would light a source a
|
||||
// keyless install cannot draw. Only redirect a provider the params switch
|
||||
// on: a filter-only update keeps whatever the user chose.
|
||||
const asked = litByParams(decoded.providers);
|
||||
const lit =
|
||||
asked && decoded.providers.get(asked) === true
|
||||
? keylessInstead(
|
||||
state.providers.get(asked).def,
|
||||
(id) => decoded.providers.get(id) === false,
|
||||
).id
|
||||
: asked;
|
||||
const before = litIds();
|
||||
for (const [id, entry] of state.providers)
|
||||
if (id !== lit) switchProvider(entry, false);
|
||||
|
||||
@@ -1127,3 +1127,59 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async (
|
||||
const view = presentStreetLevelPanel(layer.getUIState());
|
||||
assert.match(view.meta, /Google Street View needs the Google 3D map/);
|
||||
});
|
||||
|
||||
/** Mapillary (keyless unless `mapillary` says otherwise) plus KartaView. */
|
||||
function keylessLayer(t, mapillary = { hasToken: () => false }) {
|
||||
const layer = createStreetLevelLayer({
|
||||
providers: [
|
||||
createMapillaryProvider({
|
||||
source: fakeMapillarySource(mapillary),
|
||||
photoViewer: fakePhotoViewer(),
|
||||
}),
|
||||
createKartaViewProvider(),
|
||||
],
|
||||
});
|
||||
t.after(() => layer.destroy());
|
||||
return layer;
|
||||
}
|
||||
const litProviders = (layer) =>
|
||||
layer
|
||||
.getUIState()
|
||||
.providers.filter((provider) => provider.on)
|
||||
.map((provider) => provider.id);
|
||||
|
||||
test('without a Mapillary key the first keyless source is lit instead', (t) => {
|
||||
assert.deepEqual(litProviders(keylessLayer(t)), ['kartaview']);
|
||||
assert.deepEqual(litProviders(keylessLayer(t, {})), ['mapillary']);
|
||||
// A token-adding Graph proxy counts as a key.
|
||||
const proxied = keylessLayer(t, {
|
||||
hasToken: () => false,
|
||||
graphEndpoint: '/api/mapillary/graph',
|
||||
});
|
||||
assert.deepEqual(litProviders(proxied), ['mapillary']);
|
||||
});
|
||||
|
||||
test('restoring the default "Mapillary on" without its key keeps a keyless source', (t) => {
|
||||
const layer = keylessLayer(t);
|
||||
layer.setParams({ mapillary: true, providerSwitches: '' });
|
||||
assert.deepEqual(litProviders(layer), ['kartaview']);
|
||||
// A link that switches KartaView off keeps its word.
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' });
|
||||
assert.deepEqual(litProviders(layer), ['mapillary']);
|
||||
});
|
||||
|
||||
test('filter-only updates keep the lit source, even a chosen keyless Mapillary', (t) => {
|
||||
const layer = keylessLayer(t);
|
||||
layer.setParams({ pano: 'pano' });
|
||||
assert.deepEqual(litProviders(layer), ['kartaview']);
|
||||
// What the MAPILLARY chip sends, and what stored state restores.
|
||||
layer.setParams({ mapillary: true, providerSwitches: 'kartaview-0' });
|
||||
for (const params of [{ pano: 'pano' }, { sinceDays: 365 }, {}]) {
|
||||
layer.setParams(params);
|
||||
assert.deepEqual(
|
||||
litProviders(layer),
|
||||
['mapillary'],
|
||||
JSON.stringify(params),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -74,6 +74,9 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
|
||||
name: MAPILLARY_NAME,
|
||||
label: MAPILLARY_LABEL,
|
||||
requiresKeyId: MAPILLARY_KEY_ID,
|
||||
/** A browser token, or a Graph proxy that adds one server-side. */
|
||||
keyReady: () =>
|
||||
source.hasToken?.() !== false || Boolean(source.graphEndpoint),
|
||||
pickPrefix: PICK_PREFIX.root,
|
||||
colors: COLORS,
|
||||
credit: Object.freeze({ html: MAPILLARY_CREDIT_HTML }),
|
||||
|
||||
@@ -20,6 +20,8 @@ export function filtersOf(provider) {
|
||||
* @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image.
|
||||
* @property {string} [requiresMapStack] Usable only on this map stack.
|
||||
* @property {boolean} [defaultOn] Default true; the first that defaults on is lit.
|
||||
* @property {() => boolean} [keyReady] False when its key is missing now; the
|
||||
* first keyless provider is lit instead of a default that cannot draw.
|
||||
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
|
||||
@@ -295,10 +295,10 @@ test('area, imagery and OSM card lines are shown in the UI language', () => {
|
||||
notDrawn: 'the map was cleared while looking it up',
|
||||
};
|
||||
const areaLines = inLocale('de', () => presentResult('resolve_area', area).display.lines);
|
||||
assert.ok(areaLines.includes('250 m um das Wahrzeichen'), areaLines.join(' | '));
|
||||
assert.ok(areaLines.includes('Teile: 3'));
|
||||
assert.ok(areaLines.includes('Gezeichnete Teile: 2 von 3'));
|
||||
assert.ok(areaLines.some((line) => line.includes('die Karte wurde während der Suche geleert')));
|
||||
assert.ok(areaLines.includes('250 m um den Ort'), areaLines.join(' | '));
|
||||
assert.ok(areaLines.includes('3 Teile'));
|
||||
assert.ok(areaLines.includes('2 von 3 Teilen gezeichnet'));
|
||||
assert.ok(areaLines.some((line) => line.includes('die Zeichenfläche wurde während der Suche geleert')));
|
||||
const osm = {
|
||||
ok: true,
|
||||
kind: 'hospitals',
|
||||
@@ -316,7 +316,7 @@ test('area, imagery and OSM card lines are shown in the UI language', () => {
|
||||
assert.match(osmCard.title, /\bhospital\b/);
|
||||
assert.doesNotMatch(osmCard.title, /hospitals/, 'one place takes the singular');
|
||||
assert.equal(presentResult('osm_query', { ...osm, complete: true }).display.title, '1 hospital');
|
||||
assert.ok(osmCard.lines.includes('Zusammengeführte Doppelkartierungen: 2'));
|
||||
assert.ok(osmCard.lines.includes('2 Duplikate zusammengeführt'));
|
||||
assert.deepEqual(osmCard.notes, ['die ersten 500 aufgelistet', 'es gibt weitere']);
|
||||
const imagery = {
|
||||
ok: true,
|
||||
|
||||
Reference in new issue
Block a user