Compare commits

..
Author SHA1 Message Date
Mazeyar Moeini FeizabadiandClaude Fable 5.2 d4f86748ea Merge the Transit provider fix and current main; fix the Bike Share test harness
The provider/globe identity check from #1688 moves into the shared terrain
reader, so every layer gets it; Transit shows the reader the flat provider
with observe(), since it answers a flat globe itself.

The Bike Share height test kept one array entry per event-loop turn and
spread it into Math.max, which overflowed the stack on slow CI runners. It
now keeps a running maximum.

Both found in review by kvnloo.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 03:27:30 +01:00
Mazeyar Moeini Feizabadi c29ee98e23 Revert "style(transit): format the terrain provider test"
This reverts commit 5110f4cce3.
2026-11-13 03:26:17 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 5110f4cce3 style(transit): format the terrain provider test
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 03:25:07 +01:00
Mazeyar Moeini Feizabadi 8792e4701e Merge remote-tracking branch 'origin/main' into mazeyar/transit-terrain-height-cache 2026-11-13 03:22:10 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 87674240bd fix(transit): start terrain heights afresh when the provider or globe changes
A height read after the camera and tiles settled was kept until either
changed, but swapping the terrain provider or the globe with a still camera
changed neither, so the previous terrain's elevation could stand
indefinitely. The cache is now cleared when either identity changes, checked
before the flat-globe answer so terrain -> flat -> terrain is covered too.

Found in review by kvnloo.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 03:22:10 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 888662749a docs(changelog): name the shared terrain fragment after its pull request
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 23:05:39 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 0f4dda5cc6 perf(terrain): one shared, cached and budgeted terrain height reader for Transit, Rail, Bike Share and local data
globe.getHeight is a ray cast against the drawn tile's mesh and
scene.sampleHeight a GPU depth render; several layers asked for them
uncached and unbudgeted. The cache written for Transit moves to
src/services/terrainSurface.js and is handed to every layer through the
ground service, so layers share one cache and one read budget.

- Rail reads each train on a ~4 m grid instead of every frame.
- Local data layers read terrain floors through it and stop depth samples
  at 8 ms a frame.
- Bike Share draws stations at once and finds their heights afterwards in
  4 ms steps.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 23:05:24 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 d5f7926c7e docs(changelog): name the transit height fragment after its pull request
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 21:50:22 +01:00
Mazeyar Moeini FeizabadiandClaude Fable 5.2 1c49a89faa fix(transit): keep terrain heights per point so rereads stop freezing the page
On a terrain globe every floor reread asked globe.getHeight, a ray cast against
the drawn tile's mesh, for every trail fix of every vehicle in view, twice per
pass and seven passes per poll. Over Boston's 650 vehicles that was about
150,000 reads per 10 s for about 8,000 distinct points, and blocked the page
for up to 19 s at a time while zooming.

Heights are now kept per point (1e-5 degrees), read again only while the camera
moves or tiles load, and read within a time budget: vehicle positions first,
the path a marker is drawn along next, undrawn trail history last.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 21:10:49 +01:00
72 changed files with 1804 additions and 1070 deletions

No files matched your search

