Compare commits

...
Author SHA1 Message Date
Mazeyar Moeini Feizabadi c56d5f2f06 Merge pull request #1578 from bilawalsidhu/german/space-missions-failed-entry
Roll back a Space Missions entry whose mission index fails to load

Thanks @gekh. Maintainer review fixes (separate commits): exit cancels the layer's own camera flights again, a failed index load shows a specific toast, tests and docs.
2026-11-06 20:26:55 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 8da8c05eaf test(launches): keep the fixture's toast recorder for the failure-message test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:10:34 +01:00
Mazeyar Moeini Feizabadi 5dd7f41436 Merge branch 'pr-1578-new' into review/pr-1578
# Conflicts:
#	changelog.d/1578-space-missions-failed-entry.md
#	docs/CURRENT-STATE.md
#	src/layers/launches/controls.js
2026-11-06 20:10:01 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 efa4374644 fix(launches): hand the camera over before stopping, and only blame a fresh index error
- releaseCameraOwnership marks the camera released before stopping, so a
  flight navigation asked to preserve survives; exit also releases it, so an
  inactive layer never cancels another owner's flight.
- The failure toast names the mission index only when its error is from the
  current Context attempt (stats.errorAt), not an earlier one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:28:56 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 13219828e3 fix(launches): cancel mission flights on exit and name a failed index load
Review fixes on top of #1578:
- Exiting Space Missions cancels the layer's own camera flights again (entry,
  roster preview, mission focus); only a flight navigation started after
  releaseCameraOwnership() runs on.
- A failed first index load says so: "Space Missions unavailable: the
  mission index could not load. Try again later." on both the Context and
  Data Layers routes, instead of a generic transition toast. Translated.
- Regression tests for both; docs and changelog describe the real feedback.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:20:11 +01:00
German Khokhlov 096b891099 Simplify failed-entry camera handling and tests 2026-11-06 22:01:39 +04:00
Mazeyar Moeini Feizabadi 3cd7ed68aa Merge remote-tracking branch 'origin/main' into review/pr-1578 2026-11-06 18:55:07 +01:00
Mazeyar Moeini Feizabadi 4effadd3e9 Merge pull request #1601 from bilawalsidhu/fix/street-level-keyless-default
fix(street-level): without a Mapillary key, start on a keyless source

Thanks @asturksever. Maintainer review fixes (separate commits): filter-only updates keep an explicitly chosen source, keyless Street Level QA gate updated, tests and docs.
2026-11-06 18:45:17 +01:00
Mazeyar Moeini Feizabadi ddc14c8f51 Merge remote-tracking branch 'origin/main' into review/pr-1601 2026-11-06 18:32:31 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 f7b9a2e5af test(street-level): keyless QA branch handles a keyed bundle behind a keyless server
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:20:47 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 8a7402d80c fix(street-level): keep explicit provider choices on filter-only updates
Review fixes on top of #1601:
- Only redirect a provider the params switch on to a keyless source, so a
  filter-only setParams (360°, SINCE) no longer moves an explicitly chosen
  keyless Mapillary to KartaView.
- Keyless QA gate: assert the new KartaView default and that the Mapillary
  chip explains its key instead of switching (it used to expect KEY REQUIRED).
- Tests: Graph-proxy keyReady, filter-only updates; shared test helper.
- README and CURRENT-STATE describe the keyless default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 18:14:16 +01:00
German Khokhlov 72e2eba70d Merge pull request #1581 from bilawalsidhu/german/i18n-new-strings-fixes
Fix the translations of the voice geometry, fire filter, airspace and sea mark strings
2026-11-06 21:03:09 +04:00
German Khokhlov 41cc57d630 Merge branch 'main' into german/i18n-new-strings-fixes 2026-11-06 20:55:36 +04:00
Mazeyar Moeini Feizabadi 61dfdc771e Merge remote-tracking branch 'origin/main' into review/pr-1601 2026-11-06 17:54:23 +01:00
Mazeyar Moeini Feizabadi b35c810284 Merge pull request #1528 from dylanmaniatakes/codex/s2-ghostmaps-age-filter
feat: add adjustable age filtering across S2 GhostMaps

Thanks @dylanmaniatakes. Maintainer review fixes (separate commits): age-window narrowing/restore consistency, card kept across same-report reloads, date-only events cover their whole UTC day, and the age choice as a labelled Event age switch.
2026-11-06 17:52:28 +01:00
Mazeyar Moeini Feizabadi d5d824f705 Merge remote-tracking branch 'origin/main' into review/pr-1528 2026-11-06 17:40:33 +01:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 91ab9b5811 fix(ghostmaps): follow-up review fixes for the age switch
- Keep the open card across a widened reload only when its index still
  holds the same report; the server may answer a pinned version with newer
  bytes.
- Narrowing to a window the loaded data already covers clears a failed
  wider reload, so the row no longer stays in error.
- Label the every-age option "All" (contextual) so the six-option switch
  fits the row in every locale; tooltip and info line keep "All markers".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:37:29 +01:00
German Khokhlov d544613aae Merge pull request #847 from kvnloo/exp/cyclone-shared-context
feat(cyclones): publish explicit storm selection to shared context
2026-11-06 20:35:22 +04:00
Mazeyar Moeini FeizabadiandClaude Opus 5.5 c4298c4450 fix(ghostmaps): keep age-window changes consistent and make the age choice a switch
Review fixes on top of #1528:
- Narrowing the age window only removes markers; a product loaded with a
  narrower window is reloaded when the window widens again. Fixes markers
  missing after 7d -> 1d -> 7d and avoids reparsing every KMZ on narrowing.
- A wider window over the same edition keeps the open report card.
- Date-only event dates cover their whole UTC day, so 1d still shows
  yesterday's reports.
- The age options are one labelled "Event age" radio switch (row
  `segmented` control, as FIRMS confidence) instead of toggle chips mixed
  with the product chips.
- Un-nest the per-product age test; QA compares data-source counts with the
  live defaults instead of a hard-coded 2.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 17:25:01 +01:00
German Khokhlov 8917eebe12 Tighten failed-entry comments and docs 2026-11-06 20:20:11 +04:00
German Khokhlov 5bab5ec26d refactor(cyclones): drop defensive shared-context state and checks 2026-11-06 20:15:27 +04:00
German Khokhlov 5e64e599e1 docs(cyclones): tighten shared-context comments, changelog and notes 2026-11-06 20:07:56 +04:00
Mazeyar Moeini Feizabadi 7673351ce0 Merge remote-tracking branch 'origin/main' into review/pr-1528
# Conflicts:
#	src/i18n/locales/ar.json
#	src/i18n/locales/de.json
#	src/i18n/locales/es.json
#	src/i18n/locales/fr.json
#	src/i18n/locales/it.json
#	src/i18n/locales/ja.json
#	src/i18n/locales/pt.json
#	src/i18n/locales/ru.json
#	src/i18n/locales/uk.json
#	src/i18n/locales/zh-CN.json
#	src/i18n/locales/zh-Hant.json
2026-11-06 17:02:29 +01:00
German Khokhlov e1ea524b68 Merge main into german/space-missions-failed-entry 2026-11-06 20:00:21 +04:00
German Khokhlov ea22d16c1a Merge branch 'main' into exp/cyclone-shared-context 2026-11-06 19:44:54 +04:00
German Khokhlov 4e518821b5 fix(cyclones): name the classification and mark a stale cache in shared context
- Publish classificationName next to the raw NHC code; unknown codes stay
  unexpanded.
- Publish staleCacheFetchedAt only while the proxy serves its cache.
- Touch the shared store on clear only after this layer published, so a
  feed failure no longer evicts another layer's dangling selection.
- Drop the unreachable no-storm branch and the unused return value.
- Tests: split the heading fixture into due north and stationary, cover
  the unavailable and failure paths with the real store, and check that
  clear/disable leave another layer's selection alone; fold the fake-store
  test into the real-store one.
2026-11-06 19:43:04 +04:00
Said Turksever 3d6423c6f8 Merge pull request #870 from waar19/perf/fleet-tick-skip-hidden
perf(flights): skip dead-reckoning contacts that stay behind the horizon
2026-11-06 15:03:33 +00:00
Said Turksever 15fdf65a73 Merge branch 'main' into perf/fleet-tick-skip-hidden 2026-11-06 14:52:56 +00:00
Said Turksever 39d47a99ab Merge pull request #1585 from mertkayacs/fix/keyless-unicode-search
fix(geocode): preserve non-Latin names in keyless search
2026-11-06 12:25:44 +00:00
Said Turksever 3f152a71a1 Merge branch 'main' into fix/keyless-unicode-search 2026-11-06 12:09:03 +00:00
German Khokhlov 39ff131d60 Merge pull request #953 from trygvehhh/feat/datacenters-norway-refresh
data: refresh Norway datacenters from a Geofabrik OSM extract
2026-11-06 16:08:18 +04:00
German Khokhlov aca5317a2a Merge branch 'main' into exp/cyclone-shared-context 2026-11-06 16:03:13 +04:00
Said Turksever 141181f83a docs(flights): changelog names the one-in-13-ticks rotation, about once a second 2026-11-06 12:02:30 +00:00
Said Turksever dda5ac6c00 Changelog: 1601 fragment 2026-11-06 11:59:31 +00:00
German Khokhlov c058ecf21b docs(infrastructure): keep the dataset scope note 2026-11-06 15:58:12 +04:00
German Khokhlov e073e4dbe4 Merge branch 'main' into feat/datacenters-norway-refresh 2026-11-06 15:55:23 +04:00
German Khokhlov a69d0492d0 docs(datacenters): tighten the Norway refresh notes 2026-11-06 15:54:53 +04:00
Said Turksever 0dbbd9047e Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-06 11:54:29 +00:00
Said Turksever acc4e63ec4 Merge branch 'main' into fix/keyless-unicode-search 2026-11-06 11:53:32 +00:00
Said Turksever 579acf5081 Street Level: without a Mapillary key, light a keyless source instead of opening on KEY REQUIRED 2026-11-06 11:51:34 +00:00
German Khokhlov 5d8f2b878f chore(datacenters): drop the one-off Norway refresh script
The data refresh is committed and its provenance (extract, timestamp, MD5,
filter) stays in the dataset README. The script needed osmium-tool and could
only re-verify while Geofabrik still listed that extract, so it goes with its
test.
2026-11-06 15:47:36 +04:00
German Khokhlov 0de347c328 Leave other camera flights running when Space Missions stops without a replay 2026-11-06 14:45:36 +04:00
Said Turksever d7856109c7 Merge branch 'main' into fix/keyless-unicode-search 2026-11-05 22:08:58 +00:00
Said Turksever bd8ef09042 Merge branch 'main' into fix/keyless-unicode-search 2026-11-05 21:44:18 +00:00
German Khokhlov 7f8556c691 Merge branch 'main' into exp/cyclone-shared-context
# Conflicts:
#	src/app/constructCatalog.js
2026-11-05 23:01:23 +04:00
Trygve Hemminghytt 47ea4aff1d Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-11-05 19:07:11 +01:00
German Khokhlov 067383fda8 fix(datacenters): drop the redundant seen set and test skipped against exports
The merge test now passes an exported newly tagged feature, so removing
'- set(fresh)' from the skipped report fails it.
2026-11-05 21:42:22 +04:00
German Khokhlov 655d93cc92 Keep a newer camera view when a failed Space Missions entry rolls back 2026-11-05 21:21:02 +04:00
Said Turksever e2ca8d9a27 Merge remote-tracking branch 'upstream/main' into pr1585-fix 2026-11-05 17:15:30 +00:00
Said Turksever bc65c274da fix(geocode): keep a leading "the" in keyless name matching
foldPlaceName drops a leading "the", so "The Hague" over upstate New
York accepted Hague, NY as an exact nearby match. Split the script-aware
fold into foldPlaceText and use it for keyless toponyms; foldPlaceName
keeps its behaviour for the packs and street matchers.

Also: changelog fragment credits #1585, folding test pins the Latin
letters NFD does not decompose (ß, ø, ł), and CURRENT-STATE notes that
non-Latin names match only when spelled with their marks.
2026-11-05 17:13:00 +00:00
Mert Kaya e4fda9d4e6 fix(geocode): preserve non-Latin names in keyless search 2026-11-05 19:09:00 +03:00
German Khokhlov ed0ad9cf0f Add the changelog entry for #1581 2026-11-05 14:32:39 +04:00
German Khokhlov c64ed40c62 Add the changelog entry for #1578 2026-11-05 12:54:36 +04:00
German Khokhlov 5c04b12164 Roll back a Space Missions entry whose mission index fails to load
The launches layer swallowed every fetch error in its update, so the
manager treated a 429 or timeout on /api/launches as a successful start.
Context then committed the isolating mode with no missions: the other
layers stayed off, unrelated enables were refused, the camera stayed at
the full-globe view and the roster kept reading "loading".

