diff --git a/.env.example b/.env.example index 2caf21d29..a9819f100 100644 --- a/.env.example +++ b/.env.example @@ -176,7 +176,7 @@ VITE_AIS_LIVE_LABEL_MAX_ROWS=900 AISSTREAM_API_KEY= # Optional AISStream subscription filters. # AISSTREAM_BOUNDING_BOXES=[[[-90,-180],[90,180]]] -# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport +# AISSTREAM_MESSAGE_TYPES=PositionReport,StandardClassBPositionReport,ExtendedClassBPositionReport,ShipStaticData,StaticDataReport,AidsToNavigationReport # Feed watchdog: silence (ms) after which the feed is REPORTED stale; the socket # is recycled at 2.5x that. Default 120000. The watchdog self-arms only for the # default worldwide subscription — a narrow filter above can be legitimately diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 759dff105..6290835a2 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -1,13 +1,13 @@ # Data Sources & Attribution -God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the source code only — it does NOT extend to third-party data or visual assets.** Every third-party source keeps its own license and terms. This file documents the live and bundled data sources; bundled 3D-model provenance is recorded in [`public/models/README.md`](public/models/README.md). +God's Eye View's **code** is [MIT](LICENSE)-licensed. **The MIT grant covers the source code only — it does NOT extend to third-party data or visual assets.** Every third-party source keeps its own license and terms. This file documents the live and bundled data sources. The bundled 3D models are the project's own procedural fleet models, MIT-licensed with the code ([`public/models/README.md`](public/models/README.md)). How to read this: - **The non-permissive datasets are carved out, not omitted.** Some bundled data (e.g. TeleGeography, CC BY-NC-SA) isn't MIT-compatible. Rather than hide it, we **bundle it with a clear license carve-out** so the app works out of the box — but it stays under the provider's terms. - **If your use doesn't fit a dataset's license, remove that dataset.** Most importantly: TeleGeography is **NonCommercial** — commercial users must delete it (or license it from TeleGeography). It's one self-contained folder. - **Attribution is shown in-app** and listed here. Keep it intact. The required Google/Cesium credit renders on the on-globe credit line (bottom-left, `#cesium-credits`), and every per-layer credit below is registered into the expandable **"Data attribution"** lightbox on that line (`src/data/dataCredits.js` → `viewer.creditDisplay.addStaticCredit`). Both stay visible in clean-view and recording modes. -- **Bundled model attribution lives beside the model files.** [`public/models/README.md`](public/models/README.md) records each shipped model's creator, source, license, and modification status. +- **The bundled 3D models are the project's own.** Every model under `public/models/` is a procedural fleet model generated by the project maintainers and covered by the repository's MIT license; [`public/models/README.md`](public/models/README.md) describes the set. --- @@ -31,6 +31,7 @@ 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) | @@ -238,7 +239,8 @@ 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, 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. +- **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. - **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. @@ -686,4 +688,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, 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, 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. diff --git a/LICENSE b/LICENSE index 4f01699a2..86483f2d6 100644 --- a/LICENSE +++ b/LICENSE @@ -56,7 +56,6 @@ In short: the MIT grant does not extend to any third-party data. See DATA_SOURCES.md for the full per-source license + attribution summary, and remove any dataset whose terms don't fit your use. -The 3D models under public/models/ are also third-party works and are NOT -licensed under MIT. They remain under their individual licenses. See -public/models/README.md for the author, source, license, modification notice, -and attribution covering each model file. +The 3D models under public/models/ are the project's procedural fleet +models, generated from code by the project maintainers, and are covered by +the MIT license above like the source code. See public/models/README.md. diff --git a/README.md b/README.md index 74479d046..1d7cb46d3 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Start with the included data sources, then add your own. Each layer is a separat - **📡 Contacts:** A 250 km roster of everything near your target — step through live aircraft and drop into any cockpit. - **🎯 Click-to-track anything:** Camera locks on, draws a fading trail, surfaces full metadata — and a tracked fire or vessel hands you off to the nearest live camera in one click. - **🖊️ Voice whiteboard:** Speak annotations onto the world — real boundary polygons, marks, and routes. -- **🛫 3D hangar:** Real per-class aircraft models — 787, ATR-72, Citation, Bell 206, MQ-9 — and a tracked contact swaps from glyph to 3D model as you close in. +- **🛫 3D fleet:** 122 procedural models drawn life size by type — airliners, business jets, helicopters, drones, ships, buses, trams and trains — and every contact swaps from glyph to 3D model as you close in. - **🎨 Reskin reality:** GLSL sensor looks over the normal globe — CRT, NVG, FLIR/thermal, Noir. - **🟩 Detection overlay:** Screen-space bounding boxes and IDs on everything in view. - **🎖️ Military HUD:** Tactical heads-up display with intelligence-style telemetry. diff --git a/changelog.d/1579-render-failure-notice.md b/changelog.d/1579-render-failure-notice.md new file mode 100644 index 000000000..2045ae823 --- /dev/null +++ b/changelog.d/1579-render-failure-notice.md @@ -0,0 +1,6 @@ +- When the globe stops drawing after a render error or a lost WebGL context, + an alert dialog says so instead of leaving a frozen or black globe behind + working controls. Retry restarts drawing (Reload after a lost context), and + Reset view reopens the app without the view restored from the address bar. + Cesium's "Rendering has stopped" panel no longer appears in the app + (German, @gekh, #1579). diff --git a/changelog.d/1686-cockpit-subjects.md b/changelog.d/1686-cockpit-subjects.md new file mode 100644 index 000000000..715561e8e --- /dev/null +++ b/changelog.d/1686-cockpit-subjects.md @@ -0,0 +1,12 @@ +- Let an application add its own Cockpit subjects through + `services.cockpitSubjects`. A provider supplies the subject's position, + heading and HUD (in the page's language, via `getLocale` and + `onLocaleChange` from `./i18n`); the camera can follow it without the + ground clamp, anchored exactly on it, and a generic HUD adds source rows, a + banner, a veil (off under reduced motion) and a bottom progress line. The + Rail and Transit layers expose read and hold APIs so a selected train or + vehicle can be ridden, and `./layers/rail/paths` is exported; that export + and the read APIs are experimental. `docs/APPLICATION.md` shows a minimal + provider. GEV itself registers none, and entry to the built-in aircraft, + satellite and vessel views is unchanged; a provider's subject takes entry + when it was selected after them (Denis Groshev, @ZloyS, #1686). diff --git a/changelog.d/1693-cctv-frame-no-flash.md b/changelog.d/1693-cctv-frame-no-flash.md new file mode 100644 index 000000000..363d28129 --- /dev/null +++ b/changelog.d/1693-cctv-frame-no-flash.md @@ -0,0 +1,2 @@ +- 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). diff --git a/changelog.d/1694-cctv-globe-plane-clamp.md b/changelog.d/1694-cctv-globe-plane-clamp.md new file mode 100644 index 000000000..54adb8545 --- /dev/null +++ b/changelog.d/1694-cctv-globe-plane-clamp.md @@ -0,0 +1,3 @@ +- 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). diff --git a/changelog.d/1695-vessel-photos.md b/changelog.d/1695-vessel-photos.md new file mode 100644 index 000000000..71d91c9db --- /dev/null +++ b/changelog.d/1695-vessel-photos.md @@ -0,0 +1,3 @@ +- 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). diff --git a/changelog.d/1696-i18n-entry.md b/changelog.d/1696-i18n-entry.md new file mode 100644 index 000000000..7fc2b0a5a --- /dev/null +++ b/changelog.d/1696-i18n-entry.md @@ -0,0 +1,3 @@ +- 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). diff --git a/changelog.d/1698-new-strings-translations.md b/changelog.d/1698-new-strings-translations.md new file mode 100644 index 000000000..ef67e482f --- /dev/null +++ b/changelog.d/1698-new-strings-translations.md @@ -0,0 +1,5 @@ +- Fix the translations of the strings added since the last review (News, + Data Library, Wildlife Sightings, Shipwrecks, Rail, the vessel and orbital + cockpits) in all twelve languages: neutral side labels and counts no longer + break the sentence, terms follow each glossary, and Traditional Chinese + follows Simplified Chinese again (German, @gekh, #1698). diff --git a/changelog.d/1714-fleet-models.md b/changelog.d/1714-fleet-models.md new file mode 100644 index 000000000..adf11676d --- /dev/null +++ b/changelog.d/1714-fleet-models.md @@ -0,0 +1,8 @@ +- Draw every 3D model from the project's procedural fleet set: 121 models + (about 2 MB, MIT like the code) chosen per vehicle by type and drawn life + size, replacing the eight third-party aircraft models. Flights, Military + Flights and Local ADS-B pick a model by ICAO type, class and emitter + category; Vessels, Transit and Rail now draw models too, under the same 3D + toggle and distances, a ship at its AIS length and beam and a tram, bus or + train in its mode or category colour. The whole set loads once when a layer + starts (samehkhamis, #1714). diff --git a/changelog.d/1715-vessel-typical-length.md b/changelog.d/1715-vessel-typical-length.md new file mode 100644 index 000000000..9cb589974 --- /dev/null +++ b/changelog.d/1715-vessel-typical-length.md @@ -0,0 +1,3 @@ +- Draw a vessel whose feed sends no length at its ship type's typical size, so + a cruise ship or container ship no longer shows as its type's smallest model + (samehkhamis, #1715). diff --git a/changelog.d/1723-cctv-panel-collapse-on-disable.md b/changelog.d/1723-cctv-panel-collapse-on-disable.md new file mode 100644 index 000000000..ae5a49445 --- /dev/null +++ b/changelog.d/1723-cctv-panel-collapse-on-disable.md @@ -0,0 +1,5 @@ +- Collapse the CCTV panel when the layer is switched off. It stayed open with + an empty frame and a meta line that still described the last camera; the + meta line of a disabled layer now reads the catalog count. Contacts, Space + Missions and Clear selected data layers leave the panel as it is + (asturksever, #1723). diff --git a/changelog.d/1729-fleet-ground-vehicles.md b/changelog.d/1729-fleet-ground-vehicles.md new file mode 100644 index 000000000..ed198f27a --- /dev/null +++ b/changelog.d/1729-fleet-ground-vehicles.md @@ -0,0 +1,5 @@ +- Draw airport ground vehicles and ground stations as what they are instead of + the generic plane. An airport vehicle (type `ZVEH`) is a fire truck, pushback + tug, van or car by its callsign, a ground station (`GRND`) is a new 11.8 m + radio mast, and one with no altitude stands on the ground instead of at the + 10 km airborne default (samehkhamis, #1729). diff --git a/changelog.d/1730-airport-vehicle-models.md b/changelog.d/1730-airport-vehicle-models.md new file mode 100644 index 000000000..c491effa4 --- /dev/null +++ b/changelog.d/1730-airport-vehicle-models.md @@ -0,0 +1,4 @@ +- Draw airport ground vehicles as real shapes instead of stacked boxes: the + car is an operations SUV, the van a high-roof panel van, the fire truck an + airport crash tender with turrets and the pushback a low tug with a glazed + cab, all on the ground at their built size (samehkhamis, #1730). diff --git a/changelog.d/1731-aircraft-mesh-colours.md b/changelog.d/1731-aircraft-mesh-colours.md new file mode 100644 index 000000000..5443c3a1d --- /dev/null +++ b/changelog.d/1731-aircraft-mesh-colours.md @@ -0,0 +1,3 @@ +- Draw aircraft models in their own mesh colours instead of a 94% flat + white (amber for military) over the model; the tracked and emergency + colours now tint the mesh without hiding its paint (samehkhamis, #1731). diff --git a/changelog.d/1732-no-track-true-heading.md b/changelog.d/1732-no-track-true-heading.md new file mode 100644 index 000000000..1f01a1097 --- /dev/null +++ b/changelog.d/1732-no-track-true-heading.md @@ -0,0 +1,4 @@ +- An aircraft that reports no ground track (parked or taxiing, from adsb.lol + or a local receiver) now faces its reported true heading instead of north, + so one taxiing south no longer looks like it is moving backwards + (samehkhamis, #1732). diff --git a/changelog.d/1733-buoy-models.md b/changelog.d/1733-buoy-models.md new file mode 100644 index 000000000..a2de9e612 --- /dev/null +++ b/changelog.d/1733-buoy-models.md @@ -0,0 +1,11 @@ +- Draw aids to navigation as buoys instead of ships: a buoy, beacon, RACON, + light or mark from the vessel feed (or any AIS station numbered 99MIDxxxx) + is now the fleet set's buoy of its IALA mark (cardinal, port or starboard + hand, safe water, isolated danger or special mark), standing still with no + heading, and its icon is a disc rather than a chevron. Its card and readout + show its kind of mark with no speed or heading. AISStream now subscribes to + aid-to-navigation reports and names each by its kind of mark and its full + name; the feed lists every ship ahead of any aid, and `vessels_in_area` + counts ships only. A virtual aid (a mark broadcast where nothing physical + stands) keeps its disc, is never drawn as a buoy, and its card says it is + virtual; vessel sources may mark any record `virtual` (samehkhamis, #1733). diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index d3ca6ffce..3aa85f72c 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -224,7 +224,131 @@ 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. A host that does not initialize Core's locale passes +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 `localePicker: false` to `createApplicationControls` instead of editing the markup. Node tests that import application modules register the component loader with `node --import=/build/register-vue`. + +### Cockpit subjects + +Core's Cockpit rides a tracked aircraft, the tracked satellite and the +selected AIS vessel. An application adds kinds of its own with +`services.cockpitSubjects` on `createApplicationControls`: a list of +providers, checked once when the controls are built +(`src/cockpitSubjectProviders.js`). A malformed provider, or one with a member +that throws when read (a getter), is left out with a warning and the rest are +kept. A provider's subject takes entry when it was selected after every +built-in subject present (a tracked aircraft or satellite, a selected vessel), +whose own fixed order is unchanged, and needs the provider's `layerId` on; the +provider then holds its subject, hands the camera a frame and the generic HUD +a view model on every update, and releases it on exit. Provider methods are +synchronous and are called on the provider, so a class instance's methods keep +their `this`; one that throws or returns a promise is treated as having +failed. What a method returns may carry getters: one that throws when read +makes a candidate none, a frame malformed (the view ends) and a HUD view keep +the last; a worded member (labels, entry face, look) that throws keeps its +last good words. + +```js +import { getLocale, onLocaleChange } from 'gods-eye-view/i18n'; +import { pointAtS } from 'gods-eye-view/layers/rail/paths'; + +const words = { + en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' }, + de: { + view: 'Fahrtansicht', + exit: 'Fahrtansicht verlassen', + exitText: 'VERLASSEN', + }, +}; + +/** Ride the Rail layer's selected train; `rail` is its layer instance. */ +const ride = { + kind: 'example-ride', + layerId: 'rail', + entryFace: { icon: 'train', label: 'RIDE' }, + // A function is read again when the page's language changes. + labels: () => words[getLocale()] ?? words.en, + candidate() { + const train = rail.getSelectedTrain(); + return ( + train && { + id: train.key, + name: train.label, + selectedAt: train.selectedAtMs, + } + ); + }, + // Only a train bound to its line can be ridden: read() needs its path. + isEntryAllowed: () => Boolean(rail.getSelectedTrain()?.path), + hold: (id) => rail.setCockpitSubject(id), + read(nowMs, ctx) { + const train = rail.getCockpitSubject(); + if (!train?.path) return null; // ends the view + const point = pointAtS(train.path.line, train.path.s); + return { + id: train.key, + latitude: point.lat, + longitude: point.lon, + heightM: train.position.railHeightM, // null stands it on the shared floor + name: train.label, + headingDeg: + train.path.direction < 0 ? point.bearingDeg + 180 : point.bearingDeg, + speedMps: train.motion.reportedSpeedMps ?? train.motion.derivedSpeedMps, + // The layer already moves the train smoothly: sit on it. + anchor: 'exact', + eye: { upM: 3.5 }, + }; + }, + hud: (frame) => ({ title: frame.name }), + release(id, { restore }) { + rail.setCockpitSubject(null); + if (restore) rail.focusTrain(id); + }, +}; + +createApplicationControls({ /* ... */ services: { cockpitSubjects: [ride] } }); +``` + +The `./i18n` entry gives `getLocale()` and `onLocaleChange()`: +Core translates only its own strings, so a provider words its labels, entry +face and HUD for the page's language (labels may be functions, as above; the +HUD is read again on a change). `gods-eye-view/layers/rail/paths` is the +Rail layer's pure path module (`createRailPath`, `pointAtS`, `projectOnPath`) +for a provider placing a train along its line. The frame, HUD view and +candidate fields are documented in `src/cockpitSubjectProviders.js`; the Rail +and Transit read APIs in `src/layers/rail/cockpitSubject.js` and +`src/layers/transit/cockpitSubject.js`. A snapshot read again while nothing in +it changed is the one read before, so `read` may take one on every update; a +train's `path.line` is a frozen copy of its line, for `pointAtS` and +`projectOnPath`. The `layers/rail/paths` export and the +Rail and Transit read APIs (`getSelectedTrain`, `getSelectedVehicle`, +`getCockpitSubject` and the snapshots they return, `path.line` among them) +are experimental and may change without notice. + +A candidate's `selectedAt` is a token that changes each time the user selects +the subject, even the same one again, so entry counts it as selected then; a +new `id` under the same token is the same selection (a train's selection +moving to another feed's copy of it). The Rail and Transit snapshots give one, +`selectedAtMs`: when the user last selected the train or vehicle by a click or +`focusTrain`/`focusVehicle`. It is null after a deselection and for one +selected only by a hold, since a hold is not the user's selection. + +A frame with `heightM: null` stands on the shared floor, or where it last +stood while that floor is cold. If no height has been known since entry, the +view waits, showing ACQUIRING SURFACE, for at most five seconds +(`COCKPIT_GROUND_WAIT_TIMEOUT_MS`), then ends and releases the subject. + +`hold(id)` returns `true` once it holds the subject, or `false` to refuse +entry; any other answer, `undefined` included, refuses entry too, with one +warning. A refused hold, and one that throws or returns a promise, is followed +by `release(id, { restore: false })`, again once a returned promise settles, +so `release` must be harmless for a subject it does not hold and when called +more than once. + +Leaving the view does not track again an aircraft that was tracked before +entry, as with the bridge and orbital views: when the user leaves by the exit +control, the map goes back to the ridden subject through +`release(id, { restore: true })`. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d5c22fd05..8a2050a1a 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1706,7 +1706,7 @@ Applications supply the existing floor/snap, geoid, picking, sprite, camera, trail, focus, readout, context, aircraft presentation and render services. The factory never constructs a second application registry. Configure a source before initialization; replacing it while initialized is rejected. Model loads -use the supplied asset resolver, including the preload path. Enrichment receives +use the supplied asset resolver, including the up-front fleet set load. Enrichment receives an abort signal and cannot update a later lifecycle after destruction. Existing camera, terrain floor, trail, selection and measured model-size policies remain. @@ -2208,6 +2208,83 @@ the subject (tracking stopped, Satellites off) or tracking another target ends the view without refocusing. `src/ui/cockpitOrbitalView.test.mjs` covers entry, camera, zoom, look, HUD and exit. +### Application subjects + +An application can add its own first-person subjects beside the aircraft, +vessel and satellite: `services.cockpitSubjects` to `createApplicationControls`, +a list of subject providers (`src/cockpitSubjectProviders.js`, checked once by +the coordinator; a malformed one, or one with a member that throws when read, +is left out with a warning and the rest are kept). A provider names +its layer, an entry face and labels in the page's language, and supplies four +operations: `candidate()` (what entry would ride now), `hold(id)`, +`read(nowMs, ctx)` (a camera frame each update) and `hud(frame, ctx)` (a HUD +view model), with `release(id, {restore})` on exit. Entry needs the provider's +layer on, no Context change in flight and the provider's own +`isEntryAllowed(candidate)`; voice stays aircraft-only and is refused, with +the reason, while a provider's view is in use. + +The built-in kinds keep their fixed entry order (a tracked aircraft, then the +tracked satellite, then the selected vessel), so without providers entry is +unchanged. A provider's candidate takes entry only when it was selected after +every built-in subject present, by when the Cockpit first saw it or saw it +selected again (its `selectedAt` changing): a train picked after an aircraft +was tracked takes entry, and an aircraft tracked after it takes it back. +Providers' candidates are ordered most recently selected first, then by +priority; one first seen together with a built-in subject comes after it. The +first in that order decides alone: when its gate is shut there is no entry +rather than the next subject's. + +A frame places the eye `eye.upM` above `heightM` (the shared floor where the +frame gives none, or where it last stood while that floor is cold; with +neither, the camera stays where it is and the HUD reads ACQUIRING SURFACE for +at most `COCKPIT_GROUND_WAIT_TIMEOUT_MS`, after which the view ends and the +subject is released: no height stands in for a floor never read), slews toward `headingDeg` at the provider's rate, and may +turn off the floor clamps (`groundClamp: false`, for a subject inside a +structure) or keep the anchor exactly on the subject (`anchor: 'exact'`, for a +layer that already moves it smoothly; a frame with no `speedMps` is always +exact, since an inertial anchor runs on at the frame's speed); `cameraIntervalMs: 0` updates the +camera every rendered frame. A frame that is null, malformed or for another +subject ends the view (`restoreTracking: false`), as an aircraft tracked +meanwhile and the provider's layer turning off do; the exit control asks the +provider to restore its subject. The generic HUD shows the provider's kicker, +title, meta line, speed with its unit and tag, readout cards, always-visible +source rows, banner, route card and Live Signals rows, a veil that darkens the +picture under the HUD (with passing light streaks, which stop under reduced +motion), arrow-key look-around when the provider asks for it, and a progress +line along the bottom edge (`src/cockpitProgress.js`: close ticks merge, +labels never overlap). Core translates none of the provider's text: a host +words it for the page's language (`gods-eye-view/i18n`: +`getLocale`, `onLocaleChange`). Its entry face, labels and look may be +functions, read again when they are shown and on a change of language, as the +HUD view is. + +The Rail layer offers a read API for such a view: `getSelectedTrain()` and +`getCockpitSubject()` (a snapshot of the train: where it is along its line, +which way it runs (`pathBinding.js` learns it), its rail height, speed as +reported or derived, position source, feed credit and stops, and +`selectedAtMs`, when the user last selected it by a click or `focusTrain`, +even again; null after a deselection and for a train only a hold selected), +`setCockpitSubject(key)` (its marker, 3D model and card hidden, the selection kept, its +feed held, clicks and Escape left to the view; the held copy stays the one +drawn of its train while held, another feed's copy hidden, and it keeps +moving, still selected, when its category is turned off) and +`focusTrain(key)` (false for a train the layer does not draw); its pure path +module is exported as `gods-eye-view/layers/rail/paths`. That export +and these read APIs are experimental and may change without notice. A snapshot read again while nothing in it changed is +the one read before, and its `path.line` is a frozen copy. Transit +offers the same for a vehicle (`getSelectedVehicle`, `getCockpitSubject`, +`setCockpitSubject`, `focusVehicle`): a held vehicle keeps playing when it is +out of the camera's view, and its sprite, 3D model, card, trail and detection +box are hidden; released, it is out of play until a visibility sweep finds it in +view. Its snapshot's `position.groundHeightM` is the floor where playback draws +it (the drawn marker's height while it is placed, else its segment's two +reports' floors at the playback fraction), not the newest report's, and null while that floor is unknown. Rail's `path.direction` is the +way the drawn train runs: once playback has placed it, a report that runs back +turns it only when playback reaches that report. `docs/APPLICATION.md` (Cockpit subjects) shows a minimal provider. `src/ui/cockpitSubjectView.test.mjs`, +`src/cockpitSubjectProviders.test.mjs`, `src/cockpitProgress.test.mjs`, +`src/layers/rail/cockpitSubject.test.mjs` and the Transit lifecycle tests +cover them. + ## Context coordination Context controls own Contacts/Space Missions state, entry and exit transactions, @@ -2224,9 +2301,22 @@ 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. Disposal +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 releases listeners, subscriptions, image handlers, summary timers and chip-hide timers. +The panel opens when a camera becomes active and collapses when the layer is +switched off, from its own switch or anywhere else. The panel chrome applies +the collapse, so a panel the desktop accordion only folded has its saved and +shared state closed too. Clear selected data layers and Context isolation +(Contacts, Space Missions) switch the layer off without the user asking: the +panel and its share preference stay as they are, and the layer comes back to an +open panel. Only the switch-off isolation makes counts (`suspendsLayer`, true +while `_clearLayersOutsideContextMode` switches the layer off): the user +switching CCTV off inside Contacts closes the panel as anywhere else. A panel opened by hand while the layer is off stays open, and its +meta line reads the catalog count, not the last active camera. + ## UI disposal UI disposal releases the CCTV subscription, command-dock observer, legacy drag @@ -2418,8 +2508,9 @@ is validated against Cesium 1.138.0 and must be revalidated on upgrades. `scripts/qa-anime-sky.mjs` checks asset readiness, visible Normal sky and Anime band coverage against a Vite dev server. -`scripts/qa-sky-model-lighting.mjs` compares a lit `airplane.glb` against -black and white environment maps in isolated scenes. +`scripts/qa-sky-model-lighting.mjs` compares a lit fleet airliner +(`public/models/fleet/aircraft/a320.glb`) against black and white environment +maps in isolated scenes. ## Model atmosphere on Apple Metal @@ -2440,6 +2531,53 @@ future driver fix restores the effect with no code change; iOS/iPadOS platform detection is the backstop for when no probe context can be created. Applied during viewer construction, before any tile builds a draw command. +## Render failure notice + +When the globe stops drawing, the scene chrome says so instead of leaving a +black globe behind working controls. `installRenderFailureNotice()` +(`src/ui/renderFailure.js`) listens for Cesium's `renderError` and the canvas' +`webglcontextlost`, logs the error, and publishes a notice that +`RenderFailure.vue` shows as an alert dialog with focus on its first action. +The app's viewer uses `showRenderLoopErrors: false`, so Cesium's dead-end +"Rendering has stopped" panel no longer appears; `applicationViewerOptions()` +keeps it by default for other callers. + +- **Retry** restarts the render loop. The notice stays until a frame starts + and ends without an error: Cesium raises `postRender` after a failed frame + too. +- **Reload**, after a lost context, reloads the page; the address keeps the + view. +- **Reset view** opens `appRootUrl()`, without the hash or `/share/` + path; the query, such as `?lang=`, is kept. + +Pitfalls: + +- An error thrown outside Scene's error handling (a frame listener, `onTick`, + a resize) stops the loop without a `renderError`. The notice wraps the + widget's `render()` and `resize()` and raises it as one, once even with + `rethrowRenderErrors`. +- A throwing listener leaves its Cesium event mid-raise, with listener changes + only queued. The wrapper finishes those raises first (`finishRaise()` over + `frameEvents()`), so a layer turned off or on meanwhile behaves. +- A render error marks the loop stopped while its next frame is still queued. + The wrapper keeps the loop marked running, so a Retry or a shown tab + (`src/app/tools.js`) before that frame starts no second loop. +- After a lost context, later render errors are only logged, so the notice + keeps saying why. The listener neither prevents nor restores the loss. +- When the globe draws again, focus goes back where it was, or to the globe's + canvas, which the map's keys read as the map; the canvas is focusable only + until focus leaves or the globe is pressed. Focus the user moved stays put. +- The notice sits above floating panels such as the Québec camera viewer, and + its buttons are 44 px tall on touch screens. + +An inline conversation panel never reloads or navigates its host's page: it +offers Retry after a render error only, and no notice after a lost context, +where the panel runtime's own notice applies (`src/app/globePanelRuntime.js`). +Its timer (`keepPanelRendering()`) draws only while the render loop is on and +logs nothing itself, so each render error is logged once, by the notice. An +error a timer frame throws is caught there: it has already been raised as a +render error. + ## Map Source control ownership Map Source controls own chip listeners, source-state subscriptions and selection @@ -3199,6 +3337,15 @@ 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 @@ -3595,20 +3742,22 @@ This is the current runtime/source-of-truth snapshot for the project. > 24.09 m aft (70 % of the model's rendered envelope, so inside it), 2.22 m > below centre (6 %), and 2e-9 m off the centreline. > - **The tracked trail attaches to REAL HULL, aft and below (2026-08-23):** -> `MODEL_TRAIL_ANCHOR_NATIVE` (`src/data/modelVisualAnchor.js`) holds, per GLB -> and in RAW glTF coordinates (see the transform-chain entry below), the point -> of the hull's CENTRELINE PROFILE (its y = 0 slice) closest to the aft-belly -> AABB corner. It is NOT the corner: a bounding-box corner is empty -> space, 4.80 m off the nearest triangle on `airplane.glb` and 6.14 m on -> `jet.glb`, so the trail ended in mid air beside the aircraft. The head segment +> `FLEET_TRAIL_ANCHORS` (`src/data/fleetTrailAnchors.js`, generated by +> `scripts/measure-fleet-trail-anchors.mjs`) holds, per fleet aircraft and +> helicopter model and in RAW glTF coordinates (see the transform-chain entry +> below), the point of the hull's CENTRELINE PROFILE (its centreline slice) +> closest to the aft-belly AABB corner. It is NOT the corner: a bounding-box +> corner is empty space (4.80 m off the nearest triangle on the airliner +> asset this was first measured on, 6.14 m on the jet), so the trail ended in +> mid air beside the aircraft. The head segment > is drawn from a point behind the aircraft to this anchor, so the anchor must > stay aft (83–96 % of each aft extreme) or the segment enters at the tail and -> stops inside the fuselage. `modelScale.test.mjs` reads the POSITION BUFFERS — +> stops inside the fuselage. `fleetModels.test.mjs` reads the POSITION BUFFERS — > real vertices and triangles, because accessor min/max cannot tell a corner > from a surface — and re-derives on-hull, aft, lowest-at-its-station, > on-centreline, and the construction itself. Re-measure, never re-guess. > - **The trail anchor rides CESIUM'S transform chain, not a hand-rolled one -> (2026-08-23 regression fix):** `MODEL_TRAIL_ANCHOR_NATIVE` stores RAW +> (2026-08-23 regression fix):** `FLEET_TRAIL_ANCHORS` stores RAW > glTF coordinates and `modelAnchorWorld()` assembles > `modelMatrix × components.transform × axisCorrection` — the same chain > `ModelSceneGraph` renders with, built from Cesium's own exported @@ -3643,7 +3792,7 @@ This is the current runtime/source-of-truth snapshot for the project. > ENVELOPE, and between them the drawn start slides along the segment so the > visible length grows CONTINUOUSLY from zero. Three earlier cuts are recorded > because their shape matters: testing segment LENGTH against the radius HID -> real trail (58 m aft of `airplane.glb`'s 34.41 m envelope gives a 33.98 m +> real trail (58 m aft of an airliner's 34.41 m envelope gives a 33.98 m > segment, suppressed, though ~23.6 m of it is open air); CLIPPING at the > envelope and keeping only what lay outside stopped a moving trail visibly > SHORT of the aircraft, since a bounding sphere encloses a lot of empty space @@ -4819,8 +4968,9 @@ activeCameraCardEnabled: true })` may publish it through the retained > - **Flights 3D/cockpit hardening:** the destination-direction cue is an > inline SVG rather than a remotely loaded Material Icons ligature, so a > blocked or late font can no longer expand the literal word `navigation` -> across the cockpit. The grounded `airplane.glb` belly offset is calibrated -> to the current asset's measured Y-up bounds (`0.063` native units). +> across the cockpit. A grounded aircraft stands its fleet model's scaled +> clearance (the catalog's `clearance`, measured below the model's origin) +> above the ground. > Contact Previous/Next and Live Signals aircraft handoffs now > re-seed the first-person camera anchor at the selected flight immediately; > bounded feed-correction smoothing remains scoped to the same aircraft. @@ -5069,6 +5219,10 @@ Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, * views re-render, code that published translated text words it again through `onLocaleChange`, and ``/`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 ``: the title, style indicator, both panel stacks and their icon rails swap sides through logical properties (`inset-inline-*`, @@ -6603,6 +6757,10 @@ 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 @@ -7304,8 +7462,9 @@ pins and share links already supply complete boxes. normal four-samples-per-250-ms budget. Retry timestamps more than the maximum 30-second backoff ahead of the current clock are ignored. A smaller rollback can retain a bounded retry delay of up to 30 seconds. -- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Since 2026-08-16 (Hangar fleet) models are PER-CLASS: real CC-BY GLBs for light/bizjet/turboprop/widebody/helicopter/uav (`CLASS_MODEL_REAL` in `src/data/aircraftClass.js` — meters-baked, scale 1, per-model belly/radius; provenance in `public/models/README.md`), the shared `airplane.glb` for the remaining civilian classes, and the military layer maps weight classes (real GLBs / 747 heavies / `jet.glb` fastjets, per-model heading offsets, flat amber or emergency red). Textured civilians carry a HEAVY tint, not a light one: `MODEL_COLOR_BLEND_AMOUNT` is `0.94` in BOTH layers under Cesium's `ColorBlendMode.MIX`, so the class colour supplies 94% of the surface and the asset's own texture ~6%. The visual direction is clean light silhouettes with only a weak diffuse contribution from the approved textures, so liveries deliberately do NOT read. IR boost raises the blend to a full `1.0`. Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. -- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). With the class colour supplying 94% of the surface, the models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; the two preload calls only warm the glTF cache and are never drawn. +- Commercial and military aircraft use the same high-level FLEET model regime: 2D billboards when zoomed out, optional glTF models when closer, controlled by the DISPLAY rail `3D` toggle and `Proximity` / `All` modes. Every model is one of the project's procedural fleet models (`public/models/fleet/`, MIT, 122 GLBs, about 2 MB in all; `public/models/README.md`), chosen per aircraft by `aircraftModelSpec` (`src/data/fleetModels.js`): the first of its ICAO type's callsign rules in `types.json` (`aircraft_callsign`) its callsign matches, else its type's entry, else the model of its `classifyAircraft` class for a type the map lacks, else its ADS-B emitter category's, else the default airliner. It is drawn LIFE SIZE: the type's published length over the model's built length (`src/layers/flights/modelSpec.js`, shared by Flights, Military Flights and Local ADS-B), and a grounded aircraft stands the model's scaled clearance above the ground. An airport ground vehicle (feed type `ZVEH`) is a fire truck (`FIRE…`), pushback tug (`PUSH…`, `TUG…`), van (`DEICER…`, `SQUID…`, `SQB…`) or else a car by its callsign, and a ground station (`GRND`) is an 11.8 m radio mast; these models (catalog category `other`) are drawn at their built size, and such a contact with no altitude stands on the ground instead of at the airborne default (`src/layers/flights/records.js`). `classifyAircraft` still picks the 2D icons. The whole set loads once, up front, when a layer that draws it starts (`loadFleetModelSet`, which logs `[Fleet] N/122 models (X MB) loaded in N ms`); models are built from those bytes, never fetched mid-session, and an aircraft stays its billboard until the set has loaded. Every aircraft model draws its mesh's own colours (`aircraftModelBlend`, `src/data/aircraftModelLighting.js`): Cesium's `ColorBlendMode.HIGHLIGHT` with white, in Flights, Military Flights and Local ADS-B alike; the tracked colour (cyan, or amber in Military Flights) and the emergency red multiply into the mesh, so its paint still shows. IR boost draws them flat white (`MIX` at `1.0`). Under NVG/FLIR (map preset or Cockpit vision) models render unlit flat-white at full alpha and scene fog is disabled (fog otherwise blacks out distant models with the globe hidden); state restores on exit. +- Vessels, Transit and Rail draw fleet models under the same DISPLAY rail `3D` toggle, at the same altitude ceiling, caps and add/keep radii as the aircraft (`FLEET_MODEL_POLICY`, `src/layers/flights/policy.js`; `createFleetModelLayer`, `src/data/fleetModelInstances.js`). A vessel's model follows its AIS ship type and is drawn at its reported length and beam (AIS static dimensions A+B and C+D, now carried on vessel rows as `length` and `beam`), standing at the sea surface, heading by true heading then course. An aid to navigation is not a hull: a vessel record whose type names a buoy, beacon, mark, RACON or light, or whose MMSI is an aid-to-navigation station's (`99MIDxxxx`), is `isAidToNavigation` (`src/data/vesselTypes.js`) and is drawn as the set's `buoys` model of the IALA mark its type names (`aidToNavigationModel`, `src/data/fleetModels.js`: cardinal N/E/S/W, port or starboard hand, preferred channel by its side, safe water, isolated danger, special mark; a beacon, light, light vessel, RACON or reference point with no mark of its own is the special mark), else a red can (`DEFAULT_AID_MODEL`), at its built size and facing north whatever heading it reports. A type is an aid's when it is one of the 32 kinds an aid report names or uses a word for one (buoy, beacon, cardinal, safe water, RACON, lighthouse and so on); a vessel that works on aids (`Buoy Tender`, `Buoy Layer`) is not one. Its icon is a bone-white ringed disc (`AID_TO_NAVIGATION_STYLE`, `src/data/vesselLabels.js`) instead of a chevron, never rotated, and its card, selected card and HUD readout (`src/layers/vessels/cards.js`) show its kind alone, whole, in that colour: no speed or heading, since a fixed mark has neither. AISStream subscribes to `AidsToNavigationReport` (message 21) by default, and the server names each report's aid type code (0 to 31) as its kind (`aidToNavigationTypeName`: `20` is `Cardinal Mark N`) rather than reading it as a ship type, and its name runs on into the report's `NameExtension`. A virtual aid (the report's `VirtualAtoN` flag: a mark broadcast where nothing physical stands) carries `virtual: true` on its row, and on its vessel source record (the live-source contract's `virtual`, `src/sources/live/contract.js`, false unless a source says true; any source may set it). It keeps its disc but never gets a model (`vesselModelSpec` returns null for it, and the vessel layer gathers no model item for it), and its card and readout add `VIRTUAL` after its kind. `aisStreamRows` (`server/providers/vessels/ais-store.js`) puts every vessel ahead of every aid, so a capped request (the layer asks for 12,000 rows) never gives up a ship for a buoy. The `vessels_in_area` tool (and the brief built on it) counts ships only: it leaves aids out of its rows, counts, breakdowns and speed stats. A transit vehicle's model follows its layer mode (bus, tram, subway, rail or ferry; a vehicle of `unknown` mode keeps its icon), a train's its rail category and mode (rail, metro or tram). Neither the transit nor the rail sources carry a GTFS `route_type` or a finer mode (monorail, gondola, funicular, cable car), so the set's models for those and their `transit.elevation` lifts are not reached by any live source today. Both stand on the road or rail top they are drawn on, without the 1.5 m `GROUND_FLOOR_LIFT_M` their icons carry, and are painted in their mode or category colour where the model's `_TINT` attribute marks it. A vessel model is clipped at its waterline (one clipping plane per model, at the model origin's horizontal plane), so its hull below the water never shows through a plain globe, which Cesium does not depth-test models against. Under the NVG/FLIR styles (the Flights `irBoost` parameter) these models render unlit flat white, as aircraft models do. Each vehicle's icon stays the visual until its model is loaded, ready and placed; a model is picked as its icon is, and a vehicle drawn as its model keeps its selection card and its place in the detection overlay. +- **Aircraft models skip Cesium's dynamic environment map (2026-10-24).** Flights, Military Flights and Local ADS-B pass `environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS` (`{ enabled: false }`, `src/data/aircraftModelLighting.js`) to every drawn model. Otherwise each `Model` renders the sky around it and reads it back from the GPU (`readPixels`) when it is first placed, and again whenever it moves more than 1 km, the scene's atmosphere settings change, or (under sunlight dynamic lighting) an hour of scene time passes (`DynamicEnvironmentMapManager`, Cesium 1.138). The models are lit from Cesium's default spherical harmonics instead, with no specular environment map, so the sky's model-lighting cube map no longer reaches aircraft. `src/data/aircraftModelLighting.test.mjs` pins the option at every drawn model call; every model is built from the fleet set loaded up front, so there is no separate preload. - The DISPLAY-rail `3D` toggle is the user-facing activation path for both aircraft layers. Their small approved GLBs build their render resources without Cesium's frame-spread job queue, preventing continuous Photorealistic 3D Tiles streaming from starving model readiness; model caps, tracking, camera, and fallback billboards are unchanged. - **The `3D` toggle DEFAULTS ON in `proximity` on a first run.** Proximity is itself the budget — models appear only below `MODEL_ALT_CEIL_M` and only for the nearest `MODEL_MAX` in view — so the default costs nothing at globe scale, and `all` remains a deliberate opt-in. A fresh boot runs NO layer-state restoration (`LayerStateCoordinator.start()` returns early with neither a share payload nor stored state), so four independent initializers decide what a first-run operator sees and must agree: `booleanOption('models3d', 'e', true)` in `src/data/layerState.js`, `_models3dEnabled = true` in BOTH flight layers, `this._models3dEnabled = true` in `src/ui/aircraftDisplay.js`, and the `active` / `visible` classes on `#models3d-toggle` / `#models3d-mode-row` in `index.html`. All four are pinned together in `src/data/layerState.test.mjs`. Explicit state still wins: because the codec omits default-valued options, `models3d: false` is now what travels in a link (`lo=…f.e.0`) and restores OFF at both aircraft layers. **Consequence for returning users:** a stored `gev:layer-state:v2` blob is a FULL options snapshot, so a session that wrote one before this change carries `models3d:false` and keeps 3D off until the operator flips it (or clears the key) — the durable snapshot is treated as the recipient's own state, by design. - **Consequence of the flip on the recorded tracked/fleet inversion:** the 150–800 km band where surrounding contacts draw as models while the SELECTED one is still a glyph is now what an operator sees WITHOUT arming anything. The inversion itself is unchanged and still deliberate (see `src/data/trackedModelRegime.js`); only its reachability changed. diff --git a/docs/I18N.md b/docs/I18N.md index 0d57f14d5..bacfdc509 100644 --- a/docs/I18N.md +++ b/docs/I18N.md @@ -59,6 +59,30 @@ 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 '/i18n'; + +startPageLocale(document); // before any view renders: ?lang=, stored choice, browser, English; translates data-i18n markup +const stopFollowing = followPageLocale(document); // /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 `${…}`. @@ -109,6 +133,7 @@ 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/.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 | diff --git a/docs/translations/zh-Hant.md b/docs/translations/zh-Hant.md index ac355516b..d44c510c4 100644 --- a/docs/translations/zh-Hant.md +++ b/docs/translations/zh-Hant.md @@ -4,7 +4,7 @@ Catalog: `src/i18n/locales/zh-Hant.json`. General rules: [TRANSLATORS.md](../TRA `zh-Hant` is the Simplified Chinese catalog (`zh-CN`, credited to abiyeenzo, #914, and EDIT-Aeterna, #979) written in Traditional characters with OpenCC character conversion. Wording and terminology are the same as in `zh-CN`: change a term in [zh-CN.md](./zh-CN.md) and the `zh-CN` catalog first, then convert. Only the script differs. -After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布. +After `s2tw` conversion, fix the characters OpenCC picks wrongly: 年曆史 → 年歷史, 下遊 → 下游, 並道 → 併道, 發佈 → 發布, 公佈 → 公布, 控制檯 → 控制台, and 周 → 週 where it means a week (1 週/秒). ## Style diff --git a/package.json b/package.json index 6c11697bc..7559b1ddd 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "./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" }, @@ -297,6 +298,7 @@ }, "./sources/rail-service": "./src/sources/railService.js", "./layers/rail/source": "./src/layers/rail/source.js", + "./layers/rail/paths": "./src/data/railPaths.js", "./sources/map-features": "./src/sources/overpassFeatures.js", "./server/providers/mapillary": { "node": "./server/providers/mapillary.js" diff --git a/public/models/README.md b/public/models/README.md index aa7f40fbd..3f97820d3 100644 --- a/public/models/README.md +++ b/public/models/README.md @@ -1,20 +1,39 @@ -# Bundled 3D Model Attribution +# Bundled 3D Models -The model files in this directory are third-party visual assets. They are not -covered by the repository's MIT source-code license; each remains available -under the license listed below. +Every 3D model shipped in this directory is one of the project's procedural +fleet models under [`fleet/`](fleet/): aircraft, helicopters, ships, buoys, +transit and rail vehicles, and a few other airborne and ground objects. They +are generated from code by the project maintainers, contain no third-party +artwork, and are covered by the repository's [MIT license](../../LICENSE) +like the source code. -| File | Original work and creator | Source | License | Project modifications | -|---|---|---|---|---| -| `airplane.glb` | “boeing 747” by [zairiq-123](https://sketchfab.com/zairiq-123) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-747-9b16672038ba48f98e6d80a159044ed9) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Substantially modified and optimized for God's Eye View, including geometry/material simplification and coordinate/orientation preparation. The former 24× runtime calibration is baked into the mesh; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses glTF +Y-up with its nose toward local −X. Geometry remains uncompressed so the aircraft asset does not compete with photogrammetry for Draco worker capacity. | -| `jet.glb` | “Private Jet” by [Nick the Name](https://sketchfab.com/Nick_The_Name) | [Sketchfab model](https://sketchfab.com/3d-models/private-jet-cbdd1de6ced9461e950eafaa302cc82b) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Repackaged as a glTF binary for the project's military-flight visualization. Imported hierarchy transforms are baked into the meshes; location, rotation, and scale are applied; the bounding-box centre is at the origin; and the model uses meter scale, glTF +Y-up, and the shared nose −X convention. Existing materials are preserved. | -| `bell206.glb` | “Bell 206 JetRanger” by [terran4627](https://sketchfab.com/terran4627) | [Sketchfab model](https://sketchfab.com/3d-models/bell-206-jetranger-d2f7ba1d671549d4b26aaf834139a1dd) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `c172.glb` | “Cessna 172” by [e737](https://sketchfab.com/e0057537) | [Sketchfab model](https://sketchfab.com/3d-models/cessna-172-64cddaee5aff470682659a8c08525046) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `citation2.glb` | “1990 Cessna Citation, Texture Detailed, Exterior” by [BlenderCommunityHead](https://sketchfab.com/aboodgoudagad) | [Sketchfab model](https://sketchfab.com/3d-models/1990-cessna-citation-texture-detailed-exterior-a78839624fe64900a8352cb23462350a) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `mq9.glb` | “MQ-9” by [IProZenoN](https://sketchfab.com/IProZenoN) | [Sketchfab model](https://sketchfab.com/3d-models/mq-9-fabe963feb354c5584b51f9c470c3f7e) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `b789.glb` | “Boeing 787-9” by [Nobilis 2](https://sketchfab.com/nobilishornet2) | [Sketchfab model](https://sketchfab.com/3d-models/boeing-787-9-b6711e2e698e4e469675c1154a50b7a3) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures resized to 256 px WebP, orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | -| `atr72.glb` | “ATR 72 - 600” by [Oyan3D](https://sketchfab.com/oyan3D) | [Sketchfab model](https://sketchfab.com/3d-models/atr-72-600-1e1a7186f7444d288675262fcee44744) | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) | Optimized for God's Eye View: geometry/material simplification, textures removed with dominant material colors baked into PBR factors (flat “abstracted” style), orientation/scale vertex-baked to real-world meters (Y-up, nose −X), origin centered. | +| Path | What it holds | +|---|---| +| `fleet/catalog.json` | Every model by name: its file, category, built length, beam and bounds (metres, in Cesium's Z-up model frame: x along the length with the nose toward −X, y across the beam, z up), the clearance below its origin, and its 2D icon class. | +| `fleet/types.json` | Which model draws which vehicle, and at what length: ICAO aircraft and helicopter type codes, ADS-B emitter categories, AIS ship-type and length ranges, GTFS route types, transit modes and rail categories, with the defaults. | +| `fleet/aircraft/`, `fleet/helicopters/`, `fleet/ships/`, `fleet/buoys/`, `fleet/transit/`, `fleet/other/` | The models, one GLB each (EXT_meshopt_compression and KHR_mesh_quantization, vertex colours, no textures). | -CC BY 4.0 permits sharing and adaptation, including commercial use, provided -appropriate credit is retained, the license is linked, and modifications are -identified. These credits do not imply endorsement by the original creators. +How the layers use them: + +- Each layer draws a vehicle with the model its type selects, scaled to the + type's published length (a vessel to its reported AIS length and beam), so + every model is drawn life size. Ground objects, balloons and obstacles in + the `other` category are drawn at the size they are built. +- An aid to navigation (a vessel record whose type names a buoy, beacon, IALA + mark, RACON or light, or whose MMSI is an aid station's `99MIDxxxx`) is + drawn as the `buoys` model of its mark (cardinal north, east, south or west, + port or starboard hand, safe water, isolated danger, special mark), a red + can when its type names no mark, at its built size and with no heading + (`vesselModelSpec`, `aidToNavigationModel`). +- An aircraft model's origin is its axis; it stands that model's scaled + clearance above the ground. A ship's origin is its waterline, and a transit + or rail vehicle's the road or rail top (a monorail or gondola is raised by + its `transit.elevation` entry, for a source that names those modes). +- Transit and rail models carry a `_TINT` vertex attribute that marks where + the route or category colour is painted. +- The whole set loads once, up front, when a layer that draws it starts; no + model is fetched while a session runs. + +`src/data/fleetModels.js` reads the set and chooses each vehicle's model; +`src/data/fleetModels.test.mjs` checks that every model `types.json` names is +in the catalog and on disk, and that no GLB exceeds 3 MB. diff --git a/public/models/airplane.glb b/public/models/airplane.glb deleted file mode 100644 index 016a56f1d..000000000 Binary files a/public/models/airplane.glb and /dev/null differ diff --git a/public/models/atr72.glb b/public/models/atr72.glb deleted file mode 100644 index 8946dc39c..000000000 Binary files a/public/models/atr72.glb and /dev/null differ diff --git a/public/models/b789.glb b/public/models/b789.glb deleted file mode 100644 index be9849d74..000000000 Binary files a/public/models/b789.glb and /dev/null differ diff --git a/public/models/bell206.glb b/public/models/bell206.glb deleted file mode 100644 index 029fe1339..000000000 Binary files a/public/models/bell206.glb and /dev/null differ diff --git a/public/models/c172.glb b/public/models/c172.glb deleted file mode 100644 index 3c5155433..000000000 Binary files a/public/models/c172.glb and /dev/null differ diff --git a/public/models/citation2.glb b/public/models/citation2.glb deleted file mode 100644 index 2ec47d6dc..000000000 Binary files a/public/models/citation2.glb and /dev/null differ diff --git a/public/models/fleet/aircraft/a320.glb b/public/models/fleet/aircraft/a320.glb new file mode 100644 index 000000000..017d8ed2c Binary files /dev/null and b/public/models/fleet/aircraft/a320.glb differ diff --git a/public/models/fleet/aircraft/a333.glb b/public/models/fleet/aircraft/a333.glb new file mode 100644 index 000000000..7daeb8319 Binary files /dev/null and b/public/models/fleet/aircraft/a333.glb differ diff --git a/public/models/fleet/aircraft/a343.glb b/public/models/fleet/aircraft/a343.glb new file mode 100644 index 000000000..a850cea4a Binary files /dev/null and b/public/models/fleet/aircraft/a343.glb differ diff --git a/public/models/fleet/aircraft/a359.glb b/public/models/fleet/aircraft/a359.glb new file mode 100644 index 000000000..745681b27 Binary files /dev/null and b/public/models/fleet/aircraft/a359.glb differ diff --git a/public/models/fleet/aircraft/a388.glb b/public/models/fleet/aircraft/a388.glb new file mode 100644 index 000000000..58e04f2d0 Binary files /dev/null and b/public/models/fleet/aircraft/a388.glb differ diff --git a/public/models/fleet/aircraft/at72.glb b/public/models/fleet/aircraft/at72.glb new file mode 100644 index 000000000..bbe129d7e Binary files /dev/null and b/public/models/fleet/aircraft/at72.glb differ diff --git a/public/models/fleet/aircraft/b738.glb b/public/models/fleet/aircraft/b738.glb new file mode 100644 index 000000000..6e4a94a38 Binary files /dev/null and b/public/models/fleet/aircraft/b738.glb differ diff --git a/public/models/fleet/aircraft/b744.glb b/public/models/fleet/aircraft/b744.glb new file mode 100644 index 000000000..f5f9e421d Binary files /dev/null and b/public/models/fleet/aircraft/b744.glb differ diff --git a/public/models/fleet/aircraft/b763.glb b/public/models/fleet/aircraft/b763.glb new file mode 100644 index 000000000..34c59a912 Binary files /dev/null and b/public/models/fleet/aircraft/b763.glb differ diff --git a/public/models/fleet/aircraft/b77w.glb b/public/models/fleet/aircraft/b77w.glb new file mode 100644 index 000000000..a19051300 Binary files /dev/null and b/public/models/fleet/aircraft/b77w.glb differ diff --git a/public/models/fleet/aircraft/b789.glb b/public/models/fleet/aircraft/b789.glb new file mode 100644 index 000000000..5474a55a4 Binary files /dev/null and b/public/models/fleet/aircraft/b789.glb differ diff --git a/public/models/fleet/aircraft/bcs3.glb b/public/models/fleet/aircraft/bcs3.glb new file mode 100644 index 000000000..422ebe700 Binary files /dev/null and b/public/models/fleet/aircraft/bcs3.glb differ diff --git a/public/models/fleet/aircraft/be20.glb b/public/models/fleet/aircraft/be20.glb new file mode 100644 index 000000000..d1a2c4dfd Binary files /dev/null and b/public/models/fleet/aircraft/be20.glb differ diff --git a/public/models/fleet/aircraft/c17.glb b/public/models/fleet/aircraft/c17.glb new file mode 100644 index 000000000..19c51a523 Binary files /dev/null and b/public/models/fleet/aircraft/c17.glb differ diff --git a/public/models/fleet/aircraft/c172.glb b/public/models/fleet/aircraft/c172.glb new file mode 100644 index 000000000..9a5b7d73f Binary files /dev/null and b/public/models/fleet/aircraft/c172.glb differ diff --git a/public/models/fleet/aircraft/c30j.glb b/public/models/fleet/aircraft/c30j.glb new file mode 100644 index 000000000..b5d4a7122 Binary files /dev/null and b/public/models/fleet/aircraft/c30j.glb differ diff --git a/public/models/fleet/aircraft/c56x.glb b/public/models/fleet/aircraft/c56x.glb new file mode 100644 index 000000000..2185dfe79 Binary files /dev/null and b/public/models/fleet/aircraft/c56x.glb differ diff --git a/public/models/fleet/aircraft/cl35.glb b/public/models/fleet/aircraft/cl35.glb new file mode 100644 index 000000000..f2df5f86b Binary files /dev/null and b/public/models/fleet/aircraft/cl35.glb differ diff --git a/public/models/fleet/aircraft/crj9.glb b/public/models/fleet/aircraft/crj9.glb new file mode 100644 index 000000000..24e6448b6 Binary files /dev/null and b/public/models/fleet/aircraft/crj9.glb differ diff --git a/public/models/fleet/aircraft/dh8d.glb b/public/models/fleet/aircraft/dh8d.glb new file mode 100644 index 000000000..f763fec21 Binary files /dev/null and b/public/models/fleet/aircraft/dh8d.glb differ diff --git a/public/models/fleet/aircraft/e175.glb b/public/models/fleet/aircraft/e175.glb new file mode 100644 index 000000000..acdc5230e Binary files /dev/null and b/public/models/fleet/aircraft/e175.glb differ diff --git a/public/models/fleet/aircraft/e55p.glb b/public/models/fleet/aircraft/e55p.glb new file mode 100644 index 000000000..b130d2a9e Binary files /dev/null and b/public/models/fleet/aircraft/e55p.glb differ diff --git a/public/models/fleet/aircraft/generic_plane.glb b/public/models/fleet/aircraft/generic_plane.glb new file mode 100644 index 000000000..a126c8b26 Binary files /dev/null and b/public/models/fleet/aircraft/generic_plane.glb differ diff --git a/public/models/fleet/aircraft/glf4.glb b/public/models/fleet/aircraft/glf4.glb new file mode 100644 index 000000000..31ddd2c74 Binary files /dev/null and b/public/models/fleet/aircraft/glf4.glb differ diff --git a/public/models/fleet/aircraft/glider.glb b/public/models/fleet/aircraft/glider.glb new file mode 100644 index 000000000..60b722726 Binary files /dev/null and b/public/models/fleet/aircraft/glider.glb differ diff --git a/public/models/fleet/aircraft/k35r.glb b/public/models/fleet/aircraft/k35r.glb new file mode 100644 index 000000000..7a7cfbe73 Binary files /dev/null and b/public/models/fleet/aircraft/k35r.glb differ diff --git a/public/models/fleet/aircraft/md11.glb b/public/models/fleet/aircraft/md11.glb new file mode 100644 index 000000000..885a81624 Binary files /dev/null and b/public/models/fleet/aircraft/md11.glb differ diff --git a/public/models/fleet/aircraft/mq9.glb b/public/models/fleet/aircraft/mq9.glb new file mode 100644 index 000000000..737912fc3 Binary files /dev/null and b/public/models/fleet/aircraft/mq9.glb differ diff --git a/public/models/fleet/aircraft/p28a.glb b/public/models/fleet/aircraft/p28a.glb new file mode 100644 index 000000000..ca47aec1a Binary files /dev/null and b/public/models/fleet/aircraft/p28a.glb differ diff --git a/public/models/fleet/aircraft/pc12.glb b/public/models/fleet/aircraft/pc12.glb new file mode 100644 index 000000000..2b2666a0a Binary files /dev/null and b/public/models/fleet/aircraft/pc12.glb differ diff --git a/public/models/fleet/aircraft/sr22.glb b/public/models/fleet/aircraft/sr22.glb new file mode 100644 index 000000000..7c2d66fe6 Binary files /dev/null and b/public/models/fleet/aircraft/sr22.glb differ diff --git a/public/models/fleet/aircraft/t38.glb b/public/models/fleet/aircraft/t38.glb new file mode 100644 index 000000000..b9c8cd9ee Binary files /dev/null and b/public/models/fleet/aircraft/t38.glb differ diff --git a/public/models/fleet/aircraft/tex2.glb b/public/models/fleet/aircraft/tex2.glb new file mode 100644 index 000000000..8a3ec3e00 Binary files /dev/null and b/public/models/fleet/aircraft/tex2.glb differ diff --git a/public/models/fleet/aircraft/ultralight.glb b/public/models/fleet/aircraft/ultralight.glb new file mode 100644 index 000000000..965d9cddd Binary files /dev/null and b/public/models/fleet/aircraft/ultralight.glb differ diff --git a/public/models/fleet/aircraft/v22.glb b/public/models/fleet/aircraft/v22.glb new file mode 100644 index 000000000..01021ca2d Binary files /dev/null and b/public/models/fleet/aircraft/v22.glb differ diff --git a/public/models/fleet/buoys/barrel_yellow.glb b/public/models/fleet/buoys/barrel_yellow.glb new file mode 100644 index 000000000..d2fb0e017 Binary files /dev/null and b/public/models/fleet/buoys/barrel_yellow.glb differ diff --git a/public/models/fleet/buoys/buoy_cardinal_east.glb b/public/models/fleet/buoys/buoy_cardinal_east.glb new file mode 100644 index 000000000..a0053edb0 Binary files /dev/null and b/public/models/fleet/buoys/buoy_cardinal_east.glb differ diff --git a/public/models/fleet/buoys/buoy_cardinal_north.glb b/public/models/fleet/buoys/buoy_cardinal_north.glb new file mode 100644 index 000000000..adac99ccb Binary files /dev/null and b/public/models/fleet/buoys/buoy_cardinal_north.glb differ diff --git a/public/models/fleet/buoys/buoy_cardinal_south.glb b/public/models/fleet/buoys/buoy_cardinal_south.glb new file mode 100644 index 000000000..b84b392f2 Binary files /dev/null and b/public/models/fleet/buoys/buoy_cardinal_south.glb differ diff --git a/public/models/fleet/buoys/buoy_cardinal_west.glb b/public/models/fleet/buoys/buoy_cardinal_west.glb new file mode 100644 index 000000000..d9891b253 Binary files /dev/null and b/public/models/fleet/buoys/buoy_cardinal_west.glb differ diff --git a/public/models/fleet/buoys/buoy_isolated_danger.glb b/public/models/fleet/buoys/buoy_isolated_danger.glb new file mode 100644 index 000000000..e27187ac1 Binary files /dev/null and b/public/models/fleet/buoys/buoy_isolated_danger.glb differ diff --git a/public/models/fleet/buoys/buoy_lateral_port.glb b/public/models/fleet/buoys/buoy_lateral_port.glb new file mode 100644 index 000000000..dcc6bf1c1 Binary files /dev/null and b/public/models/fleet/buoys/buoy_lateral_port.glb differ diff --git a/public/models/fleet/buoys/buoy_lateral_starboard.glb b/public/models/fleet/buoys/buoy_lateral_starboard.glb new file mode 100644 index 000000000..c025c8cb3 Binary files /dev/null and b/public/models/fleet/buoys/buoy_lateral_starboard.glb differ diff --git a/public/models/fleet/buoys/buoy_safe_water.glb b/public/models/fleet/buoys/buoy_safe_water.glb new file mode 100644 index 000000000..47fbc5602 Binary files /dev/null and b/public/models/fleet/buoys/buoy_safe_water.glb differ diff --git a/public/models/fleet/buoys/buoy_special.glb b/public/models/fleet/buoys/buoy_special.glb new file mode 100644 index 000000000..faee37740 Binary files /dev/null and b/public/models/fleet/buoys/buoy_special.glb differ diff --git a/public/models/fleet/buoys/can_red.glb b/public/models/fleet/buoys/can_red.glb new file mode 100644 index 000000000..97beeffa7 Binary files /dev/null and b/public/models/fleet/buoys/can_red.glb differ diff --git a/public/models/fleet/buoys/can_red_cylinder.glb b/public/models/fleet/buoys/can_red_cylinder.glb new file mode 100644 index 000000000..bd87b55f4 Binary files /dev/null and b/public/models/fleet/buoys/can_red_cylinder.glb differ diff --git a/public/models/fleet/buoys/conical_green.glb b/public/models/fleet/buoys/conical_green.glb new file mode 100644 index 000000000..36eb6729c Binary files /dev/null and b/public/models/fleet/buoys/conical_green.glb differ diff --git a/public/models/fleet/buoys/conical_green_cone.glb b/public/models/fleet/buoys/conical_green_cone.glb new file mode 100644 index 000000000..c5a6b4476 Binary files /dev/null and b/public/models/fleet/buoys/conical_green_cone.glb differ diff --git a/public/models/fleet/buoys/pile_green_cone.glb b/public/models/fleet/buoys/pile_green_cone.glb new file mode 100644 index 000000000..6960071c9 Binary files /dev/null and b/public/models/fleet/buoys/pile_green_cone.glb differ diff --git a/public/models/fleet/buoys/pile_red_cylinder.glb b/public/models/fleet/buoys/pile_red_cylinder.glb new file mode 100644 index 000000000..a61336602 Binary files /dev/null and b/public/models/fleet/buoys/pile_red_cylinder.glb differ diff --git a/public/models/fleet/buoys/spar_lateral_port.glb b/public/models/fleet/buoys/spar_lateral_port.glb new file mode 100644 index 000000000..1152dd928 Binary files /dev/null and b/public/models/fleet/buoys/spar_lateral_port.glb differ diff --git a/public/models/fleet/buoys/spar_lateral_starboard.glb b/public/models/fleet/buoys/spar_lateral_starboard.glb new file mode 100644 index 000000000..193882ac8 Binary files /dev/null and b/public/models/fleet/buoys/spar_lateral_starboard.glb differ diff --git a/public/models/fleet/buoys/spherical_green.glb b/public/models/fleet/buoys/spherical_green.glb new file mode 100644 index 000000000..fc06b7920 Binary files /dev/null and b/public/models/fleet/buoys/spherical_green.glb differ diff --git a/public/models/fleet/buoys/spherical_yellow.glb b/public/models/fleet/buoys/spherical_yellow.glb new file mode 100644 index 000000000..d8b660b7c Binary files /dev/null and b/public/models/fleet/buoys/spherical_yellow.glb differ diff --git a/public/models/fleet/buoys/spherical_yellow_x.glb b/public/models/fleet/buoys/spherical_yellow_x.glb new file mode 100644 index 000000000..2ebbee87c Binary files /dev/null and b/public/models/fleet/buoys/spherical_yellow_x.glb differ diff --git a/public/models/fleet/buoys/stake_green_cone.glb b/public/models/fleet/buoys/stake_green_cone.glb new file mode 100644 index 000000000..25fc6758b Binary files /dev/null and b/public/models/fleet/buoys/stake_green_cone.glb differ diff --git a/public/models/fleet/buoys/stake_red_cylinder.glb b/public/models/fleet/buoys/stake_red_cylinder.glb new file mode 100644 index 000000000..31164848d Binary files /dev/null and b/public/models/fleet/buoys/stake_red_cylinder.glb differ diff --git a/public/models/fleet/buoys/stake_yellow_x.glb b/public/models/fleet/buoys/stake_yellow_x.glb new file mode 100644 index 000000000..cdd756643 Binary files /dev/null and b/public/models/fleet/buoys/stake_yellow_x.glb differ diff --git a/public/models/fleet/catalog.json b/public/models/fleet/catalog.json new file mode 100644 index 000000000..91fbc5acd --- /dev/null +++ b/public/models/fleet/catalog.json @@ -0,0 +1,2320 @@ +{ + "a139": { + "category": "helicopters", + "file": "helicopters/a139.glb", + "min": [ + -8.045, + -6.626, + -1.575 + ], + "max": [ + 7.638, + 6.626, + 2.979 + ], + "length": 15.683, + "beam": 13.253, + "clearance": 1.575, + "tris": 1464, + "icon": "heli" + }, + "a320": { + "category": "aircraft", + "file": "aircraft/a320.glb", + "min": [ + -18.785, + -17.806, + -3.063 + ], + "max": [ + 18.785, + 17.806, + 7.56 + ], + "length": 37.57, + "beam": 35.611, + "clearance": 3.063, + "tris": 2338, + "icon": "jet" + }, + "a333": { + "category": "aircraft", + "file": "aircraft/a333.glb", + "min": [ + -31.845, + -29.842, + -4.251 + ], + "max": [ + 31.845, + 29.842, + 10.854 + ], + "length": 63.69, + "beam": 59.684, + "clearance": 4.251, + "tris": 2338, + "icon": "widebody" + }, + "a343": { + "category": "aircraft", + "file": "aircraft/a343.glb", + "min": [ + -31.845, + -29.842, + -3.343 + ], + "max": [ + 31.845, + 29.842, + 10.854 + ], + "length": 63.69, + "beam": 59.684, + "clearance": 3.343, + "tris": 2638, + "icon": "quad" + }, + "a359": { + "category": "aircraft", + "file": "aircraft/a359.glb", + "min": [ + -33.4, + -32.106, + -4.83 + ], + "max": [ + 33.4, + 32.106, + 11.233 + ], + "length": 66.8, + "beam": 64.212, + "clearance": 4.83, + "tris": 2338, + "icon": "widebody" + }, + "a388": { + "category": "aircraft", + "file": "aircraft/a388.glb", + "min": [ + -36.36, + -39.291, + -4.827 + ], + "max": [ + 37.527, + 39.291, + 14.899 + ], + "length": 73.887, + "beam": 78.582, + "clearance": 4.827, + "tris": 2658, + "icon": "quad" + }, + "articulated_bus": { + "category": "transit", + "file": "transit/articulated_bus.glb", + "min": [ + -9.05, + -1.32, + -0.0 + ], + "max": [ + 9.05, + 1.355, + 3.33 + ], + "length": 18.1, + "beam": 2.675, + "clearance": 0.0, + "tris": 604, + "icon": "bus" + }, + "as50": { + "category": "helicopters", + "file": "helicopters/as50.glb", + "min": [ + -5.465, + -4.709, + -1.365 + ], + "max": [ + 6.023, + 4.709, + 2.267 + ], + "length": 11.488, + "beam": 9.418, + "clearance": 1.365, + "tris": 1488, + "icon": "heli" + }, + "at72": { + "category": "aircraft", + "file": "aircraft/at72.glb", + "min": [ + -13.585, + -13.255, + -1.402 + ], + "max": [ + 13.585, + 13.255, + 5.503 + ], + "length": 27.17, + "beam": 26.509, + "clearance": 1.402, + "tris": 2300, + "icon": "turboprop" + }, + "b06": { + "category": "helicopters", + "file": "helicopters/b06.glb", + "min": [ + -6.695, + -1.029, + -1.265 + ], + "max": [ + 5.294, + 1.029, + 1.91 + ], + "length": 11.989, + "beam": 2.057, + "clearance": 1.265, + "tris": 1476, + "icon": "heli" + }, + "b407": { + "category": "helicopters", + "file": "helicopters/b407.glb", + "min": [ + -7.137, + -5.335, + -1.315 + ], + "max": [ + 5.83, + 5.335, + 2.128 + ], + "length": 12.967, + "beam": 10.67, + "clearance": 1.315, + "tris": 1500, + "icon": "heli" + }, + "b738": { + "category": "aircraft", + "file": "aircraft/b738.glb", + "min": [ + -19.735, + -17.802, + -2.528 + ], + "max": [ + 19.735, + 17.802, + 8.122 + ], + "length": 39.47, + "beam": 35.603, + "clearance": 2.528, + "tris": 2350, + "icon": "jet" + }, + "b744": { + "category": "aircraft", + "file": "aircraft/b744.glb", + "min": [ + -35.33, + -31.777, + -3.786 + ], + "max": [ + 35.809, + 31.777, + 12.595 + ], + "length": 71.139, + "beam": 63.554, + "clearance": 3.786, + "tris": 2910, + "icon": "quad" + }, + "b763": { + "category": "aircraft", + "file": "aircraft/b763.glb", + "min": [ + -27.45, + -23.692, + -3.852 + ], + "max": [ + 27.45, + 23.692, + 10.008 + ], + "length": 54.9, + "beam": 47.385, + "clearance": 3.852, + "tris": 2338, + "icon": "widebody" + }, + "b77w": { + "category": "aircraft", + "file": "aircraft/b77w.glb", + "min": [ + -36.95, + -31.752, + -5.444 + ], + "max": [ + 36.95, + 31.752, + 11.469 + ], + "length": 73.9, + "beam": 63.504, + "clearance": 5.444, + "tris": 2186, + "icon": "widebody" + }, + "b789": { + "category": "aircraft", + "file": "aircraft/b789.glb", + "min": [ + -31.4, + -29.449, + -4.769 + ], + "max": [ + 31.4, + 29.449, + 10.973 + ], + "length": 62.8, + "beam": 58.898, + "clearance": 4.769, + "tris": 2186, + "icon": "widebody" + }, + "balloon": { + "category": "other", + "file": "other/balloon.glb", + "min": [ + -8.5, + -8.5, + -5.95 + ], + "max": [ + 8.5, + 8.5, + 19.55 + ], + "length": 16.999, + "beam": 16.999, + "clearance": 5.95, + "tris": 504, + "icon": "balloon" + }, + "barrel_yellow": { + "category": "buoys", + "file": "buoys/barrel_yellow.glb", + "min": [ + -0.85, + -0.5, + -0.3 + ], + "max": [ + 0.85, + 0.5, + 0.85 + ], + "length": 1.7, + "beam": 1.0, + "clearance": 0.3, + "tris": 80, + "icon": "buoy" + }, + "bcs3": { + "category": "aircraft", + "file": "aircraft/bcs3.glb", + "min": [ + -19.35, + -17.439, + -3.196 + ], + "max": [ + 19.35, + 17.439, + 7.086 + ], + "length": 38.7, + "beam": 34.879, + "clearance": 3.196, + "tris": 2338, + "icon": "jet" + }, + "be20": { + "category": "aircraft", + "file": "aircraft/be20.glb", + "min": [ + -6.67, + -8.139, + -0.871 + ], + "max": [ + 7.162, + 8.139, + 3.351 + ], + "length": 13.832, + "beam": 16.278, + "clearance": 0.871, + "tris": 2386, + "icon": "turboprop" + }, + "blimp": { + "category": "other", + "file": "other/blimp.glb", + "min": [ + -37.5, + -8.427, + -10.6 + ], + "max": [ + 37.5, + 8.427, + 8.427 + ], + "length": 75.0, + "beam": 16.855, + "clearance": 10.6, + "tris": 1044, + "icon": "balloon" + }, + "bulk_carrier": { + "category": "ships", + "file": "ships/bulk_carrier.glb", + "min": [ + -98.8, + -16.0, + -10.88 + ], + "max": [ + 95.0, + 16.0, + 30.76 + ], + "length": 193.8, + "beam": 32.0, + "clearance": 10.88, + "tris": 3121, + "icon": "ship" + }, + "buoy_cardinal_east": { + "category": "buoys", + "file": "buoys/buoy_cardinal_east.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.7 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 654, + "icon": "buoy" + }, + "buoy_cardinal_north": { + "category": "buoys", + "file": "buoys/buoy_cardinal_north.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.7 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 606, + "icon": "buoy" + }, + "buoy_cardinal_south": { + "category": "buoys", + "file": "buoys/buoy_cardinal_south.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.7 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 606, + "icon": "buoy" + }, + "buoy_cardinal_west": { + "category": "buoys", + "file": "buoys/buoy_cardinal_west.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.7 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 654, + "icon": "buoy" + }, + "buoy_isolated_danger": { + "category": "buoys", + "file": "buoys/buoy_isolated_danger.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.65 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 986, + "icon": "buoy" + }, + "buoy_lateral_port": { + "category": "buoys", + "file": "buoys/buoy_lateral_port.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 4.95 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 558, + "icon": "buoy" + }, + "buoy_lateral_starboard": { + "category": "buoys", + "file": "buoys/buoy_lateral_starboard.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 4.95 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 528, + "icon": "buoy" + }, + "buoy_safe_water": { + "category": "buoys", + "file": "buoys/buoy_safe_water.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 4.95 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 694, + "icon": "buoy" + }, + "buoy_special": { + "category": "buoys", + "file": "buoys/buoy_special.glb", + "min": [ + -1.56, + -1.56, + -3.2 + ], + "max": [ + 1.56, + 1.56, + 5.202 + ], + "length": 3.12, + "beam": 3.12, + "clearance": 3.2, + "tris": 514, + "icon": "buoy" + }, + "c17": { + "category": "aircraft", + "file": "aircraft/c17.glb", + "min": [ + -26.52, + -25.688, + -3.467 + ], + "max": [ + 28.899, + 25.688, + 11.893 + ], + "length": 55.419, + "beam": 51.377, + "clearance": 3.467, + "tris": 2768, + "icon": "widebody" + }, + "c172": { + "category": "aircraft", + "file": "aircraft/c172.glb", + "min": [ + -4.416, + -5.39, + -1.49 + ], + "max": [ + 4.548, + 5.39, + 1.97 + ], + "length": 8.964, + "beam": 10.78, + "clearance": 1.49, + "tris": 2162, + "icon": "light" + }, + "c30j": { + "category": "aircraft", + "file": "aircraft/c30j.glb", + "min": [ + -14.895, + -19.801, + -2.328 + ], + "max": [ + 15.001, + 19.801, + 9.051 + ], + "length": 29.896, + "beam": 39.602, + "clearance": 2.328, + "tris": 2800, + "icon": "turboprop" + }, + "c56x": { + "category": "aircraft", + "file": "aircraft/c56x.glb", + "min": [ + -7.895, + -8.413, + -0.911 + ], + "max": [ + 8.028, + 8.413, + 3.458 + ], + "length": 15.923, + "beam": 16.827, + "clearance": 0.911, + "tris": 2198, + "icon": "rear_jet" + }, + "cable_tram": { + "category": "transit", + "file": "transit/cable_tram.glb", + "min": [ + -4.3, + -1.445, + -0.0 + ], + "max": [ + 4.3, + 1.445, + 3.13 + ], + "length": 8.6, + "beam": 2.89, + "clearance": 0.0, + "tris": 384, + "icon": "tram" + }, + "can_red": { + "category": "buoys", + "file": "buoys/can_red.glb", + "min": [ + -0.8, + -0.8, + -0.6 + ], + "max": [ + 0.8, + 0.8, + 1.18 + ], + "length": 1.6, + "beam": 1.6, + "clearance": 0.6, + "tris": 228, + "icon": "buoy" + }, + "can_red_cylinder": { + "category": "buoys", + "file": "buoys/can_red_cylinder.glb", + "min": [ + -0.8, + -0.8, + -0.6 + ], + "max": [ + 0.8, + 0.8, + 1.9 + ], + "length": 1.6, + "beam": 1.6, + "clearance": 0.6, + "tris": 308, + "icon": "buoy" + }, + "car": { + "category": "other", + "file": "other/car.glb", + "min": [ + -2.31, + -1.075, + 0.018 + ], + "max": [ + 2.31, + 1.075, + 1.84 + ], + "length": 4.62, + "beam": 2.15, + "clearance": 0.0, + "tris": 736, + "icon": "vehicle" + }, + "car_carrier": { + "category": "ships", + "file": "ships/car_carrier.glb", + "min": [ + -101.0, + -16.0, + -8.96 + ], + "max": [ + 100.0, + 18.315, + 38.525 + ], + "length": 201.0, + "beam": 34.315, + "clearance": 8.96, + "tris": 3949, + "icon": "ship" + }, + "city_bus": { + "category": "transit", + "file": "transit/city_bus.glb", + "min": [ + -6.05, + -1.32, + -0.0 + ], + "max": [ + 6.05, + 1.355, + 3.33 + ], + "length": 12.1, + "beam": 2.675, + "clearance": 0.0, + "tris": 432, + "icon": "bus" + }, + "cl35": { + "category": "aircraft", + "file": "aircraft/cl35.glb", + "min": [ + -10.46, + -10.446, + -1.189 + ], + "max": [ + 12.27, + 10.446, + 4.351 + ], + "length": 22.73, + "beam": 20.892, + "clearance": 1.189, + "tris": 2434, + "icon": "rear_jet" + }, + "coach": { + "category": "transit", + "file": "transit/coach.glb", + "min": [ + -6.51, + -1.32, + -0.0 + ], + "max": [ + 6.5, + 1.355, + 3.739 + ], + "length": 13.01, + "beam": 2.675, + "clearance": 0.0, + "tris": 474, + "icon": "bus" + }, + "commuter_emu": { + "category": "transit", + "file": "transit/commuter_emu.glb", + "min": [ + -49.05, + -1.53, + -0.0 + ], + "max": [ + 49.05, + 1.53, + 5.733 + ], + "length": 98.1, + "beam": 3.06, + "clearance": 0.0, + "tris": 1570, + "icon": "rail" + }, + "conical_green": { + "category": "buoys", + "file": "buoys/conical_green.glb", + "min": [ + -0.85, + -0.85, + -0.6 + ], + "max": [ + 0.85, + 0.85, + 1.7 + ], + "length": 1.7, + "beam": 1.7, + "clearance": 0.6, + "tris": 172, + "icon": "buoy" + }, + "conical_green_cone": { + "category": "buoys", + "file": "buoys/conical_green_cone.glb", + "min": [ + -0.85, + -0.85, + -0.6 + ], + "max": [ + 0.85, + 0.85, + 2.42 + ], + "length": 1.7, + "beam": 1.7, + "clearance": 0.6, + "tris": 222, + "icon": "buoy" + }, + "container_ship": { + "category": "ships", + "file": "ships/container_ship.glb", + "min": [ + -159.9, + -24.0, + -13.92 + ], + "max": [ + 150.0, + 24.0, + 47.94 + ], + "length": 309.9, + "beam": 48.0, + "clearance": 13.92, + "tris": 14337, + "icon": "ship" + }, + "crj9": { + "category": "aircraft", + "file": "aircraft/crj9.glb", + "min": [ + -18.1, + -12.347, + -1.339 + ], + "max": [ + 18.396, + 12.347, + 5.684 + ], + "length": 36.496, + "beam": 24.694, + "clearance": 1.339, + "tris": 2334, + "icon": "rear_jet" + }, + "cruise_ship": { + "category": "ships", + "file": "ships/cruise_ship.glb", + "min": [ + -168.0, + -22.5, + -8.4 + ], + "max": [ + 150.0, + 22.5, + 67.55 + ], + "length": 318.0, + "beam": 45.0, + "clearance": 8.4, + "tris": 3759, + "icon": "passenger" + }, + "dh8d": { + "category": "aircraft", + "file": "aircraft/dh8d.glb", + "min": [ + -16.4, + -13.916, + -1.339 + ], + "max": [ + 16.4, + 13.916, + 5.709 + ], + "length": 32.8, + "beam": 27.832, + "clearance": 1.339, + "tris": 2364, + "icon": "turboprop" + }, + "double_decker": { + "category": "transit", + "file": "transit/double_decker.glb", + "min": [ + -5.55, + -1.32, + -0.0 + ], + "max": [ + 5.55, + 1.355, + 4.387 + ], + "length": 11.1, + "beam": 2.675, + "clearance": 0.0, + "tris": 442, + "icon": "bus" + }, + "dredger": { + "category": "ships", + "file": "ships/dredger.glb", + "min": [ + -53.0, + -10.06, + -7.0 + ], + "max": [ + 50.0, + 11.0, + 17.955 + ], + "length": 103.0, + "beam": 21.06, + "clearance": 7.0, + "tris": 2597, + "icon": "ship" + }, + "e175": { + "category": "aircraft", + "file": "aircraft/e175.glb", + "min": [ + -15.84, + -12.917, + -2.427 + ], + "max": [ + 15.84, + 12.917, + 5.818 + ], + "length": 31.68, + "beam": 25.835, + "clearance": 2.427, + "tris": 2338, + "icon": "jet" + }, + "e55p": { + "category": "aircraft", + "file": "aircraft/e55p.glb", + "min": [ + -7.95, + -8.042, + -0.896 + ], + "max": [ + 8.222, + 8.042, + 3.469 + ], + "length": 16.172, + "beam": 16.084, + "clearance": 0.896, + "tris": 2414, + "icon": "rear_jet" + }, + "ec35": { + "category": "helicopters", + "file": "helicopters/ec35.glb", + "min": [ + -6.834, + -5.1, + -1.365 + ], + "max": [ + 5.304, + 5.1, + 2.0 + ], + "length": 12.138, + "beam": 10.2, + "clearance": 1.365, + "tris": 1420, + "icon": "heli" + }, + "ec45": { + "category": "helicopters", + "file": "helicopters/ec45.glb", + "min": [ + -7.472, + -5.5, + -1.54 + ], + "max": [ + 6.032, + 5.5, + 2.65 + ], + "length": 13.504, + "beam": 11.0, + "clearance": 1.54, + "tris": 1420, + "icon": "heli" + }, + "fast_ferry": { + "category": "ships", + "file": "ships/fast_ferry.glb", + "min": [ + -22.4, + -5.56, + -1.32 + ], + "max": [ + 20.0, + 5.56, + 8.3 + ], + "length": 42.4, + "beam": 11.12, + "clearance": 1.32, + "tris": 4754, + "icon": "passenger" + }, + "feeder": { + "category": "ships", + "file": "ships/feeder.glb", + "min": [ + -79.95, + -11.5, + -6.67 + ], + "max": [ + 75.0, + 11.5, + 28.09 + ], + "length": 154.95, + "beam": 23.0, + "clearance": 6.67, + "tris": 3519, + "icon": "ship" + }, + "ferry": { + "category": "ships", + "file": "ships/ferry.glb", + "min": [ + -70.2, + -12.9, + -5.28 + ], + "max": [ + 65.05, + 12.9, + 31.575 + ], + "length": 135.25, + "beam": 25.8, + "clearance": 5.28, + "tris": 3157, + "icon": "passenger" + }, + "fire_truck": { + "category": "other", + "file": "other/fire_truck.glb", + "min": [ + -6.408, + -1.507, + 0.029 + ], + "max": [ + 5.51, + 1.507, + 3.64 + ], + "length": 11.918, + "beam": 3.014, + "clearance": 0.0, + "tris": 1016, + "icon": "vehicle" + }, + "fishing_boat": { + "category": "ships", + "file": "ships/fishing_boat.glb", + "min": [ + -7.2, + -2.0, + -1.2 + ], + "max": [ + 6.0, + 2.0, + 5.753 + ], + "length": 13.2, + "beam": 4.0, + "clearance": 1.2, + "tris": 2839, + "icon": "boat" + }, + "funicular": { + "category": "transit", + "file": "transit/funicular.glb", + "min": [ + -7.05, + -1.345, + -0.0 + ], + "max": [ + 7.05, + 1.345, + 5.43 + ], + "length": 14.1, + "beam": 2.69, + "clearance": 0.0, + "tris": 308, + "icon": "rail" + }, + "general_cargo": { + "category": "ships", + "file": "ships/general_cargo.glb", + "min": [ + -47.7, + -7.0, + -5.32 + ], + "max": [ + 45.0, + 7.0, + 18.846 + ], + "length": 92.7, + "beam": 14.0, + "clearance": 5.32, + "tris": 2797, + "icon": "ship" + }, + "generic_heli": { + "category": "helicopters", + "file": "helicopters/generic_heli.glb", + "min": [ + -7.37, + -5.5, + -1.365 + ], + "max": [ + 5.927, + 5.5, + 2.28 + ], + "length": 13.297, + "beam": 11.0, + "clearance": 1.365, + "tris": 1272, + "icon": "heli" + }, + "generic_plane": { + "category": "aircraft", + "file": "aircraft/generic_plane.glb", + "min": [ + -18.0, + -16.66, + -2.906 + ], + "max": [ + 18.0, + 16.66, + 7.373 + ], + "length": 36.0, + "beam": 33.32, + "clearance": 2.906, + "tris": 2166, + "icon": "jet" + }, + "generic_vessel": { + "category": "ships", + "file": "ships/generic_vessel.glb", + "min": [ + -21.6, + -4.5, + -2.7 + ], + "max": [ + 20.0, + 4.5, + 11.6 + ], + "length": 41.6, + "beam": 9.0, + "clearance": 2.7, + "tris": 2389, + "icon": "ship" + }, + "glf4": { + "category": "aircraft", + "file": "aircraft/glf4.glb", + "min": [ + -13.46, + -11.79, + -1.189 + ], + "max": [ + 15.73, + 11.79, + 5.022 + ], + "length": 29.19, + "beam": 23.58, + "clearance": 1.189, + "tris": 2474, + "icon": "rear_jet" + }, + "glider": { + "category": "aircraft", + "file": "aircraft/glider.glb", + "min": [ + -3.4, + -7.396, + -0.398 + ], + "max": [ + 3.561, + 7.396, + 1.384 + ], + "length": 6.961, + "beam": 14.793, + "clearance": 0.398, + "tris": 2314, + "icon": "light" + }, + "gondola": { + "category": "transit", + "file": "transit/gondola.glb", + "min": [ + -1.05, + -0.938, + 0.014 + ], + "max": [ + 1.05, + 0.938, + 4.6 + ], + "length": 2.1, + "beam": 1.876, + "clearance": 0.0, + "tris": 282, + "icon": "unknown" + }, + "h47": { + "category": "helicopters", + "file": "helicopters/h47.glb", + "min": [ + -10.3, + -9.151, + -2.588 + ], + "max": [ + 13.921, + 8.103, + 4.78 + ], + "length": 24.221, + "beam": 17.254, + "clearance": 2.588, + "tris": 1430, + "icon": "heli_tandem" + }, + "h60": { + "category": "helicopters", + "file": "helicopters/h60.glb", + "min": [ + -10.774, + -8.18, + -1.765 + ], + "max": [ + 9.13, + 8.18, + 3.265 + ], + "length": 19.904, + "beam": 16.36, + "clearance": 1.765, + "tris": 1560, + "icon": "heli" + }, + "h64": { + "category": "helicopters", + "file": "helicopters/h64.glb", + "min": [ + -9.875, + -7.315, + -1.475 + ], + "max": [ + 8.545, + 7.315, + 2.742 + ], + "length": 18.42, + "beam": 14.63, + "clearance": 1.475, + "tris": 2040, + "icon": "heli" + }, + "high_speed": { + "category": "transit", + "file": "transit/high_speed.glb", + "min": [ + -103.1, + -1.555, + -0.0 + ], + "max": [ + 103.1, + 1.555, + 5.285 + ], + "length": 206.2, + "beam": 3.11, + "clearance": 0.0, + "tris": 2844, + "icon": "rail" + }, + "inland_cargo": { + "category": "ships", + "file": "ships/inland_cargo.glb", + "min": [ + -56.32, + -5.725, + -3.0 + ], + "max": [ + 55.0, + 5.725, + 9.995 + ], + "length": 111.32, + "beam": 11.45, + "clearance": 3.0, + "tris": 3277, + "icon": "ship" + }, + "inland_tanker": { + "category": "ships", + "file": "ships/inland_tanker.glb", + "min": [ + -56.32, + -5.725, + -3.2 + ], + "max": [ + 55.0, + 5.725, + 7.5 + ], + "length": 111.32, + "beam": 11.45, + "clearance": 3.2, + "tris": 2709, + "icon": "tanker" + }, + "intercity": { + "category": "transit", + "file": "transit/intercity.glb", + "min": [ + -91.4, + -1.545, + -0.0 + ], + "max": [ + 91.45, + 1.545, + 5.933 + ], + "length": 182.85, + "beam": 3.09, + "clearance": 0.0, + "tris": 3168, + "icon": "rail" + }, + "k35r": { + "category": "aircraft", + "file": "aircraft/k35r.glb", + "min": [ + -20.765, + -19.541, + -2.478 + ], + "max": [ + 28.186, + 19.541, + 8.482 + ], + "length": 48.951, + "beam": 39.082, + "clearance": 2.478, + "tris": 2626, + "icon": "jet" + }, + "light_rail": { + "category": "transit", + "file": "transit/light_rail.glb", + "min": [ + -14.1, + -1.405, + -0.0 + ], + "max": [ + 14.1, + 1.405, + 5.641 + ], + "length": 28.2, + "beam": 2.81, + "clearance": 0.0, + "tris": 760, + "icon": "tram" + }, + "lng_carrier": { + "category": "ships", + "file": "ships/lng_carrier.glb", + "min": [ + -152.25, + -23.0, + -11.5 + ], + "max": [ + 145.0, + 23.0, + 45.38 + ], + "length": 297.25, + "beam": 46.0, + "clearance": 11.5, + "tris": 2951, + "icon": "tanker" + }, + "md11": { + "category": "aircraft", + "file": "aircraft/md11.glb", + "min": [ + -30.8, + -25.74, + -4.162 + ], + "max": [ + 33.345, + 25.74, + 10.88 + ], + "length": 64.145, + "beam": 51.48, + "clearance": 4.162, + "tris": 2476, + "icon": "widebody" + }, + "metro": { + "category": "transit", + "file": "transit/metro.glb", + "min": [ + -57.55, + -1.53, + -0.0 + ], + "max": [ + 57.55, + 1.53, + 3.691 + ], + "length": 115.1, + "beam": 3.06, + "clearance": 0.0, + "tris": 2226, + "icon": "subway" + }, + "monorail": { + "category": "transit", + "file": "transit/monorail.glb", + "min": [ + -27.75, + -1.53, + 0.0 + ], + "max": [ + 27.75, + 1.53, + 3.587 + ], + "length": 55.5, + "beam": 3.06, + "clearance": 0.0, + "tris": 814, + "icon": "subway" + }, + "motor_yacht": { + "category": "ships", + "file": "ships/motor_yacht.glb", + "min": [ + -9.92, + -2.3, + -1.15 + ], + "max": [ + 9.2, + 2.3, + 5.46 + ], + "length": 19.12, + "beam": 4.6, + "clearance": 1.15, + "tris": 2477, + "icon": "boat" + }, + "mq9": { + "category": "aircraft", + "file": "aircraft/mq9.glb", + "min": [ + -5.5, + -9.8, + -1.616 + ], + "max": [ + 5.85, + 9.8, + 1.582 + ], + "length": 11.35, + "beam": 19.6, + "clearance": 1.616, + "tris": 1936, + "icon": "light" + }, + "p28a": { + "category": "aircraft", + "file": "aircraft/p28a.glb", + "min": [ + -3.901, + -5.228, + -1.341 + ], + "max": [ + 3.877, + 5.228, + 1.801 + ], + "length": 7.778, + "beam": 10.457, + "clearance": 1.341, + "tris": 2136, + "icon": "light" + }, + "patrol": { + "category": "ships", + "file": "ships/patrol.glb", + "min": [ + -36.0, + -4.5, + -2.7 + ], + "max": [ + 30.0, + 4.5, + 19.6 + ], + "length": 66.0, + "beam": 9.0, + "clearance": 2.7, + "tris": 2587, + "icon": "ship" + }, + "pc12": { + "category": "aircraft", + "file": "aircraft/pc12.glb", + "min": [ + -7.62, + -8.053, + -1.435 + ], + "max": [ + 7.3, + 8.053, + 3.396 + ], + "length": 14.92, + "beam": 16.106, + "clearance": 1.435, + "tris": 2292, + "icon": "turboprop" + }, + "pile_green_cone": { + "category": "buoys", + "file": "buoys/pile_green_cone.glb", + "min": [ + -0.35, + -0.35, + -1.5 + ], + "max": [ + 0.35, + 0.35, + 4.72 + ], + "length": 0.7, + "beam": 0.7, + "clearance": 1.5, + "tris": 138, + "icon": "buoy" + }, + "pile_red_cylinder": { + "category": "buoys", + "file": "buoys/pile_red_cylinder.glb", + "min": [ + -0.35, + -0.35, + -1.5 + ], + "max": [ + 0.35, + 0.35, + 4.72 + ], + "length": 0.7, + "beam": 0.7, + "clearance": 1.5, + "tris": 168, + "icon": "buoy" + }, + "pilot_boat": { + "category": "ships", + "file": "ships/pilot_boat.glb", + "min": [ + -10.54, + -2.88, + -1.3 + ], + "max": [ + 8.5, + 2.88, + 8.716 + ], + "length": 19.04, + "beam": 5.76, + "clearance": 1.3, + "tris": 2985, + "icon": "boat" + }, + "product_tanker": { + "category": "ships", + "file": "ships/product_tanker.glb", + "min": [ + -58.76, + -9.5, + -6.46 + ], + "max": [ + 56.5, + 9.5, + 25.672 + ], + "length": 115.26, + "beam": 19.0, + "clearance": 6.46, + "tris": 2995, + "icon": "tanker" + }, + "psv": { + "category": "ships", + "file": "ships/psv.glb", + "min": [ + -45.05, + -9.5, + -6.27 + ], + "max": [ + 42.5, + 9.5, + 24.119 + ], + "length": 87.55, + "beam": 19.0, + "clearance": 6.27, + "tris": 3059, + "icon": "ship" + }, + "pushback": { + "category": "other", + "file": "other/pushback.glb", + "min": [ + -3.45, + -1.398, + 0.025 + ], + "max": [ + 3.05, + 1.398, + 2.14 + ], + "length": 6.5, + "beam": 2.795, + "clearance": 0.0, + "tris": 490, + "icon": "vehicle" + }, + "r44": { + "category": "helicopters", + "file": "helicopters/r44.glb", + "min": [ + -7.116, + -0.744, + -1.14 + ], + "max": [ + 5.079, + 0.744, + 2.03 + ], + "length": 12.195, + "beam": 1.488, + "clearance": 1.14, + "tris": 1172, + "icon": "heli" + }, + "radio_mast": { + "category": "other", + "file": "other/radio_mast.glb", + "min": [ + -0.7, + -0.815, + 0.0 + ], + "max": [ + 1.2, + 0.815, + 11.8 + ], + "length": 1.9, + "beam": 1.63, + "clearance": 0.0, + "tris": 940, + "icon": "vehicle" + }, + "regional_dmu": { + "category": "transit", + "file": "transit/regional_dmu.glb", + "min": [ + -24.75, + -1.53, + -0.0 + ], + "max": [ + 24.75, + 1.53, + 4.36 + ], + "length": 49.5, + "beam": 3.06, + "clearance": 0.0, + "tris": 770, + "icon": "rail" + }, + "river_cruise": { + "category": "ships", + "file": "ships/river_cruise.glb", + "min": [ + -69.12, + -5.7, + -1.8 + ], + "max": [ + 67.5, + 5.7, + 9.9 + ], + "length": 136.62, + "beam": 11.4, + "clearance": 1.8, + "tris": 2675, + "icon": "passenger" + }, + "s76": { + "category": "helicopters", + "file": "helicopters/s76.glb", + "min": [ + -8.949, + -6.705, + -1.525 + ], + "max": [ + 7.428, + 6.705, + 2.776 + ], + "length": 16.377, + "beam": 13.41, + "clearance": 1.525, + "tris": 1356, + "icon": "heli" + }, + "sailboat": { + "category": "ships", + "file": "ships/sailboat.glb", + "min": [ + -7.8, + -2.05, + -2.695 + ], + "max": [ + 6.5, + 2.05, + 18.698 + ], + "length": 14.3, + "beam": 4.1, + "clearance": 2.695, + "tris": 2421, + "icon": "sail" + }, + "spar_lateral_port": { + "category": "buoys", + "file": "buoys/spar_lateral_port.glb", + "min": [ + -0.47, + -0.47, + -1.5 + ], + "max": [ + 0.47, + 0.47, + 3.36 + ], + "length": 0.94, + "beam": 0.94, + "clearance": 1.5, + "tris": 290, + "icon": "buoy" + }, + "spar_lateral_starboard": { + "category": "buoys", + "file": "buoys/spar_lateral_starboard.glb", + "min": [ + -0.47, + -0.47, + -1.5 + ], + "max": [ + 0.47, + 0.47, + 3.36 + ], + "length": 0.94, + "beam": 0.94, + "clearance": 1.5, + "tris": 262, + "icon": "buoy" + }, + "speedboat": { + "category": "ships", + "file": "ships/speedboat.glb", + "min": [ + -4.96, + -1.3, + -0.55 + ], + "max": [ + 4.45, + 1.3, + 2.03 + ], + "length": 9.41, + "beam": 2.6, + "clearance": 0.55, + "tris": 2369, + "icon": "boat" + }, + "spherical_green": { + "category": "buoys", + "file": "buoys/spherical_green.glb", + "min": [ + -0.75, + -0.739, + -0.4 + ], + "max": [ + 0.75, + 0.739, + 1.25 + ], + "length": 1.5, + "beam": 1.477, + "clearance": 0.4, + "tris": 344, + "icon": "buoy" + }, + "spherical_yellow": { + "category": "buoys", + "file": "buoys/spherical_yellow.glb", + "min": [ + -0.75, + -0.739, + -0.4 + ], + "max": [ + 0.75, + 0.739, + 1.25 + ], + "length": 1.5, + "beam": 1.477, + "clearance": 0.4, + "tris": 344, + "icon": "buoy" + }, + "spherical_yellow_x": { + "category": "buoys", + "file": "buoys/spherical_yellow_x.glb", + "min": [ + -0.75, + -0.739, + -0.4 + ], + "max": [ + 0.75, + 0.739, + 2.019 + ], + "length": 1.5, + "beam": 1.477, + "clearance": 0.4, + "tris": 380, + "icon": "buoy" + }, + "sr22": { + "category": "aircraft", + "file": "aircraft/sr22.glb", + "min": [ + -4.3, + -5.723, + -1.325 + ], + "max": [ + 4.124, + 5.723, + 1.765 + ], + "length": 8.424, + "beam": 11.446, + "clearance": 1.325, + "tris": 2428, + "icon": "light" + }, + "stake_green_cone": { + "category": "buoys", + "file": "buoys/stake_green_cone.glb", + "min": [ + -0.198, + -0.198, + -1.5 + ], + "max": [ + 0.198, + 0.198, + 3.63 + ], + "length": 0.396, + "beam": 0.396, + "clearance": 1.5, + "tris": 78, + "icon": "buoy" + }, + "stake_red_cylinder": { + "category": "buoys", + "file": "buoys/stake_red_cylinder.glb", + "min": [ + -0.154, + -0.154, + -1.5 + ], + "max": [ + 0.154, + 0.154, + 3.63 + ], + "length": 0.308, + "beam": 0.308, + "clearance": 1.5, + "tris": 108, + "icon": "buoy" + }, + "stake_yellow_x": { + "category": "buoys", + "file": "buoys/stake_yellow_x.glb", + "min": [ + -0.08, + -0.232, + -1.5 + ], + "max": [ + 0.08, + 0.232, + 3.769 + ], + "length": 0.16, + "beam": 0.463, + "clearance": 1.5, + "tris": 64, + "icon": "buoy" + }, + "superyacht": { + "category": "ships", + "file": "ships/superyacht.glb", + "min": [ + -36.0, + -5.25, + -3.15 + ], + "max": [ + 30.0, + 5.25, + 17.565 + ], + "length": 66.0, + "beam": 10.5, + "clearance": 3.15, + "tris": 2689, + "icon": "passenger" + }, + "t38": { + "category": "aircraft", + "file": "aircraft/t38.glb", + "min": [ + -7.07, + -3.773, + -0.746 + ], + "max": [ + 7.074, + 3.773, + 2.592 + ], + "length": 14.144, + "beam": 7.546, + "clearance": 0.746, + "tris": 2246, + "icon": "fighter" + }, + "tanker": { + "category": "ships", + "file": "ships/tanker.glb", + "min": [ + -130.0, + -22.0, + -14.96 + ], + "max": [ + 125.0, + 22.0, + 35.608 + ], + "length": 255.0, + "beam": 44.0, + "clearance": 14.96, + "tris": 3393, + "icon": "tanker" + }, + "tex2": { + "category": "aircraft", + "file": "aircraft/tex2.glb", + "min": [ + -5.452, + -4.993, + -1.293 + ], + "max": [ + 5.134, + 4.993, + 2.168 + ], + "length": 10.586, + "beam": 9.986, + "clearance": 1.293, + "tris": 2380, + "icon": "turboprop" + }, + "tour_boat": { + "category": "ships", + "file": "ships/tour_boat.glb", + "min": [ + -14.5, + -3.0, + -1.68 + ], + "max": [ + 12.5, + 3.0, + 5.57 + ], + "length": 27.0, + "beam": 6.0, + "clearance": 1.68, + "tris": 2449, + "icon": "boat" + }, + "tower": { + "category": "other", + "file": "other/tower.glb", + "min": [ + -0.696, + -1.135, + -0.016 + ], + "max": [ + 1.296, + 1.135, + 60.6 + ], + "length": 1.992, + "beam": 2.27, + "clearance": 0.016, + "tris": 568, + "icon": "vehicle" + }, + "tram": { + "category": "transit", + "file": "transit/tram.glb", + "min": [ + -15.0, + -1.28, + -0.0 + ], + "max": [ + 15.0, + 1.28, + 5.241 + ], + "length": 30.0, + "beam": 2.56, + "clearance": 0.0, + "tris": 1004, + "icon": "tram" + }, + "trawler": { + "category": "ships", + "file": "ships/trawler.glb", + "min": [ + -12.1, + -6.338, + -2.8 + ], + "max": [ + 11.0, + 6.338, + 12.529 + ], + "length": 23.1, + "beam": 12.676, + "clearance": 2.8, + "tris": 3121, + "icon": "ship" + }, + "trolleybus": { + "category": "transit", + "file": "transit/trolleybus.glb", + "min": [ + -6.05, + -1.32, + -0.0 + ], + "max": [ + 6.05, + 1.355, + 5.656 + ], + "length": 12.1, + "beam": 2.675, + "clearance": 0.0, + "tris": 458, + "icon": "bus" + }, + "tug": { + "category": "ships", + "file": "ships/tug.glb", + "min": [ + -15.8, + -5.35, + -3.7 + ], + "max": [ + 14.4, + 5.35, + 14.1 + ], + "length": 30.2, + "beam": 10.7, + "clearance": 3.7, + "tris": 3661, + "icon": "ship" + }, + "ultralight": { + "category": "aircraft", + "file": "aircraft/ultralight.glb", + "min": [ + -3.51, + -5.243, + -1.217 + ], + "max": [ + 3.6, + 5.243, + 1.644 + ], + "length": 7.11, + "beam": 10.486, + "clearance": 1.217, + "tris": 2068, + "icon": "light" + }, + "v22": { + "category": "aircraft", + "file": "aircraft/v22.glb", + "min": [ + -8.74, + -11.647, + -1.468 + ], + "max": [ + 8.74, + 11.647, + 4.569 + ], + "length": 17.48, + "beam": 23.294, + "clearance": 1.468, + "tris": 2250, + "icon": "turboprop" + }, + "van": { + "category": "other", + "file": "other/van.glb", + "min": [ + -2.76, + -1.22, + 0.018 + ], + "max": [ + 2.76, + 1.22, + 2.56 + ], + "length": 5.52, + "beam": 2.44, + "clearance": 0.0, + "tris": 736, + "icon": "vehicle" + } +} diff --git a/public/models/fleet/helicopters/a139.glb b/public/models/fleet/helicopters/a139.glb new file mode 100644 index 000000000..f61975dfb Binary files /dev/null and b/public/models/fleet/helicopters/a139.glb differ diff --git a/public/models/fleet/helicopters/as50.glb b/public/models/fleet/helicopters/as50.glb new file mode 100644 index 000000000..597d916e9 Binary files /dev/null and b/public/models/fleet/helicopters/as50.glb differ diff --git a/public/models/fleet/helicopters/b06.glb b/public/models/fleet/helicopters/b06.glb new file mode 100644 index 000000000..25baa7fa4 Binary files /dev/null and b/public/models/fleet/helicopters/b06.glb differ diff --git a/public/models/fleet/helicopters/b407.glb b/public/models/fleet/helicopters/b407.glb new file mode 100644 index 000000000..09d67e67e Binary files /dev/null and b/public/models/fleet/helicopters/b407.glb differ diff --git a/public/models/fleet/helicopters/ec35.glb b/public/models/fleet/helicopters/ec35.glb new file mode 100644 index 000000000..c52f3d156 Binary files /dev/null and b/public/models/fleet/helicopters/ec35.glb differ diff --git a/public/models/fleet/helicopters/ec45.glb b/public/models/fleet/helicopters/ec45.glb new file mode 100644 index 000000000..b9e5cb27e Binary files /dev/null and b/public/models/fleet/helicopters/ec45.glb differ diff --git a/public/models/fleet/helicopters/generic_heli.glb b/public/models/fleet/helicopters/generic_heli.glb new file mode 100644 index 000000000..bc8f1a239 Binary files /dev/null and b/public/models/fleet/helicopters/generic_heli.glb differ diff --git a/public/models/fleet/helicopters/h47.glb b/public/models/fleet/helicopters/h47.glb new file mode 100644 index 000000000..bb2f71426 Binary files /dev/null and b/public/models/fleet/helicopters/h47.glb differ diff --git a/public/models/fleet/helicopters/h60.glb b/public/models/fleet/helicopters/h60.glb new file mode 100644 index 000000000..45f79180d Binary files /dev/null and b/public/models/fleet/helicopters/h60.glb differ diff --git a/public/models/fleet/helicopters/h64.glb b/public/models/fleet/helicopters/h64.glb new file mode 100644 index 000000000..3a44d8e50 Binary files /dev/null and b/public/models/fleet/helicopters/h64.glb differ diff --git a/public/models/fleet/helicopters/r44.glb b/public/models/fleet/helicopters/r44.glb new file mode 100644 index 000000000..845727936 Binary files /dev/null and b/public/models/fleet/helicopters/r44.glb differ diff --git a/public/models/fleet/helicopters/s76.glb b/public/models/fleet/helicopters/s76.glb new file mode 100644 index 000000000..a773aba73 Binary files /dev/null and b/public/models/fleet/helicopters/s76.glb differ diff --git a/public/models/fleet/other/balloon.glb b/public/models/fleet/other/balloon.glb new file mode 100644 index 000000000..016ad0719 Binary files /dev/null and b/public/models/fleet/other/balloon.glb differ diff --git a/public/models/fleet/other/blimp.glb b/public/models/fleet/other/blimp.glb new file mode 100644 index 000000000..3aba8e86b Binary files /dev/null and b/public/models/fleet/other/blimp.glb differ diff --git a/public/models/fleet/other/car.glb b/public/models/fleet/other/car.glb new file mode 100644 index 000000000..80f50d967 Binary files /dev/null and b/public/models/fleet/other/car.glb differ diff --git a/public/models/fleet/other/fire_truck.glb b/public/models/fleet/other/fire_truck.glb new file mode 100644 index 000000000..47cb6d245 Binary files /dev/null and b/public/models/fleet/other/fire_truck.glb differ diff --git a/public/models/fleet/other/pushback.glb b/public/models/fleet/other/pushback.glb new file mode 100644 index 000000000..565ce426c Binary files /dev/null and b/public/models/fleet/other/pushback.glb differ diff --git a/public/models/fleet/other/radio_mast.glb b/public/models/fleet/other/radio_mast.glb new file mode 100644 index 000000000..1242c7d41 Binary files /dev/null and b/public/models/fleet/other/radio_mast.glb differ diff --git a/public/models/fleet/other/tower.glb b/public/models/fleet/other/tower.glb new file mode 100644 index 000000000..39ff5c990 Binary files /dev/null and b/public/models/fleet/other/tower.glb differ diff --git a/public/models/fleet/other/van.glb b/public/models/fleet/other/van.glb new file mode 100644 index 000000000..0b79e1db6 Binary files /dev/null and b/public/models/fleet/other/van.glb differ diff --git a/public/models/fleet/ships/bulk_carrier.glb b/public/models/fleet/ships/bulk_carrier.glb new file mode 100644 index 000000000..3fe73d3bf Binary files /dev/null and b/public/models/fleet/ships/bulk_carrier.glb differ diff --git a/public/models/fleet/ships/car_carrier.glb b/public/models/fleet/ships/car_carrier.glb new file mode 100644 index 000000000..04ad3e779 Binary files /dev/null and b/public/models/fleet/ships/car_carrier.glb differ diff --git a/public/models/fleet/ships/container_ship.glb b/public/models/fleet/ships/container_ship.glb new file mode 100644 index 000000000..f8686176b Binary files /dev/null and b/public/models/fleet/ships/container_ship.glb differ diff --git a/public/models/fleet/ships/cruise_ship.glb b/public/models/fleet/ships/cruise_ship.glb new file mode 100644 index 000000000..d2dcfd88e Binary files /dev/null and b/public/models/fleet/ships/cruise_ship.glb differ diff --git a/public/models/fleet/ships/dredger.glb b/public/models/fleet/ships/dredger.glb new file mode 100644 index 000000000..4d9ab5264 Binary files /dev/null and b/public/models/fleet/ships/dredger.glb differ diff --git a/public/models/fleet/ships/fast_ferry.glb b/public/models/fleet/ships/fast_ferry.glb new file mode 100644 index 000000000..65b008200 Binary files /dev/null and b/public/models/fleet/ships/fast_ferry.glb differ diff --git a/public/models/fleet/ships/feeder.glb b/public/models/fleet/ships/feeder.glb new file mode 100644 index 000000000..1c40203bf Binary files /dev/null and b/public/models/fleet/ships/feeder.glb differ diff --git a/public/models/fleet/ships/ferry.glb b/public/models/fleet/ships/ferry.glb new file mode 100644 index 000000000..fcaa48abc Binary files /dev/null and b/public/models/fleet/ships/ferry.glb differ diff --git a/public/models/fleet/ships/fishing_boat.glb b/public/models/fleet/ships/fishing_boat.glb new file mode 100644 index 000000000..084f5dd3b Binary files /dev/null and b/public/models/fleet/ships/fishing_boat.glb differ diff --git a/public/models/fleet/ships/general_cargo.glb b/public/models/fleet/ships/general_cargo.glb new file mode 100644 index 000000000..0cd1cd4cd Binary files /dev/null and b/public/models/fleet/ships/general_cargo.glb differ diff --git a/public/models/fleet/ships/generic_vessel.glb b/public/models/fleet/ships/generic_vessel.glb new file mode 100644 index 000000000..e8c748544 Binary files /dev/null and b/public/models/fleet/ships/generic_vessel.glb differ diff --git a/public/models/fleet/ships/inland_cargo.glb b/public/models/fleet/ships/inland_cargo.glb new file mode 100644 index 000000000..9f9facc4a Binary files /dev/null and b/public/models/fleet/ships/inland_cargo.glb differ diff --git a/public/models/fleet/ships/inland_tanker.glb b/public/models/fleet/ships/inland_tanker.glb new file mode 100644 index 000000000..db46c2476 Binary files /dev/null and b/public/models/fleet/ships/inland_tanker.glb differ diff --git a/public/models/fleet/ships/lng_carrier.glb b/public/models/fleet/ships/lng_carrier.glb new file mode 100644 index 000000000..d06330645 Binary files /dev/null and b/public/models/fleet/ships/lng_carrier.glb differ diff --git a/public/models/fleet/ships/motor_yacht.glb b/public/models/fleet/ships/motor_yacht.glb new file mode 100644 index 000000000..d7283750a Binary files /dev/null and b/public/models/fleet/ships/motor_yacht.glb differ diff --git a/public/models/fleet/ships/patrol.glb b/public/models/fleet/ships/patrol.glb new file mode 100644 index 000000000..6b3352e11 Binary files /dev/null and b/public/models/fleet/ships/patrol.glb differ diff --git a/public/models/fleet/ships/pilot_boat.glb b/public/models/fleet/ships/pilot_boat.glb new file mode 100644 index 000000000..f4197ab0f Binary files /dev/null and b/public/models/fleet/ships/pilot_boat.glb differ diff --git a/public/models/fleet/ships/product_tanker.glb b/public/models/fleet/ships/product_tanker.glb new file mode 100644 index 000000000..6e31083d3 Binary files /dev/null and b/public/models/fleet/ships/product_tanker.glb differ diff --git a/public/models/fleet/ships/psv.glb b/public/models/fleet/ships/psv.glb new file mode 100644 index 000000000..d943978ec Binary files /dev/null and b/public/models/fleet/ships/psv.glb differ diff --git a/public/models/fleet/ships/river_cruise.glb b/public/models/fleet/ships/river_cruise.glb new file mode 100644 index 000000000..2da8589f0 Binary files /dev/null and b/public/models/fleet/ships/river_cruise.glb differ diff --git a/public/models/fleet/ships/sailboat.glb b/public/models/fleet/ships/sailboat.glb new file mode 100644 index 000000000..51ae8818f Binary files /dev/null and b/public/models/fleet/ships/sailboat.glb differ diff --git a/public/models/fleet/ships/speedboat.glb b/public/models/fleet/ships/speedboat.glb new file mode 100644 index 000000000..eda10fb84 Binary files /dev/null and b/public/models/fleet/ships/speedboat.glb differ diff --git a/public/models/fleet/ships/superyacht.glb b/public/models/fleet/ships/superyacht.glb new file mode 100644 index 000000000..012e69dd3 Binary files /dev/null and b/public/models/fleet/ships/superyacht.glb differ diff --git a/public/models/fleet/ships/tanker.glb b/public/models/fleet/ships/tanker.glb new file mode 100644 index 000000000..68de5a001 Binary files /dev/null and b/public/models/fleet/ships/tanker.glb differ diff --git a/public/models/fleet/ships/tour_boat.glb b/public/models/fleet/ships/tour_boat.glb new file mode 100644 index 000000000..22ee9cb43 Binary files /dev/null and b/public/models/fleet/ships/tour_boat.glb differ diff --git a/public/models/fleet/ships/trawler.glb b/public/models/fleet/ships/trawler.glb new file mode 100644 index 000000000..bb8651f2c Binary files /dev/null and b/public/models/fleet/ships/trawler.glb differ diff --git a/public/models/fleet/ships/tug.glb b/public/models/fleet/ships/tug.glb new file mode 100644 index 000000000..f20e72b6b Binary files /dev/null and b/public/models/fleet/ships/tug.glb differ diff --git a/public/models/fleet/transit/articulated_bus.glb b/public/models/fleet/transit/articulated_bus.glb new file mode 100644 index 000000000..4b10d2ef0 Binary files /dev/null and b/public/models/fleet/transit/articulated_bus.glb differ diff --git a/public/models/fleet/transit/cable_tram.glb b/public/models/fleet/transit/cable_tram.glb new file mode 100644 index 000000000..10b12a3ee Binary files /dev/null and b/public/models/fleet/transit/cable_tram.glb differ diff --git a/public/models/fleet/transit/city_bus.glb b/public/models/fleet/transit/city_bus.glb new file mode 100644 index 000000000..4e2b1dc8a Binary files /dev/null and b/public/models/fleet/transit/city_bus.glb differ diff --git a/public/models/fleet/transit/coach.glb b/public/models/fleet/transit/coach.glb new file mode 100644 index 000000000..00e23f7f5 Binary files /dev/null and b/public/models/fleet/transit/coach.glb differ diff --git a/public/models/fleet/transit/commuter_emu.glb b/public/models/fleet/transit/commuter_emu.glb new file mode 100644 index 000000000..e53f0a1be Binary files /dev/null and b/public/models/fleet/transit/commuter_emu.glb differ diff --git a/public/models/fleet/transit/double_decker.glb b/public/models/fleet/transit/double_decker.glb new file mode 100644 index 000000000..416bd2cbc Binary files /dev/null and b/public/models/fleet/transit/double_decker.glb differ diff --git a/public/models/fleet/transit/funicular.glb b/public/models/fleet/transit/funicular.glb new file mode 100644 index 000000000..4b14d6622 Binary files /dev/null and b/public/models/fleet/transit/funicular.glb differ diff --git a/public/models/fleet/transit/gondola.glb b/public/models/fleet/transit/gondola.glb new file mode 100644 index 000000000..530ccb448 Binary files /dev/null and b/public/models/fleet/transit/gondola.glb differ diff --git a/public/models/fleet/transit/high_speed.glb b/public/models/fleet/transit/high_speed.glb new file mode 100644 index 000000000..c3f09d094 Binary files /dev/null and b/public/models/fleet/transit/high_speed.glb differ diff --git a/public/models/fleet/transit/intercity.glb b/public/models/fleet/transit/intercity.glb new file mode 100644 index 000000000..2845594ec Binary files /dev/null and b/public/models/fleet/transit/intercity.glb differ diff --git a/public/models/fleet/transit/light_rail.glb b/public/models/fleet/transit/light_rail.glb new file mode 100644 index 000000000..22f80d197 Binary files /dev/null and b/public/models/fleet/transit/light_rail.glb differ diff --git a/public/models/fleet/transit/metro.glb b/public/models/fleet/transit/metro.glb new file mode 100644 index 000000000..6154049b9 Binary files /dev/null and b/public/models/fleet/transit/metro.glb differ diff --git a/public/models/fleet/transit/monorail.glb b/public/models/fleet/transit/monorail.glb new file mode 100644 index 000000000..a8dab0f07 Binary files /dev/null and b/public/models/fleet/transit/monorail.glb differ diff --git a/public/models/fleet/transit/regional_dmu.glb b/public/models/fleet/transit/regional_dmu.glb new file mode 100644 index 000000000..1a091e2d6 Binary files /dev/null and b/public/models/fleet/transit/regional_dmu.glb differ diff --git a/public/models/fleet/transit/tram.glb b/public/models/fleet/transit/tram.glb new file mode 100644 index 000000000..514cff436 Binary files /dev/null and b/public/models/fleet/transit/tram.glb differ diff --git a/public/models/fleet/transit/trolleybus.glb b/public/models/fleet/transit/trolleybus.glb new file mode 100644 index 000000000..a7691b71e Binary files /dev/null and b/public/models/fleet/transit/trolleybus.glb differ diff --git a/public/models/fleet/types.json b/public/models/fleet/types.json new file mode 100644 index 000000000..2b2388bb1 --- /dev/null +++ b/public/models/fleet/types.json @@ -0,0 +1,2037 @@ +{ + "aircraft": { + "A320": [ + "a320", + 37.57 + ], + "A20N": [ + "a320", + 37.57 + ], + "A319": [ + "a320", + 33.84 + ], + "A19N": [ + "a320", + 33.84 + ], + "A321": [ + "a320", + 44.51 + ], + "A21N": [ + "a320", + 44.51 + ], + "A318": [ + "a320", + 31.44 + ], + "B738": [ + "b738", + 39.47 + ], + "B38M": [ + "b738", + 39.52 + ], + "B737": [ + "b738", + 33.63 + ], + "B37M": [ + "b738", + 35.56 + ], + "B739": [ + "b738", + 42.11 + ], + "B39M": [ + "b738", + 42.16 + ], + "B736": [ + "b738", + 31.24 + ], + "B733": [ + "b738", + 33.4 + ], + "B734": [ + "b738", + 36.4 + ], + "B735": [ + "b738", + 31.0 + ], + "B752": [ + "b738", + 47.32 + ], + "B753": [ + "b738", + 54.4 + ], + "P8": [ + "b738", + 39.47 + ], + "BCS3": [ + "bcs3", + 38.7 + ], + "BCS1": [ + "bcs3", + 35.0 + ], + "E75L": [ + "e175", + 31.68 + ], + "E75S": [ + "e175", + 31.68 + ], + "E170": [ + "e175", + 29.9 + ], + "E190": [ + "e175", + 36.24 + ], + "E195": [ + "e175", + 38.65 + ], + "E290": [ + "e175", + 36.2 + ], + "E295": [ + "e175", + 41.5 + ], + "CRJ9": [ + "crj9", + 36.2 + ], + "CRJ7": [ + "crj9", + 32.5 + ], + "CRJ2": [ + "crj9", + 26.77 + ], + "CRJX": [ + "crj9", + 39.1 + ], + "E145": [ + "crj9", + 29.87 + ], + "E135": [ + "crj9", + 26.33 + ], + "E35L": [ + "crj9", + 26.33 + ], + "B789": [ + "b789", + 62.8 + ], + "B788": [ + "b789", + 56.7 + ], + "B78X": [ + "b789", + 68.3 + ], + "A359": [ + "a359", + 66.8 + ], + "A35K": [ + "a359", + 73.8 + ], + "B77W": [ + "b77w", + 73.9 + ], + "B77L": [ + "b77w", + 63.7 + ], + "B772": [ + "b77w", + 63.7 + ], + "B773": [ + "b77w", + 73.9 + ], + "B778": [ + "b77w", + 70.9 + ], + "B779": [ + "b77w", + 76.7 + ], + "B763": [ + "b763", + 54.9 + ], + "B762": [ + "b763", + 48.5 + ], + "B764": [ + "b763", + 61.4 + ], + "A333": [ + "a333", + 63.69 + ], + "A332": [ + "a333", + 58.82 + ], + "A339": [ + "a333", + 63.66 + ], + "A338": [ + "a333", + 58.82 + ], + "DH8D": [ + "dh8d", + 32.8 + ], + "DH8C": [ + "dh8d", + 25.7 + ], + "DH8A": [ + "dh8d", + 22.25 + ], + "DH8B": [ + "dh8d", + 22.25 + ], + "AT72": [ + "at72", + 27.17 + ], + "AT76": [ + "at72", + 27.17 + ], + "AT75": [ + "at72", + 27.17 + ], + "AT45": [ + "at72", + 22.67 + ], + "AT43": [ + "at72", + 22.67 + ], + "AT46": [ + "at72", + 22.67 + ], + "E55P": [ + "e55p", + 15.9 + ], + "E545": [ + "e55p", + 19.7 + ], + "E550": [ + "e55p", + 20.7 + ], + "E50P": [ + "e55p", + 12.8 + ], + "LJ45": [ + "e55p", + 17.7 + ], + "LJ75": [ + "e55p", + 17.7 + ], + "LJ35": [ + "e55p", + 14.83 + ], + "LJ60": [ + "e55p", + 17.9 + ], + "C25B": [ + "e55p", + 15.6 + ], + "C25A": [ + "e55p", + 14.5 + ], + "C525": [ + "e55p", + 13.0 + ], + "C25C": [ + "e55p", + 16.3 + ], + "C680": [ + "e55p", + 19.4 + ], + "C68A": [ + "e55p", + 19.7 + ], + "C700": [ + "e55p", + 22.3 + ], + "HDJT": [ + "e55p", + 13.0 + ], + "PC24": [ + "e55p", + 16.85 + ], + "C56X": [ + "c56x", + 15.79 + ], + "C560": [ + "c56x", + 14.9 + ], + "C550": [ + "c56x", + 14.4 + ], + "C510": [ + "c56x", + 12.4 + ], + "C750": [ + "c56x", + 22.0 + ], + "C501": [ + "c56x", + 13.3 + ], + "CL35": [ + "cl35", + 20.92 + ], + "CL30": [ + "cl35", + 20.92 + ], + "CL60": [ + "cl35", + 20.85 + ], + "F2TH": [ + "cl35", + 20.2 + ], + "FA50": [ + "cl35", + 18.5 + ], + "F900": [ + "cl35", + 20.2 + ], + "FA7X": [ + "cl35", + 23.2 + ], + "FA8X": [ + "cl35", + 24.5 + ], + "G280": [ + "cl35", + 20.3 + ], + "H25B": [ + "cl35", + 15.6 + ], + "GLF4": [ + "glf4", + 26.92 + ], + "GLF5": [ + "glf4", + 29.4 + ], + "GLF6": [ + "glf4", + 30.4 + ], + "GLEX": [ + "glf4", + 30.3 + ], + "GL5T": [ + "glf4", + 29.5 + ], + "GL7T": [ + "glf4", + 33.8 + ], + "GA6C": [ + "glf4", + 30.4 + ], + "C172": [ + "c172", + 8.28 + ], + "C182": [ + "c172", + 8.84 + ], + "C152": [ + "c172", + 7.34 + ], + "C150": [ + "c172", + 7.29 + ], + "C72R": [ + "c172", + 8.28 + ], + "C206": [ + "c172", + 8.61 + ], + "C210": [ + "c172", + 8.59 + ], + "C208": [ + "c172", + 11.46 + ], + "C170": [ + "c172", + 7.6 + ], + "C180": [ + "c172", + 7.8 + ], + "C177": [ + "c172", + 8.4 + ], + "P28A": [ + "p28a", + 7.25 + ], + "P28R": [ + "p28a", + 7.5 + ], + "P28B": [ + "p28a", + 7.5 + ], + "PA46": [ + "p28a", + 8.8 + ], + "DA40": [ + "p28a", + 8.06 + ], + "BE36": [ + "p28a", + 8.38 + ], + "BE35": [ + "p28a", + 8.0 + ], + "M20P": [ + "p28a", + 7.52 + ], + "M20T": [ + "p28a", + 8.15 + ], + "P32R": [ + "p28a", + 8.4 + ], + "BE33": [ + "p28a", + 7.7 + ], + "RV7": [ + "p28a", + 6.2 + ], + "RV8": [ + "p28a", + 6.3 + ], + "GLAS": [ + "p28a", + 6.0 + ], + "PA18": [ + "p28a", + 6.9 + ], + "SR22": [ + "sr22", + 7.92 + ], + "SR20": [ + "sr22", + 7.92 + ], + "S22T": [ + "sr22", + 7.92 + ], + "COL4": [ + "sr22", + 7.7 + ], + "DA20": [ + "sr22", + 7.2 + ], + "PC12": [ + "pc12", + 14.4 + ], + "TBM7": [ + "pc12", + 10.7 + ], + "TBM8": [ + "pc12", + 10.7 + ], + "TBM9": [ + "pc12", + 10.7 + ], + "P46T": [ + "pc12", + 9.0 + ], + "BE20": [ + "be20", + 13.34 + ], + "BE9L": [ + "be20", + 10.82 + ], + "B350": [ + "be20", + 14.22 + ], + "BE30": [ + "be20", + 13.34 + ], + "PA31": [ + "be20", + 10.55 + ], + "BE58": [ + "be20", + 9.09 + ], + "PA44": [ + "be20", + 8.41 + ], + "DA42": [ + "be20", + 8.56 + ], + "PA34": [ + "be20", + 8.72 + ], + "C414": [ + "be20", + 11.1 + ], + "C421": [ + "be20", + 11.1 + ], + "C310": [ + "be20", + 9.7 + ], + "BE55": [ + "be20", + 8.5 + ], + "P180": [ + "be20", + 14.4 + ], + "TEX2": [ + "tex2", + 10.16 + ], + "PC21": [ + "tex2", + 11.2 + ], + "PC9": [ + "tex2", + 10.1 + ], + "PC7": [ + "tex2", + 9.8 + ], + "T6": [ + "tex2", + 8.8 + ], + "T38": [ + "t38", + 14.14 + ], + "HAWK": [ + "t38", + 12.43 + ], + "F16": [ + "t38", + 15.06 + ], + "F18": [ + "t38", + 17.1 + ], + "F18S": [ + "t38", + 18.31 + ], + "F35": [ + "t38", + 15.7 + ], + "F15": [ + "t38", + 19.43 + ], + "EUFI": [ + "t38", + 15.96 + ], + "F22": [ + "t38", + 18.9 + ], + "T45": [ + "t38", + 11.99 + ], + "M346": [ + "t38", + 11.49 + ], + "C30J": [ + "c30j", + 34.36 + ], + "C130": [ + "c30j", + 29.79 + ], + "A400": [ + "c30j", + 45.1 + ], + "C17": [ + "c17", + 53.04 + ], + "K35R": [ + "k35r", + 41.53 + ], + "E3TF": [ + "k35r", + 46.61 + ], + "E3CF": [ + "k35r", + 46.61 + ], + "B744": [ + "b744", + 70.66 + ], + "B748": [ + "b744", + 76.3 + ], + "B742": [ + "b744", + 70.66 + ], + "B74S": [ + "b744", + 56.3 + ], + "A388": [ + "a388", + 72.72 + ], + "B712": [ + "crj9", + 37.8 + ], + "MD82": [ + "crj9", + 45.0 + ], + "MD83": [ + "crj9", + 45.0 + ], + "MD88": [ + "crj9", + 45.0 + ], + "MD87": [ + "crj9", + 39.75 + ], + "A306": [ + "a333", + 54.08 + ], + "A310": [ + "a333", + 46.66 + ], + "T206": [ + "c172", + 8.61 + ], + "C82T": [ + "c172", + 8.84 + ], + "C82S": [ + "c172", + 8.84 + ], + "C82R": [ + "c172", + 8.84 + ], + "DV20": [ + "sr22", + 7.3 + ], + "PA32": [ + "p28a", + 8.2 + ], + "PA24": [ + "p28a", + 7.6 + ], + "AA5": [ + "p28a", + 6.7 + ], + "RV12": [ + "p28a", + 6.2 + ], + "EPIC": [ + "pc12", + 10.8 + ], + "C402": [ + "be20", + 11.1 + ], + "BE95": [ + "be20", + 7.8 + ], + "PA30": [ + "be20", + 7.7 + ], + "SF34": [ + "at72", + 19.73 + ], + "BE40": [ + "e55p", + 14.8 + ], + "GALX": [ + "cl35", + 18.97 + ], + "GA5C": [ + "p28a", + 6.7 + ], + "MD11": [ + "md11", + 61.6 + ], + "DC10": [ + "md11", + 55.5 + ], + "L101": [ + "md11", + 54.2 + ], + "A343": [ + "a343", + 63.69 + ], + "A346": [ + "a343", + 75.36 + ], + "A342": [ + "a343", + 59.4 + ], + "A345": [ + "a343", + 67.9 + ], + "GLID": [ + "glider", + 6.8 + ], + "GLIM": [ + "glider", + 7.0 + ], + "DRON": [ + "mq9", + 11.0 + ], + "Q9": [ + "mq9", + 11.0 + ], + "Q4": [ + "mq9", + 14.5 + ], + "Q1": [ + "mq9", + 8.2 + ], + "PIAT": [ + "ultralight", + 6.5 + ], + "PIVI": [ + "ultralight", + 6.5 + ], + "ULAC": [ + "ultralight", + 6.0 + ], + "E45X": [ + "crj9", + 29.87 + ], + "SB20": [ + "at72", + 27.28 + ], + "C500": [ + "c56x", + 13.26 + ], + "BE50": [ + "be20", + 9.6 + ], + "AC68": [ + "be20", + 13.1 + ], + "B190": [ + "be20", + 17.63 + ], + "FA6X": [ + "cl35", + 25.7 + ], + "FA20": [ + "cl35", + 17.15 + ], + "T204": [ + "b738", + 46.1 + ], + "GA7": [ + "be20", + 9.1 + ], + "C320": [ + "be20", + 10.0 + ], + "SLG4": [ + "glider", + 6.5 + ], + "SLG2": [ + "glider", + 6.5 + ], + "SF50": [ + "sr22", + 9.4 + ], + "T210": [ + "c172", + 8.6 + ], + "PA23": [ + "be20", + 8.8 + ], + "BE23": [ + "p28a", + 7.8 + ], + "RV6": [ + "p28a", + 6.1 + ], + "CH7B": [ + "ultralight", + 6.6 + ], + "BT7": [ + "tex2", + 9.6 + ], + "TB30": [ + "tex2", + 7.6 + ], + "IL62": [ + "md11", + 53.1 + ], + "BALL": [ + "balloon", + 17.0 + ], + "SHIP": [ + "blimp", + 75.0 + ], + "ZVEH": [ + "car", + 4.61 + ], + "GRND": [ + "radio_mast", + 1.9 + ] + }, + "helicopters": { + "R44": [ + "r44", + 9.07 + ], + "R22": [ + "r44", + 6.3 + ], + "R66": [ + "r44", + 9.2 + ], + "H500": [ + "r44", + 7.0 + ], + "H269": [ + "r44", + 6.8 + ], + "B06": [ + "b06", + 9.5 + ], + "B505": [ + "b06", + 9.6 + ], + "B206": [ + "b06", + 9.5 + ], + "B407": [ + "b407", + 10.6 + ], + "B212": [ + "b407", + 12.9 + ], + "B412": [ + "b407", + 12.9 + ], + "UH1": [ + "b407", + 12.8 + ], + "AS50": [ + "as50", + 10.93 + ], + "EC30": [ + "as50", + 10.68 + ], + "A119": [ + "as50", + 11.1 + ], + "AS55": [ + "as50", + 10.93 + ], + "EC35": [ + "ec35", + 10.2 + ], + "B429": [ + "ec35", + 11.3 + ], + "EC45": [ + "ec45", + 11.6 + ], + "B105": [ + "ec45", + 8.8 + ], + "A139": [ + "a139", + 13.77 + ], + "A169": [ + "a139", + 12.1 + ], + "A109": [ + "a139", + 11.4 + ], + "A189": [ + "a139", + 14.6 + ], + "S76": [ + "s76", + 13.2 + ], + "AS65": [ + "s76", + 11.6 + ], + "EC55": [ + "s76", + 12.1 + ], + "LYNX": [ + "s76", + 12.0 + ], + "EC75": [ + "s76", + 13.7 + ], + "H60": [ + "h60", + 15.26 + ], + "S92": [ + "h60", + 17.1 + ], + "EH10": [ + "h60", + 19.5 + ], + "NH90": [ + "h60", + 16.1 + ], + "H47": [ + "h47", + 15.46 + ], + "H64": [ + "h64", + 15.06 + ], + "H1": [ + "h64", + 13.6 + ], + "TIGR": [ + "h64", + 14.0 + ], + "V22": [ + "v22", + 17.48 + ], + "GYRO": [ + "r44", + 5.0 + ] + }, + "aircraft_callsign": { + "ZVEH": [ + { + "prefixes": [ + "FIRE" + ], + "model": "fire_truck", + "length": 11.01 + }, + { + "prefixes": [ + "PUSH", + "TUG" + ], + "model": "pushback", + "length": 6.0 + }, + { + "prefixes": [ + "DEICER", + "SQUID", + "SQB" + ], + "model": "van", + "length": 5.51 + } + ] + }, + "vessels": [ + { + "types": [ + 70, + 79 + ], + "typical_len": 300, + "rules": [ + { + "min_len": 70, + "max_len": 140, + "max_bl": 0.12, + "model": "inland_cargo" + }, + { + "name_re": "\\b(LEADER|HIGHWAY|ACE|GLOVIS|HOEGH|SPIRIT OF)\\b", + "min_len": 150, + "model": "car_carrier" + }, + { + "min_len": 250, + "model": "container_ship" + }, + { + "min_len": 160, + "max_bl": 0.145, + "model": "container_ship" + }, + { + "min_len": 160, + "model": "bulk_carrier" + }, + { + "min_len": 120, + "model": "feeder" + }, + { + "model": "general_cargo" + } + ] + }, + { + "types": [ + 80, + 89 + ], + "typical_len": 250, + "rules": [ + { + "name_re": "\\bLNG\\b|\\bGAS\\b", + "min_len": 150, + "model": "lng_carrier" + }, + { + "min_len": 70, + "max_len": 140, + "max_bl": 0.12, + "model": "inland_tanker" + }, + { + "min_len": 180, + "model": "tanker" + }, + { + "model": "product_tanker" + } + ] + }, + { + "types": [ + 60, + 69 + ], + "typical_len": 140, + "rules": [ + { + "min_len": 70, + "max_len": 140, + "max_bl": 0.12, + "model": "river_cruise" + }, + { + "min_len": 200, + "model": "cruise_ship" + }, + { + "min_len": 40, + "model": "ferry" + }, + { + "min_len": 15, + "model": "tour_boat" + }, + { + "model": "speedboat" + } + ] + }, + { + "types": [ + 37, + 37 + ], + "typical_len": 19, + "rules": [ + { + "min_len": 35, + "model": "superyacht" + }, + { + "min_len": 12, + "model": "motor_yacht" + }, + { + "model": "speedboat" + } + ] + }, + { + "types": [ + 36, + 36 + ], + "rules": [ + { + "model": "sailboat" + } + ] + }, + { + "types": [ + 30, + 30 + ], + "typical_len": 24, + "rules": [ + { + "min_len": 15, + "model": "trawler" + }, + { + "model": "fishing_boat" + } + ] + }, + { + "types": [ + 31, + 32 + ], + "rules": [ + { + "model": "tug" + } + ] + }, + { + "types": [ + 52, + 52 + ], + "rules": [ + { + "model": "tug" + } + ] + }, + { + "types": [ + 50, + 51 + ], + "rules": [ + { + "model": "pilot_boat" + } + ] + }, + { + "types": [ + 53, + 54 + ], + "rules": [ + { + "model": "pilot_boat" + } + ] + }, + { + "types": [ + 35, + 35 + ], + "rules": [ + { + "model": "patrol" + } + ] + }, + { + "types": [ + 55, + 55 + ], + "typical_len": 40, + "rules": [ + { + "min_len": 25, + "model": "patrol" + }, + { + "model": "pilot_boat" + } + ] + }, + { + "types": [ + 40, + 49 + ], + "typical_len": 40, + "rules": [ + { + "min_len": 20, + "model": "fast_ferry" + }, + { + "model": "speedboat" + } + ] + }, + { + "types": [ + 33, + 33 + ], + "typical_len": 100, + "rules": [ + { + "min_len": 50, + "model": "dredger" + }, + { + "model": "tug" + } + ] + }, + { + "types": [ + 0, + 99 + ], + "rules": [ + { + "min_len": 70, + "max_len": 140, + "max_bl": 0.12, + "model": "inland_cargo" + }, + { + "model": "generic_vessel" + } + ] + } + ], + "categories": { + "A1": [ + "c172", + 8.3 + ], + "A2": [ + "be20", + 14.0 + ], + "A3": [ + "a320", + 37.6 + ], + "A4": [ + "b738", + 47.3 + ], + "A5": [ + "b77w", + 70.0 + ], + "A6": [ + "t38", + 15.0 + ], + "A7": [ + "generic_heli", + 11.0 + ], + "B1": [ + "glider", + 6.8 + ], + "B2": [ + "balloon", + 17.0 + ], + "B3": [ + "ultralight", + 5.0 + ], + "B4": [ + "ultralight", + 6.5 + ], + "B6": [ + "mq9", + 11.0 + ], + "B7": [ + "ultralight", + 4.0 + ], + "C1": [ + "fire_truck", + 11.0 + ], + "C2": [ + "van", + 5.5 + ], + "C3": [ + "tower", + 60.0 + ], + "C4": [ + "tower", + 60.0 + ], + "C5": [ + "tower", + 60.0 + ], + "A0": [ + "generic_plane", + 36.0 + ] + }, + "default_aircraft": [ + "generic_plane", + 36.0 + ], + "default_vessel": "generic_vessel", + "transit": { + "modes": { + "bus": [ + "city_bus", + 12.0 + ], + "articulated_bus": [ + "articulated_bus", + 18.0 + ], + "double_decker": [ + "double_decker", + 11.0 + ], + "trolleybus": [ + "trolleybus", + 12.0 + ], + "coach": [ + "coach", + 13.0 + ], + "tram": [ + "tram", + 30.0 + ], + "light_rail": [ + "light_rail", + 28.2 + ], + "cable_tram": [ + "cable_tram", + 8.4 + ], + "subway": [ + "metro", + 115.0 + ], + "metro": [ + "metro", + 115.0 + ], + "monorail": [ + "monorail", + 55.5 + ], + "rail": [ + "commuter_emu", + 98.1 + ], + "commuter": [ + "commuter_emu", + 98.1 + ], + "regional": [ + "regional_dmu", + 49.5 + ], + "intercity": [ + "intercity", + 182.8 + ], + "high_speed": [ + "high_speed", + 206.2 + ], + "ferry": [ + "fast_ferry", + 40.0 + ], + "aerial_lift": [ + "gondola", + 2.1 + ], + "gondola": [ + "gondola", + 2.1 + ], + "funicular": [ + "funicular", + 14.0 + ] + }, + "route_types": { + "0": [ + "tram", + 30.0 + ], + "1": [ + "metro", + 115.0 + ], + "2": [ + "commuter_emu", + 98.1 + ], + "3": [ + "city_bus", + 12.0 + ], + "4": [ + "fast_ferry", + 40.0 + ], + "5": [ + "cable_tram", + 8.4 + ], + "6": [ + "gondola", + 2.1 + ], + "7": [ + "funicular", + 14.0 + ], + "11": [ + "trolleybus", + 12.0 + ], + "12": [ + "monorail", + 55.5 + ], + "100": [ + "intercity", + 182.8 + ], + "101": [ + "high_speed", + 206.2 + ], + "102": [ + "intercity", + 182.8 + ], + "103": [ + "intercity", + 182.8 + ], + "104": [ + "intercity", + 182.8 + ], + "105": [ + "intercity", + 182.8 + ], + "106": [ + "regional_dmu", + 49.5 + ], + "107": [ + "regional_dmu", + 49.5 + ], + "108": [ + "metro", + 115.0 + ], + "109": [ + "commuter_emu", + 98.1 + ], + "110": [ + "regional_dmu", + 49.5 + ], + "111": [ + "intercity", + 182.8 + ], + "112": [ + "intercity", + 182.8 + ], + "113": [ + "intercity", + 182.8 + ], + "114": [ + "intercity", + 182.8 + ], + "115": [ + "intercity", + 182.8 + ], + "116": [ + "regional_dmu", + 49.5 + ], + "117": [ + "intercity", + 182.8 + ], + "200": [ + "coach", + 13.0 + ], + "201": [ + "coach", + 13.0 + ], + "202": [ + "coach", + 13.0 + ], + "203": [ + "coach", + 13.0 + ], + "204": [ + "coach", + 13.0 + ], + "205": [ + "coach", + 13.0 + ], + "206": [ + "coach", + 13.0 + ], + "207": [ + "coach", + 13.0 + ], + "208": [ + "coach", + 13.0 + ], + "209": [ + "coach", + 13.0 + ], + "400": [ + "metro", + 115.0 + ], + "401": [ + "metro", + 115.0 + ], + "402": [ + "metro", + 115.0 + ], + "403": [ + "metro", + 115.0 + ], + "404": [ + "metro", + 115.0 + ], + "405": [ + "monorail", + 55.5 + ], + "700": [ + "city_bus", + 12.0 + ], + "701": [ + "coach", + 13.0 + ], + "702": [ + "coach", + 13.0 + ], + "703": [ + "city_bus", + 12.0 + ], + "704": [ + "city_bus", + 12.0 + ], + "705": [ + "city_bus", + 12.0 + ], + "706": [ + "coach", + 13.0 + ], + "707": [ + "city_bus", + 12.0 + ], + "708": [ + "city_bus", + 12.0 + ], + "709": [ + "city_bus", + 12.0 + ], + "710": [ + "double_decker", + 11.0 + ], + "711": [ + "city_bus", + 12.0 + ], + "712": [ + "city_bus", + 12.0 + ], + "713": [ + "city_bus", + 12.0 + ], + "714": [ + "coach", + 13.0 + ], + "715": [ + "van", + 5.5 + ], + "716": [ + "city_bus", + 12.0 + ], + "800": [ + "trolleybus", + 12.0 + ], + "900": [ + "tram", + 30.0 + ], + "901": [ + "tram", + 30.0 + ], + "902": [ + "tram", + 30.0 + ], + "903": [ + "light_rail", + 28.2 + ], + "904": [ + "tram", + 30.0 + ], + "905": [ + "tram", + 30.0 + ], + "906": [ + "tram", + 30.0 + ], + "1000": [ + "fast_ferry", + 40.0 + ], + "1200": [ + "fast_ferry", + 40.0 + ], + "1300": [ + "gondola", + 2.1 + ], + "1301": [ + "gondola", + 2.1 + ], + "1302": [ + "gondola", + 2.1 + ], + "1306": [ + "gondola", + 2.1 + ], + "1307": [ + "gondola", + 2.1 + ], + "1400": [ + "funicular", + 14.0 + ], + "1500": [ + "car", + 4.6 + ], + "1501": [ + "van", + 5.5 + ], + "1502": [ + "car", + 4.6 + ], + "1503": [ + "car", + 4.6 + ], + "1504": [ + "car", + 4.6 + ], + "1505": [ + "car", + 4.6 + ], + "1506": [ + "car", + 4.6 + ], + "1507": [ + "car", + 4.6 + ] + }, + "rail": { + "transit": { + "rail": [ + "commuter_emu", + 98.1 + ], + "metro": [ + "metro", + 115.0 + ], + "tram": [ + "tram", + 30.0 + ] + }, + "local": { + "rail": [ + "commuter_emu", + 98.1 + ], + "metro": [ + "metro", + 115.0 + ], + "tram": [ + "tram", + 30.0 + ] + }, + "regional": { + "rail": [ + "regional_dmu", + 49.5 + ], + "metro": [ + "metro", + 115.0 + ], + "tram": [ + "light_rail", + 28.2 + ] + }, + "national": { + "rail": [ + "intercity", + 182.8 + ], + "metro": [ + "metro", + 115.0 + ], + "tram": [ + "light_rail", + 28.2 + ] + }, + "international": { + "rail": [ + "high_speed", + 206.2 + ], + "metro": [ + "metro", + 115.0 + ], + "tram": [ + "light_rail", + 28.2 + ] + } + }, + "default": [ + "city_bus", + 12.0 + ], + "elevation": { + "monorail": 6.0, + "gondola": 20.0 + } + } +} \ No newline at end of file diff --git a/public/models/jet.glb b/public/models/jet.glb deleted file mode 100644 index bb2f99f9f..000000000 Binary files a/public/models/jet.glb and /dev/null differ diff --git a/public/models/mq9.glb b/public/models/mq9.glb deleted file mode 100644 index 47bc21f91..000000000 Binary files a/public/models/mq9.glb and /dev/null differ diff --git a/scripts/format-scope.json b/scripts/format-scope.json index b49781778..c11489f1f 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -385,6 +385,8 @@ "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", @@ -555,6 +557,13 @@ "src/ui/frameRateMonitor.js", "src/ui/frameRateMonitor.test.mjs", "src/ui/frameRateView.test.mjs", + "src/ui/renderFailure.test.mjs", + "src/ui/renderFailureView.test.mjs", + "src/ui/renderFailureLoopErrors.test.mjs", + "src/ui/renderFailureEventRetry.test.mjs", + "src/app/renderFailureInlineRetry.test.mjs", + "src/ui/renderFailureSecondLoop.test.mjs", + "src/ui/renderLoopFixture.mjs", "src/layers/cctv/lifecycle.js", "src/layers/cctv/controls.js", "src/layers/cctv/geometry.js", @@ -1335,9 +1344,13 @@ "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", @@ -1422,5 +1435,19 @@ "src/ui/ownerFlagSheet.js", "src/data/placePacksLoading.test.mjs", "src/ui/placePacksLoading.js", - "src/ui/placePacksLoading.test.mjs" + "src/ui/placePacksLoading.test.mjs", + "src/cockpitSubjectProviders.js", + "src/cockpitSubjectProviders.test.mjs", + "src/cockpitProgress.js", + "src/cockpitProgress.test.mjs", + "src/ui/cockpitSubjectView.test.mjs", + "src/ui/styles/cockpitSubject.css", + "src/layers/rail/cockpitSubject.js", + "src/layers/rail/cockpitSubject.test.mjs", + "src/layers/transit/cockpitSubject.js", + "src/data/fleetModels.test.mjs", + "src/data/fleetModelInstances.test.mjs", + "src/testSupport/fleetGlb.mjs", + "src/testSupport/fleetModelFakes.mjs", + "scripts/measure-fleet-trail-anchors.mjs" ] diff --git a/scripts/measure-fleet-trail-anchors.mjs b/scripts/measure-fleet-trail-anchors.mjs new file mode 100644 index 000000000..c26874dc7 --- /dev/null +++ b/scripts/measure-fleet-trail-anchors.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +/** + * Re-measure the trail attachment point of every aircraft-layer fleet model + * and print `src/data/fleetTrailAnchors.js`. + * + * node scripts/measure-fleet-trail-anchors.mjs > src/data/fleetTrailAnchors.js + * + * Run it whenever a model under `public/models/fleet/` changes; the unit + * suite (`src/data/fleetModels.test.mjs`) re-derives each value and fails + * on a table that no longer matches its GLB. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { projectRoot } from './project-root.mjs'; +import { + fleetMesh, + measuredTrailAnchor, +} from '../src/testSupport/fleetGlb.mjs'; + +/** Catalog categories the aircraft layers draw (ADS-B ground vehicles and balloons included). */ +export const TRAIL_ANCHOR_CATEGORIES = ['aircraft', 'helicopters', 'other']; + +const root = projectRoot(import.meta.url); +const dir = path.join(root, 'public', 'models', 'fleet'); +const catalog = JSON.parse( + fs.readFileSync(path.join(dir, 'catalog.json'), 'utf8'), +); +const rows = []; +for (const [name, model] of Object.entries(catalog)) { + if (!TRAIL_ANCHOR_CATEGORIES.includes(model.category)) continue; + const anchor = measuredTrailAnchor( + await fleetMesh(path.join(dir, model.file)), + ); + if (!anchor) continue; + const [x, y] = anchor.map((value) => Number(value.toFixed(4)) || 0); + rows.push( + ` ${/^[a-z_]\w*$/.test(name) ? name : `'${name}'`}: Object.freeze([${x}, ${y}, 0]),`, + ); +} +process.stdout.write(`/** + * Aft-belly trail attachment points of the fleet models the aircraft layers + * draw, in raw glTF axes (X along the body, nose toward -X; Y up), metres + * before scale, node transforms applied. Each is the point of the model's + * centreline profile (its z = 0 slice) nearest its aft-belly bounding-box + * corner: on the hull, aft, and on the centreline (see modelVisualAnchor.js). + * + * Written by scripts/measure-fleet-trail-anchors.mjs; re-measured against the + * GLBs by fleetModels.test.mjs. + */ +export const FLEET_TRAIL_ANCHORS = Object.freeze({ +${rows.join('\n')} +}); +`); diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 26779b493..1cd00d41e 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -23,6 +23,9 @@ "src/ui/vueRuntime.js", "src/ui/views/index.js", "src/ui/views/CockpitView.vue", + "src/ui/views/panels/CockpitProgress.vue", + "src/cockpitProgress.js", + "src/cockpitSubjectProviders.js", "src/ui/views/CommandDockView.vue", "src/ui/views/ContextView.vue", "src/ui/views/panels/RadioPanel.vue", @@ -164,6 +167,7 @@ "src/ui/kit/UiBadge.vue", "src/ui/stores/globeActions.js", "src/ui/views/panels/GlobeActions.vue", + "src/ui/views/panels/RenderFailure.vue", "src/ui/views/panels/GlobeCompass.vue", "src/ui/stores/recording.js", "src/ui/styleParameters.js", @@ -176,6 +180,28 @@ ], "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"], @@ -226,6 +252,7 @@ "src/data/adsbLolFallback.js", "src/data/aisStreamAdapter.js", "src/data/aisWatchdog.js", + "src/data/vesselTypes.js", "src/sources/httpBody.js", "src/i18n/marker.js", "server/providers/acartia.js", @@ -988,6 +1015,8 @@ "src/ui/cockpitInput.js", "src/data/inputOwnership.js", "src/ui/cockpitInstruments.js", + "src/cockpitSubjectProviders.js", + "src/cockpitProgress.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", @@ -1153,6 +1182,8 @@ "src/ui/cockpitInput.js", "src/data/inputOwnership.js", "src/ui/cockpitInstruments.js", + "src/cockpitSubjectProviders.js", + "src/cockpitProgress.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", @@ -1335,6 +1366,7 @@ "src/ui/styles/status.css", "src/ui/styles/cockpit.css", "src/ui/styles/cockpitBridge.css", + "src/ui/styles/cockpitSubject.css", "src/ui/styles/cockpitRadar.css", "src/ui/styles/overlays.css", "src/ui/styles/layers.css", @@ -1475,13 +1507,18 @@ "src/sources/live/index.js", "src/sources/live/standalone.js", "src/sources/live/vessels.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/data/openVesselPhoto.js" ], "external": [] }, "civil-flights": { "exports": ["./layers/flights"], "modules": [ + "src/data/fleetModels.js", + "src/data/vesselTypes.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", @@ -1556,6 +1593,12 @@ "military-flights": { "exports": ["./layers/military"], "modules": [ + "src/layers/flights/modelSpec.js", + "src/layers/flights/policy.js", + "src/data/fleetModels.js", + "src/data/vesselTypes.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", "src/data/headingDegrees.js", "src/i18n/marker.js", "src/cyberSonar.js", @@ -1655,6 +1698,13 @@ "vessel-layer": { "exports": ["./layers/vessels"], "modules": [ + "src/data/fleetModels.js", + "src/data/aircraftClass.js", + "src/i18n/marker.js", + "src/data/fleetModelInstances.js", + "src/data/aircraftModelLighting.js", + "src/data/modelEligibility.js", + "src/data/fleetTrailAnchors.js", "src/data/headingDegrees.js", "src/layers/vessels/cards.js", "src/layers/vessels/evidence.js", @@ -1697,7 +1747,9 @@ "src/i18n/locales/sv.json", "src/i18n/locales/uk.json", "src/i18n/locales/zh-CN.json", - "src/i18n/locales/zh-Hant.json" + "src/i18n/locales/zh-Hant.json", + "src/layers/vessels/photos.js", + "src/data/openVesselPhoto.js" ], "external": ["cesium"] }, @@ -2522,6 +2574,10 @@ "./standalone/catalog" ], "modules": [ + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", + "src/app/layers/fleetModels.js", "src/sources/adsbFields.js", "src/data/headingDegrees.js", "src/data/distanceFormat.js", @@ -3074,6 +3130,8 @@ "src/ui/displayPanelScroll.js", "src/ui/cockpitInput.js", "src/ui/cockpitInstruments.js", + "src/cockpitSubjectProviders.js", + "src/cockpitProgress.js", "src/ui/cockpitLayout.js", "src/ui/cockpitPresentation.js", "src/ui/cockpitSignals.js", @@ -3091,6 +3149,7 @@ "src/ui/displayControls.js", "src/ui/effects.js", "src/ui/frameRateMonitor.js", + "src/ui/renderFailure.js", "src/ui/layerPanel.js", "src/ui/weatherPanel.js", "src/ui/railCards.js", @@ -3261,6 +3320,7 @@ "src/app/layers/rail.js", "src/data/railFeeds.js", "src/layers/rail/index.js", + "src/layers/rail/cockpitSubject.js", "src/layers/rail/source.js", "src/layers/rail/policy.js", "src/layers/rail/ingestion.js", @@ -3287,6 +3347,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/policy.js", @@ -3760,7 +3821,9 @@ "src/data/ucsSatellites.js", "src/data/imagingSensors.js", "src/data/planespottersPhoto.js", - "src/ui/stores/dataLibrary.js" + "src/ui/stores/dataLibrary.js", + "src/data/openVesselPhoto.js", + "src/layers/vessels/photos.js" ], "external": [ "hls.js", @@ -3787,6 +3850,10 @@ "application-layer-construction": { "exports": ["./application/layers"], "modules": [ + "src/data/fleetModels.js", + "src/data/fleetTrailAnchors.js", + "src/data/fleetModelInstances.js", + "src/app/layers/fleetModels.js", "src/data/feedState.js", "src/loadingFeedback.js", "src/sources/adsbFields.js", @@ -4244,6 +4311,7 @@ "src/app/layers/rail.js", "src/data/railFeeds.js", "src/layers/rail/index.js", + "src/layers/rail/cockpitSubject.js", "src/layers/rail/source.js", "src/layers/rail/policy.js", "src/layers/rail/ingestion.js", @@ -4269,6 +4337,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/policy.js", @@ -4471,7 +4540,9 @@ "src/layers/satellites/sensors.js", "src/data/ucsSatellites.js", "src/data/imagingSensors.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/layers/vessels/photos.js", + "src/data/openVesselPhoto.js" ], "external": [ "hls.js", @@ -4752,7 +4823,10 @@ "src/data/approxDistance.js", "src/data/renderAltitude.js", "src/data/aircraftClass.js", - "src/data/aircraftEmergency.js" + "src/data/aircraftEmergency.js", + "src/data/fleetModels.js", + "src/data/vesselTypes.js", + "src/data/fleetTrailAnchors.js" ], "external": [] }, @@ -5141,6 +5215,14 @@ "transit-standalone": { "exports": ["./layers/transit/standalone"], "modules": [ + "src/data/fleetModels.js", + "src/data/vesselTypes.js", + "src/data/aircraftClass.js", + "src/i18n/marker.js", + "src/data/fleetModelInstances.js", + "src/data/modelEligibility.js", + "src/data/fleetTrailAnchors.js", + "src/data/aircraftModelLighting.js", "src/data/headingDegrees.js", "src/cyberSonar.js", "src/celestialRing.js", @@ -5172,6 +5254,7 @@ "src/layers/transit/controls.js", "src/layers/transit/height.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/movement.js", @@ -5225,6 +5308,14 @@ "transit-layer": { "exports": ["./layers/transit"], "modules": [ + "src/i18n/marker.js", + "src/data/fleetModels.js", + "src/data/vesselTypes.js", + "src/data/fleetModelInstances.js", + "src/data/aircraftClass.js", + "src/data/aircraftModelLighting.js", + "src/data/fleetTrailAnchors.js", + "src/data/modelEligibility.js", "src/data/headingDegrees.js", "src/data/iconOrientation.js", "src/data/inputOwnership.js", @@ -5237,6 +5328,7 @@ "src/layers/transit/height.js", "src/services/terrainSurface.js", "src/layers/transit/index.js", + "src/layers/transit/cockpitSubject.js", "src/layers/transit/ingestion.js", "src/layers/transit/lifecycle.js", "src/layers/transit/policy.js", @@ -5726,7 +5818,8 @@ "src/layers/satellites/sensors.js", "src/data/ucsSatellites.js", "src/data/imagingSensors.js", - "src/data/planespottersPhoto.js" + "src/data/planespottersPhoto.js", + "src/data/openVesselPhoto.js" ], "external": ["@mapbox/vector-tile", "pbf"] }, @@ -5858,5 +5951,10 @@ "src/layers/wildlifeSightings/performance.js" ], "external": [] + }, + "rail-paths": { + "exports": ["./layers/rail/paths"], + "modules": ["src/data/railPaths.js"], + "external": [] } } diff --git a/scripts/qa-camera-controls.mjs b/scripts/qa-camera-controls.mjs index c2d2b5646..b16a793df 100644 --- a/scripts/qa-camera-controls.mjs +++ b/scripts/qa-camera-controls.mjs @@ -339,6 +339,45 @@ try { '#cctv-frame', (image) => !image.getAttribute('src') && image.dataset.cameraId === '', ); + // The address bar follows the share state after a short delay: wait for the + // CCTV panel token (1 collapsed, 0 open) the step expects. + const panelState = async (expectedToken) => { + const settled = await page + .waitForFunction( + (token) => + new RegExp(`(?:^|_)v\\.c\\.${token}(?:_|$)`).test( + new URLSearchParams(location.hash.slice(1)).get('ui') || '', + ), + { timeout: 5000 }, + expectedToken, + ) + .then( + () => true, + () => false, + ); + return page.evaluate( + (shared) => ({ + collapsed: document + .getElementById('cctv-panel') + .classList.contains('collapsed'), + shared, + ui: new URLSearchParams(location.hash.slice(1)).get('ui'), + }), + settled ? expectedToken : null, + ); + }; + const closedOnDisable = await panelState('1'); + check( + 'disable collapses the panel and its share preference', + closedOnDisable.collapsed && closedOnDisable.shared === '1', + JSON.stringify(closedOnDisable), + ); + // The switch sits inside the collapsed panel: reopen it from the right rail. + await page.click('[data-rail-item="cctv-panel"]'); + await page.waitForFunction( + () => + !document.getElementById('cctv-panel').classList.contains('collapsed'), + ); await page.click('#cctv-enable-btn'); await page.waitForFunction( () => @@ -351,6 +390,42 @@ try { 'disable clears the preview and re-enable reacquires the camera frame', disabledCleanly, ); + // Contacts switches CCTV off to isolate itself and restores it on the way + // out: the open panel and its share preference survive the round trip. + const cameraBeforeContacts = await page.evaluate( + () => + window.__godsEyeView.styleManager._cctvControls.getState() + ?.activeCameraId, + ); + const entered = await page.evaluate(() => + window.__godsEyeView.styleManager.setContextMode('flights'), + ); + await page.waitForFunction( + () => !window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const duringContacts = await panelState('0'); + await page.evaluate(() => + window.__godsEyeView.styleManager.setContextMode(null), + ); + await page.waitForFunction(() => + window.__godsEyeView.dataManager.isEnabled('cctv'), + ); + const afterContacts = await panelState('0'); + check( + 'Contacts isolation keeps the open CCTV panel and restores its camera', + entered?.ok === true && + !duringContacts.collapsed && + duringContacts.shared === '0' && + !afterContacts.collapsed && + afterContacts.shared === '0' && + (await page.evaluate( + (cameraId) => + window.__godsEyeView.styleManager._cctvControls.getState() + ?.activeCameraId === cameraId, + cameraBeforeContacts, + )), + JSON.stringify({ entered, duringContacts, afterContacts }), + ); await page.$eval('#cctv-frame', (image) => image.scrollIntoView({ block: 'nearest' }), ); diff --git a/scripts/qa-heading-b3.mjs b/scripts/qa-heading-b3.mjs index fb33654b1..b9151bbbd 100644 --- a/scripts/qa-heading-b3.mjs +++ b/scripts/qa-heading-b3.mjs @@ -40,7 +40,7 @@ * the DISPLAYED (render-delayed) time within 35° — catches * sign/offset inversion in the whole chain (chord lags the * tangent by ≤ half a segment ≈ 22.5°) - * Both layers: flights (airplane.glb, offset 180°) then military (jet.glb, + * Both layers: flights (fleet models, offset 180°) then military (fleet models, * normalized nose -X, offset 180°). * * VISUAL — with the turning flights plane tracked in 3D, capture screenshots @@ -721,7 +721,7 @@ async function main() { // ======================================================================== // Phase 1 — FLIGHTS layer: track TRN001, sample displayed course 30 s // ======================================================================== - console.log('\nPhase 1 — flights layer (airplane.glb, heading offset 180°)'); + console.log('\nPhase 1 — flights layer (fleet models, heading offset 180°)'); await page.evaluate((icao) => { window.__godsEyeView.dataManager.layers.get('flights').module.trackById(icao); }, TURN.flights[0].icao); @@ -807,7 +807,7 @@ async function main() { // ======================================================================== // Phase 2 — MILITARY layer: track TRNMIL1 (flights layer auto-clears) // ======================================================================== - console.log('\nPhase 2 — military layer (jet.glb, heading offset 180°)'); + console.log('\nPhase 2 — military layer (fleet models, heading offset 180°)'); await page.evaluate((hex) => { window.__godsEyeView.dataManager.layers.get('military').module.trackById(hex); }, TURN.military[0].hex); diff --git a/scripts/qa-sky-model-lighting.mjs b/scripts/qa-sky-model-lighting.mjs index 2f401506b..206d19d78 100644 --- a/scripts/qa-sky-model-lighting.mjs +++ b/scripts/qa-sky-model-lighting.mjs @@ -75,7 +75,7 @@ try { } const position = C.Cartesian3.fromDegrees(0, 0, 10000); const model = await C.Model.fromGltfAsync({ - url: '/models/airplane.glb', + url: '/models/fleet/aircraft/a320.glb', modelMatrix: C.Transforms.eastNorthUpToFixedFrame(position), }); scene.primitives.add(model); diff --git a/scripts/qa-sprites-b5.mjs b/scripts/qa-sprites-b5.mjs index 7e5b824c0..190a0c651 100644 --- a/scripts/qa-sprites-b5.mjs +++ b/scripts/qa-sprites-b5.mjs @@ -15,9 +15,9 @@ * per-class glyph (image data-URI === aircraftIcon(klass), * imported from src) and per-class scale (flights: * CLASS_SCALE_2D; military: 0.7 × CLASS_SCALE_2D) - * B3 models : with 3D on, fleet Cesium.Model.scale === the layer's - * MODEL_SCALE (both 1; legacy scale is baked into the GLB) - * × CLASS_SCALE_3D[klass] per icao; tracked too + * B3 models : with 3D on, fleet Cesium.Model.scale === the life-size + * scale of the fleet model the aircraft's type or category + * selects (aircraftModelSpec) per icao; tracked too * B4 clean : no console errors * * Visual output (qa-shots/b5/, untracked): top-down 2D spreads of both rows, @@ -33,15 +33,18 @@ import fs from 'node:fs'; import path from 'node:path'; import puppeteer from 'puppeteer'; import { stubPlanespotters } from './qa-photo-stub.mjs'; -import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; -// Hangar fleet (2026-08-16): real per-class GLBs render at scale 1; military -// heavies (airliner/quadjet/glider) render airplane.glb at 1 x class; only -// fastjet/unknown keep the per-layer jet/airplane MODEL_SCALE formula. -const flightsWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1 : FL_MODEL_SCALE * CLASS_SCALE_3D[klass]); -const MIL_PLANE_CLASSES = new Set(['airliner', 'quadjet', 'glider']); -const militaryWantScale = (klass) => (CLASS_MODEL_REAL[klass] ? 1 - : MIL_PLANE_CLASSES.has(klass) ? MIL_PLANE_MODEL_SCALE * CLASS_SCALE_3D[klass] - : MIL_MODEL_SCALE * CLASS_SCALE_3D[klass]); +import { classifyAircraft, CLASS_SCALE_2D } from '../src/data/aircraftClass.js'; +import { aircraftModelSpec } from '../src/data/fleetModels.js'; +// Every aircraft draws its fleet model life size: its type's length over the +// model's built length, chosen by type code (military) or category (flights). +const FLEET_SET = Object.freeze({ + catalog: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/catalog.json', import.meta.url), 'utf8')), + types: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/types.json', import.meta.url), 'utf8')), +}); +const flightsWantScale = (icao) => + aircraftModelSpec(FLEET_SET, { category: SPRITES.flights.find((p) => p.icao === icao)?.category }).scale; +const militaryWantScale = (hex) => + aircraftModelSpec(FLEET_SET, { typeCode: SPRITES.military.find((p) => p.hex === hex)?.t }).scale; import { aircraftIcon } from '../src/data/aircraftIcons.js'; // --------------------------------------------------------------------------- @@ -58,9 +61,6 @@ const APP_URL = getOpt('--url', 'http://localhost:4173'); const HEADFUL = getFlag('--headful'); const SHOT_DIR = path.resolve('qa-shots/b5'); -const FL_MODEL_SCALE = 1; // flights.js (airplane.glb is transform-applied and meter-scale) -const MIL_PLANE_MODEL_SCALE = 1; // military heavy classes share the same baked airplane.glb -const MIL_MODEL_SCALE = 1; // militaryFlights.js (jet.glb is already real-world scale, native radius ~29.8 m) const MIL_BILLBOARD_SCALE = 0.7; // militaryFlights BILLBOARD_SCALE const CHROME_EXECUTABLE_CANDIDATES = [ @@ -134,10 +134,9 @@ const SPRITES = { // course 0 like the rest — keeps the row a clean size-comparison line (2D // rotation is demoed by caa004 and gated by qa-heading-b3 anyway). { hex: 'dda008', flight: 'ML-FJ', t: 'F16', lon0: ROW_LON0 + 7 * ROW_STEP_DEG, lat0: 30.28, courseDeg: 0, speedMps: 80, altFt: 15000 }, - // 3D cluster: C17 vs F16 (tracked) vs B744 vs C130. jet.glb is a - // real-world-scale asset (native bounding radius ~29.8) rendered at - // MODEL_SCALE 1, so models are ~22–43 m radius — same 150 m spacing and - // 450–900 m shot ranges as the flights cluster. + // 3D cluster: C17 vs F16 (tracked) vs B744 vs C130, each drawn life size + // (~8–36 m radius) — same 150 m spacing and 450–900 m shot ranges as the + // flights cluster. { hex: 'ddb001', flight: '3DMWID', t: 'C17', lon0: CLUSTER_LON0 - mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 }, { hex: 'ddb002', flight: '3DMFJ', t: 'F16', lon0: CLUSTER_LON0, lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 }, { hex: 'ddb003', flight: '3DMQUA', t: 'B744', lon0: CLUSTER_LON0 + mDegLon(150, 30.05), lat0: 30.05, courseDeg: 0, speedMps: 60, altFt: 12000 }, @@ -464,7 +463,7 @@ async function main() { // ======================================================================== // B3 + 3D screenshots — flights cluster // ======================================================================== - console.log('\n3D — flights cluster (airplane.glb, per-class scale)'); + console.log('\n3D — flights cluster (fleet models, life size)'); await page.evaluate(() => { const dm = window.__godsEyeView.dataManager; dm.layers.get('flights').module.setParams({ models3d: true }); @@ -481,20 +480,20 @@ async function main() { const flModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready)); const modelScaleBad = []; for (const [id, want] of [ - ['cbb001', flightsWantScale(expectedFlights.get('cbb001'))], - ['cbb003', flightsWantScale(expectedFlights.get('cbb003'))], + ['cbb001', flightsWantScale('cbb001')], + ['cbb003', flightsWantScale('cbb003')], ]) { const m = flModels.find((x) => x.id === id); if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`); } const trackedM = flModels.find((x) => x.id === 'cbb002'); - const trackedWant = flightsWantScale(expectedFlights.get('cbb002')); + const trackedWant = flightsWantScale('cbb002'); if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) { modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`); } - record('B3a flights: model scales match the per-class registry (all GLBs meter-scale; shared @1×class)', + record('B3a flights: model scales match each type's life-size fleet model', modelScaleBad.length === 0, - modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry'); + modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact'); console.log(` world bounding radii (m): ${flModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`); await sleep(1500); @@ -509,7 +508,7 @@ async function main() { // ======================================================================== // B3 + 3D screenshots — military cluster // ======================================================================== - console.log('\n3D — military cluster (jet.glb, per-class scale)'); + console.log('\n3D — military cluster (fleet models, life size)'); await page.evaluate(() => { const dm = window.__godsEyeView.dataManager; dm.layers.get('military').module.setParams({ models3d: true }); @@ -526,25 +525,25 @@ async function main() { const milModels = await page.evaluate(() => window.__collectModels().filter((m) => m.ready)); const modelScaleBad = []; for (const [id, want] of [ - ['ddb001', militaryWantScale(expectedMilitary.get('ddb001'))], - ['ddb003', militaryWantScale(expectedMilitary.get('ddb003'))], - ['ddb004', militaryWantScale(expectedMilitary.get('ddb004'))], + ['ddb001', militaryWantScale('ddb001')], + ['ddb003', militaryWantScale('ddb003')], + ['ddb004', militaryWantScale('ddb004')], ]) { const m = milModels.find((x) => x.id === id); if (!m || Math.abs(m.scale - want) > 1e-9) modelScaleBad.push(`${id}: ${m ? m.scale : 'missing'} != ${want}`); } const trackedM = milModels.find((x) => x.id === 'ddb002'); - const trackedWant = militaryWantScale(expectedMilitary.get('ddb002')); + const trackedWant = militaryWantScale('ddb002'); if (!trackedM || Math.abs(trackedM.scale - trackedWant) > 1e-9) { modelScaleBad.push(`tracked: ${trackedM ? trackedM.scale : 'missing'} != ${trackedWant}`); } - record('B3b military: model scales match the weight-class registry (real/heavy/jet assets @1×class)', + record('B3b military: model scales match each type's life-size fleet model', modelScaleBad.length === 0, - modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact per registry'); + modelScaleBad.length ? modelScaleBad.join(' ') : 'all exact'); console.log(` world bounding radii (m): ${milModels.filter((m) => m.scale !== undefined).map((m) => `${m.id ?? 'tracked'}=${m.radius?.toFixed(1)}`).join(' ')}`); await sleep(1500); - // jet.glb at MODEL_SCALE 1 is real-world size — same shot ranges as the + // Fleet models are drawn life size — same shot ranges as the // flights cluster (close zoom no longer puts the camera inside a model). await zoomToRange(900); await page.screenshot({ path: path.join(SHOT_DIR, 'b5-3d-military-far.png') }); diff --git a/scripts/track-regression.mjs b/scripts/track-regression.mjs index f87250430..10d3e3979 100644 --- a/scripts/track-regression.mjs +++ b/scripts/track-regression.mjs @@ -97,9 +97,15 @@ import fs from 'node:fs'; import puppeteer from 'puppeteer'; import { respondToPlanespotters } from './qa-photo-stub.mjs'; import { arrivalAngleViolated, arrivalRotationPassed } from '../src/tooling/trackArrivalObservation.mjs'; -import { classifyAircraft, CLASS_SCALE_3D, CLASS_MODEL_REAL } from '../src/data/aircraftClass.js'; +import { aircraftModelSpec } from '../src/data/fleetModels.js'; import { ensureGeoidReady, geoidHeight } from '../src/data/geoid.js'; +/** The fleet model set the aircraft layers draw from, read from disk. */ +const FLEET_SET = Object.freeze({ + catalog: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/catalog.json', import.meta.url), 'utf8')), + types: JSON.parse(fs.readFileSync(new URL('../public/models/fleet/types.json', import.meta.url), 'utf8')), +}); + // --------------------------------------------------------------------------- // Args // --------------------------------------------------------------------------- @@ -547,13 +553,13 @@ async function main() { // Independent GLB capability control. Product model absence cannot prove // that the browser backend is incapable: that is the condition this - // harness is meant to catch. Load the already-approved airplane asset + // harness is meant to catch. Load one of the shipped fleet models // directly through Cesium, outside the Flights/Military model pipelines, // then remove the control before exercising product behavior. const glbControlStarted = await evalPage(async () => { window.__qaGlbControl = null; window.__qaGlbControlError = null; - const asset = await fetch('/models/airplane.glb'); + const asset = await fetch('/models/fleet/aircraft/a320.glb'); const assetBytes = asset.ok ? (await asset.arrayBuffer()).byteLength : 0; if (!asset.ok || assetBytes === 0) { throw new Error(`GLB control asset unavailable: HTTP ${asset.status}`); @@ -564,7 +570,7 @@ async function main() { Cesium.Cartesian3.fromDegrees(-97.7431, 30.2672, 9000), ); void Cesium.Model.fromGltfAsync({ - url: '/models/airplane.glb', + url: '/models/fleet/aircraft/a320.glb', modelMatrix, scale: 1, }).then((model) => { @@ -1454,8 +1460,8 @@ async function main() { }, trackedIcao); console.log(' (3D model did not render under SwiftShader; testing the shared display-position contract directly)'); } else { - // Full check: independently transform airplane.glb's measured AABB centre - // through the actual 3D model matrix, then compare it with detection. + // Full check: independently transform the fleet model's visual centre + // (its origin) through the actual 3D model matrix, then compare it with detection. // glTF [x,y,z] → Cesium-local [x,-z,y]. The effective rendered // computedScale is separate from modelMatrix and includes any // minimumPixelSize enlargement, so include it just as the renderer does. @@ -1469,7 +1475,7 @@ async function main() { const scale = Number.isFinite(model.computedScale) ? model.computedScale : (Number.isFinite(model.scale) ? model.scale : 1); - // airplane.glb is now bounding-box centred with transforms applied. + // Fleet models are drawn about their origin, transforms applied. const x = 0 * scale; const y = 0 * scale; const z = 0 * scale; @@ -2229,18 +2235,12 @@ async function main() { // ============================================================ console.log('\nGround 3D — grounded planes are model-eligible + snapped to the sampled skin'); const GROUND3D_STUB_H = 187.5; // stubbed tile-skin height (m, ellipsoid) - // Expected belly offsets (m): native origin-above-lowest-vertex × MODEL_SCALE - // × CLASS_SCALE_3D[klass] — constants match the layers' MODEL_BELLY_OFFSET_NATIVE - // / MODEL_SCALE pairs, which modelScale.test.mjs locks against the GLBs. - // Class is computed with the REAL classifier on the synthetic feed fields. - const g3dFlKlass = classifyAircraft({ typeCode: null, category: null }); - const g3dMilKlass = classifyAircraft({ typeCode: 'C130', category: undefined }); - const g3dFlOffset = 6.719 * 1 * (CLASS_SCALE_3D[g3dFlKlass] || 1); // airplane.glb, flights - // Hangar fleet (2026-08-16): military C130 → turboprop → the real atr72.glb, - // whose belly offset is the registry's measured bellyM (meters, scale 1) — - // NOT the jet.glb 5.631 × class formula. Pinned by modelScale.test.mjs. - const g3dMilOffset = CLASS_MODEL_REAL[g3dMilKlass]?.bellyM - ?? 5.631 * 1 * (CLASS_SCALE_3D[g3dMilKlass] || 1); // jet.glb fallback classes + // Expected belly offsets (m): the fleet model's wheel clearance at its + // life-size scale, the model chosen as the layers choose it + // (aircraftModelSpec) from the synthetic feed's fields: no type for the + // flights contact (the default airliner), a C130 for the military one. + const g3dFlOffset = aircraftModelSpec(FLEET_SET, { typeCode: null }).clearanceM; + const g3dMilOffset = aircraftModelSpec(FLEET_SET, { typeCode: 'C130' }).clearanceM; // Height-datum fix (Task 6): the flights-layer billboard for a grounded synthetic // (no geo_altitude in this stub feed, no warm terrain-height cache in a headless // run) now renders at baroM + geoidHeight(lat, lon) rather than the raw baro @@ -2509,10 +2509,10 @@ async function main() { const flDelta = g3dState.flModelRadius - g3dIngest.flBBRadius; const milDelta = g3dState.milModelRadius - g3dIngest.milBBRadius; const HEIGHT_TOL_M = 1.0; - record(`ground-3d: flights model height = sampled skin + belly offset (${g3dFlKlass})`, + record(`ground-3d: flights model height = sampled skin + belly offset (default airliner)`, Math.abs(flDelta - flWantDelta) <= HEIGHT_TOL_M, `Δradial=${flDelta.toFixed(2)} m want=${flWantDelta.toFixed(2)} m (stub ${GROUND3D_STUB_H} + offset ${g3dFlOffset.toFixed(2)} − alt ${(150 + g3dFlGeoidN).toFixed(2)} [150 baro + ${g3dFlGeoidN.toFixed(2)} geoidN])`); - record(`ground-3d: military model height = sampled skin + belly offset (${g3dMilKlass})`, + record(`ground-3d: military model height = sampled skin + belly offset (C130)`, Math.abs(milDelta - milWantDelta) <= HEIGHT_TOL_M, `Δradial=${milDelta.toFixed(2)} m want=${milWantDelta.toFixed(2)} m (stub ${GROUND3D_STUB_H} + offset ${g3dMilOffset.toFixed(2)} − alt 0)`); record('ground-3d: billboard→model handoff holds on the ground (icons hidden once models render)', @@ -2712,7 +2712,7 @@ async function main() { const scale = Number.isFinite(m.computedScale) ? m.computedScale : (Number.isFinite(m.scale) ? m.scale : 1); - // airplane.glb is now bounding-box centred with transforms applied. + // Fleet models are drawn about their origin, transforms applied. const x = 0 * scale; const y = 0 * scale; const z = 0 * scale; @@ -3722,8 +3722,8 @@ async function main() { // WARM ground, camera IN: the placement. The model is not merely visible, // it is standing ON the floor — within one class belly offset above it, - // never under it. MAX_BELLY_M clears the largest offset any class adds - // (widebody b789, 7.81 m in CLASS_MODEL_REAL); the floor itself is the + // never under it. MAX_BELLY_M clears the largest wheel clearance any + // fleet aircraft stands on at life size; the floor itself is the // hard lower bound, and a model placed at the raw feed altitude is ~240 m // under it. const MAX_BELLY_M = 12; diff --git a/server/providers/vessels/ais-live.js b/server/providers/vessels/ais-live.js index cb1dd36b0..e525763ef 100644 --- a/server/providers/vessels/ais-live.js +++ b/server/providers/vessels/ais-live.js @@ -27,6 +27,8 @@ const AISSTREAM_DEFAULT_MESSAGE_TYPES = [ 'ExtendedClassBPositionReport', 'ShipStaticData', 'StaticDataReport', + // Buoys, beacons and other aids to navigation (AIS message 21). + 'AidsToNavigationReport', ]; // Watchdog budgets (policy lives in src/data/aisWatchdog.js). Silence is // REPORTED quickly and ACTED ON slowly: a dead feed must read as dead within diff --git a/server/providers/vessels/ais-store.js b/server/providers/vessels/ais-store.js index 71cefceed..6c4d8a57a 100644 --- a/server/providers/vessels/ais-store.js +++ b/server/providers/vessels/ais-store.js @@ -1,4 +1,8 @@ import { isRecognizedAisEnvelope } from '../../../src/data/aisStreamAdapter.js'; +import { + aidToNavigationTypeName, + isAidToNavigation, +} from '../../../src/data/vesselTypes.js'; export const AISSTREAM_CACHE_MAX = 50000; export const AISSTREAM_STALE_MS = 30 * 60 * 1000; // Per-MMSI recent-path ring buffers (PRD WS-F F3). Float32 lat/lon (~1m @@ -57,8 +61,13 @@ export function ingestAisStreamEnvelope(envelope) { message.ReportB?.Valid === false ? undefined : message.ReportB?.ShipType, + Dimension: + message.ReportB?.Valid === false + ? undefined + : message.ReportB?.Dimension, } : message; + const dimensions = aisDimensions(fields.Dimension); // Static reports may be partial (including Class B's separate A/B parts). // Keep known values in the cache as well as in the current live row. const staticData = { @@ -84,6 +93,8 @@ export function ingestAisStreamEnvelope(envelope) { staticCode(live?.imo) || staticCode(previous.imo) || '', + length: dimensions.length ?? live?.length ?? previous.length ?? null, + beam: dimensions.beam ?? live?.beam ?? previous.beam ?? null, }; _aisStreamStatic.set(mmsi, staticData); mergeAisStaticIntoLiveVessel(mmsi, staticData); @@ -105,14 +116,24 @@ export function ingestAisStreamEnvelope(envelope) { if (!Number.isFinite(lat) || !Number.isFinite(lon)) return true; const staticData = _aisStreamStatic.get(mmsi) || {}; + const aidReport = messageType === 'AidsToNavigationReport'; _aisStreamVessels.set(mmsi, { lat, lon, - name: vesselNameFromAis(metadata, message, staticData) || `MMSI ${mmsi}`, + name: + (aidReport && aidNameFromAis(message)) || + vesselNameFromAis(metadata, message, staticData) || + `MMSI ${mmsi}`, mmsi, imo: stringValue(message.ImoNumber ?? message.IMO ?? staticData.imo), - type: vesselTypeFromAis(message, staticData), + // An aid-to-navigation report's type is the kind of aid (0 to 31), not + // a ship type: it is carried as the aid's name ("Cardinal Mark N"). + type: aidReport + ? aidToNavigationTypeName(message.Type) + : vesselTypeFromAis(message, staticData), destination: stringValue(message.Destination ?? staticData.destination), + length: staticData.length ?? null, + beam: staticData.beam ?? null, speed: normalizedSpeedOverGround(message.Sog ?? message.SOG), course: normalizedCourseOverGround(message.Cog ?? message.COG), heading: normalizedHeading(message.TrueHeading ?? message.Heading), @@ -122,6 +143,9 @@ export function ingestAisStreamEnvelope(envelope) { // Use the AIS message's own report time, not server ingest wall-clock — // trail spacing and dead reckoning depend on true fix epochs. last_position_epoch: aisEpochSeconds(metadata.time_utc ?? metadata.TimeUtc), + // A virtual aid is broadcast where nothing physical stands. Only such a + // row carries the field, so ship rows stay their size. + ...(aidReport && message.VirtualAtoN === true && { virtual: true }), _updatedAt: Date.now(), }); @@ -236,6 +260,25 @@ function mergeAisStaticIntoLiveVessel(mmsi, staticData) { if (staticData.type) existing.type = staticData.type; if (staticData.destination) existing.destination = staticData.destination; if (staticData.imo) existing.imo = staticData.imo; + if (staticData.length) existing.length = staticData.length; + if (staticData.beam) existing.beam = staticData.beam; +} + +/** + * A static report's hull size from its AIS dimensions: the antenna's + * distances to bow (A), stern (B), port (C) and starboard (D), in metres. + * Zero is AIS's "not available", so a side missing its two parts has none. + * @param {{A?: number, B?: number, C?: number, D?: number}|undefined} dimension + * @returns {{length: number|null, beam: number|null}} + */ +function aisDimensions(dimension) { + const part = (value) => { + const number = numberValue(value); + return number !== null && number > 0 ? number : 0; + }; + const length = part(dimension?.A) + part(dimension?.B); + const beam = part(dimension?.C) + part(dimension?.D); + return { length: length > 0 ? length : null, beam: beam > 0 ? beam : null }; } function vesselNameFromAis(metadata, message, staticData = {}) { @@ -248,6 +291,15 @@ function vesselNameFromAis(metadata, message, staticData = {}) { ); } +/** + * An aid's whole name: an aid-to-navigation report carries the first 20 + * characters in `Name` and the rest, up to 14 more, in `NameExtension`. + */ +function aidNameFromAis(message) { + const name = stringValue(message.Name); + return name ? stringValue(name + stringValue(message.NameExtension)) : ''; +} + function vesselTypeFromAis(message, staticData = {}) { return stringValue( message.Type ?? @@ -257,14 +309,25 @@ function vesselTypeFromAis(message, staticData = {}) { ); } +/** + * The current rows, newest first, with every vessel ahead of every aid to + * navigation: a capped request never gives up a ship for a buoy. + */ export function aisStreamRows(maxRows) { const cutoff = Date.now() - AISSTREAM_STALE_MS; - const rows = []; + const vessels = []; + const aids = []; for (const row of _aisStreamVessels.values()) { - if (row._updatedAt >= cutoff) rows.push(row); + if (row._updatedAt >= cutoff) + (isAidToNavigation(row) ? aids : vessels).push(row); } - rows.sort((a, b) => b._updatedAt - a._updatedAt); - return rows.slice(0, maxRows).map(({ _updatedAt, ...row }) => row); + const newestFirst = (a, b) => b._updatedAt - a._updatedAt; + vessels.sort(newestFirst); + if (vessels.length < maxRows) aids.sort(newestFirst); + return vessels + .concat(aids) + .slice(0, maxRows) + .map(({ _updatedAt, ...row }) => row); } function pruneAisStreamCache() { diff --git a/src/annotations/drawToolBehaviour.test.mjs b/src/annotations/drawToolBehaviour.test.mjs index 4cef4d4fe..b435fdf24 100644 --- a/src/annotations/drawToolBehaviour.test.mjs +++ b/src/annotations/drawToolBehaviour.test.mjs @@ -400,6 +400,27 @@ test('Enter finishes from the canvas and the label field, never from a focused b } }); +test('Enter on the globe canvas finishes, as after the render failure notice hands focus back', async () => { + const h = harness(); + try { + h.act('toggleDraw'); + h.tool.setShape('area'); + h.tool.addVertex(-97.75, 30.26); + h.tool.addVertex(-97.74, 30.26); + h.tool.addVertex(-97.74, 30.27); + + // src/ui/views/panels/RenderFailure.vue focuses the canvas when the globe + // draws again and the control that had focus is gone. + h.documentDouble.emit('keydown', { key: 'Enter', target: h.viewer.scene.canvas }); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(h.annotations.calls.annotate.length, 1, 'Enter on the canvas finishes'); + assert.equal(h.annotations.calls.annotate[0].specs[0].type, 'area'); + } finally { + h.restore(); + } +}); + test('the live preview hangs at the height the click landed on', async () => { const h = harness(); try { diff --git a/src/app/constructCatalog.js b/src/app/constructCatalog.js index 779d41c06..ce04344e9 100644 --- a/src/app/constructCatalog.js +++ b/src/app/constructCatalog.js @@ -109,6 +109,8 @@ export function createApplicationCatalog({ const vessels = createApplicationVessels({ source: sources.vessels, options: vesselOptions, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), }); const installations = createApplicationInstallations({ surface, @@ -167,7 +169,12 @@ export function createApplicationCatalog({ createApplicationTraffic({ source: sources.traffic, surface }), createApplicationCctv({ surface, source: sources.cctv }), createApplicationRadio({ surface, source: sources.radio }), - createApplicationTransit({ surface, source: sources.transit }), + createApplicationTransit({ + surface, + source: sources.transit, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), + }), createApplicationBikeshare({ surface, source: sources.bikeshare }), createApplicationDirections({ source: sources.directions }), createApplicationRecentImagery({ source: sources['recent-imagery'] }), @@ -203,7 +210,12 @@ export function createApplicationCatalog({ terrainSurface: surface.groundFloor.terrainSurface, }), createApplicationCables({ source: sources.cables }), - createApplicationRail({ surface, source: sources.rail }), + createApplicationRail({ + surface, + source: sources.rail, + displayParams: () => flights.getParams(), + ...(resolveAsset ? { resolveAsset } : {}), + }), createApplicationFirms({ surface, id: 'local-firms', diff --git a/src/app/controls.js b/src/app/controls.js index 99bc2555f..16da2c295 100644 --- a/src/app/controls.js +++ b/src/app/controls.js @@ -9,7 +9,10 @@ import { t } from '../i18n/index.js'; * Construct the existing controls and camera presentation. `initialShareCode` * opens a share code (the `` of `/share/`) at start-up, for a host * whose address does not carry one. `localePicker: false` removes the language - * picker, for a host that does not initialize Core's locale. + * picker, for a host that offers its own language choice (`switchLocale` from + * the `./i18n` entry). + * `services.cockpitSubjects` adds the application's own first-person subjects + * to the Cockpit (src/cockpitSubjectProviders.js). */ export function createApplicationControls({ scene: { viewer, mapStackController, operations }, diff --git a/src/app/embed.js b/src/app/embed.js index 85c4f0752..32541f85a 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -94,17 +94,6 @@ export function panelAwareVisibility(doc = globalThis.document) { const PANEL_FRAME_MS = 33; const MISSED_FRAMES_MS = 250; -/** A render error's details, including the plain objects workers report. */ -function describeError(error) { - if (error instanceof Error) - return `${error.name}: ${error.message}\n${error.stack}`; - try { - return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {})); - } catch { - return String(error); - } -} - /** * Keep the globe drawing in an inline panel. Some hosts report a panel on * screen as hidden, which stops the browser's animation frames and with them @@ -122,33 +111,27 @@ export function keepPanelRendering( frameRequest = windowRef.requestAnimationFrame(onFrame); }; frameRequest = windowRef.requestAnimationFrame(onFrame); - // Cesium stops drawing after a render error, and so does the timer. - // Report the error in full: workers report plain objects, which Cesium - // prints as [object Object]. - let failed = false; - const removeErrorListener = viewer.scene?.renderError?.addEventListener( - (_scene, error) => { - failed = true; - console.error( - "[God's Eye View panel] render error:", - describeError(error), - ); - }, - ); + // Cesium stops its render loop after a render error, and the timer stops + // with it; Retry restarts both. The render failure notice logs the error + // (src/ui/renderFailure.js). const timer = windowRef.setInterval(() => { if ( - failed || + viewer.useDefaultRenderLoop === false || now() - lastFrame < MISSED_FRAMES_MS || viewer.isDestroyed?.() ) return; - viewer.resize(); - viewer.render(); + try { + viewer.resize(); + viewer.render(); + } catch { + // Raised as a render error by now (src/ui/renderFailure.js), which + // stops this timer; thrown on, it would only reach the host page. + } }, PANEL_FRAME_MS); return () => { windowRef.clearInterval(timer); windowRef.cancelAnimationFrame(frameRequest); - removeErrorListener?.(); }; } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index bcc5ada9b..e4e8a1d22 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -457,8 +457,7 @@ test('a panel keeps drawing from a timer while animation frames stop', () => { assert.equal(frame, null); }); -test('a panel reports a render error in full and stops drawing', () => { - let listener = null; +test('a panel stops drawing while its render loop is stopped', () => { let tick = null; const windowRef = { requestAnimationFrame: () => 1, @@ -467,31 +466,19 @@ test('a panel reports a render error in full and stops drawing', () => { clearInterval() {}, }; const drawn = []; - const viewer = { - resize() {}, - render: () => drawn.push('render'), - scene: { - renderError: { - addEventListener: (fn) => ((listener = fn), () => (listener = null)), - }, - }, - }; - const logged = []; - const original = console.error; - console.error = (...args) => logged.push(args.join(' ')); - try { - let clock = 0; - const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); - listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' }); - assert.match(logged[0], /render error: .*"message":"worker failed"/); - clock = 1000; - tick(); - assert.deepEqual(drawn, []); - stop(); - assert.equal(listener, null); - } finally { - console.error = original; - } + const viewer = { resize() {}, render: () => drawn.push('render') }; + let clock = 0; + const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); + // Cesium stops its render loop after a render error. + viewer.useDefaultRenderLoop = false; + clock = 1000; + tick(); + assert.deepEqual(drawn, []); + // Retry restarts it, and the timer draws again. + viewer.useDefaultRenderLoop = true; + tick(); + assert.deepEqual(drawn, ['render']); + stop(); }); test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => { diff --git a/src/app/layers/aisLiveVessels.js b/src/app/layers/aisLiveVessels.js index cae0bf44c..65edc5d63 100644 --- a/src/app/layers/aisLiveVessels.js +++ b/src/app/layers/aisLiveVessels.js @@ -10,9 +10,19 @@ import * as focus from '../../data/focusDeemphasis.js'; import * as worldFocus from '../../worldFocus.js'; import * as render from '../../renderGovernor.js'; import * as readouts from '../../hudReadouts.js'; +import { fleetModelServices } from './fleetModels.js'; -/** Construct one layer using the application scene owners and a supplied source. */ -export function createApplicationVessels({ source, options = {} }) { +/** + * Construct one layer using the application scene owners and a supplied + * source. `displayParams` reads the Flights layer's DISPLAY-rail 3D + * preference, under which nearby vessels are drawn as fleet models. + */ +export function createApplicationVessels({ + source, + options = {}, + displayParams = null, + resolveAsset, +}) { return createVesselLayer({ source, options, @@ -28,6 +38,10 @@ export function createApplicationVessels({ source, options = {} }) { worldFocus, render, readouts, + fleet: fleetModelServices({ + display: displayParams, + ...(resolveAsset ? { resolveAsset } : {}), + }), }, }); } diff --git a/src/app/layers/fleetModels.js b/src/app/layers/fleetModels.js new file mode 100644 index 000000000..f6c5bcb7d --- /dev/null +++ b/src/app/layers/fleetModels.js @@ -0,0 +1,19 @@ +import { FLEET_MODEL_POLICY } from '../../layers/flights/policy.js'; + +/** + * The fleet-model service the Vessels, Transit and Rail layers draw their 3D + * models with: the Flights layer's DISPLAY-rail 3D preference + * (`{ models3d, models3dMode }`), the aircraft layers' icon-to-model rules, + * and the resolver the fleet set's files are fetched through. + * @param {{display?: (() => object|null)|null, + * resolveAsset?: (url: string) => string}} [options] + * @returns {object|null} Null without a display preference (no models). + */ +export function fleetModelServices({ + display = null, + resolveAsset = (url) => + `${import.meta.env?.BASE_URL || '/'}${url.replace(/^\//, '')}`, +} = {}) { + if (!display) return null; + return Object.freeze({ display, policy: FLEET_MODEL_POLICY, resolveAsset }); +} diff --git a/src/app/layers/rail.js b/src/app/layers/rail.js index 9b5a3bb0e..fb3a05c51 100644 --- a/src/app/layers/rail.js +++ b/src/app/layers/rail.js @@ -1,5 +1,6 @@ import { createRailLayer } from '../../layers/rail/index.js'; import * as render from '../../renderGovernor.js'; +import { fleetModelServices } from './fleetModels.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; @@ -13,10 +14,18 @@ import { /** * Construct the Rail layer on the application's scene owners and its shared * ground floor, the same floor Transit and the aircraft stand on. - * @param {{surface: object, source?: object}} options + * `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under + * which nearby trains are drawn as fleet models. + * @param {{surface: object, source?: object, + * displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options * @returns {object} */ -export function createApplicationRail({ surface, source }) { +export function createApplicationRail({ + surface, + source, + displayParams = null, + resolveAsset, +}) { if (!surface?.groundFloor) throw new TypeError('Rail needs the application surface services'); const { groundFloor } = surface; @@ -45,6 +54,10 @@ export function createApplicationRail({ surface, source }) { globalThis.document?.body?.classList?.contains('cockpit-mode') === true, }, + fleet: fleetModelServices({ + display: displayParams, + ...(resolveAsset ? { resolveAsset } : {}), + }), }, }); } diff --git a/src/app/layers/transit.js b/src/app/layers/transit.js index 367fc425e..1b430a957 100644 --- a/src/app/layers/transit.js +++ b/src/app/layers/transit.js @@ -1,5 +1,6 @@ import { createTransitLayer } from '../../layers/transit/index.js'; import * as render from '../../renderGovernor.js'; +import { fleetModelServices } from './fleetModels.js'; import * as sprites from '../../data/spriteOrder.js'; import * as picking from '../../data/pickRegistry.js'; import * as overlays from '../../overlays/worldOverlay.js'; @@ -19,10 +20,19 @@ import { * own mesh floors. An earlier cut imported the legacy module-level singleton * instead and could not see either. * - * @param {{surface: object}} options + * `displayParams` reads the Flights layer's DISPLAY-rail 3D preference, under + * which nearby vehicles are drawn as fleet models. + * + * @param {{surface: object, source?: object, + * displayParams?: () => object|null, resolveAsset?: (url: string) => string}} options * @returns {object} */ -export function createApplicationTransit({ surface, source }) { +export function createApplicationTransit({ + surface, + source, + displayParams = null, + resolveAsset, +}) { if (!surface?.groundFloor || !surface?.meshFloor) throw new TypeError('Transit needs the application surface services'); const { groundFloor, meshFloor } = surface; @@ -44,6 +54,10 @@ export function createApplicationTransit({ surface, source }) { allocateCorridorCells: groundFloor.allocateCorridorCells, }, mesh: { sampleMeshFloorCells: meshFloor.sampleMeshFloorCells }, + fleet: fleetModelServices({ + display: displayParams, + ...(resolveAsset ? { resolveAsset } : {}), + }), }, }); } diff --git a/src/app/renderFailureInlineRetry.test.mjs b/src/app/renderFailureInlineRetry.test.mjs new file mode 100644 index 000000000..fa84e880f --- /dev/null +++ b/src/app/renderFailureInlineRetry.test.mjs @@ -0,0 +1,120 @@ +// Retry in an inline panel whose host stops animation frames, so the panel +// draws from keepPanelRendering's timer. Drives Cesium's real startRenderLoop +// and useDefaultRenderLoop setter (CesiumWidget.prototype). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { keepPanelRendering } from './embed.js'; +import { renderLoopFixture } from '../ui/renderLoopFixture.mjs'; + +function setup() { + let tick = null; + let clock = 0; + // The host reports the panel hidden: its own animation frames never arrive. + const windowRef = { + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + setInterval: (fn) => ((tick = fn), 1), + clearInterval() {}, + }; + const previousError = console.error; + console.error = () => {}; + const state = { failure: null, drawn: 0 }; + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() {}, + initializeFrame() {}, + // Scene.render: a failing primitive update raises renderError + // (tryAndCatchError) and returns; a failing listener throws. + render() { + state.drawn += 1; + this.preUpdate.raiseEvent(this); + if (state.failure === 'caught') + this.renderError.raiseEvent(this, new Error('boom')); + if (state.failure === 'thrown') throw new Error('boom'); + this.postRender.raiseEvent(this); + }, + }; + const { frames, widget, viewer, store, runFrames, restore } = + renderLoopFixture(scene, { + viewer: { + isDestroyed: () => false, + render: () => widget.render(), + resize: () => widget.resize(), + }, + notice: { allowReload: false }, + }); + const stop = keepPanelRendering(viewer, { windowRef, now: () => clock }); + viewer.useDefaultRenderLoop = true; + const timerFrame = () => { + clock += 400; + tick(); + }; + return { + frames, + state, + store, + timerFrame, + animationFrames: runFrames, + restore: () => { + stop(); + restore(); + console.error = previousError; + }, + }; +} + +for (const failure of ['caught', 'thrown']) { + test(`an error ${failure} in a frame the timer draws: Retry draws again with one render loop`, () => { + const { frames, state, store, timerFrame, animationFrames, restore } = + setup(); + try { + // The host stops animation frames with the loop's next one queued. + assert.equal(frames.length, 1); + state.failure = failure; + timerFrame(); // an error thrown here does not reach the host page + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(typeof store.renderFailure.retry, 'function'); + state.failure = null; + store.renderFailure.retry(); + const before = state.drawn; + timerFrame(); + assert.ok(state.drawn > before, 'the panel draws again after Retry'); + assert.equal( + store.renderFailure, + null, + 'a clean frame clears the notice', + ); + assert.equal(frames.length, 1, 'Retry starts no second loop'); + // The host resumes frames: the globe draws once a frame. + state.drawn = 0; + animationFrames(3); + assert.equal(state.drawn, 3); + } finally { + restore(); + } + }); +} + +test('an error Scene caught in an animation frame right before the host stopped frames: Retry keeps one render loop', () => { + const { frames, state, store, timerFrame, animationFrames, restore } = + setup(); + try { + state.failure = 'caught'; + animationFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(frames.length, 1); + state.failure = null; + store.renderFailure.retry(); + timerFrame(); + assert.equal(store.renderFailure, null); + assert.equal(frames.length, 1, 'Retry starts no second loop'); + state.drawn = 0; + animationFrames(3); + assert.equal(state.drawn, 3); + } finally { + restore(); + } +}); diff --git a/src/app/scene.js b/src/app/scene.js index b9799b8ac..a1afb0b60 100644 --- a/src/app/scene.js +++ b/src/app/scene.js @@ -15,6 +15,8 @@ import { governorRequestRender, } from '../renderGovernor.js'; import { describeError } from './errors.js'; +import { isEmbeddedInline } from './embed.js'; +import { installRenderFailureNotice } from '../ui/renderFailure.js'; import { worldClock, worldTimeFor } from '../time/worldClock.js'; import { followWorldTime } from '../time/viewerClock.js'; import { onLocaleChange, t } from '../i18n/index.js'; @@ -55,11 +57,17 @@ export async function createApplicationScene({ const viewer = createApplicationViewer({ container: 'cesiumContainer', creditContainer, + // The render failure notice below replaces Cesium's dead-end panel. + showRenderLoopErrors: false, }); defer(() => { uninstallRenderGovernor(viewer); if (!viewer.isDestroyed()) viewer.destroy(); }); + // An inline panel must not reload or navigate its host's page. + defer( + installRenderFailureNotice(viewer, { allowReload: !isEmbeddedInline() }), + ); defer(installTrackpadPinchZoom(viewer)); defer( followWorldTime(viewer, worldTimeFor('sky'), { diff --git a/src/app/viewer.js b/src/app/viewer.js index e4288e8a3..adaa06406 100644 --- a/src/app/viewer.js +++ b/src/app/viewer.js @@ -375,8 +375,15 @@ export function installGlobeInViewRecovery( }; } -/** Cesium.Viewer options of the standard globe viewer. */ -export function applicationViewerOptions(creditContainer) { +/** + * Cesium.Viewer options of the standard globe viewer. `showRenderLoopErrors` + * false leaves render errors to the caller's own notice instead of Cesium's + * panel. + */ +export function applicationViewerOptions( + creditContainer, + { showRenderLoopErrors = true } = {}, +) { return { timeline: false, animation: false, @@ -396,16 +403,21 @@ export function applicationViewerOptions(creditContainer) { creditContainer, msaaSamples: 4, contextOptions: { webgl: { preserveDrawingBuffer: true } }, + showRenderLoopErrors, }; } /** Create the standard globe viewer in caller-owned, visible containers. */ -export function createApplicationViewer({ container, creditContainer }) { +export function createApplicationViewer({ + container, + creditContainer, + showRenderLoopErrors, +}) { if (!container || !creditContainer) throw new TypeError('Viewer and credit containers are required'); const viewer = new Cesium.Viewer( container, - applicationViewerOptions(creditContainer), + applicationViewerOptions(creditContainer, { showRenderLoopErrors }), ); try { viewer.targetFrameRate = 60; diff --git a/src/cockpitMath.js b/src/cockpitMath.js index 53daa63d1..c304cac75 100644 --- a/src/cockpitMath.js +++ b/src/cockpitMath.js @@ -32,8 +32,15 @@ export function slewHeading(current, target, maxStepDeg) { * then uses this amount to converge on the layer's delayed display position. * Capping correction below forward speed prevents a late feed fix from * becoming a first-person surge or reversal while still removing drift. + * @param {number} [maxDtSec] The longest step corrected for: 0.1 s, or an + * application subject's own (cockpitSubjectStepSec). */ -export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { +export function cockpitAnchorCorrectionStep( + distanceM, + speedMps, + dtSec, + maxDtSec = 0.1, +) { if ( !Number.isFinite(distanceM) || distanceM <= 0 || @@ -41,13 +48,36 @@ export function cockpitAnchorCorrectionStep(distanceM, speedMps, dtSec) { dtSec <= 0 ) return 0; - const dt = Math.min(0.1, dtSec); + const dt = Math.min( + Number.isFinite(maxDtSec) && maxDtSec > 0 ? maxDtSec : 0.1, + dtSec, + ); const speed = Number.isFinite(speedMps) ? Math.max(0, speedMps) : 0; const eased = distanceM * (1 - Math.exp(-1.25 * dt)); const correctionRateMps = Math.max(0.75, speed * 0.22); return Math.min(distanceM, eased, correctionRateMps * dt); } +/** + * The seconds an application subject's camera update advances its heading + * slew, ground lift and anchor by: the time since the last update, at most + * its update interval plus 0.1 s. A subject updated every 500 ms then turns + * and eases at its configured rates, while a long stall (a hidden tab) still + * moves it no further than one late update would. + * @param {number} elapsedMs Time since the last camera update. + * @param {number} intervalMs The subject's update interval (0: every frame). + * @returns {number} + */ +export function cockpitSubjectStepSec(elapsedMs, intervalMs) { + const elapsedSec = Number.isFinite(elapsedMs) + ? Math.max(0, elapsedMs) / 1000 + : 0; + const intervalSec = Number.isFinite(intervalMs) + ? Math.max(0, intervalMs) / 1000 + : 0; + return Math.min(intervalSec + 0.1, elapsedSec); +} + /** Return whether a throttled cockpit presentation update is due. */ export function cockpitUiUpdateDue(nowMs, lastUpdateMs, intervalMs) { if (!Number.isFinite(nowMs) || !Number.isFinite(intervalMs) || intervalMs < 0) @@ -68,6 +98,42 @@ export function cockpitSurfaceWaitExpired(nowMs, startedMs, timeoutMs = 5000) { return nowMs - startedMs >= timeoutMs; } +/** + * How fast the clearance a ground clamp adds under an application subject's + * eye comes and goes (m/s). + */ +export const COCKPIT_SUBJECT_LIFT_MPS = 1.5; + +/** + * The lift the ground clamp gives an application subject's eye this update. + * The floor is read from coarse cells that refine as tiles load, so the lift + * it asks for jumps; a ridden eye takes the clearance part of it at + * `rateMps`, while what it needs to stay out of the floor itself (`hardM`) + * applies at once. + * @param {number|null} previousM The lift given last update; null on entry. + * @param {number} targetM The lift the floor and clearance ask for (m). + * @param {number} hardM The lift that keeps the eye out of the floor (m). + * @param {number} dtSec Seconds since the last update. + * @param {number} [rateMps] + * @returns {number} At least 0, at least `hardM`. + */ +export function easeCockpitLift( + previousM, + targetM, + hardM, + dtSec, + rateMps = COCKPIT_SUBJECT_LIFT_MPS, +) { + const target = Number.isFinite(targetM) ? Math.max(0, targetM) : 0; + const hard = Number.isFinite(hardM) ? Math.max(0, hardM) : 0; + if (!Number.isFinite(previousM)) return Math.max(target, hard); + const step = + Math.max(0, Number.isFinite(rateMps) ? rateMps : 0) * + Math.max(0, Number.isFinite(dtSec) ? dtSec : 0); + const eased = previousM + Math.max(-step, Math.min(step, target - previousM)); + return Math.max(0, hard, eased); +} + /** * Keep the cockpit camera above the shared rendered-surface floor. * Unknown floors preserve the proposed camera height. diff --git a/src/cockpitMath.test.mjs b/src/cockpitMath.test.mjs index a59a4b029..104190fa8 100644 --- a/src/cockpitMath.test.mjs +++ b/src/cockpitMath.test.mjs @@ -8,8 +8,11 @@ import { cockpitAnchorCorrectionStep, cockpitAltitudeDisplayFt, cockpitGroundSafeHeight, + cockpitSubjectStepSec, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, + COCKPIT_SUBJECT_LIFT_MPS, + easeCockpitLift, compassDivisions, compassMarkerSlot, scrollingCompassDivisions, @@ -56,6 +59,28 @@ test('cockpit anchor correction bounds late-fix catch-up and render stalls', () assert.equal(cockpitAnchorCorrectionStep(Number.NaN, 250, 0.1), 0); }); +test('an application subject on a slow cadence is corrected for its whole step', () => { + // The built-in views' step stays capped at 0.1 s... + const capped = cockpitAnchorCorrectionStep(100, 0, 0.5); + assert.equal(capped, cockpitAnchorCorrectionStep(100, 0, 0.1)); + // ...while a subject updated every 500 ms is corrected for all of it. + const whole = cockpitAnchorCorrectionStep(100, 0, 0.5, 0.5); + assert.ok(Math.abs(whole / capped - 5) < 1e-9); + assert.equal(cockpitAnchorCorrectionStep(100, 0, 0.5, Number.NaN), capped); +}); + +test('an application subject steps by its interval, never by a long stall', () => { + assert.equal(cockpitSubjectStepSec(16, 0), 0.016); + assert.equal(cockpitSubjectStepSec(500, 500), 0.5); + assert.equal(cockpitSubjectStepSec(520, 500), 0.52); + // A hidden tab's minutes count as one late update. + assert.equal(cockpitSubjectStepSec(60_000, 500), 0.6); + assert.equal(cockpitSubjectStepSec(60_000, 0), 0.1); + assert.equal(cockpitSubjectStepSec(-5, 500), 0); + assert.equal(cockpitSubjectStepSec(Number.NaN, 500), 0); + assert.equal(cockpitSubjectStepSec(1000, Number.NaN), 0.1); +}); + test('cockpit camera clearance never falls below the rendered ground floor', () => { assert.equal(cockpitGroundSafeHeight(47, 57, 12), 69); assert.equal(cockpitGroundSafeHeight(1200, 57, 12), 1200); @@ -435,3 +460,30 @@ test('cockpit emergency wording states the broadcast code, not a conclusion', () assert.equal(formatCockpitEmergency(null), null); assert.equal(formatCockpitEmergency({ label: '' }), null); }); + +test('an application subject’s ground lift eases its clearance, never the floor', () => { + // On entry the lift asked for applies at once. + assert.equal(easeCockpitLift(null, 1.5, 0, 0.016), 1.5); + // A floor cell that rises by a metre is met at the lift rate... + let lift = 0; + const frames = []; + for (let i = 0; i < 60; i += 1) { + lift = easeCockpitLift(lift, 1, 0, 0.016); + frames.push(lift); + } + assert.ok(frames[0] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9); + for (let i = 1; i < frames.length; i += 1) + assert.ok( + frames[i] - frames[i - 1] <= COCKPIT_SUBJECT_LIFT_MPS * 0.016 + 1e-9, + ); + assert.ok(Math.abs(frames.at(-1) - 1) < 1e-9, 'reached in under a second'); + // ...and given back the same way. + assert.ok(Math.abs(easeCockpitLift(1, 0, 0, 0.1) - 0.85) < 1e-9); + // What keeps the eye out of the floor itself is never eased. + assert.equal(easeCockpitLift(0, 201.5, 200, 0.016), 200); + assert.equal( + easeCockpitLift(0.2, -3, -3, 0.016), + 0.2 - COCKPIT_SUBJECT_LIFT_MPS * 0.016, + ); + assert.equal(easeCockpitLift(0, Number.NaN, Number.NaN, 0.016), 0); +}); diff --git a/src/cockpitProgress.js b/src/cockpitProgress.js new file mode 100644 index 000000000..67a3be951 --- /dev/null +++ b/src/cockpitProgress.js @@ -0,0 +1,182 @@ +/** + * @module cockpitProgress + * @description Lay out a subject's progress line (cockpitSubjectProviders.js) + * at a width in pixels: where each tick, label and the marker go. + * + * Ticks sit at fractions of the line, 0 at the left. Ticks closer than + * `minTickPx` merge, so a long run still reads as a line: a labelled tick + * keeps its place and takes in the bare ticks beside it, and bare ticks merge + * with each other at their mean place. A merged tick takes the strongest tone + * of those it holds. Labels never overlap: where two would, the one of lower + * priority is left out (on a tie, the later one). + * + * Pure: no DOM. + */ + +/** Bare ticks closer than this merge (px). */ +export const COCKPIT_PROGRESS_MIN_TICK_PX = 6; +/** Room kept between two labels (px). */ +export const COCKPIT_PROGRESS_LABEL_GAP_PX = 8; + +/** Tones from weakest to strongest, for a merged tick. */ +const TONE_RANK = Object.freeze([ + 'muted', + 'normal', + 'live', + 'estimate', + 'warning', + 'alert', +]); +const stronger = (a, b) => + TONE_RANK.indexOf(b) > TONE_RANK.indexOf(a) ? b : a; + +/** A label's width estimate (px): its longer line in small capitals. */ +export function estimateProgressLabelPx(label) { + const longest = Math.max( + String(label?.text ?? '').length, + String(label?.detail ?? '').length, + ); + return longest * 6.4 + 8; +} + +/** + * The progress line at `widthPx`, or null when there is nothing to draw. + * @param {{ticks: Array<{at: number, tone: string, label: object|null}>, + * marker: {at: number, dwell: boolean}|null, tag: string}|null} progress + * As cockpitSubjectHudView gives it: ticks sorted by `at`. + * @param {number} widthPx + * @param {{minTickPx?: number, gapPx?: number, + * measure?: (label: object) => number}} [options] + * @returns {{ticks: Array<{x: number, tone: string, count: number, + * labelled: boolean}>, labels: Array<{x: number, left: number, + * width: number, align: string, text: string, detail: string, + * tone: string}>, marker: {x: number, dwell: boolean}|null, + * travelledX: number, tag: string}|null} + */ +export function cockpitProgressLayout( + progress, + widthPx, + { + minTickPx = COCKPIT_PROGRESS_MIN_TICK_PX, + gapPx = COCKPIT_PROGRESS_LABEL_GAP_PX, + measure = estimateProgressLabelPx, + } = {}, +) { + const ticks = progress?.ticks; + if (!Array.isArray(ticks) || ticks.length < 2) return null; + if (!Number.isFinite(widthPx) || widthPx <= 0) return null; + const placed = ticks.map((tick, order) => ({ + x: tick.at * widthPx, + tone: tick.tone, + label: tick.label, + order, + })); + const labelled = placed.filter((tick) => tick.label); + // Bare ticks beside a labelled one go into it. + const kept = labelled.map((tick) => ({ + x: tick.x, + tone: tick.tone, + count: 1, + labelled: true, + label: tick.label, + order: tick.order, + })); + const bare = []; + for (const tick of placed) { + if (tick.label) continue; + const host = kept.find((entry) => Math.abs(entry.x - tick.x) < minTickPx); + if (host) { + host.tone = stronger(host.tone, tick.tone); + host.count += 1; + } else bare.push(tick); + } + // Bare ticks in a chain closer than minTickPx merge at their mean place. + let cluster = null; + const flush = () => { + if (!cluster) return; + kept.push({ + x: cluster.sum / cluster.count, + tone: cluster.tone, + count: cluster.count, + labelled: false, + label: null, + order: cluster.order, + }); + cluster = null; + }; + for (const tick of bare) { + if (cluster && tick.x - cluster.last < minTickPx) { + cluster.sum += tick.x; + cluster.count += 1; + cluster.last = tick.x; + cluster.tone = stronger(cluster.tone, tick.tone); + } else { + flush(); + cluster = { + sum: tick.x, + count: 1, + last: tick.x, + tone: tick.tone, + order: tick.order, + }; + } + } + flush(); + kept.sort((a, b) => a.x - b.x || a.order - b.order); + + // Labels by priority; one that would overlap a placed label is left out. + const boxes = []; + const candidates = kept + .filter((tick) => tick.labelled) + .sort((a, b) => b.label.priority - a.label.priority || a.order - b.order); + for (const tick of candidates) { + const width = Math.min(widthPx, Math.max(0, measure(tick.label))); + let left = + tick.label.align === 'start' + ? tick.x + : tick.label.align === 'end' + ? tick.x - width + : tick.x - width / 2; + left = Math.min(Math.max(0, left), widthPx - width); + const right = left + width; + if ( + boxes.some((box) => left < box.right + gapPx && right > box.left - gapPx) + ) + continue; + boxes.push({ + x: tick.x, + left, + right, + width, + align: tick.label.align, + text: tick.label.text, + detail: tick.label.detail, + tone: tick.tone, + order: tick.order, + }); + } + boxes.sort((a, b) => a.left - b.left); + const marker = progress.marker + ? { x: progress.marker.at * widthPx, dwell: progress.marker.dwell } + : null; + return { + ticks: kept.map(({ x, tone, count, labelled: isLabelled }) => ({ + x, + tone, + count, + labelled: isLabelled, + })), + labels: boxes.map(({ x, left, width, align, text, detail, tone }) => ({ + x, + left, + width, + align, + text, + detail, + tone, + })), + marker, + travelledX: marker ? marker.x : 0, + tag: progress.tag || '', + }; +} diff --git a/src/cockpitProgress.test.mjs b/src/cockpitProgress.test.mjs new file mode 100644 index 000000000..f1bd9d819 --- /dev/null +++ b/src/cockpitProgress.test.mjs @@ -0,0 +1,144 @@ +// src/cockpitProgress.test.mjs — a subject's progress line laid out at a +// width: close bare ticks merge, labelled ticks keep their place, labels never +// overlap (the lower priority gives way) and the part before the marker is +// the travelled part. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { cockpitProgressLayout } from './cockpitProgress.js'; + +const tick = (at, label = null, tone = 'normal') => ({ at, tone, label }); +const label = (text, priority, align = 'center', detail = '') => ({ + text, + detail, + priority, + align, +}); +/** Every label is 50 px wide. */ +const measure = () => 50; + +test('nothing is drawn without two ticks or a width', () => { + assert.equal(cockpitProgressLayout(null, 500), null); + assert.equal(cockpitProgressLayout({ ticks: [tick(0)] }, 500), null); + assert.equal( + cockpitProgressLayout({ ticks: [tick(0), tick(1)], marker: null }, 0), + null, + ); +}); + +test('ticks closer than the minimum spacing merge, keeping the strongest tone', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('A', 1, 'start')), + tick(0.1), + tick(0.101, null, 'warning'), + tick(0.102), + tick(0.5), + tick(1, label('Z', 1, 'end')), + ], + marker: { at: 0.3, dwell: false }, + tag: 'TIMETABLE', + }, + 1000, + { measure }, + ); + assert.deepEqual( + layout.ticks.map(({ x, count, tone, labelled }) => [ + Math.round(x), + count, + tone, + labelled, + ]), + [ + [0, 1, 'normal', true], + [101, 3, 'warning', false], + [500, 1, 'normal', false], + [1000, 1, 'normal', true], + ], + ); + assert.deepEqual(layout.marker, { x: 300, dwell: false }); + assert.equal(layout.travelledX, 300); + assert.equal(layout.tag, 'TIMETABLE'); +}); + +test('a labelled tick keeps its place and takes in the bare ticks beside it', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('A', 1, 'start')), + tick(0.498), + tick(0.5, label('Next', 3), 'alert'), + tick(0.503, null, 'warning'), + tick(1, label('Z', 1, 'end')), + ], + marker: null, + }, + 1000, + { measure }, + ); + const next = layout.ticks.find(({ x }) => Math.round(x) === 500); + assert.equal(next.count, 3); + assert.equal(next.labelled, true); + assert.equal(next.tone, 'alert'); + assert.equal(layout.ticks.length, 3); + assert.equal(layout.marker, null); + assert.equal(layout.travelledX, 0); +}); + +test('labels hang from their ticks inside the line, and overlaps give way by priority', () => { + const layout = cockpitProgressLayout( + { + ticks: [ + tick(0, label('First', 1, 'start')), + tick(0.04, label('Previous', 2)), + tick(0.5, label('Next', 3)), + tick(0.52, label('Close', 0)), + tick(1, label('Last', 1, 'end')), + ], + marker: { at: 0.2, dwell: true }, + }, + 1000, + { measure }, + ); + assert.deepEqual( + layout.labels.map(({ text, left }) => [text, Math.round(left)]), + [ + ['Previous', 15], + ['Next', 475], + ['Last', 950], + ], + ); + // The first stop's label would cover the previous stop's: it gives way. + assert.ok(!layout.labels.some(({ text }) => text === 'First')); + assert.ok(!layout.labels.some(({ text }) => text === 'Close')); + assert.deepEqual(layout.marker, { x: 200, dwell: true }); +}); + +test('a centred label at an edge is clamped inside the line', () => { + const layout = cockpitProgressLayout( + { ticks: [tick(0, label('Edge', 1)), tick(1)], marker: null }, + 300, + { measure }, + ); + assert.equal(layout.labels[0].left, 0); + const right = cockpitProgressLayout( + { ticks: [tick(0), tick(1, label('Edge', 1))], marker: null }, + 300, + { measure }, + ); + assert.equal(right.labels[0].left, 250); +}); + +test('a long run of calls still lays out, every one a tick or part of one', () => { + const ticks = Array.from({ length: 60 }, (_, i) => tick(i / 59)); + ticks[0].label = label('First', 1, 'start'); + ticks[59].label = label('Last', 1, 'end'); + const narrow = cockpitProgressLayout({ ticks, marker: null }, 200); + const wide = cockpitProgressLayout({ ticks, marker: null }, 2000); + const count = (layout) => + layout.ticks.reduce((sum, entry) => sum + entry.count, 0); + assert.equal(count(narrow), 60); + assert.equal(count(wide), 60); + assert.ok(narrow.ticks.length < wide.ticks.length); + assert.equal(wide.ticks.length, 60); +}); diff --git a/src/cockpitSubjectProviders.js b/src/cockpitSubjectProviders.js new file mode 100644 index 000000000..88b91e62c --- /dev/null +++ b/src/cockpitSubjectProviders.js @@ -0,0 +1,644 @@ +/** + * @module cockpitSubjectProviders + * @description Subject providers: kinds of subject an application adds to the + * Cockpit beside its own aircraft, satellite and vessel. A provider names the + * subject entry would take, holds it while the view rides it, hands the camera + * a frame each update and the HUD a view model, and releases it on exit. Core + * knows nothing else about it. + * + * Everything a provider returns is normalized here before the Cockpit uses + * it: a malformed provider is left out, a malformed frame ends the view, and a + * malformed HUD view is read field by field, never thrown on. + * + * Pure: no Cesium, no DOM. + */ + +/** The kinds the Cockpit rides itself; no provider may take one. */ +export const COCKPIT_BUILT_IN_KINDS = Object.freeze([ + 'aircraft', + 'satellite', + 'vessel', +]); + +/** The tones a provider's HUD texts may take. */ +export const COCKPIT_SUBJECT_TONES = Object.freeze([ + 'normal', + 'muted', + 'live', + 'estimate', + 'warning', + 'alert', +]); + +/** + * The heights a frame may give (m, ellipsoidal): below the deepest dry + * ground, above anything ridden near it; outside, the frame stands on the + * floor. + */ +export const COCKPIT_SUBJECT_MIN_HEIGHT_M = -1_000; +export const COCKPIT_SUBJECT_MAX_HEIGHT_M = 50_000; + +/** The most readouts, sources and signals a HUD view shows. */ +export const COCKPIT_SUBJECT_MAX_READOUTS = 4; +export const COCKPIT_SUBJECT_MAX_SOURCES = 4; +export const COCKPIT_SUBJECT_MAX_SIGNALS = 4; +/** The most ticks a progress line takes. */ +export const COCKPIT_SUBJECT_MAX_TICKS = 512; + +const KIND_PATTERN = /^[a-z][a-z0-9-]{1,31}$/; +const LAYER_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/; +const ICON_PATTERN = /^[a-z0-9_]{1,40}$/; +const ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,39}$/; +const REQUIRED_METHODS = Object.freeze([ + 'candidate', + 'hold', + 'read', + 'hud', + 'release', +]); +const RESERVED = new Set(COCKPIT_BUILT_IN_KINDS); +const TONES = new Set(COCKPIT_SUBJECT_TONES); + +const isObject = (value) => + value !== null && typeof value === 'object' && !Array.isArray(value); +const finite = (value) => typeof value === 'number' && Number.isFinite(value); +const clamp = (value, low, high) => Math.min(high, Math.max(low, value)); + +/** + * `value` as display text: trimmed, at most `max` characters (the last one + * an ellipsis when cut, the cut never inside a surrogate pair, so an emoji is + * kept whole or left out); '' for anything that is not a string or a number. + */ +export function cockpitSubjectText(value, max) { + if (typeof value !== 'string' && !finite(value)) return ''; + const text = String(value).replace(/\s+/g, ' ').trim(); + if (text.length <= max) return text; + let end = max - 1; + const last = text.charCodeAt(end - 1); + if (last >= 0xd800 && last <= 0xdbff) end -= 1; + return `${text.slice(0, end)}…`; +} + +const tone = (value) => (TONES.has(value) ? value : 'normal'); + +/** A Material Symbols entry face, or null. */ +function faceOf(raw) { + if (!isObject(raw)) return null; + const label = cockpitSubjectText(raw.label, 16); + if (typeof raw.icon !== 'string' || !ICON_PATTERN.test(raw.icon) || !label) + return null; + return Object.freeze({ icon: raw.icon, label }); +} + +/** The labels a provider gives, normalized, or null without the required. */ +function labelsOf(raw) { + if (!isObject(raw)) return null; + const labels = { + view: cockpitSubjectText(raw.view, 48), + exit: cockpitSubjectText(raw.exit, 48), + exitText: cockpitSubjectText(raw.exitText, 24), + kicker: cockpitSubjectText(raw.kicker, 48), + }; + return labels.view && labels.exit && labels.exitText + ? Object.freeze(labels) + : null; +} + +/** A provider's look, normalized, or null without an aheadLabel. */ +function lookOf(raw) { + if (!isObject(raw)) return null; + const aheadLabel = cockpitSubjectText(raw.aheadLabel, 64); + if (!aheadLabel) return null; + return Object.freeze({ + restPitchDeg: finite(raw.restPitchDeg) + ? clamp(raw.restPitchDeg, -90, 90) + : 0, + aheadLabel, + }); +} + +/** + * A provider's worded member `key` as it reads now, normalized: the value, + * or what the function it is returns when called on `provider` (so it can + * follow the page's language); null when it is malformed or a read throws. + * Reading the member, and reading what it gives, runs provider code too (a + * getter), so both are guarded as the call is. + */ +function wordedNow(provider, key, normalize) { + try { + const value = provider[key]; + return ( + normalize(typeof value === 'function' ? value.call(provider) : value) ?? + null + ); + } catch { + return null; + } +} + +/** A provider's name in a warning: its kind, cut, or its place in the list. */ +function providerName(provider, order) { + try { + if (typeof provider?.kind === 'string') return provider.kind.slice(0, 32); + } catch { + // A kind getter that throws gives no name. + } + return `#${order}`; +} + +/** Why `provider` cannot be kept, or null. */ +function refusal(provider, kinds) { + if (!isObject(provider)) return 'not an object'; + if (typeof provider.kind !== 'string' || !KIND_PATTERN.test(provider.kind)) + return 'malformed kind'; + if (RESERVED.has(provider.kind)) return 'reserved kind'; + if (kinds.has(provider.kind)) return 'repeated kind'; + if ( + typeof provider.layerId !== 'string' || + !LAYER_ID_PATTERN.test(provider.layerId) + ) + return 'malformed layer id'; + for (const method of REQUIRED_METHODS) + if (typeof provider[method] !== 'function') return `no ${method}()`; + if ( + provider.isEntryAllowed !== undefined && + typeof provider.isEntryAllowed !== 'function' + ) + return 'isEntryAllowed is not a function'; + if (!wordedNow(provider, 'entryFace', faceOf)) return 'malformed entry face'; + if (!wordedNow(provider, 'labels', labelsOf)) + return 'labels need view, exit and exitText'; + if (provider.look != null && !wordedNow(provider, 'look', lookOf)) + return 'look needs an aheadLabel'; + return null; +} + +/** + * The application's subject providers, checked once and frozen, in the order + * entry asks them: ascending `priority`, ties in list order. Each has + * - `kind`: /^[a-z][a-z0-9-]{1,31}$/, not a built-in kind, unique; + * - `layerId`: the catalog layer the subject belongs to (entry needs it on); + * - `priority`: a finite number (default 0); + * - `entryFace`: `{icon, label}`, a Material Symbols name and a label of at + * most 16 characters, in the page's language; + * - `labels`: `{view, exit, exitText, kicker?}`, the HUD's and the exit + * button's accessible names, the exit button's text and the default kicker, + * in the page's language; + * - `look` (optional): `{restPitchDeg?, aheadLabel}`, arrow-key look-around as + * on a bridge, resting at `restPitchDeg` (default 0, -90..90), its Look + * ahead button named `aheadLabel`; + * `entryFace`, `labels` and `look` may each be a function returning the + * object: it is called on the provider again whenever they are shown and + * when the page's language changes, so its words can follow it + * (`./i18n`). A read that throws or is malformed keeps the last + * good one; `look` is on or off and `restPitchDeg` fixed as first read; + * - `cameraIntervalMs` (optional): 0..1000, null for the Cockpit's default; 0 + * updates the camera every rendered frame; + * - `headingSlewDps` (optional): how fast the view turns to a new heading, + * positive or Infinity, null for the Cockpit's default; + * - `candidate()`, `isEntryAllowed(candidate)` (optional), `hold(id)`, + * `read(nowMs, ctx)`, `hud(frame, ctx)` and `release(id, {restore})`, all + * synchronous (createCockpitSubjectCaller) and called on the provider, so + * a class instance's methods keep their `this`. `isEntryAllowed` allows + * entry only by returning `true`. `hold` returns `true` once it holds the + * subject, or `false` to refuse entry; any other answer refuses it too, + * with one warning. A refused hold, and one that throws or returns a + * promise, is followed by `release(id, {restore: false})`, and by another + * once that promise settles; `release` must therefore be harmless for a + * subject it does not hold or already gave back. + * + * A provider that is not so, or whose members throw when read (a getter), is + * left out with one warning naming it; the providers after it are kept. + * @param {unknown} list + * @param {{warn?: (message: string) => void}} [options] + * @returns {ReadonlyArray} + */ +export function cockpitSubjectProviders( + list = [], + { warn = (message) => console.warn(message) } = {}, +) { + const kept = []; + const kinds = new Set(); + (Array.isArray(list) ? list : []).forEach((provider, order) => { + let reason; + let copy = null; + try { + reason = refusal(provider, kinds); + if (!reason) copy = keptProvider(provider, order); + } catch { + reason = 'a member threw when read'; + } + if (reason) { + warn( + `[Cockpit] subject provider ${providerName(provider, order)} left out: ${reason}`, + ); + return; + } + kinds.add(copy.kind); + kept.push(copy); + }); + kept.sort((a, b) => a.priority - b.priority || a.order - b.order); + return Object.freeze(kept); +} + +/** The frozen copy the Cockpit keeps of a provider `refusal` passed. */ +function keptProvider(provider, order) { + // Worded members are read again on each use (see above); the last good + // read stands in for one that is malformed or throws. + let face = wordedNow(provider, 'entryFace', faceOf); + let labels = wordedNow(provider, 'labels', labelsOf); + // On or off, and its rest pitch, as first read. + let look = wordedNow(provider, 'look', lookOf); + const restPitchDeg = look?.restPitchDeg ?? 0; + return Object.freeze({ + kind: provider.kind, + layerId: provider.layerId, + priority: finite(provider.priority) ? provider.priority : 0, + order, + get entryFace() { + face = wordedNow(provider, 'entryFace', faceOf) ?? face; + return face; + }, + get labels() { + labels = wordedNow(provider, 'labels', labelsOf) ?? labels; + return labels; + }, + get look() { + if (!look) return null; + const now = wordedNow(provider, 'look', lookOf); + if (now) + look = Object.freeze({ restPitchDeg, aheadLabel: now.aheadLabel }); + return look; + }, + cameraIntervalMs: + finite(provider.cameraIntervalMs) && + provider.cameraIntervalMs >= 0 && + provider.cameraIntervalMs <= 1000 + ? provider.cameraIntervalMs + : null, + headingSlewDps: + provider.headingSlewDps === Infinity || + (finite(provider.headingSlewDps) && provider.headingSlewDps > 0) + ? provider.headingSlewDps + : null, + // Bound, so a method that uses `this` (a class instance's, say) still + // reads its own provider when called on this copy. + candidate: provider.candidate.bind(provider), + isEntryAllowed: provider.isEntryAllowed + ? provider.isEntryAllowed.bind(provider) + : () => true, + hold: provider.hold.bind(provider), + read: provider.read.bind(provider), + hud: provider.hud.bind(provider), + release: provider.release.bind(provider), + }); +} + +/** + * A provider's candidate: `{id, name, face, selectedAt}`, or null without a + * usable id. `name` falls back to the id; `face` is null unless well formed; + * `selectedAt` (a number or a short string, else null) is a token the + * provider changes each time its subject is selected, even again: a + * subject selected after the built-in ones takes entry (entryCandidates). + * Null too when reading `raw` throws (a getter runs provider code). + * @param {unknown} raw What `candidate()` returned. + */ +export function cockpitSubjectCandidate(raw) { + try { + return candidateOf(raw); + } catch { + // A getter on what candidate() returned threw: no candidate. + return null; + } +} + +function candidateOf(raw) { + if (!isObject(raw) || typeof raw.id !== 'string') return null; + const id = raw.id.trim(); + if (!id || id.length > 128) return null; + const selectedAt = + finite(raw.selectedAt) || + (typeof raw.selectedAt === 'string' && raw.selectedAt.length <= 64) + ? raw.selectedAt + : null; + return Object.freeze({ + id, + name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(id, 64), + face: faceOf(raw.face), + selectedAt, + }); +} + +/** + * One camera frame from a provider, normalized, or null when it cannot place + * the camera (the view then ends): not an object, an id other than `heldId`, + * a position that is not finite, an eye with no valid `upM` (0..1000), or a + * read of it that throws (a getter runs provider code). + * A `heightM` that is not finite or lies outside + * COCKPIT_SUBJECT_MIN_HEIGHT_M..COCKPIT_SUBJECT_MAX_HEIGHT_M reads as null: + * the eye then stands on the shared floor, or where it last stood while that + * is cold. The anchor is `'inertial'` only when asked for and the frame + * gives a speed; otherwise it is `'exact'`. An id is compared with `heldId` + * trimmed, as `cockpitSubjectCandidate` trims it. + * @param {unknown} raw What `read()` returned. + * @param {string} heldId The subject the view holds. + * @returns {object|null} `{id, name, latitude, longitude, heightM, headingDeg, + * courseDeg, speedMps, stale, groundClamp, anchor, eye: {upM, forwardM, + * pitchDeg, minClearanceM}}`. + */ +export function cockpitSubjectFrame(raw, heldId) { + try { + return frameOf(raw, heldId); + } catch { + // A getter on what read() returned threw: as a malformed frame. + return null; + } +} + +function frameOf(raw, heldId) { + if (!isObject(raw) || typeof raw.id !== 'string' || raw.id.trim() !== heldId) + return null; + const latitude = raw.latitude; + const longitude = raw.longitude; + if (!finite(latitude) || !finite(longitude) || Math.abs(latitude) > 90) + return null; + const eye = isObject(raw.eye) ? raw.eye : null; + if (!eye || !finite(eye.upM) || eye.upM < 0 || eye.upM > 1000) return null; + const heading = (value) => + finite(value) ? ((value % 360) + 360) % 360 : null; + const headingDeg = heading(raw.headingDeg); + const speedMps = + finite(raw.speedMps) && raw.speedMps >= 0 ? raw.speedMps : null; + return { + id: heldId, + name: cockpitSubjectText(raw.name, 64) || cockpitSubjectText(heldId, 64), + latitude, + longitude: ((((longitude + 180) % 360) + 360) % 360) - 180, + heightM: + finite(raw.heightM) && + raw.heightM >= COCKPIT_SUBJECT_MIN_HEIGHT_M && + raw.heightM <= COCKPIT_SUBJECT_MAX_HEIGHT_M + ? raw.heightM + : null, + headingDeg, + courseDeg: heading(raw.courseDeg) ?? headingDeg, + speedMps, + stale: raw.stale === true, + groundClamp: raw.groundClamp !== false, + // An inertial anchor runs on at the frame's speed; with none it would + // close on a moving subject at the correction's floor and fall behind. + anchor: raw.anchor === 'exact' || speedMps === null ? 'exact' : 'inertial', + eye: { + upM: eye.upM, + forwardM: + finite(eye.forwardM) && Math.abs(eye.forwardM) <= 500 + ? eye.forwardM + : 0, + pitchDeg: finite(eye.pitchDeg) ? clamp(eye.pitchDeg, -90, 90) : 0, + minClearanceM: + finite(eye.minClearanceM) && eye.minClearanceM >= 0 + ? eye.minClearanceM + : 0, + }, + }; +} + +/** A list of `{id, ...}` rows, each read by `row`, at most `max` kept. */ +function rowsOf(raw, max, row) { + if (!Array.isArray(raw)) return []; + const rows = []; + const ids = new Set(); + for (const entry of raw) { + if (rows.length >= max) break; + if (!isObject(entry)) continue; + const id = typeof entry.id === 'string' ? entry.id : ''; + if (!ID_PATTERN.test(id) || ids.has(id)) continue; + const read = row(entry); + if (!read) continue; + ids.add(id); + rows.push({ id, ...read }); + } + return rows; +} + +/** A progress line, or null without at least two ticks. */ +function progressOf(raw) { + if (!isObject(raw) || !Array.isArray(raw.ticks)) return null; + const ticks = []; + for (const tick of raw.ticks) { + if (ticks.length >= COCKPIT_SUBJECT_MAX_TICKS) break; + if (!isObject(tick) || !finite(tick.at) || tick.at < 0 || tick.at > 1) + continue; + let label = null; + if (isObject(tick.label)) { + const text = cockpitSubjectText(tick.label.text, 40); + if (text) + label = { + text, + detail: cockpitSubjectText(tick.label.detail, 64), + priority: finite(tick.label.priority) + ? clamp(Math.round(tick.label.priority), 0, 9) + : 0, + align: ['start', 'center', 'end'].includes(tick.label.align) + ? tick.label.align + : 'center', + }; + } + ticks.push({ at: tick.at, tone: tone(tick.tone), label }); + } + if (ticks.length < 2) return null; + ticks.sort((a, b) => a.at - b.at); + const marker = + isObject(raw.marker) && finite(raw.marker.at) + ? { at: clamp(raw.marker.at, 0, 1), dwell: raw.marker.dwell === true } + : null; + return { + ticks, + marker, + tag: cockpitSubjectText(raw.tag, 16), + summary: cockpitSubjectText(raw.summary, 160), + }; +} + +/** + * A provider's HUD view model, normalized; never throws. Texts are coerced + * and cut to their caps, lists to their lengths, tones to + * COCKPIT_SUBJECT_TONES, numbers clamped; a malformed member reads as absent. + * Null when reading `raw` throws (a getter runs provider code), so the caller + * keeps the view it has, as when `hud()` itself throws. + * All text is shown as given: the provider words it in the page's language. + * @param {unknown} raw What `hud()` returned. + * @returns {object|null} `{kicker, title, meta: {text, tone}, speed: {value, + * unit, tag, tone}|null, readouts: [{id, label, value, detail, tone}], + * sources: [{id, label, text, tone}], + * route: {title, from, to, status}|null, + * progress: {ticks: [{at, tone, label: {text, detail, priority, + * align}|null}], marker: {at, dwell}|null, tag, summary}|null, + * banner: {title, detail, tone}|null, veil: {level, streaks}|null, + * lookReadout, signals: [{id, tone, title, detail}]}`. + */ +export function cockpitSubjectHudView(raw) { + try { + return hudViewOf(raw); + } catch { + // A getter on what hud() returned threw: the caller keeps its last view. + return null; + } +} + +function hudViewOf(raw) { + const view = isObject(raw) ? raw : {}; + const meta = isObject(view.meta) ? view.meta : {}; + const speed = + isObject(view.speed) && + (view.speed.value === null || + (finite(view.speed.value) && view.speed.value >= 0)) + ? { + value: + view.speed.value === null + ? null + : Math.min(view.speed.value, 99_999), + unit: cockpitSubjectText(view.speed.unit, 12), + tag: cockpitSubjectText(view.speed.tag, 8), + tone: tone(view.speed.tone), + } + : null; + const route = isObject(view.route) + ? { + title: cockpitSubjectText(view.route.title, 32), + from: cockpitSubjectText(view.route.from, 48), + to: cockpitSubjectText(view.route.to, 48), + status: cockpitSubjectText(view.route.status, 64), + } + : null; + const bannerTitle = isObject(view.banner) + ? cockpitSubjectText(view.banner.title, 48) + : ''; + const veil = + isObject(view.veil) && finite(view.veil.level) + ? { + level: clamp(view.veil.level, 0, 1), + streaks: view.veil.streaks === true, + } + : null; + return { + kicker: cockpitSubjectText(view.kicker, 48), + title: cockpitSubjectText(view.title, 64), + meta: { text: cockpitSubjectText(meta.text, 96), tone: tone(meta.tone) }, + speed, + readouts: rowsOf(view.readouts, COCKPIT_SUBJECT_MAX_READOUTS, (row) => { + const label = cockpitSubjectText(row.label, 24); + const value = cockpitSubjectText(row.value, 48); + return label || value + ? { + label, + value, + detail: cockpitSubjectText(row.detail, 80), + tone: tone(row.tone), + } + : null; + }), + sources: rowsOf(view.sources, COCKPIT_SUBJECT_MAX_SOURCES, (row) => { + const text = cockpitSubjectText(row.text, 120); + return text + ? { + label: cockpitSubjectText(row.label, 16), + text, + tone: tone(row.tone), + } + : null; + }), + route: route && (route.from || route.to) ? route : null, + progress: progressOf(view.progress), + banner: bannerTitle + ? { + title: bannerTitle, + detail: cockpitSubjectText(view.banner.detail, 96), + tone: tone(view.banner.tone), + } + : null, + veil: veil && veil.level > 0 ? veil : null, + lookReadout: cockpitSubjectText(view.lookReadout, 48), + signals: rowsOf(view.signals, COCKPIT_SUBJECT_MAX_SIGNALS, (row) => { + const title = cockpitSubjectText(row.title, 48); + return title + ? { + title, + detail: cockpitSubjectText(row.detail, 96), + tone: tone(row.tone), + } + : null; + }), + }; +} + +/** + * A guarded caller for provider methods: `call(provider, method, args, + * fallback)` returns what the method returns, or `fallback` when it throws, + * warning once per provider and method. + * + * Provider methods are synchronous: the Cockpit calls them on its render + * cadence and uses the answer at once. One that returns a promise (an async + * function, say) breaks that contract: the promise is refused as a throw + * would be (its rejection, if any, is swallowed), with one warning, and + * `onSettled`, when given, runs once the promise has settled, so a caller + * can undo work that lands late. + * + * `call.warnOnce(provider, method, message, error)` warns on the same + * once-per-provider-and-method footing, for an answer the caller finds + * malformed. + * @param {{warn?: (message: string, error: unknown) => void}} [options] + */ +export function createCockpitSubjectCaller({ + warn = (message, error) => console.warn(message, error), +} = {}) { + const warned = new Set(); + const warnOnce = (provider, method, message, error) => { + const key = `${provider.kind}:${method}`; + if (warned.has(key)) return; + warned.add(key); + warn( + `[Cockpit] subject provider ${provider.kind} ${method}() ${message}`, + error, + ); + }; + function call( + provider, + method, + args = [], + fallback = null, + onSettled = null, + ) { + let result; + let thenable; + try { + result = provider[method](...args); + // Reading `then` runs provider code too (a getter may throw), so it is + // guarded as the call is. + thenable = + result !== null && + (typeof result === 'object' || typeof result === 'function') && + typeof result.then === 'function'; + } catch (error) { + warnOnce(provider, method, 'failed', error); + return fallback; + } + if (thenable) { + // Its work may still land: `onSettled` runs once it has, either way. + const settled = () => { + if (typeof onSettled === 'function') onSettled(); + }; + Promise.resolve(result).then(settled, settled); + warnOnce( + provider, + method, + 'returned a promise; provider methods are synchronous', + null, + ); + return fallback; + } + return result; + } + call.warnOnce = warnOnce; + return call; +} diff --git a/src/cockpitSubjectProviders.test.mjs b/src/cockpitSubjectProviders.test.mjs new file mode 100644 index 000000000..2063cca41 --- /dev/null +++ b/src/cockpitSubjectProviders.test.mjs @@ -0,0 +1,698 @@ +// src/cockpitSubjectProviders.test.mjs — an application's subject providers +// are checked once and what they return is normalized before the Cockpit +// uses it: a malformed provider is left out, a malformed frame ends the view, +// and a malformed HUD view is read field by field and never thrown on. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + COCKPIT_BUILT_IN_KINDS, + COCKPIT_SUBJECT_MAX_READOUTS, + COCKPIT_SUBJECT_MAX_TICKS, + cockpitSubjectCandidate, + cockpitSubjectFrame, + cockpitSubjectHudView, + cockpitSubjectProviders, + cockpitSubjectText, + createCockpitSubjectCaller, +} from './cockpitSubjectProviders.js'; + +const noop = () => null; +/** A well-formed provider, with `overrides`. */ +const provider = (overrides = {}) => ({ + kind: 'example-kind', + layerId: 'example-layer', + entryFace: { icon: 'commute', label: 'RIDE' }, + labels: { + view: 'First-person view', + exit: 'Exit first-person view', + exitText: 'EXIT VIEW', + }, + candidate: noop, + hold: () => true, + read: noop, + hud: noop, + release: noop, + ...overrides, +}); + +const keep = (list) => { + const warnings = []; + const kept = cockpitSubjectProviders(list, { + warn: (message) => warnings.push(message), + }); + return { kept, warnings }; +}; + +test('well-formed providers are kept, frozen, in priority then list order', () => { + const { kept, warnings } = keep([ + provider({ kind: 'late', priority: 5 }), + provider({ kind: 'first-of-two', priority: 1 }), + provider({ kind: 'second-of-two', priority: 1 }), + provider({ kind: 'default-priority' }), + ]); + assert.deepEqual(warnings, []); + assert.deepEqual( + kept.map(({ kind }) => kind), + ['default-priority', 'first-of-two', 'second-of-two', 'late'], + ); + assert.ok(Object.isFrozen(kept)); + assert.ok(Object.isFrozen(kept[0])); + const [first] = kept; + assert.equal(first.priority, 0); + assert.equal(first.look, null); + assert.equal(first.cameraIntervalMs, null); + assert.equal(first.headingSlewDps, null); + assert.equal(first.isEntryAllowed(), true, 'no rule of its own allows'); + assert.deepEqual(first.labels, { + view: 'First-person view', + exit: 'Exit first-person view', + exitText: 'EXIT VIEW', + kicker: '', + }); +}); + +test('malformed, reserved and repeated providers are left out with one warning each', () => { + const cases = [ + [null, /not an object/], + [provider({ kind: 'X' }), /malformed kind/], + [provider({ kind: 'a' }), /malformed kind/], + [provider({ layerId: 'Bad Layer' }), /malformed layer id/], + [provider({ hold: undefined }), /no hold\(\)/], + [provider({ read: 'read' }), /no read\(\)/], + [provider({ isEntryAllowed: true }), /isEntryAllowed/], + [provider({ entryFace: { icon: 'Bad Icon', label: 'X' } }), /entry face/], + [provider({ entryFace: { icon: 'commute', label: '' } }), /entry face/], + [provider({ labels: { view: 'v', exit: 'e' } }), /labels/], + [provider({ look: { restPitchDeg: -3 } }), /aheadLabel/], + ]; + for (const [entry, reason] of cases) { + const { kept, warnings } = keep([entry]); + assert.equal(kept.length, 0, String(reason)); + assert.equal(warnings.length, 1, String(reason)); + assert.match(warnings[0], reason); + } + for (const kind of COCKPIT_BUILT_IN_KINDS) { + const { kept, warnings } = keep([provider({ kind })]); + assert.equal(kept.length, 0, kind); + assert.match(warnings[0], /reserved kind/); + } + const { kept, warnings } = keep([provider(), provider()]); + assert.equal(kept.length, 1); + assert.match(warnings[0], /repeated kind/); + assert.deepEqual(keep('not a list').kept, []); +}); + +test('optional settings are kept when in range and defaulted otherwise', () => { + const [ranged] = keep([ + provider({ + priority: -2, + look: { restPitchDeg: -120, aheadLabel: 'Look ahead' }, + cameraIntervalMs: 0, + headingSlewDps: Infinity, + labels: { + view: 'v', + exit: 'e', + exitText: 'x', + kicker: 'K'.repeat(80), + }, + }), + ]).kept; + assert.equal(ranged.priority, -2); + assert.deepEqual(ranged.look, { + restPitchDeg: -90, + aheadLabel: 'Look ahead', + }); + assert.equal(ranged.cameraIntervalMs, 0); + assert.equal(ranged.headingSlewDps, Infinity); + assert.equal(ranged.labels.kicker.length, 48); + assert.ok(ranged.labels.kicker.endsWith('…')); + const [defaulted] = keep([ + provider({ + priority: NaN, + cameraIntervalMs: 5000, + headingSlewDps: -1, + }), + ]).kept; + assert.equal(defaulted.priority, 0); + assert.equal(defaulted.cameraIntervalMs, null); + assert.equal(defaulted.headingSlewDps, null); +}); + +test('a candidate needs an id; its name and face are read with care', () => { + assert.equal(cockpitSubjectCandidate(null), null); + assert.equal(cockpitSubjectCandidate({ id: 7 }), null); + assert.equal(cockpitSubjectCandidate({ id: ' ' }), null); + assert.equal(cockpitSubjectCandidate({ id: 'x'.repeat(129) }), null); + assert.deepEqual(cockpitSubjectCandidate({ id: ' a1 ' }), { + id: 'a1', + name: 'a1', + face: null, + selectedAt: null, + }); + // A selection token is a number or a short string. + assert.equal( + cockpitSubjectCandidate({ id: 'a1', selectedAt: 5 }).selectedAt, + 5, + ); + assert.equal( + cockpitSubjectCandidate({ id: 'a1', selectedAt: 'click-2' }).selectedAt, + 'click-2', + ); + for (const selectedAt of [Number.NaN, {}, 'x'.repeat(65)]) + assert.equal( + cockpitSubjectCandidate({ id: 'a1', selectedAt }).selectedAt, + null, + ); + const read = cockpitSubjectCandidate({ + id: 'a1', + name: ' Example 12 ', + face: { icon: 'tram', label: 'RIDE' }, + }); + assert.equal(read.name, 'Example 12'); + assert.deepEqual(read.face, { icon: 'tram', label: 'RIDE' }); + assert.equal( + cockpitSubjectCandidate({ id: 'a1', face: { icon: 'BAD' } }).face, + null, + ); +}); + +test('a frame is the held subject at a finite place with an eye height, else null', () => { + const frame = (overrides = {}) => ({ + id: 'a1', + latitude: 47, + longitude: 8, + eye: { upM: 3.5 }, + ...overrides, + }); + const read = cockpitSubjectFrame(frame(), 'a1'); + assert.deepEqual(read, { + id: 'a1', + name: 'a1', + latitude: 47, + longitude: 8, + heightM: null, + headingDeg: null, + courseDeg: null, + speedMps: null, + stale: false, + groundClamp: true, + // No speed to run an inertial anchor on: it sits on the subject. + anchor: 'exact', + eye: { upM: 3.5, forwardM: 0, pitchDeg: 0, minClearanceM: 0 }, + }); + assert.equal( + cockpitSubjectFrame(frame({ speedMps: 30 }), 'a1').anchor, + 'inertial', + 'with a speed it runs on', + ); + // A held id is trimmed (cockpitSubjectCandidate), and so is a frame's. + assert.equal(cockpitSubjectFrame(frame({ id: ' a1 ' }), 'a1')?.id, 'a1'); + for (const bad of [ + null, + frame({ id: 'other' }), + frame({ latitude: 91 }), + frame({ latitude: NaN }), + frame({ longitude: Infinity }), + frame({ eye: null }), + frame({ eye: { upM: -1 } }), + frame({ eye: { upM: 2000 } }), + frame({ eye: { upM: '3' } }), + ]) + assert.equal(cockpitSubjectFrame(bad, 'a1'), null, JSON.stringify(bad)); + const full = cockpitSubjectFrame( + frame({ + name: 'Example', + longitude: 190, + heightM: 412.5, + headingDeg: -10, + courseDeg: 725, + speedMps: 22, + stale: true, + groundClamp: false, + anchor: 'exact', + eye: { upM: 2, forwardM: 600, pitchDeg: -100, minClearanceM: 1.5 }, + }), + 'a1', + ); + assert.equal(full.longitude, -170); + assert.equal(full.heightM, 412.5); + // A height outside any plausible ground reads as none. + for (const heightM of [-6_400_000, 1e9, Infinity]) + assert.equal(cockpitSubjectFrame(frame({ heightM }), 'a1').heightM, null); + assert.equal( + cockpitSubjectFrame(frame({ heightM: -400 }), 'a1').heightM, + -400, + ); + assert.equal(full.headingDeg, 350); + assert.equal(full.courseDeg, 5); + assert.equal(full.speedMps, 22); + assert.equal(full.stale, true); + assert.equal(full.groundClamp, false); + assert.equal(full.anchor, 'exact'); + assert.deepEqual(full.eye, { + upM: 2, + forwardM: 0, + pitchDeg: -90, + minClearanceM: 1.5, + }); + // Only exactly false turns the ground clamp off; course falls back to heading. + const loose = cockpitSubjectFrame( + frame({ groundClamp: 0, anchor: 'EXACT', headingDeg: 90, speedMps: -1 }), + 'a1', + ); + assert.equal(loose.groundClamp, true); + assert.equal( + cockpitSubjectFrame(frame({ anchor: 'EXACT', speedMps: 5 }), 'a1').anchor, + 'inertial', + ); + assert.equal(loose.courseDeg, 90); + assert.equal(loose.speedMps, null); +}); + +test('a HUD view is read field by field: caps, tones, clamps, never a throw', () => { + const empty = cockpitSubjectHudView(null); + assert.deepEqual(empty, { + kicker: '', + title: '', + meta: { text: '', tone: 'normal' }, + speed: null, + readouts: [], + sources: [], + route: null, + progress: null, + banner: null, + veil: null, + lookReadout: '', + signals: [], + }); + for (const odd of [42, 'text', [], { readouts: 'x', progress: { ticks: 3 } }]) + assert.doesNotThrow(() => cockpitSubjectHudView(odd)); + const view = cockpitSubjectHudView({ + kicker: 'LINE · OPERATOR', + title: 'Name → Destination', + meta: { text: 'LIVE', tone: 'bogus' }, + speed: { value: 84.4, unit: 'km/h', tag: 'LIVE', tone: 'live' }, + readouts: [ + ...Array.from({ length: 6 }, (_, i) => ({ + id: `r${i}`, + label: 'NEXT', + value: `Stop ${i}`, + tone: 'warning', + })), + ], + sources: [ + { id: 'track', label: 'TRACK', text: 'operator shape' }, + { id: 'track', label: 'TRACK', text: 'repeated id' }, + { id: 'Bad Id', text: 'refused' }, + { id: 'empty', label: 'X', text: '' }, + ], + route: { title: 'ROUTE', from: 'A', to: 'B', status: '3 stops' }, + banner: { title: 'BANNER', detail: 'detail', tone: 'alert' }, + veil: { level: 4, streaks: 1 }, + lookReadout: 'LOOK RIGHT', + signals: [{ id: 's1', title: 'T', detail: 'D', tone: 'warning' }], + }); + assert.equal(view.meta.tone, 'normal'); + assert.deepEqual(view.speed, { + value: 84.4, + unit: 'km/h', + tag: 'LIVE', + tone: 'live', + }); + assert.equal(view.readouts.length, COCKPIT_SUBJECT_MAX_READOUTS); + assert.equal(view.readouts[0].tone, 'warning'); + assert.deepEqual( + view.sources.map(({ id }) => id), + ['track'], + ); + assert.deepEqual(view.route, { + title: 'ROUTE', + from: 'A', + to: 'B', + status: '3 stops', + }); + assert.equal(view.banner.tone, 'alert'); + assert.deepEqual(view.veil, { level: 1, streaks: false }); + assert.equal(view.lookReadout, 'LOOK RIGHT'); + assert.equal(view.signals[0].tone, 'warning'); + // A veil at 0, a speed below 0 and a route with no ends are absent. + const absent = cockpitSubjectHudView({ + veil: { level: 0 }, + speed: { value: -3 }, + route: { from: '', to: '' }, + }); + assert.equal(absent.veil, null); + assert.equal(absent.speed, null); + assert.equal(absent.route, null); + // A speed of null is a speed not yet known. + assert.equal( + cockpitSubjectHudView({ speed: { value: null } }).speed.value, + null, + ); +}); + +test('a progress line keeps ticks in 0..1, sorted and capped, and needs two', () => { + const read = cockpitSubjectHudView({ + progress: { + ticks: [ + { at: 1, label: { text: 'End', align: 'end', priority: 1 } }, + { at: 0.5, tone: 'warning' }, + { at: 2 }, + { at: -0.1 }, + { at: 0, label: { text: 'Start', align: 'start', priority: 12 } }, + { at: 0.25, label: { text: '', detail: 'no text, no label' } }, + ], + marker: { at: 1.4, dwell: true }, + tag: 'TIMETABLE', + summary: 'From Start to End', + }, + }).progress; + assert.deepEqual( + read.ticks.map(({ at }) => at), + [0, 0.25, 0.5, 1], + ); + assert.equal(read.ticks[0].label.priority, 9); + assert.equal(read.ticks[0].label.align, 'start'); + assert.equal(read.ticks[1].label, null); + assert.equal(read.ticks[2].tone, 'warning'); + assert.deepEqual(read.marker, { at: 1, dwell: true }); + assert.equal(read.tag, 'TIMETABLE'); + assert.equal(read.summary, 'From Start to End'); + assert.equal( + cockpitSubjectHudView({ progress: { ticks: [{ at: 0.5 }] } }).progress, + null, + ); + const many = cockpitSubjectHudView({ + progress: { + ticks: Array.from({ length: COCKPIT_SUBJECT_MAX_TICKS + 40 }, (_, i) => ({ + at: i / (COCKPIT_SUBJECT_MAX_TICKS + 40), + })), + }, + }).progress; + assert.equal(many.ticks.length, COCKPIT_SUBJECT_MAX_TICKS); +}); + +test('texts are trimmed, collapsed and cut with an ellipsis', () => { + assert.equal(cockpitSubjectText(' a \n b ', 10), 'a b'); + assert.equal(cockpitSubjectText('abcdef', 4), 'abc…'); + assert.equal(cockpitSubjectText(12.5, 10), '12.5'); + assert.equal(cockpitSubjectText({}, 10), ''); + assert.equal(cockpitSubjectText(NaN, 10), ''); +}); + +test('a cut never splits a surrogate pair', () => { + // The cut would fall between the emoji's halves: it is left out whole. + assert.equal(cockpitSubjectText('abcd😀efgh', 6), 'abcd…'); + // One that fits before the cut is kept whole. + assert.equal(cockpitSubjectText('abc😀efgh', 6), 'abc😀…'); + const lone = + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const failing = { + kind: 'example-kind', + read() { + throw new Error('boom'); + }, + hud: (value) => value * 2, + }; + assert.equal(call(failing, 'read', [], 'fallback'), 'fallback'); + assert.equal(call(failing, 'read', [], 'fallback'), 'fallback'); + assert.equal(call(failing, 'hud', [21]), 42); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /example-kind read\(\) failed/); +}); + +test('a provider method that returns a promise is refused as a throw is, warning once', async () => { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const asynchronous = { + kind: 'example-kind', + hold: async () => true, + read: () => Promise.reject(new Error('late')), + }; + assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused'); + assert.equal(call(asynchronous, 'hold', ['a1'], 'refused'), 'refused'); + assert.equal(call(asynchronous, 'read', [], null), null); + assert.equal(warnings.length, 2, 'once per method'); + assert.match(warnings[0], /hold\(\) returned a promise/); + // The rejected promise is not left unhandled. + await new Promise((resolve) => setTimeout(resolve, 0)); +}); + +test('an answer whose `then` throws when read is handled as a throw', () => { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const tricky = { + kind: 'example-kind', + hold: () => ({ + get then() { + throw new Error('no then'); + }, + }), + }; + assert.equal(call(tricky, 'hold', ['a1'], 'refused'), 'refused'); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /example-kind hold\(\) failed/); +}); + +test('the caller’s warnOnce shares its once-per-provider-and-method record', () => { + const warnings = []; + const call = createCockpitSubjectCaller({ + warn: (message) => warnings.push(message), + }); + const ride = { + kind: 'example-kind', + hold() { + throw new Error('boom'); + }, + }; + call.warnOnce(ride, 'read', 'is odd'); + call.warnOnce(ride, 'read', 'is odd again'); + call(ride, 'hold', ['a1']); + call.warnOnce(ride, 'hold', 'is odd'); + assert.deepEqual(warnings, [ + '[Cockpit] subject provider example-kind read() is odd', + '[Cockpit] subject provider example-kind hold() failed', + ]); +}); + +test('a class-based provider keeps its `this`, in its methods and its worded members', () => { + class Ride { + #word = 'RIDE'; + #held = null; + kind = 'example-kind'; + layerId = 'example-layer'; + speak(word) { + this.#word = word; + } + entryFace() { + return { icon: 'commute', label: this.#word }; + } + get labels() { + return { + view: `${this.#word} view`, + exit: 'Exit ride view', + exitText: 'EXIT RIDE', + }; + } + look() { + return { aheadLabel: `Look ahead, ${this.#word}` }; + } + candidate() { + return { id: 'a1', name: this.#word }; + } + isEntryAllowed(candidate) { + return candidate.id === 'a1' && this.#held === null; + } + hold(id) { + this.#held = id; + return true; + } + read() { + return this.#held; + } + hud(frame) { + return { title: `${this.#word} ${frame}` }; + } + release() { + this.#held = null; + return 'released'; + } + } + const instance = new Ride(); + const { kept, warnings } = keep([instance]); + assert.deepEqual(warnings, []); + const [ride] = kept; + assert.equal(ride.entryFace.label, 'RIDE'); + assert.equal(ride.labels.view, 'RIDE view'); + assert.equal(ride.look.aheadLabel, 'Look ahead, RIDE'); + instance.speak('FAHRT'); + assert.equal(ride.entryFace.label, 'FAHRT', 'read again, on the instance'); + assert.equal(ride.look.aheadLabel, 'Look ahead, FAHRT'); + // Called on the kept copy, as the Cockpit calls them. + const failures = []; + const call = createCockpitSubjectCaller({ + warn: (message) => failures.push(message), + }); + assert.deepEqual(call(ride, 'candidate'), { id: 'a1', name: 'FAHRT' }); + assert.equal(ride.isEntryAllowed({ id: 'a1' }), true); + assert.equal(call(ride, 'hold', ['a1']), true); + assert.equal(ride.isEntryAllowed({ id: 'a1' }), false); + assert.equal(call(ride, 'read', [0, {}]), 'a1'); + assert.deepEqual(call(ride, 'hud', ['a1', {}]), { title: 'FAHRT a1' }); + assert.equal(call(ride, 'release', ['a1', { restore: true }]), 'released'); + assert.equal(call(ride, 'read', [0, {}]), null); + assert.deepEqual(failures, []); +}); + +test('labels, entry face and look given as functions are read again on each use', () => { + let lang = 'en'; + const words = { + en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' }, + de: { view: 'Fahrtansicht', exit: 'Verlassen', exitText: 'VERLASSEN' }, + }; + const { kept, warnings } = keep([ + provider({ + labels: () => words[lang], + entryFace: () => ({ + icon: 'commute', + label: { en: 'RIDE', de: 'FAHRT' }[lang], + }), + look: () => ({ + restPitchDeg: lang === 'de' ? 40 : -3, + aheadLabel: lang === 'de' ? 'Nach vorn' : 'Look ahead', + }), + }), + ]); + assert.deepEqual(warnings, []); + const [ride] = kept; + assert.equal(ride.labels.exitText, 'EXIT RIDE'); + assert.equal(ride.entryFace.label, 'RIDE'); + assert.equal(ride.look.aheadLabel, 'Look ahead'); + lang = 'de'; + assert.equal(ride.labels.exitText, 'VERLASSEN'); + assert.equal(ride.entryFace.label, 'FAHRT'); + assert.equal(ride.look.aheadLabel, 'Nach vorn'); + assert.equal(ride.look.restPitchDeg, -3, 'the rest pitch is as first read'); + // A read that fails or is malformed keeps the last good one. + lang = 'xx'; + assert.equal(ride.labels.exitText, 'VERLASSEN'); + assert.equal(ride.entryFace.label, 'FAHRT'); + // Functions that are malformed from the start leave the provider out. + const bad = keep([ + provider({ kind: 'bad-labels', labels: () => null }), + provider({ + kind: 'thrown-face', + entryFace: () => { + throw new Error('no face'); + }, + }), + ]); + assert.deepEqual(bad.kept, []); + assert.equal(bad.warnings.length, 2); +}); + +/** `target` with a getter for `key` that throws (provider code runs on a read). */ +const throwingGetter = (target, key) => + Object.defineProperty(target, key, { + enumerable: true, + get() { + throw new Error(`${key} unavailable`); + }, + }); + +test('a provider whose getter throws is left out with one warning; the next is kept', () => { + const { kept, warnings } = keep([ + throwingGetter(provider({ kind: 'thrown-face' }), 'entryFace'), + throwingGetter(provider({ kind: 'thrown-labels' }), 'labels'), + throwingGetter(provider({ kind: 'thrown-look' }), 'look'), + throwingGetter(provider(), 'kind'), + throwingGetter(provider({ kind: 'thrown-method' }), 'read'), + // Read only once the provider is accepted, so its kind must stay free. + throwingGetter(provider({ kind: 'example-kind' }), 'priority'), + provider({ kind: 'example-kind' }), + ]); + assert.deepEqual( + kept.map(({ kind }) => kind), + ['example-kind'], + ); + assert.deepEqual(warnings, [ + '[Cockpit] subject provider thrown-face left out: malformed entry face', + '[Cockpit] subject provider thrown-labels left out: labels need view, exit and exitText', + '[Cockpit] subject provider thrown-look left out: a member threw when read', + '[Cockpit] subject provider #3 left out: a member threw when read', + '[Cockpit] subject provider thrown-method left out: a member threw when read', + '[Cockpit] subject provider example-kind left out: a member threw when read', + ]); +}); + +test('a worded getter that starts throwing keeps the last good words', () => { + let failing = false; + const words = (value) => () => { + if (failing) throw new Error('translations unavailable'); + return value; + }; + const definition = provider(); + for (const [key, value] of Object.entries({ + labels: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT' }, + entryFace: { icon: 'commute', label: 'RIDE' }, + look: { restPitchDeg: 10, aheadLabel: 'Look ahead' }, + })) + Object.defineProperty(definition, key, { get: words(value) }); + const { kept, warnings } = keep([definition]); + assert.deepEqual(warnings, []); + const [ride] = kept; + failing = true; + assert.equal(ride.labels.view, 'Ride view'); + assert.equal(ride.entryFace.label, 'RIDE'); + assert.deepEqual(ride.look, { restPitchDeg: 10, aheadLabel: 'Look ahead' }); + // So does a worded object whose own member throws when read. + failing = false; + Object.defineProperty(definition, 'labels', { + get: () => throwingGetter({ view: 'v', exit: 'e', exitText: 'x' }, 'view'), + }); + assert.equal(ride.labels.view, 'Ride view'); +}); + +test('a candidate, frame or HUD view whose getter throws reads as null', () => { + assert.equal( + cockpitSubjectCandidate(throwingGetter({ name: 'Example' }, 'id')), + null, + ); + const frame = { id: 'a1', latitude: 1, longitude: 2, eye: { upM: 3 } }; + assert.ok(cockpitSubjectFrame(frame, 'a1'), 'well formed'); + assert.equal( + cockpitSubjectFrame(throwingGetter({ ...frame }, 'latitude'), 'a1'), + null, + ); + assert.equal( + cockpitSubjectFrame({ ...frame, eye: throwingGetter({}, 'upM') }, 'a1'), + null, + ); + assert.equal( + cockpitSubjectHudView(throwingGetter({ title: 'T' }, 'meta')), + null, + ); + assert.equal( + cockpitSubjectHudView({ title: 'T', readouts: [throwingGetter({}, 'id')] }), + null, + ); + // Nothing to read is still the empty view, never null. + assert.equal(cockpitSubjectHudView(null).title, ''); +}); diff --git a/src/contextModePolicy.js b/src/contextModePolicy.js index 6e3238ec5..ec6b1f32c 100644 --- a/src/contextModePolicy.js +++ b/src/contextModePolicy.js @@ -481,6 +481,24 @@ export function orbitEntryAllowed({ ); } +/** + * Whether an application subject provider's candidate can be ridden. Like the + * bridge and orbit it needs only its own layer on, and waits out a Context + * change in flight; the provider then has its own say (`isEntryAllowed`). + * @param {{layerEnabled: boolean, contextModeChanging: boolean, + * providerAllows: boolean}} state + * @returns {boolean} + */ +export function subjectEntryAllowed({ + layerEnabled, + contextModeChanging, + providerAllows, +}) { + return ( + Boolean(layerEnabled) && !contextModeChanging && providerAllows === true + ); +} + /** * Internal context-mode id → the word the voice tools accept for it. * diff --git a/src/data/adsbLolFallback.js b/src/data/adsbLolFallback.js index 61d07387f..a9d5cbbdf 100644 --- a/src/data/adsbLolFallback.js +++ b/src/data/adsbLolFallback.js @@ -64,7 +64,11 @@ export function normalizeAdsbLolAircraftState(aircraft, nowSeconds) { const geometricFeet = finiteNumber(aircraft?.alt_geom); const groundSpeedKnots = finiteNumber(aircraft?.gs); const verticalRateFpm = readAdsbVerticalRateFpm(aircraft); - const track = finiteNumber(aircraft?.track); + // A parked or slow aircraft reports no ground track, but often its true + // heading (surface messages): the way its nose points, which is what an + // aircraft standing still is drawn by. Magnetic heading is not true north. + const track = + finiteNumber(aircraft?.track) ?? finiteNumber(aircraft?.true_heading); return [ hex, diff --git a/src/data/adsbLolFallback.test.mjs b/src/data/adsbLolFallback.test.mjs index 873a8bbe2..a99d273de 100644 --- a/src/data/adsbLolFallback.test.mjs +++ b/src/data/adsbLolFallback.test.mjs @@ -49,3 +49,15 @@ test('keeps grounded fallback contacts and rejects rows without positions', () = assert.equal(normalized.states[0][7], null); assert.equal(normalized.states[0][8], true); }); + +test('an aircraft with no track takes its true heading, never its magnetic one', () => { + const at = (extra) => + normalizeAdsbLolAircraftState( + { hex: 'abc123', lat: 37.7, lon: -122.2, alt_baro: 'ground', ...extra }, + 1000, + )[10]; + assert.equal(at({ track: 56.25, true_heading: 40 }), 56.25, 'track first'); + assert.equal(at({ true_heading: 244.69 }), 244.69, 'true heading'); + assert.equal(at({ mag_heading: 278.44 }), null, 'not magnetic'); + assert.equal(at({}), null, 'none'); +}); diff --git a/src/data/aircraftClass.js b/src/data/aircraftClass.js index 1be15e84c..4b719b3ec 100644 --- a/src/data/aircraftClass.js +++ b/src/data/aircraftClass.js @@ -467,54 +467,3 @@ export const CLASS_SCALE_2D = { bizjet: 0.72, uav: 0.75, }; - -/** 3D model scale multipliers — clamped [0.75, 1.45] while every class shares - * the single airplane.glb (a jet mesh at C172 scale reads wrong). Widen when - * real per-class models land in CLASS_MODEL_URL. */ -export const CLASS_SCALE_3D = { - light: 0.75, - glider: 0.75, - turboprop: 0.85, - airliner: 1.0, - widebody: 1.3, - quadjet: 1.45, - helicopter: 0.8, - fastjet: 0.8, - bizjet: 0.8, - uav: 0.8, -}; - -/** Per-class glTF — all the shared airplane today; drop real assets in here. - * NOTE for future models: each asset may need its own heading offset (the - * shared GLB uses MODEL_HEADING_OFFSET_DEG = 180 in both layers). */ -export const CLASS_MODEL_URL = { - light: '/models/airplane.glb', - glider: '/models/airplane.glb', - turboprop: '/models/airplane.glb', - airliner: '/models/airplane.glb', - widebody: '/models/airplane.glb', - quadjet: '/models/airplane.glb', - helicopter: '/models/airplane.glb', - fastjet: '/models/airplane.glb', - bizjet: '/models/airplane.glb', - uav: '/models/airplane.glb', -}; - -/** Real per-class GLBs (2026-08-15 Hangar fleet, owner picks; CC-BY 4.0 — - * provenance in public/models/README.md). Every asset is vertex-baked to the - * airplane.glb convention: Y-up, X = length, Z = span, nose −X (so the layers' - * MODEL_HEADING_OFFSET_DEG = 180 applies unchanged), origin at bbox centre, - * REAL-WORLD METERS (render at scale 1 — no MODEL_SCALE, no CLASS_SCALE_3D). - * bellyM = origin→lowest-vertex distance (grounded-model lift, replaces the - * airplane.glb MODEL_BELLY_OFFSET_NATIVE formula); radiusM = bounding-sphere - * radius in meters (pixel-size math). Values measured from the shipped GLBs — - * pinned by modelScale.test.mjs. Classes NOT listed (airliner, quadjet, - * glider, fastjet) still render the shared airplane.glb via CLASS_MODEL_URL. */ -export const CLASS_MODEL_REAL = { - helicopter: { url: '/models/bell206.glb', bellyM: 1.66, radiusM: 8.24 }, - light: { url: '/models/c172.glb', bellyM: 1.36, radiusM: 7.0 }, - bizjet: { url: '/models/citation2.glb', bellyM: 2.86, radiusM: 11.24 }, - uav: { url: '/models/mq9.glb', bellyM: 2.02, radiusM: 12.0 }, - widebody: { url: '/models/b789.glb', bellyM: 7.81, radiusM: 44.08 }, - turboprop: { url: '/models/atr72.glb', bellyM: 3.81, radiusM: 19.49 }, -}; diff --git a/src/data/aircraftClass.test.mjs b/src/data/aircraftClass.test.mjs index c1676a279..84b7394c2 100644 --- a/src/data/aircraftClass.test.mjs +++ b/src/data/aircraftClass.test.mjs @@ -1,7 +1,7 @@ // src/data/aircraftClass.test.mjs import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { classifyAircraft, CLASS_SCALE_2D, CLASS_SCALE_3D, CLASS_MODEL_URL, CLASS_MODEL_REAL } from './aircraftClass.js'; +import { classifyAircraft, CLASS_SCALE_2D } from './aircraftClass.js'; test('type-code classification (military layer path)', () => { assert.equal(classifyAircraft({ typeCode: 'F16' }), 'fastjet'); @@ -48,19 +48,8 @@ test('typeCode outranks category; unknown → airliner', () => { assert.equal(classifyAircraft({ typeCode: null, category: 0 }), 'airliner'); }); -test('scale/url tables cover every class', () => { +test('the icon scale table covers every class', () => { for (const kind of ['light','glider','turboprop','airliner','widebody','quadjet','helicopter','fastjet','bizjet','uav']) { assert.ok(Number.isFinite(CLASS_SCALE_2D[kind]), kind); - assert.ok(CLASS_SCALE_3D[kind] >= 0.75 && CLASS_SCALE_3D[kind] <= 1.45, kind); - assert.ok(typeof CLASS_MODEL_URL[kind] === 'string', kind); - } -}); - -test('CLASS_MODEL_REAL entries carry the fields the layers consume', () => { - for (const [kind, spec] of Object.entries(CLASS_MODEL_REAL)) { - assert.ok(spec.url.startsWith('/models/') && spec.url.endsWith('.glb'), kind); - assert.ok(spec.radiusM > 0 && spec.bellyM > 0, kind); - // every real-model class must also exist in the classifier tables - assert.ok(Number.isFinite(CLASS_SCALE_2D[kind]), kind); } }); diff --git a/src/data/aircraftEmergencyAnalyst.test.mjs b/src/data/aircraftEmergencyAnalyst.test.mjs index 27deb2b46..f26c28abc 100644 --- a/src/data/aircraftEmergencyAnalyst.test.mjs +++ b/src/data/aircraftEmergencyAnalyst.test.mjs @@ -458,7 +458,8 @@ test('tint priorities cover normal, priority, emergency, tracking and Cockpit do ); flightState._trackedIcao = null; flightState.records.data.set('civil', { emergency: null }); - assert.equal(rendering._modelColor('civil'), normal); + // An ordinary model draws its mesh's own colours, whatever the billboard's. + assert.equal(rendering._modelColor('civil'), Cesium.Color.WHITE); } }); diff --git a/src/data/aircraftIcons.js b/src/data/aircraftIcons.js index bca4ca655..f664c861e 100644 --- a/src/data/aircraftIcons.js +++ b/src/data/aircraftIcons.js @@ -160,8 +160,8 @@ const BODIES = { L -3,31 L -8,34 L -8,30 L -6,16 L -27,26 L -27,20 L -5,-8 C -4.6,-16 -4,-24 -3.5,-30 Z" fill="white" ${STROKE}/>`, - // 2026-08-15 Hangar additions: two NEW classes shipped with the real-model - // fleet (CLASS_MODEL_REAL). Same contract as the set above: 96×96 nose-up, + // 2026-08-15 additions: two NEW classes (bizjet, uav). Same contract as + // the set above: 96×96 nose-up, // white fill, hairline stroke, tint-safe. // Business jet — slender fuselage, modest swept wing, aft engines, T-tail. bizjet: ` diff --git a/src/data/aircraftModelLighting.js b/src/data/aircraftModelLighting.js index c8051c67e..38725be93 100644 --- a/src/data/aircraftModelLighting.js +++ b/src/data/aircraftModelLighting.js @@ -1,3 +1,5 @@ +import * as Cesium from 'cesium'; + /** * Lighting options shared by every aircraft 3D model (Flights, Military * Flights, Local ADS-B). @@ -6,12 +8,24 @@ * the model and reads the result back from the GPU (`readPixels`) to light it, * when the model is first placed and again when it moves more than 1 km, when * the scene's atmosphere settings change, or every scene hour under sunlight - * lighting. Aircraft models take 94% of their surface from the class colour - * (`colorBlendAmount`), so that lighting contributes little; without it they - * are lit from Cesium's default spherical harmonics. Measured 2026-10-24 - * (Intel Arc, Google 3D, ~150 models in the scene): GPU per frame at rest - * 14.3-14.8 -> 12.1-12.5 ms. + * lighting. Without it they are lit from Cesium's default spherical + * harmonics. Measured 2026-10-24 (Intel Arc, Google 3D, ~150 models in the + * scene): GPU per frame at rest 14.3-14.8 -> 12.1-12.5 ms. */ export const AIRCRAFT_ENVIRONMENT_MAP_OPTIONS = Object.freeze({ enabled: false, }); + +/** + * How an aircraft model's colour meets its mesh. HIGHLIGHT multiplies, so + * white draws the mesh's own colours as built, and the tracked or emergency + * colour tints the mesh without hiding its paint. The IR boost draws the + * model flat white instead (MIX at 1, with its unlit shader). + * @param {boolean} irBoost + * @returns {{colorBlendMode: Cesium.ColorBlendMode, colorBlendAmount: number}} + */ +export function aircraftModelBlend(irBoost) { + return irBoost + ? { colorBlendMode: Cesium.ColorBlendMode.MIX, colorBlendAmount: 1 } + : { colorBlendMode: Cesium.ColorBlendMode.HIGHLIGHT, colorBlendAmount: 0 }; +} diff --git a/src/data/aircraftModelLighting.test.mjs b/src/data/aircraftModelLighting.test.mjs index 51fd9d97c..70be8e5d4 100644 --- a/src/data/aircraftModelLighting.test.mjs +++ b/src/data/aircraftModelLighting.test.mjs @@ -6,9 +6,8 @@ import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js'; /** * Every aircraft model drawn in the scene skips Cesium's dynamic environment - * map (a sky render and GPU readback per model). The preload calls only warm - * the glTF cache and are never added to the scene, so they are left out: - * drawn models are the calls that set a minimum pixel size. + * map (a sky render and GPU readback per model). Drawn models are the calls + * that set a minimum pixel size. */ const SOURCES = [ { @@ -36,7 +35,9 @@ test('the shared option turns the dynamic environment map off', () => { for (const { name, source } of SOURCES) { test(`${name}: every drawn aircraft model skips the dynamic environment map`, () => { const calls = [ - ...source.matchAll(/(?:fromGltfAsync|loadModel)\(\{([\s\S]*?)\}\)/g), + ...source.matchAll( + /(?:fromGltfAsync\(|loadModel\(|(?:_createModel|createFleetModel)\(\s*\w+,\s*)\{([\s\S]*?)\n\s*\}[,)]/g, + ), ] .map((match) => match[1]) .filter((options) => /\bminimumPixelSize:/.test(options)); diff --git a/src/data/aircraftRecession.js b/src/data/aircraftRecession.js index 04c41e604..0cc012e68 100644 --- a/src/data/aircraftRecession.js +++ b/src/data/aircraftRecession.js @@ -243,7 +243,7 @@ export function applyAircraftBillboardTreatment({ /** * Apply layer-owned hue and composed aircraft alpha to an ambient glTF model without - * disturbing Cesium's existing MIX/colorBlendAmount presentation semantics. + * changing the model's colour blend mode or amount. * @param {object} input * @param {object} input.model * @param {{withAlpha:(alpha:number)=>object}} input.baseColor diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index e427a5412..6608eb055 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -1882,3 +1882,57 @@ test('detection objects name the vessel card their callout gives way to', () => _setVesselStateForTest({ enabled: false }); } }); + +test('an aid to navigation card shows its kind, never a speed or heading', () => { + const aid = makeRecord({ + name: 'SF APPROACH N', + mmsi: '993672001', + type: 'Cardinal Mark N', + speed: 0, + course: 135, + heading: 135, + }); + const card = buildVesselCard(aid); + assert.deepEqual(card.details, ['CARDINAL MARK N']); + assert.equal(card.accent, '217, 210, 192'); + const selected = buildSelectedVesselCard(aid); + assert.equal(selected.details[0], 'CARDINAL MARK N'); + assert.equal(selected.accent, '217, 210, 192'); + // Known only by its MMSI, an aid is named as one. + const unnamed = buildSelectedVesselCard({ ...aid, type: '' }); + assert.equal(unnamed.details[0], 'AID TO NAVIGATION'); + assert.deepEqual(buildVesselCard({ ...aid, type: '' }).details, []); +}); + +test('the HUD of a selected aid to navigation shows no speed or heading', () => { + const harness = installWireHarness(undefined, { selectedRecord: null }); + try { + Object.assign(harness.record, { + type: 'Port hand Mark', + speed: 0, + heading: 135, + course: 135, + }); + assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true); + assert.match(harness.hud.readout.text, /\nPort hand Mark\n/); + assert.doesNotMatch(harness.hud.readout.text, /SPD|HDG|KT/); + harness.record.virtual = true; + aisLiveVesselsLayer.selectById(harness.record.mmsi); + assert.match(harness.hud.readout.text, /\nPort hand Mark · VIRTUAL\n/); + } finally { + harness.cleanup(); + } +}); + +test('a virtual aid to navigation card says it is virtual', () => { + const aid = makeRecord({ + mmsi: '993672001', + type: 'Isolated danger', + virtual: true, + }); + assert.deepEqual(buildVesselCard(aid).details, ['ISOLATED DANGER · VIRTUAL']); + assert.equal( + buildSelectedVesselCard(aid).details[0], + 'ISOLATED DANGER · VIRTUAL', + ); +}); diff --git a/src/data/aisStreamSentinels.test.mjs b/src/data/aisStreamSentinels.test.mjs index 5ca3375f7..563bf9f65 100644 --- a/src/data/aisStreamSentinels.test.mjs +++ b/src/data/aisStreamSentinels.test.mjs @@ -258,3 +258,155 @@ test('partial static data keeps newer live metadata ahead of the old static cach assert.equal(rowFor(mmsi).type, '80'); assert.equal(rowFor(mmsi).destination, 'HAMBURG'); }); + +// A static report's dimensions are the antenna's distances to bow (A), stern +// (B), port (C) and starboard (D): length A+B, beam C+D. Zero is AIS's "not +// available", so a vessel reporting none has no size, not a size of zero. +test('a static report gives a vessel its hull length and beam', () => { + ingestAisStreamEnvelope(positionReport('636019825', { Sog: 14 })); + ingestAisStreamEnvelope({ + MessageType: 'ShipStaticData', + Message: { + ShipStaticData: { + UserID: 636019825, + Name: 'EVER GIVEN', + Type: 70, + Dimension: { A: 330, B: 70, C: 30, D: 29 }, + }, + }, + MetaData: { MMSI: '636019825' }, + }); + const row = rowFor('636019825'); + assert.equal(row.length, 400); + assert.equal(row.beam, 59); + + ingestAisStreamEnvelope(positionReport('636019826', { Sog: 3 })); + ingestAisStreamEnvelope({ + MessageType: 'ShipStaticData', + Message: { + ShipStaticData: { + UserID: 636019826, + Type: 52, + Dimension: { A: 0, B: 0, C: 0, D: 0 }, + }, + }, + MetaData: { MMSI: '636019826' }, + }); + assert.equal(rowFor('636019826').length, null); + assert.equal(rowFor('636019826').beam, null); +}); + +test('a Class B report carries its dimensions in part B', () => { + ingestAisStreamEnvelope(positionReport('244000777', { Sog: 6 })); + ingestAisStreamEnvelope({ + MessageType: 'StaticDataReport', + Message: { + StaticDataReport: { + UserID: 244000777, + ReportB: { + Valid: true, + ShipType: 37, + Dimension: { A: 8, B: 4, C: 2, D: 2 }, + }, + }, + }, + MetaData: { MMSI: '244000777' }, + }); + const row = rowFor('244000777'); + assert.equal(row.length, 12); + assert.equal(row.beam, 4); +}); + +test('an aid-to-navigation report is a row named by its kind of aid, not a ship type', () => { + assert.equal( + ingestAisStreamEnvelope({ + MessageType: 'AidsToNavigationReport', + Message: { + AidsToNavigationReport: { + UserID: 993672001, + Type: 20, + Name: 'SF APPROACH N', + }, + }, + MetaData: { + MMSI: '993672001', + latitude: 37.79, + longitude: -122.52, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, + }), + true, + ); + const row = rowFor('993672001'); + assert.equal(row.type, 'Cardinal Mark N'); + assert.equal(row.name, 'SF APPROACH N'); + assert.equal(row.speed, null); +}); + +test("an aid's name runs on into its name extension", () => { + ingestAisStreamEnvelope({ + MessageType: 'AidsToNavigationReport', + Message: { + AidsToNavigationReport: { + UserID: 993672002, + Type: 13, + Name: 'SAN FRANCISCO MAIN S', + NameExtension: 'HIP CHANNEL 2', + }, + }, + MetaData: { + MMSI: '993672002', + ShipName: 'SAN FRANCISCO MAIN S', + latitude: 37.79, + longitude: -122.52, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, + }); + assert.equal(rowFor('993672002').name, 'SAN FRANCISCO MAIN SHIP CHANNEL 2'); +}); + +test('a capped row request keeps every vessel ahead of aids to navigation', () => { + // The aid reports last, so it is the newest row. + ingestAisStreamEnvelope(positionReport('244060901', { Sog: 3 })); + ingestAisStreamEnvelope({ + MessageType: 'AidsToNavigationReport', + Message: { AidsToNavigationReport: { UserID: 993672003, Type: 24 } }, + MetaData: { + MMSI: '993672003', + latitude: 37.79, + longitude: -122.52, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, + }); + const rows = aisStreamRows(50000); + const firstAid = rows.findIndex((row) => row.mmsi.startsWith('99')); + const lastVessel = rows.findLastIndex((row) => !row.mmsi.startsWith('99')); + assert.ok(firstAid > lastVessel, `${firstAid} > ${lastVessel}`); + const vesselCount = + rows.length - rows.filter((row) => row.mmsi.startsWith('99')).length; + assert.ok( + aisStreamRows(vesselCount).every((row) => !row.mmsi.startsWith('99')), + ); +}); + +test('a virtual aid to navigation is marked virtual; a real one carries no flag', () => { + const aidReport = (mmsi, VirtualAtoN) => ({ + MessageType: 'AidsToNavigationReport', + Message: { + AidsToNavigationReport: { UserID: Number(mmsi), Type: 28, VirtualAtoN }, + }, + MetaData: { + MMSI: mmsi, + latitude: 37.79, + longitude: -122.52, + time_utc: '2026-09-17 20:00:00.000000000 +0000 UTC', + }, + }); + ingestAisStreamEnvelope(aidReport('993672004', true)); + ingestAisStreamEnvelope(aidReport('993672005', false)); + assert.equal(rowFor('993672004').virtual, true); + assert.equal('virtual' in rowFor('993672005'), false); + // A later real report of the same aid drops the flag. + ingestAisStreamEnvelope(aidReport('993672004', false)); + assert.equal('virtual' in rowFor('993672004'), false); +}); diff --git a/src/data/cctv.js b/src/data/cctv.js index ed3f56654..884638a96 100644 --- a/src/data/cctv.js +++ b/src/data/cctv.js @@ -29,6 +29,7 @@ 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; diff --git a/src/data/cctv.test.mjs b/src/data/cctv.test.mjs index bcd5c48ab..087422584 100644 --- a/src/data/cctv.test.mjs +++ b/src/data/cctv.test.mjs @@ -35,6 +35,7 @@ import cctvLayer, { _pushAmbientCardEntriesForTest, _setCctvOverlayHostForTest, activationProbeClampRange, + flatGroundAxisDistance, bindCctvWorldClickGesture, clearProbeClampOnDeactivation, computeFrustumGeometry, @@ -655,6 +656,17 @@ 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 }, diff --git a/src/data/cockpitModels.test.mjs b/src/data/cockpitModels.test.mjs index 8177b7414..0488a6d1e 100644 --- a/src/data/cockpitModels.test.mjs +++ b/src/data/cockpitModels.test.mjs @@ -28,11 +28,13 @@ for (const layer of LAYERS) { const source = readLayerSource(layer.path); test(`${layer.name}: every GLB creation bypasses the tile-contended frame-spread queue`, () => { - const calls = [...source.matchAll(/Cesium\.Model\.fromGltfAsync\(\{([\s\S]*?)\}\)/g)]; - assert.ok(calls.length >= 3, `expected fleet, tracked, and preload model calls; found ${calls.length}`); + const calls = [...source.matchAll(/_createModel\(\s*\w+,\s*\{([\s\S]*?)\n\s*\}\)/g)]; + assert.ok(calls.length >= 2, `expected fleet and tracked model calls; found ${calls.length}`); + assert.doesNotMatch(source, /Cesium\.Model\.fromGltfAsync\(/, + 'every model is built from the loaded fleet set through _createModel'); for (const [index, call] of calls.entries()) { assert.match(call[1], /\basynchronous:\s*false\b/, - `Model.fromGltfAsync call ${index + 1} must keep bounded GLB readiness independent of tile jobs`); + `_createModel call ${index + 1} must keep bounded GLB readiness independent of tile jobs`); } }); diff --git a/src/data/dataCredits.js b/src/data/dataCredits.js index a452fa0cf..c9281fd4a 100644 --- a/src/data/dataCredits.js +++ b/src/data/dataCredits.js @@ -141,6 +141,13 @@ export const DATA_CREDITS = [ 'Planespotters.net ' + '· each photo is © its photographer, credited beside it', }, + { + key: 'openvesselphotos', + html: + 'Vessel photos: ' + + 'Open Vessel Photos ' + + "· each photo keeps its author's open licence, credited beside it", + }, { key: 'aisstream', html: diff --git a/src/data/dataCredits.test.mjs b/src/data/dataCredits.test.mjs index 8e7179338..3c9fb0fee 100644 --- a/src/data/dataCredits.test.mjs +++ b/src/data/dataCredits.test.mjs @@ -51,6 +51,13 @@ 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. diff --git a/src/data/fleetModelInstances.js b/src/data/fleetModelInstances.js new file mode 100644 index 000000000..e3147c812 --- /dev/null +++ b/src/data/fleetModelInstances.js @@ -0,0 +1,462 @@ +import * as Cesium from 'cesium'; +import { horizonOccluder } from './iconOrientation.js'; +import { selectModelEligible } from './modelEligibility.js'; +import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from './aircraftModelLighting.js'; +import { fleetModelSet } from './fleetModels.js'; + +/** + * Cesium models of the procedural fleet set (`fleetModels.js`), and the + * icon-to-model handoff the Vessels, Transit and Rail layers share. + */ + +/** Every fleet GLB is nose -X: opposite Cesium's heading 0. */ +export const FLEET_HEADING_OFFSET_DEG = 180; + +/** + * Create a Cesium Model of `spec` from the up-front loaded bytes, so no model + * is fetched mid-session. Each model keeps its own URL as its base path: the + * glTF cache keys by it, and two models sharing one would share geometry. + * Rejects when the set is not loaded or the model's GLB failed to load. + * @param {object} spec `fleetModelSpec`. + * @param {object} options Cesium `Model.fromGltfAsync` options. + * @param {{resolveAsset?: (url: string) => string, set?: object|null, + * load?: (options: object) => Promise}} [context] + * @returns {Promise} Cesium Model. + */ +export function createFleetModel( + spec, + options, + { + resolveAsset = (url) => url, + set = fleetModelSet(), + // Looked up when called, so a replaced `fromGltfAsync` (tests) is the one used. + load = (o) => Cesium.Model.fromGltfAsync(o), + } = {}, +) { + const bytes = set?.bytes?.get(spec?.model); + if (!bytes) + return Promise.reject(new Error(`fleet model ${spec?.model} not loaded`)); + return load({ + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + ...options, + gltf: bytes, + basePath: resolveAsset(spec.url), + }); +} + +const tintShaders = new Map(); + +/** Models under the NVG/FLIR styles render unlit, as aircraft models do. */ +const IR_UNLIT_SHADER = new Cesium.CustomShader({ + lightingModel: Cesium.LightingModel.UNLIT, +}); + +/** + * The custom shader that paints a route colour where a transit model's + * `_TINT` vertex attribute marks it: drawn colour = vertex colour × + * mix(1, colour, _TINT). One shader per colour, shared by every model drawn in + * it (Cesium compiles the program once; uniforms are per shader). + * @param {Cesium.Color} color + * @returns {Cesium.CustomShader} + */ +export function fleetTintShader(color) { + const key = color.toCssHexString(); + let shader = tintShaders.get(key); + if (!shader) { + shader = new Cesium.CustomShader({ + mode: Cesium.CustomShaderMode.MODIFY_MATERIAL, + uniforms: { + u_routeColor: { + type: Cesium.UniformType.VEC3, + value: new Cesium.Cartesian3(color.red, color.green, color.blue), + }, + }, + fragmentShaderText: ` + void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { + vec3 route = czm_srgbToLinear(u_routeColor); + material.diffuse *= mix(vec3(1.0), route, fsInput.attributes.tint); + } + `, + }); + tintShaders.set(key, shader); + } + return shader; +} + +/** + * Write the world matrix of a fleet model standing at `position` heading + * `headingDeg` (clockwise from north) into `result`, stretched by the spec's + * beam and height scales relative to its length scale (a Model's own `scale` + * carries the length scale, so its minimum pixel size still applies). + * @param {Cesium.Cartesian3} position + * @param {number} headingDeg + * @param {object} spec `fleetModelSpec`. + * @param {Cesium.Matrix4} result + * @param {Cesium.HeadingPitchRoll} scratchHpr + * @returns {Cesium.Matrix4} + */ +export function fleetModelMatrix( + position, + headingDeg, + spec, + result, + scratchHpr, +) { + scratchHpr.heading = Cesium.Math.toRadians( + (headingDeg || 0) + FLEET_HEADING_OFFSET_DEG, + ); + scratchHpr.pitch = 0; + scratchHpr.roll = 0; + Cesium.Transforms.headingPitchRollToFixedFrame( + position, + scratchHpr, + Cesium.Ellipsoid.WGS84, + undefined, + result, + ); + const [sx, sy, sz] = spec.scaleXYZ; + if (sx > 0 && (sy !== sx || sz !== sx)) { + // Cesium's glTF axis correction puts the model's length on local Y, its + // beam on local X and its height on local Z. + scratchScale.x = sy / sx; + scratchScale.y = 1; + scratchScale.z = sz / sx; + Cesium.Matrix4.multiplyByScale(result, scratchScale, result); + } + return result; +} +const scratchScale = new Cesium.Cartesian3(); + +/** + * 3D models for one layer's vehicles under the DISPLAY rail's 3D toggle (the + * Flights layer's `models3d`/`models3dMode`), on the same rules the aircraft + * layers follow: models only below the camera altitude ceiling, for the + * nearest `cap` vehicles in the add/keep band, on-screen first. A vehicle's + * icon stays the visual until its model is loaded and placed; the layer reads + * `modelOwnsVisual` back from each item and hides that icon. + * + * Each frame the layer passes its vehicles as items: + * `{position, headingDeg, spec, tint, pickId, show, groundLiftM}`, + * `position` the point the model's origin stands on (the waterline, the road + * or rail top; the spec's `elevationM` is added here), `groundLiftM` any lift + * the layer's icon carries above that point (taken off again), `tint` a + * Cesium.Color for a tinted model's route colour (or null), `show` false to + * keep it hidden. + * + * Under the luminance-mapped NVG/FLIR styles (`irBoost`) models render + * unlit flat white, as aircraft models do. With `waterline`, each model is + * clipped at its origin's horizontal plane and only the part above it is + * drawn: a ship's hull below the waterline would otherwise show through a + * plain globe, which Cesium does not depth-test models against. + * @param {object} options + * @param {object} options.viewer Cesium viewer. + * @param {() => object|null} [options.display] `{models3d, models3dMode, + * irBoost}`. + * @param {object} options.policy `{altitudeCeilingM, minimumPixelSize, + * cap, capAll, addM, addAllM, keepM, keepAllM, intervalMs}`. + * @param {(url: string) => string} [options.resolveAsset] + * @param {(spec: object, options: object) => Promise} [options.createModel] + * @param {boolean} [options.waterline] Clip each model at its waterline. + * @returns {object} + */ +export function createFleetModelLayer({ + viewer, + display = () => null, + policy, + waterline = false, + resolveAsset = (url) => url, + createModel = (spec, options) => + createFleetModel(spec, options, { resolveAsset }), +}) { + const collection = new Cesium.PrimitiveCollection(); + viewer.scene.primitives.add(collection); + /** @type {Map} id -> admitted model */ + const models = new Map(); + /** @type {Map} id -> generation of an in-flight load */ + const pending = new Map(); + let generation = 0; + let destroyed = false; + let lastEligibilityAt = -Infinity; + let eligible = new Set(); + let settling = 0; + /** Whether the models drawn now were built for the NVG/FLIR styles. */ + let irBoost = false; + const scratchHpr = new Cesium.HeadingPitchRoll(); + const scratchSphere = new Cesium.BoundingSphere(Cesium.Cartesian3.ZERO, 1); + const scratchCarto = new Cesium.Cartographic(); + const scratchPosition = new Cesium.Cartesian3(); + + function release(id) { + pending.delete(id); + const model = models.get(id); + if (!model) return; + models.delete(id); + if (!collection.isDestroyed()) collection.remove(model); + } + + function releaseAll() { + for (const id of models.keys()) release(id); + pending.clear(); + generation += 1; + } + + function preferences() { + return display?.() || null; + } + + function regime(prefs) { + if (!prefs?.models3d || !fleetModelSet()) return false; + const height = viewer.camera?.positionCartographic?.height ?? Infinity; + return height < policy.altitudeCeilingM; + } + + function shaderFor(item) { + if (irBoost) return IR_UNLIT_SHADER; + return item.spec.tinted && item.tint + ? fleetTintShader(item.tint) + : undefined; + } + + /** The clipping that keeps a model's part above its waterline. */ + function waterlineClipping() { + return new Cesium.ClippingPlaneCollection({ + // The planes sit in the model matrix's frame: its origin is the + // waterline and its Z axis the local vertical, so the plane z = 0 with + // its normal up keeps the hull above the water. + planes: [new Cesium.ClippingPlane(Cesium.Cartesian3.UNIT_Z, 0)], + edgeWidth: 0, + }); + } + + async function ensure(id, item, cap) { + const key = item.spec.key; + const current = models.get(id); + if (current && current._gevSpecKey === key) return; + if (current) release(id); + if (pending.has(id) || models.size + pending.size >= cap) return; + const loadGeneration = generation; + pending.set(id, loadGeneration); + let model; + try { + model = await createModel(item.spec, { + asynchronous: false, + minimumPixelSize: policy.minimumPixelSize, + scale: item.spec.scale, + customShader: shaderFor(item), + ...(irBoost + ? { + color: Cesium.Color.WHITE, + colorBlendMode: Cesium.ColorBlendMode.MIX, + colorBlendAmount: 1, + } + : {}), + ...(waterline ? { clippingPlanes: waterlineClipping() } : {}), + id: item.pickId ?? id, + }); + } catch { + if (pending.get(id) === loadGeneration) pending.delete(id); + return; + } + const stale = + destroyed || + pending.get(id) !== loadGeneration || + generation !== loadGeneration || + !eligible.has(id) || + models.has(id); + if (pending.get(id) === loadGeneration) pending.delete(id); + if (stale) { + model.destroy?.(); + return; + } + model.id = item.pickId ?? id; + model._gevSpecKey = key; + model._gevTint = item.spec.tinted && !irBoost ? item.tint : null; + model._gevIrBoost = irBoost; + // Admitted, not yet the visual: placement turns it on. + model.show = false; + collection.add(model); + models.set(id, model); + } + + function refreshEligibility(items, prefs) { + const all = prefs.models3dMode === 'all'; + const cap = all ? policy.capAll : policy.cap; + const addM = all ? policy.addAllM : policy.addM; + const keepM = all ? policy.keepAllM : policy.keepM; + const camera = viewer.camera; + const cameraPosition = camera.positionWC; + const cull = camera.frustum.computeCullingVolume( + cameraPosition, + camera.directionWC, + camera.upWC, + ); + const candidates = []; + for (const [id, item] of items) { + if (!item.position || item.show === false || !item.spec) continue; + const distanceSq = Cesium.Cartesian3.distanceSquared( + cameraPosition, + item.position, + ); + if (distanceSq > keepM * keepM) continue; + Cesium.Cartesian3.clone(item.position, scratchSphere.center); + candidates.push([ + id, + distanceSq, + cull.computeVisibility(scratchSphere) !== Cesium.Intersect.OUTSIDE, + ]); + } + candidates.sort((a, b) => a[1] - b[1]); + eligible = selectModelEligible(candidates, { + cap, + addDistSq: addM * addM, + isModeled: (id) => models.has(id), + }); + for (const id of models.keys()) if (!eligible.has(id)) release(id); + for (const id of pending.keys()) if (!eligible.has(id)) release(id); + for (const id of eligible) void ensure(id, items.get(id), cap); + } + + /** Where the model's origin stands: the item's point raised by the spec's elevation. */ + function placement(item) { + const lift = (item.spec.elevationM || 0) - (item.groundLiftM || 0); + if (!lift) return item.position; + const carto = Cesium.Cartographic.fromCartesian( + item.position, + Cesium.Ellipsoid.WGS84, + scratchCarto, + ); + if (!carto) return item.position; + return Cesium.Cartesian3.fromRadians( + carto.longitude, + carto.latitude, + carto.height + lift, + Cesium.Ellipsoid.WGS84, + scratchPosition, + ); + } + + return { + /** Ids whose model admission is current. */ + get eligible() { + return eligible; + }, + + /** + * Per-frame pass: re-derive eligibility at the policy's cadence, then + * place every admitted model and hand each item's visual to its model + * only once the model is ready and placed (`item.modelOwnsVisual`). + * @param {Map} items + * @param {number} nowMs + * @returns {boolean} Whether any model is drawn. + */ + frame(items, nowMs) { + if (destroyed) return false; + const prefs = preferences(); + if (!regime(prefs)) { + settling = 0; + if (models.size || pending.size) releaseAll(); + eligible = new Set(); + for (const item of items.values()) item.modelOwnsVisual = false; + return false; + } + // A model's look under NVG/FLIR is set when it is built (a custom + // shader on a ready model does not take), so a style change rebuilds. + const boost = prefs.irBoost === true; + if (boost !== irBoost) { + releaseAll(); + irBoost = boost; + lastEligibilityAt = -Infinity; + } + if (nowMs - lastEligibilityAt >= policy.intervalMs) { + lastEligibilityAt = nowMs; + refreshEligibility(items, prefs); + } + const occluder = horizonOccluder(viewer.camera); + let drawn = false; + settling = pending.size; + for (const [id, item] of items) { + const model = models.get(id); + item.modelOwnsVisual = false; + if (!model) continue; + if ( + !item.position || + item.show === false || + !occluder.isPointVisible(item.position) + ) { + model.show = false; + continue; + } + if (item.spec.key !== model._gevSpecKey) { + // A new type or size: the next eligibility pass loads it. + model.show = false; + lastEligibilityAt = -Infinity; + continue; + } + const tint = item.spec.tinted && !irBoost ? item.tint : null; + if (tint && !Cesium.Color.equals(tint, model._gevTint)) { + model.customShader = fleetTintShader(tint); + model._gevTint = tint; + } + fleetModelMatrix( + placement(item), + item.headingDeg, + item.spec, + model.modelMatrix, + scratchHpr, + ); + if (!model.ready) { + model.show = false; + settling += 1; + continue; + } + model.show = true; + item.modelOwnsVisual = true; + drawn = true; + } + for (const id of models.keys()) if (!items.has(id)) release(id); + return drawn; + }, + + /** + * Models loading or not yet ready after the last frame: the layer keeps + * frames coming while any are, so each one takes over as soon as it can. + */ + get settling() { + return settling; + }, + + /** + * Whether models are drawn now: 3D on, the set loaded and the camera + * below the altitude ceiling. A layer skips building items while not. + * @returns {boolean} + */ + active() { + return !destroyed && regime(preferences()); + }, + + /** The model drawing `id`, if one is admitted. */ + modelFor(id) { + return models.get(id) || null; + }, + + /** Show or hide every model (the layer's own visibility). */ + setVisible(show) { + if (!collection.isDestroyed()) collection.show = show; + }, + + /** Drop every model (3D off, layer disabled). */ + clear: releaseAll, + + /** Number of admitted models (tests and diagnostics). */ + get size() { + return models.size; + }, + + destroy() { + destroyed = true; + releaseAll(); + if (!viewer.isDestroyed?.() && !collection.isDestroyed()) + viewer.scene.primitives.remove(collection); + }, + }; +} diff --git a/src/data/fleetModelInstances.test.mjs b/src/data/fleetModelInstances.test.mjs new file mode 100644 index 000000000..1892e1751 --- /dev/null +++ b/src/data/fleetModelInstances.test.mjs @@ -0,0 +1,487 @@ +// The icon-to-model handoff the Vessels, Transit and Rail layers share +// (fleetModelInstances.js): models only under the DISPLAY 3D toggle and the +// altitude ceiling, at most `cap` of the nearest, each vehicle's icon the +// visual until its model is loaded, ready and placed, and a tinted model +// painted with its route colour. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import * as Cesium from 'cesium'; +import { + createFleetModel, + createFleetModelLayer, + fleetModelMatrix, + fleetTintShader, + FLEET_HEADING_OFFSET_DEG, +} from './fleetModelInstances.js'; +import { + loadFleetModelSet, + transitModelSpec, + vesselModelSpec, +} from './fleetModels.js'; +import { fetchPublicFile } from '../testSupport/fleetGlb.mjs'; + +const realInfo = console.info; +console.info = () => {}; +const set = await loadFleetModelSet({ fetchImpl: fetchPublicFile }); +console.info = realInfo; + +const POLICY = Object.freeze({ + altitudeCeilingM: 800_000, + minimumPixelSize: 16, + cap: 2, + capAll: 4, + addM: 5_000, + addAllM: 20_000, + keepM: 6_000, + keepAllM: 24_000, + intervalMs: 80, +}); + +const CENTRE = Cesium.Cartesian3.fromDegrees(-122.4, 37.8, 0); + +function fakeViewer(height = 1_000) { + const primitives = []; + const position = Cesium.Cartesian3.fromDegrees(-122.4, 37.8, height); + const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( + position, + new Cesium.Cartesian3(), + ); + const camera = { + positionWC: position, + positionCartographic: { height }, + directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()), + upWC: Cesium.Cartesian3.UNIT_Z, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; + return { + camera, + scene: { + primitives: { + add(p) { + primitives.push(p); + return p; + }, + remove(p) { + const i = primitives.indexOf(p); + if (i >= 0) primitives.splice(i, 1); + return i >= 0; + }, + }, + }, + primitives, + isDestroyed: () => false, + }; +} + +function fakeModel(options) { + return { + ...options, + ready: false, + show: true, + modelMatrix: new Cesium.Matrix4(), + destroyed: false, + destroy() { + this.destroyed = true; + }, + isDestroyed() { + return this.destroyed; + }, + update() {}, + }; +} + +/** A vehicle `metres` east of the camera's foot. */ +function itemAt(metres, spec, extra = {}) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(CENTRE); + const position = Cesium.Matrix4.multiplyByPoint( + enu, + new Cesium.Cartesian3(metres, 0, 0), + new Cesium.Cartesian3(), + ); + return { position, headingDeg: 90, spec, tint: null, pickId: null, ...extra }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +test('models load from the up-front set, never by URL', async () => { + const spec = vesselModelSpec(set, { type: '70', length: 200, beam: 32 }); + let options = null; + await createFleetModel( + spec, + { scale: spec.scale }, + { + set, + resolveAsset: (url) => `/base${url}`, + load: async (o) => { + options = o; + return {}; + }, + }, + ); + assert.ok(options.gltf instanceof Uint8Array, 'the loaded GLB bytes'); + assert.equal(options.gltf, set.bytes.get(spec.model)); + assert.equal(options.basePath, `/base${spec.url}`); + assert.equal(options.url, undefined, 'no fetch by URL'); + assert.deepEqual({ ...options.environmentMapOptions }, { enabled: false }); + await assert.rejects( + createFleetModel({ model: 'missing' }, {}, { set, load: async () => ({}) }), + /not loaded/, + ); +}); + +test('the icon stays the visual until the model is ready; only the nearest are drawn', async () => { + const viewer = fakeViewer(); + const display = { models3d: true, models3dMode: 'proximity' }; + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => display, + policy: POLICY, + createModel: async (spec, options) => { + const model = fakeModel({ spec, ...options }); + created.push(model); + return model; + }, + }); + const spec = transitModelSpec(set, { mode: 'tram' }); + const items = new Map([ + ['near', itemAt(100, spec, { pickId: 'near' })], + ['mid', itemAt(1_000, spec, { pickId: 'mid' })], + ['far', itemAt(4_000, spec, { pickId: 'far' })], + ]); + layer.frame(items, 0); + await flush(); + assert.equal(created.length, POLICY.cap, 'the cap holds'); + assert.deepEqual( + created.map((m) => m.id).sort(), + ['mid', 'near'], + 'the nearest are modelled', + ); + assert.equal(created[0].scale, spec.scale, 'drawn life size'); + assert.equal(created[0].minimumPixelSize, POLICY.minimumPixelSize); + + layer.frame(items, 100); + for (const item of items.values()) + assert.equal(item.modelOwnsVisual, false, 'not ready: the icon draws'); + assert.ok( + layer.settling > 0, + 'the layer asks for frames while models settle', + ); + + for (const model of created) model.ready = true; + layer.frame(items, 200); + assert.equal(items.get('near').modelOwnsVisual, true); + assert.equal(items.get('mid').modelOwnsVisual, true); + assert.equal(items.get('far').modelOwnsVisual, false); + assert.equal(layer.settling, 0); + + // A placed model stands on its item's point, nose along its heading. + const near = created.find((m) => m.id === 'near'); + const origin = Cesium.Matrix4.getTranslation( + near.modelMatrix, + new Cesium.Cartesian3(), + ); + assert.ok( + Cesium.Cartesian3.distance(origin, items.get('near').position) < 1e-6, + ); + + // 3D off: every model goes and every icon comes back. + display.models3d = false; + layer.frame(items, 300); + assert.equal(layer.size, 0); + for (const item of items.values()) assert.equal(item.modelOwnsVisual, false); + assert.ok( + created.every((m) => m.destroyed), + 'released models are destroyed', + ); + layer.destroy(); + assert.equal(viewer.primitives.length, 0); +}); + +test('models stay off above the altitude ceiling', async () => { + const viewer = fakeViewer(POLICY.altitudeCeilingM + 1); + let calls = 0; + const layer = createFleetModelLayer({ + viewer, + display: () => ({ models3d: true }), + policy: POLICY, + createModel: async (spec, options) => { + calls += 1; + return fakeModel(options); + }, + }); + assert.equal(layer.active(), false); + layer.frame(new Map([['a', itemAt(0, transitModelSpec(set, {}))]]), 0); + await flush(); + assert.equal(calls, 0); + layer.destroy(); +}); + +test('a tinted model is painted with its route colour; an untinted one is not', async () => { + const viewer = fakeViewer(); + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => ({ models3d: true }), + policy: POLICY, + createModel: async (spec, options) => { + const model = fakeModel(options); + created.push(model); + return model; + }, + }); + const red = Cesium.Color.RED; + const tram = transitModelSpec(set, { mode: 'tram' }); + const ship = vesselModelSpec(set, { type: '70', length: 180 }); + assert.equal(tram.tinted, true); + assert.equal(ship.tinted, false); + layer.frame( + new Map([ + ['tram', itemAt(10, tram, { pickId: 'tram', tint: red })], + ['ship', itemAt(20, ship, { pickId: 'ship', tint: red })], + ]), + 0, + ); + await flush(); + const byId = Object.fromEntries(created.map((m) => [m.id, m])); + assert.equal(byId.tram.customShader, fleetTintShader(red)); + assert.equal(byId.ship.customShader, undefined); + assert.equal( + fleetTintShader(red), + fleetTintShader(Cesium.Color.RED), + 'one shader per colour', + ); + layer.destroy(); +}); + +test('an elevated vehicle is raised by its type elevation', async () => { + const viewer = fakeViewer(); + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => ({ models3d: true }), + policy: POLICY, + createModel: async (spec, options) => { + const model = fakeModel({ ...options, ready: true }); + model.ready = true; + created.push(model); + return model; + }, + }); + const monorail = transitModelSpec(set, { mode: 'monorail' }); + assert.equal(monorail.elevationM, set.types.transit.elevation.monorail); + const item = itemAt(10, monorail, { pickId: 'm' }); + const items = new Map([['m', item]]); + layer.frame(items, 0); + await flush(); + layer.frame(items, 100); + const origin = Cesium.Matrix4.getTranslation( + created[0].modelMatrix, + new Cesium.Cartesian3(), + ); + const lifted = Cesium.Cartographic.fromCartesian(origin).height; + const ground = Cesium.Cartographic.fromCartesian(item.position).height; + assert.ok(Math.abs(lifted - ground - monorail.elevationM) < 1e-3); + layer.destroy(); +}); + +test('a vessel stretched to its reported beam keeps its length scale on the model', () => { + const spec = vesselModelSpec(set, { type: '70', length: 200, beam: 60 }); + assert.ok(spec.scaleXYZ[1] > spec.scaleXYZ[0], 'a wide hull for its length'); + const matrix = fleetModelMatrix( + CENTRE, + 0, + spec, + new Cesium.Matrix4(), + new Cesium.HeadingPitchRoll(), + ); + const scale = Cesium.Matrix4.getScale(matrix, new Cesium.Cartesian3()); + // Cesium's axis correction puts the beam on local X, the length on local Y. + assert.ok(Math.abs(scale.x - spec.scaleXYZ[1] / spec.scaleXYZ[0]) < 1e-9); + assert.ok(Math.abs(scale.y - 1) < 1e-9); + assert.equal(FLEET_HEADING_OFFSET_DEG, 180, 'fleet models are built nose -X'); +}); + +test('Vessels, Transit and Rail draw fleet models under the shared 3D toggle', () => { + for (const file of [ + 'src/layers/vessels/rendering.js', + 'src/layers/transit/rendering.js', + 'src/layers/rail/rendering.js', + ]) { + const source = fs.readFileSync( + new URL(`../../${file}`, import.meta.url), + 'utf8', + ); + assert.match( + source, + /createFleetModelLayer\(/, + `${file}: draws fleet models`, + ); + assert.match( + source, + /loadFleetModelSet\(/, + `${file}: loads the set up front`, + ); + assert.match( + source, + /modelOwnsVisual/, + `${file}: hands its icon to the model`, + ); + } + for (const file of [ + 'src/app/layers/aisLiveVessels.js', + 'src/app/layers/transit.js', + 'src/app/layers/rail.js', + ]) { + const source = fs.readFileSync( + new URL(`../../${file}`, import.meta.url), + 'utf8', + ); + assert.match( + source, + /fleet: fleetModelServices\(/, + `${file}: given the fleet service`, + ); + } + const catalog = fs.readFileSync( + new URL('../app/constructCatalog.js', import.meta.url), + 'utf8', + ); + assert.equal( + catalog.match(/displayParams: \(\) => flights\.getParams\(\)/g)?.length, + 4, + 'Local ADS-B, Vessels, Transit and Rail follow the Flights 3D toggle', + ); +}); + +/** A layer whose models are all ready as soon as they are created. */ +function readyLayer({ display, waterline = false } = {}) { + const viewer = fakeViewer(); + const created = []; + const layer = createFleetModelLayer({ + viewer, + display: () => display, + policy: POLICY, + waterline, + createModel: async (spec, options) => { + const model = fakeModel(options); + model.ready = true; + created.push(model); + return model; + }, + }); + return { layer, created }; +} + +test('a model stands on the floor its icon is lifted above', async () => { + const { layer, created } = readyLayer({ display: { models3d: true } }); + const tram = transitModelSpec(set, { mode: 'tram' }); + const item = itemAt(10, tram, { pickId: 't', groundLiftM: 1.5 }); + const items = new Map([['t', item]]); + layer.frame(items, 0); + await flush(); + layer.frame(items, 100); + const origin = Cesium.Matrix4.getTranslation( + created[0].modelMatrix, + new Cesium.Cartesian3(), + ); + const drop = + Cesium.Cartographic.fromCartesian(item.position).height - + Cesium.Cartographic.fromCartesian(origin).height; + assert.ok(Math.abs(drop - 1.5) < 1e-3, `dropped ${drop} m`); + layer.destroy(); +}); + +test('under NVG/FLIR models are rebuilt unlit white, and rebuilt again on exit', async () => { + const display = { models3d: true, irBoost: false }; + const { layer, created } = readyLayer({ display }); + const tram = transitModelSpec(set, { mode: 'tram' }); + const items = new Map([ + ['t', itemAt(10, tram, { pickId: 't', tint: Cesium.Color.RED })], + ]); + layer.frame(items, 0); + await flush(); + assert.equal(created[0].customShader, fleetTintShader(Cesium.Color.RED)); + display.irBoost = true; + layer.frame(items, 100); + assert.ok(created[0].destroyed, 'the lit model is released'); + await flush(); + const boosted = created[1]; + assert.equal(boosted.customShader.lightingModel, Cesium.LightingModel.UNLIT); + assert.ok(Cesium.Color.equals(boosted.color, Cesium.Color.WHITE)); + assert.equal(boosted.colorBlendAmount, 1); + display.irBoost = false; + layer.frame(items, 200); + await flush(); + assert.ok(boosted.destroyed); + assert.equal(created[2].customShader, fleetTintShader(Cesium.Color.RED)); + layer.destroy(); +}); + +test('a waterline layer clips each model at its origin, keeping the part above', async () => { + const { layer, created } = readyLayer({ + display: { models3d: true }, + waterline: true, + }); + const ship = vesselModelSpec(set, { type: '70', length: 200, beam: 32 }); + layer.frame(new Map([['s', itemAt(10, ship, { pickId: 's' })]]), 0); + await flush(); + const planes = created[0].clippingPlanes; + assert.ok(planes instanceof Cesium.ClippingPlaneCollection); + assert.equal(planes.length, 1); + const plane = planes.get(0); + assert.equal(plane.distance, 0); + assert.ok(Cesium.Cartesian3.equals(plane.normal, Cesium.Cartesian3.UNIT_Z)); + assert.equal(planes.unionClippingRegions, false); + // Cesium keeps a point where normal · p + distance >= 0: above the waterline. + const above = Cesium.Cartesian3.dot( + plane.normal, + new Cesium.Cartesian3(0, 0, 3), + ); + const below = Cesium.Cartesian3.dot( + plane.normal, + new Cesium.Cartesian3(0, 0, -3), + ); + assert.ok(above + plane.distance > 0 && below + plane.distance < 0); + layer.destroy(); + + const plain = readyLayer({ display: { models3d: true } }); + plain.layer.frame( + new Map([['t', itemAt(10, transitModelSpec(set, { mode: 'tram' }))]]), + 0, + ); + await flush(); + assert.equal(plain.created[0].clippingPlanes, undefined, 'others unclipped'); + plain.layer.destroy(); +}); + +test('a vehicle drawn as its model keeps its card and its detection entry', () => { + const read = (file) => + fs.readFileSync(new URL(`../../${file}`, import.meta.url), 'utf8'); + assert.match( + read('src/layers/transit/selection.js'), + /marker\?\.show !== false \|\| entry\.modelOwnsVisual === true/, + ); + assert.match( + read('src/layers/rail/selection.js'), + /entry\.marker\?\.show !== false \|\| entry\.modelOwnsVisual === true/, + ); + assert.match( + read('src/layers/transit/queries.js'), + /marker\.show === false && entry\.modelOwnsVisual !== true/, + ); + assert.match( + read('src/layers/vessels/queries.js'), + /!visual\.billboard\.show &&\s*!visual\.modelOwnsVisual/, + ); + assert.match( + read('src/layers/transit/rendering.js'), + /entry\.mode === 'unknown'/, + 'a vehicle of unknown mode keeps its icon', + ); +}); diff --git a/src/data/fleetModels.js b/src/data/fleetModels.js new file mode 100644 index 000000000..69ab2a678 --- /dev/null +++ b/src/data/fleetModels.js @@ -0,0 +1,609 @@ +/** + * @module fleetModels + * @description The project's procedural fleet models (`public/models/fleet/`): + * which model draws an aircraft, a vessel or a transit or rail vehicle, and at + * what size, from the set's `catalog.json` (each model's file, bounds, + * length, beam, clearance and icon class) and `types.json` (which model + * draws which type, and the fallbacks). + * + * Every model is metres, glTF Y-up, nose toward local -X, origin at the body + * centre (an aircraft's fuselage axis, a vessel's waterline, a road or rail + * vehicle's road or rail top), and is drawn life size: a type's published + * length over the model's built length is its scale. + * + * An aid to navigation (a buoy, beacon, mark, RACON or light) is drawn as + * the set's buoy of its IALA mark, by the mark its type names. + * + * The resolvers are plain functions over the parsed JSON; `loadFleetModelSet` + * fetches the JSON and every GLB once, up front, so no model is fetched + * mid-session. + */ + +import { classifyAircraft } from './aircraftClass.js'; +import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; +import { isAidToNavigation } from './vesselTypes.js'; + +/** Where the model set is served, relative to the application base. */ +export const FLEET_MODEL_BASE = '/models/fleet/'; + +/** + * Longest and widest hulls afloat, metres (Seawise Giant 458 m; the TI + * tankers 68 m abeam). AIS gives the bow and stern from the antenna in 9 bits + * and each side in 6, their largest values (511, 63) meaning "that or more" + * or a set never configured: a 30 m tug reported 511 m long would be drawn 17 + * times its size. + */ +const MAX_HULL_LENGTH_M = 460; +const MAX_HULL_BEAM_M = 75; + +/** The model a known aircraft class draws when its type has no entry. */ +const CLASS_MODELS = Object.freeze({ + light: 'c172', + glider: 'glider', + turboprop: 'at72', + widebody: 'b789', + quadjet: 'b744', + helicopter: 'generic_heli', + fastjet: 't38', + bizjet: 'c56x', + uav: 'mq9', +}); + +/** OpenSky's integer aircraft category (state vector index 17) → ADS-B emitter category. */ +const OPENSKY_EMITTER_CATEGORY = Object.freeze({ + 2: 'A1', + 3: 'A2', + 4: 'A3', + 5: 'A4', + 6: 'A5', + 7: 'A6', + 8: 'A7', + 9: 'B1', + 10: 'B2', + 11: 'B3', + 12: 'B4', + 14: 'B6', + 15: 'B7', + 16: 'C1', + 17: 'C2', + 18: 'C3', + 19: 'C4', + 20: 'C5', +}); + +/** A text vessel type (as some AIS sources name it) → a representative AIS ship-type code. */ +const TEXT_SHIP_TYPES = [ + [/tanker|lng|lpg|gas carrier/i, 80], + [/cargo|container|bulk|carrier|freighter|ro-?ro/i, 70], + [/passenger|ferry|cruise/i, 60], + [/high.?speed/i, 40], + [/fishing|trawler/i, 30], + [/tug|towing/i, 52], + [/pilot/i, 50], + [/search|rescue|\bsar\b/i, 51], + [/dredg/i, 33], + [/military|naval|navy/i, 35], + [/law|police|patrol|coast ?guard/i, 55], + [/sail/i, 36], + [/pleasure|yacht/i, 37], +]; + +/** + * The buoy an aid to navigation is drawn as, by the mark its type names + * ("Cardinal Mark N", "Beacon, Port hand", "Safe Water"): the first pattern + * that matches. Beacons, lights, RACONs and reference points with no mark of + * their own are the special mark. + */ +const AID_MODELS = [ + [/cardinal\b.*\bn(orth)?$/i, 'buoy_cardinal_north'], + [/cardinal\b.*\be(ast)?$/i, 'buoy_cardinal_east'], + [/cardinal\b.*\bs(outh)?$/i, 'buoy_cardinal_south'], + [/cardinal\b.*\bw(est)?$/i, 'buoy_cardinal_west'], + [/isolated danger/i, 'buoy_isolated_danger'], + [/safe water/i, 'buoy_safe_water'], + [/port hand/i, 'buoy_lateral_port'], + [/starboard hand/i, 'buoy_lateral_starboard'], + [/special mark|light vessel|lanby/i, 'buoy_special'], + [ + /^(beacon|light\b|leading light|racon|reference point|navigation aid)/i, + 'buoy_special', + ], +]; + +/** The buoy an aid to navigation whose type names no mark is drawn as. */ +export const DEFAULT_AID_MODEL = 'can_red'; + +/** + * The buoy model of an aid to navigation's type, else undefined when the + * type names no mark. + * @param {string|null} [type] + * @returns {string|undefined} + */ +export function aidToNavigationModel(type) { + const text = String(type ?? '').trim(); + for (const [pattern, model] of AID_MODELS) + if (pattern.test(text)) return model; + return undefined; +} + +/** + * The model for an aircraft of ICAO type `type` (A320, B738, R44), ADS-B + * emitter category `category` and callsign `callsign`: the first of the + * type's callsign rules (`aircraft_callsign`) the callsign matches, else the + * type's, else the category's, else `default_aircraft`. An airport vehicle + * (type ZVEH) is a fire truck, tug or van by its callsign, else a car. + * @param {object} types Parsed `types.json`. + * @param {string} [type] + * @param {string} [category] + * @param {string} [callsign] + * @returns {{model: string, length: number|null}} + */ +export function resolveAircraft(types, type, category, callsign) { + const t = type?.trim().toUpperCase(); + const index = aircraftCallsignRule(types, t, callsign); + if (index >= 0) { + const rule = types.aircraft_callsign[t][index]; + return { model: rule.model, length: rule.length }; + } + const [model, length] = + (t && (types.aircraft[t] ?? types.helicopters[t])) || + (category && types.categories[category]) || + types.default_aircraft; + return { model, length }; +} + +/** + * A callsign as the rules read it: upper case, spaces removed. + * @param {string} callsign + * @returns {string} + */ +function callsignKey(callsign) { + return String(callsign).toUpperCase().replace(/\s+/g, ''); +} + +/** + * Which of type `type`'s callsign rules (`types.json`'s + * `aircraft_callsign[type]`, each `{prefixes, model, length}`, first match + * wins) `callsign` matches: its index, -1 when none does or the type has + * none. A callsign matches a rule when, upper-cased with spaces removed, it + * starts with one of the rule's prefixes. + * @param {object} types Parsed `types.json`. + * @param {string|null} [type] ICAO type. + * @param {string|null} [callsign] + * @returns {number} + */ +export function aircraftCallsignRule(types, type, callsign) { + const t = String(type ?? '') + .trim() + .toUpperCase(); + const rules = t ? types.aircraft_callsign?.[t] : undefined; + if (!rules || !callsign) return -1; + const c = callsignKey(callsign); + return rules.findIndex((rule) => + rule.prefixes.some((prefix) => c.startsWith(callsignKey(prefix))), + ); +} + +/** + * Whether an aircraft of ICAO type `type` picks its model by callsign. + * @param {object} types Parsed `types.json`. + * @param {string|null} [type] + * @returns {boolean} + */ +export function aircraftModelByCallsign(types, type) { + const t = String(type ?? '') + .trim() + .toUpperCase(); + return Boolean(t && types.aircraft_callsign?.[t]); +} + +/** + * A reported length as one a hull can have, else undefined (drawn as built). + * @param {number} [length] + * @returns {number|undefined} + */ +export function hullLength(length) { + return length !== undefined && + length !== null && + length > 0 && + length <= MAX_HULL_LENGTH_M + ? length + : undefined; +} + +/** + * A reported beam as one a hull can have, else undefined. + * @param {number} [beam] + * @returns {number|undefined} + */ +export function hullBeam(beam) { + return beam !== undefined && + beam !== null && + beam > 0 && + beam <= MAX_HULL_BEAM_M + ? beam + : undefined; +} + +/** + * The model for a vessel of AIS ship type `shipType` and the reported length + * and beam (metres) and name: the first rule that matches in the first type + * range that has one, else `default_vessel`. Drawn at the reported length; + * when it is unknown (most live feeds send none), chosen and drawn at the + * range's `typical_len`, else as built. + * @param {object} types Parsed `types.json`. + * @param {number} [shipType] + * @param {number} [length] + * @param {number} [beam] + * @param {string} [name] + * @returns {{model: string, length: number|null}} + */ +export function resolveVessel(types, shipType, length, beam, name) { + const L = hullLength(length) ?? 0; + const B = hullBeam(beam) ?? 0; + if (shipType !== undefined && shipType !== null) { + for (const group of types.vessels) { + if (!(shipType >= group.types[0] && shipType <= group.types[1])) continue; + const len = L > 0 ? L : (group.typical_len ?? 0); + const drawn = len > 0 ? len : null; + for (const rule of group.rules) { + if (rule.min_len && len < rule.min_len) continue; + if (rule.max_len && len > rule.max_len) continue; + if (rule.name_re && !new RegExp(rule.name_re).test(name ?? '')) + continue; + if (rule.max_bl && !(B > 0 && L > 0 && B / L <= rule.max_bl)) continue; + return { model: rule.model, length: drawn }; + } + } + } + return { model: types.default_vessel, length: L > 0 ? L : null }; +} + +/** + * The model for a public transport vehicle: a rail category and mode + * (`transit`/`local`/`regional`/`national`/`international` × `rail`/`metro`/ + * `tram`), else a GTFS `route_type` (basic 0-12 or extended 100-1507), else a + * mode name (`bus`, `tram`, `subway`, `rail`, `ferry`, `light_rail`, …), + * else `transit.default`. Each comes with the length to draw it at. + * @param {object} types Parsed `types.json`. + * @param {{railCategory?: string, railMode?: string, routeType?: number|string, + * mode?: string}} vehicle + * @returns {{model: string, length: number|null}} + */ +export function resolveTransit( + types, + { railCategory, railMode, routeType, mode } = {}, +) { + const transit = types.transit; + const rail = railCategory ? transit.rail[railCategory] : null; + const routeKey = + routeType === undefined || routeType === null || routeType === '' + ? null + : String(Number(routeType)); + const [model, length] = + (rail && (rail[railMode || 'rail'] ?? rail.rail)) || + (routeKey && transit.route_types[routeKey]) || + (mode && transit.modes[mode]) || + transit.default; + return { model, length }; +} + +/** + * An AIS ship type as its numeric code: a code as given ("70"), or the code + * of the family a text type names ("Cargo" → 70); undefined when it names + * none. + * @param {string|number|null|undefined} type + * @returns {number|undefined} + */ +export function aisShipTypeCode(type) { + const text = String(type ?? '').trim(); + if (!text) return undefined; + if (/^\d+$/.test(text)) { + const code = Number(text); + return code > 0 && code < 100 ? code : undefined; + } + for (const [pattern, code] of TEXT_SHIP_TYPES) + if (pattern.test(text)) return code; + return undefined; +} + +/** + * An aircraft category as an ADS-B emitter category: an emitter string as + * given ("A3"), or OpenSky's integer category mapped to one. + * @param {string|number|null|undefined} category + * @returns {string|undefined} + */ +export function emitterCategory(category) { + if (typeof category === 'number') + return OPENSKY_EMITTER_CATEGORY[category] || undefined; + const text = String(category ?? '') + .trim() + .toUpperCase(); + if (/^\d+$/.test(text)) return OPENSKY_EMITTER_CATEGORY[Number(text)]; + return /^[A-D][0-7]$/.test(text) ? text : undefined; +} + +/** + * The scale of model `model` drawn `length` metres long, along the model's + * length, beam and height. A vessel's reported beam stretches it sideways; + * its height follows the mean of the two. + * @param {object} model Catalog entry. + * @param {number|null} length + * @param {number} [beam] A vessel's reported beam. + * @returns {[number, number, number]} + */ +export function placeScale(model, length, beam) { + if (length === null || length === undefined || !(model.length > 0)) + return [1, 1, 1]; + const sx = length / model.length; + const b = hullBeam(beam); + const sy = b !== undefined && model.beam > 0 ? b / model.beam : sx; + return [sx, sy, (sx + sy) / 2]; +} + +/** Half the diagonal of a model's bounds: the radius Cesium bounds it by. */ +function boundingRadius(model) { + const [x0, y0, z0] = model.min; + const [x1, y1, z1] = model.max; + return Math.hypot(x1 - x0, y1 - y0, z1 - z0) / 2; +} + +/** + * The drawing of model `name` at `length` metres: what the layers place. + * + * `scale` is the uniform scale (along the model's length) a Cesium Model + * takes; `scaleXYZ` the model's length, beam and height scales, for a vessel + * stretched to its reported beam. `clearanceM` is how far the model reaches + * below its origin at that scale (wheels below an aircraft's axis, a keel + * below the waterline): a grounded aircraft stands its origin that far up. + * `elevationM` lifts a vehicle off the route it follows (a monorail, a + * gondola). `tinted` models carry the `_TINT` vertex attribute marking where + * a route colour applies. `nativeRadiusM` (unscaled), `visualCenterNative` + * and `trailAnchorNative` (raw glTF axes, unscaled) serve the aircraft + * layers' pixel-size, bracket and trail geometry. + * @param {{catalog: object, types: object}} set + * @param {string} name Catalog model name. + * @param {number|null} length Metres along the body; null draws it as built. + * @param {{beam?: number}} [options] + * @returns {object} + */ +export function fleetModelSpec(set, name, length, { beam } = {}) { + const model = set.catalog[name]; + // Ground vehicles, balloons and obstacles are built at the size their type + // names (a tower's 60 m is its height, not a length to scale to). + const drawnLength = model.category === 'other' ? null : length; + const scaleXYZ = placeScale(model, drawnLength, beam); + const elevationM = set.types.transit?.elevation?.[name] || 0; + return Object.freeze({ + model: name, + url: FLEET_MODEL_BASE + model.file, + category: model.category, + icon: model.icon, + length: model.length * scaleXYZ[0], + scale: scaleXYZ[0], + scaleXYZ: Object.freeze(scaleXYZ), + clearanceM: model.clearance * scaleXYZ[2], + elevationM, + tinted: model.category === 'transit', + nativeRadiusM: boundingRadius(model), + visualCenterNative: CENTRED, + trailAnchorNative: FLEET_TRAIL_ANCHORS[name] || CENTRED, + key: `${name}@${scaleXYZ.map((s) => s.toFixed(4)).join('x')}`, + }); +} + +const CENTRED = Object.freeze([0, 0, 0]); + +/** `resolved` drawn from `set`, its model replaced by `fallback` when the catalog lacks it. */ +function specOf(set, resolved, fallback, options) { + const known = set.catalog[resolved.model] ? resolved : fallback(resolved); + return fleetModelSpec(set, known.model, known.length, options); +} + +/** + * The model an aircraft is drawn with. The type's callsign rules (an airport + * vehicle by its callsign), then the type's entry; then, for a + * type the map lacks, the model of its class (a fast jet, a helicopter, a + * glider) when the type names one; then the emitter category's; then + * `default_aircraft`. + * @param {{catalog: object, types: object}} set + * @param {{typeCode?: string|null, type?: string|null, + * category?: string|number|null, callsign?: string|null}} aircraft + * @returns {object} {@link fleetModelSpec} + */ +export function aircraftModelSpec( + set, + { typeCode, type, category, callsign } = {}, +) { + const { types, catalog } = set; + const code = String(typeCode ?? type ?? '') + .trim() + .toUpperCase(); + const emitter = emitterCategory(category); + const sign = callsign ? String(callsign) : undefined; + let resolved = resolveAircraft(types, code, emitter, sign); + // A callsign rule names the model outright; a type's class only stands in + // for a type the map lacks. + const ruled = aircraftCallsignRule(types, code, sign) >= 0; + const typed = code && (types.aircraft[code] || types.helicopters[code]); + if (!ruled && !typed && code) { + const model = CLASS_MODELS[classifyAircraft({ typeCode: code })]; + if (model && catalog[model]) + resolved = { model, length: catalog[model].length }; + } + return specOf(set, resolved, () => ({ + model: types.default_aircraft[0], + length: types.default_aircraft[1], + })); +} + +/** + * The model a vessel is drawn with, from its AIS ship type (a code or a text + * family), reported length and beam (metres) and name. An aid to navigation + * ({@link isAidToNavigation}: its type names a buoy, beacon, mark, RACON or + * light, or its MMSI is an aid's) is the buoy of its mark + * ({@link aidToNavigationModel}), else {@link DEFAULT_AID_MODEL}, drawn as + * built and flagged `fixed`: it has no heading to turn to. A virtual aid + * (`virtual`) is broadcast where nothing physical stands, so it has no model. + * @param {{catalog: object, types: object}} set + * @param {{type?: string|number|null, mmsi?: string|number|null, + * virtual?: boolean, length?: number|null, beam?: number|null, + * name?: string|null}} vessel + * @returns {object|null} {@link fleetModelSpec}, with `fixed` for an aid; + * null for a virtual aid + */ +export function vesselModelSpec( + set, + { type, mmsi, virtual, length, beam, name } = {}, +) { + if (virtual === true) return null; + if (isAidToNavigation({ type, mmsi })) { + const model = aidToNavigationModel(type); + const known = model && set.catalog[model] ? model : DEFAULT_AID_MODEL; + if (set.catalog[known]) + return Object.freeze({ + ...fleetModelSpec(set, known, null), + fixed: true, + }); + } + // A beam counts only beside a reported length: a vessel drawn at its type's + // typical length keeps the model's proportions. + const reported = hullLength(length ?? undefined); + const hullWidth = reported === undefined ? undefined : (beam ?? undefined); + const resolved = resolveVessel( + set.types, + aisShipTypeCode(type), + reported, + hullWidth, + name ?? undefined, + ); + return specOf( + set, + resolved, + (r) => ({ model: set.types.default_vessel, length: r.length }), + { beam: hullWidth }, + ); +} + +/** + * The model a transit or rail vehicle is drawn with ({@link resolveTransit}). + * @param {{catalog: object, types: object}} set + * @param {object} vehicle + * @returns {object} {@link fleetModelSpec} + */ +export function transitModelSpec(set, vehicle) { + return specOf(set, resolveTransit(set.types, vehicle), () => ({ + model: set.types.transit.default[0], + length: set.types.transit.default[1], + })); +} + +/** + * Every model the type map names, defaults and fallbacks included. + * @param {object} types Parsed `types.json`. + * @returns {Set} + */ +export function namedFleetModels(types) { + const pairs = (map) => Object.values(map).map(([model]) => model); + const transit = types.transit; + return new Set([ + ...pairs(types.aircraft), + ...pairs(types.helicopters), + ...pairs(types.categories), + ...Object.values(types.aircraft_callsign ?? {}).flatMap((rules) => + rules.map((rule) => rule.model), + ), + ...types.vessels.flatMap((group) => group.rules.map((rule) => rule.model)), + types.default_aircraft[0], + types.default_vessel, + ...pairs(transit.modes), + ...pairs(transit.route_types), + ...Object.values(transit.rail).flatMap(pairs), + transit.default[0], + ...Object.keys(transit.elevation), + ...Object.values(CLASS_MODELS), + ...AID_MODELS.map(([, model]) => model), + DEFAULT_AID_MODEL, + ]); +} + +let loading = null; +let loaded = null; + +/** + * The model set once {@link loadFleetModelSet} has loaded it, else null. + * @returns {object|null} + */ +export function fleetModelSet() { + return loaded; +} + +/** + * Fetch the model set once for every layer: `catalog.json`, `types.json` and + * every GLB the catalog names, all up front, so no model is fetched while a + * session runs. Resolves to `{catalog, types, bytes, loadMs, totalBytes}`, + * `bytes` holding each model's GLB by name; a GLB that fails to load is left + * out (that model then draws nothing and its entities stay icons). + * @param {{resolveAsset?: (url: string) => string, + * fetchImpl?: typeof fetch, now?: () => number}} [options] + * @returns {Promise} + */ +export function loadFleetModelSet({ + resolveAsset = (url) => url, + fetchImpl = globalThis.fetch, + now = () => globalThis.performance?.now?.() ?? Date.now(), +} = {}) { + if (loading) return loading; + const started = now(); + const json = async (name) => { + const response = await fetchImpl(resolveAsset(FLEET_MODEL_BASE + name)); + if (!response.ok) throw new Error(`${name}: HTTP ${response.status}`); + return response.json(); + }; + loading = (async () => { + const [catalog, types] = await Promise.all([ + json('catalog.json'), + json('types.json'), + ]); + const bytes = new Map(); + let totalBytes = 0; + await Promise.all( + Object.entries(catalog).map(async ([name, model]) => { + try { + const response = await fetchImpl( + resolveAsset(FLEET_MODEL_BASE + model.file), + ); + if (!response.ok) return; + const glb = new Uint8Array(await response.arrayBuffer()); + bytes.set(name, glb); + totalBytes += glb.byteLength; + } catch { + /* that model draws nothing; its entities stay icons */ + } + }), + ); + loaded = Object.freeze({ + catalog, + types, + bytes, + loadMs: now() - started, + totalBytes, + }); + console.info( + `[Fleet] ${bytes.size}/${Object.keys(catalog).length} models ` + + `(${(totalBytes / 1048576).toFixed(2)} MB) loaded in ${Math.round(loaded.loadMs)} ms`, + ); + return loaded; + })(); + // A failed catalog load may be retried by the next layer that starts. + loading.catch(() => { + loading = null; + }); + return loading; +} + +/** Forget the loaded set (tests). */ +export function resetFleetModelSet() { + loading = null; + loaded = null; +} diff --git a/src/data/fleetModels.test.mjs b/src/data/fleetModels.test.mjs new file mode 100644 index 000000000..d48e744c1 --- /dev/null +++ b/src/data/fleetModels.test.mjs @@ -0,0 +1,596 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + DEFAULT_AID_MODEL, + FLEET_MODEL_BASE, + aidToNavigationModel, + aircraftModelSpec, + aisShipTypeCode, + emitterCategory, + fleetModelSet, + fleetModelSpec, + hullBeam, + hullLength, + loadFleetModelSet, + namedFleetModels, + placeScale, + resetFleetModelSet, + resolveAircraft, + resolveTransit, + resolveVessel, + transitModelSpec, + vesselModelSpec, +} from './fleetModels.js'; +import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; +import { + centrelineSegments, + distanceToSurface, + fleetMesh, + measuredTrailAnchor, + readGlb, + vertexBounds, +} from '../testSupport/fleetGlb.mjs'; + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../..', +); +const DIR = path.join(ROOT, 'public', 'models', 'fleet'); +const json = (name) => + JSON.parse(fs.readFileSync(path.join(DIR, name), 'utf8')); +const catalog = json('catalog.json'); +const types = json('types.json'); +const set = { catalog, types }; + +// A slice of the set's types.json and catalog.json, as the resolver's own +// reference tests use, so these read independently of later model changes. +const sliceTypes = { + aircraft: { + A320: ['a320', 37.57], + A21N: ['a320', 44.51], + A319: ['a320', 33.84], + B38M: ['b738', 39.52], + ZVEH: ['car', 4.61], + GRND: ['radio_mast', 1.9], + }, + helicopters: { EC35: ['ec35', 10.2] }, + aircraft_callsign: { + ZVEH: [ + { prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }, + { prefixes: ['PUSH', 'TUG'], model: 'pushback', length: 6 }, + { prefixes: ['DEICER', 'SQUID', 'SQB'], model: 'van', length: 5.51 }, + ], + }, + categories: { A1: ['c172', 8.3] }, + vessels: [ + { + types: [70, 79], + typical_len: 300, + rules: [ + { min_len: 70, max_len: 140, max_bl: 0.12, model: 'inland_cargo' }, + { + name_re: '\\b(LEADER|HIGHWAY|ACE|GLOVIS|HOEGH|SPIRIT OF)\\b', + min_len: 150, + model: 'car_carrier', + }, + { min_len: 250, model: 'container_ship' }, + { model: 'general_cargo' }, + ], + }, + { types: [31, 32], rules: [{ model: 'tug' }] }, + { types: [52, 52], rules: [{ model: 'tug' }] }, + { types: [0, 99], rules: [{ model: 'generic_vessel' }] }, + ], + default_aircraft: ['generic_plane', 36], + default_vessel: 'generic_vessel', +}; +const model = (category, file, length, beam, clearance) => ({ + category, + file, + min: [0, 0, 0], + max: [0, 0, 0], + length, + beam, + clearance, + tris: 1, + icon: 'jet', +}); +const sliceCatalog = { + a320: model('aircraft', 'aircraft/a320.glb', 37.57, 35.6, 3.063), + container_ship: model('ships', 'ships/container_ship.glb', 309.9, 48, 13.9), +}; + +test('aircraft resolve by type, then emitter category, then the generic plane', () => { + assert.deepEqual(resolveAircraft(sliceTypes, 'A320'), { + model: 'a320', + length: 37.57, + }); + assert.deepEqual(resolveAircraft(sliceTypes, ' b38m '), { + model: 'b738', + length: 39.52, + }); + assert.equal(resolveAircraft(sliceTypes, 'EC35').model, 'ec35'); + assert.deepEqual(resolveAircraft(sliceTypes, 'ZZZZ', 'A1'), { + model: 'c172', + length: 8.3, + }); + assert.deepEqual(resolveAircraft(sliceTypes, undefined, 'Q9'), { + model: 'generic_plane', + length: 36, + }); +}); + +test('airport vehicles (ZVEH) by callsign, first rule wins, else the type: spaces and case ignored', () => { + const zveh = (callsign) => + resolveAircraft(sliceTypes, 'ZVEH', undefined, callsign); + assert.deepEqual(zveh('FIRE 2'), { model: 'fire_truck', length: 11.01 }); + assert.deepEqual(resolveAircraft(sliceTypes, 'zveh', undefined, ' fire2 '), { + model: 'fire_truck', + length: 11.01, + }); + assert.equal(zveh('PUSH 14').model, 'pushback'); + assert.equal(zveh('TUG3').model, 'pushback'); + assert.deepEqual(zveh('DEICER 4'), { model: 'van', length: 5.51 }); + assert.equal(zveh('SQUID20').model, 'van'); + assert.equal(zveh('SQB9').model, 'van'); + assert.equal(zveh('S Q B 9').model, 'van'); + assert.deepEqual(zveh('LEADER 7'), { model: 'car', length: 4.61 }); + assert.deepEqual(zveh(undefined), { model: 'car', length: 4.61 }); + // Callsign rules belong to their type: a fire-named airliner is still an airliner. + assert.equal( + resolveAircraft(sliceTypes, 'A320', undefined, 'FIRE1').model, + 'a320', + ); + assert.deepEqual(resolveAircraft(sliceTypes, 'GRND', undefined, 'RX5'), { + model: 'radio_mast', + length: 1.9, + }); +}); + +test('vessels: the first matching rule of the first type range with one, else the generic vessel', () => { + assert.equal(resolveVessel(sliceTypes, 70, 366, 51).model, 'container_ship'); + assert.equal( + resolveVessel(sliceTypes, 71, 199, 32, 'GLOVIS SPIRIT').model, + 'car_carrier', + ); + assert.equal(resolveVessel(sliceTypes, 79, 110, 11.4).model, 'inland_cargo'); + assert.equal(resolveVessel(sliceTypes, 70, 90, 15).model, 'general_cargo'); + assert.deepEqual(resolveVessel(sliceTypes, 52, 30, 10), { + model: 'tug', + length: 30, + }); + assert.deepEqual(resolveVessel(sliceTypes, undefined, 50), { + model: 'generic_vessel', + length: 50, + }); + assert.deepEqual(resolveVessel(sliceTypes, 0), { + model: 'generic_vessel', + length: null, + }); +}); + +test("vessels of unknown length are chosen and drawn at their range's typical length", () => { + assert.deepEqual(resolveVessel(sliceTypes, 70), { + model: 'container_ship', + length: 300, + }); + assert.deepEqual(resolveVessel(sliceTypes, 70, 511, 126), { + model: 'container_ship', + length: 300, + }); + assert.deepEqual(resolveVessel(sliceTypes, 70, 90, 15), { + model: 'general_cargo', + length: 90, + }); + assert.deepEqual(resolveVessel(sliceTypes, 31), { + model: 'tug', + length: null, + }); + // Every passenger, cargo and tanker range of the set has one. + for (const code of [60, 70, 80]) { + assert.ok(resolveVessel(types, code).length > 100, String(code)); + } +}); + +test("vessels: AIS's out-of-range sizes (511 m long, 126 m abeam) are unknown, drawn as built", () => { + assert.deepEqual(resolveVessel(sliceTypes, 52, 511), { + model: 'tug', + length: null, + }); + assert.equal(hullLength(511), undefined); + assert.equal(hullLength(0), undefined); + assert.equal(hullBeam(126), undefined); + assert.equal(hullBeam(32), 32); + const ship = sliceCatalog.container_ship; + assert.deepEqual(placeScale(ship, ship.length, 126), [1, 1, 1]); +}); + +test('scale: aircraft by the type length, vessels by length and beam', () => { + const a320 = sliceCatalog.a320; + const [sx, sy, sz] = placeScale(a320, 44.51); + assert.ok(Math.abs(sx - 44.51 / 37.57) < 1e-12); + assert.equal(sy, sx); + assert.equal(sz, sx); + const ship = sliceCatalog.container_ship; + assert.deepEqual(placeScale(ship, ship.length / 2, 24), [0.5, 0.5, 0.5]); + assert.deepEqual(placeScale(ship, null), [1, 1, 1]); +}); + +test('every model the type map names is in the catalog, and its file is a GLB on disk', () => { + for (const name of namedFleetModels(types)) + assert.ok( + catalog[name], + `${name}: named by types.json but not in the catalog`, + ); + assert.equal(catalog.generic_plane?.category, 'aircraft'); + assert.equal(catalog.generic_vessel?.category, 'ships'); + assert.equal(catalog.city_bus?.category, 'transit'); + for (const [name, metres] of Object.entries(types.transit.elevation)) { + assert.ok(catalog[name], name); + assert.ok(metres > 0, name); + } + let total = 0; + for (const [name, entry] of Object.entries(catalog)) { + const file = path.join(DIR, entry.file); + assert.ok(fs.existsSync(file), `${name}: ${entry.file} is missing`); + const bytes = fs.readFileSync(file); + assert.equal(bytes.subarray(0, 4).toString('latin1'), 'glTF', name); + assert.ok(entry.length > 0, name); + assert.ok(entry.tris <= 50_000, name); + total += bytes.length; + } + // The set ships with the repository and loads up front: keep it small. + assert.ok(total < 3 * 1024 * 1024, `fleet GLBs total ${total} bytes`); +}); + +test('transit bodies mark the share of the route colour they take; other models mark none', () => { + for (const [name, entry] of Object.entries(catalog)) { + const { json: gltf } = readGlb(path.join(DIR, entry.file)); + for (const mesh of gltf.meshes) + for (const primitive of mesh.primitives) + assert.equal( + '_TINT' in primitive.attributes, + entry.category === 'transit', + name, + ); + assert.equal( + fleetModelSpec(set, name, null).tinted, + entry.category === 'transit', + ); + } +}); + +test('aircraft specs: airport vehicles by callsign, ground stations as a mast, as the set says', () => { + const zveh = (callsign) => + aircraftModelSpec(set, { typeCode: 'ZVEH', category: 'UNKNOWN', callsign }) + .model; + assert.equal(zveh('FIRE 2'), 'fire_truck'); + assert.equal(zveh('PUSHBACK1'), 'pushback'); + assert.equal(zveh('TUG 4'), 'pushback'); + assert.equal(zveh('DEICER 4'), 'van'); + assert.equal(zveh('SQUID20'), 'van'); + assert.equal(zveh('SQB9'), 'van'); + assert.equal(zveh('LEADER 7'), 'car'); + assert.equal(zveh('AOPS'), 'car'); + assert.equal(zveh(null), 'car'); + const mast = aircraftModelSpec(set, { typeCode: 'GRND', callsign: 'RX5' }); + assert.equal(mast.model, 'radio_mast'); + assert.equal(mast.scale, 1); +}); + +test('a callsign rule outranks the class model of a type the map lacks', () => { + const ruled = { + ...set, + types: { + ...set.types, + aircraft_callsign: { + ...set.types.aircraft_callsign, + PUMA: [{ prefixes: ['FIRE'], model: 'fire_truck', length: 11.01 }], + }, + }, + }; + assert.equal( + aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'FIRE 1' }).model, + 'fire_truck', + ); + // No rule matched: the class model, as before. + assert.equal( + aircraftModelSpec(ruled, { typeCode: 'PUMA', callsign: 'RESCUE1' }).model, + 'generic_heli', + ); +}); + +test('aircraft specs: by type, then class for an unlisted type, then emitter category', () => { + assert.equal(aircraftModelSpec(set, { typeCode: 'A320' }).model, 'a320'); + assert.equal(aircraftModelSpec(set, { type: 'b744' }).model, 'b744'); + // An unlisted fast jet is still a fast jet, not the generic airliner. + assert.equal( + aircraftModelSpec(set, { typeCode: 'FA18', category: 'A3' }).model, + 't38', + ); + assert.equal(aircraftModelSpec(set, { typeCode: 'MQ9' }).model, 'mq9'); + assert.equal( + aircraftModelSpec(set, { typeCode: 'PUMA' }).model, + 'generic_heli', + ); + // An unlisted type of no special class falls to its category. + assert.equal( + aircraftModelSpec(set, { typeCode: 'ZZZZ', category: 'A5' }).model, + 'b77w', + ); + // OpenSky's integer category: 8 is rotorcraft (A7), 2 light (A1). + assert.equal(emitterCategory(8), 'A7'); + assert.equal(emitterCategory('a3'), 'A3'); + assert.equal(emitterCategory(1), undefined); + assert.equal(aircraftModelSpec(set, { category: 8 }).model, 'generic_heli'); + assert.equal(aircraftModelSpec(set, { category: 2 }).model, 'c172'); + assert.equal(aircraftModelSpec(set, {}).model, types.default_aircraft[0]); +}); + +test('aircraft specs are life size, standing on their scaled clearance', () => { + const spec = aircraftModelSpec(set, { typeCode: 'A21N' }); + const [, length] = types.aircraft.A21N; + assert.equal(spec.model, types.aircraft.A21N[0]); + assert.ok(Math.abs(spec.length - length) < 1e-9); + assert.ok(Math.abs(spec.scale - length / catalog[spec.model].length) < 1e-12); + assert.ok( + Math.abs(spec.clearanceM - catalog[spec.model].clearance * spec.scale) < + 1e-12, + ); + assert.equal(spec.url, `${FLEET_MODEL_BASE}${catalog[spec.model].file}`); + assert.equal(spec.tinted, false); + assert.equal(spec.elevationM, 0); + assert.ok( + spec.nativeRadiusM * spec.scale > 25 && + spec.nativeRadiusM * spec.scale < 60, + ); + assert.deepEqual(spec.trailAnchorNative, FLEET_TRAIL_ANCHORS[spec.model]); + // A grounded service vehicle and an obstacle are drawn as built: the + // tower's 60 m is its height, not a length to stretch it to. + const tower = aircraftModelSpec(set, { category: 'C3' }); + assert.equal(tower.model, 'tower'); + assert.equal(tower.scale, 1); + assert.equal(aircraftModelSpec(set, { category: 'C1' }).scale, 1); +}); + +test('vessel specs read AIS codes and text families, stretched to the reported beam', () => { + assert.equal(aisShipTypeCode('70'), 70); + assert.equal(aisShipTypeCode(' Cargo '), 70); + assert.equal(aisShipTypeCode('Tanker'), 80); + assert.equal(aisShipTypeCode('Tug'), 52); + assert.equal(aisShipTypeCode('0'), undefined); + assert.equal(aisShipTypeCode(''), undefined); + const box = vesselModelSpec(set, { type: '71', length: 366, beam: 51 }); + assert.equal(box.model, 'container_ship'); + assert.ok(Math.abs(box.length - 366) < 1e-9); + const [sx, sy, sz] = box.scaleXYZ; + assert.ok(Math.abs(sy - 51 / catalog.container_ship.beam) < 1e-12); + assert.ok(Math.abs(sz - (sx + sy) / 2) < 1e-12); + // A beam with no reported length: the type's typical length, in the model's + // own proportions, not a typical-length hull squeezed to that beam. + const unsized = vesselModelSpec(set, { type: '70', length: 0, beam: 14 }); + assert.equal(unsized.model, 'container_ship'); + const [ux, uy, uz] = unsized.scaleXYZ; + assert.ok(Math.abs(uy - ux) < 1e-12 && Math.abs(uz - ux) < 1e-12); + assert.equal(vesselModelSpec(set, { type: 'Tug', length: 30 }).model, 'tug'); + assert.equal(vesselModelSpec(set, { type: '36' }).model, 'sailboat'); + // No type, no size: the generic vessel as built. + const unknown = vesselModelSpec(set, {}); + assert.equal(unknown.model, types.default_vessel); + assert.equal(unknown.scale, 1); +}); + +test('aids to navigation are the buoy of the mark their type names', () => { + const cases = [ + ['Cardinal Mark N', 'buoy_cardinal_north'], + ['Beacon, Cardinal E', 'buoy_cardinal_east'], + ['Cardinal Mark S', 'buoy_cardinal_south'], + ['Beacon, Cardinal W', 'buoy_cardinal_west'], + ['Port Hand Mark', 'buoy_lateral_port'], + ['Beacon, Port hand', 'buoy_lateral_port'], + ['Starboard Hand Mark', 'buoy_lateral_starboard'], + ['Preferred Channel Starboard hand', 'buoy_lateral_starboard'], + ['Safe Water', 'buoy_safe_water'], + ['Isolated danger', 'buoy_isolated_danger'], + ['Beacon, Special Mark', 'buoy_special'], + ['Special Mark', 'buoy_special'], + ['Light Vessel / LANBY / Rigs', 'buoy_special'], + ['RACON', 'buoy_special'], + ['Light, with Sectors', 'buoy_special'], + ['Reference point', 'buoy_special'], + ['Navigation aid', 'buoy_special'], + ]; + for (const [type, model] of cases) { + assert.equal(aidToNavigationModel(type), model, type); + assert.ok(catalog[model], model); + const spec = vesselModelSpec(set, { type, mmsi: '993672001' }); + assert.equal(spec.model, model, type); + assert.equal(spec.category, 'buoys', type); + assert.equal(spec.fixed, true, type); + // A buoy is drawn as built, whatever length the report gives. + assert.equal(spec.scale, 1, type); + } + assert.equal( + vesselModelSpec(set, { type: 'Safe Water', length: 120, beam: 30 }).scale, + 1, + ); + // An aid of no stated kind, known only by its MMSI, is the default can. + assert.ok(catalog[DEFAULT_AID_MODEL]); + const unnamed = vesselModelSpec(set, { type: '', mmsi: '993660123' }); + assert.equal(unnamed.model, DEFAULT_AID_MODEL); + assert.equal(unnamed.fixed, true); + assert.equal(aidToNavigationModel('Fixed structure off shore'), undefined); + // A virtual aid is broadcast where nothing stands: it keeps its disc. + assert.equal( + vesselModelSpec(set, { + type: 'Cardinal Mark N', + mmsi: '993672001', + virtual: true, + }), + null, + ); + // Vessels, including those that work on buoys, keep their hulls. + for (const vessel of [ + { type: 'Buoy Tender', mmsi: '366999001', length: 60 }, + { type: '70', mmsi: '366999002' }, + { type: 'Tug', mmsi: '' }, + ]) { + const spec = vesselModelSpec(set, vessel); + assert.notEqual(spec.category, 'buoys', vessel.type); + assert.equal(spec.fixed, undefined, vessel.type); + } +}); + +test('transit specs: rail category and mode, then route type, then mode, then the default', () => { + assert.deepEqual(resolveTransit(types, { railCategory: 'national' }), { + model: 'intercity', + length: types.transit.rail.national.rail[1], + }); + assert.equal( + resolveTransit(types, { railCategory: 'transit', railMode: 'metro' }).model, + 'metro', + ); + assert.equal( + resolveTransit(types, { railCategory: 'regional', railMode: 'tram' }).model, + 'light_rail', + ); + assert.equal(resolveTransit(types, { routeType: 3 }).model, 'city_bus'); + assert.equal(resolveTransit(types, { routeType: '0' }).model, 'tram'); + assert.equal(resolveTransit(types, { routeType: 109 }).model, 'commuter_emu'); + assert.equal(resolveTransit(types, { mode: 'subway' }).model, 'metro'); + assert.equal(resolveTransit(types, { mode: 'ferry' }).model, 'fast_ferry'); + assert.equal( + resolveTransit(types, { mode: 'unknown' }).model, + types.transit.default[0], + ); + const tram = transitModelSpec(set, { mode: 'tram' }); + assert.equal(tram.tinted, true); + assert.equal(tram.clearanceM, 0); + assert.ok(Math.abs(tram.length - types.transit.modes.tram[1]) < 1e-9); + assert.equal( + transitModelSpec(set, { routeType: 12 }).elevationM, + types.transit.elevation.monorail, + ); + assert.equal( + transitModelSpec(set, { mode: 'gondola' }).elevationM, + types.transit.elevation.gondola, + ); + assert.equal(transitModelSpec(set, { mode: 'ferry' }).tinted, false); +}); + +test('a model missing from the catalog falls back to the default of its kind', () => { + const thin = { + catalog: { ...catalog }, + types: { + ...types, + aircraft: { ...types.aircraft, XXXX: ['no_such_model', 20] }, + }, + }; + assert.equal( + aircraftModelSpec(thin, { typeCode: 'XXXX' }).model, + types.default_aircraft[0], + ); +}); + +const ANCHORED = Object.entries(catalog).filter(([, entry]) => + ['aircraft', 'helicopters', 'other'].includes(entry.category), +); + +test('every aircraft-layer model has a trail anchor on its real aft-belly hull', async () => { + assert.deepEqual( + new Set(Object.keys(FLEET_TRAIL_ANCHORS)), + new Set(ANCHORED.map(([name]) => name)), + 're-run scripts/measure-fleet-trail-anchors.mjs', + ); + for (const [name, entry] of ANCHORED) { + const mesh = await fleetMesh(path.join(DIR, entry.file)); + const { min, max } = vertexBounds(mesh.verts); + const anchor = FLEET_TRAIL_ANCHORS[name]; + // On the hull, not in the air beside it. + const offHull = distanceToSurface(anchor, mesh.verts, mesh.tris); + assert.ok( + offHull <= 1e-3, + `${name}: trail anchor is ${offHull.toFixed(4)} m off the hull`, + ); + // Aft of the middle, so the head segment does not run into the body. + assert.ok( + anchor[0] > (min[0] + max[0]) / 2, + `${name}: trail anchor is not aft`, + ); + // Nothing of the centreline hangs lower at or behind its station. + const lowest = centrelineSegments(mesh.verts, mesh.tris) + .flat() + .filter((p) => p[0] >= anchor[0] - 1e-3) + .reduce((low, p) => Math.min(low, p[1]), Infinity); + assert.ok( + anchor[1] <= lowest + 1e-3, + `${name}: trail anchor is above the hull aft of it`, + ); + assert.equal(anchor[2], 0, `${name}: trail anchor is off the centreline`); + // And it is the construction, re-derived. + const measured = measuredTrailAnchor(mesh); + for (const axis of [0, 1]) + assert.ok( + Math.abs(anchor[axis] - measured[axis]) <= 1e-3, + `${name} axis ${axis}: table ${anchor[axis]} vs measured ${measured[axis].toFixed(4)}`, + ); + } +}); + +test('catalog bounds match the GLBs they describe', async () => { + for (const [name, entry] of Object.entries(catalog)) { + const { min, max } = vertexBounds( + (await fleetMesh(path.join(DIR, entry.file))).verts, + ); + // The catalog is Z up (glTF Y), glTF Z across. + const tolerance = 0.01 * Math.max(1, entry.length); + assert.ok(Math.abs(min[0] - entry.min[0]) <= tolerance, `${name} min x`); + assert.ok(Math.abs(max[0] - entry.max[0]) <= tolerance, `${name} max x`); + assert.ok(Math.abs(min[1] - entry.min[2]) <= tolerance, `${name} min up`); + assert.ok( + Math.abs(-min[1] - entry.clearance) <= tolerance, + `${name} clearance`, + ); + assert.ok( + Math.abs(max[2] - min[2] - (entry.max[1] - entry.min[1])) <= tolerance, + `${name} beam`, + ); + } +}); + +test('the model set loads once, every GLB up front, shared by every layer', async () => { + resetFleetModelSet(); + const requested = []; + const fetchImpl = async (url) => { + requested.push(url); + const file = path.join(ROOT, 'public', url.replace(/^\/app/, '')); + if (!fs.existsSync(file)) return { ok: false, status: 404 }; + const bytes = fs.readFileSync(file); + return { + ok: true, + json: async () => JSON.parse(bytes.toString('utf8')), + arrayBuffer: async () => + bytes.buffer.slice( + bytes.byteOffset, + bytes.byteOffset + bytes.byteLength, + ), + }; + }; + const resolveAsset = (url) => `/app${url}`; + const [first, second] = await Promise.all([ + loadFleetModelSet({ resolveAsset, fetchImpl }), + loadFleetModelSet({ resolveAsset, fetchImpl }), + ]); + assert.equal(first, second); + assert.equal(fleetModelSet(), first); + assert.equal(first.bytes.size, Object.keys(catalog).length); + assert.equal(requested.length, Object.keys(catalog).length + 2); + assert.ok(requested.every((url) => url.startsWith('/app/models/fleet/'))); + assert.ok(first.totalBytes > 0 && Number.isFinite(first.loadMs)); + await loadFleetModelSet({ resolveAsset, fetchImpl }); + assert.equal(requested.length, Object.keys(catalog).length + 2); + resetFleetModelSet(); +}); diff --git a/src/data/fleetTrailAnchors.js b/src/data/fleetTrailAnchors.js new file mode 100644 index 000000000..5a04aa2ad --- /dev/null +++ b/src/data/fleetTrailAnchors.js @@ -0,0 +1,67 @@ +/** + * Aft-belly trail attachment points of the fleet models the aircraft layers + * draw, in raw glTF axes (X along the body, nose toward -X; Y up), metres + * before scale, node transforms applied. Each is the point of the model's + * centreline profile (its z = 0 slice) nearest its aft-belly bounding-box + * corner: on the hull, aft, and on the centreline (see modelVisualAnchor.js). + * + * Written by scripts/measure-fleet-trail-anchors.mjs; re-measured against the + * GLBs by fleetModels.test.mjs. + */ +export const FLEET_TRAIL_ANCHORS = Object.freeze({ + a139: Object.freeze([6.8847, 0.2766, 0]), + a320: Object.freeze([17.2078, 0.7416, 0]), + a333: Object.freeze([29.7276, 1.1084, 0]), + a343: Object.freeze([30.0371, 1.2335, 0]), + a359: Object.freeze([31.0731, 1.1573, 0]), + a388: Object.freeze([34.3139, 1.9279, 0]), + as50: Object.freeze([5.2278, -0.0999, 0]), + at72: Object.freeze([12.8258, 0.6792, 0]), + b06: Object.freeze([4.62, -0.3002, 0]), + b407: Object.freeze([5.1264, -0.2797, 0]), + b738: Object.freeze([18.3819, 0.8391, 0]), + b744: Object.freeze([33.6232, 1.5411, 0]), + b763: Object.freeze([25.5464, 0.9889, 0]), + b77w: Object.freeze([34.4639, 1.2084, 0]), + b789: Object.freeze([29.0604, 1.0649, 0]), + balloon: Object.freeze([3.8215, -0.0009, 0]), + bcs3: Object.freeze([17.8658, 0.713, 0]), + be20: Object.freeze([6.6699, 0.5505, 0]), + blimp: Object.freeze([32.2493, -7.1382, 0]), + c17: Object.freeze([26.5207, 2.332, 0]), + c172: Object.freeze([4.0507, 0.4481, 0]), + c30j: Object.freeze([13.568, 0.9917, 0]), + c56x: Object.freeze([7.4627, 0.4526, 0]), + car: Object.freeze([2.1851, 0.301, 0]), + cl35: Object.freeze([10.4607, 0.8247, 0]), + crj9: Object.freeze([17.8413, 0.8624, 0]), + dh8d: Object.freeze([15.7902, 0.7501, 0]), + e175: Object.freeze([14.697, 0.6077, 0]), + e55p: Object.freeze([7.6566, 0.503, 0]), + ec35: Object.freeze([5.1003, 0.1471, 0]), + ec45: Object.freeze([5.8004, 0.555, 0]), + fire_truck: Object.freeze([5.39, 0.4227, 0]), + generic_heli: Object.freeze([5.4997, 0.1604, 0]), + generic_plane: Object.freeze([16.5228, 0.712, 0]), + glf4: Object.freeze([13.4603, 0.8243, 0]), + glider: Object.freeze([3.3997, 0.1283, 0]), + h47: Object.freeze([7.7303, 0.0035, 0]), + h60: Object.freeze([7.6295, 0.1365, 0]), + h64: Object.freeze([6.9228, -0.5553, 0]), + k35r: Object.freeze([28.0221, -2.4508, 0]), + md11: Object.freeze([30.8002, 1.2291, 0]), + mq9: Object.freeze([5.8498, -0.062, 0]), + p28a: Object.freeze([3.3695, 0.3959, 0]), + pc12: Object.freeze([6.6416, 0.5288, 0]), + pushback: Object.freeze([2.9536, 0.388, 0]), + r44: Object.freeze([4.4219, -0.3001, 0]), + radio_mast: Object.freeze([1.2, 0, 0]), + s76: Object.freeze([6.6001, 0.1915, 0]), + sr22: Object.freeze([3.6762, 0.2376, 0]), + t38: Object.freeze([7.0246, -0.3791, 0]), + tex2: Object.freeze([4.7383, 0.2302, 0]), + tower: Object.freeze([1.2952, -0.0007, 0]), + ultralight: Object.freeze([3.2271, 0.3259, 0]), + v22: Object.freeze([7.9868, 0.2956, 0]), + van: Object.freeze([2.695, 0.2812, 0]), +}); diff --git a/src/data/modelScale.test.mjs b/src/data/modelScale.test.mjs index d91914805..89e7ea33b 100644 --- a/src/data/modelScale.test.mjs +++ b/src/data/modelScale.test.mjs @@ -1,45 +1,33 @@ import { readLayerSource } from '../testSupport/readLayerSource.mjs'; -// Regression test for the giant-military-jet bug: MODEL_SCALE must put every -// rendered aircraft at REAL-WORLD size, whatever the GLB's native scale is. -// -// airplane.glb and jet.glb are both transform-applied at real-world size and -// render with MODEL_SCALE 1. The airplane previously relied on a runtime ×24 -// calibration; that factor is now baked into its geometry so future consumers -// cannot miss it. The original military bug came from copying scale 24 onto the -// already meter-scale jet, rendering military models ~600–1000 m -// across — invisible at the ~6 km follow range where minimumPixelSize -// dominates, but zoom below ~1 km and the camera ends up INSIDE the model. -// -// The layer modules pull the full Cesium engine and don't export their -// constants, so (as in modelMatrix.test.mjs) we don't import them: constants -// are read from source, and each GLB's native bounding radius is computed from -// its POSITION accessor min/max with the node hierarchy's transforms applied -// (both legacy GLBs are now transform-applied, but the hierarchy-aware reader -// guards future asset updates). For jet.glb this yields 29.83 — Cesium's -// Model.boundingSphere.radius / scale measured in-app (2026-07-02). +// Aircraft models draw LIFE SIZE: every aircraft layer takes its model and +// scale from the procedural fleet set (fleetModels.js), a type's published +// length over the model's built length. These pins keep that true — no layer +// carries a scale constant of its own (the giant-military-jet bug was one +// copied across layers) — and keep the trail and bracket anchors riding the +// transform chain Cesium draws the models with. import { test } from 'node:test'; import * as Cesium from 'cesium'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { CLASS_SCALE_3D, CLASS_MODEL_URL, CLASS_MODEL_REAL } from './aircraftClass.js'; import { - MODEL_TRAIL_ANCHOR_NATIVE, - MODEL_VISUAL_CENTER_NATIVE, modelVisualAnchor, - trailAnchorForModel, trailHeadStart, modelAnchorWorld, } from './modelVisualAnchor.js'; +import { aircraftModelSpec } from './fleetModels.js'; +import { FLEET_TRAIL_ANCHORS } from './fleetTrailAnchors.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const FLEET = path.join(ROOT, 'public', 'models', 'fleet'); +const set = { + catalog: JSON.parse(fs.readFileSync(path.join(FLEET, 'catalog.json'), 'utf8')), + types: JSON.parse(fs.readFileSync(path.join(FLEET, 'types.json'), 'utf8')), +}; -/** Every class's rendered bounding radius (m) must land in this envelope — - * bigger than any clamped-down light aircraft, smaller than the largest real - * heavy (A380/An-124 bounding radius ~45 m), nowhere near the 500–1000 m of - * the scale-24 jet.glb bug. */ -const WORLD_RADIUS_MIN_M = 10; +/** Every aircraft drawn at its type's size: no rendered radius outside what aircraft span. */ +const WORLD_RADIUS_MIN_M = 2; const WORLD_RADIUS_MAX_M = 60; test('model-space anchors honor Cesium minimum-pixel computed scale', () => { @@ -54,560 +42,46 @@ test('model-space anchors honor Cesium minimum-pixel computed scale', () => { assert.deepEqual(result, { x: 79, y: 214, z: 272 }); }); -// --- minimal GLB bounds reader --------------------------------------------- - -function glbJson(file) { - const buf = fs.readFileSync(file); - assert.equal(buf.readUInt32LE(0), 0x46546c67, `${file}: not a GLB`); - assert.equal(buf.readUInt32LE(16), 0x4e4f534a, `${file}: first chunk not JSON`); - return JSON.parse(buf.subarray(20, 20 + buf.readUInt32LE(12)).toString('utf8')); -} - -// Column-major 4×4 helpers (glTF matrix layout). -const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; -function mul(a, b) { - const o = new Array(16).fill(0); - for (let c = 0; c < 4; c++) - for (let r = 0; r < 4; r++) - for (let k = 0; k < 4; k++) o[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k]; - return o; -} -function nodeMatrix(node) { - if (node.matrix) return node.matrix; - const t = node.translation || [0, 0, 0]; - const [x, y, z, w] = node.rotation || [0, 0, 0, 1]; - const s = node.scale || [1, 1, 1]; - const rot = [ - 1 - 2 * (y * y + z * z), 2 * (x * y + z * w), 2 * (x * z - y * w), 0, - 2 * (x * y - z * w), 1 - 2 * (x * x + z * z), 2 * (y * z + x * w), 0, - 2 * (x * z + y * w), 2 * (y * z - x * w), 1 - 2 * (x * x + y * y), 0, - 0, 0, 0, 1, - ]; - return mul( - [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, t[0], t[1], t[2], 1], - mul(rot, [s[0], 0, 0, 0, 0, s[1], 0, 0, 0, 0, s[2], 0, 0, 0, 0, 1]), - ); -} -function transformPoint(m, p) { - return [ - m[0] * p[0] + m[4] * p[1] + m[8] * p[2] + m[12], - m[1] * p[0] + m[5] * p[1] + m[9] * p[2] + m[13], - m[2] * p[0] + m[6] * p[1] + m[10] * p[2] + m[14], - ]; -} - -/** Scene-space AABB over all mesh primitives' POSITION bounds, node transforms applied. */ -function nativeBounds(file) { - const gltf = glbJson(file); - const min = [Infinity, Infinity, Infinity]; - const max = [-Infinity, -Infinity, -Infinity]; - const visit = (idx, parent) => { - const node = gltf.nodes[idx]; - const m = mul(parent, nodeMatrix(node)); - if (node.mesh != null) { - for (const prim of gltf.meshes[node.mesh].primitives) { - const acc = gltf.accessors[prim.attributes.POSITION]; - if (!acc?.min || !acc?.max) continue; - for (let i = 0; i < 8; i++) { - const corner = transformPoint(m, [ - (i & 1) ? acc.max[0] : acc.min[0], - (i & 2) ? acc.max[1] : acc.min[1], - (i & 4) ? acc.max[2] : acc.min[2], - ]); - for (let a = 0; a < 3; a++) { - min[a] = Math.min(min[a], corner[a]); - max[a] = Math.max(max[a], corner[a]); - } - } - } - } - for (const child of node.children || []) visit(child, m); - }; - for (const n of gltf.scenes[gltf.scene ?? 0].nodes) visit(n, IDENTITY); - assert.ok(Number.isFinite(min[0]), `${file}: no POSITION bounds found`); - return { min, max }; -} - -// --- real mesh reader (vertices + triangles, not just accessor bounds) ------ -// -// The bounds reader above is enough to size a model, but NOT to site a point on -// it: an AABB corner is empty space. These read the POSITION buffers themselves -// so the trail anchor can be checked against actual triangles. - -const GLB_COMPONENT_READERS = { - 5120: (buf, o) => buf.readInt8(o), - 5121: (buf, o) => buf.readUInt8(o), - 5122: (buf, o) => buf.readInt16LE(o), - 5123: (buf, o) => buf.readUInt16LE(o), - 5125: (buf, o) => buf.readUInt32LE(o), - 5126: (buf, o) => buf.readFloatLE(o), -}; -const GLB_COMPONENT_BYTES = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }; -const GLB_TYPE_COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }; - -/** JSON chunk plus the binary chunk of a GLB. */ -function glbChunks(file) { - const buf = fs.readFileSync(file); - assert.equal(buf.readUInt32LE(0), 0x46546c67, `${file}: not a GLB`); - const jsonLen = buf.readUInt32LE(12); - const gltf = JSON.parse(buf.subarray(20, 20 + jsonLen).toString('utf8')); - let bin = null; - for (let off = 20 + jsonLen; off + 8 <= buf.length;) { - const len = buf.readUInt32LE(off); - if (buf.readUInt32LE(off + 4) === 0x004e4942) { bin = buf.subarray(off + 8, off + 8 + len); break; } - off += 8 + len; - } - assert.ok(bin, `${file}: no BIN chunk`); - return { gltf, bin }; -} - -/** One accessor's elements, honouring bufferView byteStride. */ -function glbAccessor(gltf, bin, index) { - const acc = gltf.accessors[index]; - assert.ok(!acc.sparse, 'sparse accessors are not used by the bundled assets'); - const read = GLB_COMPONENT_READERS[acc.componentType]; - const size = GLB_COMPONENT_BYTES[acc.componentType]; - const n = GLB_TYPE_COMPONENTS[acc.type]; - const bv = gltf.bufferViews[acc.bufferView]; - const base = (bv.byteOffset || 0) + (acc.byteOffset || 0); - const stride = bv.byteStride || size * n; - const out = []; - for (let i = 0; i < acc.count; i++) { - const o = base + i * stride; - const el = []; - for (let c = 0; c < n; c++) el.push(read(bin, o + c * size)); - out.push(el); - } - return out; -} - -/** - * Every mesh vertex in Cesium model-local space, plus the triangle index list. - * Same glTF Y-up → Cesium Z-up mapping the anchors are expressed in. - */ -function nativeMesh(file) { - const { gltf, bin } = glbChunks(file); - assert.ok( - !(gltf.extensionsRequired || []).some((e) => e.toLowerCase().includes('draco')), - `${file}: Draco-compressed assets would need a decoder here`, - ); - const verts = []; - const tris = []; - const visit = (idx, parent) => { - const node = gltf.nodes[idx]; - const m = mul(parent, nodeMatrix(node)); - if (node.mesh != null) { - for (const prim of gltf.meshes[node.mesh].primitives) { - const pIdx = prim.attributes?.POSITION; - if (pIdx == null) continue; - const start = verts.length; - for (const p of glbAccessor(gltf, bin, pIdx)) { - // RAW glTF axes, deliberately: the anchor table stores raw glTF and - // lets Cesium's own axis correction do the rest (modelAnchorWorld). - // Converting here would re-introduce the second, hand-maintained - // convention whose half-correction put the anchor on the wrong axis. - verts.push(transformPoint(m, p)); - } - if (prim.indices != null) { - const ind = glbAccessor(gltf, bin, prim.indices).map((e) => e[0]); - for (let i = 0; i + 2 < ind.length; i += 3) { - tris.push([start + ind[i], start + ind[i + 1], start + ind[i + 2]]); - } - } else { - for (let i = start; i + 2 < verts.length; i += 3) tris.push([i, i + 1, i + 2]); - } - } - } - for (const child of node.children || []) visit(child, m); - }; - for (const n of gltf.scenes[gltf.scene ?? 0].nodes) visit(n, IDENTITY); - assert.ok(verts.length && tris.length, `${file}: no triangles found`); - return { verts, tris }; -} - -/** AABB over real vertices (Cesium model-local). */ -function vertexBounds(verts) { - const min = [Infinity, Infinity, Infinity]; - const max = [-Infinity, -Infinity, -Infinity]; - for (const v of verts) { - for (let a = 0; a < 3; a++) { - if (v[a] < min[a]) min[a] = v[a]; - if (v[a] > max[a]) max[a] = v[a]; +test('every typed aircraft renders at its published length, a real-world aircraft size', () => { + for (const map of [set.types.aircraft, set.types.helicopters]) { + for (const [type, [model, length]] of Object.entries(map)) { + const spec = aircraftModelSpec(set, { typeCode: type }); + assert.equal(spec.model, model, type); + // Balloons and obstacles ('other') are built at their own size. + const published = spec.category === 'other' ? set.catalog[model].length : length; + assert.ok(Math.abs(spec.length - published) < 1e-9, `${type}: drawn ${spec.length} m, published ${published} m`); + const radius = spec.nativeRadiusM * spec.scale; + assert.ok( + radius >= WORLD_RADIUS_MIN_M && radius <= WORLD_RADIUS_MAX_M, + `${type}: rendered radius ${radius.toFixed(2)} m outside [${WORLD_RADIUS_MIN_M}, ${WORLD_RADIUS_MAX_M}]`, + ); + // A grounded aircraft stands on its wheels: its origin the scaled + // clearance above the ground, never below it. + // A model built standing on its origin (a ground vehicle, a ground + // station's mast: its lowest point at z 0) has no clearance. + if (set.catalog[model].min[2] > -0.05) + assert.ok( + spec.clearanceM >= 0 && spec.clearanceM < 0.05 * spec.scale, + `${type}: built standing, clearance ${spec.clearanceM}`, + ); + else assert.ok(spec.clearanceM > 0, `${type}: clearance ${spec.clearanceM}`); + assert.ok(spec.clearanceM < radius, `${type}: clearance larger than the aircraft`); } } - return { min, max }; -} +}); -const dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); - -/** Closest point of triangle abc to p (Ericson, Real-Time Collision Detection). */ -function closestOnTriangle(p, a, b, c) { - const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]]; - const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2]; - const ab = sub(b, a); const ac = sub(c, a); const ap = sub(p, a); - const d1 = dot(ab, ap); const d2 = dot(ac, ap); - if (d1 <= 0 && d2 <= 0) return a; - const bp = sub(p, b); - const d3 = dot(ab, bp); const d4 = dot(ac, bp); - if (d3 >= 0 && d4 <= d3) return b; - const vc = d1 * d4 - d3 * d2; - if (vc <= 0 && d1 >= 0 && d3 <= 0) { - const v = d1 / (d1 - d3); - return [a[0] + ab[0] * v, a[1] + ab[1] * v, a[2] + ab[2] * v]; - } - const cp = sub(p, c); - const d5 = dot(ab, cp); const d6 = dot(ac, cp); - if (d6 >= 0 && d5 <= d6) return c; - const vb = d5 * d2 - d1 * d6; - if (vb <= 0 && d2 >= 0 && d6 <= 0) { - const w = d2 / (d2 - d6); - return [a[0] + ac[0] * w, a[1] + ac[1] * w, a[2] + ac[2] * w]; - } - const va = d3 * d6 - d5 * d4; - if (va <= 0 && (d4 - d3) >= 0 && (d5 - d6) >= 0) { - const w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); - return [b[0] + (c[0] - b[0]) * w, b[1] + (c[1] - b[1]) * w, b[2] + (c[2] - b[2]) * w]; - } - const den = 1 / (va + vb + vc); - const v = vb * den; const w = vc * den; - return [a[0] + ab[0] * v + ac[0] * w, a[1] + ab[1] * v + ac[1] * w, a[2] + ab[2] * v + ac[2] * w]; -} - -/** How far a point lies from the nearest triangle of the mesh. */ -function distanceToSurface(p, verts, tris) { - let best = Infinity; - for (const [i, j, k] of tris) { - const d = dist3(p, closestOnTriangle(p, verts[i], verts[j], verts[k])); - if (d < best) best = d; - } - return best; -} - -/** The hull's longitudinal cross-section: where triangles cross the y = 0 plane. */ -function centrelineProfile(verts, tris) { - const pts = []; - for (const [i, j, k] of tris) { - const t = [verts[i], verts[j], verts[k]]; - for (let e = 0; e < 3; e++) { - const a = t[e]; const b = t[(e + 1) % 3]; - if (a[2] === 0) pts.push(a); - if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) { - const s = a[2] / (a[2] - b[2]); - pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]); - } - } - } - return pts; -} - -/** Closest point of the centreline profile to `target` (segment-exact). */ -function closestProfilePoint(target, profile, tris, verts) { - // Walk the profile as the segments the slice actually produced, so the answer - // can fall between two vertices the way the real surface does. - const segs = []; - for (const [i, j, k] of tris) { - const t = [verts[i], verts[j], verts[k]]; - const pts = []; - for (let e = 0; e < 3; e++) { - const a = t[e]; const b = t[(e + 1) % 3]; - if (a[2] === 0) pts.push(a); - if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) { - const s = a[2] / (a[2] - b[2]); - pts.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]); - } - } - if (pts.length >= 2) segs.push([pts[0], pts[1]]); - } - let best = null; let bestD = Infinity; - for (const [a, b] of segs) { - const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; - const den = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2]; - let q = a; - if (den > 0) { - let s = ((target[0] - a[0]) * ab[0] + (target[1] - a[1]) * ab[1] + (target[2] - a[2]) * ab[2]) / den; - s = Math.max(0, Math.min(1, s)); - q = [a[0] + ab[0] * s, a[1] + ab[1] * s, a[2] + ab[2] * s]; - } - const d = dist3(target, q); - if (d < bestD) { bestD = d; best = q; } - } - assert.ok(best, 'the centreline slice produced no segments'); - assert.ok(profile.length > 0, 'the centreline profile is empty'); - return best; -} - -/** Native bounding radius: half-diagonal of the scene-space AABB. */ -function nativeBoundingRadius(file) { - const { min, max } = nativeBounds(file); - return Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; -} - -/** How far the glTF origin sits above the model's lowest vertex (glTF is Y-up; - * Cesium maps that to local up). This is the native "belly depth" the grounded- - * model snap must lift by so the model rests on its gear/belly, not its origin. */ -function nativeOriginAboveLowestVertex(file) { - return -nativeBounds(file).min[1]; -} - -/** Scene-AABB centre converted from glTF Y-up to Cesium model-local Z-up. */ -function nativeVisualCenter(file) { - const { min, max } = nativeBounds(file); - const gltfCenter = min.map((value, axis) => (value + max[axis]) / 2); - return [gltfCenter[0], -gltfCenter[2], gltfCenter[1]]; -} - -// --- per-layer constants, read from source (see header) -------------------- - -function layerConstants(sourceFile) { - const src = readLayerSource(path.join(ROOT, sourceFile)); - const scale = src.match(/\bconst MODEL_SCALE = ([\d.]+);/); - assert.ok(scale, `${sourceFile}: MODEL_SCALE not found`); - const belly = src.match(/\bconst MODEL_BELLY_OFFSET_NATIVE = ([\d.]+);/); - assert.ok(belly, `${sourceFile}: MODEL_BELLY_OFFSET_NATIVE not found`); - return { modelScale: Number(scale[1]), bellyOffsetNative: Number(belly[1]) }; -} - -function normalBillboardScaleByDistance(sourceFile) { - const src = readLayerSource(path.join(ROOT, sourceFile)); - const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); - assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`); - const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); - assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`); - return scalar.slice(1).map(Number); -} - -const LAYERS = [ - { - name: 'flights', - source: 'src/data/flights.js', - // All classes share one GLB today (see CLASS_MODEL_URL) — assert that, so - // a real per-class asset drop-in forces this test to grow with it. - asset: (() => { - const urls = new Set(Object.values(CLASS_MODEL_URL)); - assert.equal(urls.size, 1, 'CLASS_MODEL_URL: expected a single shared GLB'); - return [...urls][0]; - })(), - }, - { - name: 'military', - source: 'src/data/militaryFlights.js', - asset: (() => { - const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js')); - const m = src.match(/\bconst JET_MODEL_URL = '([^']+)';/); - assert.ok(m, 'militaryFlights.js: JET_MODEL_URL not found'); - return m[1]; - })(), - }, +const AIRCRAFT_LAYERS = [ + 'src/layers/flights/rendering.js', + 'src/layers/military/rendering.js', + 'src/layers/localAdsb/models.js', ]; -const measured = LAYERS.map((layer) => { - const { modelScale, bellyOffsetNative } = layerConstants(layer.source); - const assetPath = path.join(ROOT, 'public', layer.asset); - const nativeRadius = nativeBoundingRadius(assetPath); - return { - ...layer, modelScale, bellyOffsetNative, nativeRadius, - baseWorldRadius: nativeRadius * modelScale, - originAboveBelly: nativeOriginAboveLowestVertex(assetPath), - }; -}); - -// --- the invariants --------------------------------------------------------- - -for (const layer of measured) { - test(`${layer.name}: every class renders at real-world aircraft scale (${layer.asset} native r=${layer.nativeRadius.toFixed(2)} × MODEL_SCALE ${layer.modelScale})`, () => { - for (const [klass, mult] of Object.entries(CLASS_SCALE_3D)) { - const worldRadius = layer.baseWorldRadius * mult; - assert.ok( - worldRadius >= WORLD_RADIUS_MIN_M && worldRadius <= WORLD_RADIUS_MAX_M, - `${klass}: world bounding radius ${worldRadius.toFixed(1)} m outside ` + - `[${WORLD_RADIUS_MIN_M}, ${WORLD_RADIUS_MAX_M}] m — MODEL_SCALE is calibrated ` + - `for a different asset's native scale`, - ); - } - }); -} - -// Grounded-model belly offset (ground-3D, 2026-07-03): each layer's -// MODEL_BELLY_OFFSET_NATIVE must equal the GLB's actual origin-above-lowest-vertex -// height, else ground-snapped models sink into (const too small) or hover above -// (too big) the sampled tile skin. Absolute tolerance = 2% of the native bounding -// radius (sub-decimeter at world scale for both assets). -for (const layer of measured) { - test(`${layer.name}: MODEL_BELLY_OFFSET_NATIVE matches ${layer.asset}'s origin height above its belly`, () => { - const tol = layer.nativeRadius * 0.02; - assert.ok( - Math.abs(layer.bellyOffsetNative - layer.originAboveBelly) <= tol, - `MODEL_BELLY_OFFSET_NATIVE ${layer.bellyOffsetNative} vs measured ` + - `${layer.originAboveBelly.toFixed(3)} (tol ${tol.toFixed(3)}) — the constant was ` + - 'calibrated for a different asset; re-measure the GLB (glTF Y-up AABB, node transforms applied)', - ); - }); -} - -test('flights and military models render at matching world sizes (cross-layer parity)', () => { - const [flights, military] = measured; - const ratio = flights.baseWorldRadius / military.baseWorldRadius; - assert.ok( - ratio > 1 / 1.5 && ratio < 1.5, - `base world radii differ ×${(ratio >= 1 ? ratio : 1 / ratio).toFixed(1)} ` + - `(flights ${flights.baseWorldRadius.toFixed(1)} m vs military ${military.baseWorldRadius.toFixed(1)} m) — ` + - 'the layers render side by side, so a class must read the same size in both', - ); -}); - -// --- 2026-08-15 Hangar fleet: real per-class GLBs --------------------------- -// CLASS_MODEL_REAL assets are vertex-baked to REAL-WORLD METERS in the -// airplane.glb axis convention and render at scale 1, so the class-envelope -// test above does not apply to them; instead each registry entry's radiusM -// and bellyM must MATCH the shipped GLB (the layers consume these numbers for -// pixel-cap math and grounded-model lift). Envelope: a real C172 (r≈7.1 m) -// up to a real 787-9 (r≈44.1 m) — nothing clamped, nothing monstrous. -const REAL_RADIUS_MIN_M = 5; -const REAL_RADIUS_MAX_M = 60; -for (const [klass, spec] of Object.entries(CLASS_MODEL_REAL)) { - test(`hangar fleet: ${klass} registry pins match the shipped GLB (${spec.url})`, () => { - const assetPath = path.join(ROOT, 'public', spec.url); - const radius = nativeBoundingRadius(assetPath); - const belly = nativeOriginAboveLowestVertex(assetPath); - assert.ok( - radius >= REAL_RADIUS_MIN_M && radius <= REAL_RADIUS_MAX_M, - `${klass}: bounding radius ${radius.toFixed(2)} m outside the real-aircraft ` + - `envelope [${REAL_RADIUS_MIN_M}, ${REAL_RADIUS_MAX_M}] m — the GLB is not baked to meters`, - ); - const tol = radius * 0.02; - assert.ok( - Math.abs(radius - spec.radiusM) <= tol, - `${klass}: registry radiusM ${spec.radiusM} vs measured ${radius.toFixed(3)} ` + - `(tol ${tol.toFixed(3)}) — re-measure the GLB and update CLASS_MODEL_REAL`, - ); - assert.ok( - Math.abs(belly - spec.bellyM) <= tol, - `${klass}: registry bellyM ${spec.bellyM} vs measured ${belly.toFixed(3)} ` + - `(tol ${tol.toFixed(3)}) — grounded models would sink or hover; update CLASS_MODEL_REAL`, - ); - }); -} - -// militaryFlights renders airplane.glb for its heavy classes with duplicated -// PLANE_* constants — pin them to the measured meter-scale GLB + flights' -// calibration so the copies cannot drift. -test('military layer airplane.glb constants match the measured GLB and flights calibration', () => { - const src = readLayerSource(path.join(ROOT, 'src/data/militaryFlights.js')); - const grab = (name) => { - const m = src.match(new RegExp(`\\bconst ${name} = ([\\d.]+);`)); - assert.ok(m, `militaryFlights.js: ${name} not found`); - return Number(m[1]); - }; - const [flights] = measured; - assert.equal(grab('PLANE_MODEL_SCALE'), flights.modelScale, 'PLANE_MODEL_SCALE must match flights MODEL_SCALE'); - assert.ok( - Math.abs(grab('PLANE_NATIVE_RADIUS_M') - flights.nativeRadius) <= flights.nativeRadius * 0.02, - `PLANE_NATIVE_RADIUS_M vs measured ${flights.nativeRadius.toFixed(3)}`, - ); - assert.ok( - Math.abs(grab('PLANE_BELLY_OFFSET_NATIVE') - flights.originAboveBelly) <= flights.nativeRadius * 0.02, - `PLANE_BELLY_OFFSET_NATIVE vs measured ${flights.originAboveBelly.toFixed(3)}`, - ); -}); - -test('shared-model visual-centre metadata matches the shipped GLBs', () => { - for (const [url, expected] of Object.entries(MODEL_VISUAL_CENTER_NATIVE)) { - const measuredCenter = nativeVisualCenter(path.join(ROOT, 'public', url)); - measuredCenter.forEach((value, axis) => { - assert.ok( - Math.abs(value - expected[axis]) <= 0.001, - `${url} axis ${axis}: metadata ${expected[axis]} vs measured ${value.toFixed(6)}`, - ); - }); - } -}); - -// The tracked trail is drawn from a point BEHIND the aircraft to this anchor, -// so the anchor decides both whether the trail terminates ON the aeroplane and -// whether the last stretch of it crosses the airframe. An earlier round put the -// anchor on the aft-belly AABB CORNER, reasoning that a point aft of every -// vertex cannot be crossed. True, but a corner is empty space — 4.80 m from any -// real geometry on airplane.glb, 6.14 m on jet.glb — so the trail ended in mid -// air beside the model. The anchor is now a genuine hull point, and these pins -// re-derive it from the POSITION BUFFERS rather than restating the table. -test('every approved aircraft model has a trail anchor on its real aft-belly hull', () => { - const urls = new Set([ - ...Object.keys(MODEL_VISUAL_CENTER_NATIVE), - ...Object.values(CLASS_MODEL_REAL).map((spec) => spec.url), - ]); - assert.deepEqual( - new Set(Object.keys(MODEL_TRAIL_ANCHOR_NATIVE)), - urls, - 'trail-anchor metadata must cover every approved aircraft GLB', - ); - - for (const url of urls) { - const file = path.join(ROOT, 'public', url); - const { verts, tris } = nativeMesh(file); - const { min, max } = vertexBounds(verts); - const centre = min.map((value, axis) => (value + max[axis]) / 2); - const actual = MODEL_TRAIL_ANCHOR_NATIVE[url]; - - // 1. ON THE HULL. The property the AABB corner failed. Measured against the - // real triangles, not against a bounding box: the corner values sat - // metres out in open air, and this is what said so. - // Measured bound across the shipped assets is <= 5e-5 native: re-measured - // from these buffers they span 5.2e-6 (bell206) to 4.1e-5 (atr72), with - // airplane.glb at 1.5e-5 and jet.glb at 1.8e-5. None is exactly zero — the - // anchor is the nearest point of a triangulated profile, not a vertex — so - // 1e-4 is met with margin and is still tight enough to catch an anchor that - // has left the hull; the old 1e-3 was loose enough to be uninformative. - const offHull = distanceToSurface(actual, verts, tris); - assert.ok( - offHull <= 1e-4, - `${url}: trail anchor must lie ON the mesh — it is ${offHull.toFixed(4)} m off the ` + - 'nearest triangle, so the trail would end in mid air beside the aircraft', - ); - - // 2. AFT of the airframe's middle, or the head segment enters at the tail - // and stops inside the fuselage — the line through the model the review - // photographed. Every shipped asset clears 80 % of its aft extreme. - assert.ok( - actual[0] > centre[0], - `${url}: trail anchor must sit aft of the centroid (${actual[0]} <= ${centre[0]})`, - ); - assert.ok( - actual[0] >= max[0] * 0.8, - `${url}: trail anchor must sit on the AFT hull — ${actual[0].toFixed(3)} is only ` + - `${(actual[0] / max[0] * 100).toFixed(1)}% of the ${max[0].toFixed(3)} aft extreme`, - ); - - // 3. BELOW: nothing of the aircraft hangs lower than the anchor at or - // behind its own station, so the trail leaves from underneath rather - // than out of the roof. Stated against the station rather than against - // the global minimum on purpose — the global lowest vertex is a wingtip - // or a gear leg amidships, and demanding the anchor match it is what - // drove the previous round off the hull in the first place. - const profile = centrelineProfile(verts, tris); - const lowestAtOrAft = profile - .filter((p) => p[0] >= actual[0] - 1e-3) - .reduce((lowest, p) => Math.min(lowest, p[1]), Infinity); - assert.ok( - Number.isFinite(lowestAtOrAft) && actual[1] <= lowestAtOrAft + 1e-3, - `${url}: trail anchor must be the lowest centreline hull at or aft of its station ` + - `(anchor ${actual[1]}, hull reaches ${lowestAtOrAft})`, - ); - - // 4. ON THE CENTRELINE, so the attachment cannot swing screen-left or - // screen-right as the aircraft turns. - assert.equal(actual[2], 0, `${url}: trail anchor must stay on the longitudinal centreline`); - - // 5. And the value itself is the closest centreline hull point to the - // aft-belly corner — the construction, re-derived. This is what makes - // the table re-measurable rather than re-guessable. - const corner = [max[0], min[1], 0]; - const nearest = closestProfilePoint(corner, profile, tris, verts); - [0, 1].forEach((axis) => { - assert.ok( - Math.abs(actual[axis] - nearest[axis]) <= 1e-3, - `${url} trail axis ${axis}: metadata ${actual[axis]} vs re-measured ${nearest[axis].toFixed(6)}`, - ); - }); +test('every aircraft layer takes its model and scale from the fleet set, no layer scale of its own', () => { + for (const file of AIRCRAFT_LAYERS) { + const source = fs.readFileSync(path.join(ROOT, file), 'utf8'); + assert.match(source, /\baircraftLayerModelSpec\b/, `${file}: models come from aircraftLayerModelSpec`); + assert.match(source, /\bcreateFleetModel\(/, `${file}: models are built from the loaded fleet set`); + assert.doesNotMatch(source, /\bMODEL_SCALE\b|CLASS_SCALE_3D|\.glb['"]/, `${file}: no layer-owned model scale or asset`); } }); @@ -621,60 +95,46 @@ test('every approved aircraft model has a trail anchor on its real aft-belly hul // // A flat 1-D rig stands in for the world here: the model centre at the origin, // the aft-belly anchor at its own measured station, and the last body point -// swept out along +x. That is exactly the geometry the guard reasons about — -// two distances from one centre — so the arithmetic under test is the shipped -// arithmetic, with no transform chain in the way (that is the pin below). -const TRAIL_RIG_URLS = new Set([ - ...Object.keys(MODEL_VISUAL_CENTER_NATIVE), - ...Object.values(CLASS_MODEL_REAL).map((spec) => spec.url), -]); +// swept out along +x. +const TRAIL_RIG_MODELS = Object.keys(FLEET_TRAIL_ANCHORS); const trailRigAt = (d) => ({ x: d, y: 0, z: 0 }); const trailRigCentre = trailRigAt(0); /** The rig's world anchor: the model's own measured attachment point, laid out - * aft (+x) and below (y) exactly as far as it really sits from the centre. */ -function trailRigAnchor(url) { - const native = trailAnchorForModel(url); + * aft (+x) and vertically exactly as far as it really sits from the centre. */ +function trailRigAnchor(name) { + const native = FLEET_TRAIL_ANCHORS[name]; return { x: native[0] || 0, y: native[1] || 0, z: 0 }; } -/** Visible length of the head segment the guard would have drawn, in metres. - * Zero means nothing is drawn — which is the answer the whole guard exists for. */ +function trailRigEnvelope(name) { + const { min, max } = set.catalog[name]; + return Math.hypot(max[0] - min[0], max[1] - min[1], max[2] - min[2]) / 2; +} +/** Visible length of the head segment the guard would have drawn, in metres. */ function drawnHeadLength(start, anchor, centre, envelope) { const from = trailHeadStart(start, anchor, centre, envelope, { x: 0, y: 0, z: 0 }); if (!from) return 0; return Math.hypot(from.x - anchor.x, from.y - anchor.y, from.z - anchor.z); } +// The review's repro, in the numbers it used: an airliner of 34.41 m envelope +// whose aft-belly anchor sits 24.09 m aft and 2.22 m down. +const AIRLINER_ENVELOPE = 34.41; +const AIRLINER_ANCHOR = { x: 24.0879, y: -2.2163, z: 0 }; test('a stationary contact draws no trail head, and a moving one draws all of it', () => { - for (const url of TRAIL_RIG_URLS) { - const envelope = nativeBoundingRadius(path.join(ROOT, 'public', url)); - const anchor = trailRigAnchor(url); - // Parked: the last body point is where the aircraft is. Nothing at all — - // not a short segment, not a degenerate one. Nothing. + for (const name of TRAIL_RIG_MODELS) { + const envelope = trailRigEnvelope(name); + const anchor = trailRigAnchor(name); assert.equal( trailHeadStart(trailRigCentre, anchor, trailRigCentre, envelope, { x: 0, y: 0, z: 0 }), null, - `${url}: a parked contact's head segment is the anchor offset, not motion, ` + - `and must not be drawn (envelope ${envelope.toFixed(2)} m)`, + `${name}: a parked contact's head segment must not be drawn (envelope ${envelope.toFixed(2)} m)`, ); - // Still nothing while the last body point is no further out than the - // anchor's own station: every millimetre of that segment would be drawn - // FORWARD of the attachment point, into the fuselage. const station = Math.hypot(anchor.x, anchor.y, anchor.z); assert.equal(drawnHeadLength(trailRigAt(station * 0.5), anchor, trailRigCentre, envelope), 0, - `${url}: a segment that would run forward of its own anchor draws nothing`); - // At and past the envelope the segment is real and must be drawn IN FULL so - // it reaches the hull — the failure mode clipping introduced. Referentially - // the caller's own start point, so a contact that has cleared its own size - // is bit-for-bit what the containment rule drew. + `${name}: a segment that would run forward of its own anchor draws nothing`); const clear = trailRigAt(envelope * 1.01); assert.equal(trailHeadStart(clear, anchor, trailRigCentre, envelope, { x: 0, y: 0, z: 0 }), clear, - `${url}: once the contact clears its own envelope the whole head must draw`); + `${name}: once the contact clears its own envelope the whole head must draw`); } - - // The review's repro: airplane.glb, a prior fix 58 m aft of a 34.41 m - // envelope. The old length test suppressed this entirely; ~23.6 m of it is in - // open air, so it must draw. - const airliner = nativeBoundingRadius(path.join(ROOT, 'public', '/models/airplane.glb')); - const anchor = trailRigAnchor('/models/airplane.glb'); for (const [d, why] of [ [58, 'a fix 58 m aft is well outside the envelope and its segment must be drawn'], [7500, 'a jet covers ~7.5 km between fixes — that trail must never be shortened'], @@ -682,34 +142,22 @@ test('a stationary contact draws no trail head, and a moving one draws all of it ]) { const start = trailRigAt(d); assert.equal( - trailHeadStart(start, anchor, trailRigCentre, airliner, { x: 0, y: 0, z: 0 }), start, why, + trailHeadStart(start, AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE, { x: 0, y: 0, z: 0 }), start, why, ); } - // No model drawing (billboard owns the visual): nothing to cut through. const loose = trailRigAt(5); - assert.equal(trailHeadStart(loose, anchor, null, 0, { x: 0, y: 0, z: 0 }), loose, + assert.equal(trailHeadStart(loose, AIRLINER_ANCHOR, null, 0, { x: 0, y: 0, z: 0 }), loose, 'with no model envelope the whole segment always draws'); }); -// THE PIN FOR THE BOUNDARY FLASH. -// -// The containment rule this replaces was BINARY, and a binary rule at -// airplane.glb's 34.41 m envelope is a 10.33 m flash: a start at 34.40 m drew -// nothing, a start at 34.42 m drew the entire segment, and a contact whose next -// fix landed back inside dropped it again. Two centimetres of travel cannot -// change ten metres of what the operator sees, and the pin the round shipped -// with could not catch it because it only ever asked static questions. -// -// So this asks the continuity question directly: sweep the start out through -// the boundary in small steps and require the VISIBLE LENGTH to move by no more -// than the step allows. The bound is 3x the step — the drawn length is at worst -// the segment's own growth (1:1 with the step) plus the reveal sliding across a -// shell that is never shorter than the segment it reveals, so 3 is a ceiling -// with room, and it is nowhere near the 10.33 m the boolean produced. +// The containment rule trailHeadStart replaced was BINARY: at a 34.41 m +// envelope a start at 34.40 m drew nothing and one at 34.42 m the whole +// 10.33 m segment. Sweep the start out through the boundary in small steps and +// require the VISIBLE LENGTH to move by no more than the step allows. test('the trail head grows continuously across the envelope, never in one step', () => { - for (const url of TRAIL_RIG_URLS) { - const envelope = nativeBoundingRadius(path.join(ROOT, 'public', url)); - const anchor = trailRigAnchor(url); + for (const name of TRAIL_RIG_MODELS) { + const envelope = trailRigEnvelope(name); + const anchor = trailRigAnchor(name); const step = envelope / 400; let previous = 0; for (let d = 0; d <= envelope * 1.5; d += step) { @@ -717,74 +165,55 @@ test('the trail head grows continuously across the envelope, never in one step', const length = drawnHeadLength(start, anchor, trailRigCentre, envelope); assert.ok( length - previous <= step * 3 + 1e-9 && previous - length <= step * 3 + 1e-9, - `${url}: the head jumped ${Math.abs(length - previous).toFixed(3)} m across a ` + - `${step.toFixed(3)} m step at ${d.toFixed(3)} m — that is the flash, not motion`, + `${name}: the head jumped ${Math.abs(length - previous).toFixed(3)} m across a ` + + `${step.toFixed(3)} m step at ${d.toFixed(3)} m`, ); - // Never longer than the segment actually being drawn: the guard only ever - // takes trail away from the START, and only ever from inside the envelope. const full = Math.hypot(start.x - anchor.x, start.y - anchor.y, start.z - anchor.z); assert.ok(length <= full + 1e-9, - `${url}: drew ${length.toFixed(3)} m of a ${full.toFixed(3)} m segment at ${d.toFixed(3)} m`); + `${name}: drew ${length.toFixed(3)} m of a ${full.toFixed(3)} m segment at ${d.toFixed(3)} m`); previous = length; } } - - // The exact repro, in the numbers the review used. - const airliner = nativeBoundingRadius(path.join(ROOT, 'public', '/models/airplane.glb')); - const anchor = trailRigAnchor('/models/airplane.glb'); - const inside = drawnHeadLength(trailRigAt(34.40), anchor, trailRigCentre, airliner); - const outside = drawnHeadLength(trailRigAt(34.42), anchor, trailRigCentre, airliner); + const inside = drawnHeadLength(trailRigAt(34.40), AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE); + const outside = drawnHeadLength(trailRigAt(34.42), AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE); assert.ok(outside > 10, `the segment 34.42 m aft is ~10.3 m long and must draw (${outside})`); assert.ok(Math.abs(outside - inside) < 0.1, - 'crossing the 34.41 m envelope by 2 cm must change the trail by centimetres, ' + - `not by the whole ${outside.toFixed(2)} m segment (moved ${Math.abs(outside - inside).toFixed(3)} m)`); - - // And the flap: a contact whose fixes cross back and forth over the boundary - // must not strobe the whole segment on and off. Each visit draws essentially - // the same partial length as the last. + `crossing the envelope by 2 cm moved the trail ${Math.abs(outside - inside).toFixed(3)} m`); const flap = [34.40, 34.42, 34.40, 34.43, 34.39].map( - (d) => drawnHeadLength(trailRigAt(d), anchor, trailRigCentre, airliner)); - const spread = Math.max(...flap) - Math.min(...flap); - assert.ok(spread < 0.1, - `an in/out/in flap across the boundary moved the trail ${spread.toFixed(3)} m — ` + - 'the boolean rule moved the whole segment, and that is what the operator saw'); - assert.ok(Math.min(...flap) > 10, - 'every visit in that flap is a real ~10 m segment: the inside ones are partial, not absent'); + (d) => drawnHeadLength(trailRigAt(d), AIRLINER_ANCHOR, trailRigCentre, AIRLINER_ENVELOPE)); + assert.ok(Math.max(...flap) - Math.min(...flap) < 0.1, 'an in/out/in flap must not strobe the trail'); + assert.ok(Math.min(...flap) > 10, 'every visit in that flap is a real ~10 m segment'); }); -// THE PIN THAT WOULD HAVE CAUGHT THE 2026-08-23 FIELD REGRESSION. -// // The anchor is expressed in the model's own axes, and it only lands where it // is meant to if it rides the SAME transform chain Cesium renders the model -// with. It did not: the table was stored after a single glTF Y-up -> Z-up step -// and multiplied straight by `modelMatrix`, which is half of Cesium's -// correction (the `fromGltfAsync` defaults also apply Z_UP_TO_X_UP). The -// aircraft's longitudinal axis is raw glTF X, so the aft offset landed on the -// RENDERED model's LATERAL axis — a 90-degree error that `modelMatrix`'s -// heading then rotated into world space, putting the trail out to one side and -// flipping which side as the course changed. -// -// A single-heading harness cannot catch this, which is exactly how it reached -// the owner: at one heading a wrongly-framed offset can coincidentally point -// aft. So this sweeps headings and every shipped asset, and asserts the -// property directly — the anchor lies in the model's longitudinal/vertical -// plane, with NO lateral component. -// -// The mock carries a NON-IDENTITY `sceneGraph.components.transform`, because -// the chain has a third link and a mock without one cannot tell whether the -// code reads it: Cesium renders `modelMatrix x components.transform x -// axisCorrection`, and an asset with a baked root transform has to ride all -// three. A scale and an offset ALONG the model's own axes is the strongest -// choice the property survives — it changes both numbers the pin asserts -// (identity would leave them at the raw table values, which is the answer a -// dropped link also gives) while leaving lateral zero, which is the regression. +// with (`modelMatrix x components.transform x axisCorrection`). A table stored +// after half of Cesium's axis correction once put the aft offset on the +// rendered model's LATERAL axis — invisible at a single heading. So this sweeps +// headings and every fleet anchor, and asserts the anchor lies in the model's +// longitudinal/vertical plane, aft and at its own height. The mock carries a +// NON-IDENTITY root transform so a dropped link in the chain fails too. const TRAIL_ROOT_SCALE = 2; const TRAIL_ROOT_OFFSET = Object.freeze({ lengthwise: 5, vertical: -1 }); +function headingAxes(origin, headingDeg) { + const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); + const axis = (i) => { + const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4()); + return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3()); + }; + const east = axis(0); const north = axis(1); const vertical = axis(2); + const rad = Cesium.Math.toRadians(headingDeg); + const lengthwise = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( + Cesium.Cartesian3.multiplyByScalar(north, Math.cos(rad), new Cesium.Cartesian3()), + Cesium.Cartesian3.multiplyByScalar(east, Math.sin(rad), new Cesium.Cartesian3()), + new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const lateral = Cesium.Cartesian3.normalize( + Cesium.Cartesian3.cross(lengthwise, vertical, new Cesium.Cartesian3()), new Cesium.Cartesian3()); + return { lengthwise, vertical, lateral }; +} + test('the trail anchor rides the rendered longitudinal axis at every heading', () => { const position = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 3000); - const sub = (a, b) => Cesium.Cartesian3.subtract(a, b, new Cesium.Cartesian3()); - // Cesium-model-local: x is lateral, y lengthwise, z vertical once the axis - // correction has mapped raw glTF [x, y, z] -> [z, x, y]. const rootTransform = Cesium.Matrix4.fromTranslationQuaternionRotationScale( new Cesium.Cartesian3(0, TRAIL_ROOT_OFFSET.lengthwise, TRAIL_ROOT_OFFSET.vertical), Cesium.Quaternion.IDENTITY, @@ -796,133 +225,119 @@ test('the trail anchor rides the rendered longitudinal axis at every heading', ( const modelMatrix = Cesium.Transforms.headingPitchRollToFixedFrame( position, hpr, Cesium.Ellipsoid.WGS84, undefined, new Cesium.Matrix4(), ); - for (const [url, anchor] of Object.entries(MODEL_TRAIL_ANCHOR_NATIVE)) { + const origin = Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3()); + const { lengthwise, vertical, lateral } = headingAxes(origin, headingDeg); + for (const [name, anchor] of Object.entries(FLEET_TRAIL_ANCHORS)) { const model = { modelMatrix, computedScale: 1, sceneGraph: { components: { transform: rootTransform } }, }; - const origin = Cesium.Matrix4.getTranslation(modelMatrix, new Cesium.Cartesian3()); const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3()); - // Reference axes derived INDEPENDENTLY, from the ENU frame and the same - // heading the matrix was built with — deliberately NOT through - // modelAnchorWorld. Taking them through the function under test makes the - // check self-consistent: a wrong transform rotates the anchor and the - // reference frame together and the lateral component stays zero, which is - // how the first version of this pin passed against the very bug it was - // written for. - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); - const axis = (i) => { - const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4()); - return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3()); - }; - const east = axis(0); const north = axis(1); const vertical = axis(2); - const rad = Cesium.Math.toRadians(headingDeg); - const lengthwise = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(north, Math.cos(rad), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(east, Math.sin(rad), new Cesium.Cartesian3()), - new Cesium.Cartesian3()), new Cesium.Cartesian3()); - const lateral = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(lengthwise, vertical, new Cesium.Cartesian3()), new Cesium.Cartesian3()); - const offset = sub(world, origin); + const offset = Cesium.Cartesian3.subtract(world, origin, new Cesium.Cartesian3()); + const where = `${name} @ heading ${headingDeg}`; + const side = Cesium.Cartesian3.dot(offset, lateral); + assert.ok(Math.abs(side) < 1e-4, `${where}: the anchor must have NO lateral component (got ${side.toFixed(4)} m)`); const along = Cesium.Cartesian3.dot(offset, lengthwise); const up = Cesium.Cartesian3.dot(offset, vertical); - const side = Cesium.Cartesian3.dot(offset, lateral); - const where = `${url} @ heading ${headingDeg}`; - // The anchor must lie in the vertical plane through the heading. The - // regression put the whole longitudinal offset on THIS axis. - assert.ok(Math.abs(side) < 1e-4, - `${where}: the anchor must have NO lateral component (got ${side.toFixed(4)} m) — ` + - 'a sideways offset is the field regression, and it is invisible at a single heading'); - // SIGNED, not magnitudes. An `abs()` on both sides passes an anchor that - // landed on the nose instead of the tail, or on the roof instead of the - // belly — which are the two directions the table exists to fix. The - // expected values carry the root transform, so a dropped `components` - // link fails here rather than quietly rendering the raw table value. const expectedAlong = anchor[0] * TRAIL_ROOT_SCALE + TRAIL_ROOT_OFFSET.lengthwise; const expectedUp = anchor[1] * TRAIL_ROOT_SCALE + TRAIL_ROOT_OFFSET.vertical; assert.ok(Math.abs(along - expectedAlong) < 1e-4, - `${where}: the longitudinal offset must land AFT along the heading ` + - `(${along.toFixed(4)} vs ${expectedAlong.toFixed(4)})`); + `${where}: longitudinal offset ${along.toFixed(4)} vs ${expectedAlong.toFixed(4)}`); assert.ok(Math.abs(up - expectedUp) < 1e-4, - `${where}: the belly offset must land BELOW on the vertical axis ` + - `(${up.toFixed(4)} vs ${expectedUp.toFixed(4)})`); + `${where}: vertical offset ${up.toFixed(4)} vs ${expectedUp.toFixed(4)}`); } } }); -// A helicopter is in the sweep above by asset, but the field report also had a -// hovering H60 whose course is ill-defined. The anchor must not depend on the -// contact having a meaningful heading — it is a model-space offset, so an -// arbitrary heading still puts it aft of the hull. +// A hovering helicopter's course is ill-defined. The anchor is a model-space +// offset, so an arbitrary heading still puts it on the hull's centreline. test('a hovering rotorcraft anchors aft of its own hull, whatever its heading', () => { const position = Cesium.Cartesian3.fromDegrees(-97.7, 30.2, 300); - const anchor = MODEL_TRAIL_ANCHOR_NATIVE['/models/bell206.glb']; + const spec = aircraftModelSpec(set, { typeCode: 'H60' }); + const anchor = spec.trailAnchorNative; + assert.ok(anchor[0] > 0, 'the rotorcraft anchor sits aft'); for (const headingDeg of [0, 137.5, 271.9]) { const hpr = new Cesium.HeadingPitchRoll(Cesium.Math.toRadians(headingDeg), 0, 0); const model = { modelMatrix: Cesium.Transforms.headingPitchRollToFixedFrame( position, hpr, Cesium.Ellipsoid.WGS84, undefined, new Cesium.Matrix4()), - computedScale: 1, + computedScale: spec.scale, }; const origin = Cesium.Matrix4.getTranslation(model.modelMatrix, new Cesium.Cartesian3()); const world = modelAnchorWorld(model, anchor, new Cesium.Cartesian3()); - const enu = Cesium.Transforms.eastNorthUpToFixedFrame(origin, Cesium.Ellipsoid.WGS84, new Cesium.Matrix4()); - const ax = (i) => { - const c = Cesium.Matrix4.getColumn(enu, i, new Cesium.Cartesian4()); - return Cesium.Cartesian3.normalize(new Cesium.Cartesian3(c.x, c.y, c.z), new Cesium.Cartesian3()); - }; - const rad = Cesium.Math.toRadians(headingDeg); - const fwd = Cesium.Cartesian3.normalize(Cesium.Cartesian3.add( - Cesium.Cartesian3.multiplyByScalar(ax(1), Math.cos(rad), new Cesium.Cartesian3()), - Cesium.Cartesian3.multiplyByScalar(ax(0), Math.sin(rad), new Cesium.Cartesian3()), - new Cesium.Cartesian3()), new Cesium.Cartesian3()); - const lateral = Cesium.Cartesian3.normalize( - Cesium.Cartesian3.cross(fwd, ax(2), new Cesium.Cartesian3()), new Cesium.Cartesian3()); + const { lateral } = headingAxes(origin, headingDeg); const side = Cesium.Cartesian3.dot( Cesium.Cartesian3.subtract(world, origin, new Cesium.Cartesian3()), lateral); assert.ok(Math.abs(side) < 1e-4, - `bell206 @ heading ${headingDeg}: hovering rotorcraft anchor drifted sideways by ${side.toFixed(4)} m`); + `${spec.model} @ heading ${headingDeg}: hovering rotorcraft anchor drifted sideways by ${side.toFixed(4)} m`); } }); -test('legacy aircraft GLBs keep scale and orientation baked with no node transforms', () => { - for (const url of Object.keys(MODEL_VISUAL_CENTER_NATIVE)) { - const file = path.join(ROOT, 'public', url); - const gltf = glbJson(file); - for (const node of gltf.nodes || []) { - assert.equal(node.matrix, undefined, `${url}: ${node.name || 'node'} has an unapplied matrix`); - assert.equal(node.translation, undefined, `${url}: ${node.name || 'node'} has unapplied translation`); - assert.equal(node.rotation, undefined, `${url}: ${node.name || 'node'} has unapplied rotation`); - assert.equal(node.scale, undefined, `${url}: ${node.name || 'node'} has unapplied scale`); - } - const { min, max } = nativeBounds(file); - const extents = max.map((value, axis) => value - min[axis]); - assert.ok( - extents[1] < extents[0] && extents[1] < extents[2], - `${url}: glTF +Y must remain the short vertical axis; got ${extents.map((v) => v.toFixed(2)).join(' × ')}`, - ); - } -}); - -test('real per-class models remain origin-centred for visual anchoring', () => { - for (const spec of Object.values(CLASS_MODEL_REAL)) { - const center = nativeVisualCenter(path.join(ROOT, 'public', spec.url)); - const maxOffset = Math.max(...center.map(Math.abs)); - assert.ok( - maxOffset <= 0.001, - `${spec.url}: visual centre ${center.map((v) => v.toFixed(4)).join(', ')} ` + - 'must stay at the origin or receive explicit modelVisualAnchor metadata', - ); - } -}); +function normalBillboardScaleByDistance(sourceFile) { + const src = readLayerSource(path.join(ROOT, sourceFile)); + const fn = src.match(/function (?:parts\.\w+\.)?_normalBillboardScaleByDistance\(\) \{([\s\S]*?)\n\}/); + assert.ok(fn, `${sourceFile}: _normalBillboardScaleByDistance not found`); + const scalar = fn[1].match(/NearFarScalar\((\d+), ([\d.]+), (\d+), ([\d.]+)\)/); + assert.ok(scalar, `${sourceFile}: billboard NearFarScalar not found`); + return scalar.slice(1).map(Number); +} test('civilian and military globe-view aircraft retain the established 3.0 near scale and 0.5 floor', () => { - for (const layer of LAYERS) { + for (const [name, source] of [ + ['flights', 'src/data/flights.js'], + ['military', 'src/data/militaryFlights.js'], + ]) { assert.deepEqual( - normalBillboardScaleByDistance(layer.source), + normalBillboardScaleByDistance(source), [1000, 3, 8000000, 0.5], - `${layer.name}: owner-established close sizing and the orbital floor must remain readable`, + `${name}: owner-established close sizing and the orbital floor must remain readable`, ); } }); + +test('a layer asking every tick gets one cached spec per type and category', async () => { + const { aircraftLayerModelSpec } = await import('../layers/flights/modelSpec.js'); + const a = aircraftLayerModelSpec({ typeCode: 'A320' }, set); + assert.equal(aircraftLayerModelSpec({ typeCode: 'A320' }, set), a); + assert.notEqual(aircraftLayerModelSpec({ typeCode: 'B744' }, set), a); + assert.ok(Object.isFrozen(a)); +}); + +test('an airport vehicle is cached by its callsign; other aircraft by type and category only', async () => { + const { aircraftLayerModelSpec, aircraftModelSpecCacheKey } = await import( + '../layers/flights/modelSpec.js' + ); + const tender = aircraftLayerModelSpec( + { typeCode: 'ZVEH', callsign: 'FIRE 2' }, + set, + ); + const car = aircraftLayerModelSpec( + { typeCode: 'ZVEH', callsign: 'LEADER 7' }, + set, + ); + assert.equal(tender.model, 'fire_truck'); + assert.equal(car.model, 'car'); + assert.equal( + aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'BAW1' }, set), + aircraftModelSpecCacheKey({ typeCode: 'A320', callsign: 'DLH2' }, set), + ); + assert.notEqual( + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set), + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set), + ); + // Keyed by the rule matched, not the callsign: at most one entry per rule + // and one for the type's own model. + assert.equal( + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 2' }, set), + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set), + ); + assert.equal( + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'LEADER 7' }, set), + aircraftModelSpecCacheKey({ typeCode: 'ZVEH', callsign: 'ASD' }, set), + ); + assert.equal( + aircraftLayerModelSpec({ typeCode: 'ZVEH', callsign: 'FIRE 9' }, set), + tender, + ); +}); diff --git a/src/data/modelVisualAnchor.js b/src/data/modelVisualAnchor.js index 733cd5049..6659fda8f 100644 --- a/src/data/modelVisualAnchor.js +++ b/src/data/modelVisualAnchor.js @@ -1,74 +1,15 @@ import * as Cesium from 'cesium'; + /** - * Model-space visual centres measured from the shipped GLBs' scene-space AABBs. + * Model-space anchors on the aircraft layers' fleet models: where brackets + * and readouts sit (a model's visual centre) and where a tracked trail + * attaches (its aft-belly point, measured per model in fleetTrailAnchors.js + * and handed to the layers with each model's spec by fleetModels.js). * - * glTF stores these assets Y-up. Cesium's model frame is Z-up, so a glTF - * centre `[x, y, z]` becomes `[x, -z, y]` before the layer's ENU modelMatrix - * is applied. Values remain in native model units; callers supply the Model's + * Values are native model units in raw glTF axes; callers supply the Model's * effective rendered `computedScale` because Cesium does not bake it into * modelMatrix and may raise it above `scale` to satisfy minimumPixelSize. */ -const CENTERED_MODEL = Object.freeze([0, 0, 0]); - -export const MODEL_VISUAL_CENTER_NATIVE = Object.freeze({ - '/models/airplane.glb': CENTERED_MODEL, - '/models/jet.glb': CENTERED_MODEL, -}); - -/** - * Aft-belly trail attachment points, measured on each approved model's actual - * MESH SURFACE. Every bundled asset is baked to one convention (see - * aircraftClass.js): origin at the bbox centre, X = length with the NOSE at - * local −X, glTF Y = up (Cesium-local Z here). - * - * Each value is the point of the hull's CENTRELINE PROFILE (the y = 0 slice of - * the mesh) closest to that model's aft-belly AABB corner. Three things follow - * from that construction, and each is asserted rather than described in - * modelScale.test.mjs: - * - * ON THE HULL. This is the correction that matters. The previous round put - * the anchor ON the AABB corner itself, reasoning that an anchor aft of - * every vertex makes non-intersection geometric. It does — but an AABB - * corner is EMPTY SPACE, and on these airframes it is a long way from - * any of them: 4.80 m out for airplane.glb and 6.14 m for jet.glb in - * native units, which at the 450 m framing is a visible gap between the - * aircraft and the end of its own trail. The trail must terminate on the - * aeroplane, so the anchor is a surface point. - * - * AFT. The head segment is drawn from a point behind the aircraft to this - * anchor, so an anchor near the middle makes the segment enter at the - * tail and stop mid-fuselage — a line drawn through the airframe. These - * sit at 83–96 % of each model's aft extreme, on the aft-most hull the - * centreline has. - * - * BELOW. Nothing of the aircraft is lower than the anchor at or behind its - * own station, so the trail leaves from underneath the way a wake does - * rather than out of the roof. On most assets that is a frank belly - * point; on jet.glb the aft centreline profile has swept up into the - * tail cone by then, so the anchor sits just above the model's vertical - * centre (+0.13 native on a 5.63 m half-height) at the exhaust — which - * is where a fast jet's wake leaves it anyway. - * - * Y stays on the centreline — the profile is sliced at y = 0 rather than taking - * the nearest surface point in the round — so the attachment cannot drift - * screen-left or screen-right as the aircraft turns. (The unconstrained nearest - * point would put jet.glb's anchor 1.29 m off-axis.) Values are native model - * units; callers scale them by Cesium's effective `computedScale`. - * - * Locked against the shipped GLBs by modelScale.test.mjs, which re-reads the - * POSITION buffers and re-derives every property above — re-measure, never - * re-guess, when an asset changes. - */ -export const MODEL_TRAIL_ANCHOR_NATIVE = Object.freeze({ - '/models/airplane.glb': Object.freeze([24.0879, -2.2163, 0]), - '/models/jet.glb': Object.freeze([18.9103, 0.1266, 0]), - '/models/bell206.glb': Object.freeze([5.2077, -0.0005, 0]), - '/models/c172.glb': Object.freeze([3.6816, -0.2425, 0]), - '/models/citation2.glb': Object.freeze([6.8804, -0.6136, 0]), - '/models/mq9.glb': Object.freeze([5.1951, -1.1516, 0]), - '/models/b789.glb': Object.freeze([30.1007, -3.4814, 0]), - '/models/atr72.glb': Object.freeze([12.9785, -1.2935, 0]), -}); /** @constant Cesium's OWN axis correction for a glTF loaded with the * `Model.fromGltfAsync` defaults (upAxis Y, forwardAxis Z), assembled from its @@ -134,27 +75,6 @@ export function modelAnchorWorld(model, nativeAnchor, result) { return Cesium.Matrix4.multiplyByPoint(_chainScratch, _anchorScratch, result); } -/** - * Return the model-local visual centre for an approved bundled GLB. - * Assets absent from the table were audited as origin-centred. - * @param {string} url Bundled model URL. - * @returns {readonly number[]} Cesium-local `[x, y, z]` native-unit offset. - */ -export function visualCenterForModel(url) { - return MODEL_VISUAL_CENTER_NATIVE[url] || CENTERED_MODEL; -} - -/** - * Return the model-local aft-belly attachment point for a tracked trail. - * Unknown future assets safely fall back to their visual centre until their - * geometry is measured and added to the table. - * @param {string} url Bundled model URL. - * @returns {readonly number[]} Cesium-local `[x, y, z]` native-unit offset. - */ -export function trailAnchorForModel(url) { - return MODEL_TRAIL_ANCHOR_NATIVE[url] || visualCenterForModel(url); -} - /** * Where a trail head segment should START being drawn: the whole segment, a * shortened piece of it, or nothing at all. @@ -171,7 +91,7 @@ export function trailAnchorForModel(url) { * mistake matters: * * - Testing segment LENGTH against the radius hid real trail. With - * `airplane.glb`'s 34.41 m radius and 24.09 m tail anchor, a prior fix 58 m + * an airliner model's 34.41 m radius and 24.09 m tail anchor, a prior fix 58 m * aft gives a 33.98 m segment — under the radius, so suppressed — even * though ~23.6 m of it is in open air behind the aircraft. It also made the * whole segment pop in at once on crossing ~58.4 m. diff --git a/src/data/openVesselPhoto.js b/src/data/openVesselPhoto.js new file mode 100644 index 000000000..d0749b1aa --- /dev/null +++ b/src/data/openVesselPhoto.js @@ -0,0 +1,135 @@ +/** + * @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; +} diff --git a/src/data/openVesselPhoto.test.mjs b/src/data/openVesselPhoto.test.mjs new file mode 100644 index 000000000..e43fe397c --- /dev/null +++ b/src/data/openVesselPhoto.test.mjs @@ -0,0 +1,120 @@ +// 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://user@openvesselphotos.org/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, + }); +}); diff --git a/src/data/trackedModelRegime.test.mjs b/src/data/trackedModelRegime.test.mjs index 17fec219d..e673775cd 100644 --- a/src/data/trackedModelRegime.test.mjs +++ b/src/data/trackedModelRegime.test.mjs @@ -45,6 +45,18 @@ import militaryFlightsLayer, { } from './militaryFlights.js'; import { clearTr3bRegistry, setTr3b } from './tr3bRegistry.js'; import { reportMeshFloorCell, _clearMeshFloorCellsForTest } from './groundFloor.js'; +import { loadFleetModelSet } from './fleetModels.js'; +import { aircraftLayerModelSpec } from '../layers/flights/modelSpec.js'; +import { fetchPublicFile } from '../testSupport/fleetGlb.mjs'; + +// The layers draw only once the fleet model set has loaded; load it from disk. +// Model creation itself is stubbed per test through Cesium.Model.fromGltfAsync. +const _silentInfo = console.info; +console.info = () => {}; +await loadFleetModelSet({ fetchImpl: fetchPublicFile }); +console.info = _silentInfo; +const FLIGHTS_META = Object.freeze({ typeCode: 'A320', klass: 'airliner' }); +const MILITARY_META = Object.freeze({ typeCode: 'F16', klass: 'fastjet' }); // Sample altitudes are expressed RELATIVE to the band rather than as absolute // offsets. The thresholds have already been retuned once (1_000_000 → 150_000 @@ -201,7 +213,8 @@ const LAYERS = [ driveFleetModel: flightsDriveFleetModel, ensureFleetModel: flightsEnsureFleetModel, trackedBillboardColor: flightsTrackedBillboardColor, - bellyM: 6.719, + /** How far a grounded A320 stands its origin above the ground (scaled wheel clearance). */ + bellyM: aircraftLayerModelSpec(FLIGHTS_META).bellyM, seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) { const billboard = contactBillboard(); _setTrackedFlightRefreshStateForTest({ @@ -213,7 +226,7 @@ const LAYERS = [ tracked, models, modelCollection, - meta: { callsign: 'TEST123 ', altitude: 10_668, klass: 'airliner', onGround }, + meta: { callsign: 'TEST123 ', altitude: 10_668, ...FLIGHTS_META, onGround }, }); return billboard; }, @@ -226,7 +239,7 @@ const LAYERS = [ driveFleetModel: militaryDriveFleetModel, ensureFleetModel: militaryEnsureFleetModel, trackedBillboardColor: militaryTrackedBillboardColor, - bellyM: 5.631 * 0.8, + bellyM: aircraftLayerModelSpec(MILITARY_META).bellyM, seed({ viewer, tracked = true, icao24 = ICAO, modelCollection = null, onGround = false, models = [] }) { const billboard = contactBillboard(); _setTrackedMilitaryRefreshStateForTest({ @@ -238,7 +251,7 @@ const LAYERS = [ tracked, models, modelCollection, - meta: { callsign: 'TEST123 ', altitudeFt: 35_000, klass: 'fastjet', onGround }, + meta: { callsign: 'TEST123 ', altitudeFt: 35_000, ...MILITARY_META, onGround }, }); return billboard; }, @@ -841,8 +854,8 @@ for (const fixture of LAYERS) { Date.now = () => now; let attempts = 0; const requested = []; - Cesium.Model.fromGltfAsync = ({ url }) => { - attempts++; requested.push(url); + Cesium.Model.fromGltfAsync = ({ basePath }) => { + attempts++; requested.push(basePath); return Promise.reject(new Error('simulated missing GLB')); }; // The give-up path warns once by design; keep the run's output clean. diff --git a/src/data/trackedReadout.test.mjs b/src/data/trackedReadout.test.mjs index 425a69ceb..a6b69c7e4 100644 --- a/src/data/trackedReadout.test.mjs +++ b/src/data/trackedReadout.test.mjs @@ -291,7 +291,7 @@ test('tracking layers write gevLabelModel and expose only their cached display p assert.equal(sources['satellites.js'].includes('_trackedEntity.label.text'), false); }); -test('civilian and military trail heads use the lower-centre model anchor and weak-texture tint', async () => { +test('civilian and military trail heads use the lower-centre model anchor', async () => { const files = await Promise.all(['flights.js', 'militaryFlights.js'].map(async (name) => ( [name, readLayerSource(new URL(`./${name}`, import.meta.url))] ))); @@ -300,7 +300,5 @@ test('civilian and military trail heads use the lower-centre model anchor and we (/const\s*head\s*=\s*(?:parts\.motion\.)?_trackedTrailCached\(\s*,?\s*\)\s*\|\|\s*(?:parts\.motion\.)?_trackedDisplayPosition\(\s*(?:flightState\.)?_trackedIcao,?\s*\);/).test(source), `${name} trail head uses the dedicated lower-centre model anchor`, ); - assert.ok(source.includes('const MODEL_COLOR_BLEND_AMOUNT = 0.94;'), - `${name} keeps diffuse texture contribution weak through code-side MIX`); } }); diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index 0a8a164f7..3a237f53b 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -46,6 +46,15 @@ const TYPE_STYLES = [ ]; const DEFAULT_STYLE = { css: '#39d5ff', accent: '57, 213, 255' }; +/** + * An aid to navigation's disc and card accent: a bone white apart from every + * ship type's colour. + */ +export const AID_TO_NAVIGATION_STYLE = Object.freeze({ + css: '#d9d2c0', + accent: '217, 210, 192', +}); + // Card text for the type tokens above. The tokens stay English: TYPE_STYLES // and tests match on them. const TYPE_DISPLAY_KEYS = Object.freeze({ diff --git a/src/data/vesselTypes.js b/src/data/vesselTypes.js index 550f4596b..742187548 100644 --- a/src/data/vesselTypes.js +++ b/src/data/vesselTypes.js @@ -45,3 +45,110 @@ export function normalizeVesselType(type) { if (NUMERIC_TYPE_SPECIALS[code]) return NUMERIC_TYPE_SPECIALS[code]; return NUMERIC_TYPE_FAMILIES[Math.floor(code / 10)] || 'OTHER'; } + +/** + * The kind of aid to navigation an AIS aid-to-navigation report (message 21) + * names by its type code, 0 to 31 (ITU-R M.1371, IALA marks): beacons and + * floating marks by their cardinal quadrant, lateral side, safe water, + * isolated danger or special mark, and the lights, RACONs and reference + * points around them. Code 0 names no kind. + */ +const AID_TYPE_NAMES = Object.freeze([ + 'Navigation aid', + 'Reference point', + 'RACON', + 'Fixed structure off shore', + 'Emergency wreck marking buoy', + 'Light, without sectors', + 'Light, with sectors', + 'Leading light front', + 'Leading light rear', + 'Beacon, Cardinal N', + 'Beacon, Cardinal E', + 'Beacon, Cardinal S', + 'Beacon, Cardinal W', + 'Beacon, Port hand', + 'Beacon, Starboard hand', + 'Beacon, Preferred channel port hand', + 'Beacon, Preferred channel starboard hand', + 'Beacon, Isolated danger', + 'Beacon, Safe water', + 'Beacon, Special mark', + 'Cardinal Mark N', + 'Cardinal Mark E', + 'Cardinal Mark S', + 'Cardinal Mark W', + 'Port hand Mark', + 'Starboard hand Mark', + 'Preferred Channel Port hand', + 'Preferred Channel Starboard hand', + 'Isolated danger', + 'Safe Water', + 'Special Mark', + 'Light Vessel / LANBY / Rigs', +]); + +/** + * The kind an AIS aid-to-navigation type code names ("19" → "Beacon, Special + * mark"); "Navigation aid" for code 0 and for a code outside 0 to 31. + * @param {string|number|null|undefined} code + * @returns {string} + */ +export function aidToNavigationTypeName(code) { + const text = String(code ?? '').trim(); + const index = /^\d+$/.test(text) ? Number(text) : -1; + return AID_TYPE_NAMES[index] ?? AID_TYPE_NAMES[0]; +} + +/** Every kind {@link aidToNavigationTypeName} names, lowercased. */ +const AID_TYPE_NAME_SET = new Set( + AID_TYPE_NAMES.map((name) => name.toLowerCase()), +); + +/** + * Words a vessel type uses for an aid to navigation. A vessel that works on + * one ("Buoy Tender", "Buoy-Laying Vessel", "Buoy Layer") is a ship underway, + * not a mark on the water. + */ +const AID_TYPE_TEXT = + /navaid|\baton\b|aids? to nav|nav(igation)? aid|racon|lighthouse|lightship|light vessel|reference point|hand mark|cardinal|safe water|preferred channel|isolated danger|special mark|beacon|buoy/i; +const AID_WORKER_TEXT = /tender|lay(ing|er)\b/i; + +/** ITU MID 99: the MMSI of an AIS aid-to-navigation station (99MIDxxxx). */ +const AID_MMSI = /^99\d{7}$/; + +/** + * Whether each type text seen names an aid. Vessel layers ask per vessel on + * their draw passes, and a feed uses few distinct type texts, so each is + * matched once; the memo is bounded against a feed of free-form types. + */ +const AID_TYPE_MEMO = new Map(); +const AID_TYPE_MEMO_MAX = 1024; + +function typeNamesAid(text) { + let aid = AID_TYPE_MEMO.get(text); + if (aid !== undefined) return aid; + aid = + AID_TYPE_NAME_SET.has(text.toLowerCase()) || + (AID_TYPE_TEXT.test(text) && !AID_WORKER_TEXT.test(text)); + if (AID_TYPE_MEMO.size >= AID_TYPE_MEMO_MAX) AID_TYPE_MEMO.clear(); + AID_TYPE_MEMO.set(text, aid); + return aid; +} + +/** + * Whether a vessel record is an aid to navigation (a buoy, beacon, mark, + * RACON or light) rather than a vessel: its type is one of the kinds an aid + * report names or uses a word for one, or its MMSI is an aid-to-navigation + * station's, or it is a virtual aid. A numeric AIS ship type never names one. + * @param {{type?: string|number|null, mmsi?: string|number|null, + * virtual?: boolean}} vessel + * @returns {boolean} + */ +export function isAidToNavigation({ type, mmsi, virtual } = {}) { + if (virtual === true) return true; + const text = typeof type === 'string' ? type.trim() : String(type ?? ''); + if (text && typeNamesAid(text)) return true; + if (mmsi === null || mmsi === undefined || mmsi === '') return false; + return AID_MMSI.test(typeof mmsi === 'string' ? mmsi.trim() : String(mmsi)); +} diff --git a/src/data/vesselTypes.test.mjs b/src/data/vesselTypes.test.mjs new file mode 100644 index 000000000..65816743c --- /dev/null +++ b/src/data/vesselTypes.test.mjs @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { aidToNavigationTypeName, isAidToNavigation } from './vesselTypes.js'; + +test('an aid-to-navigation type code names its kind of aid', () => { + assert.equal(aidToNavigationTypeName(20), 'Cardinal Mark N'); + assert.equal(aidToNavigationTypeName('13'), 'Beacon, Port hand'); + assert.equal(aidToNavigationTypeName(2), 'RACON'); + assert.equal(aidToNavigationTypeName(29), 'Safe Water'); + assert.equal(aidToNavigationTypeName(31), 'Light Vessel / LANBY / Rigs'); + // Code 0, a code past 31 and no code name no kind. + for (const code of [0, 32, -1, '', null, undefined, 'x']) + assert.equal(aidToNavigationTypeName(code), 'Navigation aid', code); +}); + +test('aids to navigation are known by their type, else their MMSI', () => { + for (const type of [ + 'Beacon, Special Mark', + 'Cardinal Mark N', + 'Port Hand Mark', + 'Starboard Hand Mark', + 'Safe Water', + 'Isolated danger', + 'Preferred Channel Port hand', + 'RACON', + 'Light Vessel / LANBY / Rigs', + 'Reference point', + 'Navigation aid', + 'AtoN', + 'Lighthouse', + 'Buoy', + // Every kind an aid report names is an aid, whatever its MMSI. + 'Light, with sectors', + 'Leading light rear', + 'Fixed structure off shore', + 'Emergency wreck marking buoy', + ]) + assert.equal(isAidToNavigation({ type, mmsi: '' }), true, type); + // A virtual aid is an aid whatever its type or MMSI. + assert.equal( + isAidToNavigation({ type: '', mmsi: '', virtual: true }), + true, + ); + // AIS aid-to-navigation stations are numbered 99MIDxxxx. + assert.equal(isAidToNavigation({ type: '', mmsi: '993672001' }), true); + assert.equal(isAidToNavigation({ type: '0', mmsi: ' 992351234 ' }), true); + for (const vessel of [ + { type: 'Buoy Tender', mmsi: '366999001' }, + { type: 'Buoy-Laying Vessel', mmsi: '' }, + { type: 'Buoy Layer', mmsi: '' }, + { type: 'Lighter', mmsi: '' }, + { type: 'Flight deck', mmsi: '' }, + { type: 70, mmsi: 366999002 }, + { type: '70', mmsi: '366999002' }, + { type: '0', mmsi: '' }, + { type: 'Cargo', mmsi: '99' }, + { type: 'Passenger', mmsi: '9936720011' }, + {}, + ]) + assert.equal(isAidToNavigation(vessel), false, JSON.stringify(vessel)); +}); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 5b0da6060..44024e10f 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1,5 +1,5 @@ { - "News feed unavailable": "موجز الأخبار غير متاح", + "News feed unavailable": "مصدر بيانات الأخبار غير متاح", "Developing: {name}": "تطورات: {name}", "{count} events today": { "zero": "{count} حدث اليوم", @@ -39,13 +39,13 @@ "Iran–Hormuz": "إيران–هرمز", "Yemen–Red Sea": "اليمن–البحر الأحمر", "Sudan": "السودان", - "Sahel": "الساحل", + "Sahel": "الساحل الأفريقي", "Elsewhere": "أماكن أخرى", "Strike": "ضربة", "Alert": "إنذار", "Air defence": "دفاع جوي", "Ground fighting": "قتال بري", - "Casualties": "إصابات", + "Casualties": "ضحايا", "Humanitarian": "إنساني", "Disaster": "كارثة", "Protest": "احتجاج", @@ -65,20 +65,20 @@ "many": "{count} مصدرًا", "other": "{count} مصدر" }, - "News feed not configured on this server": "موجز الأخبار غير مهيأ على هذا الخادم", + "News feed not configured on this server": "مصدر بيانات الأخبار غير مهيأ على هذا الخادم", "Malformed news hotspots response": "استجابة بؤر الأخبار غير صالحة", - "Malformed news feed response": "استجابة موجز الأخبار غير صالحة", + "Malformed news feed response": "استجابة مصدر بيانات الأخبار غير صالحة", "{place} (placed by model)": "{place} (حدده النموذج)", "English headline by the model": "عنوان إنجليزي من النموذج", - "Not placed on the map": "غير موضوع على الخريطة", + "Not placed on the map": "لم يُحدَّد موقعه على الخريطة", "Close": "إغلاق", "Any severity": "أي درجة خطورة", "{severity} and above": "{severity} فأعلى", "{severity} only": "{severity} فقط", "All categories": "كل الفئات", "Reading the news…": "جارٍ قراءة الأخبار…", - "News, last {hours} hours": "الأخبار، آخر {hours} ساعة", - "Last {hours} h": "آخر {hours} ساعة", + "News, last {hours} hours": "الأخبار، الساعات الأخيرة: {hours}", + "Last {hours} h": "آخر {hours} س", "Time window": "الفترة الزمنية", "Conflicts": "النزاعات", "Local": "محلي", @@ -87,9 +87,9 @@ "Hotspots": "بؤر ساخنة", "Severity": "الخطورة", "Category": "الفئة", - "Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} ساعة", + "Nothing reported near here in the last {hours} h": "لم يُبلَّغ عن شيء قريب من هنا في آخر {hours} س", "Latest news": "آخر الأخبار", - "No news matches these filters in the last {hours} h": "لا أخبار تطابق هذه المرشحات في آخر {hours} ساعة", + "No news matches these filters in the last {hours} h": "لا أخبار تطابق هذه المرشحات في آخر {hours} س", "Load older news": "تحميل أخبار أقدم", "How to read the map": "كيف تقرأ الخريطة", "Colour: severity · icon: type of event": "اللون: الخطورة · الرمز: نوع الحدث", @@ -442,7 +442,7 @@ "AVAILABLE MISSIONS": "المهام المتاحة", "SELECT A MISSION TO INSPECT": "اختر مهمة لفحصها", "LOADING 30-DAY MISSION INDEX": "جارٍ تحميل فهرس مهام 30 يومًا", - "Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. حاول مرة أخرى لاحقًا.", + "Space Missions unavailable: the mission index could not load. Try again later.": "المهام الفضائية غير متاحة: تعذّر تحميل فهرس المهام. أعد المحاولة لاحقًا.", "30-DAY MISSION INDEX UNAVAILABLE": "فهرس مهام 30 يومًا غير متاح", "RADIO": "الراديو", "ENABLE": "تفعيل", @@ -536,12 +536,12 @@ "PLACE / CONDITIONS / POSITION": "المكان / الأحوال / الموضع", "POSITION UNAVAILABLE": "الموضع غير متاح", "REGIONAL NEWS UNAVAILABLE": "الأخبار الإقليمية غير متاحة", - "CACHED · REFRESH FAILED": "مخزنة · فشل التحديث", + "CACHED · REFRESH FAILED": "مخزّنة · فشل التحديث", "REGION UNAVAILABLE": "المنطقة غير متاحة", "NO NEWS IN THE LAST {hours} H": "لا أخبار خلال آخر {hours} س", "SOURCE": "المصدر", "{domain} · {age}": "{domain} · {age}", - "{source} · CACHED {age}": "{source} · مخزنة {age}", + "{source} · CACHED {age}": "{source} · مخزّنة {age}", "TIME UNKNOWN": "الوقت غير معروف", "{count}M AGO": "قبل {count} د", "{count}H AGO": "قبل {count} س", @@ -776,6 +776,8 @@ "TANKER": "ناقلة", "OTHER": "أخرى", "VESSEL": "سفينة", + "AID TO NAVIGATION": "مساعدة ملاحية", + "VIRTUAL": "افتراضي", "POS: LIVE": "الموضع: مباشر", "POS: {time}Z": "الموضع: {time}Z", "AIS: --": "AIS: --", @@ -3385,7 +3387,7 @@ "Reset map bearing to north": "إعادة اتجاه الخريطة إلى الشمال", "Malformed cyclone snapshot": "لقطة الأعاصير مشوّهة", "Cyclone request timed out": "انتهت مهلة طلب الأعاصير", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "عيّنة إقليمية · نصف قطر 250 ميلًا بحريًا · خلايا من المناظر التي زرتها خلال {minutes} د", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "عيّنة إقليمية · نصف قطر 250 ميلًا بحريًا · خلايا من المناطق التي استعرضتها خلال {minutes} د", "RADAR · {units}": "رادار · {units}", "Color: {units}": "اللون: {units}", "Left panels": "اللوحات اليمنى", @@ -3411,7 +3413,7 @@ "Add the current view, style and layers as the next shot": "إضافة العرض الحالي والنمط والطبقات كلقطة تالية", "+ CAPTURE SHOT": "+ التقاط لقطة", "Replace the selected shot with the current view": "استبدال اللقطة المحددة بالعرض الحالي", - "Click a shot to select it, double-click to rename.": "انقر لقطة لتحديدها، وانقر نقرًا مزدوجًا لإعادة تسميتها.", + "Click a shot to select it, double-click to rename.": "انقر على لقطة لتحديدها، وانقر عليها نقرًا مزدوجًا لإعادة تسميتها.", "Play from this scene through the rest of the project": "تشغيل من هذا المشهد حتى نهاية المشروع", "Stop playback (Esc)": "إيقاف التشغيل (Esc)", "Play the shot after the selected one, moving on to the next scene when needed": "تشغيل اللقطة التي تلي المحددة، مع الانتقال إلى المشهد التالي عند الحاجة", @@ -3618,7 +3620,7 @@ "Within {km} km of the view centre": "ضمن {km} كم من مركز العرض", "{region}, {country}": "{region}، {country}", "via {source}": "عبر {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "المحلي في طبقة الأخبار: أخبار اليوم من وسائل إعلام البلد المعروض", + "Local in the News layer: the day’s news from the outlets of the country in view": "قسم «محلي» في طبقة الأخبار: أخبار اليوم من وسائل إعلام البلد المعروض", "{provider} refused its key": "رفض {provider} مفتاحه", "Check {envVars} in Provider Settings": "تحقق من {envVars} في «إعدادات المزوّدين»", "{provider} key required": "مفتاح {provider} مطلوب", @@ -3633,7 +3635,7 @@ "Date unknown": "التاريخ غير معروف", "Position: centre of the protected area": "الموقع: مركز المنطقة المحمية", "Position approximate (within about {km} km)": "الموقع تقريبي (في حدود {km} كم تقريبًا)", - "Position surveyed (within about {m} m)": "الموقع مُسَح (في حدود {m} م تقريبًا)", + "Position surveyed (within about {m} m)": "الموقع محدَّد بالمسح (في حدود {m} م تقريبًا)", "Vessel: {value}": "السفينة: {value}", "What happened: {value}": "ما حدث: {value}", "Depth: {m} m ({quality})": "العمق: {m} م ({quality})", @@ -3644,24 +3646,24 @@ "Source: {value}": "المصدر: {value}", "Click the card to open EMODnet ↗": "انقر على البطاقة لفتح EMODnet ↗", "Click the card to open the source ↗": "انقر على البطاقة لفتح المصدر ↗", - "Wikidata unavailable; showing EMODnet only": "ويكي بيانات غير متاحة؛ يُعرض EMODnet فقط", - "EMODnet unavailable; showing Wikidata only": "EMODnet غير متاح؛ تُعرض ويكي بيانات فقط", + "Wikidata unavailable; showing EMODnet only": "Wikidata غير متاح؛ يُعرض EMODnet فقط", + "EMODnet unavailable; showing Wikidata only": "EMODnet غير متاح؛ يُعرض Wikidata فقط", "Shipwrecks unavailable": "بيانات حطام السفن غير متاحة", - "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (المياه الأوروبية) · ويكي بيانات (العالم)", + "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (المياه الأوروبية) · Wikidata (العالم)", "{count} wrecks": { - "zero": "لا حطام ({count})", - "one": "حطام واحد ({count})", - "two": "حطامان ({count})", - "few": "{count} حطامات", - "many": "{count} حطامًا", - "other": "{count} حطام" + "zero": "{count} سفينة غارقة", + "one": "سفينة غارقة واحدة ({count})", + "two": "سفينتان غارقتان ({count})", + "few": "{count} سفن غارقة", + "many": "{count} سفينة غارقة", + "other": "{count} سفينة غارقة" }, "1900–1945": "1900–1945", - "Shipwreck": "غرق سفينة", + "Shipwreck": "حطام سفينة", "Fatalities: {count}": "الوفيات: {count}", "Survivors: {count}": "الناجون: {count}", "Click the card to open the article ↗": "انقر على البطاقة لفتح المقالة ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "يأتي حطام السفن التراثي الأوروبي من EMODnet Human Activities، ويأتي الحطام البارز في العالم من ويكي بيانات. يبيّن اللون متى غرقت السفينة، ويكبر حجم العلامة مع عدد الوفيات. انقر على علامة لعرض بطاقتها.", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "يأتي حطام السفن التراثي الأوروبي من EMODnet Human Activities، ويأتي الحطام البارز في العالم من Wikidata. يبيّن اللون متى غرقت السفينة، ويكبر حجم العلامة مع عدد الوفيات. انقر على علامة لعرض بطاقتها.", "All years": "كل السنوات", "Period": "الفترة", "From": "من", @@ -3689,9 +3691,9 @@ "Other or unknown": "أخرى أو غير معروفة", "{label} {detail} — click to retry": "{label} {detail} — انقر لإعادة المحاولة", "Wildlife sightings source unavailable": "مصدر مشاهدات الحياة البرية غير متاح", - "Show only community-confirmed (research-grade) sightings": "إظهار المشاهدات المؤكدة مجتمعياً (بدرجة بحثية) فقط", - "No recent sightings in view": "لا توجد مشاهدات حديثة في العرض", - "Acartia feed unavailable": "تعذر الوصول إلى مصدر Acartia", + "Show only community-confirmed (research-grade) sightings": "إظهار المشاهدات المؤكدة مجتمعيًا (بدرجة بحثية) فقط", + "No recent sightings in view": "لا توجد مشاهدات حديثة في نطاق العرض", + "Acartia feed unavailable": "مصدر Acartia غير متاح", "Show all taxa": "إظهار جميع المجموعات التصنيفية", "BIRDS": "الطيور", "Show only birds": "إظهار الطيور فقط", @@ -3706,31 +3708,31 @@ "All rights reserved": "جميع الحقوق محفوظة", "Cetacean": "حوتي", "Wildlife Sightings": "مشاهدات الحياة البرية", - "Exit cockpit to fly to a sighting": "اخرج من قمرة القيادة للانتقال إلى مشاهدة", - "Exit cockpit to fly to a sighting area": "اخرج من قمرة القيادة للانتقال إلى منطقة مشاهدة", + "Exit cockpit to fly to a sighting": "اخرج من القمرة للانتقال إلى مشاهدة", + "Exit cockpit to fly to a sighting area": "اخرج من القمرة للانتقال إلى منطقة مشاهدة", "Wildlife": "الحياة البرية", "Zoom in for local sightings": "قرّب لرؤية المشاهدات المحلية", "Loading photo...": "جارٍ تحميل الصورة…", "Photo unavailable": "الصورة غير متاحة", - "Location obscured to a 0.2° cell": "الموقع مخفي ضمن خلية بمقدار 0.2°", + "Location obscured to a 0.2° cell": "الموقع مموّه ضمن خلية بحجم 0.2°", "Photo of {name}": "صورة {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} مشاهدة ضمن النطاق", + "{count} sightings in view": "المشاهدات في نطاق العرض: {count}", "Close sighting": "إغلاق المشاهدة", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "حياة برية شوهدت خلال 48 ساعة (الحيتان 7 أيام). يوضح اللون ورمز الشارة المجموعة التصنيفية. تُعرض الأنواع الحساسة في خلايا بحجم 0.2° فقط؛ وتُقرّب مشاهدات الثدييات البحرية في بحر ساليش عبر Acartia إلى 0.01°. ليست إحصاءً شاملاً.", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "حياة برية شوهدت خلال 48 ساعة (الحيتان 7 أيام). يوضح اللون ورمز الشارة المجموعة التصنيفية. تُعرض الأنواع الحساسة في خلايا بحجم 0.2° فقط؛ وتُقرّب مشاهدات الثدييات البحرية في بحر ساليش عبر Acartia إلى 0.01°. ليست إحصاءً شاملًا.", "Open {name} on iNaturalist": "فتح {name} على iNaturalist", "Open {name} on Acartia": "فتح {name} على Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "مساهمو iNaturalist · التراخيص لكل سجل", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "مساهمو iNaturalist · Acartia {count} (بحر ساليش) · التراخيص لكل سجل", - "{days}d": "{days}ي", + "{days}d": "{days} ي", "All markers": "كل العلامات", "Show markers of every age": "عرض العلامات من جميع الأعمار", - "Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} يوم؛ تُحفظ العلامات غير المؤرخة", + "Maximum event age: {days}d; undated markers are kept": "الحد الأقصى لعمر الحدث: {days} ي؛ تبقى العلامات غير المؤرخة ظاهرة", "event age filter|All": "الكل", "Event age": "عمر الحدث", - "Event age: {days}d · undated kept": "عمر الحدث: {days} يوم · الاحتفاظ بغير المؤرخ", + "Event age: {days}d · undated kept": "عمر الحدث: {days} ي · غير المؤرخة تبقى ظاهرة", "Close camera preview": "إغلاق معاينة الكاميرا", "Refresh map still": "تحديث صورة الخريطة الثابتة", "The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.", @@ -3755,11 +3757,11 @@ "STARLINK": "STARLINK", "The Starlink constellation (thousands of extra points)": "كوكبة Starlink (آلاف النقاط الإضافية)", "PROBES": "المسابير", - "Space science, technology demonstration and other payloads": "علوم الفضاء والعروض التقنية وحمولات أخرى", + "Space science, technology demonstration and other payloads": "علوم الفضاء واختبار التقنيات وحمولات أخرى", "DEBRIS": "الحطام", "Rocket bodies, fragments and dead satellites": "مراحل الصواريخ والشظايا والأقمار المعطلة", - "Classes may lag: SATCAT is a cached copy": "قد تتأخر الفئات: SATCAT نسخة مخزنة", - "Classes may lag: SATCAT unavailable": "قد تتأخر الفئات: SATCAT غير متاح", + "Classes may lag: SATCAT is a cached copy": "قد لا تكون الفئات محدّثة: SATCAT نسخة مخزّنة", + "Classes may lag: SATCAT unavailable": "قد لا تكون الفئات محدّثة: SATCAT غير متاح", "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "يعطي SATCAT من CelesTrak نوع كل جسم وحالته. من دون نسخة حديثة تستند الفئات إلى قاعدة بيانات UCS والأسماء ومجموعات CelesTrak، فقد يظهر قمر صناعي توقف عن العمل منذ ذلك الحين وكأنه يعمل.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", @@ -3780,7 +3782,7 @@ "Zoom out (−)": "تصغير (−)", "ORBITAL VIEW · OFF-NADIR": "العرض المداري · انحراف عن النظير", "World time": "وقت العالم", - "Back to live": "العودة إلى البث المباشر", + "Back to live": "العودة إلى الوضع المباشر", "Live": "مباشر", "1 h/s": "ساعة/ث", "1 d/s": "يوم/ث", @@ -3789,21 +3791,23 @@ "Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل", "1 min/s": "دقيقة/ث", "Rail": "السكك الحديدية", - "Urban rail": "قطارات حضرية", + "Urban rail": "القطارات الحضرية", "Local rail": "قطارات الضواحي", - "Regional rail": "قطارات إقليمية", - "National rail": "قطارات وطنية", - "International rail": "قطارات دولية", + "Regional rail": "القطارات الإقليمية", + "National rail": "القطارات الوطنية", + "International rail": "القطارات الدولية", "Hide {category}": "إخفاء {category}", "Show {category}": "إظهار {category}", "Live GPS": "GPS مباشر", "Estimated from station reports": "مقدَّر من بلاغات المحطات", "To {headsign}": "إلى {headsign}", - "Next {stop} {time} {relative}": "المحطة التالية {stop} {time} {relative}", - "Prev {stop} {time} {relative}": "المحطة السابقة {stop} {time} {relative}", + "Next {stop} {time} {relative}": "المحطة التالية: {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "المحطة السابقة: {stop} {time} {relative}", "(+{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": "الرحلة العسكرية المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف", @@ -3811,16 +3815,16 @@ "The shared vessel isn't reporting now — showing where it was last seen": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض آخر موضع معروف", "The shared vessel isn't reporting now — showing where it was at {time}": "السفينة المشتركة: لا بيانات واردة الآن — يُعرض موضعها عند {time}", "The shared satellite isn't reporting now — showing where it was last seen": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض آخر موضع معروف", - "The shared satellite isn't reporting now — showing where it was at {time}": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض موضعها عند {time}", + "The shared satellite isn't reporting now — showing where it was at {time}": "القمر الصناعي المشترك: لا بيانات واردة الآن — يُعرض موضعه عند {time}", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "معرّف UUID للسفينة: {id}", "Latitude must be within ±90° and longitude within ±180°": "يجب أن يكون خط العرض ضمن ±90° وخط الطول ضمن ±180°", - "Street Level is unavailable": "مستوى الشارع غير متاح", + "Street Level is unavailable": "«مستوى الشارع» غير متاح", "No street-level imagery source is configured": "لم يُعَدّ أي مصدر لصور مستوى الشارع", - "Street Level could not be enabled": "تعذّر تفعيل مستوى الشارع", - "Street Level changed while it looked for imagery": "تغيّر مستوى الشارع أثناء البحث عن الصور", + "Street Level could not be enabled": "تعذّر تفعيل «مستوى الشارع»", + "Street Level changed while it looked for imagery": "تغيّرت حالة «مستوى الشارع» أثناء البحث عن الصور", "Aim the view at a street": "وجّه العرض نحو شارع", "{source} is not a street-level imagery source here": "{source} ليس مصدرًا لصور مستوى الشارع هنا", "{source} needs its key": "يحتاج {source} إلى مفتاحه", @@ -3828,13 +3832,13 @@ "{source} could not be switched on": "تعذّر تفعيل {source}", "{source} is not available on this map": "{source} غير متاح على هذه الخريطة", "{source} imagery could not be opened": "تعذّر فتح صور {source}", - "{place} has no position to look for imagery at": "لا يوجد لـ{place} موضع للبحث عن الصور فيه", + "{place} has no position to look for imagery at": "{place}: لا موضع للبحث عن الصور فيه", "No {source} imagery near {place}": "لا توجد صور {source} قرب {place}", "{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})", "tiles unavailable": "البلاطات غير متاحة", - "zoom in to load this layer": "كبّر لتحميل هذه الطبقة", + "zoom in to load this layer": "قرّب لتحميل هذه الطبقة", "Zoom to layer": "تكبير إلى الطبقة", - "Zoom to layer · fly to where this layer has data": "تكبير إلى الطبقة · الطيران إلى حيث توجد بيانات هذه الطبقة", + "Zoom to layer · fly to where this layer has data": "تكبير إلى الطبقة · الانتقال إلى حيث توجد بيانات هذه الطبقة", "Remove from Data Layers": "إزالة من طبقات البيانات", "Remove from Data Layers · add it again from the library": "إزالة من طبقات البيانات · أضِفها مجددًا من المكتبة", "Coverage outline": "حدود التغطية", @@ -3845,7 +3849,7 @@ "1,000–2,000 m": "1,000–2,000 م", "2,000–3,000 m": "2,000–3,000 م", "3,000 m and above": "3,000 م فأكثر", - "Exit cockpit to fly to a layer": "اخرج من قمرة القيادة للطيران إلى طبقة", + "Exit cockpit to fly to a layer": "اخرج من القمرة للانتقال إلى طبقة", "Library": "المكتبة", "Environment": "البيئة", "Imagery": "الصور", @@ -3861,24 +3865,24 @@ "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", "{count} datasets": { "zero": "{count} مجموعة بيانات", - "one": "{count} مجموعة بيانات", - "two": "{count} مجموعتا بيانات", + "one": "مجموعة بيانات واحدة ({count})", + "two": "مجموعتا بيانات ({count})", "few": "{count} مجموعات بيانات", "many": "{count} مجموعة بيانات", "other": "{count} مجموعة بيانات" }, "{count} tile layers": { "zero": "{count} طبقة بلاطات", - "one": "{count} طبقة بلاطات", - "two": "{count} طبقتا بلاطات", + "one": "طبقة بلاطات واحدة ({count})", + "two": "طبقتا بلاطات ({count})", "few": "{count} طبقات بلاطات", "many": "{count} طبقة بلاطات", "other": "{count} طبقة بلاطات" }, "{count} packs": { "zero": "{count} حزمة", - "one": "{count} حزمة", - "two": "{count} حزمتان", + "one": "حزمة واحدة ({count})", + "two": "حزمتان ({count})", "few": "{count} حزم", "many": "{count} حزمة", "other": "{count} حزمة" @@ -3897,8 +3901,8 @@ "Map features": "معالم الخريطة", "from {count} datasets": { "zero": "من {count} مجموعة بيانات", - "one": "من {count} مجموعة بيانات", - "two": "من {count} مجموعتَي بيانات", + "one": "من مجموعة بيانات واحدة ({count})", + "two": "من مجموعتَي بيانات ({count})", "few": "من {count} مجموعات بيانات", "many": "من {count} مجموعة بيانات", "other": "من {count} مجموعة بيانات" @@ -3921,7 +3925,7 @@ "Resolution": "الدقة", "Flown": "تاريخ التصوير", "Updates": "التحديث", - "Ortho quality": "جودة التعامد", + "Ortho quality": "جودة التصحيح التعامدي", "DATA LIBRARY": "مكتبة البيانات", "Close data library": "إغلاق مكتبة البيانات", "Data Library at a glance": "مكتبة البيانات في لمحة", @@ -3930,7 +3934,7 @@ "Theme": "الموضوع", "All themes": "كل المواضيع", "Only what this instance serves": "فقط ما يقدّمه هذا المثيل", - "OPT-IN": "اختياري", + "OPT-IN": "بتفعيل المشغّل", "KEY": "مفتاح", "KEYLESS": "بلا مفتاح", "ZOOM": "تكبير", @@ -3951,5 +3955,12 @@ "Confirm: remove every added entry from Data Layers": "تأكيد: إزالة كل الإدخالات المُضافة من طبقات البيانات", "Remove every added entry from Data Layers": "إزالة كل الإدخالات المُضافة من طبقات البيانات", "Showing {shown} of {total}": "عرض {shown} من {total}", - "BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات" + "BROWSE DATA LIBRARY": "تصفّح مكتبة البيانات", + "The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.", + "Reset view reloads the app without the view restored from the address bar.": "تعيد «إعادة ضبط العرض» تحميل التطبيق دون العرض المستعاد من شريط العنوان.", + "Reload": "إعادة التحميل", + "Retry": "إعادة المحاولة", + "Reset view": "إعادة ضبط العرض" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 55b9301f9..cdb669452 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1,6 +1,6 @@ { "News feed unavailable": "Nachrichtenfeed nicht verfügbar", - "Developing: {name}": "In Entwicklung: {name}", + "Developing: {name}": "Aktuelle Entwicklung: {name}", "{count} events today": { "one": "{count} Ereignis heute", "other": "{count} Ereignisse heute" @@ -13,11 +13,11 @@ "Video": "Video", "Photos": "Fotos", "Claimed by {who}": "Behauptet von {who}", - "Claimed": "Behauptung", + "Claimed": "Behauptet", "Not yet confirmed by others": "Noch nicht von anderen bestätigt", - "Reported by {side} sources": "Gemeldet von Quellen aus {side}", - "Reported by {sides}": "Gemeldet von {sides}", - "{side} sources": "Quellen aus {side}", + "Reported by {side} sources": "Gemeldet von Quellen: {side}", + "Reported by {sides}": "Gemeldet von Quellen: {sides}", + "{side} sources": "{side}", "Official": "Offiziell", "Reported by both sides": "Von beiden Seiten gemeldet", "Independently reported": "Unabhängig gemeldet", @@ -47,8 +47,8 @@ "Significant": "Erheblich", "Major": "Schwer", "age|now": "jetzt", - "{minutes} min": "{minutes} Min.", - "{hours} h": "{hours} Std.", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", "{count} sources": { "one": "{count} Quelle", "other": "{count} Quellen" @@ -65,8 +65,8 @@ "{severity} only": "Nur {severity}", "All categories": "Alle Kategorien", "Reading the news…": "Nachrichten werden gelesen…", - "News, last {hours} hours": "Nachrichten der letzten {hours} Stunden", - "Last {hours} h": "Letzte {hours} Std.", + "News, last {hours} hours": "Nachrichten, letzte {hours} h", + "Last {hours} h": "Letzte {hours} h", "Time window": "Zeitraum", "Conflicts": "Konflikte", "Local": "Lokal", @@ -82,8 +82,8 @@ "How to read the map": "So liest man die Karte", "Colour: severity · icon: type of event": "Farbe: Schweregrad · Symbol: Art des Ereignisses", "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Durchgehender Rand: von beiden Seiten oder unabhängig gemeldet. Gestrichelter Rand: eine Behauptung. Dünner Rand: eine Quelle oder die eigene Presse einer Seite.", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Größer: mehr Quellen · blasser: älter. Bildkarte: ein schweres oder schnell wachsendes Ereignis mit zwei oder mehr Quellen.", - "Side labels describe the source, not whether a report is true.": "Seitenangaben beschreiben die Quelle, nicht ob eine Meldung stimmt.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Größer: mehr Quellen · blasser: älter. Infokarte mit Bild: ein schweres oder schnell wachsendes Ereignis mit zwei oder mehr Quellen.", + "Side labels describe the source, not whether a report is true.": "Angaben zur Seite beschreiben die Quelle, nicht ob eine Meldung stimmt.", "Exit cockpit to fly to a news report": "Cockpit verlassen, um zu einer Meldung zu fliegen", "News items in the feed": "Meldungen im Feed", "Flying to Austin, TX...": "Flug nach Austin, TX...", @@ -252,7 +252,7 @@ "loading...": "lädt...", "KEY REQUIRED": "SCHLÜSSEL ERFORDERLICH", "LIVE · updated {age}": "LIVE · aktualisiert {age}", - "24 h to {day} {time} UTC": "24 Std. bis {day} {time} UTC", + "24 h to {day} {time} UTC": "24 h bis {day} {time} UTC", "loading mapped installation context": "Kontext der kartierten Anlage wird geladen", "MAPPED INSTALLATION": "KARTIERTE ANLAGE", "LAUNCH SITE · {site}": "STARTPLATZ · {site}", @@ -282,15 +282,15 @@ "{value} KM/S": "{value} KM/S", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "NADIRABWEICHUNG {angle}° · {offset}", - "SITES STANDBY": "ORTE STANDBY", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "KARTIERTE ANLAGEN FÜR ORTS-PINGS AKTIVIEREN", + "SITES STANDBY": "STANDORTE STANDBY", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "KARTIERTE ANLAGEN FÜR STANDORT-PINGS AKTIVIEREN", "Release the camera from {name}": "Kamera von {name} lösen", "RETURN · {name}": "ZURÜCK · {name}", "PREVIOUS SATELLITE": "VORHERIGER SATELLIT", "Return to {name}": "Zurück zu {name}", "SATELLITE": "SATELLIT", "Ride {name}": "Mit {name} fliegen", - "Unnamed site": "Unbenannter Ort", + "Unnamed site": "Unbenannter Standort", "OFF-NADIR {angle}°": "NADIRABWEICHUNG {angle}°", "FOCUS · {name}": "FOKUS · {name}", "Keep the camera on {name}": "Kamera auf {name} halten", @@ -416,6 +416,8 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "SCHIFF", + "AID TO NAVIGATION": "SEEZEICHEN", + "VIRTUAL": "VIRTUELL", "POS: LIVE": "POS: LIVE", "POS: {time}Z": "POS: {time}Z", "awaiting first AIS position…": "warten auf erste AIS-Position…", @@ -493,8 +495,8 @@ "other": "{count} Kameras geladen · CCTV aktivieren" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "{count} weitere Kamera außerhalb dieser Ansicht · verschieben oder herauszoomen", - "other": "{count} weitere Kameras außerhalb dieser Ansicht · verschieben oder herauszoomen" + "one": "{count} weitere Kamera außerhalb dieser Ansicht · Karte verschieben oder herauszoomen", + "other": "{count} weitere Kameras außerhalb dieser Ansicht · Karte verschieben oder herauszoomen" }, "Enable CCTV to load camera intersections": "CCTV aktivieren, um Kreuzungskameras zu laden", "Enable CCTV to start camera-linked intelligence summaries.": "CCTV aktivieren, um kameragestützte Lagezusammenfassungen zu starten.", @@ -1619,7 +1621,7 @@ "Low-accuracy score 2–10%": "Ungenauigkeitswert 2–10 %", "Low-accuracy score > 10%": "Ungenauigkeitswert > 10 %", "GNSS integrity source unavailable": "GNSS-Integritätsquelle nicht verfügbar", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten über {minutes} min", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionale Stichprobe · Radius 250 NM · Zellen aus besuchten Ansichten der letzten {minutes} min", "Retrying automatically at {time} UTC": "Automatischer neuer Versuch um {time} UTC", "Retrying automatically after provider cooldown": "Automatischer neuer Versuch nach der Sperrzeit des Anbieters", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Aktualisiert sich nach Schwenken oder Zoomen und einmal pro Minute automatisch. Der öffentliche Feed deckt einen Kreis von 250 Seemeilen um die Bildmitte ab; Herauszoomen macht daraus keinen weltweiten Snapshot. Leere Zellen liegen außerhalb der Stichprobe oder haben zu wenige Beobachtungen.", @@ -3107,7 +3109,7 @@ "Restricted": "Flugbeschränkungsgebiet", "Prohibited & Restricted Airspace": "Sperr- und Flugbeschränkungsgebiete", "scene|PLAY": "START", - "AUTHORING": "ERSTELLUNG", + "AUTHORING": "SZENEN", "Create and edit scenes": "Szenen erstellen und bearbeiten", "SCENE": "SZENE", "Scene": "Szene", @@ -3129,8 +3131,8 @@ "Download the timing log of the last play": "Zeitprotokoll der letzten Wiedergabe herunterladen", "Edit anchors, data packs and shot details": "Anker, Datenpakete und Aufnahmedetails bearbeiten", "Download this scene as JSON or an asset bundle": "Diese Szene als JSON oder Asset-Bundle herunterladen", - "Left panels": "Linke Panels", - "Right panels": "Rechte Panels", + "Left panels": "Linke Bereiche", + "Right panels": "Rechte Bereiche", "CONFIRMED": "BESTÄTIGT", "PAD / RTLS": "STARTPLATZ / RTLS", "fire confidence filter|All": "Alle", @@ -3292,20 +3294,20 @@ "Too many chat requests. Try again in {seconds} s.": "Zu viele Chat-Anfragen. In {seconds} s erneut versuchen.", "The provider refused access for this key. Check the key's project, region or permissions.": "Der Anbieter hat den Zugriff für diesen Schlüssel verweigert. Projekt, Region oder Berechtigungen des Schlüssels prüfen.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Das Anbieterkonto hat kein Guthaben mehr. Die Abrechnung prüfen oder in den Einstellungen einen anderen KI-Anbieter wählen.", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Letzte 24 h Nachrichten. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus Text und Daten gelesen und können falsch sein. Seitenangaben, wo vorhanden, beschreiben die Quelle, nicht ob eine Meldung stimmt.", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Nachrichten der letzten 24 h. Jede Meldung verlinkt auf ihren Herausgeber; Orte und Kategorien werden automatisch aus Text und Daten gelesen und können falsch sein. Angaben zur Seite, wo vorhanden, beschreiben die Quelle, nicht ob eine Meldung stimmt.", "Economy": "Wirtschaft", "News from {area}": "Nachrichten: {area}", "Within {km} km of the view centre": "Im Umkreis von {km} km um die Bildmitte", "{region}, {country}": "{region}, {country}", "via {source}": "über {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "Lokal in der Nachrichtenebene: die Nachrichten des Tages aus den Medien des Landes im Blick", + "Local in the News layer: the day’s news from the outlets of the country in view": "„Lokal“ in der Ebene „Nachrichten“: die Nachrichten des Tages aus den Medien des Landes im Bild", "{provider} refused its key": "{provider} hat den Schlüssel abgelehnt", "Check {envVars} in Provider Settings": "{envVars} in den Anbieter-Einstellungen prüfen", - "{provider} key required": "{provider}-Schlüssel erforderlich", + "{provider} key required": "Schlüssel für {provider} erforderlich", "{provider} limit reached for today": "{provider}: Tageslimit erreicht", "News resumes tomorrow, or with a paid plan": "Nachrichten gibt es morgen wieder, oder mit einem kostenpflichtigen Tarif", "News key required": "Nachrichtenschlüssel erforderlich", - "Precise conflict points in the News layer, beside GDELT’s articles": "Genaue Konfliktpunkte in der Nachrichtenebene, neben den Artikeln von GDELT", + "Precise conflict points in the News layer, beside GDELT’s articles": "Genaue Konfliktpunkte in der Ebene „Nachrichten“, neben den Artikeln von GDELT", "Worldwide": "Weltweit", "Shipwrecks": "Schiffswracks", "Before 1900": "Vor 1900", @@ -3322,8 +3324,8 @@ "Cargo or finds: {value}": "Ladung oder Funde: {value}", "Protected wreck": "Geschütztes Wrack", "Source: {value}": "Quelle: {value}", - "Click the card to open EMODnet ↗": "Karte anklicken, um EMODnet zu öffnen ↗", - "Click the card to open the source ↗": "Karte anklicken, um die Quelle zu öffnen ↗", + "Click the card to open EMODnet ↗": "Eintrag anklicken, um EMODnet zu öffnen ↗", + "Click the card to open the source ↗": "Eintrag anklicken, um die Quelle zu öffnen ↗", "Wikidata unavailable; showing EMODnet only": "Wikidata nicht verfügbar; nur EMODnet wird angezeigt", "EMODnet unavailable; showing Wikidata only": "EMODnet nicht verfügbar; nur Wikidata wird angezeigt", "Shipwrecks unavailable": "Schiffswracks nicht verfügbar", @@ -3333,11 +3335,11 @@ "other": "{count} Wracks" }, "1900–1945": "1900–1945", - "Shipwreck": "Schiffbruch", + "Shipwreck": "Schiffswrack", "Fatalities: {count}": "Todesopfer: {count}", "Survivors: {count}": "Überlebende: {count}", - "Click the card to open the article ↗": "Karte anklicken, um den Artikel zu öffnen ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Europäische Kulturerbe-Wracks stammen von EMODnet Human Activities, bekannte Wracks weltweit von Wikidata. Die Farbe zeigt, wann das Schiff sank; die Markierung wächst mit der Zahl der Todesopfer. Klicke auf eine Markierung für ihre Karte.", + "Click the card to open the article ↗": "Eintrag anklicken, um den Artikel zu öffnen ↗", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Europäische Kulturerbe-Wracks stammen von EMODnet Human Activities, bekannte Wracks weltweit von Wikidata. Die Farbe zeigt, wann das Schiff sank; die Markierung wächst mit der Zahl der Todesopfer. Für die Infokarte eine Markierung anklicken.", "All years": "Alle Jahre", "Period": "Zeitraum", "From": "Von", @@ -3363,11 +3365,11 @@ "Fungi": "Pilze", "Other animals": "Andere Tiere", "Other or unknown": "Andere oder unbekannt", - "{label} {detail} — click to retry": "{label} {detail} — zum Wiederholen klicken", + "{label} {detail} — click to retry": "{label}: {detail} – zum erneuten Versuch klicken", "Wildlife sightings source unavailable": "Quelle für Naturbeobachtungen nicht verfügbar", "Show only community-confirmed (research-grade) sightings": "Nur gemeinschaftlich bestätigte Beobachtungen (Forschungsqualität) anzeigen", - "No recent sightings in view": "Keine aktuellen Beobachtungen im Ausschnitt", - "Acartia feed unavailable": "Acartia-Datenstrom nicht verfügbar", + "No recent sightings in view": "Keine aktuellen Beobachtungen im Bild", + "Acartia feed unavailable": "Acartia-Feed nicht verfügbar", "Show all taxa": "Alle Artengruppen anzeigen", "BIRDS": "VÖGEL", "Show only birds": "Nur Vögel anzeigen", @@ -3385,48 +3387,48 @@ "Exit cockpit to fly to a sighting": "Cockpit verlassen, um zu einer Beobachtung zu fliegen", "Exit cockpit to fly to a sighting area": "Cockpit verlassen, um zu einem Beobachtungsgebiet zu fliegen", "Wildlife": "Tierwelt", - "Zoom in for local sightings": "Für lokale Beobachtungen vergrößern", + "Zoom in for local sightings": "Hineinzoomen für lokale Beobachtungen", "Loading photo...": "Foto wird geladen…", "Photo unavailable": "Foto nicht verfügbar", "Location obscured to a 0.2° cell": "Standort auf eine 0,2°-Rasterzelle verschleiert", "Photo of {name}": "Foto von {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} Sichtungen im Ausschnitt", - "Close sighting": "Sichtung schließen", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "In den letzten 48 h beobachtete Wildtiere (Wale 7 Tage). Farbe und Abzeichensymbol zeigen die Artengruppe. Empfindliche Arten erscheinen nur als 0,2°-Zellen; Acartia-Meldungen zu Meeressäugern aus der Salish-See werden auf 0,01° gerundet. Keine Bestandszählung.", + "{count} sightings in view": "Beobachtungen im Bild: {count}", + "Close sighting": "Beobachtung schließen", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "In den letzten 48 h beobachtete Wildtiere (Wale 7 Tage). Farbe und Symbol der Markierung zeigen die Artengruppe. Empfindliche Arten erscheinen nur als 0,2°-Zellen; Acartia-Meldungen zu Meeressäugern aus der Salish-See werden auf 0,01° gerundet. Keine Bestandszählung.", "Open {name} on iNaturalist": "{name} auf iNaturalist öffnen", "Open {name} on Acartia": "{name} auf Acartia öffnen", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "iNaturalist-Beitragende · Lizenzen je Eintrag", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist-Beitragende · Acartia {count} (Salish-See) · Lizenzen je Eintrag", - "{days}d": "{days}T", + "{days}d": "{days} T", "All markers": "Alle Markierungen", "Show markers of every age": "Markierungen jeden Alters anzeigen", - "Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} Tage; undatierte Markierungen bleiben", + "Maximum event age: {days}d; undated markers are kept": "Maximales Ereignisalter: {days} T; undatierte Markierungen bleiben erhalten", "event age filter|All": "Alle", "Event age": "Ereignisalter", - "Event age: {days}d · undated kept": "Ereignisalter: {days} Tage · undatierte bleiben", + "Event age: {days}d · undated kept": "Ereignisalter: {days} T · undatierte bleiben", "Close camera preview": "Kameravorschau schließen", "Refresh map still": "Kartenstandbild aktualisieren", "The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.", - "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.", + "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt; die IP-Adresse ist für diesen Anbieter sichtbar.", "Press OPEN to load the photo": "Auf ÖFFNEN klicken, um das Foto zu laden", "Captured {month}": "Aufgenommen im {month}", "this spot": "diese Stelle", "the view centre": "die Bildmitte", - "{source} at {place}": "{source} bei {place}", - "Street level at {place}": "Straßenebene bei {place}", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Straßenebene: {place}", "Switching map": "Karte wird gewechselt", "Opening imagery": "Bilder werden geöffnet", "Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden", - "{source} cannot open this link's street-level photo without a working key": "{source} kann das Straßenfoto dieses Links ohne gültigen Schlüssel nicht öffnen", + "{source} cannot open this link's street-level photo without a working key": "{source} kann das Foto auf Straßenebene aus diesem Link ohne gültigen Schlüssel nicht öffnen", "Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen.", "STATIONS": "STATIONEN", "Crewed stations and the craft that visit them": "Bemannte Stationen und die Raumfahrzeuge, die sie anfliegen", "IMAGING": "BILDGEBUNG", "Earth observation: optical, radar, weather and science": "Erdbeobachtung: optisch, Radar, Wetter und Wissenschaft", - "TELECOM": "TELEKOM", + "TELECOM": "KOMM.", "Communications and navigation satellites, Starlink excepted": "Kommunikations- und Navigationssatelliten, ohne Starlink", "STARLINK": "STARLINK", "The Starlink constellation (thousands of extra points)": "Die Starlink-Konstellation (Tausende zusätzliche Punkte)", @@ -3443,11 +3445,11 @@ "SENSOR SWATH ≈ {value} KM": "SENSORSTREIFEN ≈ {value} KM", "SENSOR": "SENSOR", "Zoom to the imaging sensor swath": "Auf den Streifen des Bildsensors zoomen", - "<1 H OLD": "<1 STD ALT", - "{value} H OLD": "{value} STD ALT", + "<1 H OLD": "<1 H ALT", + "{value} H OLD": "{value} H ALT", "{value} D OLD": "{value} T ALT", "FWD": "VORAUS", - "AFT": "ACHTERN", + "AFT": "ACHTERAUS", "RIGHT": "RECHTS", "LEFT": "LINKS", "TRACK UP": "KURS OBEN", @@ -3456,17 +3458,17 @@ "Zoom out (−)": "Herauszoomen (−)", "ORBITAL VIEW · OFF-NADIR": "ORBITALANSICHT · NADIRABWEICHUNG", "World time": "Weltzeit", - "Back to live": "Zurück zu live", + "Back to live": "Zurück zu Live", "Live": "Live", - "1 h/s": "1 Std./s", + "1 h/s": "1 h/s", "1 d/s": "1 Tag/s", "1 wk/s": "1 Wo./s", "Play speed": "Wiedergabegeschwindigkeit", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrollen oder mit zwei Fingern zoomen, ziehen zum Verschieben · Doppelklick zeigt alles", - "1 min/s": "1 Min./s", + "1 min/s": "1 min/s", "Rail": "Bahn", "Urban rail": "Stadtverkehr", - "Local rail": "Nahverkehr", + "Local rail": "Vorortverkehr", "Regional rail": "Regionalverkehr", "National rail": "Fernverkehr", "International rail": "International", @@ -3476,10 +3478,12 @@ "Estimated from station reports": "Geschätzt aus Bahnhofsmeldungen", "To {headsign}": "Nach {headsign}", "Next {stop} {time} {relative}": "Nächster Halt {stop} {time} {relative}", - "Prev {stop} {time} {relative}": "Letzter Halt {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Vorheriger Halt {stop} {time} {relative}", "(+{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", @@ -3500,7 +3504,7 @@ "Aim the view at a street": "Die Ansicht auf eine Straße richten", "{source} is not a street-level imagery source here": "{source} ist hier keine Quelle für Bilder auf Straßenebene", "{source} needs its key": "{source} braucht seinen Schlüssel", - "{source} needs the {map} map, which is not available": "{source} braucht die {map}-Karte, die nicht verfügbar ist", + "{source} needs the {map} map, which is not available": "{source} braucht die Karte „{map}“, die nicht verfügbar ist", "{source} could not be switched on": "{source} konnte nicht eingeschaltet werden", "{source} is not available on this map": "{source} ist auf dieser Karte nicht verfügbar", "{source} imagery could not be opened": "Bilder von {source} konnten nicht geöffnet werden", @@ -3566,13 +3570,13 @@ "Turned off by this instance's operator": "Vom Betreiber dieser Instanz ausgeschaltet", "Needs a provider key on the server": "Benötigt einen Anbieterschlüssel auf dem Server", "Needs this app's server · not available in a static build": "Benötigt den Server dieser App · in einem statischen Build nicht verfügbar", - "Checking what this instance serves…": "Prüfe, was diese Instanz bereitstellt…", + "Checking what this instance serves…": "Es wird geprüft, was diese Instanz bereitstellt…", "Regional": "Regional", "Orthophoto": "Orthofoto", "Satellite mosaic": "Satellitenmosaik", "Oblique": "Schrägluftbild", "Historic imagery": "Historische Luftbilder", - "True orthophoto": "True Orthofoto", + "True orthophoto": "True-Orthofoto", "Type": "Typ", "Resolution": "Auflösung", "Flown": "Befliegung", @@ -3581,7 +3585,7 @@ "DATA LIBRARY": "DATENBIBLIOTHEK", "Close data library": "Datenbibliothek schließen", "Data Library at a glance": "Datenbibliothek auf einen Blick", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Live-Quellen über die Kernebenen hinaus. Jede wird von ihrem Anbieter abgerufen, solange sie eingeschaltet ist; in dieser App wird nichts gespeichert. Fügen Sie eine hinzu, und sie erscheint in den Datenebenen unter Bibliothek.", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Live-Quellen über die Kernebenen hinaus. Jede wird von ihrem Anbieter abgerufen, solange sie eingeschaltet ist; in dieser App wird nichts gespeichert. Hinzugefügte Quellen erscheinen in den Datenebenen unter „Bibliothek“.", "Name, provider or country": "Name, Anbieter oder Land", "Theme": "Thema", "All themes": "Alle Themen", @@ -3603,5 +3607,12 @@ "Confirm: remove every added entry from Data Layers": "Bestätigen: alle hinzugefügten Einträge aus Datenebenen entfernen", "Remove every added entry from Data Layers": "Alle hinzugefügten Einträge aus Datenebenen entfernen", "Showing {shown} of {total}": "{shown} von {total} angezeigt", - "BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN" + "BROWSE DATA LIBRARY": "DATENBIBLIOTHEK DURCHSUCHEN", + "The globe stopped drawing": "Der Globus zeichnet nicht mehr", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.", + "Reset view reloads the app without the view restored from the address bar.": "Ansicht zurücksetzen lädt die App ohne die aus der Adressleiste wiederhergestellte Ansicht neu.", + "Reload": "Neu laden", + "Retry": "Erneut versuchen", + "Reset view": "Ansicht zurücksetzen" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 3f7a5e7b0..b0d10ae06 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3,12 +3,12 @@ "Developing: {name}": "En desarrollo: {name}", "{count} events today": { "one": "{count} suceso hoy", - "many": "{count} sucesos hoy", + "many": "{count} de sucesos hoy", "other": "{count} sucesos hoy" }, "{count} reports today": { "one": "{count} noticia hoy", - "many": "{count} noticias hoy", + "many": "{count} de noticias hoy", "other": "{count} noticias hoy" }, "Disputed: {name}": "En disputa: {name}", @@ -17,9 +17,9 @@ "Claimed by {who}": "Según {who}", "Claimed": "Afirmación", "Not yet confirmed by others": "Aún sin confirmar por otros", - "Reported by {side} sources": "Informado por fuentes de {side}", - "Reported by {sides}": "Informado por {sides}", - "{side} sources": "fuentes de {side}", + "Reported by {side} sources": "Informado por fuentes: {side}", + "Reported by {sides}": "Informado por fuentes: {sides}", + "{side} sources": "{side}", "Official": "Oficial", "Reported by both sides": "Informado por ambos bandos", "Independently reported": "Informado de forma independiente", @@ -43,7 +43,7 @@ "Humanitarian": "Humanitario", "Disaster": "Catástrofe", "Protest": "Protesta", - "Political": "Político", + "Political": "Política", "No physical event": "Sin hecho físico", "Minor incident": "Incidente menor", "Significant": "Significativo", @@ -53,34 +53,34 @@ "{hours} h": "{hours} h", "{count} sources": { "one": "{count} fuente", - "many": "{count} fuentes", + "many": "{count} de fuentes", "other": "{count} fuentes" }, "News feed not configured on this server": "Noticias no configuradas en este servidor", "Malformed news hotspots response": "Respuesta de focos de noticias mal formada", "Malformed news feed response": "Respuesta de noticias mal formada", "{place} (placed by model)": "{place} (ubicado por el modelo)", - "English headline by the model": "Titular en inglés del modelo", + "English headline by the model": "Titular traducido al inglés por el modelo", "Not placed on the map": "Sin ubicar en el mapa", "Close": "Cerrar", "Any severity": "Cualquier gravedad", - "{severity} and above": "{severity} o más", + "{severity} and above": "{severity} o superior", "{severity} only": "Solo {severity}", "All categories": "Todas las categorías", "Reading the news…": "Leyendo las noticias…", - "News, last {hours} hours": "Noticias de las últimas {hours} horas", - "Last {hours} h": "Últimas {hours} h", + "News, last {hours} hours": "Noticias desde hace {hours} h", + "Last {hours} h": "Desde hace {hours} h", "Time window": "Periodo", "Conflicts": "Conflictos", "Local": "Local", "News sections": "Secciones de noticias", - "Zoom in to see local news": "Acerca el mapa para ver noticias locales", + "Zoom in to see local news": "Acércate para ver noticias locales", "Hotspots": "Focos", "Severity": "Gravedad", "Category": "Categoría", - "Nothing reported near here in the last {hours} h": "Nada informado cerca de aquí en las últimas {hours} h", + "Nothing reported near here in the last {hours} h": "No hay noticias cerca de aquí desde hace {hours} h", "Latest news": "Últimas noticias", - "No news matches these filters in the last {hours} h": "Ninguna noticia de las últimas {hours} h coincide con estos filtros", + "No news matches these filters in the last {hours} h": "Desde hace {hours} h no hay noticias que coincidan con estos filtros", "Load older news": "Cargar noticias anteriores", "How to read the map": "Cómo leer el mapa", "Colour: severity · icon: type of event": "Color: gravedad · icono: tipo de suceso", @@ -219,8 +219,8 @@ "Landmark: --": "Punto de interés: --", "Search any location": "Buscar un lugar", "Search any location...": "Buscar un lugar…", - "LOOK STBD {bearing}°": "VISTA ESTRIBOR {bearing}°", - "LOOK PORT {bearing}°": "VISTA BABOR {bearing}°", + "LOOK STBD {bearing}°": "VISTA A ESTRIBOR {bearing}°", + "LOOK PORT {bearing}°": "VISTA A BABOR {bearing}°", "LOOK ASTERN": "VISTA A POPA", "UP {angle}°": "ARRIBA {angle}°", "DOWN {angle}°": "ABAJO {angle}°", @@ -232,7 +232,7 @@ "BRIDGE WINDOW · FWD": "VENTANA DEL PUENTE · AVANTE", "OPTICAL PLANE · 01": "PLANO ÓPTICO · 01", "HEADING HOLD · AIS": "BLOQUEO DE PROA · AIS", - "NO HEADING · AIS": "SIN PROA · AIS", + "NO HEADING · AIS": "PROA N/D · AIS", "VISOR LOCK · ACTIVE": "BLOQUEO DE VISERA · ACTIVO", "FIRST PERSON": "PRIMERA PERSONA", "BRIDGE VIEW": "VISTA DEL PUENTE", @@ -240,7 +240,7 @@ "Cockpit vision style": "Estilo de visión de la cabina", "Current aircraft heading": "Rumbo actual de la aeronave", "Look ahead: turn the bridge view back to the bow": "Mirar a proa: girar de nuevo la vista del puente hacia la proa", - "Look ahead (Home or 0)": "Mirar a proa (Inicio o 0)", + "Look ahead (Home or 0)": "Mirar a proa (Home o 0)", "LOOK AHEAD": "MIRAR A PROA", "Current vessel heading": "Proa actual del buque", "GROUND SPEED · KTS": "VEL. SUELO · KN", @@ -353,7 +353,7 @@ "TRACK ACQUIRED": "RASTREO ADQUIRIDO", "{label} · COURSE {heading}°": "{label} · RUMBO {heading}°", "{label} · HEADING {heading}°": "{label} · PROA {heading}°", - "{label} · NO HEADING": "{label} · SIN PROA", + "{label} · NO HEADING": "{label} · PROA N/D", "{label} · NADIR VIEW": "{label} · VISTA AL NADIR", "Select flight {title}": "Seleccionar el vuelo {title}", "CONTEXT STANDBY": "CONTEXTO EN ESPERA", @@ -502,9 +502,9 @@ "other": "{count} cámaras cargadas · activa CCTV para activarlas" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "{count} cámara más fuera de esta vista · desplaza o aleja", - "many": "{count} de cámaras más fuera de esta vista · desplaza o aleja", - "other": "{count} cámaras más fuera de esta vista · desplaza o aleja" + "one": "{count} cámara más fuera de esta vista · desplaza el mapa o aléjate", + "many": "{count} de cámaras más fuera de esta vista · desplaza el mapa o aléjate", + "other": "{count} cámaras más fuera de esta vista · desplaza el mapa o aléjate" }, "No summary available.": "No hay resumen disponible.", "Celestial ring — available in Normal style": "Anillo celestial — disponible en el estilo Normal", @@ -650,6 +650,8 @@ "TANKER": "PETROLERO", "OTHER": "OTRO", "VESSEL": "BUQUE", + "AID TO NAVIGATION": "AYUDA A LA NAVEGACIÓN", + "VIRTUAL": "VIRTUAL", "POS: LIVE": "POS: EN VIVO", "POS: {time}Z": "POS: {time}Z", "AIS: --": "AIS: --", @@ -694,7 +696,7 @@ "Directory metadata only": "Solo metadatos del directorio", "Choose a globe marker or use next.": "Elige un marcador del globo o pulsa SIG.", "TAGS · {tags}": "ETIQUETAS · {tags}", - "Ready — playback starts only from your action": "Preparada: la reproducción empieza cuando pulses ESCUCHAR", + "Ready — playback starts only from your action": "Preparada: la reproducción solo empieza cuando tú la inicias", "Connecting directly to broadcaster…": "Conectando directamente con la emisora…", "Buffering broadcaster stream…": "Almacenando en búfer el flujo de la emisora…", "Playing {station}": "Reproduciendo {station}", @@ -756,7 +758,7 @@ "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "FUERA DEL NADIR {angle}° · {offset}", "SITES STANDBY": "SITIOS EN ESPERA", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVAR INSTALACIONES CARTOGRAFIADAS PARA PINGS DE SITIOS", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ACTIVA INSTALACIONES MAPEADAS PARA PINGS DE SITIOS", "Release the camera from {name}": "Soltar la cámara de {name}", "RETURN · {name}": "VOLVER · {name}", "PREVIOUS SATELLITE": "SATÉLITE ANTERIOR", @@ -785,7 +787,7 @@ "Hide the two-line element set": "Ocultar el conjunto de elementos de dos líneas", "Two-line element set": "Conjunto de elementos de dos líneas", "Return the view to nadir": "Volver la vista al nadir", - "Return to nadir (Home or 0)": "Volver al nadir (Inicio o 0)", + "Return to nadir (Home or 0)": "Volver al nadir (Home o 0)", "NADIR": "AL NADIR", "REPLAY ASCENT": "REPETIR ASCENSO", "Replay the estimated ascent with a following camera": "Reproduce el ascenso estimado con una cámara de seguimiento", @@ -1773,10 +1775,10 @@ "Weather data by Open-Meteo.com": "Datos meteorológicos de Open-Meteo.com", "ESC EXIT": "ESC SALIR", "ARROWS LOOK": "FLECHAS MIRAR", - "HOME RESET": "INICIO RESTABLECER", + "HOME RESET": "HOME RESTABLECER", "+/− ZOOM": "+/− ACERCAR/ALEJAR", "ARROWS TILT": "FLECHAS INCLINAR", - "HOME NADIR": "INICIO NADIR", + "HOME NADIR": "HOME AL NADIR", "SIG": "SIG", "NEWS": "NOTICIAS", "LOCAL": "LOCAL", @@ -2096,7 +2098,7 @@ "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALACIONES: {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "FUENTE MILITAR DESACTIVADA · PANORAMA PARCIAL", "FLIGHTS FEED OFF · PARTIAL PICTURE": "FUENTE DE VUELOS DESACTIVADA · PANORAMA PARCIAL", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS INCOMPLETAS · NO IMPLICA TODO DESPEJADO", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES CERCANAS: DATOS INCOMPLETOS · NO IMPLICA TODO DESPEJADO", "{source} · LAST {hours} H": "{source} · ÚLTIMAS {hours} H", "ft": "ft", "kts": "kn", @@ -2339,7 +2341,7 @@ "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Muestra regional · radio de 250 NM · celdas de las vistas visitadas en {minutes} min", "Retrying automatically at {time} UTC": "Reintento automático a las {time} UTC", "Retrying automatically after provider cooldown": "Reintento automático tras la pausa del proveedor", - "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Se actualiza automáticamente al desplazar o hacer zoom y una vez por minuto. El feed público cubre un círculo de 250 millas náuticas alrededor del centro de la vista; alejarte no lo convierte en una instantánea mundial. Las celdas vacías están fuera de la cobertura muestreada o no tienen observaciones suficientes.", + "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Se actualiza automáticamente al desplazar el mapa o hacer zoom y una vez por minuto. La fuente pública cubre un círculo de 250 millas náuticas alrededor del centro de la vista; alejarte no la convierte en una instantánea mundial. Las celdas vacías están fuera de la cobertura muestreada o no tienen observaciones suficientes.", "Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "Índice de baja precisión = 100 × max(0, malas − 1) / total: se descuenta una aeronave, así que no es la proporción bruta. Cada celda necesita {aircraft} aeronaves en {minutes} minutos. Las anomalías de integridad de navegación no son una detección de interferencias ni de suplantación.", "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", "ADS-B PRIORITY · {status}": "ADS-B PRIORIDAD · {status}", @@ -2450,7 +2452,7 @@ "TomTom key unavailable": "Clave de TomTom no disponible", "TomTom daily budget reached": "Se alcanzó el límite diario de TomTom", "Monthly traffic allowance used": "Cupo mensual de tráfico agotado", - "Traffic rate limit reached": "Límite de solicitudes de tráfico alcanzado", + "Traffic rate limit reached": "Límite de peticiones de tráfico alcanzado", "Traffic service unavailable": "Servicio de tráfico no disponible", "TomTom upstream unreachable": "Servidor de TomTom inaccesible", "TomTom flow unavailable": "Flujo de TomTom no disponible", @@ -3114,7 +3116,7 @@ "Changing provider reconnects voice if it is on.": "Cambiar de proveedor vuelve a conectar la voz si está activada.", "Gemini voice could not connect. Try again.": "No se pudo conectar la voz de Gemini. Inténtalo de nuevo.", "Gemini voice requires a same-origin app request": "La voz de Gemini requiere una solicitud del mismo origen que la aplicación", - "Add a Gemini API key in Provider Settings": "Añade una clave de API de Gemini en los ajustes de proveedores", + "Add a Gemini API key in Provider Settings": "Añade una clave de API de Gemini en Ajustes de proveedores", "The Gemini Live model configuration is invalid": "La configuración del modelo de Gemini Live no es válida", "Gemini voice is starting too often. Try again shortly.": "La voz de Gemini se está iniciando con demasiada frecuencia. Vuelve a intentarlo en breve.", "Gemini quota is unavailable. Try again later.": "La cuota de Gemini no está disponible. Inténtalo más tarde.", @@ -3215,18 +3217,18 @@ "Add the current view, style and layers as the next shot": "Añade la vista, el estilo y las capas actuales como el siguiente plano", "Replace the selected shot with the current view": "Sustituye el plano seleccionado por la vista actual", "Click a shot to select it, double-click to rename.": "Haz clic en un plano para seleccionarlo y doble clic para cambiarle el nombre.", - "scene|PLAY": "REPROD.", + "scene|PLAY": "REPRODUCIR", "Stop playback (Esc)": "Detén la reproducción (Esc)", "SAVE & SHARE": "GUARDAR Y COMPARTIR", "Download every scene as a JSON file you can import later": "Descarga todas las escenas como un archivo JSON que puedes importar más tarde", "Load scenes from an exported JSON file": "Carga escenas desde un archivo JSON exportado", "Download the timing log of the last play": "Descarga el registro de tiempos de la última reproducción", - "Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el resto del proyecto", + "Play from this scene through the rest of the project": "Reproduce desde esta escena hasta el final del proyecto", "Play the shot after the selected one, moving on to the next scene when needed": "Reproduce el plano posterior al seleccionado y pasa a la siguiente escena si hace falta", "Edit anchors, data packs and shot details": "Edita anclas, paquetes de datos y detalles de los planos", "Download this scene as JSON or an asset bundle": "Descarga esta escena como JSON o como paquete de recursos", - "Left panels": "Paneles izquierdos", - "Right panels": "Paneles derechos", + "Left panels": "Paneles de la izquierda", + "Right panels": "Paneles de la derecha", "UTILITIES": "UTILIDADES", "Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo", "Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos", @@ -3393,7 +3395,7 @@ "Date unknown": "Fecha desconocida", "Position: centre of the protected area": "Posición: centro de la zona protegida", "Position approximate (within about {km} km)": "Posición aproximada (margen de unos {km} km)", - "Position surveyed (within about {m} m)": "Posición levantada (margen de unos {m} m)", + "Position surveyed (within about {m} m)": "Posición medida (margen de unos {m} m)", "Vessel: {value}": "Embarcación: {value}", "What happened: {value}": "Qué ocurrió: {value}", "Depth: {m} m ({quality})": "Profundidad: {m} m ({quality})", @@ -3402,8 +3404,8 @@ "Cargo or finds: {value}": "Carga o hallazgos: {value}", "Protected wreck": "Pecio protegido", "Source: {value}": "Fuente: {value}", - "Click the card to open EMODnet ↗": "Haz clic en la ficha para abrir EMODnet ↗", - "Click the card to open the source ↗": "Haz clic en la ficha para abrir la fuente ↗", + "Click the card to open EMODnet ↗": "Haz clic en la tarjeta para abrir EMODnet ↗", + "Click the card to open the source ↗": "Haz clic en la tarjeta para abrir la fuente ↗", "Wikidata unavailable; showing EMODnet only": "Wikidata no disponible; solo se muestra EMODnet", "EMODnet unavailable; showing Wikidata only": "EMODnet no disponible; solo se muestra Wikidata", "Shipwrecks unavailable": "Naufragios no disponibles", @@ -3417,8 +3419,8 @@ "Shipwreck": "Naufragio", "Fatalities: {count}": "Fallecidos: {count}", "Survivors: {count}": "Supervivientes: {count}", - "Click the card to open the article ↗": "Haz clic en la ficha para abrir el artículo ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Los pecios históricos europeos proceden de EMODnet Human Activities; los naufragios notables de todo el mundo, de Wikidata. El color indica cuándo se hundió el barco; el marcador crece con el número de muertos. Haz clic en un marcador para ver su ficha.", + "Click the card to open the article ↗": "Haz clic en la tarjeta para abrir el artículo ↗", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Los pecios históricos europeos proceden de EMODnet Human Activities; los naufragios notables de todo el mundo, de Wikidata. El color indica cuándo se hundió el barco; el marcador crece con el número de muertos. Haz clic en un marcador para ver su tarjeta.", "All years": "Todos los años", "Period": "Periodo", "From": "Desde", @@ -3447,7 +3449,7 @@ "{label} {detail} — click to retry": "{label} {detail} — haz clic para reintentar", "Wildlife sightings source unavailable": "Fuente de avistamientos no disponible", "Show only community-confirmed (research-grade) sightings": "Mostrar solo observaciones confirmadas por la comunidad (grado de investigación)", - "No recent sightings in view": "No hay avistamientos recientes en la vista", + "No recent sightings in view": "No hay avistamientos recientes a la vista", "Acartia feed unavailable": "Fuente Acartia no disponible", "Show all taxa": "Mostrar todos los grupos taxonómicos", "BIRDS": "AVES", @@ -3466,33 +3468,33 @@ "Exit cockpit to fly to a sighting": "Sal de la cabina para volar a un avistamiento", "Exit cockpit to fly to a sighting area": "Sal de la cabina para volar a un área de avistamiento", "Wildlife": "Vida silvestre", - "Zoom in for local sightings": "Acerca para ver avistamientos locales", + "Zoom in for local sightings": "Acércate para ver avistamientos locales", "Loading photo...": "Cargando foto…", "Photo unavailable": "Foto no disponible", "Location obscured to a 0.2° cell": "Ubicación oculta en una celda de 0,2°", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} avistamientos en la vista", + "{count} sightings in view": "Avistamientos a la vista: {count}", "Close sighting": "Cerrar avistamiento", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada en las últimas 48 h (cetáceos 7 días). El color y el símbolo de la insignia indican el grupo taxonómico. Las especies sensibles solo se muestran en celdas de 0,2°; los informes de mamíferos marinos de Acartia en el mar de Salish se redondean a 0,01°. No es un censo.", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna y flora observadas en las últimas 48 h (cetáceos 7 días). El color y el símbolo de la insignia indican el grupo taxonómico. Las especies sensibles solo se muestran en celdas de 0,2°; los informes de mamíferos marinos de Acartia en el mar de Salish se redondean a 0,01°. No es un censo.", "Open {name} on iNaturalist": "Abrir {name} en iNaturalist", "Open {name} on Acartia": "Abrir {name} en Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Colaboradores de iNaturalist · licencias por registro", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores de iNaturalist · Acartia {count} (mar de Salish) · licencias por registro", - "{days}d": "{days}d", + "{days}d": "{days} d", "All markers": "Todos los marcadores", "Show markers of every age": "Mostrar marcadores de cualquier antigüedad", - "Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} días; se conservan los marcadores sin fecha", + "Maximum event age: {days}d; undated markers are kept": "Antigüedad máxima: {days} d; se conservan los marcadores sin fecha", "event age filter|All": "Todos", "Event age": "Antigüedad", - "Event age: {days}d · undated kept": "Antigüedad: {days} días · se conservan los marcadores sin fecha", + "Event age: {days}d · undated kept": "Antigüedad: {days} d · se conservan los marcadores sin fecha", "Close camera preview": "Cerrar vista previa de la cámara", "Refresh map still": "Actualizar imagen fija del mapa", "The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.", "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.", - "Press OPEN to load the photo": "Pulse ABRIR para cargar la foto", + "Press OPEN to load the photo": "Pulsa ABRIR para cargar la foto", "Captured {month}": "Capturada en {month}", "this spot": "este punto", "the view centre": "el centro de la vista", @@ -3537,38 +3539,40 @@ "Zoom out (−)": "Alejar (−)", "ORBITAL VIEW · OFF-NADIR": "VISTA ORBITAL · FUERA DEL NADIR", "World time": "Hora mundial", - "Back to live": "Volver al directo", - "Live": "En directo", + "Back to live": "Volver al modo en vivo", + "Live": "En vivo", "1 h/s": "1 h/s", "1 d/s": "1 d/s", "1 wk/s": "1 sem/s", "Play speed": "Velocidad de reproducción", - "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Desplaza o pellizca para ampliar, arrastra para moverte · doble clic para verlo todo", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Usa la rueda o pellizca para hacer zoom, arrastra para desplazarte · doble clic para verlo todo", "1 min/s": "1 min/s", "Rail": "Ferrocarril", "Urban rail": "Urbano", - "Local rail": "Cercanías", + "Local rail": "Suburbano", "Regional rail": "Regional", "National rail": "Larga distancia", "International rail": "Internacional", "Hide {category}": "Ocultar {category}", "Show {category}": "Mostrar {category}", "Live GPS": "GPS en vivo", - "Estimated from station reports": "Estimada a partir de avisos de estaciones", + "Estimated from station reports": "Posición estimada a partir de avisos de estaciones", "To {headsign}": "Hacia {headsign}", "Next {stop} {time} {relative}": "Próxima parada {stop} {time} {relative}", "Prev {stop} {time} {relative}": "Parada anterior {stop} {time} {relative}", "(+{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 flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba ({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", - "The shared military flight isn't reporting now — showing where it was at {time}": "El vuelo militar 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 at {time}": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde estaba ({time})", "The shared vessel isn't reporting now — showing where it was last seen": "El buque compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", - "The shared vessel isn't reporting now — showing where it was at {time}": "El buque compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "The shared vessel isn't reporting now — showing where it was at {time}": "El buque compartido no está transmitiendo ahora; se muestra dónde estaba ({time})", "The shared satellite isn't reporting now — showing where it was last seen": "El satélite compartido no está transmitiendo ahora; se muestra dónde se vio por última vez", - "The shared satellite isn't reporting now — showing where it was at {time}": "El satélite compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}", + "The shared satellite isn't reporting now — showing where it was at {time}": "El satélite compartido no está transmitiendo ahora; se muestra dónde estaba ({time})", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", @@ -3684,11 +3688,18 @@ "Resolution": "Resolución", "Flown": "Vuelo", "Updates": "Actualización", - "Ortho quality": "Calidad del orto", + "Ortho quality": "Calidad de la ortofoto", "Below 250 m": "Menos de 250 m", "250–500 m": "250–500 m", "500–1,000 m": "500–1000 m", "1,000–2,000 m": "1000–2000 m", "2,000–3,000 m": "2000–3000 m", - "3,000 m and above": "3000 m o más" + "3,000 m and above": "3000 m o más", + "The globe stopped drawing": "El globo dejó de dibujarse", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.", + "Reset view reloads the app without the view restored from the address bar.": "Restablecer la vista recarga la app sin la vista restaurada desde la barra de direcciones.", + "Reload": "Recargar", + "Retry": "Reintentar", + "Reset view": "Restablecer la vista" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 870d75390..ffe522ad9 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1,32 +1,32 @@ { - "News feed unavailable": "Fil d’actualités indisponible", - "Developing: {name}": "En cours : {name}", + "News feed unavailable": "Fil d'actualités indisponible", + "Developing: {name}": "En cours : {name}", "{count} events today": { - "one": "{count} événement aujourd’hui", - "many": "{count} événements aujourd’hui", - "other": "{count} événements aujourd’hui" + "one": "{count} événement aujourd'hui", + "many": "{count} d'événements aujourd'hui", + "other": "{count} événements aujourd'hui" }, "{count} reports today": { - "one": "{count} article aujourd’hui", - "many": "{count} articles aujourd’hui", - "other": "{count} articles aujourd’hui" + "one": "{count} article aujourd'hui", + "many": "{count} d'articles aujourd'hui", + "other": "{count} articles aujourd'hui" }, - "Disputed: {name}": "Contesté : {name}", + "Disputed: {name}": "Zone contestée : {name}", "Video": "Vidéo", "Photos": "Photos", "Claimed by {who}": "Selon {who}", "Claimed": "Affirmation", - "Not yet confirmed by others": "Pas encore confirmé par d’autres", - "Reported by {side} sources": "Rapporté par des sources {side}", - "Reported by {sides}": "Rapporté par {sides}", - "{side} sources": "sources {side}", + "Not yet confirmed by others": "Pas encore confirmé par d'autres", + "Reported by {side} sources": "Rapporté par des sources : {side}", + "Reported by {sides}": "Rapporté par des sources : {sides}", + "{side} sources": "{side}", "Official": "Officiel", "Reported by both sides": "Rapporté par les deux camps", "Independently reported": "Rapporté de façon indépendante", "Single source": "Source unique", "International": "Internationale", "Independent": "Indépendante", - "Unattributed": "Non attribué", + "Unattributed": "Non attribuée", "Outside party": "Partie extérieure", "Ukraine–Russia": "Ukraine–Russie", "Israel–Gaza–Lebanon": "Israël–Gaza–Liban", @@ -48,19 +48,19 @@ "Minor incident": "Incident mineur", "Significant": "Important", "Major": "Majeur", - "age|now": "maintenant", + "age|now": "à l'instant", "{minutes} min": "{minutes} min", "{hours} h": "{hours} h", "{count} sources": { "one": "{count} source", - "many": "{count} sources", + "many": "{count} de sources", "other": "{count} sources" }, - "News feed not configured on this server": "Fil d’actualités non configuré sur ce serveur", - "Malformed news hotspots response": "Réponse des foyers d’actualité mal formée", - "Malformed news feed response": "Réponse du fil d’actualités mal formée", + "News feed not configured on this server": "Fil d'actualités non configuré sur ce serveur", + "Malformed news hotspots response": "Réponse des foyers d'actualité mal formée", + "Malformed news feed response": "Réponse du fil d'actualités mal formée", "{place} (placed by model)": "{place} (placé par le modèle)", - "English headline by the model": "Titre anglais du modèle", + "English headline by the model": "Titre traduit en anglais par le modèle", "Not placed on the map": "Non placé sur la carte", "Close": "Fermer", "Any severity": "Toute gravité", @@ -68,26 +68,26 @@ "{severity} only": "{severity} uniquement", "All categories": "Toutes les catégories", "Reading the news…": "Lecture des actualités…", - "News, last {hours} hours": "Actualités des dernières {hours} heures", - "Last {hours} h": "Dernières {hours} h", + "News, last {hours} hours": "Actualités sur {hours} h", + "Last {hours} h": "Sur {hours} h", "Time window": "Période", "Conflicts": "Conflits", "Local": "Local", - "News sections": "Rubriques d’actualité", - "Zoom in to see local news": "Zoomez pour voir l’actualité locale", + "News sections": "Rubriques d'actualité", + "Zoom in to see local news": "Zoomez pour voir l'actualité locale", "Hotspots": "Foyers", "Severity": "Gravité", "Category": "Catégorie", - "Nothing reported near here in the last {hours} h": "Rien de signalé près d’ici ces dernières {hours} h", + "Nothing reported near here in the last {hours} h": "Rien de signalé près d'ici en {hours} h", "Latest news": "Dernières actualités", - "No news matches these filters in the last {hours} h": "Aucune actualité des dernières {hours} h ne correspond à ces filtres", + "No news matches these filters in the last {hours} h": "Aucune actualité ne correspond à ces filtres sur {hours} h", "Load older news": "Charger des actualités plus anciennes", "How to read the map": "Comment lire la carte", - "Colour: severity · icon: type of event": "Couleur : gravité · icône : type d’événement", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bord plein : rapporté par les deux camps ou de façon indépendante. Bord en tirets : une affirmation. Bord fin : une source, ou la presse d’un camp.", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Plus grand : plus de sources · plus pâle : plus ancien. Carte illustrée : un événement majeur ou en forte croissance, avec au moins deux sources.", - "Side labels describe the source, not whether a report is true.": "Les étiquettes de camp décrivent la source, pas la véracité de l’article.", - "Exit cockpit to fly to a news report": "Quittez le cockpit pour aller à un article", + "Colour: severity · icon: type of event": "Couleur : gravité · icône : type d'événement", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bord plein : rapporté par les deux camps ou de façon indépendante. Bord en tirets : une affirmation. Bord fin : une source, ou la presse d'un camp.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Plus grand : plus de sources · plus pâle : plus ancien. Fiche illustrée : un événement majeur ou en forte croissance, avec au moins deux sources.", + "Side labels describe the source, not whether a report is true.": "Les étiquettes de camp décrivent la source, pas la véracité de l'article.", + "Exit cockpit to fly to a news report": "Quittez le cockpit pour rejoindre un article", "News items in the feed": "Articles dans le fil", "Pick a shape, then click the map.": "Choisissez une forme, puis cliquez sur la carte.", "Enter to place the pin, Esc to cancel.": "Enter pour poser l'épingle, Esc pour annuler.", @@ -951,7 +951,7 @@ "Hide the two-line element set": "Masquer le jeu d'éléments à deux lignes", "Two-line element set": "Jeu d'éléments à deux lignes", "Return the view to nadir": "Ramener la vue au nadir", - "Return to nadir (Home or 0)": "Retour au nadir (Home ou 0)", + "Return to nadir (Home or 0)": "Revenir au nadir (Home ou 0)", "NADIR": "AU NADIR", "Selected Space Mission": "Mission spatiale sélectionnée", "SELECTED SPACE MISSION": "MISSION SPATIALE SÉLECTIONNÉE", @@ -1291,6 +1291,8 @@ "AIS: {name}": "AIS : {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD : {speed} HDG : {heading}", "VESSEL": "NAVIRE", + "AID TO NAVIGATION": "AIDE À LA NAVIGATION", + "VIRTUAL": "VIRTUEL", "{speed}KT": "{speed} ND", "{heading}DEG": "{heading}°", "POS: LIVE": "POS : DIRECT", @@ -1754,9 +1756,9 @@ "other": "{count} caméras chargées · activer CCTV pour les utiliser" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "{count} autre caméra hors de cette vue · déplacer ou dézoomer", - "many": "{count} d'autres caméras hors de cette vue · déplacer ou dézoomer", - "other": "{count} autres caméras hors de cette vue · déplacer ou dézoomer" + "one": "{count} autre caméra hors de cette vue · déplacez la carte ou dézoomez", + "many": "{count} d'autres caméras hors de cette vue · déplacez la carte ou dézoomez", + "other": "{count} autres caméras hors de cette vue · déplacez la carte ou dézoomez" }, "Enable CCTV to load camera intersections": "Activez CCTV pour charger les carrefours équipés de caméras", "Enable CCTV to start camera-linked intelligence summaries.": "Activez CCTV pour lancer les résumés de renseignement liés aux caméras.", @@ -1782,7 +1784,7 @@ "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Faire défiler automatiquement les pages du briefing toutes les 9 secondes (Signaux → Actus → Local). Pause pendant le survol ou quand le panneau a le focus. Les signaux en direct s'actualisent en continu dans les deux cas.", "ACQUIRING REGIONAL NEWS": "ACQUISITION DES ACTUS RÉGIONALES", "REGIONAL NEWS UNAVAILABLE": "ACTUS RÉGIONALES INDISPONIBLES", - "CACHED · REFRESH FAILED": "EN CACHE · ÉCHEC DE L’ACTUALISATION", + "CACHED · REFRESH FAILED": "EN CACHE · ÉCHEC DE L'ACTUALISATION", "RESOLVING REGION": "IDENTIFICATION DE LA RÉGION", "REGION UNAVAILABLE": "RÉGION INDISPONIBLE", "NO NEWS IN THE LAST {hours} H": "AUCUNE ACTU CES {hours} DERNIÈRES H", @@ -1794,9 +1796,9 @@ "REGIONAL NEWS": "ACTUS RÉGIONALES", "{label} · {radius} AIR/SEA WINDOW": "{label} · AIR/MER {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALLATIONS : {coverage}", - "MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE COUPÉ · SITUATION PARTIELLE", - "FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS COUPÉ · SITUATION PARTIELLE", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES INCOMPLETS · PAS UN FEU VERT", + "MILITARY FEED OFF · PARTIAL PICTURE": "FLUX MILITAIRE DÉSACTIVÉ · SITUATION PARTIELLE", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FLUX DES VOLS DÉSACTIVÉ · SITUATION PARTIELLE", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AÉRONEFS PROCHES : DONNÉES INCOMPLÈTES · PAS UN FEU VERT", "CONTEXT STANDBY": "CONTEXTE EN VEILLE", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ACTIVER LE CONTEXTE GLOBAL POUR LES PINGS DE PROXIMITÉ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTACT PERDU · DERNIER RELEVÉ CONNU · PAS UN FEU VERT", @@ -1832,7 +1834,7 @@ "ROUTE DATA UNAVAILABLE": "DONNÉES DE ROUTE INDISPONIBLES", "CONTACT NAVIGATION OFF": "NAVIGATION DES CONTACTS DÉSACTIVÉE", "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "LA PASSERELLE RESTE SUR LE NAVIRE SÉLECTIONNÉ", - "{range} NM": "{range} MN", + "{range} NM": "{range} NM", "AIS DESTINATION · CREW REPORTED": "DESTINATION AIS · DÉCLARÉE PAR L'ÉQUIPAGE", "DEST {bearing}": "DEST {bearing}", "Disable cockpit weather effects": "Désactiver les effets météo du cockpit", @@ -2867,7 +2869,7 @@ "Next cockpit vision style": "Style de vision suivant du cockpit", "Next vision style": "Style de vision suivant", "Current aircraft heading": "Cap actuel de l'aéronef", - "Look ahead: turn the bridge view back to the bow": "Vue avant : ramener la vue passerelle vers l'étrave", + "Look ahead: turn the bridge view back to the bow": "Vue avant : ramener la vue passerelle vers l'étrave", "Look ahead (Home or 0)": "Vue avant (Home ou 0)", "LOOK AHEAD": "VUE AVANT", "Current vessel heading": "Cap actuel du navire", @@ -2880,7 +2882,7 @@ "Contact cockpit summary": "Résumé cockpit du contact", "CONTACT": "CONTACT", "CONTACTS · 250 KM": "CONTACTS · 250 KM", - "RINGS {spacing} NM": "CERCLES {spacing} MN", + "RINGS {spacing} NM": "CERCLES {spacing} NM", "{count} CONTACTS": { "one": "{count} CONTACT", "many": "{count} DE CONTACTS", @@ -3195,7 +3197,7 @@ "Add the current view, style and layers as the next shot": "Ajouter la vue, le style et les couches actuels comme plan suivant", "Replace the selected shot with the current view": "Remplacer le plan sélectionné par la vue actuelle", "Click a shot to select it, double-click to rename.": "Cliquez sur un plan pour le sélectionner, double-cliquez pour le renommer.", - "Play from this scene through the rest of the project": "Lire de cette scène jusqu'à la fin du projet", + "Play from this scene through the rest of the project": "Lire à partir de cette scène jusqu'à la fin du projet", "Stop playback (Esc)": "Arrêter la lecture (Esc)", "Play the shot after the selected one, moving on to the next scene when needed": "Lire le plan qui suit la sélection, en passant à la scène suivante si besoin", "SAVE & SHARE": "ENREGISTRER ET PARTAGER", @@ -3372,40 +3374,40 @@ "Too many chat requests. Try again in {seconds} s.": "Trop de requêtes de discussion. Réessayez dans {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "Le fournisseur a refusé l'accès pour cette clé. Vérifiez le projet, la région ou les autorisations de la clé.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Le compte du fournisseur n'a plus de crédit. Vérifiez la facturation ou choisissez un autre fournisseur d'IA dans les paramètres.", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Dernières 24 h d’actualité. Chaque article renvoie à son éditeur ; lieux et catégories sont lus automatiquement dans le texte et les données et peuvent être faux. Les étiquettes de camp, là où elles figurent, décrivent la source, pas la véracité de l’article.", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Dernières 24 h d'actualité. Chaque article renvoie à son éditeur ; lieux et catégories sont lus automatiquement dans le texte et les données et peuvent être faux. Les étiquettes de camp, là où elles figurent, décrivent la source, pas la véracité de l'article.", "Economy": "Économie", - "News from {area}": "Actualités : {area}", + "News from {area}": "Actualités : {area}", "Within {km} km of the view centre": "À moins de {km} km du centre de la vue", "{region}, {country}": "{region}, {country}", "via {source}": "via {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "Local dans la couche Actualités : l’actualité du jour des médias du pays affiché", + "Local in the News layer: the day’s news from the outlets of the country in view": "Local dans la couche Actualités : l'actualité du jour des médias du pays affiché", "{provider} refused its key": "{provider} a refusé sa clé", "Check {envVars} in Provider Settings": "Vérifiez {envVars} dans les paramètres des fournisseurs", "{provider} key required": "Clé {provider} requise", "{provider} limit reached for today": "{provider} a atteint sa limite du jour", "News resumes tomorrow, or with a paid plan": "Les actualités reprennent demain, ou avec une offre payante", - "News key required": "Clé d’actualités requise", + "News key required": "Clé d'actualités requise", "Precise conflict points in the News layer, beside GDELT’s articles": "Points de conflit précis dans la couche Actualités, à côté des articles de GDELT", "Worldwide": "Monde entier", "Shipwrecks": "Épaves", "Before 1900": "Avant 1900", "After 1945": "Après 1945", "Date unknown": "Date inconnue", - "Position: centre of the protected area": "Position : centre de la zone protégée", + "Position: centre of the protected area": "Position : centre de la zone protégée", "Position approximate (within about {km} km)": "Position approximative (à environ {km} km près)", "Position surveyed (within about {m} m)": "Position relevée (à environ {m} m près)", - "Vessel: {value}": "Navire : {value}", - "What happened: {value}": "Ce qui s'est passé : {value}", - "Depth: {m} m ({quality})": "Profondeur : {m} m ({quality})", - "Depth: {m} m": "Profondeur : {m} m", - "Notes: {value}": "Notes : {value}", - "Cargo or finds: {value}": "Cargaison ou découvertes : {value}", + "Vessel: {value}": "Navire : {value}", + "What happened: {value}": "Ce qui s'est passé : {value}", + "Depth: {m} m ({quality})": "Profondeur : {m} m ({quality})", + "Depth: {m} m": "Profondeur : {m} m", + "Notes: {value}": "Notes : {value}", + "Cargo or finds: {value}": "Cargaison ou découvertes : {value}", "Protected wreck": "Épave protégée", - "Source: {value}": "Source : {value}", + "Source: {value}": "Source : {value}", "Click the card to open EMODnet ↗": "Cliquez sur la fiche pour ouvrir EMODnet ↗", "Click the card to open the source ↗": "Cliquez sur la fiche pour ouvrir la source ↗", - "Wikidata unavailable; showing EMODnet only": "Wikidata indisponible ; seul EMODnet est affiché", - "EMODnet unavailable; showing Wikidata only": "EMODnet indisponible ; seul Wikidata est affiché", + "Wikidata unavailable; showing EMODnet only": "Wikidata indisponible ; seul EMODnet est affiché", + "EMODnet unavailable; showing Wikidata only": "EMODnet indisponible ; seul Wikidata est affiché", "Shipwrecks unavailable": "Épaves indisponibles", "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (eaux européennes) · Wikidata (monde)", "{count} wrecks": { @@ -3415,14 +3417,14 @@ }, "1900–1945": "1900–1945", "Shipwreck": "Naufrage", - "Fatalities: {count}": "Morts : {count}", - "Survivors: {count}": "Survivants : {count}", + "Fatalities: {count}": "Morts : {count}", + "Survivors: {count}": "Survivants : {count}", "Click the card to open the article ↗": "Cliquez sur la fiche pour ouvrir l'article ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Les épaves patrimoniales européennes viennent d'EMODnet Human Activities ; les épaves notables du monde entier viennent de Wikidata. La couleur indique quand le navire a coulé ; le repère grossit avec le nombre de morts. Cliquez sur un repère pour voir sa fiche.", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Les épaves patrimoniales européennes viennent d'EMODnet Human Activities ; les épaves notables du monde entier viennent de Wikidata. La couleur indique quand le navire a coulé ; le marqueur grossit avec le nombre de morts. Cliquez sur un marqueur pour voir sa fiche.", "All years": "Toutes les années", "Period": "Période", - "From": "Du", - "To": "Au", + "From": "De", + "To": "À", "First year of the shipwreck period": "Première année de la période des naufrages", "Last year of the shipwreck period": "Dernière année de la période des naufrages", "SIGHTING": "OBSERVATION", @@ -3470,10 +3472,10 @@ "Loading photo...": "Chargement de la photo…", "Photo unavailable": "Photo indisponible", "Location obscured to a 0.2° cell": "Localisation masquée dans une cellule de 0,2°", - "Photo of {name}": "Photo de {name}", + "Photo of {name}": "Photo : {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} observations dans la vue", + "{count} sightings in view": "Observations dans la vue : {count}", "Close sighting": "Fermer l'observation", "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Faune observée au cours des dernières 48 h (cétacés 7 jours). La couleur et le pictogramme du badge indiquent le groupe taxonomique. Les espèces sensibles sont limitées à des cellules de 0,2° ; les signalements de mammifères marins en mer des Salish provenant d'Acartia sont arrondis à 0,01°. Pas un recensement.", "Open {name} on iNaturalist": "Ouvrir {name} sur iNaturalist", @@ -3496,17 +3498,17 @@ "Captured {month}": "Prise en {month}", "this spot": "cet endroit", "the view centre": "le centre de la vue", - "{source} at {place}": "{source} à {place}", - "Street level at {place}": "Niveau rue à {place}", + "{source} at {place}": "{source} : {place}", + "Street level at {place}": "Niveau rue : {place}", "Switching map": "Changement de carte", "Opening imagery": "Ouverture des images", - "Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger", + "Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger", "{source} cannot open this link's street-level photo without a working key": "{source} ne peut pas ouvrir la photo au niveau de la rue de ce lien sans clé valide", - "Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord.", + "Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d'abord.", "STATIONS": "STATIONS", "Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent", "IMAGING": "IMAGERIE", - "Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science", + "Earth observation: optical, radar, weather and science": "Observation de la Terre : optique, radar, météo et science", "TELECOM": "TÉLÉCOM", "Communications and navigation satellites, Starlink excepted": "Satellites de communication et de navigation, hors Starlink", "STARLINK": "STARLINK", @@ -3515,9 +3517,9 @@ "Space science, technology demonstration and other payloads": "Sciences spatiales, démonstrations technologiques et autres charges utiles", "DEBRIS": "DÉBRIS", "Rocket bodies, fragments and dead satellites": "Étages de fusée, fragments et satellites hors service", - "Classes may lag: SATCAT is a cached copy": "Classes peut-être en retard : SATCAT est une copie en cache", - "Classes may lag: SATCAT unavailable": "Classes peut-être en retard : SATCAT indisponible", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.", + "Classes may lag: SATCAT is a cached copy": "Classes peut-être pas à jour : SATCAT est une copie en cache", + "Classes may lag: SATCAT unavailable": "Classes peut-être pas à jour : SATCAT indisponible", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT de CelesTrak donne le type et l'état de chaque objet. Sans copie à jour, les classes reposent sur la base UCS, les noms et les groupes CelesTrak : un satellite hors service depuis peut encore apparaître comme actif.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", "SENSOR SWATH {value} KM": "FAUCHÉE CAPTEUR {value} KM", @@ -3531,7 +3533,7 @@ "AFT": "ARRIÈRE", "RIGHT": "DROITE", "LEFT": "GAUCHE", - "TRACK UP": "TRACE EN HAUT", + "TRACK UP": "ROUTE EN HAUT", "COURSE HOLD · AIS": "VERROU ROUTE · AIS", "Zoom in (+)": "Zoomer (+)", "Zoom out (−)": "Dézoomer (−)", @@ -3543,37 +3545,39 @@ "1 d/s": "1 j/s", "1 wk/s": "1 sem/s", "Play speed": "Vitesse de lecture", - "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-clic pour tout voir", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Faites défiler ou pincez pour zoomer, glissez pour vous déplacer · double-cliquez pour tout voir", "1 min/s": "1 min/s", "Rail": "Trains", "Urban rail": "Urbain", - "Local rail": "Trains locaux", + "Local rail": "Périurbain", "Regional rail": "Régional", "National rail": "Grandes lignes", "International rail": "International", - "Hide {category}": "Masquer {category}", - "Show {category}": "Afficher {category}", + "Hide {category}": "Masquer la catégorie {category}", + "Show {category}": "Afficher la catégorie {category}", "Live GPS": "GPS en direct", - "Estimated from station reports": "Estimée d’après les signalements en gare", + "Estimated from station reports": "Position estimée d'après les passages en gare", "To {headsign}": "Vers {headsign}", "Next {stop} {time} {relative}": "Prochain arrêt {stop} {time} {relative}", "Prev {stop} {time} {relative}": "Arrêt précédent {stop} {time} {relative}", "(+{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 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", - "The shared military flight isn't reporting now — showing where it was at {time}": "Le vol militaire partagé n'émet pas actuellement — affichage de sa position à {time}", + "The shared military flight isn't reporting now — showing where it was at {time}": "Le vol militaire partagé n'émet pas actuellement — affichage de sa position ({time})", "The shared vessel isn't reporting now — showing where it was last seen": "Le navire partagé n'émet pas actuellement — affichage de sa dernière position connue", - "The shared vessel isn't reporting now — showing where it was at {time}": "Le navire partagé n'émet pas actuellement — affichage de sa position à {time}", + "The shared vessel isn't reporting now — showing where it was at {time}": "Le navire partagé n'émet pas actuellement — affichage de sa position ({time})", "The shared satellite isn't reporting now — showing where it was last seen": "Le satellite partagé n'émet pas actuellement — affichage de sa dernière position connue", - "The shared satellite isn't reporting now — showing where it was at {time}": "Le satellite partagé n'émet pas actuellement — affichage de sa position à {time}", - "ICAO: {id}": "ICAO : {id}", - "NORAD: {id}": "NORAD : {id}", - "MMSI: {id}": "MMSI : {id}", - "Vessel UUID: {id}": "UUID du navire : {id}", - "Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise dans ±90° et la longitude dans ±180°", + "The shared satellite isn't reporting now — showing where it was at {time}": "Le satellite partagé n'émet pas actuellement — affichage de sa position ({time})", + "ICAO: {id}": "OACI : {id}", + "NORAD: {id}": "NORAD : {id}", + "MMSI: {id}": "MMSI : {id}", + "Vessel UUID: {id}": "UUID du navire : {id}", + "Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise entre −90° et 90°, et la longitude entre −180° et 180°", "Street Level is unavailable": "Niveau rue n'est pas disponible", "No street-level imagery source is configured": "Aucune source d'images au niveau de la rue n'est configurée", "Street Level could not be enabled": "Impossible d'activer Niveau rue", @@ -3587,22 +3591,22 @@ "{source} imagery could not be opened": "Impossible d'ouvrir les images de {source}", "{place} has no position to look for imagery at": "{place} n'a pas de position où chercher des images", "No {source} imagery near {place}": "Aucune image {source} près de {place}", - "{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})", + "{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})", "tiles unavailable": "tuiles indisponibles", "zoom in to load this layer": "zoomez pour charger cette couche", "Zoom to layer": "Zoomer sur la couche", - "Zoom to layer · fly to where this layer has data": "Zoomer sur la couche · voler là où cette couche a des données", - "Remove from Data Layers": "Retirer des Couches de données", - "Remove from Data Layers · add it again from the library": "Retirer des Couches de données · ajoutez-la à nouveau depuis la bibliothèque", + "Zoom to layer · fly to where this layer has data": "Zoomer sur la couche · rejoindre la zone où cette couche a des données", + "Remove from Data Layers": "Retirer du panneau Couches", + "Remove from Data Layers · add it again from the library": "Retirer du panneau Couches · ajoutez-la à nouveau depuis la bibliothèque", "Coverage outline": "Contour de couverture", "DRAPED": "DRAPÉ", "Below 250 m": "Moins de 250 m", "250–500 m": "250–500 m", - "500–1,000 m": "500–1 000 m", - "1,000–2,000 m": "1 000–2 000 m", - "2,000–3,000 m": "2 000–3 000 m", - "3,000 m and above": "3 000 m et plus", - "Exit cockpit to fly to a layer": "Quittez le cockpit pour voler vers une couche", + "500–1,000 m": "500–1 000 m", + "1,000–2,000 m": "1 000–2 000 m", + "2,000–3,000 m": "2 000–3 000 m", + "3,000 m and above": "3 000 m et plus", + "Exit cockpit to fly to a layer": "Quittez le cockpit pour rejoindre une couche", "Library": "Bibliothèque", "Environment": "Environnement", "Imagery": "Imagerie", @@ -3647,11 +3651,11 @@ }, "switch on a dataset to load features": "activez un jeu de données pour charger des entités", "Switched on by this instance · part of the CCTV layer": "Activé par cette instance · fait partie de la couche CCTV", - "Operator opt-in: set {env}=1 on the server": "Activation par l'opérateur : définir {env}=1 sur le serveur", - "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activation par l'opérateur : ajouter {id} à GEV_LIBRARY_ENABLE sur le serveur", + "Operator opt-in: set {env}=1 on the server": "Activation par l'opérateur : définir {env}=1 sur le serveur", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Activation par l'opérateur : ajouter {id} à GEV_LIBRARY_ENABLE sur le serveur", "Turned off by this instance's operator": "Désactivé par l'opérateur de cette instance", "Needs a provider key on the server": "Nécessite une clé du fournisseur sur le serveur", - "Needs this app's server · not available in a static build": "Nécessite le serveur de cette app · indisponible dans une version statique", + "Needs this app's server · not available in a static build": "Nécessite le serveur de cette application · indisponible dans une version statique", "Checking what this instance serves…": "Vérification de ce que propose cette instance…", "Regional": "Régional", "Orthophoto": "Orthophoto", @@ -3661,13 +3665,13 @@ "True orthophoto": "Orthophoto vraie", "Type": "Type", "Resolution": "Résolution", - "Flown": "Vol", + "Flown": "Prise de vue", "Updates": "Mise à jour", "Ortho quality": "Qualité ortho", "DATA LIBRARY": "BIBLIOTHÈQUE DE DONNÉES", "Close data library": "Fermer la bibliothèque de données", "Data Library at a glance": "La bibliothèque de données en un coup d'œil", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Sources en direct au-delà des couches principales. Chacune est récupérée auprès de son fournisseur tant qu'elle est active ; rien n'est stocké dans cette app. Ajoutez-en une et elle apparaît dans Couches de données, sous Bibliothèque.", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Sources en direct au-delà des couches principales. Chacune est récupérée auprès de son fournisseur tant qu'elle est active ; rien n'est stocké dans cette application. Ajoutez-en une et elle apparaît dans le panneau Couches, sous Bibliothèque.", "Name, provider or country": "Nom, fournisseur ou pays", "Theme": "Thème", "All themes": "Tous les thèmes", @@ -3679,16 +3683,23 @@ "Zoom to {name}": "Zoomer sur {name}", "WORKING…": "EN COURS…", "ADD": "AJOUTER", - "Remove {name} from Data Layers": "Retirer {name} des Couches de données", - "Add {name} to Data Layers": "Ajouter {name} aux Couches de données", + "Remove {name} from Data Layers": "Retirer {name} du panneau Couches", + "Add {name} to Data Layers": "Ajouter {name} au panneau Couches", "CONFIRM · REMOVE ALL {count}": { "one": "CONFIRMER · RETIRER {count}", "many": "CONFIRMER · RETIRER LES {count}", "other": "CONFIRMER · RETIRER LES {count}" }, "REMOVE ALL": "TOUT RETIRER", - "Confirm: remove every added entry from Data Layers": "Confirmer : retirer des Couches de données toutes les entrées ajoutées", - "Remove every added entry from Data Layers": "Retirer des Couches de données toutes les entrées ajoutées", - "Showing {shown} of {total}": "{shown} sur {total} affichés", - "BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES" + "Confirm: remove every added entry from Data Layers": "Confirmer : retirer du panneau Couches toutes les entrées ajoutées", + "Remove every added entry from Data Layers": "Retirer du panneau Couches toutes les entrées ajoutées", + "Showing {shown} of {total}": "Affichées : {shown} sur {total}", + "BROWSE DATA LIBRARY": "PARCOURIR LA BIBLIOTHÈQUE DE DONNÉES", + "The globe stopped drawing": "Le globe ne s’affiche plus", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.", + "Reset view reloads the app without the view restored from the address bar.": "Réinitialiser la vue recharge l’app sans la vue restaurée depuis la barre d’adresse.", + "Reload": "Recharger", + "Retry": "Réessayer", + "Reset view": "Réinitialiser la vue" } diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index b0bd1eac2..cfe612894 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -1149,6 +1149,8 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "אונייה", + "AID TO NAVIGATION": "עזר ניווט", + "VIRTUAL": "וירטואלי", "{speed}KT": "{speed} קשר", "{heading}DEG": "{heading}°", "POS: LIVE": "POS: חי", @@ -3293,17 +3295,17 @@ "Refresh map still": "רענון תמונת המפה", "The map still uses a separate request and may show a different moment.": "תמונת המפה נשלפת בבקשה נפרדת ועשויה להראות רגע שונה.", "Video plays from Québec 511, which sees your IP address.": "הווידאו זורם מ-Québec 511, שרואה את כתובת ה-IP שלך.", - "Press OPEN to load the photo": "לחץ על פתח כדי לטעון את התמונה", + "Press OPEN to load the photo": "לחץ על \"פתח\" כדי לטעון את התמונה", "Captured {month}": "צולם ב-{month}", "this spot": "המקום הזה", "the view centre": "מרכז התצוגה", - "{source} at {place}": "{source} ב-{place}", - "Street level at {place}": "רמת רחוב ב-{place}", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "צילומי רחוב: {place}", "Switching map": "מחליף מפה", "Opening imagery": "פותח צילומים", - "Street View is ready: press {open} to load it": "Street View מוכן: לחץ על {open} כדי לטעון אותו", - "{source} cannot open this link's street-level photo without a working key": "{source} לא יכול לפתוח את תמונת רמת הרחוב של הקישור הזה בלי מפתח תקף", - "Street-level photos are hidden in the current view. Leave it first.": "תמונות ברמת הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.", + "Street View is ready: press {open} to load it": "Street View מוכן: לחץ על \"{open}\" כדי לטעון אותו", + "{source} cannot open this link's street-level photo without a working key": "{source} לא יכול לפתוח את תמונת הרחוב שבקישור הזה בלי מפתח תקף", + "Street-level photos are hidden in the current view. Leave it first.": "תמונות הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.", "Ship bridge view": "תצוגת גשר האונייה", "Satellite orbital view": "התצוגה המסלולית של הלוויין", "Exit bridge view": "יציאה מתצוגת הגשר", @@ -3383,8 +3385,10 @@ "Next {stop} {time} {relative}": "התחנה הבאה {stop} {time} {relative}", "Prev {stop} {time} {relative}": "התחנה הקודמת {stop} {time} {relative}", "(+{minutes} min late)": "(+{minutes} דק׳ איחור)", - "Exit cockpit to fly to a train": "צא מתא הטייס כדי לטוס לרכבת", + "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": "שבוע/שנ׳", @@ -3420,16 +3424,16 @@ "{provider} limit reached for today": "המכסה היומית של {provider} נוצלה", "News resumes tomorrow, or with a paid plan": "החדשות יחזרו מחר, או עם תוכנית בתשלום", "{provider} refused its key": "{provider} דחה את המפתח", - "Check {envVars} in Provider Settings": "בדקו את {envVars} בהגדרות הספקים", + "Check {envVars} in Provider Settings": "בדוק את {envVars} בהגדרות הספקים", "{provider} key required": "נדרש מפתח של {provider}", "Developing: {name}": "מתפתח: {name}", "{count} events today": { - "one": "אירוע {count} היום", + "one": "{count} אירוע היום", "two": "{count} אירועים היום", "other": "{count} אירועים היום" }, "{count} reports today": { - "one": "דיווח {count} היום", + "one": "{count} דיווח היום", "two": "{count} דיווחים היום", "other": "{count} דיווחים היום" }, @@ -3438,12 +3442,12 @@ "via {source}": "דרך {source}", "Video": "וידאו", "Photos": "תמונות", - "Claimed by {who}": "נטען על ידי {who}", - "Claimed": "נטען", + "Claimed by {who}": "לטענת {who}", + "Claimed": "טענה", "Not yet confirmed by others": "טרם אושר על ידי אחרים", - "Reported by {side} sources": "דווח על ידי מקורות {side}", - "Reported by {sides}": "דווח על ידי {sides}", - "{side} sources": "מקורות {side}", + "Reported by {side} sources": "דווח על ידי מקורות: {side}", + "Reported by {sides}": "דווח על ידי מקורות: {sides}", + "{side} sources": "{side}", "Official": "רשמי", "Reported by both sides": "דווח על ידי שני הצדדים", "Independently reported": "דווח באופן עצמאי", @@ -3479,7 +3483,7 @@ "{minutes} min": "{minutes} דק׳", "{hours} h": "{hours} שע׳", "{count} sources": { - "one": "מקור {count}", + "one": "{count} מקור", "two": "{count} מקורות", "other": "{count} מקורות" }, @@ -3487,35 +3491,35 @@ "News feed not configured on this server": "פיד החדשות אינו מוגדר בשרת זה", "Malformed news hotspots response": "תגובת מוקדי החדשות פגומה", "Malformed news feed response": "תגובת פיד החדשות פגומה", - "Exit cockpit to fly to a news report": "צאו מתא הטייס כדי לטוס אל דיווח חדשותי", + "Exit cockpit to fly to a news report": "צא מתא הטייס כדי לטוס אל דיווח חדשותי", "{source} · LAST {hours} H": "{source} · {hours} השעות האחרונות", "CACHED · REFRESH FAILED": "מהמטמון · הרענון נכשל", "{source} · CACHED {age}": "{source} · מהמטמון {age}", - "NO NEWS IN THE LAST {hours} H": "אין חדשות ב־{hours} השעות האחרונות", - "{place} (placed by model)": "{place} (מוקם על ידי המודל)", + "NO NEWS IN THE LAST {hours} H": "אין חדשות ב-{hours} השעות האחרונות", + "{place} (placed by model)": "{place} (מיקום לפי המודל)", "English headline by the model": "כותרת באנגלית מאת המודל", - "Not placed on the map": "לא מוקם על המפה", + "Not placed on the map": "ללא מיקום על המפה", "Close": "סגירה", "Conflicts": "עימותים", "Local": "מקומי", - "Any severity": "כל חומרה", + "Any severity": "כל רמות החומרה", "{severity} and above": "{severity} ומעלה", "{severity} only": "{severity} בלבד", "All categories": "כל הקטגוריות", - "Last {hours} h": "{hours} שע׳ אחרונות", - "News from {area}": "חדשות מ{area}", - "Within {km} km of the view centre": "בטווח {km} ק״מ ממרכז התצוגה", + "Last {hours} h": "{hours} שע׳ אחורה", + "News from {area}": "חדשות מ-{area}", + "Within {km} km of the view centre": "בטווח {km} ק\"מ ממרכז התצוגה", "Reading the news…": "קורא את החדשות…", - "News, last {hours} hours": "חדשות, {hours} השעות האחרונות", + "News, last {hours} hours": "חדשות, {hours} שע׳ אחורה", "Time window": "חלון זמן", "News sections": "מדורי חדשות", "Hotspots": "מוקדים", "Severity": "חומרה", "Category": "קטגוריה", - "Zoom in to see local news": "התקרבו כדי לראות חדשות מקומיות", - "Nothing reported near here in the last {hours} h": "לא דווח דבר בסביבה ב־{hours} השעות האחרונות", + "Zoom in to see local news": "התקרב כדי לראות חדשות מקומיות", + "Nothing reported near here in the last {hours} h": "לא דווח דבר בסביבה בחלון של {hours} שע׳", "Latest news": "חדשות אחרונות", - "No news matches these filters in the last {hours} h": "אין חדשות התואמות למסננים אלה ב־{hours} השעות האחרונות", + "No news matches these filters in the last {hours} h": "אין חדשות התואמות למסננים אלה בחלון של {hours} שע׳", "Load older news": "טעינת חדשות ישנות יותר", "How to read the map": "איך לקרוא את המפה", "Colour: severity · icon: type of event": "צבע: חומרה · סמל: סוג האירוע", @@ -3529,8 +3533,8 @@ "After 1945": "אחרי 1945", "Date unknown": "תאריך לא ידוע", "Position: centre of the protected area": "מיקום: מרכז השטח המוגן", - "Position approximate (within about {km} km)": "מיקום משוער (בטווח של כ־{km} ק״מ)", - "Position surveyed (within about {m} m)": "מיקום שנמדד (בטווח של כ־{m} מ׳)", + "Position approximate (within about {km} km)": "מיקום משוער (בטווח של כ-{km} ק\"מ)", + "Position surveyed (within about {m} m)": "מיקום שנמדד (בטווח של כ-{m} מ׳)", "Shipwreck": "ספינה טרופה", "Fatalities: {count}": "הרוגים: {count}", "Survivors: {count}": "ניצולים: {count}", @@ -3547,32 +3551,32 @@ "Click the card to open the source ↗": "לחץ על הכרטיס כדי לפתוח את המקור ↗", "All years": "כל השנים", "Period": "תקופה", - "From": "מ־", + "From": "מ-", "First year of the shipwreck period": "השנה הראשונה בתקופת הטביעות", "To": "עד", "Last year of the shipwreck period": "השנה האחרונה בתקופת הטביעות", - "Wikidata unavailable; showing EMODnet only": "ויקינתונים אינו זמין; מוצג EMODnet בלבד", - "EMODnet unavailable; showing Wikidata only": "EMODnet אינו זמין; מוצג ויקינתונים בלבד", + "Wikidata unavailable; showing EMODnet only": "Wikidata אינו זמין; מוצג EMODnet בלבד", + "EMODnet unavailable; showing Wikidata only": "EMODnet אינו זמין; מוצג Wikidata בלבד", "Shipwrecks unavailable": "ספינות טרופות לא זמינות", "{count} wrecks": { "one": "{count} ספינה טרופה", "two": "{count} ספינות טרופות", "other": "{count} ספינות טרופות" }, - "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (מימי אירופה) · ויקינתונים (כל העולם)", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ספינות טרופות אירופיות בעלות ערך מורשת מגיעות מ־EMODnet Human Activities; ספינות טרופות בולטות מכל העולם מגיעות מוויקינתונים. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו.", + "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (מימי אירופה) · Wikidata (כל העולם)", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "ספינות טרופות אירופיות בעלות ערך מורשת מגיעות מ-EMODnet Human Activities; ספינות טרופות בולטות מכל העולם מגיעות מ-Wikidata. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו.", "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 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": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע", - "The shared military flight isn't reporting now — showing where it was at {time}": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום בשעה {time}", - "The shared vessel isn't reporting now — showing where it was last seen": "כלי השיט המשותף לא מדווח עכשיו — מוצג המיקום האחרון הידוע", - "The shared vessel isn't reporting now — showing where it was at {time}": "כלי השיט המשותף לא מדווח עכשיו — מוצג המיקום בשעה {time}", + "The shared military flight isn't reporting now — showing where it was at {time}": "הטיסה הצבאית המשותפת לא מדווחת עכשיו — מוצג המיקום נכון ל-{time}", + "The shared vessel isn't reporting now — showing where it was last seen": "האונייה המשותפת לא מדווחת עכשיו — מוצג המיקום האחרון הידוע", + "The shared vessel isn't reporting now — showing where it was at {time}": "האונייה המשותפת לא מדווחת עכשיו — מוצג המיקום נכון ל-{time}", "The shared satellite isn't reporting now — showing where it was last seen": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום האחרון הידוע", - "The shared satellite isn't reporting now — showing where it was at {time}": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום בשעה {time}", + "The shared satellite isn't reporting now — showing where it was at {time}": "הלוויין המשותף לא מדווח עכשיו — מוצג המיקום נכון ל-{time}", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "UUID של כלי השיט: {id}", + "Vessel UUID: {id}": "UUID של האונייה: {id}", "Latitude must be within ±90° and longitude within ±180°": "קו הרוחב חייב להיות בטווח ±90° וקו האורך בטווח ±180°", "Street Level is unavailable": "Street Level אינו זמין", "No street-level imagery source is configured": "לא הוגדר מקור לצילומי רחוב", @@ -3639,13 +3643,13 @@ "other": "{count} על המפה" }, "none yet": "אין עדיין", - "Map features": "ישויות במפה", + "Map features": "אובייקטים במפה", "from {count} datasets": { "one": "מ-{count} מערך נתונים", "two": "מ-{count} מערכי נתונים", "other": "מ-{count} מערכי נתונים" }, - "switch on a dataset to load features": "הפעל מערך נתונים כדי לטעון ישויות", + "switch on a dataset to load features": "הפעל מערך נתונים כדי לטעון אובייקטים", "Switched on by this instance · part of the CCTV layer": "מופעל במופע הזה · חלק משכבת CCTV", "Operator opt-in: set {env}=1 on the server": "הפעלה ביוזמת המפעיל: הגדר {env}=1 בשרת", "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "הפעלה ביוזמת המפעיל: הוסף את {id} ל-GEV_LIBRARY_ENABLE בשרת", @@ -3655,7 +3659,7 @@ "Checking what this instance serves…": "בודק מה המופע הזה מספק…", "Regional": "אזורי", "Orthophoto": "אורתופוטו", - "Satellite mosaic": "פסיפס לוויני", + "Satellite mosaic": "פסיפס לווייני", "Oblique": "צילום אלכסוני", "Historic imagery": "צילומים היסטוריים", "True orthophoto": "אורתופוטו אמיתי", @@ -3682,13 +3686,20 @@ "Remove {name} from Data Layers": "הסרת {name} משכבות נתונים", "Add {name} to Data Layers": "הוספת {name} לשכבות נתונים", "CONFIRM · REMOVE ALL {count}": { - "one": "אישור · הסרת {count}", - "two": "אישור · הסרת כל {count}", - "other": "אישור · הסרת כל {count}" + "one": "אישור · הסרה ({count})", + "two": "אישור · הסרת הכול ({count})", + "other": "אישור · הסרת הכול ({count})" }, "REMOVE ALL": "הסרת הכול", "Confirm: remove every added entry from Data Layers": "אישור: הסרת כל הפריטים שנוספו משכבות נתונים", "Remove every added entry from Data Layers": "הסרת כל הפריטים שנוספו משכבות נתונים", "Showing {shown} of {total}": "מוצגים {shown} מתוך {total}", - "BROWSE DATA LIBRARY": "עיון בספריית הנתונים" + "BROWSE DATA LIBRARY": "עיון בספריית הנתונים", + "The globe stopped drawing": "הגלובוס הפסיק להצטייר", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.", + "Reset view reloads the app without the view restored from the address bar.": "\"איפוס תצוגה\" טוען מחדש את האפליקציה בלי התצוגה ששוחזרה משורת הכתובת.", + "Reload": "טען מחדש", + "Retry": "נסה שוב", + "Reset view": "איפוס תצוגה" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 9a840d7c1..e8d939278 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1,25 +1,25 @@ { "News feed unavailable": "Feed delle notizie non disponibile", - "Developing: {name}": "In sviluppo: {name}", + "Developing: {name}": "In evoluzione: {name}", "{count} events today": { "one": "{count} evento oggi", - "many": "{count} eventi oggi", + "many": "{count} di eventi oggi", "other": "{count} eventi oggi" }, "{count} reports today": { "one": "{count} notizia oggi", - "many": "{count} notizie oggi", + "many": "{count} di notizie oggi", "other": "{count} notizie oggi" }, - "Disputed: {name}": "Conteso: {name}", + "Disputed: {name}": "Area contesa: {name}", "Video": "Video", "Photos": "Foto", "Claimed by {who}": "Secondo {who}", "Claimed": "Affermazione", "Not yet confirmed by others": "Non ancora confermato da altri", - "Reported by {side} sources": "Riportato da fonti {side}", - "Reported by {sides}": "Riportato da {sides}", - "{side} sources": "fonti {side}", + "Reported by {side} sources": "Riportato da fonti: {side}", + "Reported by {sides}": "Riportato da fonti: {sides}", + "{side} sources": "{side}", "Official": "Ufficiale", "Reported by both sides": "Riportato da entrambe le parti", "Independently reported": "Riportato in modo indipendente", @@ -53,14 +53,14 @@ "{hours} h": "{hours} h", "{count} sources": { "one": "{count} fonte", - "many": "{count} fonti", + "many": "{count} di fonti", "other": "{count} fonti" }, "News feed not configured on this server": "Feed delle notizie non configurato su questo server", "Malformed news hotspots response": "Risposta dei focolai di notizie non valida", "Malformed news feed response": "Risposta del feed delle notizie non valida", - "{place} (placed by model)": "{place} (collocato dal modello)", - "English headline by the model": "Titolo inglese del modello", + "{place} (placed by model)": "{place} (posizione stimata dal modello)", + "English headline by the model": "Titolo tradotto in inglese dal modello", "Not placed on the map": "Non collocato sulla mappa", "Close": "Chiudi", "Any severity": "Qualsiasi gravità", @@ -68,8 +68,8 @@ "{severity} only": "Solo {severity}", "All categories": "Tutte le categorie", "Reading the news…": "Lettura delle notizie…", - "News, last {hours} hours": "Notizie delle ultime {hours} ore", - "Last {hours} h": "Ultime {hours} h", + "News, last {hours} hours": "Notizie, periodo in ore: {hours}", + "Last {hours} h": "Periodo: {hours} h", "Time window": "Periodo", "Conflicts": "Conflitti", "Local": "Locale", @@ -78,16 +78,16 @@ "Hotspots": "Focolai", "Severity": "Gravità", "Category": "Categoria", - "Nothing reported near here in the last {hours} h": "Nulla segnalato qui vicino nelle ultime {hours} h", + "Nothing reported near here in the last {hours} h": "Nessuna segnalazione qui vicino · periodo: {hours} h", "Latest news": "Ultime notizie", - "No news matches these filters in the last {hours} h": "Nessuna notizia delle ultime {hours} h corrisponde a questi filtri", + "No news matches these filters in the last {hours} h": "Nessuna notizia corrisponde a questi filtri · periodo: {hours} h", "Load older news": "Carica notizie precedenti", "How to read the map": "Come leggere la mappa", "Colour: severity · icon: type of event": "Colore: gravità · icona: tipo di evento", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bordo pieno: riportato da entrambe le parti o in modo indipendente. Bordo tratteggiato: un’affermazione. Bordo sottile: una fonte, o la stampa di una parte.", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Bordo pieno: riportato da entrambe le parti o in modo indipendente. Bordo tratteggiato: un'affermazione. Bordo sottile: una fonte, o la stampa di una parte.", "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Più grande: più fonti · più tenue: più vecchio. Scheda con immagine: un evento grave o in rapida crescita con due o più fonti.", - "Side labels describe the source, not whether a report is true.": "Le etichette di parte descrivono la fonte, non se la notizia è vera.", - "Exit cockpit to fly to a news report": "Esci dalla cabina per volare a una notizia", + "Side labels describe the source, not whether a report is true.": "Le etichette delle parti descrivono la fonte, non se la notizia è vera.", + "Exit cockpit to fly to a news report": "Esci dal cockpit per volare a una notizia", "News items in the feed": "Notizie nel feed", "Pick a shape, then click the map.": "Scegli una forma, poi fai clic sulla mappa.", "Enter to place the pin, Esc to cancel.": "Premi Enter per posizionare il segnaposto, Esc per annullare.", @@ -1230,8 +1230,8 @@ "Hybrid": "Ibrido", "Lines": "Linee", "Dots": "Punti", - "Road lines colored by live flow: green to red": "Strade colorate in base al traffico in tempo reale: dal verde al rosso", - "Moving dots colored by live flow": "Punti in movimento colorati in base al traffico in tempo reale", + "Road lines colored by live flow: green to red": "Strade colorate in base al flusso live: dal verde al rosso", + "Moving dots colored by live flow": "Punti in movimento colorati in base al flusso live", "Moving dots": "Punti in movimento", "TomTom key unavailable": "Chiave TomTom non disponibile", "TomTom daily budget reached": "Budget giornaliero TomTom esaurito", @@ -1291,6 +1291,8 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} VEL: {speed} HDG: {heading}", "VESSEL": "NAVE", + "AID TO NAVIGATION": "AIUTO ALLA NAVIGAZIONE", + "VIRTUAL": "VIRTUALE", "{speed}KT": "{speed}KT", "{heading}DEG": "{heading}°", "POS: LIVE": "POS: LIVE", @@ -1754,9 +1756,9 @@ "other": "{count} telecamere caricate · attiva CCTV per attivarle" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "Altra {count} telecamera fuori da questa vista · sposta o riduci lo zoom", - "many": "Altre {count} di telecamere fuori da questa vista · sposta o riduci lo zoom", - "other": "Altre {count} telecamere fuori da questa vista · sposta o riduci lo zoom" + "one": "{count} altra telecamera fuori da questa vista · sposta la mappa o riduci lo zoom", + "many": "Altre {count} di telecamere fuori da questa vista · sposta la mappa o riduci lo zoom", + "other": "Altre {count} telecamere fuori da questa vista · sposta la mappa o riduci lo zoom" }, "Enable CCTV to load camera intersections": "Attiva CCTV per caricare le telecamere agli incroci", "Enable CCTV to start camera-linked intelligence summaries.": "Attiva CCTV per avviare i riepiloghi intelligence collegati alle telecamere.", @@ -1796,7 +1798,7 @@ "{scope} · INSTALLATIONS {coverage}": "{scope} · SITI {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITARE DISATTIVATO · QUADRO PARZIALE", "FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED VOLI DISATTIVATO · QUADRO PARZIALE", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "DATI AEREI VICINI INCOMPLETI · NON È UN VIA LIBERA", "CONTEXT STANDBY": "CONTESTO IN ATTESA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATTIVA IL CONTESTO GLOBALE PER I PING DI PROSSIMITÀ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATTO PERSO · ULTIMA LETTURA NOTA · NON È UN VIA LIBERA", @@ -1881,7 +1883,7 @@ "TRACK ACQUIRED": "TRACCIA ACQUISITA", "{label} · COURSE {heading}°": "{label} · ROTTA {heading}°", "{label} · HEADING {heading}°": "{label} · PRUA {heading}°", - "{label} · NO HEADING": "{label} · NESSUNA PRUA", + "{label} · NO HEADING": "{label} · PRUA N/D", "{label} · NADIR VIEW": "{label} · VISTA AL NADIR", "Context could not restore every layer; try again": "Il Contesto non ha potuto ripristinare tutti i livelli; riprova", "No selected data layers": "Nessun livello dati selezionato", @@ -2839,8 +2841,8 @@ "Aircraft cockpit view": "Vista cockpit dell'aereo", "Ship bridge view": "Vista dalla plancia della nave", "Satellite orbital view": "Vista orbitale dal satellite", - "LOOK STBD {bearing}°": "VISTA DRITTA {bearing}°", - "LOOK PORT {bearing}°": "VISTA SINISTRA {bearing}°", + "LOOK STBD {bearing}°": "VISTA A DRITTA {bearing}°", + "LOOK PORT {bearing}°": "VISTA A SINISTRA {bearing}°", "LOOK ASTERN": "VISTA A POPPA", "UP {angle}°": "SU {angle}°", "DOWN {angle}°": "GIÙ {angle}°", @@ -2852,7 +2854,7 @@ "BRIDGE WINDOW · FWD": "FINESTRA PLANCIA · AVANTI", "OPTICAL PLANE · 01": "PIANO OTTICO · 01", "HEADING HOLD · AIS": "BLOCCO PRUA · AIS", - "NO HEADING · AIS": "NESSUNA PRUA · AIS", + "NO HEADING · AIS": "PRUA N/D · AIS", "VISOR LOCK · ACTIVE": "BLOCCO VISIERA · ATTIVO", "GROUND SPEED · KTS": "VEL. AL SUOLO · KTS", "SPEED OVER GROUND · KTS": "VEL. SUL FONDO · KTS", @@ -2989,7 +2991,7 @@ "AVAILABLE MISSIONS": "MISSIONI DISPONIBILI", "SELECT A MISSION TO INSPECT": "SELEZIONA UNA MISSIONE DA ESAMINARE", "LOADING 30-DAY MISSION INDEX": "CARICAMENTO INDICE MISSIONI A 30 GIORNI", - "Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l’indice delle missioni. Riprova più tardi.", + "Space Missions unavailable: the mission index could not load. Try again later.": "Missioni spaziali non disponibili: impossibile caricare l'indice delle missioni. Riprova più tardi.", "30-DAY MISSION INDEX UNAVAILABLE": "INDICE MISSIONI A 30 GIORNI NON DISPONIBILE", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB: ANTEPRIMA · ENTER / SPACE: SELEZIONA", "Internet radio and local SDR": "Radio internet e SDR locale", @@ -3010,7 +3012,7 @@ "Radio volume": "Volume radio", "STATION SITE": "SITO STAZIONE", "DIRECTORY: RADIO BROWSER": "ELENCO: RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente dopo aver premuto play, scelto una stazione sul globo o chiesto la riproduzione a voce. Il tuo IP è visibile a quell'emittente.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "L'audio si collega direttamente all'emittente quando premi play, scegli una stazione sul globo o chiedi la riproduzione con un comando vocale. Il tuo IP è visibile a quell'emittente.", "Local RTL-SDR receiver": "Ricevitore RTL-SDR locale", "LOCAL RTL-SDR": "RTL-SDR LOCALE", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · un sintonizzatore locale condiviso tra FM e il livello ADS-B locale.", @@ -3207,7 +3209,7 @@ "Left panels": "Pannelli a sinistra", "Right panels": "Pannelli a destra", "CONFIRMED": "CONFERMATA", - "PAD / RTLS": "SITO DI LANCIO / RTLS", + "PAD / RTLS": "RAMPA / RTLS", "fire confidence filter|All": "Tutti", "fire confidence filter|Nominal+": "Nominale+", "fire confidence filter|High": "Alta", @@ -3372,7 +3374,7 @@ "Too many chat requests. Try again in {seconds} s.": "Troppe richieste di chat. Riprova tra {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "Il provider ha negato l'accesso per questa chiave. Controlla progetto, regione o autorizzazioni della chiave.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "L'account del provider ha esaurito il credito. Controlla la fatturazione o scegli un altro provider IA in Impostazioni.", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Ultime 24 h di notizie. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e dai dati e possono essere errati. Le etichette di parte, dove presenti, descrivono la fonte, non se la notizia è vera.", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Ultime 24 h di notizie. Ogni notizia rimanda al suo editore; luoghi e categorie sono letti automaticamente dal testo e dai dati e possono essere errati. Le etichette delle parti, dove presenti, descrivono la fonte, non se la notizia è vera.", "Economy": "Economia", "News from {area}": "Notizie: {area}", "Within {km} km of the view centre": "Entro {km} km dal centro della vista", @@ -3394,7 +3396,7 @@ "Position: centre of the protected area": "Posizione: centro dell'area protetta", "Position approximate (within about {km} km)": "Posizione approssimativa (entro circa {km} km)", "Position surveyed (within about {m} m)": "Posizione rilevata (entro circa {m} m)", - "Vessel: {value}": "Imbarcazione: {value}", + "Vessel: {value}": "Nave: {value}", "What happened: {value}": "Cosa è successo: {value}", "Depth: {m} m ({quality})": "Profondità: {m} m ({quality})", "Depth: {m} m": "Profondità: {m} m", @@ -3414,11 +3416,11 @@ "other": "{count} relitti" }, "1900–1945": "1900–1945", - "Shipwreck": "Naufragio", + "Shipwreck": "Relitto", "Fatalities: {count}": "Vittime: {count}", "Survivors: {count}": "Sopravvissuti: {count}", "Click the card to open the article ↗": "Fai clic sulla scheda per aprire l'articolo ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "I relitti storici europei provengono da EMODnet Human Activities; i relitti noti di tutto il mondo da Wikidata. Il colore indica quando la nave è affondata; l'indicatore cresce con il numero di vittime. Fai clic su un indicatore per la sua scheda.", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "I relitti storici europei provengono da EMODnet Human Activities; i relitti noti di tutto il mondo da Wikidata. Il colore indica quando la nave è affondata; il marcatore cresce con il numero di vittime. Fai clic su un marcatore per la sua scheda.", "All years": "Tutti gli anni", "Period": "Periodo", "From": "Dal", @@ -3444,7 +3446,7 @@ "Fungi": "Funghi", "Other animals": "Altri animali", "Other or unknown": "Altri o sconosciuti", - "{label} {detail} — click to retry": "{label} {detail} — fai clic per riprovare", + "{label} {detail} — click to retry": "{label} {detail}: clic per riprovare", "Wildlife sightings source unavailable": "Fonte degli avvistamenti non disponibile", "Show only community-confirmed (research-grade) sightings": "Mostra solo osservazioni confermate dalla comunità (grado di ricerca)", "No recent sightings in view": "Nessun avvistamento recente nella vista", @@ -3463,8 +3465,8 @@ "All rights reserved": "Tutti i diritti riservati", "Cetacean": "Cetaceo", "Wildlife Sightings": "Avvistamenti naturalistici", - "Exit cockpit to fly to a sighting": "Esci dalla cabina per volare a un avvistamento", - "Exit cockpit to fly to a sighting area": "Esci dalla cabina per volare a un’area di avvistamento", + "Exit cockpit to fly to a sighting": "Esci dal cockpit per volare a un avvistamento", + "Exit cockpit to fly to a sighting area": "Esci dal cockpit per volare a un'area di avvistamento", "Wildlife": "Natura", "Zoom in for local sightings": "Ingrandisci per gli avvistamenti locali", "Loading photo...": "Caricamento della foto…", @@ -3473,7 +3475,7 @@ "Photo of {name}": "Foto di {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} avvistamenti nella vista", + "{count} sightings in view": "Avvistamenti nella vista: {count}", "Close sighting": "Chiudi avvistamento", "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna osservata nelle ultime 48 h (cetacei 7 giorni). Colore e simbolo del contrassegno indicano il gruppo tassonomico. Le specie sensibili sono mostrate solo in celle di 0,2°; le segnalazioni di mammiferi marini di Acartia nel mar dei Salish sono arrotondate a 0,01°. Non è un censimento.", "Open {name} on iNaturalist": "Apri {name} su iNaturalist", @@ -3481,23 +3483,23 @@ "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Contributori di iNaturalist · licenze per osservazione", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Contributori di iNaturalist · Acartia {count} (Mare dei Salish) · licenze per osservazione", - "{days}d": "{days}g", - "All markers": "Tutti gli indicatori", - "Show markers of every age": "Mostra gli indicatori di qualsiasi età", - "Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} giorni; indicatori senza data mantenuti", + "{days}d": "{days} g", + "All markers": "Tutti i marcatori", + "Show markers of every age": "Mostra i marcatori di qualsiasi età", + "Maximum event age: {days}d; undated markers are kept": "Età massima degli eventi: {days} g; marcatori senza data mantenuti", "event age filter|All": "Tutti", "Event age": "Età degli eventi", - "Event age: {days}d · undated kept": "Età degli eventi: {days} giorni · senza data mantenuti", + "Event age: {days}d · undated kept": "Età degli eventi: {days} g · senza data mantenuti", "Close camera preview": "Chiudi anteprima della telecamera", "Refresh map still": "Aggiorna immagine fissa sulla mappa", - "The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.", + "The map still uses a separate request and may show a different moment.": "L'immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.", "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.", "Press OPEN to load the photo": "Premi APRI per caricare la foto", - "Captured {month}": "Scattata a {month}", + "Captured {month}": "Scattata: {month}", "this spot": "questo punto", "the view centre": "il centro della vista", - "{source} at {place}": "{source} a {place}", - "Street level at {place}": "Vista stradale a {place}", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Vista stradale: {place}", "Switching map": "Cambio mappa", "Opening imagery": "Apertura delle immagini", "Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo", @@ -3512,11 +3514,11 @@ "STARLINK": "STARLINK", "The Starlink constellation (thousands of extra points)": "La costellazione Starlink (migliaia di punti in più)", "PROBES": "SONDE", - "Space science, technology demonstration and other payloads": "Scienza spaziale, dimostrazioni tecnologiche e altri carichi utili", + "Space science, technology demonstration and other payloads": "Scienza spaziale, dimostrazioni tecnologiche e altri payload", "DEBRIS": "DETRITI", "Rocket bodies, fragments and dead satellites": "Stadi di razzi, frammenti e satelliti non operativi", - "Classes may lag: SATCAT is a cached copy": "Le classi potrebbero essere in ritardo: SATCAT è una copia in cache", - "Classes may lag: SATCAT unavailable": "Le classi potrebbero essere in ritardo: SATCAT non disponibile", + "Classes may lag: SATCAT is a cached copy": "Le classi potrebbero non essere aggiornate: SATCAT è una copia in cache", + "Classes may lag: SATCAT unavailable": "Le classi potrebbero non essere aggiornate: SATCAT non disponibile", "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "SATCAT di CelesTrak fornisce tipo e stato di ogni oggetto. Senza una copia aggiornata le classi si basano sul database UCS, sui nomi e sui gruppi CelesTrak, quindi un satellite che nel frattempo ha cessato di funzionare può ancora risultare operativo.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", @@ -3554,30 +3556,32 @@ "Hide {category}": "Nascondi {category}", "Show {category}": "Mostra {category}", "Live GPS": "GPS in tempo reale", - "Estimated from station reports": "Stimata dalle segnalazioni delle stazioni", + "Estimated from station reports": "Posizione stimata dalle segnalazioni delle stazioni", "To {headsign}": "Per {headsign}", "Next {stop} {time} {relative}": "Prossima fermata {stop} {time} {relative}", "Prev {stop} {time} {relative}": "Fermata precedente {stop} {time} {relative}", "(+{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 flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione rilevata ({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", - "The shared military flight isn't reporting now — showing where it was at {time}": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}", + "The shared military flight isn't reporting now — showing where it was at {time}": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})", "The shared vessel isn't reporting now — showing where it was last seen": "La nave condivisa non sta trasmettendo ora: viene mostrata l'ultima posizione nota", - "The shared vessel isn't reporting now — showing where it was at {time}": "La nave condivisa non sta trasmettendo ora: viene mostrata la posizione alle {time}", + "The shared vessel isn't reporting now — showing where it was at {time}": "La nave condivisa non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})", "The shared satellite isn't reporting now — showing where it was last seen": "Il satellite condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota", - "The shared satellite isn't reporting now — showing where it was at {time}": "Il satellite condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}", + "The shared satellite isn't reporting now — showing where it was at {time}": "Il satellite condiviso non sta trasmettendo ora: viene mostrata la posizione rilevata ({time})", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID della nave: {id}", "Latitude must be within ±90° and longitude within ±180°": "La latitudine deve essere entro ±90° e la longitudine entro ±180°", - "Street Level is unavailable": "Vista stradale non è disponibile", + "Street Level is unavailable": "Il livello Vista stradale non è disponibile", "No street-level imagery source is configured": "Nessuna fonte di immagini a livello stradale configurata", "Street Level could not be enabled": "Impossibile attivare Vista stradale", - "Street Level changed while it looked for imagery": "Vista stradale è cambiata durante la ricerca delle immagini", + "Street Level changed while it looked for imagery": "Il livello Vista stradale è cambiato durante la ricerca delle immagini", "Aim the view at a street": "Punta la vista su una strada", "{source} is not a street-level imagery source here": "{source} non è una fonte di immagini a livello stradale qui", "{source} needs its key": "{source} richiede la sua chiave", @@ -3589,7 +3593,7 @@ "No {source} imagery near {place}": "Nessuna immagine di {source} vicino a {place}", "{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})", "tiles unavailable": "tile non disponibili", - "zoom in to load this layer": "avvicinati per caricare questo livello", + "zoom in to load this layer": "ingrandisci per caricare questo livello", "Zoom to layer": "Zoom sul livello", "Zoom to layer · fly to where this layer has data": "Zoom sul livello · vola dove questo livello ha dati", "Remove from Data Layers": "Rimuovi da Livelli dati", @@ -3602,7 +3606,7 @@ "1,000–2,000 m": "1.000–2.000 m", "2,000–3,000 m": "2.000–3.000 m", "3,000 m and above": "3.000 m e oltre", - "Exit cockpit to fly to a layer": "Esci dalla cabina di pilotaggio per volare a un livello", + "Exit cockpit to fly to a layer": "Esci dal cockpit per volare a un livello", "Library": "Libreria", "Environment": "Ambiente", "Imagery": "Immagini", @@ -3650,7 +3654,7 @@ "Operator opt-in: set {env}=1 on the server": "Attivazione dell'operatore: imposta {env}=1 sul server", "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Attivazione dell'operatore: aggiungi {id} a GEV_LIBRARY_ENABLE sul server", "Turned off by this instance's operator": "Disattivato dall'operatore di questa istanza", - "Needs a provider key on the server": "Richiede una chiave del fornitore sul server", + "Needs a provider key on the server": "Richiede una chiave del provider sul server", "Needs this app's server · not available in a static build": "Richiede il server di questa app · non disponibile in una build statica", "Checking what this instance serves…": "Verifica di ciò che offre questa istanza…", "Regional": "Regionale", @@ -3667,8 +3671,8 @@ "DATA LIBRARY": "LIBRERIA DATI", "Close data library": "Chiudi la libreria dati", "Data Library at a glance": "La libreria dati in sintesi", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fonti in tempo reale oltre ai livelli principali. Ognuna viene caricata dal suo fornitore finché è attiva; questa app non memorizza nulla. Aggiungine una e comparirà in Livelli dati, sotto Libreria.", - "Name, provider or country": "Nome, fornitore o paese", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fonti live oltre ai livelli principali. Ognuna viene caricata dal suo provider finché è attiva; questa app non memorizza nulla. Aggiungine una e comparirà in Livelli dati, sotto Libreria.", + "Name, provider or country": "Nome, provider o paese", "Theme": "Tema", "All themes": "Tutti i temi", "Only what this instance serves": "Solo ciò che offre questa istanza", @@ -3683,12 +3687,19 @@ "Add {name} to Data Layers": "Aggiungi {name} a Livelli dati", "CONFIRM · REMOVE ALL {count}": { "one": "CONFERMA · RIMUOVI {count}", - "many": "CONFERMA · RIMUOVI TUTTI I {count}", - "other": "CONFERMA · RIMUOVI TUTTI I {count}" + "many": "CONFERMA · RIMUOVI TUTTE LE {count}", + "other": "CONFERMA · RIMUOVI TUTTE LE {count}" }, "REMOVE ALL": "RIMUOVI TUTTO", "Confirm: remove every added entry from Data Layers": "Conferma: rimuovi da Livelli dati tutte le voci aggiunte", "Remove every added entry from Data Layers": "Rimuovi da Livelli dati tutte le voci aggiunte", - "Showing {shown} of {total}": "{shown} di {total} mostrati", - "BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI" + "Showing {shown} of {total}": "Mostrati: {shown} su {total}", + "BROWSE DATA LIBRARY": "ESPLORA LIBRERIA DATI", + "The globe stopped drawing": "Il globo ha smesso di disegnare", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.", + "Reset view reloads the app without the view restored from the address bar.": "Ripristina vista ricarica l’app senza la vista ripristinata dalla barra degli indirizzi.", + "Reload": "Ricarica", + "Retry": "Riprova", + "Reset view": "Ripristina vista" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5dc71c358..8039831d8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1,21 +1,21 @@ { "News feed unavailable": "ニュースフィードを利用できません", - "Developing: {name}": "進行中: {name}", + "Developing: {name}": "進行中:{name}", "{count} events today": { "other": "本日の出来事 {count}件" }, "{count} reports today": { "other": "本日の報道 {count}件" }, - "Disputed: {name}": "係争地: {name}", + "Disputed: {name}": "係争地:{name}", "Video": "動画", "Photos": "写真", "Claimed by {who}": "{who}の主張", "Claimed": "主張", - "Not yet confirmed by others": "他からの確認はまだない", - "Reported by {side} sources": "{side}側の情報源が報道", - "Reported by {sides}": "報道: {sides}", - "{side} sources": "{side}側の情報源", + "Not yet confirmed by others": "他の情報源による確認なし", + "Reported by {side} sources": "{side}の情報源が報道", + "Reported by {sides}": "報道:{sides}", + "{side} sources": "{side}の情報源", "Official": "公式", "Reported by both sides": "双方が報道", "Independently reported": "独立した報道あり", @@ -36,13 +36,13 @@ "Air defence": "防空", "Ground fighting": "地上戦", "Casualties": "死傷者", - "Humanitarian": "人道", + "Humanitarian": "人道問題", "Disaster": "災害", "Protest": "抗議", "Political": "政治", "No physical event": "物理的な事象なし", "Minor incident": "小規模な事案", - "Significant": "重大", + "Significant": "中程度", "Major": "深刻", "age|now": "今", "{minutes} min": "{minutes}分", @@ -68,7 +68,7 @@ "Conflicts": "紛争", "Local": "ローカル", "News sections": "ニュースの区分", - "Zoom in to see local news": "拡大するとローカルニュースが表示されます", + "Zoom in to see local news": "ズームインするとローカルニュースが表示されます", "Hotspots": "ホットスポット", "Severity": "深刻度", "Category": "カテゴリ", @@ -77,10 +77,10 @@ "No news matches these filters in the last {hours} h": "直近{hours}時間で条件に合うニュースはありません", "Load older news": "以前のニュースを読み込む", "How to read the map": "地図の見方", - "Colour: severity · icon: type of event": "色: 深刻度 · アイコン: 出来事の種類", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "実線の縁: 双方または独立した情報源が報道。破線の縁: 主張。細い縁: 単一の情報源、または一方の陣営のメディア。", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "大きい: 情報源が多い · 薄い: 古い。画像カード: 情報源が2件以上ある深刻な、または急拡大中の出来事。", - "Side labels describe the source, not whether a report is true.": "陣営ラベルは情報源を示すもので、報道の真偽ではありません。", + "Colour: severity · icon: type of event": "色:深刻度 · アイコン:出来事の種類", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "実線の縁:双方または独立した情報源が報道。破線の縁:主張。細い縁:単一の情報源、または一方の陣営のメディア。", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "大きい:情報源が多い · 薄い:古い。画像カード:情報源が2件以上ある深刻な、または急拡大中の出来事。", + "Side labels describe the source, not whether a report is true.": "陣営ラベルは情報源を示すもので、報道の真偽を示すものではありません。", "Exit cockpit to fly to a news report": "ニュースへ移動するにはコックピットを終了してください", "News items in the feed": "フィード内のニュース数", "Pick a shape, then click the map.": "図形を選んでから、地図をクリックしてください。", @@ -479,7 +479,7 @@ "Remove {title} from this app's saved keys": "{title}をこのアプリの保存済みキーから削除", "Project keys: {status}": "プロジェクトのキー:{status}", "your app configuration": "アプリの設定", - "your local .env": "ローカルの .env", + "your local .env": "ローカルの.env", "Saving…": "保存中…", "Save failed: {detail}": "保存に失敗しました:{detail}", "Paste at least one key first.": "まず、キーを1つ以上貼り付けてください。", @@ -801,7 +801,7 @@ "loading...": "読込中…", "KEY REQUIRED": "キーが必要", "LIVE · updated {age}": "ライブ · 更新 {age}", - "24 h to {day} {time} UTC": "{day} {time} UTC までの24時間", + "24 h to {day} {time} UTC": "{day} {time} UTCまでの24時間", "Source snapshot time unavailable": "ソースのスナップショット時刻を取得できません", "Live data unavailable": "ライブデータを利用できません", "Intel Picture": "情勢図", @@ -831,7 +831,7 @@ "Low-accuracy score 2–10%": "低精度スコア 2–10%", "Low-accuracy score > 10%": "低精度スコア > 10%", "GNSS integrity source unavailable": "GNSSインテグリティのソースを利用できません", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "地域サンプル · 半径250海里 · {minutes}分以内に表示したビューのセル", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "地域サンプル · 半径250海里 · 過去{minutes}分間に表示したビューのセル", "Retrying automatically at {time} UTC": "{time} UTCに自動で再試行します", "Retrying automatically after provider cooldown": "プロバイダーのクールダウン後に自動で再試行します", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "パンやズームの後、および1分ごとに自動で更新されます。公開フィードは表示中心から半径250海里の円をカバーします。ズームアウトしても全世界のスナップショットにはなりません。空白のセルは、サンプリング範囲外か観測が不十分です。", @@ -889,18 +889,18 @@ "{value} KM/S": "{value} km/s", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "オフナディア {angle}° · {offset}", - "SITES STANDBY": "地点待機", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "マップ済み施設を有効にすると地点ピングを表示", - "Release the camera from {name}": "{name} からカメラを解放", + "SITES STANDBY": "施設待機", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "マップ済み施設を有効にすると施設ピングを表示", + "Release the camera from {name}": "{name}からカメラを解放", "RETURN · {name}": "戻る · {name}", "PREVIOUS SATELLITE": "前の衛星", - "Return to {name}": "{name} に戻る", + "Return to {name}": "{name}に戻る", "SATELLITE": "衛星", - "Ride {name}": "{name} に乗る", - "Unnamed site": "名称のない地点", + "Ride {name}": "{name}に乗る", + "Unnamed site": "名称不明の施設", "OFF-NADIR {angle}°": "オフナディア {angle}°", "FOCUS · {name}": "フォーカス · {name}", - "Keep the camera on {name}": "カメラを {name} に向け続ける", + "Keep the camera on {name}": "カメラを{name}に向け続ける", "ZOOM {zoom}×": "ズーム {zoom}×", "SWATH {value} KM": "観測幅 {value} km", "SWATH ≈ {value} KM": "観測幅 ≈ {value} km", @@ -909,8 +909,8 @@ "ORBITAL VELOCITY": "軌道速度", "SUB-SATELLITE POINT": "衛星直下点", "NORTH UP": "ノースアップ", - "CURRENTLY OVER": "現在の上空", - "{division}, {country}": "{division}、{country}", + "CURRENTLY OVER": "現在の直下", + "{division}, {country}": "{country}・{division}", "{region} · near {place}": "{region} · {place}付近", "NORAD {id}": "NORAD {id}", "EPOCH {time}": "元期 {time}", @@ -1253,6 +1253,8 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", + "AID TO NAVIGATION": "航路標識", + "VIRTUAL": "仮想", "{speed}KT": "{speed} kt", "{heading}DEG": "{heading}°", "POS: LIVE": "POS: ライブ", @@ -1702,7 +1704,7 @@ "other": "カメラ {count}台を読込済み · CCTVを有効にして使用" }, "{count} more cameras outside this view · pan or zoom out": { - "other": "この表示範囲外にさらに {count}台のカメラ · 移動またはズームアウト" + "other": "表示範囲外にあと{count}台のカメラ · パンまたはズームアウト" }, "Enable CCTV to load camera intersections": "CCTVを有効にすると、カメラのある交差点を読み込みます", "Enable CCTV to start camera-linked intelligence summaries.": "CCTVを有効にすると、カメラ連動のインテリジェンス概要を開始します。", @@ -1740,8 +1742,8 @@ "REGIONAL NEWS": "地域ニュース", "{label} · {radius} AIR/SEA WINDOW": "{label} · 航空機/船舶の範囲 {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · 施設 {coverage}", - "MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィードオフ · 部分的な状況", - "FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィードオフ · 部分的な状況", + "MILITARY FEED OFF · PARTIAL PICTURE": "軍用フィード オフ · 部分的な状況", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "フライトフィード オフ · 部分的な状況", "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "付近の航空機情報は不完全 · 安全の保証ではありません", "CONTEXT STANDBY": "コンテキスト待機", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "グローバルコンテキストを有効にすると近接ピングを表示", @@ -1876,7 +1878,7 @@ "Earthquakes (24h)": "地震(24時間)", "Satellites": "衛星", "Place names": "地名", - "Space Missions (30d)": "宇宙ミッション 30日", + "Space Missions (30d)": "宇宙ミッション(30日)", "Street Traffic": "道路交通", "CCTV": "CCTV", "Radio": "ラジオ", @@ -2858,7 +2860,7 @@ "Radio volume": "ラジオの音量", "STATION SITE": "放送局サイト", "DIRECTORY: RADIO BROWSER": "ディレクトリ:Radio Browser", - "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "再生ボタンを押す、地球儀で局を選ぶ、または音声コントロールで再生を指示すると、音声は放送局に直接接続されます。お使いのIPアドレスは放送局側から見えます。", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "再生ボタンを押す、地球儀で局を選ぶ、または音声アシスタントに再生を指示すると、ブラウザーが放送局に直接接続します。お使いのIPアドレスは放送局側から見えます。", "Local RTL-SDR receiver": "ローカルRTL-SDR受信機", "LOCAL RTL-SDR": "ローカルRTL-SDR", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · FMとローカルADS-Bレイヤーで1台のローカルチューナーを共有します。", @@ -3032,7 +3034,7 @@ "Restricted": "飛行制限区域", "Prohibited & Restricted Airspace": "飛行禁止・飛行制限区域", "scene|PLAY": "再生", - "AUTHORING": "作成", + "AUTHORING": "シーン作成", "Create and edit scenes": "シーンを作成・編集", "SCENE": "シーン", "Scene": "シーン", @@ -3047,7 +3049,7 @@ "Click a shot to select it, double-click to rename.": "ショットをクリックして選択、ダブルクリックで名前を変更します。", "Play from this scene through the rest of the project": "このシーンからプロジェクトの最後まで再生", "Stop playback (Esc)": "再生を停止(Esc)", - "Play the shot after the selected one, moving on to the next scene when needed": "選択中の次のショットを再生(必要に応じて次のシーンへ進む)", + "Play the shot after the selected one, moving on to the next scene when needed": "選択中のショットの次を再生(必要に応じて次のシーンへ進む)", "SAVE & SHARE": "保存と共有", "Download every scene as a JSON file you can import later": "すべてのシーンを、後でインポートできるJSONファイルとしてダウンロード", "Load scenes from an exported JSON file": "書き出したJSONファイルからシーンを読み込む", @@ -3078,7 +3080,7 @@ "Drawn shapes": "描いた図形", "Audio": "オーディオ", "UTILITIES": "ユーティリティ", - "Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください", + "Exit cockpit to fly to a drawing": "描いた図形へ移動するにはコックピットを終了してください", "Directions, drawing and your own files": "ルート案内、描画、自分のファイル", "Voice place search (needs an Overpass server)": "音声による場所検索(Overpassサーバーが必要)", "OSM Places": "OSMの場所", @@ -3212,7 +3214,7 @@ "Too many chat requests. Try again in {seconds} s.": "チャットのリクエストが多すぎます。{seconds}秒後にもう一度お試しください。", "The provider refused access for this key. Check the key's project, region or permissions.": "プロバイダーがこのキーでのアクセスを拒否しました。キーのプロジェクト、リージョン、権限を確認してください。", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "プロバイダーアカウントのクレジットが不足しています。課金状況を確認するか、設定で別のAIプロバイダーを選んでください。", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "直近24時間のニュース。各報道は発信元にリンクします。場所とカテゴリは本文とデータから自動で読み取るため誤りがあり得ます。陣営ラベルは、表示される場合、情報源を示すもので、報道の真偽ではありません。", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "直近24時間のニュース。各報道は発信元にリンクします。場所とカテゴリは本文とデータから自動で読み取るため誤りがあり得ます。陣営ラベルは、表示される場合、情報源を示すもので、報道の真偽を示すものではありません。", "Economy": "経済", "News from {area}": "{area}のニュース", "Within {km} km of the view centre": "表示中心から{km} km以内", @@ -3228,33 +3230,33 @@ "Precise conflict points in the News layer, beside GDELT’s articles": "ニュースレイヤーの正確な紛争地点(GDELTの記事に加えて)", "Worldwide": "世界", "Shipwrecks": "難破船", - "Before 1900": "1900年以前", - "After 1945": "1945年以降", + "Before 1900": "1900年より前", + "After 1945": "1946年以降", "Date unknown": "日付不明", - "Position: centre of the protected area": "位置: 保護区域の中心", + "Position: centre of the protected area": "位置:保護区域の中心", "Position approximate (within about {km} km)": "おおよその位置(誤差約{km} km)", "Position surveyed (within about {m} m)": "測量された位置(誤差約{m} m)", - "Vessel: {value}": "船種: {value}", - "What happened: {value}": "経緯: {value}", - "Depth: {m} m ({quality})": "水深: {m} m({quality})", - "Depth: {m} m": "水深: {m} m", - "Notes: {value}": "備考: {value}", - "Cargo or finds: {value}": "積荷・遺物: {value}", + "Vessel: {value}": "船種:{value}", + "What happened: {value}": "経緯:{value}", + "Depth: {m} m ({quality})": "水深:{m} m({quality})", + "Depth: {m} m": "水深:{m} m", + "Notes: {value}": "備考:{value}", + "Cargo or finds: {value}": "積荷・遺物:{value}", "Protected wreck": "保護対象の難破船", - "Source: {value}": "出典: {value}", + "Source: {value}": "出典:{value}", "Click the card to open EMODnet ↗": "カードをクリックしてEMODnetを開く ↗", "Click the card to open the source ↗": "カードをクリックして出典を開く ↗", "Wikidata unavailable; showing EMODnet only": "Wikidataを利用できません。EMODnetのみ表示しています", "EMODnet unavailable; showing Wikidata only": "EMODnetを利用できません。Wikidataのみ表示しています", "Shipwrecks unavailable": "難破船データを利用できません", - "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(欧州海域)· Wikidata(世界)", + "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(欧州海域) · Wikidata(世界)", "{count} wrecks": { - "other": "難破船 {count}件" + "other": "難破船 {count}隻" }, "1900–1945": "1900–1945", - "Shipwreck": "難破", - "Fatalities: {count}": "死者: {count}", - "Survivors: {count}": "生存者: {count}", + "Shipwreck": "難破船", + "Fatalities: {count}": "死者:{count}人", + "Survivors: {count}": "生存者:{count}人", "Click the card to open the article ↗": "カードをクリックして記事を開く ↗", "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "欧州の歴史的な難破船はEMODnet Human Activities、世界の著名な難破船はWikidataによります。色は沈没した時期を示し、マーカーは死者数に応じて大きくなります。マーカーをクリックするとカードが開きます。", "All years": "全期間", @@ -3277,13 +3279,13 @@ "Amphibians": "両生類", "Ray-finned fishes": "条鰭類", "Insects": "昆虫", - "Arachnids": "クモ類", + "Arachnids": "クモガタ類", "Plants": "植物", "Fungi": "菌類", "Other animals": "その他の動物", "Other or unknown": "その他・不明", - "{label} {detail} — click to retry": "{label} {detail} — クリックして再試行", - "Wildlife sightings source unavailable": "野生生物の目撃情報源を利用できません", + "{label} {detail} — click to retry": "{label} {detail}:クリックで再試行", + "Wildlife sightings source unavailable": "野生生物の目撃情報のソースを利用できません", "Show only community-confirmed (research-grade) sightings": "コミュニティが確認した研究グレードの目撃情報のみ表示", "No recent sightings in view": "表示範囲に最近の目撃情報はありません", "Acartia feed unavailable": "Acartiaのフィードを利用できません", @@ -3301,10 +3303,10 @@ "All rights reserved": "無断転載禁止", "Cetacean": "クジラ類", "Wildlife Sightings": "野生生物の目撃情報", - "Exit cockpit to fly to a sighting": "目撃地点に移動するにはコックピットを終了", - "Exit cockpit to fly to a sighting area": "目撃範囲に移動するにはコックピットを終了", + "Exit cockpit to fly to a sighting": "目撃地点へ移動するにはコックピットを終了してください", + "Exit cockpit to fly to a sighting area": "目撃範囲へ移動するにはコックピットを終了してください", "Wildlife": "野生生物", - "Zoom in for local sightings": "拡大して地域の目撃情報を表示", + "Zoom in for local sightings": "ズームインして地域の目撃情報を表示", "Loading photo...": "写真を読み込み中…", "Photo unavailable": "写真を表示できません", "Location obscured to a 0.2° cell": "位置は0.2°のセル内に秘匿されています", @@ -3313,12 +3315,12 @@ "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "表示範囲に{count}件の目撃情報", "Close sighting": "目撃情報を閉じる", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "過去48時間以内に観察された野生生物(クジラ類は7日間)。色とバッジのアイコンは分類群を表します。保護が必要な種は0.2°の区画のみ表示し、セイリッシュ海のAcartia海生哺乳類目撃情報は0.01°に丸めます。個体数調査ではありません。", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "過去48時間以内に観察された野生生物(クジラ類は7日間)。色とバッジのアイコンは分類群を表します。保護が必要な種は0.2°のセルとしてのみ表示し、セイリッシュ海のAcartiaによる海生哺乳類の目撃情報は0.01°に丸めます。個体数調査ではありません。", "Open {name} on iNaturalist": "iNaturalistで{name}を開く", "Open {name} on Acartia": "Acartiaで{name}を開く", "iNaturalist": "iNaturalist", - "iNaturalist contributors · licences per record": "iNaturalist の投稿者 · ライセンスは記録ごと", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist の投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと", + "iNaturalist contributors · licences per record": "iNaturalistの投稿者 · ライセンスは記録ごと", + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalistの投稿者 · Acartia {count}(セイリッシュ海) · ライセンスは記録ごと", "{days}d": "{days}日", "All markers": "すべてのマーカー", "Show markers of every age": "日付にかかわらずすべてのマーカーを表示", @@ -3329,8 +3331,8 @@ "Close camera preview": "カメラのプレビューを閉じる", "Refresh map still": "地図上の静止画を更新", "The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。", - "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。", - "Press OPEN to load the photo": "「開く」を押して写真を読み込む", + "Video plays from Québec 511, which sees your IP address.": "動画はQuébec 511から再生され、お使いのIPアドレスはQuébec 511側から見えます。", + "Press OPEN to load the photo": "[開く]を押して写真を読み込む", "Captured {month}": "{month}撮影", "this spot": "この地点", "the view centre": "表示中心", @@ -3338,8 +3340,8 @@ "Street level at {place}": "{place}のストリートレベル", "Switching map": "地図を切り替え中", "Opening imagery": "画像を開いています", - "Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます", - "{source} cannot open this link's street-level photo without a working key": "{source} は有効なキーがないため、このリンクのストリートレベル写真を開けません", + "Street View is ready: press {open} to load it": "ストリートビューの準備ができました:[{open}]を押して読み込んでください", + "{source} cannot open this link's street-level photo without a working key": "{source}は有効なキーがないため、このリンクのストリートレベル写真を開けません", "Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。", "STATIONS": "ステーション", "Crewed stations and the craft that visit them": "有人宇宙ステーションと、そこを訪れる宇宙機", @@ -3353,16 +3355,16 @@ "Space science, technology demonstration and other payloads": "宇宙科学、技術実証、その他のペイロード", "DEBRIS": "デブリ", "Rocket bodies, fragments and dead satellites": "ロケット機体、破片、運用を終えた衛星", - "Classes may lag: SATCAT is a cached copy": "分類が古い可能性: SATCAT はキャッシュのコピーです", - "Classes may lag: SATCAT unavailable": "分類が古い可能性: SATCAT を取得できません", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT は各物体の種別と運用状態を示します。最新のコピーがない間、分類は UCS データベース、名称、CelesTrak のグループのみに基づくため、その後機能を失った衛星がまだ稼働中と表示されることがあります。", + "Classes may lag: SATCAT is a cached copy": "分類が古い可能性:SATCATはキャッシュのコピーです", + "Classes may lag: SATCAT unavailable": "分類が古い可能性:SATCATを取得できません", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCATは各物体の種別と運用状態を示します。最新のコピーがない間、分類はUCSデータベース、名称、CelesTrakのグループのみに基づくため、その後機能を失った衛星がまだ稼働中と表示されることがあります。", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", "SENSOR SWATH {value} KM": "センサー観測幅 {value} km", "SENSOR SWATH ≈ {value} KM": "センサー観測幅 ≈ {value} km", "SENSOR": "センサー", "Zoom to the imaging sensor swath": "撮像センサーの観測幅までズーム", - "<1 H OLD": "1時間未満前", + "<1 H OLD": "1時間以内", "{value} H OLD": "{value}時間前", "{value} D OLD": "{value}日前", "FWD": "前方", @@ -3371,8 +3373,8 @@ "LEFT": "左", "TRACK UP": "トラックアップ", "COURSE HOLD · AIS": "対地針路保持 · AIS", - "Zoom in (+)": "ズームイン (+)", - "Zoom out (−)": "ズームアウト (−)", + "Zoom in (+)": "ズームイン(+)", + "Zoom out (−)": "ズームアウト(−)", "ORBITAL VIEW · OFF-NADIR": "軌道ビュー · オフナディア", "World time": "世界時刻", "Back to live": "ライブに戻る", @@ -3399,14 +3401,16 @@ "(+{minutes} min late)": "({minutes}分遅れ)", "Exit cockpit to fly to a train": "列車へ移動するにはコックピットを終了してください", "Photo © {photographer}": "写真 © {photographer}", - "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": "共有された軍用機は現在報告していません。最後に確認された位置を表示しています", - "The shared military flight isn't reporting now — showing where it was at {time}": "共有された軍用機は現在報告していません。{time}時点の位置を表示しています", - "The shared vessel isn't reporting now — showing where it was last seen": "共有された船舶は現在報告していません。最後に確認された位置を表示しています", - "The shared vessel isn't reporting now — showing where it was at {time}": "共有された船舶は現在報告していません。{time}時点の位置を表示しています", - "The shared satellite isn't reporting now — showing where it was last seen": "共有された衛星は現在報告していません。最後に確認された位置を表示しています", - "The shared satellite isn't reporting now — showing where it was at {time}": "共有された衛星は現在報告していません。{time}時点の位置を表示しています", + "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": "共有された軍用機の位置情報は現在届いていません。最後に確認された位置を表示しています", + "The shared military flight isn't reporting now — showing where it was at {time}": "共有された軍用機の位置情報は現在届いていません。{time}時点の位置を表示しています", + "The shared vessel isn't reporting now — showing where it was last seen": "共有された船舶の位置情報は現在届いていません。最後に確認された位置を表示しています", + "The shared vessel isn't reporting now — showing where it was at {time}": "共有された船舶の位置情報は現在届いていません。{time}時点の位置を表示しています", + "The shared satellite isn't reporting now — showing where it was last seen": "共有された衛星の位置情報は現在届いていません。最後に確認された位置を表示しています", + "The shared satellite isn't reporting now — showing where it was at {time}": "共有された衛星の位置情報は現在届いていません。{time}時点の位置を表示しています", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", @@ -3425,7 +3429,7 @@ "{source} imagery could not be opened": "{source}の画像を開けませんでした", "{place} has no position to look for imagery at": "{place}には画像を探す位置がありません", "No {source} imagery near {place}": "{place}の近くに{source}の画像はありません", - "{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})", + "{reason} (filters on: {filters})": "{reason}(有効なフィルター:{filters})", "tiles unavailable": "タイルを利用できません", "zoom in to load this layer": "ズームインしてこのレイヤーを読み込む", "Zoom to layer": "レイヤーにズーム", @@ -3434,12 +3438,12 @@ "Remove from Data Layers · add it again from the library": "データレイヤーから削除 · ライブラリから再度追加できます", "Coverage outline": "カバー範囲の輪郭", "DRAPED": "地形に投影", - "Below 250 m": "250 m 未満", + "Below 250 m": "250 m未満", "250–500 m": "250–500 m", "500–1,000 m": "500–1,000 m", "1,000–2,000 m": "1,000–2,000 m", "2,000–3,000 m": "2,000–3,000 m", - "3,000 m and above": "3,000 m 以上", + "3,000 m and above": "3,000 m以上", "Exit cockpit to fly to a layer": "レイヤーへ移動するにはコックピットを終了してください", "Library": "ライブラリ", "Environment": "環境", @@ -3455,28 +3459,28 @@ "Datasets": "データセット", "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", "{count} datasets": { - "other": "{count} 件のデータセット" + "other": "{count}件のデータセット" }, "{count} tile layers": { - "other": "{count} 件のタイルレイヤー" + "other": "{count}件のタイルレイヤー" }, "{count} packs": { - "other": "{count} 件のパック" + "other": "{count}件のパック" }, "Themes": "テーマ", "Added": "追加済み", "{count} on the map": { - "other": "地図上に {count} 件" + "other": "地図上に{count}件" }, "none yet": "まだありません", "Map features": "地図上の地物", "from {count} datasets": { - "other": "{count} 件のデータセットから" + "other": "{count}件のデータセットから" }, "switch on a dataset to load features": "データセットをオンにすると地物を読み込みます", - "Switched on by this instance · part of the CCTV layer": "このインスタンスで有効 · CCTV レイヤーの一部", - "Operator opt-in: set {env}=1 on the server": "運用者による有効化:サーバーで {env}=1 を設定", - "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "運用者による有効化:サーバーの GEV_LIBRARY_ENABLE に {id} を追加", + "Switched on by this instance · part of the CCTV layer": "このインスタンスで有効 · CCTVレイヤーの一部", + "Operator opt-in: set {env}=1 on the server": "運用者による有効化:サーバーで{env}=1を設定", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "運用者による有効化:サーバーのGEV_LIBRARY_ENABLEに{id}を追加", "Turned off by this instance's operator": "このインスタンスの運用者により無効化されています", "Needs a provider key on the server": "サーバーにプロバイダーのキーが必要です", "Needs this app's server · not available in a static build": "このアプリのサーバーが必要 · 静的ビルドでは利用できません", @@ -3504,17 +3508,24 @@ "KEY": "キー必須", "KEYLESS": "キー不要", "ZOOM": "ズーム", - "Zoom to {name}": "{name} にズーム", + "Zoom to {name}": "{name}にズーム", "WORKING…": "処理中…", "ADD": "追加", - "Remove {name} from Data Layers": "{name} をデータレイヤーから削除", - "Add {name} to Data Layers": "{name} をデータレイヤーに追加", + "Remove {name} from Data Layers": "{name}をデータレイヤーから削除", + "Add {name} to Data Layers": "{name}をデータレイヤーに追加", "CONFIRM · REMOVE ALL {count}": { - "other": "確認 · {count} 件をすべて削除" + "other": "確認 · {count}件をすべて削除" }, "REMOVE ALL": "すべて削除", "Confirm: remove every added entry from Data Layers": "確認:追加したすべての項目をデータレイヤーから削除", "Remove every added entry from Data Layers": "追加したすべての項目をデータレイヤーから削除", - "Showing {shown} of {total}": "{total} 件中 {shown} 件を表示", - "BROWSE DATA LIBRARY": "データライブラリを見る" + "Showing {shown} of {total}": "{total}件中{shown}件を表示", + "BROWSE DATA LIBRARY": "データライブラリを見る", + "The globe stopped drawing": "地球儀の描画が止まりました", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。", + "Reset view reloads the app without the view restored from the address bar.": "「ビューをリセット」は、アドレスバーから復元したビューを使わずにアプリを再読み込みします。", + "Reload": "再読み込み", + "Retry": "再試行", + "Reset view": "ビューをリセット" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index cb34c1594..18b6d1dd2 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3,12 +3,12 @@ "Developing: {name}": "Em desenvolvimento: {name}", "{count} events today": { "one": "{count} evento hoje", - "many": "{count} eventos hoje", + "many": "{count} de eventos hoje", "other": "{count} eventos hoje" }, "{count} reports today": { "one": "{count} notícia hoje", - "many": "{count} notícias hoje", + "many": "{count} de notícias hoje", "other": "{count} notícias hoje" }, "Disputed: {name}": "Em disputa: {name}", @@ -17,9 +17,9 @@ "Claimed by {who}": "Segundo {who}", "Claimed": "Alegação", "Not yet confirmed by others": "Ainda não confirmado por outros", - "Reported by {side} sources": "Noticiado por fontes de {side}", + "Reported by {side} sources": "Noticiado por fontes: {side}", "Reported by {sides}": "Noticiado por {sides}", - "{side} sources": "fontes de {side}", + "{side} sources": "fontes: {side}", "Official": "Oficial", "Reported by both sides": "Noticiado por ambos os lados", "Independently reported": "Noticiado de forma independente", @@ -27,7 +27,7 @@ "International": "Internacional", "Independent": "Independente", "Unattributed": "Sem atribuição", - "Outside party": "Parte externa", + "Outside party": "Terceiros", "Ukraine–Russia": "Ucrânia–Rússia", "Israel–Gaza–Lebanon": "Israel–Gaza–Líbano", "Iran–Hormuz": "Irã–Ormuz", @@ -43,7 +43,7 @@ "Humanitarian": "Humanitário", "Disaster": "Desastre", "Protest": "Protesto", - "Political": "Político", + "Political": "Política", "No physical event": "Sem evento físico", "Minor incident": "Incidente menor", "Significant": "Significativo", @@ -53,7 +53,7 @@ "{hours} h": "{hours} h", "{count} sources": { "one": "{count} fonte", - "many": "{count} fontes", + "many": "{count} de fontes", "other": "{count} fontes" }, "News feed not configured on this server": "Feed de notícias não configurado neste servidor", @@ -64,23 +64,23 @@ "Not placed on the map": "Não localizado no mapa", "Close": "Fechar", "Any severity": "Qualquer gravidade", - "{severity} and above": "{severity} ou mais", + "{severity} and above": "{severity} ou superior", "{severity} only": "Apenas {severity}", "All categories": "Todas as categorias", "Reading the news…": "Lendo as notícias…", - "News, last {hours} hours": "Notícias das últimas {hours} horas", - "Last {hours} h": "Últimas {hours} h", + "News, last {hours} hours": "Notícias de até {hours} h atrás", + "Last {hours} h": "Até {hours} h atrás", "Time window": "Período", "Conflicts": "Conflitos", "Local": "Local", - "News sections": "Secções de notícias", - "Zoom in to see local news": "Aproxime para ver notícias locais", + "News sections": "Seções de notícias", + "Zoom in to see local news": "Aproxime o zoom para ver notícias locais", "Hotspots": "Focos", "Severity": "Gravidade", "Category": "Categoria", - "Nothing reported near here in the last {hours} h": "Nada noticiado aqui perto nas últimas {hours} h", + "Nothing reported near here in the last {hours} h": "Nada noticiado aqui perto desde {hours} h atrás", "Latest news": "Últimas notícias", - "No news matches these filters in the last {hours} h": "Nenhuma notícia das últimas {hours} h corresponde a estes filtros", + "No news matches these filters in the last {hours} h": "Nenhuma notícia de até {hours} h atrás corresponde a estes filtros", "Load older news": "Carregar notícias anteriores", "How to read the map": "Como ler o mapa", "Colour: severity · icon: type of event": "Cor: gravidade · ícone: tipo de evento", @@ -857,7 +857,7 @@ "Low-accuracy score 2–10%": "Índice de baixa precisão 2–10%", "Low-accuracy score > 10%": "Índice de baixa precisão > 10%", "GNSS integrity source unavailable": "Fonte de integridade GNSS indisponível", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Amostra regional · raio de 250 NM · células das vistas visitadas em {minutes} min", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Amostra regional · raio de 250 NM · células das áreas visualizadas nos últimos {minutes} min", "Retrying automatically at {time} UTC": "Nova tentativa automática às {time} UTC", "Retrying automatically after provider cooldown": "Nova tentativa automática após a pausa do provedor", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Atualiza automaticamente após mover ou dar zoom e uma vez por minuto. O feed público cobre um círculo de 250 milhas náuticas ao redor do centro da vista; afastar o zoom não o transforma em um snapshot mundial. Células vazias estão fora da cobertura amostrada ou não têm observações suficientes.", @@ -926,14 +926,14 @@ "PREVIOUS SATELLITE": "SATÉLITE ANTERIOR", "Return to {name}": "Voltar para {name}", "SATELLITE": "SATÉLITE", - "Ride {name}": "Acompanhar {name}", + "Ride {name}": "Ir a bordo de {name}", "Unnamed site": "Local sem nome", "OFF-NADIR {angle}°": "FORA DO NADIR {angle}°", "FOCUS · {name}": "FOCO · {name}", "Keep the camera on {name}": "Manter a câmera em {name}", "ZOOM {zoom}×": "ZOOM {zoom}×", - "SWATH {value} KM": "FAIXA IMAGEADA {value} KM", - "SWATH ≈ {value} KM": "FAIXA IMAGEADA ≈ {value} KM", + "SWATH {value} KM": "FAIXA {value} KM", + "SWATH ≈ {value} KM": "FAIXA ≈ {value} KM", "Orbital view": "Vista orbital", "ORBITAL VIEW · NADIR": "VISTA ORBITAL · NADIR", "ORBITAL VELOCITY": "VELOCIDADE ORBITAL", @@ -1234,7 +1234,7 @@ "TomTom key unavailable": "Chave do TomTom indisponível", "TomTom daily budget reached": "Limite diário do TomTom atingido", "Monthly traffic allowance used": "Cota mensal de trânsito esgotada", - "Traffic rate limit reached": "Limite de solicitações de trânsito atingido", + "Traffic rate limit reached": "Trânsito: limite de requisições atingido", "Traffic service unavailable": "Serviço de trânsito indisponível", "TomTom upstream unreachable": "Servidor do TomTom inacessível", "TomTom flow unavailable": "Fluxo do TomTom indisponível", @@ -1289,6 +1289,8 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "EMBARCAÇÃO", + "AID TO NAVIGATION": "AUXÍLIO À NAVEGAÇÃO", + "VIRTUAL": "VIRTUAL", "{speed}KT": "{speed} KT", "{heading}DEG": "{heading}°", "POS: LIVE": "POS: AO VIVO", @@ -1754,9 +1756,9 @@ "other": "{count} câmeras carregadas · ative o CCTV para usar" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "Mais {count} câmera fora desta vista · mova ou afaste o zoom", - "many": "Mais {count} de câmeras fora desta vista · mova ou afaste o zoom", - "other": "Mais {count} câmeras fora desta vista · mova ou afaste o zoom" + "one": "Mais {count} câmera fora desta vista · mova o mapa ou afaste o zoom", + "many": "Mais {count} de câmeras fora desta vista · mova o mapa ou afaste o zoom", + "other": "Mais {count} câmeras fora desta vista · mova o mapa ou afaste o zoom" }, "Enable CCTV to load camera intersections": "Ative o CCTV para carregar os cruzamentos com câmeras", "Enable CCTV to start camera-linked intelligence summaries.": "Ative o CCTV para iniciar resumos de inteligência vinculados às câmeras.", @@ -1796,7 +1798,7 @@ "{scope} · INSTALLATIONS {coverage}": "{scope} · INSTALAÇÕES: {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "FEED MILITAR DESLIGADO · QUADRO PARCIAL", "FLIGHTS FEED OFF · PARTIAL PICTURE": "FEED DE VOOS DESLIGADO · QUADRO PARCIAL", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "AERONAVES PRÓXIMAS INCOMPLETAS · NÃO GARANTE SEGURANÇA", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "LISTA DE AERONAVES PRÓXIMAS INCOMPLETA · NÃO GARANTE SEGURANÇA", "CONTEXT STANDBY": "CONTEXTO EM ESPERA", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "ATIVE O CONTEXTO GLOBAL PARA PINGS DE PROXIMIDADE", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "CONTATO PERDIDO · ÚLTIMA LEITURA CONHECIDA · NÃO GARANTE SEGURANÇA", @@ -2848,7 +2850,7 @@ "DOWN {angle}°": "ABAIXO {angle}°", "LEVEL": "NIVELADO", "{direction} · {elevation}": "{direction} · {elevation}", - "LOOK {elevation}": "VISTA {elevation}", + "LOOK {elevation}": "VISTA · {elevation}", "Estimated destination direction": "Direção estimada do destino", "DEST ---°": "DEST ---°", "BRIDGE WINDOW · FWD": "JANELA DO PASSADIÇO · VANTE", @@ -3012,7 +3014,7 @@ "Radio volume": "Volume do rádio", "STATION SITE": "SITE DA ESTAÇÃO", "DIRECTORY: RADIO BROWSER": "DIRETÓRIO: RADIO BROWSER", - "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "O áudio conecta direto à emissora quando você aperta \"Ouvir\", escolhe uma estação no globo ou pede por voz. Seu IP fica visível para essa emissora.", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "O áudio se conecta diretamente à emissora quando você pressiona OUVIR, escolhe uma estação no globo ou pede por voz para tocar. Seu IP fica visível para essa emissora.", "Local RTL-SDR receiver": "Receptor RTL-SDR local", "LOCAL RTL-SDR": "RTL-SDR LOCAL", "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · um sintonizador local compartilhado entre o FM e a camada ADS-B local.", @@ -3372,13 +3374,13 @@ "Too many chat requests. Try again in {seconds} s.": "Chat: limite de requisições atingido. Tente novamente em {seconds} s.", "The provider refused access for this key. Check the key's project, region or permissions.": "O provedor negou o acesso para esta chave. Verifique o projeto, a região ou as permissões da chave.", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "A conta do provedor está sem crédito. Verifique o faturamento ou escolha outro provedor de IA em \"Configurações\".", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Últimas 24 h de notícias. Cada notícia leva ao seu editor; locais e categorias são lidos do texto e dos dados automaticamente e podem estar errados. Os rótulos de lado, quando exibidos, descrevem a fonte, não se a notícia é verdadeira.", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Últimas 24 h de notícias. Cada notícia tem um link para o veículo que a publicou; locais e categorias são lidos do texto e dos dados automaticamente e podem estar errados. Os rótulos de lado, quando exibidos, descrevem a fonte, não se a notícia é verdadeira.", "Economy": "Economia", "News from {area}": "Notícias: {area}", "Within {km} km of the view centre": "Até {km} km do centro da vista", "{region}, {country}": "{region}, {country}", "via {source}": "via {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "Local na camada de notícias: as notícias do dia dos veículos do país em vista", + "Local in the News layer: the day’s news from the outlets of the country in view": "\"Local\" na camada de notícias: as notícias do dia dos veículos de imprensa do país na vista", "{provider} refused its key": "{provider} recusou a chave", "Check {envVars} in Provider Settings": "Verifique {envVars} em \"Configurações de provedores\"", "{provider} key required": "Chave do {provider} necessária", @@ -3402,8 +3404,8 @@ "Cargo or finds: {value}": "Carga ou achados: {value}", "Protected wreck": "Naufrágio protegido", "Source: {value}": "Fonte: {value}", - "Click the card to open EMODnet ↗": "Clique na ficha para abrir o EMODnet ↗", - "Click the card to open the source ↗": "Clique na ficha para abrir a fonte ↗", + "Click the card to open EMODnet ↗": "Clique no cartão para abrir o EMODnet ↗", + "Click the card to open the source ↗": "Clique no cartão para abrir a fonte ↗", "Wikidata unavailable; showing EMODnet only": "Wikidata indisponível; mostrando só o EMODnet", "EMODnet unavailable; showing Wikidata only": "EMODnet indisponível; mostrando só o Wikidata", "Shipwrecks unavailable": "Naufrágios indisponíveis", @@ -3417,8 +3419,8 @@ "Shipwreck": "Naufrágio", "Fatalities: {count}": "Mortos: {count}", "Survivors: {count}": "Sobreviventes: {count}", - "Click the card to open the article ↗": "Clique na ficha para abrir o artigo ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Os naufrágios históricos europeus vêm do EMODnet Human Activities; os naufrágios notáveis do mundo vêm do Wikidata. A cor mostra quando o navio afundou; o marcador cresce com o número de mortos. Clique num marcador para ver a ficha.", + "Click the card to open the article ↗": "Clique no cartão para abrir o artigo ↗", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Os naufrágios históricos europeus vêm do EMODnet Human Activities; os naufrágios notáveis do mundo vêm do Wikidata. A cor mostra quando o navio afundou; o marcador cresce com o número de mortos. Clique em um marcador para ver o cartão.", "All years": "Todos os anos", "Period": "Período", "From": "Início", @@ -3446,7 +3448,7 @@ "Other or unknown": "Outros ou desconhecidos", "{label} {detail} — click to retry": "{label} {detail} — clique para tentar novamente", "Wildlife sightings source unavailable": "Fonte de avistamentos indisponível", - "Show only community-confirmed (research-grade) sightings": "Mostrar apenas observações confirmadas pela comunidade (grau de pesquisa)", + "Show only community-confirmed (research-grade) sightings": "Mostrar apenas avistamentos confirmados pela comunidade (grau de pesquisa)", "No recent sightings in view": "Nenhum avistamento recente na vista", "Acartia feed unavailable": "Feed Acartia indisponível", "Show all taxa": "Mostrar todos os grupos taxonômicos", @@ -3466,28 +3468,28 @@ "Exit cockpit to fly to a sighting": "Saia da cabine para voar até um avistamento", "Exit cockpit to fly to a sighting area": "Saia da cabine para voar até uma área de avistamento", "Wildlife": "Vida selvagem", - "Zoom in for local sightings": "Amplie para ver avistamentos locais", + "Zoom in for local sightings": "Aproxime o zoom para ver avistamentos locais", "Loading photo...": "Carregando foto…", "Photo unavailable": "Foto indisponível", "Location obscured to a 0.2° cell": "Localização ocultada em uma célula de 0,2°", "Photo of {name}": "Foto de {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} avistamentos na vista", + "{count} sightings in view": "Avistamentos na vista: {count}", "Close sighting": "Fechar avistamento", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Fauna observada nas últimas 48 h (cetáceos 7 dias). A cor e o símbolo do emblema indicam o grupo taxonômico. Espécies sensíveis aparecem apenas em células de 0,2°; os relatos de mamíferos marinhos da Acartia no mar de Salish são arredondados para 0,01°. Não é um censo.", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Vida selvagem observada nas últimas 48 h (cetáceos 7 dias). A cor e o símbolo do emblema indicam o grupo taxonômico. Espécies sensíveis aparecem apenas em células de 0,2°; os relatos de mamíferos marinhos do mar de Salish via Acartia são arredondados para 0,01°. Não é um censo.", "Open {name} on iNaturalist": "Abrir {name} no iNaturalist", "Open {name} on Acartia": "Abrir {name} no Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Colaboradores do iNaturalist · licenças por registro", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Colaboradores do iNaturalist · Acartia {count} (mar de Salish) · licenças por registro", - "{days}d": "{days}d", + "{days}d": "{days} d", "All markers": "Todos os marcadores", "Show markers of every age": "Mostrar marcadores de qualquer idade", - "Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} dias; marcadores sem data mantidos", + "Maximum event age: {days}d; undated markers are kept": "Idade máxima do evento: {days} d; marcadores sem data são mantidos", "event age filter|All": "Todos", "Event age": "Idade do evento", - "Event age: {days}d · undated kept": "Idade do evento: {days} dias · sem data mantidos", + "Event age: {days}d · undated kept": "Idade do evento: {days} d · sem data mantidos", "Close camera preview": "Fechar prévia da câmera", "Refresh map still": "Atualizar imagem fixa no mapa", "The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.", @@ -3496,16 +3498,16 @@ "Captured {month}": "Capturada em {month}", "this spot": "este ponto", "the view centre": "o centro da vista", - "{source} at {place}": "{source} em {place}", - "Street level at {place}": "Nível da rua em {place}", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Nível da rua: {place}", "Switching map": "Mudando de mapa", "Opening imagery": "Abrindo imagens", "Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar", - "{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto ao nível da rua deste link sem uma chave válida", - "Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro.", + "{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto no nível da rua deste link sem uma chave válida", + "Street-level photos are hidden in the current view. Leave it first.": "As fotos no nível da rua estão ocultas na vista atual. Saia dela primeiro.", "STATIONS": "ESTAÇÕES", "Crewed stations and the craft that visit them": "Estações tripuladas e as naves que as visitam", - "IMAGING": "IMAGENS", + "IMAGING": "IMAGEAMENTO", "Earth observation: optical, radar, weather and science": "Observação da Terra: óptica, radar, meteorologia e ciência", "TELECOM": "TELECOM", "Communications and navigation satellites, Starlink excepted": "Satélites de comunicações e navegação, exceto Starlink", @@ -3517,7 +3519,7 @@ "Rocket bodies, fragments and dead satellites": "Corpos de foguetes, fragmentos e satélites inativos", "Classes may lag: SATCAT is a cached copy": "As classes podem estar desatualizadas: SATCAT é uma cópia em cache", "Classes may lag: SATCAT unavailable": "As classes podem estar desatualizadas: SATCAT indisponível", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "O SATCAT da CelesTrak dá o tipo e o estado de cada objeto. Sem uma cópia atual, as classes se baseiam no banco de dados UCS, nos nomes e nos grupos da CelesTrak, então um satélite que deixou de funcionar pode ainda aparecer como operacional.", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "O SATCAT do CelesTrak dá o tipo e o estado de cada objeto. Sem uma cópia atual, as classes se baseiam no banco de dados UCS, nos nomes e nos grupos do CelesTrak, então um satélite que deixou de funcionar pode ainda aparecer como operacional.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", "SENSOR SWATH {value} KM": "FAIXA DO SENSOR {value} KM", @@ -3543,9 +3545,9 @@ "1 d/s": "1 d/s", "1 wk/s": "1 sem/s", "Play speed": "Velocidade de reprodução", - "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou faça pinça para ampliar, arraste para se mover · clique duplo para ver tudo", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Role ou use o gesto de pinça para dar zoom, arraste para percorrer · clique duplo para ver tudo", "1 min/s": "1 min/s", - "Rail": "Ferroviário", + "Rail": "Trens", "Urban rail": "Urbano", "Local rail": "Suburbano", "Regional rail": "Regional", @@ -3554,30 +3556,32 @@ "Hide {category}": "Ocultar {category}", "Show {category}": "Mostrar {category}", "Live GPS": "GPS ao vivo", - "Estimated from station reports": "Estimada a partir dos registros das estações", + "Estimated from station reports": "Posição estimada pelos registros das estações", "To {headsign}": "Para {headsign}", "Next {stop} {time} {relative}": "Próxima parada {stop} {time} {relative}", "Prev {stop} {time} {relative}": "Parada anterior {stop} {time} {relative}", "(+{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 flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava em {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", - "The shared military flight isn't reporting now — showing where it was at {time}": "O voo militar compartilhado não está transmitindo agora — mostrando onde estava às {time}", + "The shared military flight isn't reporting now — showing where it was at {time}": "O voo militar compartilhado não está transmitindo agora — mostrando onde estava em {time}", "The shared vessel isn't reporting now — showing where it was last seen": "A embarcação compartilhada não está transmitindo agora — mostrando a última posição conhecida", - "The shared vessel isn't reporting now — showing where it was at {time}": "A embarcação compartilhada não está transmitindo agora — mostrando onde estava às {time}", + "The shared vessel isn't reporting now — showing where it was at {time}": "A embarcação compartilhada não está transmitindo agora — mostrando onde estava em {time}", "The shared satellite isn't reporting now — showing where it was last seen": "O satélite compartilhado não está transmitindo agora — mostrando a última posição conhecida", - "The shared satellite isn't reporting now — showing where it was at {time}": "O satélite compartilhado não está transmitindo agora — mostrando onde estava às {time}", + "The shared satellite isn't reporting now — showing where it was at {time}": "O satélite compartilhado não está transmitindo agora — mostrando onde estava em {time}", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID da embarcação: {id}", "Latitude must be within ±90° and longitude within ±180°": "A latitude deve estar dentro de ±90° e a longitude dentro de ±180°", - "Street Level is unavailable": "Nível da rua não está disponível", + "Street Level is unavailable": "A camada \"Nível da rua\" não está disponível", "No street-level imagery source is configured": "Nenhuma fonte de imagens no nível da rua configurada", - "Street Level could not be enabled": "Não foi possível ativar Nível da rua", - "Street Level changed while it looked for imagery": "Nível da rua mudou enquanto procurava imagens", + "Street Level could not be enabled": "Não foi possível ativar a camada \"Nível da rua\"", + "Street Level changed while it looked for imagery": "A camada \"Nível da rua\" mudou enquanto procurava imagens", "Aim the view at a street": "Aponte a vista para uma rua", "{source} is not a street-level imagery source here": "{source} não é uma fonte de imagens no nível da rua aqui", "{source} needs its key": "{source} precisa da sua chave", @@ -3589,11 +3593,11 @@ "No {source} imagery near {place}": "Nenhuma imagem de {source} perto de {place}", "{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})", "tiles unavailable": "blocos indisponíveis", - "zoom in to load this layer": "aproxime para carregar esta camada", + "zoom in to load this layer": "aproxime o zoom para carregar esta camada", "Zoom to layer": "Ir para a camada", "Zoom to layer · fly to where this layer has data": "Ir para a camada · voe até onde esta camada tem dados", - "Remove from Data Layers": "Remover de Camadas de dados", - "Remove from Data Layers · add it again from the library": "Remover de Camadas de dados · adicione de novo pela biblioteca", + "Remove from Data Layers": "Remover de \"Camadas de dados\"", + "Remove from Data Layers · add it again from the library": "Remover de \"Camadas de dados\" · adicione de novo pela biblioteca", "Coverage outline": "Contorno de cobertura", "DRAPED": "SOBREPOSTO", "Below 250 m": "Abaixo de 250 m", @@ -3667,28 +3671,35 @@ "DATA LIBRARY": "BIBLIOTECA DE DADOS", "Close data library": "Fechar a biblioteca de dados", "Data Library at a glance": "A biblioteca de dados em resumo", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fontes ao vivo além das camadas principais. Cada uma é obtida do seu provedor enquanto está ativa; nada é armazenado neste app. Adicione uma e ela aparecerá em Camadas de dados, em Biblioteca.", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Fontes ao vivo além das camadas principais. Cada uma é obtida do seu provedor enquanto está ativa; nada é armazenado neste app. Adicione uma e ela aparecerá em \"Camadas de dados\", em \"Biblioteca\".", "Name, provider or country": "Nome, provedor ou país", "Theme": "Tema", "All themes": "Todos os temas", "Only what this instance serves": "Só o que esta instância oferece", - "OPT-IN": "OPCIONAL", + "OPT-IN": "ATIV. PELO OPERADOR", "KEY": "CHAVE", "KEYLESS": "SEM CHAVE", "ZOOM": "APROXIMAR", "Zoom to {name}": "Ir para {name}", "WORKING…": "PROCESSANDO…", "ADD": "ADICIONAR", - "Remove {name} from Data Layers": "Remover {name} de Camadas de dados", - "Add {name} to Data Layers": "Adicionar {name} a Camadas de dados", + "Remove {name} from Data Layers": "Remover {name} de \"Camadas de dados\"", + "Add {name} to Data Layers": "Adicionar {name} a \"Camadas de dados\"", "CONFIRM · REMOVE ALL {count}": { "one": "CONFIRMAR · REMOVER {count}", "many": "CONFIRMAR · REMOVER TODAS AS {count}", "other": "CONFIRMAR · REMOVER TODAS AS {count}" }, "REMOVE ALL": "REMOVER TUDO", - "Confirm: remove every added entry from Data Layers": "Confirmar: remover de Camadas de dados todas as entradas adicionadas", - "Remove every added entry from Data Layers": "Remover de Camadas de dados todas as entradas adicionadas", + "Confirm: remove every added entry from Data Layers": "Confirmar: remover de \"Camadas de dados\" todas as entradas adicionadas", + "Remove every added entry from Data Layers": "Remover de \"Camadas de dados\" todas as entradas adicionadas", "Showing {shown} of {total}": "Mostrando {shown} de {total}", - "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS" + "BROWSE DATA LIBRARY": "EXPLORAR BIBLIOTECA DE DADOS", + "The globe stopped drawing": "O globo parou de desenhar", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.", + "Reset view reloads the app without the view restored from the address bar.": "Redefinir vista recarrega o app sem a vista restaurada da barra de endereços.", + "Reload": "Recarregar", + "Retry": "Tentar novamente", + "Reset view": "Redefinir vista" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index de517a550..b58d78d26 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -1,6 +1,6 @@ { "News feed unavailable": "Лента новостей недоступна", - "Developing: {name}": "Развивается: {name}", + "Developing: {name}": "Развитие событий: {name}", "{count} events today": { "one": "{count} событие сегодня", "few": "{count} события сегодня", @@ -19,12 +19,12 @@ "Claimed by {who}": "Заявление: {who}", "Claimed": "Заявление", "Not yet confirmed by others": "Пока не подтверждено другими", - "Reported by {side} sources": "Сообщают источники {side}", - "Reported by {sides}": "Сообщают: {sides}", - "{side} sources": "источники {side}", + "Reported by {side} sources": "Сообщают источники: {side}", + "Reported by {sides}": "Сообщают источники: {sides}", + "{side} sources": "{side}", "Official": "Официально", "Reported by both sides": "Сообщают обе стороны", - "Independently reported": "Подтверждено независимыми источниками", + "Independently reported": "Сообщают независимые источники", "Single source": "Один источник", "International": "Международные", "Independent": "Независимые", @@ -42,7 +42,7 @@ "Air defence": "ПВО", "Ground fighting": "Наземные бои", "Casualties": "Жертвы", - "Humanitarian": "Гуманитарное", + "Humanitarian": "Гуманитарная ситуация", "Disaster": "Бедствие", "Protest": "Протест", "Political": "Политика", @@ -50,7 +50,7 @@ "Minor incident": "Незначительный инцидент", "Significant": "Значительный", "Major": "Крупный", - "age|now": "сейчас", + "age|now": "только что", "{minutes} min": "{minutes} мин", "{hours} h": "{hours} ч", "{count} sources": { @@ -60,7 +60,7 @@ "other": "{count} источника" }, "News feed not configured on this server": "Лента новостей не настроена на этом сервере", - "Malformed news hotspots response": "Некорректный ответ горячих точек новостей", + "Malformed news hotspots response": "Некорректный ответ с горячими точками новостей", "Malformed news feed response": "Некорректный ответ ленты новостей", "{place} (placed by model)": "{place} (место определила модель)", "English headline by the model": "Английский заголовок от модели", @@ -68,7 +68,7 @@ "Close": "Закрыть", "Any severity": "Любая тяжесть", "{severity} and above": "{severity} и выше", - "{severity} only": "Только {severity}", + "{severity} only": "Только: {severity}", "All categories": "Все категории", "Reading the news…": "Читаем новости…", "News, last {hours} hours": "Новости за последние {hours} ч", @@ -87,7 +87,7 @@ "Load older news": "Загрузить более ранние новости", "How to read the map": "Как читать карту", "Colour: severity · icon: type of event": "Цвет: тяжесть · значок: тип события", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Сплошной обод: сообщают обе стороны или независимые источники. Пунктирный обод: заявление. Тонкий обод: один источник или пресса одной стороны.", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Сплошной контур: сообщают обе стороны или независимые источники. Пунктирный контур: заявление. Тонкий контур: один источник или пресса одной стороны.", "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Крупнее: больше источников · бледнее: старше. Карточка с фото: крупное или быстро растущее событие от двух и более источников.", "Side labels describe the source, not whether a report is true.": "Метки сторон описывают источник, а не достоверность сообщения.", "Exit cockpit to fly to a news report": "Выйдите из кабины, чтобы перелететь к сообщению", @@ -268,7 +268,7 @@ "PAUSED · {title}": "ПАУЗА · {title}", "UNSPECIFIED": "НЕ УКАЗАНО", "UNAVAILABLE": "НЕДОСТУПНО", - "+{count} additional payload records": "+{count} доп. записи о полезной нагрузке", + "+{count} additional payload records": "Ещё записей о полезной нагрузке: {count}", "PAYLOAD DATA UNAVAILABLE": "ДАННЫЕ ПОЛЕЗНОЙ НАГРУЗКИ НЕДОСТУПНЫ", "{value} KM DOWNRANGE": "{value} КМ ПО ТРАССЕ", "POSITION UNAVAILABLE": "ПОЗИЦИЯ НЕДОСТУПНА", @@ -285,8 +285,8 @@ "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}° · {offset}", "SITES STANDBY": "ОБЪЕКТЫ В ОЖИДАНИИ", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ВКЛЮЧИТЕ ОТМЕЧЕННЫЕ ОБЪЕКТЫ ДЛЯ ПИНГОВ МЕСТ", - "Release the camera from {name}": "Освободить камеру от {name}", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "ВКЛЮЧИТЕ «ОБЪЕКТЫ НА КАРТЕ» ДЛЯ ПИНГОВ ОБЪЕКТОВ", + "Release the camera from {name}": "Отпустить камеру с объекта: {name}", "RETURN · {name}": "НАЗАД · {name}", "PREVIOUS SATELLITE": "ПРЕДЫДУЩИЙ СПУТНИК", "Return to {name}": "Вернуться к {name}", @@ -295,8 +295,8 @@ "Unnamed site": "Безымянный объект", "OFF-NADIR {angle}°": "ОТКЛОНЕНИЕ ОТ НАДИРА {angle}°", "FOCUS · {name}": "ФОКУС · {name}", - "Keep the camera on {name}": "Удерживать камеру на {name}", - "ZOOM {zoom}×": "ПРИБЛИЖЕНИЕ {zoom}×", + "Keep the camera on {name}": "Удерживать камеру на объекте: {name}", + "ZOOM {zoom}×": "МАСШТАБ {zoom}×", "SWATH {value} KM": "ПОЛОСА ОБЗОРА {value} КМ", "SWATH ≈ {value} KM": "ПОЛОСА ОБЗОРА ≈ {value} КМ", "Orbital view": "Орбитальный вид", @@ -304,7 +304,7 @@ "ORBITAL VELOCITY": "ОРБИТАЛЬНАЯ СКОРОСТЬ", "SUB-SATELLITE POINT": "ПОДСПУТНИКОВАЯ ТОЧКА", "NORTH UP": "СЕВЕР ВВЕРХУ", - "CURRENTLY OVER": "СЕЙЧАС НАД", + "CURRENTLY OVER": "ПОД СПУТНИКОМ", "{division}, {country}": "{division}, {country}", "{region} · near {place}": "{region} · поблизости: {place}", "NORAD {id}": "NORAD {id}", @@ -346,7 +346,7 @@ "Cancel replay": "Отменить повтор", "EST. DOWNRANGE": "РАСЧЁТ ПО ТРАССЕ", "CONFIRMED": "ПОДТВЕРЖДЕНО", - "PAD / RTLS": "СТАРТОВАЯ ПЛОЩАДКА / RTLS", + "PAD / RTLS": "ПЛОЩАДКА / RTLS", "PLANNED · ": "ПЛАН · ", "SUPPLIED TRAJECTORY POINTS": "ПРЕДОСТАВЛЕННЫЕ ТОЧКИ ТРАЕКТОРИИ", "RECONSTRUCTED ESTIMATE": "РЕКОНСТРУИРОВАННАЯ ОЦЕНКА", @@ -376,7 +376,7 @@ "Starlink {detail} — click to retry": "Starlink: {detail} — нажмите, чтобы повторить", "load failed": "ошибка загрузки", "Showing the full Starlink shell — click for the core catalog only": "Показана полная оболочка Starlink — нажмите, чтобы оставить только основной каталог", - "DENSE": "ПЛОТНЫЙ", + "DENSE": "ПЛОТНО", "Syncing flow · Roads: {source}": "Синхронизация потока · Дороги: {source}", "LIVE · Roads: {source} · Roads without flow hidden": "ОНЛАЙН · Дороги: {source} · Дороги без потока скрыты", "LIVE · Roads: {source} · No flow roads in view": "ОНЛАЙН · Дороги: {source} · В кадре нет дорог с потоком", @@ -394,6 +394,8 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} СКОР: {speed} КУРС: {heading}", "VESSEL": "СУДНО", + "AID TO NAVIGATION": "СРЕДСТВО НАВИГАЦИИ", + "VIRTUAL": "ВИРТУАЛЬНЫЙ", "AIS: --": "AIS: --", "awaiting first AIS position…": "ожидание первой позиции AIS…", "awaiting usable AIS positions…": "ожидание пригодных позиций AIS…", @@ -469,10 +471,10 @@ "other": "Загружено {count} камеры · включите CCTV, чтобы активировать" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "Ещё {count} камера вне этого вида · сдвиньте или отдалите", - "few": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите", - "many": "Ещё {count} камер вне этого вида · сдвиньте или отдалите", - "other": "Ещё {count} камеры вне этого вида · сдвиньте или отдалите" + "one": "Ещё {count} камера вне поля зрения · сдвиньте или отдалите карту", + "few": "Ещё {count} камеры вне поля зрения · сдвиньте или отдалите карту", + "many": "Ещё {count} камер вне поля зрения · сдвиньте или отдалите карту", + "other": "Ещё {count} камеры вне поля зрения · сдвиньте или отдалите карту" }, "Enable CCTV to load camera intersections": "Включите CCTV, чтобы загрузить камеры на перекрёстках", "Enable CCTV to start camera-linked intelligence summaries.": "Включите CCTV, чтобы запускать сводки по камерам.", @@ -545,7 +547,7 @@ "ROUTE DATA UNAVAILABLE": "ДАННЫЕ МАРШРУТА НЕДОСТУПНЫ", "CONTACT NAVIGATION OFF": "НАВИГАЦИЯ ПО КОНТАКТАМ ВЫКЛЮЧЕНА", "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МОСТИК ОСТАЁТСЯ НА ВЫБРАННОМ СУДНЕ", - "{range} NM": "{range} МОР. МИЛЬ", + "{range} NM": "{range} МОР. МИЛ.", "AIS DESTINATION · CREW REPORTED": "ПУНКТ НАЗНАЧЕНИЯ AIS · УКАЗАН ЭКИПАЖЕМ", "DEST {bearing}": "НАЗН. {bearing}", "Disable cockpit weather effects": "Выключить погодные эффекты кабины", @@ -742,7 +744,7 @@ "ACQUIRING": "ЗАХВАТ", "SHARED {subject}": "ПО ССЫЛКЕ: {subject}", "entity": "объект", - "Shared {subject} follow expired": "Срок отслеживания {subject} по ссылке истёк", + "Shared {subject} follow expired": "Срок отслеживания по ссылке истёк: {subject}", "Shared {subject} could not be restored — feed unavailable": "Не удалось восстановить {subject} по ссылке — поток данных недоступен", "Shared {subject} is unavailable": "{subject} по ссылке недоступен", "LOADING LIVE DATA": "ЗАГРУЗКА ОНЛАЙН-ДАННЫХ", @@ -804,8 +806,8 @@ "Live signals": "Сигналы в реальном времени", "Latest regional news": "Последние региональные новости", "Location-based information": "Информация по текущему месту", - "LOOK STBD {bearing}°": "ВЗГЛЯД ПР. БОРТ {bearing}°", - "LOOK PORT {bearing}°": "ВЗГЛЯД ЛЕВ. БОРТ {bearing}°", + "LOOK STBD {bearing}°": "ВЗГЛЯД НА ПР. БОРТ {bearing}°", + "LOOK PORT {bearing}°": "ВЗГЛЯД НА ЛЕВ. БОРТ {bearing}°", "LOOK ASTERN": "ВЗГЛЯД ЗА КОРМУ", "UP {angle}°": "ВВЕРХ {angle}°", "DOWN {angle}°": "ВНИЗ {angle}°", @@ -823,7 +825,7 @@ "LIVE TRACK · COURSE ALIGNED": "ТРЕК ОНЛАЙН · КУРС СОВМЕЩЁН", "GROUND SPEED": "ПУТЕВАЯ СКОРОСТЬ", "SPEED OVER GROUND": "СКОРОСТЬ ПО ГРУНТУ", - "COURSE OVER GROUND": "КУРС ПО ГРУНТУ", + "COURSE OVER GROUND": "ПУТЕВОЙ УГОЛ", "ALTITUDE": "ВЫСОТА", "CONTEXT ONLY": "ТОЛЬКО КОНТЕКСТ", "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ТОЛЬКО ДОСТУПНЫЕ ДАННЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО", @@ -842,14 +844,14 @@ "EXIT BRIDGE": "ВЫЙТИ С МОСТИКА", "EXIT ORBIT": "ВЫЙТИ С ОРБИТЫ", "CONTACTS · 250 KM": "КОНТАКТЫ · 250 КМ", - "RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛЬ", + "RINGS {spacing} NM": "КОЛЬЦА {spacing} МОР. МИЛ.", "{count} CONTACTS": { "one": "{count} КОНТАКТ", "few": "{count} КОНТАКТА", "many": "{count} КОНТАКТОВ", "other": "{count} КОНТАКТА" }, - "Radar range {range}. Select for the next range.": "Шкала дальности радара {range}. Выберите, чтобы перейти к следующей шкале.", + "Radar range {range}. Select for the next range.": "Шкала дальности радара {range} — выберите, чтобы перейти к следующей шкале.", "Bridge radar": "Радар ходового мостика", "Expand Visual Presets": "Развернуть визуальные пресеты", "Pin visual presets": "Закрепить визуальные пресеты", @@ -970,7 +972,7 @@ "DEL": "УДАЛ.", "UPDATE SHOT": "ОБНОВИТЬ КАДР", "IMPORT": "ИМПОРТ", - "RUN LOG": "ЖУРНАЛ ЗАПУСКОВ", + "RUN LOG": "ЖУРНАЛ ТАЙМИНГОВ", "CALIBRATION": "КАЛИБРОВКА", "Camera pose — click a value to type": "Поза камеры — щёлкните по значению, чтобы ввести", "SCENE SUMMARY": "СВОДКА ПО СЦЕНЕ", @@ -2131,9 +2133,9 @@ "N/A": "Н/Д", "{label} · {radius} AIR/SEA WINDOW": "{label} · ОКНО ВОЗДУХ/МОРЕ {radius}", "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБЪЕКТЫ: {coverage}", - "MILITARY FEED OFF · PARTIAL PICTURE": "ВОЕННЫЙ ПОТОК ВЫКЛ · КАРТИНА НЕПОЛНАЯ", + "MILITARY FEED OFF · PARTIAL PICTURE": "ПОТОК ВОЕННЫХ РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ", "FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТОК РЕЙСОВ ВЫКЛ · КАРТИНА НЕПОЛНАЯ", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖНИЕ ВОЗДУШНЫЕ СУДА: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЖАЙШИЕ САМОЛЁТЫ: ДАННЫЕ НЕПОЛНЫЕ · ОТСУТСТВИЕ УГРОЗ НЕ ПОДТВЕРЖДЕНО", "{source} · LAST {hours} H": "{source} · ЗА {hours} Ч", "ft": "фт", "kts": "уз", @@ -2416,8 +2418,8 @@ "Import": "Импорт", "Choose GeoJSON, KML, KMZ or GPX files": "Выберите файлы GeoJSON, KML, KMZ или GPX", "Imported files": "Импортированные файлы", - "DOCKED · {name}": "СТЫКОВКА · {name}", - "DOCKED · {name} · +{extra}": "СТЫКОВКА · {name} · +{extra}", + "DOCKED · {name}": "ПРИСТЫКОВАН · {name}", + "DOCKED · {name} · +{extra}": "ПРИСТЫКОВАНЫ · {name} · +{extra}", "loading coverage...": "загрузка покрытия...", "live feed unavailable": "поток в реальном времени недоступен", "GBFS fetch error": "ошибка загрузки GBFS", @@ -2936,7 +2938,7 @@ "Hybrid": "Гибрид", "Lines": "Линии", "Dots": "Точки", - "Road lines colored by live flow: green to red": "Линии дорог по потоку онлайн: от зелёного к красному", + "Road lines colored by live flow: green to red": "Линии дорог, окрашенные по потоку онлайн: от зелёного к красному", "Moving dots colored by live flow": "Движущиеся точки, окрашенные по потоку онлайн", "Moving dots": "Движущиеся точки", "TomTom roads only, each with its live flow": "Только дороги TomTom, у каждой — свой поток онлайн", @@ -3183,11 +3185,11 @@ "Gemini Live connection is expiring. Start voice again for a new conversation.": "Соединение с Gemini Live истекает. Запустите голосовой режим заново для нового разговора.", "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Время голосовых сеансов Gemini ограничено. Когда сеанс истечёт, запустите голосовой режим заново для нового разговора.", "Gemini Live voice control": "Голосовое управление через Gemini Live", - "Voice provider": "Поставщик голосового сервиса", - "Changing provider reconnects voice if it is on.": "Смена поставщика переподключает голосовой режим, если он включён.", + "Voice provider": "Голосовой провайдер", + "Changing provider reconnects voice if it is on.": "Смена провайдера переподключает голосовой режим, если он включён.", "Gemini voice could not connect. Try again.": "Не удалось подключить голосовой режим Gemini. Повторите попытку.", "Gemini voice requires a same-origin app request": "Голосовой режим Gemini требует запрос из того же источника, что и приложение", - "Add a Gemini API key in Provider Settings": "Добавьте ключ API Gemini в настройках поставщиков", + "Add a Gemini API key in Provider Settings": "Добавьте ключ API Gemini в «Настройках провайдеров»", "The Gemini Live model configuration is invalid": "Недопустимая конфигурация модели Gemini Live", "Gemini voice is starting too often. Try again shortly.": "Голосовой режим Gemini запускается слишком часто. Повторите попытку чуть позже.", "Gemini quota is unavailable. Try again later.": "Квота Gemini недоступна. Повторите попытку позже.", @@ -3215,7 +3217,7 @@ "S2 · {name}": "S2 · {name}", "Report": "Сводка", "Open the source for {title}": "Открыть источник: {title}", - "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "В ЭФИРЕ · GitHub {commit} · {message} · проверка каждые {seconds} с", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "ОНЛАЙН · GitHub {commit} · {message} · проверка каждые {seconds} с", "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · переподключение для обновлений в реальном времени", "Connecting to the S2 GhostMaps repository…": "Подключение к репозиторию S2 GhostMaps…", "{count} placemarks": { @@ -3292,7 +3294,7 @@ "Click a shot to select it, double-click to rename.": "Щёлкните кадр, чтобы выбрать его, дважды — чтобы переименовать.", "scene|PLAY": "ПУСК", "Stop playback (Esc)": "Остановить воспроизведение (Esc)", - "SAVE & SHARE": "СОХРАНИТЬ И ПОДЕЛИТЬСЯ", + "SAVE & SHARE": "СОХРАНЕНИЕ И ОБМЕН", "Download every scene as a JSON file you can import later": "Скачать все сцены файлом JSON для последующего импорта", "Load scenes from an exported JSON file": "Загрузить сцены из экспортированного файла JSON", "Download the timing log of the last play": "Скачать журнал таймингов последнего воспроизведения", @@ -3303,7 +3305,7 @@ "Left panels": "Левые панели", "Right panels": "Правые панели", "UTILITIES": "ИНСТРУМЕНТЫ", - "Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку", + "Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к фигуре", "Directions, drawing and your own files": "Маршруты, рисование и ваши файлы", "Voice place search (needs an Overpass server)": "Голосовой поиск мест (нужен сервер Overpass)", "OSM Places": "Места OSM", @@ -3458,14 +3460,14 @@ "Within {km} km of the view centre": "В радиусе {km} км от центра карты", "{region}, {country}": "{region}, {country}", "via {source}": "через {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "Местное в слое новостей: новости дня от СМИ страны в кадре", + "Local in the News layer: the day’s news from the outlets of the country in view": "Раздел «Местные» в слое «Новости»: новости дня от СМИ страны в кадре", "{provider} refused its key": "{provider} отклонил ключ", "Check {envVars} in Provider Settings": "Проверьте {envVars} в «Настройках провайдеров»", "{provider} key required": "Нужен ключ {provider}", "{provider} limit reached for today": "{provider}: дневной лимит исчерпан", "News resumes tomorrow, or with a paid plan": "Новости вернутся завтра или с платным тарифом", "News key required": "Нужен ключ новостей", - "Precise conflict points in the News layer, beside GDELT’s articles": "Точные точки конфликтов в слое новостей, вдобавок к статьям GDELT", + "Precise conflict points in the News layer, beside GDELT’s articles": "Точные координаты конфликтов в слое «Новости», в дополнение к статьям GDELT", "Worldwide": "Весь мир", "Shipwrecks": "Кораблекрушения", "Before 1900": "До 1900", @@ -3480,7 +3482,7 @@ "Depth: {m} m": "Глубина: {m} м", "Notes: {value}": "Примечания: {value}", "Cargo or finds: {value}": "Груз или находки: {value}", - "Protected wreck": "Охраняемый объект", + "Protected wreck": "Затонувшее судно под охраной", "Source: {value}": "Источник: {value}", "Click the card to open EMODnet ↗": "Нажмите на карточку, чтобы открыть EMODnet ↗", "Click the card to open the source ↗": "Нажмите на карточку, чтобы открыть источник ↗", @@ -3511,9 +3513,9 @@ "seen {age} ago": "замечено {age} назад", "observed {age} ago": "наблюдалось {age} назад", "Be Whale Wise": "Наблюдайте китов бережно", - "Reported by {name} ({entity})": "Сообщил: {name} ({entity})", - "Reported by {name}": "Сообщил: {name}", - "Salish Sea": "Селишское море", + "Reported by {name} ({entity})": "Источник: {name} ({entity})", + "Reported by {name}": "Источник: {name}", + "Salish Sea": "Море Селиш", "Birds": "Птицы", "Mammals": "Млекопитающие", "Reptiles": "Пресмыкающиеся", @@ -3528,8 +3530,8 @@ "{label} {detail} — click to retry": "{label} {detail} — нажмите, чтобы повторить", "Wildlife sightings source unavailable": "Источник наблюдений живой природы недоступен", "Show only community-confirmed (research-grade) sightings": "Показать только подтверждённые сообществом наблюдения исследовательского уровня", - "No recent sightings in view": "Нет недавних наблюдений в области обзора", - "Acartia feed unavailable": "Лента Acartia недоступна", + "No recent sightings in view": "В кадре нет недавних наблюдений", + "Acartia feed unavailable": "Поток Acartia недоступен", "Show all taxa": "Показать все группы организмов", "BIRDS": "ПТИЦЫ", "Show only birds": "Показать только птиц", @@ -3544,31 +3546,31 @@ "All rights reserved": "Все права защищены", "Cetacean": "Китообразное", "Wildlife Sightings": "Наблюдения живой природы", - "Exit cockpit to fly to a sighting": "Выйдите из кабины, чтобы перейти к наблюдению", - "Exit cockpit to fly to a sighting area": "Выйдите из кабины, чтобы перейти к области наблюдения", + "Exit cockpit to fly to a sighting": "Выйдите из кабины, чтобы перелететь к наблюдению", + "Exit cockpit to fly to a sighting area": "Выйдите из кабины, чтобы перелететь к области наблюдения", "Wildlife": "Живая природа", - "Zoom in for local sightings": "Приблизьте для местных наблюдений", + "Zoom in for local sightings": "Приблизьте карту, чтобы увидеть местные наблюдения", "Loading photo...": "Загрузка фото…", "Photo unavailable": "Фото недоступно", "Location obscured to a 0.2° cell": "Местоположение скрыто в ячейке 0,2°", - "Photo of {name}": "Фото {name}", + "Photo of {name}": "Фото: {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} наблюдений в области просмотра", + "{count} sightings in view": "Наблюдений в кадре: {count}", "Close sighting": "Закрыть наблюдение", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Наблюдения дикой природы за 48 ч (китообразные — 7 дней). Цвет и символ значка обозначают таксономическую группу. Уязвимые виды показаны только ячейками 0,2°; сообщения Acartia о морских млекопитающих моря Селиш округлены до 0,01°. Не перепись.", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Наблюдения живой природы за 48 ч (китообразные — 7 дней). Цвет и символ значка обозначают таксономическую группу. Уязвимые виды показаны только ячейками 0,2°; сообщения Acartia о морских млекопитающих моря Селиш округлены до 0,01°. Это не учёт численности.", "Open {name} on iNaturalist": "Открыть {name} в iNaturalist", "Open {name} on Acartia": "Открыть {name} в Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Участники iNaturalist · лицензия у каждой записи", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Сэлиш) · лицензия у каждой записи", - "{days}d": "{days}д", + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Участники iNaturalist · Acartia {count} (море Селиш) · лицензия у каждой записи", + "{days}d": "{days} дн.", "All markers": "Все метки", "Show markers of every age": "Показать метки любого возраста", - "Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты сохранены", + "Maximum event age: {days}d; undated markers are kept": "Максимальный возраст события: {days} дн.; метки без даты остаются", "event age filter|All": "Все", "Event age": "Возраст события", - "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты сохранены", + "Event age: {days}d · undated kept": "Возраст события: {days} дн. · метки без даты остаются", "Close camera preview": "Закрыть просмотр камеры", "Refresh map still": "Обновить кадр на карте", "The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.", @@ -3596,18 +3598,18 @@ "Space science, technology demonstration and other payloads": "Космическая наука, демонстрация технологий и другие полезные нагрузки", "DEBRIS": "МУСОР", "Rocket bodies, fragments and dead satellites": "Ступени ракет, обломки и неработающие спутники", - "Classes may lag: SATCAT is a cached copy": "Классы могут отставать: SATCAT — кэшированная копия", - "Classes may lag: SATCAT unavailable": "Классы могут отставать: SATCAT недоступен", + "Classes may lag: SATCAT is a cached copy": "Классы могут быть неактуальны: SATCAT — кэшированная копия", + "Classes may lag: SATCAT unavailable": "Классы могут быть неактуальны: SATCAT недоступен", "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT сообщает тип и состояние каждого объекта. Без актуальной копии классы опираются на базу UCS, названия и группы CelesTrak, поэтому вышедший с тех пор из строя спутник может по-прежнему показываться работающим.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", - "SENSOR SWATH {value} KM": "ПОЛОСА СЕНСОРА {value} КМ", - "SENSOR SWATH ≈ {value} KM": "ПОЛОСА СЕНСОРА ≈ {value} КМ", + "SENSOR SWATH {value} KM": "ПОЛОСА ЗАХВАТА {value} КМ", + "SENSOR SWATH ≈ {value} KM": "ПОЛОСА ЗАХВАТА ≈ {value} КМ", "SENSOR": "СЕНСОР", - "Zoom to the imaging sensor swath": "Приблизить до полосы съёмочного сенсора", + "Zoom to the imaging sensor swath": "Приблизить до полосы захвата сенсора", "<1 H OLD": "<1 Ч НАЗАД", "{value} H OLD": "{value} Ч НАЗАД", - "{value} D OLD": "{value} Д НАЗАД", + "{value} D OLD": "{value} ДН. НАЗАД", "FWD": "ВПЕРЁД", "AFT": "НАЗАД", "RIGHT": "ВПРАВО", @@ -3618,11 +3620,11 @@ "Zoom out (−)": "Отдалить (−)", "ORBITAL VIEW · OFF-NADIR": "ОРБИТАЛЬНЫЙ ВИД · ОТКЛОНЕНИЕ ОТ НАДИРА", "World time": "Мировое время", - "Back to live": "Вернуться к прямому эфиру", + "Back to live": "Вернуться к текущему времени", "Live": "Сейчас", "1 h/s": "1 ч/с", - "1 d/s": "1 дн/с", - "1 wk/s": "1 нед/с", + "1 d/s": "1 дн./с", + "1 wk/s": "1 нед./с", "Play speed": "Скорость воспроизведения", "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё", "1 min/s": "1 мин/с", @@ -3637,29 +3639,31 @@ "Live GPS": "GPS в реальном времени", "Estimated from station reports": "Оценка по отметкам станций", "To {headsign}": "До {headsign}", - "Next {stop} {time} {relative}": "Следующая {stop} {time} {relative}", - "Prev {stop} {time} {relative}": "Предыдущая {stop} {time} {relative}", + "Next {stop} {time} {relative}": "Следующая: {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Предыдущая: {stop} {time} {relative}", "(+{minutes} min late)": "(+{minutes} мин опоздания)", "Exit cockpit to fly to a train": "Выйдите из кабины, чтобы перелететь к поезду", "Photo © {photographer}": "Фото © {photographer}", - "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": "Общий военный рейс сейчас не передаёт данные — показано последнее известное место", - "The shared military flight isn't reporting now — showing where it was at {time}": "Общий военный рейс сейчас не передаёт данные — показано место в {time}", - "The shared vessel isn't reporting now — showing where it was last seen": "Общее судно сейчас не передаёт данные — показано последнее известное место", - "The shared vessel isn't reporting now — showing where it was at {time}": "Общее судно сейчас не передаёт данные — показано место в {time}", - "The shared satellite isn't reporting now — showing where it was last seen": "Общий спутник сейчас не передаёт данные — показано последнее известное место", - "The shared satellite isn't reporting now — showing where it was at {time}": "Общий спутник сейчас не передаёт данные — показано место в {time}", + "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": "Военный рейс по ссылке сейчас не передаёт данные — показано последнее известное положение", + "The shared military flight isn't reporting now — showing where it was at {time}": "Военный рейс по ссылке сейчас не передаёт данные — показано положение на {time}", + "The shared vessel isn't reporting now — showing where it was last seen": "Судно по ссылке сейчас не передаёт данные — показано последнее известное положение", + "The shared vessel isn't reporting now — showing where it was at {time}": "Судно по ссылке сейчас не передаёт данные — показано положение на {time}", + "The shared satellite isn't reporting now — showing where it was last seen": "Спутник по ссылке сейчас не передаёт данные — показано последнее известное положение", + "The shared satellite isn't reporting now — showing where it was at {time}": "Спутник по ссылке сейчас не передаёт данные — показано положение на {time}", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID судна: {id}", "Latitude must be within ±90° and longitude within ±180°": "Широта должна быть в пределах ±90°, а долгота — в пределах ±180°", - "Street Level is unavailable": "Уличные снимки недоступны", + "Street Level is unavailable": "Слой «Уличные снимки» недоступен", "No street-level imagery source is configured": "Источник уличных снимков не настроен", - "Street Level could not be enabled": "Не удалось включить уличные снимки", - "Street Level changed while it looked for imagery": "Уличные снимки изменились во время поиска", - "Aim the view at a street": "Наведите вид на улицу", + "Street Level could not be enabled": "Не удалось включить «Уличные снимки»", + "Street Level changed while it looked for imagery": "Состояние слоя «Уличные снимки» изменилось во время поиска", + "Aim the view at a street": "Наведите центр вида на улицу", "{source} is not a street-level imagery source here": "{source} здесь не является источником уличных снимков", "{source} needs its key": "{source}: нужен ключ", "{source} needs the {map} map, which is not available": "{source}: нужна карта {map}, но она недоступна", @@ -3667,7 +3671,7 @@ "{source} is not available on this map": "{source}: недоступно на этой карте", "{source} imagery could not be opened": "Не удалось открыть снимки {source}", "{place} has no position to look for imagery at": "У места «{place}» нет координат для поиска снимков", - "No {source} imagery near {place}": "Нет снимков {source} рядом с {place}", + "No {source} imagery near {place}": "Нет снимков {source} рядом с местом «{place}»", "{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})", "tiles unavailable": "тайлы недоступны", "Environment": "Окружающая среда", @@ -3686,15 +3690,15 @@ "Needs a provider key on the server": "Нужен ключ провайдера на сервере", "Needs this app's server · not available in a static build": "Нужен сервер приложения · недоступно в статической сборке", "Checking what this instance serves…": "Проверяем, что доступно на этом экземпляре…", - "Regional": "Региональный", + "Regional": "Регионально", "DATA LIBRARY": "БИБЛИОТЕКА ДАННЫХ", "Close data library": "Закрыть библиотеку данных", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Живые источники помимо основных слоёв. Каждый загружается у своего провайдера, пока он включён; приложение ничего не хранит. Добавьте источник, и он появится в «Слоях данных» в разделе «Библиотека».", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Источники в реальном времени помимо основных слоёв. Каждый загружается у своего провайдера, пока он включён; приложение ничего не хранит. Добавьте источник, и он появится в «Слоях данных» в разделе «Библиотека».", "Name, provider or country": "Название, провайдер или страна", "Theme": "Тема", "All themes": "Все темы", "Only what this instance serves": "Только то, что доступно на этом экземпляре", - "OPT-IN": "ПО ВЫБОРУ", + "OPT-IN": "ВКЛЮЧАЕТ ОПЕРАТОР", "KEY": "КЛЮЧ", "KEYLESS": "БЕЗ КЛЮЧА", "WORKING…": "ВЫПОЛНЯЕТСЯ…", @@ -3777,5 +3781,12 @@ "500–1,000 m": "500–1000 м", "1,000–2,000 m": "1000–2000 м", "2,000–3,000 m": "2000–3000 м", - "3,000 m and above": "3000 м и выше" + "3,000 m and above": "3000 м и выше", + "The globe stopped drawing": "Глобус перестал отрисовываться", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.", + "Reset view reloads the app without the view restored from the address bar.": "«Сбросить вид» перезагружает приложение без вида, восстановленного из адресной строки.", + "Reload": "Перезагрузить", + "Retry": "Повторить", + "Reset view": "Сбросить вид" } diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 3d4c93d9b..bd1f25a7e 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -382,6 +382,8 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "FARTYG", + "AID TO NAVIGATION": "SJÖMÄRKE", + "VIRTUAL": "VIRTUELL", "POS: LIVE": "POS: LIVE", "POS: {time}Z": "POS: {time}Z", "awaiting first AIS position…": "väntar på första AIS-positionen …", @@ -3328,7 +3330,7 @@ "Refresh map still": "Uppdatera stillbilden på kartan", "The map still uses a separate request and may show a different moment.": "Stillbilden på kartan hämtas separat och kan visa ett annat ögonblick.", "Video plays from Québec 511, which sees your IP address.": "Videon spelas upp från Québec 511, som ser din IP-adress.", - "Press OPEN to load the photo": "Tryck på ÖPPNA för att läsa in bilden", + "Press OPEN to load the photo": "Tryck på ÖPPNA för att ladda fotot", "Captured {month}": "Tagen {month}", "this spot": "den här platsen", "the view centre": "vyns mitt", @@ -3336,7 +3338,7 @@ "Street level at {place}": "Gatunivå vid {place}", "Switching map": "Byter karta", "Opening imagery": "Öppnar bilder", - "Street View is ready: press {open} to load it": "Street View är redo: tryck på {open} för att läsa in den", + "Street View is ready: press {open} to load it": "Street View är redo: tryck på {open} för att ladda den", "{source} cannot open this link's street-level photo without a working key": "{source} kan inte öppna länkens bild på gatunivå utan en fungerande nyckel", "Street-level photos are hidden in the current view. Leave it first.": "Bilder på gatunivå är dolda i den aktuella vyn. Lämna den först.", "STATIONS": "STATIONER", @@ -3457,7 +3459,7 @@ "REPORTED VOYAGE": "ANGIVEN RESA", "1 min/s": "1 min/s", "Rail": "Järnväg", - "Urban rail": "Stadstrafik", + "Urban rail": "Tunnelbana/spårväg", "Local rail": "Lokaltåg", "Regional rail": "Regionaltåg", "National rail": "Fjärrtåg", @@ -3465,13 +3467,15 @@ "Hide {category}": "Dölj {category}", "Show {category}": "Visa {category}", "Live GPS": "Live-GPS", - "Estimated from station reports": "Uppskattad från stationsrapporter", + "Estimated from station reports": "Uppskattad utifrån stationsrapporter", "To {headsign}": "Mot {headsign}", "Next {stop} {time} {relative}": "Nästa stopp {stop} {time} {relative}", "Prev {stop} {time} {relative}": "Föregående stopp {stop} {time} {relative}", - "(+{minutes} min late)": "(+{minutes} min försenad)", + "(+{minutes} min late)": "(+{minutes} min försening)", "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", @@ -3530,7 +3534,7 @@ "TILES": "KARTRUTOR", "PACK": "PAKET", "INCUBATING": "INKUBERAS", - "COMMUNITY": "GEMENSKAP", + "COMMUNITY": "ANVÄNDARBIDRAG", "VERIFIED": "VERIFIERAD", "ARCHIVED": "ARKIVERAD", "Datasets": "Datamängder", @@ -3561,11 +3565,11 @@ }, "switch on a dataset to load features": "slå på en datamängd för att ladda objekt", "Switched on by this instance · part of the CCTV layer": "Påslaget av den här instansen · del av CCTV-lagret", - "Operator opt-in: set {env}=1 on the server": "Aktiveras av operatören: sätt {env}=1 på servern", - "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Aktiveras av operatören: lägg till {id} i GEV_LIBRARY_ENABLE på servern", - "Turned off by this instance's operator": "Avstängt av instansens operatör", + "Operator opt-in: set {env}=1 on the server": "Aktiveras av administratören: sätt {env}=1 på servern", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Aktiveras av administratören: lägg till {id} i GEV_LIBRARY_ENABLE på servern", + "Turned off by this instance's operator": "Avstängt av instansens administratör", "Needs a provider key on the server": "Kräver en leverantörsnyckel på servern", - "Needs this app's server · not available in a static build": "Kräver appens server · inte tillgängligt i en statisk build", + "Needs this app's server · not available in a static build": "Kräver appens server · inte tillgängligt i en statisk version", "Checking what this instance serves…": "Kontrollerar vad instansen tillhandahåller …", "Regional": "Regionalt", "Orthophoto": "Ortofoto", @@ -3575,7 +3579,7 @@ "True orthophoto": "Sant ortofoto", "Type": "Typ", "Resolution": "Upplösning", - "Flown": "Flugen", + "Flown": "Fotograferat", "Updates": "Uppdateras", "Ortho quality": "Ortokvalitet", "DATA LIBRARY": "DATABIBLIOTEK", @@ -3586,7 +3590,7 @@ "Theme": "Tema", "All themes": "Alla teman", "Only what this instance serves": "Bara det instansen tillhandahåller", - "OPT-IN": "VALFRITT", + "OPT-IN": "TILLVAL", "KEY": "NYCKEL", "KEYLESS": "UTAN NYCKEL", "ZOOM": "ZOOMA", @@ -3603,5 +3607,12 @@ "Confirm: remove every added entry from Data Layers": "Bekräfta: ta bort alla tillagda poster från Datalager", "Remove every added entry from Data Layers": "Ta bort alla tillagda poster från Datalager", "Showing {shown} of {total}": "Visar {shown} av {total}", - "BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET" + "BROWSE DATA LIBRARY": "BLÄDDRA I DATABIBLIOTEKET", + "The globe stopped drawing": "Globen ritas inte längre", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Webbläsaren tog tillbaka globens 3D-grafik. Lager och reglage fungerar fortfarande, men kartan kan inte ritas förrän sidan laddas om.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Renderingen stoppades efter ett fel. Lager och reglage fungerar fortfarande, men kartan uppdateras inte.", + "Reset view reloads the app without the view restored from the address bar.": "”Återställ vy” laddar om appen utan vyn som återställdes från adressfältet.", + "Reload": "Ladda om", + "Retry": "Försök igen", + "Reset view": "Återställ vy" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index f64f6e8e0..9506d0a31 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1,6 +1,6 @@ { "News feed unavailable": "Стрічка новин недоступна", - "Developing: {name}": "Розвивається: {name}", + "Developing: {name}": "Розвиток подій: {name}", "{count} events today": { "one": "{count} подія сьогодні", "few": "{count} події сьогодні", @@ -19,17 +19,17 @@ "Claimed by {who}": "Заява: {who}", "Claimed": "Заява", "Not yet confirmed by others": "Поки не підтверджено іншими", - "Reported by {side} sources": "Повідомляють джерела {side}", - "Reported by {sides}": "Повідомляють: {sides}", - "{side} sources": "джерела {side}", + "Reported by {side} sources": "Повідомляють джерела: {side}", + "Reported by {sides}": "Повідомляють джерела: {sides}", + "{side} sources": "{side}", "Official": "Офіційно", "Reported by both sides": "Повідомляють обидві сторони", - "Independently reported": "Підтверджено незалежними джерелами", + "Independently reported": "Повідомляють незалежні джерела", "Single source": "Одне джерело", "International": "Міжнародні", "Independent": "Незалежні", "Unattributed": "Без атрибуції", - "Outside party": "Зовнішня сторона", + "Outside party": "Третя сторона", "Ukraine–Russia": "Україна–Росія", "Israel–Gaza–Lebanon": "Ізраїль–Газа–Ліван", "Iran–Hormuz": "Іран–Ормуз", @@ -42,7 +42,7 @@ "Air defence": "ППО", "Ground fighting": "Наземні бої", "Casualties": "Жертви", - "Humanitarian": "Гуманітарне", + "Humanitarian": "Гуманітарна ситуація", "Disaster": "Лихо", "Protest": "Протест", "Political": "Політика", @@ -60,38 +60,38 @@ "other": "{count} джерела" }, "News feed not configured on this server": "Стрічку новин не налаштовано на цьому сервері", - "Malformed news hotspots response": "Некоректна відповідь гарячих точок новин", + "Malformed news hotspots response": "Некоректна відповідь із гарячими точками новин", "Malformed news feed response": "Некоректна відповідь стрічки новин", "{place} (placed by model)": "{place} (місце визначила модель)", "English headline by the model": "Англійський заголовок від моделі", - "Not placed on the map": "Не позначено на мапі", + "Not placed on the map": "Не позначено на карті", "Close": "Закрити", "Any severity": "Будь-яка тяжкість", "{severity} and above": "{severity} і вище", "{severity} only": "Лише {severity}", "All categories": "Усі категорії", "Reading the news…": "Читаємо новини…", - "News, last {hours} hours": "Новини за останні {hours} год", + "News, last {hours} hours": "Новини за {hours} год", "Last {hours} h": "За {hours} год", "Time window": "Період", "Conflicts": "Конфлікти", "Local": "Місцеві", "News sections": "Розділи новин", - "Zoom in to see local news": "Наблизьте мапу, щоб побачити місцеві новини", + "Zoom in to see local news": "Наблизьте карту, щоб побачити місцеві новини", "Hotspots": "Гарячі точки", "Severity": "Тяжкість", "Category": "Категорія", - "Nothing reported near here in the last {hours} h": "За останні {hours} год поблизу нічого не повідомлялося", + "Nothing reported near here in the last {hours} h": "За {hours} год поблизу нічого не повідомлялося", "Latest news": "Останні новини", - "No news matches these filters in the last {hours} h": "За останні {hours} год немає новин за цими фільтрами", + "No news matches these filters in the last {hours} h": "За {hours} год немає новин за цими фільтрами", "Load older news": "Завантажити давніші новини", - "How to read the map": "Як читати мапу", + "How to read the map": "Як читати карту", "Colour: severity · icon: type of event": "Колір: тяжкість · значок: тип події", "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Суцільний обідок: повідомляють обидві сторони або незалежні джерела. Пунктирний обідок: заява. Тонкий обідок: одне джерело або преса однієї сторони.", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Більше: більше джерел · блідіше: давніше. Картка з фото: велика або швидко зростаюча подія з двох і більше джерел.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Більший маркер: більше джерел · блідіший: давніша подія. Картка з фото: велика подія або подія, що швидко розвивається, з двома чи більше джерелами.", "Side labels describe the source, not whether a report is true.": "Позначки сторін описують джерело, а не правдивість повідомлення.", "Exit cockpit to fly to a news report": "Вийдіть із кабіни, щоб перелетіти до повідомлення", - "News items in the feed": "Новин у стрічці", + "News items in the feed": "Кількість новин у стрічці", "Pick a shape, then click the map.": "Виберіть фігуру й клацніть на карті.", "Enter to place the pin, Esc to cancel.": "Enter — поставити мітку, Esc — скасувати.", "Click where the pin goes.": "Клацніть там, де має бути мітка.", @@ -936,15 +936,15 @@ "{value} KM/S": "{value} КМ/С", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}° · {offset}", - "SITES STANDBY": "ОБ'ЄКТИ: ОЧІКУВАННЯ", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "УВІМКНІТЬ ПОЗНАЧЕНІ ОБ'ЄКТИ, ЩОБ БАЧИТИ МІСЦЯ", + "SITES STANDBY": "ОБʼЄКТИ: ОЧІКУВАННЯ", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "УВІМКНІТЬ «НАНЕСЕНІ ОБʼЄКТИ», ЩОБ БАЧИТИ КОНТАКТИ", "Release the camera from {name}": "Звільнити камеру від {name}", "RETURN · {name}": "НАЗАД · {name}", "PREVIOUS SATELLITE": "ПОПЕРЕДНІЙ СУПУТНИК", "Return to {name}": "Повернутися до {name}", "SATELLITE": "СУПУТНИК", "Ride {name}": "Перейти на {name}", - "Unnamed site": "Безіменний об'єкт", + "Unnamed site": "Безіменний обʼєкт", "OFF-NADIR {angle}°": "ВІДХИЛЕННЯ ВІД НАДИРА {angle}°", "FOCUS · {name}": "ФОКУС · {name}", "Keep the camera on {name}": "Утримувати камеру на {name}", @@ -1253,9 +1253,9 @@ "Moving dots": "Рухомі точки", "TomTom key unavailable": "Ключ TomTom недоступний", "TomTom daily budget reached": "Вичерпано денний ліміт TomTom", - "Monthly traffic allowance used": "Місячний ліміт даних про трафік вичерпано", - "Traffic rate limit reached": "Досягнуто ліміту запитів даних про трафік", - "Traffic service unavailable": "Сервіс даних про трафік недоступний", + "Monthly traffic allowance used": "Місячний ліміт даних про дорожній рух вичерпано", + "Traffic rate limit reached": "Перевищено ліміт запитів до сервісу руху", + "Traffic service unavailable": "Сервіс руху недоступний", "TomTom upstream unreachable": "Сервер TomTom не відповідає", "TomTom flow unavailable": "Потік TomTom недоступний", "SIMULATED — {reason}": "ІМІТАЦІЯ — {reason}", @@ -1309,6 +1309,8 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} ШВИД: {speed} КУРС: {heading}", "VESSEL": "СУДНО", + "AID TO NAVIGATION": "ЗАСІБ НАВІГАЦІЇ", + "VIRTUAL": "ВІРТУАЛЬНИЙ", "{speed}KT": "{speed} ВУЗ", "{heading}DEG": "{heading}°", "POS: LIVE": "ПОЗ.: ОНЛАЙН", @@ -1779,10 +1781,10 @@ "other": "{count} камери завантажено · увімкніть CCTV, щоб активувати" }, "{count} more cameras outside this view · pan or zoom out": { - "one": "Ще {count} камера поза цим видом · посуньте або віддаліть", - "few": "Ще {count} камери поза цим видом · посуньте або віддаліть", - "many": "Ще {count} камер поза цим видом · посуньте або віддаліть", - "other": "Ще {count} камери поза цим видом · посуньте або віддаліть" + "one": "Ще {count} камера поза кадром · посуньте або віддаліть карту", + "few": "Ще {count} камери поза кадром · посуньте або віддаліть карту", + "many": "Ще {count} камер поза кадром · посуньте або віддаліть карту", + "other": "Ще {count} камери поза кадром · посуньте або віддаліть карту" }, "Enable CCTV to load camera intersections": "Увімкніть CCTV, щоб завантажити камери на перехрестях", "Enable CCTV to start camera-linked intelligence summaries.": "Увімкніть CCTV, щоб отримувати розвідувальні зведення за даними камер.", @@ -1822,7 +1824,7 @@ "{scope} · INSTALLATIONS {coverage}": "{scope} · ОБʼЄКТИ: {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "ВІЙСЬКОВИЙ ПОТІК ВИМК. · КАРТИНА НЕПОВНА", "FLIGHTS FEED OFF · PARTIAL PICTURE": "ПОТІК РЕЙСІВ ВИМК. · КАРТИНА НЕПОВНА", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БЛИЗЬКІ ПОВІТРЯНІ СУДНА: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "БОРТИ ПОБЛИЗУ: ДАНІ НЕПОВНІ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", "CONTEXT STANDBY": "КОНТЕКСТ: ОЧІКУВАННЯ", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "УВІМКНІТЬ ГЛОБАЛЬНИЙ КОНТЕКСТ, ЩОБ БАЧИТИ КОНТАКТИ ПОБЛИЗУ", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "КОНТАКТ ВТРАЧЕНО · ОСТАННІ ВІДОМІ ПОКАЗНИКИ · НЕ ОЗНАЧАЄ «УСЕ ЧИСТО»", @@ -1859,7 +1861,7 @@ "ROUTE DATA UNAVAILABLE": "ДАНІ МАРШРУТУ НЕДОСТУПНІ", "CONTACT NAVIGATION OFF": "НАВІГАЦІЮ КОНТАКТАМИ ВИМКНЕНО", "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "МІСТОК ЗАЛИШАЄТЬСЯ НА ВИБРАНОМУ СУДНІ", - "{range} NM": "{range} МОР. МИЛЬ", + "{range} NM": "{range} МОР. МИЛ.", "AIS DESTINATION · CREW REPORTED": "ПРИЗНАЧЕННЯ AIS · ВКАЗАНО ЕКІПАЖЕМ", "DEST {bearing}": "ПРИЗН. {bearing}", "Disable cockpit weather effects": "Вимкнути погодні ефекти кабіни", @@ -2929,7 +2931,7 @@ "NO HEADING · AIS": "НЕМАЄ КУРСУ · AIS", "VISOR LOCK · ACTIVE": "ФІКСАЦІЯ ВІЗОРА · АКТИВНА", "GROUND SPEED · KTS": "ШЛЯХОВА ШВИДКІСТЬ · ВУЗ", - "SPEED OVER GROUND · KTS": "ШВИДКІСТЬ ЩОДО ҐРУНТУ · ВУЗ", + "SPEED OVER GROUND · KTS": "ШЛЯХОВА ШВИДКІСТЬ · ВУЗ", "ALTITUDE · FT": "ВИСОТА · ФТ", "FIRST PERSON": "ВІД ПЕРШОЇ ОСОБИ", "BRIDGE VIEW": "ВИГЛЯД З МІСТКА", @@ -2946,22 +2948,22 @@ "LOOK AHEAD": "ПОГЛЯД ПО НОСІ", "Current vessel heading": "Поточний курс судна", "GROUND SPEED": "ШЛЯХОВА ШВИДКІСТЬ", - "SPEED OVER GROUND": "ШВИДКІСТЬ ЩОДО ҐРУНТУ", - "COURSE OVER GROUND": "КУРС ЩОДО ҐРУНТУ", + "SPEED OVER GROUND": "ШЛЯХОВА ШВИДКІСТЬ", + "COURSE OVER GROUND": "ШЛЯХОВИЙ КУТ", "KTS": "ВУЗ", "ALTITUDE": "ВИСОТА", "FT": "ФТ", "Contact cockpit summary": "Зведення контакту в кабіні", "CONTACT": "КОНТАКТ", "CONTACTS · 250 KM": "КОНТАКТИ · 250 КМ", - "RINGS {spacing} NM": "КІЛЬЦЯ {spacing} МОР. МИЛЬ", + "RINGS {spacing} NM": "КІЛЬЦЯ {spacing} МОР. МИЛ.", "{count} CONTACTS": { "one": "{count} КОНТАКТ", "few": "{count} КОНТАКТИ", "many": "{count} КОНТАКТІВ", "other": "{count} КОНТАКТУ" }, - "Radar range {range}. Select for the next range.": "Шкала дальності радара {range}. Виберіть, щоб перейти до наступної шкали.", + "Radar range {range}. Select for the next range.": "Шкала дальності радара {range} — виберіть, щоб перейти до наступної шкали.", "Bridge radar": "Радар ходового містка", "Contact navigation": "Навігація контактами", "CONTEXT ONLY": "ЛИШЕ КОНТЕКСТ", @@ -3064,7 +3066,7 @@ "AVAILABLE MISSIONS": "ДОСТУПНІ МІСІЇ", "SELECT A MISSION TO INSPECT": "ВИБЕРІТЬ МІСІЮ ДЛЯ ПЕРЕГЛЯДУ", "LOADING 30-DAY MISSION INDEX": "ЗАВАНТАЖЕННЯ ІНДЕКСУ МІСІЙ ЗА 30 ДНІВ", - "Space Missions unavailable: the mission index could not load. Try again later.": "Космічні місії недоступні: не вдалося завантажити список місій. Спробуйте пізніше.", + "Space Missions unavailable: the mission index could not load. Try again later.": "Режим «Космічні місії» недоступний: не вдалося завантажити індекс місій. Спробуйте пізніше.", "30-DAY MISSION INDEX UNAVAILABLE": "ІНДЕКС МІСІЙ ЗА 30 ДНІВ НЕДОСТУПНИЙ", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB — ОГЛЯД · ENTER / SPACE — ВИБІР", "Internet radio and local SDR": "Інтернет-радіо та локальний SDR", @@ -3213,7 +3215,7 @@ "FLAT": "ПЛОСКІ", "SINCE": "З ДАТИ", "UPDATE SHOT": "ОНОВИТИ КАДР", - "IMPORT": "ІМПОРТУВАТИ", + "IMPORT": "ІМПОРТ", "RUN LOG": "ЖУРНАЛ ВІДТВОРЕННЯ", "POWER UP": "ЗАПУСК", "GROUND STATION · PROVIDER SETTINGS": "НАЗЕМНА СТАНЦІЯ · НАЛАШТУВАННЯ ПОСТАЧАЛЬНИКІВ", @@ -3282,13 +3284,13 @@ "+ CAPTURE SHOT": "+ ЗНЯТИ КАДР", "Add the current view, style and layers as the next shot": "Додати поточний вигляд, стиль і шари як наступний кадр", "Replace the selected shot with the current view": "Замінити вибраний кадр поточним виглядом", - "Click a shot to select it, double-click to rename.": "Натисніть кадр, щоб вибрати його, двічі — щоб перейменувати.", + "Click a shot to select it, double-click to rename.": "Клацніть кадр, щоб вибрати його, двічі — щоб перейменувати.", "Play from this scene through the rest of the project": "Відтворити від цієї сцени до кінця проєкту", "Stop playback (Esc)": "Зупинити відтворення (Esc)", "Play the shot after the selected one, moving on to the next scene when needed": "Відтворити кадр після вибраного, за потреби перейшовши до наступної сцени", "SAVE & SHARE": "ЗБЕРЕГТИ Й ПОДІЛИТИСЯ", "Download every scene as a JSON file you can import later": "Завантажити всі сцени файлом JSON для подальшого імпорту", - "Load scenes from an exported JSON file": "Завантажити сцени з експортованого файлу JSON", + "Load scenes from an exported JSON file": "Імпортувати сцени з експортованого файлу JSON", "Download the timing log of the last play": "Завантажити журнал таймінгів останнього відтворення", "Edit anchors, data packs and shot details": "Змінити якорі, пакети даних і параметри кадрів", "Download this scene as JSON or an asset bundle": "Завантажити цю сцену у форматі JSON або архівом ресурсів", @@ -3303,7 +3305,7 @@ "Drawn shapes": "Намальовані фігури", "Audio": "Аудіо", "UTILITIES": "ІНСТРУМЕНТИ", - "Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка", + "Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до фігури", "Directions, drawing and your own files": "Маршрути, малювання та ваші файли", "Voice place search (needs an Overpass server)": "Голосовий пошук місць (потрібен сервер Overpass)", "OSM Places": "Місця OSM", @@ -3455,19 +3457,19 @@ "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Новини за останні 24 год. Кожне повідомлення веде до свого видання; місця й категорії визначаються з тексту й даних автоматично і можуть бути хибними. Позначки сторін, де вони є, описують джерело, а не правдивість повідомлення.", "Economy": "Економіка", "News from {area}": "Новини: {area}", - "Within {km} km of the view centre": "У радіусі {km} км від центру мапи", + "Within {km} km of the view centre": "У радіусі {km} км від центру карти", "{region}, {country}": "{region}, {country}", "via {source}": "через {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "Місцеве в шарі новин: новини дня від медіа країни в кадрі", + "Local in the News layer: the day’s news from the outlets of the country in view": "Розділ «Місцеві» в шарі «Новини»: новини дня від медіа країни в кадрі", "{provider} refused its key": "{provider} відхилив ключ", "Check {envVars} in Provider Settings": "Перевірте {envVars} у «Налаштуваннях постачальників»", "{provider} key required": "Потрібен ключ {provider}", "{provider} limit reached for today": "{provider}: денний ліміт вичерпано", "News resumes tomorrow, or with a paid plan": "Новини повернуться завтра або з платним тарифом", "News key required": "Потрібен ключ новин", - "Precise conflict points in the News layer, beside GDELT’s articles": "Точні точки конфліктів у шарі новин, на додачу до статей GDELT", + "Precise conflict points in the News layer, beside GDELT’s articles": "Точні місця конфліктів у шарі «Новини», на додачу до статей GDELT", "Worldwide": "Увесь світ", - "Shipwrecks": "Корабельні аварії", + "Shipwrecks": "Затонулі судна", "Before 1900": "До 1900", "After 1945": "Після 1945", "Date unknown": "Дата невідома", @@ -3480,13 +3482,13 @@ "Depth: {m} m": "Глибина: {m} м", "Notes: {value}": "Примітки: {value}", "Cargo or finds: {value}": "Вантаж або знахідки: {value}", - "Protected wreck": "Охоронюваний об'єкт", + "Protected wreck": "Охоронюваний обʼєкт", "Source: {value}": "Джерело: {value}", - "Click the card to open EMODnet ↗": "Натисніть картку, щоб відкрити EMODnet ↗", - "Click the card to open the source ↗": "Натисніть картку, щоб відкрити джерело ↗", + "Click the card to open EMODnet ↗": "Клацніть картку, щоб відкрити EMODnet ↗", + "Click the card to open the source ↗": "Клацніть картку, щоб відкрити джерело ↗", "Wikidata unavailable; showing EMODnet only": "Wikidata недоступна; показано лише EMODnet", "EMODnet unavailable; showing Wikidata only": "EMODnet недоступний; показано лише Wikidata", - "Shipwrecks unavailable": "Дані про корабельні аварії недоступні", + "Shipwrecks unavailable": "Дані про затонулі судна недоступні", "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (води Європи) · Wikidata (увесь світ)", "{count} wrecks": { "one": "{count} затонуле судно", @@ -3495,25 +3497,25 @@ "other": "{count} затонулого судна" }, "1900–1945": "1900–1945", - "Shipwreck": "Корабельна аварія", + "Shipwreck": "Затонуле судно", "Fatalities: {count}": "Загиблих: {count}", - "Survivors: {count}": "Тих, хто вижив: {count}", - "Click the card to open the article ↗": "Натисніть картку, щоб відкрити статтю ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Історичні затонулі судна Європи — з EMODnet Human Activities, відомі корабельні аварії світу — з Wikidata. Колір показує, коли судно затонуло; маркер збільшується з кількістю загиблих. Натисніть маркер, щоб відкрити картку.", + "Survivors: {count}": "Уцілілих: {count}", + "Click the card to open the article ↗": "Клацніть картку, щоб відкрити статтю ↗", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "Історичні затонулі судна Європи — з EMODnet Human Activities, відомі затонулі судна з усього світу — з Wikidata. Колір показує, коли судно затонуло; маркер збільшується з кількістю загиблих. Клацніть маркер, щоб відкрити картку.", "All years": "Усі роки", "Period": "Період", "From": "З", "To": "По", - "First year of the shipwreck period": "Перший рік періоду корабельних аварій", - "Last year of the shipwreck period": "Останній рік періоду корабельних аварій", + "First year of the shipwreck period": "Перший рік періоду загибелі суден", + "Last year of the shipwreck period": "Останній рік періоду загибелі суден", "SIGHTING": "СПОСТЕРЕЖЕННЯ", "Show {name} sighting": "Показати спостереження: {name}", "seen {age} ago": "помічено {age} тому", "observed {age} ago": "спостерігалося {age} тому", "Be Whale Wise": "Спостерігайте за китами дбайливо", - "Reported by {name} ({entity})": "Повідомив: {name} ({entity})", - "Reported by {name}": "Повідомив: {name}", - "Salish Sea": "Салішське море", + "Reported by {name} ({entity})": "Повідомляє: {name} ({entity})", + "Reported by {name}": "Повідомляє: {name}", + "Salish Sea": "море Селіш", "Birds": "Птахи", "Mammals": "Ссавці", "Reptiles": "Плазуни", @@ -3525,11 +3527,11 @@ "Fungi": "Гриби", "Other animals": "Інші тварини", "Other or unknown": "Інші або невідомі", - "{label} {detail} — click to retry": "{label} {detail} — натисніть, щоб повторити", + "{label} {detail} — click to retry": "{label}: {detail} — клацніть, щоб повторити", "Wildlife sightings source unavailable": "Джерело спостережень живої природи недоступне", "Show only community-confirmed (research-grade) sightings": "Показати лише підтверджені спільнотою спостереження дослідницького рівня", - "No recent sightings in view": "Немає недавніх спостережень в області огляду", - "Acartia feed unavailable": "Стрічка Acartia недоступна", + "No recent sightings in view": "У кадрі немає недавніх спостережень", + "Acartia feed unavailable": "Потік Acartia недоступний", "Show all taxa": "Показати всі групи організмів", "BIRDS": "ПТАХИ", "Show only birds": "Показати лише птахів", @@ -3544,31 +3546,31 @@ "All rights reserved": "Усі права захищено", "Cetacean": "Китоподібне", "Wildlife Sightings": "Спостереження живої природи", - "Exit cockpit to fly to a sighting": "Вийдіть із кабіни, щоб перейти до спостереження", - "Exit cockpit to fly to a sighting area": "Вийдіть із кабіни, щоб перейти до області спостереження", + "Exit cockpit to fly to a sighting": "Вийдіть із кабіни, щоб перелетіти до спостереження", + "Exit cockpit to fly to a sighting area": "Вийдіть із кабіни, щоб перелетіти до області спостереження", "Wildlife": "Жива природа", - "Zoom in for local sightings": "Наблизьте для місцевих спостережень", + "Zoom in for local sightings": "Наблизьте карту, щоб побачити місцеві спостереження", "Loading photo...": "Завантаження фото…", "Photo unavailable": "Фото недоступне", "Location obscured to a 0.2° cell": "Місцеположення приховано в комірці 0,2°", "Photo of {name}": "Фото {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} спостережень у полі зору", + "{count} sightings in view": "Спостережень у кадрі: {count}", "Close sighting": "Закрити спостереження", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Спостереження дикої природи за 48 год (китоподібні — 7 днів). Колір і символ значка позначають таксономічну групу. Вразливі види показано лише клітинками 0,2°; повідомлення Acartia про морських ссавців моря Селіш округлено до 0,01°. Не перепис.", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Спостереження живої природи за 48 год (китоподібні — 7 днів). Колір і символ значка позначають таксономічну групу. Вразливі види показано лише комірками 0,2°; повідомлення Acartia про морських ссавців моря Селіш округлено до 0,01°. Не перепис.", "Open {name} on iNaturalist": "Відкрити {name} в iNaturalist", "Open {name} on Acartia": "Відкрити {name} в Acartia", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "Учасники iNaturalist · ліцензія в кожного запису", "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "Учасники iNaturalist · Acartia {count} (море Селіш) · ліцензія в кожного запису", - "{days}d": "{days}д", - "All markers": "Усі позначки", - "Show markers of every age": "Показати позначки будь-якого віку", - "Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн.; позначки без дати збережено", + "{days}d": "{days} дн", + "All markers": "Усі мітки", + "Show markers of every age": "Показати мітки будь-якого віку", + "Maximum event age: {days}d; undated markers are kept": "Максимальний вік події: {days} дн; мітки без дати залишаються", "event age filter|All": "Усі", "Event age": "Вік події", - "Event age: {days}d · undated kept": "Вік події: {days} дн. · позначки без дати збережено", + "Event age: {days}d · undated kept": "Вік події: {days} дн · мітки без дати залишаються", "Close camera preview": "Закрити перегляд камери", "Refresh map still": "Оновити кадр на карті", "The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.", @@ -3581,24 +3583,24 @@ "Street level at {place}": "Вуличні знімки: {place}", "Switching map": "Перемикання карти", "Opening imagery": "Відкриття знімків", - "Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити", + "Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть «{open}», щоб завантажити", "{source} cannot open this link's street-level photo without a working key": "{source} не може відкрити вуличне фото за цим посиланням без робочого ключа", "Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього.", "STATIONS": "СТАНЦІЇ", "Crewed stations and the craft that visit them": "Пілотовані станції та кораблі, що до них прилітають", "IMAGING": "ЗЙОМКА", "Earth observation: optical, radar, weather and science": "Спостереження Землі: оптика, радар, погода й наука", - "TELECOM": "ЗВ’ЯЗОК", - "Communications and navigation satellites, Starlink excepted": "Супутники зв’язку та навігації, крім Starlink", + "TELECOM": "ЗВʼЯЗОК", + "Communications and navigation satellites, Starlink excepted": "Супутники звʼязку та навігації, крім Starlink", "STARLINK": "STARLINK", "The Starlink constellation (thousands of extra points)": "Угруповання Starlink (тисячі додаткових точок)", "PROBES": "ЗОНДИ", "Space science, technology demonstration and other payloads": "Космічна наука, демонстрація технологій та інші корисні навантаження", "DEBRIS": "УЛАМКИ", - "Rocket bodies, fragments and dead satellites": "Ступені ракет, уламки й непрацюючі супутники", + "Rocket bodies, fragments and dead satellites": "Ступені ракет, уламки й недіючі супутники", "Classes may lag: SATCAT is a cached copy": "Класи можуть відставати: SATCAT — кешована копія", "Classes may lag: SATCAT unavailable": "Класи можуть відставати: SATCAT недоступний", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT повідомляє тип і стан кожного об’єкта. Без актуальної копії класи спираються на базу UCS, назви та групи CelesTrak, тож супутник, що відтоді вийшов з ладу, може й далі показуватися робочим.", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT повідомляє тип і стан кожного обʼєкта. Без актуальної копії класи спираються на базу UCS, назви та групи CelesTrak, тож супутник, що відтоді вийшов з ладу, може й далі показуватися робочим.", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", "SENSOR SWATH {value} KM": "СМУГА СЕНСОРА {value} КМ", @@ -3618,7 +3620,7 @@ "Zoom out (−)": "Віддалити (−)", "ORBITAL VIEW · OFF-NADIR": "ОРБІТАЛЬНИЙ ВИГЛЯД · ВІДХИЛЕННЯ ВІД НАДИРА", "World time": "Світовий час", - "Back to live": "Повернутися до прямого ефіру", + "Back to live": "Повернутися до поточного часу", "Live": "Зараз", "1 h/s": "1 год/с", "1 d/s": "1 дн/с", @@ -3636,20 +3638,22 @@ "Show {category}": "Показати: {category}", "Live GPS": "GPS у реальному часі", "Estimated from station reports": "Оцінка за відмітками станцій", - "To {headsign}": "До {headsign}", - "Next {stop} {time} {relative}": "Наступна {stop} {time} {relative}", - "Prev {stop} {time} {relative}": "Попередня {stop} {time} {relative}", + "To {headsign}": "Куди: {headsign}", + "Next {stop} {time} {relative}": "Наступна: {stop} {time} {relative}", + "Prev {stop} {time} {relative}": "Попередня: {stop} {time} {relative}", "(+{minutes} min late)": "(+{minutes} хв запізнення)", "Exit cockpit to fly to a train": "Вийдіть із кабіни, щоб перелетіти до поїзда", "Photo © {photographer}": "Фото © {photographer}", - "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": "Спільний військовий рейс зараз не передає даних — показано останнє відоме місце", - "The shared military flight isn't reporting now — showing where it was at {time}": "Спільний військовий рейс зараз не передає даних — показано місце о {time}", - "The shared vessel isn't reporting now — showing where it was last seen": "Спільне судно зараз не передає даних — показано останнє відоме місце", - "The shared vessel isn't reporting now — showing where it was at {time}": "Спільне судно зараз не передає даних — показано місце о {time}", - "The shared satellite isn't reporting now — showing where it was last seen": "Спільний супутник зараз не передає даних — показано останнє відоме місце", - "The shared satellite isn't reporting now — showing where it was at {time}": "Спільний супутник зараз не передає даних — показано місце о {time}", + "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": "Військовий рейс за посиланням зараз не передає даних — показано останнє відоме місце", + "The shared military flight isn't reporting now — showing where it was at {time}": "Військовий рейс за посиланням зараз не передає даних — показано місце станом на {time}", + "The shared vessel isn't reporting now — showing where it was last seen": "Судно за посиланням зараз не передає даних — показано останнє відоме місце", + "The shared vessel isn't reporting now — showing where it was at {time}": "Судно за посиланням зараз не передає даних — показано місце станом на {time}", + "The shared satellite isn't reporting now — showing where it was last seen": "Супутник за посиланням зараз не передає даних — показано останнє відоме місце", + "The shared satellite isn't reporting now — showing where it was at {time}": "Супутник за посиланням зараз не передає даних — показано місце станом на {time}", "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", @@ -3657,9 +3661,9 @@ "Latitude must be within ±90° and longitude within ±180°": "Широта має бути в межах ±90°, а довгота — у межах ±180°", "Street Level is unavailable": "Вуличні знімки недоступні", "No street-level imagery source is configured": "Джерело вуличних знімків не налаштовано", - "Street Level could not be enabled": "Не вдалося увімкнути вуличні знімки", - "Street Level changed while it looked for imagery": "Вуличні знімки змінилися під час пошуку", - "Aim the view at a street": "Наведіть вигляд на вулицю", + "Street Level could not be enabled": "Не вдалося увімкнути «Вуличні знімки»", + "Street Level changed while it looked for imagery": "Стан шару «Вуличні знімки» змінився під час пошуку", + "Aim the view at a street": "Спрямуйте камеру на вулицю", "{source} is not a street-level imagery source here": "{source} тут не є джерелом вуличних знімків", "{source} needs its key": "{source}: потрібен ключ", "{source} needs the {map} map, which is not available": "{source}: потрібна карта {map}, але вона недоступна", @@ -3667,14 +3671,14 @@ "{source} is not available on this map": "{source}: недоступно на цій карті", "{source} imagery could not be opened": "Не вдалося відкрити знімки {source}", "{place} has no position to look for imagery at": "Для місця «{place}» немає координат для пошуку знімків", - "No {source} imagery near {place}": "Немає знімків {source} поблизу {place}", + "No {source} imagery near {place}": "Немає знімків {source} поблизу: {place}", "{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})", "tiles unavailable": "тайли недоступні", - "zoom in to load this layer": "наблизьте, щоб завантажити цей шар", + "zoom in to load this layer": "наблизьте карту, щоб завантажити цей шар", "Zoom to layer": "Наблизити до шару", "Zoom to layer · fly to where this layer has data": "Наблизити до шару · перелетіти туди, де цей шар має дані", - "Remove from Data Layers": "Вилучити з «Шарів даних»", - "Remove from Data Layers · add it again from the library": "Вилучити з «Шарів даних» · можна знову додати з бібліотеки", + "Remove from Data Layers": "Видалити з «Шарів даних»", + "Remove from Data Layers · add it again from the library": "Видалити з «Шарів даних» · можна знову додати з бібліотеки", "Coverage outline": "Контур покриття", "DRAPED": "НАКЛАДЕНО", "Below 250 m": "Нижче 250 м", @@ -3691,7 +3695,7 @@ "DATASET": "НАБІР ДАНИХ", "TILES": "ТАЙЛИ", "PACK": "ПАКЕТ", - "INCUBATING": "НА ІНКУБАЦІЇ", + "INCUBATING": "НА ВИПРОБУВАННІ", "COMMUNITY": "СПІЛЬНОТА", "VERIFIED": "ПЕРЕВІРЕНО", "ARCHIVED": "В АРХІВІ", @@ -3724,58 +3728,65 @@ "other": "{count} на карті" }, "none yet": "поки жодного", - "Map features": "Об’єкти карти", + "Map features": "Обʼєкти карти", "from {count} datasets": { "one": "з {count} набору даних", "few": "з {count} наборів даних", "many": "з {count} наборів даних", "other": "з {count} набору даних" }, - "switch on a dataset to load features": "увімкніть набір даних, щоб завантажити об’єкти", + "switch on a dataset to load features": "увімкніть набір даних, щоб завантажити обʼєкти", "Switched on by this instance · part of the CCTV layer": "Увімкнено цим екземпляром · частина шару CCTV", "Operator opt-in: set {env}=1 on the server": "Увімкнення оператором: задайте {env}=1 на сервері", "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "Увімкнення оператором: додайте {id} до GEV_LIBRARY_ENABLE на сервері", "Turned off by this instance's operator": "Вимкнено оператором цього екземпляра", "Needs a provider key on the server": "Потрібен ключ постачальника на сервері", "Needs this app's server · not available in a static build": "Потрібен сервер цього застосунку · недоступно в статичній збірці", - "Checking what this instance serves…": "Перевірка, що надає цей екземпляр…", - "Regional": "Регіональні", + "Checking what this instance serves…": "Перевірка того, що надає цей екземпляр…", + "Regional": "Регіонально", "Orthophoto": "Ортофото", "Satellite mosaic": "Супутникова мозаїка", "Oblique": "Перспективна зйомка", "Historic imagery": "Історичні знімки", - "True orthophoto": "Справжнє ортофото", + "True orthophoto": "Істинне ортофото", "Type": "Тип", - "Resolution": "Роздільність", + "Resolution": "Роздільна здатність", "Flown": "Зйомка", "Updates": "Оновлення", "Ortho quality": "Якість орто", "DATA LIBRARY": "БІБЛІОТЕКА ДАНИХ", "Close data library": "Закрити бібліотеку даних", - "Data Library at a glance": "Бібліотека даних коротко", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Живі джерела поза основними шарами. Кожне завантажується від свого постачальника, поки увімкнене; цей застосунок нічого не зберігає. Додайте джерело, і воно з’явиться в «Шарах даних» у розділі «Бібліотека».", + "Data Library at a glance": "Огляд бібліотеки даних", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "Джерела даних у реальному часі поза основними шарами. Кожне завантажується від свого постачальника, поки увімкнене; цей застосунок нічого не зберігає. Додайте джерело, і воно зʼявиться в «Шарах даних» у розділі «Бібліотека».", "Name, provider or country": "Назва, постачальник або країна", "Theme": "Тема", "All themes": "Усі теми", "Only what this instance serves": "Лише те, що надає цей екземпляр", - "OPT-IN": "ЗА ЗГОДОЮ", + "OPT-IN": "ВМИКАЄ ОПЕРАТОР", "KEY": "КЛЮЧ", "KEYLESS": "БЕЗ КЛЮЧА", "ZOOM": "НАБЛИЗИТИ", "Zoom to {name}": "Наблизити до {name}", "WORKING…": "ОБРОБКА…", "ADD": "ДОДАТИ", - "Remove {name} from Data Layers": "Вилучити {name} з «Шарів даних»", + "Remove {name} from Data Layers": "Видалити {name} з «Шарів даних»", "Add {name} to Data Layers": "Додати {name} до «Шарів даних»", "CONFIRM · REMOVE ALL {count}": { - "one": "ПІДТВЕРДИТИ · ВИЛУЧИТИ {count}", - "few": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}", - "many": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}", - "other": "ПІДТВЕРДИТИ · ВИЛУЧИТИ ВСІ {count}" + "one": "ПІДТВЕРДИТИ · ВИДАЛИТИ {count}", + "few": "ПІДТВЕРДИТИ · ВИДАЛИТИ ВСІ {count}", + "many": "ПІДТВЕРДИТИ · ВИДАЛИТИ ВСІ {count}", + "other": "ПІДТВЕРДИТИ · ВИДАЛИТИ ВСІ {count}" }, - "REMOVE ALL": "ВИЛУЧИТИ ВСЕ", - "Confirm: remove every added entry from Data Layers": "Підтвердити: вилучити всі додані записи з «Шарів даних»", - "Remove every added entry from Data Layers": "Вилучити всі додані записи з «Шарів даних»", + "REMOVE ALL": "ВИДАЛИТИ ВСЕ", + "Confirm: remove every added entry from Data Layers": "Підтвердити: видалити всі додані записи з «Шарів даних»", + "Remove every added entry from Data Layers": "Видалити всі додані записи з «Шарів даних»", "Showing {shown} of {total}": "Показано {shown} з {total}", - "BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ" + "BROWSE DATA LIBRARY": "ВІДКРИТИ БІБЛІОТЕКУ ДАНИХ", + "The globe stopped drawing": "Глобус перестав відмальовуватися", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.", + "Reset view reloads the app without the view restored from the address bar.": "«Скинути вигляд» перезавантажує застосунок без вигляду, відновленого з адресного рядка.", + "Reload": "Перезавантажити", + "Retry": "Повторити", + "Reset view": "Скинути вигляд" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index da34f1c26..f34c36028 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -10,12 +10,12 @@ "Disputed: {name}": "争议地区:{name}", "Video": "视频", "Photos": "照片", - "Claimed by {who}": "{who}声称", + "Claimed by {who}": "{who} 声称", "Claimed": "声称", "Not yet confirmed by others": "尚未获他方证实", - "Reported by {side} sources": "{side}方来源报道", + "Reported by {side} sources": "报道来源:{side}", "Reported by {sides}": "报道来源:{sides}", - "{side} sources": "{side}方来源", + "{side} sources": "{side}", "Official": "官方", "Reported by both sides": "双方均有报道", "Independently reported": "有独立来源报道", @@ -23,7 +23,7 @@ "International": "国际", "Independent": "独立", "Unattributed": "未注明来源", - "Outside party": "外部方", + "Outside party": "第三方", "Ukraine–Russia": "乌克兰–俄罗斯", "Israel–Gaza–Lebanon": "以色列–加沙–黎巴嫩", "Iran–Hormuz": "伊朗–霍尔木兹", @@ -36,17 +36,17 @@ "Air defence": "防空", "Ground fighting": "地面战斗", "Casualties": "伤亡", - "Humanitarian": "人道", + "Humanitarian": "人道主义", "Disaster": "灾害", "Protest": "抗议", "Political": "政治", "No physical event": "无实际事件", "Minor incident": "轻微事件", - "Significant": "重大", - "Major": "严重", + "Significant": "较大", + "Major": "重大", "age|now": "刚刚", - "{minutes} min": "{minutes} 分钟", - "{hours} h": "{hours} 小时", + "{minutes} min": "{minutes} 分钟前", + "{hours} h": "{hours} 小时前", "{count} sources": { "other": "{count} 个来源" }, @@ -78,10 +78,10 @@ "Load older news": "加载更早的新闻", "How to read the map": "如何解读地图", "Colour: severity · icon: type of event": "颜色:严重程度 · 图标:事件类型", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "实线边框:双方或独立来源均有报道。虚线边框:一方声称。细边框:单一来源,或一方自身的媒体。", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:来源越多 · 越淡:越早。图片卡片:有两个或以上来源的严重或快速发展的事件。", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "实线边框:双方均有报道,或有独立来源报道。虚线边框:一方声称。细边框:单一来源,或一方自身的媒体。", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:来源越多 · 越淡:越早。图片卡片:有两个或以上来源的重大或快速发展的事件。", "Side labels describe the source, not whether a report is true.": "阵营标签描述的是来源,而非报道是否属实。", - "Exit cockpit to fly to a news report": "退出驾驶舱以飞往新闻报道", + "Exit cockpit to fly to a news report": "请先退出座舱,再飞往新闻报道地点", "News items in the feed": "列表中的新闻数", "Pick a shape, then click the map.": "先选择形状,再在地图上点击。", "Enter to place the pin, Esc to cancel.": "按 Enter 放置图钉,按 Esc 取消。", @@ -598,7 +598,7 @@ "FOV {value}° (DATASHEET)": "FOV {value}°(规格书)", "FOV {value}°": "FOV {value}°", "SPEC FOV {span}° (CAPABILITY)": "规格 FOV {span}°(可调范围)", - "COVERAGE {value}km²": "覆盖 {value}km²", + "COVERAGE {value}km²": "覆盖 {value} km²", "OVERLAP {count} cams": "重叠 {count} 个摄像头", "ISOLATED VIEW": "独立视角", "PROJ MONITOR": "投影:监视器", @@ -616,8 +616,8 @@ "Hurricane": "飓风", "Tropical storm": "热带风暴", "Tropical depression": "热带低压", - "Subtropical storm": "亚热带风暴", - "Subtropical depression": "亚热带低压", + "Subtropical storm": "副热带风暴", + "Subtropical depression": "副热带低压", "Track and cone match this advisory": "路径和预报不确定范围与本期公告一致", "Track/cone awaiting advisory {number}": "路径/预报不确定范围等待第 {number} 号公告", "Track/cone unavailable": "路径/预报不确定范围不可用", @@ -808,7 +808,7 @@ "Low-accuracy score 2–10%": "低精度评分 2–10%", "Low-accuracy score > 10%": "低精度评分 > 10%", "GNSS integrity source unavailable": "GNSS 完好性数据源不可用", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "区域采样 · 半径 250 海里 · {minutes} 分钟内已访问视图中的网格", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "区域采样 · 半径 250 海里 · {minutes} 分钟内浏览过的视野中的网格", "Retrying automatically at {time} UTC": "将于 {time} UTC 自动重试", "Retrying automatically after provider cooldown": "服务商冷却结束后自动重试", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或缩放后以及每分钟自动更新。公共数据源覆盖以视野中心为圆心、半径 250 海里的范围;缩小地图并不会使其成为全球快照。空白网格位于采样覆盖范围之外,或观测数据不足。", @@ -866,18 +866,18 @@ "{value} KM/S": "{value} km/s", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "偏离天底 {angle}° · {offset}", - "SITES STANDBY": "地点待机", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "启用已测绘设施以接收地点提醒", - "Release the camera from {name}": "让镜头离开{name}", + "SITES STANDBY": "设施待机", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "启用地图标注设施以接收设施提醒", + "Release the camera from {name}": "让镜头离开 {name}", "RETURN · {name}": "返回 · {name}", "PREVIOUS SATELLITE": "上一颗卫星", - "Return to {name}": "返回{name}", + "Return to {name}": "返回 {name}", "SATELLITE": "卫星", - "Ride {name}": "乘坐{name}", - "Unnamed site": "未命名地点", + "Ride {name}": "乘坐 {name}", + "Unnamed site": "未命名设施", "OFF-NADIR {angle}°": "偏离天底 {angle}°", "FOCUS · {name}": "聚焦 · {name}", - "Keep the camera on {name}": "让镜头持续对准{name}", + "Keep the camera on {name}": "让镜头持续对准 {name}", "ZOOM {zoom}×": "变焦 {zoom}×", "SWATH {value} KM": "幅宽 {value} km", "SWATH ≈ {value} KM": "幅宽 ≈ {value} km", @@ -885,10 +885,10 @@ "ORBITAL VIEW · NADIR": "轨道视图 · 天底", "ORBITAL VELOCITY": "轨道速度", "SUB-SATELLITE POINT": "星下点", - "NORTH UP": "北向上", - "CURRENTLY OVER": "当前上空", - "{division}, {country}": "{division},{country}", - "{region} · near {place}": "{region} · {place}附近", + "NORTH UP": "正北朝上", + "CURRENTLY OVER": "当前飞越", + "{division}, {country}": "{country} {division}", + "{region} · near {place}": "{region} · {place} 附近", "NORAD {id}": "NORAD {id}", "EPOCH {time}": "历元 {time}", "TLE": "TLE", @@ -1173,7 +1173,7 @@ "Moving dots": "移动圆点", "TomTom key unavailable": "TomTom 密钥不可用", "TomTom daily budget reached": "已达 TomTom 每日配额", - "Monthly traffic allowance used": "本月交通流量额度已用完", + "Monthly traffic allowance used": "本月交通数据配额已用完", "Traffic rate limit reached": "已达到交通数据请求频率上限", "Traffic service unavailable": "交通服务不可用", "TomTom upstream unreachable": "TomTom 上游服务无法连接", @@ -1229,6 +1229,8 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", + "AID TO NAVIGATION": "助航标志", + "VIRTUAL": "虚拟", "{speed}KT": "{speed} kn", "{heading}DEG": "{heading}°", "POS: LIVE": "POS:实时", @@ -1606,7 +1608,7 @@ "Link copied!": "链接已复制!", "Link shared": "链接已分享", "This share link can't be opened": "无法打开此分享链接", - "Shared vessel is unavailable": "分享的船只不可用", + "Shared vessel is unavailable": "分享的船舶不可用", "Copy failed": "复制失败", "Return map to straight-down view": "将地图恢复为垂直俯视", "Tilt map to oblique view": "将地图倾斜至斜视角", @@ -1676,7 +1678,7 @@ "other": "已加载 {count} 个摄像头 · 启用 CCTV 以激活" }, "{count} more cameras outside this view · pan or zoom out": { - "other": "此视图外还有 {count} 个摄像头 · 平移或缩小" + "other": "视野外还有 {count} 个摄像头 · 请平移或缩小地图" }, "Enable CCTV to load camera intersections": "启用 CCTV 以加载路口摄像头", "Enable CCTV to start camera-linked intelligence summaries.": "启用 CCTV 以开始生成与摄像头关联的情报摘要。", @@ -1716,7 +1718,7 @@ "{scope} · INSTALLATIONS {coverage}": "{scope} · 设施 {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "军用数据源已关闭 · 态势不完整", "FLIGHTS FEED OFF · PARTIAL PICTURE": "航班数据源已关闭 · 态势不完整", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器信息不完整 · 不代表安全无虞", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近飞机信息不完整 · 不代表安全无虞", "CONTEXT STANDBY": "态势待机", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "启用全局态势以接收邻近目标提醒", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目标丢失 · 最后已知读数 · 不代表安全无虞", @@ -1750,7 +1752,7 @@ "ROUTE DATA UNAVAILABLE": "航线数据不可用", "CONTACT NAVIGATION OFF": "目标导航已关闭", "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "驾驶台保持在所选船舶上", - "{range} NM": "{range}海里", + "{range} NM": "{range} 海里", "AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船员申报", "DEST {bearing}": "目的地 {bearing}", "Disable cockpit weather effects": "停用座舱天气效果", @@ -1870,7 +1872,7 @@ "GNSS Integrity": "GNSS 完好性", "Rain radar": "降雨雷达", "Lightning density": "闪电密度", - "Cyclone advisories": "气旋预警", + "Cyclone advisories": "气旋公告", "ALPR Cameras": "ALPR 摄像头", "Live Vessels": "实时船舶", "Bike Share": "共享单车", @@ -2073,8 +2075,8 @@ "coverage|partial": "部分", "unknown": "未知", "no imagery": "无影像", - "{percent}% cloud": "{percent}% 云量", - "{min}–{max}% cloud": "{min}–{max}% 云量", + "{percent}% cloud": "云量 {percent}%", + "{min}–{max}% cloud": "云量 {min}–{max}%", "NO BOX": "无框选", "SEARCHING": "搜索中", "{count} DAYS": { @@ -2629,7 +2631,7 @@ "Contact cockpit summary": "目标座舱摘要", "CONTACT": "目标", "CONTACTS · 250 KM": "目标 · 250 km", - "RINGS {spacing} NM": "距标圈 {spacing}海里", + "RINGS {spacing} NM": "距标圈 {spacing} 海里", "{count} CONTACTS": { "other": "{count} 个目标" }, @@ -2718,7 +2720,7 @@ "WEATHER": "天气", "Active weather products": "已启用的天气产品", "RECENT IMAGERY": "近期影像", - "Imagery days in the box": "视野内的影像天数", + "Imagery days in the box": "框选区域内的影像天数", "CONTEXT": "态势", "Context mode": "态势模式", "CONTACTS": "目标", @@ -3078,7 +3080,7 @@ "Left panels": "左侧面板", "Right panels": "右侧面板", "UTILITIES": "工具", - "Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置", + "Exit cockpit to fly to a drawing": "请先退出座舱,再飞往已绘制的形状", "Directions, drawing and your own files": "路线导航、绘制和你自己的文件", "Voice place search (needs an Overpass server)": "语音地点搜索(需要 Overpass 服务器)", "OSM Places": "OSM 地点", @@ -3214,11 +3216,11 @@ "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服务商账户余额不足。请检查账户的计费信息,或在设置中选择其他 AI 服务商。", "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小时的新闻。每条报道都链接到其发布者;地点和类别由文本和数据自动读取,可能有误。阵营标签(如有显示)描述的是来源,而非报道是否属实。", "Economy": "经济", - "News from {area}": "{area}新闻", - "Within {km} km of the view centre": "视图中心 {km} 公里范围内", - "{region}, {country}": "{country}{region}", + "News from {area}": "{area} 新闻", + "Within {km} km of the view centre": "视野中心 {km} km 范围内", + "{region}, {country}": "{country} {region}", "via {source}": "来自 {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "新闻图层的本地:视野中国家的媒体当天的新闻", + "Local in the News layer: the day’s news from the outlets of the country in view": "新闻图层的“本地”分区:当前视野所在国家的媒体当天的新闻", "{provider} refused its key": "{provider} 拒绝了密钥", "Check {envVars} in Provider Settings": "请在服务商设置中检查 {envVars}", "{provider} key required": "需要 {provider} 密钥", @@ -3228,16 +3230,16 @@ "Precise conflict points in the News layer, beside GDELT’s articles": "新闻图层中精确的冲突地点,与 GDELT 的文章并列", "Worldwide": "全球", "Shipwrecks": "沉船", - "Before 1900": "1900年以前", - "After 1945": "1945年以后", + "Before 1900": "1900 年以前", + "After 1945": "1945 年以后", "Date unknown": "日期不详", "Position: centre of the protected area": "位置:保护区中心", - "Position approximate (within about {km} km)": "大致位置(误差约 {km} 公里)", - "Position surveyed (within about {m} m)": "实测位置(误差约 {m} 米)", - "Vessel: {value}": "船只:{value}", + "Position approximate (within about {km} km)": "大致位置(误差约 {km} km)", + "Position surveyed (within about {m} m)": "实测位置(误差约 {m} m)", + "Vessel: {value}": "船型:{value}", "What happened: {value}": "经过:{value}", - "Depth: {m} m ({quality})": "深度:{m} 米({quality})", - "Depth: {m} m": "深度:{m} 米", + "Depth: {m} m ({quality})": "深度:{m} m({quality})", + "Depth: {m} m": "深度:{m} m", "Notes: {value}": "备注:{value}", "Cargo or finds: {value}": "货物或出水文物:{value}", "Protected wreck": "受保护沉船", @@ -3253,8 +3255,8 @@ }, "1900–1945": "1900–1945", "Shipwreck": "沉船", - "Fatalities: {count}": "遇难:{count}", - "Survivors: {count}": "幸存者:{count}", + "Fatalities: {count}": "遇难人数:{count}", + "Survivors: {count}": "幸存人数:{count}", "Click the card to open the article ↗": "点击卡片打开文章 ↗", "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "欧洲历史沉船来自 EMODnet Human Activities;全球知名沉船来自 Wikidata。颜色表示船只沉没的时期,标记大小随遇难人数增加。点击标记查看卡片。", "All years": "所有年份", @@ -3264,12 +3266,12 @@ "First year of the shipwreck period": "沉船时段的起始年份", "Last year of the shipwreck period": "沉船时段的结束年份", "SIGHTING": "目击", - "Show {name} sighting": "显示{name}目击", + "Show {name} sighting": "显示 {name} 的目击记录", "seen {age} ago": "{age}前目击", - "observed {age} ago": "{age}前观察", + "observed {age} ago": "{age}前观察到", "Be Whale Wise": "负责任地观鲸", - "Reported by {name} ({entity})": "报告方 {name}({entity})", - "Reported by {name}": "报告方 {name}", + "Reported by {name} ({entity})": "报告方:{name}({entity})", + "Reported by {name}": "报告方:{name}", "Salish Sea": "萨利希海", "Birds": "鸟类", "Mammals": "哺乳类", @@ -3282,18 +3284,18 @@ "Fungi": "真菌", "Other animals": "其他动物", "Other or unknown": "其他或未知", - "{label} {detail} — click to retry": "{label} {detail} — 点击重试", + "{label} {detail} — click to retry": "{label} {detail}——点击重试", "Wildlife sightings source unavailable": "野生生物目击来源不可用", "Show only community-confirmed (research-grade) sightings": "仅显示社区确认(研究级)的目击", "No recent sightings in view": "视野内无近期目击", - "Acartia feed unavailable": "Acartia数据源不可用", + "Acartia feed unavailable": "Acartia 数据源不可用", "Show all taxa": "显示所有分类群", "BIRDS": "鸟类", "Show only birds": "仅显示鸟类", "MAMMALS": "哺乳类", "Show only mammals": "仅显示哺乳类", "WHALES": "鲸类", - "Show only whales and dolphins (7 days)": "仅显示鲸和海豚(7天)", + "Show only whales and dolphins (7 days)": "仅显示鲸和海豚(7 天)", "INSECTS": "昆虫", "Show only insects": "仅显示昆虫", "PLANTS": "植物", @@ -3301,31 +3303,31 @@ "All rights reserved": "保留所有权利", "Cetacean": "鲸类", "Wildlife Sightings": "野生生物目击", - "Exit cockpit to fly to a sighting": "退出驾驶舱以飞往目击地点", - "Exit cockpit to fly to a sighting area": "退出驾驶舱以飞往目击区域", + "Exit cockpit to fly to a sighting": "请先退出座舱,再飞往目击地点", + "Exit cockpit to fly to a sighting area": "请先退出座舱,再飞往目击区域", "Wildlife": "野生生物", "Zoom in for local sightings": "放大查看当地目击", "Loading photo...": "正在加载照片…", "Photo unavailable": "照片不可用", - "Location obscured to a 0.2° cell": "位置已模糊至0.2°网格", - "Photo of {name}": "{name}的照片", + "Location obscured to a 0.2° cell": "位置已模糊至 0.2° 网格", + "Photo of {name}": "{name} 的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "视野内有 {count} 次目击", "Close sighting": "关闭目击记录", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近48小时内观察到的野生生物(鲸类为7天)。颜色与徽章图标表示分类群。敏感物种仅以0.2°网格显示;Acartia提供的萨利希海海洋哺乳动物目击位置四舍五入到0.01°。并非种群普查。", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近 48 小时内观察到的野生生物(鲸类为 7 天)。颜色与徽章图标表示分类群。敏感物种仅以 0.2° 网格显示;Acartia 提供的萨利希海海洋哺乳动物目击位置四舍五入到 0.01°。并非种群普查。", "Open {name} on iNaturalist": "在 iNaturalist 上打开 {name}", "Open {name} on Acartia": "在 Acartia 上打开 {name}", "iNaturalist": "iNaturalist", "iNaturalist contributors · licences per record": "iNaturalist 贡献者 · 各记录单独授权", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海) · 各记录单独授权", - "{days}d": "{days}天", + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 贡献者 · Acartia {count}(萨利希海)· 各记录单独授权", + "{days}d": "{days} 天", "All markers": "所有标记", "Show markers of every age": "显示所有时间的标记", - "Maximum event age: {days}d; undated markers are kept": "事件最长保留时间:{days}天;保留无日期标记", + "Maximum event age: {days}d; undated markers are kept": "仅显示 {days} 天内的事件;保留无日期标记", "event age filter|All": "全部", "Event age": "事件时间", - "Event age: {days}d · undated kept": "事件时间:{days}天内 · 保留无日期标记", + "Event age: {days}d · undated kept": "事件时间:{days} 天内 · 保留无日期标记", "Close camera preview": "关闭摄像头预览", "Refresh map still": "刷新地图静止画面", "The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。", @@ -3333,12 +3335,12 @@ "Press OPEN to load the photo": "点击“打开”加载照片", "Captured {month}": "拍摄于 {month}", "this spot": "此处", - "the view centre": "视图中心", + "the view centre": "视野中心", "{source} at {place}": "{place} 的 {source}", "Street level at {place}": "{place} 的街景", "Switching map": "正在切换地图", "Opening imagery": "正在打开影像", - "Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载", + "Street View is ready: press {open} to load it": "街景已就绪:点击“{open}”加载", "{source} cannot open this link's street-level photo without a working key": "没有有效密钥,{source} 无法打开此链接的街景照片", "Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。", "STATIONS": "空间站", @@ -3352,7 +3354,7 @@ "PROBES": "探测器", "Space science, technology demonstration and other payloads": "空间科学、技术验证及其他载荷", "DEBRIS": "碎片", - "Rocket bodies, fragments and dead satellites": "火箭残骸、碎片和失效卫星", + "Rocket bodies, fragments and dead satellites": "火箭箭体、碎片和失效卫星", "Classes may lag: SATCAT is a cached copy": "分类可能滞后:SATCAT 为缓存副本", "Classes may lag: SATCAT unavailable": "分类可能滞后:SATCAT 不可用", "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 给出每个物体的类型和运行状态。没有最新副本时,分类仅依据 UCS 数据库、名称和 CelesTrak 分组,因此此后已失效的卫星可能仍显示为在轨工作。", @@ -3362,17 +3364,17 @@ "SENSOR SWATH ≈ {value} KM": "传感器幅宽 ≈ {value} km", "SENSOR": "传感器", "Zoom to the imaging sensor swath": "缩放到成像传感器幅宽", - "<1 H OLD": "不到1小时前", - "{value} H OLD": "{value}小时前", - "{value} D OLD": "{value}天前", + "<1 H OLD": "不到 1 小时前", + "{value} H OLD": "{value} 小时前", + "{value} D OLD": "{value} 天前", "FWD": "前方", "AFT": "后方", "RIGHT": "右", "LEFT": "左", - "TRACK UP": "航迹向上", + "TRACK UP": "航迹朝上", "COURSE HOLD · AIS": "对地航向保持 · AIS", - "Zoom in (+)": "放大 (+)", - "Zoom out (−)": "缩小 (−)", + "Zoom in (+)": "放大(+)", + "Zoom out (−)": "缩小(−)", "ORBITAL VIEW · OFF-NADIR": "轨道视图 · 偏离天底", "World time": "世界时间", "Back to live": "返回实时", @@ -3399,14 +3401,16 @@ "(+{minutes} min late)": "(晚点 {minutes} 分钟)", "Exit cockpit to fly to a train": "请先退出座舱,再飞往列车", "Photo © {photographer}": "照片 © {photographer}", - "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": "共享的军用航班目前没有发送信号——显示其最后出现的位置", - "The shared military flight isn't reporting now — showing where it was at {time}": "共享的军用航班目前没有发送信号——显示其在 {time} 的位置", - "The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前没有发送信号——显示其最后出现的位置", - "The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前没有发送信号——显示其在 {time} 的位置", - "The shared satellite isn't reporting now — showing where it was last seen": "共享的卫星目前没有发送信号——显示其最后出现的位置", - "The shared satellite isn't reporting now — showing where it was at {time}": "共享的卫星目前没有发送信号——显示其在 {time} 的位置", + "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": "分享的军用航班目前无位置更新——显示其最后出现的位置", + "The shared military flight isn't reporting now — showing where it was at {time}": "分享的军用航班目前无位置更新——显示其在 {time} 的位置", + "The shared vessel isn't reporting now — showing where it was last seen": "分享的船舶目前无位置更新——显示其最后出现的位置", + "The shared vessel isn't reporting now — showing where it was at {time}": "分享的船舶目前无位置更新——显示其在 {time} 的位置", + "The shared satellite isn't reporting now — showing where it was last seen": "分享的卫星目前无位置更新——显示其最后出现的位置", + "The shared satellite isn't reporting now — showing where it was at {time}": "分享的卫星目前无位置更新——显示其在 {time} 的位置", "ICAO: {id}": "ICAO:{id}", "NORAD: {id}": "NORAD:{id}", "MMSI: {id}": "MMSI:{id}", @@ -3440,7 +3444,7 @@ "1,000–2,000 m": "1,000–2,000 m", "2,000–3,000 m": "2,000–3,000 m", "3,000 m and above": "3,000 m 及以上", - "Exit cockpit to fly to a layer": "退出驾驶舱后才能飞往图层", + "Exit cockpit to fly to a layer": "请先退出座舱,再飞往图层", "Library": "资源库", "Environment": "环境", "Imagery": "影像", @@ -3475,8 +3479,8 @@ }, "switch on a dataset to load features": "开启一个数据集以加载要素", "Switched on by this instance · part of the CCTV layer": "已由此实例开启 · 属于 CCTV 图层", - "Operator opt-in: set {env}=1 on the server": "运营者启用:在服务器上设置 {env}=1", - "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "运营者启用:在服务器上将 {id} 添加到 GEV_LIBRARY_ENABLE", + "Operator opt-in: set {env}=1 on the server": "需运营者启用:在服务器上设置 {env}=1", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "需运营者启用:在服务器上将 {id} 添加到 GEV_LIBRARY_ENABLE", "Turned off by this instance's operator": "已被此实例的运营者关闭", "Needs a provider key on the server": "需要在服务器上配置提供方密钥", "Needs this app's server · not available in a static build": "需要此应用的服务器 · 静态构建中不可用", @@ -3516,5 +3520,12 @@ "Confirm: remove every added entry from Data Layers": "确认:从数据图层中移除所有已添加条目", "Remove every added entry from Data Layers": "从数据图层中移除所有已添加条目", "Showing {shown} of {total}": "显示 {shown} / {total}", - "BROWSE DATA LIBRARY": "浏览数据资源库" + "BROWSE DATA LIBRARY": "浏览数据资源库", + "The globe stopped drawing": "地球已停止绘制", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。", + "Reset view reloads the app without the view restored from the address bar.": "“重置视图”会重新加载应用,不使用从地址栏恢复的视图。", + "Reload": "重新加载", + "Retry": "重试", + "Reset view": "重置视图" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 52e6b2033..0e01c7773 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1,60 +1,60 @@ { - "News feed unavailable": "新聞來源無法使用", + "News feed unavailable": "新聞源不可用", "Developing: {name}": "事態發展:{name}", "{count} events today": { "other": "今日 {count} 起事件" }, "{count} reports today": { - "other": "今日 {count} 則報導" + "other": "今日 {count} 條報道" }, "Disputed: {name}": "爭議地區:{name}", - "Video": "影片", + "Video": "視頻", "Photos": "照片", - "Claimed by {who}": "{who}聲稱", + "Claimed by {who}": "{who} 聲稱", "Claimed": "聲稱", "Not yet confirmed by others": "尚未獲他方證實", - "Reported by {side} sources": "{side}方來源報導", - "Reported by {sides}": "報導來源:{sides}", - "{side} sources": "{side}方來源", + "Reported by {side} sources": "報道來源:{side}", + "Reported by {sides}": "報道來源:{sides}", + "{side} sources": "{side}", "Official": "官方", - "Reported by both sides": "雙方均有報導", - "Independently reported": "有獨立來源報導", + "Reported by both sides": "雙方均有報道", + "Independently reported": "有獨立來源報道", "Single source": "單一來源", "International": "國際", "Independent": "獨立", "Unattributed": "未註明來源", - "Outside party": "外部方", + "Outside party": "第三方", "Ukraine–Russia": "烏克蘭–俄羅斯", - "Israel–Gaza–Lebanon": "以色列–加薩–黎巴嫩", - "Iran–Hormuz": "伊朗–荷莫茲", - "Yemen–Red Sea": "葉門–紅海", + "Israel–Gaza–Lebanon": "以色列–加沙–黎巴嫩", + "Iran–Hormuz": "伊朗–霍爾木茲", + "Yemen–Red Sea": "也門–紅海", "Sudan": "蘇丹", - "Sahel": "薩赫爾", + "Sahel": "薩赫勒", "Elsewhere": "其他地區", "Strike": "打擊", "Alert": "警報", "Air defence": "防空", "Ground fighting": "地面戰鬥", "Casualties": "傷亡", - "Humanitarian": "人道", + "Humanitarian": "人道主義", "Disaster": "災害", "Protest": "抗議", "Political": "政治", "No physical event": "無實際事件", "Minor incident": "輕微事件", - "Significant": "重大", - "Major": "嚴重", + "Significant": "較大", + "Major": "重大", "age|now": "剛剛", - "{minutes} min": "{minutes} 分鐘", - "{hours} h": "{hours} 小時", + "{minutes} min": "{minutes} 分鐘前", + "{hours} h": "{hours} 小時前", "{count} sources": { "other": "{count} 個來源" }, - "News feed not configured on this server": "此伺服器未設定新聞來源", - "Malformed news hotspots response": "新聞熱點回應格式錯誤", - "Malformed news feed response": "新聞來源回應格式錯誤", + "News feed not configured on this server": "此服務器未配置新聞源", + "Malformed news hotspots response": "新聞熱點響應格式錯誤", + "Malformed news feed response": "新聞源響應格式錯誤", "{place} (placed by model)": "{place}(由模型定位)", - "English headline by the model": "模型產生的英文標題", + "English headline by the model": "模型生成的英文標題", "Not placed on the map": "未在地圖上標出", "Close": "關閉", "Any severity": "任意嚴重程度", @@ -72,16 +72,16 @@ "Hotspots": "熱點", "Severity": "嚴重程度", "Category": "類別", - "Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報導", + "Nothing reported near here in the last {hours} h": "最近 {hours} 小時附近無報道", "Latest news": "最新新聞", "No news matches these filters in the last {hours} h": "最近 {hours} 小時沒有符合這些篩選條件的新聞", - "Load older news": "載入更早的新聞", + "Load older news": "加載更早的新聞", "How to read the map": "如何解讀地圖", - "Colour: severity · icon: type of event": "顏色:嚴重程度 · 圖示:事件類型", - "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "實線邊框:雙方或獨立來源均有報導。虛線邊框:一方聲稱。細邊框:單一來源,或一方自身的媒體。", - "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:來源越多 · 越淡:越早。圖片卡片:有兩個或以上來源的嚴重或快速發展的事件。", - "Side labels describe the source, not whether a report is true.": "陣營標籤描述的是來源,而非報導是否屬實。", - "Exit cockpit to fly to a news report": "退出駕駛艙以飛往新聞報導", + "Colour: severity · icon: type of event": "顏色:嚴重程度 · 圖標:事件類型", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "實線邊框:雙方均有報道,或有獨立來源報道。虛線邊框:一方聲稱。細邊框:單一來源,或一方自身的媒體。", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "越大:來源越多 · 越淡:越早。圖片卡片:有兩個或以上來源的重大或快速發展的事件。", + "Side labels describe the source, not whether a report is true.": "陣營標籤描述的是來源,而非報道是否屬實。", + "Exit cockpit to fly to a news report": "請先退出座艙,再飛往新聞報道地點", "News items in the feed": "列表中的新聞數", "Pick a shape, then click the map.": "先選擇形狀,再在地圖上點擊。", "Enter to place the pin, Esc to cancel.": "按 Enter 放置圖釘,按 Esc 取消。", @@ -407,7 +407,7 @@ "LOADING": "加載中", "DEGRADED": "降級", "STALE": "過期", - "SIGNAL LOST · {time}": "訊號遺失 · {time}", + "SIGNAL LOST · {time}": "信號丟失 · {time}", "PARTIAL": "部分", "FALLBACK": "備用", "view band|STREET": "街道", @@ -504,7 +504,7 @@ "Real live traffic (keyless runs a simulation)": "真實實時路況(無密鑰時為模擬)", "Bing imagery map stacks + world terrain": "Bing 影像地圖組合 + 全球地形", "More flight-polling credits (anonymous works without)": "更多航班輪詢額度(無密鑰也可匿名使用)", - "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制檯註冊一個應用,並粘貼其 Client Token", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "街景影像與街景圖層中的覆蓋範圍。免費:在 Mapillary 開發者控制台註冊一個應用,並粘貼其 Client Token", "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "公開的太空任務數據無需密鑰。常規使用請留空;付費令牌只會提高請求上限。", "Air quality request failed": "空氣質量請求失敗", "No readings available": "暫無讀數", @@ -598,7 +598,7 @@ "FOV {value}° (DATASHEET)": "FOV {value}°(規格書)", "FOV {value}°": "FOV {value}°", "SPEC FOV {span}° (CAPABILITY)": "規格 FOV {span}°(可調範圍)", - "COVERAGE {value}km²": "覆蓋 {value}km²", + "COVERAGE {value}km²": "覆蓋 {value} km²", "OVERLAP {count} cams": "重疊 {count} 個攝像頭", "ISOLATED VIEW": "獨立視角", "PROJ MONITOR": "投影:監視器", @@ -615,8 +615,8 @@ "Hurricane": "颶風", "Tropical storm": "熱帶風暴", "Tropical depression": "熱帶低壓", - "Subtropical storm": "亞熱帶風暴", - "Subtropical depression": "亞熱帶低壓", + "Subtropical storm": "副熱帶風暴", + "Subtropical depression": "副熱帶低壓", "Track and cone match this advisory": "路徑和預報不確定範圍與本期公告一致", "Track/cone awaiting advisory {number}": "路徑/預報不確定範圍等待第 {number} 號公告", "Track/cone unavailable": "路徑/預報不確定範圍不可用", @@ -803,7 +803,7 @@ "Low-accuracy score 2–10%": "低精度評分 2–10%", "Low-accuracy score > 10%": "低精度評分 > 10%", "GNSS integrity source unavailable": "GNSS 完好性數據源不可用", - "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "區域採樣 · 半徑 250 海里 · {minutes} 分鐘內已訪問視圖中的網格", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "區域採樣 · 半徑 250 海里 · {minutes} 分鐘內瀏覽過的視野中的網格", "Retrying automatically at {time} UTC": "將於 {time} UTC 自動重試", "Retrying automatically after provider cooldown": "服務商冷卻結束後自動重試", "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "平移或縮放後以及每分鐘自動更新。公共數據源覆蓋以視野中心為圓心、半徑 250 海里的範圍;縮小地圖並不會使其成為全球快照。空白網格位於採樣覆蓋範圍之外,或觀測數據不足。", @@ -861,18 +861,18 @@ "{value} KM/S": "{value} km/s", "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "偏離天底 {angle}° · {offset}", - "SITES STANDBY": "地點待機", - "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "啟用已測繪設施以接收地點提醒", - "Release the camera from {name}": "讓鏡頭離開{name}", + "SITES STANDBY": "設施待機", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "啟用地圖標註設施以接收設施提醒", + "Release the camera from {name}": "讓鏡頭離開 {name}", "RETURN · {name}": "返回 · {name}", "PREVIOUS SATELLITE": "上一顆衛星", - "Return to {name}": "返回{name}", + "Return to {name}": "返回 {name}", "SATELLITE": "衛星", - "Ride {name}": "乘坐{name}", - "Unnamed site": "未命名地點", + "Ride {name}": "乘坐 {name}", + "Unnamed site": "未命名設施", "OFF-NADIR {angle}°": "偏離天底 {angle}°", "FOCUS · {name}": "聚焦 · {name}", - "Keep the camera on {name}": "讓鏡頭持續對準{name}", + "Keep the camera on {name}": "讓鏡頭持續對準 {name}", "ZOOM {zoom}×": "變焦 {zoom}×", "SWATH {value} KM": "幅寬 {value} km", "SWATH ≈ {value} KM": "幅寬 ≈ {value} km", @@ -880,10 +880,10 @@ "ORBITAL VIEW · NADIR": "軌道視圖 · 天底", "ORBITAL VELOCITY": "軌道速度", "SUB-SATELLITE POINT": "星下點", - "NORTH UP": "北向上", - "CURRENTLY OVER": "目前上空", - "{division}, {country}": "{division},{country}", - "{region} · near {place}": "{region} · {place}附近", + "NORTH UP": "正北朝上", + "CURRENTLY OVER": "當前飛越", + "{division}, {country}": "{country} {division}", + "{region} · near {place}": "{region} · {place} 附近", "NORAD {id}": "NORAD {id}", "EPOCH {time}": "曆元 {time}", "TLE": "TLE", @@ -1163,14 +1163,14 @@ "Hybrid": "混合", "Lines": "線條", "Dots": "圓點", - "Road lines colored by live flow: green to red": "依即時路況著色的道路線條:由綠到紅", - "Moving dots colored by live flow": "依即時路況著色的移動圓點", + "Road lines colored by live flow: green to red": "按實時路況著色的道路線條:從綠到紅", + "Moving dots colored by live flow": "按實時路況著色的移動圓點", "Moving dots": "移動圓點", "TomTom key unavailable": "TomTom 密鑰不可用", "TomTom daily budget reached": "已達 TomTom 每日配額", - "Monthly traffic allowance used": "本月交通流量額度已用完", - "Traffic rate limit reached": "已達交通資料請求頻率上限", - "Traffic service unavailable": "交通服務無法使用", + "Monthly traffic allowance used": "本月交通數據配額已用完", + "Traffic rate limit reached": "已達到交通數據請求頻率上限", + "Traffic service unavailable": "交通服務不可用", "TomTom upstream unreachable": "TomTom 上游服務無法連接", "TomTom flow unavailable": "TomTom 路況不可用", "SIMULATED — {reason}": "模擬——{reason}", @@ -1224,6 +1224,8 @@ "AIS: {name}": "AIS: {name}", "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", "VESSEL": "船舶", + "AID TO NAVIGATION": "助航標誌", + "VIRTUAL": "虛擬", "{speed}KT": "{speed} kn", "{heading}DEG": "{heading}°", "POS: LIVE": "POS:實時", @@ -1668,7 +1670,7 @@ "other": "已加載 {count} 個攝像頭 · 啟用 CCTV 以激活" }, "{count} more cameras outside this view · pan or zoom out": { - "other": "此視圖外還有 {count} 個攝像頭 · 平移或縮小" + "other": "視野外還有 {count} 個攝像頭 · 請平移或縮小地圖" }, "Enable CCTV to load camera intersections": "啟用 CCTV 以加載路口攝像頭", "Enable CCTV to start camera-linked intelligence summaries.": "啟用 CCTV 以開始生成與攝像頭關聯的情報摘要。", @@ -1694,7 +1696,7 @@ "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "每 9 秒自動輪換簡報頁面(信號 → 新聞 → 本地)。懸停或聚焦面板時暫停。無論是否輪換,實時信號數據都會持續刷新。", "ACQUIRING REGIONAL NEWS": "正在獲取地區新聞", "REGIONAL NEWS UNAVAILABLE": "地區新聞不可用", - "CACHED · REFRESH FAILED": "快取 · 重新整理失敗", + "CACHED · REFRESH FAILED": "緩存 · 刷新失敗", "RESOLVING REGION": "正在解析區域", "REGION UNAVAILABLE": "區域不可用", "NO NEWS IN THE LAST {hours} H": "過去 {hours} 小時無新聞", @@ -1702,13 +1704,13 @@ "{value} KM/H": "{value} km/h", "CLOUD {pct}%": "雲量 {pct}%", "CLOUD UNKNOWN": "雲量未知", - "{source} · CACHED {age}": "{source} · 快取 {age}", + "{source} · CACHED {age}": "{source} · 緩存 {age}", "REGIONAL NEWS": "地區新聞", "{label} · {radius} AIR/SEA WINDOW": "{label} · {radius} 空/海監視窗口", "{scope} · INSTALLATIONS {coverage}": "{scope} · 設施 {coverage}", "MILITARY FEED OFF · PARTIAL PICTURE": "軍用數據源已關閉 · 態勢不完整", "FLIGHTS FEED OFF · PARTIAL PICTURE": "航班數據源已關閉 · 態勢不完整", - "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近航空器資訊不完整 · 不代表安全無虞", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "附近飛機信息不完整 · 不代表安全無虞", "CONTEXT STANDBY": "態勢待機", "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "啟用全局態勢以接收鄰近目標提醒", "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "目標丟失 · 最後已知讀數 · 不代表安全無虞", @@ -1741,8 +1743,8 @@ "ARROW · ESTIMATED DIRECTION": "箭頭 · 預估方向", "ROUTE DATA UNAVAILABLE": "航線數據不可用", "CONTACT NAVIGATION OFF": "目標導航已關閉", - "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "駕駛台保持在所選船舶上", - "{range} NM": "{range}海里", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "駕駛臺保持在所選船舶上", + "{range} NM": "{range} 海里", "AIS DESTINATION · CREW REPORTED": "AIS 目的地 · 船員申報", "DEST {bearing}": "目的地 {bearing}", "Disable cockpit weather effects": "停用座艙天氣效果", @@ -1862,7 +1864,7 @@ "GNSS Integrity": "GNSS 完好性", "Rain radar": "降雨雷達", "Lightning density": "閃電密度", - "Cyclone advisories": "氣旋預警", + "Cyclone advisories": "氣旋公告", "ALPR Cameras": "ALPR 攝像頭", "Live Vessels": "實時船舶", "Bike Share": "共享單車", @@ -1887,7 +1889,7 @@ "Events": "事件", "Weather": "天氣", "Utilities": "工具", - "Audio": "音訊", + "Audio": "音頻", "Other layers": "其他圖層", "{state} · {source}": "{state} · {source}", "UNCERTAIN · {source} · lifecycle state requires reconciliation": "不確定 · {source} · 生命週期狀態需要核對", @@ -2063,8 +2065,8 @@ "coverage|partial": "部分", "unknown": "未知", "no imagery": "無影像", - "{percent}% cloud": "{percent}% 雲量", - "{min}–{max}% cloud": "{min}–{max}% 雲量", + "{percent}% cloud": "雲量 {percent}%", + "{min}–{max}% cloud": "雲量 {min}–{max}%", "NO BOX": "無框選", "SEARCHING": "搜索中", "{count} DAYS": { @@ -2576,7 +2578,7 @@ "DONE": "完成", "HERE": "此處", "Aircraft cockpit view": "飛機座艙視圖", - "Ship bridge view": "船舶駕駛台視圖", + "Ship bridge view": "船舶駕駛臺視圖", "Satellite orbital view": "衛星軌道視圖", "LOOK STBD {bearing}°": "視線 右舷 {bearing}°", "LOOK PORT {bearing}°": "視線 左舷 {bearing}°", @@ -2588,7 +2590,7 @@ "LOOK {elevation}": "視線 {elevation}", "Estimated destination direction": "預估目的地方向", "DEST ---°": "目的地 ---°", - "BRIDGE WINDOW · FWD": "駕駛台窗口 · 前方", + "BRIDGE WINDOW · FWD": "駕駛臺窗口 · 前方", "OPTICAL PLANE · 01": "光學平面 · 01", "HEADING HOLD · AIS": "船首向保持 · AIS", "NO HEADING · AIS": "無船首向 · AIS", @@ -2597,7 +2599,7 @@ "SPEED OVER GROUND · KTS": "對地航速 · kn", "ALTITUDE · FT": "高度 · ft", "FIRST PERSON": "第一人稱", - "BRIDGE VIEW": "駕駛台視圖", + "BRIDGE VIEW": "駕駛臺視圖", "LIVE TRACK · COURSE ALIGNED": "實時航跡 · 航向對準", "Cockpit vision style": "座艙視覺樣式", "Previous cockpit vision style": "上一個座艙視覺樣式", @@ -2606,7 +2608,7 @@ "Next cockpit vision style": "下一個座艙視覺樣式", "Next vision style": "下一個視覺樣式", "Current aircraft heading": "飛機當前航向", - "Look ahead: turn the bridge view back to the bow": "看向前方:將駕駛台視圖轉回船首", + "Look ahead: turn the bridge view back to the bow": "看向前方:將駕駛臺視圖轉回船首", "Look ahead (Home or 0)": "看向前方(Home 或 0)", "LOOK AHEAD": "看向前方", "Current vessel heading": "船舶當前船首向", @@ -2619,12 +2621,12 @@ "Contact cockpit summary": "目標座艙摘要", "CONTACT": "目標", "CONTACTS · 250 KM": "目標 · 250 km", - "RINGS {spacing} NM": "距標圈 {spacing}海里", + "RINGS {spacing} NM": "距標圈 {spacing} 海里", "{count} CONTACTS": { "other": "{count} 個目標" }, "Radar range {range}. Select for the next range.": "雷達量程 {range}。選擇以切換到下一量程。", - "Bridge radar": "駕駛台雷達", + "Bridge radar": "駕駛臺雷達", "Contact navigation": "目標導航", "CONTEXT ONLY": "僅供參考", "Nearby cohort counts": "附近各類目標數量", @@ -2680,10 +2682,10 @@ "Exit cockpit and return to full globe view": "退出座艙並返回完整地球視圖", "RESET": "重置", "Exit cockpit view": "退出座艙視圖", - "Exit bridge view": "退出駕駛台視圖", + "Exit bridge view": "退出駕駛臺視圖", "Exit orbital view": "退出軌道視圖", "EXIT COCKPIT": "退出座艙", - "EXIT BRIDGE": "退出駕駛台", + "EXIT BRIDGE": "退出駕駛臺", "EXIT ORBIT": "退出軌道視圖", "Navigation, voice, and visual preset controls": "導航、語音和視覺預設控件", "Expand Visual Presets": "展開視覺預設", @@ -2708,7 +2710,7 @@ "WEATHER": "天氣", "Active weather products": "已啟用的天氣產品", "RECENT IMAGERY": "近期影像", - "Imagery days in the box": "視野內的影像天數", + "Imagery days in the box": "框選區域內的影像天數", "CONTEXT": "態勢", "Context mode": "態勢模式", "CONTACTS": "目標", @@ -2717,7 +2719,7 @@ "SELECT CONTEXT": "選擇態勢", "Contact Context actions": "目標態勢操作", "COCKPIT": "座艙", - "BRIDGE": "駕駛台", + "BRIDGE": "駕駛臺", "SEARCH NEARBY SITES": "搜索附近設施", "Reclassify tracked contact as TR-3B": "將跟蹤目標重新歸類為 TR-3B", "CONTACTS CONTEXT OFF": "目標態勢已關閉", @@ -2726,7 +2728,7 @@ "AVAILABLE MISSIONS": "可用任務", "SELECT A MISSION TO INSPECT": "選擇一項任務以查看", "LOADING 30-DAY MISSION INDEX": "正在加載 30 天任務索引", - "Space Missions unavailable: the mission index could not load. Try again later.": "太空任務無法使用:無法載入任務索引。請稍後再試。", + "Space Missions unavailable: the mission index could not load. Try again later.": "太空任務不可用:無法加載任務索引。請稍後重試。", "30-DAY MISSION INDEX UNAVAILABLE": "30 天任務索引不可用", "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB 預覽 · ENTER / 空格鍵選擇", "Internet radio and local SDR": "網絡電臺與本地 SDR", @@ -2984,7 +2986,7 @@ "Take the exit {direction}": "從出口{direction}駛出", "Link shared": "鏈接已分享", "This share link can't be opened": "無法打開此分享鏈接", - "Shared vessel is unavailable": "分享的船隻不可用", + "Shared vessel is unavailable": "分享的船舶不可用", "Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。", "Press Enter to try again.": "按 Enter 重試。", "Recent speech": "最近語音", @@ -3078,7 +3080,7 @@ "Detection confidence": "探測置信度", "{shown} of {total} shown": "顯示 {shown} / {total}", "UTILITIES": "工具", - "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", + "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往已繪製的形狀", "Directions, drawing and your own files": "路線導航、繪製和你自己的文件", "Voice place search (needs an Overpass server)": "語音地點搜索(需要 Overpass 服務器)", "OSM Places": "OSM 地點", @@ -3212,64 +3214,64 @@ "Too many chat requests. Try again in {seconds} s.": "聊天請求過多。請在 {seconds} 秒後重試。", "The provider refused access for this key. Check the key's project, region or permissions.": "服務商拒絕了此密鑰的訪問請求。請檢查該密鑰所屬的項目、區域或權限。", "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "服務商賬戶餘額不足。請檢查賬戶的計費信息,或在設置中選擇其他 AI 服務商。", - "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小時的新聞。每則報導都連結至其發布者;地點與類別由文字與資料自動判讀,可能有誤。陣營標籤(如有顯示)描述的是來源,而非報導是否屬實。", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "最近 24 小時的新聞。每條報道都鏈接到其發布者;地點和類別由文本和數據自動讀取,可能有誤。陣營標籤(如有顯示)描述的是來源,而非報道是否屬實。", "Economy": "經濟", - "News from {area}": "{area}新聞", - "Within {km} km of the view centre": "視圖中心 {km} 公里範圍內", - "{region}, {country}": "{country}{region}", + "News from {area}": "{area} 新聞", + "Within {km} km of the view centre": "視野中心 {km} km 範圍內", + "{region}, {country}": "{country} {region}", "via {source}": "來自 {source}", - "Local in the News layer: the day’s news from the outlets of the country in view": "新聞圖層的本地:視野中國家的媒體當天的新聞", - "{provider} refused its key": "{provider} 拒絕了金鑰", + "Local in the News layer: the day’s news from the outlets of the country in view": "新聞圖層的“本地”分區:當前視野所在國家的媒體當天的新聞", + "{provider} refused its key": "{provider} 拒絕了密鑰", "Check {envVars} in Provider Settings": "請在服務商設置中檢查 {envVars}", - "{provider} key required": "需要 {provider} 金鑰", + "{provider} key required": "需要 {provider} 密鑰", "{provider} limit reached for today": "{provider} 已達今日上限", - "News resumes tomorrow, or with a paid plan": "新聞將於明天恢復,或升級付費方案", - "News key required": "需要新聞金鑰", + "News resumes tomorrow, or with a paid plan": "新聞將於明天恢復,或升級付費套餐", + "News key required": "需要新聞密鑰", "Precise conflict points in the News layer, beside GDELT’s articles": "新聞圖層中精確的衝突地點,與 GDELT 的文章並列", "Worldwide": "全球", "Shipwrecks": "沉船", - "Before 1900": "1900年以前", - "After 1945": "1945年以後", + "Before 1900": "1900 年以前", + "After 1945": "1945 年以後", "Date unknown": "日期不詳", "Position: centre of the protected area": "位置:保護區中心", - "Position approximate (within about {km} km)": "大致位置(誤差約 {km} 公里)", - "Position surveyed (within about {m} m)": "實測位置(誤差約 {m} 公尺)", - "Vessel: {value}": "船隻:{value}", + "Position approximate (within about {km} km)": "大致位置(誤差約 {km} km)", + "Position surveyed (within about {m} m)": "實測位置(誤差約 {m} m)", + "Vessel: {value}": "船型:{value}", "What happened: {value}": "經過:{value}", - "Depth: {m} m ({quality})": "深度:{m} 公尺({quality})", - "Depth: {m} m": "深度:{m} 公尺", + "Depth: {m} m ({quality})": "深度:{m} m({quality})", + "Depth: {m} m": "深度:{m} m", "Notes: {value}": "備註:{value}", "Cargo or finds: {value}": "貨物或出水文物:{value}", "Protected wreck": "受保護沉船", "Source: {value}": "來源:{value}", - "Click the card to open EMODnet ↗": "點擊卡片開啟 EMODnet ↗", - "Click the card to open the source ↗": "點擊卡片開啟來源 ↗", - "Wikidata unavailable; showing EMODnet only": "Wikidata 無法使用;僅顯示 EMODnet", - "EMODnet unavailable; showing Wikidata only": "EMODnet 無法使用;僅顯示 Wikidata", - "Shipwrecks unavailable": "沉船資料無法使用", + "Click the card to open EMODnet ↗": "點擊卡片打開 EMODnet ↗", + "Click the card to open the source ↗": "點擊卡片打開來源 ↗", + "Wikidata unavailable; showing EMODnet only": "Wikidata 不可用;僅顯示 EMODnet", + "EMODnet unavailable; showing Wikidata only": "EMODnet 不可用;僅顯示 Wikidata", + "Shipwrecks unavailable": "沉船數據不可用", "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet(歐洲海域)· Wikidata(全球)", "{count} wrecks": { "other": "{count} 艘沉船" }, "1900–1945": "1900–1945", "Shipwreck": "沉船", - "Fatalities: {count}": "罹難:{count}", - "Survivors: {count}": "倖存者:{count}", - "Click the card to open the article ↗": "點擊卡片開啟文章 ↗", - "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "歐洲歷史沉船來自 EMODnet Human Activities;全球知名沉船來自 Wikidata。顏色表示船隻沉沒的時期,標記大小隨罹難人數增加。點擊標記查看卡片。", + "Fatalities: {count}": "遇難人數:{count}", + "Survivors: {count}": "倖存人數:{count}", + "Click the card to open the article ↗": "點擊卡片打開文章 ↗", + "European heritage wrecks come from EMODnet Human Activities; notable wrecks worldwide come from Wikidata. Colour shows when the ship sank; marker size grows with deaths. Click a marker for its card.": "歐洲歷史沉船來自 EMODnet Human Activities;全球知名沉船來自 Wikidata。顏色表示船隻沉沒的時期,標記大小隨遇難人數增加。點擊標記查看卡片。", "All years": "所有年份", "Period": "時段", "From": "起", - "To": "迄", + "To": "止", "First year of the shipwreck period": "沉船時段的起始年份", "Last year of the shipwreck period": "沉船時段的結束年份", "SIGHTING": "目擊", - "Show {name} sighting": "顯示{name}目擊", + "Show {name} sighting": "顯示 {name} 的目擊記錄", "seen {age} ago": "{age}前目擊", - "observed {age} ago": "{age}前觀察", - "Be Whale Wise": "負責任地賞鯨", - "Reported by {name} ({entity})": "通報方 {name}({entity})", - "Reported by {name}": "通報方 {name}", + "observed {age} ago": "{age}前觀察到", + "Be Whale Wise": "負責任地觀鯨", + "Reported by {name} ({entity})": "報告方:{name}({entity})", + "Reported by {name}": "報告方:{name}", "Salish Sea": "薩利希海", "Birds": "鳥類", "Mammals": "哺乳類", @@ -3282,18 +3284,18 @@ "Fungi": "真菌", "Other animals": "其他動物", "Other or unknown": "其他或未知", - "{label} {detail} — click to retry": "{label} {detail} — 點擊重試", - "Wildlife sightings source unavailable": "野生生物目擊來源無法使用", - "Show only community-confirmed (research-grade) sightings": "僅顯示社群確認(研究級)的目擊", + "{label} {detail} — click to retry": "{label} {detail}——點擊重試", + "Wildlife sightings source unavailable": "野生生物目擊來源不可用", + "Show only community-confirmed (research-grade) sightings": "僅顯示社區確認(研究級)的目擊", "No recent sightings in view": "視野內無近期目擊", - "Acartia feed unavailable": "Acartia資料來源無法使用", + "Acartia feed unavailable": "Acartia 數據源不可用", "Show all taxa": "顯示所有分類群", "BIRDS": "鳥類", "Show only birds": "僅顯示鳥類", "MAMMALS": "哺乳類", "Show only mammals": "僅顯示哺乳類", "WHALES": "鯨類", - "Show only whales and dolphins (7 days)": "僅顯示鯨和海豚(7天)", + "Show only whales and dolphins (7 days)": "僅顯示鯨和海豚(7 天)", "INSECTS": "昆蟲", "Show only insects": "僅顯示昆蟲", "PLANTS": "植物", @@ -3301,87 +3303,87 @@ "All rights reserved": "保留所有權利", "Cetacean": "鯨類", "Wildlife Sightings": "野生生物目擊", - "Exit cockpit to fly to a sighting": "退出駕駛艙以飛往目擊地點", - "Exit cockpit to fly to a sighting area": "退出駕駛艙以飛往目擊區域", + "Exit cockpit to fly to a sighting": "請先退出座艙,再飛往目擊地點", + "Exit cockpit to fly to a sighting area": "請先退出座艙,再飛往目擊區域", "Wildlife": "野生生物", "Zoom in for local sightings": "放大查看當地目擊", - "Loading photo...": "正在載入照片…", - "Photo unavailable": "照片無法使用", - "Location obscured to a 0.2° cell": "位置已模糊至0.2°網格", - "Photo of {name}": "{name}的照片", + "Loading photo...": "正在加載照片…", + "Photo unavailable": "照片不可用", + "Location obscured to a 0.2° cell": "位置已模糊至 0.2° 網格", + "Photo of {name}": "{name} 的照片", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", "{count} sightings in view": "視野內有 {count} 次目擊", - "Close sighting": "關閉目擊紀錄", - "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近48小時內觀察到的野生生物(鯨類為7天)。顏色與徽章圖示表示分類群。敏感物種僅以0.2°網格顯示;Acartia提供的薩利希海海洋哺乳動物目擊位置四捨五入到0.01°。並非族群普查。", + "Close sighting": "關閉目擊記錄", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "近 48 小時內觀察到的野生生物(鯨類為 7 天)。顏色與徽章圖標表示分類群。敏感物種僅以 0.2° 網格顯示;Acartia 提供的薩利希海海洋哺乳動物目擊位置四捨五入到 0.01°。並非種群普查。", "Open {name} on iNaturalist": "在 iNaturalist 上打開 {name}", "Open {name} on Acartia": "在 Acartia 上打開 {name}", "iNaturalist": "iNaturalist", - "iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各紀錄個別授權", - "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海) · 各紀錄個別授權", - "{days}d": "{days}天", + "iNaturalist contributors · licences per record": "iNaturalist 貢獻者 · 各記錄單獨授權", + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalist 貢獻者 · Acartia {count}(薩利希海)· 各記錄單獨授權", + "{days}d": "{days} 天", "All markers": "所有標記", "Show markers of every age": "顯示所有時間的標記", - "Maximum event age: {days}d; undated markers are kept": "事件最長保留時間:{days}天;保留無日期標記", + "Maximum event age: {days}d; undated markers are kept": "僅顯示 {days} 天內的事件;保留無日期標記", "event age filter|All": "全部", "Event age": "事件時間", - "Event age: {days}d · undated kept": "事件時間:{days}天內 · 保留無日期標記", - "Close camera preview": "關閉攝影機預覽", - "Refresh map still": "重新整理地圖靜態畫面", - "The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。", - "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。", + "Event age: {days}d · undated kept": "事件時間:{days} 天內 · 保留無日期標記", + "Close camera preview": "關閉攝像頭預覽", + "Refresh map still": "刷新地圖靜止畫面", + "The map still uses a separate request and may show a different moment.": "地圖靜止畫面通過單獨的請求獲取,可能顯示不同的時刻。", + "Video plays from Québec 511, which sees your IP address.": "視頻從 Québec 511 播放,Québec 511 會看到你的 IP 地址。", "Press OPEN to load the photo": "點擊“打開”加載照片", "Captured {month}": "拍攝於 {month}", "this spot": "此處", - "the view centre": "視圖中心", + "the view centre": "視野中心", "{source} at {place}": "{place} 的 {source}", "Street level at {place}": "{place} 的街景", "Switching map": "正在切換地圖", "Opening imagery": "正在打開影像", - "Street View is ready: press {open} to load it": "街景已就緒:按 {open} 加載", + "Street View is ready: press {open} to load it": "街景已就緒:點擊“{open}”加載", "{source} cannot open this link's street-level photo without a working key": "沒有有效密鑰,{source} 無法打開此鏈接的街景照片", "Street-level photos are hidden in the current view. Leave it first.": "當前視圖隱藏了街景照片。請先退出該視圖。", - "STATIONS": "太空站", - "Crewed stations and the craft that visit them": "載人太空站及來訪飛行器", + "STATIONS": "空間站", + "Crewed stations and the craft that visit them": "載人空間站及來訪飛行器", "IMAGING": "成像", "Earth observation: optical, radar, weather and science": "對地觀測:光學、雷達、氣象與科學", - "TELECOM": "通訊", - "Communications and navigation satellites, Starlink excepted": "通訊與導航衛星(不含 Starlink)", + "TELECOM": "通信", + "Communications and navigation satellites, Starlink excepted": "通信與導航衛星(不含 Starlink)", "STARLINK": "STARLINK", "The Starlink constellation (thousands of extra points)": "Starlink 星座(數千個額外點位)", "PROBES": "探測器", - "Space science, technology demonstration and other payloads": "太空科學、技術驗證及其他酬載", + "Space science, technology demonstration and other payloads": "空間科學、技術驗證及其他載荷", "DEBRIS": "碎片", - "Rocket bodies, fragments and dead satellites": "火箭殘骸、碎片與失效衛星", - "Classes may lag: SATCAT is a cached copy": "分類可能落後:SATCAT 為快取副本", - "Classes may lag: SATCAT unavailable": "分類可能落後:SATCAT 無法取得", - "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 提供每個物體的類型與運作狀態。沒有最新副本時,分類僅依據 UCS 資料庫、名稱與 CelesTrak 群組,因此此後已失效的衛星可能仍顯示為運作中。", + "Rocket bodies, fragments and dead satellites": "火箭箭體、碎片和失效衛星", + "Classes may lag: SATCAT is a cached copy": "分類可能滯後:SATCAT 為緩存副本", + "Classes may lag: SATCAT unavailable": "分類可能滯後:SATCAT 不可用", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTrak SATCAT 給出每個物體的類型和運行狀態。沒有最新副本時,分類僅依據 UCS 數據庫、名稱和 CelesTrak 分組,因此此後已失效的衛星可能仍顯示為在軌工作。", "STARLINK ···": "STARLINK ···", "STARLINK ✕": "STARLINK ✕", - "SENSOR SWATH {value} KM": "感測器幅寬 {value} km", - "SENSOR SWATH ≈ {value} KM": "感測器幅寬 ≈ {value} km", - "SENSOR": "感測器", - "Zoom to the imaging sensor swath": "縮放到成像感測器幅寬", - "<1 H OLD": "不到1小時前", - "{value} H OLD": "{value}小時前", - "{value} D OLD": "{value}天前", + "SENSOR SWATH {value} KM": "傳感器幅寬 {value} km", + "SENSOR SWATH ≈ {value} KM": "傳感器幅寬 ≈ {value} km", + "SENSOR": "傳感器", + "Zoom to the imaging sensor swath": "縮放到成像傳感器幅寬", + "<1 H OLD": "不到 1 小時前", + "{value} H OLD": "{value} 小時前", + "{value} D OLD": "{value} 天前", "FWD": "前方", "AFT": "後方", "RIGHT": "右", "LEFT": "左", - "TRACK UP": "航跡向上", + "TRACK UP": "航跡朝上", "COURSE HOLD · AIS": "對地航向保持 · AIS", - "Zoom in (+)": "放大 (+)", - "Zoom out (−)": "縮小 (−)", + "Zoom in (+)": "放大(+)", + "Zoom out (−)": "縮小(−)", "ORBITAL VIEW · OFF-NADIR": "軌道視圖 · 偏離天底", "World time": "世界時間", - "Back to live": "返回即時", - "Live": "即時", + "Back to live": "返回實時", + "Live": "實時", "1 h/s": "1 小時/秒", "1 d/s": "1 天/秒", "1 wk/s": "1 週/秒", "Play speed": "播放速度", - "Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "滾動或雙指縮放,拖動以移動 · 雙擊查看全部", "1 min/s": "1 分鐘/秒", "Rail": "鐵路", "Urban rail": "城市軌道", @@ -3391,22 +3393,24 @@ "International rail": "國際鐵路", "Hide {category}": "隱藏{category}", "Show {category}": "顯示{category}", - "Live GPS": "即時 GPS", + "Live GPS": "實時 GPS", "Estimated from station reports": "根據車站報告估算", "To {headsign}": "開往 {headsign}", "Next {stop} {time} {relative}": "下一站 {stop} {time} {relative}", "Prev {stop} {time} {relative}": "上一站 {stop} {time} {relative}", - "(+{minutes} min late)": "(誤點 {minutes} 分鐘)", + "(+{minutes} min late)": "(晚點 {minutes} 分鐘)", "Exit cockpit to fly to a train": "請先退出座艙,再飛往列車", "Photo © {photographer}": "照片 © {photographer}", - "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": "共享的軍用航班目前沒有發送信號——顯示其最後出現的位置", - "The shared military flight isn't reporting now — showing where it was at {time}": "共享的軍用航班目前沒有發送信號——顯示其在 {time} 的位置", - "The shared vessel isn't reporting now — showing where it was last seen": "共享的船舶目前沒有發送信號——顯示其最後出現的位置", - "The shared vessel isn't reporting now — showing where it was at {time}": "共享的船舶目前沒有發送信號——顯示其在 {time} 的位置", - "The shared satellite isn't reporting now — showing where it was last seen": "共享的衛星目前沒有發送信號——顯示其最後出現的位置", - "The shared satellite isn't reporting now — showing where it was at {time}": "共享的衛星目前沒有發送信號——顯示其在 {time} 的位置", + "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": "分享的軍用航班目前無位置更新——顯示其最後出現的位置", + "The shared military flight isn't reporting now — showing where it was at {time}": "分享的軍用航班目前無位置更新——顯示其在 {time} 的位置", + "The shared vessel isn't reporting now — showing where it was last seen": "分享的船舶目前無位置更新——顯示其最後出現的位置", + "The shared vessel isn't reporting now — showing where it was at {time}": "分享的船舶目前無位置更新——顯示其在 {time} 的位置", + "The shared satellite isn't reporting now — showing where it was last seen": "分享的衛星目前無位置更新——顯示其最後出現的位置", + "The shared satellite isn't reporting now — showing where it was at {time}": "分享的衛星目前無位置更新——顯示其在 {time} 的位置", "ICAO: {id}": "ICAO:{id}", "NORAD: {id}": "NORAD:{id}", "MMSI: {id}": "MMSI:{id}", @@ -3426,61 +3430,61 @@ "{place} has no position to look for imagery at": "{place} 沒有可查找影像的位置", "No {source} imagery near {place}": "{place} 附近沒有 {source} 影像", "{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})", - "tiles unavailable": "圖磚無法使用", - "zoom in to load this layer": "放大以載入此圖層", + "tiles unavailable": "瓦片不可用", + "zoom in to load this layer": "放大以加載此圖層", "Zoom to layer": "縮放至圖層", - "Zoom to layer · fly to where this layer has data": "縮放至圖層 · 飛往此圖層有資料的區域", + "Zoom to layer · fly to where this layer has data": "縮放至圖層 · 飛往此圖層有數據的區域", "Remove from Data Layers": "從數據圖層中移除", - "Remove from Data Layers · add it again from the library": "從數據圖層中移除 · 可從數據資源庫重新加入", - "Coverage outline": "涵蓋範圍輪廓", + "Remove from Data Layers · add it again from the library": "從數據圖層中移除 · 可從數據資源庫重新添加", + "Coverage outline": "覆蓋範圍輪廓", "DRAPED": "貼地", "Below 250 m": "低於 250 m", "250–500 m": "250–500 m", "500–1,000 m": "500–1,000 m", "1,000–2,000 m": "1,000–2,000 m", "2,000–3,000 m": "2,000–3,000 m", - "3,000 m and above": "3,000 m 以上", - "Exit cockpit to fly to a layer": "離開駕駛艙後才能飛往圖層", + "3,000 m and above": "3,000 m 及以上", + "Exit cockpit to fly to a layer": "請先退出座艙,再飛往圖層", "Library": "資源庫", "Environment": "環境", "Imagery": "影像", "Demographics": "人口統計", - "DATASET": "資料集", - "TILES": "圖磚", - "PACK": "資料包", + "DATASET": "數據集", + "TILES": "瓦片", + "PACK": "數據包", "INCUBATING": "孵化中", - "COMMUNITY": "社群", + "COMMUNITY": "社區", "VERIFIED": "已驗證", - "ARCHIVED": "已封存", - "Datasets": "資料集", + "ARCHIVED": "已歸檔", + "Datasets": "數據集", "{datasets} · {tiles} · {packs}": "{datasets} · {tiles} · {packs}", "{count} datasets": { - "other": "{count} 個資料集" + "other": "{count} 個數據集" }, "{count} tile layers": { - "other": "{count} 個圖磚圖層" + "other": "{count} 個瓦片圖層" }, "{count} packs": { - "other": "{count} 個資料包" + "other": "{count} 個數據包" }, "Themes": "主題", - "Added": "已加入", + "Added": "已添加", "{count} on the map": { "other": "地圖上 {count} 個" }, - "none yet": "尚無", - "Map features": "地圖圖徵", + "none yet": "暫無", + "Map features": "地圖要素", "from {count} datasets": { - "other": "來自 {count} 個資料集" + "other": "來自 {count} 個數據集" }, - "switch on a dataset to load features": "開啟一個資料集以載入圖徵", - "Switched on by this instance · part of the CCTV layer": "已由此執行個體開啟 · 屬於 CCTV 圖層", - "Operator opt-in: set {env}=1 on the server": "營運者啟用:在伺服器上設定 {env}=1", - "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "營運者啟用:在伺服器上將 {id} 加入 GEV_LIBRARY_ENABLE", - "Turned off by this instance's operator": "已被此執行個體的營運者關閉", - "Needs a provider key on the server": "需要在伺服器上設定提供者金鑰", - "Needs this app's server · not available in a static build": "需要此應用程式的伺服器 · 靜態建置中無法使用", - "Checking what this instance serves…": "正在檢查此執行個體提供的內容…", + "switch on a dataset to load features": "開啟一個數據集以加載要素", + "Switched on by this instance · part of the CCTV layer": "已由此實例開啟 · 屬於 CCTV 圖層", + "Operator opt-in: set {env}=1 on the server": "需運營者啟用:在服務器上設置 {env}=1", + "Operator opt-in: add {id} to GEV_LIBRARY_ENABLE on the server": "需運營者啟用:在服務器上將 {id} 添加到 GEV_LIBRARY_ENABLE", + "Turned off by this instance's operator": "已被此實例的運營者關閉", + "Needs a provider key on the server": "需要在服務器上配置提供方密鑰", + "Needs this app's server · not available in a static build": "需要此應用的服務器 · 靜態構建中不可用", + "Checking what this instance serves…": "正在檢查此實例提供的內容…", "Regional": "區域", "Orthophoto": "正射影像", "Satellite mosaic": "衛星鑲嵌圖", @@ -3488,33 +3492,40 @@ "Historic imagery": "歷史影像", "True orthophoto": "真正射影像", "Type": "類型", - "Resolution": "解析度", - "Flown": "航拍時間", + "Resolution": "分辨率", + "Flown": "航攝時間", "Updates": "更新頻率", - "Ortho quality": "正射品質", + "Ortho quality": "正射質量", "DATA LIBRARY": "數據資源庫", "Close data library": "關閉數據資源庫", "Data Library at a glance": "數據資源庫概覽", - "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "核心圖層以外的即時資料來源。每個來源開啟時直接從其提供者取得;本應用程式不儲存任何資料。加入後,它會出現在數據圖層的「資源庫」分組中。", - "Name, provider or country": "名稱、提供者或國家", + "Live sources beyond the core layers. Each one is fetched from its provider while it is on; nothing is stored in this app. Add one and it appears in Data Layers under Library.": "核心圖層之外的實時數據源。每個數據源開啟時直接從其提供方獲取;本應用不存儲任何數據。添加後,它會出現在數據圖層的“資源庫”分組中。", + "Name, provider or country": "名稱、提供方或國家", "Theme": "主題", - "All themes": "所有主題", - "Only what this instance serves": "僅顯示此執行個體提供的內容", + "All themes": "全部主題", + "Only what this instance serves": "僅顯示此實例提供的內容", "OPT-IN": "需啟用", - "KEY": "需金鑰", - "KEYLESS": "免金鑰", + "KEY": "需密鑰", + "KEYLESS": "免密鑰", "ZOOM": "縮放", "Zoom to {name}": "縮放至 {name}", "WORKING…": "處理中…", - "ADD": "加入", + "ADD": "添加", "Remove {name} from Data Layers": "從數據圖層中移除 {name}", - "Add {name} to Data Layers": "將 {name} 加入數據圖層", + "Add {name} to Data Layers": "將 {name} 添加到數據圖層", "CONFIRM · REMOVE ALL {count}": { "other": "確認 · 全部移除 {count} 項" }, "REMOVE ALL": "全部移除", - "Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已加入項目", - "Remove every added entry from Data Layers": "從數據圖層中移除所有已加入項目", + "Confirm: remove every added entry from Data Layers": "確認:從數據圖層中移除所有已添加條目", + "Remove every added entry from Data Layers": "從數據圖層中移除所有已添加條目", "Showing {shown} of {total}": "顯示 {shown} / {total}", - "BROWSE DATA LIBRARY": "瀏覽數據資源庫" + "BROWSE DATA LIBRARY": "瀏覽數據資源庫", + "The globe stopped drawing": "地球已停止繪製", + "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。", + "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。", + "Reset view reloads the app without the view restored from the address bar.": "「重置視圖」會重新加載應用,不使用從地址欄恢復的視圖。", + "Reload": "重新加載", + "Retry": "重試", + "Reset view": "重置視圖" } diff --git a/src/i18n/page.js b/src/i18n/page.js new file mode 100644 index 000000000..5ecf13fe8 --- /dev/null +++ b/src/i18n/page.js @@ -0,0 +1,81 @@ +/** + * @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 ``/`` 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 ``/`` agree from the first frame; the + * page's static `data-i18n` markup (a loading screen) is translated now. + * @param {Document} [doc] + * @param {Parameters[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 ``/`` 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[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'; +} diff --git a/src/i18n/page.test.mjs b/src/i18n/page.test.mjs new file mode 100644 index 000000000..626912daa --- /dev/null +++ b/src/i18n/page.test.mjs @@ -0,0 +1,106 @@ +// 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'); +}); diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index b90685e68..b8a7eeb61 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -247,7 +247,9 @@ "NORAD: {id}", "COMMUNITY", "Regional", - "OPT-IN" + "OPT-IN", + "{minutes} min", + "1 min/s" ], "es": [ "1 min/s", @@ -409,7 +411,8 @@ "MMSI: {id}", "NORAD: {id}", "Regional", - "{datasets} · {tiles} · {packs}" + "{datasets} · {tiles} · {packs}", + "VIRTUAL" ], "fr": [ "1 min/s", @@ -625,11 +628,13 @@ "▲ {frp} MW", "SATELLITE", "Photo © {photographer}", + "Photo · {licence}", "PACK", "{count} packs", "Orthophoto", "Oblique", - "Type" + "Type", + "{range} NM" ], "it": [ "1 min/s", @@ -1063,7 +1068,8 @@ "ICAO: {id}", "MMSI: {id}", "NORAD: {id}", - "Regional" + "Regional", + "VIRTUAL" ], "ru": [ "1900–1945", diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index bd5078499..9d9bf75af 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -182,7 +182,22 @@ export function createPanel({ state: layerState, parts }) { : null; } - /** The tracked airliner's photo with its credit, or null. The subject + /** 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 * refresh resolves it; only the subject it was resolved for shows it. */ function presentPhoto(subject) { const held = layerState.subjectPhoto; @@ -198,10 +213,7 @@ export function createPanel({ state: layerState, parts }) { width: photo.width ?? null, height: photo.height ?? null, link: photo.link, - // A photo whose answer names no photographer shows no credit line. - credit: photo.photographer - ? t('Photo © {photographer}', { photographer: photo.photographer }) - : '', + credit: photoCredit(photo), }; } diff --git a/src/layers/awareness/panel.test.mjs b/src/layers/awareness/panel.test.mjs index ec361080d..7d21f8fa4 100644 --- a/src/layers/awareness/panel.test.mjs +++ b/src/layers/awareness/panel.test.mjs @@ -113,7 +113,52 @@ test('the Contacts view has no photo without one, or with another subject’s', ); }); -test('only the tracked flight has a photo: not a vessel, an installation, a military aircraft or another flight', () => { +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', () => { const tracked = { layerId: 'flights', id: 'abc123', diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index e8bb26a8c..e60da476e 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -406,17 +406,20 @@ export function createSubject({ state: layerState, services, parts, source }) { } /** - * The tracked airliner's Planespotters photo, for the Contacts panel. + * The subject's photo, for the Contacts panel: the tracked airliner's + * Planespotters photo, or the selected vessel's Open Vessel Photos photo. * - * 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. + * 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. * @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}} + * @returns {?{src: string, width: ?number, height: ?number, photographer: string, link: string, licence?: 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; @@ -517,10 +520,15 @@ 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, - label: record.label || record.id, + // i18n-ignore: MMSI is a radio identity code, as on the vessel card + label: mmsi && !label.includes(mmsi) ? `${label} · MMSI ${mmsi}` : label, memberNames: record.properties?.memberNames || [], position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0), }; diff --git a/src/layers/cctv/geometry.js b/src/layers/cctv/geometry.js index a1402c6c2..148c4a253 100644 --- a/src/layers/cctv/geometry.js +++ b/src/layers/cctv/geometry.js @@ -260,6 +260,21 @@ 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 — @@ -616,10 +631,14 @@ 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. * - * 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. + * 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. * @param {Object} record - Camera record being activated. */ @@ -646,9 +665,33 @@ export function createGeometry({ state: layerState, services, parts, source }) { for (const runtime of layerState._projectionEntities) { if (runtime?.planeEntity) exclude.push(runtime.planeEntity); } - const hit = scene.pickFromRay(new Cesium.Ray(mountPos, dir), exclude); - if (!hit?.position) return; - const dist = Cesium.Cartesian3.distance(mountPos, hit.position); + 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; record.probeClampRangeM = activationProbeClampRange(camera.rangeM, dist); } catch { // probe failure → keep the unclamped range @@ -831,6 +874,7 @@ export function createGeometry({ state: layerState, services, parts, source }) { hasShippedFootprint, footprintPose, activationProbeClampRange, + flatGroundAxisDistance, frustumCartesians, frustumFrameEcef, applyFrustumGeometry, diff --git a/src/layers/cctv/index.js b/src/layers/cctv/index.js index 36e1360b4..ba251da34 100644 --- a/src/layers/cctv/index.js +++ b/src/layers/cctv/index.js @@ -73,6 +73,7 @@ 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, diff --git a/src/layers/firms/windowLabel.test.mjs b/src/layers/firms/windowLabel.test.mjs index 8113db14d..c668f8bb4 100644 --- a/src/layers/firms/windowLabel.test.mjs +++ b/src/layers/firms/windowLabel.test.mjs @@ -32,10 +32,7 @@ test('a FIRMS snapshot that ends at a set time names it instead of LIVE', (conte assert.equal(methods.getStats().at, at); assert.equal(methods.getStats().source, 'NASA FIRMS', 'the row drops LIVE'); setLocale('de'); - assert.equal( - methods.getStats().loadingLabel, - '24 Std. bis 21.10. 15:00 UTC', - ); + assert.equal(methods.getStats().loadingLabel, '24 h bis 21.10. 15:00 UTC'); layerState._at = null; setLocale('en'); assert.equal(methods.getStats().loadingLabel, 'LIVE · updated 2m ago'); diff --git a/src/layers/flights/enrichment.js b/src/layers/flights/enrichment.js index 1132c36c8..c107239c4 100644 --- a/src/layers/flights/enrichment.js +++ b/src/layers/flights/enrichment.js @@ -164,10 +164,10 @@ export function createEnrichment({ const bb = flightState._billboards.get(icao24); if (bb) parts.rendering._applyFleetBillboardPresentation(icao24, bb); - // Hangar fleet: the class's GLB/scale may have changed — resync the - // live model, any in-flight load, and the tracked standalone model. - parts.rendering._syncModelToClass(icao24); } + // The type picks the fleet model and its length — resync the live + // model, any in-flight load, and the tracked standalone model. + parts.rendering._syncModelToClass(icao24); } if (icao24 === flightState._trackedIcao && flightState._trackedEntity) parts.tracking._updateTrackedLabelModel(icao24); diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index adfb11cc4..a435e6793 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { PLANE_MODEL_URL } from './policy.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; export function createLifecycle({ flightState, @@ -53,29 +53,19 @@ export function createLifecycle({ registerSpriteCollection('flights', flightState._billboardCollection); flightState._modelCollection = new Cesium.PrimitiveCollection(); viewer.scene.primitives.add(flightState._modelCollection); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!flightState._preloadModel) { + // Every fleet model loads once, up front, for every layer that draws + // them (fleetModels.js), so no model is fetched while the session runs. + // Captured against this epoch so a destroy/re-init mid-load doesn't flip + // the flag for a torn-down lifecycle. + { const epoch = flightState._modelEpoch; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(PLANE_MODEL_URL), - asynchronous: false, - }) - .then((m) => { - if (epoch === flightState._modelEpoch) { - flightState._preloadModel = m; + loadFleetModelSet({ resolveAsset }) + .then(() => { + if (epoch === flightState._modelEpoch) flightState._planeModelLoaded = true; - } else { - try { - m.destroy(); - } catch { - /* gone */ - } - } }) .catch(() => { - /* tracked plane just stays a billboard a beat longer */ + /* aircraft stay billboards */ }); } flightState._billboards = new Map(); @@ -296,14 +286,6 @@ export function createLifecycle({ flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) flightState._modelPending.clear(); flightState._modelGen.clear(); - if (flightState._preloadModel) { - try { - flightState._preloadModel.destroy(); - } catch { - /* gone */ - } - flightState._preloadModel = null; - } flightState._planeModelLoaded = false; flightState._billboards.clear(); flightState._cullPositions.clear(); diff --git a/src/layers/flights/modelSpec.js b/src/layers/flights/modelSpec.js index 850433824..2a4455b0e 100644 --- a/src/layers/flights/modelSpec.js +++ b/src/layers/flights/modelSpec.js @@ -1,52 +1,83 @@ import { - CLASS_MODEL_REAL, - CLASS_MODEL_URL, - CLASS_SCALE_3D, -} from '../../data/aircraftClass.js'; -import { - trailAnchorForModel, - visualCenterForModel, -} from '../../data/modelVisualAnchor.js'; -import { - MODEL_BELLY_OFFSET_NATIVE, - MODEL_COLOR_BLEND_AMOUNT, - MODEL_NATIVE_RADIUS_M, - MODEL_SCALE, - PLANE_MODEL_URL, -} from './policy.js'; + aircraftCallsignRule, + aircraftModelByCallsign, + aircraftModelSpec, + fleetModelSet, +} from '../../data/fleetModels.js'; /** - * The glTF asset, scale and measured geometry a civil aircraft class renders - * with. Real per-class GLBs are baked to real-world meters (scale 1, measured - * belly and radius); classes without one share airplane.glb at the class's - * 3D scale. Shared by the Flights layer and the Local ADS-B layer so both draw - * the same model for the same class. - * @param {string} klass Aircraft class from `classifyAircraft`. - * @returns {{url:string, scale:number, nativeRadiusM:number, bellyM:number, - * blendAmount:number, visualCenterNative:object, trailAnchorNative:object}} + * What an aircraft draws with before the fleet model set has loaded: no + * model (its billboard stays the visual), with neutral geometry for any + * reader that asks. */ -export function civilAircraftModelSpec(klass) { - const real = CLASS_MODEL_REAL[klass]; - if (real) { - return { - url: real.url, - scale: 1, - nativeRadiusM: real.radiusM, - bellyM: real.bellyM, - blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(real.url), - trailAnchorNative: trailAnchorForModel(real.url), - }; +export const UNLOADED_AIRCRAFT_MODEL_SPEC = Object.freeze({ + model: null, + url: null, + scale: 1, + scaleXYZ: Object.freeze([1, 1, 1]), + length: 0, + nativeRadiusM: 20, + clearanceM: 0, + bellyM: 0, + elevationM: 0, + tinted: false, + visualCenterNative: Object.freeze([0, 0, 0]), + trailAnchorNative: Object.freeze([0, 0, 0]), + key: 'unloaded', +}); + +/** + * The fleet model, life-size scale and measured geometry an aircraft draws + * with, chosen by its ICAO type and emitter category, and by its callsign + * for the types that pick by callsign (`fleetModels.js`). + * Shared by the Flights, Military Flights and Local ADS-B layers so all three + * draw the same model for the same aircraft. `bellyM` is the model's scaled + * clearance: how far a grounded aircraft's origin stands above the ground. + * @param {{typeCode?: string|null, type?: string|null, + * category?: string|number|null, + * callsign?: string|null}|null|undefined} aircraft + * @param {object|null} [set] The loaded fleet model set. + * @returns {object} The spec, or {@link UNLOADED_AIRCRAFT_MODEL_SPEC} before + * the set has loaded. + */ +export function aircraftLayerModelSpec(aircraft, set = fleetModelSet()) { + if (!set) return UNLOADED_AIRCRAFT_MODEL_SPEC; + // Layers ask on every tick; each type and category is resolved once per set. + let cache = specCaches.get(set); + if (!cache) specCaches.set(set, (cache = new Map())); + const key = aircraftModelSpecCacheKey(aircraft, set); + let layerSpec = cache.get(key); + if (!layerSpec) { + const spec = aircraftModelSpec(set, { + typeCode: aircraft?.typeCode ?? aircraft?.type ?? null, + category: aircraft?.category ?? null, + callsign: aircraft?.callsign ?? null, + }); + layerSpec = Object.freeze({ + ...spec, + bellyM: spec.clearanceM, + }); + cache.set(key, layerSpec); } - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - const url = CLASS_MODEL_URL[klass] || PLANE_MODEL_URL; - return { - url, - scale, - nativeRadiusM: MODEL_NATIVE_RADIUS_M, - bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, - blendAmount: MODEL_COLOR_BLEND_AMOUNT, - visualCenterNative: visualCenterForModel(url), - trailAnchorNative: trailAnchorForModel(url), - }; + return layerSpec; +} + +/** @type {WeakMap>} set -> cache key -> spec */ +const specCaches = new WeakMap(); + +/** + * Cache key of an aircraft's model spec: what selects its model. For a type + * that picks its model by callsign (an airport vehicle) the key adds the + * rule its callsign matches, or `type` for none, so such a type caches at + * most one entry per rule and one for its own model. + * @param {object|null|undefined} aircraft + * @param {object|null} [set] The loaded fleet model set. + * @returns {string} + */ +export function aircraftModelSpecCacheKey(aircraft, set = fleetModelSet()) { + const type = aircraft?.typeCode ?? aircraft?.type ?? ''; + const key = `${type}|${aircraft?.category ?? ''}`; + if (!set || !aircraftModelByCallsign(set.types, type)) return key; + const rule = aircraftCallsignRule(set.types, type, aircraft?.callsign); + return `${key}|${rule >= 0 ? `r${rule}` : 'type'}`; } diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index 637da2bb6..a0ee22ade 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -431,7 +431,7 @@ export function createMotion({ const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); if (parts.rendering._modelOwnsVisual(icao24)) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(icao24)?.klass, + flightState.records.data.get(icao24), ); const scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, @@ -614,7 +614,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); return modelVisualAnchor( flightState._trackedModel.modelMatrix, @@ -659,7 +659,7 @@ export function createMotion({ ) return 0; const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const scale = Number.isFinite(flightState._trackedModel.computedScale) ? flightState._trackedModel.computedScale @@ -673,7 +673,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); // Through the model's OWN render chain (modelVisualAnchor's hand-rolled // half-correction put this offset on the lateral axis — see diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 58d6a86ea..3b89a26f6 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -39,8 +39,6 @@ export const GROUND_SCALE = 0.8; // the slots go to either the nearest planes ('proximity') or every in-view plane ('all'), each // backed by a hard cap so a draw-call explosion can't tank the frame (no instancing yet). -export const PLANE_MODEL_URL = '/models/airplane.glb'; - export const MODEL_ALT_CEIL_M = 800000; // m: below this camera altitude, draw 3D models (raised so it's easy to trigger) @@ -55,10 +53,6 @@ export const TRACKED_MODEL_MIN_PX = 40; export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel -export const MODEL_NATIVE_RADIUS_M = 34.41; - -export const MODEL_SCALE = 1; -// airplane.glb is transform-applied and baked to real-world meters // Per-mode caps. Each model is its own draw call (no instancing yet), so these bound the frame cost. export const MODEL_MAX = 150; @@ -90,20 +84,7 @@ export const MODEL_ALL_KEEP_M = 450000; // all: KEEP modeled planes out to 450 km export const MODEL_HEADING_OFFSET_DEG = 180; -// airplane.glb nose is opposite Cesium heading-0 -// Owner launch-polish direction: models should read as clean light silhouettes, -// with only a weak diffuse contribution from the existing approved textures. - -export const MODEL_COLOR_BLEND_AMOUNT = 0.94; - -// Grounded-model belly offset: airplane.glb's centred origin sits 6.719 m ABOVE its -// lowest vertex (glTF Y-up scene AABB with node transforms applied — same reader as -// modelScale.test.mjs, measured after its 24× transform bake). × class multiplier ≈ 5.0–9.7 m -// of lift, so a ground-snapped model rests its lowest geometry (gear/belly) ON the sampled -// tile skin instead of sinking to the fuselage-centerline origin. Locked against the GLB -// by modelScale.test.mjs. - -export const MODEL_BELLY_OFFSET_NATIVE = 6.719; +// every fleet model is nose -X: opposite Cesium heading 0 export const CYAN_TRANSPARENT = Cesium.Color.CYAN.withAlpha(0); @@ -203,6 +184,24 @@ export const LANDED_SPEED_MAX_MPS = 23; export const FLEET_DR_INTERVAL_MS = 80; +/** + * The icon-to-model rules the aircraft layers follow, as the Vessels, Transit + * and Rail layers take them (`createFleetModelLayer`): the same altitude + * ceiling, minimum pixel size, proximity/all caps, add/keep radii and + * eligibility cadence, so every layer turns icons into models at one distance. + */ +export const FLEET_MODEL_POLICY = Object.freeze({ + altitudeCeilingM: MODEL_ALT_CEIL_M, + minimumPixelSize: MODEL_MIN_PX, + cap: MODEL_MAX, + capAll: MODEL_MAX_ALL, + addM: MODEL_PROX_ADD_M, + addAllM: MODEL_ALL_ADD_M, + keepM: MODEL_PROX_KEEP_M, + keepAllM: MODEL_ALL_KEEP_M, + intervalMs: FLEET_DR_INTERVAL_MS, +}); + /** @constant {number} Max ms between rotation passes while the camera is idle. */ export const ROTATION_REFRESH_MS = 1000; diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index 3c00b1824..c43b7c787 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -480,9 +480,7 @@ export function createQueries({ // and rises only as the coarse ground-floor cell warms. Zero extra sampling and // no `_modelDisplayPosition` call from postRender. Sprite-owned contacts keep // `bb.position` — sprite and bracket are co-located there, so association holds. - const spec = modelOwnsVisual - ? parts.rendering._modelSpec(info?.klass) - : null; + const spec = modelOwnsVisual ? parts.rendering._modelSpec(info) : null; const pos = isTracked ? parts.motion._trackedVisualCached() || bb.position : modelOwnsVisual diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js index 63cc6f5f5..02c50f3d8 100644 --- a/src/layers/flights/records.js +++ b/src/layers/flights/records.js @@ -5,6 +5,11 @@ import { } from '../../data/renderAltitude.js'; import { classifyAircraft } from '../../data/aircraftClass.js'; import { aircraftEmergency } from '../../data/aircraftEmergency.js'; +import { + aircraftCallsignRule, + aircraftModelSpec, + fleetModelSet, +} from '../../data/fleetModels.js'; import { approxDistanceKm, GROUND_FLOOR_CLAMP_RADIUS_KM, @@ -21,6 +26,7 @@ export class FlightRecords { cachedGroundFloor, floorAltitudeM, now = () => Date.now(), + fleetModels = fleetModelSet, }) { this.services = { geoidHeight, @@ -29,6 +35,8 @@ export class FlightRecords { }; /** The time records are judged at: the layer's clock. */ this.now = now; + /** The loaded fleet model set, or null before it loads. */ + this.fleetModels = fleetModels; this.data = new Map(); // Facts about an airframe, not a moment, kept across a reset. this.airframes = new Map(); @@ -92,7 +100,6 @@ export class FlightRecords { verticalRateMps: vertical_rate, } = observation; - const onGround = on_ground === true; this.missingPolls.delete(icao24); // Sticky merge: OpenSky intermittently drops callsign/velocity/track for // aircraft it still positions — hold last-known-good instead of @@ -102,6 +109,27 @@ export class FlightRecords { // The airframe's facts: this record's, or those kept across a reset. const airframe = prevMeta ?? this.airframes.get(icao24); this.airframes.delete(icao24); + // A ground vehicle or ground station (a feed's ZVEH or GRND: a model + // built standing, catalog category `other`) takes the feed's own type, + // which no airframe lookup knows; every other aircraft keeps waiting for + // its looked-up type. With no altitude at all it stands on the ground, + // where it is, instead of at the airborne default. + const groundModel = (typeCode) => + drawnAsGroundModel( + this.fleetModels(), + typeCode, + category, + stickyText(callsign, prevMeta?.callsign), + ); + const feedType = observation.typeCode || null; + const typeCode = + airframe?.typeCode ?? + (feedType && groundModel(feedType) ? feedType : null); + const onGround = + on_ground === true || + (!Number.isFinite(baro_alt) && + !Number.isFinite(geo_alt) && + groundModel(typeCode)); // Grounded planes with no baro reading sit at 0 m, not the 10 km // airborne default (a parked plane must never float). // NOTE (height-datum fix): `alt` stays the AVIATION field — the sticky @@ -319,8 +347,9 @@ export class FlightRecords { prevMeta?.lastContactEpochMs, null, ), - // adsbdb enrichment — written by the enrichment callbacks, carried across polls: - typeCode: airframe?.typeCode ?? null, + // adsbdb enrichment — written by the enrichment callbacks, carried + // across polls; a ground vehicle's or station's from the feed: + typeCode, typeName: airframe?.typeName ?? null, registration: airframe?.registration ?? null, airline: routeHeld ? (airframe.airline ?? null) : null, @@ -408,3 +437,33 @@ export class FlightRecords { this.geoidNCache.delete(id); } } + +/** Per loaded fleet model set: whether a type, category and callsign rule draw a ground model. */ +const groundModelCaches = new WeakMap(); + +/** + * Whether an aircraft of this type, emitter category and callsign is drawn + * with a model built standing on the ground (catalog category `other`: an + * airport vehicle, a ground station's mast). False before the set loads. + * Answered once per type, category and callsign rule matched. + * @param {object|null} set The loaded fleet model set. + * @param {string|null} typeCode + * @param {string|number|null} category + * @param {string|null} callsign + * @returns {boolean} + */ +function drawnAsGroundModel(set, typeCode, category, callsign) { + if (!set || !typeCode) return false; + let cache = groundModelCaches.get(set); + if (!cache) groundModelCaches.set(set, (cache = new Map())); + const rule = aircraftCallsignRule(set.types, typeCode, callsign); + const key = `${typeCode}|${category ?? ''}|${rule}`; + let ground = cache.get(key); + if (ground === undefined) { + ground = + aircraftModelSpec(set, { typeCode, category, callsign }).category === + 'other'; + cache.set(key, ground); + } + return ground; +} diff --git a/src/layers/flights/records.test.mjs b/src/layers/flights/records.test.mjs index 9fab9e129..cafe55cbf 100644 --- a/src/layers/flights/records.test.mjs +++ b/src/layers/flights/records.test.mjs @@ -1,13 +1,28 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { FlightRecords } from './records.js'; -function records() { +const FLEET = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../public/models/fleet', +); +const fleetSet = { + catalog: JSON.parse( + fs.readFileSync(path.join(FLEET, 'catalog.json'), 'utf8'), + ), + types: JSON.parse(fs.readFileSync(path.join(FLEET, 'types.json'), 'utf8')), +}; + +function records(fleetModels = () => null) { return new FlightRecords({ geoidHeight: () => 40, cachedGroundFloor: () => null, floorAltitudeM: (altitude) => altitude, approxDistanceKm: () => 0, + fleetModels, }); } const view = () => ({ @@ -260,3 +275,47 @@ test('a repeated fix time marks the contact as not updated; removal is unchanged store.forget('abc123'); assert.equal(store.fixRepeated.has('abc123'), false); }); + +test('a ground vehicle or station with no altitude stands on the ground; an aircraft does not', () => { + const store = records(() => fleetSet); + const noAltitude = { baroAltitudeM: null, ellipsoidAltitudeM: null }; + const vehicle = store.receive( + observation({ + id: 'v1', + typeCode: 'ZVEH', + callsign: 'FIRE 2', + ...noAltitude, + }), + view(), + ).meta; + assert.equal(vehicle.onGround, true); + assert.equal(vehicle.typeCode, 'ZVEH'); + assert.equal(vehicle.altitude, 0); + const mast = store.receive( + observation({ id: 'm1', typeCode: 'GRND', callsign: 'RX5', ...noAltitude }), + view(), + ).meta; + assert.equal(mast.onGround, true); + assert.equal(mast.typeCode, 'GRND'); + // An airliner with no altitude keeps the airborne default, and its feed + // type still waits for the airframe lookup. + const plane = store.receive( + observation({ id: 'p1', typeCode: 'A320', ...noAltitude }), + view(), + ).meta; + assert.equal(plane.onGround, false); + assert.equal(plane.altitude, 10000); + assert.equal(plane.typeCode, null); + // A vehicle reporting an altitude is placed by it. + const reported = store.receive( + observation({ id: 'v2', typeCode: 'ZVEH', baroAltitudeM: 12 }), + view(), + ).meta; + assert.equal(reported.onGround, false); + // Before the model set loads nothing is grounded by its model. + const unloaded = records().receive( + observation({ id: 'v3', typeCode: 'ZVEH', ...noAltitude }), + view(), + ).meta; + assert.equal(unloaded.onGround, false); +}); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index bf8dbb5ee..64d9064ff 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -2,7 +2,11 @@ import * as Cesium from 'cesium'; import { julianAt } from '../../time/julian.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; -import { civilAircraftModelSpec } from './modelSpec.js'; +import { + aircraftLayerModelSpec, + aircraftModelSpecCacheKey, +} from './modelSpec.js'; +import { createFleetModel } from '../../data/fleetModelInstances.js'; import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { isEmergency } from '../../data/aircraftEmergency.js'; import { cockpitContactDotImage } from '../../data/cockpitContactDot.js'; @@ -41,7 +45,10 @@ import { STALE_ALPHA, displayDelayMs, } from './policy.js'; -import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; +import { + AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + aircraftModelBlend, +} from '../../data/aircraftModelLighting.js'; export function createRendering({ flightState, @@ -115,11 +122,14 @@ export function createRendering({ return Number.POSITIVE_INFINITY; } - function _modelSpec(klass) { - const cached = flightState._specCache.get(klass); + /** The fleet model an aircraft draws with, by its type and emitter category. */ + function _modelSpec(meta) { + const key = aircraftModelSpecCacheKey(meta); + const cached = flightState._specCache.get(key); if (cached) return cached; - const spec = civilAircraftModelSpec(klass); - flightState._specCache.set(klass, spec); + const spec = aircraftLayerModelSpec(meta); + // Not cached before the model set loads: the answer changes once it has. + if (spec.model) flightState._specCache.set(key, spec); return spec; } @@ -185,11 +195,12 @@ export function createRendering({ * @returns {void} */ - /** Model tint, mirroring the billboard color rules. */ + /** Model colour: the mesh's own (white), tinted cyan while tracked and red + * while broadcasting an emergency. */ function _modelColor(icao24) { if (icao24 === flightState._trackedIcao) return Cesium.Color.CYAN; - return _fleetBillboardColor(icao24); + return _isEmergency(icao24) ? EMERGENCY_TINT : Cesium.Color.WHITE; } /** The FLEET's 3D-model regime: models3d enabled AND the camera zoomed in past the altitude @@ -309,7 +320,7 @@ export function createRendering({ Cesium.Ellipsoid.WGS84, flightState._scratchGroundCarto, ); - carto.height = h + _modelSpec(meta.klass).bellyM; + carto.height = h + _modelSpec(meta).bellyM; return Cesium.Cartesian3.fromRadians( carto.longitude, carto.latitude, @@ -416,16 +427,13 @@ export function createRendering({ return !!model && model.ready === true && model.show === true; } - /** Spec identity for a LOADED model: URL and scale together (same-URL classes - * differ by scale — airliner vs quadjet both ship airplane.glb). */ + /** Spec identity for a LOADED model: the model and its scale together (one + * model draws several types, each at its own length). */ - const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; - }; + const _specKeyFor = (meta) => _modelSpec(meta).key; - /** Class-change model sync (enrichment AND poll-path klass updates): when the - * aircraft's live model or in-flight load no longer matches its class's spec, + /** Type-change model sync (enrichment AND poll-path type/category updates): when the + * aircraft's live model or in-flight load no longer matches its type's spec, * drop it so the eligibility pass reloads the right asset at the right scale. * Gap-proof: the fleet billboard is re-shown BEFORE the release so the * contact never goes invisible for the tick gap; _releaseModel's generation @@ -433,7 +441,7 @@ export function createRendering({ * the same rule (its billboard entity is always the fallback visual). */ function _syncModelToClass(icao24) { - const key = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const key = _specKeyFor(flightState.records.data.get(icao24)); const current = flightState._models.get(icao24); if ( (current && current._gevSpecKey !== key) || @@ -478,6 +486,11 @@ export function createRendering({ flightState._irReloadQueue = null; } + /** A Cesium Model of `spec` from the fleet set loaded up front (no fetch mid-session). */ + function _createModel(spec, options) { + return createFleetModel(spec, options, { resolveAsset }); + } + /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ async function _ensureModel(icao24) { @@ -497,6 +510,8 @@ export function createRendering({ _modelCap() ) return; + // Until the fleet model set has loaded the aircraft stays a billboard. + if (!_modelSpec(flightState.records.data.get(icao24)).model) return; const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale flightState._modelPending.add(icao24); @@ -505,21 +520,17 @@ export function createRendering({ // aircraft mid-load, the post-await admission below rejects the stale asset. // Boost state likewise: the creation options bake it in, so a mid-load // toggle must reject too (the reload queue only covers ADMITTED models). - const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const specKey = _specKeyFor(flightState.records.data.get(icao24)); const loadIrBoost = flightState._irBoost; try { - const spec = _modelSpec(flightState.records.data.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: resolveAsset(spec.url), + const spec = _modelSpec(flightState.records.data.get(icao24)); + model = await _createModel(spec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: MODEL_MIN_PX, scale: spec.scale, color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, - // Launch presentation keeps the code-side tint dominant for every approved - // model; IR boost removes the remaining diffuse hint with flat UNLIT white. - colorBlendAmount: flightState._irBoost ? 1.0 : spec.blendAmount, + ...aircraftModelBlend(flightState._irBoost), customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined, @@ -560,7 +571,7 @@ export function createRendering({ flightState._models.has(icao24) || flightState._models.size >= _modelCap() || // Class reclassified mid-load → this GLB/scale is for the OLD class. - _specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey || + _specKeyFor(flightState.records.data.get(icao24)) !== specKey || // IR boost flipped mid-load → this model baked the wrong shader/tint. flightState._irBoost !== loadIrBoost; if (stale) { @@ -716,6 +727,9 @@ export function createRendering({ return; } if ( + // Until the fleet model set has loaded the billboard carries it. + _modelSpec(flightState.records.data.get(flightState._trackedIcao)) + .model && !flightState._trackedModel && !flightState._trackedModelLoading && parts.tracking._trackedModelLoadAllowed() @@ -723,23 +737,19 @@ export function createRendering({ flightState._trackedModelLoading = true; const gen = flightState._trackedModelGen; const trackedSpec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedKey = _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedIrBoost = flightState._irBoost; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(trackedSpec.url), + _createModel(trackedSpec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: TRACKED_MODEL_MIN_PX, scale: trackedSpec.scale, color: flightState._irBoost ? Cesium.Color.WHITE : Cesium.Color.CYAN, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // The tracked aircraft uses the same dominant light tint as the fleet; - // IR boost removes the remaining diffuse hint with flat UNLIT white. - colorBlendAmount: flightState._irBoost ? 1.0 : trackedSpec.blendAmount, + ...aircraftModelBlend(flightState._irBoost), customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined, @@ -766,7 +776,7 @@ export function createRendering({ // _trackedModel was still null): drop the stale asset; driver reloads. if ( _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ) !== trackedKey || flightState._irBoost !== trackedIrBoost ) { @@ -840,7 +850,7 @@ export function createRendering({ _applyTrackedModelFade(flightState._trackedModel); if (!flightState._trackedModel.ready) return; const spec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); flightState._trackedModel.scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 5219dcb09..cf6025dba 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -198,9 +198,14 @@ export function createFlightSnapshotRenderer({ ) { rendering._applyFleetBillboardPresentation(icao24, bb); } - // Poll-path class change (category updates): same model resync rule - // as the enrichment path — the class's GLB/scale may have changed. - if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); + // Poll-path type or category change: same model resync rule as the + // enrichment path — the fleet model or its scale may have changed. + if ( + prevMeta?.klass !== meta.klass || + prevMeta?.category !== meta.category || + prevMeta?.typeCode !== meta.typeCode + ) + rendering._syncModelToClass(icao24); } else { // Start where the fleet pass will draw it: the fleet renders behind // real time, so a fresh contact's displayed position is its fix diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index fd77dc113..17bf8b560 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -20,17 +20,13 @@ export function createFlightState({ source, services }) { * layer); see followsClock and displayDelayMs in policy.js. */ flightState.timeline = null; - /** Per-class model spec. Hangar-fleet classes (CLASS_MODEL_REAL) ship GLBs - * vertex-baked to real-world METERS in the airplane.glb axis convention, so - * they render at scale 1 with their own measured belly lift and bounding - * radius. Every other class keeps the shared-airplane.glb formula - * (MODEL_SCALE × CLASS_SCALE_3D). nativeRadiusM is PER SCALE UNIT — pixel-cap - * math multiplies it by `scale`, so world radius = nativeRadiusM × scale in - * both branches. The code-side MIX tint dominates every existing asset so - * class silhouettes stay light without modifying third-party GLBs/textures. */ - /* Specs are static per class, and the detection weld now asks for one per - * MODELED contact per frame (up to the fleet cap) on top of the 12 Hz fleet - * pass — so this is memoized, as militaryFlights.js already does. */ + /** Model spec per aircraft type and emitter category (aircraftLayerModelSpec in + * flights/modelSpec.js): the procedural fleet model that draws the type, life size, + * with its measured clearance, bounding radius and trail anchor. nativeRadiusM is PER + * SCALE UNIT — pixel-cap math multiplies it by `scale`. The code-side MIX tint dominates + * every model so silhouettes stay light (and this layer's own colour). Memoized: the + * detection weld asks for one per MODELED contact per frame on top of the 12 Hz fleet + * pass. */ flightState._specCache = new Map(); @@ -79,16 +75,11 @@ export function createFlightState({ source, services }) { // throttle the "more planes in view than the cap" console notice // The tracked entity's billboard goes transparent (rather than hidden) once the model takes // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its - // alpha after the GLB is preloaded so the model is ready to render the instant the billboard - // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not - // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + // alpha after the fleet model set is loaded so the model is ready to render the instant the + // billboard fades — no gap, no double-image. The set loads once at init (fleetModels.js). flightState._planeModelLoaded = false; - /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ - - flightState._preloadModel = null; - flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); flightState._scratchModelMtx = new Cesium.Matrix4(); diff --git a/src/layers/localAdsb/index.js b/src/layers/localAdsb/index.js index de4215387..7cf01031c 100644 --- a/src/layers/localAdsb/index.js +++ b/src/layers/localAdsb/index.js @@ -254,6 +254,9 @@ export function createLocalAdsbLayer({ ); marker.evidence = evidence; marker.klass = klass; + // What picks its fleet model: the adsbdb type, else the emitter category. + marker.typeCode = enrichment.get(marker.record.icao)?.typeCode || null; + marker.category = marker.record.category || null; const billboard = marker.entity?.billboard; if (!billboard) return; if (marker.iconKind !== klass) { @@ -697,7 +700,6 @@ export function createLocalAdsbLayer({ if (viewer.scene?.primitives) { models = createLocalAdsbModels({ viewer, - color, resolveAsset, groundSnap: services.groundSnap?.createGroundSnap?.() || null, ...(loadModel ? { loadModel } : {}), diff --git a/src/layers/localAdsb/index.test.mjs b/src/layers/localAdsb/index.test.mjs index 263c21210..414bca52f 100644 --- a/src/layers/localAdsb/index.test.mjs +++ b/src/layers/localAdsb/index.test.mjs @@ -12,6 +12,15 @@ import { localAdsbReceiverLine, localAdsbStatus, } from './index.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; +import { aircraftLayerModelSpec } from '../flights/modelSpec.js'; +import { fetchPublicFile } from '../../testSupport/fleetGlb.mjs'; + +// The layer draws models only once the fleet model set has loaded; load it from disk. +const realInfo = console.info; +console.info = () => {}; +await loadFleetModelSet({ fetchImpl: fetchPublicFile }); +console.info = realInfo; function fakeReceiver(initial = {}) { const state = { @@ -494,7 +503,7 @@ test('adsbdb type, operator and route reach the card for the selected aircraft o assert.equal(queries.length, 2, 'each key is asked for once'); }); -test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t) => { +test('the DISPLAY 3D toggle gives local aircraft their fleet models in their mesh colours', async (t) => { const receiver = fakeReceiver({ mode: 'adsb', connected: true }); const clock = { now: 100_000 }; const loads = []; @@ -529,21 +538,20 @@ test('the DISPLAY 3D toggle gives local aircraft magenta class models', async (t await layer.update(); assert.equal(loads.length, 1); const [model] = loads; + const spec = aircraftLayerModelSpec({ category: 'A7' }); assert.equal( - model.url, - '/models/bell206.glb', - 'the Flights helicopter model', + model.basePath, + '/models/fleet/helicopters/generic_heli.glb', + 'the Flights rotorcraft model', ); - assert.equal(model.scale, 1); + assert.ok(model.gltf instanceof Uint8Array, 'built from the loaded set'); + assert.equal(model.scale, spec.scale, 'drawn life size'); assert.equal(model.id, 'local-adsb:a0b702'); assert.ok( - Cesium.Color.equals( - model.color, - Cesium.Color.fromCssColorString('#ff4fd8'), - ), + Cesium.Color.equals(model.color, Cesium.Color.WHITE), + 'the mesh draws its own colours', ); - assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.MIX); - assert.equal(model.colorBlendAmount, 0.94); + assert.equal(model.colorBlendMode, Cesium.ColorBlendMode.HIGHLIGHT); assert.equal(model.show, true); const heli = sources[0].entities.getById('local-adsb:a0b702'); assert.equal( diff --git a/src/layers/localAdsb/models.js b/src/layers/localAdsb/models.js index 55fc2d11a..307412a78 100644 --- a/src/layers/localAdsb/models.js +++ b/src/layers/localAdsb/models.js @@ -1,7 +1,9 @@ import * as Cesium from 'cesium'; import { horizonOccluder } from '../../data/iconOrientation.js'; import { selectModelEligible } from '../../data/modelEligibility.js'; -import { civilAircraftModelSpec } from '../flights/modelSpec.js'; +import { aircraftLayerModelSpec } from '../flights/modelSpec.js'; +import { createFleetModel } from '../../data/fleetModelInstances.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; import { FLEET_DR_INTERVAL_MS, MODEL_ALL_ADD_M, @@ -14,24 +16,32 @@ import { MODEL_PROX_ADD_M, MODEL_PROX_KEEP_M, } from '../flights/policy.js'; -import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; +import { + AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + aircraftModelBlend, +} from '../../data/aircraftModelLighting.js'; -const specKey = (spec) => `${spec.url}@${spec.scale}`; +const specOf = (marker) => + aircraftLayerModelSpec({ + typeCode: marker?.typeCode, + category: marker?.category, + callsign: marker?.record?.callsign, + }); /** * 3D models for Local ADS-B aircraft under the DISPLAY rail's 3D toggle. * * Mirrors the public Flights fleet: models only below the altitude ceiling, * the same proximity/all caps and add/keep radii, the same visible-first - * eligibility (`selectModelEligible`), the same per-class asset and scale - * (`civilAircraftModelSpec`) and the same tint path (a MIX colour blend, here - * magenta, as Flights tints military amber). Each model owns its matrix. A + * eligibility (`selectModelEligible`), the same fleet model per type, life + * size (`aircraftLayerModelSpec`) and the same colours: the mesh's own + * (`aircraftModelBlend`). Each model owns its matrix. A * billboard stays the visual until its model is loaded AND placed; a grounded * aircraft's model rides the one-shot ground snap plus the class belly - * offset, and without a resolved ground it stays a billboard. + * offset, and without a resolved ground it stays a billboard. Every fleet + * model loads once, up front, when the layer starts (`loadFleetModelSet`). * @param {object} options * @param {object} options.viewer Cesium viewer. - * @param {Cesium.Color} options.color Model tint. * @param {(url: string) => string} options.resolveAsset * @param {object|null} [options.groundSnap] `createGroundSnap()` instance. * @param {(options: object) => Promise} [options.loadModel] @@ -39,11 +49,13 @@ const specKey = (spec) => `${spec.url}@${spec.scale}`; */ export function createLocalAdsbModels({ viewer, - color, resolveAsset, groundSnap = null, loadModel = (options) => Cesium.Model.fromGltfAsync(options), }) { + void loadFleetModelSet({ resolveAsset }).catch(() => { + /* aircraft stay billboards */ + }); const collection = new Cesium.PrimitiveCollection(); viewer.scene.primitives.add(collection); /** @type {Map} id -> admitted model */ @@ -75,8 +87,9 @@ export function createLocalAdsbModels({ } async function ensure(id, marker, cap) { - const spec = civilAircraftModelSpec(marker.klass); - const key = specKey(spec); + const spec = specOf(marker); + if (!spec.model) return; + const key = spec.key; const current = models.get(id); if (current && current._gevSpecKey === key) return; if (current) release(id); @@ -85,17 +98,19 @@ export function createLocalAdsbModels({ pending.set(id, loadGeneration); let model; try { - model = await loadModel({ - url: resolveAsset(spec.url), - asynchronous: false, - environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, - minimumPixelSize: MODEL_MIN_PX, - scale: spec.scale, - color, - colorBlendMode: Cesium.ColorBlendMode.MIX, - colorBlendAmount: spec.blendAmount, - id, - }); + model = await createFleetModel( + spec, + { + asynchronous: false, + environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + minimumPixelSize: MODEL_MIN_PX, + scale: spec.scale, + color: Cesium.Color.WHITE, + ...aircraftModelBlend(false), + id, + }, + { resolveAsset, load: loadModel }, + ); } catch { if (pending.get(id) === loadGeneration) pending.delete(id); return; @@ -106,7 +121,7 @@ export function createLocalAdsbModels({ generation !== loadGeneration || !eligible.has(id) || models.has(id) || - specKey(civilAircraftModelSpec(marker.klass)) !== key; + specOf(marker).key !== key; if (pending.get(id) === loadGeneration) pending.delete(id); if (stale) { model.destroy?.(); diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index 695286803..e1cf507e3 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -1,5 +1,5 @@ import * as Cesium from 'cesium'; -import { JET_MODEL_URL } from './policy.js'; +import { loadFleetModelSet } from '../../data/fleetModels.js'; export function createLifecycle({ flightState, @@ -47,29 +47,19 @@ export function createLifecycle({ registerSpriteCollection('military', flightState._billboardCollection); flightState._modelCollection = new Cesium.PrimitiveCollection(); viewer.scene.primitives.add(flightState._modelCollection); - // Warm the glTF cache so the tracked plane's model instantiates instantly when first needed - // (keeps the retained instance referenced; never rendered). Captured against this epoch so a - // destroy/re-init mid-load doesn't flip the flag for a torn-down lifecycle. - if (!flightState._preloadModel) { + // Every fleet model loads once, up front, for every layer that draws + // them (fleetModels.js), so no model is fetched while the session runs. + // Captured against this epoch so a destroy/re-init mid-load doesn't flip + // the flag for a torn-down lifecycle. + { const epoch = flightState._modelEpoch; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(JET_MODEL_URL), - asynchronous: false, - }) - .then((m) => { - if (epoch === flightState._modelEpoch) { - flightState._preloadModel = m; + loadFleetModelSet({ resolveAsset }) + .then(() => { + if (epoch === flightState._modelEpoch) flightState._planeModelLoaded = true; - } else { - try { - m.destroy(); - } catch { - /* gone */ - } - } }) .catch(() => { - /* tracked plane just stays a billboard a beat longer */ + /* aircraft stay billboards */ }); } flightState._billboards = new Map(); @@ -269,14 +259,6 @@ export function createLifecycle({ flightState._modelEpoch += 1; // invalidate any in-flight load from this lifecycle (settles post-destroy) flightState._modelPending.clear(); flightState._modelGen.clear(); - if (flightState._preloadModel) { - try { - flightState._preloadModel.destroy(); - } catch { - /* gone */ - } - flightState._preloadModel = null; - } flightState._planeModelLoaded = false; flightState._billboards.clear(); flightState._cullPositions.clear(); diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 6828b788e..bcff2e34a 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -301,7 +301,7 @@ export function createMotion({ const rangeM = Cesium.Cartesian3.distance(camera.positionWC, position); if (parts.rendering._modelOwnsVisual(icao24)) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(icao24)?.klass, + flightState.records.data.get(icao24), ); const scale = trackedModelScaleForPixelCap({ baseScale: spec.scale, @@ -459,7 +459,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); return modelVisualAnchor( flightState._trackedModel.modelMatrix, @@ -502,7 +502,7 @@ export function createMotion({ ) return 0; const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const scale = Number.isFinite(flightState._trackedModel.computedScale) ? flightState._trackedModel.computedScale @@ -519,7 +519,7 @@ export function createMotion({ parts.rendering._modelOwnsVisual(flightState._trackedIcao) ) { const spec = parts.rendering._modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); // Through the model's OWN render chain (modelVisualAnchor's hand-rolled // half-correction put this offset on the lateral axis — see diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index cafbaada2..ac23188ff 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -58,20 +58,18 @@ export const EMERGENCY_ICON_COLOR = export const GROUND_SCALE = 0.8; // --- 3D model rendering (mirrors flights.js) ----------------------------------------- -// When enabled, military aircraft render as 3D glTF jet models once the camera is below +// When enabled, military aircraft render as 3D glTF models once the camera is below // MODEL_ALT_CEIL_M (zoomed in); higher up they stay flat billboards. Eligibility is // FRUSTUM-based (on-screen), and the slots go to either the nearest planes ('proximity') // or every in-view plane ('all'), each backed by a hard cap so a draw-call explosion -// can't tank the frame (no instancing yet). Distinct asset + amber tint set this layer -// apart from the commercial flights layer. - -export const JET_MODEL_URL = '/models/jet.glb'; +// can't tank the frame (no instancing yet). Each aircraft draws the fleet model of its +// type, as the flights layer does; the amber tint sets this layer apart. export const MODEL_ALT_CEIL_M = 800000; // m: below this camera altitude, draw 3D models (raised so it's easy to trigger) export const MODEL_MIN_PX = 24; -// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js, whose models now share this layer's ~30 m world size) +// floor so distant models stay visible without ballooning into a min-pixel blob (mirror of flights.js) export const TRACKED_MODEL_MIN_PX = 40; // keep the glTF silhouette comparable to the selected 2D glyph at handoff @@ -79,17 +77,6 @@ export const TRACKED_MODEL_MIN_PX = 40; export const TRACKED_MODEL_MAX_PX = 200; // owner-selected close-range tracked-target feel -export const MODEL_NATIVE_RADIUS_M = 29.83; - -// jet.glb is transform-applied at real-world scale — native bounding radius -// 29.83 m at scale 1. ×1 → ~22–43 m aircraft across CLASS_SCALE_3D, matching -// flights.js world sizes. At the old copied ×24, models rendered -// 600–1000 m across (invisible at the ~6 km follow range where minimumPixelSize -// dominates, but zoom under ~1 km put the camera INSIDE the plane). Locked by -// modelScale.test.mjs. - -export const MODEL_SCALE = 1; - // Per-mode caps + radii — mirror of flights.js. Modes differ by RADIUS (not just cap) so Proximity // and All aren't identical when few planes are in range; on-screen planes win the cap (see flights.js). @@ -119,32 +106,7 @@ export const MODEL_ALL_KEEP_M = 450000; // all: KEEP modeled planes out to 450 km export const MODEL_HEADING_OFFSET_DEG = 180; -// every aircraft GLB is exported nose -X in the shared transform-applied convention -// Preserve the layer's amber identity while reducing approved texture/livery -// contribution to a weak diffuse hint, matching civilian launch presentation. - -export const MODEL_COLOR_BLEND_AMOUNT = 0.94; - -// airplane.glb (the shared 747) constants for this layer's heavy classes. The -// asset has its former 24× runtime calibration baked into transform-applied -// meter-scale geometry; these values mirror flights.js and are regression-pinned. - -export const PLANE_MODEL_URL = '/models/airplane.glb'; - -export const PLANE_MODEL_SCALE = 1; - -export const PLANE_NATIVE_RADIUS_M = 34.41; - -export const PLANE_BELLY_OFFSET_NATIVE = 6.719; - -// Grounded-model belly offset: jet.glb's centred origin sits 5.631 native units (= meters — this -// asset is real-world scale) ABOVE its lowest vertex (glTF Y-up scene AABB with node -// transforms applied — same reader as modelScale.test.mjs, measured 2026-07-03). -// × MODEL_SCALE(1) × class multiplier ≈ 4.4–8.6 m of lift, so a ground-snapped model -// rests its lowest geometry (gear/belly) ON the sampled tile skin instead of sinking to -// the fuselage-centerline origin. Locked against the GLB by modelScale.test.mjs. - -export const MODEL_BELLY_OFFSET_NATIVE = 5.631; +// every fleet model is nose -X: opposite Cesium heading 0 /** Amber fade target for the tracked billboard once the model takes over (mirrors flights.js CYAN_TRANSPARENT). */ diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index 4af14b3ba..edd3930c2 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -484,9 +484,7 @@ export function createQueries({ // contacts read the translation the fleet tick already wrote into their // modelMatrix; sprite-owned contacts keep `bb.position`, where sprite and bracket // are co-located anyway. - const spec = modelOwnsVisual - ? parts.rendering._modelSpec(info?.klass) - : null; + const spec = modelOwnsVisual ? parts.rendering._modelSpec(info) : null; const pos = isTracked ? parts.motion._trackedVisualCached() || bb.position : modelOwnsVisual diff --git a/src/layers/military/records.js b/src/layers/military/records.js index b18e946b8..72ffd2677 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -181,6 +181,8 @@ export class MilitaryFlightRecords { newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs), callsign: stickyText(callsign, prevMeta?.callsign), type: stickyType, + // The emitter category, kept for the fleet model a type-less row draws. + category: aircraft?.category ?? prevMeta?.category ?? null, // Type outranks category automatically inside classifyAircraft. klass: classifyAircraft({ typeCode: stickyType, diff --git a/src/layers/military/rendering.js b/src/layers/military/rendering.js index d783bd4a4..7e19b4567 100644 --- a/src/layers/military/rendering.js +++ b/src/layers/military/rendering.js @@ -1,12 +1,9 @@ +import { CLASS_SCALE_2D } from '../../data/aircraftClass.js'; import { - CLASS_MODEL_REAL, - CLASS_SCALE_3D, - CLASS_SCALE_2D, -} from '../../data/aircraftClass.js'; -import { - visualCenterForModel, - trailAnchorForModel, -} from '../../data/modelVisualAnchor.js'; + aircraftLayerModelSpec, + aircraftModelSpecCacheKey, +} from '../flights/modelSpec.js'; +import { createFleetModel } from '../../data/fleetModelInstances.js'; import * as Cesium from 'cesium'; import { selectModelEligible } from '../../data/modelEligibility.js'; import { cyberSonarBaseAlpha } from '../../cyberSonar.js'; @@ -20,14 +17,6 @@ import { import { limitCourseStep, courseSlewCapDps } from '../../data/motionModel.js'; import { isEmergency } from '../../data/aircraftEmergency.js'; import { - PLANE_MODEL_SCALE, - PLANE_MODEL_URL, - PLANE_NATIVE_RADIUS_M, - PLANE_BELLY_OFFSET_NATIVE, - MODEL_SCALE, - JET_MODEL_URL, - MODEL_NATIVE_RADIUS_M, - MODEL_BELLY_OFFSET_NATIVE, MODEL_HEADING_OFFSET_DEG, BILLBOARD_SCALE, GROUND_SCALE, @@ -45,7 +34,6 @@ import { MODEL_PROX_KEEP_M, IR_RELOAD_BATCH, MODEL_MIN_PX, - MODEL_COLOR_BLEND_AMOUNT, TRACKED_MODEL_MIN_PX, TRACKED_MODEL_MAX_PX, FLEET_DR_INTERVAL_MS, @@ -54,7 +42,10 @@ import { COURSE_MAX_DPS, STALE_ALPHA, } from './policy.js'; -import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js'; +import { + AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, + aircraftModelBlend, +} from '../../data/aircraftModelLighting.js'; export function createRendering({ flightState, @@ -83,48 +74,19 @@ export function createRendering({ return Number.POSITIVE_INFINITY; } - function _modelSpec(klass) { - let spec = flightState._specCache.get(klass); + /** The fleet model an aircraft draws with, by its type and emitter category + * (shared with flights.js), in this layer's own tint. */ + function _modelSpec(meta) { + const key = aircraftModelSpecCacheKey(meta); + let spec = flightState._specCache.get(key); if (spec) return spec; - const real = CLASS_MODEL_REAL[klass]; - if (real) { - spec = { - url: real.url, - scale: 1, - nativeRadiusM: real.radiusM, - bellyM: real.bellyM, - headingOffsetDeg: 180, - visualCenterNative: visualCenterForModel(real.url), - trailAnchorNative: trailAnchorForModel(real.url), - }; - } else if ( - klass === 'airliner' || - klass === 'quadjet' || - klass === 'glider' - ) { - const scale = PLANE_MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { - url: PLANE_MODEL_URL, - scale, - nativeRadiusM: PLANE_NATIVE_RADIUS_M, - bellyM: PLANE_BELLY_OFFSET_NATIVE * scale, - headingOffsetDeg: 180, - visualCenterNative: visualCenterForModel(PLANE_MODEL_URL), - trailAnchorNative: trailAnchorForModel(PLANE_MODEL_URL), - }; - } else { - const scale = MODEL_SCALE * (CLASS_SCALE_3D[klass] || 1); - spec = { - url: JET_MODEL_URL, - scale, - nativeRadiusM: MODEL_NATIVE_RADIUS_M, - bellyM: MODEL_BELLY_OFFSET_NATIVE * scale, - headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, - visualCenterNative: visualCenterForModel(JET_MODEL_URL), - trailAnchorNative: trailAnchorForModel(JET_MODEL_URL), - }; - } - flightState._specCache.set(klass, spec); + const base = aircraftLayerModelSpec(meta); + spec = Object.freeze({ + ...base, + headingOffsetDeg: MODEL_HEADING_OFFSET_DEG, + }); + // Not cached before the model set loads: the answer changes once it has. + if (spec.model) flightState._specCache.set(key, spec); return spec; } @@ -191,11 +153,15 @@ export function createRendering({ const _iconKind = (icao24, klass) => tr3bIconKind(icao24, klass, { hot: flightState._irBoost }); - /** Model tint, mirroring the billboard color rules (amber instead of cyan/white). */ + /** Model colour: the mesh's own (white), tinted amber while tracked and red + * while broadcasting an emergency. */ function _modelColor(icao24) { if (icao24 === flightState._trackedIcao) return TRACKED_ICON_COLOR; - return _fleetBillboardColor(icao24); + const info = flightState.records.data.get(icao24); + return isEmergency(info?.emergency) + ? EMERGENCY_ICON_COLOR + : Cesium.Color.WHITE; } /** Fleet (untracked) tint: red while the latest poll broadcast an emergency, @@ -312,7 +278,7 @@ export function createRendering({ Cesium.Ellipsoid.WGS84, flightState._scratchGroundCarto, ); - carto.height = h + _modelSpec(meta.klass).bellyM; + carto.height = h + _modelSpec(meta).bellyM; return Cesium.Cartesian3.fromRadians( carto.longitude, carto.latitude, @@ -350,7 +316,7 @@ export function createRendering({ displayPos, course, model.modelMatrix, - _modelSpec(flightState.records.data.get(icao24)?.klass).headingOffsetDeg, + _modelSpec(flightState.records.data.get(icao24)).headingOffsetDeg, ); if (!model.ready) { model.show = false; // not loaded yet → keep the 2D icon, no half-model flash @@ -385,20 +351,17 @@ export function createRendering({ return !!model && model.ready === true && model.show === true; } - /** Spec identity for a LOADED model — mirror of flights.js: URL and scale - * together (same-URL classes differ by scale). */ + /** Spec identity for a LOADED model — mirror of flights.js: the model and + * its scale together. */ - const _specKeyFor = (klass) => { - const spec = _modelSpec(klass); - return `${spec.url}@${spec.scale}`; - }; + const _specKeyFor = (meta) => _modelSpec(meta).key; - /** Class-change model sync — mirror of flights.js: drop a live model or - * in-flight load whose spec no longer matches the class, re-showing the + /** Type-change model sync — mirror of flights.js: drop a live model or + * in-flight load whose spec no longer matches the type, re-showing the * fleet billboard FIRST (gap-proof) and covering the tracked standalone. */ function _syncModelToClass(icao24) { - const key = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const key = _specKeyFor(flightState.records.data.get(icao24)); const current = flightState._models.get(icao24); if ( (current && current._gevSpecKey !== key) || @@ -445,6 +408,11 @@ export function createRendering({ /** Lazily create the glTF model for an aircraft (fire-and-forget; billboard shows until ready). */ + /** A Cesium Model of `spec` from the fleet set loaded up front (no fetch mid-session). */ + function _createModel(spec, options) { + return createFleetModel(spec, options, { resolveAsset }); + } + async function _ensureModel(icao24) { // Never model the TRACKED aircraft — it owns a separate entity billboard, and the fleet // tick skips it, so a model here would be orphaned + double-rendered. @@ -461,6 +429,8 @@ export function createRendering({ _modelCap() ) return; + // Until the fleet model set has loaded the aircraft stays a billboard. + if (!_modelSpec(flightState.records.data.get(icao24)).model) return; const epoch = flightState._modelEpoch; // lifecycle token: if destroy() bumps it, this load is dead const gen = flightState._modelGen.get(icao24) || 0; // capture; if it changes during the load, we're stale flightState._modelPending.add(icao24); @@ -468,20 +438,17 @@ export function createRendering({ // Spec identity captured at load START (mirror of flights.js): a mid-load // reclassification makes the post-await admission reject the stale asset. // Boost state likewise — creation options bake it in. - const specKey = _specKeyFor(flightState.records.data.get(icao24)?.klass); + const specKey = _specKeyFor(flightState.records.data.get(icao24)); const loadIrBoost = flightState._irBoost; try { - const spec = _modelSpec(flightState.records.data.get(icao24)?.klass); - model = await Cesium.Model.fromGltfAsync({ - url: resolveAsset(spec.url), + const spec = _modelSpec(flightState.records.data.get(icao24)); + model = await _createModel(spec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: MODEL_MIN_PX, scale: spec.scale, color: flightState._irBoost ? Cesium.Color.WHITE : _modelColor(icao24), - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + ...aircraftModelBlend(flightState._irBoost), customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined, @@ -522,7 +489,7 @@ export function createRendering({ flightState._models.has(icao24) || flightState._models.size >= _modelCap() || // Class reclassified mid-load → this GLB/scale is for the OLD class. - _specKeyFor(flightState.records.data.get(icao24)?.klass) !== specKey || + _specKeyFor(flightState.records.data.get(icao24)) !== specKey || // IR boost flipped mid-load → this model baked the wrong shader/tint. flightState._irBoost !== loadIrBoost; if (stale) { @@ -676,6 +643,9 @@ export function createRendering({ return; } if ( + // Until the fleet model set has loaded the billboard carries it. + _modelSpec(flightState.records.data.get(flightState._trackedIcao)) + .model && !flightState._trackedModel && !flightState._trackedModelLoading && parts.tracking._trackedModelLoadAllowed() @@ -683,22 +653,19 @@ export function createRendering({ flightState._trackedModelLoading = true; const gen = flightState._trackedModelGen; const trackedSpec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedKey = _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); const trackedIrBoost = flightState._irBoost; - Cesium.Model.fromGltfAsync({ - url: resolveAsset(trackedSpec.url), + _createModel(trackedSpec, { asynchronous: false, environmentMapOptions: AIRCRAFT_ENVIRONMENT_MAP_OPTIONS, minimumPixelSize: TRACKED_MODEL_MIN_PX, scale: trackedSpec.scale, color: flightState._irBoost ? Cesium.Color.WHITE : TRACKED_ICON_COLOR, - colorBlendMode: Cesium.ColorBlendMode.MIX, - // near self-illuminated tint so planes read uniform near AND far; IR boost → flat UNLIT white (hot) - colorBlendAmount: flightState._irBoost ? 1.0 : MODEL_COLOR_BLEND_AMOUNT, + ...aircraftModelBlend(flightState._irBoost), customShader: flightState._irBoost ? flightState._IR_UNLIT_SHADER : undefined, @@ -724,7 +691,7 @@ export function createRendering({ // _trackedModel was still null): drop the stale asset; driver reloads. if ( _specKeyFor( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ) !== trackedKey || flightState._irBoost !== trackedIrBoost ) { @@ -793,7 +760,7 @@ export function createRendering({ return; } const spec = _modelSpec( - flightState.records.data.get(flightState._trackedIcao)?.klass, + flightState.records.data.get(flightState._trackedIcao), ); _modelMatrix( displayPos, diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index 94e1921a5..0f04d7172 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -164,10 +164,15 @@ export function createMilitarySnapshotRenderer({ ) { rendering._applyFleetBillboardPresentation(icao24, bb); } - // Per-class GLBs: a class change can mean a different asset OR scale — - // resync the live model, any in-flight load, and the tracked - // standalone (mirror of flights.js). - if (prevMeta?.klass !== meta.klass) rendering._syncModelToClass(icao24); + // Per-type fleet models: a type or category change can mean a + // different model OR scale — resync the live model, any in-flight + // load, and the tracked standalone (mirror of flights.js). + if ( + prevMeta?.klass !== meta.klass || + prevMeta?.type !== meta.type || + prevMeta?.category !== meta.category + ) + rendering._syncModelToClass(icao24); // Round 5: depth policy is uniform (always depth-test-free, see // _groundDepthDistance) — nothing to flip on landing/takeoff. } else { diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 4d9febafd..9bdf2684c 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -19,15 +19,13 @@ export function createFlightState({ source, services }) { * layer); see followsClock and displayDelayMs in policy.js. */ flightState.timeline = null; - /** Per-class model spec for THIS layer (2026-08-16, owner playtest ask: - * military contacts should read as their WEIGHT CLASS, always in this layer's - * amber). Real Hangar GLBs serve the classes they cover (meters, nose −X → - * 180° offset); airliner/quadjet/glider get the shared 747 silhouette - * (airplane.glb — C-5M/RC-135-style heavies stop rendering as bizjets); - * fastjet and unknown keep jet.glb with the same 180° offset. The MIX tint stays - * dominant everywhere — military is amber, tracked is TRACKED_ICON_COLOR, - * and the tint must dominate any livery (owner: "stays yellow"). Specs are - * static per class — memoized (the fleet pass asks at 12 Hz per model). */ + /** Model spec per aircraft type and emitter category (aircraftLayerModelSpec in + * flights/modelSpec.js): the procedural fleet model that draws the type, life size, + * with its measured clearance, bounding radius and trail anchor. nativeRadiusM is PER + * SCALE UNIT — pixel-cap math multiplies it by `scale`. The code-side MIX tint dominates + * every model so silhouettes stay light (and in this layer's own colour). Memoized: the + * detection weld asks for one per MODELED contact per frame on top of the 12 Hz fleet + * pass. */ flightState._specCache = new Map(); @@ -76,16 +74,11 @@ export function createFlightState({ source, services }) { // throttle the "more planes in view than the cap" console notice // The tracked entity's billboard goes transparent (rather than hidden) once the model takes // over, so it keeps supplying a bounding sphere for follow-camera framing. We only drop its - // alpha after the GLB is preloaded so the model is ready to render the instant the billboard - // fades — no gap, no double-image. Preloaded once at init; the instance is retained (not - // destroyed) purely to keep Cesium's glTF cache warm for fast tracked-model instantiation. + // alpha after the fleet model set is loaded so the model is ready to render the instant the + // billboard fades — no gap, no double-image. The set loads once at init (fleetModels.js). flightState._planeModelLoaded = false; - /** @type {Cesium.Model|null} retained preload that keeps the glTF cache warm */ - - flightState._preloadModel = null; - flightState._scratchModelHpr = new Cesium.HeadingPitchRoll(0, 0, 0); flightState._scratchModelMtx = new Cesium.Matrix4(); diff --git a/src/layers/rail/cockpitSubject.js b/src/layers/rail/cockpitSubject.js new file mode 100644 index 000000000..80cfc1daa --- /dev/null +++ b/src/layers/rail/cockpitSubject.js @@ -0,0 +1,340 @@ +import { pointAtS } from '../../data/railPaths.js'; +import { getRegisteredRailFeed } from '../../data/railFeeds.js'; +import { displayMotion } from '../transit/movement.js'; + +/** Each line's read-only copy for a snapshot's `path.line` (lineOf). */ +const lines = new WeakMap(); + +/** + * A line as a snapshot hands it out: a frozen copy of the layer's path, its + * number arrays frozen arrays, built once per line. `pointAtS` and + * `projectOnPath` read it as they read the path, and nothing written to it + * reaches the layer. + */ +function lineOf(path) { + let line = lines.get(path); + if (!line) { + const copy = {}; + for (const [name, value] of Object.entries(path)) + copy[name] = ArrayBuffer.isView(value) + ? Object.freeze(Array.from(value)) + : value; + line = Object.freeze(copy); + lines.set(path, line); + } + return line; +} + +const NO_STOPS = Object.freeze([]); +/** Each stops list's frozen copy for a snapshot's `stops` (stopsOf). */ +const stopLists = new WeakMap(); + +/** Whether `copy` still holds each of `list`'s stops, field by field. */ +function sameStops(copy, list) { + if (copy.length !== list.length) return false; + for (let i = 0; i < list.length; i++) { + const stop = list[i]; + const kept = copy[i]; + if ( + !stop || + typeof stop !== 'object' || + !kept || + typeof kept !== 'object' + ) { + if (!Object.is(stop, kept)) return false; + continue; + } + const names = Object.keys(stop); + if (names.length !== Object.keys(kept).length) return false; + for (const name of names) + if (!Object.is(stop[name], kept[name])) return false; + } + return true; +} + +/** + * A trip's stops as a snapshot hands them out: a frozen array of frozen + * copies, kept per list the stops provider returns while that list still + * holds the same stops, so a provider that updates its list in place is + * copied again. + */ +function stopsOf(list) { + if (!Array.isArray(list)) return NO_STOPS; + let stops = stopLists.get(list); + if (!stops || !sameStops(stops, list)) { + stops = Object.freeze( + list.map((stop) => + stop && typeof stop === 'object' ? Object.freeze({ ...stop }) : stop, + ), + ); + stopLists.set(list, stops); + } + return stops; +} + +/** + * `previous` when it holds each of `next`'s values, else a frozen copy of + * `next`: a part of a snapshot that did not change is the one read before. + * `next` is a draft, filled again on every read. + */ +function reuse(previous, next) { + if (!previous) return Object.freeze({ ...next }); + for (const name in next) + if (!Object.is(previous[name], next[name])) + return Object.freeze({ ...next }); + return previous; +} + +/** + * Read access to one train, and a hold on it for a first-person view. + * + * `getSelectedTrain()` and `getCockpitSubject()` describe the selected train + * and the held one as a frozen snapshot, read again on each call: where it is + * along its line, how fast it moves and how that is known, and its stops. A + * call while nothing in it changed returns the snapshot it returned before, + * and a part that did not change (its feed, its line, its stops) is the same + * object in the next one, so a view can read it on every frame. `path.line` + * is a frozen copy of the train's line for `pointAtS` and `projectOnPath`. + * `setCockpitSubject(key)` holds a train: its marker, 3D model, card and + * selected line are hidden, it stays selected (so its stops stay fresh and its feed stays + * polled at any distance) and clicks and Escape no longer change the + * selection. A held train keeps moving, hidden, and keeps the selection when + * its category is turned off, and is the copy of its train drawn though + * another feed's copy would outrank it (dedupe.js). Released, it is a train + * like any other: one in a category turned off is deselected and stops, and + * a better copy takes over at the next poll. The hold ends with + * `setCockpitSubject(null)`, and on its own when the train is removed or the + * layer is turned off. A train removed while held leaves its feed polled + * until the next `setCockpitSubject` call, so a view that waits for it holds + * it again if the feed lists it again. + * `focusTrain(key)` selects a train and hands it the camera, as a click does. + * A snapshot's `selectedAtMs` is when the user last selected the selected + * train (a click, or `focusTrain`), even again, for a view's entry to tell a + * re-selection. It is null for any other train, after a deselection, and for + * a train selected only by a hold: a hold is not a user's selection. + * + * @param {object} context The layer's state, parts, services and timetable. + * @returns {{methods: object}} + */ +export function createRailCockpitSubject({ + state, + parts, + services, + timetable, +}) { + const liftM = Number.isFinite(services.ground?.GROUND_FLOOR_LIFT_M) + ? services.ground.GROUND_FLOOR_LIFT_M + : 0; + const finiteOrNull = (value) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + const scratch = {}; + /** Each train's last snapshot, returned again while nothing in it changed. */ + const views = new WeakMap(); + /** The drafts trainView fills on every read (reuse). */ + const draft = { + view: {}, + feed: {}, + position: {}, + path: {}, + motion: {}, + provenance: {}, + }; + + /** The catalog's public entry for a feed, else the registry's. */ + function feedOf(feedId, previous) { + const listed = state._catalog?.find((feed) => feed.id === feedId); + const feed = listed ?? getRegisteredRailFeed(feedId); + if (!feed) return null; + const next = draft.feed; + next.id = feed.id; + next.name = feed.name ?? null; + next.operator = feed.operator ?? null; + next.region = feed.region ?? null; + next.positions = listed?.positions ?? feed.positions ?? 'gps'; + next.tripUpdates = + typeof listed?.tripUpdates === 'boolean' + ? listed.tripUpdates + : typeof feed.tripUpdatesUrl === 'string'; + next.attribution = feed.attribution ?? null; + next.license = feed.license ?? null; + next.licenseUrl = feed.licenseUrl ?? null; + return reuse(previous, next); + } + + /** The route's short (else long) name in the feed's timetable, or null. */ + function routeNameOf(entry) { + const routeId = entry.record?.routeId; + if (!routeId) return null; + const schedule = timetable?.scheduleFor?.(entry.feedId); + const route = schedule?.routes?.[routeId]; + const name = route?.short || route?.long; + return typeof name === 'string' && name ? name : null; + } + + /** + * Which way the train runs along its shape: as its binding learnt it from + * successive reports, else from its drawn course against the line's + * bearing there; 0 while neither tells. + */ + function directionOf(entry) { + const binding = entry.path; + if (binding.direction === 1 || binding.direction === -1) + return binding.direction; + const course = finiteOrNull(entry.courseDeg); + const point = pointAtS(binding.path, binding.drawS, scratch); + if (course === null || !Number.isFinite(point?.bearingDeg)) return 0; + const turn = Math.abs(((course - point.bearingDeg + 540) % 360) - 180); + return turn > 90 ? -1 : 1; + } + + /** The train as a frozen snapshot (see the module comment), or null. */ + function trainView(entry) { + if (!entry?.record) return null; + const previous = views.get(entry); + const record = entry.record; + const sample = entry.sample ?? null; + const drawn = entry.drawnHeight ?? null; + const binding = entry.path ?? null; + const reported = finiteOrNull(record.speedMps); + const derived = finiteOrNull(sample?.segmentSpeedMps); + const trip = state._selectedKey === entry.key ? state._trip : null; + const status = state._feedStatus.get(entry.feedId); + + const position = draft.position; + position.latitude = + finiteOrNull(drawn?.lat) ?? finiteOrNull(sample?.lat) ?? record.lat; + position.longitude = + finiteOrNull(drawn?.lon) ?? finiteOrNull(sample?.lon) ?? record.lon; + position.railHeightM = Number.isFinite(drawn?.h) ? drawn.h - liftM : null; + position.heightQuality = Number.isFinite(drawn?.q) ? drawn.q : 0; + position.atMs = finiteOrNull(drawn?.at); + + let path = null; + if (binding && binding.path) { + path = draft.path; + path.shapeIndex = binding.shapeIndex; + path.s = binding.drawS; + path.direction = directionOf(entry); + path.lengthM = binding.path.length; + path.via = binding.via; + path.builtAt = state._pathPacks.get(entry.feedId)?.pack?.builtAt ?? null; + path.line = lineOf(binding.path); + } + + const motion = draft.motion; + motion.reportedSpeedMps = reported; + motion.derivedSpeedMps = derived; + motion.speedSource = + reported !== null ? 'reported' : derived !== null ? 'derived' : null; + motion.courseDeg = finiteOrNull(entry.courseDeg); + motion.phase = sample?.phase ?? null; + motion.word = displayMotion(entry, Date.now()).word; + motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs); + motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs); + motion.feedStale = status?.stale === true; + + const provenance = draft.provenance; + provenance.positionSource = record.positionSource ?? 'gps'; + provenance.timestampSource = record.timestampSource ?? null; + + const view = draft.view; + view.key = entry.key; + view.feedId = entry.feedId; + view.selectedAtMs = + state._selectedKey === entry.key ? state._selectedAtMs : null; + view.id = String(record.id ?? ''); + view.tripId = record.tripId || null; + view.routeId = record.routeId || null; + view.routeName = routeNameOf(entry); + view.label = record.label || null; + view.trainNumber = record.trainNumber || null; + view.headsign = trip?.headsign || null; + view.category = entry.category ?? null; + view.mode = entry.railMode ?? null; + view.feed = feedOf(entry.feedId, previous?.feed); + view.position = reuse(previous?.position, position); + view.path = path && reuse(previous?.path, path); + view.motion = reuse(previous?.motion, motion); + view.provenance = reuse(previous?.provenance, provenance); + view.stops = stopsOf(trip?.stops); + view.match = trip?.match ?? null; + const snapshot = reuse(previous, view); + views.set(entry, snapshot); + return snapshot; + } + + /** A train's marker and playback again, after a hold on it began or ended. */ + function replay(entry) { + parts.rendering.paint(entry); + parts.rendering.schedulePlayback(entry); + parts.rendering.syncRenderHold(); + } + + const methods = { + /** The selected train (see createRailCockpitSubject), or null. */ + getSelectedTrain() { + return trainView(state._trains.get(state._selectedKey)); + }, + /** The held train, or null when none is held. */ + getCockpitSubject() { + return trainView(state._trains.get(state._cockpitSubjectKey)); + }, + /** + * Hold the train `key` for a first-person view, or release the hold with + * null. A train it holds is selected, if it was not already. + * @param {string|null} key + * @returns {boolean} False for a key the layer does not list. A listed + * train is held even when its category is off or another feed's copy + * is drawn: the held copy is then the one drawn. Releasing returns true. + */ + setCockpitSubject(key) { + const previous = state._trains.get(state._cockpitSubjectKey); + if (key === null || key === undefined) { + state._cockpitSubjectKey = null; + state._cockpitLostFeedId = null; + if (previous) { + // Its category turned off meanwhile: deselected, as the toggle + // does to any other train (controls.js). + if ( + previous.key === state._selectedKey && + state._categories[previous.category] === false + ) + parts.selection.clearSelection(); + replay(previous); + } + parts.selection.refreshSelectedCard(true); + parts.routes.applyHold?.(); + return true; + } + const entry = state._trains.get(key); + if (!entry) return false; + if (state._selectedKey !== key) { + parts.selection.selectTrain(key); + // A hold is not a selection the user made (selectedAtMs). + state._selectedAtMs = null; + } + state._cockpitSubjectKey = key; + state._cockpitLostFeedId = null; + // Another feed's copy drew it: the held copy is drawn now (dedupe.js). + if (entry.duplicate === true) parts.ingestion.duplicates?.refresh(); + if (previous && previous !== entry) replay(previous); + replay(entry); + parts.selection.refreshSelectedCard(true); + parts.routes.applyHold?.(); + return true; + }, + /** + * Select the train `key` and hand it the camera, as a click on it does. + * @param {string} key + * @returns {boolean} False for a train the layer does not show: gone, in + * a category turned off, or a copy another feed draws (dedupe.js). + */ + focusTrain(key) { + const entry = state._trains.get(key); + if (!entry || !parts.rendering.categoryShown(entry)) return false; + parts.selection.selectAndFocusTrain(key); + return true; + }, + }; + return { methods, trainView }; +} diff --git a/src/layers/rail/cockpitSubject.test.mjs b/src/layers/rail/cockpitSubject.test.mjs new file mode 100644 index 000000000..4700e71e6 --- /dev/null +++ b/src/layers/rail/cockpitSubject.test.mjs @@ -0,0 +1,504 @@ +// The Rail layer's read API for a first-person view: the selected or held +// train as a snapshot (where it is along its line and which way it runs, its +// speed and how that is known, its feed's credit, its stops), and a hold that +// hides its marker and card, keeps it selected and ends when it goes. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + RAIL_POLL_MS, + STOP_REFRESH_MS, + railTrainFollowable, + railTrainKey, +} from './policy.js'; +import { + BOSTON, + railLayerHarness as harness, + railTrain as train, +} from '../../testSupport/railLayerHarness.mjs'; +import { RAIL_SELECTED_OVERLAY_SOURCE_ID } from './policy.js'; +import { publicRailCatalog } from '../../data/railFeeds.js'; +import { pointAtS } from '../../data/railPaths.js'; +import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs'; + +const M_PER_DEG = 111_320; +const LAT = 42.36; +const KX = M_PER_DEG * Math.cos((LAT * Math.PI) / 180); +const LON0 = -71.08; + +/** A flat [lat, lon, ...] line due east from LON0, one vertex per 100 m. */ +function eastLine(lengthM) { + const out = []; + for (let d = 0; d <= lengthM; d += 100) out.push(LAT, LON0 + d / KX); + return out; +} + +const PACK = Object.freeze({ + version: 1, + feedId: 'mbta-rail', + builtAt: '2026-09-09T00:00:00.000Z', + shapes: [eastLine(6000)], + dist: [[]], + shapeSources: ['operator'], + trips: { 'trip-T1': 0, 'trip-T2': 0 }, + routes: { R1: [0] }, +}); + +/** Two trains on the line, T1 running east and T2 west, a step a poll. */ +async function onTheLine(t, options = {}) { + const app = harness(t, { + paths: { 'mbta-rail': PACK }, + at: BOSTON, + ...options, + }); + let step = 0; + app.feeds.set('mbta-rail', () => [ + train('T1', LAT, LON0 + (1000 + step * 150) / KX, { speedMps: 15 }), + train('T2', LAT, LON0 + (5000 - step * 150) / KX), + ]); + await app.enable(); + for (let i = 0; i < 4; i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + return app; +} + +const T1 = railTrainKey('mbta-rail', 'T1'); +const T2 = railTrainKey('mbta-rail', 'T2'); + +test('the selected train reads as a snapshot of its place, motion and sources', async (t) => { + const app = await onTheLine(t); + assert.equal(app.layer.getSelectedTrain(), null, 'nothing selected'); + app.parts().selection.selectTrain(T1); + const view = app.layer.getSelectedTrain(); + assert.ok(Object.isFrozen(view)); + assert.equal(view.key, T1); + assert.equal(view.feedId, 'mbta-rail'); + assert.equal(view.id, 'T1'); + assert.equal(view.tripId, 'trip-T1'); + assert.equal(view.routeId, 'R1'); + assert.equal(view.category, 'regional'); + assert.equal(view.mode, 'rail'); + const listed = publicRailCatalog().find((feed) => feed.id === 'mbta-rail'); + assert.equal(view.feed.attribution, listed.attribution); + assert.equal(view.feed.license, listed.license); + assert.equal(view.feed.licenseUrl, listed.licenseUrl); + assert.equal(view.feed.positions, 'gps'); + assert.equal(view.path.shapeIndex, 0); + assert.equal(view.path.builtAt, PACK.builtAt); + assert.equal(view.path.via, 'trip'); + assert.ok(Math.abs(view.path.lengthM - 6000) < 1); + assert.ok(view.path.s > 1000 && view.path.s < 2000, `s ${view.path.s}`); + assert.equal(view.path.direction, 1, 'runs along the shape'); + assert.equal(typeof view.path.line.length, 'number'); + assert.equal(view.motion.reportedSpeedMps, 15); + assert.equal(view.motion.speedSource, 'reported'); + assert.equal(view.provenance.positionSource, 'gps'); + assert.equal(view.provenance.timestampSource, 'vehicle'); + const drawn = app.entry(T1).drawnHeight; + assert.equal(view.position.railHeightM, drawn.h - 1.5); + assert.ok(Number.isFinite(view.position.latitude)); + assert.ok(Array.isArray(view.stops)); + + app.parts().selection.selectTrain(T2); + const west = app.layer.getSelectedTrain(); + assert.equal(west.path.direction, -1, 'runs against the shape'); + assert.equal(west.motion.reportedSpeedMps, null); + assert.equal( + west.motion.speedSource, + west.motion.derivedSpeedMps === null ? null : 'derived', + ); +}); + +/** A trip's stops as a stops provider lists them. */ +const stopList = (...names) => + names.map((name, i) => ({ + stopId: name, + name, + lat: LAT, + lon: LON0 + (i * 2000) / KX, + state: 'upcoming', + })); + +test('a snapshot read again while nothing changed is the one read before', async (t) => { + let list = stopList('Alpha', 'Bravo', 'Charlie'); + const app = await onTheLine(t, { + stops: { stopsFor: () => ({ stops: list, headsign: 'Charlie' }) }, + }); + app.parts().selection.selectTrain(T1); + const first = app.layer.getSelectedTrain(); + assert.equal(app.layer.getSelectedTrain(), first, 'the same snapshot'); + // Its stops are frozen copies of the provider's. + assert.equal(first.stops.length, 3); + assert.ok(Object.isFrozen(first.stops)); + assert.ok(Object.isFrozen(first.stops[0])); + assert.notEqual(first.stops[0], list[0]); + assert.equal(first.stops[0].name, 'Alpha'); + + // It moves on: a new snapshot at its new place, what did not change kept. + app.run(RAIL_POLL_MS); + await app.poll(); + app.run(RAIL_POLL_MS); + const moved = app.layer.getSelectedTrain(); + assert.notEqual(moved, first, 'a new snapshot'); + assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.path)); + assert.ok(moved.path.s > first.path.s, 'at its new place'); + assert.equal(moved.stops, first.stops, 'the same stops'); + assert.equal(moved.feed, first.feed); + assert.equal(moved.provenance, first.provenance); + assert.equal(moved.path.line, first.path.line); + assert.equal(app.layer.getSelectedTrain(), moved); + + // The provider lists other stops: new ones. + list = stopList('Delta', 'Echo'); + app.run(STOP_REFRESH_MS); + const later = app.layer.getSelectedTrain(); + assert.notEqual(later.stops, first.stops); + assert.deepEqual( + later.stops.map((stop) => stop.name), + ['Delta', 'Echo'], + ); + // A provider that updates its list in place: the stops are copied again. + list[0].state = 'passed'; + app.run(STOP_REFRESH_MS); + const updated = app.layer.getSelectedTrain(); + assert.notEqual(updated.stops, later.stops); + assert.equal(updated.stops[0].state, 'passed'); + assert.equal(app.layer.getSelectedTrain().stops, updated.stops); +}); + +test('a snapshot’s line is a frozen copy: writing to it leaves the layer’s line', async (t) => { + const app = await onTheLine(t); + app.parts().selection.selectTrain(T1); + const { line, s } = app.layer.getSelectedTrain().path; + const own = app.entry(T1).path.path; + assert.notEqual(line, own, 'not the layer’s own'); + assert.ok(Object.isFrozen(line)); + for (const name of ['lat', 'lon', 'x', 'y', 's', 'box']) + assert.ok(Object.isFrozen(line[name]), name); + const lat = own.lat[1]; + assert.throws(() => { + line.lat[1] = 0; + }, TypeError); + assert.equal(own.lat[1], lat, 'the layer’s line is as it was'); + assert.deepEqual(pointAtS(line, s), pointAtS(own, s), 'it reads the same'); + // Copied once per line: T2 runs on the same one. + app.parts().selection.selectTrain(T2); + assert.equal(app.layer.getSelectedTrain().path.line, line); +}); + +test('a held train hides its marker and card and keeps the selection', async (t) => { + const app = await onTheLine(t); + assert.equal(app.layer.setCockpitSubject('rail:mbta-rail:nope'), false); + assert.equal(app.layer.getCockpitSubject(), null); + assert.equal(app.layer.setCockpitSubject(T1), true); + assert.equal(app.state()._selectedKey, T1, 'holding selects it'); + assert.equal(app.layer.getCockpitSubject().key, T1); + app.run(1000); + assert.equal(app.entry(T1).marker.show, false); + assert.equal(app.entry(T2).marker.show, true); + // From inside the train its selected line would run under the eye. + assert.equal(app.state()._route.primitive.show, false, 'no line'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // Escape is the view's while it holds the train. + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, T1); + // It still moves. + const before = app.layer.getCockpitSubject().path.s; + app.run(RAIL_POLL_MS); + await app.poll(); + app.run(RAIL_POLL_MS); + assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving'); + + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.layer.getCockpitSubject(), null); + app.run(1000); + assert.equal(app.entry(T1).marker.show, true); + assert.equal(app.state()._route.primitive.show, true, 'the line is back'); + assert.equal( + (app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length, + 1, + 'the card is back', + ); +}); + +test('a held train draws no 3D model either; let go, its model draws again', async (t) => { + const models = await fleetModelFakes(t); + const app = await onTheLine(t, { fleet: models.fleet, altitude: 1_000 }); + // A model's load answers off the frame; the next frames place it. + const frames = async () => { + app.run(500); + await app.settle(); + app.run(500); + }; + await frames(); + assert.equal(models.drawn(T1).length, 1, 'near the camera: its model'); + assert.equal(app.entry(T1).modelOwnsVisual, true); + assert.equal(app.entry(T1).marker.show, false, 'its icon hands over'); + + assert.equal(app.layer.setCockpitSubject(T1), true); + app.run(250); + // The view's eye is inside the train. + assert.equal(models.drawn(T1).length, 0, 'no model'); + assert.equal(app.entry(T1).modelOwnsVisual, false); + assert.equal(app.entry(T1).marker.show, false, 'and no icon'); + assert.equal(models.drawn(T2).length, 1, 'another train keeps its model'); + await frames(); + assert.equal(models.drawn(T1).length, 0, 'not on a later frame either'); + assert.equal(app.entry(T1).marker.show, false); + + assert.equal(app.layer.setCockpitSubject(null), true); + await frames(); + assert.equal(models.drawn(T1).length, 1, 'let go: its model again'); + assert.equal(app.entry(T1).modelOwnsVisual, true); + assert.equal(app.entry(T1).marker.show, false); +}); + +const MADRID = { lat: 40.4168, lon: -3.7038 }; + +test('a held train stays the copy drawn when another feed’s copy outranks it, and keeps moving', async (t) => { + const app = harness(t, { at: MADRID }); + // One Alvia in two feeds: a station-event copy, held, and a poll later a + // GPS copy that would outrank it. + let step = 0; + let gpsCopy = false; + const lat = (offset) => MADRID.lat + offset + step * 0.003; + app.feeds.set('renfe-cercanias', () => [ + train('A1', lat(0), MADRID.lon, { + label: 'ALVIA 4073', + positionSource: 'event', + }), + ]); + app.feeds.set('renfe-larga-distancia', () => + gpsCopy ? [train('B1', lat(0.01), MADRID.lon, { label: '4073' })] : [], + ); + await app.enable(); + const a = railTrainKey('renfe-cercanias', 'A1'); + const b = railTrainKey('renfe-larga-distancia', 'B1'); + for (let i = 0; i < 12 && !railTrainFollowable(app.entry(a)); i += 1) { + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.ok(railTrainFollowable(app.entry(a)), 'the train is moving'); + assert.equal(app.layer.setCockpitSubject(a), true); + + gpsCopy = true; + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + // The view knows the train by its key, and the copies stand apart: the + // held copy stays drawn and selected, the other is the one hidden. + assert.equal(app.layer.getCockpitSubject()?.key, a); + assert.equal(app.state()._selectedKey, a); + assert.equal(app.entry(a).duplicate, false); + assert.equal(app.entry(b).duplicate, true); + assert.equal(app.entry(a).marker.show, false); + assert.equal(app.entry(b).marker.show, false, 'no other marker on it'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // It moves between polls, frame by frame. + assert.ok(app.state()._moving.has(app.entry(a)), 'still playing'); + const before = app.layer.getCockpitSubject().position.latitude; + app.run(2000); + assert.ok( + app.layer.getCockpitSubject().position.latitude > before, + 'still moving', + ); + + // Let go: the better copy takes over at the next poll, with the selection. + app.layer.setCockpitSubject(null); + step += 1; + app.run(RAIL_POLL_MS); + await app.poll(); + assert.equal(app.entry(a).duplicate, true); + assert.equal(app.entry(b).duplicate, false); + assert.equal(app.state()._selectedKey, b); + assert.equal(app.entry(b).marker.show, true); + assert.equal( + (app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? []).length, + 1, + 'its card', + ); + // The copy hidden is not focused: the selection stays on the one drawn. + assert.equal(app.layer.focusTrain(a), false); + assert.equal(app.state()._selectedKey, b); + + // A hold of the copy hidden draws it at once, in place of the other. + assert.equal(app.layer.setCockpitSubject(a), true); + assert.equal(app.state()._selectedKey, a); + assert.equal(app.entry(a).duplicate, false); + assert.equal(app.entry(b).duplicate, true); + assert.equal(app.entry(b).marker.show, false); + assert.ok(app.state()._moving.has(app.entry(a)), 'playing'); +}); + +test('a held train keeps the selection and moves, hidden, when its category is turned off', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + app.run(1000); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing'); + app.layer.setParams({ regional: false }); + assert.equal(app.state()._selectedKey, T1, 'still selected'); + assert.equal(app.layer.getCockpitSubject()?.key, T1); + assert.equal(app.entry(T1).marker.show, false); + assert.equal(app.entry(T2).marker.show, false, 'its category is hidden'); + assert.equal(app.state()._route.primitive.show, false, 'no line'); + assert.deepEqual( + app.overlayEntries.get(RAIL_SELECTED_OVERLAY_SOURCE_ID) ?? [], + [], + 'no card', + ); + // It moves between polls, frame by frame. + assert.ok(app.state()._moving.has(app.entry(T1)), 'still playing'); + const before = app.layer.getCockpitSubject().path.s; + app.run(2000); + assert.ok(app.layer.getCockpitSubject().path.s > before, 'still moving'); + assert.equal(app.entry(T1).marker.show, false, 'and still hidden'); + + // Let go: a train of a hidden category, deselected and stopped as the + // toggle would have left it. + app.layer.setCockpitSubject(null); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._moving.has(app.entry(T1)), false); + assert.equal(app.entry(T1).marker.show, false); + const parked = app.entry(T1).path.drawS; + app.run(2000); + assert.equal(app.entry(T1).path.drawS, parked, 'stopped'); + // Held again, hidden, it plays at once; let go, it stops again. + assert.equal(app.layer.setCockpitSubject(T1), true); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing at once'); + app.layer.setCockpitSubject(null); + assert.equal(app.state()._moving.has(app.entry(T1)), false); + // Shown again, it is drawn and moves as any other. + app.layer.setParams({ regional: true }); + assert.equal(app.entry(T1).marker.show, true); + assert.ok(app.state()._moving.has(app.entry(T1)), 'playing again'); +}); + +test('a ride let go with restore does not focus a train its category hides', async (t) => { + const app = await onTheLine(t); + // The documented provider: hold on entry; on exit, release, then focus + // the train again when asked to restore. + assert.equal(app.layer.setCockpitSubject(T1), true); + app.layer.setParams({ regional: false }); + app.layer.setCockpitSubject(null); + assert.equal(app.layer.focusTrain(T1), false, 'not drawn'); + assert.equal(app.state()._selectedKey, null, 'not selected'); + assert.equal(app.parts().follow.stats(), null, 'not followed'); + assert.equal(app.viewer.trackedEntity, undefined); + assert.equal(app.focusRequests.length, 0, 'not flown to'); + assert.equal(app.entry(T1).marker.show, false); + + // Shown again, it focuses as before. + app.layer.setParams({ regional: true }); + assert.equal(app.layer.focusTrain(T1), true); + assert.equal(app.state()._selectedKey, T1); + assert.ok(app.parts().follow.isFollowing(T1), 'the camera is handed to it'); +}); + +test('a held train that goes takes the hold with it; focusTrain selects', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + app.feeds.set('mbta-rail', () => [train('T2', LAT, LON0 + 4000 / KX)]); + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + assert.equal(app.entry(T1), undefined, 'the train is gone'); + assert.equal(app.layer.getCockpitSubject(), null); + assert.equal(app.state()._cockpitSubjectKey, null); + assert.equal(app.layer.focusTrain(T1), false); + assert.equal(app.layer.focusTrain(T2), true); + assert.equal(app.state()._selectedKey, T2); +}); + +test('a held train its feed drops leaves the feed polled until the view lets go', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + // From the cab the camera looks along the ground, here past the feed's range. + await app.moveCamera({ lat: 40.71, lon: -74.0 }); + assert.equal(app.state()._heldFeedId, 'mbta-rail', 'held for the train'); + const others = () => [train('T2', LAT, LON0 + 4000 / KX)]; + app.feeds.set('mbta-rail', others); + for (let i = 0; i < 3; i += 1) { + app.run(RAIL_POLL_MS); + await app.poll(); + } + await app.recheck(); + assert.equal(app.entry(T1), undefined, 'the train is gone'); + assert.equal(app.layer.getCockpitSubject(), null); + assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'its feed is polled'); + await app.moveCamera({ lat: 40.72, lon: -74.0 }); + assert.ok(app.state()._activeFeeds.has('mbta-rail'), 'whatever the camera'); + + // Listed again: the view holds it again. + app.feeds.set('mbta-rail', () => [ + train('T1', LAT, LON0 + 2500 / KX, { speedMps: 15 }), + ...others(), + ]); + app.run(RAIL_POLL_MS); + await app.poll(); + assert.equal(app.layer.setCockpitSubject(T1), true); + assert.equal(app.state()._cockpitLostFeedId, null); + assert.equal(app.state()._selectedKey, T1); + + // Let go, and the feed is the viewport's again: out of range, released. + app.parts().ingestion.removeTrain(T1); + assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail'); + app.layer.setCockpitSubject(null); + await app.moveCamera({ lat: 40.71, lon: -74.0 }); + assert.equal(app.state()._activeFeeds.has('mbta-rail'), false); + assert.equal(app.state()._trains.size, 0); +}); + +test('selectedAtMs is the user’s selection time, never a hold’s or another train’s', async (t) => { + const app = await onTheLine(t); + assert.equal(app.layer.focusTrain(T1), true); + const first = app.layer.getSelectedTrain().selectedAtMs; + assert.ok(Number.isFinite(first), 'a click or focusTrain stamps it'); + // Selected again: a later stamp. + app.run(1000); + app.layer.focusTrain(T1); + assert.ok(app.layer.getSelectedTrain().selectedAtMs > first); + // A hold of another train is not the user's selection. + app.layer.setCockpitSubject(T2); + assert.equal(app.layer.getCockpitSubject().key, T2); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + app.layer.setCockpitSubject(null); + // Nor after a deselection (Escape), then a hold. + app.layer.focusTrain(T1); + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._selectedAtMs, null, 'deselection clears it'); + app.layer.setCockpitSubject(T2); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + // Releasing twice is harmless: the selection stays. + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.state()._selectedKey, T2); + assert.equal(app.layer.getCockpitSubject(), null); +}); + +test('turning the layer off ends the hold', async (t) => { + const app = await onTheLine(t); + app.layer.setCockpitSubject(T1); + app.parts().ingestion.removeTrain(T1); + assert.equal(app.state()._cockpitLostFeedId, 'mbta-rail'); + app.layer.disable(app.viewer); + assert.equal(app.state()._cockpitSubjectKey, null); + assert.equal(app.state()._cockpitLostFeedId, null); + assert.equal(app.layer.getCockpitSubject(), null); +}); diff --git a/src/layers/rail/controls.js b/src/layers/rail/controls.js index 0899ed8e9..f937cd6ae 100644 --- a/src/layers/rail/controls.js +++ b/src/layers/rail/controls.js @@ -91,7 +91,13 @@ export function createControls({ state, parts, timetable }) { return true; state._categories = next; const selected = state._trains.get(state._selectedKey); - if (selected && next[selected.category] === false) + // A held train keeps the selection until its view lets it go + // (cockpitSubject.js). + if ( + selected && + next[selected.category] === false && + selected.key !== state._cockpitSubjectKey + ) parts.selection.clearSelection(); parts.rendering.applyCategoryVisibility(); state._revision += 1; diff --git a/src/layers/rail/dedupe.js b/src/layers/rail/dedupe.js index e1ce79f78..f699c0c67 100644 --- a/src/layers/rail/dedupe.js +++ b/src/layers/rail/dedupe.js @@ -25,9 +25,13 @@ import { greatCircleKm } from '../../geoDistance.js'; * 2. the line: a copy bound to its operator's shape (a built pack), then * one without. * The copy drawn stays drawn until another scores strictly higher, so equal - * copies never trade places. A losing copy keeps its playback and is only - * hidden; when a feed leaves the viewport its copies' partners come back. - * The comparison runs once per poll, never per frame. + * copies never trade places. A copy a first-person view holds + * (cockpitSubject.js) stays drawn whatever the scores until the view lets + * go: the view knows the train by that copy's key and rides it where that + * copy's feed puts it, tens of kilometres from another feed's copy at + * times. A losing copy keeps its playback and is only hidden; when a feed + * leaves the viewport its copies' partners come back. The comparison runs + * once per poll, never per frame. */ /** @@ -288,10 +292,12 @@ function better(a, b) { * scores strictly higher. * @param {object[][]} groups From matchCopies. * @param {Map} [previous] copy key → key drawn last time. + * @param {string|null} [held] A copy drawn whatever the scores: the train a + * first-person view holds. * @returns {{winners: Map, hidden: Set}} * `winners`: every grouped copy's key → the key drawn for its train. */ -export function chooseCopies(groups, previous = new Map()) { +export function chooseCopies(groups, previous = new Map(), held = null) { const winners = new Map(); const hidden = new Set(); for (const group of groups) { @@ -305,6 +311,7 @@ export function chooseCopies(groups, previous = new Map()) { scored.reduce((a, b) => (better(b.score, a.score) ? b : a)); for (const candidate of scored) if (better(candidate.score, best.score)) best = candidate; + best = scored.find(({ copy }) => copy.key === held) ?? best; for (const { copy } of scored) { winners.set(copy.key, best.copy.key); if (copy.key !== best.copy.key) hidden.add(copy.key); @@ -318,7 +325,8 @@ export function chooseCopies(groups, previous = new Map()) { * loaded feed carries, marks the copies not drawn (`entry.duplicate`), and * moves a selection that sat on a copy now hidden to the copy drawn, so the * card follows the same physical train (its feed line names the new - * source). + * source). A held copy is never the one hidden, so the hold and the + * selection stay on it. * @param {{state: object, parts: object, * defer?: (fn: Function) => unknown}} context */ @@ -355,6 +363,7 @@ export function createDuplicateFilter({ const { winners, hidden } = chooseCopies( matchCopies(entries.map(copyOf)), previous, + state._cockpitSubjectKey ?? null, ); previous = winners; for (const entry of entries) { diff --git a/src/layers/rail/dedupe.test.mjs b/src/layers/rail/dedupe.test.mjs index ac3f61f6b..0d6538d3c 100644 --- a/src/layers/rail/dedupe.test.mjs +++ b/src/layers/rail/dedupe.test.mjs @@ -252,6 +252,32 @@ test('the filter hides the weaker copy and the selection follows its train', () assert.deepEqual(filter.stats(), { groups: 0, hidden: 0 }); }); +test('a copy a first-person view holds stays drawn, with the selection', () => { + const pair = [ + copy('a', 'f1', { number: '160', position: 'event' }), + copy('b', 'f2', { number: '160', position: 'gps' }), + ]; + assert.equal(chooseCopies([pair]).winners.get('a'), 'b'); + const held = chooseCopies([pair], new Map(), 'a'); + assert.equal(held.winners.get('b'), 'a', 'drawn whatever the scores'); + assert.deepEqual([...held.hidden], ['b']); + // Let go, the better copy takes over. + assert.equal(chooseCopies([pair], held.winners).winners.get('a'), 'b'); + + const layer = fakeLayer({ selected: 'feed-a:v160' }); + layer.state._cockpitSubjectKey = 'feed-a:v160'; + const filter = createDuplicateFilter({ ...layer, defer: (fn) => fn() }); + filter.refresh(); + const [unbound, operator] = layer.trains; + assert.equal(unbound.duplicate, false, 'the held copy'); + assert.equal(operator.duplicate, true); + assert.deepEqual(layer.calls.selected, [], 'the selection stays on it'); + layer.state._cockpitSubjectKey = null; + filter.refresh(); + assert.equal(unbound.duplicate, true); + assert.deepEqual(layer.calls.selected, [operator.key]); +}); + test('a short number after a local product names a line, not a train (S1, RB 1, U6)', () => { for (const label of ['S1', 'RB 1', 'U6', 'S 12', 'RE5']) assert.equal(recordTrainNumber({ label }), null, label); diff --git a/src/layers/rail/index.js b/src/layers/rail/index.js index 0a191c62a..ec6322fc3 100644 --- a/src/layers/rail/index.js +++ b/src/layers/rail/index.js @@ -7,6 +7,7 @@ import { createIngestion } from './ingestion.js'; import { createViewport } from './viewport.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; +import { createRailCockpitSubject } from './cockpitSubject.js'; import { createRouteDisplay } from './routeDisplay.js'; import { createRailSchedules } from './schedules.js'; import { getRegisteredRailFeed } from '../../data/railFeeds.js'; @@ -78,12 +79,24 @@ function createState({ services }) { _stopAnchors: new Map(), _categories: { ...DEFAULT_RAIL_CATEGORY_VISIBILITY }, _selectedKey: null, + /** When the user last selected a train, even the selected one (ms). */ + _selectedAtMs: null, _selectedCardAt: 0, _selectedCardText: null, /** The camera's follow of the selected train (follow.js), or null. */ _follow: null, /** A feed polled only for the selected train (viewport.js), or null. */ _heldFeedId: null, + /** + * The train a first-person view holds (cockpitSubject.js), or null: its + * marker, 3D model and card are hidden and the selection stays on it. + */ + _cockpitSubjectKey: null, + /** + * The feed of a held train the feed dropped (ingestion.js), polled until + * the view takes a train again or lets go (cockpitSubject.js), or null. + */ + _cockpitLostFeedId: null, _revision: 0, _controlsListener: null, _dataManager: null, @@ -175,11 +188,13 @@ export function createRailLayer({ parts.ingestion = createIngestion(context); parts.viewport = createViewport(context); parts.lifecycle = createLifecycle(context); + parts.cockpit = createRailCockpitSubject(context); return Object.assign( { updateInterval: RAIL_POLL_MS }, parts.lifecycle.methods, parts.controls.methods, + parts.cockpit.methods, { /** Test seams: this instance's own state and parts. */ _railStateForTest: () => state, diff --git a/src/layers/rail/ingestion.js b/src/layers/rail/ingestion.js index 54d885e37..8bd4a342f 100644 --- a/src/layers/rail/ingestion.js +++ b/src/layers/rail/ingestion.js @@ -69,6 +69,13 @@ export function createIngestion({ state, services, parts, source }) { function removeTrain(key) { const entry = state._trains.get(key); if (!entry) return; + // A first-person view on it loses its subject (cockpitSubject.js); its + // feed stays polled until the view lets go, so the train is found again + // if the feed lists it again. + if (state._cockpitSubjectKey === key) { + state._cockpitSubjectKey = null; + state._cockpitLostFeedId = entry.feedId; + } if (state._selectedKey === key) parts.selection.clearSelection(); if (entry.marker) state._markers?.remove(entry.marker); entry.marker = null; diff --git a/src/layers/rail/lifecycle.js b/src/layers/rail/lifecycle.js index e44dc0ce3..67501df7c 100644 --- a/src/layers/rail/lifecycle.js +++ b/src/layers/rail/lifecycle.js @@ -95,6 +95,7 @@ export function createLifecycle({ state, services, parts, timetable }) { parts.rendering.onPreRender, ); state._networkFade = -1; + parts.rendering.startModels(); parts.viewport.loadCatalog(); parts.viewport.runProximityCheck(); restoreSpriteOrder(viewer); @@ -125,8 +126,11 @@ export function createLifecycle({ state, services, parts, timetable }) { parts.ingestion.abortAllInFlight(); state._activeFeeds.clear(); state._heldFeedId = null; + state._cockpitSubjectKey = null; + state._cockpitLostFeedId = null; state._feedStatus.clear(); dropAllTrains(); + parts.rendering.stopModels(); // Floor cells and track samples queued for trains now gone. parts.rendering.reset(); parts.routes.hideAll(); @@ -154,6 +158,7 @@ export function createLifecycle({ state, services, parts, timetable }) { /** Tear down the collection and release every shared registration. */ destroy(viewer) { this.disable(viewer); + parts.rendering.stopModels(true); parts.routes.destroy(); if (state._markers) { unregisterSpriteCollection('rail', state._markers); diff --git a/src/layers/rail/pathBinding.js b/src/layers/rail/pathBinding.js index 36814c036..9eba452d3 100644 --- a/src/layers/rail/pathBinding.js +++ b/src/layers/rail/pathBinding.js @@ -47,11 +47,31 @@ export function pathWindowM(dtMs) { return window > PATH_WINDOW_MAX_M ? NaN : window; } +/** Arc moved between two reports before it says which way a train runs (m). */ +const DIRECTION_MIN_M = 1; + +/** +1 or -1 for an arc step longer than DIRECTION_MIN_M, else `known`. */ +function directionOf(step, known) { + return Number.isFinite(step) && Math.abs(step) > DIRECTION_MIN_M + ? Math.sign(step) + : known; +} + function createBinding(shapeIndex, path, via, s, fix) { return { shapeIndex, path, via, + /** + * Which way the drawn train runs along the shape: +1 along it, -1 + * against it, 0 until two reports or a playback segment tell. Kept until + * a rebind. Once playback has placed the train, only playback changes + * it: the newest reports run ahead of the drawn train, so a reversal + * they show turns it when playback reaches it. + */ + direction: 0, + /** The direction the last placed playback segment ran, 0 before one. */ + drawDirection: 0, /** Arc and time of the newest report. */ fixS: s, fixT: fix.t, @@ -139,6 +159,8 @@ export function followFix(binding, fix) { pathWindowM(fix.t - binding.fixT), ); if (!hit || hit.offsetM > PATH_OFF_TRACK_M) return false; + if (!binding.drawDirection) + binding.direction = directionOf(hit.s - binding.fixS, binding.direction); binding.fixS = hit.s; binding.fixT = fix.t; binding.fixLat = fix.lat; @@ -230,6 +252,13 @@ export function placeOnPath(binding, sample, segment, out) { cache.sFrom = sFrom; cache.sTo = sTo; cache.ok = Number.isFinite(sFrom) && Number.isFinite(sTo); + if (cache.ok) { + binding.drawDirection = directionOf( + sTo - sFrom, + binding.drawDirection || binding.direction || 0, + ); + binding.direction = binding.drawDirection; + } } if (!cache.ok) return false; const f = Number.isFinite(sample.fraction) diff --git a/src/layers/rail/pathBinding.test.mjs b/src/layers/rail/pathBinding.test.mjs index c8983a696..7b9e11c0c 100644 --- a/src/layers/rail/pathBinding.test.mjs +++ b/src/layers/rail/pathBinding.test.mjs @@ -201,3 +201,64 @@ test('a segment whose reports do not fit the line keeps the straight line', () = assert.equal(placeOnPath(binding, stale.sample, stale.segment, out), false); assert.equal(placeOnPath(null, stale.sample, stale.segment, out), false); }); + +test('a binding learns which way its train runs, and keeps it through a stop', () => { + const p = pack(); + const ids = { tripId: 'full', routeId: 'main' }; + const along = bindTrainPath(p, ids, fix(0, 500, 0)); + assert.equal(along.direction, 0, 'one report tells no direction'); + assert.ok(followFix(along, fix(10_000, 700, 0))); + assert.equal(along.direction, 1); + // Standing still (under a metre) keeps what it learnt. + assert.ok(followFix(along, fix(20_000, 700.4, 0))); + assert.equal(along.direction, 1); + const against = bindTrainPath(p, ids, fix(0, 1500, 0)); + assert.ok(followFix(against, fix(10_000, 1200, 0))); + assert.equal(against.direction, -1); + // A playback segment between two reports tells it too. + const played = bindTrainPath(p, ids, fix(0, 300, 0)); + const segment = { + from: { seq: 1, ...fix(0, 300, 0) }, + to: { seq: 2, ...fix(10_000, 100, 0) }, + }; + assert.ok( + placeOnPath(played, { fromSeq: 1, toSeq: 2, fraction: 0.5 }, segment, {}), + ); + assert.equal(played.direction, -1); + // A rebind starts again. + const rebound = rebindTrainPath( + p, + { routeId: 'main' }, + along, + fix(30_000, 2000, -300), + ); + assert.equal(rebound?.direction ?? 0, 0); +}); + +test('a report that runs back turns the drawn train only when playback does', () => { + const p = pack(); + const ids = { tripId: 'full', routeId: 'main' }; + const binding = bindTrainPath(p, ids, fix(0, 300, 0)); + const forward = { + from: { seq: 1, ...fix(0, 300, 0) }, + to: { seq: 2, ...fix(10_000, 500, 0) }, + }; + const sample = (fraction) => ({ fromSeq: 1, toSeq: 2, fraction }); + assert.ok(placeOnPath(binding, sample(0.2), forward, {})); + assert.equal(binding.direction, 1); + // The newest report lies behind the last one (a reversal, or GPS jitter) + // while playback still draws the forward segment. + assert.ok(followFix(binding, fix(10_000, 500, 0))); + assert.ok(followFix(binding, fix(20_000, 450, 0))); + assert.ok(placeOnPath(binding, sample(0.6), forward, {})); + assert.equal(binding.direction, 1, 'the drawn train still runs forward'); + // Playback reaches the reversed segment: now it turns. + const back = { + from: { seq: 2, ...fix(10_000, 500, 0) }, + to: { seq: 3, ...fix(20_000, 450, 0) }, + }; + assert.ok( + placeOnPath(binding, { fromSeq: 2, toSeq: 3, fraction: 0.5 }, back, {}), + ); + assert.equal(binding.direction, -1); +}); diff --git a/src/layers/rail/rendering.js b/src/layers/rail/rendering.js index 7a902bc7d..0c0bae500 100644 --- a/src/layers/rail/rendering.js +++ b/src/layers/rail/rendering.js @@ -30,6 +30,22 @@ import { UNSELECTED_TRAIN_ALPHA, } from './policy.js'; import { placeOnPath } from './pathBinding.js'; +import { + fleetModelSet, + loadFleetModelSet, + transitModelSpec, +} from '../../data/fleetModels.js'; +import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; + +/** How often the trains near enough for a model are gathered again (wall ms). */ +const MODEL_CANDIDATES_MS = 500; +/** Each category's colour, painted where a train model marks it. */ +const CATEGORY_TINTS = Object.fromEntries( + Object.entries(RAIL_CATEGORY_COLORS).map(([category, css]) => [ + category, + Cesium.Color.fromCssColorString(css), + ]), +); const SPRITE_PX = 64; /** @@ -529,6 +545,27 @@ export function createRendering({ state, services, parts }) { ); } + /** + * Whether a train's icon is drawn: shown, not drawn by its model, and no + * first-person view rides it (cockpitSubject.js). A held train keeps its + * playback; only its icon and model hide. + */ + function markerShown(entry) { + return ( + categoryShown(entry) && + !entry.modelOwnsVisual && + entry.key !== state._cockpitSubjectKey + ); + } + + /** + * Whether the train's playback runs: it is drawn, or a first-person view + * rides it, which keeps it moving, hidden, in a category turned off. + */ + function playing(entry) { + return categoryShown(entry) || entry.key === state._cockpitSubjectKey; + } + /** * A train bound to its railway line is drawn on the line, at the arc its * playback has reached; any other train (or a bound train whose reports do @@ -558,7 +595,7 @@ export function createRendering({ state, services, parts }) { undefined, scratchPosition, ); - entry.marker.show = categoryShown(entry); + entry.marker.show = markerShown(entry); } /** While a train is selected, every other train is drawn see-through. */ @@ -576,7 +613,7 @@ export function createRendering({ state, services, parts }) { entry.marker.width = size; entry.marker.height = size; entry.marker.color = tintFor(entry); - entry.marker.show = categoryShown(entry); + entry.marker.show = markerShown(entry); } /** Re-tint every train after the selection appeared, moved or cleared. */ @@ -615,7 +652,7 @@ export function createRendering({ state, services, parts }) { place(entry); const sample = entry.sample; const moving = - categoryShown(entry) && + playing(entry) && sample?.phase === 'playing' && sample.segmentSpeedMps > 0; if (moving) { @@ -623,7 +660,7 @@ export function createRendering({ state, services, parts }) { return; } state._moving.delete(entry); - if (categoryShown(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { + if (playing(entry) && Number.isFinite(sample?.nextWakeMonoMs)) { entry.wakeTimer = setTimeout( () => { entry.wakeTimer = null; @@ -680,7 +717,7 @@ export function createRendering({ state, services, parts }) { function cullSelected() { const entry = state._trains.get(state._selectedKey); if (!entry?.marker) return; - const shown = categoryShown(entry) && facesCamera(entry.marker.position); + const shown = markerShown(entry) && facesCamera(entry.marker.position); if (entry.marker.show !== shown) entry.marker.show = shown; parts.routes.cullStops(facesCamera); } @@ -688,6 +725,134 @@ export function createRendering({ state, services, parts }) { function onPreRender() { advanceMoving(); if (state._enabled && state._selectedKey !== null) cullSelected(); + if (state._enabled) updateModels(); + } + + // Under the DISPLAY rail's 3D toggle, trains near the camera are drawn as + // the project's procedural fleet models, chosen by category and mode, + // painted in their category colour where the model marks it, on the + // aircraft layers' icon-to-model rules (services.fleet). The fleet set + // loads once, up front, when the layer starts. + let fleetModels = null; + /** Items drawn last frame, and the map the next frame's are gathered into. */ + let modelItems = new Map(); + let spareItems = new Map(); + let modelCandidatesAtMs = -Infinity; + + /** Load the fleet set and create the layer's models (on enable). */ + function startModels() { + const fleet = services.fleet; + if (!fleet?.policy || !state._viewer) return; + loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => { + /* models stay off; trains keep their icons */ + }); + fleetModels ||= createFleetModelLayer({ + viewer: state._viewer, + display: fleet.display, + policy: fleet.policy, + resolveAsset: fleet.resolveAsset, + }); + } + + /** Hand a train's visual back to its icon. */ + function handBack(entry) { + if (!entry.modelOwnsVisual) return; + entry.modelOwnsVisual = false; + if (entry.marker) entry.marker.show = markerShown(entry); + } + + function modelItem(entry, set) { + let item = modelItems.get(entry.key); + if (!item || item.entry !== entry) + item = { + entry, + pickId: entry.key, + modelOwnsVisual: false, + // Markers are drawn this far above the track; a model stands on it. + groundLiftM: GROUND_FLOOR_LIFT_M, + }; + const specKey = `${entry.category}|${entry.railMode}`; + if (item.specKey !== specKey) { + item.specKey = specKey; + item.spec = transitModelSpec(set, { + railCategory: entry.category, + railMode: entry.railMode, + }); + item.tint = CATEGORY_TINTS[entry.category] || CATEGORY_TINTS.local; + } + item.position = entry.marker.position; + item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0; + // A train a first-person view rides draws no model: the camera is inside + // it (cockpitSubject.js). + item.show = categoryShown(entry) && entry.key !== state._cockpitSubjectKey; + return item; + } + + function updateModels() { + if (!fleetModels) return; + if (!fleetModels.active() || state._trains.size === 0) { + if (modelItems.size) { + for (const item of modelItems.values()) handBack(item.entry); + modelItems.clear(); + fleetModels.frame(modelItems, performance.now()); + } + return; + } + const set = fleetModelSet(); + const now = performance.now(); + const next = spareItems; + next.clear(); + if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) { + modelCandidatesAtMs = now; + const camera = state._viewer?.camera; + const policy = services.fleet.policy; + const keepM = + services.fleet.display?.()?.models3dMode === 'all' + ? policy.keepAllM + : policy.keepM; + const limitSq = keepM * keepM * 1.44; + if (camera?.positionWC) + for (const entry of state._trains.values()) { + if (!entry.marker || !categoryShown(entry)) continue; + if ( + Cesium.Cartesian3.distanceSquared( + camera.positionWC, + entry.marker.position, + ) > limitSq + ) + continue; + next.set(entry.key, modelItem(entry, set)); + } + } else { + for (const [key, item] of modelItems) + if (state._trains.get(key) === item.entry && item.entry.marker) + next.set(key, modelItem(item.entry, set)); + } + for (const [key, item] of modelItems) + if (next.get(key) !== item) handBack(item.entry); + spareItems = modelItems; + modelItems = next; + fleetModels.frame(modelItems, now); + for (const item of modelItems.values()) { + const entry = item.entry; + if (item.modelOwnsVisual) { + entry.modelOwnsVisual = true; + entry.marker.show = false; + } else handBack(entry); + } + if (fleetModels.settling) governorRequestRender('rail-models'); + } + + /** Drop every model (layer disabled); `destroy` also removes the collection. */ + function stopModels(destroy = false) { + for (const item of modelItems.values()) item.entry.modelOwnsVisual = false; + modelItems.clear(); + spareItems.clear(); + modelCandidatesAtMs = -Infinity; + if (destroy) { + fleetModels?.destroy(); + fleetModels = null; + } else fleetModels?.clear(); } function advanceMoving() { @@ -719,7 +884,7 @@ export function createRendering({ state, services, parts }) { function applyCategoryVisibility() { for (const entry of state._trains.values()) { if (!entry.marker) continue; - entry.marker.show = categoryShown(entry); + entry.marker.show = markerShown(entry); schedulePlayback(entry); } syncRenderHold(); @@ -742,5 +907,7 @@ export function createRendering({ state, services, parts }) { applyCategoryVisibility, categoryShown, reset, + startModels, + stopModels, }; } diff --git a/src/layers/rail/routeDisplay.js b/src/layers/rail/routeDisplay.js index 283d19295..69c1dc840 100644 --- a/src/layers/rail/routeDisplay.js +++ b/src/layers/rail/routeDisplay.js @@ -427,10 +427,29 @@ export function createRouteDisplay({ updateSplit(); } + /** + * Show the selected line unless a first-person view rides its train + * (cockpitSubject.js): from inside the train it would run up the middle of + * the view and under the eye. + */ + function applyHold() { + const route = state._route; + if (!route?.primitive) return; + const shown = !( + state._cockpitSubjectKey !== null && + state._cockpitSubjectKey === state._selectedKey + ); + if (route.primitive.show !== shown) { + route.primitive.show = shown; + governorRequestRender('rail-route'); + } + } + /** Move the travelled/ahead split to where the train is drawn now. */ function updateSplit() { const route = state._route; if (!route?.primitive) return; + applyHold(); const binding = route.binding; const s = binding ? binding.drawS : NaN; if (route.direction === 0 && Number.isFinite(s)) { @@ -777,6 +796,7 @@ export function createRouteDisplay({ applyNetwork, onCamera, syncRoute, + applyHold, refreshStops, cullStops, onSelectionChanged, diff --git a/src/layers/rail/selection.js b/src/layers/rail/selection.js index 4f4564bc6..e644c26d1 100644 --- a/src/layers/rail/selection.js +++ b/src/layers/rail/selection.js @@ -19,10 +19,15 @@ import { t } from '../../i18n/index.js'; export function createSelection({ state, services, parts }) { const { governorRequestRender } = services.render; - /** The card's anchor: the marker's current position, read on every paint. */ + /** + * The card's anchor: the marker's current position, read on every paint, + * whether the train is drawn as its marker or as its 3D model. + */ function anchorFor(entry) { return () => { - const p = entry.marker?.show !== false ? entry.marker?.position : null; + const drawn = + entry.marker?.show !== false || entry.modelOwnsVisual === true; + const p = drawn ? entry.marker?.position : null; return p && Number.isFinite(p.x) && Number.isFinite(p.y) ? p : null; }; } @@ -30,6 +35,16 @@ export function createSelection({ state, services, parts }) { function refreshSelectedCard(force) { const entry = state._trains.get(state._selectedKey); if (!entry) return; + // A train a first-person view rides shows no card. + if (entry.key === state._cockpitSubjectKey) { + state._selectedCardText = null; + state._overlayHost.setEntries( + RAIL_SELECTED_OVERLAY_SOURCE_ID, + [], + RAIL_SELECTED_OVERLAY_SOURCE_OPTIONS, + ); + return; + } const now = Date.now(); if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS) return; @@ -98,6 +113,8 @@ export function createSelection({ state, services, parts }) { */ function selectAndFocusTrain(key) { if (!state._trains.has(key)) return; + // A selection by the user, even of the selected train (cockpitSubject.js). + state._selectedAtMs = Date.now(); const following = parts.follow.isFollowing(key); // Another train's follow lets go first, so this is never taken for a // move between two copies of one train. @@ -115,6 +132,7 @@ export function createSelection({ state, services, parts }) { const hadSelection = state._selectedKey !== null; parts.follow.release({ untrack }); state._selectedKey = null; + state._selectedAtMs = null; state._selectedCardText = null; if (hadSelection) { parts.rendering.paintAll(); @@ -131,6 +149,8 @@ export function createSelection({ state, services, parts }) { } function onKeyDown(event) { + // While a first-person view rides the train, Escape is the view's. + if (state._cockpitSubjectKey) return; if (event.key === 'Escape' && state._selectedKey) clearSelection(); } @@ -141,6 +161,8 @@ export function createSelection({ state, services, parts }) { ); state._clickHandler.setInputAction((click) => { if (!state._enabled || !isPointerFree()) return; + // A held train keeps the selection until its view lets it go. + if (state._cockpitSubjectKey) return; const picked = viewer.scene.pick(click.position); const id = typeof picked?.id === 'string' ? picked.id : null; if (id && state._trains.has(id)) selectAndFocusTrain(id); diff --git a/src/layers/rail/viewport.js b/src/layers/rail/viewport.js index 03771bd6c..065687d7d 100644 --- a/src/layers/rail/viewport.js +++ b/src/layers/rail/viewport.js @@ -148,6 +148,12 @@ export function createViewport({ state, services, parts, source }) { )) if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed); } + // A first-person view whose train its feed dropped keeps the feed polled + // until it lets go (cockpitSubject.js): from the cab the camera looks + // along the ground, often at a point beyond the feed's range. + const lost = state._cockpitLostFeedId; + if (lost && !desired.has(lost) && state._activeFeeds.has(lost)) + desired.set(lost, state._activeFeeds.get(lost)); // The selected train's feed stays polled however far out or away the // camera goes, so the train and its line stay on the map; while nothing // else would keep the feed, it carries the selected train alone and its diff --git a/src/layers/transit/cockpitSubject.js b/src/layers/transit/cockpitSubject.js new file mode 100644 index 000000000..d598e572a --- /dev/null +++ b/src/layers/transit/cockpitSubject.js @@ -0,0 +1,242 @@ +import * as Cesium from 'cesium'; +import { + getRegisteredTransitFeed, + transitRouteLabel, +} from '../../data/transitFeeds.js'; +import { displayMotion } from './movement.js'; + +/** + * `previous` when it holds each of `next`'s values, else a frozen copy of + * `next`: a part of a snapshot that did not change is the one read before. + * `next` is a draft, filled again on every read. + */ +function reuse(previous, next) { + if (!previous) return Object.freeze({ ...next }); + for (const name in next) + if (!Object.is(previous[name], next[name])) + return Object.freeze({ ...next }); + return previous; +} + +/** + * Read access to one vehicle, and a hold on it for a first-person view. + * + * `getSelectedVehicle()` and `getCockpitSubject()` describe the selected + * vehicle and the held one as a frozen snapshot, read again on each call: + * where it is drawn, how fast it moves and how that is known. A call while + * nothing in it changed returns the snapshot it returned before, and a part + * that did not change (its feed, its stop) is the same object in the next + * one, so a view can read it on every frame. Transit has no route shapes or + * stops, so a snapshot names only the vehicle's last reported stop. + * `setCockpitSubject(key)` holds a vehicle: its sprite, 3D model, card, + * trail and detection box are hidden, it keeps moving when it is out of the camera's + * view, its feed stays polled, and clicks and Escape no longer change the + * selection. The hold ends with `setCockpitSubject(null)`, and on + * its own when the vehicle is removed or the layer is turned off. + * `focusVehicle(key)` selects a vehicle, as a click does. A snapshot's + * `selectedAtMs` is when the user last selected the selected vehicle (a + * click, or `focusVehicle`), even again. It is null for any other vehicle, + * after a deselection, and for a vehicle selected only by a hold: a hold is + * not a user's selection. + * + * @param {object} context The layer's state, parts and services. + * @returns {{methods: object}} + */ +export function createTransitCockpitSubject({ state, parts, services }) { + const liftM = Number.isFinite(services.ground?.GROUND_FLOOR_LIFT_M) + ? services.ground.GROUND_FLOOR_LIFT_M + : 0; + const finiteOrNull = (value) => + typeof value === 'number' && Number.isFinite(value) ? value : null; + /** Each vehicle's last snapshot, returned again while nothing changed. */ + const views = new WeakMap(); + /** The drafts vehicleView fills on every read (reuse). */ + const draft = { + view: {}, + feed: {}, + position: {}, + motion: {}, + stop: {}, + provenance: {}, + }; + + /** The feed in use, else the registry's entry for it. */ + function feedEntry(feedId) { + return state._activeFeeds.get(feedId) || getRegisteredTransitFeed(feedId); + } + + function feedOf(feed, previous) { + if (!feed) return null; + const next = draft.feed; + next.id = feed.id; + next.name = feed.name ?? null; + next.operator = feed.operator ?? null; + next.region = feed.region ?? null; + next.attribution = feed.attribution ?? null; + next.license = feed.license ?? null; + next.licenseUrl = feed.licenseUrl ?? null; + return reuse(previous, next); + } + + const scratchCartographic = new Cesium.Cartographic(); + /** + * The floor under the vehicle where playback draws it, without the shared + * lift: the drawn marker's height while the frame pass places it (in view, + * or held), which near the ground follows the terrain between reports; + * else its segment's two reports' floors at the playback fraction. Null + * while either floor is unknown, as on cold terrain, so a view waits for + * the surface. Not `entry.heightM`, the floor at the newest report, which + * playback has not reached yet. + */ + function drawnFloorOf(entry) { + const segment = entry.segment; + if (!segment || entry.heightPending) return null; + const placed = + entry.surfaceReady === true && + entry.hasRendered && + entry.marker?.position && + (state._visible.has(entry) || entry.key === state._cockpitSubjectKey); + if (placed) { + const drawn = Cesium.Cartographic.fromCartesian( + entry.marker.position, + undefined, + scratchCartographic, + ); + if (drawn) return drawn.height - liftM; + } + const from = segment.from?.h; + const to = segment.to?.h; + if (!Number.isFinite(from) || !Number.isFinite(to)) return null; + const f = Number.isFinite(segment.fraction) + ? Math.max(0, Math.min(1, segment.fraction)) + : 1; + return from + (to - from) * f - liftM; + } + + /** The vehicle as a frozen snapshot (see the module comment), or null. */ + function vehicleView(entry) { + if (!entry?.record) return null; + const previous = views.get(entry); + const record = entry.record; + const sample = entry.sample ?? null; + const feed = feedEntry(entry.feedId); + const reported = finiteOrNull(record.speedMps); + const derived = finiteOrNull(sample?.segmentSpeedMps); + const status = state._feedStatus.get(entry.feedId); + + const position = draft.position; + position.latitude = finiteOrNull(sample?.lat) ?? record.lat; + position.longitude = finiteOrNull(sample?.lon) ?? record.lon; + position.groundHeightM = drawnFloorOf(entry); + position.atMs = finiteOrNull(sample?.displayT); + + const motion = draft.motion; + motion.reportedSpeedMps = reported; + motion.derivedSpeedMps = derived; + motion.speedSource = + reported !== null ? 'reported' : derived !== null ? 'derived' : null; + motion.courseDeg = finiteOrNull(entry.courseDeg); + motion.reportedBearingDeg = finiteOrNull(record.bearing); + motion.segmentCourseDeg = finiteOrNull(sample?.segmentCourseDeg); + motion.phase = sample?.phase ?? null; + motion.word = displayMotion(entry, Date.now()).word; + motion.reportAgeMs = finiteOrNull(sample?.latestReportAgeMs); + motion.shownBehindMs = finiteOrNull(sample?.actualDelayMs); + motion.feedStale = status?.stale === true; + + const stop = draft.stop; + stop.stopId = record.stopId || null; + stop.status = record.status || null; + + const provenance = draft.provenance; + provenance.positionSource = 'gps'; + provenance.timestampSource = record.timestampSource ?? null; + + const view = draft.view; + view.key = entry.key; + view.feedId = entry.feedId; + view.selectedAtMs = + state._selectedKey === entry.key ? state._selectedAtMs : null; + view.id = String(record.id ?? ''); + view.routeId = record.routeId || null; + view.routeLabel = + (feed && record.routeId + ? transitRouteLabel(feed, record.routeId) + : null) || null; + view.tripId = record.tripId || null; + view.directionId = record.directionId ?? null; + view.label = record.label || null; + view.mode = entry.mode ?? null; + view.modeInferred = entry.modeInferred === true; + view.feed = feedOf(feed, previous?.feed); + view.position = reuse(previous?.position, position); + view.motion = reuse(previous?.motion, motion); + view.stop = reuse(previous?.stop, stop); + view.provenance = reuse(previous?.provenance, provenance); + const snapshot = reuse(previous, view); + views.set(entry, snapshot); + return snapshot; + } + + /** Show or hide a vehicle's sprite and trail for the hold. */ + function repaint(entry) { + if (!entry?.marker) return; + if (entry.key === state._cockpitSubjectKey) entry.marker.show = false; + else parts.rendering.placeSample(entry); + parts.rendering.schedulePlayback(entry); + parts.trails.update(); + state._detectRevision += 1; + } + + const methods = { + /** The selected vehicle (see createTransitCockpitSubject), or null. */ + getSelectedVehicle() { + return vehicleView(state._vehicles.get(state._selectedKey)); + }, + /** The held vehicle, or null when none is held. */ + getCockpitSubject() { + return vehicleView(state._vehicles.get(state._cockpitSubjectKey)); + }, + /** + * Hold the vehicle `key` for a first-person view, or release the hold + * with null. A vehicle it holds is selected, if it was not already. + * @param {string|null} key + * @returns {boolean} False for a vehicle the layer does not show. + */ + setCockpitSubject(key) { + const previous = state._vehicles.get(state._cockpitSubjectKey); + if (key === null || key === undefined) { + state._cockpitSubjectKey = null; + // The hold kept it in play out of view: it leaves until the next + // visibility sweep finds it in view again. + if (previous) { + state._visible.delete(previous); + parts.rendering.requestVisibility(); + } + repaint(previous); + parts.selection.refreshSelectedCard(true); + return true; + } + const entry = state._vehicles.get(key); + if (!entry?.marker) return false; + if (state._selectedKey !== key) parts.selection.selectVehicle(key); + state._cockpitSubjectKey = key; + state._visible.add(entry); + if (previous && previous !== entry) repaint(previous); + repaint(entry); + parts.selection.refreshSelectedCard(true); + return true; + }, + /** + * Select the vehicle `key`, as a click on it does. + * @param {string} key + * @returns {boolean} False for a vehicle the layer does not show. + */ + focusVehicle(key) { + if (!state._vehicles.get(key)?.marker) return false; + parts.selection.selectVehicle(key, { byUser: true }); + return true; + }, + }; + return { methods, vehicleView }; +} diff --git a/src/layers/transit/index.js b/src/layers/transit/index.js index 171e0fb82..e6bf1ffa7 100644 --- a/src/layers/transit/index.js +++ b/src/layers/transit/index.js @@ -10,6 +10,7 @@ import { createQueries } from './queries.js'; import { createControls } from './controls.js'; import { createLifecycle } from './lifecycle.js'; import { createTesting } from './testing.js'; +import { createTransitCockpitSubject } from './cockpitSubject.js'; import { TRANSIT_POLL_MS } from './policy.js'; /** @@ -54,12 +55,14 @@ export function createTransitLayer({ parts.controls = createControls(context); parts.lifecycle = createLifecycle(context); parts.testing = createTesting(context); + parts.cockpit = createTransitCockpitSubject(context); return Object.assign( { updateInterval: TRANSIT_POLL_MS }, parts.lifecycle.methods, parts.queries.methods, parts.controls.methods, + parts.cockpit.methods, parts.testing, ); } diff --git a/src/layers/transit/ingestion.js b/src/layers/transit/ingestion.js index 3691af86f..11b641bdf 100644 --- a/src/layers/transit/ingestion.js +++ b/src/layers/transit/ingestion.js @@ -72,6 +72,8 @@ export function createIngestion({ state, services, parts, source }) { function removeVehicle(key) { const entry = state._vehicles.get(key); if (!entry) return; + // A first-person view on it loses its subject (cockpitSubject.js). + if (state._cockpitSubjectKey === key) state._cockpitSubjectKey = null; if (state._selectedKey === key) parts.selection.clearSelection(); if (entry.marker) entry.markerCollection?.remove(entry.marker); entry.marker = null; diff --git a/src/layers/transit/lifecycle.js b/src/layers/transit/lifecycle.js index 90210063a..ad5a4d109 100644 --- a/src/layers/transit/lifecycle.js +++ b/src/layers/transit/lifecycle.js @@ -151,6 +151,7 @@ export function createLifecycle({ state, services, parts }) { state._vehicles.clear(); parts.height.clear(); state._selectedKey = null; + state._cockpitSubjectKey = null; state._lastUpdate = null; state._error = null; state._limitWarned = false; @@ -195,6 +196,7 @@ export function createLifecycle({ state, services, parts }) { parts.rendering.onPreRender, ); } + parts.rendering.startModels(); parts.viewport.runProximityCheck(); restoreSpriteOrder(viewer); }, @@ -242,6 +244,7 @@ export function createLifecycle({ state, services, parts }) { destroyTrack(entry.track); } state._vehicles.clear(); + parts.rendering.stopModels(); parts.height.clear(); state._markers?.removeAll(); state._animatedMarkers?.removeAll(); @@ -282,6 +285,7 @@ export function createLifecycle({ state, services, parts }) { */ destroy(viewer) { this.disable(viewer); + parts.rendering.stopModels(true); if (state._markers) { unregisterSpriteCollection('transit', state._markers); viewer?.scene?.primitives?.remove(state._markers); diff --git a/src/layers/transit/lifecycle.test.mjs b/src/layers/transit/lifecycle.test.mjs index fee9a60ed..71d9d1df4 100644 --- a/src/layers/transit/lifecycle.test.mjs +++ b/src/layers/transit/lifecycle.test.mjs @@ -7,9 +7,11 @@ import { FEED_STALE_AFTER_MS, MISSED_POLLS_TO_DROP, TRANSIT_POLL_MS, + TRANSIT_SELECTED_OVERLAY_SOURCE_ID, VEHICLE_MAX_FIX_AGE_MS, } from './policy.js'; import { FLOOR_WARM_PER_POLL } from './policy.js'; +import { fleetModelFakes } from '../../testSupport/fleetModelFakes.mjs'; import { FUTURE_STAMP_TOLERANCE_MS, ORDER_REJECTS_BEFORE_RESET, @@ -65,6 +67,25 @@ function snapshot( }; } +/** A camera's place and frame looking straight down from `altitude` over `at`. */ +function lookingDown(at, altitude) { + const positionWC = Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude); + const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( + positionWC, + new Cesium.Cartesian3(), + ); + return { + positionWC, + directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()), + upWC: Cesium.Cartesian3.UNIT_Z, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; +} + /** A vehicle record as the proxy emits it, with its timestamp source resolved. */ function vehicle(id, lat, lon, atSeconds, extra = {}) { return { @@ -159,6 +180,9 @@ function harness( east: at.lon + 0.1, north: at.lat + 0.1, }, + // Fleet-model services (testSupport/fleetModelFakes.mjs), with a camera + // looking straight down for them; without them vehicles stay icons. + fleet = null, } = {}, ) { const viewRect = { ...view }; @@ -219,6 +243,7 @@ function harness( viewRect.north, ); }, + ...(fleet ? lookingDown(at, altitude) : {}), }, scene: { canvas: { addEventListener() {}, removeEventListener() {} }, @@ -265,6 +290,7 @@ function harness( const layer = createTransitLayer({ source, services: { + ...(fleet ? { fleet } : {}), ground, mesh: { sampleMeshFloorCells(scene, points, options) { @@ -727,6 +753,27 @@ test('disable aborts in-flight work and a response that lands late is ignored', assert.equal(app.vehicles().length, 1); }); +test('a held vehicle with no floor yet tells its view no height', async (t) => { + // Not the ellipsoid: a first-person view waits for the surface instead. + const app = harness(t, { floorAt: () => undefined }); + app.serve('mbta', () => ({ + status: 200, + body: snapshot( + 'mbta', + 'MBTA', + [vehicle('bus-1', 42.36, -71.06, Date.now() / 1000)], + { fetchedAt: Date.now() }, + ), + })); + app.layer.enable(app.viewer); + await app.layer.update(); + app.settle(); + const entry = app.vehicles()[0]; + assert.equal(entry.heightPending, true, 'it has nothing to stand on'); + assert.ok(app.layer.setCockpitSubject(entry.key) !== false); + assert.equal(app.layer.getCockpitSubject().position.groundHeightM, null); +}); + test('a vehicle waits for its floor rather than standing on the ellipsoid', async (t) => { // The shared ground-floor system answers from the rendered mesh cell or the // real DEM cell; until one of them is warm there is no honest height to draw @@ -1774,11 +1821,19 @@ test('a segment climbs from the ground it left to the ground it reaches', async assert.ok(Math.abs(entry.heightM - 101.5) < 1, 'the destination is uphill'); const height = () => Cesium.Cartographic.fromCartesian(entry.marker.position).height; + // A first-person view reads the floor where the vehicle is drawn, not the + // newest report's floor uphill. + app.layer.setCockpitSubject(entry.key); + const floor = () => app.layer.getCockpitSubject().position.groundHeightM; app.run(400); assert.ok( height() < 30, `still on the ground it is drawn on, got ${height().toFixed(1)} m`, ); + assert.ok( + Math.abs(floor() + 1.5 - height()) < 1, + `its snapshot stands where it is drawn, got ${floor().toFixed(1)} m`, + ); // Halfway along the segment it is halfway up; at the end, on the high ground. const firstFixAt = start - AGE_MS; @@ -1792,6 +1847,7 @@ test('a segment climbs from the ground it left to the ground it reaches', async Math.abs(height() - 101.5) < 1.5, `and arrives on the high ground, got ${height().toFixed(1)} m`, ); + assert.ok(Math.abs(floor() - 100) < 1.5, 'and so does its snapshot'); }); test('a lone impossible fix leaves the displayed vehicle and health untouched', async (t) => { const app = harness(t, { floorAt: () => 5 }); @@ -4142,3 +4198,209 @@ test('the prompt names the altitude the gate opens at, not 3,000 km', async (t) 'Fly below 2,850 km to a covered region', ); }); + +/** A bus running north a step a poll, polled `polls` times; its key. */ +async function busRunningNorth(app, polls = 4, extra = {}) { + let step = 0; + app.serve('mbta', () => ({ + status: 200, + body: snapshot( + 'mbta', + 'MBTA', + [ + vehicle('bus-1', 42.36 + step * 0.001, -71.06, reported(), { + stopId: 'place-1', + status: 'IN_TRANSIT_TO', + ...extra, + }), + ], + { fetchedAt: Date.now() }, + ), + })); + app.layer.enable(app.viewer); + await app.layer.update(); + app.settle(); + for (let i = 0; i < polls; i += 1) { + step += 1; + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + app.settle(); + } + return 'mbta:bus-1'; +} + +test('the selected vehicle reads as a snapshot of its place, motion and source', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app, 4, { speedMps: 9 }); + assert.equal(app.layer.getSelectedVehicle(), null); + app.layer._transitPartsForTest().selection.selectVehicle(key); + const view = app.layer.getSelectedVehicle(); + assert.ok(Object.isFrozen(view)); + assert.equal(view.key, key); + assert.equal(view.feedId, 'mbta'); + assert.equal(view.id, 'bus-1'); + assert.equal(view.routeId, '1'); + assert.equal(typeof view.mode, 'string'); + assert.ok(view.feed.attribution, 'the feed names its credit'); + assert.equal(view.motion.reportedSpeedMps, 9); + assert.equal(view.motion.speedSource, 'reported'); + assert.equal(view.provenance.positionSource, 'gps'); + assert.deepEqual(view.stop, { stopId: 'place-1', status: 'IN_TRANSIT_TO' }); + const entry = app.vehicles()[0]; + // Where it is drawn, without the shared lift (read back from the marker). + assert.ok( + Math.abs(view.position.groundHeightM - (entry.heightM - 1.5)) < 1e-3, + ); + assert.ok(Number.isFinite(view.position.latitude)); +}); + +test('a vehicle read again while nothing changed is the snapshot read before', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + app.layer._transitPartsForTest().selection.selectVehicle(key); + const first = app.layer.getSelectedVehicle(); + assert.equal(app.layer.getSelectedVehicle(), first, 'the same snapshot'); + // It moves on: a new snapshot at its new place, what did not change kept. + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + app.settle(); + app.run(TRANSIT_POLL_MS); + const moved = app.layer.getSelectedVehicle(); + assert.notEqual(moved, first, 'a new snapshot'); + assert.ok(Object.isFrozen(moved) && Object.isFrozen(moved.position)); + assert.ok(moved.position.latitude > first.position.latitude, 'moved'); + assert.equal(moved.feed, first.feed); + assert.equal(moved.stop, first.stop); + assert.equal(moved.provenance, first.provenance); + assert.equal(app.layer.getSelectedVehicle(), moved); +}); + +test('a held vehicle draws no 3D model either; let go, its model draws again', async (t) => { + const models = await fleetModelFakes(t); + const app = harness(t, { fleet: models.fleet, altitude: 1_000 }); + const key = await busRunningNorth(app); + const [entry] = app.vehicles(); + assert.notEqual(entry.mode, 'unknown', 'a mode its model is chosen by'); + // The throttled sweep runs; a model's load answers off the frame and the + // next frames place it. + const frames = async () => { + app.advance(250); + app.settle(); + for (let i = 0; i < 8; i += 1) await Promise.resolve(); + app.settle(); + }; + await frames(); + assert.equal(models.drawn(key).length, 1, 'near the camera: its model'); + assert.equal(entry.modelOwnsVisual, true); + assert.equal(entry.marker.show, false, 'its icon hands over'); + + assert.equal(app.layer.setCockpitSubject(key), true); + app.frame(); + // The view's eye is inside the vehicle. + assert.equal(models.drawn(key).length, 0, 'no model'); + assert.equal(entry.modelOwnsVisual, false); + assert.equal(entry.marker.show, false, 'and no icon'); + await frames(); + assert.equal(models.drawn(key).length, 0, 'not on a later frame either'); + assert.equal(entry.marker.show, false); + + assert.equal(app.layer.setCockpitSubject(null), true); + await frames(); + assert.equal(models.drawn(key).length, 1, 'let go: its model again'); + assert.equal(entry.modelOwnsVisual, true); + assert.equal(entry.marker.show, false); +}); + +test('selectedAtMs is the user’s selection time; a hold and a deselection leave none', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + // A vehicle only a hold selected reports none. + assert.equal(app.layer.setCockpitSubject(key), true); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); + app.layer.setCockpitSubject(null); + assert.equal(app.layer.setCockpitSubject(null), true, 'twice is harmless'); + assert.equal(app.state()._selectedKey, key); + // The user's selection stamps it, again on a re-selection. + assert.equal(app.layer.focusVehicle(key), true); + const first = app.layer.getSelectedVehicle().selectedAtMs; + assert.ok(Number.isFinite(first)); + app.run(1000); + app.layer.focusVehicle(key); + assert.ok(app.layer.getSelectedVehicle().selectedAtMs > first); + // Deselected, then held: none. + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, null); + assert.equal(app.state()._selectedAtMs, null); + app.layer.setCockpitSubject(key); + assert.equal(app.layer.getCockpitSubject().selectedAtMs, null); +}); + +test('a held vehicle hides, keeps moving out of view, and lets go when it goes', async (t) => { + const app = harness(t); + const key = await busRunningNorth(app); + assert.equal(app.layer.setCockpitSubject('mbta:nope'), false); + assert.equal(app.layer.setCockpitSubject(key), true); + assert.equal(app.state()._selectedKey, key, 'holding selects it'); + assert.equal(app.layer.getCockpitSubject().key, key); + assert.equal( + app.overlaySources.has(TRANSIT_SELECTED_OVERLAY_SOURCE_ID), + false, + 'no card', + ); + const [entry] = app.vehicles(); + app.frame(); + assert.equal(entry.marker.show, false); + // The camera's view leaves it, as a view from inside it does: it plays on. + app.lookAt({ west: 10, south: 10, east: 11, north: 11 }); + app.settle(); + assert.equal( + app.layer._transitPartsForTest().rendering.vehicleInView(entry), + true, + ); + const before = app.layer.getCockpitSubject().position.latitude; + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + app.settle(); + app.run(TRANSIT_POLL_MS); + assert.ok( + app.layer.getCockpitSubject().position.latitude > before, + 'still moving', + ); + // Escape and clicks are the view's while it holds the vehicle. + globalThis.document.dispatchEvent( + Object.assign(new Event('keydown'), { key: 'Escape' }), + ); + assert.equal(app.state()._selectedKey, key); + + assert.equal(app.layer.setCockpitSubject(null), true); + assert.equal(app.layer.getCockpitSubject(), null); + // Let go, it is out of play until a sweep finds it in view. + assert.equal(app.state()._visible.has(entry), false, 'out of play'); + app.lookAt({ + west: -71.2, + south: 42.2, + east: -70.9, + north: 42.5, + }); + app.advance(250); + app.settle(); + assert.equal(entry.marker.show, true, 'shown again'); + assert.equal(app.layer.focusVehicle('mbta:nope'), false); + assert.equal(app.layer.focusVehicle(key), true); + + app.layer.setCockpitSubject(key); + app.silence('mbta'); + app.serve('mbta', () => ({ + status: 200, + body: snapshot('mbta', 'MBTA', [], { fetchedAt: Date.now() }), + })); + for (let i = 0; i < MISSED_POLLS_TO_DROP + 1; i += 1) { + app.run(TRANSIT_POLL_MS); + await app.layer.update(); + } + assert.equal(app.vehicles().length, 0, 'the vehicle is gone'); + assert.equal(app.state()._cockpitSubjectKey, null); + assert.equal(app.layer.getCockpitSubject(), null); +}); diff --git a/src/layers/transit/queries.js b/src/layers/transit/queries.js index 7a9f2e074..32633e775 100644 --- a/src/layers/transit/queries.js +++ b/src/layers/transit/queries.js @@ -129,7 +129,9 @@ export function createQueries({ state, parts }) { const visible = []; for (const entry of state._visible) { const marker = entry.marker; - if (!marker?.position || marker.show === false) continue; + // A vehicle drawn as its 3D model is present though its marker is hidden. + if (!marker?.position) continue; + if (marker.show === false && entry.modelOwnsVisual !== true) continue; visible.push(entry); } let chosen = visible; diff --git a/src/layers/transit/rendering.js b/src/layers/transit/rendering.js index 664b1688d..058aa3a5d 100644 --- a/src/layers/transit/rendering.js +++ b/src/layers/transit/rendering.js @@ -27,6 +27,12 @@ import { TRANSIT_MODE_COLORS, vehicleNearView, } from './policy.js'; +import { + fleetModelSet, + loadFleetModelSet, + transitModelSpec, +} from '../../data/fleetModels.js'; +import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; export const MODE_CESIUM_COLORS = Object.fromEntries( Object.entries(TRANSIT_MODE_COLORS).map(([mode, css]) => [ @@ -100,7 +106,12 @@ export function createRendering({ state, services, parts }) { ); entry.surfaceReady = !!known; entry.heightPending = !known; - entry.marker.show = !!known && vehicleInView(entry); + // A vehicle a first-person view rides keeps moving, unseen. + entry.marker.show = + !!known && + vehicleInView(entry) && + !entry.modelOwnsVisual && + entry.key !== state._cockpitSubjectKey; if (known) { entry.marker.position = state._scratchCartesian; entry.hasRendered = true; @@ -191,7 +202,7 @@ export function createRendering({ state, services, parts }) { * @returns {boolean} */ function vehicleInView(entry) { - return state._visible.has(entry); + return state._visible.has(entry) || entry.key === state._cockpitSubjectKey; } /** Cesium colour for a preset tuple, cached per profile+mode. */ @@ -403,6 +414,7 @@ export function createRendering({ state, services, parts }) { const now = state.now(); if (state._vehicles.size === 0) { state._moving.clear(); + updateModels(); syncRenderHold(); return; } @@ -450,9 +462,117 @@ export function createRendering({ state, services, parts }) { : 0; } if (state._heightDirty.size) governorRequestRender('transit-height-batch'); + updateModels(); syncRenderHold(); } + // Under the DISPLAY rail's 3D toggle, vehicles in view near the camera are + // drawn as the project's procedural fleet models, chosen by mode, painted + // in their mode colour where the model marks it, on the aircraft layers' + // icon-to-model rules (services.fleet). The fleet set loads once, up front, + // when the layer starts. + let fleetModels = null; + /** Items drawn last frame, and the map the next frame's are gathered into. */ + let modelItems = new Map(); + let spareItems = new Map(); + /** The lift icons are drawn with above the floor; a model stands on the floor. */ + const groundLiftM = services.ground?.GROUND_FLOOR_LIFT_M || 0; + + /** Load the fleet set and create the layer's models (on enable). */ + function startModels() { + const fleet = services.fleet; + if (!fleet?.policy || !state._viewer) return; + loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => { + /* models stay off; vehicles keep their icons */ + }); + fleetModels ||= createFleetModelLayer({ + viewer: state._viewer, + display: fleet.display, + policy: fleet.policy, + resolveAsset: fleet.resolveAsset, + }); + } + + /** Hand an entry's visual back to its icon, unless a view rides it. */ + function handBack(entry) { + if (!entry.modelOwnsVisual) return; + entry.modelOwnsVisual = false; + if (entry.marker) + entry.marker.show = + vehicleInView(entry) && + !entry.heightPending && + entry.surfaceReady !== false && + entry.key !== state._cockpitSubjectKey; + } + + function updateModels() { + if (!fleetModels) return; + if (!fleetModels.active() || state._vehicles.size === 0) { + if (modelItems.size) { + for (const item of modelItems.values()) handBack(item.entry); + modelItems.clear(); + fleetModels.frame(modelItems, performance.now()); + } + return; + } + const set = fleetModelSet(); + const next = spareItems; + next.clear(); + for (const entry of state._visible) { + // A vehicle of no known mode keeps its icon: no model says what it is. + if (!entry.marker || !entry.hasRendered || entry.mode === 'unknown') + continue; + let item = modelItems.get(entry.key); + if (!item || item.entry !== entry) { + item = { + entry, + pickId: entry.key, + modelOwnsVisual: false, + groundLiftM, + }; + } + if (item.mode !== entry.mode) { + item.mode = entry.mode; + item.spec = transitModelSpec(set, { mode: entry.mode }); + item.tint = + MODE_CESIUM_COLORS[entry.mode] || MODE_CESIUM_COLORS.unknown; + } + item.position = entry.marker.position; + item.headingDeg = Number.isFinite(entry.courseDeg) ? entry.courseDeg : 0; + // A vehicle a first-person view rides draws no model: the camera is + // inside it (cockpitSubject.js). + item.show = + !entry.heightPending && + entry.surfaceReady !== false && + entry.key !== state._cockpitSubjectKey; + next.set(entry.key, item); + } + for (const [key, item] of modelItems) + if (next.get(key) !== item) handBack(item.entry); + spareItems = modelItems; + modelItems = next; + fleetModels.frame(modelItems, performance.now()); + for (const item of modelItems.values()) { + const entry = item.entry; + if (item.modelOwnsVisual) { + entry.modelOwnsVisual = true; + entry.marker.show = false; + } else handBack(entry); + } + if (fleetModels.settling) governorRequestRender('transit-models'); + } + + /** Drop every model (layer disabled); `destroy` also removes the collection. */ + function stopModels(destroy = false) { + for (const item of modelItems.values()) item.entry.modelOwnsVisual = false; + modelItems.clear(); + spareItems.clear(); + if (destroy) { + fleetModels?.destroy(); + fleetModels = null; + } else fleetModels?.clear(); + } + const occluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); const sphere = new Cesium.BoundingSphere(undefined, 5); const cameraPosition = new Cesium.Cartesian3(); @@ -531,7 +651,10 @@ export function createRendering({ state, services, parts }) { } else if (frustum) inFrustum = frustum.computeVisibility(sphere) !== Cesium.Intersect.OUTSIDE; - const visible = (frustum || bounds) && horizon && inFrustum; + // A held vehicle (cockpitSubject.js) is the camera's own: it stays in + // play wherever the view looks. + const held = entry.key === state._cockpitSubjectKey; + const visible = held || ((frustum || bounds) && horizon && inFrustum); const visibility = (entry.visibility ||= {}); visibility.bounds = bounds; visibility.boundsApplied = !frustum; @@ -556,7 +679,11 @@ export function createRendering({ state, services, parts }) { } } entry.marker.show = - visible && !entry.heightPending && entry.surfaceReady !== false; + !held && + visible && + !entry.heightPending && + entry.surfaceReady !== false && + !entry.modelOwnsVisual; visibility.heightPending = !!entry.heightPending; visibility.surfaceReady = entry.surfaceReady ?? null; schedulePlayback(entry); @@ -621,5 +748,7 @@ export function createRendering({ state, services, parts }) { setStylePreset, onPreRender, syncRenderHold, + startModels, + stopModels, }; } diff --git a/src/layers/transit/selection.js b/src/layers/transit/selection.js index fd066ca55..26bf024c1 100644 --- a/src/layers/transit/selection.js +++ b/src/layers/transit/selection.js @@ -24,14 +24,16 @@ export function createSelection({ state, services, parts }) { * (the tracked-aircraft readout uses the same), so the anchor moves with the * sprite at frame rate, through poll arrivals, settling, a late floor and a * reset alike, with no per-frame work here. Null when there is nothing to - * anchor to: a hidden or removed marker draws no card. + * anchor to: a hidden or removed marker draws no card, unless the vehicle's + * 3D model is drawing it in the marker's place. * @param {object} entry * @returns {() => Cesium.Cartesian3|null} */ function anchorFor(entry) { return () => { const marker = entry.marker; - const p = marker?.show !== false ? marker?.position : null; + const drawn = marker?.show !== false || entry.modelOwnsVisual === true; + const p = drawn ? marker?.position : null; return p && Number.isFinite(p.x) && Number.isFinite(p.y) && @@ -46,6 +48,12 @@ export function createSelection({ state, services, parts }) { ? state._vehicles.get(state._selectedKey) : null; if (!entry) return; + // A vehicle a first-person view rides shows no card. + if (entry.key === state._cockpitSubjectKey) { + state._selectedCardText = null; + state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); + return; + } const now = Date.now(); if (!force && now - state._selectedCardAt < SELECTED_CARD_REFRESH_MS) return; @@ -91,6 +99,7 @@ export function createSelection({ state, services, parts }) { : null; parts.trails.clear(); state._selectedKey = null; + state._selectedAtMs = null; state._selectedCardText = null; state._detectRevision += 1; // Cleared first: the styling path reads the selection from state. @@ -98,11 +107,18 @@ export function createSelection({ state, services, parts }) { state._overlayHost.clearSource(TRANSIT_SELECTED_OVERLAY_SOURCE_ID); } - function selectVehicle(key) { + /** + * Select a vehicle. `byUser` marks a selection the user made (a click, or + * focusVehicle), even of the selected vehicle again: its time is the + * snapshot's `selectedAtMs` (cockpitSubject.js). A first-person view's hold + * selects without it. + */ + function selectVehicle(key, { byUser = false } = {}) { clearSelection(); const entry = state._vehicles.get(key); if (!entry?.marker) return; state._selectedKey = key; + if (byUser) state._selectedAtMs = Date.now(); state._detectRevision += 1; parts.rendering.paintSelected(entry, true); parts.trails.select(entry); @@ -111,6 +127,8 @@ export function createSelection({ state, services, parts }) { } function onKeyDown(event) { + // While a first-person view rides the vehicle, Escape is the view's. + if (state._cockpitSubjectKey) return; if (event.key === 'Escape' && state._selectedKey) clearSelection(); } @@ -123,6 +141,8 @@ export function createSelection({ state, services, parts }) { // Ambient selection never competes for a pointer a tool has claimed: // placing a draw vertex on top of a bus must not also select the bus. if (!isPointerFree()) return; + // A held vehicle keeps the selection until its view lets it go. + if (state._cockpitSubjectKey) return; const scene = viewer.scene; // Pick buffers precede post-processing. Map a sensor pixel back through // the active shader's barrel distortion and quantization before picking. @@ -179,11 +199,11 @@ export function createSelection({ state, services, parts }) { typeof primitiveId === 'string' && state._vehicles.has(primitiveId) ) { - selectVehicle(primitiveId); + selectVehicle(primitiveId, { byUser: true }); return; } if (typeof picked.id === 'string' && state._vehicles.has(picked.id)) { - selectVehicle(picked.id); + selectVehicle(picked.id, { byUser: true }); return; } } diff --git a/src/layers/transit/state.js b/src/layers/transit/state.js index b58f181d5..da21a08d1 100644 --- a/src/layers/transit/state.js +++ b/src/layers/transit/state.js @@ -95,6 +95,15 @@ export function createState({ services }) { /** @type {string|null} */ state._selectedKey = null; + /** When the user last selected a vehicle, even the selected one (ms). */ + state._selectedAtMs = null; + /** + * The vehicle a first-person view holds (cockpitSubject.js), or null: its + * sprite, 3D model, card, trail and detection box are hidden, and it keeps + * moving out of the camera's view. + * @type {string|null} + */ + state._cockpitSubjectKey = null; state._selectedCardAt = 0; /** The card text last published, so an unchanged card is not re-sent. */ state._selectedCardText = null; diff --git a/src/layers/transit/trails.js b/src/layers/transit/trails.js index ee99c4681..9883a5276 100644 --- a/src/layers/transit/trails.js +++ b/src/layers/transit/trails.js @@ -247,8 +247,11 @@ export function createTrails({ state, services, parts, source }) { } function update() { if (!selected || !renderer) return; + // A first-person view on the vehicle rides where its trail would start. renderer.setVisible( - state._enabled && state._vehicles.get(selected.key) === selected, + state._enabled && + state._vehicles.get(selected.key) === selected && + selected.key !== state._cockpitSubjectKey, ); renderer.setDisplaySample(selected.sample, selected.marker?.position); } diff --git a/src/layers/transit/viewport.js b/src/layers/transit/viewport.js index 38315e4ae..ba8f25a5d 100644 --- a/src/layers/transit/viewport.js +++ b/src/layers/transit/viewport.js @@ -101,6 +101,10 @@ export function createViewport({ state, services, parts }) { if (state._activeFeeds.has(feed.id)) desired.set(feed.id, feed); } } + // A vehicle a first-person view rides keeps its feed polled. + const held = state._vehicles.get(state._cockpitSubjectKey); + if (held && state._activeFeeds.has(held.feedId)) + desired.set(held.feedId, state._activeFeeds.get(held.feedId)); for (const feedId of [...state._activeFeeds.keys()]) { if (!desired.has(feedId)) { state._inFlight.get(feedId)?.controller.abort(); diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index 49c0264e2..f215c6b09 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -1,8 +1,10 @@ import { + AID_TO_NAVIGATION_STYLE, accentForVesselType, displayVesselType, normalizeVesselType, } from '../../data/vesselLabels.js'; +import { isAidToNavigation } from '../../data/vesselTypes.js'; import { formatNumber, t } from '../../i18n/index.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; @@ -16,14 +18,21 @@ export function createCards({ function updateSelectedVesselHud(record) { // Pinned vessels missing from recent refreshes get a stale marker const stale = (record.missedRefreshes || 0) > 0; + // An aid to navigation is a fixed mark: it has no speed or heading to show. + const aid = isAidToNavigation(record); services.readouts?.presentSelectedVessel({ text: [ t('AIS: {name}', { name: trimHudValue(record.name, 32) }), - t('{type} SPD: {speed} HDG: {heading}', { - type: trimHudValue(record.type || t('VESSEL'), 24), - speed: formatSpeed(record.speed), - heading: formatHeading(record.heading ?? record.course), - }), + aid + ? aidKind( + trimHudValue(record.type || t('AID TO NAVIGATION'), 24), + record, + ) + : t('{type} SPD: {speed} HDG: {heading}', { + type: trimHudValue(record.type || t('VESSEL'), 24), + speed: formatSpeed(record.speed), + heading: formatHeading(record.heading ?? record.course), + }), `MMSI: ${record.mmsi || '--'} ${formatPositionTime(record)}${stale ? ` · ${t('STALE')}` : ''}`, ].join('\n'), }); @@ -43,20 +52,22 @@ export function createCards({ /** * Card model for an ambient (decluttered-in) vessel — name title plus one * compact type/speed/heading detail line, anchored at the record's current - * rendered position (height-datum caveat: no datum work here). Pure — - * exported for unit tests. + * rendered position (height-datum caveat: no datum work here). An aid to + * navigation's line is its kind alone. Pure — exported for unit tests. * @param {Object} record - Vessel record. * @returns {Object} vesselLabels entry. */ function buildVesselCard(record) { const parts = []; + const aid = isAidToNavigation(record); const type = vesselTypeShort(record); if (type) parts.push(type); - if (record.speed !== null && record.speed !== undefined) + if (record.virtual === true) parts.push(t('VIRTUAL')); + if (!aid && record.speed !== null && record.speed !== undefined) parts.push(formatSpeed(record.speed)); const direction = record.heading ?? record.course; - if (Number.isFinite(direction)) + if (!aid && Number.isFinite(direction)) parts.push(`${roundHeadingDegrees(direction)}°`); return { id: vesselOverlayEntryId(record), @@ -65,7 +76,7 @@ export function createCards({ components.rendering.getVisual(record).billboard?.position || components.rendering.getVisual(record).position, gapPx: 10, - accent: accentForVesselType(record.type), + accent: vesselAccent(record, aid), title: trimHudValue(displayVesselName(record), 26), details: parts.length ? [parts.join(' · ')] : [], selected: false, @@ -76,22 +87,26 @@ export function createCards({ /** * Card model for the click-selected vessel — the full-detail card, drawn last * (on top) and never distance-faded by the overlay. Pinned-but-vanished - * vessels carry a STALE marker (mirrors the HUD readout). Pure — exported - * for unit tests. + * vessels carry a STALE marker (mirrors the HUD readout). An aid to + * navigation shows its kind with no speed or heading. Pure — exported for + * unit tests. * @param {Object} record - Selected vessel record. * @returns {Object} vesselLabels entry. */ function buildSelectedVesselCard(record) { + const aid = isAidToNavigation(record); const direction = record.heading ?? record.course; const details = [ - [ - vesselTypeShort(record) || t('VESSEL'), - formatSpeed(record.speed), - Number.isFinite(direction) - ? `${roundHeadingDegrees(direction)}°` - : '--°', - ].join(' · '), + aid + ? aidKind(vesselTypeShort(record) || t('AID TO NAVIGATION'), record) + : [ + vesselTypeShort(record) || t('VESSEL'), + formatSpeed(record.speed), + Number.isFinite(direction) + ? `${roundHeadingDegrees(direction)}°` + : '--°', + ].join(' · '), ]; const destination = String(record.destination || '').trim(); if (destination) details.push(`→ ${trimHudValue(destination, 24)}`); @@ -106,7 +121,7 @@ export function createCards({ components.rendering.getVisual(record).billboard?.position || components.rendering.getVisual(record).position, gapPx: 12, - accent: accentForVesselType(record.type), + accent: vesselAccent(record, aid), title: trimHudValue(displayVesselName(record), 32), details, selected: true, @@ -114,6 +129,20 @@ export function createCards({ }; } + /** An aid's kind, marked virtual when nothing physical stands there. */ + + function aidKind(kind, record) { + return record.virtual === true ? `${kind} · ${t('VIRTUAL')}` : kind; + } + + /** A card's accent: an aid's disc colour, else its ship type's. */ + + function vesselAccent(record, aid) { + return aid + ? AID_TO_NAVIGATION_STYLE.accent + : accentForVesselType(record.type); + } + /** Stable overlay identity for keyed and source-retained unkeyed rows. */ function vesselOverlayEntryId(record) { @@ -129,9 +158,15 @@ export function createCards({ * Uppercased AIS type for the card (empty string when unknown). Free feed * text is bounded to the card width; a translated type token is a short * catalog word and shows whole, never cut mid-word ("AUXILIAR DE PUERTO"). + * An aid's kind is the whole line, so it keeps the room speed and heading + * would take ("CARDINAL MARK N", not "CARDINAL MARK "). */ function vesselTypeShort(record) { + if (isAidToNavigation(record)) { + const kind = String(record.type || '').trim(); + return kind ? trimHudValue(kind.toUpperCase(), 26) : ''; + } const feedType = normalizeVesselType(record.type); const shown = displayVesselType(record.type); return shown === feedType diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js index bd8282e84..c6d1429dc 100644 --- a/src/layers/vessels/index.js +++ b/src/layers/vessels/index.js @@ -7,6 +7,7 @@ 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'; @@ -24,6 +25,7 @@ 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); @@ -90,6 +92,9 @@ 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; diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index 735f32daa..2eb2d34f0 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -213,9 +213,12 @@ export function createIngestion({ imo: record.imo, type: record.type, destination: record.destination, + length: record.lengthM ?? null, + beam: record.beamM ?? null, speed: record.speedMps == null ? null : record.speedMps / 0.514444, course: record.courseDeg, heading: record.headingDeg, + virtual: record.virtual === true, last_position_epoch: record.observedAtMs == null ? null : record.observedAtMs / 1000, last_position_UTC: diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 4c457c198..fd7b6becd 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -248,6 +248,7 @@ export function createLifecycle({ if (state.preRenderRemover) { state.preRenderRemover(); } + components.rendering.destroyModels(); resetState(); }, }; diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 92c4ec493..cffd22dbd 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -515,3 +515,50 @@ test('AIS feed rows stay keyed by their MMSI', async () => { ['235000001', '235000001'], ); }); + +test('an aid to navigation is drawn as a disc, a vessel as its chevron', async () => { + const aid = { + ...observation('993672001'), + mmsi: '993672001', + type: 'Port Hand Mark', + speedMps: 0, + }; + const ship = { ...observation('366999002'), type: 'Cargo' }; + const { layer, primitives } = setup({ + async getSnapshot() { + return snapshot([aid, ship]); + }, + }); + await layer.update(); + const svgOf = (id) => { + const primitive = [...primitives].find((p) => p.id?.id === id); + assert.ok(primitive, id); + return atob(primitive.image.split(',')[1]); + }; + assert.match(svgOf('993672001'), / [...primitives].find((p) => p.id?.id === id).id; + assert.equal(recordOf('993672001').virtual, false); + assert.equal(recordOf('366999002').virtual, false); + assert.doesNotMatch(svgOf('993672001'), / { + const aid = { + ...observation('993672009'), + mmsi: '993672009', + type: 'Isolated danger', + virtual: true, + }; + const { layer, primitives } = setup({ + async getSnapshot() { + return snapshot([aid]); + }, + }); + await layer.update(); + const primitive = [...primitives].find((p) => p.id?.id === '993672009'); + assert.ok(primitive); + assert.equal(primitive.id.virtual, true); + assert.match(atob(primitive.image.split(',')[1]), / 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 }; +} diff --git a/src/layers/vessels/photos.test.mjs b/src/layers/vessels/photos.test.mjs new file mode 100644 index 000000000..2e1163853 --- /dev/null +++ b/src/layers/vessels/photos.test.mjs @@ -0,0 +1,87 @@ +// 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'); +}); diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index d927def6d..d204bfadb 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -664,7 +664,13 @@ export function createQueries({ if ((idx - start) % stride !== 0) continue; const record = records[idx]; const visual = components.rendering.getVisual(record); - if (visual.billboard && !visual.billboard.show) continue; + // A vessel drawn as its model is present though its chevron is hidden. + if ( + visual.billboard && + !visual.billboard.show && + !visual.modelOwnsVisual + ) + continue; const position = visual.billboard?.position || visual.position; if (!position) continue; result.push({ diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index 17296cc62..a076368d4 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -25,9 +25,14 @@ export function normalizeVessel(row) { imo: String(row.imo || ''), type: String(row.type || ''), destination: String(row.destination || ''), + // Hull length and beam (metres), null when unreported. + length: positiveNumber(row.length), + beam: positiveNumber(row.beam), speed: finiteNumber(row.speed), course: finiteNumber(row.course), heading: finiteNumber(row.heading), + // A virtual aid to navigation: a mark with nothing physical there. + virtual: row.virtual === true, lastPositionUtc: String(row.last_position_UTC || ''), lastPositionEpoch: finiteNumber(row.last_position_epoch), missedRefreshes: 0, @@ -46,6 +51,11 @@ function stampFix(record, receivedAtMs, feedLatestEpoch) { record.fixFeedEpoch = feedLatestEpoch; } +function positiveNumber(value) { + const number = finiteNumber(value); + return number !== null && number > 0 ? number : null; +} + function finiteNumber(value) { if (value === null || value === undefined || value === '') return null; const number = Number(value); @@ -147,9 +157,12 @@ export class VesselRecords { record.imo = next.imo; record.type = next.type; record.destination = next.destination; + record.length = next.length; + record.beam = next.beam; record.speed = next.speed; record.course = next.course; record.heading = next.heading; + record.virtual = next.virtual; record.lastPositionUtc = next.lastPositionUtc; record.lastPositionEpoch = next.lastPositionEpoch; record.missedRefreshes = 0; diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index 7a6aa6739..7d12366e3 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -1,6 +1,7 @@ import * as Cesium from 'cesium'; import { t } from '../../i18n/index.js'; import { + AID_TO_NAVIGATION_STYLE, vesselTypeCss, vesselOverlayCohortLimit, VESSEL_OVERLAY_SOURCE_ID, @@ -22,10 +23,20 @@ import { followsClock, } from './policy.js'; import { vesselPositionAt } from './motion.js'; +import { + fleetModelSet, + loadFleetModelSet, + vesselModelSpec, +} from '../../data/fleetModels.js'; +import { createFleetModelLayer } from '../../data/fleetModelInstances.js'; +import { isAidToNavigation } from '../../data/vesselTypes.js'; /** How often vessels move on as the world clock plays (wall ms). */ const FOLLOW_POSITION_MS = 250; +/** How often the vessels near enough for a model are gathered again (wall ms). */ +const MODEL_CANDIDATES_MS = 500; + export function createRendering({ vesselState, services, @@ -56,6 +67,12 @@ export function createRendering({ VESSEL_LIFT_M, ); visual.position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM); + // A ship model's origin is its waterline: the sea surface, unlifted. + visual.waterline = Cesium.Cartesian3.fromDegrees( + lon, + lat, + heightM - VESSEL_LIFT_M, + ); visual.surfacePosition = Cesium.Cartesian3.fromDegrees(lon, lat, 0); visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( visual.position, @@ -101,6 +118,7 @@ export function createRendering({ } function ensureCollections(viewer) { + ensureModels(viewer); if (!viewer || state.billboardCollection) return; state.billboardCollection = new Cesium.BillboardCollection({ blendOption: Cesium.BlendOption.TRANSLUCENT, @@ -166,12 +184,14 @@ export function createRendering({ * Best available real-world direction of travel for a vessel, degrees * clockwise from north (true heading preferred, course-over-ground fallback). * Screen rotation is computed from this by the shared projected-rotation - * pass in updateVisibility — never directly from the compass value. + * pass in updateVisibility — never directly from the compass value. An aid + * to navigation is fixed, so it has none. * @param {Object} record - Vessel record. * @returns {number} Course in degrees (0 when unknown). */ function vesselCourseDeg(record) { + if (isAidToNavigation(record)) return 0; const direction = record.heading ?? record.course; return Number.isFinite(direction) ? direction : 0; } @@ -179,6 +199,7 @@ export function createRendering({ /** * Build (and cache) a chevron/delta-wing SVG data URL tinted for the vessel. * The shape points north (up) so billboard rotation maps directly to heading. + * An aid to navigation, which has no heading, is a ringed disc instead. * One icon is generated per color+variant and reused across all billboards. * @param {Object} record - Vessel record (drives per-type tint). * @param {boolean} selected - True for the white/brighter selected variant. @@ -186,16 +207,25 @@ export function createRendering({ */ function shipIcon(record, selected) { - const cssColor = selected ? '#ffffff' : vesselTypeCss(record.type); - const key = `${cssColor}:${selected ? 'selected' : 'normal'}`; + const aid = isAidToNavigation(record); + const cssColor = selected + ? '#ffffff' + : aid + ? AID_TO_NAVIGATION_STYLE.css + : vesselTypeCss(record.type); + const key = `${cssColor}:${selected ? 'selected' : 'normal'}:${aid ? 'aid' : 'ship'}`; if (vesselState.shipIconCache.has(key)) return vesselState.shipIconCache.get(key); const stroke = selected ? 'rgba(6,26,32,0.95)' : 'rgba(4,18,24,0.9)'; const strokeWidth = selected ? 1.1 : 0.7; + const shape = aid + ? ` + ` + : ``; const svg = ` - + ${shape} `; const icon = 'data:image/svg+xml;base64,' + btoa(svg); @@ -203,6 +233,152 @@ export function createRendering({ return icon; } + // Under the DISPLAY rail's 3D toggle, vessels near the camera are drawn as + // the project's procedural fleet models at their reported length and beam, + // on the aircraft layers' icon-to-model rules (services.fleet). The fleet + // set loads once, up front, when the layer starts. + let fleetModels = null; + /** Items drawn last frame, and the map the next frame's are gathered into. */ + let modelItems = new Map(); + let spareItems = new Map(); + let modelCandidatesAtMs = -Infinity; + const modelOccluder = new Cesium.EllipsoidalOccluder(Cesium.Ellipsoid.WGS84); + + function ensureModels(viewer) { + const fleet = services.fleet; + if (!viewer || fleetModels || !fleet?.policy) return; + loadFleetModelSet({ resolveAsset: fleet.resolveAsset }).catch(() => { + /* models stay off; vessels keep their icons */ + }); + fleetModels = createFleetModelLayer({ + viewer, + display: fleet.display, + policy: fleet.policy, + resolveAsset: fleet.resolveAsset, + // A hull below the waterline would show through a plain globe. + waterline: true, + }); + } + + /** + * Whether a vessel's chevron is drawn: on the camera's side of the Earth, + * not the bridge camera's own vessel, and not drawn by its model. The + * visibility pass and the model handoff both decide by this. + */ + function billboardShown(record, occluder) { + const visual = getVisual(record); + return ( + isVisible(visual.surfacePosition, occluder) && + record.id !== state.cockpitSubjectId && + !visual.modelOwnsVisual + ); + } + + /** + * The model item for `record`, its spec rebuilt only when its type or size + * changes; null for a virtual aid to navigation, which keeps its disc: no + * buoy stands there (vesselModelSpec). + */ + function modelItem(record, set) { + if (record.virtual === true) return null; + let item = modelItems.get(record.id); + if (!item || item.record !== record) + item = { record, pickId: record, tint: null, modelOwnsVisual: false }; + const specKey = `${record.type}|${record.mmsi}|${record.length}|${record.beam}|${record.name}`; + if (item.specKey !== specKey) { + item.specKey = specKey; + item.spec = vesselModelSpec(set, record); + } + item.position = getVisual(record).waterline; + // An aid to navigation is a fixed mark: it does not turn to a heading. + item.headingDeg = item.spec?.fixed ? 0 : vesselCourseDeg(record); + item.show = record.id !== state.cockpitSubjectId; + return item; + } + + /** Gather the vessels within the models' keep radius of the camera into `into`. */ + function gatherModelItems(set, into) { + const camera = state.viewer?.camera; + if (!camera?.positionWC) return; + const policy = services.fleet.policy; + const keepM = + services.fleet.display?.()?.models3dMode === 'all' + ? policy.keepAllM + : policy.keepM; + const limitSq = keepM * keepM * 1.44; + for (const record of state.records.all) { + const waterline = getVisual(record).waterline; + if (!waterline) continue; + if ( + Cesium.Cartesian3.distanceSquared(camera.positionWC, waterline) > + limitSq + ) + continue; + const item = modelItem(record, set); + if (item) into.set(record.id, item); + } + } + + /** Give a vessel's chevron back when its model no longer draws it. */ + function handBackVisual(record, occluder) { + const visual = getVisual(record); + if (!visual.modelOwnsVisual) return; + visual.modelOwnsVisual = false; + if (visual.billboard) + visual.billboard.show = billboardShown(record, occluder); + } + + function updateModels() { + if (!fleetModels) return; + const camera = state.viewer?.camera; + const occluder = camera?.positionWC ? modelOccluder : null; + if (occluder) occluder.cameraPosition = camera.positionWC; + if (!fleetModels.active()) { + if (modelItems.size) { + modelItems.clear(); + fleetModels.frame(modelItems, performance.now()); + for (const record of state.records.all) + handBackVisual(record, occluder); + } + return; + } + const set = fleetModelSet(); + const now = performance.now(); + const next = spareItems; + next.clear(); + if (now - modelCandidatesAtMs >= MODEL_CANDIDATES_MS) { + modelCandidatesAtMs = now; + gatherModelItems(set, next); + } else { + for (const [id, item] of modelItems) + if (state.records.byId.get(id) === item.record) { + const kept = modelItem(item.record, set); + if (kept) next.set(id, kept); + } + } + for (const [id, item] of modelItems) + if (next.get(id) !== item) handBackVisual(item.record, occluder); + spareItems = modelItems; + modelItems = next; + fleetModels.frame(modelItems, now); + for (const item of modelItems.values()) { + const visual = getVisual(item.record); + if (item.modelOwnsVisual) { + visual.modelOwnsVisual = true; + if (visual.billboard) visual.billboard.show = false; + } else handBackVisual(item.record, occluder); + } + if (fleetModels.settling) governorRequestRender('ais-vessels-models'); + } + + function destroyModels() { + fleetModels?.destroy(); + fleetModels = null; + modelItems = new Map(); + spareItems = new Map(); + modelCandidatesAtMs = -Infinity; + } + // Vessels move only when a poll lands, so the layer does not hold the // continuous render loop. Its passes run in preRender, which idle mode fires // only for rendered frames: a forced pass (new data, selection) requests its @@ -216,6 +392,7 @@ export function createRendering({ state.preRenderRemover = viewer.scene.preRender.addEventListener(() => { if (followsClock(vesselState)) followClockPositions(); updateVisibility(); + if (state.feed.enabled) updateModels(); }); } @@ -306,9 +483,13 @@ export function createRendering({ const visible = isVisible(visual.surfacePosition, occluder); if (visual.billboard) { // The bridge camera sits on this sprite; showing it would fill the view. + // The same rule as billboardShown, its horizon test already made. visual.billboard.show = - visible && record.id !== state.cockpitSubjectId; - if (visible && doRotations && scene) { + visible && + record.id !== state.cockpitSubjectId && + !visual.modelOwnsVisual; + // An aid's disc is never turned (vesselCourseDeg). + if (visible && doRotations && scene && !isAidToNavigation(record)) { const rot = screenProjectedRotation( scene, visual.position, @@ -582,6 +763,13 @@ export function createRendering({ if (state.billboardCollection) { state.billboardCollection.show = show; } + fleetModels?.setVisible(show); + if (!show && fleetModels) { + fleetModels.clear(); + for (const item of modelItems.values()) + getVisual(item.record).modelOwnsVisual = false; + modelItems.clear(); + } vesselState._vesselOverlayHost.setVisible(VESSEL_OVERLAY_SOURCE_ID, show); } return { @@ -607,5 +795,7 @@ export function createRendering({ labelPriority, hasUsefulName, setVisible, + updateModels, + destroyModels, }; } diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 99f3b674a..717a69408 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -186,6 +186,7 @@ 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); } diff --git a/src/mapStartup.test.mjs b/src/mapStartup.test.mjs index 9d438643d..ab50fa19b 100644 --- a/src/mapStartup.test.mjs +++ b/src/mapStartup.test.mjs @@ -64,12 +64,9 @@ async function standaloneStartup(Cesium, credentials, fetchImpl) { document: { getElementById: () => ({ querySelector: () => ({}) }), }, - resolveLocale: () => 'en', - setLocale() {}, - getLocale: () => 'en', - onLocaleChange: () => () => {}, + startPageLocale: () => 'en', + followPageLocale: () => () => {}, mountApplicationViews() {}, - applyDocumentLanguage() {}, applyDocumentTranslations() {}, CITY_POIS: [], createApplication: (options) => options, diff --git a/src/sources/adsbRecords.js b/src/sources/adsbRecords.js index afa952503..4c2e164a9 100644 --- a/src/sources/adsbRecords.js +++ b/src/sources/adsbRecords.js @@ -248,7 +248,8 @@ export function normalizeDump1090Aircraft(json, nowMs, { band = '1090' } = {}) { lon: hasPosition ? position.lon : null, altitudeFt: altitude, groundSpeedKt: finiteOrNull(entry.gs), - trackDeg: normalizeTrack(entry.track), + // No track (parked, slow): the true heading, where the nose points. + trackDeg: normalizeTrack(entry.track ?? entry.true_heading), verticalRateFpm: readAdsbVerticalRateFpm(entry), lastPositionAt: hasPosition ? receivedAt - Math.max(0, seenPosS) * 1000 diff --git a/src/sources/adsbRecords.test.mjs b/src/sources/adsbRecords.test.mjs index aa14133b6..37bac81a2 100644 --- a/src/sources/adsbRecords.test.mjs +++ b/src/sources/adsbRecords.test.mjs @@ -68,6 +68,34 @@ test('dump1090 aircraft.json maps to the shared local ADS-B records', () => { assert.deepEqual(summary, { heard: 4, positioned: 2 }); }); +test('a dump1090 aircraft with no track takes its true heading, never its magnetic one', () => { + const trackOf = (extra) => + normalizeDump1090Aircraft( + { + now: 1, + aircraft: [ + { + hex: 'a4418c', + lat: 37.6, + lon: -122.38, + alt_baro: 'ground', + seen: 1, + ...extra, + }, + ], + }, + 1000, + )[0].trackDeg; + assert.equal( + trackOf({ track: 56.25, true_heading: 40 }), + 56.25, + 'track first', + ); + assert.equal(trackOf({ true_heading: 185.62 }), 185.62, 'true heading'); + assert.equal(trackOf({ mag_heading: 278.44 }), null, 'not magnetic'); + assert.equal(trackOf({}), null, 'none'); +}); + test('dump1090 adapter skips invalid addresses and tolerates missing fields', () => { const records = normalizeDump1090Aircraft( { diff --git a/src/sources/live/aircraft.js b/src/sources/live/aircraft.js index 481bb6113..85b3acdc5 100644 --- a/src/sources/live/aircraft.js +++ b/src/sources/live/aircraft.js @@ -71,7 +71,8 @@ export function normalizeReadsbAircraft(row, snapshotTimeMs) { ellipsoidAltitudeM: geoFt == null ? null : geoFt * 0.3048, onGround: cleanText(row.alt_baro).toLowerCase() === 'ground', speedMps: speedKts == null ? null : speedKts * 0.514444, - courseDeg: finite(row.track), + // No track (parked, slow): the true heading, where the nose points. + courseDeg: finite(row.track) ?? finite(row.true_heading), verticalRateMps: rateFtMin == null ? null : rateFtMin * 0.00508, category: row.category ?? null, typeCode: cleanText(row.t), diff --git a/src/sources/live/contract.js b/src/sources/live/contract.js index c40d23b0f..e201fc8de 100644 --- a/src/sources/live/contract.js +++ b/src/sources/live/contract.js @@ -12,7 +12,9 @@ * snapshots. A vessel record's `id` is whatever its source gives, such as a * provider id; its `mmsi` is an optional attribute ('' or absent when * unknown), never its key, since vessels can share an MMSI. A source that - * only has MMSIs uses the MMSI as the id. + * only has MMSIs uses the MMSI as the id. A vessel record's `virtual` is true + * for a virtual aid to navigation, a mark broadcast where nothing physical + * stands (AIS message 21's virtual flag), else false; any source may set it. * * A source may also declare a timeline (see readSnapshotTimeline): it can * answer a snapshot at a given time, so the layer follows the world clock. diff --git a/src/sources/live/contract.test.mjs b/src/sources/live/contract.test.mjs index 0fe9ae913..2c22012bf 100644 --- a/src/sources/live/contract.test.mjs +++ b/src/sources/live/contract.test.mjs @@ -168,6 +168,22 @@ test('readsb units and position/contact ages are normalized once against the sou ); }); +test('a readsb aircraft with no track takes its true heading, never its magnetic one', () => { + const course = (extra) => + normalizeReadsbAircraft( + { hex: 'a4418c', lat: 37.6, lon: -122.38, alt_baro: 'ground', ...extra }, + now, + ).courseDeg; + assert.equal( + course({ track: 56.25, true_heading: 40 }), + 56.25, + 'track first', + ); + assert.equal(course({ true_heading: 185.62 }), 185.62, 'true heading'); + assert.equal(course({ mag_heading: 278.44 }), null, 'not magnetic'); + assert.equal(course({}), null, 'none'); +}); + test('readsb vertical rate falls back to the GNSS rate when no baro rate is sent', () => { // readsb reports the rate under geom_rate when the aircraft's source is // GNSS; 26 of 157 positioned rows in a live /v2/mil fetch had only that. @@ -205,6 +221,18 @@ test('vessel identity, opaque reference and heading/course remain distinct, unkn assert.equal(row.altitudeDatum, 'sea-surface'); assert.equal(row.observedAtMs, now); assert.equal('extra' in row, false); + // Only a source's own true marks a virtual aid to navigation. + assert.equal(row.virtual, false); + const base = { mmsi: '993672001', lat: 30, lon: -97 }; + assert.equal( + normalizeVesselObservation({ ...base, virtual: true }).virtual, + true, + ); + for (const virtual of ['true', 1, false, null]) + assert.equal( + normalizeVesselObservation({ ...base, virtual }).virtual, + false, + ); assert.equal( normalizeVesselObservation({ mmsi: 'x', lat: null, lon: null }), null, diff --git a/src/sources/live/index.js b/src/sources/live/index.js index 102a4262c..dbd675c67 100644 --- a/src/sources/live/index.js +++ b/src/sources/live/index.js @@ -16,4 +16,5 @@ export { createFlightSource, createMilitarySource, createVesselSource, + fetchOpenVesselPhoto, } from './standalone.js'; diff --git a/src/sources/live/standalone.js b/src/sources/live/standalone.js index b244266b0..41e858a93 100644 --- a/src/sources/live/standalone.js +++ b/src/sources/live/standalone.js @@ -12,6 +12,11 @@ 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); @@ -42,6 +47,34 @@ 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} 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( @@ -235,6 +268,11 @@ 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, diff --git a/src/sources/live/vessels.js b/src/sources/live/vessels.js index 366ef2beb..d75291911 100644 --- a/src/sources/live/vessels.js +++ b/src/sources/live/vessels.js @@ -10,6 +10,12 @@ function vesselMmsi(row) { return MMSI_PATTERN.test(text) ? text : ''; } +/** A positive measurement, else null. */ +function positive(value) { + const number = finite(value); + return number !== null && number > 0 ? number : null; +} + /** A row's stable id when its source names none: its MMSI or identifier. */ function defaultVesselId(row) { return String(row?.mmsi || row?.input_identifier || ''); @@ -39,6 +45,9 @@ export function normalizeVesselObservation(row, reference = null, id = null) { imo: String(row.imo || ''), type: String(row.type_specific || row.type || ''), destination: String(row.destination || ''), + // Hull length and beam (metres) when the source reports them. + lengthM: positive(row.length), + beamM: positive(row.beam), speedMps: speedKts == null ? null : speedKts * 0.514444, courseDeg: finite(row.course), headingDeg: finite(row.heading), @@ -46,6 +55,8 @@ export function normalizeVesselObservation(row, reference = null, id = null) { epoch(row.last_position_epoch, 1000) ?? epoch(Date.parse(row.last_position_UTC)), altitudeDatum: 'sea-surface', + // A virtual aid to navigation: a mark broadcast with nothing there. + virtual: row.virtual === true, }; } diff --git a/src/standalone/application.js b/src/standalone/application.js index fe152fd9b..85948d8e1 100644 --- a/src/standalone/application.js +++ b/src/standalone/application.js @@ -6,13 +6,11 @@ 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, - getLocale, - onLocaleChange, - setLocale, -} from '../i18n/index.js'; + followPageLocale, + startPageLocale, +} from '../i18n/page.js'; import { mountApplicationViews } from '../ui/views/index.js'; import { createGoogleTokenSource } from '../maps/googleTokens.js'; @@ -37,9 +35,7 @@ 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. - const activeLocale = resolveLocale(); - setLocale(activeLocale); - applyDocumentLanguage(document, activeLocale); + startPageLocale(document); // Views render the page's markup; wiring reads it. mountApplicationViews(document); applyDocumentTranslations(document); @@ -50,12 +46,7 @@ export function createStandaloneApplication({ return createApplication({ createScene: async (context) => { // A language switch applies in place; the page's own markup follows. - context.defer( - onLocaleChange(() => { - applyDocumentLanguage(document, getLocale()); - applyDocumentTranslations(document); - }), - ); + context.defer(followPageLocale(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, diff --git a/src/testSupport/fleetGlb.mjs b/src/testSupport/fleetGlb.mjs new file mode 100644 index 000000000..c797cc836 --- /dev/null +++ b/src/testSupport/fleetGlb.mjs @@ -0,0 +1,356 @@ +/** + * Reads the procedural fleet models' GLBs in Node: their JSON chunk, and their + * triangles in scene space (raw glTF axes, node transforms applied, metres). + * + * The GLBs use EXT_meshopt_compression and KHR_mesh_quantization, so their + * buffers are decoded with the meshoptimizer decoder Cesium itself ships (a + * dependency of `@cesium/engine`, resolved through it rather than named as one + * of this package's own). + */ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import { pathToFileURL } from 'node:url'; + +let decoderPromise = null; + +/** The meshoptimizer decoder Cesium decodes these models with. */ +async function meshoptDecoder() { + decoderPromise ??= (async () => { + const fromHere = createRequire(import.meta.url); + const fromCesium = createRequire(fromHere.resolve('cesium/package.json')); + const fromEngine = createRequire( + fromCesium.resolve('@cesium/engine/package.json'), + ); + const module = await import( + pathToFileURL(fromEngine.resolve('meshoptimizer')).href + ); + await module.MeshoptDecoder.ready; + return module.MeshoptDecoder; + })(); + return decoderPromise; +} + +/** + * A GLB's JSON and binary chunks. + * @param {string} file + * @returns {{json: object, bin: Uint8Array}} + */ +export function readGlb(file) { + const bytes = fs.readFileSync(file); + if (bytes.readUInt32LE(0) !== 0x46546c67) + throw new Error(`${file}: not a GLB`); + const jsonLength = bytes.readUInt32LE(12); + const json = JSON.parse(bytes.subarray(20, 20 + jsonLength).toString('utf8')); + const binStart = 20 + jsonLength; + const bin = + binStart + 8 <= bytes.length + ? new Uint8Array( + bytes.buffer, + bytes.byteOffset + binStart + 8, + bytes.readUInt32LE(binStart), + ) + : new Uint8Array(0); + return { json, bin }; +} + +/** Column-major node matrix from a glTF node's TRS (or its matrix). */ +function nodeMatrix(node) { + if (node.matrix) return node.matrix; + const t = node.translation || [0, 0, 0]; + const [x, y, z, w] = node.rotation || [0, 0, 0, 1]; + const s = node.scale || [1, 1, 1]; + return [ + (1 - 2 * (y * y + z * z)) * s[0], + 2 * (x * y + z * w) * s[0], + 2 * (x * z - y * w) * s[0], + 0, + 2 * (x * y - z * w) * s[1], + (1 - 2 * (x * x + z * z)) * s[1], + 2 * (y * z + x * w) * s[1], + 0, + 2 * (x * z + y * w) * s[2], + 2 * (y * z - x * w) * s[2], + (1 - 2 * (x * x + y * y)) * s[2], + 0, + t[0], + t[1], + t[2], + 1, + ]; +} + +function multiply(a, b) { + const out = new Array(16).fill(0); + for (let c = 0; c < 4; c++) + for (let r = 0; r < 4; r++) + for (let k = 0; k < 4; k++) out[c * 4 + r] += a[k * 4 + r] * b[c * 4 + k]; + return out; +} + +const COMPONENTS = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 }; +const READERS = { + 5120: ['getInt8', 1, 127], + 5121: ['getUint8', 1, 255], + 5122: ['getInt16', 2, 32767], + 5123: ['getUint16', 2, 65535], + 5125: ['getUint32', 4, 4294967295], + 5126: ['getFloat32', 4, 1], +}; + +/** + * Every element of accessor `index`, normalised integers mapped to -1..1 or + * 0..1 the way a renderer reads them. + */ +function readAccessor(gltf, views, index) { + const accessor = gltf.accessors[index]; + const view = gltf.bufferViews[accessor.bufferView]; + const bytes = views[accessor.bufferView]; + const [getter, size, max] = READERS[accessor.componentType]; + const components = COMPONENTS[accessor.type]; + const stride = view.byteStride || size * components; + const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const out = new Float64Array(accessor.count * components); + for (let i = 0; i < accessor.count; i++) + for (let c = 0; c < components; c++) { + const value = data[getter]( + (accessor.byteOffset || 0) + i * stride + c * size, + true, + ); + out[i * components + c] = accessor.normalized + ? Math.max(value / max, -1) + : value; + } + return out; +} + +/** Each buffer view's bytes, meshopt-compressed ones decoded. */ +async function bufferViews(gltf, bin) { + const decoder = await meshoptDecoder(); + return gltf.bufferViews.map((view) => { + const meshopt = view.extensions?.EXT_meshopt_compression; + if (!meshopt) + return bin.subarray( + view.byteOffset || 0, + (view.byteOffset || 0) + view.byteLength, + ); + const source = bin.subarray( + meshopt.byteOffset || 0, + (meshopt.byteOffset || 0) + meshopt.byteLength, + ); + const target = new Uint8Array(meshopt.count * meshopt.byteStride); + decoder.decodeGltfBuffer( + target, + meshopt.count, + meshopt.byteStride, + source, + meshopt.mode, + meshopt.filter, + ); + return target; + }); +} + +/** + * A fleet model's triangles in scene space: raw glTF axes (X along the body, + * nose toward -X; Y up; Z across), node transforms applied, metres. + * @param {string} file + * @returns {Promise<{verts: number[][], tris: number[][]}>} + */ +export async function fleetMesh(file) { + const { json: gltf, bin } = readGlb(file); + const views = await bufferViews(gltf, bin); + const verts = []; + const tris = []; + const visit = (index, parent) => { + const node = gltf.nodes[index]; + const m = multiply(parent, nodeMatrix(node)); + if (node.mesh != null) { + for (const primitive of gltf.meshes[node.mesh].primitives) { + const base = verts.length; + const positions = readAccessor( + gltf, + views, + primitive.attributes.POSITION, + ); + for (let i = 0; i < positions.length; i += 3) { + const [x, y, z] = [positions[i], positions[i + 1], positions[i + 2]]; + verts.push([ + m[0] * x + m[4] * y + m[8] * z + m[12], + m[1] * x + m[5] * y + m[9] * z + m[13], + m[2] * x + m[6] * y + m[10] * z + m[14], + ]); + } + const indices = readAccessor(gltf, views, primitive.indices); + for (let i = 0; i + 2 < indices.length; i += 3) + tris.push([ + base + indices[i], + base + indices[i + 1], + base + indices[i + 2], + ]); + } + } + for (const child of node.children || []) visit(child, m); + }; + const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + for (const root of gltf.scenes[gltf.scene ?? 0].nodes) visit(root, identity); + return { verts, tris }; +} + +/** Axis-aligned bounds of a vertex list. */ +export function vertexBounds(verts) { + const min = [Infinity, Infinity, Infinity]; + const max = [-Infinity, -Infinity, -Infinity]; + for (const v of verts) + for (let a = 0; a < 3; a++) { + if (v[a] < min[a]) min[a] = v[a]; + if (v[a] > max[a]) max[a] = v[a]; + } + return { min, max }; +} + +const dist3 = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); + +/** Closest point of triangle abc to p (Ericson, Real-Time Collision Detection). */ +function closestOnTriangle(p, a, b, c) { + const sub = (u, v) => [u[0] - v[0], u[1] - v[1], u[2] - v[2]]; + const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2]; + const ab = sub(b, a); + const ac = sub(c, a); + const ap = sub(p, a); + const d1 = dot(ab, ap); + const d2 = dot(ac, ap); + if (d1 <= 0 && d2 <= 0) return a; + const bp = sub(p, b); + const d3 = dot(ab, bp); + const d4 = dot(ac, bp); + if (d3 >= 0 && d4 <= d3) return b; + const vc = d1 * d4 - d3 * d2; + if (vc <= 0 && d1 >= 0 && d3 <= 0) { + const v = d1 / (d1 - d3); + return [a[0] + ab[0] * v, a[1] + ab[1] * v, a[2] + ab[2] * v]; + } + const cp = sub(p, c); + const d5 = dot(ab, cp); + const d6 = dot(ac, cp); + if (d6 >= 0 && d5 <= d6) return c; + const vb = d5 * d2 - d1 * d6; + if (vb <= 0 && d2 >= 0 && d6 <= 0) { + const w = d2 / (d2 - d6); + return [a[0] + ac[0] * w, a[1] + ac[1] * w, a[2] + ac[2] * w]; + } + const va = d3 * d6 - d5 * d4; + if (va <= 0 && d4 - d3 >= 0 && d5 - d6 >= 0) { + const w = (d4 - d3) / (d4 - d3 + (d5 - d6)); + return [ + b[0] + (c[0] - b[0]) * w, + b[1] + (c[1] - b[1]) * w, + b[2] + (c[2] - b[2]) * w, + ]; + } + const den = 1 / (va + vb + vc); + const v = vb * den; + const w = vc * den; + return [ + a[0] + ab[0] * v + ac[0] * w, + a[1] + ab[1] * v + ac[1] * w, + a[2] + ab[2] * v + ac[2] * w, + ]; +} + +/** How far a point lies from the nearest triangle of the mesh. */ +export function distanceToSurface(p, verts, tris) { + let best = Infinity; + for (const [i, j, k] of tris) { + const d = dist3(p, closestOnTriangle(p, verts[i], verts[j], verts[k])); + if (d < best) best = d; + } + return best; +} + +/** The segments where the mesh's triangles cross the z = 0 (centreline) plane. */ +export function centrelineSegments(verts, tris) { + const segments = []; + for (const [i, j, k] of tris) { + const t = [verts[i], verts[j], verts[k]]; + const points = []; + for (let e = 0; e < 3; e++) { + const a = t[e]; + const b = t[(e + 1) % 3]; + if (a[2] === 0) points.push(a); + if ((a[2] < 0 && b[2] > 0) || (a[2] > 0 && b[2] < 0)) { + const s = a[2] / (a[2] - b[2]); + points.push([a[0] + (b[0] - a[0]) * s, a[1] + (b[1] - a[1]) * s, 0]); + } + } + if (points.length >= 2) segments.push([points[0], points[1]]); + } + return segments; +} + +/** Closest point of the centreline segments to `target`, or null. */ +export function closestCentrelinePoint(target, segments) { + let best = null; + let bestD = Infinity; + for (const [a, b] of segments) { + const ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; + const den = ab[0] * ab[0] + ab[1] * ab[1] + ab[2] * ab[2]; + let q = a; + if (den > 0) { + let s = + ((target[0] - a[0]) * ab[0] + + (target[1] - a[1]) * ab[1] + + (target[2] - a[2]) * ab[2]) / + den; + s = Math.max(0, Math.min(1, s)); + q = [a[0] + ab[0] * s, a[1] + ab[1] * s, a[2] + ab[2] * s]; + } + const d = dist3(target, q); + if (d < bestD) { + bestD = d; + best = q; + } + } + return best; +} + +/** + * The trail attachment point a model's construction gives: the point of its + * centreline profile nearest its aft-belly bounding-box corner, in scene + * space (raw glTF axes, metres). Null when the centreline crosses no triangle. + * @param {{verts: number[][], tris: number[][]}} mesh + * @returns {number[]|null} + */ +export function measuredTrailAnchor({ verts, tris }) { + const { min, max } = vertexBounds(verts); + return closestCentrelinePoint( + [max[0], min[1], 0], + centrelineSegments(verts, tris), + ); +} + +/** The repository's `public/` directory, where the fleet set is served from. */ +const PUBLIC_DIR = new URL('../../public/', import.meta.url); + +/** + * A `fetch` that serves the repository's `public/` files from disk, for + * loading the fleet model set in Node. + * @param {string} url Site path, e.g. `/models/fleet/catalog.json`. + * @returns {Promise<{ok: boolean, status: number, json: () => Promise, + * arrayBuffer: () => Promise}>} + */ +export async function fetchPublicFile(url) { + const file = new URL(String(url).replace(/^\//, ''), PUBLIC_DIR); + let body; + try { + body = fs.readFileSync(file); + } catch { + return { ok: false, status: 404 }; + } + return { + ok: true, + status: 200, + json: async () => JSON.parse(body.toString('utf8')), + arrayBuffer: async () => + body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength), + }; +} diff --git a/src/testSupport/fleetModelFakes.mjs b/src/testSupport/fleetModelFakes.mjs new file mode 100644 index 000000000..3a51e994a --- /dev/null +++ b/src/testSupport/fleetModelFakes.mjs @@ -0,0 +1,68 @@ +// Fleet models for a layer test (fleetModelInstances.js): the project's model +// set loaded from public/, a DISPLAY 3D toggle that is on, and Cesium's glTF +// loader replaced by one that answers at once with a ready model, recorded. +import * as Cesium from 'cesium'; +import { loadFleetModelSet } from '../data/fleetModels.js'; +import { fetchPublicFile } from './fleetGlb.mjs'; + +/** A model policy whose eligibility is read again on every frame. */ +export const FLEET_TEST_POLICY = Object.freeze({ + altitudeCeilingM: 800_000, + minimumPixelSize: 16, + cap: 4, + capAll: 8, + addM: 5_000, + addAllM: 20_000, + keepM: 6_000, + keepAllM: 24_000, + intervalMs: 0, +}); + +/** + * Load the fleet set (once a process, as the app does) and fake the loader + * for this test. Call it before the layer's harness, which replaces `fetch`. + * @param {import('node:test').TestContext} t + * @returns {Promise<{fleet: object, created: object[], + * drawn: (id: string) => object[]}>} `fleet` for the layer's services; + * `drawn(id)` the models drawn for the vehicle `id` now. + */ +export async function fleetModelFakes(t) { + const info = console.info; + console.info = () => {}; + try { + await loadFleetModelSet({ fetchImpl: fetchPublicFile }); + } finally { + console.info = info; + } + const created = []; + t.mock.method(Cesium.Model, 'fromGltfAsync', async (options) => { + const model = { + ...options, + ready: true, + show: false, + modelMatrix: new Cesium.Matrix4(), + destroyed: false, + destroy() { + this.destroyed = true; + }, + isDestroyed() { + return this.destroyed; + }, + update() {}, + }; + created.push(model); + return model; + }); + return { + fleet: { + policy: FLEET_TEST_POLICY, + display: () => ({ models3d: true, models3dMode: 'proximity' }), + resolveAsset: (url) => url, + }, + created, + drawn: (id) => + created.filter( + (model) => model.id === id && !model.destroyed && model.show, + ), + }; +} diff --git a/src/testSupport/railLayerHarness.mjs b/src/testSupport/railLayerHarness.mjs index 5f7a2ae62..ddfcba030 100644 --- a/src/testSupport/railLayerHarness.mjs +++ b/src/testSupport/railLayerHarness.mjs @@ -12,6 +12,24 @@ import { publicRailCatalog } from '../data/railFeeds.js'; export const RAIL_HARNESS_T0 = 1_789_000_000_000; export const BOSTON = Object.freeze({ lat: 42.3601, lon: -71.0589 }); +/** A camera's frame looking straight down from `altitude` over `at`. */ +function lookingDown(at, altitude) { + const position = Cesium.Cartesian3.fromDegrees(at.lon, at.lat, altitude); + const up = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal( + position, + new Cesium.Cartesian3(), + ); + return { + directionWC: Cesium.Cartesian3.negate(up, new Cesium.Cartesian3()), + upWC: Cesium.Cartesian3.UNIT_Z, + frustum: { + computeCullingVolume: () => ({ + computeVisibility: () => Cesium.Intersect.INSIDE, + }), + }, + }; +} + /** A train as the rail proxy sends it. */ export function railTrain(id, lat, lon, extra = {}) { return { @@ -64,7 +82,7 @@ const headersOf = (values = {}) => ({ * catalog?: object[]|Promise|Function|false, * ground?: object, * stops?: object|null, scheduleStops?: boolean, - * paths?: Record|null}} [options] + * paths?: Record|null, fleet?: object|null}} [options] * `ground`: a floor service in place of the always-cold stub; `catalog`: * the server's feed catalog (it may list fewer feeds than are bundled; a * promise answers when it settles, a function is called per request and @@ -74,7 +92,9 @@ const headersOf = (values = {}) => ({ * layer's own provider, its schedule tier, in its place; * `paths`: feed id → the path pack the proxy serves, or a * failure status it answers with (404 for any other feed; without it the - * source has no path route at all). + * source has no path route at all); `fleet`: the fleet-model services + * (testSupport/fleetModelFakes.mjs), with a camera that looks straight + * down for them (none by default: trains stay icons). */ export function railLayerHarness( t, @@ -86,6 +106,7 @@ export function railLayerHarness( stops = null, scheduleStops = false, paths = null, + fleet = null, } = {}, ) { const T0 = RAIL_HARNESS_T0; @@ -179,6 +200,8 @@ export function railLayerHarness( cancelFlight() { this.flightsCancelled += 1; }, + // What the fleet models' eligibility reads: a camera looking down. + ...(fleet ? lookingDown(at, altitude) : {}), }, scene: { canvas: { addEventListener() {}, removeEventListener() {} }, @@ -308,6 +331,7 @@ export function railLayerHarness( cachedGroundFloor: () => null, warmGroundFloor() {}, }, + ...(fleet ? { fleet } : {}), camera: { framing: WORLD_FOCUS_FRAMING.train, requestWorldFocus(detail) { diff --git a/src/tools/queries/maritime.js b/src/tools/queries/maritime.js index aeb90714f..439721c77 100644 --- a/src/tools/queries/maritime.js +++ b/src/tools/queries/maritime.js @@ -21,7 +21,10 @@ import { thinEvenly, } from '../results.js'; import { roundHeadingDegrees } from '../../data/headingDegrees.js'; -import { normalizeVesselType } from '../../data/vesselTypes.js'; +import { + isAidToNavigation, + normalizeVesselType, +} from '../../data/vesselTypes.js'; const MMSI_SCHEMA = Object.freeze({ type: 'string', @@ -116,7 +119,8 @@ export const vesselsInArea = defineTool({ title: 'Vessels in an area', description: 'Ships currently reported by AIS inside an area, nearest the center ' + - 'first, optionally filtered by vessel type. group_by counts every matched ' + + 'first, optionally filtered by vessel type. Buoys, beacons and other ' + + 'aids to navigation are not ships and are left out. group_by counts every matched ' + 'ship by type or destination, and stats summarizes their speed; both ' + 'cover all matches, not just the rows returned.', inputSchema: { @@ -152,6 +156,8 @@ export const vesselsInArea = defineTool({ const rows = snapshot.records .filter( (record) => + // A buoy or beacon in the feed is not a ship to count. + !isAidToNavigation(record) && areaContains(area, { lat: record.latitude, lon: record.longitude }) && (!wanted || (vesselType(record.type) ?? '').toLowerCase().includes(wanted)), diff --git a/src/tools/queries/maritime.test.mjs b/src/tools/queries/maritime.test.mjs index fa835b5b7..5715a1e4b 100644 --- a/src/tools/queries/maritime.test.mjs +++ b/src/tools/queries/maritime.test.mjs @@ -467,3 +467,44 @@ test('vessels in an area can be counted by destination with a speed summary', as speed_mps: { count: 2, min: 6.2, median: 6.2, mean: 6.2, max: 6.2 }, }); }); + +test('vessels in an area leave out buoys and beacons', async () => { + const withAids = { + ...vessels(), + async getSnapshot() { + return { + records: [ + ...records, + vessel('993672001', 37.8, -122.42, { + name: 'SF APPROACH N', + type: 'Cardinal Mark N', + speedMps: 0, + headingDeg: 135, + }), + vessel('993672002', 37.79, -122.43, { type: '' }), + ], + source: 'AISStream', + observedAtMs: Date.UTC(2026, 0, 1), + freshness: 'current', + }; + }, + }; + const catalog = composeCatalog({ + tools: coreTools, + services: { vessels: withAids }, + }); + const all = await catalog.call('vessels_in_area', { + area: bay, + group_by: 'type', + }); + assert.equal(all.data.total, 2); + assert.equal(all.summary.split(' in ')[0], '2 vessels'); + assert.deepEqual(all.data.rows.map((row) => row.mmsi).sort(), [ + '366999712', + '538001234', + ]); + assert.equal(all.data.breakdown.unknown_count, 0); + assert.ok( + !all.data.breakdown.groups.some((group) => /mark/i.test(group.value)), + ); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 0adedd7a5..1fd86824f 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -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 does not initialize Core's locale. + * picker, for a host that offers its own language choice. */ constructor( viewer, @@ -400,6 +400,9 @@ export class StyleManager extends ShellFacade { owner: 'cockpit-orbit', switchPolicy: 'esri-if-photoreal', }) ?? null, + // The application's own first-person subjects + // (src/cockpitSubjectProviders.js), checked by the coordinator. + cockpitSubjects: services.cockpitSubjects, }, elements: { _ppToggles: this._ppToggles, @@ -764,7 +767,7 @@ export class StyleManager extends ShellFacade { this._lifetime.own( onLocaleChange(() => nextTick(() => this._sizeDisplayLabelColumn())), ); - // A host that does not initialize Core's locale shows no picker. + // A host that offers its own language choice shows no picker. if (this._localePickerEnabled === false) return; this._publishLocalePicker(); } @@ -1197,6 +1200,11 @@ export class StyleManager extends ShellFacade { toggleEnabled: (...args) => this._toggleCctvEnabled(...args), runExplicitFocus: (...args) => this._runExplicitCctvFocus(...args), setPanelCollapsed: (...args) => this.setPanelCollapsed(...args), + // Clear selected data layers and Context isolation switch the layer + // off without the user asking: the panel keeps its state for both. + preservePanelState: () => + this._preservePanelStateDuringLayerClear || + this._contextSuspendsLayer('cctv'), showToast: (message) => this._showToast(message), syncViewport: () => this._syncCctvPanelViewport(), }, diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index 4e8911695..1e227c92e 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -54,6 +54,7 @@ export class CctvControls { this._cctvSummaryTypingTimer = null; this._lastCctvSummaryText = ''; this._lastSeenCctvActiveId = null; + this._wasCctvEnabled = false; this._cctvChipHideTimer = null; this._cctvChipWasBusy = false; // The camera grid chip as last published; its label stays while hidden. diff --git a/src/ui/cctvControls.test.mjs b/src/ui/cctvControls.test.mjs index 9a5d746d7..c2ef0e776 100644 --- a/src/ui/cctvControls.test.mjs +++ b/src/ui/cctvControls.test.mjs @@ -1,6 +1,7 @@ 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(); @@ -59,6 +60,50 @@ 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); diff --git a/src/ui/cctvFrames.js b/src/ui/cctvFrames.js index cdc9185f7..761711174 100644 --- a/src/ui/cctvFrames.js +++ b/src/ui/cctvFrames.js @@ -77,6 +77,9 @@ 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: '', @@ -127,7 +130,28 @@ export function _queueCctvFrame(src, cameraId, cameraChanged) { const preloader = new Image(); this._cctvFramePreloader = preloader; - preloader.onload = () => this._settleCctvFrame(token, src, true); + // Settle once the image is decoded, not merely loaded: the panel's + // 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.onerror = () => this._settleCctvFrame(token, src, false); preloader.src = src; } diff --git a/src/ui/cctvPanel.test.mjs b/src/ui/cctvPanel.test.mjs index a3dbef786..bc9c83c9b 100644 --- a/src/ui/cctvPanel.test.mjs +++ b/src/ui/cctvPanel.test.mjs @@ -513,3 +513,96 @@ test('the panel meta line reads a bearing that rounds to north as HDG 0°', () = assert.match(meta(359.4), /· HDG 359° ·/); assert.match(meta(359.6), /· HDG 0° ·/); }); + +/** The panel collapses the test asked for, as `[panelId, collapsed]` pairs. */ +function collapseCalls(controls) { + const calls = []; + controls.actions.setPanelCollapsed = (panelId, collapsed) => { + calls.push([panelId, collapsed]); + // The panel chrome owns the class the controls read back. + controls._cctvPanel.classList.toggle('collapsed', collapsed); + }; + return calls; +} + +const activeState = { + enabled: true, + count: 1, + cameraIndex: [], + activeCameraId: camera.id, + activeCamera: camera, +}; + +test('switching the layer off collapses the panel it opened', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + controls._renderCctvState(activeState); + assert.deepEqual(calls, [['cctv-panel', false]]); + // The layer keeps its active camera while off. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); + // A panel reopened by hand stays open through later notifications while + // the layer is off: only the on → off change collapses. + controls._cctvPanel.classList.remove('collapsed'); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.equal(calls.length, 2); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); +}); + +test('a layer that was never on leaves an open panel alone', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] }); + controls._renderCctvState({ enabled: false, count: 3, cameraIndex: [] }); + assert.deepEqual(calls, []); +}); + +test('the data manager switching the layer off collapses the panel too', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + let on = true; + controls.actions.isEnabled = () => on; + controls._renderCctvState(activeState); + on = false; + controls._renderCctvState(activeState); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); +}); + +test('a layer the application suspends keeps its panel through the round trip', (t) => { + const { controls } = panel(t); + const calls = collapseCalls(controls); + // Clear selected data layers and Context isolation both answer true. + let suspended = true; + controls.actions.preservePanelState = () => suspended; + controls._renderCctvState(activeState); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); + suspended = false; + controls._renderCctvState(activeState); + assert.equal(controls._cctvPanel.classList.contains('collapsed'), false); + assert.ok(calls.every(([, collapsed]) => collapsed === false)); + // The next switch-off is the user's again. + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls.at(-1), ['cctv-panel', true]); +}); + +test('a panel the accordion folded still has its saved state closed', (t) => { + const { controls } = panel(t); + controls._renderCctvState(activeState); + const calls = []; + controls.actions.setPanelCollapsed = (...args) => calls.push(args); + // Folded for layout only: the class is there, the saved state is open. The + // panel chrome tells the two apart, so the controls always ask it. + controls._cctvPanel.classList.add('collapsed'); + controls._renderCctvState({ ...activeState, enabled: false }); + assert.deepEqual(calls, [['cctv-panel', true]]); +}); + +test('the meta line of a disabled layer does not describe its last camera', () => { + const off = { ...activeState, enabled: false, count: 3 }; + assert.equal( + presentCctvPanel(off, false).meta, + '3 cameras loaded · enable CCTV to activate', + ); + assert.match(presentCctvPanel(activeState, true).meta, /^Austin · HDG /); +}); diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 7eab9bc2d..2f4cb8584 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -90,7 +90,9 @@ export function presentCctvPanel(state, enabled) { } function metaText(state, enabled, activeCamera, catalogCount) { - if (activeCamera) { + // The layer keeps its active camera while off: a disabled panel must not + // describe a feed it no longer shows. + if (enabled && activeCamera) { const provider = cctvSourceLabelText( activeCamera.sourceLabel || activeCamera.provider || 'Configured Source', ); @@ -188,6 +190,20 @@ export function _renderCctvState(state) { }); } this._lastSeenCctvActiveId = effectiveActiveId; + // Switching the layer off closes the panel it opened: nothing is left to + // show. Only the on → off change collapses, so a panel the user opens + // while the layer is off stays open. Clear selected data layers and + // Context isolation keep the panel: the layer comes back with it. The + // panel chrome decides whether anything changes: a panel the desktop + // accordion only folded still has an open saved state to close. + if ( + this._wasCctvEnabled && + !enabled && + !this.actions.preservePanelState?.() + ) { + this.actions.setPanelCollapsed('cctv-panel', true); + } + this._wasCctvEnabled = enabled; this._updateCctvSyncChip(state?.loading, enabled); diff --git a/src/ui/cockpitBriefing.js b/src/ui/cockpitBriefing.js index e2046d72f..85678ad1f 100644 --- a/src/ui/cockpitBriefing.js +++ b/src/ui/cockpitBriefing.js @@ -164,7 +164,7 @@ export function maybeRefreshRegionalBrief(info) { !Number.isFinite(info.longitude) ) return; - const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`; + const subjectId = `${info.layerId || 'aircraft'}:${info.icao24 || (info.kind === 'vessel' || info.generic === true ? info.id : '') || info.noradId || info.registration || info.callsign || 'unknown'}`; if (subjectId !== this.regionalBriefSubjectId) { this.regionalBriefAbort?.abort(); this.regionalBriefAbort = null; diff --git a/src/ui/cockpitCamera.js b/src/ui/cockpitCamera.js index e97b7f232..ed66369b9 100644 --- a/src/ui/cockpitCamera.js +++ b/src/ui/cockpitCamera.js @@ -3,6 +3,8 @@ import * as Cesium from 'cesium'; import { cockpitAnchorCorrectionStep, cockpitGroundSafeHeight, + easeCockpitLift, + cockpitSubjectStepSec, cockpitSurfaceWaitExpired, cockpitUiUpdateDue, slewHeading, @@ -16,6 +18,7 @@ import { waterlineProbePoints, } from '../cockpitSubjects.js'; import { easeLook } from '../cockpitLook.js'; +import { cockpitSubjectFrame } from '../cockpitSubjectProviders.js'; import { COCKPIT_HEADING_SLEW_DPS, COCKPIT_FORWARD_OFFSET_M, @@ -53,6 +56,10 @@ export function update() { } const nowMs = performance.now(); + if (this.subjectProvider) { + updateSubject.call(this, nowMs); + return; + } if (this.subjectKind === 'vessel') { updateVessel.call(this, nowMs); return; @@ -297,9 +304,128 @@ function updateVessel(nowMs) { ); } +/** + * Drive the camera from an application provider's subject: read its frame, + * stand the eye `eye.upM` above `heightM` (the shared floor where the frame + * gives none) and slew toward its heading. A frame that is null, malformed or + * for another subject ends the view, as an aircraft tracked meanwhile does. + * The update runs on the provider's `cameraIntervalMs` (0: every frame), and + * its rates advance by the time that interval spans (cockpitSubjectStepSec). + */ +function updateSubject(nowMs) { + const provider = this.subjectProvider; + if (this.viewer.trackedEntity?.position) { + this.exit({ restoreTracking: false }); + return; + } + const intervalMs = provider.cameraIntervalMs ?? COCKPIT_CAMERA_UPDATE_MS; + if (!cockpitUiUpdateDue(nowMs, this.lastCameraUpdateMs, intervalMs)) return; + this.lastCameraUpdateMs = nowMs; + const frame = cockpitSubjectFrame( + this.callSubject(provider, 'read', [nowMs, this.subjectContext()], null), + this.subjectId, + ); + if (!frame) { + this.exit({ restoreTracking: false }); + return; + } + this.lastSubjectFrame = frame; + let heightM = frame.heightM; + if (heightM === null) { + this.services.warmGroundFloor?.([ + { lat: frame.latitude, lon: frame.longitude }, + ]); + const floorM = this.services.cachedGroundFloor?.( + frame.latitude, + frame.longitude, + ); + // A frame with no height stands on the shared floor; while that is cold + // it keeps the height it last stood at. + heightM = Number.isFinite(floorM) ? floorM : this.subjectHeightM; + } + if (!Number.isFinite(heightM)) { + // Nothing to stand on yet, from the first frame: the camera stays where + // it is while the floor is acquired, its HUD saying so, rather than + // standing on the ellipsoid (kilometres underground on a plateau). The + // wait is bounded as the built-in views' is. Unlike a parked aircraft, + // whose fallback is its own reported altitude, such a subject has no + // height at all to fall back to: any number would be a guess, so the + // view ends and the provider gets its subject back. + if (!this.surfaceWaitStartedMs) this.surfaceWaitStartedMs = nowMs; + if ( + cockpitSurfaceWaitExpired( + nowMs, + this.surfaceWaitStartedMs, + COCKPIT_GROUND_WAIT_TIMEOUT_MS, + ) + ) { + this.exit({ restoreTracking: false }); + return; + } + this.surfaceAcquiring = true; + if ( + cockpitUiUpdateDue(nowMs, this.lastHudUpdateMs, COCKPIT_HUD_UPDATE_MS) + ) { + this.lastHudUpdateMs = nowMs; + this.updateHud(this.subjectInfo(frame), nowMs); + } + return; + } + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.subjectHeightM = heightM; + const target = Cesium.Cartesian3.fromDegrees( + frame.longitude, + frame.latitude, + heightM, + Cesium.Ellipsoid.WGS84, + this.scratchTarget, + ); + const dtSec = cockpitSubjectStepSec(nowMs - this.lastFrameMs, intervalMs); + this.lastFrameMs = nowMs; + if (frame.headingDeg !== null) { + const rate = provider.headingSlewDps ?? COCKPIT_HEADING_SLEW_DPS; + this.heading = + rate === Infinity + ? frame.headingDeg + : slewHeading( + this.heading ?? frame.headingDeg, + frame.headingDeg, + rate * dtSec, + ); + } + let look = null; + if (provider.look) { + this.lookView = easeLook( + this.lookView, + this.lookTarget, + nowMs - (this.lookLastMs || nowMs), + ); + this.lookLastMs = nowMs; + look = this.lookView; + } + placeCamera.call( + this, + target, + this.subjectInfo(frame), + nowMs, + dtSec, + { + forwardM: frame.eye.forwardM, + upM: frame.eye.upM, + pitchDeg: frame.eye.pitchDeg, + minClearanceM: frame.eye.minClearanceM, + }, + look, + ); +} + /** * Place the camera for one update: advance the inertial anchor, clamp it and * the eye against the rendered surface, and look along the slewed heading. + * A subject with `anchor: 'exact'` keeps the anchor on its target (its layer + * already moves it smoothly), and one with `groundClamp: false` (inside a + * structure, say) is never pushed up to the floor: a lift it had eases out. * @param {Cesium.Cartesian3} target Authoritative subject position. * @param {object} info Subject info; `motionCourse` (when present) is the * direction the subject moves, otherwise it moves where it faces. @@ -316,6 +442,7 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { if (!this.cockpitAnchorValid) { Cesium.Cartesian3.clone(target, this.cockpitAnchor); this.cockpitAnchorValid = true; + this.subjectLiftM = null; } // First-person motion cannot use the delayed feed correction as a raw @@ -339,7 +466,8 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { ? Math.max(0, info.velocityMps) : 0; - if (info.stale) { + const groundClamp = info.groundClamp !== false; + if (info.stale || info.anchor === 'exact') { // A feed backoff has no authoritative velocity epoch to advance from. // Hold the cockpit on the exact layer-rendered position so the camera // cannot coast away while the icon correctly remains fixed. @@ -377,10 +505,13 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { const correctionDistanceM = Cesium.Cartesian3.magnitude( this.scratchCorrection, ); + // An application subject's step is already bounded by its own update + // interval (cockpitSubjectStepSec); the built-in views keep the 0.1 s. const correctionStepM = cockpitAnchorCorrectionStep( correctionDistanceM, speedMps, dtSec, + info.generic === true ? dtSec : undefined, ); if (correctionStepM > 0 && correctionDistanceM > 0) { Cesium.Cartesian3.multiplyByScalar( @@ -498,7 +629,14 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { cockpitFloorM = targetCartographic.height - this.services.GROUND_FLOOR_LIFT_M; } - if (anchorCartographic && Number.isFinite(cockpitFloorM)) { + // An application subject's anchor is where its provider puts it; only its + // eye is kept out of the floor, below. + if ( + groundClamp && + info.generic !== true && + anchorCartographic && + Number.isFinite(cockpitFloorM) + ) { const minimumAnchorHeightM = cockpitGroundSafeHeight( anchorCartographic.height, cockpitFloorM, @@ -579,7 +717,7 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { Cesium.Ellipsoid.WGS84, this.scratchCameraCartographic, ); - if (cameraCartographic) { + if (groundClamp && cameraCartographic) { const cameraLat = Cesium.Math.toDegrees(cameraCartographic.latitude); const cameraLon = Cesium.Math.toDegrees(cameraCartographic.longitude); const cameraFloorM = this.services.cachedGroundFloor(cameraLat, cameraLon); @@ -589,11 +727,26 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { cameraFloorM, ); } - const safeHeightM = cockpitGroundSafeHeight( + let safeHeightM = cockpitGroundSafeHeight( cameraCartographic.height, cockpitFloorM, profile.minClearanceM, ); + // An application subject's eye rides its provider's smoothed height: + // the clearance over the coarse, refining floor eases in and out + // rather than jumping at each cell (easeCockpitLift). + if (info.generic === true) { + const hardM = + cockpitGroundSafeHeight(cameraCartographic.height, cockpitFloorM, 0) - + cameraCartographic.height; + this.subjectLiftM = easeCockpitLift( + this.subjectLiftM, + safeHeightM - cameraCartographic.height, + hardM, + dtSec, + ); + safeHeightM = cameraCartographic.height + this.subjectLiftM; + } if (safeHeightM !== cameraCartographic.height) { cameraCartographic.height = safeHeightM; Cesium.Ellipsoid.WGS84.cartographicToCartesian( @@ -601,6 +754,17 @@ function placeCamera(target, info, nowMs, dtSec, profile, look = null) { this.scratchCamera, ); } + } else if (info.generic === true) { + // Inside a structure the floor holds the eye up no more: the lift it gave + // goes at the rate it came (easeCockpitLift), never at once. + this.subjectLiftM = easeCockpitLift(this.subjectLiftM, 0, 0, dtSec); + if (cameraCartographic && this.subjectLiftM > 0) { + cameraCartographic.height += this.subjectLiftM; + Cesium.Ellipsoid.WGS84.cartographicToCartesian( + cameraCartographic, + this.scratchCamera, + ); + } } this.viewer.camera.setView({ diff --git a/src/ui/cockpitController.js b/src/ui/cockpitController.js index f7f236dea..34ba0a341 100644 --- a/src/ui/cockpitController.js +++ b/src/ui/cockpitController.js @@ -3,12 +3,18 @@ import { readAircraftInfo, readSatelliteInfo, readVesselInfo, + readSubjectCandidate, readSubjectLayerId, + subjectContext, + subjectInfo, + entryCandidates, + resolveEntry, resolveEntryKind, dispatchCockpitModeChanged, toggleTrackedTr3b, syncTr3bToggle, syncEntry, + refreshSubjectWords, publishPresenceView, navigateContext, _adoptTrackedEntity, @@ -100,6 +106,7 @@ import { import { DEFAULT_RADAR_RANGE_NM, nextRadarRange } from '../cockpitRadar.js'; import { VESSEL_BRIDGE_PITCH_DEG } from '../cockpitSubjects.js'; import { stepLook } from '../cockpitLook.js'; +import { createCockpitSubjectCaller } from '../cockpitSubjectProviders.js'; import * as Cesium from 'cesium'; /** Whether the user asked for reduced motion: look turns then jump. */ @@ -120,6 +127,8 @@ export class CockpitViewController { isEntryAllowed = null, isBridgeEntryAllowed = null, isOrbitEntryAllowed = null, + subjectProviders = [], + isSubjectEntryAllowed = null, isUtilityDisclosureOpen = null, onEntered = null, onExited = null, @@ -134,10 +143,27 @@ export class CockpitViewController { this.disposed = false; this.active = false; /** - * 'aircraft', 'vessel' or 'satellite' while active; which subject the - * camera rides. + * 'aircraft', 'vessel', 'satellite' or a provider's kind while active; + * which subject the camera rides. */ this.subjectKind = null; + /** + * The application's subject providers (cockpitSubjectProviders.js), + * checked by the coordinator; the one ridden while active, else null. + */ + this.subjectProviders = Array.isArray(subjectProviders) + ? subjectProviders + : []; + this.subjectProvider = null; + /** The ridden provider subject's last frame and normalized HUD view. */ + this.lastSubjectFrame = null; + this.subjectHud = null; + /** The Live Signals rows the provider's HUD view pushed. */ + this.subjectSignalKeys = new Set(); + /** Provider methods run through one guard: a throw warns once. */ + this.callSubject = createCockpitSubjectCaller(); + /** The look's rest pitch for a provider's view; null on a bridge. */ + this.lookRestPitchDeg = null; /** * The vessel subject's id or the satellite's NORAD id; aircraft are * identified by trackedEntity. @@ -160,6 +186,10 @@ export class CockpitViewController { this.surfaceWaitStartedMs = 0; this.surfaceAcquiring = false; this.surfaceFallback = false; + /** The height an application subject's frame last stood at (m). */ + this.subjectHeightM = null; + /** The ground clamp's eased lift under that subject's eye (m). */ + this.subjectLiftM = null; /** * Where the bridge view is turned to (`lookTarget`) and where the camera * looks on its way there (`lookView`): yaw off the bow, starboard @@ -203,6 +233,11 @@ export class CockpitViewController { typeof isOrbitEntryAllowed === 'function' ? isOrbitEntryAllowed : () => false; + // Nor a provider's subject without a gate for it. + this.isSubjectEntryAllowed = + typeof isSubjectEntryAllowed === 'function' + ? isSubjectEntryAllowed + : () => false; this.isUtilityDisclosureOpen = typeof isUtilityDisclosureOpen === 'function' ? isUtilityDisclosureOpen @@ -246,7 +281,14 @@ export class CockpitViewController { entryAvailable: false, // Which subject the entry button would enter, and the active subject. entryKind: null, + // A provider's entry face, `{icon, label}`; null for the built-ins. + entryFace: null, subjectKind: null, + // While a provider's subject is ridden: its HUD and exit labels, and + // `{aheadLabel}` when it looks around. + subjectGeneric: false, + subjectLabels: null, + subjectLook: null, tr3b: null, context: false, // The Contact and briefing cards' collapse, which their toggles show. @@ -508,6 +550,21 @@ export class CockpitViewController { if (reset && hadFocus) this.mapViewButton?.focus({ preventScroll: true }); return reset; } + readSubjectCandidate(provider) { + return readSubjectCandidate.call(this, provider); + } + subjectContext() { + return subjectContext.call(this); + } + subjectInfo(frame) { + return subjectInfo.call(this, frame); + } + entryCandidates(nowMs) { + return entryCandidates.call(this, nowMs); + } + resolveEntry(nowMs) { + return resolveEntry.call(this, nowMs); + } readSubjectLayerId() { return readSubjectLayerId.call(this); } @@ -529,6 +586,9 @@ export class CockpitViewController { syncEntry() { return syncEntry.call(this); } + refreshSubjectWords() { + return refreshSubjectWords.call(this); + } navigateContext(direction, options) { return navigateContext.call(this, direction, options); } @@ -568,6 +628,19 @@ export class CockpitViewController { presentLook() { return presentLook.call(this); } + /** Whether the active view looks around: a bridge, or a provider's with `look`. */ + get looksAround() { + return ( + this.active && + (this.subjectKind === 'vessel' || Boolean(this.subjectProvider?.look)) + ); + } + /** Where the look rests: the bridge's pitch, or the provider's. */ + get lookRestPitch() { + return Number.isFinite(this.lookRestPitchDeg) + ? this.lookRestPitchDeg + : VESSEL_BRIDGE_PITCH_DEG; + } /** * Turn the bridge view one arrow step (see `lookKeyStep`). The camera * eases there unless the user prefers reduced motion. @@ -575,7 +648,7 @@ export class CockpitViewController { * @returns {boolean} True when a bridge view was turned. */ turnLook(step) { - if (!this.active || this.subjectKind !== 'vessel' || !step) return false; + if (!this.looksAround || !step) return false; this.lookTarget = stepLook(this.lookTarget, step); if (prefersReducedMotion()) this.lookView = { ...this.lookTarget }; this.presentLook(); @@ -588,7 +661,7 @@ export class CockpitViewController { * as entry and exit do. */ resetLook({ immediate = false } = {}) { - this.lookTarget = { yawDeg: 0, pitchDeg: VESSEL_BRIDGE_PITCH_DEG }; + this.lookTarget = { yawDeg: 0, pitchDeg: this.lookRestPitch }; if (immediate || prefersReducedMotion()) this.lookView = { ...this.lookTarget }; this.lookLastMs = performance.now(); @@ -601,7 +674,7 @@ export class CockpitViewController { * @returns {boolean} True on a bridge. */ lookAhead() { - if (!this.active || this.subjectKind !== 'vessel') return false; + if (!this.looksAround) return false; const button = document.getElementById('cockpit-look-ahead'); const hadFocus = !!button && button.contains(document.activeElement); this.resetLook(); @@ -718,6 +791,9 @@ export class CockpitViewController { this.relocalizeBrief(); this.publishVision(createVisionState(this.visionMode)); this.routeSignature = null; + // A provider's words, ridden or on the entry, follow the language. + this.refreshSubjectWords(); + if (!this.active) this.syncEntry(); if (this.active && this.lastSubjectInfo) { this.rewordingSignals = true; try { diff --git a/src/ui/cockpitCoordinator.js b/src/ui/cockpitCoordinator.js index c431a2ef5..baf6cc862 100644 --- a/src/ui/cockpitCoordinator.js +++ b/src/ui/cockpitCoordinator.js @@ -5,7 +5,9 @@ import { bridgeEntryAllowed, cockpitEntryAllowed, orbitEntryAllowed, + subjectEntryAllowed, } from '../contextModePolicy.js'; +import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js'; import { formatAwarenessLabel } from '../data/militaryAwarenessEngine.js'; import { enterCockpitWithTracking } from '../cockpitTracking.js'; import { cockpitVisionModeForStyle } from '../cockpitVisionPolicy.js'; @@ -60,8 +62,11 @@ export class CockpitCoordinator { regionalDistanceM, weatherCodeLabel, acquireOrbitImagery, + cockpitSubjects, } = services; this._cockpitDisplayPortal = null; + // The application's own subjects (services.cockpitSubjects), checked once. + const subjectProviders = cockpitSubjectProviders(cockpitSubjects ?? []); this.cockpitView = new CockpitViewController(viewer, { services: { flightsLayer, @@ -108,6 +113,21 @@ export class CockpitCoordinator { vesselsEnabled: !!this._dataManager?.isEnabled('ais-live-vessels'), contextModeChanging: this._contextModeChanging, }), + subjectProviders, + // A provider's subject needs its own layer on, no Context change in + // flight, and the provider's own say. + isSubjectEntryAllowed: (provider, candidate) => + subjectEntryAllowed({ + layerEnabled: this._isLayerEnabled(provider.layerId), + contextModeChanging: this._contextModeChanging, + providerAllows: + this.cockpitView?.callSubject( + provider, + 'isEntryAllowed', + [candidate], + false, + ) === true, + }), isOrbitEntryAllowed: () => orbitEntryAllowed({ satellitesEnabled: !!this._dataManager?.isEnabled('satellites'), @@ -197,6 +217,9 @@ export class CockpitCoordinator { this.cockpitView.subjectKind === 'satellite' ) return 'Cockpit is in an orbital view — exit it first, then track an aircraft to enter Cockpit'; + // And in an application's own first-person view. + if (this.cockpitView?.active && this.cockpitView.subjectProvider) + return 'Cockpit is in another first-person view — exit it first, then track an aircraft to enter Cockpit'; if (!this._aircraftLayerEnabled) return 'No aircraft layer is on — turn on Flights or Military Flights and track an aircraft to enter Cockpit'; return null; @@ -216,6 +239,17 @@ export class CockpitCoordinator { active && this.cockpitView?.subjectKind === 'satellite' ? this.cockpitView.readSatelliteInfo?.() : null; + // And a provider's subject in use. + const provided = + active && this.cockpitView?.subjectProvider + ? { + kind: this.cockpitView.subjectProvider.kind, + id: this.cockpitView.subjectId, + layerId: this.cockpitView.subjectProvider.layerId, + callsign: null, + name: this.cockpitView.lastSubjectFrame?.name ?? null, + } + : null; const gateOpen = Boolean(this.cockpitView?.isEntryAllowed?.()); // "Could Cockpit be ENTERED right now" — so it is false while already // inside, unconditionally. Cockpit takes the entity off @@ -244,30 +278,32 @@ export class CockpitCoordinator { ? 'subject-layer-off' : 'no-tracked-aircraft', visionMode: this.cockpitView?.visionMode || null, - subject: satellite - ? { - kind: 'satellite', - id: satellite.id, - layerId: satellite.layerId, - callsign: null, - name: satellite.name, - } - : vessel + subject: provided + ? provided + : satellite ? { - kind: 'vessel', - id: vessel.id, - layerId: vessel.layerId, + kind: 'satellite', + id: satellite.id, + layerId: satellite.layerId, callsign: null, - name: vessel.name, + name: satellite.name, } - : info + : vessel ? { - kind: 'aircraft', - id: info.icao24 || info.id || null, - layerId: info.layerId || null, - callsign: info.callsign || null, + kind: 'vessel', + id: vessel.id, + layerId: vessel.layerId, + callsign: null, + name: vessel.name, } - : null, + : info + ? { + kind: 'aircraft', + id: info.icao24 || info.id || null, + layerId: info.layerId || null, + callsign: info.callsign || null, + } + : null, navigation: snapshot ? { canPrevious: Boolean(snapshot.navigation?.canPrevious), diff --git a/src/ui/cockpitEntryGate.test.mjs b/src/ui/cockpitEntryGate.test.mjs index fd5dfb52b..8abebe9e9 100644 --- a/src/ui/cockpitEntryGate.test.mjs +++ b/src/ui/cockpitEntryGate.test.mjs @@ -55,8 +55,11 @@ function fixture( changing = false, dataManager: suppliedManager = null, readContext = null, + subjects = [], + vessel = null, } = {}, ) { + let selectedVessel = vessel; document.body.replaceChildren(); const host = document.body.appendChild(document.createElement('div')); const pinia = createPinia(); @@ -101,7 +104,13 @@ function fixture( services: { flightsLayer: { getTrackedInfo: trackedFrom('flights') }, militaryFlightsLayer: { getTrackedInfo: trackedFrom('military') }, - aisLiveVesselsLayer: { getCockpitSubject: () => null }, + // Like the layer, no subject while Live AIS Vessels is off. + aisLiveVesselsLayer: { + getCockpitSubject: () => + selectedVessel && layers.has('ais-live-vessels') + ? { ...selectedVessel } + : null, + }, // Like the layer, no subject while Satellites is off. satellitesLayer: { getCockpitSubject: () => @@ -113,6 +122,7 @@ function fixture( isTr3b: () => false, holdContinuousRender() {}, releaseContinuousRender() {}, + cockpitSubjects: subjects, }, elements: {}, operations: { @@ -147,6 +157,7 @@ function fixture( 'cockpit-mode', 'cockpit-bridge', 'cockpit-orbit', + 'cockpit-subject', ); }); /** Turn a layer off and tell the Context owner, as the data manager does. */ @@ -171,10 +182,35 @@ function fixture( viewer.trackedEntity = entityFor(subject); entities.add(viewer.trackedEntity); }; + /** Select a vessel on the map, or none. */ + const selectVessel = (info) => { + selectedVessel = info; + }; const $ = (selector) => document.querySelector(selector); - return { $, coordinator, cockpit, store, layers, disable, retrack, pinia }; + return { + $, + coordinator, + cockpit, + store, + layers, + disable, + retrack, + selectVessel, + pinia, + }; } +/** A selected vessel as the AIS layer gives it to the bridge view. */ +const VESSEL = Object.freeze({ + id: '211000001', + name: 'EXAMPLE', + latitude: 54, + longitude: 10, + headingDeg: 90, + courseDeg: 90, + speedMps: 5, +}); + test('a civilian aircraft enters with Live Flights alone: no Contacts, no Military', (t) => { const { coordinator, cockpit } = fixture(t, { enabled: ['flights'], @@ -567,6 +603,32 @@ test('the readout entry reads BRIDGE for a selected vessel', async (t) => { assert.match(readoutEntry.textContent, /BRIDGE/); }); +test('without providers the built-in order decides: a vessel selected after an aircraft still reads COCKPIT', async (t) => { + const { $, cockpit, store, selectVessel } = fixture(t, { + enabled: ['flights', 'ais-live-vessels'], + tracked: { layerId: 'flights', info: CIVIL }, + }); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'aircraft'); + await new Promise((resolve) => setTimeout(resolve, 2)); + selectVessel(VESSEL); + cockpit.syncEntry(); + await nextTick(); + assert.equal(store.presence.entryKind, 'aircraft'); + assert.match($('#cockpit-entry-readout').textContent, /COCKPIT/); + // And the other way round: an aircraft tracked after the vessel. + const later = fixture(t, { + enabled: ['flights', 'ais-live-vessels'], + vessel: VESSEL, + }); + later.cockpit.syncEntry(); + assert.equal(later.store.presence.entryKind, 'vessel'); + await new Promise((resolve) => setTimeout(resolve, 2)); + later.retrack({ layerId: 'flights', info: CIVIL }); + later.cockpit.syncEntry(); + assert.equal(later.store.presence.entryKind, 'aircraft'); +}); + test('a tracked satellite needs Satellites on for the ORBIT entry', async (t) => { const { $, cockpit } = fixture(t, { satellite: SATELLITE }); cockpit.syncEntry(); @@ -648,3 +710,148 @@ test('turning Satellites off ends the orbital view', (t) => { disable('satellites'); assert.equal(cockpit.active, false); }); + +/** An application provider whose subject sits on `example-layer`. */ +function exampleSubject() { + const released = []; + return { + released, + provider: { + kind: 'example-kind', + layerId: 'example-layer', + entryFace: { icon: 'commute', label: 'RIDE' }, + labels: { view: 'Example view', exit: 'Exit example', exitText: 'EXIT' }, + candidate: () => ({ id: 'a1', name: 'Example 12' }), + hold: () => true, + read: () => ({ + id: 'a1', + name: 'Example 12', + latitude: 30, + longitude: -97, + heightM: 200, + headingDeg: 90, + eye: { upM: 2 }, + }), + hud: () => ({ title: 'Example 12' }), + release: (id, { restore }) => released.push([id, restore]), + }, + }; +} + +test("a provider's subject needs only its own layer, and never a Context change in flight", async (t) => { + const { provider } = exampleSubject(); + let changing = false; + const off = fixture(t, { subjects: [provider] }); + off.cockpit.syncEntry(); + await nextTick(); + assert.equal(off.$('#cockpit-entry').hidden, true, 'its layer is off'); + assert.equal(off.cockpit.enter(), false); + const on = fixture(t, { + enabled: ['example-layer'], + subjects: [provider], + readContext: () => ({ mode: null, changing }), + }); + changing = true; + on.cockpit.syncEntry(); + await nextTick(); + assert.equal(on.$('#cockpit-entry').hidden, true, 'Context is changing'); + changing = false; + on.cockpit.syncEntry(); + await nextTick(); + assert.equal(on.$('#cockpit-entry').hidden, false); + assert.match(on.$('#cockpit-entry').textContent, /RIDE/); + assert.match(on.$('#cockpit-entry-readout').textContent, /commute/); +}); + +test("a provider's view reports its subject, refuses voice entry and ends with its layer", async (t) => { + const { provider, released } = exampleSubject(); + const { cockpit, coordinator, disable } = fixture(t, { + enabled: ['example-layer', 'flights'], + subjects: [provider], + }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + const state = coordinator.getCockpitState(); + assert.equal(state.active, true); + assert.deepEqual(state.subject, { + kind: 'example-kind', + id: 'a1', + layerId: 'example-layer', + callsign: null, + name: 'Example 12', + }); + assert.equal(coordinator.controlCockpit('enter').ok, false); + assert.match(coordinator._cockpitEntryRefusal(), /another first-person view/); + disable('flights'); + assert.equal(cockpit.active, true, 'another layer is free to go off'); + disable('example-layer'); + assert.equal(cockpit.active, false, 'its own layer going off ends it'); + assert.deepEqual(released, [['a1', false]]); +}); + +test("a provider's guards: a throwing candidate, gate or release, and a gate that is not true", async (t) => { + const warned = []; + t.mock.method(console, 'warn', (message) => warned.push(String(message))); + const entered = (provider) => { + const { cockpit } = fixture(t, { + enabled: ['example-layer'], + subjects: [provider], + }); + cockpit.syncEntry(); + return { cockpit, entered: cockpit.enter() }; + }; + const { provider: base } = exampleSubject(); + // A candidate() that throws offers nothing. + const noCandidate = entered({ + ...base, + candidate: () => { + throw new Error('candidate failed'); + }, + }); + assert.equal(noCandidate.entered, false); + assert.equal(noCandidate.cockpit.resolveEntry(), null); + // An isEntryAllowed() that throws, or answers anything but true, shuts + // the gate. + for (const isEntryAllowed of [ + () => { + throw new Error('gate failed'); + }, + () => 1, + () => 'yes', + () => ({}), + ]) { + const gated = entered({ ...base, isEntryAllowed }); + assert.equal(gated.entered, false, String(isEntryAllowed)); + assert.equal(gated.cockpit.active, false); + } + // A release() that throws still ends the view. + const { provider } = exampleSubject(); + const releasing = entered({ + ...provider, + release: () => { + throw new Error('release failed'); + }, + }); + assert.equal(releasing.entered, true); + assert.equal(releasing.cockpit.exit(), true); + assert.equal(releasing.cockpit.active, false); + assert.equal(document.body.classList.contains('cockpit-subject'), false); + for (const method of ['candidate', 'isEntryAllowed', 'release']) + assert.ok( + warned.some((line) => line.includes(`${method}() failed`)), + method, + ); +}); + +test('a malformed provider is left out and the built-in kinds are unchanged', async (t) => { + t.mock.method(console, 'warn', () => {}); + const { cockpit } = fixture(t, { + enabled: ['flights'], + tracked: { layerId: 'flights', info: CIVIL }, + subjects: [{ kind: 'aircraft', layerId: 'flights' }], + }); + assert.deepEqual(cockpit.subjectProviders, []); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + assert.equal(cockpit.subjectKind, 'aircraft'); +}); diff --git a/src/ui/cockpitInput.js b/src/ui/cockpitInput.js index 4ce5d5b63..9574068ce 100644 --- a/src/ui/cockpitInput.js +++ b/src/ui/cockpitInput.js @@ -41,6 +41,9 @@ function subjectWorldPosition(kind) { } if (kind === 'satellite') return this.services.satellitesLayer?.getCockpitPosition?.() ?? null; + // An application's subject has no position before its view is entered: + // the selected vessel's sprite must not stand in for it. + if (kind !== 'vessel') return null; const info = this.readVesselInfo(); if (!Number.isFinite(info?.latitude) || !Number.isFinite(info?.longitude)) return null; @@ -189,7 +192,7 @@ const LOOK_KEY_OWNERS = [ * @returns {boolean} True when the key was taken. */ function handleLookKey(event) { - if (!this.active || this.subjectKind !== 'vessel') return false; + if (!this.looksAround) return false; if (event.metaKey || event.ctrlKey || event.altKey) return false; const step = lookKeyStep(event.key, event.shiftKey); const reset = !event.repeat && (event.key === 'Home' || event.key === '0'); diff --git a/src/ui/cockpitInstrumentState.js b/src/ui/cockpitInstrumentState.js index 0ba28d89a..ccc6a5b89 100644 --- a/src/ui/cockpitInstrumentState.js +++ b/src/ui/cockpitInstrumentState.js @@ -52,7 +52,7 @@ function createRimState(value) { */ export function createInstrumentState() { return { - // 'aircraft' or 'vessel': which readouts the HUD labels and shows. + // 'aircraft', 'vessel' or 'subject': which readouts the HUD shows. kind: 'aircraft', layer: null, callsign: t('AIRCRAFT'), @@ -76,6 +76,19 @@ export function createInstrumentState() { look: { offset: false, yawDeg: 0, readout: '' }, clock: '--:--:--Z', position: '--° · --°', + // A provider subject's HUD (kind 'subject', cockpitSubjectProviders.js): + // its kicker, speed unit and tag, readouts, sources, banner, veil and + // progress line, all worded by the provider. + kicker: '', + speedUnit: '', + speedTag: '', + speedTone: 'normal', + readouts: [], + sources: [], + banner: null, + veil: null, + progress: null, + lookEnabled: false, }; } @@ -86,8 +99,10 @@ export function createInstrumentState() { */ export function createRouteState() { return { - // A vessel's card shows only the free-text AIS destination. + // A vessel's card shows only the free-text AIS destination; a provider's + // subject's (kind 'subject') carries its own title. kind: 'aircraft', + title: '', visible: false, from: t('UNKNOWN'), to: t('UNKNOWN'), diff --git a/src/ui/cockpitInstruments.js b/src/ui/cockpitInstruments.js index c5e9863cd..62cb0e6ec 100644 --- a/src/ui/cockpitInstruments.js +++ b/src/ui/cockpitInstruments.js @@ -35,6 +35,7 @@ import { vesselCameraProfile, } from '../cockpitSubjects.js'; import { lookIsOffset, lookReadoutParts, wrapLookYaw } from '../cockpitLook.js'; +import { cockpitSubjectHudView } from '../cockpitSubjectProviders.js'; import { RADAR_MAX_CONTACTS, radarPicture, @@ -90,6 +91,13 @@ export function publishInstrumentsView() { compass: view.compass.map((division) => ({ ...division })), compassMarkers: { ...view.compassMarkers }, look: { ...view.look }, + // A provider's view model is rebuilt on each HUD update, never edited + // in place, so it is published as it stands. + readouts: view.readouts, + sources: view.sources, + banner: view.banner, + veil: view.veil, + progress: view.progress, }); } @@ -118,24 +126,162 @@ function wordLook({ side, bearingDeg, elevation, angleDeg }) { */ export function presentLook() { const target = this.lookTarget; + // A provider's view words its own readout (its HUD view's `lookReadout`). + const provider = Boolean(this.subjectProvider); + const rest = provider ? this.lookRestPitch : VESSEL_BRIDGE_PITCH_DEG; const offset = - this.active && - this.subjectKind === 'vessel' && - lookIsOffset(target, VESSEL_BRIDGE_PITCH_DEG); + (this.active && this.subjectKind === 'vessel') || + (provider && this.looksAround) + ? lookIsOffset(target, rest) + : false; this.instruments.look = offset ? { offset: true, yawDeg: wrapLookYaw(target.yawDeg), - readout: wordLook(lookReadoutParts(target, VESSEL_BRIDGE_PITCH_DEG)), + readout: provider + ? (this.subjectHud?.lookReadout ?? '') + : wordLook(lookReadoutParts(target, VESSEL_BRIDGE_PITCH_DEG)), } : { offset: false, yawDeg: 0, readout: '' }; } +/** Live Signals' tone for a provider's signal row. */ +const SUBJECT_SIGNAL_TONES = Object.freeze({ + warning: 'warning', + alert: 'warning', +}); + +/** + * Present a provider subject's HUD: its view model (cockpitSubjectHudView) + * in the generic readouts, the compass at the camera's heading, the route + * card, and its signals in Live Signals. A HUD view that is null, a `hud()` + * that throws, or a view whose reading throws keeps the last one. Contact + * navigation stays with aircraft, so the Contact card is withdrawn. + */ +function updateSubjectHud(info, nowMs, forceContext) { + const provider = this.subjectProvider; + this.lastSubjectInfo = info; + const frame = this.lastSubjectFrame; + const look = { + yawDeg: this.lookTarget?.yawDeg ?? 0, + pitchDeg: this.lookTarget?.pitchDeg ?? 0, + offset: + this.looksAround && lookIsOffset(this.lookTarget, this.lookRestPitch), + }; + const raw = frame + ? this.callSubject( + provider, + 'hud', + [frame, Object.freeze({ ...this.subjectContext(), look })], + null, + ) + : null; + if (raw != null) + this.subjectHud = cockpitSubjectHudView(raw) ?? this.subjectHud; + const hud = this.subjectHud ?? cockpitSubjectHudView(null); + const view = this.instruments; + view.kind = 'subject'; + view.trackSource = null; + view.kicker = hud.kicker || provider.labels.kicker; + view.callsign = hud.title || info.name || ''; + // While the floor under a subject with no height is acquired + // (cockpitCamera.js), the meta line says so in place of the provider's. + const acquiring = this.surfaceAcquiring === true; + view.meta.text = acquiring ? t('ACQUIRING SURFACE') : hud.meta.text; + view.meta.tone = acquiring ? 'warning' : hud.meta.tone; + view.meta.emergency = !acquiring && hud.meta.tone === 'alert'; + const speed = hud.speed?.value ?? null; + view.speed = hud.speed + ? { + text: speed === null ? '---' : formatNumber(Math.round(speed)), + value: speed === null ? null : Math.round(speed), + immediate: forceContext, + } + : null; + view.speedUnit = hud.speed?.unit ?? ''; + view.speedTag = hud.speed?.tag ?? ''; + view.speedTone = hud.speed?.tone ?? 'normal'; + view.speedRim.unavailable = true; + view.altitude = null; + view.altitudeRim.unavailable = true; + view.course = null; + const heading = normalizeHeading(this.heading ?? info.track ?? 0); + view.heading = { + text: formatHeadingReadout(heading), + value: heading, + immediate: forceContext, + }; + const viewHeading = normalizeHeading( + heading + (provider.look ? (this.lookView?.yawDeg ?? 0) : 0), + ); + view.compass = scrollingCompassDivisions(viewHeading).map((division) => ({ + slot: division.slot, + heading: division.heading, + active: division.nearest, + label: compassPointLabel(formatCompassDivision(division.heading)), + })); + view.compassMarkers = { + prograde: compassMarkerSlot(heading, viewHeading), + retrograde: compassMarkerSlot(heading + 180, viewHeading), + }; + view.clock = new Date().toISOString().slice(11, 19) + 'Z'; + view.position = `${formatCockpitCoordinate(info.latitude, 'N', 'S')} · ${formatCockpitCoordinate(info.longitude, 'E', 'W')}`; + view.readouts = hud.readouts; + view.sources = hud.sources; + view.banner = hud.banner; + view.veil = hud.veil; + view.progress = hud.progress; + view.lookEnabled = Boolean(provider.look); + view.layer = provider.layerId; + this.presentLook(); + this.publishInstrumentsView(); + const route = this.routeView; + route.kind = 'subject'; + route.title = hud.route?.title ?? ''; + route.from = hud.route?.from ?? ''; + route.to = hud.route?.to ?? ''; + route.status = hud.route?.status ?? ''; + route.visible = Boolean(hud.route); + // No destination arrow; clearing also publishes the card. + this.clearPredictiveRoute(); + if ( + forceContext || + cockpitUiUpdateDue( + nowMs, + this.lastContextUpdateMs, + COCKPIT_CONTEXT_UPDATE_MS, + ) + ) { + this.lastContextUpdateMs = nowMs; + this.updateLocalPosition(info); + this.maybeRefreshRegionalBrief(info); + this.showContextCard(false); + const keys = new Set(); + for (const signal of hud.signals) { + const key = `subject:${signal.id}`; + keys.add(key); + this.pushCockpitSignal( + key, + SUBJECT_SIGNAL_TONES[signal.tone] ?? 'info', + signal.title, + signal.detail || signal.title, + ); + } + for (const key of this.subjectSignalKeys ?? []) + if (!keys.has(key)) this.dropCockpitSignal(key); + this.subjectSignalKeys = keys; + } +} + export function updateHud( info, nowMs = performance.now(), forceContext = false, ) { + if (this.subjectProvider && info?.generic === true) { + updateSubjectHud.call(this, info, nowMs, forceContext); + return; + } if (info?.kind === 'vessel') { updateVesselHud.call(this, info, nowMs, forceContext); return; diff --git a/src/ui/cockpitOrbitalView.test.mjs b/src/ui/cockpitOrbitalView.test.mjs index 72130a9e8..26dad341b 100644 --- a/src/ui/cockpitOrbitalView.test.mjs +++ b/src/ui/cockpitOrbitalView.test.mjs @@ -994,7 +994,7 @@ test('a place that lands after the exit is dropped', async (t) => { assert.equal(store.orbital, null, 'no HUD comes back for a stale answer'); }); -test('a tracked aircraft takes entry before a tracked satellite', async (t) => { +test('an aircraft and a satellite first seen in one sync: the aircraft takes entry, by the fixed order', async (t) => { const aircraftInfo = { icao24: 'abc123', callsign: 'TEST1', @@ -1007,6 +1007,9 @@ test('a tracked aircraft takes entry before a tracked satellite', async (t) => { }; const { cockpit, viewer, aircraft, store } = fixture(t, { aircraftInfo }); viewer.trackedEntity = aircraft; + // The satellite and the aircraft are both new to this sync; the fixed order + // decides between subjects first seen together. Selected one after the + // other, the later one takes entry (cockpitEntryGate.test.mjs). cockpit.syncEntry(); assert.equal(store.presence.entryKind, 'aircraft'); }); diff --git a/src/ui/cockpitPresentation.js b/src/ui/cockpitPresentation.js index 344c6f799..99727c7be 100644 --- a/src/ui/cockpitPresentation.js +++ b/src/ui/cockpitPresentation.js @@ -184,11 +184,15 @@ const COCKPIT_ENTRY_FACES = Object.freeze({ }); /** - * The entry's glyph and localized label for the subject it would ride. - * @param {'aircraft'|'vessel'|'satellite'|null|undefined} kind + * The entry's glyph and localized label for the subject it would ride. An + * application provider's subject brings its own face, already in the page's + * language (`presence.entryFace`). + * @param {string|null|undefined} kind + * @param {{icon: string, label: string}|null} [face] * @returns {{icon: string, label: string}} */ -export function cockpitEntryFace(kind) { - const face = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft; - return { icon: face.icon, label: t(face.label) }; +export function cockpitEntryFace(kind, face = null) { + if (face?.icon && face?.label) return { icon: face.icon, label: face.label }; + const known = COCKPIT_ENTRY_FACES[kind] ?? COCKPIT_ENTRY_FACES.aircraft; + return { icon: known.icon, label: t(known.label) }; } diff --git a/src/ui/cockpitSubjectView.test.mjs b/src/ui/cockpitSubjectView.test.mjs new file mode 100644 index 000000000..e8549442f --- /dev/null +++ b/src/ui/cockpitSubjectView.test.mjs @@ -0,0 +1,1182 @@ +// src/ui/cockpitSubjectView.test.mjs — an application provider's subject in +// the Cockpit: its entry face, the hold and release around the view, the +// frame that places the camera (eye height, ground clamp, exact anchor), the +// generic HUD it words, the most recent selection winning entry, and the +// ways the view ends. Over happy-dom. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createPinia } from 'pinia'; +import { createApp, defineComponent, h, nextTick } from 'vue'; +import * as Cesium from 'cesium'; +import { CockpitViewController } from './cockpitController.js'; +import { useCockpitStore } from './stores/cockpit.js'; +import CockpitView from './views/CockpitView.vue'; +import ContextView from './views/ContextView.vue'; +import { cockpitSubjectProviders } from '../cockpitSubjectProviders.js'; +import { getLocale, setLocale } from '../i18n/index.js'; +import { COCKPIT_GROUND_WAIT_TIMEOUT_MS } from './cockpitPresentation.js'; +import { COCKPIT_SUBJECT_LIFT_MPS } from '../cockpitMath.js'; +import { vesselCockpitInfo } from '../layers/vessels/cockpitSubject.js'; + +const LAT = 47; +const LON = 8; +const FLOOR_M = 400; + +/** A HUD view model as a provider words it. */ +const HUD = Object.freeze({ + kicker: 'LINE 12 · OPERATOR', + title: 'Example 12 → Terminus', + meta: { text: 'LIVE TRACK · EYE 3.5 M', tone: 'live' }, + speed: { value: 84, unit: 'km/h', tag: 'LIVE', tone: 'live' }, + readouts: [ + { + id: 'next', + label: 'NEXT STOP', + value: 'Central', + detail: '14:32 · T−4 · +2 MIN', + tone: 'normal', + }, + ], + sources: [{ id: 'track', label: 'TRACK', text: 'operator shape' }], + route: { title: 'LINE', from: 'Origin', to: 'Terminus', status: '9 stops' }, + banner: { title: 'IN STRUCTURE', detail: '2.4 KM · OUT IN 1:30' }, + veil: { level: 0.8, streaks: true }, + progress: { + ticks: [ + { at: 0, label: { text: 'Origin', align: 'start', priority: 1 } }, + { at: 0.5 }, + { at: 1, label: { text: 'Terminus', align: 'end', priority: 1 } }, + ], + marker: { at: 0.25, dwell: false }, + tag: 'TIMETABLE', + summary: 'Route progress: Origin to Terminus', + }, + lookReadout: 'LOOK RIGHT 45°', + signals: [{ id: 'note', title: 'NOTE', detail: 'a provider row' }], +}); + +/** A scriptable provider; `calls` records what the Cockpit asked of it. */ +function subjectProvider(overrides = {}) { + const calls = []; + const subject = { + candidate: { id: 'a1', name: 'Example 12' }, + frame: { + id: 'a1', + name: 'Example 12', + latitude: LAT, + longitude: LON, + heightM: FLOOR_M, + headingDeg: 90, + speedMps: 20, + groundClamp: true, + anchor: 'exact', + eye: { upM: 3.5, forwardM: 0, pitchDeg: -3, minClearanceM: 1.5 }, + }, + hud: HUD, + allowed: true, + hold: true, + }; + const definition = { + kind: 'example-kind', + layerId: 'example-layer', + priority: 1, + entryFace: { icon: 'commute', label: 'RIDE' }, + labels: { + view: 'Example ride view', + exit: 'Exit example view', + exitText: 'EXIT RIDE', + kicker: 'RIDE', + }, + look: { restPitchDeg: -3, aheadLabel: 'Look ahead along the line' }, + cameraIntervalMs: 0, + headingSlewDps: Infinity, + candidate: () => subject.candidate, + isEntryAllowed: (candidate) => { + calls.push(['allowed', candidate.id]); + return subject.allowed; + }, + hold: (id) => { + calls.push(['hold', id]); + return subject.hold; + }, + read: (nowMs, ctx) => { + calls.push(['read', typeof ctx.groundHeightAt]); + if (subject.frame instanceof Error) throw subject.frame; + return typeof subject.frame === 'function' + ? subject.frame(ctx) + : subject.frame; + }, + hud: (frame, ctx) => { + calls.push(['hud', frame.id, ctx.look.offset]); + if (subject.hud instanceof Error) throw subject.hud; + return subject.hud; + }, + release: (id, options) => calls.push(['release', id, options.restore]), + ...overrides, + }; + return { definition, subject, calls }; +} + +function fixture(t, { providers = [], vessel = null, layerOn = true } = {}) { + document.body.replaceChildren(); + const host = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp( + defineComponent(() => () => [h(ContextView), h(CockpitView)]), + ).use(pinia); + app.mount(host); + const store = useCockpitStore(pinia); + const listeners = () => ({ addEventListener: () => () => {} }); + const shots = []; + const aircraftPosition = Cesium.Cartesian3.fromDegrees(LON, LAT, 3000); + const aircraft = { + position: { + getValue: (time, result) => + Cesium.Cartesian3.clone(aircraftPosition, result), + }, + show: true, + gevTrackedId: 'flights:abc123', + }; + const viewer = { + trackedEntity: undefined, + clock: { currentTime: null }, + camera: { + heading: 0, + cancelFlight() {}, + setView({ destination, orientation }) { + shots.push({ + destination: Cesium.Cartesian3.clone(destination), + direction: Cesium.Cartesian3.clone(orientation.direction), + }); + }, + }, + entities: { contains: (candidate) => candidate === aircraft }, + scene: { + preUpdate: listeners(), + screenSpaceCameraController: { enableInputs: true }, + }, + trackedEntityChanged: listeners(), + }; + let vesselSelected = vessel; + let trackedAircraftInfo = null; + let floorM = FLOOR_M; + const warned = []; + t.mock.method(console, 'warn', (message) => warned.push(String(message))); + const subjectProviders = cockpitSubjectProviders( + providers.map((entry) => entry.definition), + ); + const signals = []; + const dropped = []; + const cockpit = new CockpitViewController(viewer, { + services: { + flightsLayer: { getTrackedInfo: () => trackedAircraftInfo }, + militaryFlightsLayer: { getTrackedInfo: () => null }, + aisLiveVesselsLayer: { + getCockpitSubject: () => + vesselSelected + ? { ...vesselCockpitInfo(vesselSelected), seaSurfaceHeightM: 0 } + : null, + setCockpitSubject: () => true, + }, + militaryAwarenessLayer: {}, + isTr3b: () => false, + holdContinuousRender() {}, + releaseContinuousRender() {}, + cachedGroundFloor: () => floorM, + warmGroundFloor() {}, + GROUND_FLOOR_LIFT_M: 1.5, + }, + isBridgeEntryAllowed: () => true, + subjectProviders, + isSubjectEntryAllowed: (provider, candidate) => + layerOn && provider.isEntryAllowed(candidate) === true, + publish: { + presence: (view) => store.publishPresence(view), + instruments: (view) => store.publishInstruments(view), + route: (view) => store.publishRoute(view), + radar: (view) => store.publishRadar(view), + }, + }); + Object.assign(cockpit, { + setVisionMode() {}, + showBriefPage() {}, + startBriefRotation() {}, + stopBriefRotation() {}, + pushCockpitSignal(key, tone, title, detail) { + signals.push({ key, tone, title, detail }); + }, + dropCockpitSignal(key) { + dropped.push(key); + }, + scheduleContextLayout() {}, + updateLocalPosition() {}, + maybeRefreshRegionalBrief() {}, + showContextCard() {}, + }); + t.after(() => { + cockpit.stop(); + app.unmount(); + document.body.classList.remove( + 'cockpit-mode', + 'cockpit-subject', + 'cockpit-bridge', + ); + }); + const $ = (selector) => document.querySelector(selector); + return { + $, + cockpit, + viewer, + aircraft, + store, + shots, + warned, + signals, + dropped, + selectVessel(record) { + vesselSelected = record; + }, + /** Track the fixture's aircraft as Live Flights does, or untrack it. */ + trackAircraft(info) { + trackedAircraftInfo = info; + viewer.trackedEntity = info ? aircraft : undefined; + }, + setFloor(value) { + floorM = value; + }, + }; +} + +function manualClock(t, startMs) { + const originalNow = performance.now; + const originalDateNow = Date.now; + let nowMs = startMs; + performance.now = () => nowMs; + Date.now = () => nowMs; + t.after(() => { + performance.now = originalNow; + Date.now = originalDateNow; + }); + return { + advance(ms) { + nowMs += ms; + }, + }; +} + +async function settle() { + await nextTick(); + await Promise.resolve(); +} + +/** The provider's last release call. */ +const lastRelease = (calls) => calls.findLast(([name]) => name === 'release'); + +/** The camera's height above the ellipsoid in its last shot (m). */ +const shotHeight = (shots) => + Cesium.Cartographic.fromCartesian(shots.at(-1).destination).height; + +const VESSEL = Object.freeze({ + id: '211000000', + mmsi: '211000000', + name: 'NORDIC STAR', + type: '70', + lat: 54.5, + lon: 10.2, + speed: 12, + course: 95, + heading: 90, + missedRefreshes: 0, +}); + +/** The fixture's aircraft as Live Flights reports it once tracked. */ +const TRACKED_AIRCRAFT = Object.freeze({ + icao24: 'abc123', + callsign: 'UAL123', + track: 92, + latitude: LAT, + longitude: LON, +}); + +test('a provider candidate offers its own entry face; a shut gate hides it', async (t) => { + const ride = subjectProvider(); + const { $, cockpit, store } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + await nextTick(); + const entry = $('#cockpit-entry'); + assert.equal(entry.hidden, false); + assert.match(entry.textContent, /commute/); + assert.match(entry.textContent, /RIDE/); + assert.equal(store.presence.entryKind, 'example-kind'); + // A candidate may bring its own face. + ride.subject.candidate = { + id: 'a2', + name: 'Tram 4', + face: { icon: 'tram', label: 'BOARD' }, + }; + cockpit.syncEntry(); + await nextTick(); + assert.match(entry.textContent, /tram/); + assert.match(entry.textContent, /BOARD/); + ride.subject.allowed = false; + cockpit.syncEntry(); + await nextTick(); + assert.equal(entry.hidden, true, "the provider's own rule shuts entry"); + assert.equal(cockpit.enter(), false); + ride.subject.allowed = true; + ride.subject.candidate = null; + cockpit.syncEntry(); + await nextTick(); + assert.equal(entry.hidden, true, 'no candidate, no entry'); +}); + +test('entry holds the subject, takes the camera and shows the generic HUD', async (t) => { + manualClock(t, 10_000); + const ride = subjectProvider(); + const { $, cockpit, shots, store, signals } = fixture(t, { + providers: [ride], + }); + cockpit.syncEntry(); + await nextTick(); + $('#cockpit-entry').click(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal(cockpit.subjectKind, 'example-kind'); + assert.deepEqual( + ride.calls.filter(([name]) => name === 'hold'), + [['hold', 'a1']], + ); + assert.ok(document.body.classList.contains('cockpit-subject')); + assert.equal(store.presence.subjectGeneric, true); + const hud = $('#cockpit-hud'); + assert.equal(hud.getAttribute('data-subject'), 'example-kind'); + assert.ok(hud.hasAttribute('data-generic')); + assert.equal(hud.getAttribute('aria-label'), 'Example ride view'); + assert.equal( + $('#map-view-switch').getAttribute('aria-label'), + 'Exit example view', + ); + assert.match($('#map-view-switch').textContent, /EXIT RIDE/); + // The provider's words, not the aircraft's. + assert.equal($('.cockpit-kicker').textContent, HUD.kicker); + assert.equal($('#cockpit-callsign').textContent, HUD.title); + assert.equal($('#cockpit-aircraft-meta').getAttribute('data-tone'), 'live'); + assert.match($('.cockpit-speed').textContent, /84/); + assert.match($('.cockpit-speed').textContent, /km\/h/); + assert.match($('.cockpit-speed').textContent, /LIVE/); + assert.equal($('.cockpit-scan-array'), null); + assert.equal($('#cockpit-altitude-rim'), null); + assert.equal($('#cockpit-speed-rim'), null); + assert.equal($('.cockpit-roll-arc'), null); + assert.equal($('.cockpit-reticle'), null); + const card = $('[data-readout="next"]'); + assert.match(card.textContent, /NEXT STOP/); + assert.match(card.textContent, /Central/); + assert.match($('[data-source="track"]').textContent, /operator shape/); + assert.match($('.cockpit-banner').textContent, /IN STRUCTURE/); + const veil = $('.cockpit-veil'); + assert.equal(hud.firstElementChild, veil, 'the veil lies under the HUD'); + assert.ok(veil.classList.contains('streaks')); + assert.equal(veil.style.opacity, '0.8'); + const progress = $('.cockpit-progress'); + assert.equal( + progress.getAttribute('aria-label'), + 'Route progress: Origin to Terminus', + ); + assert.equal(progress.querySelectorAll('.cockpit-progress-tick').length, 3); + assert.match(progress.textContent, /Origin/); + assert.match(progress.textContent, /TIMETABLE/); + assert.ok(hud.hasAttribute('data-progress')); + assert.match($('.cockpit-help').textContent, /ARROWS LOOK/); + // The route card is titled by the provider, not as a flight plan. + const route = $('#cockpit-route'); + assert.equal(route.hidden, false); + assert.equal($('.cockpit-route-kicker', route).textContent, 'LINE'); + assert.equal(route.getAttribute('aria-label'), 'LINE'); + // TRACK ACQUIRED names the subject; the HUD view's rows reach Live Signals. + assert.deepEqual(signals[0], { + key: 'track', + tone: 'track', + title: 'TRACK ACQUIRED', + detail: 'Example 12', + }); + assert.ok( + signals.some( + (row) => row.key === 'subject:note' && row.detail === 'a provider row', + ), + ); + assert.ok(shots.length >= 1 || cockpit.lastSubjectFrame); +}); + +test('a HUD view with no speed shows no speed readout; one with no value shows it unknown', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.hud = { ...HUD, speed: undefined }; + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + await settle(); + assert.ok($('.cockpit-speed') === null, 'no speed readout'); + assert.ok($('#cockpit-speed-value') === null, 'no speed value'); + assert.ok($('#cockpit-heading-value') !== null, 'the heading stays'); + ride.subject.hud = { ...HUD, speed: { value: null, unit: 'km/h' } }; + clock.advance(1000); + cockpit.update(); + await settle(); + assert.match($('.cockpit-speed').textContent, /---/); + assert.match($('.cockpit-speed').textContent, /km\/h/); +}); + +test('a progress line is an image named by its summary, else hidden from assistive technology', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.hud = { ...HUD, progress: { ...HUD.progress, summary: '' } }; + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + await settle(); + let progress = $('.cockpit-progress'); + assert.equal(progress.hasAttribute('role'), false); + assert.equal(progress.hasAttribute('aria-label'), false); + assert.equal(progress.getAttribute('aria-hidden'), 'true'); + ride.subject.hud = HUD; + clock.advance(1000); + cockpit.update(); + await settle(); + progress = $('.cockpit-progress'); + assert.equal(progress.getAttribute('role'), 'img'); + assert.equal(progress.getAttribute('aria-label'), HUD.progress.summary); + assert.equal(progress.hasAttribute('aria-hidden'), false); +}); + +test('the eye stands its height above the frame, and the ground clamp can be lifted', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); + // Every frame updates (cameraIntervalMs 0). + const before = shots.length; + clock.advance(16); + cockpit.update(); + assert.equal(shots.length, before + 1); + // Inside a structure the frame stands below the rendered floor: clamped, + // the eye is pushed out of the floor at once and its clearance eases in; + // a view entered with groundClamp false stays inside (a clamp lifted + // mid-ride lets the eye down gradually, below). + setFloor(FLOOR_M + 200); + clock.advance(16); + cockpit.update(); + const lifted = shotHeight(shots); + assert.ok(lifted >= FLOOR_M + 200 - 0.01, 'out of the floor at once'); + assert.ok(lifted < FLOOR_M + 200 + 1.5 - 0.01, 'the clearance eases in'); + for (let i = 0; i < 80; i += 1) { + clock.advance(16); + cockpit.update(); + } + assert.ok(shotHeight(shots) >= FLOOR_M + 200 + 1.5 - 0.01, 'clamped'); + ride.subject.frame = { ...ride.subject.frame, groundClamp: false }; + cockpit.exit(); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + assert.ok( + Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01, + 'not clamped', + ); + // No height in the frame: the eye stands on the shared floor. + setFloor(FLOOR_M); + ride.subject.frame = { ...ride.subject.frame, heightM: null }; + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); +}); + +test('a ground clamp lifted mid-ride lets the eye down at the rate it came up', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + // The floor rises to the eye: its clearance eases in. + setFloor(FLOOR_M + 3.5); + for (let i = 0; i < 80; i += 1) { + clock.advance(16); + cockpit.update(); + } + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 5)) < 0.01, 'clamped'); + // Into a structure: the lift goes at the same rate, not at once. + ride.subject.frame = { ...ride.subject.frame, groundClamp: false }; + clock.advance(100); + cockpit.update(); + assert.ok( + Math.abs( + shotHeight(shots) - (FLOOR_M + 5 - COCKPIT_SUBJECT_LIFT_MPS * 0.1), + ) < 0.01, + 'eased out', + ); + for (let i = 0; i < 12; i += 1) { + clock.advance(100); + cockpit.update(); + } + assert.ok( + Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01, + 'inside the structure', + ); +}); + +test('a subject updated every 500 ms turns, eases and closes on its target at its rates', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider({ cameraIntervalMs: 500, headingSlewDps: 10 }); + ride.subject.frame = { + ...ride.subject.frame, + anchor: 'inertial', + speedMps: 0, + }; + const { cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + clock.advance(500); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); + // One 500 ms update: the subject turns, the floor rises to the eye and the + // frame moves 100 m east of the anchor. Each rate advances by the 0.5 s, + // five times the 0.1 s a frame's step is capped at. + ride.subject.frame = { + ...ride.subject.frame, + headingDeg: 180, + longitude: LON + 100 / (111_320 * Math.cos(Cesium.Math.toRadians(LAT))), + }; + setFloor(FLOOR_M + 3.5); + clock.advance(500); + cockpit.update(); + assert.ok(Math.abs(cockpit.heading - 95) < 1e-6, 'heading 10°/s × 0.5 s'); + assert.ok( + Math.abs( + shotHeight(shots) - (FLOOR_M + 3.5 + COCKPIT_SUBJECT_LIFT_MPS * 0.5), + ) < 0.01, + 'lift 1.5 m/s × 0.5 s', + ); + const ground = (shot) => { + const at = Cesium.Cartographic.fromCartesian(shot.destination); + return Cesium.Cartesian3.fromRadians(at.longitude, at.latitude, 0); + }; + const closedM = Cesium.Cartesian3.distance( + ground(shots.at(-1)), + ground(shots.at(-2)), + ); + // A standing subject's anchor closes at the correction's 0.75 m/s. + assert.ok(Math.abs(closedM - 0.75 * 0.5) < 0.01, 'anchor 0.75 m/s × 0.5 s'); + // A long stall (a hidden tab) moves it as one late update would. + clock.advance(60_000); + cockpit.update(); + assert.ok(Math.abs(cockpit.heading - 101) < 1e-6, 'at most 0.6 s'); +}); + +test('an exact anchor sits on the subject, wherever the frame moves it', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, shots } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + for (const step of [1, 2, 3]) { + const longitude = LON + step * 0.001; + ride.subject.frame = { ...ride.subject.frame, longitude }; + clock.advance(16); + cockpit.update(); + const at = Cesium.Cartographic.fromCartesian(shots.at(-1).destination); + assert.ok( + Math.abs(Cesium.Math.toDegrees(at.longitude) - longitude) < 1e-7, + `step ${step}`, + ); + } +}); + +test('the view ends when the frame stops coming, and gives the subject back', async (t) => { + const clock = manualClock(t, 10_000); + for (const ending of [ + null, + { latitude: LAT, longitude: LON, id: 'other', eye: { upM: 2 } }, + // A frame whose getter throws runs provider code: as a malformed frame. + Object.defineProperty( + { id: 'a1', longitude: LON, eye: { upM: 2 } }, + 'latitude', + { + get() { + throw new Error('latitude unavailable'); + }, + }, + ), + ]) { + const ride = subjectProvider(); + const { cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + ride.subject.frame = ending; + clock.advance(16); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); + assert.equal(document.body.classList.contains('cockpit-subject'), false); + } +}); + +test('leaving by the exit control asks the provider to restore its subject', async (t) => { + const ride = subjectProvider(); + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + await settle(); + $('#map-view-switch').click(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', true]); +}); + +test('entry fails when the gate is shut, the hold fails or no first frame comes, releasing only what it held', async (t) => { + const shut = subjectProvider(); + shut.subject.allowed = false; + let app = fixture(t, { providers: [shut] }); + app.cockpit.syncEntry(); + assert.equal(app.cockpit.enter(), false); + assert.equal( + shut.calls.some(([name]) => name === 'hold' || name === 'release'), + false, + 'a shut gate neither holds nor releases', + ); + const blank = subjectProvider(); + blank.subject.frame = null; + app = fixture(t, { providers: [blank] }); + app.cockpit.syncEntry(); + assert.equal(app.cockpit.enter(), false); + assert.deepEqual(lastRelease(blank.calls), ['release', 'a1', false]); + assert.equal(app.cockpit.active, false); + // A hold that throws may have half happened: it is released. + const thrown = subjectProvider({ + hold: (id) => { + thrown.calls.push(['hold', id]); + throw new Error('hold failed'); + }, + }); + app = fixture(t, { providers: [thrown] }); + app.cockpit.syncEntry(); + assert.equal(app.cockpit.enter(), false); + assert.deepEqual(lastRelease(thrown.calls), ['release', 'a1', false]); + assert.equal(app.warned.length, 1); + // So is one that answers with a promise: provider methods are synchronous. + const later = subjectProvider({ + hold: async () => true, + }); + app = fixture(t, { providers: [later] }); + app.cockpit.syncEntry(); + assert.equal(app.cockpit.enter(), false); + assert.deepEqual(lastRelease(later.calls), ['release', 'a1', false]); + assert.match(app.warned[0], /returned a promise/); +}); + +test('a hold that answers false refuses entry; any answer but true or false warns once; each is released', async (t) => { + for (const [answer, warns] of [ + [false, false], + [undefined, true], + [1, true], + ['held', true], + ]) { + const ride = subjectProvider(); + ride.subject.hold = answer; + const { cockpit, warned } = fixture(t, { providers: [ride] }); + const holdWarnings = () => + warned.filter((message) => + /example-kind hold\(\) must return true or false/.test(message), + ).length; + cockpit.syncEntry(); + assert.equal(cockpit.enter(), false, `hold answered ${String(answer)}`); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); + assert.equal(holdWarnings(), warns ? 1 : 0); + assert.equal(warned.length, warns ? 1 : 0, 'no other warning'); + // Once per provider and method. + assert.equal(cockpit.enter(), false); + assert.equal(holdWarnings(), warns ? 1 : 0); + assert.equal( + ride.calls.filter(([name]) => name === 'release').length, + 2, + 'released each time', + ); + } +}); + +test('a class-based provider keeps its `this` from entry to release', async (t) => { + manualClock(t, 10_000); + const { frame } = subjectProvider().subject; + class Ride { + #word = 'RIDE'; + #held = null; + calls = []; + kind = 'example-kind'; + layerId = 'example-layer'; + entryFace() { + return { icon: 'commute', label: this.#word }; + } + get labels() { + return { + view: `${this.#word} view`, + exit: 'Exit ride view', + exitText: `EXIT ${this.#word}`, + }; + } + look() { + return { aheadLabel: `Look ahead, ${this.#word}` }; + } + candidate() { + return { id: 'a1', name: `Example ${this.#word}` }; + } + isEntryAllowed(candidate) { + return this.#held === null && candidate.id === 'a1'; + } + hold(id) { + this.#held = id; + this.calls.push(['hold', id]); + return true; + } + read() { + this.calls.push(['read', this.#held]); + return this.#held ? { ...frame, id: this.#held } : null; + } + hud(view) { + this.calls.push(['hud', view.id]); + return { ...HUD, title: `${this.#word} ${view.id}` }; + } + release(id, { restore }) { + this.calls.push(['release', id, restore]); + this.#held = null; + } + } + const ride = new Ride(); + const { $, cockpit, warned } = fixture(t, { + providers: [{ definition: ride }], + }); + cockpit.syncEntry(); + await nextTick(); + assert.match($('#cockpit-entry').textContent, /RIDE/); + $('#cockpit-entry').click(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-callsign').textContent, 'RIDE a1'); + assert.equal($('#cockpit-hud').getAttribute('aria-label'), 'RIDE view'); + assert.match($('#map-view-switch').textContent, /EXIT RIDE/); + assert.equal( + $('#cockpit-look-ahead').getAttribute('aria-label'), + 'Look ahead, RIDE', + ); + assert.ok(ride.calls.some(([name, id]) => name === 'read' && id === 'a1')); + assert.ok(ride.calls.some(([name, id]) => name === 'hud' && id === 'a1')); + $('#map-view-switch').click(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', true]); + assert.deepEqual(warned, []); +}); + +test('a frame with no height and a cold floor never stands the eye on the ellipsoid', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.frame = { ...ride.subject.frame, heightM: null }; + const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + // Nothing to stand on from the first frame: the camera waits where it is, + // its HUD saying so and still read from the provider. + setFloor(undefined); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + const before = shots.length; + const huds = () => ride.calls.filter(([name]) => name === 'hud').length; + const hudsBefore = huds(); + for (let i = 0; i < 5; i += 1) { + clock.advance(200); + cockpit.update(); + } + await settle(); + assert.equal(shots.length, before, 'no camera placed'); + assert.equal(cockpit.active, true, 'the view waits'); + assert.ok(huds() > hudsBefore, 'the HUD is not frozen'); + assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE'); + assert.equal( + $('#cockpit-aircraft-meta').getAttribute('data-tone'), + 'warning', + ); + // The floor arrives: the eye stands on it. + setFloor(FLOOR_M); + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); + // It goes cold again: the eye keeps the height it stood at, never 0 m, + // with the ground clamp off too. + setFloor(undefined); + ride.subject.frame = { ...ride.subject.frame, groundClamp: false }; + for (let i = 0; i < 5; i += 1) { + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); + } + // A height out of any plausible range reads as none. + ride.subject.frame = { ...ride.subject.frame, heightM: -6_400_000 }; + clock.advance(16); + cockpit.update(); + assert.ok(Math.abs(shotHeight(shots) - (FLOOR_M + 3.5)) < 0.01); +}); + +test('a floor that never comes ends the view after the bounded wait, releasing the subject', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.frame = { ...ride.subject.frame, heightM: null }; + const { $, cockpit, shots, setFloor } = fixture(t, { providers: [ride] }); + setFloor(undefined); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), true); + const before = shots.length; + // Acquiring, the HUD updating, for just under the wait... + for (let elapsed = 0; elapsed < COCKPIT_GROUND_WAIT_TIMEOUT_MS - 250;) { + clock.advance(250); + elapsed += 250; + cockpit.update(); + } + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-aircraft-meta').textContent, 'ACQUIRING SURFACE'); + // ...then no guessed height: the view ends and the subject goes back. + clock.advance(500); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.equal(shots.length, before, 'never placed on a guess'); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); +}); + +test('an asynchronous hold that lands after entry was refused is released again', async (t) => { + let heldId = null; + const releases = []; + const ride = subjectProvider({ + hold: async (id) => { + await Promise.resolve(); + heldId = id; + return true; + }, + release: (id, options) => { + releases.push([id, options.restore]); + heldId = null; + }, + }); + const { cockpit, warned } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.enter(), false, 'refused at once'); + assert.deepEqual(releases, [['a1', false]]); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(heldId, null, 'the late hold is let go'); + assert.deepEqual(releases, [ + ['a1', false], + ['a1', false], + ]); + assert.equal(cockpit.active, false); + assert.match(warned[0], /hold\(\) returned a promise/); +}); + +test('a provider’s labels, entry face and look follow a change of language', async (t) => { + t.after(() => setLocale('en')); + setLocale('en'); + const clock = manualClock(t, 10_000); + const words = { + en: { view: 'Ride view', exit: 'Exit ride view', exitText: 'EXIT RIDE' }, + de: { + view: 'Fahrtansicht', + exit: 'Fahrtansicht verlassen', + exitText: 'VERLASSEN', + }, + }; + const german = () => getLocale().startsWith('de'); + const ride = subjectProvider({ + labels: () => words[german() ? 'de' : 'en'], + entryFace: () => ({ icon: 'commute', label: german() ? 'FAHRT' : 'RIDE' }), + look: () => ({ + restPitchDeg: -3, + aheadLabel: german() ? 'Nach vorn sehen' : 'Look ahead', + }), + }); + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + await nextTick(); + assert.match($('#cockpit-entry').textContent, /RIDE/); + // On the entry, before the view. + setLocale('de'); + await settle(); + assert.match($('#cockpit-entry').textContent, /FAHRT/); + setLocale('en'); + await settle(); + assert.equal(cockpit.enter(), true); + clock.advance(16); + cockpit.update(); + await settle(); + assert.match($('#map-view-switch').textContent, /EXIT RIDE/); + // While riding. + setLocale('de'); + await settle(); + assert.match($('#map-view-switch').textContent, /VERLASSEN/); + assert.equal( + $('#map-view-switch').getAttribute('aria-label'), + 'Fahrtansicht verlassen', + ); + assert.equal($('#cockpit-hud').getAttribute('aria-label'), 'Fahrtansicht'); + assert.equal( + $('#cockpit-look-ahead').getAttribute('aria-label'), + 'Nach vorn sehen', + ); +}); + +test('a read that throws ends the view once; a hud that throws keeps the last', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { $, cockpit, warned } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + await settle(); + ride.subject.hud = new Error('hud failed'); + clock.advance(200); + cockpit.update(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-callsign').textContent, HUD.title, 'last view kept'); + // So does a view whose getter throws when read. + ride.subject.hud = Object.defineProperty({ ...HUD }, 'title', { + get() { + throw new Error('title unavailable'); + }, + }); + clock.advance(200); + cockpit.update(); + await settle(); + assert.equal(cockpit.active, true); + assert.equal($('#cockpit-callsign').textContent, HUD.title, 'last view kept'); + ride.subject.frame = new Error('read failed'); + clock.advance(16); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.equal( + warned.filter((message) => /example-kind read\(\) failed/.test(message)) + .length, + 1, + ); + assert.equal( + warned.filter((message) => /example-kind hud\(\) failed/.test(message)) + .length, + 1, + ); +}); + +test('an aircraft tracked during the view ends it, never adopted', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, viewer, aircraft } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + viewer.trackedEntity = aircraft; + assert.equal(cockpit._adoptTrackedEntity(performance.now()), false); + clock.advance(16); + cockpit.update(); + assert.equal(cockpit.active, false); + assert.deepEqual(lastRelease(ride.calls), ['release', 'a1', false]); + assert.equal(cockpit.navigateContext(1), false); +}); + +test('the most recently selected subject wins entry', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = null; + const { cockpit, store, selectVessel } = fixture(t, { + providers: [ride], + vessel: VESSEL, + }); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'vessel'); + // A subject selected after the vessel takes the entry. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12' }; + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'example-kind'); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // Another vessel selected later takes it back. + clock.advance(500); + selectVessel({ ...VESSEL, id: '211000099', mmsi: '211000099' }); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'vessel'); + // The same subject selected again comes back to the front. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 2 }; + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'example-kind', 're-selected'); + clock.advance(500); + selectVessel({ ...VESSEL, id: '211000098', mmsi: '211000098' }); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'vessel'); + clock.advance(500); + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'vessel', 'an unchanged token'); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 3 }; + cockpit.syncEntry(); + assert.equal(store.presence.entryKind, 'example-kind'); + // The newest decides alone: its shut gate leaves no entry. + clock.advance(500); + ride.subject.candidate = { id: 'a3', name: 'Example 3' }; + ride.subject.allowed = false; + cockpit.syncEntry(); + assert.equal(store.presence.entryAvailable, false); + assert.equal(cockpit.enter(), false); +}); + +test('the built-in kinds keep their order; a subject takes entry only when selected after all of them', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = null; + const { cockpit, selectVessel, trackAircraft } = fixture(t, { + providers: [ride], + }); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // A subject selected after the aircraft was tracked takes entry. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12' }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // A vessel selected after it: a built-in kind is newer, so the built-in + // order decides, as without providers, and the aircraft comes first. + clock.advance(500); + selectVessel(VESSEL); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // The subject selected again is newer than every built-in one. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 2 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // An aircraft tracked after it takes entry back. + selectVessel(null); + trackAircraft(null); + cockpit.syncEntry(); + clock.advance(500); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); +}); + +test('a new id under the same selectedAt is not a new selection', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 1 }; + const { cockpit, trackAircraft } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // An aircraft tracked after it takes entry. + clock.advance(500); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // The selection moves to another copy of the same subject: same token. + clock.advance(500); + ride.subject.candidate = { id: 'b1', name: 'Example 12', selectedAt: 1 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + // A new token is a new selection. + clock.advance(500); + ride.subject.candidate = { id: 'b1', name: 'Example 12', selectedAt: 2 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // Without a token, a new id is a new selection. + clock.advance(500); + trackAircraft(null); + cockpit.syncEntry(); + trackAircraft(TRACKED_AIRCRAFT); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'aircraft'); + clock.advance(500); + ride.subject.candidate = { id: 'c1', name: 'Example 3' }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); +}); + +test('a subject first seen together with a built-in one comes after it', async (t) => { + manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, store } = fixture(t, { providers: [ride], vessel: VESSEL }); + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'vessel'); + assert.equal(store.presence.entryKind, 'vessel'); +}); + +test("a double-click on the selected vessel does not enter an application's subject", async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + ride.subject.candidate = null; + const { cockpit, store, viewer } = fixture(t, { + providers: [ride], + vessel: VESSEL, + }); + cockpit.syncEntry(); + // The application's subject is selected after the vessel: it takes entry. + clock.advance(500); + ride.subject.candidate = { id: 'a1', name: 'Example 12', selectedAt: 1 }; + cockpit.syncEntry(); + assert.equal(cockpit.resolveEntryKind(), 'example-kind'); + // Every world position lands under the pointer, the vessel's sprite too. + viewer.scene.cartesianToCanvasCoordinates = (_position, result = {}) => + Object.assign(result, { x: 400, y: 300 }); + assert.equal(cockpit.onSceneDoubleClick({ x: 400, y: 300 }), false); + await settle(); + assert.notEqual(store.presence.active, true); +}); + +test('a provider view with look turns with the arrow keys and words its readout', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { $, cockpit } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + await settle(); + assert.equal(cockpit.lookTarget.pitchDeg, -3, 'rests at its own pitch'); + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }), + ); + clock.advance(200); + cockpit.update(); + await settle(); + assert.notEqual(cockpit.lookTarget.yawDeg, 0); + assert.ok(ride.calls.some(([name, , offset]) => name === 'hud' && offset)); + assert.equal($('.cockpit-look').hidden, false); + assert.equal($('#cockpit-look-readout').textContent, HUD.lookReadout); + assert.equal( + $('#cockpit-look-ahead').getAttribute('aria-label'), + 'Look ahead along the line', + ); + // Without `look` the keys leave the view alone. + const still = subjectProvider({ look: undefined }); + const other = fixture(t, { providers: [still] }); + other.cockpit.syncEntry(); + other.cockpit.enter(); + assert.equal( + other.cockpit.turnLook({ axis: 'yaw', sign: 1, stepDeg: 15 }), + false, + ); +}); + +test('rows a HUD view no longer lists leave Live Signals', async (t) => { + const clock = manualClock(t, 10_000); + const ride = subjectProvider(); + const { cockpit, dropped } = fixture(t, { providers: [ride] }); + cockpit.syncEntry(); + cockpit.enter(); + ride.subject.hud = { ...HUD, signals: [] }; + clock.advance(1000); + cockpit.update(); + assert.ok(dropped.includes('subject:note')); +}); diff --git a/src/ui/cockpitTrackingController.js b/src/ui/cockpitTrackingController.js index 069441149..8327d5a07 100644 --- a/src/ui/cockpitTrackingController.js +++ b/src/ui/cockpitTrackingController.js @@ -5,6 +5,10 @@ import { normalizeHeading, resolveTrackedAircraftInfo, } from '../cockpitMath.js'; +import { + cockpitSubjectCandidate, + cockpitSubjectFrame, +} from '../cockpitSubjectProviders.js'; /** The camera's own heading in degrees, or 0 when it has none. */ function cameraHeadingDeg(camera) { @@ -33,15 +37,187 @@ export function readSatelliteInfo() { return this.services.satellitesLayer?.getCockpitSubject?.() || null; } +/** + * What an application subject provider would have entry ride now, or null. + * @param {object} provider A provider from cockpitSubjectProviders. + * @returns {{id: string, name: string, face: object|null}|null} + */ +export function readSubjectCandidate(provider) { + return cockpitSubjectCandidate( + this.callSubject(provider, 'candidate', [], null), + ); +} + +/** + * What a provider's `read` and `hud` receive besides the frame: the shared + * ground floor at a point (metres, or null), a way to warm its cells, and the + * floor service's lift. + */ +export function subjectContext() { + this._subjectContext ??= Object.freeze({ + groundHeightAt: (latitude, longitude) => { + const height = this.services.cachedGroundFloor?.(latitude, longitude); + return Number.isFinite(height) ? height : null; + }, + warmGround: (points) => { + this.services.warmGroundFloor?.(points); + }, + liftM: Number.isFinite(this.services.GROUND_FLOOR_LIFT_M) + ? this.services.GROUND_FLOOR_LIFT_M + : 0, + }); + return this._subjectContext; +} + +/** + * The ridden provider subject as the camera and HUD read it: the frame's + * place and motion, named like the built-in kinds' info. + * @param {object} frame From cockpitSubjectFrame. + */ +export function subjectInfo(frame) { + return { + kind: this.subjectProvider?.kind ?? null, + generic: true, + layerId: this.subjectProvider?.layerId ?? null, + id: frame.id, + name: frame.name, + latitude: frame.latitude, + longitude: frame.longitude, + track: frame.headingDeg, + motionCourse: frame.courseDeg, + velocityMps: frame.speedMps ?? 0, + stale: frame.stale, + groundClamp: frame.groundClamp, + anchor: frame.anchor, + onGround: false, + }; +} + +/** The built-in kinds' fixed entry order. */ +const BUILT_IN_ENTRY_ORDER = Object.freeze({ + aircraft: 0, + satellite: 1, + vessel: 2, +}); + +/** + * Every subject entry could ride now, in entry order: a tracked aircraft, + * the tracked satellite, the selected vessel and each provider's candidate. + * The built-in kinds keep their fixed order (aircraft, satellite, vessel), + * whenever each was selected. A provider's candidate goes ahead of them only + * when it was selected after every built-in one present: a train picked + * after an aircraft was tracked takes entry, an aircraft tracked after it + * takes it back. Providers' candidates are ordered most recently selected + * first, then by priority. A candidate's time is when this controller first + * saw it (it looks on its entry cadence and on tracking changes), or saw it + * selected again: another subject of a built-in kind, a provider candidate + * whose `selectedAt` changes, or one without a `selectedAt` whose id changes. + * A new id under the same `selectedAt` is the same selection, as when a + * train's selection moves to another feed's copy of it. One + * first seen together with a built-in candidate comes after it. With no + * provider, entry is the built-in order alone. Each carries `allowed()`, its + * kind's entry gate. + * @param {number} [nowMs] + * @returns {Array<{kind: string, id: string, seenAtMs: number, order: number, + * allowed: () => boolean, provider?: object, candidate?: object}>} + */ +export function entryCandidates(nowMs = performance.now()) { + const seen = (this._entrySeen ??= new Map()); + const list = []; + const note = (kind, id, entry, selectedAt = null) => { + const last = seen.get(kind); + // A provider's token says when its subject was selected: a new id under + // the same token (a train handed to another feed's copy of it) is not a + // new selection. Without a token, a new id is. + if ( + !last || + last.selectedAt !== selectedAt || + (selectedAt === null && last.id !== id) + ) + seen.set(kind, { id, selectedAt, atMs: nowMs }); + else if (last.id !== id) seen.set(kind, { ...last, id }); + list.push({ kind, id, seenAtMs: seen.get(kind).atMs, ...entry }); + }; + const aircraft = this.readAircraftInfo(); + if (aircraft && this.viewer.trackedEntity?.position) + note( + 'aircraft', + String( + this.viewer.trackedEntity.gevTrackedId || + aircraft.icao24 || + aircraft.id || + 'aircraft', + ), + { + order: BUILT_IN_ENTRY_ORDER.aircraft, + allowed: () => this.isEntryAllowed(), + }, + ); + const satellite = this.readSatelliteInfo(); + if (satellite) + note('satellite', String(satellite.noradId ?? satellite.id ?? ''), { + order: BUILT_IN_ENTRY_ORDER.satellite, + allowed: () => this.isOrbitEntryAllowed(), + }); + const vessel = this.readVesselInfo(); + if (vessel) + note('vessel', String(vessel.id ?? ''), { + order: BUILT_IN_ENTRY_ORDER.vessel, + allowed: () => this.isBridgeEntryAllowed(), + }); + this.subjectProviders.forEach((provider, index) => { + const candidate = this.readSubjectCandidate(provider); + if (candidate) + note( + provider.kind, + candidate.id, + { + order: 3 + index, + provider, + candidate, + allowed: () => this.isSubjectEntryAllowed(provider, candidate), + }, + candidate.selectedAt, + ); + }); + // A subject gone and back counts as selected again. + for (const kind of [...seen.keys()]) + if (!list.some((entry) => entry.kind === kind)) seen.delete(kind); + const builtIns = list + .filter((entry) => !entry.provider) + .sort((a, b) => a.order - b.order); + const provided = list + .filter((entry) => entry.provider) + .sort((a, b) => b.seenAtMs - a.seenAtMs || a.order - b.order); + const builtInSeenAtMs = Math.max( + -Infinity, + ...builtIns.map((entry) => entry.seenAtMs), + ); + const later = provided.filter((entry) => entry.seenAtMs > builtInSeenAtMs); + const earlier = provided.filter((entry) => entry.seenAtMs <= builtInSeenAtMs); + return [...later, ...builtIns, ...earlier]; +} + +/** + * The subject entry would ride: the first in entry order (entryCandidates). + * It decides alone, so when its own gate is shut entry is unavailable rather + * than taken by the next one. + * @returns {object|null} An entry of entryCandidates, or null. + */ +export function resolveEntry(nowMs = performance.now()) { + return this.entryCandidates(nowMs)[0] ?? null; +} + /** * The layer the active view rides: Live AIS Vessels on a bridge, Satellites - * in orbit, else the aircraft's own layer. It is read from the tracked - * entity's id once the layer has dropped its tracking, so a layer turned off - * still names itself. - * @returns {'flights'|'military'|'ais-live-vessels'|'satellites'|null} + * in orbit, a provider's own layer, else the aircraft's own layer. It is read + * from the tracked entity's id once the layer has dropped its tracking, so a + * layer turned off still names itself. + * @returns {string|null} */ export function readSubjectLayerId() { if (!this.active) return null; + if (this.subjectProvider) return this.subjectProvider.layerId; if (this.subjectKind === 'vessel') return 'ais-live-vessels'; if (this.subjectKind === 'satellite') return 'satellites'; const layerId = this.readAircraftInfo()?.layerId; @@ -51,23 +227,20 @@ export function readSubjectLayerId() { } /** - * The subject entry would take: a Cesium-tracked aircraft first, then the - * tracked satellite, else the selected vessel. Vessels never take Cesium - * tracking, so an aircraft and a vessel cannot be live through the same owner. - * @returns {'aircraft'|'satellite'|'vessel'|null} + * The kind entry would take, the first in entry order's (resolveEntry): a + * Cesium-tracked aircraft, the tracked satellite, the selected vessel or a + * provider's candidate. + * @returns {string|null} */ export function resolveEntryKind() { - if (this.readAircraftInfo() && this.viewer.trackedEntity?.position) - return 'aircraft'; - if (this.readSatelliteInfo()) return 'satellite'; - return this.readVesselInfo() ? 'vessel' : null; + return this.resolveEntry()?.kind ?? null; } export function dispatchCockpitModeChanged(active, info = null) { const subjectId = active ? String( info?.icao24 || - (info?.kind === 'vessel' ? info.id : '') || + (info?.kind === 'vessel' || info?.generic === true ? info.id : '') || info?.noradId || '', ) @@ -76,9 +249,13 @@ export function dispatchCockpitModeChanged(active, info = null) { : null; const layerId = active && - ['flights', 'military', 'ais-live-vessels', 'satellites'].includes( + (['flights', 'military', 'ais-live-vessels', 'satellites'].includes( info?.layerId, - ) + ) || + (info?.generic === true && + this.subjectProviders.some( + (provider) => provider.layerId === info.layerId, + ))) ? info.layerId : null; window.dispatchEvent( @@ -127,40 +304,60 @@ export function syncTr3bToggle(info) { this.publishPresenceView(); } +/** + * Word the ridden provider subject's labels and Look ahead button again, + * after a change of the page's language: a provider may give them as + * functions (cockpitSubjectProviders). + */ +export function refreshSubjectWords() { + const provider = this.subjectProvider; + if (!this.active || !provider) return; + this.presence.subjectLabels = { ...provider.labels }; + const look = provider.look; + this.presence.subjectLook = look ? { aheadLabel: look.aheadLabel } : null; + this.publishPresenceView(); +} + export function syncEntry() { if (this.destroyed) return false; if (this.active) return; - const info = this.readAircraftInfo(); - const trackedContact = !!(info && this.viewer.trackedEntity?.position); - this.syncTr3bToggle(trackedContact ? info : null); - const orbitContact = !trackedContact && !!this.readSatelliteInfo(); - const bridgeContact = - !trackedContact && !orbitContact && !!this.readVesselInfo(); - let entryKind = null; - if (trackedContact) entryKind = this.isEntryAllowed() ? 'aircraft' : null; - else if (orbitContact) - entryKind = this.isOrbitEntryAllowed() ? 'satellite' : null; - else if (bridgeContact) - entryKind = this.isBridgeEntryAllowed() ? 'vessel' : null; + const candidates = this.entryCandidates(performance.now()); + const tracked = candidates.some(({ kind }) => kind === 'aircraft'); + this.syncTr3bToggle(tracked ? this.readAircraftInfo() : null); + // The first in entry order decides alone (resolveEntry). + const chosen = candidates[0] ?? null; + const entryKind = chosen?.allowed() ? chosen.kind : null; + const face = + entryKind && chosen.provider + ? (chosen.candidate.face ?? chosen.provider.entryFace) + : null; const available = entryKind !== null; // Change-only publishes: this runs on a preUpdate cadence, and a fresh // presence every frame would re-render the views even when nothing // changed. (perf item 9) if ( this.presence.entryAvailable === available && - this.presence.entryKind === entryKind + this.presence.entryKind === entryKind && + this.presence.entryFace?.icon === face?.icon && + this.presence.entryFace?.label === face?.label ) return; this.presence.entryAvailable = available; this.presence.entryKind = entryKind; + this.presence.entryFace = face && { icon: face.icon, label: face.label }; this.publishPresenceView(); } export function navigateContext(direction, options = {}) { if (this.destroyed) return false; - // Contact navigation moves between aircraft; a bridge does not jump ship - // and an orbital view stays with its satellite. - if (this.subjectKind === 'vessel' || this.subjectKind === 'satellite') + // Contact navigation moves between aircraft; a bridge does not jump ship, + // an orbital view stays with its satellite and a provider's view with its + // subject. + if ( + this.subjectKind === 'vessel' || + this.subjectKind === 'satellite' || + this.subjectProvider + ) return false; const method = direction < 0 ? 'navigatePrevious' : 'navigateNext'; const wasActive = this.active; @@ -180,9 +377,14 @@ export function navigateContext(direction, options = {}) { } export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { - // An aircraft tracked from the bridge or from orbit ends that view (see the - // vessel and orbital camera updates); it is never adopted as its subject. - if (this.subjectKind === 'vessel' || this.subjectKind === 'satellite') + // An aircraft tracked from the bridge, from orbit or from a provider's view + // ends that view (see the camera updates); it is never adopted as its + // subject. + if ( + this.subjectKind === 'vessel' || + this.subjectKind === 'satellite' || + this.subjectProvider + ) return false; const nextEntity = this.viewer.trackedEntity; if ( @@ -213,16 +415,153 @@ export function _adoptTrackedEntity(nowMs, suppliedInfo = null) { } /** - * Enter first person on a tracked aircraft or, with none tracked, the - * tracked satellite's orbital view or the selected vessel's bridge. - * @param {{kind?: 'aircraft'|'satellite'|'vessel'|null}} [options] Restrict - * entry to one subject kind; voice entry is aircraft-only. + * Enter a provider's view on its candidate: hold it, read a first frame, then + * take the camera as the built-in views do. Entry fails when the gate is shut, + * the hold answers anything but `true` or no frame comes; past the gate, a + * failed entry releases the subject, since the hold may have taken it. + * @param {object} provider From cockpitSubjectProviders. + * @returns {boolean} + */ +function enterSubject(provider) { + const candidate = this.readSubjectCandidate(provider); + if (!candidate || !this.isSubjectEntryAllowed(provider, candidate)) + return false; + const failed = {}; + const release = () => + this.callSubject(provider, 'release', [candidate.id, { restore: false }]); + // An asynchronous hold may land after it was refused: it is released + // again once it settles, unless this subject is being ridden by then. + const releaseLate = () => { + if ( + this.active && + this.subjectProvider === provider && + this.subjectId === candidate.id + ) + return; + release(); + }; + const held = this.callSubject( + provider, + 'hold', + [candidate.id], + failed, + releaseLate, + ); + if (held !== true) { + // `false` refuses entry; a hold that threw, returned a promise or + // answered anything else (warned of once) may still have taken the + // subject. Each is released. + if (held !== false && held !== failed) + this.callSubject.warnOnce( + provider, + 'hold', + 'must return true or false; entry refused', + held, + ); + release(); + return false; + } + const frame = cockpitSubjectFrame( + this.callSubject( + provider, + 'read', + [performance.now(), this.subjectContext()], + null, + ), + candidate.id, + ); + if (!frame) { + this.callSubject(provider, 'release', [candidate.id, { restore: false }]); + return false; + } + this.entryFocusOrigin = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + this.onCameraTakeover?.(); + this.viewer.camera.cancelFlight(); + this.subjectKind = provider.kind; + this.subjectProvider = provider; + this.subjectId = candidate.id; + this.lastSubjectFrame = frame; + this.subjectHud = null; + this.subjectSignalKeys = new Set(); + this.vesselWaterline = null; + this.vesselFix = null; + this.trackedEntity = null; + this.heading = normalizeHeading( + frame.headingDeg ?? cameraHeadingDeg(this.viewer.camera), + ); + this.lastFrameMs = performance.now(); + this.lastCameraUpdateMs = 0; + this.lastHudUpdateMs = 0; + this.lastContextUpdateMs = 0; + this.contextNavigationDeadlineMs = 0; + this.lastGroundProbeMs = 0; + this.surfaceWaitStartedMs = 0; + this.surfaceAcquiring = false; + this.surfaceFallback = false; + this.cockpitAnchorValid = false; + this.subjectHeightM = null; + this.lookRestPitchDeg = provider.look?.restPitchDeg ?? null; + this.resetLook({ immediate: true }); + this.active = true; + this.services.holdContinuousRender('cockpit'); + this.viewer.trackedEntity = undefined; + this.viewer.scene.screenSpaceCameraController.enableInputs = false; + document.body.classList.add('cockpit-mode', 'cockpit-subject'); + this._tr3bSignature = null; + Object.assign(this.presence, { + active: true, + entryAvailable: false, + entryKind: null, + entryFace: null, + subjectKind: provider.kind, + subjectGeneric: true, + subjectLabels: { ...provider.labels }, + subjectLook: provider.look + ? { aheadLabel: provider.look.aheadLabel } + : null, + tr3b: null, + }); + this.publishPresenceView(); + this.publishRadar(null); + this.signalItems = []; + this.signalSignatures.clear(); + this.showBriefPage(0); + this.startBriefRotation(); + this.pushCockpitSignal('track', 'track', t('TRACK ACQUIRED'), frame.name); + const info = this.subjectInfo(frame); + this.updateHud(info, performance.now(), true); + this.setVisionMode(this.getInitialVisionMode()); + this.scheduleContextLayout(); + Promise.resolve().then(() => { + if (this.active && !this.destroyed) + this.mapViewButton?.focus({ preventScroll: true }); + }); + this.onEntered?.(); + this.onRide?.(provider.layerId); + this.dispatchCockpitModeChanged(true, info); + return true; +} + +/** + * Enter first person on the first subject in entry order (resolveEntry): a + * tracked aircraft, the tracked satellite's orbital view, the selected + * vessel's bridge or an application provider's subject. + * @param {{kind?: string|null}} [options] Restrict entry to one subject kind; + * voice entry is aircraft-only. * @returns {boolean} True when the view was entered. */ export function enter({ kind = null } = {}) { if (this.destroyed) return false; if (this.active) return false; const subjectKind = kind ?? this.resolveEntryKind() ?? 'aircraft'; + const provider = this.subjectProviders.find( + (entry) => entry.kind === subjectKind, + ); + if (provider) return enterSubject.call(this, provider); + if (!['aircraft', 'satellite', 'vessel'].includes(subjectKind)) return false; const vessel = subjectKind === 'vessel'; const satellite = subjectKind === 'satellite'; const allowed = vessel @@ -371,11 +710,17 @@ export function enter({ kind = null } = {}) { export function exit({ restoreTracking = true } = {}) { if (!this.active) return false; const entity = this.trackedEntity; + const provider = this.subjectProvider; + const providerSubject = provider ? this.subjectId : null; const vesselSubject = this.subjectKind === 'vessel' ? this.subjectId : null; const satelliteSubject = this.subjectKind === 'satellite' ? this.subjectId : null; this.active = false; this.subjectKind = null; + this.subjectProvider = null; + this.lastSubjectFrame = null; + this.subjectHud = null; + this.lookRestPitchDeg = null; this.vesselWaterline = null; this.vesselFix = null; this.subjectId = null; @@ -399,6 +744,7 @@ export function exit({ restoreTracking = true } = {}) { 'cockpit-mode', 'cockpit-bridge', 'cockpit-orbit', + 'cockpit-subject', ); // The camera's own field of view returns before any follow resumes. this.endOrbital(); @@ -406,6 +752,9 @@ export function exit({ restoreTracking = true } = {}) { this.publishUtilityLayout(null); this.presence.active = false; this.presence.subjectKind = null; + this.presence.subjectGeneric = false; + this.presence.subjectLabels = null; + this.presence.subjectLook = null; this.publishPresenceView(); this.routeView.visible = false; this.clearPredictiveRoute(); @@ -419,6 +768,13 @@ export function exit({ restoreTracking = true } = {}) { this.services.aisLiveVesselsLayer?.setCockpitSubject?.(null); if (satelliteSubject !== null) this.services.satellitesLayer?.setCockpitSubject?.(null); + // The provider gives its subject back; `restore` asks it to bring the map + // back to it, as the built-in kinds' refocus does. + if (provider) + this.callSubject(provider, 'release', [ + providerSubject, + { restore: restoreTracking === true }, + ]); this.dispatchCockpitModeChanged(false); if (restoreTracking && entity && this.viewer.entities.contains(entity)) { this.viewer.trackedEntity = entity; diff --git a/src/ui/contextControls.js b/src/ui/contextControls.js index 81da5f20e..7acb31ef5 100644 --- a/src/ui/contextControls.js +++ b/src/ui/contextControls.js @@ -51,6 +51,8 @@ export class ContextControls { this._contextRestoreState = null; this._contextLayerReactionPromises = new Set(); this._preservePanelStateDuringLayerClear = false; + /** Layers Context isolation is switching off right now. */ + this._isolatingLayerIds = new Set(); this._userFacingContextNotificationTokens = new Set(); this._clearSelectedLayersPromise = null; this._clearSelectedLayersManagerPromise = null; @@ -59,6 +61,16 @@ export class ContextControls { showToast(...args) { if (!this.destroyed) this.actions.showToast(...args); } + /** + * Whether Context isolation is switching this layer off right now: it is + * suspended, and leaving the mode restores it. A switch-off by the user, + * inside the mode or out of it, is not. + * @param {string} layerId Layer id. + * @returns {boolean} + */ + suspendsLayer(layerId) { + return this._isolatingLayerIds.has(layerId); + } setClearBusy(...args) { if (!this.destroyed) this.actions.setClearBusy(...args); } diff --git a/src/ui/contextControls.test.mjs b/src/ui/contextControls.test.mjs index 33b3c4d10..2bceb7e75 100644 --- a/src/ui/contextControls.test.mjs +++ b/src/ui/contextControls.test.mjs @@ -153,3 +153,56 @@ test('a rejected tracked reaction settles without an unhandled rejection', async await controls._waitForContextLayerSettlement(); assert.equal(controls._contextLayerReactionPromises.size, 0); }); + +test('only a layer Context isolation is switching off is suspended', async (t) => { + const { controls } = fixture(t); + const enabled = new Set(['cctv']); + // What the CCTV panel is told at each visibility change of its layer. + const seen = []; + controls._dataManager = { + layers: new Map([['cctv', {}]]), + getEnabledLayerIds: () => [...enabled], + getLayerParams: () => null, + setLayerParams() {}, + isEnabled: (layerId) => enabled.has(layerId), + isEffectivelyEnabled: (layerId) => enabled.has(layerId), + async setEnabled(layerId, next) { + // The layer tells its panel it is off while it is being disabled. + if (next) enabled.add(layerId); + else enabled.delete(layerId); + seen.push([layerId, next, controls.suspendsLayer(layerId)]); + return true; + }, + async restoreEnabledLayerIds(layerIds) { + for (const layerId of layerIds) await this.setEnabled(layerId, true); + }, + }; + assert.equal(controls.suspendsLayer('cctv'), false); + controls._captureContextSessionSnapshot(); + await controls._clearLayersOutsideContextMode('flights'); + assert.deepEqual(seen, [['cctv', false, true]]); + assert.equal(controls.suspendsLayer('cctv'), false, 'only while isolating'); + // Inside the mode the user turns CCTV back on, then off: their own. + await controls._dataManager.setEnabled('cctv', true); + await controls._dataManager.setEnabled('cctv', false); + assert.deepEqual(seen.at(-1), ['cctv', false, false]); + await controls._restoreContextSession(); + assert.equal(enabled.has('cctv'), true); + // A switch-off after the mode is left is the user's own too. + await controls._dataManager.setEnabled('cctv', false); + assert.deepEqual(seen.at(-1), ['cctv', false, false]); +}); + +test('a failed isolation leaves no layer suspended', async (t) => { + const { controls } = fixture(t); + controls._dataManager = { + layers: new Map([['cctv', {}]]), + isEffectivelyEnabled: () => true, + isEnabled: () => true, + setEnabled: async () => { + throw new Error('disable failed'); + }, + }; + await assert.rejects(controls._clearLayersOutsideContextMode('flights')); + assert.equal(controls.suspendsLayer('cctv'), false); +}); diff --git a/src/ui/contextTransactions.js b/src/ui/contextTransactions.js index e8a69b5fc..1f17faeef 100644 --- a/src/ui/contextTransactions.js +++ b/src/ui/contextTransactions.js @@ -240,25 +240,35 @@ export async function _clearLayersOutsideContextMode( ) { const allowed = contextAllowedLayerIds(mode); const pending = []; - for (const [layerId] of this._dataManager.layers || []) { - // Effective visibility: a disallowed layer still mid-ENABLING must be - // isolated too, or it settles ON inside the exclusive mode. - if ( - !allowed.has(layerId) && - this._dataManager.isEffectivelyEnabled(layerId) - ) { - pending.push({ - layerId, - transition: this._dataManager.setEnabled(layerId, false, { - notificationToken, - ...(signal ? { signal } : {}), - }), - }); + // Panels read this while their layer goes off (suspendsLayer): a layer + // isolation switches off is suspended, not switched off by the user. + const isolating = []; + let results; + try { + for (const [layerId] of this._dataManager.layers || []) { + // Effective visibility: a disallowed layer still mid-ENABLING must be + // isolated too, or it settles ON inside the exclusive mode. + if ( + !allowed.has(layerId) && + this._dataManager.isEffectivelyEnabled(layerId) + ) { + if (!this._isolatingLayerIds.has(layerId)) { + this._isolatingLayerIds.add(layerId); + isolating.push(layerId); + } + pending.push({ + layerId, + transition: this._dataManager.setEnabled(layerId, false, { + notificationToken, + ...(signal ? { signal } : {}), + }), + }); + } } + results = await Promise.all(pending.map(({ transition }) => transition)); + } finally { + for (const layerId of isolating) this._isolatingLayerIds.delete(layerId); } - const results = await Promise.all( - pending.map(({ transition }) => transition), - ); const failed = pending .filter( ({ layerId }, index) => diff --git a/src/ui/renderFailure.js b/src/ui/renderFailure.js new file mode 100644 index 000000000..b593f57f4 --- /dev/null +++ b/src/ui/renderFailure.js @@ -0,0 +1,216 @@ +import { useStatusStore } from './stores/status.js'; +import { applicationPinia } from './vueRuntime.js'; +import { describeError } from '../app/errors.js'; +import { appRootUrl } from '../share/shareLinkPath.js'; + +/** + * Show a recoverable notice when the globe stops drawing, instead of a black + * globe behind working controls. Retry restarts the render loop, or reloads + * after a lost WebGL context; Reset opens the app's root address. With + * `allowReload: false`, Reset is dropped and a lost context shows no notice: + * only a reload draws again, and the page says so itself. + * Returns a function that removes the notice. + */ +export function installRenderFailureNotice( + viewer, + { + store = useStatusStore(applicationPinia()), + locationRef = globalThis.location, + allowReload = true, + logError = (error) => + console.error('[Render] The globe stopped drawing:', error), + } = {}, +) { + const { scene } = viewer; + const { canvas } = viewer; + let notice = null; + let contextLost = false; + // With rethrowRenderErrors, Scene throws the error it has just raised; + // the render() wrapper must not raise it again. + let raisedByScene = null; + let frameFailed = false; + let removeFrameListeners = null; + + function retry() { + if (contextLost) { + locationRef.reload(); + return; + } + viewer.useDefaultRenderLoop = true; + scene.requestRender(); + } + + function reset() { + locationRef.assign(appRootUrl(locationRef.href)); + } + + function stopWatchingFrames() { + removeFrameListeners?.(); + removeFrameListeners = null; + } + + function clear() { + stopWatchingFrames(); + if (store.renderFailure === notice) store.publishRenderFailure(null); + notice = null; + } + + // Cesium raises postRender after a failed frame too, so only a frame that + // starts and ends without a render error clears the notice. + function watchFrames() { + if (removeFrameListeners || contextLost) return; + const removeStart = scene.preUpdate.addEventListener(() => { + frameFailed = false; + raisedByScene = null; + }); + const removeEnd = scene.postRender.addEventListener(() => { + if (!frameFailed) clear(); + }); + removeFrameListeners = () => { + removeStart(); + removeEnd(); + }; + } + + function present(detail) { + const reason = contextLost ? 'context-lost' : 'render-error'; + // A loop restarted into the same error keeps the notice as it was. + if (notice?.reason === reason && notice.detail === detail) return; + notice = { + reason, + detail, + retry, + reset: allowReload ? reset : null, + }; + store.publishRenderFailure(notice); + watchFrames(); + } + + const onRenderError = (_scene, error) => { + raisedByScene = { error }; + frameFailed = true; + logError(error); + // A lost context fails every frame after it; the notice keeps saying why. + if (contextLost) return; + present(error ? describeError(error) : ''); + }; + const onContextLost = () => { + contextLost = true; + // No frame can clear this one; only a reload draws again. + stopWatchingFrames(); + // A page that must not reload has nothing to offer: an inline panel + // shows its own lost-context notice (src/app/globePanelRuntime.js). + if (!allowReload) { + clear(); + return; + } + present(''); + }; + + const removeRenderError = scene.renderError.addEventListener(onRenderError); + // An error thrown outside Scene's error handling (a frame listener, onTick, + // resize) stops the loop without a renderError. Raise one, so Cesium, an + // inline panel's timer and this notice all see it. First finish the event + // raises it cut short, so listeners added or removed meanwhile apply. + const unguard = ['resize', 'render'].map((name) => + reportThrown(viewer.cesiumWidget, name, (error) => { + frameEvents(viewer).forEach(finishRaise); + if (!raisedByScene || raisedByScene.error !== error) + scene.renderError.raiseEvent(scene, error); + raisedByScene = null; + }), + ); + canvas.addEventListener('webglcontextlost', onContextLost); + return () => { + removeRenderError(); + unguard.forEach((restore) => restore()); + canvas.removeEventListener('webglcontextlost', onContextLost); + clear(); + }; +} + +/** + * Events Cesium raises in a frame outside Scene's error handling, which a + * listener's error can leave mid-raise. Only top-level tilesets are reached. + */ +function frameEvents(viewer) { + const { scene } = viewer; + const events = [ + scene.preUpdate, + scene.postUpdate, + scene.preRender, + scene.postRender, + scene.camera?.changed, + scene.camera?.moveStart, + scene.camera?.moveEnd, + viewer.clock?.onTick, + viewer.clock?.onStop, + scene.globe?.tileLoadProgressEvent, + ]; + const primitives = scene.primitives; + for (let i = 0; i < (primitives?.length ?? 0); i += 1) { + const tileset = primitives.get(i); + events.push( + tileset?.loadProgress, + tileset?.allTilesLoaded, + tileset?.initialTilesLoaded, + ); + } + return events; +} + +/** + * Finish a raise a throwing listener cut short. Until a raise ends, Cesium's + * `Event` only queues added and removed listeners; apply that queue. + * These are Cesium's private fields (`_invokingListeners`, `_toAdd`, + * `_toRemove`, `_listeners`, and the widget's `_renderLoopRunning`), checked + * against cesium 1.138 / @cesium/engine 22.3.0; the render loop fixture tests + * drive the real classes and fail when an upgrade renames them. + */ +function finishRaise(event) { + if (!event?._invokingListeners) return; + event._invokingListeners = false; + for (const [listener, scopes] of event._toAdd) { + const added = event._listeners.get(listener) ?? new Set(); + scopes.forEach((scope) => added.add(scope)); + event._listeners.set(listener, added); + } + event._toAdd.clear(); + for (const [listener, scopes] of event._toRemove) { + const left = event._listeners.get(listener); + scopes.forEach((scope) => left?.delete(scope)); + if (left?.size === 0) event._listeners.delete(listener); + } + event._toRemove.clear(); +} + +/** + * Report an error the widget's `name` method throws, and rethrow it. + * + * A render error marks the loop stopped, but a call made while it ran leaves + * its next frame queued, and that frame stops it. Keep the loop marked + * running, so a Retry or a shown tab before that frame starts no second loop. + * After a throw, the loop's own catch still marks it stopped. + */ +function reportThrown(widget, name, report) { + const original = widget?.[name]; + if (typeof original !== 'function') return () => {}; + const own = Object.hasOwn(widget, name); + const guarded = function (...args) { + const looping = widget._renderLoopRunning; + try { + return original.apply(this, args); + } catch (error) { + report(error); + throw error; + } finally { + if (looping) widget._renderLoopRunning = true; + } + }; + widget[name] = guarded; + return () => { + if (widget[name] !== guarded) return; + if (own) widget[name] = original; + else delete widget[name]; + }; +} diff --git a/src/ui/renderFailure.test.mjs b/src/ui/renderFailure.test.mjs new file mode 100644 index 000000000..913348740 --- /dev/null +++ b/src/ui/renderFailure.test.mjs @@ -0,0 +1,177 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { installRenderFailureNotice } from './renderFailure.js'; + +function fixture(t, options = {}) { + const canvasListeners = new Map(); + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() { + this.requests = (this.requests ?? 0) + 1; + }, + }; + const viewer = { + scene, + useDefaultRenderLoop: true, + canvas: { + addEventListener: (name, listener) => canvasListeners.set(name, listener), + removeEventListener: (name, listener) => { + if (canvasListeners.get(name) === listener) + canvasListeners.delete(name); + }, + }, + }; + const store = { + renderFailure: null, + published: [], + publishRenderFailure(next) { + this.renderFailure = next; + this.published.push(next); + }, + }; + const visits = []; + const locationRef = { + href: 'https://gev.example/share/AQc?lang=ru#lat=48.8584&lon=2.2945', + reload: () => visits.push('reload'), + assign: (url) => visits.push(String(url)), + }; + const logged = []; + const uninstall = installRenderFailureNotice(viewer, { + store, + locationRef, + logError: (error) => logged.push(error), + ...options, + }); + t.after(uninstall); + // One Cesium frame: preUpdate, an optional render error, then postRender, + // which Cesium raises after a failed frame too. + const frame = (error) => { + scene.preUpdate.raiseEvent(scene); + if (error) { + viewer.useDefaultRenderLoop = false; + scene.renderError.raiseEvent(scene, error); + } + scene.postRender.raiseEvent(scene); + }; + return { + viewer, + scene, + store, + visits, + logged, + uninstall, + frame, + loseContext: () => canvasListeners.get('webglcontextlost')?.({}), + canvasListeners, + }; +} + +test('a render error shows a notice that survives the failed frame', (t) => { + const f = fixture(t); + assert.equal(f.store.renderFailure, null); + const error = new Error('Expected width to be greater than 0'); + f.frame(error); + const notice = f.store.renderFailure; + assert.equal(notice.reason, 'render-error'); + assert.equal(notice.detail, 'Expected width to be greater than 0'); + assert.equal(typeof notice.retry, 'function'); + assert.equal(typeof notice.reset, 'function'); + assert.deepEqual(f.logged, [error]); +}); + +test('Retry restarts the render loop and a clean frame clears the notice', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.store.renderFailure.retry(); + assert.equal(f.viewer.useDefaultRenderLoop, true); + assert.equal(f.scene.requests, 1); + assert.ok(f.store.renderFailure, 'the notice waits for a frame'); + f.frame(); + assert.equal(f.store.renderFailure, null); + // Clearing stops the per-frame listeners. + assert.equal(f.scene.preUpdate.numberOfListeners, 0); + assert.equal(f.scene.postRender.numberOfListeners, 0); +}); + +test('a loop restarted into the same error keeps one notice', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + const first = f.store.renderFailure; + f.store.renderFailure.retry(); + f.frame(new Error('boom')); + assert.equal(f.store.renderFailure, first); + assert.equal(f.store.published.length, 1); + f.frame(new Error('other')); + assert.equal(f.store.renderFailure.detail, 'other'); + assert.ok(!f.store.published.includes(null), 'no new appearance'); +}); + +test('a new failure after recovery is a new appearance', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.store.renderFailure.retry(); + f.frame(); + f.frame(new Error('boom')); + assert.deepEqual( + f.store.published.map((notice) => notice?.reason ?? null), + ['render-error', null, 'render-error'], + ); +}); + +test('a render error without a value shows no detail', (t) => { + const f = fixture(t); + f.viewer.useDefaultRenderLoop = false; + f.scene.renderError.raiseEvent(f.scene, undefined); + assert.equal(f.store.renderFailure.reason, 'render-error'); + assert.equal(f.store.renderFailure.detail, ''); +}); + +test('Reset opens the app root without the restored view', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.store.renderFailure.reset(); + assert.deepEqual(f.visits, ['https://gev.example/?lang=ru']); +}); + +test('a lost context offers a reload and no frame clears it', (t) => { + const f = fixture(t); + f.loseContext(); + const notice = f.store.renderFailure; + assert.equal(notice.reason, 'context-lost'); + assert.equal(notice.detail, ''); + // Cesium's next frame then fails on the dead drawing buffer. + f.frame(new Error('Expected width to be greater than 0')); + assert.equal(f.store.renderFailure, notice, 'the notice keeps saying why'); + f.frame(); + assert.ok(f.store.renderFailure, 'a lost context never draws again'); + f.store.renderFailure.retry(); + assert.deepEqual(f.visits, ['reload']); +}); + +test('a page that must not reload offers neither reload nor reset', (t) => { + const f = fixture(t, { allowReload: false }); + f.frame(new Error('boom')); + assert.equal(typeof f.store.renderFailure.retry, 'function'); + assert.equal(f.store.renderFailure.reset, null); + // Only a reload draws again, which this page must not offer: the notice + // goes, and the page's own lost-context notice speaks instead. + f.loseContext(); + assert.equal(f.store.renderFailure, null); + f.frame(new Error('Expected width to be greater than 0')); + assert.equal(f.store.renderFailure, null); +}); + +test('uninstall removes the listeners and the notice', (t) => { + const f = fixture(t); + f.frame(new Error('boom')); + f.uninstall(); + assert.equal(f.store.renderFailure, null); + assert.equal(f.scene.renderError.numberOfListeners, 0); + assert.equal(f.scene.postRender.numberOfListeners, 0); + assert.equal(f.canvasListeners.size, 0); + f.frame(new Error('later')); + assert.equal(f.store.renderFailure, null); +}); diff --git a/src/ui/renderFailureEventRetry.test.mjs b/src/ui/renderFailureEventRetry.test.mjs new file mode 100644 index 000000000..a41697493 --- /dev/null +++ b/src/ui/renderFailureEventRetry.test.mjs @@ -0,0 +1,207 @@ +// Retry after a scene event listener throws. Cesium's Event.raiseEvent has no +// try/finally, so the event stays mid-raise and only queues listener changes. +// Drives Cesium's real Event and startRenderLoop. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { installRenderFailureNotice } from './renderFailure.js'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; + +function setup() { + // Scene.render as in Scene.js:4369: preUpdate always, preRender/postRender + // only when a frame is rendered (requestRenderMode idle skips them). + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRenderMode: true, + _renderRequested: false, + rendered: 0, + requestRender() { + this._renderRequested = true; + }, + initializeFrame() {}, + render(time) { + this.preUpdate.raiseEvent(this, time); + const shouldRender = !this.requestRenderMode || this._renderRequested; + if (!shouldRender) return; + this._renderRequested = false; + this.preRender.raiseEvent(this, time); + this.rendered += 1; + this.postRender.raiseEvent(this, time); + }, + }; + return { scene, ...renderLoopFixture(scene) }; +} + +for (const eventName of ['postRender', 'preUpdate']) { + test(`idle: a ${eventName} listener that threw once; a successful Retry clears the notice`, () => { + const { scene, viewer, store, runFrames, restore } = setup(); + try { + let broken = true; + scene[eventName].addEventListener(() => { + if (broken) throw new TypeError('listener failed'); + }); + scene.requestRender(); + viewer.useDefaultRenderLoop = true; + runFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(viewer.useDefaultRenderLoop, false); + broken = false; // the cause goes away + store.renderFailure.retry(); + const before = scene.rendered; + runFrames(10); // idle: no camera move, no render request + assert.ok(scene.rendered > before, 'the globe drew again'); + assert.equal(store.renderFailure, null, 'the notice is cleared'); + } finally { + restore(); + } + }); +} + +test('a throwing postRender listener removed before Retry is not called again', () => { + const { scene, viewer, store, runFrames, restore } = setup(); + try { + let calls = 0; + const remove = scene.postRender.addEventListener(() => { + calls += 1; + throw new TypeError('layer listener failed'); + }); + scene.requestRender(); + viewer.useDefaultRenderLoop = true; + runFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + remove(); // the user turns the layer off + store.renderFailure.retry(); + runFrames(5); + assert.equal(calls, 1, 'the removed listener ran again after Retry'); + assert.equal(viewer.useDefaultRenderLoop, true, 'the loop keeps running'); + assert.equal(store.renderFailure, null, 'the notice is cleared'); + } finally { + restore(); + } +}); + +const listenerCount = (event) => + [...event._listeners.values()].reduce((sum, scopes) => sum + scopes.size, 0); + +// While the loop is stopped, a layer turned off and on again, or a ground +// task in worldAnnotationRenderer that starts, times out and starts again. +const RESUBSCRIBE = { + 'turned off and on': (event, listener) => { + event.removeEventListener(listener); + event.addEventListener(listener); + }, + 'added, removed and added again': (event, listener) => { + event.addEventListener(listener); + event.removeEventListener(listener); + event.addEventListener(listener); + }, +}; + +for (const [name, resubscribe] of Object.entries(RESUBSCRIBE)) { + test(`a listener ${name} while the loop is stopped runs after Retry`, () => { + const { scene, viewer, store, runFrames, restore } = setup(); + try { + let broken = true; + scene.postRender.addEventListener(() => { + if (broken) throw new TypeError('layer listener failed'); + }); + let calls = 0; + const listener = () => { + calls += 1; + }; + if (name === 'turned off and on') + scene.postRender.addEventListener(listener); + scene.requestRender(); + viewer.useDefaultRenderLoop = true; + runFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + resubscribe(scene.postRender, listener); + broken = false; + store.renderFailure.retry(); + calls = 0; + scene.requestRender(); + runFrames(3); + assert.ok(calls > 0, 'the re-added listener runs after Retry'); + assert.equal( + scene.postRender.numberOfListeners, + listenerCount(scene.postRender), + ); + } finally { + restore(); + } + }); +} + +// Every event a listener's error can cut short is finished when the error +// surfaces, so a listener added at once is subscribed. +const FRAME_EVENTS = { + 'scene.preUpdate': (v) => v.scene.preUpdate, + 'scene.postUpdate': (v) => v.scene.postUpdate, + 'scene.preRender': (v) => v.scene.preRender, + 'scene.postRender': (v) => v.scene.postRender, + 'camera.changed': (v) => v.scene.camera.changed, + 'camera.moveStart': (v) => v.scene.camera.moveStart, + 'camera.moveEnd': (v) => v.scene.camera.moveEnd, + 'clock.onTick': (v) => v.clock.onTick, + 'clock.onStop': (v) => v.clock.onStop, + 'globe.tileLoadProgressEvent': (v) => v.scene.globe.tileLoadProgressEvent, + 'tileset.loadProgress': (v) => v.scene.primitives.get(0).loadProgress, + 'tileset.allTilesLoaded': (v) => v.scene.primitives.get(0).allTilesLoaded, + 'tileset.initialTilesLoaded': (v) => + v.scene.primitives.get(0).initialTilesLoaded, +}; + +for (const [name, pick] of Object.entries(FRAME_EVENTS)) { + test(`a listener that throws in ${name} leaves it finished`, () => { + const events = () => ({ + changed: new Cesium.Event(), + moveStart: new Cesium.Event(), + moveEnd: new Cesium.Event(), + }); + const tileset = { + loadProgress: new Cesium.Event(), + allTilesLoaded: new Cesium.Event(), + initialTilesLoaded: new Cesium.Event(), + }; + const viewer = { + scene: { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + camera: events(), + globe: { tileLoadProgressEvent: new Cesium.Event() }, + primitives: { length: 1, get: () => tileset }, + }, + clock: { onTick: new Cesium.Event(), onStop: new Cesium.Event() }, + canvas: { addEventListener() {}, removeEventListener() {} }, + cesiumWidget: { + _renderLoopRunning: false, + render: () => pick(viewer).raiseEvent(), + }, + }; + const store = { publishRenderFailure() {} }; + const uninstall = installRenderFailureNotice(viewer, { + store, + locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, + logError() {}, + }); + try { + const event = pick(viewer); + event.addEventListener(() => { + throw new TypeError('listener failed'); + }); + assert.throws(() => viewer.cesiumWidget.render(), TypeError); + assert.equal(event._invokingListeners, false); + const listener = () => {}; + event.addEventListener(listener); + assert.ok(event._listeners.has(listener)); + } finally { + uninstall(); + } + }); +} diff --git a/src/ui/renderFailureLoopErrors.test.mjs b/src/ui/renderFailureLoopErrors.test.mjs new file mode 100644 index 000000000..1446c149f --- /dev/null +++ b/src/ui/renderFailureLoopErrors.test.mjs @@ -0,0 +1,46 @@ +// Errors thrown in the render loop outside Scene's tryAndCatchError. Drives +// Cesium's real startRenderLoop through the useDefaultRenderLoop setter. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; + +function runLoop(render) { + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + postRender: new Cesium.Event(), + requestRender() {}, + initializeFrame() {}, + render, + }; + const fixture = renderLoopFixture(scene); + fixture.viewer.useDefaultRenderLoop = true; + fixture.runFrames(3); + return { ...fixture, uninstall: fixture.restore }; +} + +test('an error thrown outside tryAndCatchError still shows the notice', () => { + // Scene.render raises preRender outside tryAndCatchError (Scene.js:4439), + // so a listener's exception propagates. + const { widget, store, logged, uninstall } = runLoop(function () { + this.preUpdate.raiseEvent(this); + throw new TypeError("Cannot read properties of undefined (reading 'x')"); + }); + assert.equal(widget.useDefaultRenderLoop, false); + assert.equal(store.renderFailure?.reason, 'render-error'); + assert.equal(logged.length, 1); + uninstall(); +}); + +test('an error Scene raised and threw on with rethrowRenderErrors is reported once', () => { + const { store, logged, uninstall } = runLoop(function () { + this.preUpdate.raiseEvent(this); + const error = new Error('boom'); + this.renderError.raiseEvent(this, error); + throw error; + }); + assert.equal(store.renderFailure?.detail, 'boom'); + assert.equal(logged.length, 1); + uninstall(); +}); diff --git a/src/ui/renderFailureSecondLoop.test.mjs b/src/ui/renderFailureSecondLoop.test.mjs new file mode 100644 index 000000000..2b5c6196f --- /dev/null +++ b/src/ui/renderFailureSecondLoop.test.mjs @@ -0,0 +1,95 @@ +// Retry after an error Scene catches itself: the loop is marked stopped but +// its next frame is still queued, so an early Retry must not start a second +// loop. Drives Cesium's real startRenderLoop and useDefaultRenderLoop setter. +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { renderLoopFixture } from './renderLoopFixture.mjs'; + +function setup({ targetFrameRate } = {}) { + const scene = { + renderError: new Cesium.Event(), + preUpdate: new Cesium.Event(), + preRender: new Cesium.Event(), + postRender: new Cesium.Event(), + broken: false, + drawn: 0, + requestRender() {}, + initializeFrame() {}, + // Scene.render (Scene.js:4369): an update error is caught by + // tryAndCatchError, which raises renderError and returns normally. + render(time) { + this.drawn += 1; + this.preUpdate.raiseEvent(this, time); + if (this.broken) this.renderError.raiseEvent(this, new Error('boom')); + this.preRender.raiseEvent(this, time); + this.postRender.raiseEvent(this, time); + }, + }; + return { scene, ...renderLoopFixture(scene, { targetFrameRate }) }; +} + +for (const targetFrameRate of [undefined, 60]) { + test(`targetFrameRate ${targetFrameRate}: Retry before the next frame after an error Scene caught keeps one render loop`, () => { + const { frames, scene, viewer, store, runFrames, restore } = setup({ + targetFrameRate, + }); + try { + viewer.useDefaultRenderLoop = true; + runFrames(2); + scene.broken = true; + runFrames(1); + assert.equal(store.renderFailure?.reason, 'render-error'); + // The cause is gone; Retry runs (a queued Enter on the focused Retry) + // before the loop's next animation frame. + scene.broken = false; + store.renderFailure.retry(); + assert.equal(frames.length, 1, 'one animation frame queued after Retry'); + scene.drawn = 0; + runFrames(3); + assert.equal(store.renderFailure, null); + assert.equal(scene.drawn, 3, 'the globe draws once a frame'); + } finally { + restore(); + } + }); +} + +test('control: Retry after the next frame ran keeps one render loop', () => { + const { frames, scene, viewer, store, runFrames, restore } = setup(); + try { + viewer.useDefaultRenderLoop = true; + runFrames(2); + scene.broken = true; + runFrames(1); + scene.broken = false; + runFrames(1); + assert.equal(frames.length, 0, 'the stopped loop queued nothing'); + store.renderFailure.retry(); + scene.drawn = 0; + runFrames(3); + assert.equal(scene.drawn, 3); + } finally { + restore(); + } +}); + +test('a tab hidden and shown again before the next frame after an error Scene caught keeps one render loop', () => { + const { scene, viewer, runFrames, restore } = setup(); + try { + viewer.useDefaultRenderLoop = true; + runFrames(2); + scene.broken = true; + runFrames(1); + scene.broken = false; + // src/app/tools.js turns the loop off while the tab is hidden and back on + // when it is shown, before the queued frame runs. + viewer.useDefaultRenderLoop = false; + viewer.useDefaultRenderLoop = true; + scene.drawn = 0; + runFrames(3); + assert.equal(scene.drawn, 3, 'the globe draws once a frame'); + } finally { + restore(); + } +}); diff --git a/src/ui/renderFailureView.test.mjs b/src/ui/renderFailureView.test.mjs new file mode 100644 index 000000000..89213bffa --- /dev/null +++ b/src/ui/renderFailureView.test.mjs @@ -0,0 +1,173 @@ +// The scene chrome renders the render failure notice and its actions. +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import { useStatusStore } from './stores/status.js'; +import SceneChromeView from './views/SceneChromeView.vue'; + +function mount(t) { + document.body.replaceChildren(); + const pinia = createPinia(); + const app = createApp(SceneChromeView).use(pinia); + app.mount(document.body.appendChild(document.createElement('div'))); + t.after(() => app.unmount()); + return useStatusStore(pinia); +} + +const notice = () => document.getElementById('render-failure'); +const buttons = () => + [...(notice()?.querySelectorAll('button') ?? [])].map((button) => + button.textContent.trim(), + ); + +test('a render failure shows an alert dialog that moves focus to Retry', async (t) => { + const store = mount(t); + assert.equal(notice(), null, 'no notice while the globe draws'); + const calls = []; + store.publishRenderFailure({ + reason: 'render-error', + detail: 'Expected width to be greater than 0', + retry: () => calls.push('retry'), + reset: () => calls.push('reset'), + }); + await nextTick(); + await nextTick(); + const element = notice(); + assert.equal(element.getAttribute('role'), 'alertdialog'); + assert.equal( + document.getElementById(element.getAttribute('aria-labelledby')) + .textContent, + 'The globe stopped drawing', + ); + assert.match( + document.getElementById(element.getAttribute('aria-describedby')) + .textContent, + /^Rendering stopped after an error\./, + ); + assert.equal( + element.querySelector('.render-failure-detail').textContent, + 'Expected width to be greater than 0', + ); + assert.deepEqual(buttons(), ['Retry', 'Reset view']); + assert.equal(document.activeElement?.textContent.trim(), 'Retry'); + for (const button of element.querySelectorAll('button')) button.click(); + assert.deepEqual(calls, ['retry', 'reset']); + store.publishRenderFailure(null); + await nextTick(); + assert.equal(notice(), null); +}); + +test('a lost context offers Reload, and a panel that cannot reload offers nothing', async (t) => { + const store = mount(t); + store.publishRenderFailure({ + reason: 'context-lost', + detail: '', + retry: () => {}, + reset: () => {}, + }); + await nextTick(); + assert.match( + document.getElementById('render-failure-message').textContent, + /3D graphics/, + ); + assert.equal(notice().querySelector('.render-failure-detail'), null); + assert.deepEqual(buttons(), ['Reload', 'Reset view']); + store.publishRenderFailure({ + reason: 'context-lost', + detail: '', + retry: null, + reset: null, + }); + await nextTick(); + assert.deepEqual(buttons(), []); + assert.equal(notice().querySelector('.render-failure-hint'), null); +}); + +const failure = () => ({ + reason: 'render-error', + detail: '', + retry: () => {}, + reset: null, +}); +const settle = async () => { + await nextTick(); + await nextTick(); +}; + +test('focus goes back where it was when the globe draws again', async (t) => { + const store = mount(t); + const search = document.body.appendChild(document.createElement('input')); + search.focus(); + store.publishRenderFailure(failure()); + await settle(); + assert.equal(document.activeElement?.textContent.trim(), 'Retry'); + store.publishRenderFailure(null); + await settle(); + assert.equal(document.activeElement, search); +}); + +// The globe's canvas, as CesiumWidget builds it inside the container. +const addGlobeCanvas = () => { + const widget = document.createElement('div'); + widget.className = 'cesium-widget'; + document.getElementById('cesiumContainer').appendChild(widget); + return widget.appendChild(document.createElement('canvas')); +}; + +test('focus goes to the globe canvas when the element it came from is gone', async (t) => { + const store = mount(t); + const canvas = addGlobeCanvas(); + const search = document.body.appendChild(document.createElement('input')); + search.focus(); + store.publishRenderFailure(failure()); + await settle(); + search.remove(); + store.publishRenderFailure(null); + await settle(); + // The map's keys, Enter to finish a drawing among them, read the canvas + // as the map (src/annotations/drawTool.js). + assert.equal(document.activeElement, canvas); + assert.equal(canvas.getAttribute('tabindex'), '-1'); + canvas.blur(); + assert.equal(canvas.hasAttribute('tabindex'), false, 'only this once'); +}); + +test('focus goes to the globe canvas when the element it came from cannot take it', async (t) => { + const store = mount(t); + const canvas = addGlobeCanvas(); + const button = document.body.appendChild(document.createElement('button')); + button.focus(); + store.publishRenderFailure(failure()); + await settle(); + button.disabled = true; + store.publishRenderFailure(null); + await settle(); + assert.equal(document.activeElement, canvas); + // A press on the globe leaves focus on the page again, as Cesium has it. + canvas.dispatchEvent(new Event('pointerdown')); + assert.equal(document.activeElement, document.body); + assert.equal(canvas.hasAttribute('tabindex'), false); +}); + +test('focus the user moved while the notice was up stays put', async (t) => { + const store = mount(t); + store.publishRenderFailure(failure()); + await settle(); + const chat = document.body.appendChild(document.createElement('textarea')); + chat.focus(); + store.publishRenderFailure(null); + await settle(); + assert.equal(document.activeElement, chat); +}); + +test('the globe drawing from the start leaves focus alone', async (t) => { + mount(t); + await settle(); + assert.equal(document.activeElement, document.body); + assert.equal( + document.getElementById('cesiumContainer').hasAttribute('tabindex'), + false, + ); +}); diff --git a/src/ui/renderLoopFixture.mjs b/src/ui/renderLoopFixture.mjs new file mode 100644 index 000000000..562e2d3f7 --- /dev/null +++ b/src/ui/renderLoopFixture.mjs @@ -0,0 +1,72 @@ +// A widget on Cesium's real CesiumWidget.prototype (startRenderLoop, the +// useDefaultRenderLoop setter, render) around a scene double, with animation +// frames queued by hand and the render failure notice installed. +import * as Cesium from 'cesium'; +import { installRenderFailureNotice } from './renderFailure.js'; + +export function renderLoopFixture( + scene, + { targetFrameRate, viewer: viewerExtras = {}, notice = {} } = {}, +) { + const frames = []; + const previousRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (fn) => frames.push(fn); + const widget = Object.create(Cesium.CesiumWidget.prototype); + Object.assign(widget, { + _scene: scene, + _clock: { tick() {} }, + _canRender: true, + _useDefaultRenderLoop: false, + _renderLoopRunning: false, + // What src/app/scene.js passes to the viewer. + _showRenderLoopErrors: false, + _targetFrameRate: targetFrameRate, + isDestroyed: () => false, + resize() {}, + }); + // CesiumWidget's own renderError handler, which marks the loop stopped. + scene.renderError.addEventListener(() => { + widget._useDefaultRenderLoop = false; + widget._renderLoopRunning = false; + }); + const viewer = Object.assign( + { + scene, + cesiumWidget: widget, + canvas: { addEventListener() {}, removeEventListener() {} }, + get useDefaultRenderLoop() { + return widget.useDefaultRenderLoop; + }, + set useDefaultRenderLoop(value) { + widget.useDefaultRenderLoop = value; + }, + }, + viewerExtras, + ); + const store = { + renderFailure: null, + publishRenderFailure(next) { + this.renderFailure = next; + }, + }; + const logged = []; + const uninstall = installRenderFailureNotice(viewer, { + store, + locationRef: { href: 'https://gev.example/', reload() {}, assign() {} }, + logError: (error) => logged.push(error), + ...notice, + }); + let time = 0; + const runFrames = (n) => { + for (let i = 0; i < n; i += 1) { + // Above a 60 fps target's interval, so every frame draws. + time += 17; + for (const fn of frames.splice(0)) fn(time); + } + }; + const restore = () => { + uninstall(); + globalThis.requestAnimationFrame = previousRaf; + }; + return { frames, widget, viewer, store, logged, runFrames, restore }; +} diff --git a/src/ui/sceneControls.test.mjs b/src/ui/sceneControls.test.mjs index b0aea6cfd..b8505cfdb 100644 --- a/src/ui/sceneControls.test.mjs +++ b/src/ui/sceneControls.test.mjs @@ -627,7 +627,7 @@ test('Authoring PLAY uses the scene context, not the radio PLAY', async (context const f = fixture(); try { await nextTick(); - assert.equal(f.elements.start.textContent, 'REPROD.'); + assert.equal(f.elements.start.textContent, 'REPRODUCIR'); } finally { f.restore(); } diff --git a/src/ui/shellFacade.js b/src/ui/shellFacade.js index 678b69c79..276a95547 100644 --- a/src/ui/shellFacade.js +++ b/src/ui/shellFacade.js @@ -891,6 +891,10 @@ export class ShellFacade { return this._contextControls?._preservePanelStateDuringLayerClear ?? false; } + _contextSuspendsLayer(layerId) { + return this._contextControls?.suspendsLayer(layerId) ?? false; + } + _runUserFacingContextAction(...args) { return this._contextControls?._runUserFacingContextAction(...args); } diff --git a/src/ui/stores/status.js b/src/ui/stores/status.js index 34f66233d..d51fefd03 100644 --- a/src/ui/stores/status.js +++ b/src/ui/stores/status.js @@ -7,7 +7,9 @@ import { markRaw, shallowRef } from 'vue'; * road network chip, CctvControls the camera grid chip; the toast shows * what any owner's createToast() (src/ui/toast.js) publishes, and the title * bar the FPS readout createFrameRateMonitor() publishes. Each label keeps its - * last text while hidden, so the next change flaps from what was shown. + * last text while hidden, so the next change flaps from what was shown. The + * render failure notice is what installRenderFailureNotice() + * (src/ui/renderFailure.js) publishes while the globe cannot draw. */ export const useStatusStore = defineStore('gev-status', () => { /** @@ -32,6 +34,13 @@ export const useStatusStore = defineStore('gev-status', () => { */ const fps = shallowRef(null); + /** + * The globe's render failure, `{reason, detail, retry, reset}`; null while + * the globe draws. `reason` is 'render-error' or 'context-lost', and `retry` + * and `reset` are null where that action is not offered. + */ + const renderFailure = shallowRef(null); + /** @param {?object} next A fresh presentation of the loading status. */ function publishLoading(next) { loading.value = next && markRaw(next); @@ -57,6 +66,11 @@ export const useStatusStore = defineStore('gev-status', () => { fps.value = next && markRaw(next); } + /** @param {?object} next A fresh presentation of the render failure. */ + function publishRenderFailure(next) { + renderFailure.value = next && markRaw(next); + } + return { fps, publishFps, @@ -68,5 +82,7 @@ export const useStatusStore = defineStore('gev-status', () => { publishTraffic, cctv, publishCctv, + renderFailure, + publishRenderFailure, }; }); diff --git a/src/ui/styles/cctv.css b/src/ui/styles/cctv.css index 7754e563f..fd406485d 100644 --- a/src/ui/styles/cctv.css +++ b/src/ui/styles/cctv.css @@ -250,8 +250,11 @@ 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 { diff --git a/src/ui/styles/cockpitSubject.css b/src/ui/styles/cockpitSubject.css new file mode 100644 index 000000000..37031ee8b --- /dev/null +++ b/src/ui/styles/cockpitSubject.css @@ -0,0 +1,315 @@ +/* An application provider's first-person view + (src/cockpitSubjectProviders.js): a window view with the provider's board, + sources, banner, darkening veil and progress line. Every rule is scoped to + the generic view, so the built-in views keep their own looks. */ +#cockpit-hud[data-generic] { + --cockpit-subject-panel: rgba(0, 7, 12, 0.66); + --cockpit-subject-live: var(--cockpit-accent); + --cockpit-subject-estimate: #c9b6ff; + --cockpit-subject-warning: #ffb800; + --cockpit-subject-alert: var(--cockpit-emergency); + --cockpit-subject-progress-band: 46px; +} +#cockpit-hud[data-generic] [data-tone='muted'] { + opacity: 0.62; +} +#cockpit-hud[data-generic] [data-tone='live'] { + color: var(--cockpit-subject-live); +} +#cockpit-hud[data-generic] [data-tone='estimate'] { + color: var(--cockpit-subject-estimate); +} +#cockpit-hud[data-generic] [data-tone='warning'] { + color: var(--cockpit-subject-warning); +} +#cockpit-hud[data-generic] [data-tone='alert'] { + color: var(--cockpit-subject-alert); +} + +/* The veil darkens the picture under the HUD; its streaks are lights + passing the window. */ +#cockpit-hud[data-generic] .cockpit-veil { + position: absolute; + inset: 0; + background: #000; + pointer-events: none; + transition: opacity 0.25s linear; +} +#cockpit-hud[data-generic] .cockpit-veil.streaks::before { + content: ''; + position: absolute; + inset: 34% -40% 46%; + background: repeating-linear-gradient( + 90deg, + transparent 0 22%, + rgba(255, 214, 150, 0.16) 23%, + rgba(255, 236, 200, 0.42) 24%, + rgba(255, 214, 150, 0.16) 25%, + transparent 26% 48% + ); + filter: blur(3px); + animation: cockpit-veil-streaks 1.4s linear infinite; +} +@keyframes cockpit-veil-streaks { + from { + transform: translateX(0); + } + to { + transform: translateX(-26%); + } +} +@media (prefers-reduced-motion: reduce) { + #cockpit-hud[data-generic] .cockpit-veil.streaks::before { + animation: none; + } + #cockpit-hud[data-generic] .cockpit-veil { + transition: none; + } +} + +/* The banner: top centre, below the top line. */ +#cockpit-hud[data-generic] .cockpit-banner { + position: absolute; + top: clamp(92px, 13vh, 128px); + left: 50%; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + max-width: min(560px, 80vw); + padding: 6px 14px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 56%, transparent); + border-radius: 2px; + background: var(--cockpit-subject-panel); + text-align: center; + transform: translateX(-50%); +} +#cockpit-hud[data-generic] .cockpit-banner strong { + font-size: 15px; + letter-spacing: 0.14em; +} +#cockpit-hud[data-generic] .cockpit-banner small { + font-size: 11px; + letter-spacing: 0.08em; +} + +/* The board: cards on the left, below the page's left rail and the Intel + HUD's top corner (both stay on in first person), the sources under them. */ +#cockpit-hud[data-generic] { + --cockpit-subject-board-top: max(23rem, 41vh); +} +#cockpit-hud[data-generic] .cockpit-board { + position: absolute; + top: var(--cockpit-subject-board-top); + left: clamp(22px, 4vw, 58px); + display: flex; + flex-direction: column; + gap: 8px; + width: min(320px, 30vw); + min-width: 220px; + max-height: calc(100vh - var(--cockpit-subject-board-top) - 11rem); + overflow: hidden; +} +#cockpit-hud[data-generic] .cockpit-board-card { + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px 10px; + border-left: 2px solid + color-mix(in srgb, var(--cockpit-accent) 72%, transparent); + background: var(--cockpit-subject-panel); +} +#cockpit-hud[data-generic] .cockpit-board-card small { + font-size: 10px; + letter-spacing: 0.16em; + opacity: 0.78; +} +#cockpit-hud[data-generic] .cockpit-board-card strong { + font-size: 17px; + letter-spacing: 0.04em; + overflow-wrap: anywhere; +} +#cockpit-hud[data-generic] .cockpit-board-card span { + font-size: 12px; + letter-spacing: 0.06em; +} +#cockpit-hud[data-generic] .cockpit-sources { + display: flex; + flex-direction: column; + gap: 3px; + margin: 0; + padding: 6px 10px; + background: rgba(0, 7, 12, 0.5); + font-size: 10px; + letter-spacing: 0.06em; +} +#cockpit-hud[data-generic] .cockpit-sources div { + display: flex; + gap: 6px; +} +#cockpit-hud[data-generic] .cockpit-sources dt { + flex: none; + min-width: 64px; + opacity: 0.7; + letter-spacing: 0.14em; +} +#cockpit-hud[data-generic] .cockpit-sources dd { + margin: 0; + overflow-wrap: anywhere; +} + +/* The speed's tag (live or estimated) sits where a label would. */ +#cockpit-hud[data-generic] .cockpit-speed-tag { + font-weight: 700; +} +/* With no speed readout the heading still holds the middle of the row. */ +#cockpit-hud[data-generic] .cockpit-heading-value { + grid-column: 2; +} +#cockpit-hud[data-generic] .cockpit-aircraft-meta[data-tone='warning'], +#cockpit-hud[data-generic] .cockpit-aircraft-meta[data-tone='alert'] { + font-weight: 700; +} + +/* Look-around: the readout and LOOK AHEAD centred under the style switcher, + in the sky over the forward view; the bottom corners hold the position + readouts. Styled as on a bridge. */ +#cockpit-hud[data-generic] .cockpit-look { + position: absolute; + top: calc(clamp(22px, 4vh, 42px) + 70px); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + gap: 8px; +} +#cockpit-hud[data-generic] .cockpit-look[hidden] { + display: none; +} +#cockpit-hud[data-generic] .cockpit-look-readout { + padding: 2px 6px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 46%, transparent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.62); + color: var(--cockpit-accent); + font-family: var(--font-mono); + font-size: 11px; + letter-spacing: 0.12em; + white-space: nowrap; +} +#cockpit-hud[data-generic] .cockpit-look-ahead { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border: 1px solid color-mix(in srgb, var(--cockpit-accent) 64%, transparent); + border-radius: 2px; + background: rgba(0, 7, 12, 0.78); + color: #f4fbff; + font: inherit; + font-size: 10px; + letter-spacing: 0.14em; + cursor: pointer; + pointer-events: auto; +} +#cockpit-hud[data-generic] .cockpit-look-ahead:hover { + border-color: var(--cockpit-accent); +} +#cockpit-hud[data-generic] .cockpit-look-ahead:focus-visible { + outline: 2px solid var(--cockpit-accent); + outline-offset: 2px; +} + +/* The progress line takes a band along the bottom edge; the readouts that + sit there move up above it. */ +#cockpit-hud[data-generic][data-progress] + :is(.cockpit-help, .cockpit-position-readout) { + bottom: calc( + clamp(22px, 4vh, 42px) + var(--cockpit-subject-progress-band) - 14px + ); +} +#cockpit-hud[data-generic][data-progress] .cockpit-compass { + bottom: calc(clamp(44px, 7vh, 72px) + var(--cockpit-subject-progress-band)); +} +#cockpit-hud[data-generic] .cockpit-progress { + position: absolute; + right: clamp(22px, 4vw, 58px); + bottom: 6px; + left: clamp(22px, 4vw, 58px); + height: var(--cockpit-subject-progress-band); +} +#cockpit-hud[data-generic] .cockpit-progress-line { + position: absolute; + right: 0; + bottom: 8px; + left: 0; + height: 2px; + background: color-mix(in srgb, var(--cockpit-accent) 78%, transparent); +} +#cockpit-hud[data-generic] .cockpit-progress-travelled { + position: absolute; + inset: 0 auto 0 0; + background: rgba(0, 7, 12, 0.72); +} +#cockpit-hud[data-generic] .cockpit-progress-tick { + position: absolute; + bottom: 5px; + width: 2px; + height: 8px; + margin-left: -1px; + background: currentColor; +} +#cockpit-hud[data-generic] .cockpit-progress-tick.labelled { + height: 12px; +} +#cockpit-hud[data-generic] .cockpit-progress-tick.merged { + width: 4px; + margin-left: -2px; +} +#cockpit-hud[data-generic] .cockpit-progress-tick.travelled { + opacity: 0.45; +} +#cockpit-hud[data-generic] .cockpit-progress-label { + position: absolute; + bottom: 20px; + display: flex; + flex-direction: column; + overflow: hidden; + font-size: 10px; + letter-spacing: 0.06em; + line-height: 1.2; + white-space: nowrap; +} +#cockpit-hud[data-generic] .cockpit-progress-label[data-align='center'] { + align-items: center; + text-align: center; +} +#cockpit-hud[data-generic] .cockpit-progress-label[data-align='end'] { + align-items: flex-end; + text-align: end; +} +#cockpit-hud[data-generic] .cockpit-progress-label small { + opacity: 0.82; +} +#cockpit-hud[data-generic] .cockpit-progress-marker { + position: absolute; + bottom: 4px; + width: 10px; + height: 10px; + margin-left: -5px; + border-radius: 50%; + background: var(--cockpit-accent); + box-shadow: 0 0 10px var(--cockpit-accent); +} +#cockpit-hud[data-generic] .cockpit-progress-marker.dwell { + background: transparent; + border: 2px solid var(--cockpit-accent); +} +#cockpit-hud[data-generic] .cockpit-progress-tag { + position: absolute; + right: 0; + bottom: -2px; + font-size: 9px; + letter-spacing: 0.14em; + opacity: 0.7; +} diff --git a/src/ui/styles/status.css b/src/ui/styles/status.css index eebb4e785..ce212a46d 100644 --- a/src/ui/styles/status.css +++ b/src/ui/styles/status.css @@ -405,6 +405,76 @@ html:not([lang='en']) #global-loading-detail { } } +/* ── Render failure notice ───────────────────── */ +/* Above the loading screen, so a globe that fails while it loads says so too, + and above floating panels such as the Québec camera viewer (2000). */ +#render-failure { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 2001; + box-sizing: border-box; + width: min(420px, calc(100vw - 32px)); + max-height: calc(100vh - 32px); + overflow-y: auto; + padding: 20px 22px; + background: var(--glass-bg); + border: 1px solid #ff7272; + border-radius: 12px; + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + box-shadow: 0 0 24px rgba(255, 88, 88, 0.18); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 13px; + line-height: 1.5; +} + +#render-failure-title { + margin: 0 0 8px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: 2px; + text-transform: uppercase; + color: #ff7272; +} + +#render-failure p { + margin: 0 0 10px; +} + +#render-failure .render-failure-detail { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +#render-failure .render-failure-hint { + font-size: 12px; + color: var(--text-secondary); +} + +#render-failure .render-failure-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +#render-failure .gev-action-button { + font-size: 11px; + padding: 8px 14px; +} + +@media (pointer: coarse) { + #render-failure .gev-action-button { + min-height: 44px; + } +} + /* ── Toast Notification ──────────────────────── */ #toast { position: fixed; diff --git a/src/ui/views/CockpitView.vue b/src/ui/views/CockpitView.vue index d02122878..0d7fc4e9a 100644 --- a/src/ui/views/CockpitView.vue +++ b/src/ui/views/CockpitView.vue @@ -13,6 +13,7 @@ import { useGlobeActionsStore } from '../stores/globeActions.js'; import CockpitBriefing from './panels/CockpitBriefing.vue'; import CockpitContext from './panels/CockpitContext.vue'; import CockpitOrbital from './panels/CockpitOrbital.vue'; +import CockpitProgress from './panels/CockpitProgress.vue'; import CockpitRadar from './panels/CockpitRadar.vue'; import CockpitRollingValue from './panels/CockpitRollingValue.vue'; import CockpitUtilityControls from './panels/CockpitUtilityControls.vue'; @@ -29,30 +30,58 @@ const active = computed(() => Boolean(presence.value?.active)); const bridge = computed(() => presence.value?.subjectKind === 'vessel'); // A satellite subject keeps only its orbital HUD over the nadir picture. const orbital = computed(() => presence.value?.subjectKind === 'satellite'); +// An application provider's subject (src/cockpitSubjectProviders.js): a +// window view whose readouts, labels and texts the provider words. +const generic = computed(() => presence.value?.subjectGeneric === true); +const labels = computed(() => presence.value?.subjectLabels ?? null); const subject = computed(() => - bridge.value ? 'vessel' : orbital.value ? 'satellite' : 'aircraft', + generic.value + ? presence.value.subjectKind + : bridge.value + ? 'vessel' + : orbital.value + ? 'satellite' + : 'aircraft', ); const hudLabel = computed(() => - bridge.value - ? t('Ship bridge view') - : orbital.value - ? t('Satellite orbital view') - : t('Aircraft cockpit view'), + generic.value + ? labels.value?.view + : bridge.value + ? t('Ship bridge view') + : orbital.value + ? t('Satellite orbital view') + : t('Aircraft cockpit view'), ); const exitLabel = computed(() => - bridge.value - ? t('Exit bridge view') - : orbital.value - ? t('Exit orbital view') - : t('Exit cockpit view'), + generic.value + ? labels.value?.exit + : bridge.value + ? t('Exit bridge view') + : orbital.value + ? t('Exit orbital view') + : t('Exit cockpit view'), ); const exitText = computed(() => - bridge.value - ? t('EXIT BRIDGE') - : orbital.value - ? t('EXIT ORBIT') - : t('EXIT COCKPIT'), + generic.value + ? labels.value?.exitText + : bridge.value + ? t('EXIT BRIDGE') + : orbital.value + ? t('EXIT ORBIT') + : t('EXIT COCKPIT'), ); +// Look-around: on a bridge, or in a provider's view that has it. +const looks = computed( + () => bridge.value || (generic.value && Boolean(presence.value?.subjectLook)), +); +const aheadLabel = computed(() => + generic.value + ? presence.value?.subjectLook?.aheadLabel + : t('Look ahead: turn the bridge view back to the bow'), +); +const veilStyle = computed(() => ({ + opacity: String(hud.value.veil?.level ?? 0), +})); const globe = useGlobeActionsStore(); const { resetBusy } = storeToRefs(globe); const initialInstruments = createInstrumentState(); @@ -92,7 +121,9 @@ const tickStyle = (tick) =>