+2 -4
View File
@@ -31,7 +31,6 @@ How to read this:
| **adsb.lol** | Military flights + aircraft traces | ODbL 1.0 | "adsb.lol" (ODbL) |
| **adsbdb** (`api.adsbdb.com`) | Aircraft type, model name and registration by ICAO hex for enriched flights; airline and origin/destination airports for the tracked flight's callsign | No licence or rate limit is published for the service. Its own credits: aircraft data from PlaneBase, ICAO-to-N-number conversion by Guillaume Michel, and flight route data that "is the work of David Taylor, Edinburgh and Jim Mason, Glasgow, and may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh" | "adsbdb — api.adsbdb.com" (courtesy); route data credited to David Taylor, Edinburgh, and Jim Mason, Glasgow |
| **Planespotters.net** (`api.planespotters.net/pub/photos/hex/{hex}`) | One thumbnail photo of the tracked aircraft, by ICAO hex, in the CONTEXT panel's CONTACTS view under the tracked flight's name | [Planespotters photo API terms](https://www.planespotters.net/photo/api); each photo remains the copyright of its photographer. Thumbnails may be hotlinked on the condition that the photographer is named next to the photo and the photo links to its Planespotters page with a followable link | "Photo © {photographer}" beside every photo that names one, linked to its Planespotters page; "Planespotters.net" in Data attribution |
| **Open Vessel Photos** (`openvesselphotos.org/v1/mmsi/{mmsi}`) | One photo of the selected vessel, by MMSI, in the CONTEXT panel's CONTACTS view under the vessel's name and MMSI | Free, keyless public API ([README](https://github.com/louptheron/openvesselphotos)); each photo keeps the open licence its author chose (CC BY, CC BY-SA, CC0, or public domain), given with it in the API | "Photo: {photographer} · {licence}" beside every photo, linked to the vessel's Open Vessel Photos page; "Open Vessel Photos" in Data attribution |
| **AISStream.io** | Live vessels (AIS) | Free, beta, no formal ToS; AIS is a public broadcast | "AISStream.io" (courtesy) |
| **CelesTrak** | Satellite TLEs (SGP4) and SATCAT records (object type, operational status, owner) that file each satellite under a class | US-government-origin data, no license; citation requested | "CelesTrak (celestrak.org), Dr. T.S. Kelso" |
| **The Space Devs — Launch Library 2 v2.3** | Recent launch, payload, stage, and recovery metadata for Space Missions (30d) | [The Space Devs terms of use](https://github.com/TheSpaceDevs/Tutorials/blob/main/faqs/faq_TSD.md#terms-of-use): data may be used and shared in any form; avoid forwarding it without added value; attribution is encouraged (not mandatory). [Official API limits](https://ll.thespacedevs.com/docs/): 15 unauthenticated calls/hour; optional token | "Launch Library 2 — The Space Devs" (courtesy attribution) |
@@ -239,8 +238,7 @@ The optional Local ADS-B layer and the broadcast-FM receiver use radio you recei
- **adsbdb enrichment.** `/api/adsbdb` resolves an ICAO hex to aircraft type, model name and registration, and the tracked flight's callsign to an airline and an origin/destination pair. Fan-out is deliberately small because adsbdb is a free community API that publishes no quota: at most 4 requests in flight, dispatches dripped to roughly 5/s, and each key asked for at most once per session (for the flights layer's enrichment, a lookup that failed temporarily, such as an outage, a timeout, a rate limit or an authorization refusal that may clear, may be asked again after a 60-second cool-down; an answer, found or not, and a permanent refusal such as a 404 are final; Local ADS-B lookups are asked once), with route lookups reserved for the tracked aircraft. The dev-server proxy caches each key for 24 hours in `.gev-cache/adsbdb.json`, negative answers included, so fresh entries survive server restarts. That gitignored cache remains local to the deployment and is not bundled with this repo. adsbdb publishes no general service license; its README says the route data may not be copied, published, or incorporated into other databases without the explicit permission of David J Taylor, Edinburgh. Deployers are responsible for obtaining any permission their use requires. Enrichment is best-effort and fail-silent — a flight that gets no answer keeps what the flight feed already gave it.
- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, and whose page is an `https://www.planespotters.net` URL is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel (see Open Vessel Photos below), an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name, when the answer gives one, is visible text next to it ("Photo © {photographer}"; a photo whose answer names no one shows no credit line), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden.
- **Open Vessel Photos.** When you select a vessel with a nine-digit MMSI, the browser asks `https://openvesselphotos.org/v1/mmsi/{mmsi}` directly (the API is keyless and answers cross-origin, so no key and no server proxy are involved; account credentials are never sent) for that vessel only, once per MMSI per session, with an 8-second timeout. A miss (a 404 or a vessel without photos) is remembered for the session; a failed request may be asked again after five minutes. The first photo whose image is an `https://openvesselphotos.org` URL and which names a licence is shown, at the 800 px size, in the CONTEXT panel's CONTACTS view under the vessel's name and MMSI (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else (`src/data/openVesselPhoto.js`, `src/layers/vessels/photos.js`). The image is hotlinked from the URL the API gives (never copied or re-hosted); the photographer and licence are visible text next to it ("Photo: {photographer} · {licence}", or "Photo · {licence}" when the answer names no one), and the photo and credit link to the vessel's Open Vessel Photos page with `target="_blank" rel="noopener"`. If the image fails to load, the photo and its credit are hidden.
- **Planespotters photos.** When you track an aircraft, the browser asks `https://api.planespotters.net/pub/photos/hex/{hex}` directly (the API answers cross-origin, so no key and no server proxy are involved) for that aircraft only, once per hex per session, through the same enrichment queue as adsbdb, with an 8-second timeout. A miss is remembered for the session; a request that failed temporarily (an outage, the timeout or a rate limit) may be asked again after a 60-second cool-down while the aircraft stays tracked, as for the flights layer's other enrichment. Either way no photo shows meanwhile. The first photo whose image is an `https://` URL on `*.plnspttrs.net`, and whose page is an `https://www.planespotters.net` URL is shown in the CONTEXT panel's CONTACTS view, under the tracked flight's name (`src/ui/views/panels/AwarenessPanel.vue`), and nowhere else; a vessel, an installation, a military aircraft or a converted TR-3B has none, and a military aircraft or TR-3B is never looked up. Anything else is dropped, so the app never shows or links outside Planespotters (`src/data/planespottersPhoto.js`, also exported as `gods-eye-view/sources/planespotters` for servers that cache the lookup). The photo follows the attribution rules: the thumbnail is hotlinked from the URL the API gives (never copied or re-hosted), the photographer's name, when the answer gives one, is visible text next to it ("Photo © {photographer}"; a photo whose answer names no one shows no credit line), and the photo and credit are a plain link to the photo's Planespotters page with `target="_blank" rel="noopener"` and never `rel="nofollow"`. If the image fails to load, the photo and its credit are hidden.
- **Launch Library 2.** `/api/launches` makes a server-side rolling-30-day query against the supported v2.3 detailed launch endpoint, caches successful responses for 15 minutes in memory and on disk, and serves the last successful response during a throttle or transient outage. Anonymous access is limited to 15 calls/hour; deployments can provide `LL2_API_TOKEN` for authenticated access. The Space Devs' published terms permit using and sharing the API data in any form, ask users not to forward it without adding value, disclaim complete accuracy, and encourage—but do not require—attribution. This app keeps a courtesy credit. Payload and stage/recovery records are shown only when supplied. Failed launches expose their source status and never receive fallback orbit geometry or a live/estimated marker. LL2 supplies launch context and event timing, not continuous ascent telemetry or live orbital state.
- **TfL JamCams.** The camera list comes from the keyless `api.tfl.gov.uk` endpoint (an optional `TFL_APP_KEY` raises its rate limit); frames come from TfL's public S3 bucket. The "Powered by TfL Open Data" attribution is required by TfL's terms and is registered in the Data attribution popover.
- **Ontario 511 CCTV.** The camera list comes from the `511on.ca/api/v2/get/cameras` endpoint, which now requires a free developer key (`ONTARIO_511_API_KEY`: sign up at 511on.ca/my511/register, then request it while logged in at 511on.ca/developers/doc; throttled to 10 calls a minute). Without the key the pack is skipped. Frames are server-registered still images under `511on.ca/map/Cctv/`. The source is live-fetched at runtime and attributed under the Open Government Licence - Ontario. Access is governed by Ontario 511's Developer's Access Agreement (511on.ca/developers/resources): the data stays under the Open Government Licence - Ontario unless otherwise specified; key requests are reviewed case by case and ask for the intended use and any third parties that will receive the data; attribution by link to 511on.ca is encouraged, and the Ontario 511 logo may not be altered or used to imply endorsement (this app links and shows no logo); and the data is provided "as is", a disclaimer distributors should pass on, which the credit does.
@@ -688,4 +686,4 @@ NOAA density imagery with the raw GLM product.
## In-app attribution
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, Open Vessel Photos, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
The required Google Maps / Cesium credit renders on the on-globe credit line (`#cesium-credits`, bottom-left) and must stay visible — including in clean-view and recording modes (the whole line, logo + "Google Maps" + the "Data attribution" link, stays on screen; only the GEV panels/HUD fade). The layer-specific credits (adsb.lol, adsbdb, Planespotters.net, TeleGeography, OSM datacenters/dams/roads, NASA FIRMS, CelesTrak, USGS, City of Austin, Fintraffic, GBFS, Radio Browser, OpenSky, AISStream) are registered into the expandable **"Data attribution"** popover on that credit line via `viewer.creditDisplay.addStaticCredit(new Cesium.Credit(html, /* showOnScreen */ false))` — see `src/data/dataCredits.js`. When you add a new data source, add its license and attribution to this file **and** append an entry to `DATA_CREDITS` in `src/data/dataCredits.js` so it surfaces in the app.
@@ -0,0 +1,7 @@
- Fix the page freezing for seconds at a time with Transit on over a terrain
globe, most visibly while zooming over a large fleet. Every reread asked the
terrain for the height of every trail fix of every vehicle in view; heights
are now kept per point, read again only while the camera moves or tiles
load, and read within a time budget that puts vehicle positions first.
Switching the terrain provider or globe starts the kept heights afresh
(mazy1998, review by kvnloo, #1688).
@@ -0,0 +1,5 @@
- Read terrain heights through one shared, cached and budgeted reader
(`src/services/terrainSurface.js`) in Transit, Rail, Bike Share and the local
data layers. Opening Data Centers no longer blocks the page for over two
seconds, opening Bike Share for most of a second, and Rail asks the terrain
about a twentieth as often (mazy1998, #1689).
-2
View File
@@ -1,2 +0,0 @@
- Show a new CCTV camera's frame the moment it is decoded, with no fade, so
switching cameras no longer lingers on an empty preview (samehkhamis, #1693).
@@ -1,3 +0,0 @@
- End a CCTV camera's monitor plane where its view meets the ground on globe
maps too, instead of a kilometre-wide plane hundreds of metres up
(samehkhamis, #1694).
-3
View File
@@ -1,3 +0,0 @@
- Show a selected vessel's photo from Open Vessel Photos, credited with its
photographer and open licence, and its MMSI beside its name in the CONTACTS
view (samehkhamis, #1695).
-3
View File
@@ -1,3 +0,0 @@
- Add an `./i18n` entry so an application with its own language menu can
start, follow and switch the interface language as the globe picker does
(samehkhamis, #1696).
+1 -3
View File
@@ -224,9 +224,7 @@ leaving out `command-dock` the voice control and its pointer reticle;
leaving out `context` the Bhote Koshi reconstruction panel; and leaving out
`intel-hud` the Intel HUD overlay (`#intel-hud`), whose readouts the HUD
implementation publishes to the `gev-hud` store. An unknown
view name is an error. An application that offers its own language choice
starts and switches the language with the `./i18n` entry (`startPageLocale`,
`followPageLocale`, `switchLocale`; see [I18N.md](./I18N.md)) and passes
view name is an error. A host that does not initialize Core's locale passes
`localePicker: false` to `createApplicationControls` instead of editing the
markup. Node tests that import application modules register the component
loader with `node --import=<core package>/build/register-vue`.
+34 -20
View File
@@ -2224,9 +2224,7 @@ Frame loading, calibration editing and status display have separate modules;
providers, camera placement and navigation policy retain their existing owners.
Changing cameras invalidates old image callbacks and cancels an unfinished
calibration edit. Failed refreshes preserve settled pixels for the same camera,
while a newly selected camera never shows the prior camera's image. A frame is
shown without a fade once it is decoded (or after one second if its decode never
settles), so a new camera's preview is blank only while its frame loads. Disposal
while a newly selected camera never shows the prior camera's image. Disposal
releases listeners, subscriptions, image handlers, summary timers and chip-hide timers.
## UI disposal
@@ -3201,15 +3199,6 @@ stays in view or tracked; an answer, found or not, and a permanent refusal
stay final for the session. A route belongs to the callsign it was looked up
for, so a callsign change drops the old route. See `DATA_SOURCES.md`.
A selected vessel gets a photo the same way. The browser asks Open Vessel
Photos for it by MMSI (`fetchOpenVesselPhoto` in
`src/sources/live/standalone.js`, read by `src/data/openVesselPhoto.js`), once
per MMSI (`src/layers/vessels/photos.js`), and the CONTACTS view shows it under
the vessel's name and MMSI ("NAME · MMSI 366999709") with the photographer,
the photo's open licence and a link to the vessel's page. A vessel without an
MMSI is never looked up; a miss stays final for the session, and a failed
lookup is asked again after five minutes.
`gods-eye-view/server/providers/live` is a Node-only entry for the existing
plugins and shared request helpers. Importing it starts no sockets or timers.
The existing aircraft normalizer is separately available through the portable
@@ -5080,10 +5069,6 @@ Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, *
views re-render, code that published translated text words it again through
`onLocaleChange`, and `<html lang>`/`dir` and the page's `data-i18n` markup
follow. Nothing is lost; a toast already on screen keeps its words.
An application that offers the language elsewhere (a menu) uses the
`./i18n` entry (`src/i18n/page.js`): `startPageLocale` at boot,
`followPageLocale` for the markup and `switchLocale`, which does what a
picker choice does, with `localePicker: false`.
- **Right to left.** Arabic (`ar`) and Hebrew (`he`) are in `RTL_LOCALES`, so
`<html dir="rtl">`: the title, style indicator, both panel stacks and their
icon rails swap sides through logical properties (`inset-inline-*`,
@@ -5821,6 +5806,39 @@ Noir samples also exclude its deliberately vignetted outer field, using shader
settings and screen position rather than observed brightness. Selected-trail
diagnostics report every condition even when selection fails.
Heights of the terrain globe's loaded surface come from one shared reader,
`src/services/terrainSurface.js`, which the ground service hands to every layer
(`groundFloor.terrainSurface`). `globe.getHeight` is a ray cast against the
drawn tile's mesh, so the reader keeps each answer per point, about a metre
apart. While the camera moves or tiles load a kept height is read again after
2 s; a height read once both have been still for 5 s is kept until either
changes. Reads are budgeted at 16 ms per 200 ms window across all layers, 12 ms
of it for re-reads, longest-kept first. A request ranks as a marker (always
read), drawn geometry, or background (a quarter of each budget). Past the
budget a kept point keeps its last height and a new point takes the last
height read in its ~111 m floor cell or waits.
- Transit asks for every vehicle and trail fix: vehicle positions as markers,
the path a marker is drawn along next, the undrawn trail history of
unselected vehicles as background, and its reread cycle continues until no
point waits. Before this, every reread cast a ray for every trail fix of
every vehicle in view, which blocked the page for up to 19 s at a time over
a 650-vehicle fleet while zooming.
- Rail asks for each train every frame on a grid about 4 m apart, as a marker,
so a moving train reads a few times a second and a standing one not at all.
- Local data layers (Data Centers, Dams) read terrain floors through it, and
their depth samples (`scene.sampleHeight`, a GPU render) stop at 8 ms a
frame per layer and continue on the next frames.
- Bike Share draws a city's stations at once at the fallback height and finds
their ground heights afterwards in 4 ms steps, from the terrain reader on a
terrain globe and from depth samples where the globe is hidden.
Verified in the browser on a keyless terrain globe only: Boston for Transit,
Rail and Bike Share, northern Virginia for Data Centers. Not checked there:
photorealistic 3D Tiles, a terrain globe with a building tileset on top, Dams,
and any city steeper than Boston. Street Traffic keeps its own height cache
and ALPR cameras their own sample budget.
Transit brackets use the normal-composite overlay surface: a 1.25 px mode-colour
stroke over 3.25 px dark backing. Other contact themes retain their existing
behavior. The selected trail uses a 3 px core over 5 px backing, mode-coloured
@@ -6585,10 +6603,6 @@ silently demoting every later lookup for the session.
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
a hit at or beyond the camera's range counts as a miss, and on a globe map the plane also clamps
short of the globe's terrain along the axis (flat ground at the mount's ground when terrain is
missed or met beyond the range), whichever is nearer, so it never stands at the full range there
(about a kilometre wide and hundreds of metres up);
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
`godsEyeView.cctv.calibration.v2` (wiped clean, no v1 import); a panel-only CAL badge shows
`CALIBRATED`/`CURATED`/`RAW PRIOR` (no in-world tint). Panel is titled "CCTV" (not "CCTV
-25
View File
@@ -59,30 +59,6 @@ The language picker switches in place, without a reload. A view that calls `t()`
A toast already on screen keeps its words.
## An application with its own language choice
An application that composes Core and offers the language somewhere else (a menu, a settings page) imports the `./i18n` entry (`src/i18n/page.js`) and passes `localePicker: false` to `createApplicationControls`:
```js
import {
LOCALES,
LOCALE_NAMES,
followPageLocale,
getLocale,
onLocaleChange,
startPageLocale,
switchLocale,
} from '<core package>/i18n';
startPageLocale(document); // before any view renders: ?lang=, stored choice, browser, English; translates data-i18n markup
const stopFollowing = followPageLocale(document); // <html lang>/dir and data-i18n markup follow each switch
for (const code of LOCALES) addMenuItem(LOCALE_NAMES[code], code === getLocale());
menu.onChoose = (code) => switchLocale(code); // switched in place and stored (in ?lang when storage is blocked)
```
`switchLocale` does what a choice in the globe picker does, and returns `'unchanged'` for the active language. `onLocaleChange` tells the application's own menu to mark the new language.
## Rules
- **Pass literals.** `t('Close')`, not `t(label)` (unless `label` holds a `msg()` value) and never a template literal with `${…}`.
@@ -133,7 +109,6 @@ Icons (`aria-hidden="true"`, Material Symbols), comments, CSS values and lowerca
| `src/ui/i18n.js` | the page's vue-i18n instance: the active locale and the catalogs |
| `src/i18n/marker.js` | `msg` alone, for modules the server or tools import (no catalogs) |
| `src/i18n/locale.js` | shipped languages, their names, choosing the language at boot |
| `src/i18n/page.js` | the `./i18n` entry: start, follow and switch the page's language |
| `src/i18n/locales/<code>.json` | one catalog per language, keyed by English text |
| `src/i18n/same-as-english.json` | per language, the entries that stay as in English on purpose |
| `scripts/i18n.mjs` | `npm run i18n`: extracts messages, checks the catalogs, finds unmarked UI text |
+1
View File
@@ -34,6 +34,7 @@ const layers = createInfrastructureLayers({
| `clearSelectedEntityContextForLayer` | `(layerId)`; clear selection only if owned by this layer |
| `removeEntityContextsForLayer` | `(layerId)`; remove records owned by the layer on destruction or failed setup |
| `governorRequestRender` | `(reason)`; request a frame through the existing render scheduler |
| `terrainSurface` | Optional. The application's shared terrain surface (`groundFloor.terrainSurface`), so its layers read each terrain height once; without it each layer keeps its own |
Each returned layer supports `init(viewer)`, `enable(viewer)`, `disable(viewer)`,
`update(viewer)`, `destroy(viewer)`, `getStats()`, `getLodDiagnostics()` and
-1
View File
@@ -99,7 +99,6 @@
"./application": "./src/app/application.js",
"./application/viewer": "./src/app/viewer.js",
"./time": "./src/time/worldClock.js",
"./i18n": "./src/i18n/page.js",
"./build/vite": {
"node": "./build/vite.js"
},
-6
View File
@@ -385,8 +385,6 @@
"src/layers/vessels/index.js",
"src/layers/vessels/ingestion.js",
"src/layers/vessels/lifecycle.js",
"src/layers/vessels/photos.js",
"src/layers/vessels/photos.test.mjs",
"src/layers/vessels/policy.js",
"src/layers/vessels/queries.js",
"src/layers/vessels/rendering.js",
@@ -1337,13 +1335,9 @@
"src/app/sourceComposition.js",
"src/app/sourceComposition.test.mjs",
"src/data/dataCredits.js",
"src/data/openVesselPhoto.js",
"src/data/openVesselPhoto.test.mjs",
"src/data/layerState.js",
"src/data/layerState.test.mjs",
"src/data/spriteOrder.js",
"src/i18n/page.js",
"src/i18n/page.test.mjs",
"src/i18n/locales/ar.json",
"src/i18n/locales/de.json",
"src/i18n/locales/es.json",
+13 -36
View File
@@ -11,7 +11,8 @@
"src/data/localGeojsonLod.js",
"src/data/inputOwnership.js",
"src/data/infrastructureOverlayEntry.js",
"src/sources/infrastructureData.js"
"src/sources/infrastructureData.js",
"src/services/terrainSurface.js"
],
"external": ["cesium"]
},
@@ -175,28 +176,6 @@
],
"external": ["vue", "pinia", "vue-i18n"]
},
"i18n": {
"exports": ["./i18n"],
"modules": [
"src/i18n/page.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/he.json",
"src/i18n/locales/it.json",
"src/i18n/locales/ja.json",
"src/i18n/locales/pt.json",
"src/i18n/locales/ru.json",
"src/i18n/locales/sv.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json"
],
"external": []
},
"time": {
"exports": ["./time"],
"modules": ["src/time/worldClock.js"],
@@ -1496,8 +1475,7 @@
"src/sources/live/index.js",
"src/sources/live/standalone.js",
"src/sources/live/vessels.js",
"src/data/planespottersPhoto.js",
"src/data/openVesselPhoto.js"
"src/data/planespottersPhoto.js"
],
"external": []
},
@@ -1719,9 +1697,7 @@
"src/i18n/locales/sv.json",
"src/i18n/locales/uk.json",
"src/i18n/locales/zh-CN.json",
"src/i18n/locales/zh-Hant.json",
"src/layers/vessels/photos.js",
"src/data/openVesselPhoto.js"
"src/i18n/locales/zh-Hant.json"
],
"external": ["cesium"]
},
@@ -2086,6 +2062,7 @@
"src/layers/bikeshare/state.js",
"src/layers/bikeshare/testing.js",
"src/layers/bikeshare/viewport.js",
"src/services/terrainSurface.js",
"src/data/inputOwnership.js",
"src/data/cameraSensitivity.js",
"src/i18n/index.js",
@@ -3033,6 +3010,7 @@
"src/search/placeSearch.js",
"src/services/application.js",
"src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/groundSnap.js",
"src/services/meshFloorSampler.js",
"src/services/requests.js",
@@ -3782,9 +3760,7 @@
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js",
"src/ui/stores/dataLibrary.js",
"src/data/openVesselPhoto.js",
"src/layers/vessels/photos.js"
"src/ui/stores/dataLibrary.js"
],
"external": [
"hls.js",
@@ -4204,6 +4180,7 @@
"src/scopeMaskPolicy.js",
"src/scopeMask.js",
"src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/data/routeSteps.js",
"src/layers/directions/index.js",
"src/data/pointerOwnerNames.js",
@@ -4494,9 +4471,7 @@
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js",
"src/layers/vessels/photos.js",
"src/data/openVesselPhoto.js"
"src/data/planespottersPhoto.js"
],
"external": [
"hls.js",
@@ -4550,6 +4525,7 @@
"src/search/placeSearch.js",
"src/services/application.js",
"src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/groundSnap.js",
"src/services/meshFloorSampler.js",
"src/services/requests.js",
@@ -5214,6 +5190,7 @@
"src/renderGovernor.js",
"src/services/application.js",
"src/services/groundFloor.js",
"src/services/terrainSurface.js",
"src/services/meshFloorSampler.js",
"src/services/requests.js",
"src/services/terrainHeights.js",
@@ -5258,6 +5235,7 @@
"src/data/transitPresetStyle.js",
"src/layers/transit/controls.js",
"src/layers/transit/height.js",
"src/services/terrainSurface.js",
"src/layers/transit/index.js",
"src/layers/transit/ingestion.js",
"src/layers/transit/lifecycle.js",
@@ -5748,8 +5726,7 @@
"src/layers/satellites/sensors.js",
"src/data/ucsSatellites.js",
"src/data/imagingSensors.js",
"src/data/planespottersPhoto.js",
"src/data/openVesselPhoto.js"
"src/data/planespottersPhoto.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
},
+5 -2
View File
@@ -168,7 +168,7 @@ export function createApplicationCatalog({
createApplicationCctv({ surface, source: sources.cctv }),
createApplicationRadio({ surface, source: sources.radio }),
createApplicationTransit({ surface, source: sources.transit }),
createApplicationBikeshare({ source: sources.bikeshare }),
createApplicationBikeshare({ surface, source: sources.bikeshare }),
createApplicationDirections({ source: sources.directions }),
createApplicationRecentImagery({ source: sources['recent-imagery'] }),
createApplicationOsmPlaces(),
@@ -198,7 +198,10 @@ export function createApplicationCatalog({
}),
createApplicationCyclones({ feed: sources.cyclones }),
...createOsmOverlayLayers(),
...createInfrastructureLayers(localGeoJsonServices),
...createInfrastructureLayers({
...localGeoJsonServices,
terrainSurface: surface.groundFloor.terrainSurface,
}),
createApplicationCables({ source: sources.cables }),
createApplicationRail({ surface, source: sources.rail }),
createApplicationFirms({
+1 -2
View File
@@ -9,8 +9,7 @@ import { t } from '../i18n/index.js';
* Construct the existing controls and camera presentation. `initialShareCode`
* opens a share code (the `<code>` of `/share/<code>`) at start-up, for a host
* whose address does not carry one. `localePicker: false` removes the language
* picker, for a host that offers its own language choice (`switchLocale` from
* the `./i18n` entry).
* picker, for a host that does not initialize Core's locale.
*/
export function createApplicationControls({
scene: { viewer, mapStackController, operations },
+9 -2
View File
@@ -5,9 +5,16 @@ import * as picking from '../../data/pickRegistry.js';
import * as overlays from '../../overlays/worldOverlay.js';
/** Construct one layer using the application scene owners and a supplied source. */
export function createApplicationBikeshare({ source }) {
export function createApplicationBikeshare({ source, surface }) {
return createBikeshareLayer({
source,
services: { render, sprites, picking, overlays },
services: {
render,
sprites,
picking,
overlays,
// Station heights come from the terrain surface every layer shares.
ground: { terrainSurface: surface?.groundFloor?.terrainSurface },
},
});
}
-1
View File
@@ -29,7 +29,6 @@ export const writeCalibrationStoreV2 = layer.writeCalibrationStoreV2;
export const deriveCalBadge = layer.deriveCalBadge;
export const computeFrustumGeometry = layer.computeFrustumGeometry;
export const activationProbeClampRange = layer.activationProbeClampRange;
export const flatGroundAxisDistance = layer.flatGroundAxisDistance;
export const frameSignatureFromPixels = layer.frameSignatureFromPixels;
export const _createCctvProjectionPlaneForTest =
layer._createCctvProjectionPlaneForTest;
-12
View File
@@ -35,7 +35,6 @@ import cctvLayer, {
_pushAmbientCardEntriesForTest,
_setCctvOverlayHostForTest,
activationProbeClampRange,
flatGroundAxisDistance,
bindCctvWorldClickGesture,
clearProbeClampOnDeactivation,
computeFrustumGeometry,
@@ -656,17 +655,6 @@ test('activation obstruction clamp uses the field-derived 12 m floor', () => {
assert.equal(activationProbeClampRange(210, 250), null);
});
test('on a globe map the axis ends on flat ground at the mount ground', () => {
// A 24 m mount pitched 30° down meets the ground 48 m along its axis.
assert.ok(Math.abs(flatGroundAxisDistance(24, -30) - 48) < 1e-9);
// The default 700 m range then clamps just short of that, not 700 m out.
const clamp = activationProbeClampRange(700, flatGroundAxisDistance(24, -30));
assert.ok(Math.abs(clamp - 44) < 1e-9);
assert.equal(flatGroundAxisDistance(24, 0), null);
assert.equal(flatGroundAxisDistance(24, 10), null);
assert.equal(flatGroundAxisDistance(0, -30), null);
});
test('deactivation clears the probe clamp and restores nominal frustum geometry', () => {
const record = {
camera: { ...UNCLAMPED_CAMERA },
-7
View File
@@ -141,13 +141,6 @@ export const DATA_CREDITS = [
'<a href="https://www.planespotters.net" target="_blank" rel="noopener">Planespotters.net</a> ' +
'· each photo is © its photographer, credited beside it',
},
{
key: 'openvesselphotos',
html:
'Vessel photos: ' +
'<a href="https://openvesselphotos.org" target="_blank" rel="noopener">Open Vessel Photos</a> ' +
"· each photo keeps its author's open licence, credited beside it",
},
{
key: 'aisstream',
html:
-7
View File
@@ -51,13 +51,6 @@ test('Planespotters is credited with a plain followable link', () => {
assert.doesNotMatch(credit.html, /nofollow/);
});
test('Open Vessel Photos is credited with a plain followable link', () => {
const credit = DATA_CREDITS.find((entry) => entry.key === 'openvesselphotos');
assert.ok(credit, 'the selected vessel photos come from Open Vessel Photos');
assert.match(credit.html, /href="https:\/\/openvesselphotos\.org"/);
assert.doesNotMatch(credit.html, /nofollow/);
});
test('the UCS Satellite Database is acknowledged with its version and webpage', () => {
// UCS database manual, section 3: references name the version (the Excel
// file's name) and link www.ucsusa.org/satellite_database.
+1
View File
@@ -196,6 +196,7 @@ test('consumer build includes only infrastructure code and resolves assets under
'infrastructureOverlayEntry.js',
'localGeojsonCore.js',
'localGeojsonLod.js',
'terrainSurface.js',
]);
assert.deepEqual(
entry.imports,
+185 -5
View File
@@ -23,9 +23,10 @@ import {
} from '../renderGovernor.js';
/** The catalog's services, with the overlay host each test supplies. */
const createLocalGeoJsonLayer = (options) =>
const createLocalGeoJsonLayer = (options, services = {}) =>
createLayer(options, {
...localGeoJsonServices,
...services,
overlayHost: options.overlayHost ?? localGeoJsonServices.overlayHost,
});
@@ -58,10 +59,15 @@ class MockLayerEvent {
* @param {boolean} [options.sampleHeightSupported] Scene height-sampling capability.
* @param {Function} [options.sampleHeight] Initial scene.sampleHeight behavior
* (default: throws like a scene whose tiles are not sampleable yet).
* @param {number[][]} [options.points] `[lon, lat]` sites to load
* instead of the single dam polygon.
* @param {object} [options.services] Extra services for the layer.
*/
async function createRealLocalLayerHarness({
sampleHeightSupported = false,
sampleHeight = () => { throw new Error('tiles not sampleable yet'); },
points = null,
services = {},
} = {}) {
const originalFetch = globalThis.fetch;
const originalWindow = globalThis.window;
@@ -72,7 +78,22 @@ async function createRealLocalLayerHarness({
globalThis.fetch = async () => ({
ok: true,
status: 200,
text: async () => JSON.stringify({
text: async () => points ? points.map(([lon, lat], index) => JSON.stringify({
type: 'Feature',
id: `site-${index}`,
properties: { name: `Site ${index}` },
// A small outline: the layer stems its centre. (Cesium draws a point
// feature as a canvas pin, which Node cannot make.)
geometry: {
type: 'Polygon',
coordinates: [[
[lon - 0.001, lat - 0.001],
[lon + 0.001, lat - 0.001],
[lon, lat + 0.001],
[lon - 0.001, lat - 0.001],
]],
},
})).join('\n') : JSON.stringify({
type: 'Feature',
id: 'real-dam',
properties: { name: 'Runtime Dam', tags: { associated_river: 'Test River' } },
@@ -149,7 +170,7 @@ async function createRealLocalLayerHarness({
setInputAction() {},
destroy() {},
}),
});
}, services);
try {
await layer.enable(viewer);
} finally {
@@ -903,12 +924,13 @@ test('settled terrain refinement lifts an already sampled stem without another G
env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'wait for the refined mesh to settle');
globe.tilesLoaded = true;
clock.advance(500);
// The terrain surface reuses a height for 2 s before reading the mesh again.
clock.advance(2_100);
env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a parked marker must follow the refined floor');
terrain = 110;
clock.advance(500);
clock.advance(2_100);
env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 120) < 0.01, 'a lower floor must not flatten existing geometry');
terrain = 140;
@@ -1493,3 +1515,161 @@ test('globe-LOD re-selects during continuous motion, without ever seeing a moveE
assert.deepEqual(env.layer.getLodDiagnostics(), parked, 'a parked camera never re-selects');
});
/** A loaded terrain globe that counts its ray casts. */
function countingTerrain(height) {
const globe = {
show: true,
tilesLoaded: true,
reads: 0,
getHeight: () => { globe.reads += 1; return height; },
};
return globe;
}
/** Height of every stem base, in entity order. */
function allBaseHeightsM(env) {
return env.dataSources[0].entities.values.map(
(entity) => Cesium.Cartographic.fromCartesian(entity.__localBaseCartesian).height,
);
}
const SAMPLE_SITES = [
[-97.70, 30.20], [-97.69, 30.20], [-97.70, 30.21], [-97.69, 30.21], [-97.695, 30.205],
];
test('repeated walks over a grounded stem reuse its terrain height instead of re-casting it', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
const globe = countingTerrain(117);
env.viewer.scene.globe = globe;
setCameraAltitude(env, 20_000);
for (let walk = 0; walk < 40; walk += 1) {
env.preRender.raise();
clock.advance(500);
}
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01);
// 20 s parked: a few reads while the surface settles, then none.
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 parked walks`);
const settled = globe.reads;
for (let walk = 0; walk < 20; walk += 1) {
env.preRender.raise();
clock.advance(500);
}
assert.equal(globe.reads, settled, 'a parked camera over settled terrain casts no more rays');
});
test('repeated walks past an unseen base do not re-cast its terrain height', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
const globe = countingTerrain(117);
env.viewer.scene.globe = globe;
setCameraAltitude(env, 20_000);
env.setBaseInView(false);
for (let walk = 0; walk < 40; walk += 1) {
env.preRender.raise();
clock.advance(500);
}
assert.equal(env.sampleCalls.count, 0);
assert.ok(globe.reads <= 8, `${globe.reads} terrain ray casts over 40 walks past an unseen base`);
});
test('terrain heights come from the shared surface the host supplies, in degrees', async (t) => {
const asked = [];
const terrainSurface = {
height: (viewer, lat, lon, need) => { asked.push({ viewer, lat, lon, need }); return 130; },
};
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true, sampleHeight: () => 117,
points: [[-97.695, 30.205]],
services: { terrainSurface },
});
const clock = installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
const globe = countingTerrain(117);
env.viewer.scene.globe = globe;
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.ok(Math.abs(baseHeightM(env) - 117) < 0.01, 'the sample is floored by the globe itself');
clock.advance(500);
env.preRender.raise();
assert.ok(asked.length >= 2);
for (const call of asked) {
assert.equal(call.viewer, env.viewer);
assert.ok(Math.abs(call.lat - 30.205) < 0.002 && Math.abs(call.lon + 97.695) < 0.002,
`asked at ${call.lat}, ${call.lon}`);
}
assert.ok(Math.abs(baseHeightM(env) - 130) < 0.01, 'the refined floor comes from the shared surface');
assert.equal(globe.reads, 1, 'only the floor under a fresh sample is read from the globe directly');
});
test('a walk of expensive ground samples stops at its budget and later frames finish the rest', async (t) => {
const heights = new Map();
let clock = null;
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true,
points: SAMPLE_SITES,
sampleHeight: (carto) => {
clock.advance(15); // one GPU depth render
const height = 100 + heights.size;
heights.set(`${carto.longitude},${carto.latitude}`, height);
return height;
},
});
_resetRenderGovernorForTest();
const governorScene = { renders: 0, requestRender() { this.renders += 1; } };
installRenderGovernor({ scene: governorScene });
governorScene.renders = 0; // discard the governor's own install settling frame
clock = installFakeClock(t);
t.after(() => {
env.layer.destroy(env.viewer);
env.cleanup();
_resetRenderGovernorForTest();
});
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.equal(env.sampleCalls.count, 1, 'one 15 ms sample spends the walk budget');
assert.equal(governorScene.renders, 1, 'the walk asks for the frame that continues it');
// The following frames arrive well inside the 450 ms walk interval.
for (let frame = 2; frame <= SAMPLE_SITES.length; frame += 1) {
clock.advance(16);
env.preRender.raise();
assert.equal(env.sampleCalls.count, frame, 'each frame takes up where the last stopped');
}
assert.equal(governorScene.renders, SAMPLE_SITES.length - 1, 'no frame is requested once every site is sampled');
clock.advance(16);
env.preRender.raise();
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length, 'nothing is sampled twice');
const expected = env.dataSources[0].entities.values.map((entity) => {
const carto = entity.__localBaseCarto;
return heights.get(`${carto.longitude},${carto.latitude}`);
});
assert.deepEqual(allBaseHeightsM(env).map((h) => Math.round(h * 100) / 100), expected);
for (const entity of env.dataSources[0].entities.values) {
const stem = entity.polyline.positions.getValue();
assert.ok(Cesium.Cartesian3.equals(stem[0], entity.__localBaseCartesian), 'each stem stands on its sampled base');
}
});
test('cheap ground samples are all taken on the first walk, at the same heights as paced ones', async (t) => {
const env = await createRealLocalLayerHarness({
sampleHeightSupported: true,
points: SAMPLE_SITES,
sampleHeight: () => 117,
});
installFakeClock(t);
t.after(() => { env.layer.destroy(env.viewer); env.cleanup(); });
setCameraAltitude(env, 20_000);
env.preRender.raise();
assert.equal(env.sampleCalls.count, SAMPLE_SITES.length);
for (const height of allBaseHeightsM(env)) assert.ok(Math.abs(height - 117) < 0.01);
});
+134 -15
View File
@@ -11,6 +11,10 @@ import {
applyInfraEvictionGrace,
shouldRecomputeInfraLod,
} from './localGeojsonLod.js';
import {
createTerrainSurface,
TERRAIN_NEED,
} from '../services/terrainSurface.js';
const DEFAULT_LABEL_MAX = 900;
const DEFAULT_LABEL_GRID_PX = 132;
@@ -38,6 +42,15 @@ const GROUND_SAMPLE_MAX_ABS_HEIGHT_M = 9000;
* 30 × 2 s ≈ 60 s, far longer than a tile stream-in.
*/
export const GROUND_SAMPLE_MAX_ARMED_RETRIES = 30;
/**
* Milliseconds of scene.sampleHeight one frame may spend. Each call is an
* offscreen depth render (about 15 ms measured), and a first walk near a dense
* cluster wants one per site in view: 168 of them held the main thread for
* 2.6 s. Past the budget the remaining sites queue, and each following frame
* takes as many as fit, so the sites reach the same heights a frame or a few
* apart instead of all inside one blocked frame.
*/
export const GROUND_SAMPLE_FRAME_BUDGET_MS = 8;
/** Ignore sub-metre camera-derived stem-tip noise at camera settle. */
export const LOCAL_STEM_TIP_EPSILON_M = 0.5;
const LOCAL_STEM_TIP_EPSILON_SQ = LOCAL_STEM_TIP_EPSILON_M ** 2;
@@ -336,8 +349,20 @@ export function createLocalGeoJsonLayer(
governorRequestRender,
showOsmCredit,
hideOsmCredit,
terrainSurface,
},
) {
/**
* What ground sampling shares across a layer's records: the terrain
* surface (the application's, so every layer reads a point once and spends
* one budget, or this layer's own), the sample time spent this frame, and
* the records the frame budget left for a later frame.
*/
const _ground = {
terrain: terrainSurface || createTerrainSurface(),
sampleMs: 0,
queued: [],
};
let _dataSource = null;
let _enabled = false;
let _clickHandler = null;
@@ -444,6 +469,42 @@ export function createLocalGeoJsonLayer(
clearTimeout(_groundRetryTimer);
_groundRetryTimer = null;
}
/**
* Sample the sites an earlier frame's budget left over, within this
* frame's. They were never attempted, so the 2 s retry spacing does not
* apply to them.
* @param {Cesium.Viewer} viewer
* @param {number} now
* @returns {void}
*/
function sampleQueuedGround(viewer, now) {
const queued = _ground.queued;
if (queued.length === 0) return;
_ground.queued = [];
for (const record of queued) {
record.groundSampleQueued = false;
// The camera may have left the site since it queued.
if (_lodComputed && !_activeLodIds.has(record.id)) continue;
const distance = Cesium.Cartesian3.distance(
viewer.camera.positionWC,
record.base,
);
if (distance >= GROUND_SAMPLE_MAX_DISTANCE_M) continue;
// Past the budget this puts the record back in the queue.
if (sampleLocalGroundHeight(viewer, record, now, _ground)) {
updateLocalStemGeometry(viewer, record, now, _ground, distance);
_groundRetryArms = 0;
}
}
}
/** Ask for the frame that samples what this one's budget left over. */
function requestQueuedGroundFrame() {
if (_ground.queued.length > 0)
governorRequestRender(`local-ground-sample:${id}`);
}
const _overlayPublisher = createLocalInfrastructureOverlayPublisher({
sourceId: id,
host: overlayHost,
@@ -472,6 +533,7 @@ export function createLocalGeoJsonLayer(
const source = _dataSource;
_dataSource = null;
_stemRecords = [];
_ground.queued = [];
_stemGeometryDirty = true;
_lastVisibilityUpdate = Number.NEGATIVE_INFINITY;
removeEntityContextsForLayer(id);
@@ -744,6 +806,8 @@ export function createLocalGeoJsonLayer(
// Store references for bounded stem scaling and native picking.
feature.__localBaseCarto = carto;
feature.__localBaseCartesian = base;
const latDeg = Cesium.Math.toDegrees(carto.latitude);
const lonDeg = Cesium.Math.toDegrees(carto.longitude);
const context = registerEntityContext(feature, {
id: `${id}:${recordId}`,
layerId: id,
@@ -795,7 +859,11 @@ export function createLocalGeoJsonLayer(
stemPositionBuffers,
stemPositionBufferIndex: 0,
groundHeight,
// Degrees, as the terrain surface takes them.
latDeg,
lonDeg,
groundSampled: false,
groundSampleQueued: false,
lastGroundSampleMs: 0,
priority,
entry: labels
@@ -916,7 +984,13 @@ export function createLocalGeoJsonLayer(
_preRenderRemover = viewer.scene.preRender.addEventListener(() => {
if (!_enabled || !_dataSource) return;
const now = performance.now();
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) return;
// One sample budget per frame, shared by the queue and the walk.
_ground.sampleMs = 0;
sampleQueuedGround(viewer, now);
if (now - _lastVisibilityUpdate < VISIBILITY_UPDATE_MS) {
requestQueuedGroundFrame();
return;
}
_lastVisibilityUpdate = now;
const cameraPos = viewer.camera.positionWC;
@@ -967,6 +1041,9 @@ export function createLocalGeoJsonLayer(
_lastGroundSampleCapability = canSampleGround;
let groundRetryPending = false;
let groundSampleProgress = false;
// This walk asks for no height while tiles load, so it shows the
// surface the load itself; see terrainSurface.js observe().
_ground.terrain.observe?.(viewer);
// --- Globe-LOD: recompute the active-stem set on camera moves. ---
// `_stemGeometryDirty` is set by moveEnd, the first enable, and the
@@ -1028,9 +1105,10 @@ export function createLocalGeoJsonLayer(
const terrainFloorChanged = refreshLocalTerrainFloor(
viewer,
record,
_ground,
);
if (refreshStemGeometry || terrainFloorChanged) {
updateLocalStemGeometry(viewer, record, now);
updateLocalStemGeometry(viewer, record, now, _ground);
} else if (
canSampleGround &&
!record.groundSampled &&
@@ -1044,9 +1122,9 @@ export function createLocalGeoJsonLayer(
);
if (
distance < GROUND_SAMPLE_MAX_DISTANCE_M &&
sampleLocalGroundHeight(viewer, record, now)
sampleLocalGroundHeight(viewer, record, now, _ground)
) {
updateLocalStemGeometry(viewer, record, now, distance);
updateLocalStemGeometry(viewer, record, now, _ground, distance);
}
}
if (!wasGroundSampled && record.groundSampled)
@@ -1058,16 +1136,19 @@ export function createLocalGeoJsonLayer(
// the user happens to move. Schedule the frame the retry needs.
// Gated on a sampleable scene and in-range, in-view records only,
// so a far camera (or a keyless scene) stays fully idle; the
// distance is only computed for still-unsampled stems.
// distance is only computed for still-unsampled stems. A record
// the frame budget queued is sampled by the frame the queue asks
// for, with no timer.
if (
canSampleGround &&
!record.groundSampled &&
!record.groundSampleQueued &&
!groundRetryPending &&
Cesium.Cartesian3.distance(
viewer.camera.positionWC,
record.base,
) < GROUND_SAMPLE_MAX_DISTANCE_M &&
groundSampleInView(viewer, record)
groundSampleInView(viewer, record, _ground)
) {
groundRetryPending = true;
}
@@ -1078,6 +1159,7 @@ export function createLocalGeoJsonLayer(
// so the records still waiting get their own bounded run of retries.
if (groundSampleProgress) _groundRetryArms = 0;
if (groundRetryPending) scheduleGroundRetryRender(viewer);
requestQueuedGroundFrame();
const canvas = viewer.scene.canvas;
const cohort = selectLocalInfrastructureOverlayCohort(
@@ -1268,7 +1350,7 @@ function insertLocalCellContender(contenders, record) {
contenders.length = LOCAL_OVERLAY_CELL_SURPLUS;
}
function refreshLocalTerrainFloor(viewer, record) {
function refreshLocalTerrainFloor(viewer, record, ground) {
const globe = viewer.scene.globe;
if (!record.groundSampled || !globe?.show || globe.tilesLoaded === false)
return false;
@@ -1280,7 +1362,15 @@ function refreshLocalTerrainFloor(viewer, record) {
// Camera motion can refine terrain after the first successful depth sample.
// Reuse the loaded mesh on existing bounded walks; never arm a timer or do
// another GPU readback just to maintain this floor. Keep roof elevations.
const height = globe.getHeight?.(record.carto);
// The surface keeps the height per point and casts the ray again only after
// the camera moves or tiles load. MARKER: the answer is this point's own
// height, never a neighbour's, since a height taken here is kept for good.
const height = ground.terrain.height(
viewer,
record.latDeg,
record.lonDeg,
TERRAIN_NEED.MARKER,
);
if (
!Number.isFinite(height) ||
Math.abs(height) > GROUND_SAMPLE_MAX_ABS_HEIGHT_M ||
@@ -1323,11 +1413,20 @@ function groundSamplePoint(record, height, result) {
* sample can accept, so high or below-ellipsoid ground in view is never
* mistaken for an unseen base. Unseen ground is sampled on the first walk that
* sees it.
*
* The terrain height comes from the shared surface, which keeps it per point:
* this runs for every unsampled site in range on every walk, and a ray cast
* each time was 17,733 casts and 1.3 s over one zoom. The view test needs the
* height only roughly, so a height the surface has not read yet, or tiles
* still loading, count as unknown ground.
*/
function groundSampleInView(viewer, record) {
function groundSampleInView(viewer, record, ground) {
const camera = viewer.camera;
const globe = viewer.scene.globe;
const terrain = globe?.show ? globe.getHeight?.(record.carto) : undefined;
const terrain =
globe?.show && globe.tilesLoaded !== false
? ground.terrain.height(viewer, record.latDeg, record.lonDeg)
: undefined;
const known =
Number.isFinite(terrain) &&
Math.abs(terrain) <= GROUND_SAMPLE_MAX_ABS_HEIGHT_M;
@@ -1368,18 +1467,30 @@ function groundSampleInView(viewer, record) {
);
}
function sampleLocalGroundHeight(viewer, record, now) {
function sampleLocalGroundHeight(viewer, record, now, ground) {
if (record.groundSampled || !viewer.scene.sampleHeightSupported) return false;
if (now - record.lastGroundSampleMs < GROUND_SAMPLE_RETRY_MS) return false;
if (!groundSampleInView(viewer, record)) return false;
// This frame's samples have used its budget: leave the record for the next
// frame, unattempted, so the retry spacing does not hold it back.
if (ground.sampleMs >= GROUND_SAMPLE_FRAME_BUDGET_MS) {
if (!record.groundSampleQueued) {
record.groundSampleQueued = true;
ground.queued.push(record);
}
return false;
}
if (!groundSampleInView(viewer, record, ground)) return false;
record.lastGroundSampleMs = now;
const globe = viewer.scene.globe;
if (globe?.show && globe.tilesLoaded === false) return false;
let sampled;
const startedAt = performance.now();
try {
sampled = viewer.scene.sampleHeight(record.carto, [record.entity]);
} catch {
return false; // tiles not ready; retry on a later bounded update
} finally {
ground.sampleMs += performance.now() - startedAt;
}
if (
!Number.isFinite(sampled) ||
@@ -1389,7 +1500,9 @@ function sampleLocalGroundHeight(viewer, record, now) {
// A coarse scene-depth sample can lie below the terrain already loaded by
// the visible globe. Keep that terrain as a floor while allowing roofs and
// valid below-sea-level elevations. Photoreal scenes hide the globe, so its
// inactive terrain must not constrain their geometry.
// inactive terrain must not constrain their geometry. Read from the globe
// itself, once per site: the floor under a sample must be the mesh that is
// loaded now, not a height kept from a moment before.
const terrainHeight = globe?.show
? globe.getHeight?.(record.carto)
: undefined;
@@ -1416,12 +1529,18 @@ function setLocalGroundHeight(record, height) {
record.entity.__localBaseCartesian = record.base;
}
function updateLocalStemGeometry(viewer, record, now, knownDistance = null) {
function updateLocalStemGeometry(
viewer,
record,
now,
ground,
knownDistance = null,
) {
const distance = Number.isFinite(knownDistance)
? knownDistance
: Cesium.Cartesian3.distance(viewer.camera.positionWC, record.base);
if (distance < GROUND_SAMPLE_MAX_DISTANCE_M)
sampleLocalGroundHeight(viewer, record, now);
sampleLocalGroundHeight(viewer, record, now, ground);
// Keep the intended screen-size scaling in close-up views too. A 5 km
// minimum made a marker hundreds of metres tall beside a nearby building.
const effectiveDistance = Math.max(distance, 1);
-135
View File
@@ -1,135 +0,0 @@
/**
* @module openVesselPhoto
* @description Reads an Open Vessel Photos answer into the one photo every
* surface shows. Open Vessel Photos (https://openvesselphotos.org) serves a
* free, keyless JSON and image API by MMSI. Every photo keeps the open licence
* its author chose (CC BY, CC BY-SA, CC0 or public domain), so it is shown
* with its credit and licence and links back to the vessel's page. A photo
* whose image is not on Open Vessel Photos' own host, or whose answer names
* no licence, is "no photo": nothing arbitrary is ever shown or linked.
*
* Accepts the upstream answer (`GET https://openvesselphotos.org/v1/mmsi/{mmsi}`,
* `{mmsi, photos: [...]}`) and an already-read photo
* (`{found, src, width, height, photographer, licence, link}`).
* Pure: no fetch, no DOM.
*/
/** The only host a photo may come from or link to. */
const HOST = 'openvesselphotos.org';
/** The API's per-vessel answer. */
const API = `https://${HOST}/v1/mmsi/`;
/** The image width asked for: sharp in the panel on a 2× screen, ~60 KB. */
const IMAGE_SIZE = '800';
const NO_PHOTO = Object.freeze({ found: false });
/** A vessel's MMSI: nine digits. */
export const MMSI = /^\d{9}$/;
/** The API answer for one MMSI. */
export function openVesselPhotosUrl(mmsi) {
return API + mmsi;
}
/** The vessel's page on Open Vessel Photos. */
function vesselPage(mmsi) {
return `https://${HOST}/vessels/mmsi/${mmsi}`;
}
/** The parsed `https:` URL on the Open Vessel Photos host, or null. */
function ownUrl(value) {
if (typeof value !== 'string' || !value) return null;
let url;
try {
url = new URL(value);
} catch {
return null;
}
return url.protocol === 'https:' &&
!url.username &&
!url.password &&
!url.port &&
url.hostname === HOST
? url
: null;
}
function dimension(value) {
const number = Number(value);
return value != null && Number.isFinite(number) && number > 0
? Math.round(number)
: null;
}
function text(value) {
return typeof value === 'string' ? value.trim() : '';
}
/** One upstream photo of the vessel `mmsi`, checked. */
function checkedUpstream(photo, mmsi) {
if (!photo || typeof photo !== 'object') return null;
const image = ownUrl(photo.urls?.mmsi);
const licence = text(photo.licenceName) || text(photo.licence);
if (!image || !licence) return null;
image.search = '';
image.hash = '';
image.searchParams.set('size', IMAGE_SIZE);
// The answer gives the full image's size; the 800 px one keeps its shape.
const width = dimension(photo.width);
const height = dimension(photo.height);
const scale =
width && width > Number(IMAGE_SIZE) ? Number(IMAGE_SIZE) / width : 1;
return {
found: true,
src: image.href,
width: width ? Math.round(width * scale) : null,
height: height ? Math.round(height * scale) : null,
photographer: text(photo.credit),
licence,
link: vesselPage(mmsi),
};
}
/** An already-read photo, checked again. */
function checkedRead(photo) {
const image = ownUrl(photo.src);
const page = ownUrl(photo.link);
const licence = text(photo.licence);
if (!image || !page || !licence) return null;
return {
found: true,
src: image.href,
width: dimension(photo.width),
height: dimension(photo.height),
photographer: text(photo.photographer),
licence,
link: page.href,
};
}
/**
* Read an Open Vessel Photos answer into the first photo that passes the
* checks.
* @param {*} payload Upstream answer or already-read photo.
* @param {?string} [requestedMmsi] The MMSI asked for; an upstream answer
* naming another is no photo.
* @returns {{found: true, src: string, width: ?number, height: ?number,
* photographer: string, licence: string, link: string} | {found: false}}
*/
export function parseOpenVesselPhoto(payload, requestedMmsi = null) {
if (!payload || typeof payload !== 'object') return NO_PHOTO;
if (Array.isArray(payload.photos)) {
const mmsi = String(payload.mmsi ?? '');
if (!MMSI.test(mmsi)) return NO_PHOTO;
// An answer about another vessel is no photo of this one.
if (requestedMmsi != null && mmsi !== String(requestedMmsi))
return NO_PHOTO;
for (const photo of payload.photos) {
const checked = checkedUpstream(photo, mmsi);
if (checked) return checked;
}
return NO_PHOTO;
}
if (payload.found !== true) return NO_PHOTO;
return checkedRead(payload) || NO_PHOTO;
}
-120
View File
@@ -1,120 +0,0 @@
// src/data/openVesselPhoto.test.mjs — an Open Vessel Photos answer is read
// into one credited, licensed photo, and nothing outside Open Vessel Photos'
// own host is ever shown or linked.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
openVesselPhotosUrl,
parseOpenVesselPhoto,
} from './openVesselPhoto.js';
const UPSTREAM = Object.freeze({
mmsi: '366999709',
name: '01 USCGC Sockeye WPB87337',
photos: [
{
n: 1,
urls: {
cfr: null,
mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg',
},
credit: 'Gordon Leggett',
licence: 'CC-BY-4.0',
licenceName: 'CC BY 4.0',
width: 1600,
height: 1200,
},
],
});
const PHOTO = Object.freeze({
found: true,
src: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg?size=800',
width: 800,
height: 600,
photographer: 'Gordon Leggett',
licence: 'CC BY 4.0',
link: 'https://openvesselphotos.org/vessels/mmsi/366999709',
});
const withPhoto = (overrides) => ({
...UPSTREAM,
photos: [{ ...UPSTREAM.photos[0], ...overrides }],
});
test('the upstream answer maps to the 800 px image, its credit, licence and page', () => {
assert.deepEqual(parseOpenVesselPhoto(UPSTREAM), PHOTO);
assert.equal(
openVesselPhotosUrl('366999709'),
'https://openvesselphotos.org/v1/mmsi/366999709',
);
});
test('an image narrower than 800 px keeps its own size', () => {
const photo = parseOpenVesselPhoto(withPhoto({ width: 640, height: 480 }));
assert.equal(photo.width, 640);
assert.equal(photo.height, 480);
});
test('an already-read photo passes through its checks again', () => {
assert.deepEqual(parseOpenVesselPhoto(PHOTO), PHOTO);
assert.deepEqual(parseOpenVesselPhoto({ ...PHOTO, licence: '' }), {
found: false,
});
});
test('a photo off the Open Vessel Photos host, or without a licence, is no photo', () => {
for (const overrides of [
{ urls: { mmsi: 'https://example.com/1.jpg' } },
{
urls: {
mmsi: 'http://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg',
},
},
{ urls: { mmsi: 'https://openvesselphotos.org.evil.com/1.jpg' } },
{ urls: { mmsi: 'https://[email protected]/1.jpg' } },
{ licence: '', licenceName: '' },
])
assert.deepEqual(parseOpenVesselPhoto(withPhoto(overrides)), {
found: false,
});
assert.deepEqual(
parseOpenVesselPhoto({ ...PHOTO, link: 'https://example.com/' }),
{ found: false },
);
});
test('a vessel without photos, or an answer with a bad MMSI, is no photo', () => {
assert.deepEqual(parseOpenVesselPhoto({ ...UPSTREAM, photos: [] }), {
found: false,
});
assert.deepEqual(parseOpenVesselPhoto({ ...UPSTREAM, mmsi: '12/../x' }), {
found: false,
});
assert.deepEqual(parseOpenVesselPhoto(null), { found: false });
});
test('the next photo stands in when the first fails its checks', () => {
const second = {
...UPSTREAM.photos[0],
n: 2,
urls: {
mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/2.jpg',
},
};
const photo = parseOpenVesselPhoto({
...UPSTREAM,
photos: [{ ...UPSTREAM.photos[0], licenceName: '', licence: '' }, second],
});
assert.equal(
photo.src,
'https://openvesselphotos.org/v1/mmsi/366999709/photos/2.jpg?size=800',
);
});
test('an answer about another vessel than the one asked for is no photo', () => {
assert.deepEqual(parseOpenVesselPhoto(UPSTREAM, '366999709'), PHOTO);
assert.deepEqual(parseOpenVesselPhoto(UPSTREAM, '227000101'), {
found: false,
});
});
-2
View File
@@ -3804,8 +3804,6 @@
"(+{minutes} min late)": "(تأخير {minutes} د)",
"Exit cockpit to fly to a train": "اخرج من القمرة للانتقال إلى قطار",
"Photo © {photographer}": "صورة © {photographer}",
"Photo: {photographer} · {licence}": "صورة: {photographer} · {licence}",
"Photo · {licence}": "صورة · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
"The shared flight isn't reporting now — showing where it was at {time}": "الرحلة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
-2
View File
@@ -3480,8 +3480,6 @@
"(+{minutes} min late)": "(+{minutes} min Verspätung)",
"Exit cockpit to fly to a train": "Cockpit verlassen, um zu einem Zug zu fliegen",
"Photo © {photographer}": "Foto © {photographer}",
"Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}",
"Photo · {licence}": "Foto · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "Der geteilte Flug sendet gerade nicht – letzte bekannte Position wird gezeigt",
"The shared flight isn't reporting now — showing where it was at {time}": "Der geteilte Flug sendet gerade nicht – Position um {time} wird gezeigt",
"The shared military flight isn't reporting now — showing where it was last seen": "Der geteilte Militärflug sendet gerade nicht – letzte bekannte Position wird gezeigt",
-2
View File
@@ -3561,8 +3561,6 @@
"(+{minutes} min late)": "(+{minutes} min de retraso)",
"Exit cockpit to fly to a train": "Sal de la cabina para volar a un tren",
"Photo © {photographer}": "Foto © {photographer}",
"Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}",
"Photo · {licence}": "Foto · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
"The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
-2
View File
@@ -3561,8 +3561,6 @@
"(+{minutes} min late)": "(+{minutes} min de retard)",
"Exit cockpit to fly to a train": "Quittez le cockpit pour rejoindre un train",
"Photo © {photographer}": "Photo © {photographer}",
"Photo: {photographer} · {licence}": "Photo : {photographer} · {licence}",
"Photo · {licence}": "Photo · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue",
"The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "Le vol militaire partagé n'émet pas actuellement — affichage de sa dernière position connue",
-2
View File
@@ -3385,8 +3385,6 @@
"(+{minutes} min late)": "(+{minutes} דק׳ איחור)",
"Exit cockpit to fly to a train": "צא מתא הטייס כדי לטוס לרכבת",
"Photo © {photographer}": "צילום © {photographer}",
"Photo: {photographer} · {licence}": "צילום: {photographer} · {licence}",
"Photo · {licence}": "צילום · {licence}",
"1 h/s": "שעה/שנ׳",
"1 d/s": "יום/שנ׳",
"1 wk/s": "שבוע/שנ׳",
-2
View File
@@ -3561,8 +3561,6 @@
"(+{minutes} min late)": "(+{minutes} min di ritardo)",
"Exit cockpit to fly to a train": "Esci dal cockpit per volare a un treno",
"Photo © {photographer}": "Foto © {photographer}",
"Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}",
"Photo · {licence}": "Foto · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
"The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
-2
View File
@@ -3399,8 +3399,6 @@
"(+{minutes} min late)": "({minutes}分遅れ)",
"Exit cockpit to fly to a train": "列車へ移動するにはコックピットを終了してください",
"Photo © {photographer}": "写真 © {photographer}",
"Photo: {photographer} · {licence}": "写真:{photographer} · {licence}",
"Photo · {licence}": "写真 · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "共有されたフライトは現在報告していません。最後に確認された位置を表示しています",
"The shared flight isn't reporting now — showing where it was at {time}": "共有されたフライトは現在報告していません。{time}時点の位置を表示しています",
"The shared military flight isn't reporting now — showing where it was last seen": "共有された軍用機は現在報告していません。最後に確認された位置を表示しています",
-2
View File
@@ -3561,8 +3561,6 @@
"(+{minutes} min late)": "(+{minutes} min de atraso)",
"Exit cockpit to fly to a train": "Saia da cabine para voar até um trem",
"Photo © {photographer}": "Foto © {photographer}",
"Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}",
"Photo · {licence}": "Foto · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida",
"The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "O voo militar compartilhado não está transmitindo agora — mostrando a última posição conhecida",
-2
View File
@@ -3642,8 +3642,6 @@
"(+{minutes} min late)": "(+{minutes} мин опоздания)",
"Exit cockpit to fly to a train": "Выйдите из кабины, чтобы перелететь к поезду",
"Photo © {photographer}": "Фото © {photographer}",
"Photo: {photographer} · {licence}": "Фото: {photographer} · {licence}",
"Photo · {licence}": "Фото · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "Общий рейс сейчас не передаёт данные — показано последнее известное место",
"The shared flight isn't reporting now — showing where it was at {time}": "Общий рейс сейчас не передаёт данные — показано место в {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "Общий военный рейс сейчас не передаёт данные — показано последнее известное место",
-2
View File
@@ -3472,8 +3472,6 @@
"(+{minutes} min late)": "(+{minutes} min försenad)",
"Exit cockpit to fly to a train": "Lämna cockpiten för att flyga till ett tåg",
"Photo © {photographer}": "Foto © {photographer}",
"Photo: {photographer} · {licence}": "Foto: {photographer} · {licence}",
"Photo · {licence}": "Foto · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "Den delade flygningen rapporterar inte just nu – visar senast kända position",
"The shared flight isn't reporting now — showing where it was at {time}": "Den delade flygningen rapporterar inte just nu – visar positionen från {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "Den delade militärflygningen rapporterar inte just nu – visar senast kända position",
-2
View File
@@ -3642,8 +3642,6 @@
"(+{minutes} min late)": "(+{minutes} хв запізнення)",
"Exit cockpit to fly to a train": "Вийдіть із кабіни, щоб перелетіти до поїзда",
"Photo © {photographer}": "Фото © {photographer}",
"Photo: {photographer} · {licence}": "Фото: {photographer} · {licence}",
"Photo · {licence}": "Фото · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "Спільний рейс зараз не передає даних — показано останнє відоме місце",
"The shared flight isn't reporting now — showing where it was at {time}": "Спільний рейс зараз не передає даних — показано місце о {time}",
"The shared military flight isn't reporting now — showing where it was last seen": "Спільний військовий рейс зараз не передає даних — показано останнє відоме місце",
-2
View File
@@ -3399,8 +3399,6 @@
"(+{minutes} min late)": "(晚点 {minutes} 分钟)",
"Exit cockpit to fly to a train": "请先退出座舱,再飞往列车",
"Photo © {photographer}": "照片 © {photographer}",
"Photo: {photographer} · {licence}": "照片:{photographer} · {licence}",
"Photo · {licence}": "照片 · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前没有发送信号——显示其最后出现的位置",
"The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前没有发送信号——显示其在 {time} 的位置",
"The shared military flight isn't reporting now — showing where it was last seen": "共享的军用航班目前没有发送信号——显示其最后出现的位置",
-2
View File
@@ -3399,8 +3399,6 @@
"(+{minutes} min late)": "(誤點 {minutes} 分鐘)",
"Exit cockpit to fly to a train": "請先退出座艙,再飛往列車",
"Photo © {photographer}": "照片 © {photographer}",
"Photo: {photographer} · {licence}": "照片:{photographer} · {licence}",
"Photo · {licence}": "照片 · {licence}",
"The shared flight isn't reporting now — showing where it was last seen": "共享的航班目前沒有發送信號——顯示其最後出現的位置",
"The shared flight isn't reporting now — showing where it was at {time}": "共享的航班目前沒有發送信號——顯示其在 {time} 的位置",
"The shared military flight isn't reporting now — showing where it was last seen": "共享的軍用航班目前沒有發送信號——顯示其最後出現的位置",
-81
View File
@@ -1,81 +0,0 @@
/**
* @module i18n/page
* @description The page's interface language, for an application that
* composes Core and offers its own language choice (a menu item, a settings
* page) instead of the globe picker. It starts the language the standalone
* application starts, keeps `<html lang>`/`<html dir>` and the page's
* `data-i18n` markup in step, and switches in place, stored for the next
* visit, exactly as the picker does. Guide: docs/I18N.md.
*/
import {
DEFAULT_LOCALE,
LOCALES,
LOCALE_NAMES,
applyDocumentLanguage,
isRtlLocale,
normalizeLocale,
resolveLocale,
} from './locale.js';
import {
applyDocumentTranslations,
getLocale,
onLocaleChange,
persistLocale,
setLocale,
} from './index.js';
export {
DEFAULT_LOCALE,
LOCALES,
LOCALE_NAMES,
applyDocumentTranslations,
getLocale,
isRtlLocale,
onLocaleChange,
};
/**
* Start the page in its language: `?lang=`, then the stored choice, then the
* browser's languages, then English. Call it before any view renders, so
* every lookup and `<html lang>`/`<html dir>` agree from the first frame; the
* page's static `data-i18n` markup (a loading screen) is translated now.
* @param {Document} [doc]
* @param {Parameters<typeof resolveLocale>[0]} [inputs] Location, storage
* and languages, for tests.
* @returns {string} The active locale.
*/
export function startPageLocale(doc = globalThis.document, inputs) {
const locale = setLocale(resolveLocale(inputs));
applyDocumentLanguage(doc, locale);
applyDocumentTranslations(doc);
return locale;
}
/**
* Keep `<html lang>`/`<html dir>` and the page's `data-i18n` markup in step
* with every language switch.
* @param {Document} [doc]
* @returns {() => void} Stops following.
*/
export function followPageLocale(doc = globalThis.document) {
return onLocaleChange(() => {
applyDocumentLanguage(doc, getLocale());
applyDocumentTranslations(doc);
});
}
/**
* Switch the page to `locale` in place and keep it for the next visit. The
* active language, or an unknown code that would fall back to it, changes
* nothing.
* @param {string} locale One of LOCALES.
* @param {Parameters<typeof persistLocale>[1]} [options] Location, history
* and storage, for tests.
* @returns {'unchanged'|'switched'}
*/
export function switchLocale(locale, options) {
const next = normalizeLocale(locale) || DEFAULT_LOCALE;
if (next === getLocale()) return 'unchanged';
setLocale(persistLocale(next, options));
return 'switched';
}
-106
View File
@@ -1,106 +0,0 @@
// src/i18n/page.test.mjs — an application with its own language choice starts,
// follows and switches the page's language as the globe picker does.
import { afterEach, test } from 'node:test';
import assert from 'node:assert/strict';
import { LOCALE_STORAGE_KEY } from './locale.js';
import {
followPageLocale,
getLocale,
startPageLocale,
switchLocale,
} from './page.js';
import { setLocale } from './index.js';
afterEach(() => setLocale('en'));
function memoryStorage() {
const data = new Map();
return {
data,
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => data.set(key, String(value)),
};
}
function page(elements = []) {
return {
documentElement: { lang: '', dir: '' },
querySelectorAll: (selector) =>
selector === '[data-i18n]' ? elements : [],
};
}
/** A data-i18n element, as applyDocumentTranslations reads one. */
function marked(text) {
return {
textContent: text,
hasAttribute: (name) => name === 'data-i18n',
getAttribute: () => null,
};
}
test('the page starts in ?lang=, else the stored choice, with lang and dir set', () => {
const doc = page();
const storage = memoryStorage();
storage.setItem(LOCALE_STORAGE_KEY, 'de');
assert.equal(
startPageLocale(doc, {
location: { search: '?lang=ar' },
storage,
languages: ['ru'],
}),
'ar',
);
assert.equal(getLocale(), 'ar');
assert.deepEqual(doc.documentElement, { lang: 'ar', dir: 'rtl' });
assert.equal(
startPageLocale(doc, { location: { search: '' }, storage, languages: [] }),
'de',
);
assert.deepEqual(doc.documentElement, { lang: 'de', dir: 'ltr' });
});
test("the page's data-i18n markup is in its language from the start", () => {
const close = marked('Close');
startPageLocale(page([close]), {
location: { search: '?lang=de' },
storage: memoryStorage(),
languages: [],
});
assert.equal(close.textContent, 'Schließen');
});
test('a switch is stored, drops ?lang, and the followed page takes it', () => {
const doc = page();
const stop = followPageLocale(doc);
const storage = memoryStorage();
const replaced = [];
const options = {
location: { href: 'https://example.test/?lang=fr#view' },
history: { replaceState: (_s, _t, url) => replaced.push(url) },
storage,
};
assert.equal(switchLocale('he', options), 'switched');
assert.equal(getLocale(), 'he');
assert.equal(storage.data.get(LOCALE_STORAGE_KEY), 'he');
assert.deepEqual(replaced, ['https://example.test/#view']);
assert.deepEqual(doc.documentElement, { lang: 'he', dir: 'rtl' });
stop();
switchLocale('ja', options);
assert.deepEqual(doc.documentElement, { lang: 'he', dir: 'rtl' });
});
test('choosing the active language, or one that falls back to it, changes nothing', () => {
const storage = memoryStorage();
const options = {
location: { href: 'https://example.test/' },
history: { replaceState() {} },
storage,
};
assert.equal(switchLocale('en', options), 'unchanged');
assert.equal(switchLocale('xx', options), 'unchanged');
assert.equal(storage.data.size, 0);
assert.equal(switchLocale('pt-BR', options), 'switched');
assert.equal(getLocale(), 'pt');
assert.equal(switchLocale('pt', options), 'unchanged');
});
-1
View File
@@ -625,7 +625,6 @@
"▲ {frp} MW",
"SATELLITE",
"Photo © {photographer}",
"Photo · {licence}",
"PACK",
"{count} packs",
"Orthophoto",
+5 -17
View File
@@ -182,22 +182,7 @@ export function createPanel({ state: layerState, parts }) {
: null;
}
/** The credit line: the photographer, and an open photo's licence. A
* photo whose answer names no photographer shows no photographer. */
function photoCredit(photo) {
if (photo.licence)
return photo.photographer
? t('Photo: {photographer} · {licence}', {
photographer: photo.photographer,
licence: photo.licence,
})
: t('Photo · {licence}', { licence: photo.licence });
return photo.photographer
? t('Photo © {photographer}', { photographer: photo.photographer })
: '';
}
/** The subject's photo with its credit, or null. The subject
/** The tracked airliner's photo with its credit, or null. The subject
* refresh resolves it; only the subject it was resolved for shows it. */
function presentPhoto(subject) {
const held = layerState.subjectPhoto;
@@ -213,7 +198,10 @@ export function createPanel({ state: layerState, parts }) {
width: photo.width ?? null,
height: photo.height ?? null,
link: photo.link,
credit: photoCredit(photo),
// A photo whose answer names no photographer shows no credit line.
credit: photo.photographer
? t('Photo © {photographer}', { photographer: photo.photographer })
: '',
};
}
+1 -46
View File
@@ -113,52 +113,7 @@ test('the Contacts view has no photo without one, or with another subject’s',
);
});
test('the selected vessel shows its own photo, by its id', () => {
const asked = [];
const { resolveSubjectPhoto, subjectFromContext } = createSubject({
state: {},
services: {
flights: {},
military: {},
vessels: {
getVesselPhoto: (id) => {
asked.push(id);
return id === 'uuid-1' ? PHOTO : null;
},
},
installations: {},
},
parts: { navigation: { isFlightLayer: () => false } },
});
assert.deepEqual(
resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'uuid-1' }),
PHOTO,
);
assert.equal(
resolveSubjectPhoto({ layerId: 'ais-live-vessels', id: 'uuid-2' }),
null,
);
assert.deepEqual(asked, ['uuid-1', 'uuid-2']);
const vessel = (label, mmsi) =>
subjectFromContext({
layerId: 'ais-live-vessels',
id: 'ais-uuid-1',
label,
latitude: 38.3,
longitude: -123.05,
properties: { vesselId: 'uuid-1', mmsi },
});
assert.equal(
vessel('USCGC SOCKEYE', '366999709').label,
'USCGC SOCKEYE · MMSI 366999709',
);
assert.equal(vessel('USCGC SOCKEYE', '366999709').id, 'uuid-1');
// A vessel named by its MMSI, or without one, is not given it twice.
assert.equal(vessel('MMSI 366999709', '366999709').label, 'MMSI 366999709');
assert.equal(vessel('USCGC SOCKEYE', null).label, 'USCGC SOCKEYE');
});
test('only the tracked flight has a flight photo: not an installation, a military aircraft or another flight', () => {
test('only the tracked flight has a photo: not a vessel, an installation, a military aircraft or another flight', () => {
const tracked = {
layerId: 'flights',
id: 'abc123',
+6 -14
View File
@@ -406,20 +406,17 @@ export function createSubject({ state: layerState, services, parts, source }) {
}
/**
* The subject's photo, for the Contacts panel: the tracked airliner's
* Planespotters photo, or the selected vessel's Open Vessel Photos photo.
* The tracked airliner's Planespotters photo, for the Contacts panel.
*
* The flights layer looks photos up only for the plane it tracks, the
* vessel layer only for the vessel selected; an installation, a military
* aircraft or an untracked flight has none.
* Only the flights layer looks photos up, and only for the plane it tracks;
* a vessel, an installation, a military aircraft or an untracked flight has
* none.
* @param {{layerId: string, id: string}|null} subject Current subject.
* @param {?object} [tracked] The subject's tracked descriptor, when the
* caller already has it.
* @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string, licence?: string}}
* @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string}}
*/
function resolveSubjectPhoto(subject, tracked = trackedSubjectOf(subject)) {
if (subject?.layerId === 'ais-live-vessels')
return aisLiveVesselsLayer.getVesselPhoto?.(subject.id) ?? null;
return subject?.layerId === 'flights' && tracked?.photo
? tracked.photo
: null;
@@ -520,15 +517,10 @@ export function createSubject({ state: layerState, services, parts, source }) {
const latitude = Number(record.latitude);
const longitude = Number(record.longitude);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
// A vessel reads as its name and MMSI; a vessel named by its MMSI alone
// already carries it.
const label = String(record.label || record.id);
const mmsi = String(record.properties?.mmsi || '');
return {
layerId: record.layerId,
id: record.properties?.vesselId || record.id,
// i18n-ignore: MMSI is a radio identity code, as on the vessel card
label: mmsi && !label.includes(mmsi) ? `${label} · MMSI ${mmsi}` : label,
label: record.label || record.id,
memberNames: record.properties?.memberNames || [],
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
};
@@ -0,0 +1,331 @@
// Station ground heights. A city's stations are all drawn at once at the
// fallback height; their ground heights are found afterwards a few
// milliseconds at a time, so a city of hundreds of docks never holds the main
// thread for the length of hundreds of depth renders. Each case drives the
// real rendering part (and the real lifecycle where it tears down) over a
// scene whose height reads are counted and timed.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createState } from './state.js';
import { createModel } from './model.js';
import { createRendering } from './rendering.js';
import { createLifecycle } from './lifecycle.js';
import { POINT_HEIGHT_OFFSET_M } from './policy.js';
import { createTerrainSurface } from '../../services/terrainSurface.js';
globalThis.document ??= {
addEventListener() {},
removeEventListener() {},
};
/** Milliseconds one fake depth render holds the thread. */
const SAMPLE_COST_MS = 1;
/** The ground under a point, as the fake scene and globe both report it. */
const groundAt = (lat, lon) => 100 + (lat - 42) * 1000 + (lon + 71) * 10;
function busyWait(ms) {
const until = performance.now() + ms;
while (performance.now() < until);
}
/** `count` stations on a line through Boston. */
function stations(count, prefix = 's') {
return {
data: {
stations: Array.from({ length: count }, (_, i) => ({
station_id: `${prefix}${i}`,
name: `Dock ${i}`,
lat: 42.3 + i * 1e-4,
lon: -71.1 + i * 1e-4,
capacity: 10,
})),
},
};
}
/**
* The rendering part over a scene that counts its height reads.
* @param {{terrainGlobe?: boolean, shared?: boolean, unloaded?: (lat: number) => boolean}} options
* `terrainGlobe`: the globe is showing (else photorealistic tiles hide it);
* `shared`: the application's terrain surface is supplied;
* `unloaded`: latitudes whose terrain tile is not drawn.
*/
function makeLayer({
terrainGlobe = false,
shared = false,
unloaded = () => false,
} = {}) {
const calls = { sampleHeight: 0, getHeight: 0, sharedHeight: 0, render: 0 };
// sampleHeight calls in this turn of the event loop, and the most in any
// turn so far. Two numbers, not a list: a slow run has a great many turns.
let thisTurn = 0;
let mostInATurn = 0;
let ticking = true;
const tick = () => {
if (!ticking) return;
thisTurn = 0;
setImmediate(tick);
};
setImmediate(tick);
const degrees = (carto) => [
Cesium.Math.toDegrees(carto.latitude),
Cesium.Math.toDegrees(carto.longitude),
];
const viewer = {
camera: {
positionCartographic: Cesium.Cartographic.fromDegrees(-71, 42.3, 12000),
changed: { addEventListener() {}, removeEventListener() {} },
},
scene: {
sampleHeightSupported: true,
sampleHeight(carto) {
calls.sampleHeight += 1;
thisTurn += 1;
if (thisTurn > mostInATurn) mostInATurn = thisTurn;
busyWait(SAMPLE_COST_MS);
return groundAt(...degrees(carto));
},
globe: {
show: terrainGlobe,
tilesLoaded: true,
getHeight(carto) {
calls.getHeight += 1;
const [lat, lon] = degrees(carto);
return unloaded(lat) ? undefined : groundAt(lat, lon);
},
},
primitives: { add() {}, remove() {} },
},
};
const surface = createTerrainSurface();
const services = {
render: {
governorRequestRender() {
calls.render += 1;
},
},
overlays: {
setOverlayEntries() {},
setOverlaySourceVisible() {},
clearOverlaySource() {},
},
sprites: { registerSpriteCollection() {}, restoreSpriteOrder() {} },
picking: { registerPickOwner() {}, unregisterPickOwner() {} },
};
if (shared) {
services.ground = {
terrainSurface: {
...surface,
height(...args) {
calls.sharedHeight += 1;
return surface.height(...args);
},
},
};
}
const state = createState({ services });
const parts = {};
const context = { state, services, parts, source: {} };
parts.model = createModel(context);
parts.rendering = createRendering(context);
parts.selection = {
_installClickHandler() {},
_clearSelection() {},
_onKeyDown() {},
};
parts.ingestion = { abortAllInFlight() {} };
// Leaves the points alone, so a test of disable() sees what disable() stops.
parts.viewport = {
onCameraChanged() {},
deactivateAllCities() {},
runProximityCheck: async () => {},
};
parts.lifecycle = createLifecycle(context);
parts.lifecycle.methods.init(viewer);
state._pointCollection = {
show: true,
add: (options) => ({ ...options }),
remove() {},
};
const heightOf = (key) =>
Cesium.Cartographic.fromCartesian(
state._stationRenderMap.get(key).point.position,
).height;
return {
state,
parts,
viewer,
calls,
rendering: parts.rendering,
lifecycle: parts.lifecycle.methods,
heightOf,
add(cityId, count) {
const info = parts.model.parseStationInformation(stations(count, cityId));
parts.rendering.ensureCityPoints(cityId, info);
return info;
},
/** Wait until every queued station has its height, or fail. */
async refined() {
const deadline = performance.now() + 20_000;
while (parts.rendering.pendingHeightCount() > 0) {
assert.ok(performance.now() < deadline, 'refinement finished');
await new Promise((resolve) => setTimeout(resolve, 5));
}
},
/** The most sampleHeight calls made without yielding to the loop. */
longestBurst() {
return mostInATurn;
},
done() {
ticking = false;
parts.rendering.stopHeightRefinement();
},
};
}
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
test('a city of stations is drawn at once without sampling heights', (t) => {
const layer = makeLayer();
t.after(layer.done);
const started = performance.now();
layer.add('boston', 300);
const spent = performance.now() - started;
assert.equal(layer.state._stationRenderMap.size, 300, 'every station drawn');
assert.equal(layer.calls.sampleHeight, 0, 'no depth render while drawing');
assert.ok(spent < 100, `drawing took ${spent.toFixed(1)} ms`);
assert.ok(
Math.abs(layer.heightOf('boston:boston0') - POINT_HEIGHT_OFFSET_M) < 1e-3,
'drawn at the fallback height',
);
assert.equal(layer.rendering.pendingHeightCount(), 300);
});
test('heights arrive afterwards in short steps and match a direct sample', async (t) => {
const layer = makeLayer();
t.after(layer.done);
const info = layer.add('boston', 120);
const rendersBefore = layer.calls.render;
await layer.refined();
assert.equal(layer.calls.sampleHeight, 120, 'one sample per station');
// 120 ms of samples in one go is the stall; a step is a few milliseconds.
assert.ok(
layer.longestBurst() <= 10,
`longest burst was ${layer.longestBurst()} samples`,
);
for (const station of info.values()) {
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
const actual = layer.heightOf(`boston:${station.stationId}`);
assert.ok(
Math.abs(actual - expected) < 1e-3,
`${station.stationId}: ${actual} vs ${expected}`,
);
}
assert.ok(layer.calls.render > rendersBefore, 'moved points ask for a frame');
});
test('on a terrain globe the shared terrain surface answers, not a depth render', async (t) => {
const layer = makeLayer({ terrainGlobe: true, shared: true });
t.after(layer.done);
const info = layer.add('boston', 200);
assert.equal(layer.calls.getHeight, 0, 'nothing read while drawing');
await layer.refined();
assert.equal(layer.calls.sampleHeight, 0);
assert.equal(layer.calls.sharedHeight, 200, 'asked the shared surface');
assert.equal(layer.calls.getHeight, 200);
for (const station of info.values()) {
const expected = groundAt(station.lat, station.lon) + POINT_HEIGHT_OFFSET_M;
const actual = layer.heightOf(`boston:${station.stationId}`);
assert.ok(Math.abs(actual - expected) < 1e-3, station.stationId);
}
});
test('without a shared surface the layer reads the terrain globe itself', async (t) => {
const layer = makeLayer({ terrainGlobe: true });
t.after(layer.done);
layer.add('boston', 50);
await layer.refined();
assert.equal(layer.calls.sampleHeight, 0);
assert.equal(layer.calls.getHeight, 50);
assert.ok(
Math.abs(
layer.heightOf('boston:boston7') -
(groundAt(42.3007, -71.0993) + POINT_HEIGHT_OFFSET_M),
) < 1e-3,
);
});
test('a station whose terrain tile is not drawn falls back to a depth render', async (t) => {
// Stations 10 and up lie on a tile the globe is not drawing.
const layer = makeLayer({
terrainGlobe: true,
shared: true,
unloaded: (lat) => lat > 42.30095,
});
t.after(layer.done);
layer.add('boston', 20);
await layer.refined();
assert.equal(layer.calls.sampleHeight, 10);
assert.ok(
Math.abs(
layer.heightOf('boston:boston15') -
(groundAt(42.3015, -71.0985) + POINT_HEIGHT_OFFSET_M),
) < 1e-3,
);
});
test('removing a city stops its refinement and leaves the other running', async (t) => {
const layer = makeLayer();
t.after(layer.done);
layer.add('boston', 40);
layer.add('toronto', 40);
layer.rendering.removeCityPoints('boston');
assert.equal(layer.rendering.pendingHeightCount(), 40);
await layer.refined();
assert.equal(layer.calls.sampleHeight, 40, 'only the remaining city');
assert.ok(layer.heightOf('toronto:toronto3') > 50);
layer.add('boston', 40);
layer.rendering.removeCityPoints('boston');
layer.rendering.removeCityPoints('toronto');
assert.equal(layer.rendering.pendingHeightCount(), 0);
assert.equal(layer.rendering.heightRefinementScheduled(), false);
await pause(60);
assert.equal(layer.calls.sampleHeight, 40, 'nothing sampled after removal');
});
test('disabling the layer stops refinement and leaves no timer', async (t) => {
const layer = makeLayer();
t.after(layer.done);
layer.lifecycle.enable(layer.viewer);
layer.add('boston', 200);
await pause(30);
const partWay = layer.calls.sampleHeight;
assert.ok(partWay < 200, 'still refining');
layer.lifecycle.disable(layer.viewer);
assert.equal(layer.rendering.pendingHeightCount(), 0);
assert.equal(layer.rendering.heightRefinementScheduled(), false);
await pause(60);
assert.equal(layer.calls.sampleHeight, partWay, 'no sample after disable');
});
test('a station selected before its height arrives is re-selected at it', async (t) => {
const layer = makeLayer();
t.after(layer.done);
layer.add('boston', 3);
const reselected = [];
layer.state._selectedKey = 'boston:boston1';
// The selection part's select path; here it records the key.
layer.parts.selection._selectStation = (key) => reselected.push(key);
await layer.refined();
assert.deepEqual(reselected, ['boston:boston1']);
});
+3
View File
@@ -44,6 +44,7 @@ export function createLifecycle({
layerState._inFlightInfo = new Map();
layerState._inFlightStatus = new Map();
layerState._stationRenderMap = new Map();
parts.rendering?.stopHeightRefinement?.();
layerState._clickHandler = null;
layerState._selectedKey = null;
layerState._selectedEntity = null;
@@ -134,6 +135,7 @@ export function createLifecycle({
parts.ingestion.abortAllInFlight();
parts.viewport.deactivateAllCities();
parts.rendering?.stopHeightRefinement?.();
layerState._cityRuntime.clear();
layerState._pointCollection.show = false;
layerState._count = 0;
@@ -165,6 +167,7 @@ export function createLifecycle({
layerState._cameraChangedAttached = false;
}
parts.ingestion.abortAllInFlight();
parts.rendering?.stopHeightRefinement?.();
if (layerState._pointCollection) {
viewer.scene.primitives.remove(layerState._pointCollection);
layerState._pointCollection = null;
+11
View File
@@ -50,6 +50,17 @@ export const DEFAULT_CAPACITY = 15;
export const POINT_HEIGHT_OFFSET_M = 2.0;
/**
* Milliseconds one step may spend finding station ground heights. A height
* the terrain globe cannot give is a depth render of a millisecond or more.
*/
export const HEIGHT_REFINE_BUDGET_MS = 4;
/** Milliseconds between those steps, about a frame. */
export const HEIGHT_REFINE_INTERVAL_MS = 16;
/** Hard cap on total rendered station points across all cities. */
export const MAX_TOTAL_POINTS = 8000;
+106 -12
View File
@@ -11,7 +11,13 @@ import {
POINT_HEIGHT_OFFSET_M,
MAX_TOTAL_POINTS,
COLOR_OUTLINE,
HEIGHT_REFINE_BUDGET_MS,
HEIGHT_REFINE_INTERVAL_MS,
} from './policy.js';
import {
createTerrainSurface,
TERRAIN_NEED,
} from '../../services/terrainSurface.js';
export function createRendering({
state: layerState,
@@ -20,6 +26,14 @@ export function createRendering({
source,
}) {
const { governorRequestRender } = services.render;
// The terrain globe's loaded surface: the application's shared reader, or
// the layer's own when it is built without one.
const terrainSurface =
services.ground?.terrainSurface || createTerrainSurface();
/** Stations drawn at the fallback height, waiting for their ground height. */
let heightQueue = [];
let heightTimer = null;
const heightPoint = new Cesium.Cartographic();
/**
* Resolve the effective dock capacity for a station.
@@ -87,8 +101,10 @@ export function createRendering({
}
/**
* Create a Cartesian3 position for a station, terrain-clamped when possible.
* Falls back to a fixed small height offset if terrain sampling is unsupported.
* Create a Cartesian3 position for a station at the fallback height. Its
* ground height is found afterwards (refineHeights): a city's stations are
* drawn at once, and finding hundreds of heights in the same task held the
* main thread for most of a second.
* @param {Object} station - Station info object with lat/lon.
* @returns {Cesium.Cartesian3|null} World position, or null if coordinates are invalid.
*/
@@ -97,18 +113,78 @@ export function createRendering({
const lon = Number(station?.lon);
const lat = Number(station?.lat);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return Cesium.Cartesian3.fromDegrees(lon, lat, POINT_HEIGHT_OFFSET_M);
}
let height = POINT_HEIGHT_OFFSET_M;
// Sample terrain height so points sit on ground rather than at ellipsoid level
if (layerState._viewer?.scene?.sampleHeightSupported) {
const carto = Cesium.Cartographic.fromDegrees(lon, lat);
const sampled = layerState._viewer.scene.sampleHeight(carto);
if (Number.isFinite(sampled)) {
height = sampled + POINT_HEIGHT_OFFSET_M;
}
/**
* The ground height under a station, or undefined when the scene cannot say.
* A terrain globe answers from its loaded surface, which is cheap. Where it
* cannot (the globe is hidden under photorealistic tiles, or draws no tile
* at the point) the scene renders the depth there, at a millisecond or more
* a point.
*/
function sampleGroundHeight(lat, lon) {
const viewer = layerState._viewer;
const scene = viewer?.scene;
if (!scene) return undefined;
// A station is a marker: its one read is not left for a later window.
// The step's own time budget bounds what a pass spends.
const terrain = terrainSurface.height(
viewer,
lat,
lon,
TERRAIN_NEED.MARKER,
);
if (Number.isFinite(terrain)) return terrain;
if (!scene.sampleHeightSupported) return undefined;
Cesium.Cartographic.fromDegrees(lon, lat, 0, heightPoint);
const sampled = scene.sampleHeight(heightPoint);
return Number.isFinite(sampled) ? sampled : undefined;
}
/**
* Move queued stations onto the ground for a few milliseconds, then leave
* the rest to the next step. A station the scene has no height for keeps
* the fallback height, as it did when heights were read while drawing.
*/
function refineHeights() {
heightTimer = null;
const started = performance.now();
let moved = false;
while (
heightQueue.length > 0 &&
performance.now() - started < HEIGHT_REFINE_BUDGET_MS
) {
const { record, lat, lon } = heightQueue.pop();
const ground = sampleGroundHeight(lat, lon);
if (ground === undefined) continue;
record.point.position = Cesium.Cartesian3.fromDegrees(
lon,
lat,
ground + POINT_HEIGHT_OFFSET_M,
);
moved = true;
// The highlight and card of a selected station copy its position.
if (layerState._selectedKey === record.key)
parts.selection._selectStation(record.key);
}
if (moved) governorRequestRender('bikeshare-heights');
scheduleHeightRefinement();
}
return Cesium.Cartesian3.fromDegrees(lon, lat, height);
function scheduleHeightRefinement() {
if (heightTimer !== null || heightQueue.length === 0) return;
heightTimer = setTimeout(refineHeights, HEIGHT_REFINE_INTERVAL_MS);
}
/** Drop every queued station and the pending step. */
function stopHeightRefinement() {
heightQueue = [];
clearTimeout(heightTimer);
heightTimer = null;
}
/**
@@ -178,7 +254,7 @@ export function createRendering({
id: key,
});
layerState._stationRenderMap.set(key, {
const record = {
key,
cityId,
stationId: station.stationId,
@@ -191,12 +267,19 @@ export function createRendering({
isInstalled: station.isInstalled,
isRenting: station.isRenting,
isReturning: station.isReturning,
};
layerState._stationRenderMap.set(key, record);
heightQueue.push({
record,
lat: Number(station.lat),
lon: Number(station.lon),
});
runtime.stationKeys.add(key);
}
layerState._count = layerState._stationRenderMap.size;
scheduleHeightRefinement();
}
/**
@@ -224,6 +307,12 @@ export function createRendering({
layerState._stationRenderMap.delete(key);
}
// Its stations no longer wait for a height.
heightQueue = heightQueue.filter(
({ record }) => layerState._stationRenderMap.get(record.key) === record,
);
if (heightQueue.length === 0) stopHeightRefinement();
layerState._cityRuntime.delete(cityId);
layerState._count = layerState._stationRenderMap.size;
}
@@ -280,5 +369,10 @@ export function createRendering({
ensureCityPoints,
removeCityPoints,
applyStatusToPoints,
stopHeightRefinement,
/** Stations still waiting for their ground height. */
pendingHeightCount: () => heightQueue.length,
/** Whether a refinement step is waiting on a timer. */
heightRefinementScheduled: () => heightTimer !== null,
};
}
+7 -51
View File
@@ -260,21 +260,6 @@ export function createGeometry({ state: layerState, services, parts, source }) {
return Math.max(PROBE_MIN_RANGE_M, hitDistance - PROBE_CLEARANCE_M);
}
/**
* Distance along a pitched-down view axis to flat ground at the mount's own
* ground height, or null for an axis at or above the horizon.
* @param {number} mountHeightM Mount height above its ground.
* @param {number} pitchDeg Axis pitch (negative looks down).
* @returns {number|null}
*/
function flatGroundAxisDistance(mountHeightM, pitchDeg) {
const height = Number(mountHeightM);
const pitch = Number(pitchDeg);
if (!Number.isFinite(height) || height <= 0 || !(pitch < 0)) return null;
return height / Math.sin(Cesium.Math.toRadians(Math.min(89, -pitch)));
}
/**
* Converts a computeFrustumGeometry result into the Cartesian3 positions the
* entities consume. Fresh objects per call (geometry updates are rare —
@@ -631,14 +616,10 @@ export function createGeometry({ state: layerState, services, parts, source }) {
* range, clamp the plane's effective range just short of the first hit so the
* "big and dramatic" true end cap never clips into downtown buildings.
*
* On a globe map it also picks the globe's terrain along the same axis
* (flat ground at the mount's ground when terrain is missed or met beyond
* the range) and clamps at the nearer end. These are the ONLY raycasts in the
* whole CCTV subsystem — once per activation, never per-frame (the
* zero-raycast invariant applies to steady state). The per-camera range
* slider overrides the clamp: a user-set rangeScale skips the probe
* entirely. A miss on the photoreal stack or a probe failure keeps the
* unclamped range.
* This is the ONLY raycast in the whole CCTV subsystem — once per activation,
* never per-frame (the zero-raycast invariant applies to steady state). The
* per-camera range slider overrides the clamp: a user-set rangeScale skips the
* probe entirely. Probe failure/miss keeps the unclamped range.
* @param {Object} record - Camera record being activated.
*/
@@ -665,33 +646,9 @@ export function createGeometry({ state: layerState, services, parts, source }) {
for (const runtime of layerState._projectionEntities) {
if (runtime?.planeEntity) exclude.push(runtime.planeEntity);
}
const ray = new Cesium.Ray(mountPos, dir);
// pickFromRay hits only primitives (the photoreal tiles), never the
// globe: on a globe map the ray runs on through the Earth and can meet
// a primitive thousands of kilometres away, which blocks nothing. Such
// a hit or none left the plane at the full nominal range, about a
// kilometre wide and hundreds of metres up. A hit at or beyond the range
// counts as none. On a globe map the axis also ends on the globe's
// terrain, the nearer of that and any primitive hit (a primitive behind
// a hill the axis has already entered blocks nothing). Terrain still
// loading at a coarse level can be missed or met beyond the range; then
// flat ground at the mount's own ground stands in for it.
const inRange = (distance) =>
Number.isFinite(distance) && distance > 0 && distance < camera.rangeM;
const hit = scene.pickFromRay(ray, exclude)?.position;
const hitDist = hit ? Cesium.Cartesian3.distance(mountPos, hit) : NaN;
const distances = inRange(hitDist) ? [hitDist] : [];
if (scene.globe?.show) {
const terrain = scene.globe.pick(ray, scene);
const terrainDist = terrain
? Cesium.Cartesian3.distance(mountPos, terrain)
: NaN;
const ground = inRange(terrainDist)
? terrainDist
: flatGroundAxisDistance(camera.mountHeightM, camera.pitchDeg);
if (Number.isFinite(ground) && ground > 0) distances.push(ground);
}
const dist = distances.length ? Math.min(...distances) : null;
const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude);
if (!hit?.position) return;
const dist = Cesium.Cartesian3.distance(mountPos, hit.position);
record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist);
} catch {
// probe failure → keep the unclamped range
@@ -874,7 +831,6 @@ export function createGeometry({ state: layerState, services, parts, source }) {
hasShippedFootprint,
footprintPose,
activationProbeClampRange,
flatGroundAxisDistance,
frustumCartesians,
frustumFrameEcef,
applyFrustumGeometry,
-1
View File
@@ -73,7 +73,6 @@ export function createCctvLayer({ services, source }) {
deriveCalBadge: parts.calibration.deriveCalBadge,
computeFrustumGeometry: parts.geometry.computeFrustumGeometry,
activationProbeClampRange: parts.geometry.activationProbeClampRange,
flatGroundAxisDistance: parts.geometry.flatGroundAxisDistance,
frameSignatureFromPixels: parts.frames.frameSignatureFromPixels,
_createCctvProjectionPlaneForTest:
parts.testing._createCctvProjectionPlaneForTest,
+25 -3
View File
@@ -1,6 +1,10 @@
import * as Cesium from 'cesium';
import { applyDisplayCourse, updatePlayback } from '../transit/movement.js';
import { HEIGHT_SAMPLE_MAX_ALTITUDE_M } from '../transit/policy.js';
import {
TERRAIN_NEED,
createTerrainSurface,
} from '../../services/terrainSurface.js';
import {
COLD_CELL_FLUSH_MS,
COLD_CELL_RETRY_MS,
@@ -28,6 +32,11 @@ import {
import { placeOnPath } from './pathBinding.js';
const SPRITE_PX = 64;
/**
* Terrain reads per degree for a train's height: about 3.7 m apart. At 11 m
* a train on fine tiles stood up to a couple of metres off the surface.
*/
const TERRAIN_GRID = 3e4;
/** Billboard tints; the setter copies, so sharing one instance is safe. */
const FULL_TINT = Cesium.Color.WHITE;
const DIM_TINT = new Cesium.Color(1, 1, 1, UNSELECTED_TRAIN_ALPHA);
@@ -101,8 +110,11 @@ export function createRendering({ state, services, parts }) {
neighborFloorM = null,
coarseFloorCoord = null,
} = services.ground;
// The terrain globe's loaded surface: the application's shared reader, or
// the layer's own when it is built without one.
const terrainSurface =
services.ground.terrainSurface || createTerrainSurface();
const sprites = new Map();
const terrainPoint = new Cesium.Cartographic();
const scratchPosition = new Cesium.Cartesian3();
/** Scratch for the on-line position; place() runs every frame. */
const pathPoint = { lat: NaN, lon: NaN, bearingDeg: NaN };
@@ -379,8 +391,18 @@ export function createRendering({ state, services, parts }) {
function floorHeight(lat, lon, entry = null, stop = false) {
const globe = state._viewer?.scene?.globe;
if (globe?.show === true) {
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
const rendered = globe.getHeight?.(terrainPoint);
// A moving train is asked about every frame, at a new point each
// time. On a ~4 m grid it asks the terrain a few times a second
// instead, and a standing train not at all; the drawn height is
// smoothed along the track anyway (displayHeightFor). Read as a
// marker, never rationed: a train that starts on a fallback floor only
// creeps to the terrain at a railway's grade.
const rendered = terrainSurface.height(
state._viewer,
Math.round(lat * TERRAIN_GRID) / TERRAIN_GRID,
Math.round(lon * TERRAIN_GRID) / TERRAIN_GRID,
TERRAIN_NEED.MARKER,
);
if (Number.isFinite(rendered))
return answer(rendered + GROUND_FLOOR_LIFT_M, 3);
}
+50 -16
View File
@@ -7,6 +7,10 @@ import {
HEIGHT_SAMPLE_MAX_ALTITUDE_M,
withinViewBounds,
} from './policy.js';
import {
TERRAIN_NEED,
createTerrainSurface,
} from '../../services/terrainSurface.js';
/** Surface preparation off the render path, shared by markers and history. */
export function createHeight({ state, services, parts }) {
@@ -47,35 +51,44 @@ export function createHeight({ state, services, parts }) {
return cameraAltitude() <= HEIGHT_SAMPLE_MAX_ALTITUDE_M;
}
// Loaded-terrain heights come from the application's shared reader, which
// keeps them per point and rations the reads (services/terrainSurface.js).
// A layer built without one keeps its own.
const sharedSurface = services.ground.terrainSurface;
const terrainSurface = sharedSurface || createTerrainSurface();
/**
* The floor under a vehicle, and the best fallback if its own cell is cold.
* @param {number} lat
* @param {number} lon
* @param {?object} [entry] The vehicle, for a fixture floor.
* @param {number} [need] How much the answer matters; see TERRAIN_NEED.
* @returns {{cell: string, height: number|null, prior: number|null}}
*/
const terrainPoint = new Cesium.Cartographic();
function requestHeight(lat, lon, entry = null) {
function requestHeight(lat, lon, entry = null, need = TERRAIN_NEED.PATH) {
const coarse = coarseFloorCoord(lat, lon);
const cell = `${coarse.lat},${coarse.lon}`;
const fixtureFloor =
entry?.qaFloorM ?? state._qaFixtureFloors?.get(entry?.key);
if (Number.isFinite(fixtureFloor))
return { cell, height: fixtureFloor, prior: null };
// On a terrain globe, use the loaded surface under the marker. This is
// synchronous cache work and does not launch a terrain request.
// On a terrain globe, use the loaded surface under the marker. This reads
// tiles already loaded and does not launch a terrain request.
const globe = state._viewer?.scene?.globe;
if (globe?.show === true) {
Cesium.Cartographic.fromDegrees(lon, lat, 0, terrainPoint);
const rendered = globe.getHeight?.(terrainPoint);
const flat =
if (
state._viewer?.terrainProvider instanceof
Cesium.EllipsoidTerrainProvider;
if (Number.isFinite(rendered) || flat)
return {
cell,
height: (flat ? 0 : rendered) + GROUND_FLOOR_LIFT_M,
prior: null,
};
Cesium.EllipsoidTerrainProvider
) {
// Nothing is asked of the terrain while the globe is flat: show the
// reader the flat provider, so the heights kept from the terrain
// before it do not come back with the terrain after it.
terrainSurface.observe(state._viewer);
return { cell, height: GROUND_FLOOR_LIFT_M, prior: null };
}
const rendered = terrainSurface.height(state._viewer, lat, lon, need);
if (Number.isFinite(rendered))
return { cell, height: rendered + GROUND_FLOOR_LIFT_M, prior: null };
}
const floor = cachedGroundFloor(lat, lon);
if (Number.isFinite(floor)) {
@@ -107,6 +120,7 @@ export function createHeight({ state, services, parts }) {
function rereadFloors() {
state._floorTimer = null;
if (!state._enabled) return;
const waitingBefore = terrainSurface.waitingCount();
const onGround = nearGround(),
candidates = [];
const entries = [...state._vehicles.values()].filter(
@@ -120,6 +134,13 @@ export function createHeight({ state, services, parts }) {
Number(b.key === state._selectedKey) -
Number(a.key === state._selectedKey),
);
// Every vehicle's own position before any trail: the first trails of a
// pass must not spend the budget the last markers are waiting on.
for (const entry of entries) {
const newest = entry.fixes.at(-1);
if (newest)
requestHeight(newest.lat, newest.lon, entry, TERRAIN_NEED.MARKER);
}
for (const entry of entries) {
const cells = new Map();
const collect = (point, answer) => {
@@ -141,7 +162,12 @@ export function createHeight({ state, services, parts }) {
};
parts.trails.prepareEntry(entry, collect);
const newest = entry.fixes.at(-1);
const answer = requestHeight(newest.lat, newest.lon, entry);
const answer = requestHeight(
newest.lat,
newest.lon,
entry,
TERRAIN_NEED.MARKER,
);
entry.heightCell = answer.cell;
// The selected vehicle's path also offers its history, newest segment
// first; the vehicle's own cell is read where its newest fix is.
@@ -227,7 +253,11 @@ export function createHeight({ state, services, parts }) {
for (const entry of entries) parts.trails.prepareEntry(entry);
parts.rendering.requestVisibility();
governorRequestRender('transit-floor');
if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS)
// A pass that ran out of terrain budget before reaching every point
// comes back for the rest. Each pass reads its budget, and points stop
// falling due once the terrain settles, so this ends.
const waiting = terrainSurface.waitingCount() - waitingBefore;
if (state._floorAttempts++ < FLOOR_REREAD_ATTEMPTS || waiting > 0)
state._floorTimer = setTimeout(rereadFloors, FLOOR_REREAD_MS);
}
@@ -258,6 +288,8 @@ export function createHeight({ state, services, parts }) {
}
state._floorAttempts = 0;
state._heightDirty.clear();
// The shared reader's heights belong to every layer still showing.
if (!sharedSurface) terrainSurface.clear();
}
return {
@@ -267,5 +299,7 @@ export function createHeight({ state, services, parts }) {
anchorFloors,
rereadFloors,
clear,
/** Terrain read counters since the layer was created, for checks. */
terrainStats: () => terrainSurface.stats(),
};
}
+389
View File
@@ -0,0 +1,389 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { createHeight } from './height.js';
import { FLOOR_REREAD_ATTEMPTS, FLOOR_REREAD_MS } from './policy.js';
import {
TERRAIN_HEIGHT_CACHE_MAX,
TERRAIN_HEIGHT_FRESH_MS,
TERRAIN_NEED,
TERRAIN_READ_BUDGET_MS,
TERRAIN_READ_WINDOW_MS,
TERRAIN_REFRESH_BUDGET_MS,
TERRAIN_SETTLE_MS,
} from '../../services/terrainSurface.js';
const LIFT = 1.5;
/**
* A height part over a terrain globe whose every read costs `costMs` of a
* clock the test owns. `surface(lat, lon)` is the terrain; `reads` lists the
* points actually read.
*/
function terrain(t, { costMs = 0, surface = () => 100, provider = {} } = {}) {
const clock = { now: 1_000_000 };
t.mock.method(performance, 'now', () => clock.now);
const reads = [];
const world = { surface, costMs };
const globe = {
show: true,
getHeight(point) {
const lat = Cesium.Math.toDegrees(point.latitude);
const lon = Cesium.Math.toDegrees(point.longitude);
reads.push(`${lat.toFixed(5)},${lon.toFixed(5)}`);
clock.now += world.costMs;
return world.surface(lat, lon);
},
};
const state = {
_enabled: true,
_selectedKey: null,
_vehicles: new Map(),
_visible: new Set(),
_floorCursor: 0,
_floorAttempts: 0,
_floorTimer: null,
_heightDirty: new Set(),
_viewer: {
camera: { positionCartographic: { height: 500 } },
scene: { globe },
terrainProvider: provider,
},
};
const parts = {
trails: { prepareEntry() {} },
rendering: { requestVisibility() {} },
};
const height = createHeight({
state,
services: {
render: { governorRequestRender() {} },
ground: {
coarseFloorCoord: (lat, lon) => ({
lat: Number(lat.toFixed(3)),
lon: Number(lon.toFixed(3)),
}),
cachedGroundFloor: () => null,
neighborFloorM: () => null,
warmGroundFloor() {},
GROUND_FLOOR_LIFT_M: LIFT,
},
},
parts,
});
t.after(() => height.clear());
const nextWindow = () => {
clock.now += TERRAIN_READ_WINDOW_MS;
};
return { height, state, parts, globe, world, clock, reads, nextWindow };
}
test('a point is read from the terrain once and reused until its reuse time passes', (t) => {
const { height, reads, clock, world } = terrain(t);
const first = height.requestHeight(42.36, -71.06);
assert.deepEqual(first, { cell: '42.36,-71.06', height: 100 + LIFT, prior: null });
for (let i = 0; i < 500; i++) height.requestHeight(42.36, -71.06);
assert.equal(reads.length, 1, '501 requests, one terrain read');
// A point a metre away is its own point; the same cell does not stand in
// for it while the budget allows a read.
world.surface = (lat) => (lat > 42.36 ? 140 : 100);
assert.equal(height.requestHeight(42.36002, -71.06).height, 140 + LIFT);
assert.equal(reads.length, 2);
// The surface under the first point changed (the globe drew a finer tile).
world.surface = () => 120;
clock.now += TERRAIN_HEIGHT_FRESH_MS - 1;
assert.equal(height.requestHeight(42.36, -71.06).height, 100 + LIFT);
clock.now += 1;
assert.equal(height.requestHeight(42.36, -71.06).height, 120 + LIFT);
assert.equal(reads.length, 3);
assert.deepEqual(height.terrainStats(), {
reads: 3,
reused: 501,
deferred: 0,
borrowed: 0,
held: 2,
});
});
test('terrain that is not loaded yet is not remembered as an answer', (t) => {
const { height, reads, world } = terrain(t, { surface: () => undefined });
assert.deepEqual(height.requestHeight(42.36, -71.06), {
cell: '42.36,-71.06',
height: null,
prior: null,
});
assert.equal(height.requestHeight(42.36, -71.06).height, null);
assert.equal(reads.length, 2, 'each request asks again');
world.surface = () => 12;
assert.equal(height.requestHeight(42.36, -71.06).height, 12 + LIFT);
assert.equal(height.terrainStats().held, 1);
});
test('a tile that leaves the globe keeps the height it gave', (t) => {
const { height, clock, world } = terrain(t, { surface: () => 30 });
assert.equal(height.requestHeight(42.36, -71.06).height, 30 + LIFT);
world.surface = () => undefined;
clock.now += TERRAIN_HEIGHT_FRESH_MS * 20;
assert.equal(height.requestHeight(42.36, -71.06).height, 30 + LIFT);
});
test('new points stop being read once a window has spent its budget', (t) => {
const costMs = 5;
const { height, reads, nextWindow } = terrain(t, {
costMs,
surface: (lat, lon) => Math.round(lon * -1000),
});
const affordable = Math.ceil(TERRAIN_READ_BUDGET_MS / costMs);
// One point per floor cell (0.001° apart), more than the window affords.
const answers = [];
for (let i = 0; i < affordable + 3; i++)
answers.push(height.requestHeight(42.36, -71.06 - i * 0.001).height);
assert.equal(reads.length, affordable);
assert.deepEqual(
answers.slice(affordable),
[null, null, null],
'a point in a cell never read waits',
);
assert.ok(reads.length * costMs < TERRAIN_READ_BUDGET_MS + costMs, 'the overrun is one read at most');
// A new point in a cell that was read borrows that cell's height.
const borrowed = height.requestHeight(42.36004, -71.06);
assert.equal(borrowed.height, 71060 + LIFT);
assert.equal(reads.length, affordable, 'borrowing reads nothing');
assert.equal(height.terrainStats().borrowed, 1);
// The next window reads the points that waited, and the borrower its own.
nextWindow();
for (let i = affordable; i < affordable + 3; i++)
assert.equal(
height.requestHeight(42.36, -71.06 - i * 0.001).height,
Math.round((-71.06 - i * 0.001) * -1000) + LIFT,
);
assert.equal(reads.length, affordable + 3);
});
test("a vehicle's own position is read even when the window is spent", (t) => {
const { height, reads } = terrain(t, { costMs: TERRAIN_READ_BUDGET_MS });
height.requestHeight(42.36, -71.06);
assert.equal(height.requestHeight(42.37, -71.07).height, null, 'a trail point waits');
assert.equal(height.requestHeight(42.38, -71.08, null, TERRAIN_NEED.MARKER).height, 100 + LIFT);
assert.equal(reads.length, 2);
});
test('undrawn trail history leaves most of the budget to the paths markers are drawn along', (t) => {
const costMs = 1;
const { height, reads } = terrain(t, { costMs });
// New points, one per floor cell: history first, as a pass offers them.
let lon = -71;
const ask = (need) => height.requestHeight(42, (lon -= 0.001), null, need).height;
const history = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.BACKGROUND));
const read = history.filter((h) => h !== null).length;
assert.equal(read, TERRAIN_READ_BUDGET_MS / 4, 'history stops at a quarter of the budget');
const path = Array.from({ length: 30 }, () => ask(TERRAIN_NEED.PATH));
assert.equal(path.filter((h) => h !== null).length, TERRAIN_READ_BUDGET_MS - read, 'the path takes the rest');
assert.equal(ask(TERRAIN_NEED.BACKGROUND), null);
assert.equal(ask(TERRAIN_NEED.MARKER), 100 + LIFT, 'a marker is still read');
assert.equal(reads.length, TERRAIN_READ_BUDGET_MS + 1);
});
test('held points keep their last height while re-reads are over budget, and the longest-held are re-read first so none starves', (t) => {
const costMs = TERRAIN_REFRESH_BUDGET_MS / 2;
const { height, reads, clock, world, nextWindow } = terrain(t, { surface: () => 50 });
const points = Array.from({ length: 40 }, (_, i) => [42.36, -71.06 - i * 0.001]);
for (const [lat, lon] of points) height.requestHeight(lat, lon);
assert.equal(reads.length, 40);
world.costMs = costMs;
world.surface = () => 80;
clock.now += TERRAIN_HEIGHT_FRESH_MS;
reads.length = 0;
const pass = () => points.map(([lat, lon]) => height.requestHeight(lat, lon).height);
const first = pass();
const perWindow = Math.ceil(TERRAIN_REFRESH_BUDGET_MS / costMs);
assert.equal(reads.length, perWindow, 'one window re-reads its budget and no more');
assert.deepEqual(first.slice(0, perWindow), Array(perWindow).fill(80 + LIFT));
assert.deepEqual(first.slice(perWindow), Array(40 - perWindow).fill(50 + LIFT), 'the rest keep their last height');
// Pass after pass, a window apart: the budget moves down the list instead
// of returning to the first points, whose reuse time runs out again long
// before the last ones have had a turn (40 points, ${perWindow} a window).
const turns = new Map();
for (const point of reads) turns.set(point, 1);
const cycle = Math.ceil(40 / perWindow);
for (let passes = 1; passes < cycle; passes++) {
nextWindow();
reads.length = 0;
pass();
for (const point of reads) turns.set(point, (turns.get(point) || 0) + 1);
}
assert.equal(turns.size, 40, 'every point was re-read in one cycle');
assert.ok([...turns.values()].every((count) => count === 1), 'and none twice before the others once');
assert.ok(cycle * TERRAIN_READ_WINDOW_MS > TERRAIN_HEIGHT_FRESH_MS, 'the cycle is longer than the reuse time, so the first points were stale again');
assert.deepEqual(pass().slice(-1), [80 + LIFT]);
});
test('once the camera and the tiles have settled a height is kept until either changes', (t) => {
const { height, state, globe, reads, clock, world } = terrain(t, { surface: () => 10 });
const read = () => height.requestHeight(42.36, -71.06).height;
assert.equal(read(), 10 + LIFT);
// Still settling: the point falls due and is read again.
world.surface = () => 20;
clock.now += TERRAIN_SETTLE_MS;
assert.equal(read(), 20 + LIFT);
assert.equal(reads.length, 2);
// Read after the settle time: kept, however long the view stays put.
world.surface = () => 99;
for (let i = 0; i < 50; i++) {
clock.now += 60_000;
assert.equal(read(), 20 + LIFT);
}
assert.equal(reads.length, 2);
// The camera moves: the globe may draw the point from a finer tile.
state._viewer.camera.positionCartographic = { longitude: 0, latitude: 0, height: 250 };
clock.now += TERRAIN_READ_WINDOW_MS;
assert.equal(read(), 99 + LIFT);
assert.equal(reads.length, 3);
// Settled again, then a tile starts loading with the camera still.
world.surface = () => 101;
clock.now += TERRAIN_SETTLE_MS;
assert.equal(read(), 101 + LIFT);
clock.now += 60_000;
assert.equal(read(), 101 + LIFT);
assert.equal(reads.length, 4);
world.surface = () => 105;
globe.tilesLoaded = false;
clock.now += TERRAIN_READ_WINDOW_MS;
assert.equal(read(), 105 + LIFT);
assert.equal(reads.length, 5);
});
test('another terrain provider or globe is read afresh, however settled the old heights were', (t) => {
const { height, state, globe, world, clock, reads } = terrain(t);
const viewer = state._viewer;
const at = () => height.requestHeight(42.36, -71.06).height;
// Read once the camera and tiles have long settled: kept indefinitely.
height.requestHeight(10, 10);
clock.now += TERRAIN_SETTLE_MS * 2;
reads.length = 0;
assert.equal(at(), 100 + LIFT);
clock.now += TERRAIN_SETTLE_MS * 10;
world.surface = () => 250;
assert.equal(at(), 100 + LIFT, 'same terrain: the kept height stands');
assert.equal(reads.length, 1);
viewer.terrainProvider = {};
assert.equal(at(), 250 + LIFT, 'a new provider is a new surface');
assert.equal(reads.length, 2);
world.surface = () => 400;
viewer.scene.globe = { show: true, getHeight: globe.getHeight };
assert.equal(at(), 400 + LIFT, 'and so is a new globe');
assert.equal(reads.length, 3);
// Terrain → flat → the same terrain provider: nothing asks the terrain
// while the globe is flat, yet the old heights must not come back.
const real = viewer.terrainProvider;
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
assert.equal(at(), LIFT);
world.surface = () => 30;
viewer.terrainProvider = real;
assert.equal(at(), 30 + LIFT);
assert.equal(reads.length, 4);
});
test('a flat globe answers without reading terrain', (t) => {
const { height, reads } = terrain(t, {
provider: new Cesium.EllipsoidTerrainProvider(),
});
assert.deepEqual(height.requestHeight(42.36, -71.06), {
cell: '42.36,-71.06',
height: LIFT,
prior: null,
});
assert.equal(reads.length, 0);
});
test('a hidden globe reads no terrain and clear() forgets what was read', (t) => {
const { height, reads, globe } = terrain(t);
height.requestHeight(42.36, -71.06);
height.clear();
assert.equal(height.terrainStats().held, 0);
height.requestHeight(42.36, -71.06);
assert.equal(reads.length, 2, 'a cleared layer reads again');
globe.show = false;
assert.equal(height.requestHeight(42.5, -71.5).height, null);
assert.equal(reads.length, 2);
});
test('the points kept stay bounded', (t) => {
const { height } = terrain(t);
const total = TERRAIN_HEIGHT_CACHE_MAX + 5_000;
for (let i = 0; i < total; i++)
height.requestHeight(10 + Math.floor(i / 1000) * 0.001, 20 + (i % 1000) * 0.0001);
const { held, reads } = height.terrainStats();
assert.equal(reads, total);
assert.ok(held <= TERRAIN_HEIGHT_CACHE_MAX, `${held} held`);
assert.ok(held >= TERRAIN_HEIGHT_CACHE_MAX * 0.7, `${held} held`);
});
test('a fleet rereading every trail fix reads each point once, not once per request', (t) => {
const { height, state, parts, reads } = terrain(t);
t.mock.timers.enable({ apis: ['setTimeout'] });
// 600 vehicles with 40 fixes each, 25 m apart along a street.
const fleet = Array.from({ length: 600 }, (_, v) => ({
key: `bus-${v}`,
fixes: Array.from({ length: 40 }, (_, i) => ({
lat: 42 + v * 0.002,
lon: -71 + i * 0.0003,
})),
}));
for (const entry of fleet) {
state._vehicles.set(entry.key, entry);
state._visible.add(entry);
}
let requests = 0;
parts.trails.prepareEntry = (entry, collect) => {
for (const fix of entry.fixes) {
requests++;
const answer = height.requestHeight(fix.lat, fix.lon, entry);
if (collect) collect(fix, answer);
}
};
height.anchorFloors();
for (let i = 0; i < FLOOR_REREAD_ATTEMPTS; i++) t.mock.timers.tick(FLOOR_REREAD_MS);
assert.equal(requests, 600 * 40 * 2 * (FLOOR_REREAD_ATTEMPTS + 1));
assert.equal(new Set(reads).size, 600 * 40);
assert.equal(reads.length, 600 * 40, `${requests} requests, ${reads.length} terrain reads`);
assert.ok(fleet.every((entry) => entry.heightM === 100 + LIFT && entry.heightResolved));
});
test('a reread cycle outlasts its attempts while trail points still wait for a read, then stops', (t) => {
const costMs = 4;
const { height, state, parts, reads, clock } = terrain(t, { costMs });
t.mock.timers.enable({ apis: ['setTimeout'] });
const entry = {
key: 'bus',
// One fix per floor cell, so nothing can be borrowed.
fixes: Array.from({ length: 60 }, (_, i) => ({ lat: 42, lon: -71 + i * 0.001 })),
};
state._vehicles.set(entry.key, entry);
state._visible.add(entry);
parts.trails.prepareEntry = (e) => {
for (const fix of e.fixes) height.requestHeight(fix.lat, fix.lon, e);
};
height.anchorFloors();
let passes = 1;
while (state._floorTimer && passes < 200) {
clock.now += FLOOR_REREAD_MS;
t.mock.timers.tick(FLOOR_REREAD_MS);
passes++;
}
assert.equal(new Set(reads).size, 60, 'every fix was read in the end');
assert.ok(passes > FLOOR_REREAD_ATTEMPTS + 1, `${passes} passes: the cycle kept going for the unread fixes`);
assert.ok(passes < 120, `${passes} passes: and stopped once none waited`);
assert.equal(state._floorTimer, null);
});
+13 -2
View File
@@ -13,6 +13,7 @@ import {
transitVehicleKey,
vehicleReportTimeMs,
} from './policy.js';
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
import {
transitModeFor,
transitModeResolved,
@@ -272,7 +273,12 @@ export function createIngestion({ state, services, parts, source }) {
// DEM one, or a change of map regime, moves a parked vehicle too. A
// cell still cold takes the lowest neighbour as a prior, as before.
const priorHeightM = entry.heightM;
const height = parts.height.requestHeight(record.lat, record.lon);
const height = parts.height.requestHeight(
record.lat,
record.lon,
null,
TERRAIN_NEED.MARKER,
);
if (height.height !== null) {
entry.heightCell = height.cell;
entry.heightM = height.height;
@@ -309,7 +315,12 @@ export function createIngestion({ state, services, parts, source }) {
}
continue;
}
const height = parts.height.requestHeight(record.lat, record.lon);
const height = parts.height.requestHeight(
record.lat,
record.lon,
null,
TERRAIN_NEED.MARKER,
);
// Keep the local prior separate from resolved historical floors.
// A neighbouring cell is not a guaranteed lower bound.
const prior = height.height === null ? height.prior : height.height;
-1
View File
@@ -77,7 +77,6 @@ export const FLOOR_WARM_PER_POLL = 300;
export const FLOOR_REREAD_MS = 900;
/** Re-reads before a poll's cycle gives up and waits for the next poll. */
export const FLOOR_REREAD_ATTEMPTS = 6;
// --- Rendering ---
/** On-screen glyph size in CSS px at the near end of the distance ramp. */
export const MARKER_PIXEL_SIZE = 20;
+20 -8
View File
@@ -8,6 +8,7 @@ import {
} from '../../data/contactPlayback.js';
import { createContactTrailRenderer } from '../../data/contactTrailRenderer.js';
import { fixDistanceM } from './movement.js';
import { TERRAIN_NEED } from '../../services/terrainSurface.js';
import { transitStyleProfile } from '../../data/transitPresetStyle.js';
const MODE_COLOR = {
@@ -72,8 +73,13 @@ export function createTrails({ state, services, parts, source }) {
b = {};
let vertices = 0,
signature = '';
const floor = (point) => {
const answer = parts.height.requestHeight(point.lat, point.lon, entry);
const floor = (point, need = TERRAIN_NEED.PATH) => {
const answer = parts.height.requestHeight(
point.lat,
point.lon,
entry,
need,
);
if (collect) collect(point, answer);
return answer.height;
};
@@ -99,12 +105,6 @@ export function createTrails({ state, services, parts, source }) {
lon: a.lon + ((b.lon - a.lon) * i) / n,
});
}
for (let i = 0; i < entry.track.count; i++) {
readFix(entry.track, i, a);
const h = floor(a);
if (h !== null) correctHeight(entry.track, a.seq, h);
}
const order = [];
const activeIndex = Math.max(
0,
Math.min(
@@ -112,6 +112,18 @@ export function createTrails({ state, services, parts, source }) {
entry.sample.fromSeq - entry.track.baseSeq,
),
);
for (let i = 0; i < entry.track.count; i++) {
readFix(entry.track, i, a);
// Fixes behind an unselected vehicle are kept but not drawn.
const h = floor(
a,
entry === selected || i >= activeIndex
? TERRAIN_NEED.PATH
: TERRAIN_NEED.BACKGROUND,
);
if (h !== null) correctHeight(entry.track, a.seq, h);
}
const order = [];
for (let i = activeIndex; i + 1 < entry.track.count; i++) order.push(i);
if (entry === selected)
for (let i = activeIndex - 1; i >= 0; i--) order.push(i);
-5
View File
@@ -7,7 +7,6 @@ import { createRendering } from './rendering.js';
import { createSelection } from './selection.js';
import { createTracking } from './tracking.js';
import { createCards } from './cards.js';
import { createPhotos } from './photos.js';
import { createTesting } from './testing.js';
import { createEvidence } from './evidence.js';
import { createQueries } from './queries.js';
@@ -25,7 +24,6 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
parts.selection = createSelection(context);
parts.tracking = createTracking(context);
parts.cards = createCards(context);
parts.photos = createPhotos(context);
parts.testing = createTesting(context);
parts.evidence = createEvidence(context);
parts.queries = createQueries(context);
@@ -92,9 +90,6 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
buildVesselCard: parts.cards.buildVesselCard,
buildSelectedVesselCard: parts.cards.buildSelectedVesselCard,
cardScreenSeparated: parts.cards.cardScreenSeparated,
/** The vessel's Open Vessel Photos photo, once its lookup answered. */
getVesselPhoto: (id) =>
parts.photos.vesselPhoto(vesselState.state.records.byId.get(id)),
});
Object.defineProperty(layer, 'testing', { value: parts.testing });
return layer;
-56
View File
@@ -1,56 +0,0 @@
import { MMSI, parseOpenVesselPhoto } from '../../data/openVesselPhoto.js';
/** A failed lookup is asked again after this long; a miss is final. */
const PHOTO_RETRY_MS = 5 * 60 * 1000;
/** Lookups remembered per session, oldest dropped first. */
const PHOTO_CACHE_LIMIT = 500;
/**
* The selected vessel's photo, looked up once per MMSI when it is selected.
* The answer is read with `parseOpenVesselPhoto` whatever shape the source
* returns, so only a checked photo is ever kept. A source without
* `getEnrichment` has no photos.
*/
export function createPhotos({ vesselState }) {
/** MMSI → photo, `null` for a final miss, or `{failedAtMs}`/`{pending}`. */
const answers = new Map();
function remember(mmsi, answer) {
answers.delete(mmsi);
answers.set(mmsi, answer);
if (answers.size > PHOTO_CACHE_LIMIT)
answers.delete(answers.keys().next().value);
}
/** Ask for the record's photo unless it is known, asked or cooling down. */
function requestVesselPhoto(record, now = Date.now()) {
const mmsi = String(record?.mmsi || '');
const source = vesselState._source;
if (!MMSI.test(mmsi) || typeof source?.getEnrichment !== 'function') return;
const known = answers.get(mmsi);
if (known !== undefined) {
if (known?.pending || known === null || known.found) return;
if (now - known.failedAtMs < PHOTO_RETRY_MS) return;
}
remember(mmsi, { pending: true });
Promise.resolve()
.then(() => source.getEnrichment({ kind: 'photo', id: mmsi }))
.then(
(answer) => {
const photo = parseOpenVesselPhoto(answer);
remember(mmsi, photo.found ? photo : null);
},
() => remember(mmsi, { failedAtMs: Date.now() }),
);
}
/** The vessel's checked photo, or null. */
function vesselPhoto(record) {
const answer = answers.get(String(record?.mmsi || ''));
if (!answer?.found) return null;
const { src, width, height, photographer, licence, link } = answer;
return { src, width, height, photographer, licence, link };
}
return { requestVesselPhoto, vesselPhoto };
}
-87
View File
@@ -1,87 +0,0 @@
// src/layers/vessels/photos.test.mjs — a selected vessel's photo is asked for
// once per MMSI; a miss is final and a failure is retried only after a pause.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPhotos } from './photos.js';
const ANSWER = Object.freeze({
mmsi: '366999709',
photos: [
{
urls: {
mmsi: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg',
},
credit: 'Gordon Leggett',
licenceName: 'CC BY 4.0',
width: 1600,
height: 1200,
},
],
});
const settle = () => new Promise((resolve) => setImmediate(resolve));
function fixture(answer) {
const asked = [];
const source = {
getSnapshot() {},
async getEnrichment(query) {
asked.push(query);
return answer(query);
},
};
const photos = createPhotos({ vesselState: { _source: source } });
return { photos, asked };
}
test('a selected vessel is asked once and shows its checked photo', async () => {
const { photos, asked } = fixture(() => ANSWER);
const record = { id: 'v1', mmsi: '366999709' };
photos.requestVesselPhoto(record);
photos.requestVesselPhoto(record);
await settle();
photos.requestVesselPhoto(record);
assert.deepEqual(asked, [{ kind: 'photo', id: '366999709' }]);
assert.deepEqual(photos.vesselPhoto(record), {
src: 'https://openvesselphotos.org/v1/mmsi/366999709/photos/1.jpg?size=800',
width: 800,
height: 600,
photographer: 'Gordon Leggett',
licence: 'CC BY 4.0',
link: 'https://openvesselphotos.org/vessels/mmsi/366999709',
});
});
test('a vessel without a valid MMSI, or a source without lookups, is never asked', async () => {
const { photos, asked } = fixture(() => ANSWER);
photos.requestVesselPhoto({ id: 'v1', mmsi: '' });
photos.requestVesselPhoto({ id: 'v2', mmsi: '12345' });
await settle();
assert.equal(asked.length, 0);
const bare = createPhotos({ vesselState: { _source: { getSnapshot() {} } } });
bare.requestVesselPhoto({ id: 'v3', mmsi: '366999709' });
assert.equal(bare.vesselPhoto({ mmsi: '366999709' }), null);
});
test('a miss is final; a failure is asked again only after the pause', async () => {
let fail = true;
const { photos, asked } = fixture(({ id }) => {
if (id === '111111111') return { mmsi: id, photos: [] };
if (fail) throw new Error('down');
return ANSWER;
});
const missing = { mmsi: '111111111' };
const flaky = { mmsi: '366999709' };
photos.requestVesselPhoto(missing);
photos.requestVesselPhoto(flaky, 0);
await settle();
photos.requestVesselPhoto(missing);
photos.requestVesselPhoto(flaky, Date.now() + 1000);
assert.equal(asked.length, 2);
fail = false;
photos.requestVesselPhoto(flaky, Date.now() + 10 * 60 * 1000);
await settle();
assert.equal(asked.length, 3);
assert.equal(photos.vesselPhoto(missing), null);
assert.equal(photos.vesselPhoto(flaky).photographer, 'Gordon Leggett');
});
-1
View File
@@ -186,7 +186,6 @@ export function createSelection({
// click, not up to VISIBILITY_UPDATE_MS later.
components.rendering.updateVisibility(true);
components.cards.updateSelectedVesselHud(record);
components.photos.requestVesselPhoto(record);
if (registerSelectedContext(record)) {
selectEntityContext(record);
}
+5 -2
View File
@@ -64,9 +64,12 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) {
document: {
getElementById: () => ({ querySelector: () => ({}) }),
},
startPageLocale: () => 'en',
followPageLocale: () => () => {},
resolveLocale: () => 'en',
setLocale() {},
getLocale: () => 'en',
onLocaleChange: () => () => {},
mountApplicationViews() {},
applyDocumentLanguage() {},
applyDocumentTranslations() {},
CITY_POIS: [],
createApplication: (options) => options,
+3
View File
@@ -1,3 +1,4 @@
import { createTerrainSurface } from './terrainSurface.js';
/** Construct an instance-owned groundFloor service with explicit dependencies. */
export function createGroundFloor({ terrain, signal }) {
const { cachedRealEllipsoidalGround, resolveEllipsoidalGround } = terrain;
@@ -698,6 +699,8 @@ export function createGroundFloor({ terrain, signal }) {
{ once: true },
);
return {
// The terrain globe's loaded surface, read once per point for every layer.
terrainSurface: createTerrainSurface(),
GROUND_FLOOR_LIFT_M,
MESH_FLOOR_BELOW_PRIOR_M,
MESH_FLOOR_ABOVE_PRIOR_M,
+259
View File
@@ -0,0 +1,259 @@
import * as Cesium from 'cesium';
/**
* The height of the terrain globe's loaded surface at a point, kept per point
* and read within a time budget.
*
* `globe.getHeight` casts a ray against the mesh of the tile drawn under the
* point: geometry work, not a table lookup, and dearer the finer the tile. A
* layer that asks for every vehicle, trail fix or site on every pass asks the
* same few thousand points over and over, so the answers are kept and the
* reads are rationed. One instance serves every layer of an application, so
* the layers share one budget instead of each spending its own.
*/
/**
* While the camera moves or tiles load, a kept height is reused this long
* before the point is read again.
*/
export const TERRAIN_HEIGHT_FRESH_MS = 2_000;
/**
* A height read once the camera has been still and the globe's tiles loaded
* for this long is kept until either changes: the globe draws a point from
* another tile only after one of them does.
*/
export const TERRAIN_SETTLE_MS = 5_000;
/** Read budgets are counted over windows of this length. */
export const TERRAIN_READ_WINDOW_MS = 200;
/**
* Milliseconds of terrain reads in one window. Past it a point never read
* takes the last height read in its ~111 m floor cell, or waits for a later
* request; a MARKER point is read regardless.
*/
export const TERRAIN_READ_BUDGET_MS = 16;
/**
* The share of that budget re-reads of kept points may use, the longest-kept
* first, leaving the rest for points never read.
*/
export const TERRAIN_REFRESH_BUDGET_MS = 12;
/**
* How much a height matters to what is drawn, lowest first. BACKGROUND is a
* point nobody is looking at (the undrawn trail history of a vehicle) and may
* use a quarter of each budget; PATH is drawn geometry; MARKER is a thing's
* own position, read even when the window's budget is spent, since a marker
* without a height is not drawn.
*/
export const TERRAIN_NEED = Object.freeze({
BACKGROUND: 0,
PATH: 1,
MARKER: 2,
});
/** Points kept; the oldest quarter is dropped past it. */
export const TERRAIN_HEIGHT_CACHE_MAX = 60_000;
/** A number unique to a point on a `scale`-per-degree grid. */
function gridKey(lat, lon, scale) {
return (
(Math.round(lat * scale) + 90 * scale) * (360 * scale + 1) +
Math.round(lon * scale) +
180 * scale
);
}
/**
* @returns {{
* height: (viewer: object, lat: number, lon: number, need?: number) => number|undefined,
* observe: (viewer: object) => void,
* waitingCount: () => number,
* stats: () => {reads: number, reused: number, deferred: number, borrowed: number, held: number},
* clear: () => void,
* }}
*/
export function createTerrainSurface() {
const point = new Cesium.Cartographic();
/** Point key → `{ h, at }`: the height read there and when. */
const heights = new Map();
/** Floor-cell key → the last height read anywhere in that cell. */
const cells = new Map();
/** The globe and terrain provider the kept heights were read from. */
let globeRead = null;
let providerRead = null;
let windowAt = -Infinity;
let readMs = 0;
let refreshMs = 0;
// Per need (background, path): the oldest read among the points this window
// left for later, and the bound the next window re-reads up to.
const deferredAt = [Infinity, Infinity];
const refreshUpTo = [Infinity, Infinity];
let waiting = 0;
/** When the camera last moved or the globe was last loading tiles. */
let changedAt = -Infinity;
let cameraLon = NaN,
cameraLat = NaN,
cameraHeight = NaN;
const counters = { reads: 0, reused: 0, deferred: 0, borrowed: 0 };
function clear() {
heights.clear();
cells.clear();
globeRead = providerRead = null;
windowAt = -Infinity;
readMs = refreshMs = 0;
deferredAt.fill(Infinity);
refreshUpTo.fill(Infinity);
changedAt = -Infinity;
cameraLon = cameraLat = cameraHeight = NaN;
}
/**
* The globe redraws a point from another tile only after the camera moves
* or a tile arrives: note when either was last seen.
*/
function noteChange(viewer, globe, now) {
const camera = viewer.camera?.positionCartographic;
if (
globe.tilesLoaded === false ||
camera?.longitude !== cameraLon ||
camera?.latitude !== cameraLat ||
camera?.height !== cameraHeight
) {
changedAt = now;
cameraLon = camera?.longitude;
cameraLat = camera?.latitude;
cameraHeight = camera?.height;
}
}
/** The viewer's terrain globe, the one whose heights are held, or null. */
function terrainGlobe(viewer) {
const globe = viewer?.scene?.globe;
if (globe?.show !== true || typeof globe.getHeight !== 'function')
return null;
// A height belongs to the terrain it was read from: another globe or
// another terrain provider is another surface, however settled the camera.
const provider = viewer.terrainProvider;
if (globe !== globeRead || provider !== providerRead) {
clear();
globeRead = globe;
providerRead = provider;
}
return globe;
}
/**
* Look at the camera and the globe's tile loading without asking for a
* height. The surface only sees a change when it is asked something, so a
* caller that asks nothing while tiles load (it waits for them) calls this
* on its own walk; otherwise the load would pass unseen and the heights
* kept from before it would never be read again.
* @param {object} viewer
*/
function observe(viewer) {
const globe = terrainGlobe(viewer);
if (globe) noteChange(viewer, globe, performance.now());
}
/**
* The loaded terrain's height at a point, in metres above the ellipsoid, or
* undefined when it is not known yet or the viewer has no terrain globe
* showing. A point read within the reuse time answers from memory, and so
* does one read after the camera and the globe's tiles had settled: the
* surface under it cannot have changed since. Past the window's budget a
* kept point keeps its last height and a new point borrows its floor
* cell's. Requests tend to come in the same order every pass, so re-reads
* go to the longest-kept points first: otherwise the first few would be
* re-read forever and the rest never.
* @param {object} viewer The Cesium viewer whose globe is read.
* @param {number} lat Degrees.
* @param {number} lon Degrees.
* @param {number} [need] How much the answer matters; see TERRAIN_NEED.
*/
function height(viewer, lat, lon, need = TERRAIN_NEED.PATH) {
const globe = terrainGlobe(viewer);
if (!globe) return undefined;
const now = performance.now();
if (now - windowAt >= TERRAIN_READ_WINDOW_MS) {
for (let i = 0; i < deferredAt.length; i++) {
refreshUpTo[i] = deferredAt[i];
deferredAt[i] = Infinity;
}
windowAt = now;
readMs = refreshMs = 0;
noteChange(viewer, globe, now);
}
const key = gridKey(lat, lon, 1e5);
const held = heights.get(key);
if (
held &&
(now - held.at < TERRAIN_HEIGHT_FRESH_MS ||
held.at >= changedAt + TERRAIN_SETTLE_MS)
) {
counters.reused++;
return held.h;
}
const cellKey = gridKey(lat, lon, 1e3);
const marker = need === TERRAIN_NEED.MARKER;
// Background may use a quarter of each budget; drawn geometry the rest.
const share = need === TERRAIN_NEED.BACKGROUND ? 0.25 : 1;
const affordable =
marker && !held
? true
: readMs < TERRAIN_READ_BUDGET_MS * (marker ? 1 : share) &&
(!held ||
marker ||
(refreshMs < TERRAIN_REFRESH_BUDGET_MS * share &&
held.at <= refreshUpTo[need]));
if (!affordable) {
counters.deferred++;
if (held) {
if (!marker && held.at < deferredAt[need]) deferredAt[need] = held.at;
waiting++;
return held.h;
}
const borrowed = cells.get(cellKey);
if (borrowed !== undefined) {
counters.borrowed++;
return borrowed;
}
waiting++;
return undefined;
}
Cesium.Cartographic.fromDegrees(lon, lat, 0, point);
const rendered = globe.getHeight(point);
const spent = performance.now() - now;
readMs += spent;
if (held) refreshMs += spent;
counters.reads++;
// A tile that left the globe keeps the height it gave.
if (!Number.isFinite(rendered)) return held?.h;
if (held) {
held.h = rendered;
held.at = now;
} else {
if (heights.size >= TERRAIN_HEIGHT_CACHE_MAX) {
let drop = TERRAIN_HEIGHT_CACHE_MAX >> 2;
for (const old of heights.keys()) {
heights.delete(old);
if (--drop <= 0) break;
}
cells.clear();
}
heights.set(key, { h: rendered, at: now });
}
cells.set(cellKey, rendered);
return rendered;
}
return {
height,
observe,
/**
* Requests so far that were left unread or due a re-read. A caller that
* reads it before and after its own pass learns whether to come back.
*/
waitingCount: () => waiting,
/** Read counters since creation, for checks. */
stats: () => ({ ...counters, held: heights.size }),
clear,
};
}
+154
View File
@@ -0,0 +1,154 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import {
TERRAIN_NEED,
TERRAIN_READ_BUDGET_MS,
TERRAIN_READ_WINDOW_MS,
TERRAIN_SETTLE_MS,
createTerrainSurface,
} from './terrainSurface.js';
/** A viewer over a terrain globe whose every read costs `costMs` of a clock the test owns. */
function world(t, { costMs = 0, surface = () => 100 } = {}) {
const clock = { now: 5_000_000 };
t.mock.method(performance, 'now', () => clock.now);
const reads = [];
const model = { surface, costMs };
const globe = () => ({
show: true,
getHeight(point) {
const lat = Cesium.Math.toDegrees(point.latitude);
const lon = Cesium.Math.toDegrees(point.longitude);
reads.push(`${lat.toFixed(5)},${lon.toFixed(5)}`);
clock.now += model.costMs;
return model.surface(lat, lon);
},
});
const viewer = {
scene: { globe: globe() },
camera: {
positionCartographic: { longitude: 0, latitude: 0, height: 900 },
},
};
return { viewer, clock, reads, model, globe };
}
test('a viewer without a terrain globe showing has no terrain height', (t) => {
const { viewer, reads } = world(t);
const surface = createTerrainSurface();
viewer.scene.globe.show = false;
assert.equal(surface.height(viewer, 42, -71), undefined);
assert.equal(surface.height({ scene: {} }, 42, -71), undefined);
assert.equal(surface.height(null, 42, -71), undefined);
assert.equal(reads.length, 0);
});
test('every caller of one surface shares its heights and its budget', (t) => {
const costMs = 2;
const { viewer, reads, clock } = world(t, { costMs });
const surface = createTerrainSurface();
// Two layers asking about the same street: the second reads nothing.
const transit = () => surface.height(viewer, 42.36, -71.06);
const rail = () => surface.height(viewer, 42.36, -71.06);
assert.equal(transit(), 100);
assert.equal(rail(), 100);
assert.equal(reads.length, 1);
// And what one spends in a window the other cannot spend again.
const affordable = TERRAIN_READ_BUDGET_MS / costMs;
let lon = -72;
const fresh = () => surface.height(viewer, 42, (lon -= 0.001));
for (let i = 0; i < affordable - 1; i++) assert.equal(fresh(), 100);
const before = surface.waitingCount();
assert.equal(fresh(), undefined, 'the window is spent, whoever asks');
assert.equal(surface.waitingCount(), before + 1);
assert.equal(
surface.height(viewer, 43, -73, TERRAIN_NEED.MARKER),
100,
'a marker is still read',
);
clock.now += TERRAIN_READ_WINDOW_MS;
assert.equal(fresh(), 100);
assert.deepEqual(surface.stats(), {
reads: affordable + 2,
reused: 1,
deferred: 1,
borrowed: 0,
held: affordable + 2,
});
});
test("another globe's surface is read afresh", (t) => {
const { viewer, reads, clock, model, globe } = world(t, {
surface: () => 10,
});
const surface = createTerrainSurface();
assert.equal(surface.height(viewer, 42.36, -71.06), 10);
clock.now += TERRAIN_SETTLE_MS;
assert.equal(surface.height(viewer, 42.36, -71.06), 10);
clock.now += 60_000;
model.surface = () => 250;
assert.equal(surface.height(viewer, 42.36, -71.06), 10, 'settled: kept');
// The map source changed and the viewer has a new globe.
viewer.scene.globe = globe();
assert.equal(surface.height(viewer, 42.36, -71.06), 250);
assert.equal(reads.length, 3);
assert.equal(surface.stats().held, 1);
});
test('another terrain provider is read afresh, however settled the old heights were', (t) => {
const { viewer, reads, model, clock } = world(t);
const surface = createTerrainSurface();
viewer.terrainProvider = {};
const at = () => surface.height(viewer, 42.36, -71.06);
// Read once the camera and tiles have long settled: kept indefinitely.
surface.height(viewer, 10, 10);
clock.now += TERRAIN_SETTLE_MS * 2;
reads.length = 0;
assert.equal(at(), 100);
clock.now += TERRAIN_SETTLE_MS * 10;
model.surface = () => 250;
assert.equal(at(), 100, 'same terrain: the kept height stands');
assert.equal(reads.length, 1);
const real = (viewer.terrainProvider = {});
assert.equal(at(), 250, 'a new provider is a new surface');
assert.equal(reads.length, 2);
// Terrain → flat → the same provider. A caller answers a flat globe
// itself and asks nothing, so it shows the flat provider with observe().
viewer.terrainProvider = new Cesium.EllipsoidTerrainProvider();
surface.observe(viewer);
model.surface = () => 30;
viewer.terrainProvider = real;
assert.equal(at(), 30);
assert.equal(reads.length, 3);
});
test('observe() lets a caller that asks nothing while tiles load report the load', (t) => {
const { viewer, reads, clock, model } = world(t, { surface: () => 10 });
const surface = createTerrainSurface();
const read = () => surface.height(viewer, 42.36, -71.06);
read();
clock.now += TERRAIN_SETTLE_MS;
read();
clock.now += 60_000;
assert.equal(reads.length, 2, 'settled and kept');
// Finer tiles load and finish while nobody asks for a height.
model.surface = () => 14;
viewer.scene.globe.tilesLoaded = false;
clock.now += 1_000;
surface.observe(viewer);
viewer.scene.globe.tilesLoaded = true;
clock.now += 3_000;
assert.equal(
read(),
14,
'the height kept from before the load is read again',
);
assert.equal(reads.length, 3);
surface.observe(null);
surface.observe({ scene: {} });
});
-1
View File
@@ -16,5 +16,4 @@ export {
createFlightSource,
createMilitarySource,
createVesselSource,
fetchOpenVesselPhoto,
} from './standalone.js';
-38
View File
@@ -12,11 +12,6 @@ import {
} from './aircraft.js';
import { normalizeVesselTrack, vesselSnapshot } from './vessels.js';
import { parsePlanespottersPhoto } from '../../data/planespottersPhoto.js';
import {
MMSI,
openVesselPhotosUrl,
parseOpenVesselPhoto,
} from '../../data/openVesselPhoto.js';
const defaultFetch = (...args) => globalThis.fetch(...args);
const header = (response, name) => response.headers?.get?.(name);
@@ -47,34 +42,6 @@ async function planespottersPhoto(fetchImpl, hex, signal) {
return parsePlanespottersPhoto(payload);
}
/**
* A vessel's Open Vessel Photos photo, read and host-checked. The API is
* keyless and answers with `access-control-allow-origin: *`, so the browser
* asks it straight. An MMSI it does not know answers 404: no photo.
* @param {Function} fetchImpl fetch.
* @param {string} mmsi The vessel's nine-digit MMSI.
* @param {AbortSignal} [signal] Abort signal.
* @returns {Promise<object>} A `parseOpenVesselPhoto` result.
*/
export async function fetchOpenVesselPhoto(fetchImpl, mmsi, signal) {
const id = String(mmsi || '');
if (!MMSI.test(id))
throw new LiveSourceError('unsupported', 'Enrichment unavailable');
const timeout = AbortSignal.timeout(PHOTO_TIMEOUT_MS);
const { response, payload } = await readResponse(
fetchImpl,
openVesselPhotosUrl(id),
{
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
credentials: 'omit',
},
'Open Vessel Photos',
);
if (response.status === 404) return parseOpenVesselPhoto(null);
if (!response.ok) throw httpError(response, 'Open Vessel Photos');
return parseOpenVesselPhoto(payload, id);
}
function openSkyError(response) {
const error = httpError(response, 'Flights');
const mode = String(
@@ -268,11 +235,6 @@ export function createVesselSource({
status: response.status,
};
},
async getEnrichment(query, { signal } = {}) {
if (query.kind !== 'photo')
throw new LiveSourceError('unsupported', 'Enrichment unavailable');
return fetchOpenVesselPhoto(fetchImpl, query.id, signal);
},
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
+14 -5
View File
@@ -6,11 +6,13 @@ import { createStandaloneScene } from './scene.js';
import { createStandaloneControls } from './controls.js';
import { createStandaloneData } from './data.js';
import { createStandaloneTools } from './tools.js';
import { resolveLocale, applyDocumentLanguage } from '../i18n/locale.js';
import {
applyDocumentTranslations,
followPageLocale,
startPageLocale,
} from '../i18n/page.js';
getLocale,
onLocaleChange,
setLocale,
} from '../i18n/index.js';
import { mountApplicationViews } from '../ui/views/index.js';
import { createGoogleTokenSource } from '../maps/googleTokens.js';
@@ -35,7 +37,9 @@ export function createStandaloneApplication({
// scripts execute after parsing, so the DOM is ready here: the views
// translate as they render, and the page's static data-i18n* markup (the
// loading screen) is translated here and after every language switch.
startPageLocale(document);
const activeLocale = resolveLocale();
setLocale(activeLocale);
applyDocumentLanguage(document, activeLocale);
// Views render the page's markup; wiring reads it.
mountApplicationViews(document);
applyDocumentTranslations(document);
@@ -46,7 +50,12 @@ export function createStandaloneApplication({
return createApplication({
createScene: async (context) => {
// A language switch applies in place; the page's own markup follows.
context.defer(followPageLocale(document));
context.defer(
onLocaleChange(() => {
applyDocumentLanguage(document, getLocale());
applyDocumentTranslations(document);
}),
);
placeSearch = createStandalonePlaceSearch({
// The bundled city and landmark data the offline name provider reads.
// The search package takes it as plain data rather than importing it,
+2 -2
View File
@@ -96,7 +96,7 @@ export class StyleManager extends ShellFacade {
* @param {Cesium.Viewer} viewer - The CesiumJS viewer instance.
* @param {object} [options]
* @param {boolean} [options.localePicker=true] - false removes the language
* picker, for a host that offers its own language choice.
* picker, for a host that does not initialize Core's locale.
*/
constructor(
viewer,
@@ -764,7 +764,7 @@ export class StyleManager extends ShellFacade {
this._lifetime.own(
onLocaleChange(() => nextTick(() => this._sizeDisplayLabelColumn())),
);
// A host that offers its own language choice shows no picker.
// A host that does not initialize Core's locale shows no picker.
if (this._localePickerEnabled === false) return;
this._publishLocalePicker();
}
-45
View File
@@ -1,7 +1,6 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { CctvControls } from './cctvControls.js';
import { CCTV_FRAME_DECODE_WAIT_MS } from './cctvFrames.js';
function element() {
const classes = new Set();
@@ -60,50 +59,6 @@ test('a late image completion cannot replace a newer camera preview', (t) => {
assert.equal(controls._frame.cameraId, 'b');
});
test('a camera frame settles once decoded, and a stale decode cannot land', async (t) => {
const { controls, requests } = fixture(t);
const decodes = [];
const queue = (src, id) => {
controls._queueCctvFrame(src, id, true);
const request = requests.at(-1);
request.decode = () =>
new Promise((resolve) => decodes.push({ src, resolve }));
return request;
};
const first = queue('first.jpg', 'a');
first.onload();
assert.equal(controls._frame.hasFrame, false);
const second = queue('second.jpg', 'b');
second.onload();
decodes[1].resolve();
await Promise.resolve();
decodes[0].resolve();
await Promise.resolve();
assert.equal(controls._frame.src, 'second.jpg');
assert.equal(controls._frame.cameraId, 'b');
assert.equal(controls._frame.hasFrame, true);
});
test('a loaded frame shows when its decode rejects or never settles', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { controls, requests } = fixture(t);
controls._queueCctvFrame('broken.jpg', 'a', true);
requests[0].decode = () => Promise.reject(new Error('EncodingError'));
requests[0].onload();
await Promise.resolve();
await Promise.resolve();
assert.equal(controls._frame.src, 'broken.jpg');
assert.equal(controls._frame.hasFrame, true);
controls._queueCctvFrame('hidden.jpg', 'b', true);
requests[1].decode = () => new Promise(() => {});
requests[1].onload();
assert.equal(controls._frame.hasFrame, false);
t.mock.timers.tick(CCTV_FRAME_DECODE_WAIT_MS);
assert.equal(controls._frame.src, 'hidden.jpg');
assert.equal(controls._frame.hasFrame, true);
});
test('failed refresh preserves settled pixels, but changing cameras clears them', (t) => {
const { controls, requests } = fixture(t);
controls._queueCctvFrame('first.jpg', 'a', true);
+1 -25
View File
@@ -77,9 +77,6 @@ export function cctvSourceLabelText(label) {
return SOURCE_LABELS[text] ? t(SOURCE_LABELS[text]) : text;
}
/** Longest wait for a loaded frame's decode before it is shown anyway. */
export const CCTV_FRAME_DECODE_WAIT_MS = 1000;
/** The preview before any frame: nothing requested, nothing shown. */
export const EMPTY_CCTV_FRAME = Object.freeze({
cameraId: '',
@@ -130,28 +127,7 @@ export function _queueCctvFrame(src, cameraId, cameraChanged) {
const preloader = new Image();
this._cctvFramePreloader = preloader;
// Settle once the image is decoded, not merely loaded: the panel's <img>
// then paints the frame the moment it takes the src, instead of showing
// its empty box for the decode.
// A decode that rejects after a load still has drawable data, as it was
// before decoding was awaited. A decode that never settles (a hidden tab may
// hold it until the page draws) shows the loaded frame after
// CCTV_FRAME_DECODE_WAIT_MS rather than leaving the preview loading.
preloader.onload = () => {
if (typeof preloader.decode !== 'function') {
this._settleCctvFrame(token, src, true);
return;
}
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
this._settleCctvFrame(token, src, true);
};
const timer = setTimeout(settle, CCTV_FRAME_DECODE_WAIT_MS);
preloader.decode().then(settle, settle);
};
preloader.onload = () => this._settleCctvFrame(token, src, true);
preloader.onerror = () => this._settleCctvFrame(token, src, false);
preloader.src = src;
}
+1 -4
View File
@@ -250,11 +250,8 @@ html:not([lang='en']) .cctv-controls .scene-btn {
height: auto;
object-fit: contain;
display: block;
/* No fade: a camera change hides the old frame at once (never its pixels
under the new camera) and shows the new one as soon as it is decoded. A
180 ms fade-in after every arriving frame made each camera click linger
on black. */
opacity: 0;
transition: opacity 180ms ease;
}
#cctv-video {