Compare commits

...
179 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
Sameh Khamis a33bc379cb Merge pull request #1595 from bilawalsidhu/fix/traffic-flow-error-labels
Traffic: name an account's traffic limit instead of a daily budget
2026-11-05 21:29:09 -08:00
Sameh Khamis 58adbb11c7 fix(traffic): name an account's traffic limit instead of a daily budget
A failed flow tile now carries the server's reason. A hosted deployment that limits each account answers USAGE_LIMIT_REACHED or RATE_LIMITED, which the traffic panel labels as the monthly allowance or a rate limit; a 503 with another reason reads as the traffic service being unavailable, not a missing key. The local proxy's no_key and daily budget labels are unchanged.
2026-11-05 21:21:26 -08:00
Sameh Khamis ad009ea1eb Merge pull request #1591 from lyehe/pr/wildlife-sightings
feat: Wildlife Sightings layer from iNaturalist and Acartia
2026-11-05 17:32:05 -08:00
Sameh Khamis 93d495b0e5 Merge branch 'main' into pr/wildlife-sightings 2026-11-05 17:24:06 -08:00
Sameh Khamis 3650dd818b Merge pull request #1532 from manjunath22466/fix/globe-free-look-orbit
fix(camera): keep the globe in view from orbit
2026-11-05 17:23:37 -08:00
Sameh Khamis d807c3007e Merge branch 'main' into pr/wildlife-sightings 2026-11-05 17:15:10 -08:00
Sameh Khamis fcad75a7b6 Merge remote-tracking branch 'origin/main' into fix/globe-free-look-orbit
# Conflicts:
#	src/cameraVerbs.js
2026-11-05 17:08:07 -08:00
Sameh Khamis d00ced38bf Merge pull request #1520 from manjunath22466/manjunath/hal-695-voice-tilt
Fix voice camera tilt from a top-down view
2026-11-05 17:07:42 -08:00
Sameh Khamis 4951393997 Keep the place in view when a late zoom out catches a tilt
From 400 km, a tilt to -5° that a zoom out x2.5 caught 70 frames in eased down from the camera's old pitch at the new range. That put the camera below the place's horizon: the first frame's crosshair hit the ground 186 km from London, and London stayed hidden for about 11 frames. The re-solve also left the pivot pitch at 0°, so a zoom in before the next frame computed an infinite clearance floor, was refused, and stayed at 1,000 km.

The re-solved pivot pitch now stops at the place's horizon limit, or where the pivot already was. A camera that the zoom leaves past that limit steps down to it with the zoom and holds it, and the zoom still reports that angle. A zoom in straight after brings the range back to 400 km.
2026-11-05 16:55:54 -08:00
Sameh Khamis 6d7fe53570 Let a shell without its navigation owner report no camera handoffs
The voice runner subscribes to camera handoffs as it is created. The Cyber sonar test builds its runner on a StyleManager made from the prototype, which has no navigation owner, so the facade's delegation threw and both Node jobs failed. The facade now returns nothing there, like its other optional owners; the real shell builds its navigation owner before voice creates a runner.
2026-11-05 16:55:54 -08:00
Sameh Khamis dac9b5627d fix(wildlife): wait for the longest effective delay among every failed request
One error was chosen before each failure's delay was known. An antimeridian
box 400 hid the other box's 503 Retry-After; a grid tile 400 with Retry-After
set a cooldown through the snapshot metadata; a short Retry-After on one tile
undercut a sibling 5xx's default backoff; and a huge Retry-After made the
deadline Infinity.

Every failed request now keeps its status and Retry-After. One function gives
each failure's delay: 4xx other than 429 none, 429 and 5xx their Retry-After
or the default backoff. The layer waits for the longest, and shows the
failure that set it. Retry-After is capped at one day, the proxy's own
daily-budget deadline, and non-finite values are ignored. Each publish of
one acquisition derives the default backoff from the backoff before it
started, so repeated tile publishes no longer double it.
2026-11-05 16:42:45 -08:00
Sameh Khamis 3438a88cc7 Ease a late zoomed tilt to its new limit instead of jumping there
From 400 km, a tilt to -5° that a zoom out x2.5 caught at -8.0005° reported -8°, and the next frame jumped to -9.407° and ended there. The zoom kept the camera's pitch as the goal when it was already past the new range's horizon limit, but at the same pivot pitch the longer range looks down more steeply, so the next lookAt dropped the camera 1.4° at once.

The zoom now re-solves the pivot pitch so the new range keeps the camera's pitch, and aims the tilt at the asked angle or the new limit, even when that limit is below where the camera stands: the tilt eases down to it. The zoom's reply names that end, and the camera reaches it with no frame moving more than 0.1°.
2026-11-05 16:31:46 -08:00
Sameh Khamis 42fa974ee3 fix(wildlife): keep each failed source's retry timing after a partial failure
A partial failure reset the layer's cooldown, so a camera move retried at
once. Acartia's error was discarded, an absent X-Upstream-Status hid the
HTTP 503 (the proxy's daily-budget reply), and a grid schema error masked
the observation 503. Each failed source now keeps its status and
Retry-After, the HTTP status counts when no upstream status is sent, and the
layer waits for the longest 429/5xx cooldown among them. The shown error is
the one that explains the cooldown.
2026-11-05 16:27:19 -08:00
Sameh Khamis f332150689 fix(camera): hold the globe recovery until every pointer is released
The held-pointer guard was one flag, so with two fingers down, lifting one cleared it and the recovery turned the view down under the other (pitch -42° to -90°). It now tracks each pressed pointer by id and defers until the last is released.

A release outside the window that the page never sees could leave the recovery deferred for good. A lost window focus, a hidden page or a lost pointer capture now releases every held pointer, and a pointer that moves with no button down is released.
2026-11-05 16:16:05 -08:00
Sameh Khamis 198c2faf4f Say the -45° tilt default in the pitchDeg description again
The trim that fitted the tilt wording under the Realtime prompt ceiling dropped the -45° default from pitchDeg, so the model no longer read why a bare tilt goes oblique. pitchDeg reads 'Absolute; -45 default, -89 top-down.' again, and the direction description becomes 'Pan/rotate/tilt steps; orbit/rotate_globe default right.' to pay for it: the Overpass prompt measures 55,499, 1 under the 55,500 ceiling. The schema pin and the measured-size note follow.
2026-11-05 16:14:27 -08:00
Sameh Khamis 6479167b50 Pivot a tilt at exactly 1,000 km and report where a zoom now ends it
A pivot range rebuilt from the camera's position came back a hair over the 1,000 km cutoff when the view sat exactly on it, so a tilt from 1,000 km ran in place and lifted the crosshair onto the sky. The cutoff now allows 1 m of slack.

A zoom during a tilt to an angle changes the range the pivot holds, and with it the place's horizon limit, but the tilt kept its old goal and its reply its old angle: from 400 km, a tilt to -5° zoomed out x2.5 reported -5° with no note and ended at -9.4°. The zoom now re-judges the goal at the new range, eases the tilt from where it is to it, and its result names the angle the tilt now ends at, with a note.
2026-11-05 16:14:27 -08:00
Sameh Khamis fb0a58ef3c docs(wildlife): cite the documented iNaturalist UTFGrid endpoint and Acartia's key request
/grid/{z}/{x}/{y}.grid.json is documented in the v1 and v2 API docs as
Grid Tiles UTFGrid; only its per-cell records are unspecified, which is
what the schema guard covers. DATA_SOURCES.md now also notes that
Acartia's README asks API users to create an account and an API key,
although /sightings/current answers without one today.
2026-11-05 16:07:48 -08:00
Sameh Khamis 5391687d46 fix(wildlife): draw what arrived when one sighting source fails
An iNaturalist rejection discarded a successful Acartia reply, and the
antimeridian Promise.all lost both halves when one failed. Each box now
settles independently; the failed part is reported on
sources.inaturalist (error and failed boxes), the layer keeps that
area's last iNaturalist rows and shows DEGRADED with retry timing, and
the update fails outright only when every requested source failed.
2026-11-05 16:06:41 -08:00
Sameh Khamis 3cb30c3f97 fix(wildlife): point the Acartia card link only at acartia.io
The card's "Acartia" link was built from the contributor-supplied
photo_url, so any https site could appear labelled as Acartia. Acartia
has no documented per-sighting page, so the source link is now always
the Acartia site, on the server projection and again in the card.
The separate "Open on Acartia" fallback link is no longer needed.
2026-11-05 16:05:02 -08:00
Sameh Khamis 82138478d0 fix(wildlife): treat a valid empty Acartia snapshot as a fresh success
A quiet or fully filtered feed raised invalid_snapshot, so the proxy
answered 502 with a 15 s cooldown and re-polled about four times a
minute. Only a non-array payload is malformed now; an empty projection
caches for the normal ten minutes and clears earlier rows.
2026-11-05 16:03:52 -08:00
Sameh Khamis bd99000433 Merge remote-tracking branch 'origin/main' into pr/wildlife-sightings 2026-11-05 16:00:37 -08:00
Sameh Khamis 44106b2bfb fix(camera): hold the globe recovery while a pointer is down
Holding a drag still for half a second on a view that already showed no surface looked like a camera at rest, so the recovery turned the view down under the user's hand. The recovery now defers while a pointer pressed on the canvas is held, and runs once it is released, as for a flight or a playing scene.
2026-11-05 15:55:52 -08:00
Sameh Khamis a20076fc5e fix(camera): judge a voice tilt's frame limit by the way its pitch moves
The pre-check picked the in-place look from args.direction alone, while the tilt itself turns down for any direction but up. A tilt sent as left or right at the frame limit was checked as a turn left or right, refused as losing the globe, though it turns down toward the planet. A rotate sent as up or down was checked as a look up or down, though it turns right.

A tilt's look now comes from its goal pitch against the current pitch (tiltLook), and the tick tilts in place through tiltInPlaceWithinFrame(cam, m, goalPitch). Neither depends on the direction word, so a tilt to an absolute angle, or a tilt with no direction, can use them as they are.
2026-11-05 15:55:52 -08:00
Sameh Khamis f53342932d Merge branch 'main' into fix/globe-free-look-orbit 2026-11-05 15:52:21 -08:00
Sameh Khamis dae95e1c7f Refuse a motion after its reply's fly-to found nothing, also on Gemini
Gemini runs one turn's calls one after another. By the time "pan left" ran beside "fly to Xyzzyville", the lookup had already found nothing, so there was no flight to wait on and the pan moved the old view with ok: true. OpenAI's parallel calls refused the same reply.

The runner now remembers, for the latest reply that sent a voice fly-to, whether one moved the camera and whether one found nothing. A tilt, orbit, pan or rotate of that reply with no flight left to wait on is refused when its fly-to found nothing and none moved. The reply is Gemini's turn, passed as a per-turn reply object, or OpenAI's authorityGroup. A motion in a later reply, or beside a fly-to of the same reply that did land, runs as before.
2026-11-05 15:51:14 -08:00
Sameh Khamis 1eef14454a Drop a queued voice motion when another navigation takes the camera mid-flight
A motion queued behind a voice fly-to checked only that its reply was current and that the user had not touched the map. A location-panel search, share link or file import stamps a deferred navigation without stopping the flight, so the flight landed, the motion took navigation, and the search's reassert then failed: the search was silently dropped while the camera kept orbiting.

The runner now counts camera handoffs from subscribeCameraHandoff, less the ones voice fly-tos make themselves, and each voice flight records that count when it starts. A motion whose flight saw another navigation since then is not run and says so. Voice fly-tos' own stamps do not count, so a later fly-to in the same reply, including a lookup that finds nothing, is still followed as before. The test harness now publishes handoffs and stamps its voice lookups, as the shell does.
2026-11-05 15:48:21 -08:00
Sameh Khamis c4910d24d0 Keep the place centred when a far tilt reaches the horizon
From 550 to 1,000 km of range the camera's -5° limit lies past the place's own horizon. The swing ran into its 150 m clearance, let go of the pivot and tilted the rest in place, which lifted the crosshair onto the sky (28 to 63 km up from 600 to 900 km), and a return to top-down then landed hundreds of km from the place.

The swing now hands over to an in-place tilt only where the clearance is what stops it (within about 17 km). Farther out it stops with the camera 0.5° above the place's horizon, still pivoting: a tilt to an angle reports the angle it reached with a note, a continuous tilt stops there, and a further tilt up is refused as at the limit. Tests check that the crosshair stays on the place, frame by frame for a continuous tilt, and that top-down returns over it.
2026-11-05 15:43:51 -08:00
Sameh Khamis 395eba4548 Fit the tilt wording under the Realtime prompt ceiling
With main merged, the Overpass prompt measured 55,549 against the 55,500 ceiling. The pitchDeg and direction descriptions now read 'Absolute; -89 top-down.' and 'Needed for pan/rotate/tilt steps', bringing it to 55,497. The schema pin and the measured-size note follow.
2026-11-05 15:38:32 -08:00
Sameh Khamis 8687512fc8 Merge branch 'main' into manjunath/hal-695-voice-tilt
Re-derive the Realtime schema pin for main's control_cockpit wording and this branch's pitchDeg.
2026-11-05 15:37:50 -08:00
Sameh Khamis 2470b39f88 Merge pull request #1556 from ZloyS/denis/gev-cockpit-entry
feat(cockpit): enter Cockpit with only the ridden aircraft's layer on
2026-11-05 14:11:28 -08:00
Said Turksever d7856109c7 Merge branch 'main' into fix/keyless-unicode-search 2026-11-05 22:08:58 +00:00
Yehe Liu 4423f4b4a6 Merge branch 'main' into pr/wildlife-sightings 2026-11-05 17:04:06 -05:00
Sameh Khamis e3f668831c Merge branch 'main' into denis/gev-cockpit-entry 2026-11-05 14:01:31 -08:00
Sameh Khamis 63fd559496 Keep the ridden aircraft's layer when Contacts ends, and clear its rows from Live Signals
Riding an aircraft now adopts its layer the way a direct selection does, and
Contacts gives up a dependency the user adopts, so ending Contacts mid-session
(one of its layers off, or Contacts itself) no longer turns off the ridden
layer and ends the Cockpit. When Contacts goes off, Live Signals drops the
rows built from its snapshot instead of leaving stale distances marked
CURRENT, and the feed-off warning is no longer duplicated.
2026-11-05 14:01:17 -08:00
Sameh Khamis 0ff48ba059 Merge pull request #1546 from manjunath22466/fix/chat-provider-switch-keeps-context
fix(voice): keep chat's view across a provider switch and clear stale errors
2026-11-05 13:50:48 -08:00
Said Turksever bd8ef09042 Merge branch 'main' into fix/keyless-unicode-search 2026-11-05 21:44:18 +00:00
Sameh Khamis a75b495cf9 Merge branch 'main' into fix/chat-provider-switch-keeps-context 2026-11-05 13:43:40 -08:00
Yehe LiuandClaude Opus 5.5 ecbe1d99e6 docs: number the wildlife sightings changelog fragment (#1591)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:18:27 -05:00
Yehe LiuandClaude Opus 5.5 14f88b3e08 feat: Wildlife Sightings layer from iNaturalist and Acartia
Recent community wildlife observations, as a new Wildlife group in the
layer panel.

- Server: keyless iNaturalist proxy (v2 observations with only the
  projected fields; undocumented v1 UTFGrid density tiles, decoded on the
  server) sharing one paced queue (burst 4, ~1/s, 60/min, 8,000/day budget,
  up to four in flight), in-memory caches and upstream-only cooldowns;
  Acartia Salish Sea marine-mammal feed rounded to 0.01 degrees with
  comments dropped.
- Layer: grid counts drawn as a soft view-ranked density glow; real sampled
  sightings as taxon badges at their true positions with ambient photo
  thumbnails and labels; obscured records stay privacy cells; a clicked
  sighting stays pinned through camera moves and refreshes.
- Anchored photo card per selection with the single iNaturalist link;
  attribution in the layer row and the Data attribution popover.
- Filters (CONFIRMED, taxa presets) round-trip in share links with token 9.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:17:12 -05: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 edd6212793 Merge pull request #1481 from IMBrandao/feat/rio-transit-gtfs-rt
feat(transit): add Rio de Janeiro buses and BRT (SMTR GTFS-RT, CC BY 4.0)
2026-11-05 21:40:29 +04:00
German Khokhlov b5cef8daf4 Tighten the Rio comments, notes and credits
Name each Rio feed in its credit instead of repeating the licence, drop the
deprecation history from the bus note, list Rio among the feeds with a
route-name table, and shorten the route-table header and test comments.
2026-11-05 21:29:21 +04:00
German Khokhlov 017a3bc99c Merge branch 'main' into feat/rio-transit-gtfs-rt 2026-11-05 21:21:13 +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
German Khokhlov 1011dc04fe Merge pull request #1521 from bilawalsidhu/german/hal-721-provider-labels
Name the reporting provider in Contacts and Cockpit counts
2026-11-05 21:11:37 +04:00
German Khokhlov e9e7ed4c1a Merge branch 'main' into german/hal-721-provider-labels 2026-11-05 21:02:35 +04: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
Manjunath ef4b319f97 docs(changelog): credit the review behind the follow-ups 2026-11-05 15:57:09 +05:30
German Khokhlov 70f405cf1b chore(transit): register rioRouteNames.js in the package and format scopes 2026-11-05 13:48:11 +04:00
German Khokhlov a67c7969fd fix(transit): read Rio speeds as the m/s SMTR publishes
Both SMTR feeds now publish Position.speed in m/s. Compared with how far
vehicles moved between reports on 2026-11-05, the published values ran
3.1x (buses) and 3.6x (BRT) below the observed speed, so the km/h
conversion divided real speeds by 3.6. Drop speedUnit and the conversion.
2026-11-05 13:48:11 +04:00
German Khokhlov e5dd7c71ac Merge main into feat/rio-transit-gtfs-rt 2026-11-05 13:35:27 +04:00
Manjunath 852e93d074 fix(camera): keep a deferred recovery check through long holds
A Director hold longer than the settle check's five-minute bound used
to expire the deferred check, so a run that ended on a stars-only shot
after a long hold stayed there until the next move. While a flight or a
playing scene defers the check, its bound no longer counts down; the
flight or scene bounds the deferral itself.

Also correct the trackball note in CURRENT-STATE: Cesium's tilt tests
the screen centre, so any tilt (drag or touch) with the centre on the
stars between 1,000 and 7,500 km now tilts about the planet's edge or
does nothing, wherever it starts. Rotation drags that start on the
planet are unchanged.
2026-11-05 14:45:29 +05:30
Manjunath 29935cc094 fix(camera): run a recovery check a scene deferred once it ends
A check skipped because a flight or a playing Director scene was in
progress used to be dropped, so a run that ended or held on a stars-only
shot stayed there until the next camera move. Keep watching instead:
the settle check retries once the flight or scene ends, within its
existing five-minute bound, and a moveEnd that arrives while it is
deferred keeps the check armed rather than stopping it.

Also test that the free-look limit never raises Cesium's trackball
height and that dispose keeps a value set after install, note in its
JSDoc the enableLook case it cannot tell apart, and document the
trackball change and the retry in CURRENT-STATE and the changelog.
2026-11-05 14:36:10 +05:30
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
Manjunath 2f9d13f39d fix(camera): orbit on drags from the stars above 1,000 km
Below Cesium's trackball height (7,500 km by default) a drag that starts
on the stars turns the camera in place, whatever enableLook says, which
was the drag path to a view with no planet in it. Lower that height to
the free-look height with the free-look limit, never raising it, and
restore it on dispose. Above 1,000 km such a drag now orbits the globe,
as it already did above 7,500 km, at a similar rate, and a tilt drag
from the stars there tilts about the planet's edge or does nothing
instead of turning the camera. Drags that start on the planet are
unchanged.
2026-11-05 12:20:26 +05:30
Manjunath 64377db91a fix(camera): leave flights and scenes to their framing
Review follow-ups for the globe-in-view recovery and voice limits.

- Leave a camera flight in progress and a playing Director scene, holds
  included, to their own framing. The recovery is now installed with the
  scene tools, which own the Director, and reads its running state.
- Reuse a voice turn's half-degree proof only from the position and
  direction it was proven at, so a release that moves the camera checks
  the turn again.
- Share one free-globe-camera check between the recovery, the voice
  turn limit and the globe rotation.
- Keep an enableLook setting other code makes after the free-look limit
  is installed, instead of writing back the install-time value.
- Name the nudging test for what it asserts, the 1% floor, and document
  that a view losing its surface without a camera move waits for the
  next one.
2026-11-05 11:59:11 +05:30
Manjunath 1c59105a80 Judge tilts by camera pitch and follow a newer flight at once
- A tilt's angle, limits and "unchanged" check use the camera's own
  pitch. At regional range the pivot pitch is up to about 8 degrees
  shallower, so "tilt to 5 degrees" from 900 km ended at -12.9 degrees.
  The swing now solves for the pivot pitch that gives the camera angle.
- A zoom out during a pivoting tilt stops at the 1,000 km pivot limit
  instead of the orbit's 5,000 km, and a zoom in that reaches the
  clearance is applied before the pivot ends.
- An angle the camera already holds still takes navigation, so it stops
  a running motion and releases the follow camera.
- A motion queued behind a fly-to moves to a newer fly-to as soon as it
  begins, so a stalled lookup no longer holds it past a flight that
  landed. Wait listeners are removed when a wait ends, and a lookup that
  found nothing points straight at the flight that moved.
- A fly-to that stops a running motion names it (stoppedMotion).
- A tilt left or right is refused, and the pitchDeg description says
  steps use direction.
2026-11-05 11:36:29 +05:30
Itallo BrandãoandClaude Opus 5.5 ad48305dac feat(transit): name Rio routes from SMTR's published GTFS
SMTR's realtime feeds carry internal route keys, so a Rio card read
"Route O0731AAN0A" where riders know line SN731. Join both feeds to the
route_short_name in SMTR's static GTFS routes.txt (CC BY 4.0, same
publisher and terms). The keys cannot be parsed: O0014AAA0A is LECD135.
A 2026-11-04 snapshot found all 282 bus and 21 BRT routes in service in
the table; an unknown key still falls through raw.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 22:49:54 -03:00
Denis Groshev 459b0e8a71 docs(changelog): add the #1556 fragment 2026-11-04 21:53:06 +01:00
Denis Groshev 219f3e1079 feat(cockpit): enter Cockpit with only the ridden aircraft's layer on
Cockpit entry no longer needs Contacts or both flight feeds: a civilian
aircraft needs Live Flights, a military one Military Flights. Contacts and
every other layer can be turned on or off before or during a session, and
turning off the ridden layer ends it. A Context mode change in flight still
blocks entry.

When the other air feed is off, Live Signals and the Contact card say
FLIGHTS FEED OFF or MILITARY FEED OFF · PARTIAL PICTURE instead of
presenting a partial picture as complete. Voice refuses entry with
subject-layer-off rather than contacts-inactive, and its tool description
says Contacts is not required.

Double-clicking the followed aircraft enters Cockpit through the same gate.
The Cockpit takes the scene's double-click before Cesium's stock handler,
which would otherwise untrack a followed aircraft whose model is a
primitive. The entry also shows under the Intel HUD readout, sharing the
Contacts button's face. A test keeps the classes the Cockpit adds to the
body from matching another element's rule.
2026-11-04 21:53:05 +01:00
German Khokhlov 7d8b80db0a Merge pull request #1561 from bilawalsidhu/german/chat-autocomplete-arabic-marks
Ignore Arabic short vowels and shadda in chat autocomplete
2026-11-04 23:58:19 +04:00
German Khokhlov e84cae6baa Add the changelog entry for #1561 2026-11-04 23:35:46 +04:00
German Khokhlov 06c84f92d3 Ignore Arabic short vowels and shadda in chat autocomplete
Arabic catalog strings carry optional marks ("تتبّع"), but people type
without them, so "تتبع" found no suggestion. Fold the marks out of both
the draft and the candidates before comparing.
2026-11-04 23:35:25 +04:00
manjunath22466 3577d19513 Cover the real provider switch and share chat Settings and stop checks
- Host test switches AI Provider from chat Settings through the real
  provider commands and checks the new mount keeps chat's context, frame,
  Back to chat and focus on the provider choice.
- Drop the open and minimized branches of the carried presentation: the
  provider choice lives only in Settings, so chat is never open then.
- One showChatSettings helper for the gear and the restore, and one
  isStoppable helper for the live-or-error check in chat and the panel close.
2026-11-05 00:17:08 +05:30
Denis Groshev 82f17e694e refactor(cockpit): place the camera from a per-subject eye profile
Split the Cockpit camera update into the aircraft's own front half and a
shared placeCamera that reads the eye's forward and up offsets, pitch and
ground clearance from a profile. The aircraft profile carries the existing
constants, so the aircraft view is unchanged; other subjects can now supply
their own eye.
2026-11-04 17:03:52 +01:00
Itallo BrandãoandClaude Opus 5.5 1b0b4a10dc Merge main into feat/rio-transit-gtfs-rt
Resolves the DATA_SOURCES.md conflict by keeping main's rewritten Transit
and Radio bullets and restating the count for this branch: eighteen feeds
covering seventeen regions (the two Rio feeds share one region).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-04 11:53:46 -03:00
German Khokhlov 55e6c0017c Tighten the provider-label comments and docs 2026-11-04 18:15:16 +04:00
manjunath22466 46dde909cc docs(changelog): add #1546 fragment 2026-11-04 19:37:47 +05:30
manjunath22466 53ea76251f fix(voice): keep chat's view across a provider switch and clear stale errors
Switching AI Provider in Settings opened from chat rebuilt the voice
control, so Settings fell back to voice and the dock went idle, widening
the wings. The switch now carries chat's Settings, or chat itself, to the
new provider as it already carries the input mode; the conversation still
starts fresh and focus stays on the provider choice.

After a voice error, opening chat or closing the voice panel left the dock
red on ERROR, because both stopped only an active session. They now also
end a session left in error, which returns the dock to idle.
2026-11-04 19:34:30 +05:30
German Khokhlov 9b7d9ad537 Merge origin/main 2026-11-04 16:35:00 +04:00
German Khokhlov 99831ea19b Keep STALE on Contacts while a poll is in flight
A poll that returned the same stale snapshot dropped STALE from the
Contacts cohort until the next answer, because the in-flight loading flag
outranked stale and the source revision ignored the provider name. A
source that has answered now reads its status without the loading flag;
one that has never answered (AIS first connect) still reads as loading,
so it shows no STALE before any snapshot exists.
2026-11-04 16:34:39 +04:00
Manjunath 4c9b27dc20 Keep a settled flight in reach of a later failed lookup
A new voice fly-to now records the last flight that moved as the one it replaced, landed or not; only a still-flying one passes on its manual-input count. A slow lookup cancelled by a direct flight that landed, followed by a lookup that found nothing, used to drop the queued motion, and a cancelled direct flight in the same place let it fall back to an older landing.
2026-11-04 14:48:07 +05:30
Manjunath b683b8a64b Look past voice lookups that found nothing
A motion queued behind a voice fly-to acts on the last flight that moved the camera. A later lookup that was not found, threw, or was cancelled once a newer voice fly-to had begun moved nothing, so the motion looks past it rather than refusing or running early and cancelling the flight still in the air. It never acts at a flight older than the one it started waiting on. A lookup cancelled while still the latest voice flight (a search, a share link, Cockpit) still refuses.

The top-down no-op now reports the camera's real pitch.
2026-11-04 14:29:37 +05:30
Manjunath bf73a8a1e1 Merge main into the voice tilt branch
No conflicts. The tool payload pin and the Realtime prompt size are unchanged (55,492 with Overpass).
2026-11-04 13:45:45 +05:30
Manjunath 44860df90e Follow a replacement voice flight whatever its predecessor reports
A motion queued behind a voice fly-to now follows a later fly-to in the same reply whether the earlier one arrived, was cancelled or failed. A geocode that loses navigation reports failed, so the motion used to be dropped; and a flight left flying while the next place is looked up could land and run the motion there, taking navigation and cancelling the newer flight. The deadline, abort and stale-reply checks still end the wait.

pitchDeg -90 or -89 from a top-down view is now unchanged rather than a 1° tilt up, and the tilt tests' pitch loops are bounded.
2026-11-04 13:40:43 +05:30
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
Manjunath d36b28f352 docs(changelog): add the globe-in-view fragment 2026-11-04 01:19:22 +05:30
Manjunath 99520b529c fix(camera): keep the globe in view from orbit
From the whole-globe view the camera could end up facing empty space with
only stars on screen. Cesium's free look (Shift+drag) turns the camera in
place, and from 18,000 km the planet's edge is only about 15 degrees from
nadir, so a few drags turned it out of frame. The rolled pose was then
saved into the share hash, so a reload or shared link opened on stars too.

- Allow free look only below 1,000 km. Orbit, tilt and zoom are unchanged.
- Measure how much of the frame shows the planet's surface by casting a
  32x18 grid of view rays at the WGS84 ellipsoid. While the scope mask is
  on (the default) only the rays inside its keyhole count, since the mask
  hides or dims the rest of the frame.
- When a camera move ends above 1,000 km with no ray on the surface, turn
  straight down at the same position and heading. Limb glow and a sliver
  up to about one grid cell thick can count as out of view. The end of a
  move comes from Cesium's moveEnd and from a check of its own that
  samples the pose every 250 ms after moveStart and acts once it has held
  for half a second: at some of these poses the camera drifts by a
  rounding error every frame, so Cesium never sees it stop and never
  raises moveEnd. The zoom-out guard from #1522 keeps wheel and pinch
  zoom-outs in view; this catches the other ways a move can end looking
  away, such as a saved or shared view that did, or a
  drag that Cesium turns into an in-place look below its 7,500 km
  trackball height (one that starts on the stars, or a tilt drag with the
  screen centre off the planet). Views with more surface, tracked or
  locked cameras and the 2D and Columbus modes are left alone.
- Voice tilt and in-place turns of a free 3D globe camera above 1,000 km
  stop when about a tenth of the frame still shows the planet (or about as
  much as it showed when it started, two grid cells of sampling noise
  allowed), never below about 1% of the visible frame; a view that already
  shows less only keeps or gains what it has. A turn that cannot go half a
  degree further without crossing that limit is refused with a reason
  instead of reported as done, and a turn it accepts turns at least that
  half degree. A turn that keeps or gains surface is not refused, even
  after the window has been resized, except that near the limit, where the
  planet's edge crosses the sample grid, sampling wobble can occasionally
  refuse one (most often above about 15,000 km and on phone or ultrawide
  frames); nothing then turns away from the planet. A turn that starts
  where the last voice turn left the camera keeps that turn's limit, or a
  higher one if the planet now fills more, so repeating the request does
  not take any more; once anything else has moved or turned the camera, a
  turn is measured from what is on screen. A request made while the camera
  is tracked is judged by the pose voice's release of the follow camera
  will leave, before that release, so a refused turn keeps the tracking.

The frame measure and the look-step limit live in src/globeInView.js,
shared by the viewer and the voice camera verbs.
2026-11-04 01:17:47 +05:30
Manjunath eeb20d24e9 Merge main into the voice tilt branch
Re-pin the Realtime tool payload hash for the merged tree (voice geometry plus pitchDeg). To fit main's 55,500 prompt ceiling with Overpass on, the pitchDeg schema drops its -90..90 range (the runner already clamps to -89..-5, now pinned by a test) and its description is shorter. The Overpass prompt measures 55,492.
2026-11-04 00:06:56 +05:30
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
Manjunath fdecc8c581 Keep the tilt clearance through a zoom in
A zoom in during a pivoting tilt stops at the range that keeps the camera 150 m above the pivot at its current pitch. A zoom that moves the range by less than half a metre (at the clearance, the 80 m floor or the 5,000 km cap) now answers not zoomed instead of ok, and a tilt reports tiltRangeAdjusted rather than orbitRadiusAdjusted.
2026-11-03 22:23:38 +05:30
Manjunath 9d8db97a33 Merge main into the voice tilt branch
Re-pin the Realtime tool payload hash for the merged tree (main's layer changes plus pitchDeg). The Realtime prompt measures 51,960 of 52,000.
2026-11-03 21:58:10 +05:30
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
Manjunath 9e64d517ad Follow up voice tilt review: superseded flights, wait, globe range
- A motion waiting on a voice fly-to follows a later fly-to in the
  same reply that cancels it, within one deadline, instead of
  reporting that its flight did not arrive. A stale reply stops
  following.
- The wait is 15 s, down from 30 s: it holds the tool output, and on
  Gemini the later tools in the turn.
- Beyond 1,000 km of range a tilt happens in place instead of
  swinging thousands of km about the crosshair point.
- The tilt-nudge limits read the pivot pitch the motion starts from,
  including for a voice tilt from a tracked view, whose follow camera
  is released before it moves.
- move_camera's argument checks run before the flight wait, so a
  malformed call is refused at once.
- Tests: t.after cleanup in the fly-then-motion loop, the pitchDeg
  pin asserts the field before removing it, and the handoff-order
  pin follows the extracted argument checks.
2026-11-03 20:05:34 +05:30
Manjunath 2aef67197c Merge main into the voice tilt branch 2026-11-03 19:40:13 +05:30
Manjunath 5c74b860b7 Address review: zoom, drag, clearance and pitchDeg for voice tilt
- A zoom during a pivoting tilt now scales the range the tilt holds,
  as it already did for orbit, instead of being undone on the next
  frame while reporting success. The zoom limits no longer move a view
  the wrong way: a zoom in leaves a view closer than 80 m in place, and
  a zoom out leaves an orbit beyond 5,000 km where it is.
- A grab, touch or wheel on the map while a voice fly-to is in flight
  drops the motion queued behind it. Cesium lands the flight anyway,
  so the orbit or tilt would otherwise take the camera back. Chained
  fly-tos in one reply keep the earliest snapshot.
- A pivot tilt toward the horizon stops lowering the camera at 150 m
  above the pivot and tilts the rest in place, so a close view no
  longer ends inside tiles or a hillside.
- pitchDeg is an absolute angle. A direction that contradicts it is
  refused, judged from the pitch the swing starts at, and the tool
  description says so.
2026-11-03 19:35:02 +05:30
German Khokhlov 490c8d1769 Add the changelog entry for #1521 2026-11-03 16:27:25 +04:00
German Khokhlov 415f23739f Name the reporting provider in Contacts and Cockpit counts
The Cockpit Contact counts named fixed providers (OpenSky, adsb.lol,
AISStream) while the Layers panel and Contacts named the provider the last
snapshot reported, and the vessels layer kept that name out of getStats(),
so Contacts always said AISStream. Each cohort now carries the provider and
whether it answered as a fallback or from a stale snapshot, and both views
show it the way the Layers row does ("adsb.lol · FALLBACK").

Fixes HAL-721.
2026-11-03 16:26:55 +04:00
Manjunath 2ac73519ec Add changelog fragment for voice camera tilt (#1520) 2026-11-03 16:43:01 +05:30
Manjunath 75000cf6a9 Hold a voice orbit, pan or rotate until its fly-to lands
"Fly to Paris and orbit it" reaches the app as two sibling tool calls,
which OpenAI runs at once. With a place that needs a lookup, the orbit
started over the old view and the late flight then replaced it; with a
known place, pan and rotate cancelled the flight already in the air.

Orbit, pan and rotate now wait for a pending voice flight the same way
tilt does: up to 30 s, and not run (saying so) if the flight is
cancelled, finds nothing or times out. Stop and rotate_globe do not
wait. Orbit keeps its own arming for flights the voice runner does not
track.
2026-11-03 16:42:50 +05:30
Manjunath 91a3f787d4 Hold a voice tilt until its fly-to lands
"Fly to London and tilt the camera" reaches the app as two sibling tool
calls, and OpenAI runs them concurrently. The tilt's navigation step
cancelled the flight already in the air, so the camera tilted where it
was; when the place still needed a lookup, the late flight replaced the
tilt and London came out top-down.

The voice runner now tracks its latest flight. A tilt sent while that
flight is still looking up or flying waits for it to land (up to 30 s),
then tilts at the destination. If the flight is cancelled, finds nothing
or times out, the tilt is not run and says so.
2026-11-03 16:42:39 +05:30
Manjunath 69152868e4 Fix voice camera tilt from a top-down view
A voice fly-to frames a city top-down, at pitch -90. The tilt clamp
rejected any step that left the pitch below -89, so the first frame of
"tilt up" stopped the motion and "tilt down" was refused: from the view
voice lands on, no tilt ever moved the camera. A bare "tilt the camera"
also failed because tilt required a direction.

- Clamp tilt only in the direction of travel, so -90 can tilt up.
- Swing the camera about the ground under the crosshair, so the place
  stays centred and the heading is kept. A tracked view still tilts in
  place, and a pivot tilt yields when tracking takes the camera.
- Add an optional move_camera pitchDeg for "tilt 45 degrees"; a tilt
  with neither angle nor direction settles at -45 (oblique view).
- Re-derive the voice tool schema pins for the new argument and wording,
  and keep the Realtime prompt under its 52,000-character budget.
2026-11-03 16:42:18 +05:30
German Khokhlov 92666fdbaf fix(cyclones): preserve movement in selected voice context 2026-11-03 15:10:20 +04:00
German Khokhlov c68f167acb fix(transit): count distinct coverage regions 2026-11-03 15:07:00 +04:00
German Khokhlov 04d626a4b1 fix(datacenters): preserve missing geometry and compare ring topology 2026-11-03 15:02:42 +04:00
Itallo BrandãoandClaude Opus 5.5 53d30c9e80 docs(changelog): add fragment for #1481
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:30:47 -03:00
Itallo BrandãoandClaude Opus 5.5 a4c7b20ca8 feat(transit): add Rio de Janeiro buses and BRT (SMTR GTFS-RT, CC BY 4.0)
Register Rio's two keyless GTFS-Realtime VehiclePositions feeds from the
city transport secretariat (SMTR): municipal buses (SPPO) and the
Mobi-Rio BRT, the Transit layer's first Latin American region.

Both feeds publish Position.speed in km/h instead of m/s. A new optional
registry field, speedUnit: 'kmh', makes buildTransitSnapshot convert it
once, so every speedMps consumer stays correct and other feeds are
untouched.

Document the source in DATA_SOURCES.md and update the region lists in
README.md and docs/CURRENT-STATE.md.

Refs #1480

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:11:10 -03: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
251 changed files with 32159 additions and 984 deletions

No files matched your search

+8 -2
View File
@@ -41,6 +41,8 @@ How to read this:
| **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app | | **S2 Underground GhostMaps** (`github.com/s2underground/GhostMaps`) | S2 GhostMaps layer: the current edition of S2's KMZ intel products (Common Intelligence Picture, 30-day kinetic pull, Border Crisis Map, Iran and Yemen products, newest incident drop). Keyless. `/api/ghostmaps` reads the branch head from git's ref advertisement every 15 s (`GHOSTMAPS_POLL_SECONDS`, minimum 5 s), lists the tree through the GitHub REST API only when the head changes (optional `GITHUB_TOKEN`), downloads files by commit-pinned raw URLs into `.gev-cache/ghostmaps/` and pushes new catalogs to browsers over server-sent events | [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/) (repository README: "S2 Underground © 2025 by S2A1"); non-commercial, attribution required; files are fetched at runtime, never stored in this repository | "S2 GhostMaps intel products: S2 Underground © by S2A1, CC BY-NC-SA 4.0" — shown in-app |
| **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" | | **InciWeb** (inciweb.wildfire.gov) | Per-incident "InciWeb ↗" card links to official incident pages (narratives, evacuation/closure notices) | U.S. government public incident information. Links are matched best-effort by incident name and state, with incident page origin and update times checked on selection. Same-origin `/api/fire-perimeters/inciweb` routes cache the catalog for 1 hour and publication metadata for 30 minutes; linked pages remain InciWeb content | "Incident information: InciWeb (inciweb.wildfire.gov)" |
| **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" | | **FAA Special Use Airspace** (`services6.arcgis.com` FeatureServer `Special_Use_Airspace`) | Prohibited & Restricted Airspace layer: US prohibited (P) and restricted (R) special-use airspace polygons (not the UAS Facility Map or UAS flight restrictions), altitude limits from the `*_VAL`/`*_UOM`/`*_CODE` fields, times of use, and controlling agency | U.S. public domain (FAA aeronautical data published through the [FAA AIS Open Data portal](https://adds-faa.opendata.arcgis.com/)); fetched keyless through `/api/drone-airspace` (1-hour server cache). Informational only — not a substitute for official UAS flight-planning tools | "Prohibited & restricted airspace: FAA Special Use Airspace" |
| **iNaturalist API** (`api.inaturalist.org/v2/observations`, `/v1/grid/{z}/{x}/{y}.grid.json`) | Wildlife Sightings layer: verifiable wildlife observed within the last 48 h (whales 7 d), by observation time, in the current view, with taxon, observation time, quality grade and eligible photo | Per-record licences; observations default CC BY-NC. Photos CC0/BY/BY-SA/BY-NC/BY-NC-SA shown; ND and all-rights-reserved photos link-only. Keyless via `/api/inaturalist/observations` (5-min cache; global sample 10 min; stale ≤ 1 h; 60 requests/min and 8,000/day upstream caps). **NonCommercial content:** commercial redistributors must filter to CC0/BY/BY-SA or remove the layer. No AI training on this content | "Wildlife sightings: iNaturalist contributors (licences per record; photos credited on each card)" |
| **Acartia** (`acartia.io/api/v1/sightings/current`) | Wildlife Sightings layer: Salish Sea marine mammal reports with validated species, observation time, reporting provider and trust flag; WHALES includes only the frozen cetacean list | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), each provider credited per sighting. Keyless via `/api/acartia/sightings` (10-min cache; stale ≤ 24 h). Positions rounded to 0.01° on the server; free-text comments excluded. Marine-conservation use; Be Whale Wise | "Wildlife sightings: Acartia (CC BY 4.0; each provider credited per sighting) · Be Whale Wise" |
| **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles | | **OpenFreeMap / OpenMapTiles** | Vector tiles | OpenStreetMap data under ODbL 1.0 | OpenFreeMap © OpenMapTiles |
| **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation | | **Overture Maps Foundation** | Military area names pack | [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/) | Overture Maps Foundation |
| **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates | | **TomTom Traffic API** (flow vector tiles) | Traffic roads and live congestion (optional, BYOK) | [TomTom for Developers terms](https://docs.tomtom.com/legal/terms-and-conditions) (proprietary, your own key; free tier currently 200K tile requests/month — see [current pricing](https://docs.tomtom.com/pricing/)) | "Traffic flow data © TomTom" — registered when live mode activates |
@@ -98,6 +100,7 @@ How to read this:
| **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) | | **Barrie Transit** (myridebarrie.ca) | Transit layer live vehicles, Barrie ON | Barrie Transit realtime data, published by the City of Barrie under a licence accepted by use of the data. The licence is revocable and requires no attribution. | Courtesy credit to the City of Barrie (the licence requires none, and its trademark clause is broad) |
| **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required | | **YRT/Viva** (rtu.york.ca) | Transit layer live vehicles, York Region ON | York Region Transit realtime data, first-party York Region infrastructure under the YRT Open Data Licence v2.0, which permits commercial use and makes attribution optional. Viva route ids are named by colour from York Region’s published GTFS. | "Contains public transit Information made available under YRT’s Open Data Licence" — the licence’s own wording, given though not required |
| **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires | | **Burlington Transit** (opendata.burlington.ca) | Transit layer live vehicles, Burlington ON | Burlington Transit realtime data under the City of Burlington Open Data Terms of Use, which permit any lawful purpose. The terms are revocable and must travel with the data, so the credit links them. Route ids are internal keys and are joined to the City’s published GTFS. | Credit to the City of Burlington plus a link to its Open Data Terms of Use, which the licence requires |
| **SMTR Rio de Janeiro** (dados.mobilidade.rio) | Transit layer live vehicles, Rio de Janeiro: municipal buses (SPPO) and the Mobi-Rio BRT, as two feeds | CC BY 4.0, stated on both data.rio catalog pages. Keyless GTFS-Realtime published by the Secretaria Municipal de Transportes, labelled beta, with a gateway limit of 5 requests a second and 60 a minute. Line names come from SMTR’s GTFS `routes.txt` (dados.mobilidade.rio/gtfs/schedule), under the same licence. | Attribution to SMTR / Prefeitura da Cidade do Rio de Janeiro under CC BY 4.0 |
| **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster | | **Radio Browser** | Geolocated internet-radio station directory and station-level tags | Public-domain directory data under PDDL 1.0; individual broadcaster stream terms apply | "Radio Browser" plus a link to the selected broadcaster |
| **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" | | **Re:Earth Terrain** (Mapterhorn) | Terrain (keyless globe stacks — OSM etc. — + `/api/terrain/heights` ellipsoidal-height lookups) | Terrain mesh: CC BY 4.0; geoid: EGM2008 (NGA, public domain) | "Terrain (keyless globe stacks): Re:Earth Terrain / Mapterhorn (CC BY 4.0) / EGM2008 (NGA)" |
| **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" | | **OpenSeaMap** (`tiles.openseamap.org`) | Sea Marks layer: raster overlay of buoys, lighthouses and ports (#10) | Chart tiles CC BY-SA 2.0; data ODbL 1.0 via OpenStreetMap | "Sea marks © OpenSeaMap contributors (CC BY-SA 2.0) · Data © OpenStreetMap contributors (ODbL)" |
@@ -185,7 +188,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived. - **Houston TranStar cameras.** Keyless. The camera list is the public `traffic.houstontranstar.org/data/layers/cctvSnapshots_json.js` catalog (~1,000 cameras across Greater Houston); frames are stills at `www.houstontranstar.org/snapshots/cctv/<id>.jpg`, synthesized from a validated numeric id rather than taken from the payload, and only that host is registered. Cameras whose catalog id is `0` (a different, non-snapshot class) and cameras outside Greater Houston are dropped. TranStar's `dir` field is the side of the freeway the camera sits on, not the way it faces: TranStar's own map shows it as "Traffic closest to the camera is traveling …" (South → eastbound, North → westbound, West → southbound, East → northbound). That travel direction (90°, 270°, 180°, 0°) seeds the heading as a low-confidence prior with the low-confidence pose; `None` or an unknown value uses the shared id-hash fallback at low confidence. Headings are corrected with the in-app calibration gizmo. Off by default. TranStar's [Content Usage terms](https://traffic.houstontranstar.org/disclaimer.aspx) prohibit copying or distributing the site's information, and external framing or linking to specific images, without TxDOT's prior written permission; its FAQ grants permission-free use only for the RSS feeds. This project does not hold that permission for downstream installations, so the pack loads only when an operator who holds TxDOT's written permission sets `CCTV_HOUSTON_ENABLED=1`; with the flag unset, empty or any other value, the server makes no request to TranStar. Once enabled, it loads the 250 cameras nearest downtown, Uptown, the Medical Center, IAH and Galveston; `CCTV_HOUSTON_MAX_SOURCES` (8–400) changes the cap. Snapshots update about every 3 minutes, which is also the ambient still cadence. Frames are fetched from TranStar and relayed to the browser on demand, buffered only transiently per request, sent with `Cache-Control: no-store`, and not archived.
- **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`. - **Autostrada del Brennero (A22) webcams.** Keyless. The camera list is one request to the Open Data Hub tourism API (`tourism.api.opendatahub.com/v1/WebcamInfo?source=a22`), run by NOI Techpark in South Tyrol, which republishes Autobrennero's own list of 13 motorway webcams with coordinates and kilometre markers. Frames are the operator's stills on `www.autobrennero.it/WebCamImg/km<N>.jpg`, refreshed about once a minute, and only the exact image for each camera's kilometre is registered. The feed names no bearing, so headings use the id-hash fallback at low confidence. The road climbs from about 20 m in the Po valley to about 1,370 m at the Brenner Pass, so each camera's ground elevation comes from a per-kilometre table sampled from the Copernicus GLO-90 DEM (via Open-Meteo's elevation API); a camera not in the table uses a 200 m prior. The Open Data Hub tags every record CC0 (`ClosedData: false`, Autobrennero as licence holder), but that tag is the aggregator's metadata and Autobrennero publishes no terms of its own for the images, so they are credited to the operator as a courtesy and are not redistributed. Frames are fetched at request time and never stored. Disable with `CCTV_A22_ENABLED=0`.
- **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals. - **Camera frame content.** Public camera frames can contain people, vehicles and license plates, and the camera layer does nothing with them beyond putting them on the map. A successful upstream response is relayed as the provider served it: nothing in the camera pipeline blurs, redacts, enhances or restores it, and nothing there runs face, plate or object detection or otherwise recognises or classifies what is in the picture. (The ALPR layer described below maps where plate-reader cameras are, from locations contributors tagged in OpenStreetMap; it reads nothing from any camera frame.) When an upstream has no frame for a camera, what you see instead is a Google Street View still or the app's own placeholder card; the panel's source badge names which of them you are looking at, and a refresh that fails after a frame has arrived leaves the last good picture on screen rather than replacing it. The browser resamples each frame for display: onto a fixed 1920x1080 surface for the camera's projection plane in the scene, and onto a 192x108 canvas for the small ambient card. That scaling is the only change made to the picture, and the only pixels the camera layer reads back are a 64x36 downscale hashed to tell whether the picture has changed. Live streams are piped through; the still path reads the image into memory to pass it on — TxDOT wraps its JPEG in a JSON envelope, which is decoded there — and no frame is written to disk (the disk cache, `.gev-cache/`, holds feed JSON for other layers and never camera imagery). Outside that pipeline, the voice assistant is the one feature that sends imagery anywhere: when it answers a question about what is in view it may attach a downscaled screenshot of the rendered scene — including any camera plane on screen — to the request it sends to the voice model. Frames are fetched only from server-registered camera URLs — a client cannot hand the proxy a URL (see [SECURITY.md](SECURITY.md)). What the app does stays inside the line README draws: no named-person search, no face recognition, no tracking individuals.
- **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All sixteen registered regions are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington and YRT publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for. - **Transit (GTFS-Realtime).** The proxy requests only registered operator feeds, validates redirects, bounds responses, and uses conditional requests, short snapshot caching and failure backoff. All eighteen registered feeds, covering seventeen regions, are live-enabled; each can be disabled independently. Vehicles use delayed playback between reports, with report age and waiting states shown separately. Session-local selected trails are available for every feed. MBTA additionally retains up to 15 minutes of recently observed positions in the running proxy, so available history can survive a browser reload; server restart clears it. History reads never fetch upstream, and other feeds have no proxy retention. Nothing is archived to disk or collected in the background. Operator text credits are included as a courtesy and do not imply endorsement. Heights are aligned to work with Google 3D tiles; subway markers are shown at street level because these feeds do not supply depth. The TTC feed is BusTime, the surface system: it carries buses and streetcars and no subway at all. About four in ten of its vehicles report no route at all (unassigned or deadheading), and those are shown with the feed default rather than a route-established mode. It sends no ETag or Last-Modified, so its conditional requests fall through to the ordinary snapshot cache. Three Golden Horseshoe neighbours run alongside it — MiWay, Hamilton HSR and Durham Region Transit — each bus-only and each polled only near its own region. Durham Region Transit publishes no bearing on any vehicle, so those sprites take their heading from observed movement like any other feed that omits it. Guelph, Kingston and Barrie extend the same pattern eastward and north. Hamilton, Guelph, Burlington, YRT and Rio’s SMTR publish route ids that are not the number on the vehicle, so those entries carry a route-name table taken from each operator’s own published GTFS; any id missing from a table renders unchanged rather than blank. A label is never invented: YRT runs a route `9101` that appears nowhere in its published routes, so it stays as the feed sends it. Burlington’s is the one that cannot be shortcut: `351` is route 1 while `3510` is route 10, so the table is a real join rather than a trimmed prefix. GO Transit / Metrolinx is deliberately absent: its REALTIME feed requires an API key issued by manual review, which the keyless admission rule excludes (its static schedule download is keyless, but carries no vehicle positions). Barrie and YRT/Viva each invite acceptance of their licence through a form, and each licence also states that using the data is itself acceptance; this deployment relies on that clause, which is recorded in both entries. Grand River Transit in Waterloo is absent for a different reason: it answers, but its feed header was months stale and it publishes no per-vehicle timestamps, so it would render vehicles that look live and are not. Ottawa’s OC Transpo falls to the same keyless admission rule as GO Transit — its realtime feed requires an API key. London Transit and Thunder Bay Transit publish vehicle positions over plain HTTP with no HTTPS endpoint available. Every other Ontario system — Brampton, Oakville, Niagara, Milton, Windsor, Greater Sudbury, UP Express and a dozen smaller operators — publishes static GTFS schedules only, with no realtime vehicle positions to register. Between the nine included and these exclusions, every Ontario realtime vehicle-position feed catalogued in the Mobility Database is accounted for.
- **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query. - **Radio country coverage (optional).** Set `RADIO_PRIORITY_COUNTRIES=IN,PL` in the local `.env` and restart to include stations beyond the global click charts. The operator may choose up to eight unique ISO alpha-2 country codes; no key is needed. Each country adds one bounded query (at most 200 rows) and reserves up to 40 unique, usable stations inside the existing 750-station catalog. Country seeds precede specialist-tag seeds and global popularity fill; unused slots return to the shared pool. This is a coverage preference, not an exhaustive national directory or a guarantee of stations in every city. Countries with no eligible HTTPS MP3/AAC geolocated stations remain empty. An unset list preserves global selection. Country requests share the directory cache and three-worker pool; failed country queries are reported in coverage without blocking a healthy global refresh. Failed/degraded refreshes back off for one minute, with at most three mirrors per query.
- **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply. - **Radio Browser.** In every installation, including when `RADIO_PRIORITY_COUNTRIES` is unset, each request tries at most three mirror candidates (later fallback candidates are skipped), and failed or partial catalog refreshes back off for 60 seconds. A cold-start failure therefore continues returning 503 during that delay even if upstream recovers. `/api/radio/stations` discovers official API mirrors, makes bounded and coalesced healthy/geolocated HTTPS-station queries, caches the normalized public-domain directory for 45 minutes, and may serve the last good catalog for up to seven days during an outage. Refreshes must meet minimum accepted-query and station coverage before replacing a warm catalog; broad/specialist responses whose rows all fail the product's health policy do not count as successful queries. Optional priority-country outcomes are advisory: valid, in-limit responses with no eligible stations succeed as empty results, while failed or malformed country queries report unsuccessful coverage without degrading the global catalog. A usable partial cold catalog is explicitly `DEGRADED`, and non-array or empty broad/specialist payloads are rejected atomically. Individual structurally malformed rows are skipped rather than failing the whole query; the response carries their bounded `rejectedStationCount` (at most 10,000), which the client adds to its own rejected rows and reports as degraded coverage without discarding the accepted generation. Every directory and click-count request rejects redirects, validates all resolved addresses as globally routable (including reserved/documentation IPv4 and special/non-global IPv6 exclusions), and pins the TLS connection to a validated address. Only MP3/AAC non-HLS directory rows with public HTTPS stream targets are returned; favicons are intentionally omitted. Pressing play connects one browser audio element directly to the selected broadcaster and calls the directory's click counter through known-ID-only `POST /api/radio/click/:uuid`. GEV never proxies, caches, records, bundles, or redistributes audio. Radio Browser supplies station-level tags, not dependable current-song or upcoming-program metadata, so Radio filtering never claims either. Direct playback exposes the listener's IP address to the broadcaster, whose own stream terms apply.
@@ -194,6 +197,9 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable. - **Re:Earth Terrain.** Keyless (no API key). Used two ways: (1) `src/mapStackController.js` swaps in a `Cesium.CesiumTerrainProvider` pointed at Re:Earth's `cesium-mesh/ellipsoid` quantized-mesh endpoint for globe stacks without a Cesium ion token (e.g. OSM), replacing a flat `EllipsoidTerrainProvider`; falls back to the flat provider if the endpoint can't be reached. (2) The server-side `/api/terrain/heights` proxy (disk-cached, serve-stale) resolves per-point ellipsoidal ground height for entity placement. Both are best-effort with a keyless-safe fallback (bundled EGM96 geoid math) if Re:Earth is unreachable.
- **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit. - **OSRM on the FOSSGIS routing servers.** Keyless (no API key). Every route is requested by a person: clicking A and B in the Directions layer, or a voice "route from A to B". The server-side `/api/route` proxy sends an identifying `User-Agent`, pins the upstream host, accepts 2-12 coordinates, rejects any leg over 600 km or a total over 2,500 km, caps the buffered response at 8 MB, times the upstream out at 12 s, rate-limits per client, caches each route for 10 minutes, and coalesces identical requests that are in flight at the same time. Turn-by-turn maneuvers are asked for only when a caller wants them (`steps=1`), so the callers that do not read them send the smaller request. The "fix the map" link the policy asks for is in the Data attribution popover beside the OpenStreetMap credit.
- **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover. - **FAA Special Use Airspace.** Keyless. `/api/drone-airspace` queries the public FAA AIS FeatureServer for prohibited (P) and restricted (R) zones only — not MOAs, TFRs or other SUA types — with simplified geometry, a body cap, paging, a one-hour cache and stale-on-error. The layer is US coverage, informational only, and is not a substitute for B4UFLY, LAANC or a preflight briefing. Data is fetched at request time and never stored or redistributed. US government public domain; courtesy attribution is registered in the Data attribution popover.
- **iNaturalist.** Keyless. `/api/inaturalist/observations` returns cached random view samples (newest first globally), at most 200 per request. Recency uses observation time: the v1 `d1` filter and a rolling 48 h cutoff (whales 7 d); date-only rows qualify when their day starts within the window. Observation caches last 5 min (global 10 min), with stale fallback ≤1 h. `/api/inaturalist/grid` supplies density counts, cached 15 min. Both share a paced queue capped at 60/min and 8,000/day; only request counters persist in `.gev-cache/`. The server caches projected records, never raw responses. Private records are excluded; obscured coordinates become 0.2° cells. Observer identity is retained only for photo attribution. Eligible photos load directly from `inaturalist-open-data.s3.amazonaws.com`, with creator credit on ambient cards and creator/licence on selection. Per-record licences and noncommercial restrictions apply as listed above. Attribution appears in the layer row and Data attribution popover. Coverage follows community observations, not a census; Acartia adds Salish Sea marine mammals.
- **iNaturalist grid (cell schema risk).** `/api/inaturalist/grid` shares observation-date, taxa, quality, pacing and budget rules with observations. A strict 64×64 UTFGrid schema guard fails closed; only logical-cell keys, counts and coordinates survive projection. Raw tiles, observation identifiers and free text are not retained. Projected tiles use a 400-entry LRU, 15-minute freshness and at most one hour of stale fallback. Views request at most six tiles; cached and adjacent-zoom coverage publishes before network waits, independently of observation pages. Cells supply a noninteractive density glow ranked within the view, from teal to amber with mono/CRT support. Every sighting badge comes from a real observation at its public position. Counts describe observations, not a population census. The endpoint is documented in the v1 and v2 API docs as “Grid Tiles UTFGrid” (`/grid/{z}/{x}/{y}.grid.json`), but its per-cell records are not specified; schema drift disables grids until revalidated. See `docs/KNOWN-ISSUES.md`.
- **Acartia.** Keyless. `/api/acartia/sightings` reads the current 7-day marine-mammal feed for ALL, MAMMALS and WHALES views intersecting 45.5–50.5° N, 126–121° W in the Salish Sea. WHALES accepts recognized cetaceans; other identified marine mammals appear under ALL/MAMMALS. The server rounds positions to 0.01° and drops comments before caching projected records (10 min fresh, stale ≤24 h); raw responses are not retained. Selected text cards credit the reporting provider under CC BY 4.0 and show Be Whale Wise guidance. `/sightings/current` answers without a key today, but Acartia's README asks API users to create an account and generate an API key on the [Acartia dashboard](https://acartia.io/) and to follow its [community guidelines](https://acartia.io/register). Attribution also appears in the layer row and Data attribution popover. Opportunistic sightings, not a census.
- **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage. - **Mapped installations.** OpenFreeMap military polygons use a bundled Overture/OSM name index; wide views show named points. Optional operator-configured Overpass and explicit Google Places searches keep their own source paths. Mapped data does not establish current activity or complete coverage.
- **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries. - **Place search.** Named-place fly-to uses Google Geocoding when a Maps key is configured, then Photon (`PHOTON_URL`; unset uses the public instance, empty disables it), then `/api/geocode` (Nominatim search) when neither answers. `/api/geocode` and the voice outline route `/api/geocode/outline` share one Nominatim queue (`NOMINATIM_URL`; unset, empty and invalid values disable it; an operator must explicitly select an authorized endpoint): one request at a time at least 1.1 s apart, an identifying `User-Agent` and `Referer`, a short queue that refuses a burst, a pause after 429/403/5xx answers (honouring `Retry-After` in full), at most one retry after a network failure, and a per-install daily cap on public-instance requests (`NOMINATIM_DAILY_CAP`, default 50). Processes using the same installation state file hold one lock through the actual fetch invocation and recording the next public start, with the daily slot persisted before invocation; restarts share that count, spacing and any pause. An unfinished dispatch marker fails closed until operator recovery; it is never automatically reclaimed. Public access fails closed if the state file cannot be read, locked, written or semantically validated. This coordination does not span independent installations or state directories and does not establish an application-wide public-service allowance or authorize public use. An operator-selected non-public endpoint is outside the public cap, spacing and durable-state requirement; an upstream cooldown is kept with the endpoint that returned it rather than leaking across operator URLs. Answers are cached on disk (outlines 90 days, places 30 days, no-results 24 hours); failures are not cached. Outlines are looked up only for an explicit ask, after bundled data and any configured Overpass, and only results whose type suits the ask are used. Administrative outline responses expose country/admin-1/admin-2 identity only when the selected Nominatim row has a recognized, unambiguous address type consistent with its own name; numeric rank, ambiguous types, missing fields and contradictory names do not manufacture identity. It is not used for systematic or bulk queries.
- **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name. - **Street and building outlines.** Voice street highlights, pointed-at buildings and grounds use the OpenFreeMap vector tiles the traffic layer already loads. Traffic and outlines share one bounded pending fetch/decode owner per immutable XYZ tile; materialized outline geometry is charged to the retained-byte ceiling and capped by coordinate count. Consumer result caches and request lifetimes remain independent, while eviction or explicit teardown cancels queued idle projections and releases their decoded owner. No Overpass request is made unless `OVERPASS_UPSTREAMS` is configured. Named grounds require a returned name matching the selected place, except that an ask whose head noun names a kind of grounds (a school, university or college, hospital, cemetery, stadium or zoo) may use the part of the smallest unnamed tile area of that class that holds the resolved place; a differently named area never answers. Other anonymous landcover polygons cannot establish identity, so unsupported grounds stay unavailable rather than borrowing a nearby POI name.
@@ -211,7 +217,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p
| Dataset | Folder | License | Commercial use? | Attribution | | 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 | | **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 | | **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" | | **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" |
+6 -5
View File
@@ -426,7 +426,7 @@ _Ask for radio near anywhere and the globe starts broadcasting — every station
## 🛰️ What's on the Globe ## 🛰️ What's on the Globe
Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.) Twenty-two layers and map sources. **Twenty have a keyless path.** Some offer additional capabilities with a provider key. (🟢 no key · 🟡 free key · 🔴 metered.)
| Layer | What you get | Source | Auth | | Layer | What you get | Source | Auth |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- | | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------- |
@@ -444,7 +444,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer
| 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 | | 📷 **Mapped ALPR Cameras** | License-plate-reader camera locations tagged by OpenStreetMap contributors, loaded one city-sized view at a time, with **SHOW NEAREST**. Locations and tags only: no plate data, no video | OpenStreetMap (incl. DeFlock mapping) | 🟢 |
| 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key | | 📷 **Street Level** | Street-level photos behind one panel, one chip per provider: coverage on the streets, image cones, an embedded viewer with full-screen **EXPAND**. Mapillary, Google Street View, KartaView and Panoramax | Mapillary · Google · KartaView · Panoramax | 🟢 KartaView, Panoramax · 🟡 Mapillary token · 🔴 Google Maps key |
| 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 | | 📻 **Radio** | Geolocated world radio with an **analog tuner** — drag the needle across up to 750 stations and the globe flies to each broadcaster | Radio Browser / broadcasters | 🟢 |
| 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 | | 🚌 **Transit** | Live buses, trams, metros, trains and ferries with delayed playback between reports, selected-vehicle trails, and mode-coloured DETECT labels — Boston, Austin, Minneapolis, Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro (buses and BRT), and nine in Ontario: Toronto (surface vehicles; the subway publishes no positions), Mississauga, Hamilton, Durham Region, York Region, Guelph, Burlington, Barrie and Kingston | Operator GTFS-Realtime feeds | 🟢 |
| 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 | | 🚲 **Bikeshare** | Live station availability | GBFS | 🟢 |
| 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 | | 🧭 **Directions** | Click A and B on the globe for a street-following drive, walk or cycle route draped on the terrain with turn-by-turn steps — then FLY the camera along it. No key, no geocoder, no mic | OSRM on FOSSGIS servers (OpenStreetMap) | 🟢 |
| ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise | | ⌖ **OSM Places** | Voice place search ("hospitals in Kathmandu") from OpenStreetMap, drawn as numbered points that follow-up questions can filter. Needs your own Overpass server (`OVERPASS_UPSTREAMS`); off otherwise |
@@ -452,6 +452,7 @@ Twenty-one layers and map sources. **Nineteen have a keyless path.** Some offer
| 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) | | 🚀 **Space Missions** | Rolling 30-day launches with payload, stage, and recovery detail | Launch Library 2 | 🟢 (🔴 optional paid token raises the allowance) |
| 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 | | 🎖️ **Mapped Installations** | Viewport-bounded military-site context from community mapping — incomplete by nature, and labeled that way | OpenStreetMap | 🟢 |
| 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 | | 🚫 **Prohibited & Restricted Airspace** | FAA prohibited (P) and restricted (R) special-use airspace as ground polygons, with name, altitude range and times of use on select. Not the UAS Facility Map or UAS flight restrictions; informational only, not for flight planning | FAA SUA | 🟢 |
| 🦉 **Wildlife Sightings** | Wildlife observed within 48 h (whales 7 d): density glow, taxon badges and anchored photo cards with creator/licence credit. Salish Sea marine mammals via Acartia; sensitive locations shown as 0.2° cells. Not a census | iNaturalist, Acartia | 🟢 |
| 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 | | 🌬️ **Wind** | Animated 10 m forecast wind with an optional speed, temperature or pressure color field and a reading at map center | NOAA GFS / ECMWF IFS | 🟢 |
| 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 | | 🌦️ **Observed Weather** | Rain radar (contiguous US), Satellite clouds (GOES regional or global infrared, Clouds only or Full image) and Lightning density (Americas and Pacific) on one history timeline | NOAA nowCOAST | 🟢 |
| 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 | | 🌀 **Cyclones** | Advisory positions, forecast tracks and uncertainty cones for the Atlantic and eastern/central North Pacific | NOAA NHC / CPHC | 🟢 |
@@ -472,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×._ _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) ![Diving into the Bahamas and revealing labeled submarine cable routes beneath the globe](docs/media/09-undersea-cables.gif)
@@ -484,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). > 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`. 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`.
+1 -1
View File
@@ -81,7 +81,7 @@ The data proxies under `server/providers/` are written so the browser cannot tur
- **Sanitized errors** — internal error details are not echoed back to clients. - **Sanitized errors** — internal error details are not echoed back to clients.
- **Coalesced OAuth refresh** and cached successful responses only (OpenSky). - **Coalesced OAuth refresh** and cached successful responses only (OpenSky).
- **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing. - **Redacted debug logging.** The voice debug log (`.gev-logs/`, gitignored) strips API keys, bearer tokens, client secrets, and image data URLs before writing.
- **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers. - **Cross-site gate on cost/log endpoints.** The cost-bearing (`/api/realtime/token`, `/api/openai/hud-summary`, `/api/google/nearby-places`, `/api/google/text-search`), quota-bearing (`/api/mapillary/*`, with a per-IP limit on tiles, and `/api/tomtom/*`, backed by its persistent daily tile budget) and log (`/api/realtime/debug-log`) endpoints refuse cross-site browser requests: a foreign or opaque `Origin`, a `Sec-Fetch-Site` other than `same-origin`/`none` (this is what blocks an `<img>`/navigation that carries no `Origin`), or any reverse-proxy/CDN forwarding header returns `403`. Non-browser loopback tools (the repo QA harnesses POST the token endpoint with no `Origin`) and the explicit `HOST=0.0.0.0` LAN opt-in keep working — the gate deliberately does not require a loopback remote address; that stricter requirement belongs only to the credential panel (`admitKeySetupRequest`). The Host check (`allowedHosts`, applied by `hostCheckPlugin` ahead of every route, since Vite's own check runs only after plugin middleware) rejects a rebinding `Host` before any handler runs, in every mode, and the gate is defense in depth behind it: the LAN opt-in keeps that check and accepts only IP addresses, `localhost` and names listed in `GEV_ALLOWED_HOSTS`. **Limit:** a name you list there is trusted like the app's own address, so list only names you control. The keyless map routes (`/api/terrain/heights`, `/api/drone-airspace`, `/api/inaturalist/observations`, `/api/acartia/sightings`, `/api/overpass` and voice place search's `/api/osm/features`, which spends the same operator Overpass) use the same cross-site checks, minus the forwarding-header refusal: they guard only per-client quotas and caches, not a key or a bill, and the map and voice need them to work behind a reverse proxy. `/api/overpass` also admits at most 300 POSTs a minute per peer (IPv6 peers keyed by /64) and 900 a minute across all remote peers before it reads a body. A direct loopback request — a loopback socket with none of the forwarding headers `hasProxySignals` checks (`Forwarded`, `Via`, `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`, `X-Real-IP`, `CF-Connecting-IP`, `CF-Ray`) — draws on its own per-peer admission window, so remote peers cannot exhaust the owner's admission. A reverse proxy on the same machine must add at least one of those headers (Caddy and Cloudflare do by default; nginx needs `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`, plus `proxy_set_header Host $http_host;` and `proxy_set_header X-Forwarded-Proto $scheme;` so the `Origin` check matches), or its traffic is indistinguishable from the owner's and shares the owner's window. With one, its requests are then counted as remote, in one bucket per proxy socket — the forwarded client address is never trusted as identity — so all users behind one proxy share 300/min. The isolation needs a loopback socket: under the default Docker bridge network every host-side client, the owner included, arrives from the container gateway and is one remote peer. **Limit:** this isolates admission only. The upstream budget (90 requests/min per client key, 300/min across all clients) is shared, so once remote traffic has used the global upstream budget, an owner request that misses the cache can still get `429`, or `503` when remote misses hold every upstream concurrency slot; cache hits are unaffected. The CCTV routes (`/api/cctv/*`) use the same keyless checks; their one key-bearing step, the Street View fallback, is skipped for any request with forwarding headers.
- **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements. - **Full Content-Security-Policy.** The dev and preview servers send a real CSP (`script-src 'self' 'unsafe-eval' blob:` plus the YouTube, Facebook and X scripts the Nepal event's embedded media loads and the Maps JavaScript API that Street View runs on, with a matching `frame-src` for their players and posts: no inline script and no other script origin; `blob:` covers the object URLs Cesium's bundled workers bootstrap through in the built app), alongside the existing `X-Frame-Options: DENY` and `frame-ancestors 'none'`. `'unsafe-eval'` stays because Knockout, bundled inside Cesium's widgets package, resolves the global object with `eval` at load time and the globe does not initialize without it; styles permit `'unsafe-inline'` and Google Fonts because Vite's dev client injects `<style>` elements.
Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance. Allowing `maps.googleapis.com` in `script-src` also trusts its JSONP endpoints, which can bypass the script allowlist if an HTML-injection bug exists; this is an accepted limitation of loading the Maps JavaScript API, alongside the existing `'unsafe-eval'` allowance.
+5
View File
@@ -0,0 +1,5 @@
- Add Rio de Janeiro to the Transit layer: municipal buses and the Mobi-Rio BRT
from SMTR's keyless GTFS-Realtime feeds (CC BY 4.0), the layer's first Latin
American region. Rio's internal route keys are joined to SMTR's published
GTFS `routes.txt`, so a card reads "Route SN731" rather than
"Route O0731AAN0A" (IMBrandao, #1481).
+6
View File
@@ -0,0 +1,6 @@
- Voice "tilt the camera" works from the top-down view a fly-to lands on: the
camera swings about the place under the crosshair, keeps its heading, and can
tilt to a spoken angle ("tilt 45 degrees") or a default oblique view. A tilt,
orbit, pan or rotate said with a fly-to ("fly to Paris and orbit it") now
waits for the flight to land, and is dropped if you move the map meanwhile
(Manjunath, @manjunath22466, #1520).
+4
View File
@@ -0,0 +1,4 @@
- Cockpit Contact counts and the Contacts list name the provider that
answered, marked `FALLBACK` or `STALE` as in the Layers panel. Flights from
the adsb.lol fallback no longer read OpenSky, and vessels no longer always
read AISStream (German, @gekh, #1521).
@@ -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).
+11
View File
@@ -0,0 +1,11 @@
- Keep the globe in view from orbit. Cesium's free look (Shift+drag) turned
the camera in place, so a few drags from the whole-globe view left only
stars, and the pose stayed in the share link. Free look now works only below
1,000 km, and above it a drag that starts on the stars orbits the globe
instead of turning the camera; a camera move that ends above it with no
planet surface in view (inside the scope keyhole when the scope is on) turns
straight down; and voice tilt and turns from orbit stop while the planet is
still on screen and say so instead of reporting a turn they could not make.
Thanks to [Chad Elliott](https://github.com/THEtheChad) for the review that
closed the drag path and kept authored scenes framed (Manjunath,
@manjunath22466, #1532).
+5
View File
@@ -0,0 +1,5 @@
- Keep typed chat's view when switching AI Provider: Settings opened from
chat stay chat's Settings for the new provider instead of falling back to
voice and widening the dock. Opening chat or closing the
voice panel now also clears a voice error left on the dock (Manjunath,
@manjunath22466, #1546).
@@ -0,0 +1,6 @@
- Enter Cockpit with only the ridden aircraft's layer on. Contacts is no longer
required, and ending Contacts mid-session keeps the view and the layer it
rides; turning off the aircraft's own feed ends the view, a missing other
feed reads `… FEED OFF · PARTIAL PICTURE`, and double-clicking the followed
aircraft or the entry beside its readout enters it (Denis Groshev, @ZloyS,
#1556).
@@ -0,0 +1,3 @@
- Chat autocomplete finds Arabic suggestions typed without short vowels or
shadda, so «تتبع» now suggests «اعرض تتبّع الرحلات المباشر»
(German, @gekh, #1561).
@@ -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).
+12
View File
@@ -0,0 +1,12 @@
- Add Wildlife Sightings in the Wildlife layer group, off by default, using
keyless iNaturalist observations within 48 h (whales 7 d) and Acartia Salish
Sea marine mammals. CONFIRMED and taxa filters share via token `9`.
A noninteractive density glow accompanies real-sighting taxon badges,
decluttered species labels and credited ambient photos. Selection stays
pinned across camera and sample changes and opens an anchored photo card
with a contained image, creator/licence and one iNaturalist source link.
Obscured and Acartia sightings use text-only cards; private records and
Acartia comments are excluded. Attribution appears in the layer row and
Data attribution popover. Cached progressive tiles, bounded image/billboard
work and a paced queue preserve iNaturalist's 60/minute and 8,000/day limits.
All 11 locales supported. (lyehe, #1591)
@@ -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).
+228 -47
View File
@@ -65,6 +65,59 @@ settling. Oblique views below 300 km are left alone.
`QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises `QA_BASE_URL=http://localhost:4173 node scripts/qa-zoom-out-guard.mjs` exercises
the real browser controller at desktop and phone widths with keyless fixtures. the real browser controller at desktop and phone widths with keyless fixtures.
Cesium's free look (Shift+drag, which turns the camera in place) works only
below 1,000 km. Above that height a few drags could turn the whole planet out of
frame and leave only stars, so Shift+drag does nothing there. Cesium also turns
the camera in place for a drag that starts on the stars below its trackball
height (7,500 km by default); that height is lowered to 1,000 km, so such a drag
orbits the globe above it, as it does higher up, and a tilt (drag or touch)
while the screen centre is on the stars there tilts about the planet's edge or
does nothing, wherever it starts. Rotation drags that start on the planet, and
zoom, are unchanged. How much of the frame shows the planet's surface is
measured by casting a 32×18 grid of view rays at the ellipsoid; while the scope
mask is on (the default) only the rays inside its keyhole count, since the mask
hides or dims the rest. Voice tilt and in-place turns of a free 3D camera above
1,000 km stop when about a tenth of the frame still shows the surface (or about
as much as it showed when it started), never less than about 1% of the visible
frame, and a view that already shows less only keeps or gains what it has. A
turn that cannot go half a degree further without crossing that limit is refused
with a reason instead of reported as done, and a turn it accepts turns at least
that half degree. A voice tilt to an angle (or a bare tilt, to -45°) that the
limit stops short of is aimed at the angle where it stops, and its result names
that angle (`pitchDeg`) with a `note`. A turn that keeps or gains surface is not refused, even after
the window has been resized, except that near the limit, where the planet's edge
crosses the sample grid, sampling wobble can occasionally refuse one (most often
above about 15,000 km and on phone or ultrawide frames); nothing then turns away
from the planet. A turn that starts where the last voice turn left the camera
keeps that turn's limit, or a higher one if the planet now fills more, so
repeating the request does not take any more; once anything else has moved or
turned the camera, a turn is measured from what is on screen. When a camera move
ends above 1,000 km with no ray on the surface, the camera turns straight down
at the same position and heading. The end of a move is taken from Cesium's
move-end event and from a check of its own that samples the pose every 250 ms
after a move starts and acts once it has held for half a second, because at some
of these poses the camera drifts by a rounding error every frame and Cesium
never sees it stop. That check keeps watching until move-end arrives, recovering
once after each move that comes to rest, and gives up five minutes after the
camera last moved. Limb glow, and a surface sliver up to about one grid cell
thick (some 50 px on a 900 px tall frame), can count as out of view. The
zoom-out guard above keeps wheel and pinch zoom-outs in view; this catches the
other ways a move can end looking away, such as a saved or shared view that did.
Views with more surface than that, such as the oblique orbital Director shots,
are left as they are, and so are tracked or locked cameras, a camera flight in
progress, a Director scene while it plays (holds included), a pointer held
down on the globe (a drag held still; with several fingers down, until the
last is released) and the 2D and Columbus modes; a check a flight, scene or
held pointer defers runs once it ends. A release outside the window that the
page never sees counts as given once the window loses focus, the page is
hidden, the canvas loses the pointer's capture, or the pointer moves with no
button down. A Director
shot loaded or replayed outside a run is not a playing scene. A view that loses
its surface without a camera move (a resize, or turning the scope on) is left as
it is until the next move. A voice turn requested while the camera is tracked is
judged by the pose voice's release of the follow camera will leave, before that
release, so a refused turn keeps the tracking.
Analyst region queries resolve unique Natural Earth names offline without Analyst region queries resolve unique Natural Earth names offline without
geocoding, so Gold Coast and Ivory Coast name the West African natural regions. geocoding, so Gold Coast and Ivory Coast name the West African natural regions.
Duplicate names require a geocoded containment anchor; when several entries for Duplicate names require a geocoded containment anchor; when several entries for
@@ -98,6 +151,105 @@ across the antimeridian, so they cannot use the strict ±180 check as-is, and
`src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own `src/sources/adsbRecords.js` and `server/providers/common/geo.js` keep their own
implementations. implementations.
## Wildlife Sightings — iNaturalist and Acartia
**Wildlife Sightings** (`wildlife-sightings`, share-link token `9`) is off by
default in the Wildlife group between Events and Weather. CONFIRMED and six
taxa presets round-trip in share links. Voice is off for this layer for now;
it is outside analyst queries and DETECT. Community observations are not a
census; private records are excluded.
`/api/inaturalist/observations` requests verifiable wildlife in view within
48 h (whales 7 d). Recency uses observation time: the `d1` date filter and a
strict rolling cutoff. Date-only rows qualify only when their day starts
within the window. Close views use a cached random sample of at most 200 rows;
global samples use newest observations first. Observation caches last five
minutes (global ten minutes), with stale fallback for at most one hour.
Observations and grid tiles share a paced priority queue: a four-call burst,
then one request per second, at most four in flight, ≤60/minute and a persisted
8,000/day budget. Superseded consumers leave before dispatch. The recovered
queue tail isolates failures; ordinary grid failures do not fail observations.
Provider 429 cooldown is shared; 5xx cooldown is resource-specific. Failed tiles
retain good cells for at most one hour. Stale replies carry `X-Data-Stale` and
known `Retry-After`. Failures keep filters and drawn rows, reporting DEGRADED
with retry timing; logs exclude request URLs and upstream exception text.
A partial failure still draws what arrived: a failed iNaturalist request, or
one failed half of an antimeridian view, keeps a good Acartia reply and the
other half, retains that area's last iNaturalist rows and reports DEGRADED.
The update fails outright only when every requested source failed. Each failed
source keeps its HTTP status and `Retry-After` (the HTTP status counts when no
`X-Upstream-Status` is sent), and the next request waits out the longest 429 or
5xx cooldown among them. One update requests both sources, so the cooldown
holds the whole layer; a fully successful refresh clears it.
`/api/acartia/sightings` adds Salish Sea marine mammals within 45.5–50.5° N,
126–121° W, cached ten minutes and stale for at most 24 h. A valid empty
snapshot, including one whose rows all fall outside coverage or the species
checks, is a fresh success that clears earlier rows; only a malformed or failed
upstream reply serves stale and starts a retry cooldown. WHALES accepts only
recognized cetaceans; other identified marine mammals appear under ALL or
MAMMALS. Sensitive iNaturalist locations remain 0.2° privacy cells; Acartia
positions are rounded to 0.01° on the server. Acartia comments are never retained.
Observer identity is retained only for photo attribution.
`/api/inaturalist/grid` supplies a dedicated, nonpickable billboard density glow,
with a view-relative density ramp from cool teal to warm amber. Overlapping
Gaussian splats span four logical cell widths; mono and CRT styles follow map
and cockpit vision changes. Empty snapshots paint nothing. Full footprints
stay inside the globe limb with a 5% radius margin; opacity fades from full at
3,000 km to zero at 8,000 km camera altitude. Culling and cached coordinates
add no per-frame work and leave sighting counts intact.
Only real sampled observation rows produce taxon badges at their public
positions. The badges use 28 px screen buckets, a 22 px overlap check and a
5,000-observation cap. Tinted circles have a white Material Symbols glyph and
1.5 px dark outline, remaining 12–22 px at all distances, including selection.
Glyphs: birds `raven`, mammals `pets`, reptiles `egg_alt`, amphibians
`water_drop`, fishes `set_meal`, insects `bug_report`, arachnids `pest_control`,
plants `eco`, fungi `umbrella`, other animals `category`, unknown `question_mark`.
A plain circle displays while the icon font loads. Row counts read
“{n} sightings in view”, using visible grid totals when available.
Views request at most six tiles, coarsening zoom for complete coverage. Server
and client keep bounded 400-tile caches for 15 minutes. Cached, stale and
adjacent-zoom tiles publish before network waits; each arriving tile replaces
its covered area, including healthy empty tiles. Grid tiles publish even while
the independent observation request is pending or failed. Enable waits for one
camera settle; camera acquisition uses moveEnd with a 600 ms debounce. The
documented UTFGrid endpoint's unspecified cell records have a schema guard;
see `KNOWN-ISSUES.md`.
Ambient cards use a fixed 96 px image width and a short creator name credit,
with a bounded frame cache. New cards fill free screen regions; visible cards
survive a random sample refresh until they leave the view or observation
window. The 110/125 km show/hide thresholds provide hysteresis; cards scale
and fade gradually through 120 km. Only painted photos suppress their own
badge and label. Labels yield to painted cards, then compete by ambient score;
repeated translated names within 80 screen pixels yield to higher-priority
content. At most 150 labels enter the host. Titles prefer a word boundary for
ellipses; accessible names remain complete. Selected thumbnails or names use
the shared selected chrome and paint lane, with no canvas source link.
Clicking a badge, label or photo flies to its supplied position and opens a
compact anchored photo card. Selection stays pinned across camera, filter and
sample changes until explicit deselection or layer disable. Its medium image
uses `object-fit: contain`, with names, compact observed age and creator/licence.
The card is the single source link to iNaturalist. Obscured sightings also stay
text-only; Acartia stays text-only with provider credit and whale guidance,
and its source link is always `https://acartia.io/`, never a contributed URL.
Placement avoids actual HUD, toolbar and rail rectangles, panning only when
necessary while preserving zoom. Escape, close or an outside click closes the
card and clears selection. Camera/layout changes reposition the card without
an idle frame listener; shell teardown also removes its host and subscription.
Attribution appears in the layer row and Data attribution popover:
“iNaturalist contributors · licences per record”, plus Acartia when drawn.
Disable/destroy releases the glow collection, texture atlas, timers, queues and
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 ## Prohibited & Restricted Airspace — FAA special-use airspace
The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`, The Infrastructure group includes **Prohibited & Restricted Airspace** (`drone-airspace`,
@@ -589,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 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. 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. 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. 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. 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.
@@ -766,9 +924,12 @@ each side, and the empty Voice Preview suggests things to say until voice
starts. Settings opened from chat keep chat's frame and show the shared AI starts. Settings opened from chat keep chat's frame and show the shared AI
provider, the quick-commands option and then the voice preferences (Voice provider, the quick-commands option and then the voice preferences (Voice
Preview, inactivity timeout and model tier), so they stay in reach while Preview, inactivity timeout and model tier), so they stay in reach while
Preview is off, with Back to chat; voice Settings show Back to voice. Escape steps back from either Settings view, closes Voice Preview is off, with Back to chat; voice Settings show Back to voice. Switching
AI Provider keeps that view: Settings opened from chat stay chat's Settings,
in chat's frame, for the new provider. Escape steps back from either Settings view, closes Voice
Preview, and does nothing in chat. Closing chat ends a typed session, and the Preview, and does nothing in chat. Closing chat ends a typed session, and the
voice panel's close turns voice off. Asking the assistant from chat to open voice panel's close turns voice off. Opening chat or closing the voice panel
also clears a voice error left on the dock. Asking the assistant from chat to open
Voice Settings or Voice Preview puts chat away, minimized or not, and shows the Voice Settings or Voice Preview puts chat away, minimized or not, and shows the
voice panel; the typed conversation and its running reply carry on, and the voice panel; the typed conversation and its running reply carry on, and the
chat button brings them back. chat button brings them back.
@@ -1541,6 +1702,8 @@ are owned by a renderer-free panel component. The layer manager supplies current
snapshots, lifecycle actions and row descriptors. Remount and teardown remove snapshots, lifecycle actions and row descriptors. Remount and teardown remove
listeners and row subscriptions; obsolete completions do not repaint old rows. listeners and row subscriptions; obsolete completions do not repaint old rows.
The clear control presents busy state while its existing action owns the transaction. The clear control presents busy state while its existing action owns the transaction.
The Wildlife group follows Events and precedes Weather; Wildlife Sightings
provides its confirmed toggle, taxa presets and fixed-order, nonempty legend.
## Sky background ## Sky background
@@ -4184,34 +4347,35 @@ nearby place—always use station selection. Unqualified “turn on/start the ra
requests use Play; a qualified Play-shaped tool call is normalized to Select so requests use Play; a qualified Play-shaped tool call is normalized to Select so
its criteria cannot be silently ignored. its criteria cannot be silently ignored.
| Layer | Source | File | Proxy | Update Interval | | Layer | Source | File | Proxy | Update Interval |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------- | | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s | | Live Flights ✈️ | OpenSky Network; bounded adsb.lol regional fallback | `src/data/flights.js` | `/api/flights` (OAuth + fallback) | 30s |
| Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s | | Military Flights 🎖️ | adsb.lol /v2/mil | `src/data/militaryFlights.js` | `/api/military` | 15s |
| Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) | | Live AIS Vessels 🚢 | AISStream websocket | `src/data/aisLiveVessels.js` | `/api/vessels` | 60s (+800ms visibility pass) |
| Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles | | Mapped Installations ⌖ | OpenFreeMap military areas; optional configured Overpass names and Google Places search | `src/data/militaryInstallations.js` | OpenFreeMap tiles, `/api/military-installations`, `/api/google/text-search` | viewport-driven; transient errors back off, missing capability switches to tiles |
| Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s | | Earthquakes | USGS | `src/data/earthquakes.js` | — | 60s |
| Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog | | Air quality | ECCC MSC GeoMet OGC API (AQHI, Canada only) | `src/layers/airQuality/` | — | 20 min readings, 12 h station catalog |
| Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s | | Satellites | CelesTrak | `src/data/satellites.js` | `/api/celestrak` | 120s |
| Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min | | Space Missions (30d) | Launch Library 2 + CelesTrak | `src/data/rocketLaunches.js` | `/api/launches` + `/api/celestrak/active` | 5 min |
| Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache | | Traffic | Selectable TomTom / OpenStreetMap / Hybrid roads; optional TomTom flow (BYOK) | `src/data/traffic.js` | browser-direct OpenFreeMap tiles; `/api/tomtom` for flow | viewport-driven; capped tile cache |
| CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) | | CCTV | Austin + Caltrans (CA) + TfL London + Ontario 511 + Fintraffic (FI) + DriveBC (BC) + TxDOT (TX) + Estonia (Tallinn, Tarktee) + Live Traffic NSW + Open Calgary Open Data + Statens vegvesen (NO, stills + live HLS, Kartverket ground heights) + King County (WA) Open Data + WSDOT (WA) Open Data + NYC DOT TMC (opt-in) + City of Toronto RESCU + Catalonia (Servei Català de Trànsit) + Hong Kong Transport Department + FL511 (FL, opt-in) + Freeway Bureau, MOTC (Taiwan) + Houston TranStar (opt-in) + Autostrada del Brennero (IT, A22 stills) + Street View fallback | `src/data/cctv.js` | `/api/cctv` | 10s (active) |
| Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh | | Radio | Radio Browser (public-domain station directory) | `src/data/radio.js` | `/api/radio/stations`, `/api/radio/click/:uuid` | 45 min directory refresh |
| Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) | | Transit 🚌 | Operator GTFS-Realtime VehiclePositions (7 keyless regions, `src/data/transitFeeds.js`) | `src/layers/transit/` via `src/app/layers/transit.js` | `/api/transit` | 15s (poll + delayed playback) |
| Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s | | Bikeshare 🚲 | GBFS (Lyft + BCycle + Social Bicycles + PBSC incl. Bike Share Toronto, Bike Itaú (Brazil), Bicing (Barcelona) and seven other Spanish cities + Urban Sharing, Entur, JCDecaux (Lillestrøm, Valencia, Seville) + nextbike (AMBici)) | `src/data/bikeshare.js` | `/api/gbfs` | 60s |
| Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change | | Directions 🧭 | OSRM on FOSSGIS servers (OpenStreetMap) | `src/data/directions.js` | `/api/route` (`steps=1`) | on placement / mode change |
| Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static | | Datacenters ▣ | OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static | | Dams ▰ | OpenInfraMap/OSM extract (bundled) | `src/data/infrastructure.js` | — | static |
| Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static | | Submarine Cables ◠ | TeleGeography public map (bundled) | `src/data/telegeographySubmarineCables.js` | — | static |
| FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) | | FIRMS Active Fires ▲ | NASA FIRMS live (VIIRS ×3 NRT + MODIS NRT, trailing 24h) | `src/data/firmsHeatmap.js` | `/api/firms` (`FIRMS_MAP_KEY`) | 10 min (proxy TTL 30 min) |
| Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) | | Street Level 📷 | Mapillary street-level imagery (vector tiles, Graph API, MapillaryJS) | `src/layers/streetLevel/` via `src/app/layers/streetLevel.js` | `/api/mapillary/status`, `/api/mapillary/tiles/coverage/{z}/{x}/{y}` (`MAPILLARY_CLIENT_TOKEN`) | camera-driven (320 ms debounce, ≤9 tiles, server memory cache 24 h) |
| Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) | | Wind 🌬 | NOAA GFS 10 m wind (keyless, 0.25°→1° grid; animated particles) | `src/data/wind.js` | `/api/wind` | 1 h (forecast cycle) |
| GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) | | GNSS Integrity 📡 (`gnss-integrity`) | adsb.lol point snapshot, low navigation accuracy binned per 0.5° cell (GEV NIC/NACp threshold + gpsjam.org formula and bands, keyless, ODbL) | `src/layers/gnss/` via `src/app/layers/gnss.js` | `/api/gnss-integrity` | camera-driven (600 ms debounce, minimum 2.5 s between reads) + 60s poll (server cache 60 s; 30 min rolling window) |
| S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop | | S2 GhostMaps 👻 (`s2-ghostmaps`) | S2 Underground's public GhostMaps repository on GitHub (KMZ intel products, keyless, CC BY-NC-SA 4.0) | `src/layers/ghostMaps/` via `src/app/layers/ghostMaps.js` | `/api/ghostmaps/catalog`, `/api/ghostmaps/events` (server-sent events), `/api/ghostmaps/file/{product}` | branch head checked every 15 s; new commits pushed to the page; 5 min catalog backstop |
| Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | | Sea Marks ⚓ (`sea-marks`) | OpenSeaMap seamark raster tiles (keyless; CC BY-SA 2.0 tiles, OSM data ODbL) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) | | Ski Pistes ⛷ (`ski-pistes`) | OpenSnowMap piste raster tiles (keyless; CC BY-SA tiles, OSM data ODbL; no bulk download) | `src/data/osmOverlays.js` | — (browser-direct tiles) | on view (Cesium tile requests; no polling) |
| Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) | | Fire Perimeters 🔥 | NIFC WFIGS current interagency perimeters (keyless, paged past the 2000-record cap); InciWeb catalog + incident page origin and update time checks for verified incident-page links | `src/layers/perimeters/` via `src/app/layers/perimeters.js` | `/api/fire-perimeters` + `/api/fire-perimeters/inciweb/*` | 5 min (server caches: catalog 1 h; publication 30 min) |
| Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) | | Prohibited & Restricted Airspace 🚫 | FAA Special Use Airspace prohibited (P) and restricted (R) zones (keyless FeatureServer, simplified geometry) | `src/layers/droneAirspace/` via `src/app/layers/droneAirspace.js` | `/api/drone-airspace` | 1 h (server cache 1 h; stale-on-error) |
| Wildlife Sightings 🦉 | iNaturalist wildlife observed within 48 h (whales 7 d), per view; Acartia Salish Sea marine mammal coverage (keyless) | `src/layers/wildlifeSightings/` via `src/app/layers/wildlifeSightings.js` | `/api/inaturalist/observations`, `/api/inaturalist/grid`, `/api/acartia/sightings` | camera moveEnd (600 ms debounce; enable settles 300 ms) + 5 min (observations 5 / 10 min; tiles 15 min; stale ≤ 1 h / 24 h) |
GNSS Integrity samples a 250 nm circle around the Earth point under the screen GNSS Integrity samples a 250 nm circle around the Earth point under the screen
centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap; centre. Camera reads debounce for 600 ms with a monotonic 2.5 s minimum gap;
@@ -4265,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, 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 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 Street View, KartaView and Panoramax are registered. Without
`MAPILLARY_CLIENT_TOKEN` the panel reads KEY REQUIRED and the controls are `MAPILLARY_CLIENT_TOKEN` (and no Graph proxy) Street Level starts on the first
disabled. With it, coverage draws as z11–14 sequence lines below 60 km; the proxy strips the unused `image` layer from z14 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 tiles (12 MB → ~80 KB) and shares one upstream fetch between concurrent
requests for a tile. requests for a tile.
@@ -4280,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 the date line count a shared tile once). Lines are draped
`GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain `GroundPolylinePrimitive`s (`ClassificationType.BOTH`), so they follow terrain
and Google 3D tiles alike; cones and the photo marker are ground-clamped 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 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 key reads KEY REJECTED and stops tile requests until the layer goes off; a 429
pauses them for its Retry-After. pauses them for its Retry-After.
@@ -4644,8 +4810,9 @@ Transit is off by default, and share links use token `j`. The movement panel ord
places Transit directly after Street Traffic and before Bike Share. Cameras and places Transit directly after Street Traffic and before Bike Share. Cameras and
utilities retain their own groups. The layer polls utilities retain their own groups. The layer polls
registered feeds near the camera every 15 seconds, below a 3,000 km altitude registered feeds near the camera every 15 seconds, below a 3,000 km altitude
gate. Sixteen keyless regions are available: Boston, Austin, Minneapolis–St Paul, gate. Seventeen keyless regions are available: Boston, Austin, Minneapolis–St Paul,
Helsinki, the Netherlands, Norway, South East Queensland, and nine in Helsinki, the Netherlands, Norway, South East Queensland, Rio de Janeiro
(municipal buses and the Mobi-Rio BRT, two feeds), and nine in
Ontario — Toronto (TTC surface vehicles; the subway publishes no Ontario — Toronto (TTC surface vehicles; the subway publishes no
positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph, positions), MiWay, Hamilton HSR, Durham Region, YRT/Viva, Guelph,
Burlington, Barrie and Kingston. Mode silhouettes Burlington, Barrie and Kingston. Mode silhouettes
@@ -4855,7 +5022,7 @@ cannot claim full acceptance.
**Lifecycle and proxy.** Contacts temporarily disables Transit and restores its **Lifecycle and proxy.** Contacts temporarily disables Transit and restores its
prior state on exit; Space Missions refuses enablement while isolating replay prior state on exit; Space Missions refuses enablement while isolating replay
data. Cockpit enters through Contacts and does not independently enable Transit. data. Cockpit does not need Contacts and does not independently enable Transit.
The proxy fetches registered URLs only, validates redirect origins, bounds The proxy fetches registered URLs only, validates redirect origins, bounds
responses and decoding, caches successful snapshots, and backs off after failed responses and decoding, caches successful snapshots, and backs off after failed
refreshes. A 304 confirms contact without appending history. Missing vehicles refreshes. A 304 confirms contact without appending history. Missing vehicles
@@ -5169,10 +5336,11 @@ 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. - 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. - 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`. - 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`. - 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. - 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. - 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.
- Contacts rows and the Cockpit Contact counts name each cohort's provider: the source of the last snapshot, marked `FALLBACK` or `STALE` as its layer row would be. A poll in flight keeps the mark, where the layer row shows `LOADING`. A flight snapshot from the adsb.lol regional fallback reads `adsb.lol · FALLBACK`, not `OpenSky`.
- The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m. - The window's edge is drawn on the globe as a 250 km range ring: a 4 px polyline with a 2 px Context-blue core inside a 1 px dark halo on each side, 2.5 km above the ellipsoid (`AWARENESS_RANGE_RING_STYLE` in `src/layers/awareness/policy.js`). The halo keeps it legible over bright desert and snow, dark ocean and forest, and under the FLIR, NVG and CRT styles, where the earlier 1 px ellipse outline disappeared. It is a polyline because WebGL draws ellipse outlines at 1 px whatever width is asked for, drawn as an unpickable primitive rather than an entity so a click or tap on its edge reaches the contact underneath (headless viewers without a primitive collection get a static entity instead); it is rebuilt when the subject moves. Three dim sonar pulses (3 px with a 1 px dark outline, same colours, at most 60% opacity) grow slowly from the subject at constant speed, one every 3 s and each taking 9 s, and arrive at the ring at about a third of that. Behind each pulse a radial gradient in the same blue fades inward over 35% of the radius. One unpickable 250 km disc draws both the pulse lines and the gradient, its shader reading the pulse radii and opacities each frame, so a click or tap near a pulse still reaches the contact underneath; the disc follows the subject by a model-matrix rotation and is rebuilt only after the subject moves 20 km, and not at all while the pulses rest. While the camera is below the disc's 2.5 km height, where it would sweep across the sky, the pulses rest: the disc is hidden and there is no ring flash, no echo and no frame request. Terrain or 3D tiles higher than 2.5 km (the Alps, Andes, Rockies) still cut through the disc and the ring when seen from above, as the ring alone already did; the height check uses the camera's height above the ellipsoid, not above the ground. While pulses run, the ring dims to 40% and flashes to full strength as each pulse reaches it, fading over 1.8 s. As a pulse front crosses a flight, military aircraft or AIS vessel, the detection overlay draws a second bracket around that contact's own bracket, in the pulse blue on the default detection style and in the style's own line colour under NVG, FLIR and CRT, which spreads outward as it fades over 1.4 s (`AWARENESS_RANGE_ECHO`, read through `src/awarenessEcho.js`; no layer's icon is restyled, and mapped installations have no detection bracket). Pulses are born 5 km out, so the subject itself and anything nearer never echo. Pulses run for 9 s (three ring flashes) after a selection or a camera move, fading in and out over 1.2 s, then rest: the ring stays on screen at full strength, the pulses, gradient and echoes are hidden, and nothing asks for frames, so a parked Contacts view is quiet again. Only a camera move within 1.5 s of the user handling the map (pointer, wheel or touch on the Cesium canvas, which covers a drag's inertia, and a drag or touch begun there keeps counting until its last finger or button lifts, even outside the canvas, unless the window loses focus or the page is hidden first; keys typed elsewhere do not count, nor does input while Cesium's camera controls are off, as in the cockpit view, and a gesture still pending when the cockpit or another owner takes the camera is dropped) wakes them; a moving subject and the app's own camera moves (a tracked aircraft, the cockpit view, an orbit, a fly-to) do not, since they move on every refresh. While they run they never hold continuous rendering: each drawn frame arms one render request 80 ms later (the Cyber sonar cadence, `CYBER_SONAR_RENDER_INTERVAL_MS`), so they repaint at about 12.5 fps and never beat the detection lane's 80 ms gate. Under `prefers-reduced-motion`, or in a scene without primitives or preRender and postRender hooks, there are no pulses or echoes and the ring stays on screen; changing the preference mid-session rebuilds the ring. The ring follows a moving subject once it has moved 250 m.
- For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop. - For a selected live aircraft or vessel, the context window refreshes from the existing tracker/feed position every 750 ms, so nearest distances and cohort counts follow the subject without introducing a duplicate poll loop.
- Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff. - Aircraft cohort membership uses every locally loaded, selectable contact inside the 250 km window, including a plane hidden only by horizon culling or because its 3D model owns the visual. Counts and navigation therefore do not change with the current camera angle or billboard/model handoff.
@@ -5189,7 +5357,7 @@ silently demoting every later lookup for the session.
### Motion & Symbology Correctness (June 10, 2026) ### 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. - **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. - **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. - **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.
@@ -5585,7 +5753,7 @@ and unreachable upstream (502/504) separately from road geometry.
> branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on > branch: **2,281 on Node 25.6.1** (allocation suites skipped) = 2,255 on
> `main` + 26 route pins; the same tree on Node 24 reports 2,295. > `main` + 26 route pins; the same tree on Node 24 reports 2,295.
- **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid altitude varied. A close-up or overlap refuses without moving the camera. When the unchanged Globe View preset fits, voice says “Globe rotation isn’t available from this view. You can say ‘Reset to Globe View and rotate the globe.’” without asking a permission question or automatically resetting. Explicit Reset/Show Globe View commands remain direct; an explicit reset-and-rotate request waits for reset success before requesting rotation. If OpenAI dispatches both tools together, rotation waits for earlier same-response resets and is skipped if any fails or is cancelled. OpenAI rejects later tool calls in the same response after a globe refusal, including calls arriving while globe admission is pending, and preserves response-scoped `tool_choice: none` for the refusal follow-up; Gemini rejects further tool execution, including queued sibling calls, until fresh typed input or admitted speech. Model interruption, advisory captions and microphone toggles do not release that guard. There is no pending Yes/No offer. Outside the refused response, explicit-command interpretation and avoiding repeated resets remain model-guided; a distinct model-issued reset call is not an app-enforced proof of user intent. A failed standalone reset can still be retried by the model, and a reset result does not guarantee the camera has fully settled. Before suggesting a reset, a cloned camera checks the unchanged Globe View destination against the same keyhole/depth rules without moving the live camera. If that preset cannot fit, `globe-reset-unavailable` asks for further zoom and the refusal instructs further zoom instead of a reset; this also covers ultrawide views. Unsupported view geometry or non-WGS84 projection assumptions fail closed without the closer-zoom instruction. Supported camera inputs/navigation intents cancel rotation before their action. Finite radius greater than the ellipsoid's maximum radius proves whole-arc clearance at constant centre-distance; no low-altitude swept solver is needed. The unchanged Globe View reset preset remains 18,000,000 m. Terrain and buildings remain outside the guarantee. - **Camera verbs** (`src/cameraVerbs.js`) — one motion slot, driven per clock tick. `move_camera` orbits/pans/tilts/rotates; `fly_route` is a cinematic dolly along an existing route annotation. `rotate_globe` rotates the world-space camera rigidly around Earth's center using its screen-up axis, preserving geocentric range and framing. Left/right describe visible globe motion, while `rotate` turns the camera in place. `tilt` swings the camera about the ground under the crosshair, so the place stays centred and the heading is kept. It tilts in place instead with no ground under the crosshair, from beyond 1,000 km of range, or while an entity is tracked (voice releases the follow camera before moving, so a voice tilt from a tracked view still pivots); pitch stays within -89° to -5°, clamped only in the direction of travel so a top-down (-90°) view can tilt up out of it. Angles and limits are the camera's own pitch; at regional range the pivot's pitch is up to about 8° shallower (the Earth curves away), so the swing solves for the pivot pitch that gives the camera the angle asked for. A pivot swing toward the horizon lowers the camera (height above the pivot is range × sin|pitch|), so the swing stops lowering it at 150 m above the pivot (not above the terrain behind the camera) and the rest of the tilt happens in place. Beyond about 17 km of range the swing never hands over to an in-place tilt (that would lift the crosshair onto the sky); it keeps the camera at least 0.5° above the place's own horizon, which from roughly 500 km stops it short of -5° with the place still centred: a tilt to an angle beyond that stops at the angle reached and says so (`pitchDeg` is the angle reached, with a `note`), a continuous tilt stops there, and a further tilt up is refused as at the limit. A zoom during a pivoting tilt scales the range it holds, as for orbit, but a zoom in stops at the range that keeps that 150 m clearance; a zoom that can move no further (that floor, the 80 m floor, or the cap: 1,000 km for a tilt, where pivoting stops, and 5,000 km for an orbit) is reported as not zoomed. A zoom during a tilt to an angle re-judges the place's horizon limit at the new range: when that moves where the tilt ends, the tilt eases from where it is to the new end (holding the camera's pitch through the range change; a camera the zoom leaves past the new range's limit steps down to it with the zoom, since past it the place would drop behind the Earth), and the zoom's result names that angle (`pitchDeg`) with a `note`. Once the tilt continues in place, a zoom moves the camera like any zoom. A tilt with `pitchDeg` (or with neither angle nor direction, which means -45°) eases to that angle; a positive angle means the same angle below the horizon. `pitchDeg` is absolute, not a step: sent with a `direction` it contradicts, the tilt is refused. An angle the camera already holds answers `unchanged` but still takes navigation, so it stops a running motion and releases the follow camera. A tilt left or right is refused. A voice tilt, orbit, pan or rotate sent while a voice `fly_to_location` is still looking up or flying ("fly to London and tilt the camera" or "fly to Paris and orbit it" arrives as two sibling calls) waits for that flight to land, up to 15 s (moving to a later fly-to in the same reply as soon as it begins, whatever the earlier one reports), so it neither cancels the flight nor gets replaced by it; if the flight is cancelled, finds nothing or times out, the user grabs, touches or scrolls the map during it, or another navigation (a location-panel search, share link or file import, which leave the flight flying) takes the camera during it, the motion is not run and says so. A motion that runs after a fly-to of the same reply has already found nothing (Gemini runs one turn's calls one by one, and passes its turn as the reply) is refused the same way rather than moving the old view; a motion in a later reply runs. `stop` and `rotate_globe` do not wait, and a motion sent before its fly-to is not reordered: it runs at once, and the fly-to's result names the motion it stopped (`stoppedMotion`). Orbit still arms (`armed: 'waiting-for-arrival'`) when it starts during any other camera flight, and captures its target once the tween queue has been quiet for 0.8 s. Omitted globe direction/mode default to right/continuous; explicit values prevail. Other camera motions keep their once default. Admission requires 3D, no Cockpit/tracking owner and the whole globe inside the existing keyhole circle, using the shared keyhole/projection geometry and vertical camera FOV with a rotation-local bounding-sphere cone for off-axis containment. Canvas-edge clipping alone does not block a rigid rotation or its unchanged Globe View preset. There is no altitude cutoff. An already smaller, fully framed globe rotates directly; repeated nudges, direction and speed changes recheck current framing without demanding a reset merely because ellipsoid aLine truncated
- **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing. - **The route dolly is shaped, not linear** (2026-08-20). A trapezoid speed profile (smoothstep up, cruise, smoothstep down, distance taken as the closed-form integral) eases both ends without changing the pace — duration is still `totalM / ROUTE_M_S[speed]`, with one exception: a 0.5 s minimum keeps a degenerate route from being an instant teleport, so routes under 10 m (slow) / 20 m (normal) / 45 m (fast) fly SLOWER than the speed word, never faster. Turns bank up to **10°** (a 90° street corner settles near 7.5°), measured as a triangular pulse over a 4 s window centred on the camera so the roll leads in and unwinds after. Altitude breathes ±20 m around the 260 m mean and lifts up to 26 m into turns. Pitch is LOCKED at −32°; heading comes from a gaze that leads the path by 6.5 s of travel. `prefers-reduced-motion` zeroes the roll and the altitude shaping and keeps the easing.
- **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped. - **Invariants — do not "fix" these.** (1) **Every** release levels the roll: completion unwinds it through the ease-out envelope, and `interruptCameraMotion` zeroes it synchronously (heading/pitch/position preserved). Cesium keeps the last up vector it was handed, so skipping this leaves the user holding a tilted horizon. (2) Heading is interpolated as an ANGLE about the local up, never as a Cartesian lerp — a lerp cannot cross an antipodal pair, so an out-and-back route looked backwards for the entire return leg; `signedTurnRad` branches the exact-180° case deterministically because the cross product's sign there is a floating-point coin toss. (3) A COLD floor cell is missing data, not flat ground: route vertices carry height 0, so trusting them flew a mountain corridor at 260 m above the ELLIPSOID. The corridor warm is fire-and-forget, so the dolly ARMS — camera untouched, no teleport onto the route — for up to 1.2 s waiting for real floor data, falls back to a rendered-mesh probe (`scene.sampleHeight`) when the DEM stays cold — latched to ONE firing per flight, for exactly the cells the cache could not answer, so a route costs at most 8 `sampleHeight` calls however long it arms — and otherwise holds the launch altitude for the whole route. A corridor is only RESOLVED when every cell is accounted for: one warm cell says nothing about the ground under the other seven, and treating it as an answer let the dolly descend to 460 m over a 1,600 m rendered surface. The probe reads the rendered surface at the current LOD (rooftops and primitives included) — a better estimate than nothing, not a guaranteed upper bound. Never descend blind. The floor is SMOOTHED in both directions for the ride while the hard clearance clamp reads the RAW sample, so a cell boundary cannot pop the eye but a cliff is still cleared on the frame it is seen. A pre-departure floor is adopted whole; one arriving mid-flight is eased onto, and the eye's descent rate is capped.
- Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet). - Pins: `src/routeCinematics.test.mjs` (26 tests), with `scripts/qa-flyroute-mutations.mjs` reverting each fix individually to prove they are load-bearing (19 named defects); rendered proof `scripts/qa-flyroute-cinema.mjs` (drives the real voice runner, measures the real Cesium camera every frame, writes a labelled contact sheet).
@@ -5720,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 deadline, with Photon requests capped at six seconds each. Caller/application
cancellation stops retries and late cache writes. Replacing a location search 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 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 candidates, and an unbiased retry for name mismatches. Name comparison folds
prefers a candidate named exactly for the place whose city, county, state, 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 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 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 fail that check are retried unbiased. After the region checks of both passes, a
@@ -5844,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. - **What it shows.** Events ▸ **S2 GhostMaps** draws the current edition of S2 Underground's KMZ intel products from `github.com/s2underground/GhostMaps`. The products are the Common Intelligence Picture and the 30-day kinetic pull (both on by default), the Border Crisis Map, the Iran Common Intelligence Picture, Iran Kinetic Events, Yemen FLOT, and **Latest Drop**: the newest dated incident file outside those series, `0_Archive`, `Basedata` and `Reference Material`. Each product has a chip in the row. The chip choice is kept in `localStorage` (`gev.ghostmaps.products`) and falls back to the defaults when storage is blocked.
- **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere. - **Editions.** `src/layers/ghostMaps/catalog.js` (pure) matches whole repository paths per product. The newest edition wins by the date written in the file name, then by `Export_N`/`vN`. Undated names, such as the fixed 30-day file, are identified by blob sha. Files over 50 MiB are left out (the 62 MB census KMZ, for example). A renamed folder drops the product rather than matching elsewhere.
- **Event age filter.** One **Event age** switch in the S2 GhostMaps row applies to every selected product: 1, 3, 7 (default), 14 or 30 days, or **All** (every marker). The choice is saved separately from product selection in `localStorage` (`gev.ghostmaps.ageDays`); missing, invalid or blocked storage falls back to seven days. Age comes only from the placemark's `Date of Event` (or `EventDate`/`Date`), never from report, prose or KMZ edition dates. Undated, unparseable and future dates stay visible. ArcGIS `M/D/YYYY` dates with optional AM/PM times and ISO dates are read as UTC when no zone is given; a date without a time covers its whole UTC day, so **1d** still shows yesterday's reports. Events age out every minute while the layer is on. Narrowing the window only removes markers; widening restores them from the cached KMZ. An open card stays while its marker is shown.
- **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub. - **Updates.** The repository is not ours, so there is no webhook. `server/providers/ghostMaps.js` starts with the first `/api/ghostmaps` request, so an install that never enables the layer never contacts GitHub.
- It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min. - It reads the branch head from git's smart-HTTP ref advertisement (`/info/refs?service=git-upload-pack`, about 0.5 KB, outside the REST rate limit) every `GHOSTMAPS_POLL_SECONDS` (15 s by default, 5 s minimum). Failures back off from 15 s to 5 min.
- Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit. - Only a new head costs REST calls: one recursive tree and one commit. An optional `GITHUB_TOKEN` raises the REST limit.
@@ -6077,12 +6249,21 @@ apply to `public/` assets or built-preview serving.
- Panoptic mode shows labels on ALL items (no stride skipping). Tracked/selected items keep bounding box but suppress label (skipLabel flag). Tracked items get enlarged bounding boxes (56×44 vs default 22×14). - Panoptic mode shows labels on ALL items (no stride skipping). Tracked/selected items keep bounding box but suppress label (skipLabel flag). Tracked items get enlarged bounding boxes (56×44 vs default 22×14).
- The 3D aircraft toggle reveals `Proximity` and `All` modes and drives both commercial and military aircraft layers. It ships ON in `Proximity`, so the button paints lit and the mode row paints open from markup; the panel is therefore ~36 px taller than before, which Cockpit's Display/Radio strip absorbs through its existing primary-only corridor solver. - The 3D aircraft toggle reveals `Proximity` and `All` modes and drives both commercial and military aircraft layers. It ships ON in `Proximity`, so the button paints lit and the mode row paints open from markup; the panel is therefore ~36 px taller than before, which Cockpit's Display/Radio strip absorbs through its existing primary-only corridor solver.
- The host-painted tracked-target readout sits above the post-FX layer and follows tracked aircraft/satellites or selected mapped installations using each layer's display-position contract. - The host-painted tracked-target readout sits above the post-FX layer and follows tracked aircraft/satellites or selected mapped installations using each layer's display-position contract.
- Cockpit entry is gated to the operational Contacts context bundle. The - Cockpit entry needs only the layer of the aircraft it rides and that aircraft
Context chooser must be in Contacts mode, Live Flights and Military Flights must both tracked: Live Flights for a civilian, Military Flights for a military aircraft
be enabled, and a civilian or military aircraft must be tracked. The visible (the tracked info's `layerId`; `cockpitEntryAllowed` in
Cockpit actions and the `C` shortcut use the same gate, so ordinary standalone `src/contextModePolicy.js`). Contacts is not required, and the other aircraft
flight-layer selection cannot enter a context-dependent cockpit. layer, Live AIS Vessels and every other layer may be turned on or off before
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`). Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leaving both that text and the lower-center instrument cluster readable. They collapse independently to slim tabs without stopping live data updates. Narrow screens use separated top/bottom fallbacks. Empty-space globe clicks are inert while cockpit owns the camera; `C`, `Escape`, or `EXIT COCKPIT` explicitly exits and restores the same tracked entity and standard follow camera. Unknown feeds remain unknown, selection loss still exits safely without inventing a replacement track, and the cockpit never presents the summary as threat scoring or an all-clear. This is a desktop first-person presentation, not a WebXR session. or during a session. A Context mode change still in flight blocks entry until
it settles. Turning off the layer the view rides ends it. Riding an aircraft
while Contacts is on adopts its layer as the user's own, as a direct selection
does (`adoptLayerVisibility`), so Contacts ending, by its own switch or one of
its layers going off, restores the pre-Contacts layers but keeps the ridden
one and the view; Live Signals then drops the rows Contacts built and shows
`CONTEXT STANDBY`. The visible Cockpit actions, the `C` shortcut and voice use
the same gate; voice entry still opens Contacts first, as its layer-targeted
entry navigates through Contacts.
- **Aircraft cockpit view:** selecting a commercial or military aircraft reveals a `COCKPIT` action (`C`) once that aircraft's own layer is on; Contacts is not required. The action shows in Contacts (`#cockpit-entry`) and under the Intel HUD's bottom-right readout (`#cockpit-entry-readout`, with the HUD on, in its Tactical and Cyber layouts), so entry needs no Contacts panel. Turning off the ridden aircraft's layer ends the view (the layer clears its tracking). When an air feed the surrounding picture uses is off, the Cockpit says so instead of blocking: Live Signals leads with `MILITARY FEED OFF · PARTIAL PICTURE` or `FLIGHTS FEED OFF · PARTIAL PICTURE`, and the Contact card shows that line in place of its inputs status, so a partial picture is never presented as complete. `control_cockpit` refuses entry with `entryBlockedReason` `contacts-starting` (a Context change in flight), `subject-layer-off` (the aircraft's layer, or with none tracked any aircraft layer, is off) or `no-tracked-aircraft`. Cockpit mode temporarily releases Cesium's orbit-follow transform and drives a first-person camera from the tracked aircraft's existing smoothed display position and course. Its concave helmet-visor HUD shows callsign, UTC time, coordinates, curved roll/pitch guides, and a seven-division heading tape. Ambient commercial and military AIR contacts use a Cockpit near/far band selected by the shared Display 3D mode: Proximity admits at 150 km and retains to 185 km; All admits at 400 km and retains to 450 km. The shared 3D toggle now applies in Cockpit: Off keeps in-range contacts as rotating 2D aircraft silhouettes, while On lets a ready admitted glTF take over without a drawing gap. The Cockpit model cap remains 60 and can only lower the map budget; in-range contacts that are capped or still loading remain 2D silhouettes instead of degrading to out-of-range dots. Contacts outside the selected band use small rotation-free cyan-white pips for civilian aircraft and amber pips for military. The pilot's own airframe is not drawn in first person, and exiting clears the Cockpit band and restores normal map silhouettes/models. The normal left accordion remains available for Layers and Authoring and keeps the same 26vh HUD-aligned anchor used in map mode, independent of whether the bottom-left Contact card is expanded or collapsed; the right-rail CCTV and Context chooser are hidden to avoid duplicating or overlapping the cockpit presentation. When the intelligence HUD is enabled, its classification, scene summary, collection/orbit metadata, coordinates, and imaging-status text remain visible as reduced peripheral cockpit telemetry; the optical center and flight instruments stay clear, and turning the HUD off still hides it. Ground speed, exact heading, and rendered altitude form one compact lower-center instrument cluster, keeping the horizon and peripheral view open. A second live altitude tape hugs the inside-right visor rim: its rail and nine moving ticks are derived from the same responsive keyhole radius, remain 20 px inside the circular edge even on wide displays, fade deeply at both vertical ends, and move continuously behind a fixed current-altitude pointer; its interval tightens automatically near the surface and it is suppressed on narrow screens. Cockpit mode also has a weather-backed transparent volumetric-cloud pass derived from the supplied FBM/domain-warped R&D shader. It renders at no more than 520×320, uses 24 ray steps/three FBM octaves at 12 FPS, is clipped to the visor, fails clear when Open-Meteo is unavailable, and stops its animation completely on cockpit exit. It does not restore the prior CPU weather canvases, precipitation, scene fog, or any map-mode effect. When Contacts is active for the tracked aircraft, the compact `CONTACT` rail adds the 250 km subject window, four cohort counts, nearest observed/mapped example with relative bearing and distance, freshness, explicit uncertainty, and Previous and Next controls plus its own collapse control. The mirrored right rail is a three-page **cockpit briefing carousel**: source-backed live signals, location-matched regional headlines, and physical-region/current-weather context from bundled Natural Earth polygons and Open-Meteo. It is manual-first: Previous, Next, and direct page controls are always available, and the visible `CYCLE OFF` / `CYCLE ON` control starts or stops the nine-second page cycle. The cycle pauses on hover or keyboard focus and stops while collapsed, hidden, or outside cockpit mode; live signal data continues refreshing either way. Empty news matches and unavailable news use compact text states rather than reserving an empty media frame; partial local data remains explicit. Article links open their original publisher, and no headline is treated as verified risk intelligence. On desktop both cockpit rails use the same width and share a bottom-aligned safe baseline in opposite corners above the peripheral MGRS/GSD/time telemetry, leavinLine truncated
- **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor. - **Cockpit left-panel clearance:** the Cockpit Contact card and peripheral HUD participate in the adaptive left accordion's live obstacle measurements, including live viewport-height changes. Expanding Layers or Authoring keeps the active panel in the available upper-left corridor with internal scrolling; it does not cover the Contact card, lower Cockpit controls, or Cesium credit line. Outside Cockpit the hidden card does not alter the normal corridor.
- **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey. - **Cockpit Context scope:** the 250 km radius applies to the air/sea proximity cohorts. Installation counts come only from the currently loaded viewport and are labeled `CURRENT VIEWPORT ONLY` in the cockpit as well as the normal Context panel; neither surface presents them as a complete 250 km installation survey.
- **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged. - **Cockpit camera anchor:** first-person mode does not write feed-boundary corrections directly into the camera. A cockpit-only inertial anchor advances from the selected aircraft's displayed course and speed, then converges toward the authoritative delayed track with correction capped below forward motion. The displayed kinematics are derived from the same consecutive fix segment as the rendered position, with raw feed speed/course used only as fallback; a transient zero/missing feed speed therefore cannot freeze a visibly moving aircraft after layer enable or a map/cockpit handoff. Rendered altitude continues to come from that interpolated track position. Late ADS-B fixes and short render stalls can remove drift without accelerating or reversing the view. Camera placement runs before scene update/culling at a bounded 20 Hz so a moving cockpit does not force Photoreal 3D Tiles to retraverse on every display frame; textual instruments update at 10 Hz and context/layout work at 4 Hz. Every far Cockpit contact pip shares one stable Cesium texture-atlas entry and skips unused screen-projected course calculations, while only in-range 2D aircraft silhouettes pay the screen-projected rotation cost; ambient glTF collections are hidden/retained rather than synchronously destroyed at cockpit entry, and context rails lay out only on explicit content/state changes and viewport resize. The deliberate 15/30-second layer interpolation delays and per-Cesium-frame position caches remain unchanged.
+1 -1
View File
@@ -57,6 +57,6 @@ Consumers use the package exports, which do not import standalone application
globals. globals.
Dataset files and source/license notices remain under `src/data/local_data/`. 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 expand multipart geometries into multiple entities; entity and stem counts are
different measurements. This change does not refresh or relicense the datasets. different measurements. This change does not refresh or relicense the datasets.
+18
View File
@@ -10,6 +10,24 @@ For the roadmap and open backlog, see the repository issue tracker.
## Open ## Open
### Wildlife grid relies on the iNaturalist UTFGrid cell schema
Status: Open (guarded fallback)
The `/v1/grid/{z}/{x}/{y}.grid.json` endpoint is documented in the v1 and v2
API docs (“Grid Tiles UTFGrid”), but the documented response only defines the
UTFGrid `grid`, `keys` and `data` members; the per-cell records are not
specified. Its 64×64 UTFGrid shape, keys and cell records
are validated in full at the allow-list proxy. A schema change or unavailable
tile retains its last good cells for at most one hour; a cold unavailable grid
uses sampled observations with the same badges and screen buckets, without
claiming complete worldwide coverage. Tests use
synthetic fixtures with the locally inspected tile's exact structure. Retest
and update the schema guard before accepting an upstream shape change. Grid
and observation calls share the paced queue, provider budget and 429 cooldown;
ordinary tile failures do not block observation queries.
### Street traffic can be slow/uneven when panning across dense city blocks ### Street traffic can be slow/uneven when panning across dense city blocks
Status: Open (partially mitigated) Status: Open (partially mitigated)
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. --> a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,public,radar,radio,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,settings,skip_next,skip_previous,streetview,timeline,tune,upload_file,videocam,view_in_ar,visibility,visibility_off" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,partly_cloudy_day,pentagon,pest_control,pets,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop" rel="stylesheet" />
</head> </head>
<body> <body>
<div class="gev-view" data-gev-view="scene-chrome"></div> <div class="gev-view" data-gev-view="scene-chrome"></div>
+5 -2
View File
@@ -2,7 +2,7 @@
"name": "gods-eye-view", "name": "gods-eye-view",
"private": true, "private": true,
"version": "0.3.0", "version": "0.3.0",
"description": "A real-time intelligence console for planet Earth — photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.", "description": "A real-time intelligence console for planet Earth \u2014 photorealistic 3D globe, live aircraft/ships/satellites/earthquakes/CCTV, and hands-free voice control. Runs in a browser.",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"author": "Bilawal Sidhu", "author": "Bilawal Sidhu",
@@ -47,6 +47,7 @@
"build:panel": "node scripts/build-panel.mjs", "build:panel": "node scripts/build-panel.mjs",
"preview": "vite preview", "preview": "vite preview",
"test": "node scripts/run-unit-tests.mjs", "test": "node scripts/run-unit-tests.mjs",
"test:perf": "node scripts/run-perf-tests.mjs",
"test:track": "node scripts/track-regression.mjs", "test:track": "node scripts/track-regression.mjs",
"qa:map-source-tray": "node scripts/qa-map-source-tray.mjs", "qa:map-source-tray": "node scripts/qa-map-source-tray.mjs",
"qa:street-level": "node scripts/qa-street-level.mjs", "qa:street-level": "node scripts/qa-street-level.mjs",
@@ -292,6 +293,8 @@
"./tools/panel": "./src/tools/panel.js", "./tools/panel": "./src/tools/panel.js",
"./tools/services": "./src/tools/services.js", "./tools/services": "./src/tools/services.js",
"./view": "./src/view/index.js", "./view": "./src/view/index.js",
"./agent/world-packet": "./src/agent/worldPacket.js" "./agent/world-packet": "./src/agent/worldPacket.js",
"./layers/wildlife-sightings": "./src/layers/wildlifeSightings/index.js",
"./layers/wildlife-sightings/source": "./src/layers/wildlifeSightings/source.js"
} }
} }
+90 -1
View File
@@ -347,6 +347,7 @@
"src/layers/flights/tracking.js", "src/layers/flights/tracking.js",
"src/layers/flights/rendering.js", "src/layers/flights/rendering.js",
"src/layers/flights/testing.js", "src/layers/flights/testing.js",
"src/layers/flights/hiddenContactRotation.test.mjs",
"src/layers/flights/evidence.js", "src/layers/flights/evidence.js",
"src/layers/flights/ingestion.js", "src/layers/flights/ingestion.js",
"src/layers/flights/queries.js", "src/layers/flights/queries.js",
@@ -474,6 +475,7 @@
"src/layers/satellites/stale.test.mjs", "src/layers/satellites/stale.test.mjs",
"src/layers/launches/source.test.mjs", "src/layers/launches/source.test.mjs",
"src/layers/launches/ingestion.test.mjs", "src/layers/launches/ingestion.test.mjs",
"src/layers/launches/failedEntry.test.mjs",
"src/layers/installations/lifecycle.js", "src/layers/installations/lifecycle.js",
"src/layers/installations/viewport.js", "src/layers/installations/viewport.js",
"src/layers/installations/controls.js", "src/layers/installations/controls.js",
@@ -786,6 +788,7 @@
"server/providers/transit.js", "server/providers/transit.js",
"server/providers/transitHistory.js", "server/providers/transitHistory.js",
"server/providers/transitHistory.test.mjs", "server/providers/transitHistory.test.mjs",
"src/data/rioRouteNames.js",
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/transitFeeds.test.mjs", "src/data/transitFeeds.test.mjs",
"src/data/transitProxy.js", "src/data/transitProxy.js",
@@ -1126,6 +1129,8 @@
"scripts/qa-status-badges.mjs", "scripts/qa-status-badges.mjs",
"src/dockerTrustedPeers.test.mjs", "src/dockerTrustedPeers.test.mjs",
"src/cameraGlobeRotation.test.mjs", "src/cameraGlobeRotation.test.mjs",
"src/cameraTilt.test.mjs",
"src/ui/shellFacade.test.mjs",
"src/voice/globeResetConfirmation.test.mjs", "src/voice/globeResetConfirmation.test.mjs",
"src/hudDms.test.mjs", "src/hudDms.test.mjs",
"src/hudSummaryGate.test.mjs", "src/hudSummaryGate.test.mjs",
@@ -1267,5 +1272,89 @@
"src/voice/geminiTextChat.js", "src/voice/geminiTextChat.js",
"src/voice/panelLayout.js", "src/voice/panelLayout.js",
"src/voice/textChat.js", "src/voice/textChat.js",
"src/voice/textProtocol.js" "src/voice/textProtocol.js",
"src/app/sourceComposition.js",
"src/app/sourceComposition.test.mjs",
"src/data/dataCredits.js",
"src/data/layerState.js",
"src/data/layerState.test.mjs",
"src/data/spriteOrder.js",
"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",
"src/i18n/same-as-english.json",
"src/navigationPolicy.js",
"src/overlays/worldOverlayDraw.js",
"src/overlays/worldOverlayDraw.test.mjs",
"src/sources/rateLimit.js",
"src/ui/layerNames.js",
"src/ui/styles/cyber.css",
"src/voice/layerManifest.js",
"src/worldFocus.js",
"src/worldFocus.test.mjs",
"server/providers/acartia.js",
"server/providers/acartia.test.mjs",
"server/providers/common/userAgent.js",
"server/providers/inaturalist/client.js",
"server/providers/inaturalist/observations.js",
"server/providers/inaturalist/observations.test.mjs",
"src/app/layers/wildlifeSightings.js",
"src/layers/wildlifeSightings/cards.js",
"src/layers/wildlifeSightings/cards.test.mjs",
"src/layers/wildlifeSightings/controls.js",
"src/layers/wildlifeSightings/docs.test.mjs",
"src/layers/wildlifeSightings/index.js",
"src/layers/wildlifeSightings/lifecycle.js",
"src/layers/wildlifeSightings/model.js",
"src/layers/wildlifeSightings/model.test.mjs",
"src/layers/wildlifeSightings/ownership.test.mjs",
"src/layers/wildlifeSightings/photos.js",
"src/layers/wildlifeSightings/photos.test.mjs",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/records.test.mjs",
"src/layers/wildlifeSightings/rendering.js",
"src/layers/wildlifeSightings/selection.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/source.test.mjs",
"src/layers/wildlifeSightings/viewport.js",
"src/layers/wildlifeSightings/viewport.test.mjs",
"src/sources/rateLimit.test.mjs",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/layers/wildlifeSightings/acquisition.test.mjs",
"server/providers/inaturalist/client.test.mjs",
"src/layers/wildlifeSightings/acartiaTaxa.test.mjs",
"src/ui/wildlifeSightingCard.test.mjs",
"src/overlays/wildlifeSightingOverlay.test.mjs",
"scripts/run-perf-tests.mjs",
"src/perfTestRunner.test.mjs",
"src/testSupport/wildlifeBillboardModel.mjs",
"src/layers/wildlifeSightings/gridViewport.test.mjs",
"src/layers/wildlifeSightings/rendering.test.mjs",
"src/layers/wildlifeSightings/sprites.test.mjs",
"src/layers/wildlifeSightings/performance.test.mjs",
"src/layers/wildlifeSightings/model.perf.test.mjs",
"src/ui/wildlifeSightingPlacement.js",
"server/providers/inaturalist/queue.js",
"server/providers/inaturalist/queue.test.mjs",
"server/providers/inaturalist/grid.js",
"server/providers/inaturalist/grid.test.mjs",
"server/providers/inaturalist/grid.fixture.mjs",
"src/layers/wildlifeSightings/performance.js",
"src/layers/wildlifeSightings/renderPerformanceFixtures.mjs",
"server/providers/inaturalist/speed.test.mjs",
"src/layers/wildlifeSightings/render.perf.test.mjs",
"src/layers/wildlifeSightings/cache.integration.test.mjs",
"src/layers/wildlifeSightings/density.test.mjs",
"src/layers/wildlifeSightings/selection.test.mjs",
"server/providers/inaturalist/proxy.fixture.mjs"
] ]
+178 -7
View File
@@ -39,6 +39,9 @@
"src/ui/views/panels/CockpitBriefing.vue", "src/ui/views/panels/CockpitBriefing.vue",
"src/ui/views/panels/CockpitContext.vue", "src/ui/views/panels/CockpitContext.vue",
"src/ui/views/panels/CockpitRoute.vue", "src/ui/views/panels/CockpitRoute.vue",
"src/ui/cockpitPresentation.js",
"src/data/headingDegrees.js",
"src/data/distanceFormat.js",
"src/ui/views/panels/CockpitRollingValue.vue", "src/ui/views/panels/CockpitRollingValue.vue",
"src/ui/views/panels/CockpitSignalList.vue", "src/ui/views/panels/CockpitSignalList.vue",
"src/ui/views/panels/CockpitUtilityControls.vue", "src/ui/views/panels/CockpitUtilityControls.vue",
@@ -151,7 +154,8 @@
"src/ui/views/panels/GlobeCompass.vue", "src/ui/views/panels/GlobeCompass.vue",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/styleParameters.js", "src/ui/styleParameters.js",
"src/ui/i18n.js" "src/ui/i18n.js",
"src/ui/surfaceKeyboard.js"
], ],
"external": ["vue", "pinia", "vue-i18n"] "external": ["vue", "pinia", "vue-i18n"]
}, },
@@ -160,6 +164,7 @@
"modules": [ "modules": [
"src/app/viewer.js", "src/app/viewer.js",
"src/app/atmosphereCompat.js", "src/app/atmosphereCompat.js",
"src/globeInView.js",
"src/sky/starrySky.js", "src/sky/starrySky.js",
"src/sky/stars.js", "src/sky/stars.js",
"src/data/bundledJson.js", "src/data/bundledJson.js",
@@ -199,7 +204,22 @@
"src/data/aisStreamAdapter.js", "src/data/aisStreamAdapter.js",
"src/data/aisWatchdog.js", "src/data/aisWatchdog.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
"src/i18n/marker.js" "src/i18n/marker.js",
"server/providers/acartia.js",
"server/providers/common/rate-limit.js",
"server/providers/common/same-site.js",
"server/providers/common/userAgent.js",
"server/providers/inaturalist/client.js",
"server/providers/inaturalist/observations.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/policy.js",
"src/localRequestGate.mjs",
"src/sources/rateLimit.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"server/providers/inaturalist/grid.js",
"src/layers/wildlifeSightings/gridViewport.js",
"server/providers/inaturalist/queue.js"
], ],
"external": ["ws"] "external": ["ws"]
}, },
@@ -552,6 +572,7 @@
"src/search/placeSearch.js", "src/search/placeSearch.js",
"src/search/google.js", "src/search/google.js",
"src/keylessGeocoder.js", "src/keylessGeocoder.js",
"src/data/placeNames.js",
"src/search/geospatial.js", "src/search/geospatial.js",
"src/search/http.js", "src/search/http.js",
"src/search/defaults.js", "src/search/defaults.js",
@@ -869,6 +890,7 @@
"src/ui/cockpitDisplayPortal.js", "src/ui/cockpitDisplayPortal.js",
"src/ui/displayPanelScroll.js", "src/ui/displayPanelScroll.js",
"src/ui/cockpitInput.js", "src/ui/cockpitInput.js",
"src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js", "src/ui/cockpitInstruments.js",
"src/ui/cockpitLayout.js", "src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js", "src/ui/cockpitPresentation.js",
@@ -891,6 +913,7 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/ui/layerNames.js",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -996,6 +1019,7 @@
"src/ui/cockpitDisplayPortal.js", "src/ui/cockpitDisplayPortal.js",
"src/ui/displayPanelScroll.js", "src/ui/displayPanelScroll.js",
"src/ui/cockpitInput.js", "src/ui/cockpitInput.js",
"src/data/inputOwnership.js",
"src/ui/cockpitInstruments.js", "src/ui/cockpitInstruments.js",
"src/ui/cockpitLayout.js", "src/ui/cockpitLayout.js",
"src/ui/cockpitPresentation.js", "src/ui/cockpitPresentation.js",
@@ -1148,7 +1172,14 @@
"src/ui/stores/globeActions.js", "src/ui/stores/globeActions.js",
"src/ui/stores/recording.js", "src/ui/stores/recording.js",
"src/ui/i18n.js", "src/ui/i18n.js",
"src/ui/kit/UiSegmented.vue" "src/ui/kit/UiSegmented.vue",
"src/ui/wildlifeSightingCard.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/ui/views/panels/WildlifeSightingCard.vue",
"src/ui/wildlifeSightingPlacement.js",
"src/overlays/worldOverlayChrome.js",
"src/layers/wildlifeSightings/photoText.js"
], ],
"external": ["cesium", "vue", "pinia", "vue-i18n"] "external": ["cesium", "vue", "pinia", "vue-i18n"]
}, },
@@ -1744,6 +1775,10 @@
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json", "src/i18n/locales/zh-Hant.json",
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/data/installationFeedback.js",
"src/ui/layerNames.js",
"src/ui/unitLabels.js" "src/ui/unitLabels.js"
], ],
"external": ["cesium"] "external": ["cesium"]
@@ -2301,6 +2336,7 @@
"src/app/layers/alprCameras.js", "src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js", "src/app/layers/bikeshare.js",
"src/app/layers/cctv.js", "src/app/layers/cctv.js",
"src/app/layers/cyclones.js",
"src/app/layers/earthquakes.js", "src/app/layers/earthquakes.js",
"src/app/layers/firms.js", "src/app/layers/firms.js",
"src/app/layers/flights.js", "src/app/layers/flights.js",
@@ -2330,6 +2366,7 @@
"src/camera.js", "src/camera.js",
"src/cameraGroundGuard.js", "src/cameraGroundGuard.js",
"src/cameraVerbs.js", "src/cameraVerbs.js",
"src/globeInView.js",
"src/cctvFocusPolicy.js", "src/cctvFocusPolicy.js",
"src/cctvFocusRequest.js", "src/cctvFocusRequest.js",
"src/celestialRing.js", "src/celestialRing.js",
@@ -2954,6 +2991,7 @@
"src/app/layers/transit.js", "src/app/layers/transit.js",
"src/data/transit.js", "src/data/transit.js",
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/data/transitIcons.js", "src/data/transitIcons.js",
"src/data/transitPresetStyle.js", "src/data/transitPresetStyle.js",
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
@@ -3257,7 +3295,32 @@
"src/voice/osmActions.js", "src/voice/osmActions.js",
"src/layers/osmPlaces/index.js", "src/layers/osmPlaces/index.js",
"src/app/layers/osmPlaces.js", "src/app/layers/osmPlaces.js",
"src/voice/areaActions.js" "src/voice/areaActions.js",
"src/app/layers/wildlifeSightings.js",
"src/layers/wildlifeSightings/cards.js",
"src/layers/wildlifeSightings/controls.js",
"src/layers/wildlifeSightings/index.js",
"src/layers/wildlifeSightings/lifecycle.js",
"src/layers/wildlifeSightings/model.js",
"src/layers/wildlifeSightings/photos.js",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/rendering.js",
"src/layers/wildlifeSightings/density.js",
"src/layers/wildlifeSightings/selection.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/viewport.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/ui/wildlifeSightingCard.js",
"src/ui/views/panels/WildlifeSightingCard.vue",
"src/layers/wildlifeSightings/gridViewport.js",
"src/layers/wildlifeSightings/billboardModel.js",
"src/layers/wildlifeSightings/sprites.js",
"src/ui/wildlifeSightingPlacement.js",
"src/overlays/worldOverlayChrome.js",
"src/layers/wildlifeSightings/photoText.js",
"src/layers/wildlifeSightings/performance.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -3282,6 +3345,8 @@
"application-layer-construction": { "application-layer-construction": {
"exports": ["./application/layers"], "exports": ["./application/layers"],
"modules": [ "modules": [
"src/data/feedState.js",
"src/loadingFeedback.js",
"src/sources/adsbFields.js", "src/sources/adsbFields.js",
"src/data/headingDegrees.js", "src/data/headingDegrees.js",
"src/data/distanceFormat.js", "src/data/distanceFormat.js",
@@ -3316,6 +3381,7 @@
"src/app/layers/alprCameras.js", "src/app/layers/alprCameras.js",
"src/app/layers/bikeshare.js", "src/app/layers/bikeshare.js",
"src/app/layers/cctv.js", "src/app/layers/cctv.js",
"src/app/layers/cyclones.js",
"src/app/layers/earthquakes.js", "src/app/layers/earthquakes.js",
"src/app/layers/firms.js", "src/app/layers/firms.js",
"src/app/layers/flights.js", "src/app/layers/flights.js",
@@ -3658,6 +3724,9 @@
"src/annotations/worldAnnotationRenderer.js", "src/annotations/worldAnnotationRenderer.js",
"src/cameraGroundGuard.js", "src/cameraGroundGuard.js",
"src/cameraVerbs.js", "src/cameraVerbs.js",
"src/globeInView.js",
"src/scopeMaskPolicy.js",
"src/scopeMask.js",
"src/services/groundFloor.js", "src/services/groundFloor.js",
"src/data/routeSteps.js", "src/data/routeSteps.js",
"src/layers/directions/index.js", "src/layers/directions/index.js",
@@ -3704,6 +3773,7 @@
"src/sources/nominatim.js", "src/sources/nominatim.js",
"src/app/layers/transit.js", "src/app/layers/transit.js",
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/data/transitIcons.js", "src/data/transitIcons.js",
"src/data/transitPresetStyle.js", "src/data/transitPresetStyle.js",
"src/layers/transit/controls.js", "src/layers/transit/controls.js",
@@ -3877,7 +3947,30 @@
"src/data/local_data/wof_neighborhoods/files.js", "src/data/local_data/wof_neighborhoods/files.js",
"src/layers/osmPlaces/model.js", "src/layers/osmPlaces/model.js",
"src/layers/osmPlaces/index.js", "src/layers/osmPlaces/index.js",
"src/app/layers/osmPlaces.js" "src/app/layers/osmPlaces.js",
"src/app/layers/wildlifeSightings.js",
"src/layers/wildlifeSightings/cards.js",
"src/layers/wildlifeSightings/controls.js",
"src/layers/wildlifeSightings/index.js",
"src/layers/wildlifeSightings/lifecycle.js",
"src/layers/wildlifeSightings/model.js",
"src/layers/wildlifeSightings/photos.js",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/rendering.js",
"src/layers/wildlifeSightings/density.js",
"src/layers/wildlifeSightings/selection.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/viewport.js",
"src/ui/rail/utcClock.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/layers/wildlifeSightings/gridViewport.js",
"src/layers/wildlifeSightings/billboardModel.js",
"src/layers/wildlifeSightings/sprites.js",
"src/overlays/worldOverlayChrome.js",
"src/layers/wildlifeSightings/photoText.js",
"src/layers/wildlifeSightings/performance.js"
], ],
"external": [ "external": [
"hls.js", "hls.js",
@@ -4413,7 +4506,15 @@
"src/layers/airQuality/model.js", "src/layers/airQuality/model.js",
"src/layers/airQuality/source.js", "src/layers/airQuality/source.js",
"src/layers/droneAirspace/source.js", "src/layers/droneAirspace/source.js",
"src/layers/droneAirspace/records.js" "src/layers/droneAirspace/records.js",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/viewport.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/layers/wildlifeSightings/gridViewport.js",
"src/layers/wildlifeSightings/performance.js"
], ],
"external": [] "external": []
}, },
@@ -4465,6 +4566,7 @@
"server/providers/transitHistory.js", "server/providers/transitHistory.js",
"src/data/gtfsRealtime.js", "src/data/gtfsRealtime.js",
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js", "src/geoDistance.js",
"src/data/transitProxy.js", "src/data/transitProxy.js",
"src/sources/httpBody.js", "src/sources/httpBody.js",
@@ -4481,6 +4583,7 @@
"modules": [ "modules": [
"src/data/gtfsRealtime.js", "src/data/gtfsRealtime.js",
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js", "src/geoDistance.js",
"src/data/transitProxy.js", "src/data/transitProxy.js",
"src/sources/transitHistory.js" "src/sources/transitHistory.js"
@@ -4514,6 +4617,7 @@
"src/data/terrainHeights.js", "src/data/terrainHeights.js",
"src/data/transit.js", "src/data/transit.js",
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/data/transitIcons.js", "src/data/transitIcons.js",
"src/data/transitPresetStyle.js", "src/data/transitPresetStyle.js",
"src/data/transitProxy.js", "src/data/transitProxy.js",
@@ -4561,7 +4665,8 @@
"src/i18n/locales/ru.json", "src/i18n/locales/ru.json",
"src/i18n/locales/uk.json", "src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json", "src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json" "src/i18n/locales/zh-Hant.json",
"src/overlays/worldOverlayChrome.js"
], ],
"external": ["cesium", "egm96-universal", "pbf"] "external": ["cesium", "egm96-universal", "pbf"]
}, },
@@ -4572,6 +4677,7 @@
"src/data/iconOrientation.js", "src/data/iconOrientation.js",
"src/data/inputOwnership.js", "src/data/inputOwnership.js",
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js", "src/geoDistance.js",
"src/data/transitIcons.js", "src/data/transitIcons.js",
"src/data/transitPresetStyle.js", "src/data/transitPresetStyle.js",
@@ -4629,6 +4735,7 @@
"exports": ["./sources/transit-service"], "exports": ["./sources/transit-service"],
"modules": [ "modules": [
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/geoDistance.js", "src/geoDistance.js",
"src/data/transitProxy.js", "src/data/transitProxy.js",
"src/data/gtfsRealtime.js", "src/data/gtfsRealtime.js",
@@ -4873,6 +4980,7 @@
"src/tools/surfaces.js", "src/tools/surfaces.js",
"src/layers/transit/policy.js", "src/layers/transit/policy.js",
"src/data/transitFeeds.js", "src/data/transitFeeds.js",
"src/data/rioRouteNames.js",
"src/layers/transit/movement.js", "src/layers/transit/movement.js",
"src/data/contactPlayback.js", "src/data/contactPlayback.js",
"src/data/layerSnapshot.js", "src/data/layerSnapshot.js",
@@ -5040,5 +5148,68 @@
"src/i18n/marker.js" "src/i18n/marker.js"
], ],
"external": ["cesium"] "external": ["cesium"]
},
"wildlife-sightings": {
"exports": ["./layers/wildlife-sightings"],
"modules": [
"src/data/dataCredits.js",
"src/data/iconOrientation.js",
"src/i18n/index.js",
"src/i18n/locale.js",
"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",
"src/i18n/marker.js",
"src/layers/wildlifeSightings/cards.js",
"src/layers/wildlifeSightings/controls.js",
"src/layers/wildlifeSightings/index.js",
"src/layers/wildlifeSightings/lifecycle.js",
"src/layers/wildlifeSightings/model.js",
"src/layers/wildlifeSightings/photos.js",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/rendering.js",
"src/layers/wildlifeSightings/density.js",
"src/layers/wildlifeSightings/selection.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/viewport.js",
"src/overlays/worldOverlayDraw.js",
"src/overlays/worldOverlayTokens.js",
"src/sources/httpBody.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/ui/statusText.js",
"src/ui/layerNames.js",
"src/layers/wildlifeSightings/gridViewport.js",
"src/layers/wildlifeSightings/billboardModel.js",
"src/layers/wildlifeSightings/sprites.js",
"src/layers/wildlifeSightings/photoText.js",
"src/layers/wildlifeSightings/performance.js"
],
"external": ["cesium"]
},
"wildlife-sightings-source": {
"exports": ["./layers/wildlife-sightings/source"],
"modules": [
"src/i18n/marker.js",
"src/layers/wildlifeSightings/policy.js",
"src/layers/wildlifeSightings/records.js",
"src/layers/wildlifeSightings/source.js",
"src/layers/wildlifeSightings/viewport.js",
"src/sources/httpBody.js",
"src/layers/wildlifeSightings/bounds.js",
"src/layers/wildlifeSightings/acartiaTaxa.js",
"src/layers/wildlifeSightings/gridViewport.js",
"src/layers/wildlifeSightings/performance.js"
],
"external": []
} }
} }
+73
View File
@@ -28,6 +28,8 @@
* (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled: * (vii) FAR SIDE — pins skip the depth test, so they are horizon-culled:
* seen from over the Indian Ocean no pin clearly behind the planet is * seen from over the Indian Ocean no pin clearly behind the planet is
* shown; back over North America the near-side pins show again. * shown; back over North America the near-side pins show again.
* (viii) AGE — seven days is the default; shorter/longer windows and All
* markers update the globe, and the age choice survives a reload.
* *
* Screenshots go to qa-shots/ghostmaps-*.png (gitignored). * Screenshots go to qa-shots/ghostmaps-*.png (gitignored).
* *
@@ -224,6 +226,77 @@ async function main() {
record('LIVE: the /api/ghostmaps/events stream is open', stream); record('LIVE: the /api/ghostmaps/events stream is open', stream);
await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') }); await page.screenshot({ path: path.join(SHOTS_DIR, 'ghostmaps-live.png') });
const ageChips = await page.evaluate(
(id) =>
[
...document.querySelectorAll(
`[data-layer-id="${id}"] [data-segmented-id="age"] [role="radio"]`,
),
].map((option) => ({
id: option.dataset.chipId,
active: option.getAttribute('aria-checked') === 'true',
})),
LAYER_ID,
);
record(
'AGE: seven days is the default with an All markers option',
live.ageDays === 7 &&
ageChips.length === 6 &&
ageChips
.filter((chip) => chip.active)
.map((chip) => chip.id)
.join() === 'age-7' &&
ageChips.some((chip) => chip.id === 'age-all'),
);
const selectAge = async (days) => {
await showRow(page);
const selector = `[data-layer-id="${LAYER_ID}"] [data-chip-id="age-${days ?? 'all'}"]`;
await page.waitForSelector(selector, { timeout: 15000 });
// Dispatch through the rendered button, matching the product-chip gate.
await page.$eval(selector, (button) => button.click());
await page.waitForFunction(
(id, age) => {
const s = window.__godsEyeView.dataManager.layers
.get(id)
.module.getStats();
return s.ageDays === age && !s.loading;
},
{ timeout: TIMEOUT_MS },
LAYER_ID,
days,
);
return stats(page);
};
const all = await selectAge(null);
record(
'AGE: All markers restores old events without duplicate data sources',
all.count > live.count &&
(await sources(page)).length === live.products.length,
`${live.count} → ${all.count}`,
);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => window.__godsEyeView?.dataManager, {
timeout: TIMEOUT_MS,
});
await enableAndSettle(page);
const persistedAge = await stats(page);
record(
'AGE: All markers survives a reload',
persistedAge.ageDays === null && persistedAge.count === all.count,
);
const thirty = await selectAge(30);
const one = await selectAge(1);
const seven = await selectAge(7);
record(
'AGE: counts follow the window order and seven days restores the default',
one.count <= live.count &&
thirty.count >= live.count &&
thirty.count <= all.count &&
seven.count === live.count &&
seven.ageDays === 7,
`1d=${one.count}, 7d=${seven.count}, 30d=${thirty.count}, all=${all.count}`,
);
// ---- (ii) PICK ---------------------------------------------------------- // ---- (ii) PICK ----------------------------------------------------------
const target = await page.evaluate((id) => { const target = await page.evaluate((id) => {
const gev = window.__godsEyeView; const gev = window.__godsEyeView;
+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) { async function assertKeyRequired(page) {
await page.evaluate(() => await page.evaluate(() =>
window.__godsEyeView.dataManager.setEnabled('street-level', true, { window.__godsEyeView.dataManager.setEnabled('street-level', true, {
@@ -528,11 +544,25 @@ async function main() {
); );
await expandPanel(page); 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', () => await step('keyless server: KEY REQUIRED gates the controls', () =>
assertKeyRequired(page), assertKeyRequired(page),
); );
skip('no Mapillary key', 'the keyed steps'); 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 { } else {
await park(page); await park(page);
await step( await step(
@@ -751,6 +781,8 @@ async function main() {
await park(page); await park(page);
await fixtureStep( 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', 'keyless server (second page): KEY REQUIRED gates the controls',
async () => { async () => {
const keyless = await open( const keyless = await open(
+19 -10
View File
@@ -2321,9 +2321,11 @@ async function runBehaviorLayer() {
`result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`, `result=${JSON.stringify(r)?.slice(0, 120)} state=${JSON.stringify(contextExited)?.slice(0, 80)}`,
); );
// (9f) With Contacts OFF the entry gate is shut. Entry must refuse with the // (9f) Cockpit needs only the tracked aircraft's own layer, not Contacts.
// reason rather than produce the half-entered state the operator saw: a // With Contacts OFF, status must explain a shut gate without blaming
// plane anchored under the camera with no HUD and no exit control. // Contacts, and entry must either enter cleanly or refuse with the reason
// rather than produce the half-entered state the operator saw: a plane
// anchored under the camera with no HUD and no exit control.
r = await run('control_cockpit', { action: 'status' }); r = await run('control_cockpit', { action: 'status' });
const gateReport = r?.state || {}; const gateReport = r?.state || {};
const cockpitBeforeGate = await page.evaluate(() => const cockpitBeforeGate = await page.evaluate(() =>
@@ -2331,10 +2333,13 @@ async function runBehaviorLayer() {
); );
report( report(
gateReport.active === false && gateReport.active === false &&
gateReport.entryAllowed === false && (gateReport.entryAllowed === true
gateReport.entryBlockedReason === 'contacts-inactive' && ? gateReport.entryBlockedReason === null
: ['subject-layer-off', 'no-tracked-aircraft'].includes(
gateReport.entryBlockedReason,
)) &&
!cockpitBeforeGate, !cockpitBeforeGate,
'behavior: cockpit status names why entry is blocked with Contacts off', 'behavior: cockpit status explains its gate without requiring Contacts',
`state=${JSON.stringify(gateReport)?.slice(0, 150)}`, `state=${JSON.stringify(gateReport)?.slice(0, 150)}`,
); );
@@ -2348,12 +2353,16 @@ async function runBehaviorLayer() {
Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active), Boolean(window.__godsEyeView?.styleManager?.cockpitView?.active),
); );
report( report(
gated?.ok === false && gated?.ok === true
/contacts/i.test(gated?.error || '') && ? cockpitAfterGate
!cockpitAfterGate, : !cockpitAfterGate && !/contacts/i.test(gated?.error || ''),
'behavior: cockpit entry with Contacts off is refused, not half-entered', 'behavior: cockpit entry with Contacts off follows the own-layer gate, never half-entered',
`ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`, `ok=${gated?.ok} error=${String(gated?.error || '').slice(0, 90)} active=${cockpitAfterGate}`,
); );
if (cockpitAfterGate)
await page.evaluate(() =>
window.__godsEyeView?.styleManager?.controlCockpit?.('exit'),
);
await runBundledOutlineScenarios({ await runBundledOutlineScenarios({
page, page,
+59
View File
@@ -0,0 +1,59 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import {
discoverUnitTestFiles,
UNIT_TEST_NODE_FLAGS,
} from './run-unit-tests.mjs';
/** Discover every layer/provider benchmark, including those added by other branches. */
export function discoverPerfTestFiles(root = process.cwd()) {
return discoverUnitTestFiles(root).filter((file) =>
file.endsWith('.perf.test.mjs'),
);
}
/** Shared by benchmarks so normal unit runs visibly skip timing assertions. */
export function perfBudgetTestOptions(env = process.env) {
return {
skip:
env.GEV_PERF_BUDGETS === '1'
? false
: 'Timing budgets are opt-in; run npm run test:perf (GEV_PERF_BUDGETS=1). Deterministic delta/write checks still run in the normal suite.',
};
}
/** Build a serial invocation so benchmarks do not compete for CPU time. */
export function perfTestArgs(files) {
// A single serial test process keeps benchmark files from competing with
// each other; the normal suite still discovers them and reports their skips.
return [
...UNIT_TEST_NODE_FLAGS,
'--test',
'--test-concurrency=1',
'--test-isolation=none',
...files,
];
}
/** Run discovered benchmarks with wall-clock budgets enabled. */
export function runPerfTests({
root = process.cwd(),
env = process.env,
run = spawnSync,
} = {}) {
const files = discoverPerfTestFiles(root);
if (!files.length) throw new Error('No *.perf.test.mjs benchmarks found');
const result = run(process.execPath, perfTestArgs(files), {
cwd: root,
stdio: 'inherit',
env: { ...env, GEV_PERF_BUDGETS: '1' },
});
if (result.error) throw result.error;
return result.status ?? 1;
}
const invoked = process.argv[1]
? pathToFileURL(path.resolve(process.argv[1])).href
: '';
if (import.meta.url === invoked) process.exitCode = runPerfTests();
+181
View File
@@ -0,0 +1,181 @@
import { normalizeAcartiaSightings } from '../../src/layers/wildlifeSightings/records.js';
import { coalesceProxyRequest, readResponseJsonCapped } from './common/http.js';
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
import { admitSameSiteKeyless } from './common/same-site.js';
import { providerUserAgent } from './common/userAgent.js';
const API_URL = 'https://acartia.io/api/v1/sightings/current';
// All views share the provider's seven-day snapshot.
const TTL_MS = 600_000;
const STALE_MAX_MS = 24 * 60 * 60_000;
const MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
const TIMEOUT_MS = 15_000;
const COOLDOWN_MS = 60_000;
const COOLDOWN_MIN_MS = 5000;
const COOLDOWN_MAX_MS = 60 * 60_000;
const FAILURE_COOLDOWN_MS = 15_000;
/** Bound upstream Retry-After seconds or dates to five seconds–one hour. */
export function acartiaRetryCooldownMs(raw, nowMs) {
const value = typeof raw === 'string' ? raw.trim() : '';
if (value) {
const seconds = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : NaN;
const deadline =
!Number.isFinite(seconds) && /[a-z]/i.test(value)
? Date.parse(value)
: NaN;
const delay = Number.isFinite(seconds) ? seconds * 1000 : deadline - nowMs;
if (Number.isFinite(delay) && delay >= 0)
return Math.min(COOLDOWN_MAX_MS, Math.max(COOLDOWN_MIN_MS, delay));
}
return COOLDOWN_MS;
}
/** Salish Sea coverage. Precise coordinates and comments never enter cache. */
export function acartiaProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
cache = new Map(),
warn = (...args) => console.warn(...args),
} = {}) {
const inFlight = new Map();
const allow = makeRateLimiter({
windowMs: 60_000,
max: 60,
globalMax: 600,
now,
});
let cooldownUntil = 0;
let cooldownStatus = 429;
let warnedClamp = false;
const retrySeconds = () =>
Math.max(1, Math.ceil((cooldownUntil - now()) / 1000));
const unavailable = (status = 502) =>
Object.assign(new Error('acartia_unavailable'), {
status,
retryAfterS: retrySeconds(),
});
function coolDown(status, delay) {
const until = now() + delay;
if (until > cooldownUntil) {
cooldownUntil = until;
cooldownStatus = status;
} else if (status === 429 && now() < cooldownUntil) cooldownStatus = 429;
}
async function load() {
if (now() < cooldownUntil) throw unavailable(cooldownStatus);
const signal = AbortSignal.timeout(TIMEOUT_MS);
try {
const response = await fetchImpl(API_URL, {
signal,
redirect: 'error',
headers: {
Accept: 'application/json',
'User-Agent': providerUserAgent('wildlife-sightings'),
},
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
if (response.status === 429) {
const delay = acartiaRetryCooldownMs(
response.headers.get('retry-after'),
now(),
);
if (delay === COOLDOWN_MAX_MS && !warnedClamp) {
warnedClamp = true;
warn('[acartia-proxy] upstream cooldown capped at one hour');
}
coolDown(429, delay);
throw unavailable(429);
}
throw unavailable();
}
// This endpoint documents a JSON array, not a results/data envelope.
const payload = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
signal,
);
const rows = normalizeAcartiaSightings(payload, now());
// Only a non-array is malformed. A quiet feed, or one whose rows all fall
// outside coverage or the species checks, is a valid empty snapshot.
if (!rows) throw new Error('invalid_snapshot');
return { fetchedAt: now(), rows };
} catch (error) {
if (error.status !== 429) coolDown(502, FAILURE_COOLDOWN_MS);
warn('[acartia-proxy] upstream refresh failed');
throw unavailable(error.status === 429 ? 429 : 502);
}
}
function age(entry) {
if (!entry) return Infinity;
const elapsed = now() - entry.savedAt;
return elapsed < 0 ? Infinity : elapsed;
}
async function acquire() {
const previous = cache.get('sightings');
if (age(previous) < TTL_MS) return { value: previous.value, stale: false };
if (age(previous) > STALE_MAX_MS) cache.delete('sightings');
try {
const { promise } = coalesceProxyRequest(
inFlight,
'sightings',
async () => {
const value = await load();
cache.set('sightings', { value, savedAt: now() });
return value;
},
);
return { value: await promise, stale: false };
} catch (error) {
if (age(previous) <= STALE_MAX_MS)
return { value: previous.value, stale: true };
throw error;
}
}
async function handler(req, res) {
const json = (status, value, { stale = false, retryAfterS } = {}) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(retryAfterS ? { 'Retry-After': String(retryAfterS) } : {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
if (admitSameSiteKeyless(req, res)) return;
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const pathname = (req.url || '/').split('?')[0];
if (pathname !== '/sightings') return json(404, { error: 'unknown_route' });
if (!allow(clientKey(req)))
return json(429, { error: 'rate_limited' }, { retryAfterS: 60 });
try {
const { value, stale } = await acquire();
return json(200, stale ? { ...value, stale: true } : value, { stale });
} catch (error) {
return json(
error.status === 429 ? 429 : 502,
{ error: 'acartia_unavailable' },
{ retryAfterS: error.retryAfterS },
);
}
}
return {
name: 'acartia',
configureServer({ middlewares }) {
middlewares.use('/api/acartia', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/acartia', handler);
},
};
}
+419
View File
@@ -0,0 +1,419 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { acartiaProxy, acartiaRetryCooldownMs } from './acartia.js';
const NOW = Date.parse('2026-11-04T18:05:00Z');
const sighting = (id = 'synthetic-1') => ({
entry_id: id,
latitude: 48.12345,
longitude: -123.12345,
created: '2026-11-04 17:00:00',
type: 'Orca',
no_sighted: '5',
photo_url: 'https://provider.example/photos/synthetic.jpg',
data_source_name: 'Synthetic Whale Network',
data_source_entity: 'Community reports',
trusted: true,
data_source_comments: '[email protected] +1-555-000-0000',
});
function install(options = {}, hook = 'configureServer') {
let handler;
const plugin = acartiaProxy({ now: () => NOW, warn() {}, ...options });
assert.equal(plugin.name, 'acartia');
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(mount, callback) {
assert.equal(mount, '/api/acartia');
handler = callback;
},
},
});
return async (
url = '/sightings',
{ method = 'GET', peer = 'local', headers = {} } = {},
) => {
const res = {
headers: {},
writeHead(status, headers) {
this.status = status;
this.headers = headers;
},
setHeader(key, value) {
this.headers[key] = value;
},
end(value) {
this.status ??= this.statusCode;
this.body = JSON.parse(value);
},
};
await handler(
{ url, method, headers, socket: { remoteAddress: peer } },
res,
);
return res;
};
}
test('cache stores only supported marine mammals inside the coverage box', async () => {
const cache = new Map();
const request = install({
cache,
fetchImpl: async () =>
Response.json([
sighting('orca'),
{ ...sighting('seal'), type: 'Harbor seal' },
{ ...sighting('unknown'), type: 'Unknown' },
{ ...sighting('fish'), type: 'Whale shark' },
{ ...sighting('outside'), latitude: 37.8 },
{ ...sighting('rounded-edge'), latitude: 45.49999 },
]),
});
const result = await request();
assert.equal(result.status, 200);
for (const rows of [result.body.rows, cache.get('sightings').value.rows]) {
assert.deepEqual(
rows.map((row) => [row.stableId, row.cetacean]),
[
['acartia:orca', true],
['acartia:seal', false],
],
);
}
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: documented array is normalized and coarsened before caching`, async () => {
const cache = new Map();
let calls = 0;
const request = install(
{
cache,
fetchImpl: async (url, options) => {
calls += 1;
assert.equal(url, 'https://acartia.io/api/v1/sightings/current');
assert.equal(options.redirect, 'error');
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.headers.Accept, 'application/json');
assert.equal(
options.headers['User-Agent'],
'gods-eye-view-wildlife-sightings-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
);
return Response.json([sighting()]);
},
},
hook,
);
assert.equal(calls, 0);
assert.equal(cache.size, 0);
const result = await request(
'/sightings?url=https://attacker.example&latitude=1',
);
assert.equal(result.status, 200);
assert.equal(result.headers['Cache-Control'], 'no-store');
assert.equal(result.body.fetchedAt, NOW);
assert.equal(result.body.rows[0].stableId, 'acartia:synthetic-1');
assert.equal(result.body.rows[0].lat, 48.12);
assert.equal(result.body.rows[0].lon, -123.12);
assert.equal(result.body.rows[0].provider.name, 'Synthetic Whale Network');
const retained = cache.get('sightings');
assert.equal(retained.value.rows[0].lat, 48.12);
assert.equal(retained.value.rows[0].lon, -123.12);
for (const value of [result.body, [...cache.values()]])
assert.doesNotMatch(
JSON.stringify(value),
/private-observer|555|data_source_comments|48\.12345|123\.12345/,
);
assert.equal(calls, 1);
});
}
test('gate, method and fixed route run before cache or upstream', async () => {
const cache = new Map();
let calls = 0;
const request = install({
cache,
fetchImpl: async () => {
calls += 1;
return Response.json([sighting()]);
},
});
for (let i = 0; i < 65; i += 1) {
assert.equal(
(
await request('/sightings', {
headers: {
host: 'localhost:5173',
origin: 'https://foreign.example',
'sec-fetch-site': 'cross-site',
},
})
).status,
403,
);
}
const method = await request('/sightings', { method: 'POST' });
assert.equal(method.status, 405);
assert.equal(method.headers.Allow, 'GET');
for (const route of ['/', '/unknown', '/sightings/extra']) {
const result = await request(route);
assert.equal(result.status, 404);
assert.equal(result.body.error, 'unknown_route');
}
assert.equal(calls, 0);
assert.equal(cache.size, 0);
const sameOrigin = await request('/sightings', {
headers: {
host: 'gev.example',
origin: 'https://gev.example',
'sec-fetch-site': 'same-origin',
'x-forwarded-proto': 'https',
'x-forwarded-for': '203.0.113.9',
},
});
assert.equal(sameOrigin.status, 200);
});
test('request 61 is locally limited even with a fresh cache', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls += 1;
return Response.json([sighting()]);
},
});
for (let i = 0; i < 60; i += 1) assert.equal((await request()).status, 200);
const limited = await request();
assert.equal(limited.status, 429);
assert.equal(limited.body.error, 'rate_limited');
assert.equal(limited.headers['Retry-After'], '60');
assert.equal(calls, 1);
});
test('one global snapshot single-flights and stays fresh for ten minutes', async () => {
let at = NOW;
let calls = 0;
let release;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
if (calls === 1)
await new Promise((resolve) => {
release = resolve;
});
return Response.json([sighting()]);
},
});
const first = request();
const concurrent = request();
await new Promise(setImmediate);
assert.equal(calls, 1);
release();
assert.deepEqual((await first).body, (await concurrent).body);
at += 599_999;
await request();
assert.equal(calls, 1);
at += 1;
await request();
assert.equal(calls, 2);
});
test('a valid empty or fully filtered snapshot is a fresh success that clears old rows', async () => {
for (const quiet of [
[],
[null],
[{ ...sighting(), data_source_name: '' }],
[{ ...sighting(), latitude: 37.8 }],
[{ ...sighting(), type: 'Unknown' }],
]) {
let at = NOW;
let calls = 0;
let empty = false;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(empty ? quiet : [sighting()]);
},
});
assert.equal((await request()).body.rows.length, 1);
empty = true;
at += 600_001;
const result = await request();
assert.equal(result.status, 200);
assert.deepEqual(result.body, { fetchedAt: at, rows: [] });
assert.equal(result.headers['X-Data-Stale'], undefined);
assert.equal(result.headers['Retry-After'], undefined);
assert.deepEqual(cache.get('sightings').value.rows, []);
// The quiet feed keeps the normal ten-minute cadence, not a failure retry.
at += 599_999;
assert.equal((await request()).status, 200);
assert.equal(calls, 2);
at += 1;
await request();
assert.equal(calls, 3);
const cold = install({ fetchImpl: async () => Response.json(quiet) });
const coldResult = await cold();
assert.equal(coldResult.status, 200);
assert.deepEqual(coldResult.body, { fetchedAt: NOW, rows: [] });
}
});
test('an invented wrapped snapshot never replaces the cache', async () => {
const bad = { results: [sighting()] };
let at = NOW;
let fail = false;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () => Response.json(fail ? bad : [sighting()]),
});
const fresh = await request();
const retained = cache.get('sightings');
fail = true;
at += 600_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, fresh.body.fetchedAt);
assert.equal(cache.get('sightings'), retained);
const coldCache = new Map();
const cold = install({
cache: coldCache,
fetchImpl: async () => Response.json(bad),
});
const result = await cold();
assert.equal(result.status, 502);
assert.equal(result.body.error, 'acartia_unavailable');
assert.equal(result.headers['Retry-After'], '15');
assert.equal(coldCache.size, 0);
});
test('failure serves stale for at most 24 hours and never reports a cold success', async () => {
let at = NOW;
let fail = false;
const request = install({
now: () => at,
fetchImpl: async () => {
if (fail) throw new Error('private upstream detail');
return Response.json([sighting()]);
},
});
const first = await request();
fail = true;
at += 23 * 60 * 60_000 + 59 * 60_000;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
at += 2 * 60_000;
const expired = await request();
assert.equal(expired.status, 502);
assert.deepEqual(expired.body, { error: 'acartia_unavailable' });
});
test('upstream 429 honors Retry-After, caps it, and spends no requests while cooling down', async () => {
assert.equal(acartiaRetryCooldownMs('2', NOW), 5000);
assert.equal(acartiaRetryCooldownMs('999999', NOW), 3_600_000);
assert.equal(
acartiaRetryCooldownMs(new Date(NOW + 120_000).toUTCString(), NOW),
120_000,
);
assert.equal(acartiaRetryCooldownMs(null, NOW), 60_000);
let at = NOW;
let calls = 0;
const warnings = [];
const request = install({
now: () => at,
warn: (message) => warnings.push(message),
fetchImpl: async () => {
calls += 1;
return new Response('limit', {
status: 429,
headers: { 'Retry-After': '999999' },
});
},
});
const first = await request();
assert.equal(first.status, 429);
assert.equal(first.body.error, 'acartia_unavailable');
assert.equal(first.headers['Retry-After'], '3600');
at += 5000;
const cooling = await request();
assert.equal(cooling.headers['Retry-After'], '3595');
assert.equal(calls, 1);
assert.equal(
warnings.filter((message) => message.includes('capped')).length,
1,
);
at += 3_600_000;
await request();
assert.equal(calls, 2);
assert.equal(
warnings.filter((message) => message.includes('capped')).length,
1,
);
});
test('upstream 429 also serves a prior snapshot stale', async () => {
let at = NOW;
let fail = false;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return fail
? new Response('limit', { status: 429 })
: Response.json([sighting()]);
},
});
const first = await request();
fail = true;
at += 600_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
at += 1000;
assert.equal((await request()).body.stale, true);
assert.equal(calls, 2);
});
test('bad JSON, 5xx and capped bodies start a 15 second failure cooldown', async () => {
for (const bad of [
() => new Response('{malformed'),
() => new Response('private upstream message', { status: 503 }),
() =>
new Response('{}', {
headers: { 'Content-Length': String(2 * 1024 * 1024 + 1) },
}),
]) {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return calls === 1 ? bad() : Response.json([sighting()]);
},
});
const first = await request();
assert.equal(first.status, 502);
assert.deepEqual(first.body, { error: 'acartia_unavailable' });
assert.equal(first.headers['Retry-After'], '15');
at += 14_999;
assert.equal((await request()).headers['Retry-After'], '1');
assert.equal(calls, 1);
at += 1;
assert.equal((await request()).status, 200);
assert.equal(calls, 2);
}
});
+4
View File
@@ -0,0 +1,4 @@
/** Identify each provider while keeping the project contact URL consistent. */
export function providerUserAgent(name) {
return `gods-eye-view-${name}-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)`;
}
+370
View File
@@ -0,0 +1,370 @@
import path from 'node:path';
import { promises as fs } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { normalizeInatObservations } from '../../../src/layers/wildlifeSightings/records.js';
import { readResponseJsonCapped } from '../common/http.js';
import { clampInt } from '../common/query.js';
import { providerUserAgent } from '../common/userAgent.js';
import { projectInatGrid, validInatGridTile } from './grid.js';
import { createInatQueue } from './queue.js';
const DAILY_BUDGET = 8000;
const DAY_MS = 24 * 60 * 60_000;
// One page is capped independently of upstream Content-Length.
const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
const TIMEOUT_MS = 20_000;
const API_URL = 'https://api.inaturalist.org/v1';
// Request only the fields used by the public observation projection.
const OBSERVATIONS_URL = 'https://api.inaturalist.org/v2/observations';
const OBSERVATION_FIELDS =
'(id:!t,uri:!t,location:!t,geojson:all,geoprivacy:!t,taxon_geoprivacy:!t,' +
'obscured:!t,quality_grade:!t,observed_on:!t,' +
'time_observed_at:!t,faves_count:!t,' +
'num_identification_agreements:!t,' +
'photos:(url:!t,attribution:!t,license_code:!t),' +
'taxon:(name:!t,preferred_common_name:!t,iconic_taxon_name:!t,' +
'threatened:!t))';
const COOLDOWN_MS = 60_000;
const FAILURE_COOLDOWN_MS = 15_000;
/** Seconds and HTTP dates are accepted; a missing value earns one minute. */
export function inatRetryCooldownMs(raw, nowMs) {
const value = typeof raw === 'string' ? raw.trim() : '';
if (value) {
const seconds = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : NaN;
const deadline =
!Number.isFinite(seconds) && /[a-z]/i.test(value)
? Date.parse(value)
: NaN;
const delay = Number.isFinite(seconds) ? seconds * 1000 : deadline - nowMs;
if (Number.isFinite(delay) && delay >= 0) return delay;
}
return COOLDOWN_MS;
}
function upstreamError(code, status, retryAfterS, upstreamStatus) {
return Object.assign(new Error(code), {
code,
status,
retryAfterS,
upstreamStatus,
});
}
function observationFilters(params) {
const query = new URLSearchParams({
verifiable: 'true',
captive: 'false',
// d1 is an observation DATE, unlike creation-time created_d1.
d1: params.observedD1,
});
if (params.taxa !== 'whales') query.set('photos', 'true');
if (params.quality === 'research') query.set('quality_grade', 'research');
if (params.taxa === 'whales') query.set('taxon_id', '152871');
else if (['Aves', 'Mammalia', 'Insecta', 'Plantae'].includes(params.taxa))
query.set('iconic_taxa', params.taxa);
return query;
}
/**
* Shared iNaturalist governor. Only request counters are persisted; observation
* payloads are projected immediately and are never written to disk.
*/
export function createInatClient({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
cacheDir = path.join(process.cwd(), '.gev-cache', 'inaturalist'),
warn = (...args) => console.warn(...args),
queueOptions,
} = {}) {
const enqueue = createInatQueue(queueOptions);
let budget = null;
let budgetLoaded = false;
let budgetTail = Promise.resolve();
let cooldownUntil = 0;
// An unavailable UTFGrid tile must never place observation queries into a
// 502 cooldown. Only an upstream 429 is a provider-wide restriction.
const failures = new Map();
function dailyLimit() {
return clampInt(
process.env.INATURALIST_DAILY_BUDGET,
1,
Number.MAX_SAFE_INTEGER,
DAILY_BUDGET,
);
}
function day() {
return new Date(now()).toISOString().slice(0, 10);
}
function currentBudget() {
const date = day();
if (budget?.date !== date) budget = { date, count: 0 };
return budget;
}
async function readBudgetOnce() {
if (budgetLoaded) return;
budgetLoaded = true;
if (cacheDir === null) return;
try {
const value = JSON.parse(
await fs.readFile(path.join(cacheDir, 'budget.json'), 'utf8'),
);
if (
typeof value?.date === 'string' &&
/^\d{4}-\d{2}-\d{2}$/.test(value.date) &&
Number.isSafeInteger(value.count) &&
value.count >= 0
)
budget = { date: value.date, count: value.count };
} catch (error) {
if (error.code !== 'ENOENT')
warn('[inaturalist-proxy] budget read failed');
}
}
async function persistBudget(value) {
if (cacheDir === null) return;
const destination = path.join(cacheDir, 'budget.json');
const temporary = `${destination}.${randomUUID()}.tmp`;
try {
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(temporary, JSON.stringify(value), 'utf8');
await fs.rename(temporary, destination);
} catch {
await fs.rm(temporary, { force: true }).catch(() => {});
warn('[inaturalist-proxy] budget write failed');
}
}
function retrySeconds(until = cooldownUntil) {
return Math.max(0, Math.ceil((until - now()) / 1000));
}
function checkCooldown(resource) {
if (now() < cooldownUntil)
throw upstreamError('inaturalist_rate_limited', 429, retrySeconds(), 429);
const failure = failures.get(resource);
if (failure && now() < failure.until)
throw upstreamError(
'inaturalist_unavailable',
502,
retrySeconds(failure.until),
failure.status,
);
failures.delete(resource);
}
// Reserve and atomically persist one attempt before issuing it. A recovered
// tail prevents one disk/reservation failure from poisoning future calls.
function reserveAttempt(signal, resource) {
const result = budgetTail.then(async () => {
signal?.throwIfAborted();
checkCooldown(resource);
await readBudgetOnce();
signal?.throwIfAborted();
checkCooldown(resource);
const value = currentBudget();
if (value.count >= dailyLimit()) {
const retry = Math.max(
1,
Math.ceil((Date.parse(`${day()}T00:00:00Z`) + DAY_MS - now()) / 1000),
);
throw upstreamError('inaturalist_budget_exhausted', 503, retry);
}
value.count += 1;
await persistBudget({ ...value });
});
budgetTail = result.catch(() => {});
return result;
}
async function requestProjected(url, project, resource, { signal } = {}) {
signal?.throwIfAborted();
checkCooldown(resource);
return enqueue(() => dispatch(url, project, resource, signal), {
signal,
priority: resource === 'observations' ? 0 : 1,
});
}
async function dispatch(url, project, resource, signal) {
signal?.throwIfAborted();
await reserveAttempt(signal, resource);
signal?.throwIfAborted();
checkCooldown(resource);
const timeout = AbortSignal.timeout(TIMEOUT_MS);
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
let upstreamStatus;
let retryDelay = FAILURE_COOLDOWN_MS;
try {
const response = await fetchImpl(url, {
signal: requestSignal,
redirect: 'error',
headers: {
Accept: 'application/json',
'User-Agent': providerUserAgent('wildlife-sightings'),
},
});
upstreamStatus = response.status;
if (!response.ok) {
await response.body?.cancel().catch(() => {});
if (response.status === 429) {
const delay = inatRetryCooldownMs(
response.headers.get('retry-after'),
now(),
);
cooldownUntil = Math.max(cooldownUntil, now() + delay);
throw upstreamError(
'inaturalist_rate_limited',
429,
retrySeconds(),
429,
);
}
if (response.headers.has('retry-after'))
retryDelay = inatRetryCooldownMs(
response.headers.get('retry-after'),
now(),
);
throw upstreamError('inaturalist_unavailable', 502);
}
const payload = await readResponseJsonCapped(
response,
MAX_RESPONSE_BYTES,
requestSignal,
);
return project(payload);
} catch (error) {
// Log only safe diagnostic categories, never a URL, body, status text or
// exception message (each can contain request/private information).
const reason = signal?.aborted
? 'abort'
: timeout.aborted || error.name === 'TimeoutError'
? 'timeout'
: upstreamStatus && upstreamStatus >= 400
? 'http'
: error.code === 'RESPONSE_TOO_LARGE'
? 'response-too-large'
: upstreamStatus
? 'invalid-payload'
: 'network';
warn(
`[inaturalist-proxy] upstream refresh failed resource=${resource} status=${upstreamStatus ?? 'none'} reason=${reason}`,
);
if (signal?.aborted) throw error;
// Network, abort, schema and size failures are not upstream cooldown
// signals. A later healthy request may recover immediately.
if (upstreamStatus >= 500)
failures.set(resource, {
until: now() + retryDelay,
status: upstreamStatus,
});
const failure = failures.get(resource);
throw upstreamError(
error.status === 429
? 'inaturalist_rate_limited'
: 'inaturalist_unavailable',
error.status === 429 ? 429 : 502,
error.status === 429
? retrySeconds()
: failure
? retrySeconds(failure.until)
: undefined,
upstreamStatus === 429 || upstreamStatus >= 500
? upstreamStatus
: undefined,
);
}
}
/** Request one bounded observation sample and project its public fields. */
async function getObservations(params, options = {}) {
const query = observationFilters(params);
// View samples stay stable in the route cache until their TTL expires.
query.set('order_by', params.scope === 'view' ? 'random' : 'observed_on');
if (params.scope !== 'view') query.set('order', 'desc');
query.set('per_page', '200');
query.set('fields', OBSERVATION_FIELDS);
if (params.scope === 'view') {
query.set('swlat', String(params.box.south));
query.set('swlng', String(params.box.west));
query.set('nelat', String(params.box.north));
query.set('nelng', String(params.box.east));
}
return requestProjected(
`${OBSERVATIONS_URL}?${query}`,
(payload) => {
if (
!Array.isArray(payload?.results) ||
!Number.isSafeInteger(payload.total_results) ||
payload.total_results < 0
)
throw upstreamError('inaturalist_unavailable', 502);
const rows = normalizeInatObservations(payload.results, {
nowMs: now(),
windowMs: (params.taxa === 'whales' ? 7 : 2) * DAY_MS,
});
// Empty views need total_results: 0. A wholly invalid page must not
// replace good data. Valid rows excluded by the finer rolling-time
// cutoff are a healthy empty result of the upstream DATE over-fetch.
if (
!rows ||
(!rows.length &&
(payload.results.length || payload.total_results) &&
!normalizeInatObservations(payload.results)?.length)
)
throw upstreamError('inaturalist_unavailable', 502);
return {
rows,
totalResults: payload.total_results,
...(payload.total_results > payload.results.length
? { partial: true, rawRowCount: payload.total_results }
: {}),
};
},
'observations',
options,
);
}
/** Decode one validated density tile without observation identifiers. */
async function getGrid(params, options = {}) {
if (!validInatGridTile(params)) throw new TypeError('Invalid grid tile');
const query = observationFilters(params);
return requestProjected(
`${API_URL}/grid/${params.z}/${params.x}/${params.y}.grid.json?${query}`,
(payload) => projectInatGrid(payload, params),
'grid',
options,
);
}
return {
getObservations,
getGrid,
/** Current provider-wide or resource-specific retry information. */
cooldownState(resource) {
if (now() < cooldownUntil)
return {
upstreamStatus: 429,
retryAfterS: retrySeconds(),
};
const failure = failures.get(resource);
if (failure && now() < failure.until)
return {
upstreamStatus: failure.status,
retryAfterS: retrySeconds(failure.until),
};
return undefined;
},
/** The current UTC day's reserved upstream attempts and configured limit. */
budgetState() {
const value = currentBudget();
return { ...value, budget: dailyLimit() };
},
};
}
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInatClient } from './client.js';
import { syntheticGrid } from './grid.fixture.mjs';
const NOW = Date.parse('2026-11-04T18:05:00Z');
const observation = (id, time) => ({
id,
geojson: { coordinates: [-122.3, 47.6] },
quality_grade: 'research',
time_observed_at: time,
observed_on: time.slice(0, 10),
// Upload age does not determine whether an observation is recent.
created_at: '2026-11-04T18:00:00Z',
taxon: { name: 'Synthetic taxon', iconic_taxon_name: 'Aves' },
});
function clientFor(results) {
return createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0 },
warn() {},
fetchImpl: async () =>
Response.json({ results, total_results: results.length }),
});
}
test('iNaturalist drops ancient and boundary-expired observations uploaded today', async () => {
const client = clientFor([
observation(1, '2010-11-04T18:00:00Z'),
observation(2, '2026-11-02T18:04:59Z'),
observation(3, '2026-11-02T18:05:00Z'),
]);
const result = await client.getObservations({
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
});
assert.deepEqual(
result.rows.map((row) => row.stableId),
['inat:3'],
);
});
test('a valid date-filtered page can be empty after the exact recency cutoff', async () => {
const client = clientFor([observation(1, '2026-11-02T06:00:00Z')]);
const result = await client.getObservations({
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
});
assert.deepEqual(result.rows, []);
assert.equal(result.totalResults, 1);
});
const params = {
scope: 'view',
box: { south: 47, west: -123, north: 48, east: -122 },
taxa: 'all',
observedD1: '2026-11-02',
};
test('observations and tiles share pacing, and observation priority wins a cold burst', async () => {
let at = 0;
const calls = [];
const client = createInatClient({
cacheDir: null,
now: () => NOW + at,
queueOptions: {
now: () => at,
wait: async (ms) => {
at += ms;
},
},
fetchImpl: async (url) => {
const isGrid = new URL(url).pathname.includes('/grid/');
calls.push([isGrid ? 'grid' : 'observations', at]);
return Response.json(
isGrid ? syntheticGrid() : { results: [], total_results: 0 },
);
},
});
const tiles = Array.from({ length: 3 }, (_, x) =>
client.getGrid({ ...params, z: 2, x, y: 1 }),
);
await Promise.all([...tiles, client.getObservations(params)]);
assert.deepEqual(calls, [
['observations', 0],
['grid', 0],
['grid', 0],
['grid', 0],
]);
assert.equal(client.budgetState().count, 4);
});
test('upstream status and safe failure reasons are logged without upstream messages or URLs', async () => {
for (const [response, expected] of [
[
() =>
new Response('request-secret', {
status: 503,
statusText: 'request-secret',
}),
/status=503 reason=http/,
],
[
() => Promise.reject(new DOMException('request-secret', 'TimeoutError')),
/status=none reason=timeout/,
],
[
() => Response.json({ unexpected: 'request-secret' }),
/status=200 reason=invalid-payload/,
],
]) {
const warnings = [];
const client = createInatClient({
cacheDir: null,
now: () => NOW,
queueOptions: { intervalMs: 0 },
warn: (message) => warnings.push(message),
fetchImpl: response,
});
await assert.rejects(client.getObservations(params), { status: 502 });
assert.equal(warnings.length, 1);
assert.match(warnings[0], expected);
assert.doesNotMatch(warnings[0], /request-secret|https?:|swlat|d1=/);
}
});
test('a dispatched abort is diagnosed, spends only its attempt, and leaves the next request healthy', async () => {
const warnings = [];
const controller = new AbortController();
let started;
const didStart = new Promise((resolve) => {
started = resolve;
});
let calls = 0;
const client = createInatClient({
cacheDir: null,
now: () => NOW,
queueOptions: { intervalMs: 0 },
warn: (message) => warnings.push(message),
fetchImpl: async (url, { signal }) => {
calls += 1;
if (calls > 1) return Response.json({ results: [], total_results: 0 });
started();
return new Promise((resolve, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), {
once: true,
}),
);
},
});
const pending = client.getObservations(params, { signal: controller.signal });
const failed = assert.rejects(pending, { name: 'AbortError' });
await didStart;
controller.abort();
await failed;
assert.match(warnings[0], /resource=observations status=none reason=abort/);
await client.getObservations(params);
assert.equal(calls, 2);
});
@@ -0,0 +1,51 @@
/** Build a synthetic UTFGrid with logical cell counts and repeated hit pixels. */
export function syntheticGrid({
z = 2,
x = 1,
y = 1,
cells = [
{ row: 1, column: 51, count: 6, iconic: 3 },
{ row: 3, column: 49, count: 12, iconic: 47170 },
],
} = {}) {
const pixels = Array.from({ length: 64 }, () => Array(64).fill(' '));
const keys = [''];
const data = {};
cells.forEach((cell, index) => {
const key = String(900_000_000 + index);
keys.push(key);
const value = index + 1;
let code = value + 32;
if (code >= 34) code += 1;
if (code >= 92) code += 1;
const character = String.fromCharCode(code);
for (const [row, column] of [
[cell.row, cell.column],
[cell.row - 1, cell.column],
[cell.row + 1, cell.column],
[cell.row, cell.column - 1],
[cell.row, cell.column + 1],
]) {
if (row >= 0 && row < 64 && column >= 0 && column < 64)
pixels[row][column] = character;
}
data[key] = {
captive: null,
cellCount: cell.count,
geoprivacy: null,
id: Number(key),
latitude:
(Math.atan(
Math.sinh(Math.PI * (1 - 2 * ((y + cell.row / 64) / 2 ** z))),
) *
180) /
Math.PI,
longitude: ((x + cell.column / 64) / 2 ** z) * 360 - 180,
private_location: null,
quality_grade: 'needs_id',
'taxon.iconic_taxon_id': cell.iconic,
uuid: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`,
};
});
return { grid: pixels.map((row) => row.join('')), keys, data };
}
+127
View File
@@ -0,0 +1,127 @@
// iNaturalist's operational /v1/grid/{z}/{x}/{y}.grid.json endpoint is not
// covered by its v1 Swagger contract. Validate the entire UTFGrid shape before
// accepting any cell; callers preserve the last good sample on schema drift.
import {
wildlifeGridCellId,
WILDLIFE_GRID_MAX_ZOOM,
} from '../../../src/layers/wildlifeSightings/gridViewport.js';
const INAT_GRID_SIZE = 64;
export const INAT_GRID_DECODE_MEASURE = 'wildlife:server:tile-decode';
const grades = new Set(['research', 'needs_id', 'casual']);
const object = (value) =>
!!value && typeof value === 'object' && !Array.isArray(value);
/** Accept only integer XYZ coordinates within the supported zoom range. */
export function validInatGridTile({ z, x, y } = {}) {
return (
Number.isInteger(z) &&
z >= 0 &&
z <= WILDLIFE_GRID_MAX_ZOOM &&
Number.isInteger(x) &&
Number.isInteger(y) &&
x >= 0 &&
y >= 0 &&
x < 2 ** z &&
y < 2 ** z
);
}
/** Pure allow-list projection. A UTFGrid key is an observation identifier and
* its repeated pixels form a dilated hit area, not additional occupied cells.
* Neither those keys nor representative/private fields cross this boundary. */
export function projectInatGrid(payload, tile) {
const start = `${INAT_GRID_DECODE_MEASURE}:start`;
const end = `${INAT_GRID_DECODE_MEASURE}:end`;
performance.mark(start);
try {
return decodeInatGrid(payload, tile);
} finally {
performance.mark(end);
// Keep the latest sample inspectable without retaining every tile forever.
// PerformanceObservers still receive each individual decode measurement.
performance.clearMeasures(INAT_GRID_DECODE_MEASURE);
performance.measure(INAT_GRID_DECODE_MEASURE, start, end);
performance.clearMarks(start);
performance.clearMarks(end);
}
}
function decodeInatGrid(payload, tile) {
const invalid = () => {
throw new TypeError('Invalid iNaturalist UTFGrid');
};
if (
!validInatGridTile(tile) ||
!object(payload) ||
!Array.isArray(payload.grid) ||
payload.grid.length !== INAT_GRID_SIZE ||
!payload.grid.every(
(row) => typeof row === 'string' && row.length === INAT_GRID_SIZE,
) ||
!Array.isArray(payload.keys) ||
!payload.keys.length ||
payload.keys.length > INAT_GRID_SIZE ** 2 + 1 ||
payload.keys[0] !== '' ||
!payload.keys.every(
(key) => typeof key === 'string' && key.length <= 128,
) ||
new Set(payload.keys).size !== payload.keys.length ||
!object(payload.data)
)
invalid();
const dataKeys = Object.keys(payload.data);
if (dataKeys.length !== payload.keys.length - 1) invalid();
for (const key of payload.keys.slice(1)) {
if (!key || !Object.hasOwn(payload.data, key)) invalid();
const value = payload.data[key];
if (
!object(value) ||
!Number.isSafeInteger(value.cellCount) ||
value.cellCount <= 0 ||
!Number.isFinite(value.latitude) ||
Math.abs(value.latitude) > 90 ||
!Number.isFinite(value.longitude) ||
Math.abs(value.longitude) > 180 ||
!grades.has(value.quality_grade) ||
!(
value['taxon.iconic_taxon_id'] === null ||
(Number.isSafeInteger(value['taxon.iconic_taxon_id']) &&
value['taxon.iconic_taxon_id'] > 0)
)
)
invalid();
}
const occupied = new Set();
for (const line of payload.grid) {
for (let column = 0; column < INAT_GRID_SIZE; column += 1) {
let code = line.charCodeAt(column);
if (code < 32 || code === 34 || code === 92) invalid();
if (code >= 93) code -= 1;
if (code >= 35) code -= 1;
const index = code - 32;
if (index < 0 || index >= payload.keys.length) invalid();
if (index) occupied.add(payload.keys[index]);
}
}
if (occupied.size !== dataKeys.length) invalid();
const cells = new Map();
for (const key of occupied) {
const value = payload.data[key];
const id = wildlifeGridCellId(value.latitude, value.longitude, tile.z);
const cell = {
id,
cellCount: value.cellCount,
lat: value.latitude,
lon: value.longitude,
};
// Two different representatives claiming one logical cell are a contract
// change; summing or choosing one silently would fabricate the count.
if (cells.has(id)) invalid();
cells.set(id, cell);
}
const rows = [...cells.values()];
const totalResults = rows.reduce((total, cell) => total + cell.cellCount, 0);
if (!Number.isSafeInteger(totalResults)) invalid();
return { cells: rows, totalResults };
}
+374
View File
@@ -0,0 +1,374 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { projectInatGrid } from './grid.js';
import { syntheticGrid } from './grid.fixture.mjs';
import { installInatProxy, NOW } from './proxy.fixture.mjs';
import { createInatClient } from './client.js';
const tile = { z: 2, x: 1, y: 1 };
const gridUrl = '/grid?z=2&x=1&y=1&taxa=Aves&quality=research';
const install = (options, hook) => installInatProxy(gridUrl, options, hook);
test('UTFGrid projection validates the full shape and counts each logical occupied cell once', () => {
const raw = syntheticGrid();
const projected = projectInatGrid(raw, tile);
assert.equal(raw.grid.length, 64);
assert.ok(raw.grid.every((row) => row.length === 64));
assert.equal(
raw.grid.join('').split('!').length - 1,
5,
'one key is repeated by its hit area',
);
assert.equal(projected.cells.length, 2);
assert.equal(projected.totalResults, 18);
assert.deepEqual(projected.cells[0], {
id: 'grid:2/1/1:1:51',
cellCount: 6,
lat: raw.data[raw.keys[1]].latitude,
lon: raw.data[raw.keys[1]].longitude,
});
for (const cell of projected.cells)
assert.deepEqual(Object.keys(cell).sort(), [
'cellCount',
'id',
'lat',
'lon',
]);
for (const privateField of [
'900000000',
'uuid',
'geoprivacy',
'private_location',
'captive',
'quality_grade',
])
assert.ok(!JSON.stringify(projected).includes(privateField));
});
test('legitimate blank grid and null/unknown iconic ids remain safe', () => {
assert.deepEqual(projectInatGrid(syntheticGrid({ cells: [] }), tile), {
cells: [],
totalResults: 0,
});
const raw = syntheticGrid({
cells: [{ row: 1, column: 1, count: 1, iconic: null }],
});
const projected = projectInatGrid(raw, tile);
assert.equal(projected.cells[0].cellCount, 1);
raw.data[raw.keys[1]]['taxon.iconic_taxon_id'] = 999999;
assert.deepEqual(projectInatGrid(raw, tile), projected);
});
test('schema drift anywhere fails closed, including unused, malformed and orphan cells', () => {
const changes = [
(raw) => {
raw.grid.pop();
},
(raw) => {
raw.grid[0] = '';
},
(raw) => {
raw.grid[0] = '~'.repeat(64);
},
(raw) => {
raw.keys[0] = 'not-empty';
},
(raw) => {
raw.keys.push(raw.keys[1]);
},
(raw) => {
delete raw.data[raw.keys[1]];
},
(raw) => {
raw.data.extra = {};
},
(raw) => {
raw.data[raw.keys[1]].cellCount = -1;
},
(raw) => {
raw.data[raw.keys[1]].latitude = '47';
},
(raw) => {
raw.data[raw.keys[1]].longitude = 181;
},
(raw) => {
raw.data[raw.keys[1]].quality_grade = 'new-grade';
},
(raw) => {
delete raw.data[raw.keys[1]]['taxon.iconic_taxon_id'];
},
(raw) => {
raw.data[raw.keys[1]]['taxon.iconic_taxon_id'] = 'Aves';
},
(raw) => {
raw.grid = Array(64).fill(' '.repeat(64));
},
];
for (const change of changes) {
const raw = syntheticGrid();
change(raw);
assert.throws(
() => projectInatGrid(raw, tile),
/Invalid iNaturalist UTFGrid/,
);
}
});
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: fixed grid URL shares filters, caps and allow-list cache`, async () => {
const calls = [];
const cache = new Map();
const request = install(
{
cache,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.equal(options.redirect, 'error');
assert.ok(options.signal instanceof AbortSignal);
assert.match(
options.headers['User-Agent'],
/^gods-eye-view-wildlife-sightings-proxy/,
);
return Response.json(syntheticGrid());
},
},
hook,
);
assert.equal(calls.length, 0);
const response = await request(
`${gridUrl}&url=https://attacker.test&per_page=999`,
);
assert.equal(response.status, 200);
assert.equal(response.body.cells.length, 2);
assert.equal(response.body.fetchedAt, NOW);
assert.equal(response.headers['Cache-Control'], 'no-store');
assert.equal(
calls[0].origin + calls[0].pathname,
'https://api.inaturalist.org/v1/grid/2/1/1.grid.json',
);
assert.deepEqual(Object.fromEntries(calls[0].searchParams), {
verifiable: 'true',
captive: 'false',
d1: '2026-11-02',
photos: 'true',
quality_grade: 'research',
iconic_taxa: 'Aves',
});
await request(gridUrl);
assert.equal(calls.length, 1, 'fresh stable tile/filter key is cached');
assert.ok(!JSON.stringify([...cache]).includes('private_location'));
assert.ok(!JSON.stringify([...cache]).includes('900000000'));
const whale = await request('/grid?z=2&x=1&y=1&taxa=whales&confirmed=true');
assert.equal(whale.status, 200);
assert.equal(calls[1].searchParams.get('taxon_id'), '152871');
assert.equal(calls[1].searchParams.get('d1'), '2026-10-28');
assert.equal(calls[1].searchParams.has('photos'), false);
});
}
test('grid admission rejects cross-site/method/path/tile before acquisition', async () => {
const request = install({
fetchImpl() {
assert.fail('invalid grid request acquired');
},
});
assert.equal(
(
await request(gridUrl, {
headers: {
host: 'localhost:5173',
origin: 'https://other.test',
'sec-fetch-site': 'cross-site',
},
})
).status,
403,
);
assert.equal((await request('/grid', { method: 'POST' })).status, 405);
assert.equal((await request('/grid/extra')).status, 404);
for (const query of [
'',
'z=2&x=4&y=0',
'z=-1&x=0&y=0',
'z=23&x=0&y=0',
'z=2&x=1.5&y=0',
'z=2&x=&y=0',
'z=2&x=0&y=4',
]) {
const result = await request(`/grid?${query}`);
assert.equal(result.status, 400, query);
assert.equal(result.body.error, 'invalid_tile');
}
});
test('grid refresh single-flight, stale fallback and hour-key expiry keep the original age', async () => {
let time = NOW;
let calls = 0;
let resolve;
let mode = 'pending';
const request = install({
now: () => time,
fetchImpl: async () => {
calls += 1;
if (mode === 'pending')
return new Promise((done) => {
resolve = done;
});
if (mode === 'bad') return Response.json({ grid: [] });
return Response.json(syntheticGrid());
},
});
const first = request();
const second = request();
for (let i = 0; i < 100 && !resolve; i += 1) await new Promise(setImmediate);
assert.equal(calls, 1);
resolve(Response.json(syntheticGrid()));
assert.equal((await first).status, 200);
assert.equal((await second).status, 200);
time += 900_001;
mode = 'bad';
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, NOW);
assert.equal(stale.headers['X-Data-Stale'], 'true');
time = NOW + 3_600_001;
const expired = await request();
assert.equal(expired.status, 502);
assert.equal(expired.body.error, 'inaturalist_unavailable');
});
test('grid and observations share the upstream cooldown and daily reservations', async () => {
let calls = 0;
const client = createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0 },
warn() {},
fetchImpl: async () => {
calls += 1;
return new Response('', {
status: 429,
headers: { 'Retry-After': '120' },
});
},
});
await assert.rejects(
client.getGrid({ ...tile, taxa: 'all', observedD1: '2026-11-02' }),
(error) => error.status === 429 && error.retryAfterS === 120,
);
await assert.rejects(
client.getObservations({
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
}),
(error) => error.status === 429,
);
assert.equal(calls, 1);
assert.equal(client.budgetState().count, 1);
});
test('grid body cap and upstream failures return fixed errors; only real 5xx cools down', async () => {
for (const [response, retryAfter] of [
[() => new Response('x'.repeat(8 * 1024 * 1024 + 1)), undefined],
[() => new Response('', { status: 503 }), '15'],
]) {
const request = install({ fetchImpl: async () => response() });
const result = await request();
assert.equal(result.status, 502);
assert.deepEqual(result.body, { error: 'inaturalist_unavailable' });
assert.equal(result.headers['Retry-After'], retryAfter);
}
});
test('a failed tile preserves last good cells and cannot send unrelated observations into 502', async () => {
let at = NOW;
let failGrid = false;
const calls = [];
const warnings = [];
const request = install({
now: () => at,
warn: (message) => warnings.push(message),
fetchImpl: async (url) => {
const isGrid = new URL(url).pathname.includes('/grid/');
calls.push(isGrid ? 'grid' : 'observations');
if (isGrid && failGrid)
return new Response('private upstream text', {
status: 503,
headers: { 'Retry-After': '45' },
});
return Response.json(
isGrid ? syntheticGrid() : { results: [], total_results: 0 },
);
},
});
const first = await request();
at += 900_001;
failGrid = true;
const failed = await request();
assert.equal(failed.status, 200);
assert.equal(failed.headers['X-Data-Stale'], 'true');
assert.equal(failed.body.stale, true);
assert.equal(failed.body.fetchedAt, first.body.fetchedAt);
assert.deepEqual(failed.body.cells, first.body.cells);
assert.equal(
(await request('/observations?scope=global&taxa=whales')).status,
200,
);
assert.deepEqual(calls, ['grid', 'grid', 'observations']);
assert.match(warnings[0], /resource=grid status=503 reason=http/);
assert.doesNotMatch(warnings[0], /private upstream text/);
// The tile cooldown also preserves its data without another upstream try.
const cooling = await request();
assert.equal(cooling.body.stale, true);
assert.equal(cooling.headers['Retry-After'], '45');
assert.equal(cooling.headers['X-Upstream-Status'], '503');
assert.equal(calls.length, 3);
at += 45_000;
failGrid = false;
const refreshing = await request();
assert.equal(refreshing.body.stale, true);
await new Promise(setImmediate);
const recovered = await request();
assert.equal(recovered.status, 200);
assert.equal(recovered.body.stale, undefined);
assert.equal(
calls.length,
4,
'a failed queue task does not poison later tile work',
);
});
test('twelve queued tiles leave observation capacity and give the next dispatch to observations', async () => {
const calls = [];
let release;
const request = install({
fetchImpl: (url) => {
const path = new URL(url).pathname;
calls.push(path);
if (calls.length === 1)
return new Promise((resolve) => {
release = resolve;
});
return Response.json(
path.includes('/grid/')
? syntheticGrid()
: { results: [], total_results: 0 },
);
},
});
const first = request('/grid?z=4&x=0&y=1');
for (let i = 0; i < 100 && !release; i += 1) await new Promise(setImmediate);
assert.ok(release);
const tiles = Array.from({ length: 11 }, (_, i) =>
request(`/grid?z=4&x=${i + 1}&y=1`),
);
const observation = request('/observations?scope=global');
const queuedTile = request('/grid?z=4&x=12&y=1');
release(Response.json(syntheticGrid()));
const results = await Promise.all([first, observation, ...tiles, queuedTile]);
assert.ok(results.every((result) => result.status === 200));
assert.equal(calls.length, 14);
assert.equal(calls[1], '/v2/observations');
});
@@ -0,0 +1,343 @@
import { createInatClient } from './client.js';
import { coalesceProxyRequest } from '../common/http.js';
import { admitSameSiteKeyless } from '../common/same-site.js';
import { requiredFiniteQueryNumber } from '../common/query.js';
import { snapWildlifeBox } from '../../../src/layers/wildlifeSightings/bounds.js';
import { validInatGridTile } from './grid.js';
// The upstream advertises five-minute freshness; global samples cost less often.
const TTL_MS = 300_000;
const GLOBAL_TTL_MS = 600_000;
const INAT_GRID_CACHE_TTL_MS = 15 * 60_000;
const STALE_MAX_MS = 3_600_000;
const INAT_CACHE_MAX = 400;
const HOUR_MS = 3_600_000;
const TAXA = Object.freeze([
'all',
'Aves',
'Mammalia',
'Insecta',
'Plantae',
'whales',
]);
function requestParams(pathname, query, nowMs) {
const grid = pathname === '/grid';
const scope = grid ? 'grid' : (query.get('scope') ?? 'view');
if (!grid && scope !== 'view' && scope !== 'global')
return { error: 'invalid_scope' };
const quality =
query.get('quality') === 'research' ||
(grid && query.get('confirmed') === 'true')
? 'research'
: 'all';
const taxa = TAXA.includes(query.get('taxa')) ? query.get('taxa') : 'all';
let box = null;
let tile;
if (grid) {
tile = Object.fromEntries(
['z', 'x', 'y'].map((name) => [
name,
requiredFiniteQueryNumber(query, name),
]),
);
if (!validInatGridTile(tile)) return { error: 'invalid_tile' };
} else if (scope === 'view') {
const south = requiredFiniteQueryNumber(query, 'south');
const west = requiredFiniteQueryNumber(query, 'west');
const north = requiredFiniteQueryNumber(query, 'north');
const east = requiredFiniteQueryNumber(query, 'east');
box = snapWildlifeBox({ south, west, north, east });
if (!box) return { error: 'invalid_bbox' };
}
const windowMs = (taxa === 'whales' ? 7 * 24 : 48) * HOUR_MS;
const windowKey = new Date(Math.floor((nowMs - windowMs) / HOUR_MS) * HOUR_MS)
.toISOString()
.replace('.000Z', 'Z');
const bounds = box ? `${box.south},${box.west},${box.north},${box.east}` : '';
const baseKey = grid
? `grid:v1:${tile.z}/${tile.x}/${tile.y}:${quality}:${taxa}`
: `v1:${scope}:${bounds}:${quality}:${taxa}`;
return {
scope,
...(grid
? { ...tile, waitForFresh: query.get('refresh') === 'wait' }
: { box }),
quality,
taxa,
observedD1: windowKey.slice(0, 10),
baseKey,
key: `${baseKey}:${windowKey}`,
};
}
/** A shared-provider route for dev and preview; acquisition starts on demand. */
export function inaturalistProxy({
fetchImpl = (...args) => globalThis.fetch(...args),
now = () => Date.now(),
cacheDir,
cache = new Map(),
warn = (...args) => console.warn(...args),
queueOptions,
} = {}) {
const inFlight = new Map();
const consumers = new Map();
let client = null;
function age(entry) {
if (!entry) return Infinity;
const elapsed = now() - entry.savedAt;
return elapsed < 0 ? Infinity : elapsed;
}
function joinFlight(promise, group, signal) {
group.count += 1;
return new Promise((resolve, reject) => {
let released = false;
function release() {
if (released) return;
released = true;
signal?.removeEventListener('abort', abort);
group.count -= 1;
if (!group.count && !group.background && !group.settled) {
group.controller.abort();
// A fresh consumer must not join an abandoned queued generation.
if (inFlight.get(group.key) === promise) inFlight.delete(group.key);
if (consumers.get(group.key) === group) consumers.delete(group.key);
}
}
function abort() {
release();
reject(signal.reason);
}
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) abort();
promise.then(
(value) => {
release();
resolve(value);
},
(error) => {
release();
reject(error);
},
);
});
}
function refresh(params, background = false) {
// Global samples share pending refreshes across hourly window changes.
const flightKey = params.scope === 'global' ? params.baseKey : params.key;
let group = consumers.get(flightKey);
if (!group) {
group = {
key: flightKey,
controller: new AbortController(),
count: 0,
settled: false,
background,
};
consumers.set(flightKey, group);
}
group.background ||= background;
const { promise, shared } = coalesceProxyRequest(
inFlight,
flightKey,
async () => {
client ??= createInatClient({
fetchImpl,
now,
cacheDir,
warn,
queueOptions,
});
const options = { signal: group.controller.signal };
const result = await (params.scope === 'grid'
? client.getGrid(params, options)
: client.getObservations(params, options));
const value = {
fetchedAt: now(),
...(params.scope === 'grid'
? {}
: { scope: params.scope, box: params.box }),
...result,
};
cache.delete(params.key);
cache.set(params.key, {
value,
savedAt: now(),
baseKey: params.baseKey,
});
while (cache.size > INAT_CACHE_MAX)
cache.delete(cache.keys().next().value);
return value;
},
);
if (!shared) {
const settle = () => {
group.settled = true;
if (consumers.get(flightKey) === group) consumers.delete(flightKey);
};
void promise.then(settle, settle);
}
return { promise, group };
}
async function acquire(params, signal) {
signal?.throwIfAborted();
for (const [key, entry] of cache) {
if (age(entry) > STALE_MAX_MS) cache.delete(key);
}
const current = cache.get(params.key);
const ttl =
params.scope === 'grid'
? INAT_GRID_CACHE_TTL_MS
: params.scope === 'global'
? GLOBAL_TTL_MS
: TTL_MS;
if (current && age(current) < ttl) {
cache.delete(params.key);
cache.set(params.key, current);
return { value: current.value, stale: false };
}
// Window-hour rollover can reuse prior cells without claiming a new age.
let previous = current;
let previousKey = params.key;
if (!previous) {
for (const [key, entry] of cache) {
if (
entry.baseKey === params.baseKey &&
(!previous || entry.savedAt > previous.savedAt)
) {
previous = entry;
previousKey = key;
}
}
}
if (params.scope === 'global' && age(previous) < GLOBAL_TTL_MS)
return { value: previous.value, stale: false };
if (
params.scope === 'grid' &&
!params.waitForFresh &&
age(previous) <= STALE_MAX_MS
) {
cache.delete(previousKey);
cache.set(previousKey, previous);
// Retain one background consumer after this HTTP response closes. A stale
// tile is useful immediately; waiting for the token queue defeats SWR.
const cooling = client?.cooldownState('grid');
if (!cooling) {
const { promise } = refresh(params, true);
void promise.catch(() => {});
}
return {
value: previous.value,
stale: true,
...cooling,
};
}
try {
const { promise, group } = refresh(params);
return { value: await joinFlight(promise, group, signal), stale: false };
} catch (error) {
if (signal?.aborted) throw error;
if (age(previous) <= STALE_MAX_MS)
return {
value: previous.value,
stale: true,
retryAfterS: error.retryAfterS,
upstreamStatus: error.upstreamStatus,
};
throw error;
}
}
async function handler(req, res) {
const json = (
status,
value,
{ stale = false, retryAfterS, upstreamStatus } = {},
) => {
if (res.destroyed) return;
res.writeHead(status, {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
...(status === 405 ? { Allow: 'GET' } : {}),
...(Number.isFinite(retryAfterS)
? { 'Retry-After': String(retryAfterS) }
: {}),
...(upstreamStatus
? { 'X-Upstream-Status': String(upstreamStatus) }
: {}),
...(stale ? { 'X-Data-Stale': 'true' } : {}),
});
res.end(JSON.stringify(value));
};
if (admitSameSiteKeyless(req, res)) return;
if (req.method !== 'GET') return json(405, { error: 'method_not_allowed' });
const url = String(req.url || '/');
const queryAt = url.indexOf('?');
const pathname = queryAt < 0 ? url : url.slice(0, queryAt);
if (pathname !== '/observations' && pathname !== '/grid')
return json(404, { error: 'unknown_route' });
const params = requestParams(
pathname,
new URLSearchParams(queryAt < 0 ? '' : url.slice(queryAt + 1)),
now(),
);
if (params.error) return json(400, { error: params.error });
const controller = new AbortController();
const abort = () => controller.abort();
// IncomingMessage.close also fires after a complete request body. The
// response close identifies the abandoned browser fetch, while aborted
// covers a request cut short before it reached the response stage.
req.once?.('aborted', abort);
res.once?.('close', abort);
try {
const { value, stale, retryAfterS, upstreamStatus } = await acquire(
params,
controller.signal,
);
return json(200, stale ? { ...value, stale: true } : value, {
stale,
retryAfterS,
upstreamStatus,
});
} catch (error) {
if (controller.signal.aborted) return;
const status =
error.code === 'inaturalist_budget_exhausted'
? 503
: error.status === 429
? 429
: 502;
const code =
error.code === 'inaturalist_budget_exhausted'
? error.code
: status === 429
? 'inaturalist_rate_limited'
: 'inaturalist_unavailable';
return json(
status,
{ error: code },
{
retryAfterS: error.retryAfterS,
upstreamStatus: error.upstreamStatus,
},
);
} finally {
req.removeListener?.('aborted', abort);
res.removeListener?.('close', abort);
}
}
return {
name: 'inaturalist',
configureServer({ middlewares }) {
middlewares.use('/api/inaturalist', handler);
},
configurePreviewServer({ middlewares }) {
middlewares.use('/api/inaturalist', handler);
},
};
}
@@ -0,0 +1,893 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { installInatProxy, NOW } from './proxy.fixture.mjs';
import { createInatClient, inatRetryCooldownMs } from './client.js';
const observation = (id = 1) => ({
id,
uri: `https://www.inaturalist.org/observations/${id}`,
geojson: { coordinates: [-122.351, 47.613] },
time_observed_at: '2026-11-04T17:00:00Z',
observed_on: '2026-11-04',
created_at: '2026-11-04T17:01:00Z',
quality_grade: 'research',
license_code: 'cc-by',
positional_accuracy: 12,
faves_count: 2,
num_identification_agreements: 3,
taxon: {
name: 'Calypte anna',
preferred_common_name: "Anna's hummingbird",
iconic_taxon_name: 'Aves',
},
photos: [
{
url: 'https://inaturalist-open-data.s3.amazonaws.com/photos/123/square.jpeg',
license_code: 'cc-by',
attribution: 'Synthetic photographer, CC BY',
},
],
user: { login: 'must-never-leave-server' },
});
const envelope = (rows = [observation()], total = rows.length) => ({
total_results: total,
page: 1,
per_page: 200,
results: rows,
});
const boxUrl = (index = 0, extra = '') =>
`/observations?south=47.51&west=${-122.49 + index * 0.2}&north=47.69&east=${-122.31 + index * 0.2}${extra}`;
function install(options = {}, hook = 'configureServer') {
return installInatProxy(
boxUrl(),
{
// Serial dispatch makes queued cancellation and coalescing observable.
queueOptions: { intervalMs: 0, windowMs: 0, maxInFlight: 1 },
...options,
},
hook,
);
}
async function until(check) {
for (let i = 0; i < 100; i += 1) {
if (check()) return;
await new Promise(setImmediate);
}
assert.fail('expected fake upstream calls were not dispatched');
}
for (const hook of ['configureServer', 'configurePreviewServer']) {
test(`${hook}: actual v2 envelope, fixed query, projection and privacy`, async () => {
const calls = [];
const cache = new Map();
const request = install(
{
cache,
fetchImpl: async (url, options) => {
calls.push(new URL(url));
assert.equal(options.redirect, 'error');
assert.ok(options.signal instanceof AbortSignal);
assert.equal(options.headers.Accept, 'application/json');
assert.equal(
options.headers['User-Agent'],
'gods-eye-view-wildlife-sightings-proxy/1.0 (+https://github.com/bilawalsidhu/gods-eye-view)',
);
return Response.json(envelope());
},
},
hook,
);
assert.equal(calls.length, 0, 'construction and mounting do not acquire');
const result = await request(
boxUrl(0, '&url=https://attacker.example&per_page=999'),
);
assert.equal(result.status, 200);
assert.equal(result.headers['Cache-Control'], 'no-store');
assert.equal(result.body.rows[0].stableId, 'inat:1');
assert.equal(result.body.fetchedAt, NOW);
assert.equal(result.body.scope, 'view');
assert.deepEqual(result.body.box, {
south: 47.5,
west: -122.5,
north: 47.7,
east: -122.3,
});
const query = calls[0].searchParams;
assert.equal(
calls[0].origin + calls[0].pathname,
'https://api.inaturalist.org/v2/observations',
);
// Only the projected fields are requested, never user or identification data.
assert.match(query.get('fields'), /^\(id:!t,uri:!t,.*photos:\(url:!t/);
assert.doesNotMatch(query.get('fields'), /user|identifications/);
assert.equal(query.get('swlat'), '47.5');
assert.equal(query.get('swlng'), '-122.5');
assert.equal(query.get('nelat'), '47.7');
assert.equal(query.get('nelng'), '-122.3');
assert.equal(query.get('d1'), '2026-11-02');
assert.equal(query.has('created_d1'), false);
assert.equal(query.get('per_page'), '200');
assert.equal(query.get('verifiable'), 'true');
assert.equal(query.get('photos'), 'true');
assert.equal(query.get('captive'), 'false');
assert.equal(query.get('order_by'), 'random');
assert.equal(query.has('order'), false);
assert.doesNotMatch(
JSON.stringify(result.body),
/must-never-leave-server|"user"/,
);
assert.doesNotMatch(
JSON.stringify([...cache.values()]),
/must-never-leave-server|"user"/,
);
});
}
test('gate, method, route and query validation run before quota, cache or upstream', async () => {
const cache = new Map();
let calls = 0;
const request = install({
cache,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
for (let i = 0; i < 32; i += 1) {
const response = await request('/observations?scope=global', {
headers: {
host: 'localhost:5173',
origin: 'https://foreign.example',
'sec-fetch-site': 'cross-site',
},
});
assert.equal(response.status, 403);
}
const method = await request('/observations', { method: 'POST' });
assert.equal(method.status, 405);
assert.equal(method.headers.Allow, 'GET');
for (const route of ['/', '/unknown', '/observations/extra', '//[invalid'])
assert.equal((await request(route)).body.error, 'unknown_route');
assert.equal(
(await request('/observations?scope=planet')).body.error,
'invalid_scope',
);
for (const query of [
'',
'south=NaN&west=0&north=1&east=1',
'south=&west=0&north=1&east=1',
'south=0&west=0&north=0&east=1',
'south=0&west=1&north=1&east=0',
'south=-91&west=0&north=1&east=1',
'south=0&west=-181&north=1&east=1',
'south=0&west=0&north=41&east=1',
'south=0&west=0&north=1&east=41',
]) {
const result = await request(`/observations?${query}`);
assert.equal(result.status, 400, query);
assert.equal(result.body.error, 'invalid_bbox', query);
}
assert.equal(cache.size, 0);
assert.equal(calls, 0);
const allowed = await request('/observations?scope=global', {
headers: {
host: 'gev.example',
origin: 'https://gev.example',
'sec-fetch-site': 'same-origin',
'x-forwarded-proto': 'https',
'x-forwarded-for': '203.0.113.9',
},
});
assert.equal(allowed.status, 200);
});
test('cached requests never exhaust a local request admission limiter', async () => {
let calls = 0;
const request = install({
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
for (let i = 0; i < 30; i += 1) assert.equal((await request()).status, 200);
const cached = await request();
assert.equal(cached.status, 200);
assert.equal(cached.headers['Retry-After'], undefined);
assert.equal(calls, 1);
});
test('proxy rejects oversized snapped spans before acquisition and preserves canonical client boxes', async () => {
const urls = [];
const request = install({
fetchImpl: async (url) => {
urls.push(new URL(url));
return Response.json(envelope());
},
});
for (const query of [
'south=10.2&west=0&north=50.1&east=1',
'south=0&west=10.2&north=1&east=50.1',
]) {
const result = await request(`/observations?${query}`);
assert.equal(result.status, 400);
assert.equal(result.body.error, 'invalid_bbox');
}
assert.equal(urls.length, 0);
const canonical =
'/observations?south=47.4&west=-122.3&north=47.6&east=-122.1';
const first = await request(canonical);
assert.equal(first.status, 200);
assert.deepEqual(first.body.box, {
south: 47.4,
west: -122.3,
north: 47.6,
east: -122.1,
});
assert.equal(urls[0].searchParams.get('swlat'), '47.4');
assert.equal(urls[0].searchParams.get('swlng'), '-122.3');
const second = await request(canonical);
assert.deepEqual(second.body, first.body);
assert.equal(urls.length, 1, 'canonical boxes share their cache key');
});
test('view snapping, whales, research and global queries obey the requested policy', async () => {
const urls = [];
const request = install({
fetchImpl: async (url) => {
urls.push(new URL(url));
return Response.json(envelope());
},
});
await request(
'/observations?south=40.2&west=-123.7&north=46.1&east=-121.4&quality=research&taxa=Aves',
);
assert.equal(urls[0].searchParams.get('swlat'), '40');
assert.equal(urls[0].searchParams.get('nelat'), '47');
assert.equal(urls[0].searchParams.get('per_page'), '200');
assert.equal(urls[0].searchParams.get('quality_grade'), 'research');
assert.equal(urls[0].searchParams.get('iconic_taxa'), 'Aves');
await request(boxUrl(0, '&taxa=whales'));
const whales = urls[1].searchParams;
assert.equal(whales.get('taxon_id'), '152871');
assert.equal(whales.get('d1'), '2026-10-28');
assert.equal(whales.has('created_d1'), false);
assert.equal(whales.has('photos'), false);
assert.equal(whales.has('iconic_taxa'), false);
await request('/observations?scope=global&quality=typo&taxa=typo&south=bad');
const global = urls[2].searchParams;
for (const field of [
'swlat',
'swlng',
'nelat',
'nelng',
'quality_grade',
'iconic_taxa',
])
assert.equal(global.has(field), false, field);
assert.equal(global.get('per_page'), '200');
assert.equal(global.get('d1'), '2026-11-02');
assert.equal(global.has('created_d1'), false);
assert.equal(global.get('order_by'), 'observed_on');
assert.equal(global.get('order'), 'desc');
await request('/observations?scope=global&taxa=whales');
const globalWhales = urls[3].searchParams;
assert.equal(globalWhales.get('d1'), '2026-10-28');
assert.equal(globalWhales.has('created_d1'), false);
assert.equal(globalWhales.get('order_by'), 'observed_on');
assert.equal(globalWhales.get('order'), 'desc');
});
test('fresh cache hits and global ten-minute TTL spend no upstream quota', async () => {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
const first = await request();
at += 299_999;
assert.deepEqual((await request()).body, first.body);
at += 1;
await request();
assert.equal(calls, 2);
await request('/observations?scope=global');
at += 599_999;
await request('/observations?scope=global');
assert.equal(calls, 3);
at += 1;
await request('/observations?scope=global');
assert.equal(calls, 4);
});
test('the same snapped view and window hour returns one cached random sample until expiry', async () => {
let at = NOW;
let draws = 0;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async (url) => {
const query = new URL(url).searchParams;
assert.equal(query.get('order_by'), 'random');
assert.equal(query.get('per_page'), '200');
return Response.json(envelope([observation(++draws)]));
},
});
const first = await request();
for (const elapsed of [1, 1000, 60_000, 299_999]) {
at = NOW + elapsed;
const next = await request();
assert.deepEqual(next.body, first.body);
}
assert.equal(draws, 1);
assert.match(cache.keys().next().value, /2026-11-02T18:00:00Z$/);
at = NOW + 300_000;
const refresh = await request();
assert.equal(refresh.body.rows[0].stableId, 'inat:2');
assert.equal(draws, 2);
});
test('disconnected queued views are dropped while a remaining coalesced consumer keeps its request', async () => {
const calls = [];
const releases = [];
const cache = new Map();
const request = install({
cache,
fetchImpl: (url, { signal }) => {
calls.push({ url: new URL(url), signal });
return new Promise((resolve) => releases.push(resolve));
},
});
const first = request(boxUrl(0));
await until(() => releases.length === 1);
const superseded = new AbortController();
const oldView = request(boxUrl(1), { signal: superseded.signal });
const oneConsumer = new AbortController();
const one = request(boxUrl(2), { signal: oneConsumer.signal });
const other = request(boxUrl(2));
superseded.abort();
oneConsumer.abort();
await Promise.all([oldView, one]);
// Rejoining the abandoned key creates a healthy generation; the old
// coalescing promise must not later delete or cancel this replacement.
const replacement = request(boxUrl(1));
releases[0](Response.json(envelope()));
await until(() => releases.length === 2);
assert.equal(calls[1].url.searchParams.get('swlng'), '-122.1');
assert.equal(calls[1].signal.aborted, false);
releases[1](Response.json(envelope([observation(2)])));
await until(() => releases.length === 3);
assert.equal(calls[2].url.searchParams.get('swlng'), '-122.3');
releases[2](Response.json(envelope([observation(3)])));
const results = await Promise.all([first, other, replacement]);
assert.ok(results.every((result) => result.status === 200));
assert.equal(
calls.length,
3,
'the abandoned queued generation never dispatched',
);
assert.equal(cache.size, 3);
assert.equal(results[1].body.rows[0].stableId, 'inat:2');
assert.equal(results[2].body.rows[0].stableId, 'inat:3');
});
test('observation windows reject old sightings uploaded today and keep recent observations', async () => {
for (const [taxa, recentTime, expectedD1] of [
['all', '2026-11-04T17:00:00Z', '2026-11-02'],
['whales', '2026-10-30T17:00:00Z', '2026-10-28'],
]) {
let upstreamQuery;
const request = install({
fetchImpl: async (url) => {
upstreamQuery = new URL(url).searchParams;
return Response.json(
envelope([
{
...observation(80),
time_observed_at: '2010-06-01T12:00:00Z',
observed_on: '2010-06-01',
},
{
...observation(81),
time_observed_at: recentTime,
observed_on: recentTime.slice(0, 10),
},
]),
);
},
});
const result = await request(boxUrl(0, `&taxa=${taxa}`));
assert.equal(result.status, 200, taxa);
assert.equal(upstreamQuery.get('d1'), expectedD1);
assert.equal(upstreamQuery.has('created_d1'), false);
assert.equal(upstreamQuery.get('order_by'), 'random');
assert.deepEqual(
result.body.rows.map((row) => row.stableId),
['inat:81'],
);
}
});
test('same-key requests single-flight and a seventeenth distinct in-flight key queues', async () => {
const releases = [];
const request = install({
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
});
const a = request();
const b = request();
await until(() => releases.length === 1);
releases.shift()(Response.json(envelope()));
assert.deepEqual((await a).body, (await b).body);
const pending = Array.from({ length: 16 }, (_, i) => request(boxUrl(i + 1)));
await until(() => releases.length === 1);
pending.push(request(boxUrl(18)));
for (let i = 0; i < 17; i += 1) {
await until(() => releases.length > i);
releases[i](Response.json(envelope()));
}
assert.ok((await Promise.all(pending)).every(({ status }) => status === 200));
});
test('a fresh global sample survives the hourly observation-window rollover without another fetch', async () => {
let at = Date.parse('2026-11-04T18:59:00Z');
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
const first = await request('/observations?scope=global');
at += 60_000;
const nextHour = await request('/observations?scope=global');
assert.deepEqual(nextHour.body, first.body);
assert.equal(nextHour.body.stale, undefined);
assert.equal(calls, 1);
at += 9 * 60_000;
await request('/observations?scope=global');
assert.equal(calls, 2);
});
test('pending global refresh coalesces across the hourly observation-window rollover with a busy queue', async () => {
let at = Date.parse('2026-11-04T18:59:59Z');
const releases = [];
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: (url) =>
new Promise((resolve) =>
releases.push({
resolve,
url: new URL(url),
}),
),
});
const first = request(
'/observations?scope=global&quality=research&taxa=Aves',
);
const views = Array.from({ length: 15 }, (_, i) => request(boxUrl(i)));
await until(() => releases.length === 1);
assert.equal(releases[0].url.searchParams.get('d1'), '2026-11-02');
at = Date.parse('2026-11-04T19:00:01Z');
const nextHour = request(
'/observations?scope=global&quality=research&taxa=Aves',
);
await new Promise(setImmediate);
assert.equal(
releases.length,
1,
'the same global query joins the pending refresh',
);
const distinct = request(
'/observations?scope=global&quality=research&taxa=Mammalia',
);
for (let i = 0; i < 17; i += 1) {
await until(() => releases.length > i);
releases[i].resolve(Response.json(envelope()));
}
const [original, joined] = await Promise.all([first, nextHour]);
assert.equal(original.status, 200);
assert.equal((await distinct).status, 200);
assert.deepEqual(joined.body, original.body);
assert.ok((await Promise.all(views)).every(({ status }) => status === 200));
const globalEntries = [...cache.entries()].filter(
([, entry]) => entry.value.scope === 'global',
);
assert.equal(globalEntries.length, 2);
assert.match(globalEntries[0][0], /2026-11-02T18:00:00Z$/);
assert.equal(
(await request('/observations?scope=global&quality=research&taxa=Aves'))
.status,
200,
);
assert.equal(
releases.length,
17,
'the accepted global answer keeps its ten-minute freshness',
);
});
test('view refreshes retain distinct hourly keys while a previous-hour request is pending', async () => {
let at = Date.parse('2026-11-04T18:59:59Z');
const releases = [];
const request = install({
now: () => at,
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
});
const first = request();
await until(() => releases.length === 1);
at = Date.parse('2026-11-04T19:00:01Z');
const nextHour = request();
await new Promise(setImmediate);
assert.equal(
releases.length,
1,
'the next view queues behind the current call',
);
releases[0](Response.json(envelope()));
await until(() => releases.length === 2);
releases[1](Response.json(envelope()));
assert.ok(
(await Promise.all([first, nextHour])).every(
({ status }) => status === 200,
),
);
});
test('upstream minute ceiling waits for request 61 across client addresses', async () => {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
queueOptions: {
now: () => at,
intervalMs: 0,
wait: async (ms) => {
at += ms;
},
},
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
for (let i = 0; i < 60; i += 1)
assert.equal(
(await request(boxUrl(i), { peer: `10.0.0.${i}` })).status,
200,
);
const queued = await request(boxUrl(60), { peer: '10.0.1.1' });
assert.equal(queued.status, 200);
assert.equal(queued.headers['Retry-After'], undefined);
assert.equal(at, NOW + 60_000);
assert.equal(calls, 61);
});
test('views refresh after many prior requests without fabricating an upstream limit', async () => {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
});
const first = await request();
at += 300_001;
for (let i = 1; i <= 60; i += 1)
await request(boxUrl(i), { peer: `10.0.0.${i}` });
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, undefined);
assert.notEqual(stale.body.fetchedAt, first.body.fetchedAt);
assert.equal(calls, 62);
});
test('bounded samples preserve partial metadata, and well-formed empty views are valid', async () => {
let at = NOW;
let fail = false;
const request = install({
now: () => at,
fetchImpl: async () =>
fail
? new Response('unavailable', { status: 503 })
: Response.json(envelope([observation()], 500)),
});
const first = await request();
assert.equal(first.body.partial, true);
assert.equal(first.body.rawRowCount, 500);
fail = true;
at += 59 * 60_000;
const stale = await request();
assert.equal(
stale.status,
200,
'hourly window changes still permit stale fallback',
);
assert.equal(stale.headers['X-Data-Stale'], 'true');
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
assert.equal(stale.body.partial, true);
at += 2 * 60_000;
const expired = await request();
assert.equal(expired.status, 502);
assert.equal(expired.body.error, 'inaturalist_unavailable');
const empty = install({
fetchImpl: async () => Response.json(envelope([], 0)),
});
const result = await empty();
assert.equal(result.status, 200);
assert.deepEqual(result.body.rows, []);
});
test('bad envelopes, JSON and capped bodies fail closed without inventing a cooldown', async () => {
for (const bad of [
() => Response.json({ total_results: 1, results: {} }),
() => Response.json({ total_results: 1, observations: [observation()] }),
() => Response.json({ total_results: '1', results: [observation()] }),
() => Response.json(envelope([], 1)),
() => Response.json(envelope([{ ...observation(), id: null }], 1)),
() => new Response('{malformed'),
() =>
new Response('{}', {
headers: { 'Content-Length': String(8 * 1024 * 1024 + 1) },
}),
]) {
let at = NOW;
let calls = 0;
const request = install({
now: () => at,
fetchImpl: async () => {
calls += 1;
return calls === 1 ? bad() : Response.json(envelope());
},
});
const first = await request();
assert.equal(first.status, 502);
assert.equal(first.body.error, 'inaturalist_unavailable');
assert.equal(first.headers['Retry-After'], undefined);
assert.equal((await request(boxUrl(1))).status, 200);
assert.equal(calls, 2);
}
});
test('a wholly invalid iNat page cannot replace a previously valid snapshot', async () => {
let at = NOW;
let fail = false;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () =>
Response.json(
fail
? envelope([{ ...observation(), quality_grade: 'casual' }])
: envelope(),
),
});
const first = await request();
const original = cache.values().next().value;
fail = true;
at += 300_001;
const stale = await request();
assert.equal(stale.status, 200);
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, first.body.fetchedAt);
assert.equal(cache.values().next().value, original);
});
test('429 cooldown honors seconds, HTTP dates, and long upstream deadlines', async () => {
assert.equal(inatRetryCooldownMs(null, NOW), 60_000);
assert.equal(inatRetryCooldownMs('0', NOW), 0);
assert.equal(inatRetryCooldownMs('2', NOW), 2000);
assert.equal(inatRetryCooldownMs('999999', NOW), 999_999_000);
assert.equal(inatRetryCooldownMs('7200', NOW), 7_200_000);
assert.equal(
inatRetryCooldownMs(new Date(NOW + 7_200_000).toUTCString(), NOW),
7_200_000,
);
let at = NOW;
const releases = [];
const warnings = [];
const request = install({
now: () => at,
warn: (message) => warnings.push(message),
fetchImpl: () => new Promise((resolve) => releases.push(resolve)),
});
const first = request();
const later = request(boxUrl(1));
await until(() => releases.length === 1);
releases[0](
new Response('limit', {
status: 429,
headers: { 'Retry-After': '999999' },
}),
);
assert.equal((await first).headers['Retry-After'], '999999');
assert.equal((await later).headers['Retry-After'], '999999');
at += 1000;
assert.equal((await request(boxUrl(1))).headers['Retry-After'], '999998');
at += 5000;
const cooling = await request(boxUrl(2));
assert.equal(cooling.status, 429);
assert.equal(cooling.headers['Retry-After'], '999993');
assert.equal(
releases.length,
1,
'queued calls do not dispatch during cooldown',
);
assert.equal(
warnings.filter((message) => message.includes('capped')).length,
0,
);
});
for (const [upstreamStatus, expectedStatus, code, retryAfterS] of [
[429, 429, 'inaturalist_rate_limited', 120],
[503, 502, 'inaturalist_unavailable', 120],
]) {
test(`concurrent reservations honor an immediate upstream ${upstreamStatus} before dispatch`, async () => {
let calls = 0;
const warnings = [];
const client = createInatClient({
now: () => 0,
cacheDir: null,
queueOptions: { intervalMs: 0 },
warn: (message) => warnings.push(message),
fetchImpl: async () => {
calls += 1;
return new Response('synthetic failure', {
status: upstreamStatus,
headers: { 'Retry-After': '120' },
});
},
});
const params = {
scope: 'global',
quality: 'all',
taxa: 'all',
observedD1: '1969-12-30',
};
const results = await Promise.allSettled(
Array.from({ length: 3 }, () => client.getObservations(params)),
);
assert.equal(
calls,
1,
'pending reservations must not dispatch during cooldown',
);
for (const result of results) {
assert.equal(result.status, 'rejected');
assert.equal(result.reason.status, expectedStatus);
assert.equal(result.reason.code, code);
assert.equal(result.reason.retryAfterS, retryAfterS);
}
assert.equal(
warnings.length,
1,
'only the dispatched refresh logs a failure',
);
await assert.rejects(client.getObservations(params), {
status: expectedStatus,
code,
retryAfterS,
});
assert.equal(calls, 1);
});
}
test('budget is read lazily, persisted as counters only, enforced across restart and UTC rollover', async (t) => {
const parent = await mkdtemp(path.join(os.tmpdir(), 'gev-inat-budget-'));
t.after(() => rm(parent, { recursive: true, force: true }));
const cacheDir = path.join(parent, 'not-created');
const before = process.env.INATURALIST_DAILY_BUDGET;
process.env.INATURALIST_DAILY_BUDGET = '2';
t.after(() =>
before === undefined
? delete process.env.INATURALIST_DAILY_BUDGET
: (process.env.INATURALIST_DAILY_BUDGET = before),
);
let at = NOW;
let calls = 0;
const options = {
now: () => at,
cacheDir,
warn() {},
fetchImpl: async () => {
calls += 1;
return Response.json(envelope());
},
};
const client = createInatClient(options);
assert.deepEqual(
await readdir(parent),
[],
'construction performs no directory write',
);
const params = {
scope: 'global',
quality: 'all',
taxa: 'all',
observedD1: '2026-11-02',
};
await Promise.all([
client.getObservations(params),
client.getObservations(params),
]);
assert.deepEqual(
JSON.parse(await readFile(path.join(cacheDir, 'budget.json'), 'utf8')),
{ date: '2026-11-04', count: 2 },
);
assert.deepEqual(
await readdir(cacheDir),
['budget.json'],
'atomic temporary files are retired',
);
const restarted = createInatClient(options);
await assert.rejects(restarted.getObservations(params), {
status: 503,
code: 'inaturalist_budget_exhausted',
});
assert.equal(calls, 2);
at = Date.parse('2026-11-05T00:00:00Z');
await restarted.getObservations(params);
assert.deepEqual(restarted.budgetState(), {
date: '2026-11-05',
count: 1,
budget: 2,
});
assert.deepEqual(
JSON.parse(await readFile(path.join(cacheDir, 'budget.json'), 'utf8')),
{ date: '2026-11-05', count: 1 },
);
assert.equal(calls, 3);
});
test('memory cache is an LRU bounded to 400 projected entries', async () => {
let at = NOW;
const cache = new Map();
const request = install({
cache,
now: () => at,
fetchImpl: async () => Response.json(envelope()),
});
for (let i = 0; i < 400; i += 1) {
if (i && i % 20 === 0) at += 61_000;
await request(boxUrl(i), { peer: String(i) });
}
assert.equal(cache.size, 400);
const firstKey = cache.keys().next().value;
const secondKey = [...cache.keys()][1];
await request(boxUrl(0));
await request(boxUrl(400));
assert.equal(cache.size, 400);
assert.equal(cache.has(firstKey), true);
assert.equal(cache.has(secondKey), false);
});
test('aborted client calls spend no budget and never fetch', async () => {
const controller = new AbortController();
controller.abort();
const client = createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0 },
fetchImpl: () => assert.fail('aborted call fetched'),
});
await assert.rejects(
client.getObservations({}, { signal: controller.signal }),
{ name: 'AbortError' },
);
assert.equal(client.budgetState().count, 0);
});
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { inaturalistProxy } from './observations.js';
export const NOW = Date.parse('2026-11-04T18:05:00Z');
/** Mount the proxy over synthetic HTTP objects, without opening a socket. */
export function installInatProxy(
defaultUrl,
options = {},
hook = 'configureServer',
) {
let handler;
const plugin = inaturalistProxy({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0, windowMs: 0 },
warn() {},
...options,
});
assert.equal(plugin.name, 'inaturalist');
assert.equal(typeof plugin.configureServer, 'function');
assert.equal(typeof plugin.configurePreviewServer, 'function');
plugin[hook]({
middlewares: {
use(mount, callback) {
assert.equal(mount, '/api/inaturalist');
handler = callback;
},
},
});
return async (
url = defaultUrl,
{ method = 'GET', peer = 'local', headers = {}, signal } = {},
) => {
const res = Object.assign(new EventEmitter(), {
headers: {},
writeHead(status, headers) {
this.status = status;
Object.assign(this.headers, headers);
},
setHeader(key, value) {
this.headers[key] = value;
},
end(value) {
this.status ??= this.statusCode;
this.body = JSON.parse(value);
},
});
const abort = () => {
res.destroyed = true;
res.emit('close');
};
signal?.addEventListener('abort', abort, { once: true });
try {
await handler(
Object.assign(new EventEmitter(), {
url,
method,
headers,
socket: { remoteAddress: peer },
}),
res,
);
} finally {
signal?.removeEventListener('abort', abort);
}
return res;
};
}
+109
View File
@@ -0,0 +1,109 @@
import { setTimeout as delay } from 'node:timers/promises';
// A four-request burst keeps a cold view responsive; thereafter every provider
// call shares the same one-per-second token supply. The rolling minute ceiling
// queues the few requests a token bucket alone could allow above 60/min.
const INAT_INTERVAL_MS = 1000;
const INAT_BURST = 4;
const INAT_WINDOW_MS = 60_000;
const INAT_MAX_PER_WINDOW = 60;
const INAT_MAX_IN_FLIGHT = 4;
/** Observation queries outrank tiles waiting for a token. Aborted consumers
* leave the queue before an attempt or daily-budget reservation is spent. */
export function createInatQueue({
now = () => performance.now(),
wait = (ms) => delay(ms),
intervalMs = INAT_INTERVAL_MS,
burst = INAT_BURST,
windowMs = INAT_WINDOW_MS,
maxInFlight = INAT_MAX_IN_FLIGHT,
} = {}) {
const pending = [];
const dispatchedAt = [];
let tokens = burst;
let refilledAt = null;
let running = false;
let inFlight = 0;
let slotFreed = null;
function refill() {
const at = now();
if (refilledAt !== null && intervalMs > 0)
tokens = Math.min(
burst,
tokens + Math.max(0, at - refilledAt) / intervalMs,
);
if (intervalMs <= 0) tokens = burst;
refilledAt = at;
}
function schedule() {
if (running) return;
running = true;
// A microtask also lets same-turn observation queries take priority.
void Promise.resolve().then(drain);
}
async function drain() {
try {
while (pending.length) {
if (inFlight >= maxInFlight) {
await new Promise((resolve) => (slotFreed = resolve));
continue;
}
refill();
const at = now();
while (dispatchedAt.length && dispatchedAt[0] <= at - windowMs)
dispatchedAt.shift();
const tokenWait = tokens < 1 ? (1 - tokens) * intervalMs : 0;
const windowWait =
windowMs > 0 && dispatchedAt.length >= INAT_MAX_PER_WINDOW
? dispatchedAt[0] + windowMs - at
: 0;
if (tokenWait > 0 || windowWait > 0) {
await wait(Math.ceil(Math.max(tokenWait, windowWait)));
continue;
}
// Stable sorting preserves FIFO order within each priority.
pending.sort((a, b) => a.priority - b.priority);
const job = pending.shift();
job.signal?.removeEventListener('abort', job.abort);
if (job.signal?.aborted) {
job.reject(job.signal.reason);
continue;
}
tokens -= 1;
if (windowMs > 0) dispatchedAt.push(at);
inFlight += 1;
// A rejected task releases its slot without stopping the queue.
void new Promise((resolve) => resolve(job.task()))
.then(job.resolve, job.reject)
.finally(() => {
inFlight -= 1;
const wake = slotFreed;
slotFreed = null;
wake?.();
});
}
} finally {
running = false;
}
}
return function enqueue(task, { signal, priority = 1 } = {}) {
if (signal?.aborted) return Promise.reject(signal.reason);
return new Promise((resolve, reject) => {
const job = { task, signal, priority, resolve, reject };
job.abort = () => {
const index = pending.indexOf(job);
if (index >= 0) pending.splice(index, 1);
signal.removeEventListener('abort', job.abort);
reject(signal.reason);
};
signal?.addEventListener('abort', job.abort, { once: true });
pending.push(job);
schedule();
});
};
}
+111
View File
@@ -0,0 +1,111 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInatQueue } from './queue.js';
async function until(check) {
for (let i = 0; i < 100; i += 1) {
if (check()) return;
await new Promise(setImmediate);
}
assert.fail('queue did not reach its expected state');
}
test('shared governor paces a four-call burst within the rolling minute ceiling', async () => {
let at = 0;
const waits = [];
const queue = createInatQueue({
now: () => at,
wait: async (ms) => {
waits.push(ms);
at += ms;
},
});
const dispatched = [];
await Promise.all(
Array.from({ length: 65 }, () => queue(() => dispatched.push(at))),
);
assert.equal(dispatched.length, 65);
assert.deepEqual(dispatched.slice(0, 7), [0, 0, 0, 0, 1000, 2000, 3000]);
assert.deepEqual(waits.slice(0, 3), [1000, 1000, 1000]);
for (const time of dispatched)
assert.ok(
dispatched.filter((other) => other > time - 60_000 && other <= time)
.length <= 60,
);
assert.equal(dispatched[60], 60_000);
});
test('an observation arriving during the pacing wait overtakes queued tiles', async () => {
let at = 0;
const waits = [];
const queue = createInatQueue({
burst: 1,
now: () => at,
wait: (ms) => new Promise((resolve) => waits.push({ ms, resolve })),
});
const dispatched = [];
await queue(() => dispatched.push('first tile'));
const tile = queue(() => dispatched.push('waiting tile'));
await until(() => waits.length === 1);
const observation = queue(() => dispatched.push('observation'), {
priority: 0,
});
at += waits[0].ms;
waits[0].resolve();
await observation;
assert.deepEqual(dispatched, ['first tile', 'observation']);
await until(() => waits.length === 2);
at += waits[1].ms;
waits[1].resolve();
await tile;
assert.deepEqual(dispatched, ['first tile', 'observation', 'waiting tile']);
});
test('superseded tasks leave before dispatch, and a rejection cannot poison the tail', async () => {
const queue = createInatQueue({ intervalMs: 0 });
const dispatched = [];
let release;
const first = queue(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
await until(() => release);
const controller = new AbortController();
const superseded = queue(() => assert.fail('superseded tile dispatched'), {
signal: controller.signal,
});
const dropped = assert.rejects(superseded, { name: 'AbortError' });
controller.abort();
await dropped;
const failed = queue(() => {
dispatched.push('failed');
throw new Error('synthetic');
});
const failure = assert.rejects(failed, /synthetic/);
const succeeding = queue(() => dispatched.push('recovered'));
release();
await Promise.all([first, failure, succeeding]);
assert.deepEqual(dispatched, ['failed', 'recovered']);
});
test('a slow call does not hold later calls; at most four run at once', async () => {
const queue = createInatQueue({ intervalMs: 0, windowMs: 0 });
const releases = [];
const started = [];
const slow = (id) => () =>
new Promise((resolve) => {
started.push(id);
releases.push(() => resolve(id));
});
const jobs = [1, 2, 3, 4, 5].map((id) => queue(slow(id)));
await until(() => started.length === 4);
assert.deepEqual(started, [1, 2, 3, 4], 'four start without waiting');
releases[1]();
assert.equal(await jobs[1], 2, 'a later call finishes before the first');
await until(() => started.length === 5);
assert.deepEqual(started, [1, 2, 3, 4, 5], 'a freed slot starts the next');
for (const release of releases) release();
assert.deepEqual(await Promise.all(jobs), [1, 2, 3, 4, 5]);
});
+218
View File
@@ -0,0 +1,218 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createInatClient } from './client.js';
import { installInatProxy, NOW } from './proxy.fixture.mjs';
import { syntheticGrid } from './grid.fixture.mjs';
import { INAT_GRID_DECODE_MEASURE } from './grid.js';
const gridUrl = '/grid?z=2&x=1&y=1&taxa=Aves';
const params = {
scope: 'global',
taxa: 'all',
observedD1: '2026-11-02',
};
const tick = () => new Promise(setImmediate);
const install = (options, hook) => installInatProxy(gridUrl, options, hook);
test('tile cache lasts fifteen minutes, then returns stale before revalidation finishes', async () => {
let at = NOW;
let calls = 0;
let release;
const request = install({
now: () => at,
fetchImpl: () => {
calls += 1;
return calls === 1
? Response.json(syntheticGrid())
: new Promise((resolve) => {
release = resolve;
});
},
});
const first = await request();
at += 899_999;
const freshPromise = request();
await tick();
assert.equal(calls, 1, 'a fresh tile never spends another upstream attempt');
assert.deepEqual((await freshPromise).body, first.body);
at += 1;
const stalePromise = request();
let resolved = false;
void stalePromise.then(() => {
resolved = true;
});
await tick();
try {
assert.equal(
resolved,
true,
'stale cells publish while refresh is pending',
);
const stale = await stalePromise;
assert.equal(stale.body.stale, true);
assert.equal(stale.body.fetchedAt, NOW);
assert.deepEqual(stale.body.cells, first.body.cells);
await request();
assert.equal(calls, 2, 'all stale consumers share one background refresh');
const refreshPromise = request(`${gridUrl}&refresh=wait`);
let refreshed = false;
void refreshPromise.then(() => {
refreshed = true;
});
await tick();
assert.equal(
refreshed,
false,
'a background consumer can await the same refresh',
);
release(Response.json(syntheticGrid()));
const fresh = await refreshPromise;
assert.equal(fresh.body.stale, undefined);
assert.equal(fresh.body.fetchedAt, at);
assert.equal(
calls,
2,
'waiting for fresh cells never duplicates upstream work',
);
release = null;
} finally {
release?.(Response.json(syntheticGrid()));
}
});
test('more than sixteen queued views and thirty local requests never report local rate limiting', async () => {
let release;
let calls = 0;
const request = install({
fetchImpl: async () => {
calls += 1;
if (calls === 1)
await new Promise((resolve) => {
release = resolve;
});
return Response.json(syntheticGrid());
},
});
const pending = Array.from({ length: 40 }, (_, x) =>
request(`/grid?z=6&x=${x}&y=1`),
);
await tick();
release();
const results = await Promise.all(pending);
assert.ok(results.every((result) => result.status === 200));
assert.equal(calls, 40);
});
test('only real upstream 429 or 5xx failures start cooldown', async () => {
for (const bad of [
() => Promise.reject(new Error('network')),
() => Response.json({ malformed: true }),
() => new Response('', { status: 404 }),
]) {
let calls = 0;
const client = createInatClient({
now: () => NOW,
cacheDir: null,
queueOptions: { intervalMs: 0, windowMs: 0 },
warn() {},
fetchImpl: () => {
calls += 1;
return calls === 1
? bad()
: Response.json({ results: [], total_results: 0 });
},
});
await assert.rejects(client.getObservations(params), { status: 502 });
await client.getObservations(params);
assert.equal(
calls,
2,
'a non-cooldown failure must not hold the next attempt',
);
}
});
test('actual upstream cooldown is marked separately from a local budget stop', async () => {
for (const upstream of [429, 503]) {
let calls = 0;
const request = install({
fetchImpl: () => {
calls += 1;
return new Response('', {
status: upstream,
headers: { 'Retry-After': '120' },
});
},
});
const first = await request();
assert.equal(first.headers['X-Upstream-Status'], String(upstream));
assert.equal(first.headers['Retry-After'], '120');
await request();
assert.equal(calls, 1);
}
});
test('upstream Retry-After zero survives the HTTP response and permits an immediate next request', async () => {
for (const upstream of [429, 503]) {
let calls = 0;
const request = install({
fetchImpl: () => {
calls += 1;
return calls === 1
? new Response('', {
status: upstream,
headers: { 'Retry-After': '0' },
})
: Response.json(syntheticGrid());
},
});
const first = await request();
assert.equal(first.headers['X-Upstream-Status'], String(upstream));
assert.equal(first.headers['Retry-After'], '0');
assert.equal((await request()).status, 200);
assert.equal(calls, 2);
}
});
test('a local daily budget stop has a distinct code and no upstream cooldown marker', async (t) => {
const before = process.env.INATURALIST_DAILY_BUDGET;
process.env.INATURALIST_DAILY_BUDGET = '1';
t.after(() => {
if (before === undefined) delete process.env.INATURALIST_DAILY_BUDGET;
else process.env.INATURALIST_DAILY_BUDGET = before;
});
let calls = 0;
const request = install({
fetchImpl: () => {
calls += 1;
return Response.json(syntheticGrid());
},
});
assert.equal((await request()).status, 200);
const stopped = await request('/grid?z=2&x=0&y=1');
assert.equal(stopped.status, 503);
assert.equal(stopped.body.error, 'inaturalist_budget_exhausted');
assert.equal(stopped.headers['X-Upstream-Status'], undefined);
assert.equal((await request()).status, 200, 'cached data remains available');
assert.equal(calls, 1);
});
test('cached tiles avoid UTFGrid decode and decode timing entries stay bounded', async () => {
const request = install({
fetchImpl: () => Response.json(syntheticGrid()),
});
await request();
const sample = performance.getEntriesByName(INAT_GRID_DECODE_MEASURE)[0];
assert.ok(sample.duration >= 0);
for (let i = 0; i < 10; i += 1) await request();
assert.equal(
performance.getEntriesByName(INAT_GRID_DECODE_MEASURE)[0],
sample,
);
for (let x = 0; x < 4; x += 1) await request(`/grid?z=3&x=${x}&y=1`);
assert.equal(
performance.getEntriesByName(INAT_GRID_DECODE_MEASURE).length,
1,
);
});
+4
View File
@@ -29,6 +29,8 @@ import { firePerimetersProxy } from './firePerimeters.js';
import { gnssIntegrityProxy } from './gnssIntegrity.js'; import { gnssIntegrityProxy } from './gnssIntegrity.js';
import { ghostMapsProxy } from './ghostMaps.js'; import { ghostMapsProxy } from './ghostMaps.js';
import { droneAirspaceProxy } from './droneAirspace.js'; import { droneAirspaceProxy } from './droneAirspace.js';
import { inaturalistProxy } from './inaturalist/observations.js';
import { acartiaProxy } from './acartia.js';
import { cycloneProxy } from './cyclones.js'; import { cycloneProxy } from './cyclones.js';
import { windProxy } from './wind.js'; import { windProxy } from './wind.js';
@@ -81,6 +83,8 @@ function localProviderPlugins({
gnssIntegrityProxy(), gnssIntegrityProxy(),
ghostMapsProxy(), ghostMapsProxy(),
droneAirspaceProxy(), droneAirspaceProxy(),
inaturalistProxy(),
acartiaProxy(),
keySetupEndpoint(), keySetupEndpoint(),
]; ];
} }
+6 -2
View File
@@ -135,7 +135,7 @@ export const ACTION_DESCRIPTIONS = {
}, },
control_cockpit: { control_cockpit: {
description: description:
'Explicit Cockpit requests only: enter from a selected/tracked aircraft (establishes Contacts itself; no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.', 'Explicit Cockpit requests only: enter from a selected/tracked aircraft (needs only its own Flights/Military layer on, not Contacts; opens Contacts itself, no set_context_mode), exit, status, or next/previous. Explicit exit-and-show voice: voicePanel preview/settings (Voice Settings), no confirmation.',
$position: 1, $position: 1,
parameters: { parameters: {
properties: { properties: {
@@ -523,7 +523,7 @@ export const ACTION_DESCRIPTIONS = {
properties: { properties: {
direction: { direction: {
description: description:
'Required for pan/tilt/rotate; orbit/rotate_globe default right.', 'Pan/rotate/tilt steps; orbit/rotate_globe default right.',
$position: 2, $position: 2,
}, },
mode: { mode: {
@@ -531,6 +531,10 @@ export const ACTION_DESCRIPTIONS = {
'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.', 'once = bounded eased nudge; continuous = until stop/manual input. rotate_globe defaults continuous; other motions default once.',
$position: 2, $position: 2,
}, },
pitchDeg: {
description: 'Absolute; -45 default, -89 top-down.',
$position: 1,
},
}, },
}, },
}, },
+663 -1
View File
@@ -2,7 +2,20 @@ import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { createApplication } from './application.js'; import { createApplication } from './application.js';
import { installTrackpadPinchZoom } from './viewer.js'; import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
import * as Cesium from 'cesium';
import {
installFreeLookHeightLimit,
installGlobeInViewRecovery,
installTrackpadPinchZoom,
} from './viewer.js';
import {
FREE_LOOK_MAX_HEIGHT_METERS,
globeFrameShare,
globeOutOfFrame,
lookAngleWithinFrame,
lookStepLosesGlobe,
} from '../globeInView.js';
const phases = ['Scene', 'Controls', 'Data', 'Tools']; const phases = ['Scene', 'Controls', 'Data', 'Tools'];
function fixture(overrides = {}) { function fixture(overrides = {}) {
@@ -343,3 +356,652 @@ test('trackpad pinch falls back to the original event when relay construction fa
assert.equal(event.propagationStopped, false); assert.equal(event.propagationStopped, false);
assert.equal(f.relayed.length, 0); assert.equal(f.relayed.length, 0);
}); });
function freeLookFixture({ enableLook = true, height = 0 } = {}) {
const listeners = new Set();
const controller = { enableLook, minimumTrackBallHeight: 7_500_000 };
const camera = { positionCartographic: { height } };
const viewer = {
camera,
scene: {
screenSpaceCameraController: controller,
preUpdate: {
addEventListener(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
},
};
const frame = (nextHeight) => {
camera.positionCartographic = { height: nextHeight };
for (const listener of listeners) listener();
};
return { viewer, controller, listeners, frame };
}
test('free look follows camera height and is restored on dispose', () => {
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
assert.equal(f.controller.enableLook, true);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS + 1);
assert.equal(f.controller.enableLook, false);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS);
assert.equal(f.controller.enableLook, true);
f.frame(Number.NaN);
assert.equal(f.controller.enableLook, true);
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
dispose();
dispose();
assert.equal(f.controller.enableLook, true);
assert.equal(f.listeners.size, 0);
});
test('drags that start on the stars orbit above the free-look height', () => {
// Below Cesium's trackball height such a drag turns the camera in place.
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
assert.equal(
f.controller.minimumTrackBallHeight,
FREE_LOOK_MAX_HEIGHT_METERS,
);
dispose();
assert.equal(f.controller.minimumTrackBallHeight, 7_500_000);
const lower = freeLookFixture({ height: 500 });
lower.controller.minimumTrackBallHeight = 500_000;
const disposeLower = installFreeLookHeightLimit(lower.viewer);
assert.equal(
lower.controller.minimumTrackBallHeight,
500_000,
'never raised',
);
disposeLower();
assert.equal(lower.controller.minimumTrackBallHeight, 500_000);
const later = freeLookFixture({ height: 500 });
const disposeLater = installFreeLookHeightLimit(later.viewer);
later.controller.minimumTrackBallHeight = 2_000_000;
disposeLater();
assert.equal(
later.controller.minimumTrackBallHeight,
2_000_000,
'dispose keeps a value set after install',
);
});
test('free look keeps a setting other code makes after it is installed', () => {
const f = freeLookFixture({ height: 500 });
const dispose = installFreeLookHeightLimit(f.viewer);
f.controller.enableLook = false;
f.frame(10);
assert.equal(f.controller.enableLook, false, 'turned off stays off');
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
f.frame(10);
assert.equal(f.controller.enableLook, false, 'and stays off after orbit');
f.controller.enableLook = true;
f.frame(10);
assert.equal(f.controller.enableLook, true, 'turned back on');
f.frame(FREE_LOOK_MAX_HEIGHT_METERS * 20);
assert.equal(f.controller.enableLook, false, 'still limited from orbit');
f.frame(10);
f.controller.enableLook = false;
dispose();
assert.equal(f.controller.enableLook, false, 'dispose keeps a later setting');
});
test('free look stays off when the viewer had it disabled', () => {
const f = freeLookFixture({ enableLook: false, height: 500 });
installFreeLookHeightLimit(f.viewer);
f.frame(10);
assert.equal(f.controller.enableLook, false);
});
// A real Cesium camera on the smallest scene its constructor reads, so the
// frustum test runs on Cesium's own view math rather than a stub.
function realCamera({ width = 1600, height = 900, mode } = {}) {
const scene = {
canvas: { clientWidth: width, clientHeight: height },
drawingBufferWidth: width,
drawingBufferHeight: height,
mapProjection: new Cesium.GeographicProjection(),
mode: mode ?? Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
ellipsoid: Cesium.Ellipsoid.WGS84,
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
tweens: new Cesium.TweenCollection(),
renders: 0,
requestRender() {
this.renders += 1;
},
};
const camera = new Cesium.Camera(scene);
camera.frustum.aspectRatio = width / height;
return { camera, scene, viewer: { camera, scene } };
}
function aim(
camera,
{ lon = -94.69, lat = 18.87, alt, heading = 0, pitch, roll = 0 },
) {
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(lon, lat, alt),
orientation: {
heading: Cesium.Math.toRadians(heading),
pitch: Cesium.Math.toRadians(pitch),
roll: Cesium.Math.toRadians(roll),
},
});
}
const reportedStuckView = {
alt: 17_996_811,
heading: 180,
pitch: -42,
roll: 180,
};
// Oblique poses that still show part of the planet: Director recipe shots
// (Global Flights Radar, Omniscience Pullback) and a voice tilt from orbit.
const partialGlobeViews = [
{ lon: -30, lat: 20, alt: 19_000_000, pitch: -65 },
{ lon: 2, lat: 46, alt: 8_000_000, pitch: -52 },
{ lon: 110, lat: 20, alt: 9_500_000, pitch: -66 },
{ lon: 0, lat: 0, alt: 2_000_000, pitch: -30 },
];
// Stars and a few pixels of limb, no surface: where a frame limit measured
// against a sphere of the planet's largest radius used to stop a voice tilt.
const limbOnlyView = { alt: 18_000_000, pitch: -56.75 };
/**
* An oracle independent of the code under test: the share of an 80×45 grid of
* Cesium pick rays across the canvas that hit the ellipsoid surface.
*/
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
let hits = 0;
let counted = 0;
for (let row = 0; row < 45; row += 1) {
for (let column = 0; column < 80; column += 1) {
const x = ((column + 0.5) * width) / 80;
const y = ((row + 0.5) * height) / 45;
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
continue;
counted += 1;
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
hits += 1;
}
}
return hits / counted;
}
test('the globe is out of frame only when no surface is on screen', () => {
const cases = [
[reportedStuckView, 1225, 923, true],
[limbOnlyView, 1600, 900, true],
[{ alt: 17_996_811, pitch: -90 }, 1225, 923, false],
...partialGlobeViews.map((view) => [view, 1600, 900, false]),
];
for (const [view, width, height, out] of cases) {
const { camera } = realCamera({ width, height });
aim(camera, view);
const label = JSON.stringify(view);
assert.equal(globeOutOfFrame(camera), out, label);
assert.equal(surfacePixelShare(camera, width, height) === 0, out, label);
assert.ok(
Math.abs(
globeFrameShare(camera) - surfacePixelShare(camera, width, height),
) < 0.03,
`${label}: the frame share matches the pick-ray oracle`,
);
}
});
test('a move that ends with the globe out of frame turns back to nadir', () => {
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const dispose = installGlobeInViewRecovery(viewer);
aim(camera, reportedStuckView);
const position = Cesium.Cartesian3.clone(camera.positionWC);
const heading = camera.heading;
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
assert.ok(Cesium.Math.equalsEpsilon(camera.heading, heading, 1e-6));
assert.ok(
Cesium.Cartesian3.equalsEpsilon(camera.positionWC, position, 0, 1e-3),
);
assert.equal(globeOutOfFrame(camera), false);
assert.equal(scene.renders, 1);
// Looking up near the ground, then zoomed out past the limit.
aim(camera, { alt: 3_834_000, pitch: 10 });
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
// Only the limb on a 16:9 screen still counts as lost.
const wide = realCamera({ width: 1600, height: 900 });
const disposeWide = installGlobeInViewRecovery(wide.viewer);
aim(wide.camera, limbOnlyView);
wide.camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(wide.camera.pitch, -Math.PI / 2, 1e-6));
disposeWide();
dispose();
dispose();
aim(camera, reportedStuckView);
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'disposed recovery is inert');
});
// True when a moveEnd leaves the camera's view direction as it was.
function moveEndKeepsView(camera) {
const direction = Cesium.Cartesian3.clone(camera.directionWC);
camera.moveEnd.raiseEvent();
return Cesium.Cartesian3.equalsEpsilon(camera.directionWC, direction, 1e-9);
}
test('globe recovery leaves partial, low, locked and 2D views alone', () => {
for (const view of partialGlobeViews) {
const { camera, viewer } = realCamera();
installGlobeInViewRecovery(viewer);
aim(camera, view);
assert.ok(moveEndKeepsView(camera), JSON.stringify(view));
}
const low = realCamera();
installGlobeInViewRecovery(low.viewer);
aim(low.camera, { alt: 900_000, pitch: 85 });
assert.equal(globeOutOfFrame(low.camera), true);
assert.ok(moveEndKeepsView(low.camera), 'below the limit');
// Locked on a point in space, between it and the planet, facing away.
const locked = realCamera();
installGlobeInViewRecovery(locked.viewer);
locked.camera.lookAtTransform(
Cesium.Matrix4.fromTranslation(new Cesium.Cartesian3(30_000_000, 0, 0)),
new Cesium.Cartesian3(-1_000_000, 0, 0),
);
assert.equal(globeOutOfFrame(locked.camera), true);
assert.ok(moveEndKeepsView(locked.camera), 'locked camera');
// Tracking an entity that has no position yet keeps an identity transform.
const tracked = realCamera();
tracked.viewer.trackedEntity = { id: 'pending-fix' };
installGlobeInViewRecovery(tracked.viewer);
aim(tracked.camera, reportedStuckView);
assert.ok(moveEndKeepsView(tracked.camera), 'tracked camera');
const flat = realCamera({ mode: Cesium.SceneMode.COLUMBUS_VIEW });
installGlobeInViewRecovery(flat.viewer);
flat.scene.mode = Cesium.SceneMode.SCENE3D;
aim(flat.camera, reportedStuckView);
flat.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
assert.ok(moveEndKeepsView(flat.camera), 'Columbus view');
});
test('the look limit from orbit tolerates grid noise and never reaches zero', () => {
const radians = Cesium.Math.toRadians;
// About 2% of a 16:9 frame on the planet, below the 10% limit.
const sliver = { alt: 18_000_000, pitch: -60 };
const { camera } = realCamera();
aim(camera, sliver);
const start = surfacePixelShare(camera, 1600, 900);
assert.ok(start > 0 && start < 0.1, `starts below the limit: ${start}`);
// Sideways across the sliver: the share wobbles by a cell, the planet stays.
assert.equal(lookStepLosesGlobe(camera, 'lookLeft', radians(0.5)), false);
const startShare = globeFrameShare(camera);
for (let i = 0; i < 20; i += 1) {
const step = lookAngleWithinFrame(camera, 'lookLeft', radians(0.25), {
startShare,
});
assert.equal(step, radians(0.25), `sideways step ${i + 1}`);
camera.lookLeft(step);
}
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
// Toward the planet is always allowed.
aim(camera, sliver);
assert.equal(
lookAngleWithinFrame(camera, 'lookDown', radians(10)),
radians(10),
);
// Away from it stops short of losing it.
aim(camera, sliver);
const away = lookAngleWithinFrame(camera, 'lookUp', radians(30));
assert.ok(away < radians(30));
camera.lookUp(away);
assert.ok(globeFrameShare(camera) > 0);
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('the look limit leaves a view with no surface to the move-end recovery', () => {
const { camera } = realCamera({ width: 1225, height: 923 });
aim(camera, reportedStuckView);
assert.equal(
lookAngleWithinFrame(camera, 'lookUp', Cesium.Math.toRadians(5)),
Cesium.Math.toRadians(5),
);
});
// Surface only in the corners of a 16:9 frame, all of it outside the scope's
// keyhole: the full frame shows some planet, the scope shows none.
const cornerOnlyView = { alt: 18_000_000, pitch: -52, roll: 60 };
test('with a scope keyhole, surface hidden in the masked corners counts as none', () => {
const { camera } = realCamera();
aim(camera, cornerOnlyView);
const keyhole = { keyholeRadius: KEYHOLE_OUTER_RADIUS };
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
assert.equal(surfacePixelShare(camera, 1600, 900, { keyhole: true }), 0);
assert.equal(globeOutOfFrame(camera), false);
assert.equal(globeOutOfFrame(camera, keyhole), true);
aim(camera, { alt: 18_000_000, pitch: -90 });
assert.ok(
Math.abs(
globeFrameShare(camera, keyhole) -
surfacePixelShare(camera, 1600, 900, { keyhole: true }),
) < 0.03,
'the keyhole share matches the pick-ray oracle',
);
});
test('the recovery follows the scope: corner-only views are turned back only behind it', () => {
for (const [scopeOn, expectRecovery] of [
[true, true],
[false, false],
]) {
const { camera, viewer } = realCamera();
installGlobeInViewRecovery(viewer, {
keyholeRadius: () => (scopeOn ? KEYHOLE_OUTER_RADIUS : 0),
});
aim(camera, cornerOnlyView);
camera.moveEnd.raiseEvent();
assert.equal(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
expectRecovery,
`scope ${scopeOn ? 'on' : 'off'}`,
);
}
});
test('the recovery leaves a camera flight and an authored scene alone', () => {
let playing = true;
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
scene.camera = camera;
scene.screenSpaceCameraController = { enableInputs: true };
installGlobeInViewRecovery(viewer, { paused: () => playing });
aim(camera, reportedStuckView);
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'a playing scene is left alone');
playing = false;
// A flight that pauses looking away is left alone until it ends.
camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(10, 20, 18_000_000),
duration: 5,
});
camera.moveEnd.raiseEvent();
assert.equal(globeOutOfFrame(camera), true, 'a flight is left alone');
camera.cancelFlight();
camera.moveEnd.raiseEvent();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
});
test('a check a scene or flight defers runs once it ends', () => {
for (const viaMoveEnd of [false, true]) {
let playing = true;
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, {
paused: () => playing,
...timers,
});
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
// A hold on a stars-only shot: quiet, with or without Cesium's moveEnd.
for (let i = 0; i < 4; i += 1) timers.tick();
if (viaMoveEnd) camera.moveEnd.raiseEvent();
// Longer than the settle check's five-minute bound.
for (let i = 0; i < 1500; i += 1) timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'left alone while it plays');
assert.equal(timers.pending(), 1, 'still watching');
playing = false;
timers.tick();
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
`recovered once the scene ends (${viaMoveEnd ? 'after moveEnd' : 'no moveEnd'})`,
);
}
});
// Holding a drag still for half a second on a view that already showed no
// surface looked like a settled camera, and the view was turned down under
// the user's hand.
test('the recovery waits while a pointer is held on the globe', () => {
const timers = fakeTimers();
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const document = new EventTarget();
scene.canvas = Object.assign(new EventTarget(), {
clientWidth: 1225,
clientHeight: 923,
ownerDocument: document,
});
const dispose = installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
scene.canvas.dispatchEvent(new Event('pointerdown'));
camera.moveStart.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
camera.moveEnd.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'left alone while held');
document.dispatchEvent(new Event('pointerup'));
for (let i = 0; i < 3; i += 1) timers.tick();
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
'recovered once the pointer is released',
);
dispose();
aim(camera, reportedStuckView);
scene.canvas.dispatchEvent(new Event('pointerdown'));
document.dispatchEvent(new Event('pointerup'));
assert.equal(timers.pending(), 0, 'disposed recovery holds no listeners');
});
/** A held-pointer recovery over a canvas in a document in a window. */
function heldPointerRecovery() {
const timers = fakeTimers();
const { camera, scene, viewer } = realCamera({ width: 1225, height: 923 });
const window = new EventTarget();
const document = Object.assign(new EventTarget(), {
defaultView: window,
visibilityState: 'visible',
});
scene.canvas = Object.assign(new EventTarget(), {
clientWidth: 1225,
clientHeight: 923,
ownerDocument: document,
});
const dispose = installGlobeInViewRecovery(viewer, timers);
const pointer = (target, type, pointerId, init = {}) =>
target.dispatchEvent(
Object.assign(new Event(type), { pointerId, ...init }),
);
/** Press, move and hold still: the check defers while a pointer is held. */
const holdStill = () => {
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
camera.moveEnd.raiseEvent();
for (let i = 0; i < 8; i += 1) timers.tick();
};
const settle = () => {
for (let i = 0; i < 3; i += 1) timers.tick();
};
const recovered = () =>
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6);
return {
camera,
document,
window,
dispose,
pointer,
holdStill,
settle,
recovered,
canvas: scene.canvas,
};
}
// Releasing one of two fingers cleared the held flag, and the recovery
// turned the view down under the finger still on the glass.
test('the recovery waits until every held pointer is released', () => {
const h = heldPointerRecovery();
h.pointer(h.canvas, 'pointerdown', 1);
h.pointer(h.canvas, 'pointerdown', 2);
h.holdStill();
h.pointer(h.document, 'pointerup', 1);
h.settle();
assert.equal(h.recovered(), false, 'left alone under the second finger');
assert.equal(globeOutOfFrame(h.camera), true);
h.pointer(h.document, 'pointercancel', 2);
h.settle();
assert.equal(h.recovered(), true, 'recovered once both are released');
h.dispose();
});
// A release the page never sees (the pointer let go outside the window)
// must not hold the recovery off for good.
for (const [label, lose] of [
['the window loses focus', (h) => h.window.dispatchEvent(new Event('blur'))],
[
'the page is hidden',
(h) => {
h.document.visibilityState = 'hidden';
h.document.dispatchEvent(new Event('visibilitychange'));
},
],
[
'the canvas loses the pointer capture',
(h) => h.pointer(h.canvas, 'lostpointercapture', 1),
],
[
'the pointer moves with no button down',
(h) => h.pointer(h.document, 'pointermove', 1, { buttons: 0 }),
],
]) {
test(`a held pointer whose release is lost stops deferring when ${label}`, () => {
const h = heldPointerRecovery();
h.pointer(h.canvas, 'pointerdown', 1);
h.holdStill();
h.pointer(h.document, 'pointermove', 1, { buttons: 1 });
h.settle();
assert.equal(h.recovered(), false, 'left alone while held');
lose(h);
h.settle();
assert.equal(h.recovered(), true, 'recovered once the hold is let go');
h.dispose();
});
}
function fakeTimers() {
let next = 1;
const pending = new Map();
return {
setTimeout: (callback) => {
const id = next++;
pending.set(id, callback);
return id;
},
clearTimeout: (id) => pending.delete(id),
pending: () => pending.size,
/** Run the earliest pending callback, as 250 ms passing would. */
tick() {
const [id, callback] = pending.entries().next().value ?? [];
if (id === undefined) return false;
pending.delete(id);
callback();
return true;
},
};
}
test('a stuck view is recovered once it has held still, without waiting for moveEnd', () => {
// At this pose the camera can drift by a rounding error every frame, so
// Cesium never sees it stop and never raises moveEnd for it.
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'one quiet sample is not enough');
// A rounding-error drift between samples still counts as at rest.
camera.lookUp(1e-15);
timers.tick();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
// Under that drift Cesium raises no new moveStart, so a later move that
// ends looking away is caught by the same check.
aim(camera, reportedStuckView);
timers.tick();
timers.tick();
assert.equal(globeOutOfFrame(camera), true, 'not yet at rest');
timers.tick();
assert.ok(Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6));
camera.moveEnd.raiseEvent();
assert.equal(timers.pending(), 0, 'moveEnd ends the check');
});
test('the settle check follows a long move and gives up five minutes after the last one', () => {
// A drag of half a minute that stops at a stuck pose is still recovered.
const long = fakeTimers();
const dragged = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(dragged.viewer, long);
aim(dragged.camera, reportedStuckView);
dragged.camera.moveStart.raiseEvent();
for (let i = 0; i < 120; i += 1) {
dragged.camera.moveLeft(0.01);
long.tick();
}
assert.equal(globeOutOfFrame(dragged.camera), true, 'still moving');
while (long.tick());
assert.ok(
Cesium.Math.equalsEpsilon(dragged.camera.pitch, -Math.PI / 2, 1e-6),
'recovered once it stopped',
);
// A camera that keeps moving is followed, and left alone, for as long as it
// moves; once it rests the check gives up five minutes later.
const timers = fakeTimers();
const { camera, viewer } = realCamera({ width: 1225, height: 923 });
installGlobeInViewRecovery(viewer, timers);
aim(camera, reportedStuckView);
camera.moveStart.raiseEvent();
for (let i = 0; i < 2000; i += 1) {
camera.moveLeft(0.01);
timers.tick();
}
assert.equal(timers.pending(), 1, 'still following after eight minutes');
assert.equal(globeOutOfFrame(camera), true, 'a moving camera is left alone');
let idleSamples = 0;
while (timers.tick()) idleSamples += 1;
assert.ok(
Cesium.Math.equalsEpsilon(camera.pitch, -Math.PI / 2, 1e-6),
'recovered once it stopped',
);
assert.ok(
idleSamples >= 1200 && idleSamples <= 1205,
`gave up ${idleSamples} samples after it stopped`,
);
const disposed = fakeTimers();
const other = realCamera({ width: 1225, height: 923 });
const dispose = installGlobeInViewRecovery(other.viewer, disposed);
aim(other.camera, reportedStuckView);
other.camera.moveStart.raiseEvent();
dispose();
assert.equal(disposed.pending(), 0, 'dispose cancels the pending check');
});
+1
View File
@@ -5,6 +5,7 @@ const CONTROL_LAYER_IDS = Object.freeze({
satellitesLayer: 'satellites', satellitesLayer: 'satellites',
cctvLayer: 'cctv', cctvLayer: 'cctv',
streetLevelLayer: 'street-level', streetLevelLayer: 'street-level',
wildlifeSightingsLayer: 'wildlife-sightings',
radioLayer: 'radio', radioLayer: 'radio',
bikeshareLayer: 'bikeshare', bikeshareLayer: 'bikeshare',
transitLayer: 'transit', transitLayer: 'transit',
+2
View File
@@ -139,6 +139,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
'rocket-launches', 'rocket-launches',
'street-level', 'street-level',
'local-adsb', 'local-adsb',
'wildlife-sightings',
]; ];
const layers = ids.map((id) => ({ id })); const layers = ids.map((id) => ({ id }));
const catalog = createLayerCatalog(layers, metadata(layers)); const catalog = createLayerCatalog(layers, metadata(layers));
@@ -147,6 +148,7 @@ test('controls bind catalog instances rather than similarly named defaults', ()
assert.equal(services.transitLayer, layers[7]); assert.equal(services.transitLayer, layers[7]);
assert.equal(services.aisLiveVesselsLayer, layers[8]); assert.equal(services.aisLiveVesselsLayer, layers[8]);
assert.equal(services.localAdsbLayer, layers[13]); assert.equal(services.localAdsbLayer, layers[13]);
assert.equal(services.wildlifeSightingsLayer, layers[14]);
assert.equal(new Set(Object.values(services)).size, layers.length); assert.equal(new Set(Object.values(services)).size, layers.length);
assert.throws( assert.throws(
() => catalogControlServices(createLayerCatalog([], [])), () => catalogControlServices(createLayerCatalog([], [])),
+7 -2
View File
@@ -1,7 +1,7 @@
import { resolveCatalogSources } from './sourceComposition.js'; import { resolveCatalogSources } from './sourceComposition.js';
import { createWeatherClock } from '../layers/weather/clock.js'; import { createWeatherClock } from '../layers/weather/clock.js';
import { createWeatherLayer } from '../layers/weather/index.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 { createWindLayer } from '../layers/wind/index.js';
import { createLayerCatalog } from './catalog.js'; import { createLayerCatalog } from './catalog.js';
import { LAYER_STATE_REGISTRY } from '../data/layerState.js'; import { LAYER_STATE_REGISTRY } from '../data/layerState.js';
@@ -30,6 +30,7 @@ import { createApplicationFirePerimeters } from './layers/perimeters.js';
import { createApplicationGnssIntegrity } from './layers/gnss.js'; import { createApplicationGnssIntegrity } from './layers/gnss.js';
import { createApplicationGhostMaps } from './layers/ghostMaps.js'; import { createApplicationGhostMaps } from './layers/ghostMaps.js';
import { createApplicationDroneAirspace } from './layers/droneAirspace.js'; import { createApplicationDroneAirspace } from './layers/droneAirspace.js';
import { createApplicationWildlifeSightings } from './layers/wildlifeSightings.js';
import { createApplicationCables } from './layers/submarineCables.js'; import { createApplicationCables } from './layers/submarineCables.js';
import { createOsmOverlayLayers } from '../data/osmOverlays.js'; import { createOsmOverlayLayers } from '../data/osmOverlays.js';
import { createApplicationStreetLevel } from './layers/streetLevel.js'; import { createApplicationStreetLevel } from './layers/streetLevel.js';
@@ -137,6 +138,10 @@ export function createApplicationCatalog({
createApplicationDroneAirspace({ createApplicationDroneAirspace({
source: sources['drone-airspace'], source: sources['drone-airspace'],
}), }),
createApplicationWildlifeSightings({
source: sources['wildlife-sightings'],
groundFloor: surface.groundFloor,
}),
createApplicationAlpr({ surface, source: sources.alpr }), createApplicationAlpr({ surface, source: sources.alpr }),
createApplicationStreetLevel({ createApplicationStreetLevel({
surface, surface,
@@ -177,7 +182,7 @@ export function createApplicationCatalog({
id: 'weather-lightning', id: 'weather-lightning',
clock: weatherClock, clock: weatherClock,
}), }),
createCyclonesLayer({ feed: sources.cyclones }), createApplicationCyclones({ feed: sources.cyclones }),
...createOsmOverlayLayers(), ...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices), ...createInfrastructureLayers(localGeoJsonServices),
createApplicationCables({ source: sources.cables }), createApplicationCables({ source: sources.cables }),
+2 -2
View File
@@ -40,7 +40,7 @@ test('catalogs construct distinct layers and classification from their supplied
signal: b.signal, signal: b.signal,
surface: fixtureSurface(b.signal), surface: fixtureSurface(b.signal),
}); });
assert.equal(first.layers.length, 37); assert.equal(first.layers.length, 38);
assert.ok(first.get('local-adsb'), 'Local ADS-B is registered'); assert.ok(first.get('local-adsb'), 'Local ADS-B is registered');
assert.deepEqual( assert.deepEqual(
first.metadata.find(({ id }) => id === 'osm-places'), first.metadata.find(({ id }) => id === 'osm-places'),
@@ -161,7 +161,7 @@ test('Mapillary can be omitted or replaced without changing catalog membership',
surface: fixtureSurface(lifetime.signal), surface: fixtureSurface(lifetime.signal),
}; };
const catalog = createApplicationCatalog(options); const catalog = createApplicationCatalog(options);
assert.equal(catalog.layers.length, 37); assert.equal(catalog.layers.length, 38);
assert.deepEqual(catalog.get('street-level').providerIds, []); assert.deepEqual(catalog.get('street-level').providerIds, []);
assert.equal( assert.equal(
catalogControlServices(catalog).streetLevelLayer, catalogControlServices(catalog).streetLevelLayer,
+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,
});
}
+30
View File
@@ -0,0 +1,30 @@
import * as Cesium from 'cesium';
import { createWildlifeSightingsLayer } from '../../layers/wildlifeSightings/index.js';
import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
import * as sprites from '../../data/spriteOrder.js';
import * as render from '../../renderGovernor.js';
import * as context from '../../data/contextStore.js';
import * as focus from '../../worldFocus.js';
import { isPointerFree } from '../../data/inputOwnership.js';
/** Bind the wildlife layer to the application's scene and selection owners. */
export function createApplicationWildlifeSightings(options) {
return createWildlifeSightingsLayer({
overlayHost: {
setEntries: overlays.setOverlayEntries,
setVisible: overlays.setOverlaySourceVisible,
clearSource: overlays.clearOverlaySource,
hitTest: overlays.hitTestWorldOverlay,
},
screenSpaceEventHandlerFactory: (viewer) =>
new Cesium.ScreenSpaceEventHandler(viewer.scene.canvas),
picking,
pointer: { isPointerFree },
sprites,
render,
context,
focus,
...options,
});
}
+2
View File
@@ -2,6 +2,7 @@ import { createApplicationOperations } from './operations.js';
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { import {
createApplicationViewer, createApplicationViewer,
installFreeLookHeightLimit,
installTrackpadPinchZoom, installTrackpadPinchZoom,
} from '../app/viewer.js'; } from '../app/viewer.js';
import { registerDataCredits } from '../data/dataCredits.js'; import { registerDataCredits } from '../data/dataCredits.js';
@@ -58,6 +59,7 @@ export async function createApplicationScene({
if (!viewer.isDestroyed()) viewer.destroy(); if (!viewer.isDestroyed()) viewer.destroy();
}); });
defer(installTrackpadPinchZoom(viewer)); defer(installTrackpadPinchZoom(viewer));
defer(installFreeLookHeightLimit(viewer));
registerDataCredits(viewer, credits); registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document); configureCreditKeyboardAccess(document);
defer(onLocaleChange(() => configureCreditKeyboardAccess(document))); defer(onLocaleChange(() => configureCreditKeyboardAccess(document)));
+1
View File
@@ -50,6 +50,7 @@ export const CATALOG_SOURCE_CONTRACTS = Object.freeze({
'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']), 'gnss-integrity': contract(['gnss-integrity'], ['getSnapshot']),
's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']), 's2-ghostmaps': contract(['s2-ghostmaps'], ['getCatalog', 'getFile']),
'drone-airspace': contract(['drone-airspace'], ['getSnapshot']), 'drone-airspace': contract(['drone-airspace'], ['getSnapshot']),
'wildlife-sightings': contract(['wildlife-sightings'], ['getSnapshot']),
cables: contract(['telegeography-submarine-cables'], ['fetch']), cables: contract(['telegeography-submarine-cables'], ['fetch']),
transit: contract(['transit'], ['requestSnapshot', 'getHistory']), transit: contract(['transit'], ['requestSnapshot', 'getHistory']),
}); });
+1 -1
View File
@@ -37,7 +37,7 @@ test('every acquisition source can be omitted without losing catalog membership'
test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => { test('all acquisition sources may be absent, and enable fails before initializing or fetching', async (t) => {
const actual = catalog({}, t); const actual = catalog({}, t);
assert.equal(actual.layers.length, 37); assert.equal(actual.layers.length, 38);
const manager = new LayerLifecycle( const manager = new LayerLifecycle(
{}, {},
{ getSourceAvailability: actual.getSourceAvailability }, { getSourceAvailability: actual.getSourceAvailability },
+15 -1
View File
@@ -8,7 +8,13 @@ import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js'; import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js'; import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js'; import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js'; import {
installScopeMask,
destroyScopeMask,
isScopeMaskEnabled,
} from '../scopeMask.js';
import { KEYHOLE_OUTER_RADIUS } from '../celestialRing.js';
import { installGlobeInViewRecovery } from './viewer.js';
import { installLayerReleaseTrim } from '../sceneCommandRelease.js'; import { installLayerReleaseTrim } from '../sceneCommandRelease.js';
import { import {
installRenderGovernor, installRenderGovernor,
@@ -44,6 +50,14 @@ export function createApplicationTools({
.get('bhote-koshi-2026') .get('bhote-koshi-2026')
?.module.attachSceneController(sceneDirector); ?.module.attachSceneController(sceneDirector);
defer(() => sceneDirector.destroy()); defer(() => sceneDirector.destroy());
defer(
installGlobeInViewRecovery(viewer, {
// The scope mask hides everything outside its keyhole.
keyholeRadius: () => (isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0),
// An authored scene frames its own shots, holds included.
paused: () => sceneDirector.running,
}),
);
onSceneDirector?.(sceneDirector); onSceneDirector?.(sceneDirector);
const annotations = initAnnotations({ const annotations = initAnnotations({
viewer, viewer,
+230
View File
@@ -1,6 +1,11 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js'; import { applyModelAtmosphereWorkaround } from './atmosphereCompat.js';
import { StarrySky } from '../sky/starrySky.js'; import { StarrySky } from '../sky/starrySky.js';
import {
FREE_LOOK_MAX_HEIGHT_METERS,
globeOutOfFrame,
isFreeGlobeCamera,
} from '../globeInView.js';
const PINCH_ZOOM_MULTIPLIER = 8; const PINCH_ZOOM_MULTIPLIER = 8;
const MAX_PINCH_PIXEL_DELTA = 120; const MAX_PINCH_PIXEL_DELTA = 120;
@@ -103,6 +108,231 @@ export function installTrackpadPinchZoom(
}; };
} }
/**
* Allow Cesium's free look only below `maxHeight` and return its disposer.
* Orbit, tilt and zoom stay available at every height. A value other code
* sets later is kept as the one to allow below that height, and restored on
* dispose; a `false` written while the camera is above `maxHeight` cannot be
* told apart from this limit's own write, so dispose restores the earlier
* value then. `enableLook` gates the Shift+drag binding only: below its trackball
* height Cesium also turns the camera in place for a drag that starts on the
* stars. That height is lowered to `maxHeight` too, so above it such a drag
* orbits the globe, as it does above Cesium's own 7,500 km.
*/
export function installFreeLookHeightLimit(
viewer,
maxHeight = FREE_LOOK_MAX_HEIGHT_METERS,
) {
const scene = viewer?.scene;
const controller = scene?.screenSpaceCameraController;
const camera = viewer?.camera;
if (!controller || !camera || !scene?.preUpdate)
throw new TypeError('A complete Cesium viewer is required');
const originalTrackBallHeight = controller.minimumTrackBallHeight;
const trackBallHeight = Math.min(originalTrackBallHeight, maxHeight);
controller.minimumTrackBallHeight = trackBallHeight;
let wanted = controller.enableLook;
let written;
const sync = () => {
if (written !== undefined && controller.enableLook !== written)
wanted = controller.enableLook;
const height = camera.positionCartographic?.height;
written = wanted && !(Number.isFinite(height) && height > maxHeight);
controller.enableLook = written;
};
sync();
const removeListener = scene.preUpdate.addEventListener(sync);
let active = true;
return () => {
if (!active) return;
active = false;
removeListener();
if (controller.enableLook === written) controller.enableLook = wanted;
if (controller.minimumTrackBallHeight === trackBallHeight)
controller.minimumTrackBallHeight = originalTrackBallHeight;
};
}
// How often a moving camera is sampled to see whether it has come to rest, how
// far a pose may drift between samples and still count as at rest, and how
// long after the camera last moved the check gives up if Cesium never raises
// moveEnd.
const SETTLE_SAMPLE_MS = 250;
const SETTLE_POSITION_TOLERANCE_M = 1e-3;
const SETTLE_DIRECTION_TOLERANCE = 1e-9;
const SETTLE_GIVE_UP_MS = 5 * 60_000;
/**
* Turn the camera back to the planet when a move ends above `minHeight` with
* no surface on screen (`globeOutOfFrame`: limb glow and slivers up to about
* one sample cell thick can count as none), and return the disposer. Views with
* more surface, such as an oblique orbital shot, are left as they are. This
* catches a saved or shared view that looked away, or any other move that ends
* looking away from the planet.
* Heading and position are kept. Tracked cameras, locked ones (non-identity
* transform), a camera flight in progress, a pointer held down on the canvas
* (a drag held still), the 2D and Columbus modes, and any time `paused`
* returns true (an authored scene playing) are left alone; a check they defer
* runs once they end, however long they last.
* `keyholeRadius`
* returns the radius of the visible circle when a scope mask hides the rest of
* the frame (see `globeFrameShare`), or 0; it is read at every check.
*
* Cesium's `moveEnd` alone is not enough: Cesium compares the camera with the
* last frame's to the last rounding error, and at some of these poses the
* camera drifts by a rounding error every frame, so Cesium never sees it stop.
* So each `moveStart` also starts a check of its own that samples the pose
* every 250 ms, with a tolerance for that drift, and once the pose has held
* for half a second after moving runs the same recovery. It keeps watching
* until Cesium raises `moveEnd`, since under that drift Cesium raises no new
* `moveStart` for a later move, and gives up five minutes after the camera
* last moved.
* `setTimeout` and `clearTimeout` can be injected for tests.
*/
export function installGlobeInViewRecovery(
viewer,
{
minHeight = FREE_LOOK_MAX_HEIGHT_METERS,
keyholeRadius = () => 0,
paused = () => false,
setTimeout: schedule = globalThis.setTimeout,
clearTimeout: unschedule = globalThis.clearTimeout,
} = {},
) {
const scene = viewer?.scene;
const camera = viewer?.camera;
if (!scene || !camera?.moveEnd || !camera?.moveStart)
throw new TypeError('A complete Cesium viewer is required');
// A pointer held on the globe is a gesture in progress, even when it holds
// still for half a second: the check waits until every pointer pressed on
// it (each finger of a pinch) is released. A release the page never sees,
// outside the window, is taken as given when the window loses focus, the
// page is hidden, the canvas loses the pointer's capture, or the pointer
// moves with no button down.
const canvas = scene.canvas;
const releaseTarget = canvas?.ownerDocument ?? canvas;
const releaseWindow = canvas?.ownerDocument?.defaultView;
const heldPointers = new Set();
const onPointerDown = (event) => heldPointers.add(event?.pointerId);
const onPointerUp = (event) => heldPointers.delete(event?.pointerId);
const onPointerMove = (event) => {
if (event?.buttons === 0) heldPointers.delete(event.pointerId);
};
const releaseAll = () => heldPointers.clear();
const onVisibilityChange = () => {
if (canvas.ownerDocument.visibilityState === 'hidden') releaseAll();
};
const listeners = [
[canvas, 'pointerdown', onPointerDown],
[canvas, 'lostpointercapture', onPointerUp],
[releaseTarget, 'pointerup', onPointerUp],
[releaseTarget, 'pointercancel', onPointerUp],
[releaseTarget, 'pointermove', onPointerMove],
[canvas?.ownerDocument, 'visibilitychange', onVisibilityChange],
[releaseWindow, 'blur', releaseAll],
];
for (const [target, type, listener] of listeners)
target?.addEventListener?.(type, listener);
// Returns false while a flight, a paused scene or a held pointer defers the
// check, so the caller can try again once it ends.
const recover = () => {
if (!isFreeGlobeCamera(viewer)) return true;
// Cesium has no public running-flight flag.
if (camera._currentFlight || paused() || heldPointers.size > 0)
return false;
const height = camera.positionCartographic?.height;
if (
!(height > minHeight) ||
!globeOutOfFrame(camera, { keyholeRadius: keyholeRadius() })
)
return true;
camera.setView({
orientation: {
heading: camera.heading,
pitch: -Cesium.Math.PI_OVER_TWO,
roll: 0,
},
});
scene.requestRender?.();
return true;
};
const lastPosition = new Cesium.Cartesian3();
const lastDirection = new Cesium.Cartesian3();
let settleTimer = null;
let settleElapsed = 0;
let restingSamples = 0;
let movedSinceCheck = false;
const stopSettling = () => {
if (settleTimer !== null) unschedule(settleTimer);
settleTimer = null;
};
const sample = () => {
settleTimer = null;
settleElapsed += SETTLE_SAMPLE_MS;
const atRest =
Cesium.Cartesian3.equalsEpsilon(
camera.positionWC,
lastPosition,
0,
SETTLE_POSITION_TOLERANCE_M,
) &&
Cesium.Cartesian3.equalsEpsilon(
camera.directionWC,
lastDirection,
0,
SETTLE_DIRECTION_TOLERANCE,
);
restingSamples = atRest ? restingSamples + 1 : 0;
if (!atRest) {
movedSinceCheck = true;
settleElapsed = 0;
}
// Two quiet samples in a row after a move: at rest for half a second, as
// for moveEnd.
if (restingSamples >= 2 && movedSinceCheck) {
if (recover()) movedSinceCheck = false;
// A flight or a playing scene bounds its own deferral.
else settleElapsed = 0;
}
if (settleElapsed >= SETTLE_GIVE_UP_MS) return;
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
};
const startSettling = () => {
stopSettling();
settleElapsed = 0;
restingSamples = 0;
movedSinceCheck = true;
Cesium.Cartesian3.clone(camera.positionWC, lastPosition);
Cesium.Cartesian3.clone(camera.directionWC, lastDirection);
settleTimer = schedule(sample, SETTLE_SAMPLE_MS);
};
const removeMoveEnd = camera.moveEnd.addEventListener(() => {
if (recover()) stopSettling();
// Deferred: keep sampling so the check runs once the flight or scene ends.
else if (settleTimer === null) startSettling();
});
const removeMoveStart = camera.moveStart.addEventListener(startSettling);
let active = true;
return () => {
if (!active) return;
active = false;
stopSettling();
removeMoveEnd();
removeMoveStart();
for (const [target, type, listener] of listeners)
target?.removeEventListener?.(type, listener);
heldPointers.clear();
};
}
/** Cesium.Viewer options of the standard globe viewer. */ /** Cesium.Viewer options of the standard globe viewer. */
export function applicationViewerOptions(creditContainer) { export function applicationViewerOptions(creditContainer) {
return { return {
+669
View File
@@ -13,8 +13,16 @@ import {
moveCamera, moveCamera,
} from './cameraVerbs.js'; } from './cameraVerbs.js';
import { getRenderGovernorDiagnostics } from './renderGovernor.js'; import { getRenderGovernorDiagnostics } from './renderGovernor.js';
import {
MIN_GLOBE_FRAME_SHARE,
globeFrameShare,
globeOutOfFrame,
} from './globeInView.js';
import { KEYHOLE_OUTER_RADIUS } from './celestialRing.js';
import { setScopeMaskEnabled } from './scopeMask.js';
const radians = Cesium.Math.toRadians; const radians = Cesium.Math.toRadians;
const degrees = Cesium.Math.toDegrees;
const clone = (value) => Cesium.Cartesian3.clone(value); const clone = (value) => Cesium.Cartesian3.clone(value);
const longitude = (camera) => const longitude = (camera) =>
Cesium.Cartographic.fromCartesian(camera.positionWC).longitude; Cesium.Cartographic.fromCartesian(camera.positionWC).longitude;
@@ -1347,3 +1355,664 @@ test('close-up ultrawide refusal does not offer an unfit reset or move the camer
samePose(h.camera, before); samePose(h.camera, before);
assert.equal(hasRenderHold(), false); assert.equal(hasRenderHold(), false);
}); });
/**
* An oracle independent of the code under test: the share of an 80×45 grid of
* Cesium pick rays across the canvas that hit the ellipsoid surface. With
* `keyhole`, only the pixels inside the scope's visible circle count.
*/
function surfacePixelShare(camera, width, height, { keyhole = false } = {}) {
const radius = (KEYHOLE_OUTER_RADIUS * height) / 2;
let hits = 0;
let counted = 0;
for (let row = 0; row < 45; row += 1) {
for (let column = 0; column < 80; column += 1) {
const x = ((column + 0.5) * width) / 80;
const y = ((row + 0.5) * height) / 45;
if (keyhole && Math.hypot(x - width / 2, y - height / 2) > radius)
continue;
counted += 1;
const ray = camera.getPickRay(new Cesium.Cartesian2(x, y));
if (Cesium.IntersectionTests.rayEllipsoid(ray, Cesium.Ellipsoid.WGS84))
hits += 1;
}
}
return hits / counted;
}
/** Run a test with the scope mask on or off, restoring the default after. */
function withScope(t, enabled) {
setScopeMaskEnabled(enabled);
t.after(() => setScopeMaskEnabled(true));
}
// The frame limit is measured on a coarser grid than the oracle.
const SHARE_SLACK = 0.02;
// Far-out poses, height in metres and pitch in degrees, where a few tilts up
// are accepted before the limit refuses the next one.
const FAR_OUT_POSES = [
[35_884_000, -86.26],
[35_884_000, -89],
[30_000_000, -86],
[25_000_000, -80],
[40_000_000, -88],
];
test('voice tilt from orbit stops with the planet still on screen and then says so', (t) => {
withScope(t, false); // the whole frame is visible
// Slightly off nadir: from straight down a tilt is clamped before it moves.
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
const results = [];
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
results.push(result);
if (result.ok) finishOnce();
assert.ok(
surfacePixelShare(camera, 1600, 900) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`after tilt ${i + 1}`,
);
}
// From 18,000 km a 16:9 frame keeps a tenth of its area on the planet down
// to about -67°: the first tilt stops there, and later ones are refused.
assert.deepEqual(
results.map((result) => result.ok),
[true, false, false, false],
);
assert.match(results[1].error, /globe out of view/);
assert.ok(camera.pitch > radians(-68.5) && camera.pitch < radians(-66));
});
// The frame limit judges a tilt by the way its pitch moves, not by the
// direction word: a tilt down or to a steeper angle turns toward the planet.
for (const args of [{ direction: 'down' }, { pitchDeg: -80 }]) {
test(`a tilt ${JSON.stringify(args)} from the frame limit is judged as the look down it is`, (t) => {
withScope(t, false);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
finishOnce();
const atLimit = camera.pitch;
const result = moveCamera({ motion: 'tilt', ...args });
assert.equal(result.ok, true, result.error);
finishOnce();
assert.ok(camera.pitch < atLimit - radians(5), 'the tilt turned down');
});
}
for (const args of [{ pitchDeg: -10 }, {}]) {
test(`a tilt ${JSON.stringify(args)} up from the frame limit is refused, not reported as done`, (t) => {
withScope(t, false);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
finishOnce();
const atLimit = camera.pitch;
// A bare tilt means -45°: up from the -67° limit.
const result = moveCamera({ motion: 'tilt', ...args });
assert.equal(result.ok, false);
assert.match(result.error, /globe out of view/);
assert.equal(camera.pitch, atLimit);
});
}
// A tilt to an angle, or a bare tilt (-45°), from orbit turns in place like
// a nudge and must keep the planet in frame the whole way, or the globe
// recovery would snap the view back down.
for (const [label, args, options] of [
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
[
'a continuous tilt up',
{ motion: 'tilt', direction: 'up', mode: 'continuous' },
{ pitch: -90 },
],
[
'a tilt to -5° from 2,000 km',
{ motion: 'tilt', pitchDeg: -5 },
{ pitch: -90, height: 2_000_000 },
],
]) {
test(`${label} from top-down never turns the planet out of frame`, (t) => {
withScope(t, false);
const { camera, tick } = harness(t, {
...options,
viewportWidth: 1600,
viewportHeight: 900,
});
const startPitch = camera.pitch;
const result = moveCamera(args);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
surfacePixelShare(camera, 1600, 900) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`frame ${i}: the planet left the frame`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
assert.equal(globeOutOfFrame(camera), false, 'no snap-back');
if (result.ok) assert.ok(camera.pitch > startPitch, 'the tilt moved');
});
}
// The frame limit stops a tilt to an angle short of it: the reply names the
// angle the camera reaches, not the one asked for (a tilt to -10° from
// top-down reported -10° and ended at -66.5°).
for (const [label, args, options] of [
['a bare tilt', { motion: 'tilt' }, { pitch: -90 }],
['a tilt to -10°', { motion: 'tilt', pitchDeg: -10 }, { pitch: -90 }],
[
'a tilt to -10° from -80°',
{ motion: 'tilt', pitchDeg: -10 },
{ pitch: -80 },
],
[
'a tilt to -5° from 2,000 km',
{ motion: 'tilt', pitchDeg: -5 },
{ pitch: -90, height: 2_000_000 },
],
// A rolled camera's look turns its pitch by less than the look angle:
// reported -25°, ended at -27.3°.
[
'a tilt to -5° from 2,000 km rolled 30°',
{ motion: 'tilt', pitchDeg: -5 },
{ pitch: -70, roll: 30, height: 2_000_000 },
],
]) {
test(`${label} from orbit reports the angle it reaches`, (t) => {
withScope(t, false);
const { camera, tick } = harness(t, {
...options,
viewportWidth: 1600,
viewportHeight: 900,
});
const result = moveCamera(args);
assert.equal(result.ok, true, result.error);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
const reached = degrees(camera.pitch);
assert.ok(
Math.abs(reached - result.pitchDeg) < 1,
`reported ${result.pitchDeg}°, reached ${reached}°`,
);
const asked = args.pitchDeg ?? -45;
if (Math.abs(reached - asked) >= 1)
assert.match(result.note ?? '', /globe out of view/);
else assert.equal(result.note, undefined);
});
}
// A continuous tilt promises no angle: it runs until stopped or the frame
// limit ends it.
test('a continuous tilt up from orbit claims no angle it may not reach', (t) => {
withScope(t, false);
const { tick } = harness(t, {
pitch: -90,
viewportWidth: 1600,
viewportHeight: 900,
});
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'continuous',
});
assert.equal(result.ok, true, result.error);
assert.equal('pitchDeg' in result, false);
assert.equal(result.mode, 'continuous');
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'the frame limit ends it');
});
test('voice turn in place from orbit stops with the planet still on screen', (t) => {
withScope(t, false); // the whole frame is visible
const { camera, tick } = harness(t, { pitch: -75 });
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
true,
);
const heading = camera.heading;
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
surfacePixelShare(camera, 1280, 800) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`tick ${i + 1}`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.notEqual(camera.heading, heading, 'the turn moved before stopping');
});
test('a long voice turn below the limit keeps the share it started with', (t) => {
withScope(t, false); // the whole frame is visible
// About 2% of a 16:9 frame on the planet: the limit is that share, less two
// grid cells of sampling noise, for the whole motion rather than per step.
const { camera, tick } = harness(t, {
pitch: -60,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' }).ok,
true,
);
for (let i = 0; i < 400 && getActiveCameraMotion(); i += 1) {
tick();
assert.ok(
globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9,
`tick ${i + 1}`,
);
}
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('repeated voice turns from one spot cannot each take a little more', (t) => {
withScope(t, false); // the whole frame is visible
// About 2% of a 16:9 frame on the planet. Every turn from this position is
// measured against the same baseline, so asking again stops being allowed.
const { camera, finishOnce, tick } = harness(t, {
pitch: -60,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
const floor = start - 2 / (32 * 18) - 1e-9;
const results = [];
for (const mode of ['once', 'once', 'continuous', 'once', 'continuous']) {
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({ motion: 'rotate', direction: 'left', mode });
results.push(result.ok);
if (!result.ok) continue;
if (mode === 'once') finishOnce();
else for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
assert.ok(globeFrameShare(camera) >= floor, `${mode} turn ${i + 1}`);
}
}
assert.ok(results.includes(false), 'a turn at the limit is refused');
assert.equal(results.at(-1), false);
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
});
test('a voice turn after something else turned the camera starts over', (t) => {
withScope(t, false); // the whole frame is visible
// The same spot, first framed straight down, then turned to about 2% of the
// frame by something other than voice: a turn left is measured from there.
const { camera, finishOnce } = harness(t, {
pitch: -90,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
true,
);
finishOnce();
camera.setView({
orientation: { heading: 0, pitch: radians(-60), roll: 0 },
});
const start = globeFrameShare(camera);
assert.ok(start > 0 && start < MIN_GLOBE_FRAME_SHARE);
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'once' }).ok,
true,
);
finishOnce();
assert.ok(globeFrameShare(camera) >= start - 2 / (32 * 18) - 1e-9);
});
test('nudging the camera between voice turns cannot take the planet below the 1% floor', (t) => {
withScope(t, false); // the whole frame is visible
// A wheel notch or an orbit drag between requests starts each turn afresh;
// the 1% floor still holds, and below it a turn may only keep what it has.
for (const [nudge, pitch] of [
[(camera) => camera.moveBackward(2), -60],
[(camera) => camera.rotateRight(radians(0.2)), -60],
[(camera) => camera.moveBackward(2), -58],
]) {
const { camera, tick } = harness(t, {
pitch,
viewportWidth: 1600,
viewportHeight: 900,
});
const start = globeFrameShare(camera);
const floor = Math.min(0.01, start) - 1e-9;
for (let i = 0; i < 12; i += 1) {
nudge(camera);
if (
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
.ok
)
for (let j = 0; j < 400 && getActiveCameraMotion(); j += 1) tick();
assert.ok(
globeFrameShare(camera) >= floor,
`start ${start}, turn ${i + 1}`,
);
}
assert.ok(surfacePixelShare(camera, 1600, 900) > 0);
}
});
test('after the frame changes size, a voice turn toward the planet is allowed', (t) => {
withScope(t, false); // the whole frame is visible
// A tilt stops at its limit; then the window widens at the same pose, so the
// frame shows less than the carried limit. Toward the planet must still go.
const { camera, canvas, viewer, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
true,
);
finishOnce();
canvas.clientWidth = canvas.width = viewer.scene.drawingBufferWidth = 3200;
camera.frustum.aspectRatio = 3200 / 900;
const narrowed = globeFrameShare(camera);
assert.ok(narrowed < MIN_GLOBE_FRAME_SHARE, `frame now shows ${narrowed}`);
const down = moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' });
assert.equal(down.ok, true, down.error);
finishOnce();
assert.ok(globeFrameShare(camera) > narrowed);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'down', mode: 'once' }).ok,
true,
);
finishOnce();
const up = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
if (up.ok) finishOnce();
assert.ok(surfacePixelShare(camera, 3200, 900) > 0);
});
test('with the scope on, a voice tilt stops while the keyhole still shows the planet', (t) => {
withScope(t, true);
const { camera, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
const results = [];
for (let i = 0; i < 4; i += 1) {
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
results.push(result.ok);
if (result.ok) finishOnce();
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`after tilt ${i + 1}`,
);
}
assert.equal(results[0], true);
assert.equal(results.at(-1), false, 'a tilt past the limit is refused');
});
test('with the scope on, a voice turn cannot leave the planet only in the masked corners', (t) => {
// An oblique orbit view: turning in place swings the planet toward a corner,
// where the full frame still shows it but the scope mask hides it.
for (const [height, pitch] of [
[5_000_000, -70],
[3_000_000, -75],
[18_000_000, -80],
]) {
withScope(t, true);
const { camera, tick } = harness(t, {
height,
pitch,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'rotate', direction: 'left', mode: 'continuous' })
.ok,
true,
);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'the turn stops at the limit');
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: true }) >=
MIN_GLOBE_FRAME_SHARE - SHARE_SLACK,
`${height / 1000} km at ${pitch}°`,
);
}
});
test('far out, a voice tilt is either refused or really moves, at display frame rates', (t) => {
// A small planet disc: its sampled share wobbles as it crosses the grid.
// Whatever the limit decides, "done" must mean the camera turned, and some
// of these tilts must be accepted for that to be checked at all.
let accepted = 0;
for (const [height, pitch] of FAR_OUT_POSES) {
withScope(t, true);
const { camera, tick } = harness(t, {
latitude: 45.37,
longitudeDeg: -33.33,
height,
heading: 89.9,
pitch,
viewportWidth: 1280,
viewportHeight: 1024,
});
for (let i = 0; i < 4; i += 1) {
const before = camera.pitch;
const result = moveCamera({
motion: 'tilt',
direction: 'up',
mode: 'once',
});
if (!result.ok) {
assert.match(result.error, /zoom in first/);
continue;
}
accepted += 1;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
assert.ok(
camera.pitch - before > radians(0.5) - 1e-9,
`${height / 1000} km at ${pitch}°: tilt ${i + 1} reported done but moved ${Cesium.Math.toDegrees(camera.pitch - before)}°`,
);
}
assert.ok(surfacePixelShare(camera, 1280, 1024, { keyhole: true }) > 0);
}
assert.ok(accepted > 0, 'no far-out tilt was accepted');
});
test('a voice turn reported as done turns at least the half degree its check proved', (t) => {
// A grazing sliver: the first frame's small step lands in a dip of the
// sampled share, which used to stop the turn before it moved at all.
for (const scope of [true, false]) {
withScope(t, scope);
const { camera, tick } = harness(t, {
latitude: 20,
longitudeDeg: 10,
height: 18_000_000,
pitch: -58,
heading: 37,
viewportWidth: 1600,
viewportHeight: 900,
});
const before = Cesium.Cartesian3.clone(camera.directionWC);
const result = moveCamera({
motion: 'rotate',
direction: 'left',
mode: 'once',
});
if (!result.ok) {
assert.match(result.error, /globe out of view/);
continue;
}
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
assert.ok(
turned >= radians(0.5) - 1e-9,
`scope ${scope ? 'on' : 'off'}: turned ${Cesium.Math.toDegrees(turned)}°`,
);
assert.ok(surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0);
}
});
/** Track an entity below `camera`, locking it into that entity's frame. */
function trackFrom(viewer, camera) {
viewer.trackedEntity = { id: 'high-satellite' };
const below = Cesium.Cartesian3.fromDegrees(10, 20, 400_000);
camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(below));
}
test('a voice turn that releases a tracked camera is checked as released', (t) => {
// Voice releases the follow camera inside the navigation that runs the
// motion, so the request is judged by the pose the release will leave.
let accepted = 0;
for (const [scope, direction, motion] of [
[true, 'up', 'tilt'],
[false, 'up', 'tilt'],
[false, 'left', 'rotate'],
]) {
withScope(t, scope);
const { camera, viewer, tick } = harness(t, {
latitude: 20,
longitudeDeg: 10,
height: 18_000_000,
pitch: -58,
heading: 37,
viewportWidth: 1600,
viewportHeight: 900,
});
trackFrom(viewer, camera);
const releaseThenRun = (start) => {
viewer.trackedEntity = undefined;
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
return start();
};
const before = Cesium.Cartesian3.clone(camera.directionWC);
const result = moveCamera(
{ motion, direction, mode: 'once' },
releaseThenRun,
);
const label = `${motion} ${direction}, scope ${scope ? 'on' : 'off'}`;
if (!result.ok) {
assert.match(result.error, /globe out of view/, label);
continue;
}
accepted += 1;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick(1 / 60);
const turned = Cesium.Cartesian3.angleBetween(before, camera.directionWC);
assert.ok(turned >= radians(0.5) - 1e-9, `${label}: turned ${turned}`);
assert.ok(
surfacePixelShare(camera, 1600, 900, { keyhole: scope }) > 0,
`${label}: the planet stays on screen`,
);
}
assert.ok(accepted > 0, 'no released turn was accepted');
});
test('a refused voice turn leaves a tracked camera tracking', (t) => {
for (const scope of [true, false]) {
withScope(t, scope);
const { camera, viewer, tick } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
// Tilt up to the limit untracked, then track from there.
for (let i = 0; i < 40; i += 1) {
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
break;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
}
trackFrom(viewer, camera);
const tracked = viewer.trackedEntity;
const transform = Cesium.Matrix4.clone(camera.transform);
let released = false;
const result = moveCamera(
{ motion: 'tilt', direction: 'up', mode: 'once' },
(start) => {
released = true;
viewer.trackedEntity = undefined;
camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
return start();
},
);
const label = `scope ${scope ? 'on' : 'off'}`;
assert.equal(result.ok, false, label);
assert.match(result.error, /globe out of view/, label);
assert.equal(released, false, label);
assert.equal(viewer.trackedEntity, tracked, label);
assert.ok(Cesium.Matrix4.equals(camera.transform, transform), label);
}
});
test('a turn proven at one position is checked again after a release moves the camera', (t) => {
withScope(t, false);
const { camera, tick } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
// Tilt up to the limit, then check from 4,000 km closer along the same view
// direction, where the next tilt fits; the release moves the camera back.
for (let i = 0; i < 40; i += 1) {
if (!moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok)
break;
for (let j = 0; j < 600 && getActiveCameraMotion(); j += 1) tick(1 / 60);
}
camera.moveForward(4_000_000);
const result = moveCamera(
{ motion: 'tilt', direction: 'up', mode: 'once' },
(start) => {
camera.moveBackward(4_000_000);
return start();
},
);
assert.equal(result.ok, false);
assert.match(result.error, /globe out of view/);
});
test('the voice frame limit leaves Columbus view alone', (t) => {
// At its limit in 3D a further tilt up is refused; the same tilt in Columbus
// view is not limited by the frame.
withScope(t, false);
const { viewer, finishOnce } = harness(t, {
pitch: -80,
viewportWidth: 1600,
viewportHeight: 900,
});
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' }).ok,
true,
);
finishOnce();
const refused = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.match(refused.error || '', /globe out of view/);
viewer.scene.mode = Cesium.SceneMode.COLUMBUS_VIEW;
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.doesNotMatch(result.error || '', /globe out of view/);
});
test('voice tilt below the free-look height is not limited by the frame', (t) => {
const { camera, finishOnce } = harness(t, { height: 500_000, pitch: -60 });
const result = moveCamera({ motion: 'tilt', direction: 'up', mode: 'once' });
assert.equal(result.ok, true);
finishOnce();
near(camera.pitch, radians(-45), 1e-6, 'full 15° tilt');
});
+10 -1
View File
@@ -223,11 +223,20 @@ test('validated voice camera destinations share the UI navigation authority faca
'move camera', 'move camera',
); );
ordered(move, [ ordered(move, [
"if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))", 'if (argsError) return argsError;',
'const start = () => {', 'const start = () => {',
/const preserveCameraFlight =\s*motion === 'orbit'/, /const preserveCameraFlight =\s*motion === 'orbit'/,
'runNavigation(start, { preserveCameraFlight })', 'runNavigation(start, { preserveCameraFlight })',
], 'move validation before handoff'); ], 'move validation before handoff');
const moveArgs = body(
cameraVerbs,
/export function moveCameraArgsError\(args = \{\}\) \{([\s\S]*?)\n\}/,
'move camera argument checks',
);
assert.ok(
moveArgs.includes("if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion))"),
'unknown motions are refused before handoff',
);
const route = body( const route = body(
cameraVerbs, cameraVerbs,
+773
View File
@@ -0,0 +1,773 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as Cesium from 'cesium';
import {
adjustOrbitRange,
getActiveCameraMotion,
initCameraVerbs,
interruptCameraMotion,
moveCamera,
} from './cameraVerbs.js';
import { createGevActionRunner } from './voice/gevActions.js';
const radians = Cesium.Math.toRadians;
const degrees = Cesium.Math.toDegrees;
/** Real Cesium camera over London with no renderer: the voice fly-to frame. */
function harness(
t,
{ height = 4000, heading = 0, pitch = -90, pivot = true } = {},
) {
const realPerformance = globalThis.performance;
let nowMs = 0;
globalThis.performance = { now: () => nowMs };
const canvas = Object.assign(new EventTarget(), {
clientWidth: 1280,
clientHeight: 800,
width: 1280,
height: 800,
});
const ellipsoid = Cesium.Ellipsoid.WGS84;
const scene = {
canvas,
drawingBufferWidth: 1280,
drawingBufferHeight: 800,
frameState: { mode: Cesium.SceneMode.SCENE3D },
mapProjection: new Cesium.GeographicProjection(ellipsoid),
globe: { ellipsoid },
mapMode2D: Cesium.MapMode2D.INFINITE_SCROLL,
mode: Cesium.SceneMode.SCENE3D,
pixelRatio: 1,
tweens: [],
requestRender() {},
};
const camera = new Cesium.Camera(scene);
scene.camera = camera;
camera.setView({
destination: Cesium.Cartesian3.fromDegrees(-0.1276, 51.5072, height),
orientation: { heading: radians(heading), pitch: radians(pitch), roll: 0 },
});
const viewer = {
camera,
scene,
trackedEntity: undefined,
trackedEntityChanged: new Cesium.Event(),
clock: { onTick: new Cesium.Event() },
};
// The app's view target: the ground point under the screen centre.
const viewTarget = () => {
const ray = new Cesium.Ray(camera.positionWC, camera.directionWC);
const hit = Cesium.IntersectionTests.rayEllipsoid(ray, ellipsoid);
return hit ? Cesium.Ray.getPoint(ray, hit.start) : undefined;
};
const target = viewTarget();
initCameraVerbs(viewer, pivot ? viewTarget : () => undefined);
t.after(() => {
interruptCameraMotion('test-cleanup');
globalThis.performance = realPerformance;
});
const tick = (seconds = 1 / 30) => {
nowMs += seconds * 1000;
viewer.clock.onTick.raiseEvent(viewer.clock);
};
const finish = () => {
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) tick();
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
};
return { viewer, camera, target, tick, finish, viewTarget };
}
const pitchDeg = (camera) => degrees(camera.pitch);
/** Tick until done() holds; a regression fails here instead of hanging. */
const tickUntil = (h, done, frames = 600) => {
for (let i = 0; i < frames && !done(); i += 1) h.tick();
assert.ok(done(), `not reached in ${frames} frames`);
};
const headingDeg = (camera) =>
degrees(Cesium.Math.negativePiToPi(camera.heading));
/** Metres between the original centre and the ground now under the crosshair. */
function centreDrift(h) {
return Cesium.Cartesian3.distance(h.viewTarget(), h.target);
}
test('tilt up leaves a top-down view and keeps the place centred', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(result.ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -75) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
});
test('continuous tilt up from top-down keeps moving', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
true,
);
for (let i = 0; i < 30; i += 1) h.tick();
assert.equal(getActiveCameraMotion()?.kind, 'tilt');
assert.ok(pitchDeg(h.camera) > -82, `pitch ${pitchDeg(h.camera)}`);
});
test('a requested angle tilts to that pitch', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
assert.equal(result.ok, true);
assert.equal(result.direction, 'up');
assert.equal(result.pitchDeg, -45);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -45) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('a bare tilt settles on the default oblique angle', (t) => {
const h = harness(t);
const result = moveCamera({ motion: 'tilt' });
assert.equal(result.ok, true);
assert.equal(result.pitchDeg, -45);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
});
test('a requested angle can tilt back down toward top-down', (t) => {
const h = harness(t, { pitch: -30 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -80 });
assert.equal(result.direction, 'down');
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
});
test('requested angles clamp to the tilt limits', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -2 }).pitchDeg, -5);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a positive angle is read as the same angle below the horizon', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 45 }).pitchDeg, -45);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
});
for (const pitch of ['', null, false, ' ', 'steep'])
test(`a blank or non-numeric angle ${JSON.stringify(pitch)} is ignored`, (t) => {
harness(t);
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
// No angle and no direction: the default oblique view, never the horizon.
assert.equal(result.pitchDeg, -45);
});
test('a requested angle the camera already holds is a no-op', (t) => {
harness(t, { pitch: -45 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 });
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(getActiveCameraMotion(), null);
});
for (const pitch of [-90, -89]) {
test(`a top-down view asked for ${pitch}° stays put`, (t) => {
// The tool description calls -89 top-down; the clamp must not tilt a
// -90° view up 1° and report it as a move up.
const h = harness(t);
const before = pitchDeg(h.camera);
const result = moveCamera({ motion: 'tilt', pitchDeg: pitch });
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(result.pitchDeg, Math.round(before));
assert.equal(getActiveCameraMotion(), null);
assert.equal(pitchDeg(h.camera), before);
});
}
test('tilt down from top-down still refuses honestly', (t) => {
harness(t);
const result = moveCamera({ motion: 'tilt', direction: 'down' });
assert.equal(result.ok, false);
assert.match(result.error, /straight down/);
});
test('a tilt up nudge stops at the horizon limit', (t) => {
const h = harness(t, { pitch: -12 });
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('without a ground pivot a tilt still leaves top-down in place', (t) => {
const h = harness(t, { pivot: false });
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -75) < 0.5);
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-6);
});
for (const heading of [0, 30, -120])
test(`tilt from top-down keeps the ${heading}° heading`, (t) => {
const h = harness(t, { heading, height: 67000 });
// Top-down float noise must not pick a new heading for the pivot.
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.finish();
assert.ok(
Math.abs(headingDeg(h.camera) - heading) < 1,
`heading ${headingDeg(h.camera)}`,
);
assert.ok(centreDrift(h) < 50, `centre drifted ${centreDrift(h)} m`);
});
test('an oblique tilt keeps its heading toward the pivot', (t) => {
const h = harness(t, { heading: 75, pitch: -60 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -30 }).ok, true);
h.finish();
assert.ok(Math.abs(headingDeg(h.camera) - 75) < 0.5);
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('a pivoting tilt yields when tracking takes the camera', (t) => {
const h = harness(t);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.tick();
const entity = { id: 'tracked' };
h.viewer.trackedEntity = entity;
h.viewer.trackedEntityChanged.raiseEvent(entity);
assert.equal(getActiveCameraMotion(), null);
});
/** Metres between the camera and the point it pivots about. */
const pivotRange = (h) =>
Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
const heightM = (camera) =>
Cesium.Cartographic.fromCartesian(camera.positionWC).height;
test('a zoom during a pivoting tilt changes the range it holds', (t) => {
const h = harness(t);
assert.equal(
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' }).ok,
true,
);
h.tick();
const before = pivotRange(h);
assert.deepEqual(
adjustOrbitRange(0.5),
{ kind: 'tilt', changed: true },
'the tilt must take the zoom',
);
for (let i = 0; i < 5; i += 1) h.tick();
// The next frame's lookAt must not put the old range back.
assert.ok(
Math.abs(pivotRange(h) - before / 2) < 1,
`range ${pivotRange(h)} m, wanted ${before / 2} m`,
);
});
test('a zoom with no pivot to hold is left to the camera', (t) => {
harness(t, { pivot: false });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
assert.equal(adjustOrbitRange(0.5), null);
});
test('a pivot tilt toward the horizon keeps its clearance above the ground', (t) => {
// A close top-down view: swinging 250 m of range to -5° would end ~22 m up.
const h = harness(t, { height: 250 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -5 }).ok, true);
let lowest = Infinity;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
lowest = Math.min(lowest, heightM(h.camera));
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(lowest > 149, `camera dropped to ${lowest} m`);
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(Cesium.Matrix4.equals(h.camera.transform, Cesium.Matrix4.IDENTITY));
});
test('a continuous tilt up from close in keeps its clearance', (t) => {
const h = harness(t, { height: 250 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a city-scale pivot tilt keeps the place centred to the horizon limit', (t) => {
const h = harness(t);
moveCamera({ motion: 'tilt', pitchDeg: -5 });
h.finish();
// 4 km of range clears the floor at every pitch: all pivot, no drift.
assert.ok(centreDrift(h) < 5, `centre drifted ${centreDrift(h)} m`);
});
test('pitchDeg against a contradicting direction is refused', (t) => {
harness(t, { pitch: -45 });
// "tilt down 10 degrees": -10° is an absolute angle, which is up from -45°.
const result = moveCamera({
motion: 'tilt',
direction: 'down',
pitchDeg: 10,
});
assert.equal(result.ok, false);
assert.match(result.error, /absolute angle/);
assert.equal(getActiveCameraMotion(), null);
});
test('pitchDeg with a direction that agrees still tilts', (t) => {
const h = harness(t, { pitch: -45 });
const result = moveCamera({
motion: 'tilt',
direction: 'down',
pitchDeg: -80,
});
assert.equal(result.ok, true);
assert.equal(result.direction, 'down');
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -80) < 0.5);
});
test('the pitchDeg and direction check uses the camera pitch', (t) => {
// At regional range the Earth curves away and the pivot pitch is a degree
// or two shallower than the camera's (-30°); -29° is still up from it.
harness(t, { height: 150000, pitch: -30 });
const down = moveCamera({ motion: 'tilt', direction: 'down', pitchDeg: -29 });
assert.equal(down.ok, false);
assert.match(down.error, /tilts up, not down/);
const up = moveCamera({ motion: 'tilt', direction: 'up', pitchDeg: -29 });
assert.equal(up.ok, true);
assert.equal(up.direction, 'up');
});
test('after a zoom in, a pivoting tilt never lowers the camera further', (t) => {
const h = harness(t, { height: 1000 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -30);
adjustOrbitRange(1 / 2.4);
adjustOrbitRange(1 / 2.4);
h.tick();
const afterZoom = heightM(h.camera);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(
heightM(h.camera) > afterZoom - 1,
`camera fell from ${afterZoom} m to ${heightM(h.camera)} m`,
);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a zoom in during a close pivot tilt never pushes the camera out', (t) => {
// 62 m of range, under the 80 m zoom floor.
const h = harness(t, { height: 40, pitch: -40 });
moveCamera({ motion: 'tilt', direction: 'down', mode: 'continuous' });
h.tick();
const before = pivotRange(h);
assert.ok(before < 80, `range ${before} m`);
adjustOrbitRange(1 / 1.6);
h.tick();
assert.ok(pivotRange(h) <= before + 0.01, `range grew to ${pivotRange(h)} m`);
});
test('a zoom out during a far orbit never pulls the camera in', (t) => {
// 15,000 km up, beyond the 5,000 km zoom cap.
const h = harness(t, { height: 15_000_000 });
assert.equal(moveCamera({ motion: 'orbit', mode: 'continuous' }).ok, true);
h.tick();
const before = pivotRange(h);
assert.ok(before > 5_000_000, `range ${before} m`);
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'orbit', changed: false });
h.tick();
assert.ok(pivotRange(h) >= before - 1, `range fell to ${pivotRange(h)} m`);
});
test('an out-of-range pitchDeg is clamped to the tilt limits', (t) => {
// The schema carries no range; 120 reads as 120° below the horizon.
const h = harness(t, { pitch: -45 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: 120 }).ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -89) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('a zoom in during a pivot tilt stops at the clearance', (t) => {
// 400 m of range at -40°, then "zoom in a lot" twice: unfloored, that left
// the camera about 107 m up, then at the 80 m zoom floor.
const h = harness(t, { height: 400 });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -40);
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: true });
// At -40° the floor is 150 / sin 40° ≈ 233 m, which the first zoom reached.
assert.deepEqual(adjustOrbitRange(1 / 2.4), { kind: 'tilt', changed: false });
// The camera must take that range, not just report it.
h.tick();
const floor = 150 / Math.sin(radians(40));
assert.ok(
Math.abs(pivotRange(h) - floor) < floor * 0.02,
`range ${pivotRange(h)} m, wanted about ${floor} m`,
);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
}
assert.ok(Math.abs(pitchDeg(h.camera) - -5) < 0.5);
});
test('a voice zoom during a pivot tilt says what it changed', async (t) => {
const h = harness(t, { height: 400 });
const runner = createGevActionRunner({
viewer: h.viewer,
styleManager: {},
dataManager: { layers: new Map(), getAll: () => [] },
});
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
h.tick();
const zoom = { direction: 'in', amount: 'lot' };
const first = await runner('adjust_camera_zoom', zoom);
assert.equal(first.ok, true);
assert.equal(first.tiltRangeAdjusted, true);
assert.equal('orbitRadiusAdjusted' in first, false);
// 400 m → 167 m → the 150 m clearance; a third zoom in moves nothing, and
// says so.
assert.equal((await runner('adjust_camera_zoom', zoom)).ok, true);
const third = await runner('adjust_camera_zoom', zoom);
assert.equal(third.ok, false);
assert.match(third.error, /tilt is already at its closest range/);
});
test('from globe range a tilt happens in place', (t) => {
// A pivot 2,000 km out would swing the camera thousands of km.
const h = harness(t, { height: 2_000_000 });
const before = Cesium.Cartesian3.clone(h.camera.positionWC);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -45 }).ok, true);
h.finish();
assert.ok(Math.abs(pitchDeg(h.camera) - -45) < 0.5);
assert.ok(Cesium.Cartesian3.distance(h.camera.positionWC, before) < 1e-3);
});
for (const height of [100_000, 400_000])
test(`from ${height / 1000} km a tilt to -5° ends with the camera at -5°`, (t) => {
// The pivot pitch is shallower than the camera's by up to 4° at this
// range; the camera reached only -5.9° and -8.6° when the swing stopped
// at a pivot pitch of -5°.
const h = harness(t, { height });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.pitchDeg, -5);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(nudge.ok, false);
assert.match(nudge.error, /upper tilt limit/);
});
// From 550 km out the camera's -5° lies beyond the place's own horizon: the
// swing ran into its clearance, let go of the pivot and tilted the rest in
// place, which put the crosshair on the sky (28 to 63 km up), and a return
// to top-down then landed 600 to 900 km from the place.
for (const height of [600_000, 700_000, 900_000]) {
test(`from ${height / 1000} km a tilt to -5° keeps the place centred`, (t) => {
const h = harness(t, { height });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.ok, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
assert.ok(heightM(h.camera) > 149, `camera at ${heightM(h.camera)} m`);
// The reply names the angle the camera reached, not the one asked for.
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
assert.ok(
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
);
assert.match(result.note, /horizon/);
const nudge = moveCamera({ motion: 'tilt', direction: 'up' });
assert.equal(nudge.ok, false);
assert.match(nudge.error, /upper tilt limit/);
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -89 }).ok, true);
h.finish();
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
// Back over the place at the range it began with (-89° is ~1° off nadir).
const range = Cesium.Cartesian3.distance(h.camera.positionWC, h.target);
assert.ok(
Math.abs(range - height) < height * 0.01,
`top-down ${range} m from the place`,
);
});
test(`a continuous tilt up from ${height / 1000} km stops with the place centred`, (t) => {
const h = harness(t, { height });
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
for (let i = 0; i < 1200 && getActiveCameraMotion(); i += 1) {
h.tick();
assert.ok(h.viewTarget(), `the crosshair is on the sky at frame ${i}`);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must stop at its limit');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
});
}
test('a pitchDeg tilt toward the horizon limit says it is already there', (t) => {
const h = harness(t, { height: 900_000 });
moveCamera({ motion: 'tilt', pitchDeg: 5 });
h.finish();
const again = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(again.ok, false);
assert.match(again.error, /upper tilt limit/);
});
test('a regional tilt-up nudge goes to the camera horizon limit', (t) => {
// 20 km up at -7°: the pivot 186 km out sits at -5.3°, but the camera can
// still tilt up 2°.
const h = harness(t, { height: 20000, pitch: -7 });
assert.equal(moveCamera({ motion: 'tilt', direction: 'up' }).ok, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('a voice tilt from a tracked view judges its limit from the camera pitch', (t) => {
const h = harness(t, { height: 20000, pitch: -5.2 });
h.viewer.trackedEntity = { id: 'tracked' };
// Voice releases the follow camera before the motion starts.
const release = (start) => {
h.viewer.trackedEntity = undefined;
return start();
};
const result = moveCamera({ motion: 'tilt', direction: 'up' }, release);
assert.equal(result.ok, false);
assert.match(result.error, /upper tilt limit/);
});
test('a zoom out during a pivoting tilt stops at the pivot range limit', (t) => {
// 900 km top-down, "tilt to 10°", then "zoom out a lot" twice: capped at
// the orbit's 5,000 km, the swing reached 2,386 km up at a real -44°.
const h = harness(t, { height: 900_000 });
assert.equal(moveCamera({ motion: 'tilt', pitchDeg: -10 }).ok, true);
h.tick();
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: true });
assert.deepEqual(adjustOrbitRange(2.4), { kind: 'tilt', changed: false });
let farthest = 0;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
farthest = Math.max(farthest, pivotRange(h));
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(farthest <= 1_000_001, `swung ${farthest} m from the pivot`);
assert.ok(
Math.abs(pitchDeg(h.camera) - -10) < 0.5,
`pitch ${pitchDeg(h.camera)}`,
);
});
test('an unchanged angle still stops a running motion through navigation', (t) => {
const h = harness(t);
moveCamera({ motion: 'tilt', direction: 'up', mode: 'continuous' });
tickUntil(h, () => pitchDeg(h.camera) >= -45.3);
let handoffs = 0;
const navigate = (start) => {
handoffs += 1;
return start();
};
const result = moveCamera({ motion: 'tilt', pitchDeg: -45 }, navigate);
assert.equal(result.ok, true);
assert.equal(result.unchanged, true);
assert.equal(handoffs, 1, 'the request must take navigation');
assert.equal(getActiveCameraMotion(), null, 'the tilt must stop');
const held = pitchDeg(h.camera);
for (let i = 0; i < 30; i += 1) h.tick();
assert.equal(pitchDeg(h.camera), held);
});
for (const direction of ['left', 'right'])
test(`a tilt ${direction} is refused`, (t) => {
harness(t, { pitch: -45 });
const result = moveCamera({ motion: 'tilt', direction });
assert.equal(result.ok, false);
assert.match(result.error, /tilt needs up or down/);
const angled = moveCamera({ motion: 'tilt', direction, pitchDeg: -30 });
assert.equal(angled.ok, false);
assert.match(angled.error, /pitchDeg alone/);
assert.equal(getActiveCameraMotion(), null);
});
// The 1,000 km pivot cutoff includes its boundary: at exactly 1,000 km the
// pivot range came back a hair over it, so the tilt ran in place and put the
// crosshair on the sky.
test('from exactly 1,000 km a tilt to -5° still pivots about the place', (t) => {
const h = harness(t, { height: 1_000_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: 5 });
assert.equal(result.ok, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(centreDrift(h) < 100, `centre drifted ${centreDrift(h)} m`);
assert.ok(
Math.abs(pitchDeg(h.camera) - result.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, reported ${result.pitchDeg}°`,
);
assert.match(result.note, /horizon/);
});
// A zoom mid-tilt moves the pivot's horizon limit: the zoom's reply names the
// angle the tilt now ends at, and the camera ends there.
test('a zoom out during a tilt to an angle reports the angle it now ends at', (t) => {
const h = harness(t, { height: 400_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
assert.equal(result.pitchDeg, -5);
assert.equal(result.note, undefined);
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.kind, 'tilt');
assert.equal(zoom.changed, true);
h.finish();
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
assert.ok(zoom.pitchDeg < -5, `zoom reported ${zoom.pitchDeg}°`);
assert.ok(
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
test('a zoom in during a horizon-limited tilt reports the angle it now reaches', (t) => {
const h = harness(t, { height: 900_000 });
const result = moveCamera({ motion: 'tilt', pitchDeg: -5 });
assert.ok(result.pitchDeg < -5, `reported ${result.pitchDeg}°`);
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = adjustOrbitRange(1 / 2.4);
assert.equal(zoom.changed, true);
h.finish();
assert.equal(zoom.pitchDeg, -5);
assert.ok(
Math.abs(pitchDeg(h.camera) - -5) < 0.5,
`camera at ${pitchDeg(h.camera)}°`,
);
});
test('a voice zoom during a tilt to an angle passes on the new end angle', async (t) => {
const h = harness(t, { height: 400_000 });
const runner = createGevActionRunner({
viewer: h.viewer,
styleManager: {},
dataManager: { layers: new Map(), getAll: () => [] },
});
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 10; i += 1) h.tick();
const zoom = await runner('adjust_camera_zoom', {
direction: 'out',
amount: 'lot',
});
assert.equal(zoom.ok, true);
assert.equal(zoom.tiltRangeAdjusted, true);
h.finish();
assert.ok(
Math.abs(pitchDeg(h.camera) - zoom.pitchDeg) < 0.5,
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// Late in the tilt the camera is already past the angle the new range's
// horizon allows (from 400 km at -8°, a zoom out x2.5 reported -8°): past it
// the place is behind the Earth, so the camera steps down to that limit with
// the zoom, then holds it with no further jump, and the zoom's reply names
// the angle it ends at, not the one it stood at.
test('a late zoom out during a tilt steps to the new limit and reports it', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.changed, true);
h.tick();
assert.equal(zoom.pitchDeg, Math.round(pitchDeg(h.camera)));
let last = pitchDeg(h.camera);
let maxStep = 0;
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
maxStep = Math.max(maxStep, Math.abs(pitchDeg(h.camera) - last));
last = pitchDeg(h.camera);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(maxStep < 0.1, `pitch jumped ${maxStep}° in one frame`);
assert.ok(h.viewTarget(), 'the crosshair is on the sky');
// Replies give whole degrees: the reported angle is the reached one rounded.
assert.equal(
zoom.pitchDeg,
Math.round(pitchDeg(h.camera)),
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// Easing from the camera's old pitch at the zoomed-out range put the camera
// below the place's horizon: the first frame's crosshair hit the ground
// 186 km from London, and London stayed hidden for ~11 frames.
test('a late zoom out during a tilt keeps the place under the crosshair', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const zoom = adjustOrbitRange(2.5);
assert.equal(zoom.changed, true);
for (let i = 0; i < 600 && getActiveCameraMotion(); i += 1) {
h.tick();
const hit = h.viewTarget();
assert.ok(hit, `frame ${i}: the crosshair is on the sky`);
const drift = centreDrift(h);
assert.ok(
drift < 100,
`frame ${i}: crosshair ${Math.round(drift)} m off the place`,
);
}
assert.equal(getActiveCameraMotion(), null, 'tilt must finish');
assert.ok(
Math.abs(pivotRange(h) - 1_000_000) < 1,
`range ${pivotRange(h)} m`,
);
assert.equal(
zoom.pitchDeg,
Math.round(pitchDeg(h.camera)),
`camera at ${pitchDeg(h.camera)}°, zoom reported ${zoom.pitchDeg}°`,
);
assert.match(zoom.note, /horizon/);
});
// The zoom out left the pivot pitch level with the place, so the zoom in's
// clearance floor came out infinite and it was refused at 1,000 km.
test('a zoom in right after a late zoom out brings the tilt back in', (t) => {
const h = harness(t, { height: 400_000 });
moveCamera({ motion: 'tilt', pitchDeg: -5 });
for (let i = 0; i < 70; i += 1) h.tick();
const range = pivotRange(h);
assert.equal(adjustOrbitRange(2.5).changed, true);
const zoomIn = adjustOrbitRange(0.4);
assert.equal(zoomIn.changed, true, 'the zoom in must not be refused');
h.finish();
assert.ok(
Math.abs(pivotRange(h) - range) < 1,
`range ${pivotRange(h)} m, wanted ${range} m`,
);
assert.ok(
centreDrift(h) < 100,
`crosshair ${centreDrift(h)} m off the place`,
);
});
+748 -83
View File
@@ -20,11 +20,19 @@
import * as Cesium from 'cesium'; import * as Cesium from 'cesium';
import { GLOBE_VIEW } from './locations.js'; import { GLOBE_VIEW } from './locations.js';
import {
globeFrameShare,
isFreeGlobeCamera,
lookAngleWithinFrame,
lookStepLosesGlobe,
} from './globeInView.js';
import { import {
projectEarthDiscToViewport, projectEarthDiscToViewport,
earthDiscScreenRadius, earthDiscScreenRadius,
getKeyholeGeometry, getKeyholeGeometry,
KEYHOLE_OUTER_RADIUS,
} from './celestialRing.js'; } from './celestialRing.js';
import { isScopeMaskEnabled } from './scopeMask.js';
import { import {
holdContinuousRender, holdContinuousRender,
releaseContinuousRender, releaseContinuousRender,
@@ -48,6 +56,33 @@ const ONCE = {
}; };
const PITCH_MIN = Cesium.Math.toRadians(-89); const PITCH_MIN = Cesium.Math.toRadians(-89);
const PITCH_MAX = Cesium.Math.toRadians(-5); const PITCH_MAX = Cesium.Math.toRadians(-5);
/** A bare "tilt the camera" / "oblique view" settles here. */
const TILT_DEFAULT_PITCH_DEG = -45;
/** °/s for a tilt to a requested angle; short ones keep the once duration. */
const TILT_TO_DEG_S = 30;
/**
* A pivoting tilt keeps the range, so its height above the pivot is
* range × sin|pitch|. It swings no lower than this; the rest tilts in place.
*/
const TILT_MIN_CLEARANCE_M = 150;
/**
* Beyond this range a pivot would swing the camera thousands of km about the
* crosshair point, so the tilt happens in place instead.
*/
const TILT_PIVOT_MAX_RANGE_M = 1_000_000;
/**
* A pivot range rebuilt from the camera's position comes back a hair off the
* range it was set at: a range this close past the cutoff still counts as on
* it.
*/
const TILT_PIVOT_RANGE_SLACK_M = 1;
/**
* The shallowest a pivot swing goes when its clearance is not what stops it.
* From several hundred km the camera's own horizon limit lies past the
* place's horizon; tilting the rest in place there would lift the crosshair
* onto the sky, so the swing stops with the place this far above it.
*/
const TILT_MIN_PIVOT_ELEVATION = Cesium.Math.toRadians(0.5);
/* ── Route dolly: cinematic tuning ────────────────────────────────────────── /* ── Route dolly: cinematic tuning ──────────────────────────────────────────
* Every knob an owner may want to retune lives in this block. The shaping is * Every knob an owner may want to retune lives in this block. The shaping is
@@ -966,6 +1001,17 @@ let _trackingRemover = null;
let _inputRemovers = []; let _inputRemovers = [];
/** Monotonic id for the motion currently running; 0 means "no motion". */ /** Monotonic id for the motion currently running; 0 means "no motion". */
let _motionSeq = 0; let _motionSeq = 0;
/** Canvas grabs, touches and wheel turns since load. */
let _manualInputCount = 0;
/**
* Lets a caller holding a motion back (behind a fly-to) see whether the user
* took the camera meanwhile; Cesium does not cancel a flight on input.
* @returns {number} Manual camera inputs so far.
*/
export function manualCameraInputCount() {
return _manualInputCount;
}
/** /**
* Install a motion and stamp it with a fresh id. The id is what lets a caller * Install a motion and stamp it with a fresh id. The id is what lets a caller
@@ -1008,6 +1054,187 @@ export function interruptCameraMotionIfActive(
return interruptCameraMotion(reason); return interruptCameraMotion(reason);
} }
// The frame share that in-place voice turns measure their limit against, with
// the pose the last voice turn left the camera in. A turn that starts where
// the last one left off keeps that baseline (raised if the planet now fills
// more of the frame), so repeating a request cannot take a little more each
// time. Once anything else has moved or turned the camera, a turn starts over
// from what is on screen.
let _lookBaseline = null;
// The pre-check's proof that a half-degree turn from this pose keeps the planet
// in view: which way, from which position and direction. A motion that starts from that
// pose turns at least that far, so a request reported as done really moves
// even when a smaller first step lands in a dip of the sampled share.
const PROVEN_TURN_RAD = Cesium.Math.toRadians(0.5);
let _provenLook = null;
/** The part of the frame a viewer can see: the scope keyhole when it is on. */
function visibleFrame() {
return { keyholeRadius: isScopeMaskEnabled() ? KEYHOLE_OUTER_RADIUS : 0 };
}
/** The frame share an in-place turn from `cam`'s pose is limited against. */
function lookBaselineShare(cam) {
const share = globeFrameShare(cam, visibleFrame());
const continues =
_lookBaseline &&
Cesium.Cartesian3.equalsEpsilon(
_lookBaseline.position,
cam.positionWC,
0,
1,
) &&
Cesium.Cartesian3.equalsEpsilon(
_lookBaseline.direction,
cam.directionWC,
0,
1e-9,
);
_lookBaseline = {
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
share: continues ? Math.max(_lookBaseline.share, share) : share,
};
return _lookBaseline.share;
}
/**
* One in-place look step of motion `m`. From orbit a free globe camera turns
* only as far as keeps the planet in frame, measured against the baseline the
* turn started from, then the motion ends there.
*/
function lookWithinFrame(cam, m, look, angle) {
let allowed = angle;
if (isFreeGlobeCamera(_viewer)) {
if (m.globeShareAtStart === undefined) {
m.globeShareAtStart = lookBaselineShare(cam);
m.provenTurnRad = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
m.turnedRad = 0;
}
_provenLook = null;
allowed = lookAngleWithinFrame(cam, look, angle, {
...visibleFrame(),
startShare: m.globeShareAtStart,
});
const owed = Math.min(angle, m.provenTurnRad - m.turnedRad);
if (allowed < owed) allowed = owed;
m.turnedRad += allowed;
}
if (allowed > 0) cam[look](allowed);
// The pose this turn leaves, for a turn that continues from it.
if (_lookBaseline)
Cesium.Cartesian3.clone(cam.directionWC, _lookBaseline.direction);
if (allowed < angle) interruptCameraMotion('globe-out-of-frame');
}
/**
* The in-place look a pitch change from `fromPitch` to `goalPitch` makes:
* 'lookUp', 'lookDown', or null when it does not move. A tilt is judged by
* the way its pitch moves, whatever form or direction word it came in.
*/
function tiltLook(fromPitch, goalPitch) {
if (!Number.isFinite(fromPitch) || !Number.isFinite(goalPitch)) return null;
if (goalPitch > fromPitch) return 'lookUp';
return goalPitch < fromPitch ? 'lookDown' : null;
}
/** Tilt `m` in place to `goalPitch`, as far as the frame limit allows. */
function tiltInPlaceWithinFrame(cam, m, goalPitch) {
const look = tiltLook(cam.pitch, goalPitch);
if (look) lookWithinFrame(cam, m, look, Math.abs(goalPitch - cam.pitch));
}
/**
* The pitch an in-place tilt from `cam`'s pose toward `goalPitch` reaches
* before the frame limit stops it, judged as its steps will be: `goalPitch`
* itself when the planet stays in frame the whole way, or the camera is not a
* free globe camera. The steps run on a copy of the camera, each looking by
* the pitch still to go as the tilt's frames do, because a rolled camera's
* look turns its pitch by less than the look angle.
*/
function tiltPitchWithinFrame(cam, goalPitch) {
const look = tiltLook(cam.pitch, goalPitch);
if (!look || !isFreeGlobeCamera(_viewer)) return goalPitch;
const frame = { ...visibleFrame(), startShare: lookBaselineShare(cam) };
// A proven half degree is turned whatever the sampled share says.
let owed = lookProven(cam, look) ? PROVEN_TURN_RAD : 0;
const probe = Cesium.Camera.clone(cam);
for (let i = 0; i < 32; i += 1) {
if (tiltLook(probe.pitch, goalPitch) !== look) return goalPitch;
const angle = Math.abs(goalPitch - probe.pitch);
if (angle < 1e-6) return goalPitch;
const allowed = Math.max(
lookAngleWithinFrame(probe, look, angle, frame),
Math.min(angle, owed),
);
owed -= allowed;
if (allowed > 0) probe[look](allowed);
if (allowed < angle) return probe.pitch;
}
return probe.pitch;
}
/** The reply's note for a tilt the frame limit stops short of its angle. */
function tiltFrameNote(pitch) {
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this height a shallower angle would turn the globe out of view. Zoom in to tilt further.`;
}
/** True when the pre-check proved a half-degree `look` from `cam`'s pose. */
function lookProven(cam, look) {
return (
_provenLook?.look === look &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.position,
cam.positionWC,
0,
1,
) &&
Cesium.Cartesian3.equalsEpsilon(
_provenLook.direction,
cam.directionWC,
0,
1e-9,
)
);
}
/**
* The check a voice in-place turn (`look`: 'lookUp', 'lookDown', 'lookLeft'
* or 'lookRight') must pass from orbit: refused when even half a degree would
* cross the frame limit, otherwise proven for that half degree from this pose.
* With `released`, the turn runs through a navigation that first releases a
* tracked or locked camera and keeps its world pose. The frame measure and the
* turn are the same in any reference frame, so such a camera is judged as it
* is, before the release, and a refusal leaves tracking as it was. Otherwise a
* camera that is not a free globe camera is not checked. Returns the refusal,
* or null.
*/
function checkLookFromOrbit(look, { released = false } = {}) {
_provenLook = null;
if (_viewer.scene.mode !== Cesium.SceneMode.SCENE3D) return null;
const cam = _viewer.camera;
if (!released && !isFreeGlobeCamera(_viewer)) return null;
if (
lookStepLosesGlobe(cam, look, PROVEN_TURN_RAD, {
...visibleFrame(),
startShare: lookBaselineShare(cam),
})
)
return {
ok: false,
action: 'move_camera',
error:
'From this height that would turn the globe out of view — zoom in first, or turn the other way.',
};
_provenLook = {
look,
position: Cesium.Cartesian3.clone(cam.positionWC),
direction: Cesium.Cartesian3.clone(cam.directionWC),
};
return null;
}
function clearLookAt() { function clearLookAt() {
try { try {
_viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); _viewer.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
@@ -1052,7 +1279,9 @@ export function interruptCameraMotion(reason = 'interrupt') {
if ( if (
wasActive && wasActive &&
!tracking && !tracking &&
(_active.kind === 'orbit' || _active.kind === 'route') (_active.kind === 'orbit' ||
_active.kind === 'route' ||
(_active.kind === 'tilt' && _active.hpr))
) )
clearLookAt(); clearLookAt();
// Instant, not eased: the user grabbed the camera, or the flight is over. // Instant, not eased: the user grabbed the camera, or the flight is over.
@@ -1104,7 +1333,7 @@ function onTick() {
let scale = 1; let scale = 1;
if (m.mode === 'once') { if (m.mode === 'once') {
m.elapsed = (m.elapsed || 0) + dt; m.elapsed = (m.elapsed || 0) + dt;
const t = Math.min(1, m.elapsed / ONCE.durationS); const t = Math.min(1, m.elapsed / (m.durationS || ONCE.durationS));
scale = 1 - (1 - t) * (1 - t); // ease-out progress share scale = 1 - (1 - t) * (1 - t); // ease-out progress share
if (t >= 1) { if (t >= 1) {
interruptCameraMotion('once-complete'); interruptCameraMotion('once-complete');
@@ -1173,30 +1402,20 @@ function onTick() {
else if (m.direction === 'right') cam.moveRight(step); else if (m.direction === 'right') cam.moveRight(step);
else if (m.direction === 'up') cam.moveUp(step); else if (m.direction === 'up') cam.moveUp(step);
else cam.moveDown(step); else cam.moveDown(step);
} else if ( } else if (m.kind === 'tilt') {
m.kind === 'tilt' || tickTilt(cam, m, scale, dt);
m.kind === 'rotate' || } else if (m.kind === 'rotate' || m.kind === 'rotate_globe') {
m.kind === 'rotate_globe'
) {
const degS = TILT_DEG_S[m.speed]; const degS = TILT_DEG_S[m.speed];
const stepRad = const stepRad =
m.mode === 'once' m.mode === 'once'
? Cesium.Math.toRadians( ? Cesium.Math.toRadians(ONCE.rotateDeg) * (scale - (m.lastScale || 0))
m.kind === 'tilt' ? ONCE.tiltDeg : ONCE.rotateDeg,
) *
(scale - (m.lastScale || 0))
: Cesium.Math.toRadians(degS) * dt; : Cesium.Math.toRadians(degS) * dt;
m.lastScale = scale; m.lastScale = scale;
if (m.kind === 'rotate_globe') { if (m.kind === 'rotate_globe') {
// Tracking can acquire the camera without going through navigation. // Tracking can acquire the camera without going through navigation.
// Yield without resetting its pose/frame: the world-space arc proof // Yield without resetting its pose/frame: the world-space arc proof
// only describes Cesium's rotation while the frame is identity. // only describes Cesium's rotation while the frame is identity.
if ( if (!isFreeGlobeCamera(_viewer) || _isCockpitActive?.()) {
_viewer.scene.mode !== Cesium.SceneMode.SCENE3D ||
_viewer.trackedEntity ||
_isCockpitActive?.() ||
!Cesium.Matrix4.equals(cam.transform, Cesium.Matrix4.IDENTITY)
) {
interruptCameraMotion('globe-camera-owner-changed'); interruptCameraMotion('globe-camera-owner-changed');
return; return;
} }
@@ -1218,18 +1437,14 @@ function onTick() {
return; return;
} }
cam.rotate(m.rotationAxis, angle); cam.rotate(m.rotationAxis, angle);
} else if (m.kind === 'tilt') { } else {
const up = m.direction === 'up'; lookWithinFrame(
// Clamp pitch to [-89°, -5°]: never through the ground, never at the sky. cam,
const next = cam.pitch + (up ? stepRad : -stepRad); m,
if (next > PITCH_MAX || next < PITCH_MIN) { m.direction === 'left' ? 'lookLeft' : 'lookRight',
interruptCameraMotion('tilt-clamp'); stepRad,
return; );
} }
if (up) cam.lookUp(stepRad);
else cam.lookDown(stepRad);
} else if (m.direction === 'left') cam.lookLeft(stepRad);
else cam.lookRight(stepRad);
} else if (m.kind === 'route') { } else if (m.kind === 'route') {
// The dolly's shaping lives in advanceRouteFlight(); the tick only // The dolly's shaping lives in advanceRouteFlight(); the tick only
// applies the pose and honours the same completion path as before. // applies the pose and honours the same completion path as before.
@@ -1252,6 +1467,186 @@ function onTick() {
} }
} }
/**
* One frame of tilt. With a ground pivot the camera swings about it (lookAt),
* so the place under the crosshair stays centred; without one it looks up or
* down in place. Every angle and limit is the camera's own pitch, which at
* regional range is several degrees steeper than the pivot's (the Earth
* curves away), so the swing solves for the pivot pitch that gives it. Pitch
* is clamped to [-89°, -5°] only in the direction of travel: a top-down view
* sits at -90°, below the limit, and must still be able to tilt up out of it.
* A pivot swing toward the horizon lowers the camera, so it stops at
* TILT_MIN_CLEARANCE_M and the rest tilts in place. Farther out it stops with
* the camera TILT_MIN_PIVOT_ELEVATION above the place's horizon instead, and
* keeps the place centred: tilting in place there would lift the crosshair
* onto the sky.
*/
function tickTilt(cam, m, scale, dt) {
const from = cam.pitch;
const want =
m.mode === 'once'
? m.startPitch + (m.goalPitch - m.startPitch) * scale
: from +
Cesium.Math.toRadians(TILT_DEG_S[m.speed]) *
dt *
(m.direction === 'up' ? 1 : -1);
const up = want > from;
const limited = up
? Math.min(want, Math.max(from, PITCH_MAX))
: Math.max(want, Math.min(from, PITCH_MIN));
let atLimit = false;
if (m.hpr) {
// Apply a range a zoom set since the last frame, even when the pitch
// then has nowhere to go (a zoom in to the clearance ends the pivot).
cam.lookAt(m.target, m.hpr);
// Up toward the horizon the swing lowers the camera: hold the clearance,
// and keep the place below the horizon.
const cap = up
? Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range))
: Infinity;
for (let i = 0; i < 3 && Math.abs(limited - cam.pitch) > 1e-6; i += 1) {
const pitch = Math.min(
cap,
Math.max(-Cesium.Math.PI_OVER_TWO, m.hpr.pitch + limited - cam.pitch),
);
if (pitch === m.hpr.pitch) break;
m.hpr.pitch = pitch;
cam.lookAt(m.target, m.hpr);
}
// A once motion that just completed released the lookAt frame before
// this last step re-took it.
if (_active !== m) clearLookAt();
if (up && m.hpr.pitch >= cap && cam.pitch < limited - 1e-6) {
if (tiltClearanceBinds(m.hpr.range)) {
// Down to its clearance: let go of the pivot and tilt the rest in
// place, which keeps the height.
if (_active === m) clearLookAt();
m.hpr = null;
m.target = null;
} else {
// The place is at its horizon limit: stop here, still centred.
atLimit = true;
}
}
}
if (!m.hpr) tiltInPlaceWithinFrame(cam, m, limited);
if (m.mode !== 'once' && (limited !== want || atLimit))
interruptCameraMotion('tilt-clamp');
}
/** The refusal for a tilt up the camera has no room left for. */
function tiltUpLimitRefusal() {
return {
ok: false,
action: 'move_camera',
error:
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
};
}
/** Shallowest pivot pitch that keeps the clearance at this range. */
function tiltPivotPitchMax(range) {
return Math.min(
-Math.asin(Math.min(1, TILT_MIN_CLEARANCE_M / range)),
-TILT_MIN_PIVOT_ELEVATION,
);
}
/**
* Whether the clearance, not the horizon, stops a swing at this range. Only
* then does the rest of the tilt happen in place: that close in (under ~17
* km) the camera's -5° limit stays well below its own horizon.
*/
function tiltClearanceBinds(range) {
return TILT_MIN_CLEARANCE_M / range >= Math.sin(TILT_MIN_PIVOT_ELEVATION);
}
/**
* The shallowest camera pitch a tilt starting from this frame can reach with
* the place under the crosshair still in view: PITCH_MAX, or less where a
* far pivot meets its horizon limit first.
*/
function tiltUpLimit({ target, pivot }) {
if (!pivot || tiltClearanceBinds(pivot.range)) return PITCH_MAX;
return Math.min(
PITCH_MAX,
pivotCameraPitch(
target,
pivot,
Math.max(pivot.pitch, tiltPivotPitchMax(pivot.range)),
),
);
}
/** The camera pitch lookAt gives for this pivot at the given pivot pitch. */
function pivotCameraPitch(target, { heading, range }, pitch) {
const offset = new Cesium.Cartesian3(
-range * Math.cos(pitch) * Math.sin(heading),
-range * Math.cos(pitch) * Math.cos(heading),
-range * Math.sin(pitch),
);
const eye = Cesium.Matrix4.multiplyByPoint(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
offset,
new Cesium.Cartesian3(),
);
const look = Cesium.Cartesian3.subtract(target, eye, new Cesium.Cartesian3());
Cesium.Cartesian3.normalize(look, look);
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
const upAtEye = ellipsoid.geodeticSurfaceNormal(eye, new Cesium.Cartesian3());
return Math.asin(
Cesium.Math.clamp(Cesium.Cartesian3.dot(look, upAtEye), -1, 1),
);
}
/**
* Where a tilt starts. It swings about the ground under the crosshair so the
* place stays centred. It tilts in place with no ground there, from globe
* range, or while an entity is tracked (the follow camera owns that frame;
* voice releases it before moving, so a voice tilt still pivots).
*/
function tiltStartFrame(cam) {
const target = _viewer.trackedEntity ? null : _getTarget?.(_viewer);
let pivot = target ? tiltPivot(cam, target) : null;
if (pivot && !tiltPivotInReach(pivot.range)) pivot = null;
return { target, pivot, from: cam.pitch };
}
/** Whether a tilt may pivot at this range: up to and including the cutoff. */
function tiltPivotInReach(range) {
return range <= TILT_PIVOT_MAX_RANGE_M + TILT_PIVOT_RANGE_SLACK_M;
}
/** The reply's note for a tilt the place's horizon stops short of its angle. */
function tiltHorizonNote(pitch) {
return `Stopped at ${Math.round(Cesium.Math.toDegrees(pitch))}°: from this range a shallower angle puts the place past the horizon. Zoom in to tilt further.`;
}
/** Ground pivot for a tilt: heading, pitch and range in the target's own frame. */
function tiltPivot(cam, target) {
const local = Cesium.Matrix4.multiplyByPoint(
Cesium.Matrix4.inverseTransformation(
Cesium.Transforms.eastNorthUpToFixedFrame(target),
new Cesium.Matrix4(),
),
cam.positionWC,
new Cesium.Cartesian3(),
);
const range = Cesium.Cartesian3.magnitude(local);
if (!(range > 1) || local.z <= 0) return null;
// Near straight overhead the offset's heading is float noise (it flipped a
// north-up London view to south); keep the camera's own.
const heading =
Math.hypot(local.x, local.y) > range * 0.01
? Math.atan2(-local.x, -local.y)
: cam.heading;
return new Cesium.HeadingPitchRange(
heading,
-Math.asin(Math.min(1, local.z / range)),
range,
);
}
/** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */ /** Rigid origin-centred rotation stays outside the actual ellipsoid's bounding sphere. */
function globeRotationHasClearance(position) { function globeRotationHasClearance(position) {
const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84; const ellipsoid = _viewer.scene.globe?.ellipsoid ?? Cesium.Ellipsoid.WGS84;
@@ -1413,6 +1808,7 @@ export function initCameraVerbs(
_isCockpitActive = _isCockpitActive =
typeof isCockpitActive === 'function' ? isCockpitActive : null; typeof isCockpitActive === 'function' ? isCockpitActive : null;
_viewer = viewer; _viewer = viewer;
_lookBaseline = null;
if (_tickRemover) _tickRemover(); if (_tickRemover) _tickRemover();
_tickRemover = viewer.clock.onTick.addEventListener(onTick); _tickRemover = viewer.clock.onTick.addEventListener(onTick);
if (_trackingRemover) _trackingRemover(); if (_trackingRemover) _trackingRemover();
@@ -1421,18 +1817,108 @@ export function initCameraVerbs(
_trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => { _trackingRemover = viewer.trackedEntityChanged?.addEventListener((entity) => {
if (entity && _active?.kind === 'rotate_globe') if (entity && _active?.kind === 'rotate_globe')
interruptCameraMotion('globe-camera-owner-changed'); interruptCameraMotion('globe-camera-owner-changed');
// A pivoting tilt's lookAt would fight the follow camera every frame.
else if (entity && _active?.kind === 'tilt' && _active.hpr)
interruptCameraMotion('tilt-camera-owner-changed');
}); });
for (const rm of _inputRemovers) rm(); for (const rm of _inputRemovers) rm();
_inputRemovers = []; _inputRemovers = [];
const canvas = viewer.scene.canvas; const canvas = viewer.scene.canvas;
// ANY manual camera input reclaims control — the cancelFlight reflex. // ANY manual camera input reclaims control — the cancelFlight reflex.
for (const evt of ['pointerdown', 'touchstart', 'wheel']) { for (const evt of ['pointerdown', 'touchstart', 'wheel']) {
const h = () => interruptCameraMotion('manual-input'); const h = () => {
_manualInputCount += 1;
interruptCameraMotion('manual-input');
};
canvas.addEventListener(evt, h, { passive: true }); canvas.addEventListener(evt, h, { passive: true });
_inputRemovers.push(() => canvas.removeEventListener(evt, h)); _inputRemovers.push(() => canvas.removeEventListener(evt, h));
} }
} }
/**
* A tilt's angle: the one asked for, the default oblique view when a bare
* "tilt the camera" names neither angle nor direction, or null for a nudge.
*/
function requestedTiltPitch(args, direction) {
// Only a real number or numeric text: Number('') and Number(null) are 0,
// which would swing the camera to the horizon.
const raw = args.pitchDeg;
const requested =
typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== '')
? Number(raw)
: NaN;
// The camera never looks above the horizon, so a positive angle can only
// mean the same angle below it ("tilt 45 degrees" sent as 45).
if (Number.isFinite(requested))
return Cesium.Math.clamp(
Cesium.Math.toRadians(-Math.abs(requested)),
PITCH_MIN,
PITCH_MAX,
);
return direction === null
? Cesium.Math.toRadians(TILT_DEFAULT_PITCH_DEG)
: null;
}
/**
* Argument errors move_camera refuses whatever the camera is doing, so a
* caller holding a motion back behind a flight can refuse it before waiting.
* @param {Object} args
* @returns {Object|null} The refusal, or null when the arguments are usable.
*/
export function moveCameraArgsError(args = {}) {
const motion = String(args.motion || '').toLowerCase();
const direction = args.direction
? String(args.direction).toLowerCase()
: null;
if (motion === 'stop') return null;
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) {
return {
ok: false,
action: 'move_camera',
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`,
};
}
if (
motion !== 'orbit' &&
motion !== 'rotate_globe' &&
(motion !== 'tilt' || requestedTiltPitch(args, direction) === null) &&
!['left', 'right', 'up', 'down'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: `${motion} needs a direction (${motion === 'tilt' ? 'up/down' : motion === 'rotate' ? 'left/right' : 'left/right/up/down'}).`,
};
}
if (motion === 'tilt' && (direction === 'left' || direction === 'right')) {
return {
ok: false,
action: 'move_camera',
error: 'tilt needs up or down, or pitchDeg alone for an angle.',
};
}
if (motion === 'rotate' && !['left', 'right'].includes(direction)) {
return {
ok: false,
action: 'move_camera',
error: 'rotate needs left or right.',
};
}
if (
motion === 'rotate_globe' &&
direction !== null &&
!['left', 'right'].includes(direction)
) {
return {
ok: false,
action: 'move_camera',
error: 'rotate_globe needs left or right.',
};
}
return null;
}
/** /**
* move_camera implementation. Returns the house result shape; rejections are * move_camera implementation. Returns the house result shape; rejections are
* plain-English `ok:false` errors. * plain-English `ok:false` errors.
@@ -1465,54 +1951,66 @@ export function moveCamera(args = {}, runNavigation = null) {
}; };
return typeof runNavigation === 'function' ? runNavigation(stop) : stop(); return typeof runNavigation === 'function' ? runNavigation(stop) : stop();
} }
if (!['orbit', 'pan', 'tilt', 'rotate', 'rotate_globe'].includes(motion)) { const argsError = moveCameraArgsError(args);
return { if (argsError) return argsError;
ok: false, const tiltToPitch =
action: 'move_camera', motion === 'tilt' ? requestedTiltPitch(args, direction) : null;
error: `Unknown motion "${args.motion}" — use orbit, pan, tilt, rotate, rotate_globe, or stop.`, if (motion === 'tilt' && tiltToPitch !== null) {
}; // Angles and limits are the camera's own pitch, whatever the swing pivots
} // about; releasing the follow camera does not change it.
if ( const from = _viewer.camera.pitch;
motion !== 'orbit' && // -89 or -90 means top-down, which a -90° view already is: the clamp
motion !== 'rotate_globe' && // must not tilt it up 1°.
!['left', 'right', 'up', 'down'].includes(direction) const atTopDown = tiltToPitch <= PITCH_MIN && from <= PITCH_MIN;
) { if (
return { atTopDown ||
ok: false, Math.abs(tiltToPitch - from) < Cesium.Math.toRadians(0.5)
action: 'move_camera', ) {
error: `${motion} needs a direction (left/right${motion !== 'rotate' ? '/up/down' : ''}).`, // Still a camera request: it stops a running motion and releases the
}; // follow camera, as a tilt that moves would.
} const hold = () => {
if (motion === 'rotate' && !['left', 'right'].includes(direction)) { interruptCameraMotion('replaced');
return { return {
ok: false, ok: true,
action: 'move_camera', action: 'move_camera',
error: 'rotate needs left or right.', motion,
}; pitchDeg: Math.round(
} Cesium.Math.toDegrees(atTopDown ? from : tiltToPitch),
if ( ),
motion === 'rotate_globe' && unchanged: true,
direction !== null && };
!['left', 'right'].includes(direction) };
) { return typeof runNavigation === 'function' ? runNavigation(hold) : hold();
return { }
ok: false, // pitchDeg is an absolute angle, not a step: "tilt down 10 degrees" sent
action: 'move_camera', // as {direction:'down', pitchDeg:10} would tilt up from -45°.
error: 'rotate_globe needs left or right.', const towards = tiltToPitch > from ? 'up' : 'down';
}; if ((direction === 'up' || direction === 'down') && direction !== towards) {
} const target = Math.round(Cesium.Math.toDegrees(tiltToPitch));
if (motion === 'tilt') { const now = Math.round(Cesium.Math.toDegrees(from));
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
const pitch = _viewer.camera.pitch;
if (direction === 'up' && pitch >= PITCH_MAX - Cesium.Math.toRadians(0.5)) {
return { return {
ok: false, ok: false,
action: 'move_camera', action: 'move_camera',
error: error: `pitchDeg is an absolute angle, not a step: ${target}° from ${now}° tilts ${towards}, not ${direction}. Send direction alone for a nudge, or pitchDeg alone for an angle.`,
'Already at the upper tilt limit (near the horizon) — tilt down, or zoom/pan instead.',
}; };
} }
if (
towards === 'up' &&
from >=
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
)
return tiltUpLimitRefusal();
} else if (motion === 'tilt') {
// Honest limits: at the clamp a tilt is a no-op — say so instead of
// reporting success and letting the model gaslight itself (field test).
const pitch = _viewer.camera.pitch;
if (
direction === 'up' &&
pitch >=
tiltUpLimit(tiltStartFrame(_viewer.camera)) - Cesium.Math.toRadians(0.5)
) {
return tiltUpLimitRefusal();
}
if ( if (
direction === 'down' && direction === 'down' &&
pitch <= PITCH_MIN + Cesium.Math.toRadians(0.5) pitch <= PITCH_MIN + Cesium.Math.toRadians(0.5)
@@ -1524,6 +2022,34 @@ export function moveCamera(args = {}, runNavigation = null) {
}; };
} }
} }
// A tilt is judged by the way its pitch will move: toward its angle, or a
// nudge up or down; a rotate turns left only when told to, as it runs.
const look =
motion === 'tilt'
? tiltLook(
_viewer.camera.pitch,
tiltToPitch ??
_viewer.camera.pitch +
Cesium.Math.toRadians(ONCE.tiltDeg) *
(direction === 'up' ? 1 : -1),
)
: motion === 'rotate'
? direction === 'left'
? 'lookLeft'
: 'lookRight'
: null;
// Honest limits from orbit too: a stopped turn rests within 0.05° of its
// limit, so one that cannot go half a degree further is refused rather than
// run and reported as done, and one that can is then turned at least that
// far. Near the limit the sampled share wobbles as the planet's edge crosses
// the grid, so a turn that would keep the planet can occasionally be refused
// too.
// A camera that is tracked or locked now is judged as the navigation that
// runs the turn will release it, so a refusal keeps the tracking.
const lookRefusal =
look &&
checkLookFromOrbit(look, { released: typeof runNavigation === 'function' });
if (lookRefusal) return lookRefusal;
if ( if (
(motion === 'rotate_globe' || (motion === 'rotate_globe' ||
(motion === 'orbit' && typeof runNavigation !== 'function')) && (motion === 'orbit' && typeof runNavigation !== 'function')) &&
@@ -1607,14 +2133,70 @@ export function moveCamera(args = {}, runNavigation = null) {
error: 'Globe rotation requires a clear ellipsoid bounding sphere.', error: 'Globe rotation requires a clear ellipsoid bounding sphere.',
}; };
} }
// The angle a tilt to an angle stops at short of it, and why.
let stoppedAt = null;
let stopNote = null;
const start = () => { const start = () => {
interruptCameraMotion('replaced'); interruptCameraMotion('replaced');
// Checked again only if the release left the camera somewhere other than
// where the check above expected it.
if (look && !lookProven(_viewer.camera, look)) {
const refusal = checkLookFromOrbit(look);
if (refusal) return refusal;
}
const state = { const state = {
kind: motion, kind: motion,
direction: direction || 'right', direction: direction || 'right',
speed, speed,
mode, mode,
}; };
if (motion === 'tilt') {
const frame = tiltStartFrame(_viewer.camera);
const { target, pivot, from } = frame;
if (pivot) {
state.target = target;
state.hpr = pivot;
}
state.startPitch = from;
const upLimit = tiltUpLimit(frame);
if (tiltToPitch !== null) {
// From far out the place reaches its horizon before the camera
// reaches the asked angle: stop there, and say so.
let goal =
tiltToPitch > from
? Math.min(tiltToPitch, Math.max(from, upLimit))
: tiltToPitch;
if (goal !== tiltToPitch) stopNote = tiltHorizonNote(goal);
// From orbit an in-place tilt stops where the planet would leave the
// frame: aim it there, and say so.
if (!pivot) {
const framed = tiltPitchWithinFrame(_viewer.camera, goal);
if (framed !== goal) {
goal = framed;
stopNote = tiltFrameNote(goal);
}
}
state.mode = 'once';
state.direction = goal > from ? 'up' : 'down';
state.goalPitch = goal;
// A zoom mid-tilt moves the horizon limit; it re-judges the goal
// against the angle asked for.
state.askedPitch = tiltToPitch;
state.durationS = Math.max(
ONCE.durationS,
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
);
if (goal !== tiltToPitch) stoppedAt = goal;
} else {
// A nudge stops at the limit it travels toward.
const step =
Cesium.Math.toRadians(ONCE.tiltDeg) * (direction === 'up' ? 1 : -1);
state.goalPitch =
step > 0
? Math.min(from + step, Math.max(from, upLimit))
: Math.max(from + step, Math.min(from, PITCH_MIN));
}
}
if (motion === 'rotate_globe') { if (motion === 'rotate_globe') {
// Convert an inherited ground-target frame without changing the world // Convert an inherited ground-target frame without changing the world
// pose. Rotation must pivot about Earth, never the previous local target. // pose. Rotation must pivot about Earth, never the previous local target.
@@ -1633,9 +2215,11 @@ export function moveCamera(args = {}, runNavigation = null) {
} }
if (motion === 'orbit') { if (motion === 'orbit') {
if (_viewer.scene.tweens.length > 0) { if (_viewer.scene.tweens.length > 0) {
// A camera flight is in progress ("fly to X and orbit it") — ALWAYS arm // A camera flight is in progress — ALWAYS arm and capture at the
// and capture at the destination; a target existing right now is // destination; a target existing right now is meaningless (the globe
// meaningless (the globe is always under the crosshair). // is always under the crosshair). The voice runner already holds an
// orbit until its own fly-to lands; this covers any other
// flight.
setActiveMotion({ setActiveMotion({
kind: 'orbit', kind: 'orbit',
direction: direction || 'right', direction: direction || 'right',
@@ -1699,7 +2283,11 @@ export function moveCamera(args = {}, runNavigation = null) {
motion, motion,
direction: state.direction, direction: state.direction,
speed, speed,
mode, mode: state.mode,
...(tiltToPitch !== null && {
pitchDeg: Math.round(Cesium.Math.toDegrees(stoppedAt ?? tiltToPitch)),
}),
...(stopNote !== null && { note: stopNote }),
}; };
}; };
const preserveCameraFlight = const preserveCameraFlight =
@@ -1714,16 +2302,93 @@ export function moveCamera(args = {}, runNavigation = null) {
/** /**
* Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom * Zoom while orbiting: scale the orbit RADIUS instead of letting the zoom
* fight the per-frame lookAt (field test: "zoom out did literally nothing"). * fight the per-frame lookAt (field test: "zoom out did literally nothing").
* A tilt swinging about its pivot holds a range the same way.
* @param {number} factor >1 zooms out (radius grows), <1 zooms in. * @param {number} factor >1 zooms out (radius grows), <1 zooms in.
* @returns {boolean} true when an orbit consumed the zoom. * @returns {{kind: string, changed: boolean, pitchDeg?: number,
* note?: string}|null} the motion that consumed the zoom, and whether its
* range moved; null when none did. A tilt to an angle whose end the new
* range moves also names the angle it now ends at (pitchDeg), with a note.
*/ */
export function adjustOrbitRange(factor) { export function adjustOrbitRange(factor) {
if (!_active || _active.kind !== 'orbit' || !_active.hpr) return false; if (!_active || !['orbit', 'tilt'].includes(_active.kind) || !_active.hpr)
_active.hpr.range = Math.max( return null;
80, // A tilt's zoom in stops at the range that keeps its clearance. The limits
Math.min(5_000_000, _active.hpr.range * factor), // never move a view the wrong way: a zoom in leaves a closer view in place,
// and a zoom out leaves a farther one.
const { kind, hpr } = _active;
const floor =
kind === 'tilt'
? Math.max(80, TILT_MIN_CLEARANCE_M / Math.abs(Math.sin(hpr.pitch)))
: 80;
// A tilt pivots only within TILT_PIVOT_MAX_RANGE_M; farther out the swing
// would carry the camera thousands of km.
const ceiling = kind === 'tilt' ? TILT_PIVOT_MAX_RANGE_M : 5_000_000;
const range = hpr.range;
hpr.range = Math.max(
Math.min(floor, range),
Math.min(Math.max(ceiling, range), range * factor),
); );
return true; // Under half a metre counts as not moved, as for a plain zoom.
const changed = Math.abs(hpr.range - range) >= 0.5;
return { kind, changed, ...(changed && retargetTiltAfterZoom(_active)) };
}
/**
* A zoom changed the range a tilt to an angle holds, and with it the place's
* horizon limit: aim the tilt at the angle asked for or the new limit,
* whichever comes first, from where the camera is now. The pivot pitch is
* re-solved so the new range keeps the camera's pitch, and a limit the camera
* is already past is eased down to rather than jumped to. Returns the end
* angle and a note for the reply when that end moved, otherwise nothing.
*/
function retargetTiltAfterZoom(m) {
if (m.kind !== 'tilt' || m.mode !== 'once' || m.askedPitch === undefined)
return {};
let from = _viewer.camera.pitch;
if (!(m.askedPitch > from)) return {};
if (m.hpr) {
// At the same pivot pitch a longer range looks down more steeply (the
// Earth curves away): find the pivot pitch that holds this camera pitch.
// It stays below the place's horizon limit (or where the pivot already
// was): past it the place drops behind the Earth, so a camera the zoom
// leaves past that limit steps down to it with the zoom.
const cap = Math.max(m.hpr.pitch, tiltPivotPitchMax(m.hpr.range));
for (let i = 0; i < 4; i += 1) {
const error = from - pivotCameraPitch(m.target, m.hpr, m.hpr.pitch);
if (Math.abs(error) < 1e-7) break;
const pitch = Cesium.Math.clamp(
m.hpr.pitch + error,
-Cesium.Math.PI_OVER_TWO,
cap,
);
if (pitch === m.hpr.pitch) break;
m.hpr.pitch = pitch;
}
from = Math.min(from, pivotCameraPitch(m.target, m.hpr, m.hpr.pitch));
}
const limit =
m.hpr && !tiltClearanceBinds(m.hpr.range)
? Math.min(
PITCH_MAX,
pivotCameraPitch(m.target, m.hpr, tiltPivotPitchMax(m.hpr.range)),
)
: PITCH_MAX;
const goal = Math.min(m.askedPitch, limit);
if (Math.abs(goal - m.goalPitch) < 1e-6) return {};
m.goalPitch = goal;
m.startPitch = from;
m.elapsed = 0;
m.durationS = Math.max(
ONCE.durationS,
Math.abs(Cesium.Math.toDegrees(goal - from)) / TILT_TO_DEG_S,
);
const limited = goal !== m.askedPitch;
return {
pitchDeg: Math.round(Cesium.Math.toDegrees(goal)),
note: limited
? tiltHorizonNote(goal)
: `The tilt now reaches ${Math.round(Cesium.Math.toDegrees(goal))}°.`,
};
} }
/** /**
+19 -6
View File
@@ -1306,12 +1306,12 @@ test('voice Cockpit entry honours a requested contact layer before it enters', (
ui.indexOf("if (normalized === 'exit') {"), ui.indexOf("if (normalized === 'exit') {"),
); );
assert.ok(branch, 'cockpit enter branch is missing'); assert.ok(branch, 'cockpit enter branch is missing');
const gateIndex = branch.indexOf('isEntryAllowed?.()'); const gateIndex = branch.indexOf('this._cockpitEntryRefusal()');
const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer('); const retargetIndex = branch.indexOf('_retargetCockpitEntryLayer(');
const enterIndex = branch.indexOf('enterCockpitWithTracking({'); const enterIndex = branch.indexOf('enterCockpitWithTracking({');
assert.ok( assert.ok(
gateIndex >= 0, gateIndex >= 0,
'entry consults the same gate the manual chip uses', 'entry refuses on the gate it can check before tracking',
); );
assert.ok( assert.ok(
retargetIndex > gateIndex, retargetIndex > gateIndex,
@@ -1362,9 +1362,21 @@ test('voice Cockpit entry refuses when the entry gate is shut', () => {
ui.indexOf("if (normalized === 'enter') {"), ui.indexOf("if (normalized === 'enter') {"),
ui.indexOf("if (normalized === 'exit') {"), ui.indexOf("if (normalized === 'exit') {"),
); );
assert.match(branch, /if \(!this\.cockpitView\.isEntryAllowed\?\.\(\)\) \{/); // Contacts is not required: only a Context change in flight or no aircraft
assert.match(branch, /Contacts must be active to enter Cockpit/); // layer to ride shuts the gate before tracking, and the entry itself then
assert.match(branch, /Contacts is still starting up/); // applies the manual chip's gate to the aircraft it rides.
assert.match(
branch,
/const refusal = this\._cockpitEntryRefusal\(\);\s*if \(refusal\) \{/,
);
assert.doesNotMatch(branch, /Contacts must be active/);
const refusal = ui.slice(
ui.indexOf(' _cockpitEntryRefusal() {'),
ui.indexOf(' getCockpitState() {'),
);
assert.match(refusal, /Context is still changing/);
assert.match(refusal, /No aircraft layer is on/);
assert.doesNotMatch(refusal, /[Cc]ontacts/);
}); });
test('cockpit state cannot report entryAllowed while already active', () => { test('cockpit state cannot report entryAllowed while already active', () => {
@@ -1382,7 +1394,8 @@ test('cockpit state cannot report entryAllowed while already active', () => {
); );
assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained'); assert.match(state, /entryBlockedReason:/, 'and a refusal can be explained');
assert.match(state, /'contacts-starting'/); assert.match(state, /'contacts-starting'/);
assert.match(state, /'contacts-inactive'/); assert.match(state, /'subject-layer-off'/);
assert.doesNotMatch(state, /'contacts-inactive'/, 'Contacts is not required');
assert.match(state, /'no-tracked-aircraft'/); assert.match(state, /'no-tracked-aircraft'/);
}); });
+34
View File
@@ -147,6 +147,40 @@ const snapshot = (distanceM = 2000) => ({
], ],
}); });
test('a feed-off row leads the rebuilt rows and the list stays five long', async (t) => {
const f = fixture(t);
const crowded = {
...snapshot(),
cohorts: [
{
id: 'flights',
count: 6,
nearest: [1, 2, 3, 4, 5, 6].map((n) => ({
id: `n${n}`,
label: `N${n}`,
distanceM: n * 1000,
})),
},
],
};
updateCockpitSignals.call(f.owner, crowded, 0, {
title: 'MILITARY FEED OFF · PARTIAL PICTURE',
detail: 'NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR',
});
await nextTick();
assert.equal(f.owner.signalItems.length, 5);
assert.equal(f.owner.signalItems[0].key, 'feed-status');
assert.equal(f.owner.signalItems[0].tone, 'warning');
assert.equal(f.owner.signalItems[0].target, null);
assert.match(f.list.textContent, /MILITARY FEED OFF · PARTIAL PICTURE/);
updateCockpitSignals.call(f.owner, crowded, 0);
assert.equal(
f.owner.signalItems.some((item) => item.key === 'feed-status'),
false,
'with the feed back, the row goes',
);
});
test('rows render the signals the controller presents', async (t) => { test('rows render the signals the controller presents', async (t) => {
const f = fixture(t); const f = fixture(t);
await render(f, [ await render(f, [
+23 -16
View File
@@ -411,30 +411,37 @@ export function contextRestoreLayerIds(snapshot = {}) {
return restored; return restored;
} }
/** The layers an aircraft Cockpit subject can come from. */
export const COCKPIT_AIRCRAFT_LAYER_IDS = Object.freeze([
'flights',
'military',
]);
/** /**
* Cockpit entry belongs to the operational Contacts context bundle. A tracked * Cockpit entry needs only the aircraft it rides: that aircraft's own layer
* aircraft alone is not sufficient: both observed-flight feeds must still be * (Live Flights for a civilian, Military Flights for a military aircraft) must
* active so the cockpit's surrounding-contact picture is not presented as a * be on. Contacts and the other feeds are not required. When a source the
* complete context view when one source is disabled. * surrounding picture uses is off, the Cockpit's Contact card and Live
* Signals say the picture is partial instead of blocking entry, so a partial
* picture is never presented as complete. A Context mode change in flight
* still owns the camera, so entry waits it out.
* *
* @param {object} input Current context and dependency visibility. * @param {object} input Current subject and Context state.
* @param {string|null} input.contextMode Active Context mode. * @param {string|null} input.subjectLayerId Layer of the tracked aircraft
* @param {boolean} input.contextModeChanging Whether the Context transaction is unsettled. * ('flights' or 'military'), or null with none tracked.
* @param {boolean} input.flightsEnabled Whether Live Flights is enabled. * @param {boolean} input.subjectLayerEnabled Whether that layer is enabled.
* @param {boolean} input.militaryEnabled Whether Military Flights is enabled. * @param {boolean} input.contextModeChanging Whether a Context transaction is unsettled.
* @returns {boolean} Whether cockpit entry may be offered or activated. * @returns {boolean} Whether cockpit entry may be offered or activated.
*/ */
export function cockpitEntryAllowed({ export function cockpitEntryAllowed({
contextMode, subjectLayerId,
subjectLayerEnabled,
contextModeChanging, contextModeChanging,
flightsEnabled,
militaryEnabled,
}) { }) {
return ( return (
contextMode === 'flights' && COCKPIT_AIRCRAFT_LAYER_IDS.includes(subjectLayerId) &&
!contextModeChanging && Boolean(subjectLayerEnabled) &&
Boolean(flightsEnabled) && !contextModeChanging
Boolean(militaryEnabled)
); );
} }
+18 -23
View File
@@ -400,37 +400,32 @@ test('explicit layer intent finishing during restore overrides the stale queued
} }
}); });
test('cockpit entry requires Contacts context with both aircraft feeds enabled', () => { test("cockpit entry needs only the tracked aircraft's own layer", () => {
assert.equal(cockpitEntryAllowed({ assert.equal(cockpitEntryAllowed({
contextMode: 'flights', subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: false, contextModeChanging: false,
flightsEnabled: true, }), true, 'a civilian needs Live Flights alone: no Contacts, no Military');
militaryEnabled: true,
}), true);
assert.equal(cockpitEntryAllowed({ assert.equal(cockpitEntryAllowed({
contextMode: 'flights', subjectLayerId: 'military',
subjectLayerEnabled: true,
contextModeChanging: false,
}), true, 'a military aircraft needs Military Flights alone');
assert.equal(cockpitEntryAllowed({
subjectLayerId: 'flights',
subjectLayerEnabled: true,
contextModeChanging: true, contextModeChanging: true,
flightsEnabled: true, }), false, 'Cockpit remains unavailable until a Context mode change settles');
militaryEnabled: true,
}), false, 'Cockpit remains unavailable until Contacts activation settles');
assert.equal(cockpitEntryAllowed({ assert.equal(cockpitEntryAllowed({
contextMode: null, subjectLayerId: 'military',
subjectLayerEnabled: false,
contextModeChanging: false, contextModeChanging: false,
flightsEnabled: true, }), false, "the ridden aircraft's own layer is required");
militaryEnabled: true,
}), false, 'ordinary layer selection is not an operational Contacts context');
assert.equal(cockpitEntryAllowed({ assert.equal(cockpitEntryAllowed({
contextMode: 'flights', subjectLayerId: null,
subjectLayerEnabled: false,
contextModeChanging: false, contextModeChanging: false,
flightsEnabled: true, }), false, 'there is no aircraft to ride');
militaryEnabled: false,
}), false, 'the military contact feed is required');
assert.equal(cockpitEntryAllowed({
contextMode: 'flights',
contextModeChanging: false,
flightsEnabled: false,
militaryEnabled: true,
}), false, 'the civilian contact feed is required');
}); });
test('context teardown guard restores coordination state after success and failure', async () => { test('context teardown guard restores coordination state after success and failure', async () => {
+12
View File
@@ -34,6 +34,14 @@ export const OPENMAPTILES_CREDIT = {
html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>', html: 'Vector tiles: <a href="https://openfreemap.org" target="_blank" rel="noopener">OpenFreeMap</a> © <a href="https://openmaptiles.org" target="_blank" rel="noopener">OpenMapTiles</a>',
}; };
/** Conditional Salish Sea coverage; registered when its sightings first render. */
export const ACARTIA_CREDIT = Object.freeze({
key: 'acartia',
html:
'Wildlife sightings: <a href="https://acartia.io" target="_blank" rel="noopener">Acartia</a> (CC BY 4.0; each provider credited per sighting) · ' +
'<a href="https://www.bewhalewise.org" target="_blank" rel="noopener">Be Whale Wise</a>',
});
const osmDisplays = new WeakMap(); const osmDisplays = new WeakMap();
function updateOsmDisplay(viewer, state) { function updateOsmDisplay(viewer, state) {
@@ -188,6 +196,10 @@ export const DATA_CREDITS = [
'<a href="https://adds-faa.opendata.arcgis.com/" target="_blank" rel="noopener">FAA Special Use Airspace</a>' + '<a href="https://adds-faa.opendata.arcgis.com/" target="_blank" rel="noopener">FAA Special Use Airspace</a>' +
' (prohibited and restricted zones; informational only)', ' (prohibited and restricted zones; informational only)',
}, },
{
key: 'inaturalist',
html: 'Wildlife sightings: <a href="https://www.inaturalist.org" target="_blank" rel="noopener">iNaturalist</a> contributors (licences per record; photos credited on each card)',
},
OSM_CREDIT, OSM_CREDIT,
OPENMAPTILES_CREDIT, OPENMAPTILES_CREDIT,
{ {
+5
View File
@@ -1,5 +1,10 @@
# Test fixtures # 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 - `tomtom-flow-austin-12-935-1686.pbf` — one real TomTom traffic-flow vector
tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin tile (Mapbox Vector Tile protobuf, layer `"Traffic flow"`), downtown Austin
z12 x935 y1686, captured 2026-07-16 from 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', () => { test('dataset URLs still name the complete bundled sources', () => {
for (const [file, count] of [ for (const [file, count] of [
['datacenters', 4351], ['datacenters', 4352],
['dams', 704], ['dams', 704],
]) { ]) {
const lines = readFileSync( const lines = readFileSync(
+23
View File
@@ -312,6 +312,23 @@ const OPTION_GROUPS = Object.freeze({
high: 'h', high: 'h',
}), }),
]), ]),
'wildlife-sightings': Object.freeze([
booleanOption('confirmed', 'c', false),
enumOption(
'taxa',
't',
'all',
['all', 'Aves', 'Mammalia', 'whales', 'Insecta', 'Plantae'],
{
all: 'a',
Aves: 'b',
Mammalia: 'm',
whales: 'w',
Insecta: 'i',
Plantae: 'p',
},
),
]),
traffic: Object.freeze([ traffic: Object.freeze([
enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], { enumOption('roadMode', 'r', null, ['tomtom', 'osm', 'hybrid'], {
tomtom: 't', tomtom: 't',
@@ -733,6 +750,12 @@ export const LAYER_STATE_REGISTRY = Object.freeze([
disposition: 'enabled+options', disposition: 'enabled+options',
optionOwner: 'weather-satellite', optionOwner: 'weather-satellite',
}), }),
Object.freeze({
id: 'wildlife-sightings',
token: '9',
disposition: 'enabled+options',
optionOwner: 'wildlife-sightings',
}),
Object.freeze({ Object.freeze({
id: 'wind', id: 'wind',
token: 'k', token: 'k',
+34 -7
View File
@@ -203,8 +203,8 @@ function encode(state) {
test('production registry is exact, canonical, and rejects incomplete contracts', async () => { test('production registry is exact, canonical, and rejects incomplete contracts', async () => {
assert.equal(validateLayerStateRegistry(), true); assert.equal(validateLayerStateRegistry(), true);
assert.equal(REGISTERED_LAYER_IDS.length, 35); assert.equal(REGISTERED_LAYER_IDS.length, 36);
assert.equal(new Set(REGISTERED_LAYER_IDS).size, 35); assert.equal(new Set(REGISTERED_LAYER_IDS).size, 36);
assert.ok(REGISTERED_LAYER_IDS.includes('transit')); assert.ok(REGISTERED_LAYER_IDS.includes('transit'));
assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort()); assert.deepEqual(REGISTERED_LAYER_IDS, [...REGISTERED_LAYER_IDS].sort());
assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, { assert.deepEqual(LEGACY_LAYER_STATE_TOKENS, {
@@ -243,7 +243,7 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) { for (const [id, token] of Object.entries(LEGACY_LAYER_STATE_TOKENS)) {
assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token); assert.equal(LAYER_STATE_TOKEN_RESERVATIONS[id], token);
} }
assert.equal(nextLayerStateToken(), '9'); assert.equal(nextLayerStateToken(), '00');
assert.equal( assert.equal(
nextLayerStateToken({ nextLayerStateToken({
...LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
@@ -306,8 +306,8 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
assert.equal( assert.equal(
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
future: '9', future: '00',
next: '00', next: '01',
}), }),
true, true,
); );
@@ -315,12 +315,13 @@ test('production registry is exact, canonical, and rejects incomplete contracts'
() => () =>
validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, { validateLayerStateAllocations(LAYER_STATE_TOKEN_RESERVATIONS, {
...LAYER_STATE_TOKEN_RESERVATIONS, ...LAYER_STATE_TOKEN_RESERVATIONS,
future: '00', future: '01',
}), }),
/next free token 9/, /next free token 00/,
); );
const beforeLastDigit = { ...digitsExhausted }; const beforeLastDigit = { ...digitsExhausted };
delete beforeLastDigit['prior-9']; delete beforeLastDigit['prior-9'];
delete beforeLastDigit['wildlife-sightings'];
assert.equal( assert.equal(
validateLayerStateAllocations(beforeLastDigit, { validateLayerStateAllocations(beforeLastDigit, {
...beforeLastDigit, ...beforeLastDigit,
@@ -2695,6 +2696,32 @@ test('the recent-imagery split is share-link only: never stored locally, and a s
); );
}); });
test('wildlife confirmed and every taxa preset round-trip through the full share codec', () => {
assert.equal(
LAYER_STATE_REGISTRY.find(({ id }) => id === 'wildlife-sightings').token,
'9',
);
for (const taxa of [
'all',
'Aves',
'Mammalia',
'whales',
'Insecta',
'Plantae',
]) {
const state = normalizeLayerState({
enabledLayerIds: ['wildlife-sightings'],
options: { 'wildlife-sightings': { confirmed: true, taxa } },
});
const encoded = encode(state);
assert.doesNotMatch(encoded, /undefined/);
assert.deepEqual(
decodeLayerStateParams(new URLSearchParams(encoded)),
state,
);
}
});
test('drone restrictions uses digit 8 without colliding with fire perimeters', () => { test('drone restrictions uses digit 8 without colliding with fire perimeters', () => {
const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=8.2')); const decoded = decodeLayerStateParams(new URLSearchParams('v=2&l=8.2'));
assert.deepEqual(decoded.enabledLayerIds, [ assert.deepEqual(decoded.enabledLayerIds, [
+25 -15
View File
@@ -97,6 +97,10 @@ function withPublishedBase(
} }
} }
const preWildlifeRows = reservationRows.filter(
([id]) => id !== 'wildlife-sightings',
);
function writeCodecFixture(cwd, rows, entries) { function writeCodecFixture(cwd, rows, entries) {
const providerCodec = path.join(cwd, 'src/data/idSwitches.js'); const providerCodec = path.join(cwd, 'src/data/idSwitches.js');
mkdirSync(path.dirname(providerCodec), { recursive: true }); mkdirSync(path.dirname(providerCodec), { recursive: true });
@@ -104,7 +108,12 @@ function writeCodecFixture(cwd, rows, entries) {
providerCodec, providerCodec,
readFileSync(new URL('./idSwitches.js', import.meta.url)), readFileSync(new URL('./idSwitches.js', import.meta.url)),
); );
const source = readFileSync(CODEC_SOURCE_PATH, 'utf8'); let source = readFileSync(CODEC_SOURCE_PATH, 'utf8');
if (!rows.some(([id]) => id === 'wildlife-sightings'))
source = source.replace(
/ Object\.freeze\(\{\s*id: 'wildlife-sightings',[\s\S]*?\}\),\n/,
'',
);
const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS'; const registryEnd = ']);\n\nexport const REGISTERED_LAYER_IDS';
assert.equal(source.split(registryEnd).length, 2); assert.equal(source.split(registryEnd).length, 2);
const additions = entries const additions = entries
@@ -128,7 +137,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
{ ...parseLayerStateTokenReservations(reservationRows) }, { ...parseLayerStateTokenReservations(reservationRows) },
{ ...LAYER_STATE_TOKEN_RESERVATIONS }, { ...LAYER_STATE_TOKEN_RESERVATIONS },
); );
assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 35); assert.equal(Object.keys(LAYER_STATE_TOKEN_RESERVATIONS).length, 36);
assert.deepEqual( assert.deepEqual(
{ ...LAYER_STATE_TOKEN_RESERVATIONS }, { ...LAYER_STATE_TOKEN_RESERVATIONS },
{ {
@@ -140,6 +149,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
'sea-marks': '6', 'sea-marks': '6',
'ski-pistes': '7', 'ski-pistes': '7',
'drone-airspace': '8', 'drone-airspace': '8',
'wildlife-sightings': '9',
}, },
); );
assert.throws( assert.throws(
@@ -165,9 +175,9 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
/Duplicate layer-state token reservation/, /Duplicate layer-state token reservation/,
); );
assert.equal( assert.equal(
parseLayerStateTokenReservations([...reservationRows, ['future', '9']]) parseLayerStateTokenReservations([...reservationRows, ['future', '00']])
.future, .future,
'9', '00',
); );
assert.throws( assert.throws(
() => () =>
@@ -185,7 +195,7 @@ test('reservation ledger is complete, pinned, and rejects duplicate or malformed
test('published retired digits and competing PRs advance the merge-time allocation', () => { test('published retired digits and competing PRs advance the merge-time allocation', () => {
withPublishedBase( withPublishedBase(
{ rows: [...reservationRows, ['retired-layer', '9']] }, { rows: [...preWildlifeRows, ['retired-layer', '9']] },
(cwd) => { (cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd); const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '00'); assert.equal(nextLayerStateToken(published), '00');
@@ -217,7 +227,7 @@ test('published retired digits and competing PRs advance the merge-time allocati
}); });
test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => { test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () => {
withPublishedBase({ rows: [...reservationRows, ['pr-a', '9']] }, (cwd) => { withPublishedBase({ rows: [...preWildlifeRows, ['pr-a', '9']] }, (cwd) => {
mkdirSync(path.join(cwd, 'scripts')); mkdirSync(path.join(cwd, 'scripts'));
const fixtureChecker = path.join( const fixtureChecker = path.join(
cwd, cwd,
@@ -238,7 +248,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
writeCodecFixture( writeCodecFixture(
cwd, cwd,
[...reservationRows, ['pr-b', '9']], [...preWildlifeRows, ['pr-b', '9']],
[{ id: 'pr-b', token: '9' }], [{ id: 'pr-b', token: '9' }],
); );
const beforeRebase = runCheck(); const beforeRebase = runCheck();
@@ -247,7 +257,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
writeCodecFixture( writeCodecFixture(
cwd, cwd,
[...reservationRows, ['pr-a', '9'], ['pr-b', '9']], [...preWildlifeRows, ['pr-a', '9'], ['pr-b', '9']],
[ [
{ id: 'pr-a', token: '9' }, { id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '9' }, { id: 'pr-b', token: '9' },
@@ -259,7 +269,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
const published = readPublishedLayerStateReservations('HEAD', cwd); const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(nextLayerStateToken(published), '00'); assert.equal(nextLayerStateToken(published), '00');
const validBaselineRows = [...reservationRows, ['pr-a', '9']]; const validBaselineRows = [...preWildlifeRows, ['pr-a', '9']];
writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]); writeCodecFixture(cwd, validBaselineRows, [{ id: 'pr-a', token: '9' }]);
const helper = spawnSync( const helper = spawnSync(
process.execPath, process.execPath,
@@ -275,7 +285,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
); );
writeCodecFixture( writeCodecFixture(
cwd, cwd,
[...reservationRows, ['pr-a', '9'], ['pr-b', '00']], [...preWildlifeRows, ['pr-a', '9'], ['pr-b', '00']],
[ [
{ id: 'pr-a', token: '9' }, { id: 'pr-a', token: '9' },
{ id: 'pr-b', token: '00' }, { id: 'pr-b', token: '00' },
@@ -290,7 +300,7 @@ test('PR B manually replaces provisional 9 with 00 after PR A publishes 9', () =
test('allocation batches cross the last digit and base-36 pair boundaries in order', () => { test('allocation batches cross the last digit and base-36 pair boundaries in order', () => {
const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]); const digitRows = [...'9'].map((digit) => [`retired-${digit}`, digit]);
const allDigits = parseLayerStateTokenReservations([ const allDigits = parseLayerStateTokenReservations([
...reservationRows, ...preWildlifeRows,
...digitRows, ...digitRows,
]); ]);
const beforeNine = { ...allDigits }; const beforeNine = { ...allDigits };
@@ -349,7 +359,7 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
token, token,
})); }));
const fixtureRows = [ const fixtureRows = [
...reservationRows, ...preWildlifeRows,
...priorDigits, ...priorDigits,
...fixtureLayers.map(({ id, token }) => [id, token]), ...fixtureLayers.map(({ id, token }) => [id, token]),
]; ];
@@ -358,13 +368,13 @@ test('an isolated valid two-character fixture round-trips an l field beyond the
assert.equal(codec.validateLayerStateRegistry(), true); assert.equal(codec.validateLayerStateRegistry(), true);
assert.equal( assert.equal(
nextLayerStateToken( nextLayerStateToken(
parseLayerStateTokenReservations([...reservationRows, ...priorDigits]), parseLayerStateTokenReservations([...preWildlifeRows, ...priorDigits]),
), ),
'00', '00',
); );
assert.equal( assert.equal(
validateLayerStateAllocations( validateLayerStateAllocations(
LAYER_STATE_TOKEN_RESERVATIONS, parseLayerStateTokenReservations(preWildlifeRows),
codec.LAYER_STATE_TOKEN_RESERVATIONS, codec.LAYER_STATE_TOKEN_RESERVATIONS,
), ),
true, true,
@@ -395,7 +405,7 @@ test('checker reads a complete future base ledger and rejects retired-token reus
}, },
(cwd) => { (cwd) => {
const published = readPublishedLayerStateReservations('HEAD', cwd); const published = readPublishedLayerStateReservations('HEAD', cwd);
assert.equal(Object.keys(published).length, 36); assert.equal(Object.keys(published).length, 37);
assert.equal(published['retired-layer'], '00'); assert.equal(published['retired-layer'], '00');
assert.throws( assert.throws(
() => () =>
@@ -33,5 +33,6 @@
["weather-lightning", "l"], ["weather-lightning", "l"],
["weather-radar", "v"], ["weather-radar", "v"],
["weather-satellite", "o"], ["weather-satellite", "o"],
["wildlife-sightings", "9"],
["wind", "k"] ["wind", "k"]
] ]
+10 -1
View File
@@ -5,7 +5,7 @@ layer.
- Source: OpenStreetMap contributors - Source: OpenStreetMap contributors
- License: Open Database License (ODbL) 1.0 - License: Open Database License (ODbL) 1.0
- Feature count: 4,351 - Feature count: 4,352
- Runtime file: `datacenters.geojsonl` - Runtime file: `datacenters.geojsonl`
The public-release snapshot removes contact-oriented tags such as email, phone, 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 The original extraction date and query were not recorded alongside this
snapshot. Future refreshes should record both before replacing the file. 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":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":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":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":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":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":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":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":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":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.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":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":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":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}} {"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":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":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":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}}
+50
View File
@@ -98,6 +98,7 @@ function installAwarenessRuntime({
// status its module never emits guards nothing. See the settled-but-fetching // status its module never emits guards nothing. See the settled-but-fetching
// tests below, which use militaryInstallations' genuine first-fetch readout. // tests below, which use militaryInstallations' genuine first-fetch readout.
layerStats = {}, layerStats = {},
subscribe = undefined,
} = {}) { } = {}) {
const originalWindow = globalThis.window; const originalWindow = globalThis.window;
const originalDocument = globalThis.document; const originalDocument = globalThis.document;
@@ -215,6 +216,7 @@ function installAwarenessRuntime({
setEnabled, setEnabled,
getLayerLifecycleState, getLayerLifecycleState,
layers, layers,
...(subscribe ? { subscribe } : {}),
}); });
militaryAwarenessLayer.setParams({ passive: true }); militaryAwarenessLayer.setParams({ passive: true });
militaryAwarenessLayer.init(viewer); militaryAwarenessLayer.init(viewer);
@@ -383,6 +385,54 @@ test('Contacts does not claim a dependency already enabling for the user', async
} }
}); });
test('a dependency the user adopts stays on when Contacts turns off', async () => {
// A direct selection, or the Cockpit riding one of its aircraft, promotes a
// layer Contacts turned on into the user's own (adoptLayerVisibility). The
// coordinator must give it up, or its OFF release tears the layer down —
// and the track or the Cockpit with it — before Context restores it.
const calls = [];
const subscribers = new Set();
const runtime = installAwarenessRuntime({
isEnabled: () => false,
isEffectivelyEnabled: () => false,
setEnabled: (layerId, enabled) => {
calls.push({ layerId, enabled });
return Promise.resolve(true);
},
subscribe: (listener) => {
subscribers.add(listener);
return () => subscribers.delete(listener);
},
});
try {
militaryAwarenessLayer.setParams({ passive: false });
await nextTurn();
for (const listener of subscribers)
listener({
type: 'visibility',
layerId: 'flights',
enabled: true,
origin: 'user',
adopted: true,
adoptedFromSelection: true,
});
await militaryAwarenessLayer.disable();
const released = calls
.filter(({ enabled }) => !enabled)
.map(({ layerId }) => layerId)
.sort();
assert.deepEqual(
released,
AWARENESS_DEPENDENCIES.filter((layerId) => layerId !== 'flights').sort(),
'the adopted layer is the user\'s now; the rest are still released',
);
} finally {
runtime.restore();
}
assert.equal(subscribers.size, 0, 'destroy drops the manager subscription');
});
test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => { test('operational awareness becomes ready after aircraft while deferred sources keep loading', async () => {
let enabled = false; let enabled = false;
let releaseAircraft; let releaseAircraft;
+19 -9
View File
@@ -11,17 +11,16 @@ const FOLD = {
}; };
/** /**
* Comparable form of a place name, shared by the bundled packs, the * Script-aware fold of a place name: case folded, accents removed from Latin
* OpenFreeMap street and grounds matchers and the Nominatim server: case * letters only, punctuation and symbols collapsed to single spaces. Other
* folded, accents removed from Latin letters only, punctuation and symbols * scripts keep their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while
* collapsed to single spaces, and a leading "the" dropped. Other scripts keep * "São Paulo", "sao paulo" and "SÃO PAULO!" meet. Every word is kept: "The
* their marks, so "कुल मार्ग" and "काली मार्ग" stay distinct while "São Paulo", * Hague" is not "Hague".
* "sao paulo" and "SÃO PAULO!" meet.
* *
* @param {unknown} value * @param {unknown} value
* @returns {string} * @returns {string}
*/ */
export function foldPlaceName(value) { export function foldPlaceText(value) {
return String(value ?? '') return String(value ?? '')
.toLowerCase() .toLowerCase()
.replace(/[ßøæłđıœþ]/g, (c) => FOLD[c]) .replace(/[ßøæłđıœþ]/g, (c) => FOLD[c])
@@ -29,8 +28,19 @@ export function foldPlaceName(value) {
.replace(/(\p{Script=Latin})\p{M}+/gu, '$1') .replace(/(\p{Script=Latin})\p{M}+/gu, '$1')
.normalize('NFKC') .normalize('NFKC')
.replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ') .replace(/[^\p{L}\p{M}\p{N}]+/gu, ' ')
.trim() .trim();
.replace(/^the /, ''); }
/**
* 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 /, '');
} }
/** /**
+505
View File
@@ -0,0 +1,505 @@
/**
* Rio de Janeiro route ids to the line a rider would say.
*
* SMTR's realtime feeds send internal keys (`O0731AAN0A` is SN731,
* `20000501110` is BRT line 50). They look decodable but are not:
* `O0014AAA0A` is LECD135, not 14. The table is copied from `routes.txt` in
* SMTR's static GTFS (https://dados.mobilidade.rio/gtfs/schedule, CC BY 4.0),
* valid to 2026-12-31. An id missing here renders as its raw key.
*/
export const RIO_ROUTE_NAMES = Object.freeze({
20000101130: '10',
20000111110: '11',
20000121130: '12',
20000131130: '13',
20000141130: '14',
20000151130: '15',
20000161130: '16',
20000171110: '17',
20000181130: '18',
20000191130: '19',
20000201130: '20',
20000221110: '22',
20000251130: '25',
20000281130: '28',
20000311140: '31',
20000351110: '35',
20000401130: '40',
20000411130: '41',
20000421110: '42',
20000431130: '43',
20000461130: '46',
20000501110: '50',
20000511110: '51',
20000521110: '52',
20000531130: '53',
20000601110: '60',
20000611130: '61',
20000671130: '67',
20000681130: '68',
20000701110: '70',
20000731130: '73',
20000801110: '80',
20000901110: '90',
'20000EXEC1110': 'ESP01',
E2110AAA0A: '2110',
E2111AAA0A: '2111',
E2112AAA0A: '2112',
E2114AAA0A: '2114',
E2303AAA0A: '2303',
E2305AAA0A: '2305',
E2307AAA0A: '2307',
E2308AAA0A: '2308',
E2309AAA0A: '2309',
E2310AAA0A: '2310',
E2334AAA0A: '2334',
E2335AAA0A: '2335',
E2336AAA0A: '2336',
E2337AAA0A: '2337',
E2338AAA0A: '2338',
E2339AAA0A: '2339',
E2342AAA0A: '2342',
E2343AAA0A: '2343',
E2344AAA0A: '2344',
E2345AAA0A: '2345',
E2381AAA0A: '2381',
E2383AAA0A: '2383',
E2613AAA0A: '2613',
E2614AAA0A: '2614',
E2802AAA0A: '2802',
E2803AAA0A: '2803',
E2804AAA0A: '2804',
O0006AAA0A: '006',
O0007AAA0A: '007',
O0007AAN0A: 'SN007',
O0010AAA0A: '010',
O0014AAA0A: 'LECD135',
O0100AAA0A: '100',
O0104AAA0A: '104',
O0104AAN0A: 'SN104',
O0107AAA0A: '107',
O0109AAA0A: 'LECD131',
O0111AAA0A: 'LECD134',
O0117AAA0A: '117',
O0133AAA0A: '133',
O0133AAN0A: 'SN133',
O0157AAA0A: 'LECD127',
O0160AAA0A: '160',
O0161AAA0A: '161',
O0162AAA0A: '162',
O0164AAA0A: 'LECD136',
O0165AAA0A: '165',
O0167AAA0A: '167',
O0169AAA0A: '169',
O0201AAA0A: '201',
O0202AAA0A: '202',
O0209AAA0A: '209',
O0209AAN0A: 'SN209',
O0210AAA0A: '210',
O0217AAA0A: '217',
O0220AAA0A: '220',
O0229AAA0A: '229',
O0232AAA0A: '232',
O0232AAN0A: 'SN232',
O0238AAA0A: '238',
O0238AAN0A: 'SN238',
O0239AAA0A: '239',
O0247AAA0A: '247',
O0249AAA0A: '249',
O0254AAA0A: '254',
O0265AAA0A: '265',
O0265AAN0A: 'SN265',
O0265AAP0A: 'SP265',
O0275AAA0A: '275',
O0277AAA0A: '277',
O0292AAA0A: '292',
O0292AAN0A: 'SN292',
O0296AAA0A: '296',
O0298AAA0A: '298',
O0298AAN0A: 'SN298',
O0300AAA0A: '300',
O0300AAR0A: 'SR300',
O0301AAA0A: '301',
O0302AAA0A: '302',
O0302AAN0A: 'SN302',
O0306AAA0A: '306',
O0311AAA0A: '311',
O0312AAA0A: '312',
O0312AAN0A: 'SN312',
O0313AAA0A: '313',
O0315AAA0A: '315',
O0319AAA0A: '319',
O0319AAN0A: 'SN319',
O0319AAV0A: 'SV319',
O0321AAA0A: '321',
O0323AAA0A: '323',
O0324AAA0A: '324',
O0324AAN0A: 'SN324',
O0324AAP0A: 'SP324',
O0325AAA0A: '325',
O0326AAA0A: '326',
O0326AAPBA: 'SPB326',
O0327AAA0A: '327',
O0328AAA0A: '328',
O0328AAN0A: 'SN328',
O0328AAP0A: 'SP328',
O0328AAR0A: 'SR328',
O0329AAA0A: '329',
O0329AAP0A: 'SP329',
O0335AAA0A: '335',
O0338AAA0A: '338',
O0341AAA0A: '341',
O0342AAA0A: '342',
O0342AAR0A: 'SR342',
O0343AAA0A: '343',
O0343AAP0A: 'SP343',
O0348AAA0A: '348',
O0349AAA0A: '349',
O0350AAA0A: '350',
O0352AAA0A: '352',
O0353AAA0A: '353',
O0353AAN0A: 'SN353',
O0355AAA0A: '355',
O0355AAN0A: 'SN355',
O0355AAR0A: 'SR355',
O0361AAA0A: '361',
O0362AAA0A: '362',
O0362AAR0A: 'SR362',
O0363AAA0A: '363',
O0363AAN0A: 'SN363',
O0363AAV0A: 'SV363',
O0368AAA0A: '368',
O0368AAN0A: 'SN368',
O0369AAA0A: '369',
O0371AAA0A: '371',
O0378AAA0A: '378',
O0378AAR0A: 'SR378',
O0380AAA0A: '380',
O0383AAA0A: '383',
O0383AAN0A: 'SN383',
O0384AAA0A: '384',
O0384AAR0A: 'SR384',
O0385AAA0A: '385',
O0386AAA0A: '386',
O0386AAR0A: 'SR386',
O0388AAA0A: '388',
O0388AAN0A: 'SN388',
O0388AAR0A: 'SR388',
O0389AAP0A: 'SP389',
O0390AAA0A: '390',
O0391AAV0A: 'SV391',
O0393AAA0A: '393',
O0393AAN0A: 'SN393',
O0393AAR0A: 'SR393',
O0397AAA0A: '397',
O0397AAN0A: 'SN397',
O0397AAR0A: 'SR397',
O0399AAA0A: '399',
O0399AAN0A: 'SN399',
O0399AAR0A: 'SR399',
O0404AAP0A: 'SP404',
O0409AAA0A: '409',
O0410AAA0A: '410',
O0410AAN0A: 'LECD139',
O0415AAA0A: '415',
O0415AAN0A: 'SN415',
O0416AAA0A: '416',
O0422AAA0A: '422',
O0422AAN0A: 'SN422',
O0426AAA0A: '426',
O0433AAA0A: 'LECD150',
O0435AAA0A: 'LECD151',
O0448AAA0A: '448',
O0455AAA0A: '455',
O0456AAA0A: 'LECD132',
O0457AAA0A: '457',
O0457AAN0A: 'SN457',
O0461AAA0A: '461',
O0472AAA0A: '472',
O0473AAA0A: '473',
O0474AAA0A: '474',
O0474AAN0A: 'SN474',
O0474AAV0A: 'SV474',
O0475AAA0A: 'LECD133',
O0476AAA0A: '476',
O0483AAA0A: '483',
O0483AAN0A: 'SN483',
O0483AAPAA: 'SPA483',
O0483AAPBA: 'SPB483',
O0483AAV0A: 'SV483',
O0484AAA0A: '484',
O0485AAA0A: '485',
O0485AAP0A: 'SP485',
O0486AAA0A: '486',
O0492AAA0A: '492',
O0497AAA0A: '497',
O0497AAN0A: 'SN497',
O0498AAA0A: '498',
O0507AAA0A: '507',
O0513AAA0A: '513',
O0518AAA0A: '518',
O0519AAA0A: '519',
O0536AAA0A: 'LECD138',
O0539AAA0A: '539',
O0550AAA0A: '550',
O0550AAN0A: 'SN550',
O0550AAPAA: 'SPA550',
O0550AAPBA: 'SPB550',
O0552AAA0A: '552',
O0552AAN0A: 'LECD149',
O0553AAA0A: '553',
O0554AAA0A: '554',
O0554AAN0A: 'SN554',
O0555AAA0A: '555',
O0557AAA0A: '557',
O0565AAA0A: '565',
O0565AAN0A: 'SN565',
O0583AAA0A: '583',
O0584AAA0A: '584',
O0585AAA0A: '585',
O0600AAA0A: '600',
O0600AAN0A: 'SN600',
O0601AAA0A: '601',
O0603AAA0A: '603',
O0606AAA0A: '606',
O0606AAV0A: 'SV606',
O0607AAA0A: '607',
O0608AAA0A: '608',
O0610AAA0A: '610',
O0611AAA0A: '611',
O0613AAA0A: '613',
O0613AAPAA: 'SPA613',
O0614AAA0A: '614',
O0616AAA0A: '616',
O0621AAA0A: '621',
O0622AAA0A: '622',
O0623AAA0A: '623',
O0624AAA0A: '624',
O0624AAN0A: 'SN624',
O0624AAV0A: 'SV624',
O0625AAA0A: '625',
O0626AAA0A: '626',
O0627AAA0A: '627',
O0628AAA0A: '628',
O0629AAA0A: '629',
O0629AAN0A: 'SN629',
O0630AAA0A: '630',
O0634AAA0A: '634',
O0635AAA0A: '635',
O0635AAP0A: 'SP635',
O0636AAA0A: '636',
O0636AAN0A: 'SN636',
O0638AAA0A: '638',
O0639AAA0A: '639',
O0639AAN0A: 'SN639',
O0645AAA0A: '645',
O0649AAA0A: 'LECD126',
O0650AAA0A: '650',
O0651AAA0A: '651',
O0652AAA0A: '652',
O0653AAA0A: '653',
O0663AAA0A: '663',
O0665AAA0A: '665',
O0665AAVAA: 'LECD147',
O0665AAVBA: 'SVB665',
O0669AAA0A: '669',
O0669AAV0A: 'SV669',
O0678AAA0A: '678',
O0679AAA0A: '679',
O0685AAA0A: 'LECD140',
O0685AAVAA: 'LECD148',
O0685AAVBA: 'LECD141',
O0687AAA0A: '687',
O0688AAA0A: '688',
O0691AAA0A: '691',
O0692AAA0A: '692',
O0692AAV0A: 'SV692',
O0693AAA0A: '693',
O0696AAA0A: '696',
O0696AAN0A: 'SN696',
O0711AAA0A: '711',
O0712AAA0A: '712',
O0721AAA0A: '721',
O0731AAA0A: '731',
O0731AAN0A: 'SN731',
O0737AAA0A: '737',
O0741AAA0A: '741',
O0743AAA0A: '743',
O0745AAA0A: '745',
O0746AAA0A: '746',
O0753AAA0A: '753',
O0754AAA0A: '754',
O0756AAA0A: '756',
O0757AAA0A: '757',
O0759AAA0A: '759',
O0759AAP0A: 'SP759',
O0764AAA0A: '764',
O0765AAA0A: '765',
O0766AAA0A: '766',
O0766AAP0A: 'SP766',
O0770AAA0A: '770',
O0771AAA0A: '771',
O0774AAA0A: '774',
O0774AAV0A: 'LECD142',
O0775AAA0A: '775',
O0777AAV0A: 'SV777',
O0778AAA0A: '778',
O0779AAA0A: '779',
O0779AAN0A: 'SN779',
O0780AAA0A: '780',
O0783AAA0A: '783',
O0785AAA0A: '785',
O0786AAA0A: '786',
O0788AAA0A: '788',
O0790AAA0A: '790',
O0790AAN0A: 'SN790',
O0790AAV0A: 'SV790',
O0791AAA0A: '791',
O0793AAA0A: '793',
O0794AAA0A: '794',
O0794AAN0A: 'SN794',
O0795AAP0A: 'SP795',
O0796AAA0A: '796',
O0798AAA0A: '798',
O0799AAA0A: '799',
O0801AAA0A: '801',
O0801AAP0A: 'SP801',
O0802AAA0A: '802',
O0803AAA0A: '803',
O0803AAN0A: 'SN803',
O0803AAP0A: 'SP803',
O0804AAA0A: '804',
O0805AAA0A: '805',
O0805AAN0A: 'SN805',
O0805AAP0A: 'SP805',
O0807AAA0A: '807',
O0808AAA0A: '808',
O0809AAA0A: '809',
O0810AAA0A: '810',
O0810AAN0A: 'SN810',
O0810AAP0A: 'SP810',
O0812AAA0A: '812',
O0817AAA0A: '817',
O0819AAA0A: '819',
O0820AAA0A: '820',
O0821AAA0A: '821',
O0821AAN0A: 'SN821',
O0822AAA0A: '822',
O0825AAA0A: '825',
O0825AAN0A: 'SN825',
O0826AAA0A: '826',
O0829AAA0A: '829',
O0830AAA0A: '830',
O0831AAA0A: '831',
O0831AAV0A: 'SV831',
O0833AAA0A: '833',
O0834AAA0A: '834',
O0835AAA0A: '835',
O0836AAA0A: '836',
O0837AAA0A: '837',
O0838AAA0A: '838',
O0840AAA0A: '840',
O0840AAN0A: 'SN840',
O0841AAA0A: '841',
O0842AAA0A: '842',
O0843AAA0A: '843',
O0844AAA0A: '844',
O0845AAA0A: '845',
O0846AAA0A: '846',
O0846AAN0A: 'SN846',
O0847AAA0A: '847',
O0848AAA0A: '848',
O0848AAN0A: 'SN848',
O0849AAA0A: '849',
O0850AAA0A: '850',
O0851AAA0A: '851',
O0852AAA0A: '852',
O0853AAA0A: '853',
O0853AAN0A: 'SN853',
O0853AAV0A: 'SV853',
O0854AAAAA: '854',
O0855AAA0A: '855',
O0856AAA0A: '856',
O0857AAA0A: '857',
O0857AAN0A: 'SN857',
O0859AAA0A: '859',
O0861AAA0A: '861',
O0861AAN0A: 'SN861',
O0862AAA0A: '862',
O0863AAA0A: '863',
O0863AAV0A: 'SV863',
O0864AAA0A: '864',
O0864AAP0A: 'SP864',
O0865AAA0A: '865',
O0866AAA0A: '866',
O0866AAV0A: 'SV866',
O0867AAA0A: '867',
O0867AAN0A: 'SN867',
O0868AAA0A: '868',
O0869AAA0A: '869',
O0870AAA0A: '870',
O0871AAA0A: '871',
O0872AAA0A: '872',
O0878AAA0A: '878',
O0878AAN0A: 'SN878',
O0878AAV0A: 'SV878',
O0880AAA0A: '880',
O0880AAN0A: 'SN880',
O0881AAA0A: '881',
O0883AAA0A: '883',
O0884AAA0A: '884',
O0884AAN0A: 'SN884',
O0885AAA0A: '885',
O0885AAN0A: 'SN885',
O0892AAA0A: '892',
O0893AAA0A: '893',
O0893AAN0A: 'SN893',
O0894AAA0A: '894',
O0895AAA0A: '895',
O0895AAN0A: 'SN895',
O0897AAA0A: '897',
O0897AAN0A: 'SN897',
O0898AAA0A: '898',
O0898AAN0A: 'SN898',
O0899AAA0A: '899',
O0899AAV0A: 'SV899',
O0900AAA0A: '900',
O0901AAA0A: '901',
O0901AAVBA: 'SVB901',
O0905AAA0A: '905',
O0906AAA0A: '906',
O0908AAA0A: '908',
O0908AAN0A: 'SN908',
O0908AAV0A: 'SV908',
O0910AAA0A: '910',
O0913AAA0A: '913',
O0915AAA0A: '915',
O0917AAA0A: '917',
O0917AAV0A: 'SV917',
O0918AAA0A: '918',
O0918AAP0A: 'SP918',
O0919AAA0A: '919',
O0919AAN0A: 'SN919',
O0920AAA0A: '920',
O0921AAA0A: '921',
O0921AAV0A: 'SV921',
O0922AAA0A: '922',
O0922AAV0A: 'SV922',
O0925AAA0A: '925',
O0926AAA0A: '926',
O0928AAA0A: '928',
O0932AAA0A: '932',
O0940AAA0A: '940',
O0944AAA0A: 'LECD143',
O0946AAA0A: '946',
O0950AAA0A: 'LECD144',
O0951AAA0A: 'LECD145',
O0954AAA0A: '954',
O0954AAN0A: 'SN954',
O0959AAA0A: 'LECD146',
O0961AAA0A: '961',
O0975AAA0A: '975',
O0979AAA0A: '979',
O0987AAA0A: '987',
O0990AAA0A: '990',
});
+1
View File
@@ -2,6 +2,7 @@
export const SPRITE_LAYER_ORDER = Object.freeze([ export const SPRITE_LAYER_ORDER = Object.freeze([
'cctv', 'cctv',
'firms', 'firms',
'wildlife-sightings',
'bikeshare', 'bikeshare',
'transit', 'transit',
'transit-motion', 'transit-motion',
+22
View File
@@ -30,6 +30,28 @@ test('a superseded flow fetch is not an outage', () => {
assert.equal(deriveTrafficFlowError(undefined), null); assert.equal(deriveTrafficFlowError(undefined), null);
}); });
test('hosted account limits and service failures name their own reason', () => {
const failure = (status, code) =>
deriveTrafficFlowError(
Object.assign(new Error(`TomTom flow unavailable (HTTP ${status})`), {
status,
code,
}),
);
assert.equal(
failure(429, 'USAGE_LIMIT_REACHED'),
'Monthly traffic allowance used',
);
assert.equal(failure(429, 'RATE_LIMITED'), 'Traffic rate limit reached');
assert.equal(failure(429, 'budget'), 'TomTom daily budget reached');
assert.equal(failure(503, 'no_key'), 'TomTom key unavailable');
assert.equal(failure(503, null), 'TomTom key unavailable');
assert.equal(
failure(503, 'USAGE_UNAVAILABLE'),
'Traffic service unavailable',
);
});
test('flow failures map onto short, specific reasons', () => { test('flow failures map onto short, specific reasons', () => {
const reason = (message) => deriveTrafficFlowError(new Error(message)); const reason = (message) => deriveTrafficFlowError(new Error(message));
assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable'); assert.equal(reason('flow tile 12/1/1: HTTP 503'), 'TomTom key unavailable');
+59
View File
@@ -1,4 +1,5 @@
import { greatCircleKm } from '../geoDistance.js'; import { greatCircleKm } from '../geoDistance.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
/** /**
* @module transitFeeds * @module transitFeeds
@@ -376,6 +377,18 @@ function burlingtonRouteLabel(routeId) {
return BURLINGTON_ROUTE_NAMES[routeId] || routeId; return BURLINGTON_ROUTE_NAMES[routeId] || routeId;
} }
/**
* Rio route key to the line a rider would say. One SMTR `routes.txt` covers
* both feeds (see rioRouteNames.js).
* @param {string|null} routeId
* @returns {string|null}
*/
function rioRouteLabel(routeId) {
return Object.hasOwn(RIO_ROUTE_NAMES, routeId)
? RIO_ROUTE_NAMES[routeId]
: routeId;
}
/** /**
* Registry of feeds. Order is presentation order in the stats/credit text. * Registry of feeds. Order is presentation order in the stats/credit text.
* `loadRadiusKm` is the distance from `center` inside which the feed is polled. * `loadRadiusKm` is the distance from `center` inside which the feed is polled.
@@ -729,6 +742,52 @@ export const TRANSIT_FEED_REGISTRY = Object.freeze([
defaultMode: 'bus', defaultMode: 'bus',
routeLabel: burlingtonRouteLabel, routeLabel: burlingtonRouteLabel,
}), }),
Object.freeze({
id: 'smtr-rio-onibus',
name: 'Rio buses (SPPO)',
operator: 'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro',
region: 'Rio de Janeiro, RJ',
// Every vehicle in a 2026-10-29 snapshot, from Santa Cruz in the west to
// the Zona Sul, sat within 31 km of this point.
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/onibus/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/997bc16cc4344a5cb0e48ab06cbdfd1f',
attribution: 'SMTR / Prefeitura da Cidade do Rio de Janeiro — buses (SPPO)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'The data.rio catalog page labels the feed [beta] and states a gateway limit of 5 requests a second and 60 a minute behind a 10-second cache, which the 15-second proxy TTL stays inside. SMTR relays the concessionaires’ ITS data and repairs trips with its own matching; the page places accuracy with the suppliers. Speeds are m/s, as the spec requires: checked against vehicle movement on 2026-11-05, though a 2026-10-29 snapshot read as km/h. Line names come from SMTR’s GTFS `routes.txt`, under the same CC BY 4.0 terms.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
Object.freeze({
id: 'smtr-rio-brt',
name: 'Mobi-Rio BRT',
operator:
'Secretaria Municipal de Transportes (SMTR), Rio de Janeiro, for the Mobi-Rio BRT fleet',
region: 'Rio de Janeiro, RJ',
center: Object.freeze({ lat: -22.92, lon: -43.42 }),
loadRadiusKm: 45,
url: 'https://dados.mobilidade.rio/gtfs/realtime/brt/vehicle-positions',
license: 'CC BY 4.0',
licenseUrl:
'https://www.data.rio/documents/8bb196cc02174d25b77437bb2f6443d4',
attribution:
'SMTR / Prefeitura da Cidade do Rio de Janeiro — BRT (Mobi-Rio)',
defaultEnabled: true,
terms: Object.freeze({
quote:
'This work is licensed under a Creative Commons Attribution 4.0 International License',
note: 'Same catalog terms, [beta] label and gateway limits as the bus feed. The fleet is operated by Mobi-Rio and tracked by SONDA, whose data SMTR relays. Speeds are m/s too. Line names come from the same `routes.txt`.',
}),
defaultMode: 'bus',
routeLabel: rioRouteLabel,
}),
]); ]);
/** /**
+70
View File
@@ -15,6 +15,7 @@ import {
transitModeResolved, transitModeResolved,
transitRouteLabel, transitRouteLabel,
} from './transitFeeds.js'; } from './transitFeeds.js';
import { RIO_ROUTE_NAMES } from './rioRouteNames.js';
test('every registered feed is keyless, https, licensed, and uniquely identified', () => { test('every registered feed is keyless, https, licensed, and uniquely identified', () => {
const ids = new Set(); const ids = new Set();
@@ -482,3 +483,72 @@ test('Burlington is live and carries its terms link, which its licence requires'
assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/); assert.match(bur.licenseUrl, /Open%20Data%20Terms%20of%20Use\.pdf$/);
assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all'); assert.match(bur.url, /^https:\/\//, 'port 80 has no listener at all');
}); });
test('Rio buses and BRT cover the city from Centro to Santa Cruz, and not São Paulo', () => {
const ids = (lat, lon) =>
transitFeedsInRange(lat, lon).map((feed) => feed.id);
for (const [place, lat, lon] of [
['Centro', -22.9068, -43.1729],
['Barra da Tijuca', -23.0004, -43.3659],
['Santa Cruz', -22.9194, -43.6847],
]) {
assert.ok(
ids(lat, lon).includes('smtr-rio-onibus'),
`buses reach ${place}`,
);
assert.ok(ids(lat, lon).includes('smtr-rio-brt'), `BRT reaches ${place}`);
}
assert.deepEqual(
ids(-23.5505, -46.6333).filter((id) => id.startsWith('smtr-rio')),
[],
'São Paulo, 360 km away, never polls Rio',
);
});
test('Rio route keys read as the line on the front of the bus', () => {
// Some keys look parseable and are not (`O0014AAA0A` is LECD135).
const buses = getTransitFeed('smtr-rio-onibus');
assert.equal(transitRouteLabel(buses, 'O0868AAA0A'), '868');
assert.equal(transitRouteLabel(buses, 'O0731AAN0A'), 'SN731');
assert.equal(transitRouteLabel(buses, 'O0759AAP0A'), 'SP759');
assert.equal(transitRouteLabel(buses, 'O0014AAA0A'), 'LECD135');
assert.equal(transitRouteLabel(buses, 'E2303AAA0A'), '2303');
const brt = getTransitFeed('smtr-rio-brt');
assert.equal(transitRouteLabel(brt, '20000501110'), '50');
assert.equal(transitRouteLabel(brt, '20000EXEC1110'), 'ESP01');
// Unknown ids, prototype names included, keep their raw key.
assert.equal(transitRouteLabel(buses, 'O9999AAA0A'), 'O9999AAA0A');
assert.equal(transitRouteLabel(buses, 'constructor'), 'constructor');
assert.equal(transitRouteLabel(brt, null), null);
});
test('the Rio route table is a clean join of published keys to names', () => {
const entries = Object.entries(RIO_ROUTE_NAMES);
assert.ok(entries.length > 400, 'the whole published table is carried');
for (const [id, name] of entries) {
assert.match(id, /^(?:[OE]\d{4}[A-Z0-9]{5}|2\d{4}[A-Z0-9]{4,8})$/, id);
assert.ok(name && name === name.trim(), `${id} has a name`);
assert.notEqual(name, id, `${id} is translated`);
}
});
test('Rio feeds are CC BY 4.0 and live', () => {
for (const id of ['smtr-rio-onibus', 'smtr-rio-brt']) {
const feed = getTransitFeed(id);
assert.equal(feed.defaultEnabled, true, `${id} is live`);
assert.equal(feed.license, 'CC BY 4.0');
assert.match(feed.licenseUrl, /^https:\/\/www\.data\.rio\/documents\//);
assert.match(feed.attribution, /Prefeitura da Cidade do Rio de Janeiro/);
assert.equal(transitModeFor(feed, null), 'bus');
}
const sources = readFileSync(
new URL('../../DATA_SOURCES.md', import.meta.url),
'utf8',
);
assert.ok(
sources
.split('\n')
.some((line) => line.startsWith('| **SMTR Rio de Janeiro')),
'Rio has a DATA_SOURCES row',
);
});
+10
View File
@@ -137,6 +137,7 @@ function encodeFeed({
const position = new PbfWriter(); const position = new PbfWriter();
position.writeFloatField(1, entity.lat); position.writeFloatField(1, entity.lat);
position.writeFloatField(2, entity.lon); position.writeFloatField(2, entity.lon);
if (entity.speed != null) position.writeFloatField(5, entity.speed);
vehicle.writeBytesField(2, position.finish()); vehicle.writeBytesField(2, position.finish());
if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp); if (entity.timestamp) vehicle.writeVarintField(5, entity.timestamp);
const descriptor = new PbfWriter(); const descriptor = new PbfWriter();
@@ -284,6 +285,15 @@ test('the snapshot builder fills timestamps from the header it decoded', () => {
assert.equal(snapshot.feedTimestamp, 1_700_000_000); assert.equal(snapshot.feedTimestamp, 1_700_000_000);
}); });
test('Rio speeds pass through as the m/s they are published in', () => {
// Checked against vehicle movement on 2026-11-05.
const bytes = encodeFeed({
entities: [{ id: 'moving', lat: -22.9, lon: -43.2, speed: 10 }],
});
const rio = buildTransitSnapshot(getTransitFeed('smtr-rio-onibus'), bytes, 1);
assert.equal(rio.vehicles[0].speedMps, 10);
});
test('conditional-request validators and gzip ride along with feed identification', () => { test('conditional-request validators and gzip ride along with feed identification', () => {
const entur = getTransitFeed('entur-norway'); const entur = getTransitFeed('entur-norway');
const plain = transitUpstreamHeaders(entur); const plain = transitUpstreamHeaders(entur);
+11 -2
View File
@@ -1020,6 +1020,14 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
'noir', 'noir',
]); ]);
visualStyle.enum = [...visualStyle.enum, 'snow']; visualStyle.enum = [...visualStyle.enum, 'snow'];
// Tilt to an angle adds pitchDeg and rewords direction; restore
// the legacy camera fields to keep the pin.
const camera = legacyTools.find((tool) => tool.name === 'move_camera')
.parameters.properties;
assert.equal(camera.pitchDeg.type, 'number');
delete camera.pitchDeg;
camera.direction.description =
'Required for pan/tilt/rotate; orbit/rotate_globe default right.';
const block = JSON.stringify(legacyTools); const block = JSON.stringify(legacyTools);
// Re-derived for the voice layer manifest (generated layer enums, alias and // Re-derived for the voice layer manifest (generated layer enums, alias and
// field hints), point-and-ask's pointer/referent arguments and the prompt // field hints), point-and-ask's pointer/referent arguments and the prompt
@@ -1039,10 +1047,11 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// The Utilities panel adds one set_panel_open target; Sea Marks and Ski // The Utilities panel adds one set_panel_open target; Sea Marks and Ski
// Pistes extend the generated layer enums and alias hint; voice geometry // Pistes extend the generated layer enums and alias hint; voice geometry
// adds the analyst area scopes and the osm-places layer. // adds the analyst area scopes and the osm-places layer.
assert.equal(block.length, 31318, 'serialized tool schema length drifted'); // control_cockpit states the own-layer entry rule (Contacts not required).
assert.equal(block.length, 31372, 'serialized tool schema length drifted');
assert.equal( assert.equal(
crypto.createHash('sha256').update(block).digest('hex'), crypto.createHash('sha256').update(block).digest('hex'),
'f5b42f41c2f575838f979e88a1dbff615a77ccd85fdf8fcd0a0a47d02f7d3ddc', '7f593e78c232db972b04441f830b6a60f93916a6e5bc5a68eca7422cd1035508',
'the first-run missions must ride EXISTING tools: no schema edit, no cache bust', 'the first-run missions must ride EXISTING tools: no schema edit, no cache bust',
); );
const instructions = fs.readFileSync( const instructions = fs.readFileSync(
+179
View File
@@ -0,0 +1,179 @@
import * as Cesium from 'cesium';
// Cesium's free look (Shift+drag) turns the camera in place. Near the ground
// that surveys a city; from orbit a few drags turn the whole planet out of
// frame and leave only stars.
export const FREE_LOOK_MAX_HEIGHT_METERS = 1_000_000;
// A voice turn from orbit stops when about this share of the frame still shows
// the planet's surface.
export const MIN_GLOBE_FRAME_SHARE = 0.1;
/**
* A free globe camera: the viewer is in 3D, follows no tracked entity, and its
* camera is not locked to a reference frame.
*/
export function isFreeGlobeCamera(viewer) {
return (
viewer.scene.mode === Cesium.SceneMode.SCENE3D &&
!viewer.trackedEntity &&
Cesium.Matrix4.equals(viewer.camera.transform, Cesium.Matrix4.IDENTITY)
);
}
const GRID_COLUMNS = 32;
const GRID_ROWS = 18;
// A turn across a sliver of planet moves its edge through sample points, so
// the share wobbles by a cell or two without the planet leaving.
const GRID_NOISE_CELLS = 2;
// Whatever the baseline, a voice turn never takes the planet below this share
// of the frame; a view that already shows less may only keep what it has.
const LOOK_ABSOLUTE_FLOOR_SHARE = 0.01;
const sampleRay = new Cesium.Ray();
const sampleOffset = new Cesium.Cartesian3();
/**
* Cast a 32×18 grid of view rays, one through the centre of each cell, at the
* WGS84 ellipsoid. With `keyholeRadius` (a radius around the frame centre, in
* units of half the frame height) only the cells inside that circle count: the
* scope mask hides the rest. Returns the surface hits and the cells counted, or
* null for a frustum other than a perspective one.
*/
function sampleFrame(camera, keyholeRadius) {
const frustum = camera.frustum;
if (!(frustum instanceof Cesium.PerspectiveFrustum)) return null;
const aspect = frustum.aspectRatio;
const tanY = Math.tan(frustum.fovy / 2);
const tanX = tanY * aspect;
const keyholeSquared = keyholeRadius > 0 ? keyholeRadius * keyholeRadius : 0;
Cesium.Cartesian3.clone(camera.positionWC, sampleRay.origin);
let hits = 0;
let cells = 0;
for (let row = 0; row < GRID_ROWS; row += 1) {
const rowY = (2 * (row + 0.5)) / GRID_ROWS - 1;
for (let column = 0; column < GRID_COLUMNS; column += 1) {
const columnX = (2 * (column + 0.5)) / GRID_COLUMNS - 1;
if (
keyholeSquared &&
(columnX * aspect) ** 2 + rowY * rowY > keyholeSquared
)
continue;
cells += 1;
Cesium.Cartesian3.multiplyByScalar(
camera.rightWC,
columnX * tanX,
sampleOffset,
);
Cesium.Cartesian3.add(
camera.directionWC,
sampleOffset,
sampleRay.direction,
);
Cesium.Cartesian3.multiplyByScalar(
camera.upWC,
rowY * tanY,
sampleOffset,
);
Cesium.Cartesian3.add(
sampleRay.direction,
sampleOffset,
sampleRay.direction,
);
Cesium.Cartesian3.normalize(sampleRay.direction, sampleRay.direction);
if (
Cesium.IntersectionTests.rayEllipsoid(sampleRay, Cesium.Ellipsoid.WGS84)
)
hits += 1;
}
}
return { hits, cells };
}
/**
* Share of the frame, from 0 to 1, that shows the planet's surface: the
* fraction of a 32×18 grid of view rays, one through the centre of each cell,
* that hit the WGS84 ellipsoid. With `keyholeRadius` (see `sampleFrame`) it is
* the share of the visible circle instead. A frustum other than a perspective
* one counts as fully covered.
*/
export function globeFrameShare(camera, { keyholeRadius = 0 } = {}) {
const sample = sampleFrame(camera, keyholeRadius);
if (!sample || sample.cells === 0) return 1;
return sample.hits / sample.cells;
}
/** True when none of the planet's surface is in `camera`'s visible frame. */
export function globeOutOfFrame(camera, options) {
return globeFrameShare(camera, options) === 0;
}
const lookProbes = new WeakMap();
const LOOK_EDGE_TOLERANCE = Cesium.Math.toRadians(0.05);
/**
* How much of one in-place look step (`lookUp`, `lookDown`, `lookLeft` or
* `lookRight` by a non-negative `angle` in radians) keeps the planet in frame
* from orbit. Above the free-look height the step may not leave less than
* `MIN_GLOBE_FRAME_SHARE` of the visible frame on the planet, or less than
* `startShare` when that is lower, give or take two grid cells of sampling
* noise. However the baseline was taken, it never leaves less than 1% of the
* frame, and a view that already shows less may only keep or gain what it has.
* `startShare` is the baseline the turn is measured against (the current share
* by default): callers pass one value to every step of a motion, and to every
* turn that continues from where the last one left off, so the limit cannot
* slide down a step or a command at a time. `keyholeRadius` limits the measure
* to the scope's visible circle, as in `globeFrameShare`.
* With nothing on screen the limit is nothing either, so the step is left to
* the move-end recovery. Returns `angle` when the whole step fits, otherwise a
* smaller angle that fits, found by bisection to within 0.05°, so a stopped
* motion rests at the limit. Steps are tried on a copy; `camera` is not moved.
*/
export function lookAngleWithinFrame(
camera,
look,
angle,
{ startShare, keyholeRadius = 0 } = {},
) {
if (
!(angle > 0) ||
!(camera.positionCartographic?.height > FREE_LOOK_MAX_HEIGHT_METERS)
)
return angle;
const sample = sampleFrame(camera, keyholeRadius);
if (!sample || sample.cells === 0) return angle;
const share = sample.hits / sample.cells;
const baseline = startShare ?? share;
// Capped at what is on screen now, so a step that keeps or gains surface is
// never refused, even when the frame has shrunk since the baseline was taken
// (a resized window or a turned phone).
const floor = Math.min(
share,
Math.max(
Math.min(MIN_GLOBE_FRAME_SHARE, baseline) -
GRID_NOISE_CELLS / sample.cells,
Math.min(LOOK_ABSOLUTE_FLOOR_SHARE, baseline),
),
);
const fits = (step) => {
const probe = Cesium.Camera.clone(camera, lookProbes.get(camera));
lookProbes.set(camera, probe);
probe[look](step);
// Shares are whole grid cells; the epsilon keeps rounding in `floor` from
// costing a cell.
return globeFrameShare(probe, { keyholeRadius }) >= floor - 1e-9;
};
if (fits(angle)) return angle;
let inside = 0;
let outside = angle;
while (outside - inside > LOOK_EDGE_TOLERANCE) {
const middle = (inside + outside) / 2;
if (fits(middle)) inside = middle;
else outside = middle;
}
return inside;
}
/** True when an in-place look step of `angle` would lose part of its turn. */
export function lookStepLosesGlobe(camera, look, angle, options) {
return lookAngleWithinFrame(camera, look, angle, options) < angle;
}
+91 -20
View File
@@ -301,6 +301,8 @@
"AVAILABLE MISSIONS": "المهام المتاحة", "AVAILABLE MISSIONS": "المهام المتاحة",
"SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها", "SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها",
"LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا", "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": "الراديو", "RADIO": "الراديو",
"ENABLE": "تفعيل", "ENABLE": "تفعيل",
"Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة", "Previous filtered radio station": "محطة الراديو السابقة في القائمة المصفّاة",
@@ -1749,6 +1751,9 @@
"Hybrid": "هجين", "Hybrid": "هجين",
"TomTom key unavailable": "مفتاح TomTom غير متاح", "TomTom key unavailable": "مفتاح TomTom غير متاح",
"TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom", "TomTom daily budget reached": "بلغت الحصة اليومية لـ TomTom",
"Monthly traffic allowance used": "تم استهلاك حصة حركة المرور الشهرية",
"Traffic rate limit reached": "تم بلوغ حد طلبات حركة المرور",
"Traffic service unavailable": "خدمة حركة المرور غير متاحة",
"TomTom upstream unreachable": "تعذّر الوصول إلى خادم TomTom", "TomTom upstream unreachable": "تعذّر الوصول إلى خادم TomTom",
"TomTom flow unavailable": "تدفق TomTom غير متاح", "TomTom flow unavailable": "تدفق TomTom غير متاح",
"SIMULATED — {reason}": "محاكى — {reason}", "SIMULATED — {reason}": "محاكى — {reason}",
@@ -2187,6 +2192,9 @@
"{value} KM/H": "{value} كم/س", "{value} KM/H": "{value} كم/س",
"{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}", "{label} · {radius} AIR/SEA WINDOW": "{label} · نطاق جو/بحر {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · المنشآت {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "مصدر البيانات العسكرية متوقف · صورة جزئية",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "مصدر الرحلات متوقف · صورة جزئية",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "قائمة الطائرات القريبة غير مكتملة · لا يعني أن الوضع آمن",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}", "SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}", "ADS-B PRIORITY · {status}": "أولوية ADS-B · {status}",
"ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}", "ADS-B EMERGENCY · {status}": "طوارئ ADS-B · {status}",
@@ -3220,7 +3228,7 @@
"Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة", "Every detection — many of the strongest fires are rated low confidence": "كل عمليات الرصد — كثير من أقوى الحرائق مصنّفة بثقة منخفضة",
"Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية", "Nominal and high confidence detections": "عمليات الرصد ذات الثقة المتوسطة والعالية",
"High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط", "High confidence detections only": "عمليات الرصد ذات الثقة العالية فقط",
"Fire intensity (FRP)": "الشدة (FRP)", "Fire intensity (FRP)": "شدة الحريق (FRP)",
"≥ {value} MW": "{value} MW على الأقل", "≥ {value} MW": "{value} MW على الأقل",
"Confidence": "الثقة", "Confidence": "الثقة",
"Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط", "Minimum fire radiative power, megawatts": "الحد الأدنى للقدرة الإشعاعية للحريق، بالميغاواط",
@@ -3238,15 +3246,15 @@
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة", "Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
"Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)", "Voice place search (needs an Overpass server)": "البحث الصوتي عن الأماكن (يتطلب خادم Overpass)",
"OSM Places": "أماكن OSM", "OSM Places": "أماكن OSM",
"state or province": "ولاية أو مقاطعة", "state or province": "ولاية أو إقليم",
"county or district": "محافظة أو قضاء", "county or district": "مقاطعة أو قضاء",
"neighbourhood": "حي", "neighbourhood": "حي",
"natural region": "منطقة طبيعية", "natural region": "منطقة طبيعية",
"Which {place}?": "أي {place} تقصد؟", "Which {place}?": "أي {place} تقصد؟",
"Which place?": "أي مكان تقصد؟", "Which place?": "أي مكان تقصد؟",
"Not drawn: {parts}": "لم يُرسم: {parts}", "Not drawn: {parts}": "لم يُرسم المخطط: {parts}",
"approximate": "تقريبي", "approximate": "تقريبي",
"{pct}% cloud": "غيوم {pct}%", "{pct}% cloud": "الغيوم {pct}%",
"Over {area}": "فوق {area}", "Over {area}": "فوق {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"few": "الصور المطابقة: {count} من {total}", "few": "الصور المطابقة: {count} من {total}",
@@ -3257,12 +3265,12 @@
"other": "الصور المطابقة: {count} من {total}" "other": "الصور المطابقة: {count} من {total}"
}, },
"{n} acquisitions searched": { "{n} acquisitions searched": {
"few": "الصور التي جرى البحث فيها: {n}", "zero": "الصور المفحوصة: {n}",
"many": "الصور التي جرى البحث فيها: {n}", "one": "الصور المفحوصة: {n}",
"one": "الصور التي جرى البحث فيها: {n}", "two": "الصور المفحوصة: {n}",
"two": "الصور التي جرى البحث فيها: {n}", "few": "الصور المفحوصة: {n}",
"zero": "الصور التي جرى البحث فيها: {n}", "many": "الصور المفحوصة: {n}",
"other": "الصور التي جرى البحث فيها: {n}" "other": "الصور المفحوصة: {n}"
}, },
"No matching imagery": "لا توجد صور مطابقة", "No matching imagery": "لا توجد صور مطابقة",
"Unknown number of {kind}": "{kind}: العدد غير معروف", "Unknown number of {kind}": "{kind}: العدد غير معروف",
@@ -3270,10 +3278,10 @@
"At least {count} {kind}": "{kind}: {count} على الأقل", "At least {count} {kind}": "{kind}: {count} على الأقل",
"{count} {kind}": "{kind}: {count}", "{count} {kind}": "{kind}: {count}",
"In {area}": "في {area}", "In {area}": "في {area}",
"Area around {place}": "المنطقة المحيطة بـ{place}", "Area around {place}": "المنطقة حول {place}",
"Area around landmark": "المنطقة المحيطة بمعلم", "Area around landmark": "المنطقة حول المعلم",
"Outline {place}": "تحديد مخطط {place}", "Outline {place}": "رسم مخطط {place}",
"Outline area": "تحديد مخطط منطقة", "Outline area": "رسم مخطط منطقة",
"Find {place}": "البحث عن {place}", "Find {place}": "البحث عن {place}",
"Find area": "البحث عن منطقة", "Find area": "البحث عن منطقة",
"Find recent imagery": "البحث عن صور حديثة", "Find recent imagery": "البحث عن صور حديثة",
@@ -3316,9 +3324,9 @@
"zero": "التكرارات المدمجة: {count}" "zero": "التكرارات المدمجة: {count}"
}, },
"listed the first {limit}": "عُرض أول {limit}", "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": "أُلغي البحث", "Search cancelled": "أُلغي البحث",
"Sea Marks": "علامات بحرية", "Sea Marks": "العلامات البحرية",
"Ski Pistes": "مسارات التزلج", "Ski Pistes": "مسارات التزلج",
"Fly to Tokyo": "انتقل إلى طوكيو", "Fly to Tokyo": "انتقل إلى طوكيو",
"Show live flights": "اعرض تتبّع الرحلات المباشر", "Show live flights": "اعرض تتبّع الرحلات المباشر",
@@ -3393,7 +3401,7 @@
"Too many chat requests. Try again in {seconds} s.": "طلبات دردشة كثيرة جدًا. أعد المحاولة بعد {seconds} ث.", "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 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.": "نفد رصيد حساب المزوّد. تحقّق من الفوترة، أو اختر مزوّد ذكاء اصطناعي آخر في الإعدادات.", "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}", "Up to {upper}": "حتى {upper}",
"From {lower}": "من {lower}", "From {lower}": "من {lower}",
"Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات", "Informational only · not for flight planning": "للعلم فقط · لا يصلح لتخطيط الرحلات",
@@ -3401,8 +3409,71 @@
"Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح", "Restricted airspace source unavailable": "مصدر المجال الجوي المقيّد غير متاح",
"Prohibited (P)": "محظور (P)", "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 أو قيود طيران 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": "محظور", "Prohibited": "محظور",
"Restricted": "مقيّد", "Restricted": "مقيّد",
"Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد" "Prohibited & Restricted Airspace": "المجال الجوي المحظور والمقيّد",
"SIGHTING": "مشاهدة",
"Show {name} sighting": "إظهار مشاهدة {name}",
"seen {age} ago": "شوهد قبل {age}",
"observed {age} ago": "رُصد قبل {age}",
"Be Whale Wise": "راقب الحيتان بمسؤولية",
"Reported by {name} ({entity})": "أبلغ عنه {name} ({entity})",
"Reported by {name}": "أبلغ عنه {name}",
"Salish Sea": "بحر ساليش",
"Birds": "الطيور",
"Mammals": "الثدييات",
"Reptiles": "الزواحف",
"Amphibians": "البرمائيات",
"Ray-finned fishes": "الأسماك شعاعية الزعانف",
"Insects": "الحشرات",
"Arachnids": "العنكبوتيات",
"Plants": "النباتات",
"Fungi": "الفطريات",
"Other animals": "حيوانات أخرى",
"Other or unknown": "أخرى أو غير معروفة",
"{label} {detail} — click to retry": "{label} {detail} — انقر لإعادة المحاولة",
"Wildlife sightings source unavailable": "مصدر مشاهدات الحياة البرية غير متاح",
"Show only community-confirmed (research-grade) sightings": "إظهار المشاهدات المؤكدة مجتمعياً (بدرجة بحثية) فقط",
"No recent sightings in view": "لا توجد مشاهدات حديثة في العرض",
"Acartia feed unavailable": "تعذر الوصول إلى مصدر Acartia",
"Show all taxa": "إظهار جميع المجموعات التصنيفية",
"BIRDS": "الطيور",
"Show only birds": "إظهار الطيور فقط",
"MAMMALS": "الثدييات",
"Show only mammals": "إظهار الثدييات فقط",
"WHALES": "الحيتان",
"Show only whales and dolphins (7 days)": "إظهار الحيتان والدلافين فقط (7 أيام)",
"INSECTS": "الحشرات",
"Show only insects": "إظهار الحشرات فقط",
"PLANTS": "النباتات",
"Show only plants": "إظهار النباتات فقط",
"All rights reserved": "جميع الحقوق محفوظة",
"Cetacean": "حوتي",
"Wildlife Sightings": "مشاهدات الحياة البرية",
"Exit cockpit to fly to a sighting": "اخرج من قمرة القيادة للانتقال إلى مشاهدة",
"Exit cockpit to fly to a sighting area": "اخرج من قمرة القيادة للانتقال إلى منطقة مشاهدة",
"Wildlife": "الحياة البرية",
"Zoom in for local sightings": "قرّب لرؤية المشاهدات المحلية",
"Loading photo...": "جارٍ تحميل الصورة…",
"Photo unavailable": "الصورة غير متاحة",
"Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°",
"Photo of {name}": "صورة {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} مشاهدة ضمن النطاق",
"Close sighting": "إغلاق المشاهدة",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "حياة برية شوهدت خلال 48 ساعة (الحيتان 7 أيام). يوضح اللون ورمز الشارة المجموعة التصنيفية. تُعرض الأنواع الحساسة في خلايا بحجم 0.2° فقط؛ وتُقرّب مشاهدات الثدييات البحرية في بحر ساليش عبر Acartia إلى 0.01°. ليست إحصاءً شاملاً.",
"Open {name} on iNaturalist": "فتح {name} على iNaturalist",
"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} (بحر ساليش) · التراخيص لكل سجل",
"{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} يوم · الاحتفاظ بغير المؤرخ"
} }
+89 -18
View File
@@ -760,6 +760,8 @@
"AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN", "AVAILABLE MISSIONS": "VERFÜGBARE MISSIONEN",
"SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN", "SELECT A MISSION TO INSPECT": "MISSION ZUM ANSEHEN WÄHLEN",
"LOADING 30-DAY MISSION INDEX": "30-TAGE-MISSIONSINDEX WIRD GELADEN", "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", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: VORSCHAU · ENTER / LEERTASTE: AUSWAHL",
"Internet radio and local SDR": "Internetradio und lokales SDR", "Internet radio and local SDR": "Internetradio und lokales SDR",
"STATION TAG": "KATEGORIE", "STATION TAG": "KATEGORIE",
@@ -1667,6 +1669,9 @@
"Hybrid": "Hybrid", "Hybrid": "Hybrid",
"TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar", "TomTom key unavailable": "TomTom-Schlüssel nicht verfügbar",
"TomTom daily budget reached": "TomTom-Tagesbudget erreicht", "TomTom daily budget reached": "TomTom-Tagesbudget erreicht",
"Monthly traffic allowance used": "Monatliches Verkehrskontingent aufgebraucht",
"Traffic rate limit reached": "Verkehrs-Anfragelimit erreicht",
"Traffic service unavailable": "Verkehrsdienst nicht verfügbar",
"TomTom upstream unreachable": "TomTom-Upstream nicht erreichbar", "TomTom upstream unreachable": "TomTom-Upstream nicht erreichbar",
"TomTom flow unavailable": "TomTom-Verkehrsfluss nicht verfügbar", "TomTom flow unavailable": "TomTom-Verkehrsfluss nicht verfügbar",
"SIMULATED — {reason}": "SIMULIERT – {reason}", "SIMULATED — {reason}": "SIMULIERT – {reason}",
@@ -2087,6 +2092,9 @@
"{value} KM/H": "{value} KM/H", "{value} KM/H": "{value} KM/H",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} LUFT-/SEEFENSTER",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ANLAGEN {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄR-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUG-FEED AUS · UNVOLLSTÄNDIGES LAGEBILD",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLUGZEUGE IN DER NÄHE UNVOLLSTÄNDIG · KEINE ENTWARNUNG",
"SQUAWK {code} · {status}": "SQUAWK {code} · {status}", "SQUAWK {code} · {status}": "SQUAWK {code} · {status}",
"ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}", "ADS-B PRIORITY · {status}": "ADS-B-PRIORITÄT · {status}",
"ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}", "ADS-B EMERGENCY · {status}": "ADS-B-NOTFALL · {status}",
@@ -2912,7 +2920,7 @@
"From {lower}": "Ab {lower}", "From {lower}": "Ab {lower}",
"Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung", "Informational only · not for flight planning": "Nur zur Information · nicht für die Flugplanung",
"Unnamed zone": "Unbenannte Zone", "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)", "Prohibited (P)": "Sperrgebiet (P)",
"Restricted (R)": "Flugbeschränkungsgebiet (R)", "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).", "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).",
@@ -2949,13 +2957,13 @@
"fire confidence filter|All": "Alle", "fire confidence filter|All": "Alle",
"fire confidence filter|Nominal+": "Mittel+", "fire confidence filter|Nominal+": "Mittel+",
"fire confidence filter|High": "Hoch", "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", "Nominal and high confidence detections": "Erkennungen mit mittlerer und hoher Konfidenz",
"High confidence detections only": "Nur Erkennungen mit hoher Konfidenz", "High confidence detections only": "Nur Erkennungen mit hoher Konfidenz",
"Fire intensity (FRP)": "Intensität (FRP)", "Fire intensity (FRP)": "Intensität (FRP)",
"≥ {value} MW": "≥ {value} MW", "≥ {value} MW": "≥ {value} MW",
"Confidence": "Konfidenz", "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", "fire intensity filter|All": "Alle",
"Detection confidence": "Konfidenz der Erkennung", "Detection confidence": "Konfidenz der Erkennung",
"{shown} of {total} shown": "{shown} von {total} angezeigt", "{shown} of {total} shown": "{shown} von {total} angezeigt",
@@ -2995,9 +3003,9 @@
"{count} {kind}": "{kind}: {count}", "{count} {kind}": "{kind}: {count}",
"In {area}": "Gebiet: {area}", "In {area}": "Gebiet: {area}",
"Area around {place}": "Gebiet um {place}", "Area around {place}": "Gebiet um {place}",
"Area around landmark": "Gebiet um ein Wahrzeichen", "Area around landmark": "Gebiet um einen Ort",
"Outline {place}": "{place} umreißen", "Outline {place}": "Umriss von {place} zeichnen",
"Outline area": "Gebiet umreißen", "Outline area": "Umriss zeichnen",
"Find {place}": "{place} finden", "Find {place}": "{place} finden",
"Find area": "Gebiet finden", "Find area": "Gebiet finden",
"Find recent imagery": "Aktuelle Bilddaten finden", "Find recent imagery": "Aktuelle Bilddaten finden",
@@ -3008,27 +3016,27 @@
"Loading imagery": "Bilddaten werden geladen", "Loading imagery": "Bilddaten werden geladen",
"Searching OpenStreetMap": "OpenStreetMap wird durchsucht", "Searching OpenStreetMap": "OpenStreetMap wird durchsucht",
"voice area|Area": "Gebiet", "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", "enclosing mapped area": "umschließendes kartiertes Gebiet",
"the map was cleared while looking it up": "die Karte wurde während der Suche geleert", "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", "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", "the catalog covers the last 30 days": "der Katalog umfasst die letzten 30 Tage",
"there are more": "es gibt weitere", "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": { "{count} parts": {
"one": "Teile: {count}", "one": "{count} Teil",
"other": "Teile: {count}" "other": "{count} Teile"
}, },
"{drawn} of {total} parts drawn": { "{drawn} of {total} parts drawn": {
"one": "Gezeichnete Teile: {drawn} von {total}", "one": "{drawn} von {total} Teil gezeichnet",
"other": "Gezeichnete Teile: {drawn} von {total}" "other": "{drawn} von {total} Teilen gezeichnet"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"one": "Zusammengeführte Doppelkartierungen: {count}", "one": "{count} Duplikat zusammengeführt",
"other": "Zusammengeführte Doppelkartierungen: {count}" "other": "{count} Duplikate zusammengeführt"
}, },
"listed the first {limit}": "die ersten {limit} aufgelistet", "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", "Search cancelled": "Suche abgebrochen",
"Sea Marks": "Seezeichen", "Sea Marks": "Seezeichen",
"Ski Pistes": "Skipisten", "Ski Pistes": "Skipisten",
@@ -3104,5 +3112,68 @@
"The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.", "The provider rejected the API key. Check it in Provider Settings.": "Der Anbieter hat den API-Schlüssel abgelehnt. Den Schlüssel in den Anbieter-Einstellungen prüfen.",
"Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.", "Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.", "The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen." "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.",
"SIGHTING": "BEOBACHTUNG",
"Show {name} sighting": "Beobachtung von {name} anzeigen",
"seen {age} ago": "vor {age} gesichtet",
"observed {age} ago": "vor {age} beobachtet",
"Be Whale Wise": "Wale respektvoll beobachten",
"Reported by {name} ({entity})": "Gemeldet von {name} ({entity})",
"Reported by {name}": "Gemeldet von {name}",
"Salish Sea": "Salish-See",
"Birds": "Vögel",
"Mammals": "Säugetiere",
"Reptiles": "Reptilien",
"Amphibians": "Amphibien",
"Ray-finned fishes": "Strahlenflosser",
"Insects": "Insekten",
"Arachnids": "Spinnentiere",
"Plants": "Pflanzen",
"Fungi": "Pilze",
"Other animals": "Andere Tiere",
"Other or unknown": "Andere oder unbekannt",
"{label} {detail} — click to retry": "{label} {detail} — zum Wiederholen klicken",
"Wildlife sightings source unavailable": "Quelle für Naturbeobachtungen nicht verfügbar",
"Show only community-confirmed (research-grade) sightings": "Nur gemeinschaftlich bestätigte Beobachtungen (Forschungsqualität) anzeigen",
"No recent sightings in view": "Keine aktuellen Beobachtungen im Ausschnitt",
"Acartia feed unavailable": "Acartia-Datenstrom nicht verfügbar",
"Show all taxa": "Alle Artengruppen anzeigen",
"BIRDS": "VÖGEL",
"Show only birds": "Nur Vögel anzeigen",
"MAMMALS": "SÄUGETIERE",
"Show only mammals": "Nur Säugetiere anzeigen",
"WHALES": "WALE",
"Show only whales and dolphins (7 days)": "Nur Wale und Delfine anzeigen (7 Tage)",
"INSECTS": "INSEKTEN",
"Show only insects": "Nur Insekten anzeigen",
"PLANTS": "PFLANZEN",
"Show only plants": "Nur Pflanzen anzeigen",
"All rights reserved": "Alle Rechte vorbehalten",
"Cetacean": "Wal oder Delfin",
"Wildlife Sightings": "Naturbeobachtungen",
"Exit cockpit to fly to a sighting": "Cockpit verlassen, um zu einer Beobachtung zu fliegen",
"Exit cockpit to fly to a sighting area": "Cockpit verlassen, um zu einem Beobachtungsgebiet zu fliegen",
"Wildlife": "Tierwelt",
"Zoom in for local sightings": "Für lokale Beobachtungen vergrößern",
"Loading photo...": "Foto wird geladen…",
"Photo unavailable": "Foto nicht verfügbar",
"Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert",
"Photo of {name}": "Foto von {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} Sichtungen im Ausschnitt",
"Close sighting": "Sichtung schließen",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "In den letzten 48 h beobachtete Wildtiere (Wale 7 Tage). Farbe und Abzeichensymbol zeigen die Artengruppe. Empfindliche Arten erscheinen nur als 0,2°-Zellen; Acartia-Meldungen zu Meeressäugern aus der Salish-See werden auf 0,01° gerundet. Keine Bestandszählung.",
"Open {name} on iNaturalist": "{name} auf iNaturalist öffnen",
"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",
"{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"
} }
+86 -15
View File
@@ -192,6 +192,8 @@
"AVAILABLE MISSIONS": "MISIONES DISPONIBLES", "AVAILABLE MISSIONS": "MISIONES DISPONIBLES",
"SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR", "SELECT A MISSION TO INSPECT": "SELECCIONA UNA MISIÓN PARA INSPECCIONAR",
"LOADING 30-DAY MISSION INDEX": "CARGANDO ÍNDICE DE MISIONES DE 30 DÍAS", "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", "RADIO": "RADIO",
"ENABLE": "ACTIVAR", "ENABLE": "ACTIVAR",
"Previous filtered radio station": "Emisora de radio filtrada anterior", "Previous filtered radio station": "Emisora de radio filtrada anterior",
@@ -1920,6 +1922,9 @@
"N/A": "N/D", "N/A": "N/D",
"{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}", "{label} · {radius} AIR/SEA WINDOW": "{label} · VENTANA AIRE/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO",
"{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE", "{source} · LOCATION QUERY · RECENT": "{source} · CONSULTA POR UBICACIÓN · RECIENTE",
"ft": "ft", "ft": "ft",
"kts": "kn", "kts": "kn",
@@ -2271,6 +2276,9 @@
"SIMULATED — {reason}": "SIMULADO — {reason}", "SIMULATED — {reason}": "SIMULADO — {reason}",
"TomTom key unavailable": "Clave de TomTom no disponible", "TomTom key unavailable": "Clave de TomTom no disponible",
"TomTom daily budget reached": "Se alcanzó el límite diario de TomTom", "TomTom daily budget reached": "Se alcanzó el límite diario de TomTom",
"Monthly traffic allowance used": "Cupo mensual de tráfico agotado",
"Traffic rate limit reached": "Límite de solicitudes de tráfico alcanzado",
"Traffic service unavailable": "Servicio de tráfico no disponible",
"TomTom upstream unreachable": "Servidor de TomTom inaccesible", "TomTom upstream unreachable": "Servidor de TomTom inaccesible",
"TomTom flow unavailable": "Flujo de TomTom no disponible", "TomTom flow unavailable": "Flujo de TomTom no disponible",
"API key rejected — check AISSTREAM_API_KEY": "Clave de API rechazada — revisa AISSTREAM_API_KEY", "API key rejected — check AISSTREAM_API_KEY": "Clave de API rechazada — revisa AISSTREAM_API_KEY",
@@ -2986,7 +2994,7 @@
"fire confidence filter|All": "Todas", "fire confidence filter|All": "Todas",
"fire confidence filter|Nominal+": "Nominal+", "fire confidence filter|Nominal+": "Nominal+",
"fire confidence filter|High": "Alta", "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", "Nominal and high confidence detections": "Detecciones de confianza nominal y alta",
"High confidence detections only": "Solo detecciones de confianza alta", "High confidence detections only": "Solo detecciones de confianza alta",
"Fire intensity (FRP)": "Intensidad (FRP)", "Fire intensity (FRP)": "Intensidad (FRP)",
@@ -2995,8 +3003,8 @@
"Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios", "Minimum fire radiative power, megawatts": "Potencia radiativa mínima del fuego, en megavatios",
"fire intensity filter|All": "Todo", "fire intensity filter|All": "Todo",
"Detection confidence": "Confianza de la detección", "Detection confidence": "Confianza de la detección",
"{shown} of {total} shown": "{shown} de {total} mostrados", "{shown} of {total} shown": "Mostrando {shown} de {total}",
"{lower} to {upper}": "{lower} a {upper}", "{lower} to {upper}": "De {lower} a {upper}",
"Up to {upper}": "Hasta {upper}", "Up to {upper}": "Hasta {upper}",
"From {lower}": "Desde {lower}", "From {lower}": "Desde {lower}",
"Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos", "Informational only · not for flight planning": "Solo informativo · no apto para planificar vuelos",
@@ -3004,9 +3012,9 @@
"Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible", "Restricted airspace source unavailable": "Fuente de espacio aéreo restringido no disponible",
"Prohibited (P)": "Prohibido (P)", "Prohibited (P)": "Prohibido (P)",
"Restricted (R)": "Restringido (R)", "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).", "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": "Prohibido", "Prohibited": "Zona prohibida",
"Restricted": "Restringido", "Restricted": "Zona restringida",
"Prohibited & Restricted Airspace": "Espacio aéreo prohibido y restringido", "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", "routing busy — too many routes at once": "servicio de rutas ocupado: demasiadas rutas a la vez",
"route proxy error": "error del proxy de rutas", "route proxy error": "error del proxy de rutas",
@@ -3044,11 +3052,11 @@
"county or district": "condado o distrito", "county or district": "condado o distrito",
"neighbourhood": "barrio", "neighbourhood": "barrio",
"natural region": "región natural", "natural region": "región natural",
"Which {place}?": "¿Cuál {place}?", "Which {place}?": "¿Qué {place}?",
"Which place?": "¿Qué lugar?", "Which place?": "¿Qué lugar?",
"Not drawn: {parts}": "Sin dibujar: {parts}", "Not drawn: {parts}": "Sin dibujar: {parts}",
"approximate": "aproximado", "approximate": "aproximada",
"{pct}% cloud": "{pct} % de nubes", "{pct}% cloud": "{pct}% de nubes",
"Over {area}": "Zona: {area}", "Over {area}": "Zona: {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"many": "Adquisiciones coincidentes: {count} de {total}", "many": "Adquisiciones coincidentes: {count} de {total}",
@@ -3062,7 +3070,7 @@
}, },
"No matching imagery": "No hay imágenes que coincidan", "No matching imagery": "No hay imágenes que coincidan",
"Unknown number of {kind}": "{kind}: número desconocido", "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}", "At least {count} {kind}": "{kind}: al menos {count}",
"{count} {kind}": "{kind}: {count}", "{count} {kind}": "{kind}: {count}",
"In {area}": "En {area}", "In {area}": "En {area}",
@@ -3081,12 +3089,12 @@
"Searching OpenStreetMap": "Buscando en OpenStreetMap", "Searching OpenStreetMap": "Buscando en OpenStreetMap",
"voice area|Area": "Área", "voice area|Area": "Área",
"buffer sized to the place": "zona aproximada del tamaño del lugar", "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", "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", "the catalog covers the last 30 days": "el catálogo cubre los últimos 30 días",
"there are more": "hay más", "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": { "{count} parts": {
"many": "Partes: {count}", "many": "Partes: {count}",
"one": "Partes: {count}", "one": "Partes: {count}",
@@ -3103,7 +3111,7 @@
"other": "Duplicados fusionados: {count}" "other": "Duplicados fusionados: {count}"
}, },
"listed the first {limit}": "se listaron los primeros {limit}", "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", "Search cancelled": "Búsqueda cancelada",
"Sea Marks": "Señales marítimas", "Sea Marks": "Señales marítimas",
"Ski Pistes": "Pistas de esquí", "Ski Pistes": "Pistas de esquí",
@@ -3179,5 +3187,68 @@
"The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.", "The provider rejected the API key. Check it in Provider Settings.": "El proveedor rechazó la clave de API. Revísala en Ajustes de proveedores.",
"Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.", "Too many chat requests. Try again in {seconds} s.": "Demasiadas solicitudes de chat. Vuelve a intentarlo en {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.", "The provider refused access for this key. Check the key's project, region or permissions.": "El proveedor denegó el acceso a esta clave. Revisa el proyecto, la región o los permisos de la clave.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes." "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "La cuenta del proveedor no tiene crédito. Revisa la facturación o elige otro proveedor de IA en Ajustes.",
"SIGHTING": "AVISTAMIENTO",
"Show {name} sighting": "Mostrar avistamiento de {name}",
"seen {age} ago": "visto hace {age}",
"observed {age} ago": "observado hace {age}",
"Be Whale Wise": "Observa cetáceos con respeto",
"Reported by {name} ({entity})": "Reportado por {name} ({entity})",
"Reported by {name}": "Reportado por {name}",
"Salish Sea": "Mar de Salish",
"Birds": "Aves",
"Mammals": "Mamíferos",
"Reptiles": "Reptiles",
"Amphibians": "Anfibios",
"Ray-finned fishes": "Peces de aletas radiadas",
"Insects": "Insectos",
"Arachnids": "Arácnidos",
"Plants": "Plantas",
"Fungi": "Hongos",
"Other animals": "Otros animales",
"Other or unknown": "Otros o desconocidos",
"{label} {detail} — click to retry": "{label} {detail} — haz clic para reintentar",
"Wildlife sightings source unavailable": "Fuente de avistamientos no disponible",
"Show only community-confirmed (research-grade) sightings": "Mostrar solo observaciones confirmadas por la comunidad (grado de investigación)",
"No recent sightings in view": "No hay avistamientos recientes en la vista",
"Acartia feed unavailable": "Fuente Acartia no disponible",
"Show all taxa": "Mostrar todos los grupos taxonómicos",
"BIRDS": "AVES",
"Show only birds": "Mostrar solo aves",
"MAMMALS": "MAMÍFEROS",
"Show only mammals": "Mostrar solo mamíferos",
"WHALES": "CETÁCEOS",
"Show only whales and dolphins (7 days)": "Mostrar solo ballenas y delfines (7 días)",
"INSECTS": "INSECTOS",
"Show only insects": "Mostrar solo insectos",
"PLANTS": "PLANTAS",
"Show only plants": "Mostrar solo plantas",
"All rights reserved": "Todos los derechos reservados",
"Cetacean": "Cetáceo",
"Wildlife Sightings": "Avistamientos de fauna y flora",
"Exit cockpit to fly to a sighting": "Sal de la cabina para volar a un avistamiento",
"Exit cockpit to fly to a sighting area": "Sal de la cabina para volar a un área de avistamiento",
"Wildlife": "Vida silvestre",
"Zoom in for local sightings": "Acerca para ver avistamientos locales",
"Loading photo...": "Cargando foto…",
"Photo unavailable": "Foto no disponible",
"Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°",
"Photo of {name}": "Foto de {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} avistamientos en la vista",
"Close sighting": "Cerrar avistamiento",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada en las últimas 48 h (cetáceos 7 días). El color y el símbolo de la insignia indican el grupo taxonómico. Las especies sensibles solo se muestran en celdas de 0,2°; los informes de mamíferos marinos de Acartia en el mar de Salish se redondean a 0,01°. No es un censo.",
"Open {name} on iNaturalist": "Abrir {name} en iNaturalist",
"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",
"{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"
} }
+99 -28
View File
@@ -1105,6 +1105,9 @@
"Hybrid": "Hybride", "Hybrid": "Hybride",
"TomTom key unavailable": "Clé TomTom indisponible", "TomTom key unavailable": "Clé TomTom indisponible",
"TomTom daily budget reached": "Quota quotidien TomTom atteint", "TomTom daily budget reached": "Quota quotidien TomTom atteint",
"Monthly traffic allowance used": "Quota mensuel de trafic épuisé",
"Traffic rate limit reached": "Limite de requêtes de trafic atteinte",
"Traffic service unavailable": "Service de trafic indisponible",
"TomTom upstream unreachable": "Serveur TomTom injoignable", "TomTom upstream unreachable": "Serveur TomTom injoignable",
"TomTom flow unavailable": "Flux TomTom indisponible", "TomTom flow unavailable": "Flux TomTom indisponible",
"SIMULATED — {reason}": "SIMULÉ — {reason}", "SIMULATED — {reason}": "SIMULÉ — {reason}",
@@ -1660,6 +1663,9 @@
"REGIONAL NEWS": "ACTUS RÉGIONALES", "REGIONAL NEWS": "ACTUS RÉGIONALES",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}", "{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT",
"CONTEXT STANDBY": "CONTEXTE EN VEILLE", "CONTEXT STANDBY": "CONTEXTE EN VEILLE",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT",
@@ -2799,6 +2805,8 @@
"AVAILABLE MISSIONS": "MISSIONS DISPONIBLES", "AVAILABLE MISSIONS": "MISSIONS DISPONIBLES",
"SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER", "SELECT A MISSION TO INSPECT": "CHOISIR UNE MISSION À EXAMINER",
"LOADING 30-DAY MISSION INDEX": "CHARGEMENT DE L'INDEX DES MISSIONS À 30 JOURS", "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", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB APERÇU · ENTER / SPACE SÉLECTIONNE",
"Internet radio and local SDR": "Radio Internet et SDR local", "Internet radio and local SDR": "Radio Internet et SDR local",
"STATION TAG": "TAG DE STATION", "STATION TAG": "TAG DE STATION",
@@ -2977,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.", "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é", "Malformed cyclone snapshot": "Instantané cyclonique mal formé",
"Cyclone request timed out": "Délai dépassé pour la requête cyclonique", "Cyclone request timed out": "Délai dépassé pour la requête cyclonique",
"{lower} to {upper}": "{lower} à {upper}", "{lower} to {upper}": "De {lower} à {upper}",
"Up to {upper}": "Jusqu’à {upper}", "Up to {upper}": "Jusqu'à {upper}",
"From {lower}": "À partir de {lower}", "From {lower}": "À partir de {lower}",
"Informational only · not for flight planning": "À titre informatif uniquement · ne pas utiliser pour la planification de vol", "Informational only · not for flight planning": "À titre indicatif · ne pas utiliser pour la préparation des vols",
"Unnamed zone": "Zone sans nom", "Unnamed zone": "Sans nom",
"Restricted airspace source unavailable": "Source de l’espace aérien réglementé indisponible", "Restricted airspace source unavailable": "Source de l'espace aérien réglementé indisponible",
"Prohibited (P)": "Zone interdite (P)", "Prohibited (P)": "Zone interdite (P)",
"Restricted (R)": "Zone réglementée (R)", "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", "Prohibited": "Zone interdite",
"Restricted": "Zone réglementée", "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", "scene|PLAY": "LIRE",
"AUTHORING": "CRÉATION", "AUTHORING": "CRÉATION",
"Create and edit scenes": "Créer et modifier des scènes", "Create and edit scenes": "Créer et modifier des scènes",
@@ -3019,7 +3027,7 @@
"fire confidence filter|All": "Toutes", "fire confidence filter|All": "Toutes",
"fire confidence filter|Nominal+": "Nominale+", "fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Élevée", "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", "Nominal and high confidence detections": "Détections de confiance nominale et élevée",
"High confidence detections only": "Détections de confiance élevée uniquement", "High confidence detections only": "Détections de confiance élevée uniquement",
"Fire intensity (FRP)": "Intensité (FRP)", "Fire intensity (FRP)": "Intensité (FRP)",
@@ -3028,7 +3036,7 @@
"Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts", "Minimum fire radiative power, megawatts": "Puissance radiative minimale du feu, en mégawatts",
"fire intensity filter|All": "Toutes", "fire intensity filter|All": "Toutes",
"Detection confidence": "Confiance de la détection", "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}", "Name of {name}": "Nom de {name}",
"Change the colour of {name}": "Changer la couleur de {name}", "Change the colour of {name}": "Changer la couleur de {name}",
"Colour of {name}": "Couleur de {name}", "Colour of {name}": "Couleur de {name}",
@@ -3038,7 +3046,7 @@
"UTILITIES": "OUTILS", "UTILITIES": "OUTILS",
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin", "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", "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", "OSM Places": "Lieux OSM",
"state or province": "État ou province", "state or province": "État ou province",
"county or district": "comté ou district", "county or district": "comté ou district",
@@ -3046,9 +3054,9 @@
"natural region": "région naturelle", "natural region": "région naturelle",
"Which {place}?": "Quel lieu nommé {place} ?", "Which {place}?": "Quel lieu nommé {place} ?",
"Which place?": "Quel lieu ?", "Which place?": "Quel lieu ?",
"Not drawn: {parts}": "Non dessiné : {parts}", "Not drawn: {parts}": "Contour non tracé : {parts}",
"approximate": "approximatif", "approximate": "approximatif",
"{pct}% cloud": "{pct} % de nuages", "{pct}% cloud": "{pct} % de nuages",
"Over {area}": "Zone : {area}", "Over {area}": "Zone : {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"many": "Acquisitions correspondantes : {count} sur {total}", "many": "Acquisitions correspondantes : {count} sur {total}",
@@ -3067,43 +3075,43 @@
"{count} {kind}": "{kind} : {count}", "{count} {kind}": "{kind} : {count}",
"In {area}": "Zone : {area}", "In {area}": "Zone : {area}",
"Area around {place}": "Environs : {place}", "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 {place}": "Délimiter {place}",
"Outline area": "Délimiter une zone", "Outline area": "Délimiter une zone",
"Find {place}": "Trouver {place}", "Find {place}": "Trouver {place}",
"Find area": "Trouver une zone", "Find area": "Trouver une zone",
"Find recent imagery": "Trouver des images récentes", "Find recent imagery": "Trouver des images récentes",
"Find {what}": "Trouver {what}", "Find {what}": "Trouver : {what}",
"Find places": "Trouver des lieux", "Find places": "Trouver des lieux",
"Drawing outline": "Dessin du contour", "Drawing outline": "Dessin du contour",
"Searching imagery catalog": "Recherche dans le catalogue d'images", "Searching imagery catalog": "Recherche dans le catalogue d'images",
"Loading imagery": "Chargement des images", "Loading imagery": "Chargement des images",
"Searching OpenStreetMap": "Recherche dans OpenStreetMap", "Searching OpenStreetMap": "Recherche dans OpenStreetMap",
"voice area|Area": "Zone", "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", "enclosing mapped area": "zone cartographiée englobante",
"the map was cleared while looking it up": "la carte a été effacée pendant la recherche", "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", "the catalog covers the last 30 days": "le catalogue couvre les 30 derniers jours",
"there are more": "il y en a d'autres", "there are more": "il y en a d'autres",
"{radius} m around the landmark": "{radius} m autour du lieu", "{radius} m around the landmark": "{radius} m autour du lieu",
"{count} parts": { "{count} parts": {
"many": "Parties : {count}", "many": "Parties : {count}",
"one": "Parties : {count}", "one": "Parties : {count}",
"other": "Parties : {count}" "other": "Parties : {count}"
}, },
"{drawn} of {total} parts drawn": { "{drawn} of {total} parts drawn": {
"many": "Parties tracées : {drawn} sur {total}", "many": "Parties tracées : {drawn} sur {total}",
"one": "Parties tracées : {drawn} sur {total}", "one": "Parties tracées : {drawn} sur {total}",
"other": "Parties tracées : {drawn} sur {total}" "other": "Parties tracées : {drawn} sur {total}"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"many": "Doublons fusionnés : {count}", "many": "Doublons fusionnés : {count}",
"one": "Doublons fusionnés : {count}", "one": "Doublons fusionnés : {count}",
"other": "Doublons fusionnés : {count}" "other": "Doublons fusionnés : {count}"
}, },
"listed the first {limit}": "les {limit} premiers sont listés", "listed the first {limit}": "liste limitée à {limit}",
"counted over the bounding box of {area}, not its outline": "compté sur le rectangle englobant ({area}), pas sur le contour", "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", "Search cancelled": "Recherche annulée",
"Sea Marks": "Balisage maritime", "Sea Marks": "Balisage maritime",
"Ski Pistes": "Pistes de ski", "Ski Pistes": "Pistes de ski",
@@ -3179,5 +3187,68 @@
"The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.", "The provider rejected the API key. Check it in Provider Settings.": "Le fournisseur a refusé la clé API. Vérifiez-la dans les paramètres des fournisseurs.",
"Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.", "Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres." "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.",
"SIGHTING": "OBSERVATION",
"Show {name} sighting": "Afficher l’observation de {name}",
"seen {age} ago": "vu il y a {age}",
"observed {age} ago": "observé il y a {age}",
"Be Whale Wise": "Respectez les cétacés",
"Reported by {name} ({entity})": "Signalé par {name} ({entity})",
"Reported by {name}": "Signalé par {name}",
"Salish Sea": "Mer des Salish",
"Birds": "Oiseaux",
"Mammals": "Mammifères",
"Reptiles": "Reptiles",
"Amphibians": "Amphibiens",
"Ray-finned fishes": "Poissons à nageoires rayonnées",
"Insects": "Insectes",
"Arachnids": "Arachnides",
"Plants": "Plantes",
"Fungi": "Champignons",
"Other animals": "Autres animaux",
"Other or unknown": "Autres ou inconnus",
"{label} {detail} — click to retry": "{label} {detail} — cliquer pour réessayer",
"Wildlife sightings source unavailable": "Source des observations naturalistes indisponible",
"Show only community-confirmed (research-grade) sightings": "Afficher uniquement les observations confirmées par la communauté (qualité recherche)",
"No recent sightings in view": "Aucune observation récente dans la vue",
"Acartia feed unavailable": "Flux Acartia indisponible",
"Show all taxa": "Afficher tous les groupes taxonomiques",
"BIRDS": "OISEAUX",
"Show only birds": "Afficher uniquement les oiseaux",
"MAMMALS": "MAMMIFÈRES",
"Show only mammals": "Afficher uniquement les mammifères",
"WHALES": "CÉTACÉS",
"Show only whales and dolphins (7 days)": "Afficher uniquement les baleines et dauphins (7 jours)",
"INSECTS": "INSECTES",
"Show only insects": "Afficher uniquement les insectes",
"PLANTS": "PLANTES",
"Show only plants": "Afficher uniquement les plantes",
"All rights reserved": "Tous droits réservés",
"Cetacean": "Cétacé",
"Wildlife Sightings": "Observations naturalistes",
"Exit cockpit to fly to a sighting": "Quittez le cockpit pour rejoindre une observation",
"Exit cockpit to fly to a sighting area": "Quittez le cockpit pour rejoindre une zone d’observation",
"Wildlife": "Faune et flore",
"Zoom in for local sightings": "Zoomez pour les observations locales",
"Loading photo...": "Chargement de la photo…",
"Photo unavailable": "Photo indisponible",
"Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°",
"Photo of {name}": "Photo de {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} observations dans la vue",
"Close sighting": "Fermer l’observation",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins Acartia en mer des Salish sont arrondis à 0,01°. Pas un recensement.",
"Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist",
"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",
"{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"
} }
+92 -21
View File
@@ -1105,6 +1105,9 @@
"Hybrid": "Ibrido", "Hybrid": "Ibrido",
"TomTom key unavailable": "Chiave TomTom non disponibile", "TomTom key unavailable": "Chiave TomTom non disponibile",
"TomTom daily budget reached": "Budget giornaliero TomTom esaurito", "TomTom daily budget reached": "Budget giornaliero TomTom esaurito",
"Monthly traffic allowance used": "Quota mensile del traffico esaurita",
"Traffic rate limit reached": "Limite di richieste del traffico raggiunto",
"Traffic service unavailable": "Servizio traffico non disponibile",
"TomTom upstream unreachable": "Servizio TomTom irraggiungibile", "TomTom upstream unreachable": "Servizio TomTom irraggiungibile",
"TomTom flow unavailable": "Flusso TomTom non disponibile", "TomTom flow unavailable": "Flusso TomTom non disponibile",
"SIMULATED — {reason}": "SIMULATO: {reason}", "SIMULATED — {reason}": "SIMULATO: {reason}",
@@ -1660,6 +1663,9 @@
"REGIONAL NEWS": "NOTIZIE REGIONALI", "REGIONAL NEWS": "NOTIZIE REGIONALI",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}", "{label} · {radius} AIR/SEA WINDOW": "{label} · ARIA/MARE {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA",
"CONTEXT STANDBY": "CONTESTO IN ATTESA", "CONTEXT STANDBY": "CONTESTO IN ATTESA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA",
@@ -2799,6 +2805,8 @@
"AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI",
"SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE",
"LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", "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", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA",
"Internet radio and local SDR": "Radio internet e SDR locale", "Internet radio and local SDR": "Radio internet e SDR locale",
"STATION TAG": "TAG STAZIONE", "STATION TAG": "TAG STAZIONE",
@@ -2977,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.", "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", "Malformed cyclone snapshot": "Snapshot dei cicloni non valido",
"Cyclone request timed out": "La richiesta dei cicloni è andata in timeout", "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}", "Up to {upper}": "Fino a {upper}",
"From {lower}": "Da {lower}", "From {lower}": "Da {lower}",
"Informational only · not for flight planning": "Solo a scopo informativo · non per la pianificazione del volo", "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", "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)", "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).", "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 proibita", "Prohibited": "Zona vietata",
"Restricted": "Zona regolamentata", "Restricted": "Zona regolamentata",
"Prohibited & Restricted Airspace": "Zone proibite e regolamentate", "Prohibited & Restricted Airspace": "Spazio aereo vietato e regolamentato",
"scene|PLAY": "AVVIA", "scene|PLAY": "AVVIA",
"AUTHORING": "CREAZIONE", "AUTHORING": "CREAZIONE",
"Create and edit scenes": "Crea e modifica le scene", "Create and edit scenes": "Crea e modifica le scene",
@@ -3019,7 +3027,7 @@
"fire confidence filter|All": "Tutti", "fire confidence filter|All": "Tutti",
"fire confidence filter|Nominal+": "Nominale+", "fire confidence filter|Nominal+": "Nominale+",
"fire confidence filter|High": "Alta", "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", "Nominal and high confidence detections": "Rilevamenti con confidenza nominale e alta",
"High confidence detections only": "Solo rilevamenti con confidenza alta", "High confidence detections only": "Solo rilevamenti con confidenza alta",
"Fire intensity (FRP)": "Intensità (FRP)", "Fire intensity (FRP)": "Intensità (FRP)",
@@ -3028,7 +3036,7 @@
"Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt", "Minimum fire radiative power, megawatts": "Potenza radiativa minima dell'incendio, in megawatt",
"fire intensity filter|All": "Tutti", "fire intensity filter|All": "Tutti",
"Detection confidence": "Confidenza del rilevamento", "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}", "Name of {name}": "Nome di {name}",
"Change the colour of {name}": "Cambia il colore di {name}", "Change the colour of {name}": "Cambia il colore di {name}",
"Colour of {name}": "Colore di {name}", "Colour of {name}": "Colore di {name}",
@@ -3046,10 +3054,10 @@
"natural region": "regione naturale", "natural region": "regione naturale",
"Which {place}?": "Quale {place}?", "Which {place}?": "Quale {place}?",
"Which place?": "Quale luogo?", "Which place?": "Quale luogo?",
"Not drawn: {parts}": "Non disegnati: {parts}", "Not drawn: {parts}": "Contorno non disegnato: {parts}",
"approximate": "approssimativo", "approximate": "approssimata",
"{pct}% cloud": "{pct}% di nuvole", "{pct}% cloud": "{pct}% di nuvole",
"Over {area}": "Zona: {area}", "Over {area}": "Area: {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"many": "Acquisizioni corrispondenti: {count} su {total}", "many": "Acquisizioni corrispondenti: {count} su {total}",
"one": "Acquisizioni corrispondenti: {count} su {total}", "one": "Acquisizioni corrispondenti: {count} su {total}",
@@ -3068,8 +3076,8 @@
"In {area}": "Area: {area}", "In {area}": "Area: {area}",
"Area around {place}": "Area intorno a {place}", "Area around {place}": "Area intorno a {place}",
"Area around landmark": "Area intorno a un punto di interesse", "Area around landmark": "Area intorno a un punto di interesse",
"Outline {place}": "Traccia contorno di {place}", "Outline {place}": "Traccia il contorno di {place}",
"Outline area": "Traccia contorno area", "Outline area": "Traccia il contorno dell'area",
"Find {place}": "Trova {place}", "Find {place}": "Trova {place}",
"Find area": "Trova area", "Find area": "Trova area",
"Find recent imagery": "Trova immagini recenti", "Find recent imagery": "Trova immagini recenti",
@@ -3077,16 +3085,16 @@
"Find places": "Trova luoghi", "Find places": "Trova luoghi",
"Drawing outline": "Disegno del contorno", "Drawing outline": "Disegno del contorno",
"Searching imagery catalog": "Ricerca nel catalogo immagini", "Searching imagery catalog": "Ricerca nel catalogo immagini",
"Loading imagery": "Caricamento immagini", "Loading imagery": "Caricamento delle immagini",
"Searching OpenStreetMap": "Ricerca in OpenStreetMap", "Searching OpenStreetMap": "Ricerca in OpenStreetMap",
"voice area|Area": "Area", "voice area|Area": "Area",
"buffer sized to the place": "zona tampone delle dimensioni del luogo", "buffer sized to the place": "zona tampone delle dimensioni del luogo",
"enclosing mapped area": "area mappata circostante", "enclosing mapped area": "area mappata che contiene il punto di interesse",
"the map was cleared while looking it up": "la mappa è stata cancellata durante la ricerca", "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", "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", "the catalog covers the last 30 days": "il catalogo copre gli ultimi 30 giorni",
"there are more": "ce ne sono altri", "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": { "{count} parts": {
"many": "Parti: {count}", "many": "Parti: {count}",
"one": "Parti: {count}", "one": "Parti: {count}",
@@ -3102,8 +3110,8 @@
"one": "Duplicati uniti: {count}", "one": "Duplicati uniti: {count}",
"other": "Duplicati uniti: {count}" "other": "Duplicati uniti: {count}"
}, },
"listed the first {limit}": "elencati i primi {limit}", "listed the first {limit}": "elenco limitato ai primi risultati: {limit}",
"counted over the bounding box of {area}, not its outline": "contato sul riquadro di delimitazione ({area}), non sul contorno", "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", "Search cancelled": "Ricerca annullata",
"Sea Marks": "Segnalamenti marittimi", "Sea Marks": "Segnalamenti marittimi",
"Ski Pistes": "Piste da sci", "Ski Pistes": "Piste da sci",
@@ -3179,5 +3187,68 @@
"The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.", "The provider rejected the API key. Check it in Provider Settings.": "Il provider ha rifiutato la chiave API. Controllala in Impostazioni provider.",
"Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.", "Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.", "The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni." "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.",
"SIGHTING": "AVVISTAMENTO",
"Show {name} sighting": "Mostra avvistamento di {name}",
"seen {age} ago": "avvistato {age} fa",
"observed {age} ago": "osservato {age} fa",
"Be Whale Wise": "Rispetta i cetacei",
"Reported by {name} ({entity})": "Segnalato da {name} ({entity})",
"Reported by {name}": "Segnalato da {name}",
"Salish Sea": "Mare dei Salish",
"Birds": "Uccelli",
"Mammals": "Mammiferi",
"Reptiles": "Rettili",
"Amphibians": "Anfibi",
"Ray-finned fishes": "Pesci con pinne raggiate",
"Insects": "Insetti",
"Arachnids": "Aracnidi",
"Plants": "Piante",
"Fungi": "Funghi",
"Other animals": "Altri animali",
"Other or unknown": "Altri o sconosciuti",
"{label} {detail} — click to retry": "{label} {detail} — fai clic per riprovare",
"Wildlife sightings source unavailable": "Fonte degli avvistamenti non disponibile",
"Show only community-confirmed (research-grade) sightings": "Mostra solo osservazioni confermate dalla comunità (grado di ricerca)",
"No recent sightings in view": "Nessun avvistamento recente nella vista",
"Acartia feed unavailable": "Feed Acartia non disponibile",
"Show all taxa": "Mostra tutti i gruppi tassonomici",
"BIRDS": "UCCELLI",
"Show only birds": "Mostra solo uccelli",
"MAMMALS": "MAMMIFERI",
"Show only mammals": "Mostra solo mammiferi",
"WHALES": "CETACEI",
"Show only whales and dolphins (7 days)": "Mostra solo balene e delfini (7 giorni)",
"INSECTS": "INSETTI",
"Show only insects": "Mostra solo insetti",
"PLANTS": "PIANTE",
"Show only plants": "Mostra solo piante",
"All rights reserved": "Tutti i diritti riservati",
"Cetacean": "Cetaceo",
"Wildlife Sightings": "Avvistamenti naturalistici",
"Exit cockpit to fly to a sighting": "Esci dalla cabina per volare a un avvistamento",
"Exit cockpit to fly to a sighting area": "Esci dalla cabina per volare a un’area di avvistamento",
"Wildlife": "Natura",
"Zoom in for local sightings": "Ingrandisci per gli avvistamenti locali",
"Loading photo...": "Caricamento della foto…",
"Photo unavailable": "Foto non disponibile",
"Location obscured to a 0.2° cell": "Posizione oscurata in una cella di 0,2°",
"Photo of {name}": "Foto di {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} avvistamenti nella vista",
"Close sighting": "Chiudi avvistamento",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna osservata nelle ultime 48 h (cetacei 7 giorni). Colore e simbolo del contrassegno indicano il gruppo tassonomico. Le specie sensibili sono mostrate solo in celle di 0,2°; le segnalazioni di mammiferi marini di Acartia nel mar dei Salish sono arrotondate a 0,01°. Non è un censimento.",
"Open {name} on iNaturalist": "Apri {name} su iNaturalist",
"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",
"{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"
} }
+107 -36
View File
@@ -1073,6 +1073,9 @@
"Hybrid": "ハイブリッド", "Hybrid": "ハイブリッド",
"TomTom key unavailable": "TomTomキーを利用できません", "TomTom key unavailable": "TomTomキーを利用できません",
"TomTom daily budget reached": "TomTomの1日の上限に達しました", "TomTom daily budget reached": "TomTomの1日の上限に達しました",
"Monthly traffic allowance used": "今月の交通情報の上限に達しました",
"Traffic rate limit reached": "交通情報のリクエスト上限に達しました",
"Traffic service unavailable": "交通情報サービスを利用できません",
"TomTom upstream unreachable": "TomTomのサービスに接続できません", "TomTom upstream unreachable": "TomTomのサービスに接続できません",
"TomTom flow unavailable": "TomTomの交通流を利用できません", "TomTom flow unavailable": "TomTomの交通流を利用できません",
"SIMULATED — {reason}": "シミュレーション:{reason}", "SIMULATED — {reason}": "シミュレーション:{reason}",
@@ -1612,6 +1615,9 @@
"REGIONAL NEWS": "地域ニュース", "REGIONAL NEWS": "地域ニュース",
"{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}", "{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません",
"CONTEXT STANDBY": "コンテキスト待機", "CONTEXT STANDBY": "コンテキスト待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "コンタクト喪失 · 最終既知の値 · 安全の保証ではありません",
@@ -1684,7 +1690,7 @@
"Restore real aircraft": "実際の航空機に戻す", "Restore real aircraft": "実際の航空機に戻す",
"Reclassify as TR-3B": "TR-3Bに再分類", "Reclassify as TR-3B": "TR-3Bに再分類",
"TRACK ACQUIRED": "追跡開始", "TRACK ACQUIRED": "追跡開始",
"{label} · COURSE {heading}°": "{label} · 針路 {heading}°", "{label} · COURSE {heading}°": "{label} · 対地針路 {heading}°",
"Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください", "Context could not restore every layer; try again": "コンテキストがすべてのレイヤーを復元できませんでした。もう一度お試しください",
"No selected data layers": "選択中のデータレイヤーはありません", "No selected data layers": "選択中のデータレイヤーはありません",
"{count} data layers could not be cleared": { "{count} data layers could not be cleared": {
@@ -2657,6 +2663,8 @@
"AVAILABLE MISSIONS": "利用可能なミッション", "AVAILABLE MISSIONS": "利用可能なミッション",
"SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示", "SELECT A MISSION TO INSPECT": "ミッションを選択して詳細を表示",
"LOADING 30-DAY MISSION INDEX": "30日間のミッション一覧を読込中", "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で選択", "TAB PREVIEWS · ENTER / SPACE SELECTS": "Tabでプレビュー · Enter / Spaceで選択",
"Internet radio and local SDR": "インターネットラジオとローカルSDR", "Internet radio and local SDR": "インターネットラジオとローカルSDR",
"STATION TAG": "局タグ", "STATION TAG": "局タグ",
@@ -2837,18 +2845,18 @@
"route proxy error": "ルート検索プロキシでエラーが発生しました", "route proxy error": "ルート検索プロキシでエラーが発生しました",
"Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です", "Malformed cyclone snapshot": "サイクロンのスナップショットの形式が不正です",
"Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました", "Cyclone request timed out": "サイクロンのリクエストがタイムアウトしました",
"{lower} to {upper}": "{lower}~{upper}", "{lower} to {upper}": "{lower}–{upper}",
"Up to {upper}": "{upper}まで", "Up to {upper}": "{upper}まで",
"From {lower}": "{lower}から", "From {lower}": "{lower}から",
"Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください", "Informational only · not for flight planning": "参考情報のみ · 飛行計画には使用しないでください",
"Unnamed zone": "名称なしのゾーン", "Unnamed zone": "名称不明の区域",
"Restricted airspace source unavailable": "制限空域のデータソースを利用できません", "Restricted airspace source unavailable": "制限空域のデータソースを利用できません",
"Prohibited (P)": "飛行禁止区域 (P)", "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や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": "飛行禁止区域", "Prohibited": "飛行禁止区域",
"Restricted": "制限区域", "Restricted": "飛行制限区域",
"Prohibited & Restricted Airspace": "飛行禁止区域と制限区域", "Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域",
"scene|PLAY": "再生", "scene|PLAY": "再生",
"AUTHORING": "作成", "AUTHORING": "作成",
"Create and edit scenes": "シーンを作成・編集", "Create and edit scenes": "シーンを作成・編集",
@@ -2879,10 +2887,10 @@
"fire confidence filter|All": "すべて", "fire confidence filter|All": "すべて",
"fire confidence filter|Nominal+": "中以上", "fire confidence filter|Nominal+": "中以上",
"fire confidence filter|High": "高", "fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "すべての検知 — 最も強い火災の多くは信頼度が低と判定されています", "Every detection — many of the strongest fires are rated low confidence": "すべての検知。最も強い火災の多くは信頼度「低」と判定されています",
"Nominal and high confidence detections": "信頼度が中・高の検知", "Nominal and high confidence detections": "信頼度「中」以上の検知",
"High confidence detections only": "信頼度が高の検知のみ", "High confidence detections only": "信頼度「高」の検知のみ",
"Fire intensity (FRP)": "強度(FRP)", "Fire intensity (FRP)": "火災強度(FRP)",
"≥ {value} MW": "≥ {value} MW", "≥ {value} MW": "≥ {value} MW",
"Confidence": "信頼度", "Confidence": "信頼度",
"Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)", "Minimum fire radiative power, megawatts": "火災放射強度の最小値(メガワット)",
@@ -2900,16 +2908,16 @@
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル", "Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
"Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)", "Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)",
"OSM Places": "OSMの場所", "OSM Places": "OSMの場所",
"state or province": "州・省", "state or province": "州・県",
"county or district": "郡・地区", "county or district": "郡・地区",
"neighbourhood": "近隣地区", "neighbourhood": "近隣地区",
"natural region": "自然地域", "natural region": "自然地域",
"Which {place}?": "どの{place}ですか?", "Which {place}?": "どの「{place}」ですか?",
"Which place?": "どの場所ですか?", "Which place?": "どの場所ですか?",
"Not drawn: {parts}": "未描画:{parts}", "Not drawn: {parts}": "未描画:{parts}",
"approximate": "概算", "approximate": "近似",
"{pct}% cloud": "雲量{pct}%", "{pct}% cloud": "雲量 {pct}%",
"Over {area}": "{area}上空", "Over {area}": "範囲:{area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"other": "一致した画像:{count}/{total}件" "other": "一致した画像:{count}/{total}件"
}, },
@@ -2918,45 +2926,45 @@
}, },
"No matching imagery": "一致する画像はありません", "No matching imagery": "一致する画像はありません",
"Unknown number of {kind}": "{kind}:件数不明", "Unknown number of {kind}": "{kind}:件数不明",
"About {count} {kind}": "{kind}:約{count}件", "About {count} {kind}": "{kind}:約{count}か所",
"At least {count} {kind}": "{kind}:{count}件以上", "At least {count} {kind}": "{kind}:{count}か所以上",
"{count} {kind}": "{kind}:{count}件", "{count} {kind}": "{kind}:{count}か所",
"In {area}": "{area}内", "In {area}": "{area}内",
"Area around {place}": "{place}周辺のエリア", "Area around {place}": "{place}周辺のエリア",
"Area around landmark": "ランドマーク周辺のエリア", "Area around landmark": "ランドマーク周辺のエリア",
"Outline {place}": "{place}の輪郭を描く", "Outline {place}": "{place}の輪郭を描画",
"Outline area": "エリアの輪郭を描く", "Outline area": "エリアの輪郭を描画",
"Find {place}": "{place}を検索", "Find {place}": "{place}を検索",
"Find area": "エリアを検索", "Find area": "エリアを検索",
"Find recent imagery": "最近の衛星画像を検索", "Find recent imagery": "最近の衛星画像を検索",
"Find {what}": "{what}を検索", "Find {what}": "{what}を検索",
"Find places": "場所を検索", "Find places": "場所を検索",
"Drawing outline": "輪郭を作図中", "Drawing outline": "輪郭を地図に描画中",
"Searching imagery catalog": "画像カタログを検索中", "Searching imagery catalog": "画像カタログを検索中",
"Loading imagery": "画像を読み込み中", "Loading imagery": "画像を読み込み中",
"Searching OpenStreetMap": "OpenStreetMapを検索中", "Searching OpenStreetMap": "OpenStreetMapを検索中",
"voice area|Area": "エリア", "voice area|Area": "エリア",
"buffer sized to the place": "場所の大きさに合わせたバッファ", "buffer sized to the place": "場所の大きさに合わせたバッファー",
"enclosing mapped area": "周囲の地図上のエリア", "enclosing mapped area": "ランドマークを含むマップ済みのエリア",
"the map was cleared while looking it up": "検索中に地図がクリアされました", "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 日分です", "the catalog covers the last 30 days": "カタログの対象は過去30日間です",
"there are more": "ほかにもあります", "there are more": "ほかにもあります",
"{radius} m around the landmark": "ランドマークの周囲 {radius} m", "{radius} m around the landmark": "ランドマークから半径{radius} m",
"{count} parts": { "{count} parts": {
"other": "パーツ数: {count}" "other": "パーツ数:{count}"
}, },
"{drawn} of {total} parts drawn": { "{drawn} of {total} parts drawn": {
"other": "描画したパーツ: {drawn}/{total}" "other": "描画したパーツ:{drawn}/{total}"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"other": "統合した重複: {count}" "other": "重複登録{count}件を統合"
}, },
"listed the first {limit}": "最初の {limit} 件を表示", "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": "検索をキャンセルしました", "Search cancelled": "検索をキャンセルしました",
"Sea Marks": "航路標識", "Sea Marks": "航路標識",
"Ski Pistes": "スキーゲレンデ", "Ski Pistes": "スキーコース",
"Fly to Tokyo": "東京へ移動して", "Fly to Tokyo": "東京へ移動して",
"Show live flights": "ライブフライトを表示して", "Show live flights": "ライブフライトを表示して",
"Reset to globe view": "地球全体ビューに戻して", "Reset to globe view": "地球全体ビューに戻して",
@@ -3029,5 +3037,68 @@
"The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。", "The provider rejected the API key. Check it in Provider Settings.": "プロバイダーがAPIキーを拒否しました。プロバイダー設定で確認してください。",
"Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。", "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 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.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。" "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。",
"SIGHTING": "目撃情報",
"Show {name} sighting": "{name}の目撃情報を表示",
"seen {age} ago": "{age}前に目撃",
"observed {age} ago": "{age}前に観察",
"Be Whale Wise": "クジラに配慮しましょう",
"Reported by {name} ({entity})": "報告元 {name}({entity})",
"Reported by {name}": "報告元 {name}",
"Salish Sea": "セイリッシュ海",
"Birds": "鳥類",
"Mammals": "哺乳類",
"Reptiles": "爬虫類",
"Amphibians": "両生類",
"Ray-finned fishes": "条鰭類",
"Insects": "昆虫",
"Arachnids": "クモ類",
"Plants": "植物",
"Fungi": "菌類",
"Other animals": "その他の動物",
"Other or unknown": "その他・不明",
"{label} {detail} — click to retry": "{label} {detail} — クリックして再試行",
"Wildlife sightings source unavailable": "野生生物の目撃情報源を利用できません",
"Show only community-confirmed (research-grade) sightings": "コミュニティが確認した研究グレードの目撃情報のみ表示",
"No recent sightings in view": "表示範囲に最近の目撃情報はありません",
"Acartia feed unavailable": "Acartiaのフィードを利用できません",
"Show all taxa": "すべての分類群を表示",
"BIRDS": "鳥類",
"Show only birds": "鳥類のみ表示",
"MAMMALS": "哺乳類",
"Show only mammals": "哺乳類のみ表示",
"WHALES": "クジラ類",
"Show only whales and dolphins (7 days)": "クジラとイルカのみ表示(7日間)",
"INSECTS": "昆虫",
"Show only insects": "昆虫のみ表示",
"PLANTS": "植物",
"Show only plants": "植物のみ表示",
"All rights reserved": "無断転載禁止",
"Cetacean": "クジラ類",
"Wildlife Sightings": "野生生物の目撃情報",
"Exit cockpit to fly to a sighting": "目撃地点に移動するにはコックピットを終了",
"Exit cockpit to fly to a sighting area": "目撃範囲に移動するにはコックピットを終了",
"Wildlife": "野生生物",
"Zoom in for local sightings": "拡大して地域の目撃情報を表示",
"Loading photo...": "写真を読み込み中…",
"Photo unavailable": "写真を表示できません",
"Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています",
"Photo of {name}": "{name}の写真",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "表示範囲に{count}件の目撃情報",
"Close sighting": "目撃情報を閉じる",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "過去48時間以内に観察された野生生物(クジラ類は7日間)。色とバッジのアイコンは分類群を表します。保護が必要な種は0.2°の区画のみ表示し、セイリッシュ海のAcartia海生哺乳類目撃情報は0.01°に丸めます。個体数調査ではありません。",
"Open {name} on iNaturalist": "iNaturalistで{name}を開く",
"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}(セイリッシュ海) · ライセンスは記録ごと",
"{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}日以内 · 日付なしは保持"
} }
+95 -24
View File
@@ -1103,6 +1103,9 @@
"Hybrid": "Híbrido", "Hybrid": "Híbrido",
"TomTom key unavailable": "Chave do TomTom indisponível", "TomTom key unavailable": "Chave do TomTom indisponível",
"TomTom daily budget reached": "Limite diário do TomTom atingido", "TomTom daily budget reached": "Limite diário do TomTom atingido",
"Monthly traffic allowance used": "Cota mensal de trânsito esgotada",
"Traffic rate limit reached": "Limite de solicitações de trânsito atingido",
"Traffic service unavailable": "Serviço de trânsito indisponível",
"TomTom upstream unreachable": "Servidor do TomTom inacessível", "TomTom upstream unreachable": "Servidor do TomTom inacessível",
"TomTom flow unavailable": "Fluxo do TomTom indisponível", "TomTom flow unavailable": "Fluxo do TomTom indisponível",
"SIMULATED — {reason}": "SIMULADO — {reason}", "SIMULATED — {reason}": "SIMULADO — {reason}",
@@ -1660,6 +1663,9 @@
"REGIONAL NEWS": "NOTÍCIAS DA ÁREA", "REGIONAL NEWS": "NOTÍCIAS DA ÁREA",
"{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}", "{label} · {radius} AIR/SEA WINDOW": "{label} · AR/MAR {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA",
"CONTEXT STANDBY": "CONTEXTO EM ESPERA", "CONTEXT STANDBY": "CONTEXTO EM ESPERA",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA",
@@ -1736,7 +1742,7 @@
"Restore real aircraft": "Restaurar a aeronave real", "Restore real aircraft": "Restaurar a aeronave real",
"Reclassify as TR-3B": "Reclassificar como TR-3B", "Reclassify as TR-3B": "Reclassificar como TR-3B",
"TRACK ACQUIRED": "RASTRO ADQUIRIDO", "TRACK ACQUIRED": "RASTRO ADQUIRIDO",
"{label} · COURSE {heading}°": "{label} · CURSO {heading}°", "{label} · COURSE {heading}°": "{label} · RUMO {heading}°",
"Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente", "Context could not restore every layer; try again": "O contexto não conseguiu restaurar todas as camadas; tente novamente",
"No selected data layers": "Nenhuma camada de dados selecionada", "No selected data layers": "Nenhuma camada de dados selecionada",
"{count} data layers could not be cleared": { "{count} data layers could not be cleared": {
@@ -2801,6 +2807,8 @@
"AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS", "AVAILABLE MISSIONS": "MISSÕES DISPONÍVEIS",
"SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR", "SELECT A MISSION TO INSPECT": "SELECIONE UMA MISSÃO PARA INSPECIONAR",
"LOADING 30-DAY MISSION INDEX": "CARREGANDO ÍNDICE DE MISSÕES DE 30 DIAS", "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", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB MOSTRA PRÉVIA · ENTER / SPACE SELECIONA",
"Internet radio and local SDR": "Rádio pela internet e SDR local", "Internet radio and local SDR": "Rádio pela internet e SDR local",
"STATION TAG": "TAG DA ESTAÇÃO", "STATION TAG": "TAG DA ESTAÇÃO",
@@ -2981,14 +2989,14 @@
"Up to {upper}": "Até {upper}", "Up to {upper}": "Até {upper}",
"From {lower}": "A partir de {lower}", "From {lower}": "A partir de {lower}",
"Informational only · not for flight planning": "Apenas informativo · não usar para planejamento de voo", "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", "Restricted airspace source unavailable": "Fonte de espaço aéreo restrito indisponível",
"Prohibited (P)": "Área proibida (P)", "Prohibited (P)": "Área proibida (P)",
"Restricted (R)": "Área restrita (R)", "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", "Prohibited": "Área proibida",
"Restricted": "Área restrita", "Restricted": "Área restrita",
"Prohibited & Restricted Airspace": "Áreas proibidas e restritas", "Prohibited & Restricted Airspace": "Espaço aéreo proibido e restrito",
"scene|PLAY": "INICIAR", "scene|PLAY": "INICIAR",
"AUTHORING": "CRIAÇÃO", "AUTHORING": "CRIAÇÃO",
"Create and edit scenes": "Criar e editar cenas", "Create and edit scenes": "Criar e editar cenas",
@@ -3028,7 +3036,7 @@
"Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts", "Minimum fire radiative power, megawatts": "Potência radiativa mínima do fogo, em megawatts",
"fire intensity filter|All": "Todas", "fire intensity filter|All": "Todas",
"Detection confidence": "Confiança da detecção", "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}", "Name of {name}": "Nome de {name}",
"Change the colour of {name}": "Mudar a cor de {name}", "Change the colour of {name}": "Mudar a cor de {name}",
"Colour of {name}": "Cor de {name}", "Colour of {name}": "Cor de {name}",
@@ -3047,18 +3055,18 @@
"Which {place}?": "Qual {place}?", "Which {place}?": "Qual {place}?",
"Which place?": "Qual lugar?", "Which place?": "Qual lugar?",
"Not drawn: {parts}": "Não desenhado: {parts}", "Not drawn: {parts}": "Não desenhado: {parts}",
"approximate": "aproximado", "approximate": "aproximada",
"{pct}% cloud": "{pct}% de nuvens", "{pct}% cloud": "{pct}% de nuvens",
"Over {area}": "Área: {area}", "Over {area}": "Área: {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"many": "Aquisições correspondentes: {count} de {total}", "one": "Imagens correspondentes: {count} de {total}",
"one": "Aquisições correspondentes: {count} de {total}", "many": "Imagens correspondentes: {count} de {total}",
"other": "Aquisições correspondentes: {count} de {total}" "other": "Imagens correspondentes: {count} de {total}"
}, },
"{n} acquisitions searched": { "{n} acquisitions searched": {
"many": "Aquisições pesquisadas: {n}", "one": "Imagens pesquisadas: {n}",
"one": "Aquisições pesquisadas: {n}", "many": "Imagens pesquisadas: {n}",
"other": "Aquisições pesquisadas: {n}" "other": "Imagens pesquisadas: {n}"
}, },
"No matching imagery": "Nenhuma imagem correspondente", "No matching imagery": "Nenhuma imagem correspondente",
"Unknown number of {kind}": "{kind}: número desconhecido", "Unknown number of {kind}": "{kind}: número desconhecido",
@@ -3070,23 +3078,23 @@
"Area around landmark": "Área ao redor de um ponto de referência", "Area around landmark": "Área ao redor de um ponto de referência",
"Outline {place}": "Traçar contorno de {place}", "Outline {place}": "Traçar contorno de {place}",
"Outline area": "Traçar contorno da área", "Outline area": "Traçar contorno da área",
"Find {place}": "Encontrar {place}", "Find {place}": "Buscar {place}",
"Find area": "Encontrar área", "Find area": "Buscar área",
"Find recent imagery": "Encontrar imagens recentes", "Find recent imagery": "Buscar imagens recentes",
"Find {what}": "Encontrar {what}", "Find {what}": "Buscar {what}",
"Find places": "Encontrar lugares", "Find places": "Buscar lugares",
"Drawing outline": "Desenhando contorno", "Drawing outline": "Desenhando contorno",
"Searching imagery catalog": "Buscando no catálogo de imagens", "Searching imagery catalog": "Buscando no catálogo de imagens",
"Loading imagery": "Carregando imagens", "Loading imagery": "Carregando imagens",
"Searching OpenStreetMap": "Buscando no OpenStreetMap", "Searching OpenStreetMap": "Buscando no OpenStreetMap",
"voice area|Area": "Área", "voice area|Area": "Área",
"buffer sized to the place": "zona de margem do tamanho do local", "buffer sized to the place": "círculo do tamanho aproximado do lugar",
"enclosing mapped area": "área mapeada envolvente", "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", "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", "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", "the catalog covers the last 30 days": "o catálogo cobre os últimos 30 dias",
"there are more": "há mais", "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": { "{count} parts": {
"many": "Partes: {count}", "many": "Partes: {count}",
"one": "Partes: {count}", "one": "Partes: {count}",
@@ -3102,10 +3110,10 @@
"one": "Duplicatas mescladas: {count}", "one": "Duplicatas mescladas: {count}",
"other": "Duplicatas mescladas: {count}" "other": "Duplicatas mescladas: {count}"
}, },
"listed the first {limit}": "listados os primeiros {limit}", "listed the first {limit}": "lista limitada a {limit}",
"counted over the bounding box of {area}, not its outline": "contado na caixa delimitadora ({area}), não no contorno", "counted over the bounding box of {area}, not its outline": "contagem na caixa delimitadora de {area}, não no contorno",
"Search cancelled": "Busca cancelada", "Search cancelled": "Busca cancelada",
"Sea Marks": "Sinais marítimos", "Sea Marks": "Sinalização náutica",
"Ski Pistes": "Pistas de esqui", "Ski Pistes": "Pistas de esqui",
"Fly to Tokyo": "Voe para Tóquio", "Fly to Tokyo": "Voe para Tóquio",
"Show live flights": "Mostre os voos ao vivo", "Show live flights": "Mostre os voos ao vivo",
@@ -3179,5 +3187,68 @@
"The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".", "The provider rejected the API key. Check it in Provider Settings.": "O provedor recusou a chave de API. Verifique-a em \"Configurações de provedores\".",
"Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.", "Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.",
"The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.", "The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.",
"The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\"." "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\".",
"SIGHTING": "AVISTAMENTO",
"Show {name} sighting": "Mostrar avistamento de {name}",
"seen {age} ago": "visto há {age}",
"observed {age} ago": "observado há {age}",
"Be Whale Wise": "Respeite os cetáceos",
"Reported by {name} ({entity})": "Relatado por {name} ({entity})",
"Reported by {name}": "Relatado por {name}",
"Salish Sea": "Mar de Salish",
"Birds": "Aves",
"Mammals": "Mamíferos",
"Reptiles": "Répteis",
"Amphibians": "Anfíbios",
"Ray-finned fishes": "Peixes de nadadeiras raiadas",
"Insects": "Insetos",
"Arachnids": "Aracnídeos",
"Plants": "Plantas",
"Fungi": "Fungos",
"Other animals": "Outros animais",
"Other or unknown": "Outros ou desconhecidos",
"{label} {detail} — click to retry": "{label} {detail} — clique para tentar novamente",
"Wildlife sightings source unavailable": "Fonte de avistamentos indisponível",
"Show only community-confirmed (research-grade) sightings": "Mostrar apenas observações confirmadas pela comunidade (grau de pesquisa)",
"No recent sightings in view": "Nenhum avistamento recente na vista",
"Acartia feed unavailable": "Feed Acartia indisponível",
"Show all taxa": "Mostrar todos os grupos taxonômicos",
"BIRDS": "AVES",
"Show only birds": "Mostrar apenas aves",
"MAMMALS": "MAMÍFEROS",
"Show only mammals": "Mostrar apenas mamíferos",
"WHALES": "CETÁCEOS",
"Show only whales and dolphins (7 days)": "Mostrar apenas baleias e golfinhos (7 dias)",
"INSECTS": "INSETOS",
"Show only insects": "Mostrar apenas insetos",
"PLANTS": "PLANTAS",
"Show only plants": "Mostrar apenas plantas",
"All rights reserved": "Todos os direitos reservados",
"Cetacean": "Cetáceo",
"Wildlife Sightings": "Avistamentos de vida selvagem",
"Exit cockpit to fly to a sighting": "Saia da cabine para voar até um avistamento",
"Exit cockpit to fly to a sighting area": "Saia da cabine para voar até uma área de avistamento",
"Wildlife": "Vida selvagem",
"Zoom in for local sightings": "Amplie para ver avistamentos locais",
"Loading photo...": "Carregando foto…",
"Photo unavailable": "Foto indisponível",
"Location obscured to a 0.2° cell": "Localização ocultada em uma célula de 0,2°",
"Photo of {name}": "Foto de {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} avistamentos na vista",
"Close sighting": "Fechar avistamento",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada nas últimas 48 h (cetáceos 7 dias). A cor e o símbolo do emblema indicam o grupo taxonômico. Espécies sensíveis aparecem apenas em células de 0,2°; os relatos de mamíferos marinhos da Acartia no mar de Salish são arredondados para 0,01°. Não é um censo.",
"Open {name} on iNaturalist": "Abrir {name} no iNaturalist",
"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",
"{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"
} }
+93 -22
View File
@@ -128,7 +128,7 @@
"SRC {value}": "ИСТ. {value}", "SRC {value}": "ИСТ. {value}",
"{value} CONTEXT": "КОНТЕКСТ {value}", "{value} CONTEXT": "КОНТЕКСТ {value}",
"Focus fire detection {title}, {details}": "Фокус на очаге пожара {title}, {details}", "Focus fire detection {title}, {details}": "Фокус на очаге пожара {title}, {details}",
"{value} conf": "увер.: {value}", "{value} conf": "дост.: {value}",
"{value} ago": "{value} назад", "{value} ago": "{value} назад",
"sensor n/a": "сенсор н/д", "sensor n/a": "сенсор н/д",
"FIRE · {frp} MW": "ПОЖАР · {frp} МВт", "FIRE · {frp} MW": "ПОЖАР · {frp} МВт",
@@ -458,7 +458,7 @@
"SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН", "SOURCE STATUS UNAVAILABLE": "СТАТУС ИСТОЧНИКА НЕДОСТУПЕН",
"Select flight {title}": "Выбрать рейс {title}", "Select flight {title}": "Выбрать рейс {title}",
"TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА", "TRACK ACQUIRED": "ЦЕЛЬ ЗАХВАЧЕНА",
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°", "{label} · COURSE {heading}°": "{label} · ПУТЕВОЙ УГОЛ {heading}°",
"Restore real aircraft": "Вернуть реальный самолёт", "Restore real aircraft": "Вернуть реальный самолёт",
"Reclassify as TR-3B": "Переклассифицировать как TR-3B", "Reclassify as TR-3B": "Переклассифицировать как TR-3B",
"No selected data layers": "Нет выбранных слоёв данных", "No selected data layers": "Нет выбранных слоёв данных",
@@ -722,6 +722,8 @@
"Contact Context actions": "Действия контекста контакта", "Contact Context actions": "Действия контекста контакта",
"Available Space Missions": "Доступные космические миссии", "Available Space Missions": "Доступные космические миссии",
"LOADING 30-DAY MISSION INDEX": "ЗАГРУЗКА ИНДЕКСА МИССИЙ ЗА 30 ДНЕЙ", "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 playback": "Воспроизведение радио",
"Radio volume": "Громкость радио", "Radio volume": "Громкость радио",
"STATION TAG": "ТЕГ СТАНЦИИ", "STATION TAG": "ТЕГ СТАНЦИИ",
@@ -1953,6 +1955,9 @@
"N/A": "Н/Д", "N/A": "Н/Д",
"{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}", "{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО",
"{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ", "{source} · LOCATION QUERY · RECENT": "{source} · ЗАПРОС ПО МЕСТУ · НЕДАВНИЕ",
"ft": "фт", "ft": "фт",
"kts": "уз", "kts": "уз",
@@ -2310,6 +2315,9 @@
"SIMULATED — {reason}": "СИМУЛЯЦИЯ — {reason}", "SIMULATED — {reason}": "СИМУЛЯЦИЯ — {reason}",
"TomTom key unavailable": "Ключ TomTom недоступен", "TomTom key unavailable": "Ключ TomTom недоступен",
"TomTom daily budget reached": "Исчерпан дневной лимит TomTom", "TomTom daily budget reached": "Исчерпан дневной лимит TomTom",
"Monthly traffic allowance used": "Месячный лимит данных о трафике исчерпан",
"Traffic rate limit reached": "Достигнут лимит запросов данных о трафике",
"Traffic service unavailable": "Сервис данных о трафике недоступен",
"TomTom upstream unreachable": "Сервер TomTom не отвечает", "TomTom upstream unreachable": "Сервер TomTom не отвечает",
"TomTom flow unavailable": "Поток TomTom недоступен", "TomTom flow unavailable": "Поток TomTom недоступен",
"API key rejected — check AISSTREAM_API_KEY": "Ключ API отклонён — проверьте AISSTREAM_API_KEY", "API key rejected — check AISSTREAM_API_KEY": "Ключ API отклонён — проверьте AISSTREAM_API_KEY",
@@ -3056,27 +3064,27 @@
"fire confidence filter|All": "Все", "fire confidence filter|All": "Все",
"fire confidence filter|Nominal+": "Средняя+", "fire confidence filter|Nominal+": "Средняя+",
"fire confidence filter|High": "Высокая", "fire confidence filter|High": "Высокая",
"Every detection — many of the strongest fires are rated low confidence": "Все детекции — у многих сильнейших пожаров достоверность низкая", "Every detection — many of the strongest fires are rated low confidence": "Все очаги — многим сильнейшим пожарам присвоена низкая достоверность",
"Nominal and high confidence detections": "Детекции средней и высокой достоверности", "Nominal and high confidence detections": "Очаги средней и высокой достоверности",
"High confidence detections only": "Только детекции высокой достоверности", "High confidence detections only": "Только очаги высокой достоверности",
"Fire intensity (FRP)": "Интенсивность (FRP)", "Fire intensity (FRP)": "Интенсивность (FRP)",
"≥ {value} MW": "≥ {value} МВт", "≥ {value} MW": "≥ {value} МВт",
"Confidence": "Достоверность", "Confidence": "Достоверность",
"Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты", "Minimum fire radiative power, megawatts": "Минимальная мощность излучения пожара, мегаватты",
"fire intensity filter|All": "Все", "fire intensity filter|All": "Все",
"Detection confidence": "Достоверность детекции", "Detection confidence": "Достоверность обнаружения",
"{shown} of {total} shown": "показано {shown} из {total}", "{shown} of {total} shown": "Показано {shown} из {total}",
"{lower} to {upper}": "от {lower} до {upper}", "{lower} to {upper}": "От {lower} до {upper}",
"Up to {upper}": "До {upper}", "Up to {upper}": "До {upper}",
"From {lower}": "От {lower}", "From {lower}": "От {lower}",
"Informational only · not for flight planning": "Только для информации · не для планирования полётов", "Informational only · not for flight planning": "Только для информации · не для планирования полётов",
"Unnamed zone": "Зона без названия", "Unnamed zone": "Без названия",
"Restricted airspace source unavailable": "Источник данных об ограниченном воздушном пространстве недоступен", "Restricted airspace source unavailable": "Источник данных о зонах ограничения полётов недоступен",
"Prohibited (P)": "Запретная зона (P)", "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).", "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": "Запретная зона", "Prohibited": "Запретная зона",
"Restricted": "Зона ограничения", "Restricted": "Зона ограничения полётов",
"Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения", "Prohibited & Restricted Airspace": "Запретные зоны и зоны ограничения",
"routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно", "routing busy — too many routes at once": "сервис маршрутов занят: слишком много маршрутов одновременно",
"route proxy error": "ошибка прокси маршрутов", "route proxy error": "ошибка прокси маршрутов",
@@ -3110,13 +3118,13 @@
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы", "Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
"Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)", "Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)",
"OSM Places": "Места OSM", "OSM Places": "Места OSM",
"state or province": "штат или провинция", "state or province": "штат, провинция или область",
"county or district": "округ или район", "county or district": "округ или район",
"neighbourhood": "район города", "neighbourhood": "район города",
"natural region": "природный регион", "natural region": "природный регион",
"Which {place}?": "Какой из вариантов «{place}»?", "Which {place}?": "Какой из вариантов «{place}»?",
"Which place?": "Какое место?", "Which place?": "Какое место?",
"Not drawn: {parts}": "Не нарисовано: {parts}", "Not drawn: {parts}": "Контур не нарисован: {parts}",
"approximate": "приблизительно", "approximate": "приблизительно",
"{pct}% cloud": "Облачность {pct}%", "{pct}% cloud": "Облачность {pct}%",
"Over {area}": "Область: {area}", "Over {area}": "Область: {area}",
@@ -3137,7 +3145,7 @@
"About {count} {kind}": "{kind}: около {count}", "About {count} {kind}": "{kind}: около {count}",
"At least {count} {kind}": "{kind}: не менее {count}", "At least {count} {kind}": "{kind}: не менее {count}",
"{count} {kind}": "{kind}: {count}", "{count} {kind}": "{kind}: {count}",
"In {area}": "Территория: {area}", "In {area}": "Область: {area}",
"Area around {place}": "Окрестности: {place}", "Area around {place}": "Окрестности: {place}",
"Area around landmark": "Окрестности ориентира", "Area around landmark": "Окрестности ориентира",
"Outline {place}": "Контур: {place}", "Outline {place}": "Контур: {place}",
@@ -3153,12 +3161,12 @@
"Searching OpenStreetMap": "Поиск в OpenStreetMap", "Searching OpenStreetMap": "Поиск в OpenStreetMap",
"voice area|Area": "Область", "voice area|Area": "Область",
"buffer sized to the place": "буферная зона по размеру места", "buffer sized to the place": "буферная зона по размеру места",
"enclosing mapped area": "охватывающая область на карте", "enclosing mapped area": "область на карте, включающая ориентир",
"the map was cleared while looking it up": "карта была очищена во время поиска", "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 дней", "the catalog covers the last 30 days": "каталог охватывает последние 30 дней",
"there are more": "есть ещё", "there are more": "есть и другие",
"{radius} m around the landmark": "{radius} м вокруг объекта", "{radius} m around the landmark": "{radius} м вокруг ориентира",
"{count} parts": { "{count} parts": {
"few": "Частей: {count}", "few": "Частей: {count}",
"many": "Частей: {count}", "many": "Частей: {count}",
@@ -3177,11 +3185,11 @@
"one": "Объединено дублей: {count}", "one": "Объединено дублей: {count}",
"other": "Объединено дублей: {count}" "other": "Объединено дублей: {count}"
}, },
"listed the first {limit}": "показаны первые {limit}", "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": "Поиск отменён", "Search cancelled": "Поиск отменён",
"Sea Marks": "Морские знаки", "Sea Marks": "Навигационные знаки",
"Ski Pistes": "Горнолыжные трассы", "Ski Pistes": "Лыжные трассы",
"Fly to Tokyo": "Перелети в Токио", "Fly to Tokyo": "Перелети в Токио",
"Show live flights": "Покажи рейсы в реальном времени", "Show live flights": "Покажи рейсы в реальном времени",
"Reset to globe view": "Вернись к виду всего глобуса", "Reset to globe view": "Вернись к виду всего глобуса",
@@ -3254,5 +3262,68 @@
"The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в «Настройках провайдеров».", "The provider rejected the API key. Check it in Provider Settings.": "Провайдер отклонил ключ API. Проверьте его в «Настройках провайдеров».",
"Too many chat requests. Try again in {seconds} s.": "Слишком много запросов в чате. Повторите попытку через {seconds} с.", "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 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.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках." "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашем счёте у провайдера закончились средства. Проверьте оплату или выберите другого провайдера ИИ в настройках.",
"SIGHTING": "НАБЛЮДЕНИЕ",
"Show {name} sighting": "Показать наблюдение: {name}",
"seen {age} ago": "замечено {age} назад",
"observed {age} ago": "наблюдалось {age} назад",
"Be Whale Wise": "Наблюдайте китов бережно",
"Reported by {name} ({entity})": "Сообщил: {name} ({entity})",
"Reported by {name}": "Сообщил: {name}",
"Salish Sea": "Селишское море",
"Birds": "Птицы",
"Mammals": "Млекопитающие",
"Reptiles": "Пресмыкающиеся",
"Amphibians": "Земноводные",
"Ray-finned fishes": "Лучепёрые рыбы",
"Insects": "Насекомые",
"Arachnids": "Паукообразные",
"Plants": "Растения",
"Fungi": "Грибы",
"Other animals": "Другие животные",
"Other or unknown": "Прочие или неизвестные",
"{label} {detail} — click to retry": "{label} {detail} — нажмите, чтобы повторить",
"Wildlife sightings source unavailable": "Источник наблюдений живой природы недоступен",
"Show only community-confirmed (research-grade) sightings": "Показать только подтверждённые сообществом наблюдения исследовательского уровня",
"No recent sightings in view": "Нет недавних наблюдений в области обзора",
"Acartia feed unavailable": "Лента Acartia недоступна",
"Show all taxa": "Показать все группы организмов",
"BIRDS": "ПТИЦЫ",
"Show only birds": "Показать только птиц",
"MAMMALS": "МЛЕКОПИТАЮЩИЕ",
"Show only mammals": "Показать только млекопитающих",
"WHALES": "КИТООБРАЗНЫЕ",
"Show only whales and dolphins (7 days)": "Показать только китов и дельфинов (7 дней)",
"INSECTS": "НАСЕКОМЫЕ",
"Show only insects": "Показать только насекомых",
"PLANTS": "РАСТЕНИЯ",
"Show only plants": "Показать только растения",
"All rights reserved": "Все права защищены",
"Cetacean": "Китообразное",
"Wildlife Sightings": "Наблюдения живой природы",
"Exit cockpit to fly to a sighting": "Выйдите из кабины, чтобы перейти к наблюдению",
"Exit cockpit to fly to a sighting area": "Выйдите из кабины, чтобы перейти к области наблюдения",
"Wildlife": "Живая природа",
"Zoom in for local sightings": "Приблизьте для местных наблюдений",
"Loading photo...": "Загрузка фото…",
"Photo unavailable": "Фото недоступно",
"Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°",
"Photo of {name}": "Фото {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} наблюдений в области просмотра",
"Close sighting": "Закрыть наблюдение",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Наблюдения дикой природы за 48 ч (китообразные — 7 дней). Цвет и символ значка обозначают таксономическую группу. Уязвимые виды показаны только ячейками 0,2°; сообщения Acartia о морских млекопитающих моря Селиш округлены до 0,01°. Не перепись.",
"Open {name} on iNaturalist": "Открыть {name} в iNaturalist",
"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} (море Сэлиш) · лицензия у каждой записи",
"{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} дн. · метки без даты сохранены"
} }
+94 -23
View File
@@ -1120,6 +1120,9 @@
"Hybrid": "Гібрид", "Hybrid": "Гібрид",
"TomTom key unavailable": "Ключ TomTom недоступний", "TomTom key unavailable": "Ключ TomTom недоступний",
"TomTom daily budget reached": "Вичерпано денний ліміт TomTom", "TomTom daily budget reached": "Вичерпано денний ліміт TomTom",
"Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано",
"Traffic rate limit reached": "Досягнуто ліміту запитів даних про трафік",
"Traffic service unavailable": "Сервіс даних про трафік недоступний",
"TomTom upstream unreachable": "Сервер TomTom не відповідає", "TomTom upstream unreachable": "Сервер TomTom не відповідає",
"TomTom flow unavailable": "Потік TomTom недоступний", "TomTom flow unavailable": "Потік TomTom недоступний",
"SIMULATED — {reason}": "ІМІТАЦІЯ — {reason}", "SIMULATED — {reason}": "ІМІТАЦІЯ — {reason}",
@@ -1683,6 +1686,9 @@
"REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ", "REGIONAL NEWS": "РЕГІОНАЛЬНІ НОВИНИ",
"{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}", "{label} · {radius} AIR/SEA WINDOW": "{label} · РЕЙСИ / СУДНА · РАДІУС {radius}",
"{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
"CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ", "CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»",
@@ -1761,7 +1767,7 @@
"Restore real aircraft": "Повернути справжній літак", "Restore real aircraft": "Повернути справжній літак",
"Reclassify as TR-3B": "Перекласифікувати як TR-3B", "Reclassify as TR-3B": "Перекласифікувати як TR-3B",
"TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО", "TRACK ACQUIRED": "ТРЕК ЗАХОПЛЕНО",
"{label} · COURSE {heading}°": "{label} · КУРС {heading}°", "{label} · COURSE {heading}°": "{label} · ШЛЯХОВИЙ КУТ {heading}°",
"Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз", "Context could not restore every layer; try again": "Не вдалося відновити всі шари контексту; спробуйте ще раз",
"No selected data layers": "Шари даних не вибрано", "No selected data layers": "Шари даних не вибрано",
"{count} data layers could not be cleared": { "{count} data layers could not be cleared": {
@@ -2869,6 +2875,8 @@
"AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ", "AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ",
"SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", "SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ",
"LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ", "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 — ВИБІР", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР",
"Internet radio and local SDR": "Інтернет-радіо та локальний SDR", "Internet radio and local SDR": "Інтернет-радіо та локальний SDR",
"STATION TAG": "ТЕГ СТАНЦІЇ", "STATION TAG": "ТЕГ СТАНЦІЇ",
@@ -3057,19 +3065,19 @@
"Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати", "Minimum fire radiative power, megawatts": "Мінімальна потужність випромінювання пожежі, мегавати",
"fire intensity filter|All": "Усі", "fire intensity filter|All": "Усі",
"Detection confidence": "Достовірність виявлення", "Detection confidence": "Достовірність виявлення",
"{shown} of {total} shown": "показано {shown} з {total}", "{shown} of {total} shown": "Показано {shown} з {total}",
"{lower} to {upper}": "від {lower} до {upper}", "{lower} to {upper}": "Від {lower} до {upper}",
"Up to {upper}": "До {upper}", "Up to {upper}": "До {upper}",
"From {lower}": "Від {lower}", "From {lower}": "Від {lower}",
"Informational only · not for flight planning": "Лише для довідки · не для планування польотів", "Informational only · not for flight planning": "Лише для довідки · не для планування польотів",
"Unnamed zone": "Зона без назви", "Unnamed zone": "Без назви",
"Restricted airspace source unavailable": "Джерело даних про обмежений повітряний простір недоступне", "Restricted airspace source unavailable": "Джерело даних про зони обмеження польотів недоступне",
"Prohibited (P)": "Заборонена зона (P)", "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).", "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": "Заборонена зона", "Prohibited": "Заборонена зона",
"Restricted": "Зона обмеження", "Restricted": "Зона обмеження",
"Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження", "Prohibited & Restricted Airspace": "Заборонені зони та зони обмеження польотів",
"Malformed cyclone snapshot": "Некоректний знімок циклонів", "Malformed cyclone snapshot": "Некоректний знімок циклонів",
"Cyclone request timed out": "Минув час очікування запиту циклонів", "Cyclone request timed out": "Минув час очікування запиту циклонів",
"scene|PLAY": "СТАРТ", "scene|PLAY": "СТАРТ",
@@ -3110,7 +3118,7 @@
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли", "Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
"Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)", "Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)",
"OSM Places": "Місця OSM", "OSM Places": "Місця OSM",
"state or province": "штат або провінція", "state or province": "область, штат або провінція",
"county or district": "округ або район", "county or district": "округ або район",
"neighbourhood": "район міста", "neighbourhood": "район міста",
"natural region": "природний регіон", "natural region": "природний регіон",
@@ -3141,9 +3149,9 @@
"Area around {place}": "Околиці: {place}", "Area around {place}": "Околиці: {place}",
"Area around landmark": "Околиці орієнтира", "Area around landmark": "Околиці орієнтира",
"Outline {place}": "Контур: {place}", "Outline {place}": "Контур: {place}",
"Outline area": "Контур області", "Outline area": "Контур території",
"Find {place}": "Пошук: {place}", "Find {place}": "Пошук: {place}",
"Find area": "Знайти область", "Find area": "Знайти територію",
"Find recent imagery": "Знайти свіжі знімки", "Find recent imagery": "Знайти свіжі знімки",
"Find {what}": "Пошук: {what}", "Find {what}": "Пошук: {what}",
"Find places": "Знайти місця", "Find places": "Знайти місця",
@@ -3151,14 +3159,14 @@
"Searching imagery catalog": "Пошук у каталозі знімків", "Searching imagery catalog": "Пошук у каталозі знімків",
"Loading imagery": "Завантаження знімків", "Loading imagery": "Завантаження знімків",
"Searching OpenStreetMap": "Пошук в OpenStreetMap", "Searching OpenStreetMap": "Пошук в OpenStreetMap",
"voice area|Area": "Область", "voice area|Area": "Територія",
"buffer sized to the place": "буферна зона за розміром місця", "buffer sized to the place": "буферна зона за розміром місця",
"enclosing mapped area": "охопна область на карті", "enclosing mapped area": "нанесений контур, що охоплює орієнтир",
"the map was cleared while looking it up": "карту було очищено під час пошуку", "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 днів", "the catalog covers the last 30 days": "каталог охоплює останні 30 днів",
"there are more": "є ще", "there are more": "є й інші",
"{radius} m around the landmark": "{radius} м навколо об’єкта", "{radius} m around the landmark": "{radius} м навколо орієнтира",
"{count} parts": { "{count} parts": {
"few": "Частин: {count}", "few": "Частин: {count}",
"many": "Частин: {count}", "many": "Частин: {count}",
@@ -3172,16 +3180,16 @@
"other": "Намальовано частин: {drawn} з {total}" "other": "Намальовано частин: {drawn} з {total}"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"few": "Об’єднано дублікатів: {count}", "one": "Обʼєднано дублікатів: {count}",
"many": "Об’єднано дублікатів: {count}", "few": "Обʼєднано дублікатів: {count}",
"one": "Об’єднано дублікатів: {count}", "many": "Обʼєднано дублікатів: {count}",
"other": "Об’єднано дублікатів: {count}" "other": "Обʼєднано дублікатів: {count}"
}, },
"listed the first {limit}": "показано перші {limit}", "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": "Пошук скасовано", "Search cancelled": "Пошук скасовано",
"Sea Marks": "Морські знаки", "Sea Marks": "Навігаційні знаки",
"Ski Pistes": "Гірськолижні траси", "Ski Pistes": "Лижні траси",
"Fly to Tokyo": "Полети до Токіо", "Fly to Tokyo": "Полети до Токіо",
"Show live flights": "Покажи рейси онлайн", "Show live flights": "Покажи рейси онлайн",
"Reset to globe view": "Поверни вигляд глобуса", "Reset to globe view": "Поверни вигляд глобуса",
@@ -3254,5 +3262,68 @@
"The provider rejected the API key. Check it in Provider Settings.": "Постачальник відхилив ключ API. Перевірте його в «Налаштуваннях постачальників».", "The provider rejected the API key. Check it in Provider Settings.": "Постачальник відхилив ключ API. Перевірте його в «Налаштуваннях постачальників».",
"Too many chat requests. Try again in {seconds} s.": "Забагато запитів до чату. Спробуйте ще раз через {seconds} с.", "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 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.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях." "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "На вашому рахунку в постачальника закінчилися кошти. Перевірте оплату або виберіть іншого постачальника ШІ в налаштуваннях.",
"SIGHTING": "СПОСТЕРЕЖЕННЯ",
"Show {name} sighting": "Показати спостереження: {name}",
"seen {age} ago": "помічено {age} тому",
"observed {age} ago": "спостерігалося {age} тому",
"Be Whale Wise": "Спостерігайте за китами дбайливо",
"Reported by {name} ({entity})": "Повідомив: {name} ({entity})",
"Reported by {name}": "Повідомив: {name}",
"Salish Sea": "Салішське море",
"Birds": "Птахи",
"Mammals": "Ссавці",
"Reptiles": "Плазуни",
"Amphibians": "Земноводні",
"Ray-finned fishes": "Променепері риби",
"Insects": "Комахи",
"Arachnids": "Павукоподібні",
"Plants": "Рослини",
"Fungi": "Гриби",
"Other animals": "Інші тварини",
"Other or unknown": "Інші або невідомі",
"{label} {detail} — click to retry": "{label} {detail} — натисніть, щоб повторити",
"Wildlife sightings source unavailable": "Джерело спостережень живої природи недоступне",
"Show only community-confirmed (research-grade) sightings": "Показати лише підтверджені спільнотою спостереження дослідницького рівня",
"No recent sightings in view": "Немає недавніх спостережень в області огляду",
"Acartia feed unavailable": "Стрічка Acartia недоступна",
"Show all taxa": "Показати всі групи організмів",
"BIRDS": "ПТАХИ",
"Show only birds": "Показати лише птахів",
"MAMMALS": "ССАВЦІ",
"Show only mammals": "Показати лише ссавців",
"WHALES": "КИТОПОДІБНІ",
"Show only whales and dolphins (7 days)": "Показати лише китів і дельфінів (7 днів)",
"INSECTS": "КОМАХИ",
"Show only insects": "Показати лише комах",
"PLANTS": "РОСЛИНИ",
"Show only plants": "Показати лише рослини",
"All rights reserved": "Усі права захищено",
"Cetacean": "Китоподібне",
"Wildlife Sightings": "Спостереження живої природи",
"Exit cockpit to fly to a sighting": "Вийдіть із кабіни, щоб перейти до спостереження",
"Exit cockpit to fly to a sighting area": "Вийдіть із кабіни, щоб перейти до області спостереження",
"Wildlife": "Жива природа",
"Zoom in for local sightings": "Наблизьте для місцевих спостережень",
"Loading photo...": "Завантаження фото…",
"Photo unavailable": "Фото недоступне",
"Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°",
"Photo of {name}": "Фото {name}",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "{count} спостережень у полі зору",
"Close sighting": "Закрити спостереження",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Спостереження дикої природи за 48 год (китоподібні — 7 днів). Колір і символ значка позначають таксономічну групу. Вразливі види показано лише клітинками 0,2°; повідомлення Acartia про морських ссавців моря Селіш округлено до 0,01°. Не перепис.",
"Open {name} on iNaturalist": "Відкрити {name} в iNaturalist",
"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} (море Селіш) · ліцензія в кожного запису",
"{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} дн. · позначки без дати збережено"
} }
+93 -22
View File
@@ -1049,6 +1049,9 @@
"Hybrid": "混合", "Hybrid": "混合",
"TomTom key unavailable": "TomTom 密钥不可用", "TomTom key unavailable": "TomTom 密钥不可用",
"TomTom daily budget reached": "已达 TomTom 每日配额", "TomTom daily budget reached": "已达 TomTom 每日配额",
"Monthly traffic allowance used": "本月交通流量额度已用完",
"Traffic rate limit reached": "已达到交通数据请求频率上限",
"Traffic service unavailable": "交通服务不可用",
"TomTom upstream unreachable": "TomTom 上游服务无法连接", "TomTom upstream unreachable": "TomTom 上游服务无法连接",
"TomTom flow unavailable": "TomTom 路况不可用", "TomTom flow unavailable": "TomTom 路况不可用",
"SIMULATED — {reason}": "模拟——{reason}", "SIMULATED — {reason}": "模拟——{reason}",
@@ -1588,6 +1591,9 @@
"REGIONAL NEWS": "地区新闻", "REGIONAL NEWS": "地区新闻",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海监视窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞",
"CONTEXT STANDBY": "态势待机", "CONTEXT STANDBY": "态势待机",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞",
@@ -1660,7 +1666,7 @@
"Restore real aircraft": "恢复真实飞机", "Restore real aircraft": "恢复真实飞机",
"Reclassify as TR-3B": "重新归类为 TR-3B", "Reclassify as TR-3B": "重新归类为 TR-3B",
"TRACK ACQUIRED": "已获取航迹", "TRACK ACQUIRED": "已获取航迹",
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°", "{label} · COURSE {heading}°": "{label} · 对地航向 {heading}°",
"Context could not restore every layer; try again": "态势未能恢复所有图层,请重试", "Context could not restore every layer; try again": "态势未能恢复所有图层,请重试",
"No selected data layers": "没有已选数据图层", "No selected data layers": "没有已选数据图层",
"{count} data layers could not be cleared": { "{count} data layers could not be cleared": {
@@ -2556,6 +2562,8 @@
"AVAILABLE MISSIONS": "可用任务", "AVAILABLE MISSIONS": "可用任务",
"SELECT A MISSION TO INSPECT": "选择一项任务以查看", "SELECT A MISSION TO INSPECT": "选择一项任务以查看",
"LOADING 30-DAY MISSION INDEX": "正在加载 30 天任务索引", "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 / 空格键选择", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 预览 · ENTER / 空格键选择",
"Internet radio and local SDR": "网络电台与本地 SDR", "Internet radio and local SDR": "网络电台与本地 SDR",
"STATION TAG": "电台标签", "STATION TAG": "电台标签",
@@ -2844,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 产品。新上传的内容会在数秒内被检测到,并自动替换旧版本。点击标记查看报告。", "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|All": "全部",
"fire confidence filter|Nominal+": "标称+", "fire confidence filter|Nominal+": "中+",
"fire confidence filter|High": "高", "fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有检测——许多最强的火点置信度为低", "Every detection — many of the strongest fires are rated low confidence": "所有火点探测——许多强度最高的火点被评为低置信度",
"Nominal and high confidence detections": "标称和高置信度的检测", "Nominal and high confidence detections": "中、高置信度的火点探测",
"High confidence detections only": "仅高置信度的检测", "High confidence detections only": "仅高置信度的火点探测",
"Fire intensity (FRP)": "火势强度 (FRP)", "Fire intensity (FRP)": "火势强度(FRP)",
"≥ {value} MW": "≥ {value} MW", "≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度", "Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)", "Minimum fire radiative power, megawatts": "最小火辐射功率(兆瓦)",
"fire intensity filter|All": "全部", "fire intensity filter|All": "全部",
"Detection confidence": "检测置信度", "Detection confidence": "探测置信度",
"{shown} of {total} shown": "显示 {shown} / {total}", "{shown} of {total} shown": "显示 {shown} / {total}",
"{lower} to {upper}": "{lower} 至 {upper}", "{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}", "Up to {upper}": "最高 {upper}",
"From {lower}": "自 {lower} 起", "From {lower}": "最低 {lower}",
"Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划", "Informational only · not for flight planning": "仅供参考 · 不可用于飞行规划",
"Unnamed zone": "未命名区域", "Unnamed zone": "未命名空域",
"Restricted airspace source unavailable": "受限空域数据源不可用", "Restricted airspace source unavailable": "禁区与限制区空域数据源不可用",
"Prohibited (P)": "禁区 (P)", "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)。", "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": "禁区", "Prohibited": "禁区",
"Restricted": "限制区", "Restricted": "限制区",
@@ -2909,7 +2917,7 @@
"Not drawn: {parts}": "未绘制:{parts}", "Not drawn: {parts}": "未绘制:{parts}",
"approximate": "近似", "approximate": "近似",
"{pct}% cloud": "云量 {pct}%", "{pct}% cloud": "云量 {pct}%",
"Over {area}": "{area} 上空", "Over {area}": "覆盖 {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"other": "匹配影像:{count}/{total}" "other": "匹配影像:{count}/{total}"
}, },
@@ -2924,36 +2932,36 @@
"In {area}": "位于 {area}", "In {area}": "位于 {area}",
"Area around {place}": "{place} 周边区域", "Area around {place}": "{place} 周边区域",
"Area around landmark": "地标周边区域", "Area around landmark": "地标周边区域",
"Outline {place}": "勾勒 {place} 轮廓", "Outline {place}": "绘制 {place} 轮廓",
"Outline area": "勾勒区域轮廓", "Outline area": "绘制区域轮廓",
"Find {place}": "查找 {place}", "Find {place}": "查找 {place}",
"Find area": "查找区域", "Find area": "查找区域",
"Find recent imagery": "查找近期影像", "Find recent imagery": "查找近期影像",
"Find {what}": "查找 {what}", "Find {what}": "查找 {what}",
"Find places": "查找地点", "Find places": "查找地点",
"Drawing outline": "正在勾勒轮廓", "Drawing outline": "正在地图上绘制轮廓",
"Searching imagery catalog": "正在搜索影像目录", "Searching imagery catalog": "正在搜索影像目录",
"Loading imagery": "正在加载影像", "Loading imagery": "正在加载影像",
"Searching OpenStreetMap": "正在搜索 OpenStreetMap", "Searching OpenStreetMap": "正在搜索 OpenStreetMap",
"voice area|Area": "区域", "voice area|Area": "区域",
"buffer sized to the place": "按地点大小设置的缓冲区", "buffer sized to the place": "按地点大小设置的缓冲区",
"enclosing mapped area": "包含该地点的地图区域", "enclosing mapped area": "包含该地标的地图标注区域",
"the map was cleared while looking it up": "查找期间地图已清空", "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 天", "the catalog covers the last 30 days": "目录涵盖最近 30 天",
"there are more": "还有更多", "there are more": "还有更多",
"{radius} m around the landmark": "地标周围 {radius} 米", "{radius} m around the landmark": "地标周围 {radius} m",
"{count} parts": { "{count} parts": {
"other": "部分数:{count}" "other": "{count} 个部分"
}, },
"{drawn} of {total} parts drawn": { "{drawn} of {total} parts drawn": {
"other": "已绘制部分:{drawn}/{total}" "other": "已绘制 {drawn}/{total} 个部分"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"other": "已合并重复项:{count}" "other": "已合并重复项:{count}"
}, },
"listed the first {limit}": "已列出前 {limit} 个", "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": "搜索已取消", "Search cancelled": "搜索已取消",
"Sea Marks": "航标", "Sea Marks": "航标",
"Ski Pistes": "滑雪道", "Ski Pistes": "滑雪道",
@@ -3029,5 +3037,68 @@
"The provider rejected the API key. Check it in Provider Settings.": "服务商拒绝了 API 密钥。请在服务商设置中检查。", "The provider rejected the API key. Check it in Provider Settings.": "服务商拒绝了 API 密钥。请在服务商设置中检查。",
"Too many chat requests. Try again in {seconds} s.": "聊天请求过多。请在 {seconds} 秒后重试。", "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 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.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。" "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。",
"SIGHTING": "目击",
"Show {name} sighting": "显示{name}目击",
"seen {age} ago": "{age}前目击",
"observed {age} ago": "{age}前观察",
"Be Whale Wise": "负责任地观鲸",
"Reported by {name} ({entity})": "报告方 {name}({entity})",
"Reported by {name}": "报告方 {name}",
"Salish Sea": "萨利希海",
"Birds": "鸟类",
"Mammals": "哺乳类",
"Reptiles": "爬行动物",
"Amphibians": "两栖动物",
"Ray-finned fishes": "辐鳍鱼类",
"Insects": "昆虫",
"Arachnids": "蛛形动物",
"Plants": "植物",
"Fungi": "真菌",
"Other animals": "其他动物",
"Other or unknown": "其他或未知",
"{label} {detail} — click to retry": "{label} {detail} — 点击重试",
"Wildlife sightings source unavailable": "野生生物目击来源不可用",
"Show only community-confirmed (research-grade) sightings": "仅显示社区确认(研究级)的目击",
"No recent sightings in view": "视野内无近期目击",
"Acartia feed unavailable": "Acartia数据源不可用",
"Show all taxa": "显示所有分类群",
"BIRDS": "鸟类",
"Show only birds": "仅显示鸟类",
"MAMMALS": "哺乳类",
"Show only mammals": "仅显示哺乳类",
"WHALES": "鲸类",
"Show only whales and dolphins (7 days)": "仅显示鲸和海豚(7天)",
"INSECTS": "昆虫",
"Show only insects": "仅显示昆虫",
"PLANTS": "植物",
"Show only plants": "仅显示植物",
"All rights reserved": "保留所有权利",
"Cetacean": "鲸类",
"Wildlife Sightings": "野生生物目击",
"Exit cockpit to fly to a sighting": "退出驾驶舱以飞往目击地点",
"Exit cockpit to fly to a sighting area": "退出驾驶舱以飞往目击区域",
"Wildlife": "野生生物",
"Zoom in for local sightings": "放大查看当地目击",
"Loading photo...": "正在加载照片…",
"Photo unavailable": "照片不可用",
"Location obscured to a 0.2° cell": "位置已模糊至0.2°网格",
"Photo of {name}": "{name}的照片",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "视野内有 {count} 次目击",
"Close sighting": "关闭目击记录",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近48小时内观察到的野生生物(鲸类为7天)。颜色与徽章图标表示分类群。敏感物种仅以0.2°网格显示;Acartia提供的萨利希海海洋哺乳动物目击位置四舍五入到0.01°。并非种群普查。",
"Open {name} on iNaturalist": "在 iNaturalist 上打开 {name}",
"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}(萨利希海) · 各记录单独授权",
"{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}天内 · 保留无日期标记"
} }
+97 -26
View File
@@ -1044,6 +1044,9 @@
"Hybrid": "混合", "Hybrid": "混合",
"TomTom key unavailable": "TomTom 密鑰不可用", "TomTom key unavailable": "TomTom 密鑰不可用",
"TomTom daily budget reached": "已達 TomTom 每日配額", "TomTom daily budget reached": "已達 TomTom 每日配額",
"Monthly traffic allowance used": "本月交通流量額度已用完",
"Traffic rate limit reached": "已達交通資料請求頻率上限",
"Traffic service unavailable": "交通服務無法使用",
"TomTom upstream unreachable": "TomTom 上游服務無法連接", "TomTom upstream unreachable": "TomTom 上游服務無法連接",
"TomTom flow unavailable": "TomTom 路況不可用", "TomTom flow unavailable": "TomTom 路況不可用",
"SIMULATED — {reason}": "模擬——{reason}", "SIMULATED — {reason}": "模擬——{reason}",
@@ -1578,6 +1581,9 @@
"REGIONAL NEWS": "地區新聞", "REGIONAL NEWS": "地區新聞",
"{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口",
"{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}",
"MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整",
"FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整",
"NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞",
"CONTEXT STANDBY": "態勢待機", "CONTEXT STANDBY": "態勢待機",
"ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒",
"CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞",
@@ -1650,7 +1656,7 @@
"Restore real aircraft": "恢復真實飛機", "Restore real aircraft": "恢復真實飛機",
"Reclassify as TR-3B": "重新歸類為 TR-3B", "Reclassify as TR-3B": "重新歸類為 TR-3B",
"TRACK ACQUIRED": "已獲取航跡", "TRACK ACQUIRED": "已獲取航跡",
"{label} · COURSE {heading}°": "{label} · 航向 {heading}°", "{label} · COURSE {heading}°": "{label} · 對地航向 {heading}°",
"Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試", "Context could not restore every layer; try again": "態勢未能恢復所有圖層,請重試",
"No selected data layers": "沒有已選數據圖層", "No selected data layers": "沒有已選數據圖層",
"{count} data layers could not be cleared": { "{count} data layers could not be cleared": {
@@ -2544,6 +2550,8 @@
"AVAILABLE MISSIONS": "可用任務", "AVAILABLE MISSIONS": "可用任務",
"SELECT A MISSION TO INSPECT": "選擇一項任務以查看", "SELECT A MISSION TO INSPECT": "選擇一項任務以查看",
"LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", "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 / 空格鍵選擇", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇",
"Internet radio and local SDR": "網絡電臺與本地 SDR", "Internet radio and local SDR": "網絡電臺與本地 SDR",
"STATION TAG": "電臺標籤", "STATION TAG": "電臺標籤",
@@ -2847,16 +2855,16 @@
"route proxy error": "路線代理出錯", "route proxy error": "路線代理出錯",
"{lower} to {upper}": "{lower} 至 {upper}", "{lower} to {upper}": "{lower} 至 {upper}",
"Up to {upper}": "最高 {upper}", "Up to {upper}": "最高 {upper}",
"From {lower}": "自 {lower} 起", "From {lower}": "最低 {lower}",
"Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃", "Informational only · not for flight planning": "僅供參考 · 不可用於飛行規劃",
"Unnamed zone": "未命名區域", "Unnamed zone": "未命名空域",
"Restricted airspace source unavailable": "限制空域資料來源無法使用", "Restricted airspace source unavailable": "禁區與限制區空域數據源不可用",
"Prohibited (P)": "禁航區 (P)", "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)。", "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": "禁航區", "Prohibited": "禁區",
"Restricted": "限航區", "Restricted": "限制區",
"Prohibited & Restricted Airspace": "禁航區與限航區空域", "Prohibited & Restricted Airspace": "禁區與限制區空域",
"AUTHORING": "創作", "AUTHORING": "創作",
"Create and edit scenes": "創建和編輯場景", "Create and edit scenes": "創建和編輯場景",
"SCENE": "場景", "SCENE": "場景",
@@ -2883,17 +2891,17 @@
"Left panels": "左側面板", "Left panels": "左側面板",
"Right panels": "右側面板", "Right panels": "右側面板",
"fire confidence filter|All": "全部", "fire confidence filter|All": "全部",
"fire confidence filter|Nominal+": "標稱+", "fire confidence filter|Nominal+": "中+",
"fire confidence filter|High": "高", "fire confidence filter|High": "高",
"Every detection — many of the strongest fires are rated low confidence": "所有檢測——許多最強的火點置信度為低", "Every detection — many of the strongest fires are rated low confidence": "所有火點探測——許多強度最高的火點被評為低置信度",
"Nominal and high confidence detections": "標稱和高置信度的檢測", "Nominal and high confidence detections": "中、高置信度的火點探測",
"High confidence detections only": "僅高置信度的檢測", "High confidence detections only": "僅高置信度的火點探測",
"Fire intensity (FRP)": "火勢強度 (FRP)", "Fire intensity (FRP)": "火勢強度(FRP)",
"≥ {value} MW": "≥ {value} MW", "≥ {value} MW": "≥ {value} MW",
"Confidence": "置信度", "Confidence": "置信度",
"Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)", "Minimum fire radiative power, megawatts": "最小火輻射功率(兆瓦)",
"fire intensity filter|All": "全部", "fire intensity filter|All": "全部",
"Detection confidence": "檢測置信度", "Detection confidence": "探測置信度",
"{shown} of {total} shown": "顯示 {shown} / {total}", "{shown} of {total} shown": "顯示 {shown} / {total}",
"UTILITIES": "工具", "UTILITIES": "工具",
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
@@ -2909,7 +2917,7 @@
"Not drawn: {parts}": "未繪製:{parts}", "Not drawn: {parts}": "未繪製:{parts}",
"approximate": "近似", "approximate": "近似",
"{pct}% cloud": "雲量 {pct}%", "{pct}% cloud": "雲量 {pct}%",
"Over {area}": "{area} 上空", "Over {area}": "覆蓋 {area}",
"{count} of {total} acquisitions match": { "{count} of {total} acquisitions match": {
"other": "匹配影像:{count}/{total}" "other": "匹配影像:{count}/{total}"
}, },
@@ -2924,36 +2932,36 @@
"In {area}": "位於 {area}", "In {area}": "位於 {area}",
"Area around {place}": "{place} 周邊區域", "Area around {place}": "{place} 周邊區域",
"Area around landmark": "地標周邊區域", "Area around landmark": "地標周邊區域",
"Outline {place}": "勾勒 {place} 輪廓", "Outline {place}": "繪製 {place} 輪廓",
"Outline area": "勾勒區域輪廓", "Outline area": "繪製區域輪廓",
"Find {place}": "查找 {place}", "Find {place}": "查找 {place}",
"Find area": "查找區域", "Find area": "查找區域",
"Find recent imagery": "查找近期影像", "Find recent imagery": "查找近期影像",
"Find {what}": "查找 {what}", "Find {what}": "查找 {what}",
"Find places": "查找地點", "Find places": "查找地點",
"Drawing outline": "正在勾勒輪廓", "Drawing outline": "正在地圖上繪製輪廓",
"Searching imagery catalog": "正在搜索影像目錄", "Searching imagery catalog": "正在搜索影像目錄",
"Loading imagery": "正在加載影像", "Loading imagery": "正在加載影像",
"Searching OpenStreetMap": "正在搜索 OpenStreetMap", "Searching OpenStreetMap": "正在搜索 OpenStreetMap",
"voice area|Area": "區域", "voice area|Area": "區域",
"buffer sized to the place": "按地點大小設置的緩衝區", "buffer sized to the place": "按地點大小設置的緩衝區",
"enclosing mapped area": "包含該地點的地圖區域", "enclosing mapped area": "包含該地標的地圖標註區域",
"the map was cleared while looking it up": "查找期間地圖已清空", "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 天", "the catalog covers the last 30 days": "目錄涵蓋最近 30 天",
"there are more": "還有更多", "there are more": "還有更多",
"{radius} m around the landmark": "地標周圍 {radius} 米", "{radius} m around the landmark": "地標周圍 {radius} m",
"{count} parts": { "{count} parts": {
"other": "部分數:{count}" "other": "{count} 個部分"
}, },
"{drawn} of {total} parts drawn": { "{drawn} of {total} parts drawn": {
"other": "已繪製部分:{drawn}/{total}" "other": "已繪製 {drawn}/{total} 個部分"
}, },
"{count} duplicate mappings merged": { "{count} duplicate mappings merged": {
"other": "已合併重複項:{count}" "other": "已合併重複項:{count}"
}, },
"listed the first {limit}": "已列出前 {limit} 個", "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": "搜索已取消", "Search cancelled": "搜索已取消",
"Sea Marks": "航標", "Sea Marks": "航標",
"Ski Pistes": "滑雪道", "Ski Pistes": "滑雪道",
@@ -3029,5 +3037,68 @@
"The provider rejected the API key. Check it in Provider Settings.": "服務商拒絕了 API 密鑰。請在服務商設置中檢查。", "The provider rejected the API key. Check it in Provider Settings.": "服務商拒絕了 API 密鑰。請在服務商設置中檢查。",
"Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。", "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 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.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。" "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。",
"SIGHTING": "目擊",
"Show {name} sighting": "顯示{name}目擊",
"seen {age} ago": "{age}前目擊",
"observed {age} ago": "{age}前觀察",
"Be Whale Wise": "負責任地賞鯨",
"Reported by {name} ({entity})": "通報方 {name}({entity})",
"Reported by {name}": "通報方 {name}",
"Salish Sea": "薩利希海",
"Birds": "鳥類",
"Mammals": "哺乳類",
"Reptiles": "爬行動物",
"Amphibians": "兩棲動物",
"Ray-finned fishes": "輻鰭魚類",
"Insects": "昆蟲",
"Arachnids": "蛛形動物",
"Plants": "植物",
"Fungi": "真菌",
"Other animals": "其他動物",
"Other or unknown": "其他或未知",
"{label} {detail} — click to retry": "{label} {detail} — 點擊重試",
"Wildlife sightings source unavailable": "野生生物目擊來源無法使用",
"Show only community-confirmed (research-grade) sightings": "僅顯示社群確認(研究級)的目擊",
"No recent sightings in view": "視野內無近期目擊",
"Acartia feed unavailable": "Acartia資料來源無法使用",
"Show all taxa": "顯示所有分類群",
"BIRDS": "鳥類",
"Show only birds": "僅顯示鳥類",
"MAMMALS": "哺乳類",
"Show only mammals": "僅顯示哺乳類",
"WHALES": "鯨類",
"Show only whales and dolphins (7 days)": "僅顯示鯨和海豚(7天)",
"INSECTS": "昆蟲",
"Show only insects": "僅顯示昆蟲",
"PLANTS": "植物",
"Show only plants": "僅顯示植物",
"All rights reserved": "保留所有權利",
"Cetacean": "鯨類",
"Wildlife Sightings": "野生生物目擊",
"Exit cockpit to fly to a sighting": "退出駕駛艙以飛往目擊地點",
"Exit cockpit to fly to a sighting area": "退出駕駛艙以飛往目擊區域",
"Wildlife": "野生生物",
"Zoom in for local sightings": "放大查看當地目擊",
"Loading photo...": "正在載入照片…",
"Photo unavailable": "照片無法使用",
"Location obscured to a 0.2° cell": "位置已模糊至0.2°網格",
"Photo of {name}": "{name}的照片",
"Acartia": "Acartia",
"CC BY 4.0": "CC BY 4.0",
"{count} sightings in view": "視野內有 {count} 次目擊",
"Close sighting": "關閉目擊紀錄",
"Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近48小時內觀察到的野生生物(鯨類為7天)。顏色與徽章圖示表示分類群。敏感物種僅以0.2°網格顯示;Acartia提供的薩利希海海洋哺乳動物目擊位置四捨五入到0.01°。並非族群普查。",
"Open {name} on iNaturalist": "在 iNaturalist 上打開 {name}",
"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}(薩利希海) · 各紀錄個別授權",
"{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}天內 · 保留無日期標記"
} }
+69 -7
View File
@@ -4,10 +4,12 @@
"978 MHz UAT", "978 MHz UAT",
"AIS: --", "AIS: --",
"AIS: {name}", "AIS: {name}",
"Acartia",
"BRG {value}°", "BRG {value}°",
"BRG —", "BRG —",
"CAL {value}", "CAL {value}",
"CAL · {badge}", "CAL · {badge}",
"CC BY 4.0",
"CCTV", "CCTV",
"CRS {value}°", "CRS {value}°",
"CRT", "CRT",
@@ -28,8 +30,11 @@
"SRC {value}", "SRC {value}",
"Sentinel-2", "Sentinel-2",
"WHOT/BHOT", "WHOT/BHOT",
"acartia.io ↗",
"dB", "dB",
"hPa", "hPa",
"iNaturalist",
"iNaturalist ↗",
"{source} HTTP {status}", "{source} HTTP {status}",
"▲ {frp} MW" "▲ {frp} MW"
], ],
@@ -40,6 +45,7 @@
"AIS: {name}", "AIS: {name}",
"ANIME", "ANIME",
"AUTO", "AUTO",
"Acartia",
"Alternative", "Alternative",
"Ambient", "Ambient",
"Anime", "Anime",
@@ -50,6 +56,7 @@
"Bloom", "Bloom",
"Blues", "Blues",
"Bus", "Bus",
"CC BY 4.0",
"CCTV", "CCTV",
"CHAT", "CHAT",
"COCKPIT", "COCKPIT",
@@ -178,11 +185,14 @@
"WSW", "WSW",
"Washington DC", "Washington DC",
"Wind", "Wind",
"acartia.io ↗",
"dB", "dB",
"feed status|live", "feed status|live",
"fire behavior|Minimal", "fire behavior|Minimal",
"ft", "ft",
"hPa", "hPa",
"iNaturalist",
"iNaturalist ↗",
"km", "km",
"km/h", "km/h",
"min", "min",
@@ -334,15 +344,24 @@
"≥ {value} MW", "≥ {value} MW",
"ΔE", "ΔE",
"ΔN", "ΔN",
"▲ {frp} MW" "▲ {frp} MW",
"ANIMAL · {name}",
"Acartia",
"CC BY 4.0",
"Reptiles",
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
], ],
"fr": [ "fr": [
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
"ALTITUDE", "ALTITUDE",
"ALTITUDE · FT", "ALTITUDE · FT",
"ANIMAL · {name}",
"ANIME", "ANIME",
"AUTO", "AUTO",
"Acartia",
"Action", "Action",
"Allocation", "Allocation",
"Altitude {altitude}", "Altitude {altitude}",
@@ -361,6 +380,7 @@
"CALIBRATION", "CALIBRATION",
"CARGO", "CARGO",
"CAUSE", "CAUSE",
"CC BY 4.0",
"CCTV", "CCTV",
"CCTV OFF", "CCTV OFF",
"CCTV ON", "CCTV ON",
@@ -437,6 +457,7 @@
"OK", "OK",
"ON", "ON",
"ON/OFF", "ON/OFF",
"Obs. {licence}",
"Oldies", "Oldies",
"PAUSE", "PAUSE",
"POSITION {age}", "POSITION {age}",
@@ -452,8 +473,10 @@
"RADAR · {units}", "RADAR · {units}",
"RADIO", "RADIO",
"REC", "REC",
"REPTILE · {name}",
"Radio", "Radio",
"Reggae", "Reggae",
"Reptiles",
"Rock", "Rock",
"S2 GhostMaps", "S2 GhostMaps",
"SAR", "SAR",
@@ -488,10 +511,13 @@
"VOLUME", "VOLUME",
"VS", "VS",
"WHOT/BHOT", "WHOT/BHOT",
"acartia.io ↗",
"audio {state}", "audio {state}",
"dB", "dB",
"ft", "ft",
"hPa", "hPa",
"iNaturalist",
"iNaturalist ↗",
"image", "image",
"installation class|BASE", "installation class|BASE",
"installation class|INSTALLATION", "installation class|INSTALLATION",
@@ -694,17 +720,24 @@
"≥ {value} MW", "≥ {value} MW",
"ΔE", "ΔE",
"ΔN", "ΔN",
"▲ {frp} MW" "▲ {frp} MW",
"Acartia",
"CC BY 4.0",
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
], ],
"ja": [ "ja": [
"1090 MHz", "1090 MHz",
"978 MHz UAT", "978 MHz UAT",
"AIS: --", "AIS: --",
"AIS: {name}", "AIS: {name}",
"Acartia",
"BRG {value}°", "BRG {value}°",
"BRG —", "BRG —",
"CAL {value}", "CAL {value}",
"CAL · {badge}", "CAL · {badge}",
"CC BY 4.0",
"CCTV", "CCTV",
"CH {index} / {total}", "CH {index} / {total}",
"CRS {value}°", "CRS {value}°",
@@ -729,9 +762,12 @@
"Sentinel-2", "Sentinel-2",
"USB {rate} msg/s", "USB {rate} msg/s",
"WHOT/BHOT", "WHOT/BHOT",
"acartia.io ↗",
"dB", "dB",
"ft", "ft",
"hPa", "hPa",
"iNaturalist",
"iNaturalist ↗",
"km", "km",
"km/h", "km/h",
"kt", "kt",
@@ -877,11 +913,19 @@
"{value} KM/S", "{value} KM/S",
"ΔN", "ΔN",
"▲ {frp} MW", "▲ {frp} MW",
"≥ {value} MW" "≥ {value} MW",
"ANIMAL · {name}",
"Acartia",
"CC BY 4.0",
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
], ],
"ru": [ "ru": [
"AIS: --", "AIS: --",
"AIS: {name}", "AIS: {name}",
"Acartia",
"CC BY 4.0",
"CCTV", "CCTV",
"CRT", "CRT",
"FLIR", "FLIR",
@@ -895,11 +939,16 @@
"S2 GhostMaps", "S2 GhostMaps",
"S2 · {name}", "S2 · {name}",
"VS", "VS",
"WHOT/BHOT" "WHOT/BHOT",
"acartia.io ↗",
"iNaturalist",
"iNaturalist ↗"
], ],
"uk": [ "uk": [
"AIS: --", "AIS: --",
"AIS: {name}", "AIS: {name}",
"Acartia",
"CC BY 4.0",
"CCTV", "CCTV",
"CRT", "CRT",
"FLIR", "FLIR",
@@ -908,7 +957,10 @@
"NVG", "NVG",
"REC", "REC",
"S2 GhostMaps", "S2 GhostMaps",
"WHOT/BHOT" "WHOT/BHOT",
"acartia.io ↗",
"iNaturalist",
"iNaturalist ↗"
], ],
"zh-CN": [ "zh-CN": [
"1090 MHz", "1090 MHz",
@@ -966,7 +1018,12 @@
"≥ {value} MW", "≥ {value} MW",
"ΔE", "ΔE",
"ΔN", "ΔN",
"▲ {frp} MW" "▲ {frp} MW",
"Acartia",
"CC BY 4.0",
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
], ],
"zh-Hant": [ "zh-Hant": [
"1090 MHz", "1090 MHz",
@@ -1024,6 +1081,11 @@
"≥ {value} MW", "≥ {value} MW",
"ΔE", "ΔE",
"ΔN", "ΔN",
"▲ {frp} MW" "▲ {frp} MW",
"Acartia",
"CC BY 4.0",
"acartia.io ↗",
"iNaturalist ↗",
"iNaturalist"
] ]
} }
+7 -9
View File
@@ -1,3 +1,5 @@
import { foldPlaceText } from './data/placeNames.js';
/** Photon/OpenStreetMap place-search adapter. */ /** Photon/OpenStreetMap place-search adapter. */
const PHOTON_ENDPOINT = 'https://photon.komoot.io/api/'; const PHOTON_ENDPOINT = 'https://photon.komoot.io/api/';
@@ -233,19 +235,15 @@ export function photonSearchUrl(
} }
/** /**
* Place name reduced to comparable form: unaccented, lowercased, punctuation * Place name reduced to comparable form: lowercased, Latin accents removed,
* collapsed to single spaces. "Huế" and "Hue" have to compare equal, because a * other scripts preserved, and punctuation collapsed to single spaces. "Huế"
* user typing either means the same city. * and "Hue" have to compare equal, because a user typing either means the same
* city.
* @param {string} text * @param {string} text
* @returns {string} * @returns {string}
*/ */
export function normalizeToponym(text) { export function normalizeToponym(text) {
return String(text ?? '') return foldPlaceText(text);
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
} }
/** /**
+58
View File
@@ -35,6 +35,12 @@ import {
import { geocodeNavigationMode } from './locations.js'; import { geocodeNavigationMode } from './locations.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); 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. */ /** Photon's real answer for "Hanoi", trimmed to the fields we consume. */
const HANOI = { 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'); 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 () => { 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 // The other half of the rule, and the reason bias exists at all: "Sixth
// Street" over Austin must stay Austin's. // Street" over Austin must stay Austin's.
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff. Show more