Until the first index loads, the update now reports the failure. The
manager fails the enable closed and both entry routes roll back to the
pre-entry layers. The camera pose captured at entry is restored when the
mode is left before any index loaded, and the roster shows a failed
state instead of loading. A valid empty index still enters, and a
refresh failure after a loaded index keeps its records.
2026-11-05 12:43:49 +04:00
German Khokhlov 33a2876bc0 Fix the translations of the voice geometry, fire filter, airspace and sea mark strings 2026-11-05 12:36:13 +04:00
Trygve Hemminghytt 1f0a8b5f16 fix(datacenters): report newly tagged features osmium cannot export
A still-tagged feature without exportable geometry already keeps its old
line. A newly tagged one had nowhere to go and was dropped silently; it is
now listed as skipped with a warning, so a refresh never loses a site or
hides a broken one.
2026-11-03 22:58:09 +01:00
Dylan Maniatakes 134597c3cf docs: add S2 age filter changelog for PR 1528 2026-11-03 13:23:40 -05:00
Dylan Maniatakes 36fceb5675 Add adjustable event age filtering to S2 GhostMaps 2026-11-03 13:15:33 -05:00
Said Turksever 2c936ae131 docs: correct hidden-contact rotation interval 2026-11-03 16:26:45 +00:00
Said Turksever b68cd25e9e Merge commit '2ebde97fb3af19402eab83fca2bfbec9ddfd166a' into easy-wins-20261103-pr870 2026-11-03 16:17:34 +00:00
Said Turksever 66a5c483c2 fix: resolve review findings and refresh PR #870 against main 2026-11-03 16:17:31 +00:00
German Khokhlov 92666fdbaf fix(cyclones): preserve movement in selected voice context 2026-11-03 15:10:20 +04:00
German Khokhlov 04d626a4b1 fix(datacenters): preserve missing geometry and compare ring topology 2026-11-03 15:02:42 +04:00
Kevin Rajan deb02f635e Merge current upstream main into explicit cyclone context PR
Preserve reviewed selection event-ordering semantics and real-store tests.
Register the new app-layer wrapper in both package boundary sections.
2026-10-22 17:39:51 -05:00
Said Turksever c6decfced4 Merge latest main before publishing PR #847 fixes 2026-10-20 14:42:17 +01:00
Said Turksever b358b5dba1 fix(cyclones): distinguish deliberate clears from feed eviction 2026-10-20 14:17:39 +01:00
Said Turksever 1330605c18 Merge main into PR #847 preparation candidate 2026-10-20 14:14:38 +01:00
Said Turksever 9a5cafca27 Merge upstream/main into exp/cyclone-shared-context 2026-10-19 14:23:54 +01:00
Trygve Hemminghytt 4318f51de4 docs: move the changelog entry to changelog.d 2026-10-18 20:03:23 +02:00
Trygve Hemminghytt 52603f0afe Merge remote-tracking branch 'origin/main' into HEAD 2026-10-18 20:02:39 +02:00
Kevin Rajan 0e2ae76f0a test(cyclones): assert source-owned context label 2026-10-17 03:36:09 -05:00
Kevin Rajan 3a7c7b7c6a fix(cyclones): keep selected names source-owned for i18n 2026-10-17 03:36:07 -05:00
Kevin Rajan abaaf0c254 fix(cyclones): reconcile explicit shared selection with current main
Carry reviewer-verified stale A-to-B record eviction, explicit selection
and refresh semantics over latest i18n/cyclone source ownership.
2026-10-17 03:31:28 -05:00
Kevin Rajan a803be1af2 fix(cyclones): evict prior shared selection on storm switch
Regression: explicit storm A→B→A keeps one active record.
Reviewer finding: quanticsoul4772 (#847).
2026-10-16 23:01:44 -05:00
Trygve 7e765a3f93 Merge branch 'main' into feat/datacenters-norway-refresh 2026-10-16 22:57:02 +02:00
Trygve Hemminghytt 3c26636ab4 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-16 22:23:03 +02:00
Trygve Hemminghytt 5b5d942d95 fix(data): make the Norway datacenter script portable and re-runnable
Read and write files as UTF-8 with LF line endings so the script and
--verify work on Windows, where the default codec is cp1252 (thanks to
quanticsoul4772 for the report). Geofabrik keeps only its newest dated
extract, so --extract now defaults to the current one and accepts a dated
name for --verify while it is listed; the dataset README says so. Downloads
time out instead of hanging.
2026-10-16 22:23:02 +02:00
Trygve Hemminghytt 2e0d855cf8 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-15 23:04:04 +02:00
Trygve Hemminghytt 4ce28c83c0 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-15 22:35:13 +02:00
Trygve Hemminghytt 239ba14005 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-15 21:06:43 +02:00
Trygve Hemminghytt cd83fc62fe Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-15 15:08:35 +02:00
Trygve Hemminghytt 6292b13d75 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-15 14:14:26 +02:00
Trygve Hemminghytt ed1982ab96 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-14 16:42:18 +02:00
Trygve Hemminghytt a9f16fecd3 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-13 18:33:51 +02:00
Trygve Hemminghytt bba786d215 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-12 18:38:24 +02:00
trygvehhh 295d089377 Merge branch 'main' into feat/datacenters-norway-refresh 2026-10-11 13:24:12 +02:00
Trygve Hemminghytt 6cf641967b Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-08 21:28:09 +02:00
Trygve Hemminghytt 7ee51c093a Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-08 00:39:37 +02:00
Trygve Hemminghytt 621b87c52c Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh 2026-10-07 21:41:41 +02:00
Trygve Hemminghytt a953c9a545 Merge remote-tracking branch 'origin/main' into feat/datacenters-norway-refresh
# Conflicts:
#	CHANGELOG.md
2026-10-07 15:44:35 +02:00
Trygve HemminghyttandClaude Opus 5.5 aa745bcee5 data: refresh Norway datacenters from Geofabrik extract
Add a maintainer script that merges a pinned, MD5-verified Geofabrik Norway
extract (osmium tags-filter telecom/building=data_center) into the bundled
datacenter layer. Only features inside the Geofabrik Norway boundary are
touched; unchanged features keep their original line.

norway-261005 (OSM 2026-10-05) adds Troll Housing (w1551029155) and updates
tags on four existing features. Provenance is recorded in the dataset README
and the bundled count moves from 4,351 to 4,352.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-07 04:16:50 +02:00
Wilmer AlzateandClaude Opus 5.5 81f49406b8 perf(flights): skip dead-reckoning contacts that stay behind the horizon
The fleet tick dead-reckoned, floored and distance-tested every contact on
every ~80 ms tick, then hid the ones behind the horizon. The worldwide feed
carries ~13k contacts; zoomed in, well under 1% are on screen, so nearly all
of that work produced a hidden billboard.

A contact that is already hidden and still behind the horizon at its last
position now re-reckons only on its turn in a staggered one-in-12-ticks
rotation (about once a second, HIDDEN_CONTACT_DR_TICKS). Anything that
position puts in view, and any contact with a fleet model, takes the full
pass immediately, so on-screen behaviour is unchanged. Hidden-inclusive
proximity queries (getNearby includeHidden, getAllPositions) read positions
at most about a second old, well inside the 30 s poll's uncertainty.

Measured in Chrome on a Radeon 780M, ~12.9k contacts, 56 on screen over
Colombia at 4 km, same view before and after:
  fleet tick   7.66 -> 2.03 ms/frame, longest tick 50.9 -> ~16 ms
  frame        20.6 -> 14.0 ms, 42.9 -> 57.8 FPS
Teleporting the camera to Madrid showed 975 contacts within 100 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 12:31:59 -05:00
Kevin Rajan 28f3243090 feat(cyclones): publish explicit selection to shared context 2026-09-29 00:20:16 -05:00
64 changed files with 2865 additions and 422 deletions

No files matched your search

+1 -1
View File
@@ -217,7 +217,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| Dataset | Folder | License | Commercial use? | Attribution |
| --------------------------------------------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------- |
| **Datacenters** (4,351) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Datacenters** (4,352) | `datacenters/` | **ODbL 1.0** (OpenStreetMap extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) |
| **Dams** (704) | `dams/` | **ODbL 1.0** (OpenInfraMap / OSM extract) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Open Infrastructure Map |
| **Military area names** (36,466) | `osm_military_names/` | **ODbL 1.0** (OpenStreetMap, via Overture Maps) | ✅ (attribution + share-alike on data) | © OpenStreetMap contributors (shared credit) + Overture Maps Foundation |
| **TeleGeography Submarine Cable Map** (712 cables + 1,917 landing points) | `telegeography_submarine_cables/` | **CC BY-NC-SA 3.0** | ❌ **NonCommercial — remove for commercial use** | "© TeleGeography — submarinecablemap.com" |
+3 -3
View File
@@ -473,7 +473,7 @@ In the app, **Data Layers** groups them as Movement, Cameras, Infrastructure, Ev
_The Space Missions layer replaying a Falcon 9 ascent — labeled `RECONSTRUCTED ESTIMATE`, scrubbable 0.25×–4×._
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,351), Dams (704), and Submarine Cables (712).
**Also on the globe:** neighborhood overlays · an optional cockpit WX cloud effect. **Bundled static infrastructure:** Datacenters (4,352), Dams (704), and Submarine Cables (712).
![Diving into the Bahamas and revealing labeled submarine cable routes beneath the globe](docs/media/09-undersea-cables.gif)
@@ -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 @@
- Space Missions no longer locks the workspace when its mission index fails to
load (429, timeout or bad payload): entry rolls back to the previous layers
and camera view and a toast says the index could not load (German, @gekh,
#1578).
@@ -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,3 @@
- Keep keyless place search on a nearby exact match for names written in
non-Latin scripts instead of discarding the match and retrying without its
proximity ranking (mertkayacs, #1585).
@@ -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).
+4
View File
@@ -0,0 +1,4 @@
- Reduce dead-reckoning work for flights already hidden behind the horizon.
Hidden contacts update on a staggered one-in-13-ticks rotation (about once a
second) while visible contacts and contacts with fleet models retain the full
update path (Wilmer Alzate, #870).
@@ -0,0 +1,2 @@
- Add Troll Housing to the Datacenters layer (4,352 features) by refreshing
Norway from OpenStreetMap data of 2026-10-05 (trygvehhh, #953).
+48 -37
View File
@@ -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.
@@ -5329,7 +5336,7 @@ silently demoting every later lookup for the session.
- Context is set for reading in every theme: mode, standby and action labels are 10 px, and the panel draws no glow halo. Outside Cyber, the two modes form one segmented control, the standby guidance is plain left-aligned text rather than a dashed placeholder, and the panel's section titles share the side panels' dock-label colour. Cyber keeps its separately framed mode buttons, its red panel titles, and in layout-focus a framed, centred standby card over its radar art.
- The expanded desktop header omits the redundant `ON` label; the active mode button carries state. Expanded Contact results also omit the duplicate `GLOBAL CONTEXT` / `CONTEXT ONLY` status row and begin with the selected subject and its 250 km scope. Global Context does not fabricate a selected-entity model preview: the provisional hand-authored aircraft wireframe was removed because it was not geometry extracted from the selected entity's actual asset.
- Dependency ownership is reversible: disabling Global Context releases only dependencies it enabled, while user-enabled layers remain on. This also removes the Military-layer suppression handoff when Global Context owned Military, allowing an already-enabled civilian Flights layer to resume its normal mixed rendering. If OpenSky is unavailable and has no last-good cache, Flights requests a capped 250 nm adsb.lol point snapshot around the current view anchor and labels that provenance explicitly; it never relabels military-feed rows as civilian data. If both inputs fail, Flights remains `UNKNOWN`.
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
- Space Missions is replay-isolated: Rocket Launches and Satellites are the only Data Layers permitted while the mode is active. Direct UI and voice entry capture the same pre-entry snapshot; internal dependency and restoration enables do not create a user-owned Context session. Entry waits for incompatible layers to shut down, direct incompatible enables are blocked before lifecycle work, and the entry gate remains active through the complete Rocket Launches enable. That enable includes the first mission-index load: an HTTP error, timeout, network failure or malformed payload before any index has loaded fails the enable, so Context rolls back to the pre-entry layers instead of committing an empty, isolating mode; the camera returns to its pre-entry view unless navigation took it, and a toast says the mission index could not load. A valid empty index still enters, and a refresh failure after an index loaded keeps its records and the mode. Exiting cancels the layer's own camera flights but never one navigation started. A newer same-target ON request takes ownership of the pending entry without releasing its isolation snapshot, including when it arrives while the prior request is awaiting the adoption guard. A caller abort, resource cancellation, newer OFF, or layer teardown waits for exact manager settlement and restores that snapshot without resurrecting Rocket Launches. If an abort lands after only part of a restore settles, Context completes the same exact target without the stale caller signal and then replays newer explicit layer intent. Dedicated Voice Context cancellation reports a stable cancelled result plus the current Context state; generic layer visibility additionally exposes manager phase, reason, successor, and lifecycle details. Once an exact voice visibility intent commits, a newer voice turn cannot relabel it as cancelled while Context settlement completes; pre-commit aborts remain cancellable and final lifecycle mismatches remain failures. Clear Selected Layers reserves its complete captured OFF set before sequential teardown; a newer absolute request of any origin supersedes only its layer reservation and remains authoritative. A rejected layer teardown retains the truthful enabled state, rolls already-stopped siblings back to the captured pre-entry set, and aborts replay; rapid exit/re-entry serializes the full Satellite enabled-state and parameter restore before a new snapshot is taken. Contacts remains additive and restores user-enabled layers normally.
- Enabled Data Layer controls report normalized feed health on the button (`LOADING`, `DEGRADED`, `STALE`, `FALLBACK`, or `UNAVAILABLE`) while the metadata line retains the source and reason. A partial CelesTrak group failure keeps the usable catalog and reports `DEGRADED`; a total outage keeps last-good catalog data visible but reports `UNAVAILABLE`.
- On activation it focuses the nearest currently observed aircraft across the civilian and military feeds, with military winning an exact distance tie; if none are available, it focuses an observed AIS vessel. The aircraft search is deliberately uncapped and one refresh-tick retry handles initially empty feeds. This is an attention-priority navigation shortcut, not a high-risk, affiliation, or threat classification.
- A selected aircraft, AIS vessel, or mapped installation gets a 250 km **context window** with nearby cohort counts, nearest examples, source labels, and stale/unavailable reasons. It emits `NEARBY` or `UNKNOWN`; no detection, engagement, affiliation, or sensor-activity conclusion is calculated.
@@ -5350,7 +5357,7 @@ silently demoting every later lookup for the session.
### Motion & Symbology Correctness (June 10, 2026)
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The whole fleet dead-reckons at ~12Hz (1m² write gating); aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **Flights (commercial + military)** render one poll interval behind real time (30s/15s) and interpolate between two known feed-stamped fixes (OpenSky `time_position`; adsb.lol `receipt − seen_pos`). The display latency is an intentional product decision — do not "fix" it away. The fleet dead-reckons at ~12Hz (1m² write gating); a contact already hidden behind the horizon whose last position still is re-reckons on a staggered one-in-13-ticks rotation (about once a second) instead, so the worldwide feed's ~13k contacts no longer cost a full pass when well under 1% are on screen. Anything that position puts in view, and any contact with a fleet model, takes the full pass immediately, and hidden-inclusive proximity queries read positions at most about a second old; aircraft get a 3-poll grace period (faded icon) before removal. When a position epoch pauses, both layers coast for at least 60 seconds of contact grace with an absolute five-minute ceiling. Source backoff marks each contact and the cockpit `STALE`; the cockpit then holds the exact layer position instead of continuing inertial flight. Repeated-position kinematic changes create a forward-only synthetic fix rather than mutating history, and grounded history is lifted only when no owned 3D model already controls its datum. A nominally successful worldwide OpenSky response whose own snapshot epoch is more than two minutes old prefers the existing 250-nm viewport-scoped adsb.lol fallback and labels the source/coverage accordingly when that upstream is available. If the fallback is also unavailable, the layer's freshness/error fields use the source epoch—never the cache receipt time—so the UI reports an old snapshot rather than “just now.”
- **World-space headings at every angle** (`src/data/iconOrientation.js`): aircraft/vessel icon rotation uses the camera right/up basis per tick (alignedAxis always ZERO), which is exact at screen center and for orthographic/nadir views and remains stable through >180° tracked orbits. Perspective rays vary across the viewport, so off-center contacts at oblique pitch can diverge from an exact finite-difference window projection; a regression test pins that known regime, and field evidence decides whether to adopt exact projection with the basis method as fallback. Fleet rotations refresh on camera-pose change; tracked entities per frame. Billboards are horizon-culled via a shared EllipsoidalOccluder.
- **Military/OpenSky reconciliation** (`src/data/militaryRegistry.js`): known-military ICAOs render amber in the flights layer (60s self-poll of the cached mil endpoint when the military layer is off) and are suppressed there while the military layer renders them.
- **Satellites**: 838-sat core catalog (stations/visual/GPS/GLONASS/Galileo/GEO), tracking lands ~726km out via `viewFrom` (tracked entity owns a point graphic so the tracking camera engages), rings realign via primitive modelMatrix (no per-second rebuild flicker), optional `setParams({catalog:'dense'})` Starlink mode.
@@ -5881,8 +5888,11 @@ HTTP refusals and outages remain retryable. Searches share a 12-second total
deadline, with Photon requests capped at six seconds each. Caller/application
cancellation stops retries and late cache writes. Replacing a location search
cancels the previous lookup; disposing its controls cancels the active lookup. Photon uses a soft proximity bias, up to five
candidates, and an unbiased retry for name mismatches. A "place, region" query
prefers a candidate named exactly for the place whose city, county, state,
candidates, and an unbiased retry for name mismatches. Name comparison folds
Latin accents while preserving letters and marks in other scripts, so a
non-Latin name spelled exactly, marks included, keeps its proximity ranking. A
leading "the" counts: "The Hague" does not take a nearby "Hague". A "place, region" query prefers a
candidate named exactly for the place whose city, county, state,
country or country code (or USA/UK) contains every word of the region, and a
settlement or administrative area over a POI when both fit. Nearby results that
fail that check are retried unbiased. After the region checks of both passes, a
@@ -6005,6 +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.
+1 -1
View File
@@ -57,6 +57,6 @@ Consumers use the package exports, which do not import standalone application
globals.
Dataset files and source/license notices remain under `src/data/local_data/`.
Their input counts are 4,351 datacenter features and 704 dam features. Cesium may
Their input counts are 4,352 datacenter features and 704 dam features. Cesium may
expand multipart geometries into multiple entities; entity and stem counts are
different measurements. This change does not refresh or relicense the datasets.
+2
View File
@@ -347,6 +347,7 @@
"src/layers/flights/tracking.js",
"src/layers/flights/rendering.js",
"src/layers/flights/testing.js",
"src/layers/flights/hiddenContactRotation.test.mjs",
"src/layers/flights/evidence.js",
"src/layers/flights/ingestion.js",
"src/layers/flights/queries.js",
@@ -474,6 +475,7 @@
"src/layers/satellites/stale.test.mjs",
"src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs",
"src/layers/launches/failedEntry.test.mjs",
"src/layers/installations/lifecycle.js",
"src/layers/installations/viewport.js",
"src/layers/installations/controls.js",
+3
View File
@@ -572,6 +572,7 @@
"src/search/placeSearch.js",
"src/search/google.js",
"src/keylessGeocoder.js",
"src/data/placeNames.js",
"src/search/geospatial.js",
"src/search/http.js",
"src/search/defaults.js",
@@ -2335,6 +2336,7 @@
"src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js",
"src/app/layers/cctv.js",
"src/app/layers/cyclones.js",
"src/app/layers/earthquakes.js",
"src/app/layers/firms.js",
"src/app/layers/flights.js",
@@ -3379,6 +3381,7 @@
"src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js",
"src/app/layers/cctv.js",
"src/app/layers/cyclones.js",
"src/app/layers/earthquakes.js",
"src/app/layers/firms.js",
"src/app/layers/flights.js",
+73
View File
@@ -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;
+33 -1
View File
@@ -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(
+2 -2
View File
@@ -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 }),
+10
View File
@@ -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,
});
}
+5
View File
@@ -1,5 +1,10 @@
# Test fixtures
- `photon-moscow-search.json` - Photon responses for `Москва`, captured
2026-11-05 with and without a proximity bias near the Tajikistan namesake.
The fixture is trimmed to the fields the keyless geocoder consumes and is
used only by offline search tests. Data from OpenStreetMap contributors,
ODbL 1.0 (https://www.openstreetmap.org/copyright).
- `tomtom-flow-austin-12-935-1686.pbf` — one real TomTom traffic-flow vector
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
z12 x935 y1686, captured 2026-07-16 from
+110
View File
@@ -0,0 +1,110 @@
{
"biased": {
"features": [
{
"geometry": {
"type": "Point",
"coordinates": [71.8339931, 38.948412]
},
"properties": {
"name": "Москва",
"country": "Тоҷикистон"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [37.6063916, 55.625578]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [37.6174782, 55.7505412]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [37.0536908, 55.7098009]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [32.163334, 56.917778]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
}
]
},
"unbiased": {
"features": [
{
"geometry": {
"type": "Point",
"coordinates": [37.6063916, 55.625578]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [37.6174782, 55.7505412]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [37.0536908, 55.7098009]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [32.163334, 56.917778]
},
"properties": {
"name": "Москва",
"country": "Россия"
}
},
{
"geometry": {
"type": "Point",
"coordinates": [71.8339931, 38.948412]
},
"properties": {
"name": "Москва",
"country": "Тоҷикистон"
}
}
]
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ test('infrastructure factory preserves identity and creates independent state wi
test('dataset URLs still name the complete bundled sources', () => {
for (const [file, count] of [
['datacenters', 4351],
['datacenters', 4352],
['dams', 704],
]) {
const lines = readFileSync(
+10 -1
View File
@@ -5,7 +5,7 @@ layer.
- Source: OpenStreetMap contributors
- License: Open Database License (ODbL) 1.0
- Feature count: 4,351
- Feature count: 4,352
- Runtime file: `datacenters.geojsonl`
The public-release snapshot removes contact-oriented tags such as email, phone,
@@ -16,3 +16,12 @@ ODbL 1.0 with the required OpenStreetMap attribution.
The original extraction date and query were not recorded alongside this
snapshot. Future refreshes should record both before replacing the file.
## Norway refresh
Features inside Geofabrik's Norway boundary (`norway.poly`, with Svalbard) come
from a newer extract than the rest of the file. Contact tags are dropped as above.
- Extract: Geofabrik `norway-261005.osm.pbf`, OSM data
2026-10-05T20:21:35Z, MD5 `1d2cfdeb98980fce11b8891d0f9146c0`
- Filter: `osmium tags-filter nwr/telecom=data_center nwr/building=data_center`
@@ -3770,15 +3770,15 @@
{"id":939340882,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021364183,52.315373019],[21.02232433,52.315435883],[21.022363641,52.315209656],[21.021403578,52.315146875],[21.021364183,52.315373019]]]},"properties":{"id":11407,"tags":{"name":"T-Mobile Warszawa-Szlachecka","telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":939340882}}
{"id":1005973518,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.021325375,52.315595978],[21.022285857,52.315658423],[21.02232433,52.315435883],[21.021364183,52.315373019],[21.021325375,52.315595978]]]},"properties":{"id":11976,"tags":{"telecom":"data_center","building":"office","building:levels":"3"},"type":"data_center","osm_id":1005973518}}
{"id":1156823776,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[21.115130857,52.235385942],[21.115189866,52.236349777],[21.115232027,52.236667954],[21.115818174,52.237174556],[21.116175578,52.237468677],[21.117262124,52.236985628],[21.11728157,52.236976576],[21.117262795,52.236960985],[21.117340495,52.236926033],[21.117360276,52.2369433],[21.117447532,52.236904491],[21.11804097,52.236654459],[21.118030996,52.236645742],[21.118041054,52.236641719],[21.116603055,52.235624072],[21.116518984,52.235676375],[21.115986231,52.235696659],[21.115954966,52.235361551],[21.115130857,52.235385942]]]},"properties":{"id":15342,"tags":{"name":"Atman Data Center Warsaw-1","telecom":"data_center","website":"https://www.atman.pl"},"type":"data_center","osm_id":1156823776}}
{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.598350034,58.860111293]},"properties":{"id":24849,"tags":{"name":"Maan Group","telecom":"data_center","operator":"Privat"},"type":"data_center","osm_id":7405586142}}
{"id":7405586142,"type":"Feature","geometry":{"type":"Point","coordinates":[5.5983501,58.8601113]},"properties":{"id":24849,"tags":{"name":"Maan Group","opening_hours":"24/7","operator":"Privat","telecom":"data_center"},"type":"data_center","osm_id":7405586142}}
{"id":481615983,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[9.572510149,56.481777951],[9.574069938,56.484921751],[9.57465357,56.48483349],[9.574639572,56.484805243],[9.574700676,56.484795939],[9.574714925,56.484824857],[9.575298557,56.484736595],[9.57454888,56.483225422],[9.574609229,56.483216369],[9.574510155,56.483016629],[9.574449722,56.483025849],[9.573738769,56.481592795],[9.573155137,56.481680972],[9.573170643,56.481712321],[9.573109539,56.481721625],[9.573093781,56.48168969],[9.572510149,56.481777951]]]},"properties":{"id":7881,"tags":{"telecom":"data_center","building":"data_center","operator":"Apple","wikidata":"Q19721053","wikipedia":"en:Foulum Data Center","description":"Apple Datacenter"},"type":"data_center","osm_id":481615983}}
{"id":618756435,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.500360092,57.318668738],[10.500399236,57.318671672],[10.500382891,57.318734871],[10.500552038,57.318744175],[10.500545249,57.318787677],[10.500501495,57.318785666],[10.500458999,57.318957746],[10.500970295,57.31899647],[10.501009187,57.318847189],[10.501084959,57.318852888],[10.50111966,57.318719197],[10.500843896,57.318698159],[10.500865521,57.318615178],[10.500859235,57.318614759],[10.500869461,57.318575699],[10.500954537,57.318582153],[10.501022347,57.31832223],[10.500736691,57.318300437],[10.500669133,57.318559689],[10.500674162,57.318560025],[10.500664272,57.318598498],[10.500383981,57.318577124],[10.500360092,57.318668738]]]},"properties":{"id":8842,"tags":{"ref":"AAL01-01","name":"Prime Data Centers - Saeby Campus AAL01","telecom":"data_center","website":"https://primedatacenters.com/locations/saeby/","building":"industrial","operator":"Prime Data Centers"},"type":"data_center","osm_id":618756435}}
{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002243225,59.633471864],[11.003903261,59.633446551],[11.007046726,59.633399696],[11.006983443,59.632420941],[11.006982772,59.63227057],[11.006911023,59.632265289],[11.006852936,59.632253973],[11.006765848,59.632228576],[11.00671782,59.632205275],[11.006119687,59.632213573],[11.006119352,59.632202592],[11.006118933,59.63219245],[11.005930424,59.632194378],[11.005923635,59.632120869],[11.005980799,59.632120031],[11.005979542,59.632104524],[11.005977027,59.63206865],[11.00597225,59.631998242],[11.005412171,59.632035541],[11.005286945,59.632043839],[11.00527848,59.632044342],[11.003707962,59.632147272],[11.003107483,59.63219178],[11.002741864,59.632235533],[11.002729375,59.63223788],[11.002345987,59.632309294],[11.002308352,59.632316251],[11.002302066,59.632477351],[11.002277088,59.632913881],[11.002243225,59.633471864]]]},"properties":{"id":107,"tags":{"name":"STACK Infrastructure OSL04","type":"multipolygon","telecom":"data_center","website":"https://www.stackinfra.com","operator":"Stack Infrastructure","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":-14367602}}
{"id":-14367602,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.0022433,59.6334719],[11.0022771,59.6329139],[11.0023021,59.6324774],[11.0023084,59.6323163],[11.002346,59.6323093],[11.0027294,59.6322379],[11.0027419,59.6322356],[11.0031075,59.6321918],[11.003708,59.6321473],[11.0052785,59.6320444],[11.005287,59.6320439],[11.0054122,59.6320356],[11.0059723,59.6319983],[11.0059771,59.6320687],[11.0059796,59.6321046],[11.0059808,59.6321201],[11.0059237,59.6321209],[11.0059305,59.6321944],[11.006119,59.6321925],[11.0061194,59.6322026],[11.0061197,59.6322136],[11.0067179,59.6322053],[11.0067659,59.6322286],[11.006853,59.632254],[11.0069111,59.6322653],[11.0069828,59.6322706],[11.0069835,59.632421],[11.0070468,59.6333997],[11.0039033,59.6334466],[11.0022433,59.6334719]]]},"properties":{"id":107,"tags":{"landuse":"industrial","name":"STACK Infrastructure OSL04","operator":"Stack Infrastructure","operator:wikidata":"Q115477173","telecom":"data_center","website":"https://www.stackinfra.com","type":"multipolygon"},"type":"data_center","osm_id":-14367602}}
{"id":1028687251,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.004111886,59.632578437],[11.004142983,59.633037262],[11.004275166,59.633034999],[11.004274998,59.63303173],[11.00505133,59.633019157],[11.005056443,59.633099372],[11.005224416,59.63309669],[11.005190637,59.632562344],[11.005060885,59.632564439],[11.005060969,59.632567457],[11.004587978,59.632575419],[11.00458538,59.632536527],[11.004459819,59.632538623],[11.004462418,59.632576844],[11.004242476,59.6325807],[11.004242141,59.632576174],[11.004111886,59.632578437]]]},"properties":{"id":12226,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1028687251}}
{"id":1164840308,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.002600126,59.632599392],[11.002631223,59.633058217],[11.00276349,59.633055954],[11.002763238,59.633052685],[11.00353957,59.633040196],[11.003544683,59.633120327],[11.00371274,59.633117645],[11.003678961,59.632583382],[11.003549042,59.632585478],[11.003549293,59.632588495],[11.003076135,59.632596458],[11.00307362,59.632557566],[11.002948143,59.632559661],[11.002950657,59.632597883],[11.002730716,59.632601571],[11.002730465,59.632597129],[11.002600126,59.632599392]]]},"properties":{"id":15459,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840308}}
{"id":1164840309,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[11.005613672,59.632723444],[11.005644769,59.633182269],[11.005776951,59.633179922],[11.005776784,59.633176737],[11.006553199,59.633164164],[11.006558229,59.633244463],[11.006726286,59.633241781],[11.006692423,59.632707351],[11.006562671,59.632709446],[11.006562839,59.632712464],[11.006089764,59.632720426],[11.006087166,59.632681534],[11.005961772,59.632683797],[11.005964287,59.632721851],[11.005744262,59.632725623],[11.005743927,59.632721181],[11.005613672,59.632723444]]]},"properties":{"id":15460,"tags":{"building":"data_center","ref:bygningsnr":"300783262"},"type":"data_center","osm_id":1164840309}}
{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.991467366,59.759024056],[10.992780978,59.759406942],[10.992773937,59.759423957],[10.992948197,59.759474248],[10.993073842,59.759363859],[10.993022293,59.759348939],[10.993291688,59.759112653],[10.993128827,59.759065547],[10.993181885,59.759019027],[10.993083062,59.758990445],[10.992976947,59.759083484],[10.992845519,59.759045346],[10.992813584,59.759073342],[10.9927876,59.759065798],[10.993409789,59.758524243],[10.99263002,59.758297094],[10.991938848,59.758898663],[10.991693594,59.758827165],[10.991467366,59.759024056]]]},"properties":{"id":11377,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"warehouse","ref:bygningsnr":"300752944"},"type":"data_center","osm_id":935092287}}
{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.992826324,59.757448761],[10.994530281,59.757601396],[10.994634888,59.757303168],[10.992928332,59.757152545],[10.992826324,59.757448761]]]},"properties":{"id":15426,"tags":{"name":"Green Mountain Ytre Enebakk","telecom":"data_center","website":"https://greenmountain.no/","building":"industrial"},"type":"data_center","osm_id":1161764012}}
{"id":935092287,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9914674,59.7590241],[10.9916936,59.7588272],[10.9919389,59.7588987],[10.9926301,59.7582971],[10.9934098,59.7585243],[10.9927876,59.7590658],[10.9928136,59.7590734],[10.9928456,59.7590454],[10.992977,59.7590835],[10.9930831,59.7589905],[10.9931819,59.7590191],[10.9931289,59.7590656],[10.9932917,59.7591127],[10.9930223,59.759349],[10.9930739,59.7593639],[10.9929482,59.7594743],[10.992774,59.759424],[10.992781,59.759407],[10.9914674,59.7590241]]]},"properties":{"id":11377,"tags":{"building":"warehouse","name":"Green Mountain Ytre Enebakk","ref:bygningsnr":"300752944","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":935092287}}
{"id":1161764012,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.9928264,59.7574488],[10.9929284,59.7571526],[10.9946349,59.7573032],[10.9945303,59.7576014],[10.9928264,59.7574488]]]},"properties":{"id":15426,"tags":{"building":"industrial","name":"Green Mountain Ytre Enebakk","source":"Norway Orthophoto 2024;https://greenmountain.no/data-center/osl1-enebakk/","telecom":"data_center","website":"https://greenmountain.no/"},"type":"data_center","osm_id":1161764012}}
{"id":30724580,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.808790995,59.924984985],[10.808893925,59.925031923],[10.80893793,59.92500594],[10.808982941,59.925025972],[10.809042955,59.92501298],[10.809083943,59.925031923],[10.809012948,59.925077856],[10.80926583,59.925189755],[10.80955115,59.925025972],[10.809676543,59.925081293],[10.81032899,59.924701928],[10.809890365,59.924508138],[10.80982155,59.924515347],[10.809691631,59.924457847],[10.808820667,59.924557592],[10.809099365,59.924699581],[10.808806418,59.924865291],[10.808918149,59.924914828],[10.808875988,59.924937962],[10.808790995,59.924984985]]]},"properties":{"id":261,"tags":{"name":"Stack Infrastructure OSLO1A","telecom":"data_center","building":"office","operator":"Stack Infrastructure","ref:bygningsnr":"80877293","building:levels":"4","operator:wikidata":"Q115477173"},"type":"data_center","osm_id":30724580}}
{"id":80871917,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[10.83408892,59.938588981],[10.834305927,59.938720996],[10.834575992,59.938720996],[10.834574987,59.938736],[10.834581943,59.938736922],[10.834579932,59.938771958],[10.834736925,59.938771958],[10.834734997,59.93893792],[10.836717988,59.938944961],[10.836717988,59.938983937],[10.836817984,59.938983937],[10.83681899,59.938944961],[10.836844974,59.938944961],[10.836846985,59.938774976],[10.836841956,59.938774976],[10.836842962,59.938744968],[10.836849919,59.938744968],[10.836851931,59.938653941],[10.836683957,59.938652935],[10.836684125,59.938640278],[10.836686975,59.938414973],[10.83490297,59.938406926],[10.834899953,59.938646984],[10.834812949,59.938645978],[10.83481496,59.938551933],[10.834579932,59.938550927],[10.834580938,59.938564925],[10.834567946,59.938564925],[10.834567946,59.938551933],[10.834089926,59.938549922],[10.83408892,59.938588981]]]},"properties":{"id":1464,"tags":{"name":"Bulk Data Centers Oslo Internet Exchange","telecom":"data_center","website":"https://bulkinfrastructure.com/data-centers/locations/os-ix","building":"industrial","ref:bygningsnr":"80709943"},"type":"data_center","osm_id":80871917}}
{"id":12158409495,"type":"Feature","geometry":{"type":"Point","coordinates":[9.689363435,62.59439936]},"properties":{"id":28723,"tags":{"name":"VisBook AS","telecom":"data_center","website":"https://visbook.com/"},"type":"data_center","osm_id":12158409495}}
@@ -4349,3 +4349,4 @@
{"id":540895898,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[101.71747909,56.303855963],[101.71782015,56.304420065],[101.721113819,56.303806929],[101.720772759,56.303242994],[101.71747909,56.303855963]]]},"properties":{"id":8271,"tags":{"name":"Битривер Рус","name:en":"Bitriver Rus","name:ru":"Битривер Рус","telecom":"data_center","website":"https://bitriver.farm/","building":"data_center"},"type":"data_center","osm_id":540895898}}
{"id":1082803064,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[123.948021193,47.340264299],[123.948354625,47.340268239],[123.948490663,47.340269831],[123.948488568,47.340349292],[123.948809762,47.340353064],[123.948811942,47.340267485],[123.948918643,47.340268658],[123.948922918,47.340137817],[123.948023959,47.340126082],[123.948021193,47.340264299]]]},"properties":{"id":13377,"tags":{"name":"联通云数据中心·齐齐哈尔","name:en":"Unicom Data Center of Qiqihar","name:zh":"联通云数据中心·齐齐哈尔","telecom":"data_center","building":"yes","name:zh-Hans":"联通云数据中心·齐齐哈尔","name:zh-Hant":"聯通雲數據中心·齊齊哈爾"},"type":"data_center","osm_id":1082803064}}
{"id":442479059,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[142.76019226,46.910096898],[142.760192427,46.910325724],[142.760447656,46.910328155],[142.760460397,46.910102263],[142.76019226,46.910096898]]]},"properties":{"id":7471,"tags":{"telecom":"data_center","building":"yes"},"type":"data_center","osm_id":442479059}}
{"id":1551029155,"type":"Feature","geometry":{"type":"Polygon","coordinates":[[[7.1069147,62.8627625],[7.1071207,62.8621617],[7.1075917,62.8616616],[7.1088647,62.8620476],[7.1106308,62.862645],[7.1098655,62.8632827],[7.1089604,62.8630042],[7.1086734,62.8632156],[7.1069147,62.8627625]]]},"properties":{"id":29471,"tags":{"layer":"-1","loc_name":"Troll Housing Klempertåsen","name":"Troll Housing","operator":"Troll Housing AS","telecom":"data_center","website":"https://www.trollhousing.no/"},"type":"data_center","osm_id":1551029155}}
+19 -9
View File
@@ -11,17 +11,16 @@ const FOLD = {
};
/**
* Comparable form of a place name, shared by the bundled packs, the
* OpenFreeMap street and grounds matchers and the Nominatim server: case
* folded, accents removed from Latin letters only, punctuation and symbols
* collapsed to single spaces, and a leading "the" dropped. Other scripts keep
* their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while "São Paulo",
* "sao paulo" and "SÃO PAULO!" meet.
* Script-aware fold of a place name: case folded, accents removed from Latin
* letters only, punctuation and symbols collapsed to single spaces. Other
* scripts keep their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while
* "São Paulo", "sao paulo" and "SÃO PAULO!" meet. Every word is kept: "The
* Hague" is not "Hague".
*
* @param {unknown} value
* @returns {string}
*/
export function foldPlaceName(value) {
export function foldPlaceText(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
@@ -29,8 +28,19 @@ export function foldPlaceName(value) {
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFKC')
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim()
.replace(/^the /, '');
.trim();
}
/**
* Comparable form of a place name, shared by the bundled packs, the
* OpenFreeMap street and grounds matchers and the Nominatim server:
* `foldPlaceText` with a leading "the" dropped.
*
* @param {unknown} value
* @returns {string}
*/
export function foldPlaceName(value) {
return foldPlaceText(value).replace(/^the /, '');
}
/**
+29 -20
View File
@@ -301,6 +301,8 @@
"AVAILABLE MISSIONS": "المهام المتاحة",
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا",
"Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.",
"30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح",
"RADIO": "الراديو",
"ENABLE": "تفعيل",
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
@@ -3226,7 +3228,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": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
@@ -3244,15 +3246,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}",
@@ -3263,12 +3265,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}: العدد غير معروف",
@@ -3276,10 +3278,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": "البحث عن صور حديثة",
@@ -3322,9 +3324,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": "اعرض تتبّع الرحلات المباشر",
@@ -3399,7 +3401,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": "للعلم فقط · لا يصلح لتخطيط الرحلات",
@@ -3407,7 +3409,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": "المجال الجوي المحظور والمقيّد",
@@ -3466,5 +3468,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} يوم · الاحتفاظ بغير المؤرخ"
}
+27 -18
View File
@@ -760,6 +760,8 @@
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN",
"Space Missions unavailable: the mission index could not load. Try again later.": "Raummissionen nicht verfügbar: Der Missionsindex konnte nicht geladen werden. Später erneut versuchen.",
"30-DAY MISSION INDEX UNAVAILABLE": "30-TAGE-MISSIONSINDEX NICHT VERFÜGBAR",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
"Internet radio and local SDR": "Internetradio und lokales SDR",
"STATION TAG": "KATEGORIE",
@@ -2918,7 +2920,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).",
@@ -2955,13 +2957,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",
@@ -3001,9 +3003,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",
@@ -3014,27 +3016,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",
@@ -3166,5 +3168,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"
}
+24 -15
View File
@@ -192,6 +192,8 @@
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Misiones espaciales no disponibles: no se pudo cargar el índice de misiones. Inténtalo más tarde.",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISIONES DE 30 DÍAS NO DISPONIBLE",
"RADIO": "RADIO",
"ENABLE": "ACTIVAR",
"Previous filtered radio station": "Emisora de radio filtrada anterior",
@@ -2992,7 +2994,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)",
@@ -3001,8 +3003,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",
@@ -3010,9 +3012,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",
@@ -3050,11 +3052,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}",
@@ -3068,7 +3070,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}",
@@ -3087,12 +3089,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}",
@@ -3109,7 +3111,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í",
@@ -3241,5 +3243,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"
}
+37 -28
View File
@@ -2805,6 +2805,8 @@
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missions spatiales indisponibles : impossible de charger l’index des missions. Réessayez plus tard.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDEX DES MISSIONS À 30 JOURS INDISPONIBLE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
"Internet radio and local SDR": "Radio Internet et SDR local",
"STATION TAG": "TAG DE STATION",
@@ -2983,18 +2985,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",
@@ -3025,7 +3027,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)",
@@ -3034,7 +3036,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}",
@@ -3044,7 +3046,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",
@@ -3052,9 +3054,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}",
@@ -3073,43 +3075,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",
@@ -3241,5 +3243,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"
}
+30 -21
View File
@@ -2805,6 +2805,8 @@
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.",
"30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
"Internet radio and local SDR": "Radio internet e SDR locale",
"STATION TAG": "TAG STAZIONE",
@@ -2983,18 +2985,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",
@@ -3025,7 +3027,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)",
@@ -3034,7 +3036,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}",
@@ -3052,10 +3054,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}",
@@ -3074,8 +3076,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",
@@ -3083,16 +3085,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}",
@@ -3108,8 +3110,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",
@@ -3241,5 +3243,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"
}
+44 -35
View File
@@ -2663,6 +2663,8 @@
"AVAILABLE MISSIONS": "利用可能なミッション",
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中",
"Space Missions unavailable: the mission index could not load. Try again later.": "宇宙ミッションを利用できません:ミッション一覧を読み込めませんでした。後でもう一度お試しください。",
"30-DAY MISSION INDEX UNAVAILABLE": "30日間のミッション一覧を取得できません",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
"Internet radio and local SDR": "インターネットラジオとローカルSDR",
"STATION TAG": "局タグ",
@@ -2843,18 +2845,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": "シーンを作成・編集",
@@ -2885,10 +2887,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": "火災放射強度の最小値(メガワット)",
@@ -2906,16 +2908,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}件"
},
@@ -2924,45 +2926,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": "地球全体ビューに戻して",
@@ -3091,5 +3093,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}日以内 · 日付なしは保持"
}
+32 -23
View File
@@ -2807,6 +2807,8 @@
"AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS",
"SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR",
"LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS",
"Space Missions unavailable: the mission index could not load. Try again later.": "Missões espaciais indisponíveis: não foi possível carregar o índice de missões. Tente novamente mais tarde.",
"30-DAY MISSION INDEX UNAVAILABLE": "ÍNDICE DE MISSÕES DE 30 DIAS INDISPONÍVEL",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA",
"Internet radio and local SDR": "Rádio pela internet e SDR local",
"STATION TAG": "TAG DA ESTAÇÃO",
@@ -2987,14 +2989,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",
@@ -3034,7 +3036,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}",
@@ -3053,18 +3055,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",
@@ -3076,23 +3078,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}",
@@ -3108,10 +3110,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",
@@ -3241,5 +3243,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"
}
+30 -21
View File
@@ -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} МВт",
@@ -722,6 +722,8 @@
"Contact Context actions": "Действия контекста контакта",
"Available Space Missions": "Доступные космические миссии",
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ",
"Space Missions unavailable: the mission index could not load. Try again later.": "Космические миссии недоступны: не удалось загрузить список миссий. Повторите попытку позже.",
"30-DAY MISSION INDEX UNAVAILABLE": "ИНДЕКС МИССИЙ ЗА 30 ДНЕЙ НЕДОСТУПЕН",
"Radio playback": "Воспроизведение радио",
"Radio volume": "Громкость радио",
"STATION TAG": "ТЕГ СТАНЦИИ",
@@ -3062,27 +3064,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": "ошибка прокси маршрутов",
@@ -3116,13 +3118,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}",
@@ -3143,7 +3145,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}",
@@ -3159,12 +3161,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}",
@@ -3183,11 +3185,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": "Вернись к виду всего глобуса",
@@ -3316,5 +3318,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} дн. · метки без даты сохранены"
}
+31 -22
View File
@@ -2875,6 +2875,8 @@
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ",
"Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.",
"30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
"STATION TAG": "ТЕГ СТАНЦІЇ",
@@ -3063,19 +3065,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": "СТАРТ",
@@ -3116,7 +3118,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": "природний регіон",
@@ -3147,9 +3149,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": "Знайти місця",
@@ -3157,14 +3159,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}",
@@ -3178,16 +3180,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": "Поверни вигляд глобуса",
@@ -3316,5 +3318,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} дн. · позначки без дати збережено"
}
+30 -21
View File
@@ -2562,6 +2562,8 @@
"AVAILABLE MISSIONS": "可用任务",
"SELECT A MISSION TO INSPECT": "选择一项任务以查看",
"LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引",
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任务不可用:无法加载任务索引。请稍后重试。",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任务索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择",
"Internet radio and local SDR": "网络电台与本地 SDR",
"STATION TAG": "电台标签",
@@ -2850,26 +2852,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": "限制区",
@@ -2915,7 +2917,7 @@
"Not drawn: {parts}": "未绘制:{parts}",
"approximate": "近似",
"{pct}% cloud": "云量 {pct}%",
"Over {area}": "{area} 上空",
"Over {area}": "覆盖 {area}",
"{count} of {total} acquisitions match": {
"other": "匹配影像:{count}/{total}"
},
@@ -2930,36 +2932,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": "滑雪道",
@@ -3091,5 +3093,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}天内 · 保留无日期标记"
}
+34 -25
View File
@@ -2550,6 +2550,8 @@
"AVAILABLE MISSIONS": "可用任務",
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引",
"Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。",
"30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用",
"TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
"Internet radio and local SDR": "網絡電臺與本地 SDR",
"STATION TAG": "電臺標籤",
@@ -2853,16 +2855,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": "場景",
@@ -2889,17 +2891,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": "請先退出座艙,再飛往繪製位置",
@@ -2915,7 +2917,7 @@
"Not drawn: {parts}": "未繪製:{parts}",
"approximate": "近似",
"{pct}% cloud": "雲量 {pct}%",
"Over {area}": "{area} 上空",
"Over {area}": "覆蓋 {area}",
"{count} of {total} acquisitions match": {
"other": "匹配影像:{count}/{total}"
},
@@ -2930,36 +2932,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": "滑雪道",
@@ -3091,5 +3093,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}天內 · 保留無日期標記"
}
+7 -9
View File
@@ -1,3 +1,5 @@
import { foldPlaceText } from './data/placeNames.js';
/** Photon/OpenStreetMap place-search adapter. */
const PHOTON_ENDPOINT = 'https://photon.komoot.io/api/';
@@ -233,19 +235,15 @@ export function photonSearchUrl(
}
/**
* Place name reduced to comparable form: unaccented, lowercased, punctuation
* collapsed to single spaces. "Huế" and "Hue" have to compare equal, because a
* user typing either means the same city.
* Place name reduced to comparable form: lowercased, Latin accents removed,
* other scripts preserved, and punctuation collapsed to single spaces. "Huế"
* and "Hue" have to compare equal, because a user typing either means the same
* city.
* @param {string} text
* @returns {string}
*/
export function normalizeToponym(text) {
return String(text ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
return foldPlaceText(text);
}
/**
+58
View File
@@ -35,6 +35,12 @@ import {
import { geocodeNavigationMode } from './locations.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const MOSCOW_RESPONSES = JSON.parse(
fs.readFileSync(
path.join(ROOT, 'src/data/fixtures/photon-moscow-search.json'),
'utf8',
),
);
/** Photon's real answer for "Hanoi", trimmed to the fields we consume. */
const HANOI = {
@@ -249,6 +255,58 @@ test('a biased near-miss is refused, and the unbiased answer is taken instead',
assert.equal(fetchImpl.urls.length, 2, 'the refused bias costs exactly one extra request');
});
test('a non-Latin exact match keeps the nearby namesake', async () => {
const fetchImpl = photonPair(
MOSCOW_RESPONSES.biased.features,
MOSCOW_RESPONSES.unbiased.features,
);
const result = await geocodeKeyless('Москва', {
bias: '38.90,71.80|39.00,71.90',
fetchImpl,
});
assert.equal(result.label, 'Москва, Тоҷикистон');
assert.equal(fetchImpl.urls.length, 1);
});
test('toponym folding preserves non-Latin scripts', () => {
const cases = [
['Huế', 'hue'],
['Москва', 'москва'],
['Αθήνα', 'αθήνα'],
['القاهرة', 'القاهرة'],
['東京', '東京'],
['São 東京', 'sao 東京'],
['Straße', 'strasse'],
['Tromsø', 'tromso'],
['Łódź', 'lodz'],
['The Hague', 'the hague'],
];
for (const [input, expected] of cases) {
assert.equal(normalizeToponym(input), expected, input);
}
});
test('a leading "the" is part of the name a nearby match must equal', async () => {
// "The Hague" over upstate New York means the Dutch city, not Hague, NY.
const hagueNy = {
geometry: { type: 'Point', coordinates: [-73.5004, 43.6967] },
properties: { osm_key: 'place', osm_value: 'town', type: 'city', name: 'Hague', country: 'United States' },
};
const theHague = {
geometry: { type: 'Point', coordinates: [4.3113, 52.0799] },
properties: { osm_key: 'place', osm_value: 'city', type: 'city', name: 'The Hague', country: 'Nederland' },
};
const fetchImpl = photonPair([hagueNy], [theHague]);
const result = await geocodeKeyless('The Hague', { bias: '43.65,-73.55|43.75,-73.45', fetchImpl });
assert.equal(Math.round(result.lat), 52, `got ${result.label}`);
assert.equal(fetchImpl.urls.length, 2);
});
test('a biased match that leads with the name is taken without a second request', async () => {
// The other half of the rule, and the reason bias exists at all: "Sixth
// Street" over Austin must stay Austin's.
+67
View File
@@ -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();
+255 -1
View File
@@ -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 });
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createFlightState } from './state.js';
import { createRendering } from './rendering.js';
import {
FLEET_DR_INTERVAL_MS,
HIDDEN_CONTACT_DR_INTERVAL_MS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
} from './policy.js';
// Contacts hidden behind the horizon re-reckon on one fleet tick per rotation
// instead of every tick. Their positions still feed hidden-inclusive proximity
// queries, so the rotation must reach every contact about once a second, and
// it must spread them across ticks so no single tick carries the whole fleet.
const icao = (n) => n.toString(16).padStart(6, '0');
test('the rotation revisits each hidden contact about once a second', () => {
const periodMs = HIDDEN_CONTACT_DR_TICKS * FLEET_DR_INTERVAL_MS;
assert.ok(HIDDEN_CONTACT_DR_TICKS > 1);
assert.ok(
Math.abs(periodMs - HIDDEN_CONTACT_DR_INTERVAL_MS) <= FLEET_DR_INTERVAL_MS,
);
});
test('a contact keeps the same slot, inside the rotation', () => {
for (let n = 0; n < 500; n++) {
const phase = hiddenContactDrPhase(icao(n * 7919));
assert.ok(Number.isInteger(phase));
assert.ok(phase >= 0 && phase < HIDDEN_CONTACT_DR_TICKS);
assert.equal(hiddenContactDrPhase(icao(n * 7919)), phase);
}
});
test('a worldwide fleet is spread evenly enough that no tick carries a burst', () => {
const fleet = 13000;
const perSlot = new Array(HIDDEN_CONTACT_DR_TICKS).fill(0);
// Real ICAO24 addresses cluster by national block; sample several blocks.
for (let n = 0; n < fleet; n++)
perSlot[hiddenContactDrPhase(icao(0x0a0000 + n * 53))]++;
const fair = fleet / HIDDEN_CONTACT_DR_TICKS;
for (const count of perSlot) assert.ok(count < fair * 1.5, `${count} > 1.5×`);
});
/** Real fleet tick and horizon culling, with motion and presentation injected. */
function fleetFixture() {
let now = 1000;
let reckonings = 0;
const services = {
groundSnap: { createGroundSnap: () => ({ clear() {} }) },
militaryRegistry: { isMilitaryIcao: () => false },
aircraftPresentation: { isTr3b: () => false },
camera: {},
focus: {
focusNowMs: () => now,
getFocusTarget: () => null,
nearFarScalarValueAtDistance: () => 1,
advanceProjectedSpriteFocus: () => ({ factor: 1 }),
},
recession: {
applyAircraftBillboardTreatment: () => ({
factors: { scale: 1 },
alpha: 1,
}),
},
};
const flightState = createFlightState({ services });
const camera = {
positionWC: Cesium.Cartesian3.fromDegrees(0, 0, 100),
positionCartographic: { height: 100 },
heading: 0,
pitch: 0,
roll: 0,
frustum: {
computeCullingVolume: () => ({
computeVisibility: () => Cesium.Intersect.INSIDE,
}),
},
};
const id = 'abc123';
// 111 km away at ground level is beyond a 100 m camera's horizon, but
// inside the model keep radius. No WebGL scene is needed for the culler.
const billboard = {
show: false,
position: Cesium.Cartesian3.fromDegrees(1, 0, 0),
};
flightState._viewer = { camera, scene: {} };
flightState._billboardCollection = { show: true };
flightState._billboards.set(id, billboard);
flightState.records.data.set(id, {});
flightState._models3dEnabled = false;
// Far cockpit contacts avoid glyph rasterization, keeping the fixture DOM-free.
flightState._cockpitContactMode = true;
const parts = {
tracking: { _refreshCockpitNearContacts() {} },
motion: {
_deadReckon() {
reckonings++;
return billboard.position;
},
_floorGroundedDisplayPosition(id, info, position) {
return position;
},
},
};
const rendering = createRendering({ flightState, services, parts });
return {
flightState,
camera,
billboard,
id,
reckonings: () => reckonings,
tick() {
now += FLEET_DR_INTERVAL_MS;
rendering._fleetTick();
},
offTurn() {
flightState._fleetTickSeq =
(hiddenContactDrPhase(id) + 1) % HIDDEN_CONTACT_DR_TICKS;
},
};
}
test('the fleet tick dead-reckons a hidden contact once per complete rotation', () => {
const f = fleetFixture();
for (let i = 0; i < HIDDEN_CONTACT_DR_TICKS; i++) f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(f.billboard.show, false);
});
test('a camera reveal bypasses the hidden rotation on the same fleet tick', () => {
const f = fleetFixture();
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 0);
f.camera.positionWC = Cesium.Cartesian3.fromDegrees(1, 0, 100);
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(f.billboard.show, true);
});
test('a hidden contact with a retained fleet model is never rotation-skipped', () => {
const f = fleetFixture();
f.flightState._models3dEnabled = true;
const model = { ready: true, show: true };
f.flightState._models.set(f.id, model);
f.offTurn();
f.tick();
assert.equal(f.reckonings(), 1);
assert.equal(
f.flightState._models.get(f.id),
model,
'model remains in keep range',
);
assert.equal(
model.show,
false,
'the full pass hides its model beyond the horizon',
);
});
+31
View File
@@ -198,6 +198,37 @@ export const FLEET_DR_INTERVAL_MS = 80;
export const ROTATION_REFRESH_MS = 1000;
/**
* @constant {number} Dead-reckoning cadence (ms) for contacts hidden behind the
* horizon. The worldwide feed carries ~13k contacts and a zoomed-in view shows
* well under 1% of them; the rest still need positions for hidden-inclusive
* proximity queries, but ~1 s old (≈250 m for an airliner) is well inside the
* 30 s poll's own uncertainty. Each hidden contact is re-reckoned on one tick in
* every HIDDEN_CONTACT_DR_TICKS, staggered by id so no tick carries them all.
*/
export const HIDDEN_CONTACT_DR_INTERVAL_MS = 1000;
export const HIDDEN_CONTACT_DR_TICKS = Math.max(
1,
Math.round(HIDDEN_CONTACT_DR_INTERVAL_MS / FLEET_DR_INTERVAL_MS),
);
/**
* Stable slot in [0, HIDDEN_CONTACT_DR_TICKS) for a contact id, so each hidden
* contact re-reckons on the same tick of every rotation.
* @param {string} id Contact id (ICAO24).
* @returns {number}
*/
export function hiddenContactDrPhase(id) {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++)
hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return hash % HIDDEN_CONTACT_DR_TICKS;
}
/** Max course slew (deg/s) — well above real turns (≤4°/s), hides fix-boundary
* steps. Scaled down toward COURSE_MIN_DPS at low speed (courseSlewCapDps). */
+24
View File
@@ -36,6 +36,8 @@ import {
COURSE_SLEW_DT_MAX_SEC,
ROTATION_REFRESH_MS,
COURSE_MAX_DPS,
HIDDEN_CONTACT_DR_TICKS,
hiddenContactDrPhase,
} from './policy.js';
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
@@ -839,6 +841,12 @@ export function createRendering({
}
}
/** The contact's hidden-rotation slot, cached on its billboard. */
function _hiddenDrPhase(bb, icao24) {
bb._gevHiddenDrPhase ??= hiddenContactDrPhase(icao24);
return bb._gevHiddenDrPhase;
}
function _fleetTick() {
if (
!flightState._viewer ||
@@ -965,9 +973,25 @@ export function createRendering({
for (const icao of toRelease) _releaseModel(icao);
}
const hiddenDrPhase = flightState._fleetTickSeq++ % HIDDEN_CONTACT_DR_TICKS;
for (const [icao24, bb] of flightState._billboards) {
if (icao24 === flightState._trackedIcao) continue; // tracked entity owns its own motion
// A contact still behind the horizon at its last position would only be
// re-reckoned to be hidden again, so off its turn it keeps that position
// (see HIDDEN_CONTACT_DR_TICKS). Anything the stale position puts in view,
// and any contact with a fleet model, takes the full pass below at once.
if (
!bb.show &&
_hiddenDrPhase(bb, icao24) !== hiddenDrPhase &&
!flightState._models.has(icao24) &&
!occluder.isPointVisible(
flightState._cullPositions.get(icao24) || bb.position,
)
)
continue;
const info = flightState.records.data.get(icao24);
const dr = parts.motion._deadReckon(
+4
View File
@@ -204,6 +204,10 @@ export function createFlightState({ source, services }) {
flightState._lastFleetTickMs = 0;
/** @type {number} Fleet ticks run; selects which hidden contacts re-reckon */
flightState._fleetTickSeq = 0;
/** @type {string} Camera pose signature at the last rotation pass */
flightState._lastCamPoseSig = '';
+190 -57
View File
@@ -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,
+440 -9
View File
@@ -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', '&lt;Null&gt;'),
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
View File
@@ -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;
+83
View File
@@ -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', '&lt;Null&gt;')}${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, [
+4
View File
@@ -12,6 +12,9 @@ export function createControls({ state: layerState, services, parts, source }) {
/** Release only Space Mission camera ownership, preserving layer and selection state. */
releaseCameraOwnership() {
// Navigation takes the camera; a failed entry must not restore the old view.
layerState._entryCameraView = null;
layerState._cameraReleased = true;
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
parts.selection.stopMissionZoomAnchor();
@@ -57,6 +60,7 @@ export function createControls({ state: layerState, services, parts, source }) {
orbitMatches: layerState._orbitMatches,
lastUpdate: layerState._lastUpdate,
error: layerState._lastError,
errorAt: layerState._lastErrorAt,
stale: layerState._stale,
};
},
+495
View File
@@ -0,0 +1,495 @@
// The real manager, Context coordinator and launches ingestion run against a
// stubbed /api/launches, so a failed first load takes the browser's path.
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { DataLayerManager } from '../../data/manager.js';
import { ContextControls } from '../../ui/contextControls.js';
import { spaceMissionsIndexFailure } from '../../ui/contextActions.js';
import { createLaunchSource } from './source.js';
import { createState } from './state.js';
import { createModel } from './model.js';
import { createPolicyHelpers } from './policyHelpers.js';
import { createIngestion } from './ingestion.js';
import { createPanel } from './panel.js';
import { createControls } from './controls.js';
import { createLifecycle } from './lifecycle.js';
import { createReplay } from './replay.js';
const noop = () => {};
const launchRecord = {
id: 'fixture-launch',
name: 'Fixture mission',
net: new Date(Date.now() - 3600000).toISOString(),
status: { name: 'Launch Successful' },
pad: { name: 'Fixture pad', latitude: 28.6, longitude: -80.6 },
rocket: { payloads: [] },
};
function plainLayer(id, name, params = {}) {
let current = { ...params };
return {
id,
name,
icon: '',
source: 'test',
updateInterval: -1,
init: noop,
enable: noop,
disable: noop,
update: noop,
getParams: () => ({ ...current }),
setParams(next) {
current = { ...current, ...next };
return true;
},
};
}
function fakeCamera() {
const camera = {
position: new Cesium.Cartesian3(1, 2, 3),
heading: 0.1,
pitch: -0.5,
roll: 0,
flights: 0,
cancelled: 0,
flight: null,
get positionWC() {
return camera.position;
},
// Like Cesium, a new flight cancels the one in progress.
flyTo({ destination, complete, cancel }) {
camera.flight?.cancel?.();
camera.flight = { complete, cancel };
camera.flights += 1;
camera.position = Cesium.Cartesian3.clone(destination);
camera.heading = 0;
camera.pitch = -Math.PI / 2;
},
land() {
const flight = camera.flight;
camera.flight = null;
flight?.complete?.();
},
cancelFlight() {
camera.cancelled += 1;
const flight = camera.flight;
camera.flight = null;
flight?.cancel?.();
},
lookAtTransform: noop,
setView({ destination, orientation }) {
camera.position = Cesium.Cartesian3.clone(destination);
camera.heading = orientation.heading;
camera.pitch = orientation.pitch;
camera.roll = orientation.roll;
},
};
return camera;
}
async function fixture(t) {
const upstream = { status: 429, results: [launchRecord] };
const source = createLaunchSource({
async fetchImpl(url) {
if (url === '/api/celestrak/active') return new Response('');
if (upstream.hold) await upstream.hold;
return Response.json(
upstream.status === 200 ? { results: upstream.results } : {},
{ status: upstream.status },
);
},
});
const services = {
overlays: {
setOverlayEntries: noop,
setOverlaySourceVisible: noop,
clearOverlaySource: noop,
},
render: { holdContinuousRender: noop, releaseContinuousRender: noop },
};
const state = createState({ services });
const camera = fakeCamera();
const canvas = Object.assign(new EventTarget(), {
disableRootEvents: true,
onwheel: null,
style: {},
clientWidth: 1200,
clientHeight: 800,
});
const viewer = {
camera,
dataSources: new Cesium.DataSourceCollection(),
scene: { canvas, preRender: new Cesium.Event(), drillPick: () => [] },
};
// Scene presentation is stubbed; ingestion, lifecycle and panel are real.
const parts = {
overlays: {
shortMissionLabel: (name) => name,
createReplayVehicleOverlay: noop,
clearMissionOverlaySources: noop,
hideReplayVehicleOverlay: noop,
destroyReplayVehicleOverlay: noop,
syncMissionOverlayEntries: noop,
},
selection: {
focusFullGlobe(target, flight) {
target.camera.flyTo({
destination: new Cesium.Cartesian3(9, 9, 9),
...flight,
});
return true;
},
setSelectedMission: noop,
focusMission: noop,
stopMissionZoomAnchor: noop,
entityLaunchId: () => null,
},
orbitRendering: {
removeMissionOrbitPrimitives: noop,
syncMissionOrbitPrimitiveVisibility: noop,
},
launchPad: {
initLaunchPadZonePrimitive: noop,
hideLaunchPadZone: noop,
destroyLaunchPadZonePrimitive: noop,
},
rendering: {
updateMissionFrame: noop,
addLaunchEntity(record) {
state._dataSource.entities.add({
id: record.id,
position: Cesium.Cartesian3.fromDegrees(record.lon, record.lat),
});
},
},
};
const context = { state, services, parts, source };
parts.policyHelpers = createPolicyHelpers(context);
parts.model = createModel(context);
parts.panel = createPanel(context);
parts.replay = createReplay(context);
parts.ingestion = createIngestion(context);
parts.controls = createControls(context);
parts.lifecycle = createLifecycle(context);
const missions = Object.assign(
{},
parts.controls.methods,
parts.lifecycle.methods,
parts.ingestion.methods,
);
const rosters = [];
parts.panel.subscribePanel((view) => rosters.push(view.roster));
const manager = new DataLayerManager(viewer);
manager.register(plainLayer('earthquakes', 'Earthquakes (24h)'));
manager.register(plainLayer('satellites', 'Satellites'));
manager.register(plainLayer('wind', 'Wind'));
manager.register(missions);
missions.attachDataManager(manager);
const toasts = [];
const controls = new ContextControls({
elements: {},
installations: {},
actions: {
getCockpit: () => null,
claimVisualAuthority: noop,
showToast: (message) => toasts.push(message),
setClearBusy: noop,
syncDetection: noop,
scheduleLayout: noop,
refreshRadio: noop,
setPanelCollapsed: noop,
},
});
controls.connect(manager);
t.after(async () => {
controls.stop();
controls.disconnect();
for (const id of ['rocket-launches', 'satellites', 'earthquakes', 'wind'])
await manager.setEnabled(id, false);
await missions.destroy(viewer);
});
const enabledIds = () => [...manager.getEnabledLayerIds()].sort();
const view = () => ({
x: camera.position.x,
y: camera.position.y,
z: camera.position.z,
heading: camera.heading,
pitch: camera.pitch,
});
const settle = async () => {
await controls._waitForContextLayerSettlement();
for (const id of ['rocket-launches', 'satellites', 'earthquakes'])
await manager.waitForLayerSettled(id);
await controls._waitForContextLayerSettlement();
};
return {
upstream,
manager,
controls,
missions,
toasts,
state,
camera,
rosters,
enabledIds,
view,
settle,
};
}
const entries = {
direct: (f) =>
f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
'Context-panel': async (f) =>
(
await f.controls.setContextMode('space-missions', {
claimVisualAuthority: false,
})
).ok,
};
for (const [path, enter] of Object.entries(entries))
test(`a ${path} Space Missions entry that gets HTTP 429 rolls back instead of isolating`, async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const before = f.view();
const entered = await enter(f);
await f.settle();
assert.equal(entered, false, 'a failed mission index fails the enable');
assert.deepEqual(f.enabledIds(), ['earthquakes']);
assert.equal(f.controls.getContextModeState().mode, null);
assert.equal(f.controls.getContextModeState().active, false);
assert.equal(f.missions.getStats().error, 'HTTP 429');
assert.deepEqual(f.view(), before, 'the pre-entry view comes back');
assert.ok(f.camera.cancelled > 0, 'the full-globe flight is cancelled');
assert.equal(
await f.manager.setEnabled('wind', true, { origin: 'user' }),
true,
'an unrelated layer is not blocked after the failed entry',
);
});
// Holds the /api/launches reply until the returned release is called.
function holdReply(f) {
let release;
f.upstream.hold = new Promise((resolve) => {
release = resolve;
});
return () => {
f.upstream.hold = null;
release();
};
}
async function untilFlown(f) {
for (let i = 0; i < 200 && f.camera.flights === 0; i++)
await new Promise((resolve) => setTimeout(resolve, 5));
assert.ok(f.camera.flights > 0, 'the entry started its full-globe flight');
}
test('a landed entry flight still gives the view back after a failed first load', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const before = f.view();
const release = holdReply(f);
const entering = f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await untilFlown(f);
f.camera.land();
release();
assert.equal(await entering, false);
await f.settle();
assert.deepEqual(f.view(), before);
});
test('a camera move after the entry flight survives a failed first load', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const release = holdReply(f);
const entering = f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await untilFlown(f);
f.camera.land();
// The user drags the globe while the mission index is still loading.
f.camera.setView({
destination: new Cesium.Cartesian3(7, 7, 7),
orientation: { heading: 0.3, pitch: -0.2, roll: 0 },
});
const moved = f.view();
release();
assert.equal(await entering, false);
await f.settle();
assert.deepEqual(f.view(), moved);
});
test('a navigation during a failed first load is not cancelled by the rollback', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
const release = holdReply(f);
const entering = f.manager.setEnabled('rocket-launches', true, {
origin: 'user',
});
await untilFlown(f);
// Search or Home: navigation releases mission camera ownership, then flies.
f.missions.releaseCameraOwnership();
let navigationCancelled = false;
f.camera.flyTo({
destination: new Cesium.Cartesian3(5, 5, 5),
cancel: () => {
navigationCancelled = true;
},
});
const cancelled = f.camera.cancelled;
const navigated = f.view();
release();
assert.equal(await entering, false);
await f.settle();
assert.equal(
navigationCancelled,
false,
'the rollback does not cancel the Search/Home flight',
);
assert.equal(f.camera.cancelled, cancelled);
assert.deepEqual(f.view(), navigated);
});
test('the roster reports a failed mission index instead of loading forever', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const roster = f.rosters.at(-1);
assert.ok(roster, 'a failed load publishes a roster view');
assert.deepEqual(roster.entries, []);
assert.equal(roster.empty, '30-DAY MISSION INDEX UNAVAILABLE');
// A new attempt starts from the loading state, then shows the index.
f.upstream.status = 200;
const loadingFrom = f.rosters.length;
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.equal(f.rosters.slice(loadingFrom).includes(null), true);
assert.equal(f.rosters.at(-1).entries.length, 1);
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
});
test('a valid empty mission index is a successful entry', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
f.upstream.results = [];
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
assert.equal(
f.rosters.at(-1).empty,
'NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW',
);
assert.ok(f.camera.flights > 0, 'a successful entry frames the globe');
});
test('a refresh failure after a loaded index keeps the mode and its records', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const framed = f.view();
f.upstream.status = 429;
await f.missions.update();
await f.settle();
assert.equal(f.manager.isEnabled('rocket-launches'), true);
assert.equal(f.controls.getContextModeState().mode, 'space-missions');
assert.equal(f.missions.getStats().count, 1);
assert.equal(f.missions.getStats().error, 'HTTP 429');
assert.equal(f.rosters.at(-1).entries.length, 1);
// Leaving a loaded mode does not restore the pre-entry view.
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
assert.deepEqual(f.view(), framed);
});
test('exiting during a successful entry flight cancels the mission-owned flight', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
assert.equal(
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' }),
true,
);
await f.settle();
assert.ok(f.camera.flight, 'the 2.4 s entry flight is still in progress');
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
assert.equal(
f.camera.flight,
null,
'leaving Space Missions stops the flight it started (main cancels it)',
);
});
// The rollback hides the roster, so the toast has to name the cause.
test('a failed first load tells the user the mission index is unavailable', async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('earthquakes', true, { origin: 'user' });
await f.controls.setContextMode('space-missions', {
claimVisualAuthority: false,
});
await f.settle();
assert.equal(f.controls.getContextModeState().mode, null, 'rolled back');
assert.ok(
f.toasts.some((message) => /mission index/i.test(message)),
`visible feedback names the failure, got ${JSON.stringify(f.toasts)}`,
);
});
test('navigation taking the camera keeps a mission flight it asked to preserve', async (t) => {
const f = await fixture(t);
f.upstream.status = 200;
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
const flight = f.camera.flight;
assert.ok(flight, 'the entry flight is in progress');
f.missions.releaseCameraOwnership();
assert.equal(f.camera.flight, flight, 'navigation decides about the flight');
// After exit an inactive layer never cancels another owner's flight.
await f.manager.setEnabled('rocket-launches', false, { origin: 'user' });
await f.settle();
f.camera.flyTo({ destination: new Cesium.Cartesian3(1, 1, 1) });
const other = f.camera.flight;
f.missions.releaseCameraOwnership();
assert.equal(f.camera.flight, other);
});
test("an earlier attempt's index error is not blamed for a later failure", async (t) => {
const f = await fixture(t);
await f.manager.setEnabled('rocket-launches', true, { origin: 'user' });
await f.settle();
assert.match(spaceMissionsIndexFailure(f.manager), /mission index/);
assert.equal(spaceMissionsIndexFailure(f.manager, Date.now() + 1), null);
});
+14 -4
View File
@@ -50,8 +50,9 @@ export function createIngestion({
layerState._postTleRetryCount++;
layerState._retryTimer = setTimeout(() => {
layerState._retryTimer = null;
// A failed retry is already recorded in the layer stats.
if (layerState._enabled && token === layerState._lifecycleToken)
requestMissionUpdate();
requestMissionUpdate().catch(() => {});
}, POST_TLE_RETRY_DELAY_MS);
}
@@ -201,6 +202,10 @@ export function createIngestion({
if (layerState._renderedTleText === layerState._activeTleText)
clearPostTleRetry();
layerState._count = launches.length;
// The roster reads these to tell a loaded empty index from loading.
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
parts.panel.renderMissionRoster();
if (
!layerState._selectedLaunchId ||
@@ -219,13 +224,18 @@ export function createIngestion({
}
layerState._focusAfterActiveLookup = false;
parts.panel.renderMissionPanel();
layerState._lastUpdate = Date.now();
layerState._lastError = null;
layerState._stale = stale === true;
layerState._entryCameraView = null;
} catch (error) {
if (layerState._enabled && token === layerState._lifecycleToken) {
layerState._lastError = error.message;
layerState._lastErrorAt = Date.now();
console.warn('[Data:RocketLaunches] Fetch error:', error);
// No index yet: fail the enable so Context rolls back.
// After a loaded index, a failed refresh keeps its records.
if (layerState._lastUpdate === null) {
parts.panel.renderMissionRoster();
throw error;
}
}
}
}
+68 -1
View File
@@ -111,6 +111,43 @@ export function createLaunchHover({
};
}
/** The camera pose to give back, in world coordinates. */
function captureCameraView(viewer) {
const camera = viewer?.camera;
if (!camera?.positionWC) return null;
return {
destination: Cesium.Cartesian3.clone(camera.positionWC),
orientation: {
heading: camera.heading,
pitch: camera.pitch,
roll: camera.roll,
},
};
}
function sameCameraView(camera, view) {
return (
Cesium.Cartesian3.equalsEpsilon(
camera.positionWC,
view.destination,
0,
1,
) &&
['heading', 'pitch', 'roll'].every((key) =>
Cesium.Math.equalsEpsilon(camera[key], view.orientation[key], 0, 1e-6),
)
);
}
/** Restore the pre-entry view if the entry flight still holds the camera. */
function restoreCameraView(viewer, view, landedView) {
const camera = viewer?.camera;
if (!camera) return;
if (!landedView) camera.cancelFlight?.();
else if (!sameCameraView(camera, landedView)) return;
camera.setView(view);
}
export function createLifecycle({
state: layerState,
services,
@@ -200,7 +237,27 @@ export function createLifecycle({
layerState._updateDirty = false;
if (layerState._dataSource) layerState._dataSource.show = true;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
parts.selection.focusFullGlobe(layerState._viewer);
if (layerState._lastUpdate === null) {
// A new attempt at an index that never loaded starts from loading.
layerState._lastError = null;
parts.panel.renderMissionRoster();
// Kept until the mission index loads, so an entry that never does
// gives the view back. A newer flight takes the camera and drops it.
layerState._entryCameraView = captureCameraView(layerState._viewer);
layerState._entryLandedView = null;
}
const token = layerState._lifecycleToken;
const flying = parts.selection.focusFullGlobe(layerState._viewer, {
complete: () => {
if (token === layerState._lifecycleToken)
layerState._entryLandedView = captureCameraView(layerState._viewer);
},
cancel: () => {
if (token === layerState._lifecycleToken)
layerState._entryCameraView = null;
},
});
if (!flying) layerState._entryCameraView = null;
if (layerState._selectedLaunchId)
parts.selection.setSelectedMission(
layerState._selectedLaunchId,
@@ -214,12 +271,21 @@ export function createLifecycle({
hover?.reset();
layerState._sourceController.abort();
layerState._enabled = false;
const entryView = layerState._entryCameraView;
layerState._entryCameraView = null;
if (entryView)
restoreCameraView(
layerState._viewer,
entryView,
layerState._entryLandedView,
);
releaseContinuousRender('rocket-launches');
layerState._lifecycleToken++;
layerState._updateDirty = false;
parts.ingestion.clearPostTleRetry();
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
layerState._cameraReleased = true;
parts.selection.stopMissionZoomAnchor();
if (layerState._dataSource) layerState._dataSource.show = false;
parts.orbitRendering.syncMissionOrbitPrimitiveVisibility();
@@ -244,6 +310,7 @@ export function createLifecycle({
await parts.ingestion.restoreSatelliteDependency();
parts.panel.clearMissionRosterHover();
parts.replay.stopMissionReplay();
layerState._cameraReleased = true;
parts.selection.stopMissionZoomAnchor();
parts.ingestion.clearPostTleRetry();
if (layerState._clickHandler) layerState._clickHandler.destroy();
+9 -1
View File
@@ -263,8 +263,16 @@ export function createPanel({ state: layerState, services, parts, source }) {
publishPanel();
}
/** The roster entries as the list shows them. */
/** The roster as listed; null until the first index loads or fails. */
function presentRoster() {
if (layerState._lastUpdate === null && !layerState._launches.length) {
if (!layerState._lastError) return null;
return {
count: '—',
entries: [],
empty: t('30-DAY MISSION INDEX UNAVAILABLE'),
};
}
const entries = parts.policyHelpers
.missionRosterEntries(layerState._launches)
.map(({ launch, index }) => {
+1
View File
@@ -229,6 +229,7 @@ export function createPolicyHelpers({
layerState._viewer.camera.positionCartographic?.height,
);
const position = Cesium.Cartesian3.fromDegrees(launch.lon, launch.lat);
layerState._cameraReleased = false;
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(position, 0),
{
+8 -2
View File
@@ -314,9 +314,15 @@ export function createReplay({ state: layerState, services, parts, source }) {
layerState._replayCameraToken++;
if (layerState._replayCameraRemover) layerState._replayCameraRemover();
layerState._replayCameraRemover = null;
layerState._viewer?.camera?.cancelFlight();
if (layerState._viewer?.camera)
// Cancel the layer's own flights, never one navigation started after
// taking the camera.
if (
(stoppedLaunchId || !layerState._cameraReleased) &&
layerState._viewer?.camera
) {
layerState._viewer.camera.cancelFlight();
layerState._viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
}
layerState._replayCameraLaunchId = null;
layerState._replayPaused = false;
layerState._replayPausedAtMs = null;
+9 -2
View File
@@ -9,7 +9,8 @@ export function createSelection({
}) {
const { getKeyholeGeometry } = services.geometry;
function focusFullGlobe(viewer, duration = 2.4) {
/** @returns {boolean} Whether the flight started. */
function focusFullGlobe(viewer, { duration = 2.4, complete, cancel } = {}) {
const canvas = viewer?.scene?.canvas;
const height = canvas?.clientHeight || canvas?.height;
const width = canvas?.clientWidth || canvas?.width;
@@ -23,7 +24,7 @@ export function createSelection({
fovy <= 0 ||
fovy >= Math.PI
)
return;
return false;
const earthRadius = Cesium.Ellipsoid.WGS84.maximumRadius;
const keyholeRadius = getKeyholeGeometry(width, height).radius;
const targetScreenRadius = keyholeRadius * 0.61;
@@ -32,6 +33,7 @@ export function createSelection({
);
const distance = earthRadius / Math.max(Math.sin(angularRadius), 1e-4);
const altitude = Math.max(earthRadius * 1.55, distance - earthRadius);
layerState._cameraReleased = false;
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromRadians(
cartographic.longitude,
@@ -45,7 +47,10 @@ export function createSelection({
},
duration,
easingFunction: Cesium.EasingFunction.CUBIC_IN_OUT,
complete,
cancel,
});
return true;
}
function entityLaunchId(entity) {
@@ -187,6 +192,7 @@ export function createSelection({
Cesium.Cartesian3.cross(right, direction, new Cesium.Cartesian3()),
new Cesium.Cartesian3(),
);
layerState._cameraReleased = false;
layerState._viewer.camera.flyTo({
destination,
orientation: { direction, up },
@@ -209,6 +215,7 @@ export function createSelection({
layerState._viewer.selectedEntity = layerState._dataSource.entities.getById(
`rocket-launch:${launch.id}`,
);
layerState._cameraReleased = false;
layerState._viewer.camera.flyToBoundingSphere(
new Cesium.BoundingSphere(launchPosition, 0),
{
+9
View File
@@ -19,6 +19,15 @@ export function createState({ services }) {
state._lastError = null;
state._lastErrorAt = null;
state._entryCameraView = null;
state._entryLandedView = null;
/** Navigation took the camera; set until the layer flies again. */
state._cameraReleased = false;
state._stale = false;
state._orbitMatches = 0;
+22 -3
View File
@@ -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);
+56
View File
@@ -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 }),
+2
View File
@@ -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").
+17 -1
View File
@@ -4,6 +4,19 @@ import {
contextModeWord,
} from '../contextModePolicy.js';
/** Why a Space Missions entry failed: its mission index never loaded, and
* the error is no older than `since` (an earlier attempt's is not the cause). */
export function spaceMissionsIndexFailure(dataManager, since = 0) {
const stats = dataManager?.layers
?.get('rocket-launches')
?.module?.getStats?.();
return stats?.error && stats.lastUpdate == null && stats.errorAt >= since
? t(
'Space Missions unavailable: the mission index could not load. Try again later.',
)
: null;
}
export async function _runUserFacingContextAction(
operation,
message = t('Context could not restore every layer; try again'),
@@ -19,7 +32,10 @@ export async function _runUserFacingContextAction(
onFailure: (error) => {
if (this.destroyed) return;
console.warn('[Context] user-facing transition failed', error);
this.showToast(localizedMessage || message);
this.showToast(
localizedMessage ||
(typeof message === 'function' ? message() : message),
);
},
});
} finally {
+7 -3
View File
@@ -1,4 +1,5 @@
import { t } from '../i18n/index.js';
import { spaceMissionsIndexFailure } from './contextActions.js';
import { shouldExpandGlobalContextPanel } from '../rightRailPolicy.js';
export function _initGlobalContextPanel() {
@@ -59,12 +60,15 @@ export function _initGlobalContextPanel() {
const nextMode =
this._contextMode === 'space-missions' ? null : 'space-missions';
this._claimContextVisualAuthority();
const startedAt = Date.now();
void this._runUserFacingContextAction(
(notificationToken) =>
this._selectContextMode(nextMode, { notificationToken }),
t(
'Space Missions could not complete the requested transition; try again',
),
() =>
(nextMode && spaceMissionsIndexFailure(this._dataManager, startedAt)) ||
t(
'Space Missions could not complete the requested transition; try again',
),
).then((succeeded) => {
if (
!this.destroyed &&
+9 -4
View File
@@ -9,6 +9,7 @@ import {
spaceMissionEntryCancellationDisposition,
} from '../contextModePolicy.js';
import { localizeLayerName } from './layerNames.js';
import { spaceMissionsIndexFailure } from './contextActions.js';
/**
* A blocked-visibility reason in the UI language. Reasons stay English (voice
@@ -120,10 +121,14 @@ export function _handleContextLayerChange(change) {
return;
}
if (change?.type === 'visibility-failed') {
const failureMessage = t('{layerId} could not {action} cleanly', {
layerId: change.layerId,
action: change.enabled ? t('start') : t('stop'),
});
const failureMessage =
(change.layerId === 'rocket-launches' &&
change.enabled &&
spaceMissionsIndexFailure(this._dataManager)) ||
t('{layerId} could not {action} cleanly', {
layerId: change.layerId,
action: change.enabled ? t('start') : t('stop'),
});
// A failed direct Context-shell START has already had its siblings
// cleared by the visibility guard. Wait outside the synchronous manager
// notification for this queue to settle, then reconcile the complete
+5 -5
View File
@@ -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,