mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +00:00
Merge remote-tracking branch 'upstream/main' into pr1277
This commit is contained in:
commit
8767eb23ab
155 files changed
+10660
-915
No files matched your search
@@ -0,0 +1,4 @@
|
||||
- Without `OPENAI_API_KEY`, the mic now says the key is missing and to add it in
|
||||
Provider Settings, instead of showing the raw server error with a hint to
|
||||
check microphone permissions. `/api/realtime/token` returns
|
||||
`code: "API_KEY_MISSING"` with its 503 (asturksever, #1599).
|
||||
@@ -0,0 +1,3 @@
|
||||
- Add Swedish (sv) to the language picker: the whole interface in Swedish,
|
||||
with Swedish number and date formats and a style guide and glossary in
|
||||
docs/translations/sv.md (Kaj Obst, @Kajjan7610, #1630).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Keyless Gemini voice no longer blames the microphone. Without
|
||||
`GEMINI_API_KEY`, the voice control shows only "Add a Gemini API key in
|
||||
Provider Settings", without the microphone-permission hint, as OpenAI voice
|
||||
already does (Sameh Khamis, @samehkhamis, #1660).
|
||||
@@ -0,0 +1,4 @@
|
||||
- Gemini voice messages follow a language change. A Gemini Live status or
|
||||
error, such as "Gemini quota is unavailable", is shown in the new interface
|
||||
language after you switch it, instead of staying in the old one, as OpenAI
|
||||
voice messages already do (Sameh Khamis, @samehkhamis, #1661).
|
||||
@@ -0,0 +1,36 @@
|
||||
- Flights, Military Flights and AIS Vessels follow the world clock when their
|
||||
source declares a timeline, that is, can answer a snapshot at a given time
|
||||
(`source.timeline`, read by `readSnapshotTimeline` from
|
||||
`gods-eye-view/sources/live`). Away from live they show the snapshot at the
|
||||
clock's time and move on with it as it plays; back at live they poll as
|
||||
before. A source's `timeline.subscribe` may also tell of a better answer
|
||||
for a time it has answered; paused, the layer loads the time shown again.
|
||||
A source with no timeline keeps them live, unchanged (Sameh Khamis,
|
||||
@samehkhamis, #1662).
|
||||
- AIS Vessels are keyed by the stable id their live source gives (`id` on a
|
||||
vessel record), not by MMSI: vessels can share an MMSI, through spoofing,
|
||||
reused numbers or defaults such as 0, and a number can move to another
|
||||
hull. Two vessels sharing an MMSI are now two vessels, each selected,
|
||||
followed and carded on its own. The MMSI is an attribute (`mmsi`), shown
|
||||
when known and `--` otherwise, up to ten digits; looking a vessel up or
|
||||
selecting it by MMSI (an older link, say) still finds a vessel with it, and
|
||||
a vessel with no name is no longer named by its id. `vesselSnapshot` takes
|
||||
`idFor(row)` for a source whose rows carry an id of their own. Tool and voice results carry the vessel's id beside its MMSI
|
||||
(`id`, or `vesselId` where `id` is a display label), and `get_vessel_track`
|
||||
also takes an `id`. AISStream has only MMSIs, so its vessels are keyed by
|
||||
MMSI and nothing changes there (Sameh Khamis, @samehkhamis, #1662).
|
||||
- A share link made while a vessel is selected (and the camera follows
|
||||
nothing) now opens that vessel: the link names it, opens with the camera
|
||||
where it was, looking at the vessel, and selects the vessel once the feed
|
||||
has it, at the link's world time when it has one. A vessel id that is a
|
||||
source's stable id, such as a UUID, travels as its 16 bytes in a new thing
|
||||
field (13), so the link stays short (62 characters where a followed
|
||||
aircraft's is 44). MMSI and other ids, and links already shared, are
|
||||
unchanged; an app that predates the field opens such a link at the same
|
||||
camera, without selecting the vessel. A camera that no follow offset can
|
||||
carry (below the vessel's horizon, say) still makes a place view.
|
||||
A shared flight, military flight, vessel or satellite that is not reporting
|
||||
now says so in a sentence ("The shared vessel isn't reporting now — showing
|
||||
where it was at 14:05"), with its id on a second line under it (ICAO,
|
||||
NORAD, MMSI or vessel UUID) instead of at the head of the sentence (Sameh
|
||||
Khamis, @samehkhamis, #1662).
|
||||
@@ -0,0 +1,4 @@
|
||||
- A weather feed can supply decoded frames to the weather layers. With an
|
||||
optional `getFrameImage` or `getFrameTile`, Core draws the images and tiles
|
||||
the feed returns instead of reading the image and tile routes; a feed
|
||||
without them is unchanged (samehkhamis, #1665).
|
||||
@@ -0,0 +1,7 @@
|
||||
- The voice debug log now says why OpenAI Realtime did or did not get a
|
||||
viewport screenshot or pointer crop. When an entity-context answer reaches
|
||||
the image step, the log records a reason-coded decision and, when an image
|
||||
was due, one outcome under the same id: sent, or why it was dropped (a stale
|
||||
pointer, a camera move, a newer turn). What is captured, sent and kept is
|
||||
unchanged, and no image data or user text is logged (Kevin Rajan, @kvnloo,
|
||||
Manjunath, @manjunath22466, #835).
|
||||
@@ -188,6 +188,13 @@ state restoration, and release them after the consumers stop. The catalog
|
||||
constructor (`application/layers`) also requires `wind`, `weather` and
|
||||
`cyclones` sources with `getSnapshot` for the Weather layers, and owns the
|
||||
observed-history clock those layers share for the catalog's lifetime.
|
||||
A `weather` source may also supply decoded frames: an optional
|
||||
`getFrameImage({ product, time, size, bbox, signal })` returns a whole-extent or
|
||||
detail image and an optional `getFrameTile({ product, time, z, x, y, size,
|
||||
signal })` returns one tile or null for a transparent tile, each as an
|
||||
`ImageBitmap`, `ImageData`, canvas or `VideoFrame` of exactly the requested
|
||||
size, which the layer draws once and closes. Without them the layers read the
|
||||
image and tile routes (`WeatherFrameFeed` in `src/layers/weather/source.js`).
|
||||
|
||||
`application/services` accepts boundary, terrain, regional-context, weather and
|
||||
summary services; its weather service supplies cockpit local conditions, not the
|
||||
|
||||
@@ -634,8 +634,9 @@ source-time fallback and model-owned ground policy. The renderer supplies that
|
||||
ownership fact and handles stale-ground lifting, Cesium history and primitives;
|
||||
record reconciliation does not import the engine or application.
|
||||
|
||||
`layers/vessels/records` owns plain AIS metadata, stable MMSI identity and bounded
|
||||
retention. `layers/vessels/ingestion` owns source requests and feed state through
|
||||
`layers/vessels/records` owns plain AIS metadata, identity by the stable id the
|
||||
source gives (an MMSI only for a feed that has nothing else; the MMSI itself is
|
||||
an attribute) and bounded retention. `layers/vessels/ingestion` owns source requests and feed state through
|
||||
explicit operations. Both exports exclude Cesium, DOM and application assembly.
|
||||
The snapshot renderer applies record changes; rendering owns a weak map of
|
||||
geometry and billboard resources used by cards, picking and trails.
|
||||
|
||||
+45
-3
@@ -1145,7 +1145,7 @@ Source factories have dedicated `layers/<family>/source` exports. ALPR and earth
|
||||
|
||||
Voice controls bind to a protocol-independent session factory. The default WebRTC adapter preserves the existing Realtime connection, push-to-talk, cost controls and radio handoff. Session subscriptions expose state, transcript, action call/progress/result, interruption, completion and turn-metrics events; stopping or replacing a session cancels pending actions. An active session's normal Stop publishes one idle transition, including adapters that emit their own terminal state; a restart triggered by that transition keeps ownership of its new startup state. Alternate adapters can use the same controls and action runner.
|
||||
|
||||
The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||
The standalone app offers OpenAI and Gemini Live through the **AI Provider** setting, shared by voice and typed chat. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. With no Gemini key the token route answers `503` with code `API_KEY_MISSING`, and the voice control says `Add a Gemini API key in Provider Settings` without the microphone-permission hint. Voice status and error messages keep the message as given on the session's `state` event (`sourceDetail`, English catalog text when it is the app's own, with `sourceParams` for its placeholders), beside the worded `detail`, so the voice control words them again after a language change. Typed-chat errors and the chat panel are worded once. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter.
|
||||
|
||||
Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 33 tools using separate description-only metadata; a session is offered 32 of them unless `OVERPASS_UPSTREAMS` is set, which adds `osm_query` and its one prompt sentence (`server/providers/openai/tools.js`). Description customization cannot replace argument types, enum values or required fields.
|
||||
|
||||
@@ -1599,6 +1599,47 @@ messages; arbitrary HTTP response bodies are not surfaced as diagnostics.
|
||||
Sources receive cancellation signals and check them after body parsing. The
|
||||
layer's existing lifecycle and selection guards continue to reject late work.
|
||||
|
||||
### Following the world clock
|
||||
|
||||
A source may declare a timeline: it can answer a snapshot at a given time.
|
||||
`source.timeline` is `{ coverage(), stepMs, subscribe? }`, validated by
|
||||
`readSnapshotTimeline` (`src/sources/live/contract.js`): `coverage()` returns
|
||||
the span `{ start, end }` in epoch ms it can answer, or null while it does not
|
||||
know yet; `stepMs` is the spacing of its snapshots; the optional
|
||||
`subscribe(listener)` tells of a change in coverage. Flights, Military Flights
|
||||
and AIS Vessels follow the world clock only while their source declares one
|
||||
(`src/time/snapshotTimeline.js`); with none they never follow it, and every
|
||||
request, timing and display stays as it is live.
|
||||
|
||||
While such a layer is on it follows the clock and registers the source's
|
||||
coverage on the timeline. At the live edge it polls as it always has. Away
|
||||
from it the layer stops polling and asks for `getSnapshot({ ...query, atMs })`
|
||||
at the step at or before the clock's time; a request at the live edge carries
|
||||
no `atMs`. A seek, the time going backwards or leaving live is a jump (the
|
||||
clock's `getState().jumps` counts seeks and returns to live, not play): what
|
||||
loads is dropped, play holds while the snapshot loads, and the snapshot then
|
||||
stands in for every contact. A contact it lacks is gone at once; a tracked or
|
||||
selected contact it lacks is released, and one it has starts its trail afresh,
|
||||
whose track request carries `atMs` and draws only points at or before it.
|
||||
Playing, the layer stays a snapshot ahead of the time, and play does not pass a
|
||||
snapshot still loading for more than 8 seconds. Those snapshots apply as polls
|
||||
do, so contacts move on smoothly: aircraft are drawn at the clock's time
|
||||
rather than behind it, and vessels between two fixes move along the great
|
||||
circle, never past the newer one. Records, absences and the Layers panel's age
|
||||
are judged at the clock's time. Outside the coverage the layer shows no
|
||||
contacts and loads nothing; while the coverage is unknown it loads nothing and
|
||||
clears nothing. A snapshot that fails clears the time and is asked again after
|
||||
5 seconds; one the source drops for a newer request (an `AbortError`) is no
|
||||
failure, and nothing is cleared. A layer has one snapshot loading at a time:
|
||||
the older is aborted before a newer starts. Moving the view while vessels follow the clock asks for the new
|
||||
area at the same time. Back at live the contacts are cleared and the layer
|
||||
polls at once.
|
||||
|
||||
A snapshot at `atMs` describes the world as it stood then: `observedAtMs` is
|
||||
at or before `atMs`, no record is observed later than `atMs`, freshness and
|
||||
staleness are judged against `atMs`, `atMs` is echoed back, and a vessel
|
||||
snapshot reports `transportStatus: 'live'`.
|
||||
|
||||
## State and action outcomes
|
||||
|
||||
Share preferences, place lookups and Scene playback expose immutable snapshots
|
||||
@@ -4788,7 +4829,7 @@ test:track` 43 tracking invariants · headless QA harnesses under
|
||||
### Interface localization (October 2026)
|
||||
|
||||
Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **he**,
|
||||
**it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and
|
||||
**it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **sv**, **uk**, **zh-CN** and
|
||||
**zh-Hant**. The code lives in
|
||||
`src/i18n/`. How to mark UI text: [`docs/I18N.md`](./I18N.md); translator workflow:
|
||||
[`docs/TRANSLATORS.md`](./TRANSLATORS.md).
|
||||
@@ -6279,7 +6320,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
|
||||
`GEV MIC` button (bottom UI) starts an OpenAI Realtime session over WebRTC:
|
||||
|
||||
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token.
|
||||
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. With no OpenAI key the token route answers `503` with code `API_KEY_MISSING`, and the voice control shows `Needs OPENAI_API_KEY — add it in Provider Settings` in the interface language, reworded on a language change, without the microphone-permission hint.
|
||||
- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn.
|
||||
- **Thirty tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`.
|
||||
|
||||
@@ -6319,6 +6360,7 @@ and unreachable upstream (502/504) separately from road geometry.
|
||||
- **Reliability**: tool-call dedupe (2.5s window across call/item/args keys); response-create queueing that respects active responses and defers follow-ups when the user starts speaking; per-tool follow-up instructions so the agent confirms only what actually happened (zoom confirms only on `ok=true`).
|
||||
- **Aircraft identity honesty:** “What is this aircraft?” reads callsign, operator, registration, type, and route only from the selected contact context. Missing operator, route, or type enrichment is named explicitly rather than silently omitted or inferred from the callsign.
|
||||
- **Diagnostics**: every client/server event is posted to `/api/realtime/debug-log` and appended to `.gev-logs/realtime-conversations.jsonl` (gitignored) with secret/image redaction; last 30 errors persist in `localStorage` (`gev-realtime-errors`); `window.__gevVoiceCommands.getDiagnostics()` in the console.
|
||||
- **Visual-context escalation** (OpenAI Realtime): when a response's last tool result is a `get_entity_context` answer, a tool output from that response was sent and the data channel is open, it logs one `viewport_context.decision` with `contextId`, `send` and `reason`, plus `viewScale` and, when `send` is true, `kind` (`viewport` or `crop`). Decision reasons: `structured-identity-sufficient` (structured context made an image unnecessary), `view-scale-not-local`, `structured-identity-missing` (requests a viewport), `pointer-crop-eligible` (a pointer answer with no identity; requests a crop, and the crop checks below decide whether one is sent), `pointer-identity-present` and `pointer-missing`. A send decision is followed by exactly one outcome with the same `contextId` and `kind`: `viewport_context.result` (`sent`, `data-channel-send-failed`), logged as soon as the send returns, or `viewport_context.skipped`. Pointer-crop skips are `pointer-not-fresh` (no snapshot, or a stale one), `pointer-not-ground`, `pointer-off-screen`, `pointer-scale-not-useful` (zoomed out past `local` or `city`), `pointer-already-requested` (the most recently requested snapshot again: already sent, still being captured, or an earlier attempt for it threw), `pointer-expired`, `pointer-pose-unknown` and `pose-changed`; dropped captures are `capture-unavailable`, `session-reset`, `superseded` (a newer request or turn), `channel-lost` and `frame-stale`; a step that throws before an outcome closes the pair with `error`, and the caller then logs `viewport_context.failed`. Direct calls to the crop or image senders log their skips with a null `contextId`. Decision and outcome are separate records, so the debug-log rate limit can drop one half of a pair. Payloads are static codes only, never image data, tool payloads or user text, and other tool results log nothing here. Gemini Live uses a different gate in `geminiSession.js` (no pointer crop, a 1 s throttle), which is not instrumented.
|
||||
- **Counting semantics ("near")** — a CONTRACT; new count-bearing tool work inherits it. Three honest numbers exist for one question: the Contacts cohort (250 km around the subject, what the panel shows), `analyst_query`'s count of _currently-loaded_ records for the requested scope, and the layer-wide loaded total in `coverage.layersQueried[].total`. They diverge legitimately — the flights layer loads by viewport, so after a camera dive the loaded set can hold a fraction of the cohort (field case: panel 42, analyst 8). The contract:
|
||||
1. **Contacts ACTIVE** → "near / nearby / how many aircraft" means the **Contacts window** — the panel's numbers, spoken verbatim. Mechanism: `contactsWindow` (`{centeredOn, radiusKm, flights, military, vessels, complete}`), carried by both `analyst_query` and `get_current_view_state`, derived by `contactsWindowFromSnapshot()` from the same snapshot the panel renders so the two cannot drift. Voice filtering and follow-up memory use that immutable snapshot too. A cohort whose feed cannot answer reports `'unknown'`, never a confident zero; a cohort at the 20,000-row retention bound reports a lower bound.
|
||||
2. **Contacts OFF** → "nearby" means **in view**; "near \<place\>" means a radius around that place. A radius query with Contacts active and no explicit centre is centred on the **active contact**, not the camera.
|
||||
|
||||
+41
-14
@@ -28,7 +28,11 @@ Following an aircraft is 42–50: 42 when the link is made from the app's own
|
||||
follow camera with the fix taken as the link is made, 44 with a fix four
|
||||
seconds older, and 50 after orbiting to another heading and pitch. A view in
|
||||
any other style that keeps the style's own look adds only the style (two
|
||||
bytes): the 42-character followed aircraft in Noir is 44.
|
||||
bytes): the 42-character followed aircraft in Noir is 44. A selected vessel
|
||||
named by its MMSI costs what an aircraft does (46 characters for the
|
||||
44-character aircraft above); one named by a UUID costs 13 bytes more, as its
|
||||
16 bytes travel raw (62 characters where the aircraft is 44, rather than about
|
||||
88 with the UUID as text).
|
||||
|
||||
## Opening a short link
|
||||
|
||||
@@ -47,9 +51,22 @@ best effort: until the entity is found, and if it is not found in time, the
|
||||
camera is where the link was made, looking at the entity's last-known fix,
|
||||
and the app says that the entity isn't reporting and when the fix was taken.
|
||||
The share button makes a thing view while the camera follows an aircraft,
|
||||
military aircraft or satellite. A selected vessel is not followed (the camera
|
||||
stays where the user puts it), so a link made then is a place view; vessel
|
||||
thing views open, but the app does not make them yet.
|
||||
military aircraft or satellite. While the camera follows nothing and a vessel
|
||||
is selected, it makes a vessel thing view: the fix is the vessel's position
|
||||
as drawn (as of the world time while the vessel layer follows the world
|
||||
clock, else as of its latest position report), and the offset is the one whose derived camera is
|
||||
where the user's camera is (`followOffsetFor` in `src/share/follow.js`, the
|
||||
inverse of `followCameraFor`, taken from the fix at the format's precision).
|
||||
The opened camera is then within the offset's whole metres and degrees of
|
||||
where the link was made, looking at the vessel. When no offset can carry the
|
||||
camera, because it is not above the vessel's local horizontal (it would look
|
||||
up at the vessel, or through the earth) or its range is outside 1 m to
|
||||
10⁸ m, the link is a place view as before. A vessel is selected, not
|
||||
followed, so the camera stays where the user puts it. Following an entity
|
||||
takes priority over a selected vessel, and a followed entity the format
|
||||
cannot name still makes a place view. Opening a vessel thing view with a
|
||||
world time waits for the vessel layer's snapshot at that time and selects the
|
||||
vessel once it has it.
|
||||
|
||||
## Payload
|
||||
|
||||
@@ -274,14 +291,23 @@ sub-message:
|
||||
| 10 | fix alt | sint metres | 0 |
|
||||
| 11 | fix age | sint seconds the fix predates the created-at time | 0 |
|
||||
| 12 | observed-at | epoch, when the fix was taken; only without a created-at | none |
|
||||
| 13 | vessel id | UUID, 16 bytes (LEN) | |
|
||||
|
||||
The entity kind is the field that carries the id (its code in
|
||||
`SHARE_VIEW_ENTITY_KINDS`), so it costs nothing; exactly one of fields 1–4 is
|
||||
present (two is malformed). Entity ids are the ones the layers look up: an
|
||||
aircraft's ICAO 24-bit address (six hex digits, stored as a number; other
|
||||
forms such as TIS-B `~abc123` as text), a vessel's MMSI and a satellite's
|
||||
NORAD catalog number (decimal numbers; an MMSI with a leading zero stays
|
||||
text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`.
|
||||
`SHARE_VIEW_ENTITY_KINDS`, or field 13 for a vessel), so it costs nothing;
|
||||
exactly one of fields 1–4 and 13 is present (two is malformed). Entity ids are
|
||||
the ones the layers look up: an aircraft's ICAO 24-bit address (six hex
|
||||
digits, stored as a number; other forms such as TIS-B `~abc123` as text), a
|
||||
vessel's id (its MMSI, or a source's stable id, such as a UUID) and a
|
||||
satellite's NORAD catalog number (decimal numbers; an MMSI with a leading
|
||||
zero stays text). Ids are lowercase and match `[0-9a-z~_-]{1,16}`, except a
|
||||
vessel id that is a UUID (`xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, lowercase
|
||||
hex): it is written as its 16 bytes in field 13 rather than as 36 characters
|
||||
of text in field 3, after the other thing fields, and decodes as the
|
||||
lowercase dashed UUID. Field 13 of any other length is malformed. A decoder
|
||||
that predates field 13 skips it, finds no entity and opens the view as a
|
||||
place at its derived camera (`unknown-entity-kind`), as for a new entity kind,
|
||||
so the field needed no new version.
|
||||
|
||||
Fields 5–7 are the follow offset: the camera looks along (heading, pitch) at
|
||||
the entity from `range` metres away, heading clockwise from north, in the
|
||||
@@ -319,8 +345,9 @@ bytes, a check byte that does not match (`checksum`), an unknown version byte,
|
||||
a truncated value, a varint over 8 bytes or above 2⁵³ − 1, an
|
||||
overlong varint, a LEN longer than what remains (at any level, including
|
||||
inside the thing sub-message), a reserved wire type, field number 0, a known
|
||||
field with the wrong wire type or repeated, two entity ids in one thing, a
|
||||
duplicate panel/option/style entry, non-ASCII text, and a latitude or
|
||||
field with the wrong wire type or repeated, two entity ids in one thing
|
||||
(field 13 counts as one), a UUID vessel id that is not 16 bytes
|
||||
(`bad-length`), a duplicate panel/option/style entry, non-ASCII text, and a latitude or
|
||||
longitude (camera or fix) beyond ±90/±180.
|
||||
|
||||
Tolerated and reported in `unknownFields`: unknown fields at any level
|
||||
@@ -358,8 +385,8 @@ the app that can open it.
|
||||
boolean takes the next flag bit instead, which costs nothing when it is at
|
||||
its default.
|
||||
6. **New view kinds and entity kinds** get the next code. A new entity kind's
|
||||
id takes the next free thing field (13 onwards), so older apps skip it and
|
||||
fall back to the camera. A thing that needs more than an id (for example a
|
||||
id takes the next free thing field (14 onwards; 13 is a vessel id that is
|
||||
a UUID), so older apps skip it and fall back to the camera. A thing that needs more than an id (for example a
|
||||
CCTV camera, a radio station or a transit vehicle) adds further thing
|
||||
fields the same way.
|
||||
7. **A new layer option** needs an entry in `SHARE_VIEW_LAYER_OPTION_ABSENT`
|
||||
|
||||
+1
-1
@@ -270,7 +270,7 @@ and the development server's internal routes. See SECURITY.md.
|
||||
| `get_aircraft_info` | `aircraft` | Aircraft type and registration, and flight route, from adsbdb |
|
||||
| `vessels_in_area` | `vessels` | Ships reported by AIS in an area, nearest first, optionally by type; `group_by` and `stats` count and summarize every match |
|
||||
| `find_vessel` | `vessels` | Ships anywhere by MMSI, IMO number or name |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship, thinned to 200 points |
|
||||
| `get_vessel_track` | `vessels` | Recent positions of one ship by vessel id or MMSI, thinned to 200 points |
|
||||
| `next_satellite_pass` | `satellites` | Next pass over a place or point (default the ISS), with naked-eye visibility |
|
||||
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a place or point now, highest first |
|
||||
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Translating God's Eye View
|
||||
|
||||
Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Hebrew** (`he`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
|
||||
Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **Hebrew** (`he`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Swedish** (`sv`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`) and **Traditional Chinese** (`zh-Hant`).
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -64,4 +64,4 @@ These are contracts, not copy: layer ids and registry keys, status enums and oth
|
||||
|
||||
## Language notes
|
||||
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Hebrew](./translations/he.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
|
||||
Each language keeps its glossary, plural forms, units and style in its own file, `docs/translations/<code>.md`: [Arabic](./translations/ar.md), [German](./translations/de.md), [Spanish](./translations/es.md), [French](./translations/fr.md), [Hebrew](./translations/he.md), [Italian](./translations/it.md), [Japanese](./translations/ja.md), [Brazilian Portuguese](./translations/pt.md), [Russian](./translations/ru.md), [Swedish](./translations/sv.md), [Ukrainian](./translations/uk.md), [Simplified Chinese](./translations/zh-CN.md), [Traditional Chinese](./translations/zh-Hant.md). Read it before translating, and add a term there when you settle on one.
|
||||
@@ -0,0 +1,84 @@
|
||||
# Swedish (`sv`)
|
||||
|
||||
Catalog: `src/i18n/locales/sv.json`. General rules: [TRANSLATORS.md](../TRANSLATORS.md).
|
||||
|
||||
Translation source: [God's Eye View Sverige](https://github.com/Kajjan7610/Gods-view-sweden) by Kaj Obst, November 2026.
|
||||
|
||||
## Style
|
||||
|
||||
- Standard Swedish as in Swedish software and on Swedish authority sites (SMHI, Trafikverket, Polisen).
|
||||
- Actions and hints use the imperative without a pronoun: «Försök igen», «Klicka på en kamera för att aktivera den», «Välj en tagning först», «Tillåt mikrofonen och försök igen». Avoid «du»/«ni»; "your" becomes «egen» or is left out: «Egen RTL-SDR-mottagare», «Stationen ser din IP-adress» only where a person must be addressed.
|
||||
- Sentence case, not Title Case: "Live Flights" → «Liveflyg», "Provider Settings" → «Leverantörsinställningar». UPPERCASE English stays UPPERCASE: «LIVEDATA AV», NOT AN ALL-CLEAR → «INTE FARA ÖVER».
|
||||
- Compounds are written as one word, with a hyphen after a name, code or abbreviation: «datalager», «väderrutor», «TomTom-nyckel», «AIS-fartyg», «Street View-reserv». Never split a compound with a space.
|
||||
- Short labels, chips and HUD readouts stay short; use a standard abbreviation with a period over a wrapped line: RÄCKV., HAST., NEDERB., ÖVR., MEDD., FÖRST., KAL. «Jämför» is JFR, without a period.
|
||||
- The avionics codes HDG, BRG, CRS, DEST, SPD, ALT, GS, TRK, V/S, FOV and SQUAWK stay in English, as in the cockpit. Words in a sentence are translated: heading → «kurs», bearing → «bäring», range → «räckvidd».
|
||||
- A dash between clauses is a spaced en dash « – ». Quotes are ”…” (Swedish closing quotes on both sides).
|
||||
- Status and waiting messages: «Väntar på uppdatering», «Laddar …». Reasons after a dash stay lower case: «Scenen sparades inte – projektet är större än 5 MiB».
|
||||
- A layer named in a sentence takes its panel name in quotes: «Lagret ”Liveflyg” är inte aktiverat».
|
||||
- Rolling windows say their length: LAST YEAR → «SENASTE 12 MÅN.», LAST MONTH → «SENASTE 30 DAGARNA».
|
||||
- Keyboard keys: Mellanslag, Backsteg, Skift; Esc, Enter, Tab and letter keys stay.
|
||||
- Decimal comma in fixed text: «M4,4», «28,2710° N». Thousands separator is a space: «1 000 km». Formatted numbers come from `Intl` and are already Swedish.
|
||||
- Units: km, m, km/h, m/s, kn (knop), ft (fot), hPa, MW, h, min, s, d (dygn, in short readouts); in UPPERCASE text KM, KM/H, KN, FT, MIN, H, D. A space between number and unit, also before %: «{value} m», «{count} min», «50 %». `°` sticks to the number.
|
||||
- "ago": «för {count} min sedan», short «{count} min sedan». When a placeholder already holds a localized "ago" text (`{age}`, `{ago}`), do not add «sedan» again.
|
||||
- Compass points use O for öst and V for väst, as SMHI does: N, NNO, NO, ONO, O, OSO, SO, SSO, S, SSV, SV, VSV, V, VNV, NV, NNV; hemisphere letters N, S, O, V.
|
||||
- Routing: «Sväng {direction} in på {road}», «Fortsätt {direction}», «Ta {exit} avfarten» with ordinals «2:a», «3:e».
|
||||
|
||||
## Plurals
|
||||
|
||||
A `tn()` entry needs two forms. `Intl.PluralRules('sv')` picks:
|
||||
|
||||
| Form | Counts | Example |
|
||||
| ------- | --------------------------- | ----------- |
|
||||
| `one` | 1 | 1 kamera |
|
||||
| `other` | everything else: 0, 2, 1,5… | 3 kameror |
|
||||
|
||||
Abbreviated units and codes do not change: «{count} MEDD.».
|
||||
|
||||
## Glossary
|
||||
|
||||
| English | Svenska |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------- |
|
||||
| layer / data layer | lager / datalager |
|
||||
| feed / source / data source | flöde / källa / datakälla |
|
||||
| LIVE (status) / live data | LIVE / livedata |
|
||||
| SIMULATED | SIMULERAT |
|
||||
| stale / degraded / unavailable | inaktuell / begränsad / otillgänglig |
|
||||
| retry / retrying / retrying slowly | nytt försök / försöker igen / försöker igen med längre intervall |
|
||||
| fallback | reserv |
|
||||
| key (API key) / POWER UP / POWERED UP | nyckel / KOPPLA PÅ / PÅKOPPLAD |
|
||||
| Provider Settings | Leverantörsinställningar |
|
||||
| ON / OFF (state chips) | PÅ / AV |
|
||||
| enable / disable | aktivera / inaktivera |
|
||||
| globe / map / map source / basemap | glob / karta / kartkälla / bakgrundskarta |
|
||||
| aircraft / flight / military flights | luftfartyg (short labels: flygplan) / flygning / militärflyg |
|
||||
| vessel, ship | fartyg |
|
||||
| satellite / shell (Starlink) | satellit / skal |
|
||||
| launch (rocket) / launch site / space mission | uppskjutning / uppskjutningsplats / rymduppdrag |
|
||||
| contact / tracking | kontakt / följning |
|
||||
| installation / site | anläggning / plats |
|
||||
| camera / CCTV camera | kamera / CCTV-kamera |
|
||||
| earthquake / active fire / fire perimeter | jordbävning / aktiv brand / brandgräns |
|
||||
| cyclone / advisory (NHC) / cone | cyklon / bulletin / kon |
|
||||
| radio station / broadcast FM | radiostation / FM-radio |
|
||||
| road / traffic flow | väg / trafikflöde |
|
||||
| transit / bike share | kollektivtrafik / hyrcyklar |
|
||||
| scene / shot / capture shot / camera move | scen / tagning / spara tagning / kameraåkning |
|
||||
| anchor / data pack / bundle | ankare / datapaket / paket |
|
||||
| briefing / panel / dock | genomgång / panel / docka |
|
||||
| preset / visual preset | förval / visuellt förval |
|
||||
| Scope (circular viewport mask) / keyhole | Scope / nyckelhål |
|
||||
| overlay / detection overlay | överlägg / detekteringsöverlägg |
|
||||
| Clean UI | Rent gränssnitt |
|
||||
| Night vision / Thermal / Snow (styles) | Mörkerseende / Värmekamera / Snö |
|
||||
| heading / altitude / ground speed | kurs / höjd / markfart |
|
||||
| RANGE / PITCH / HGT (CCTV calibration chips) | RÄCKV. / LUTN. / HÖJD |
|
||||
| PREV / NEXT / RESUME | FÖRRA / NÄSTA / FORTSÄTT |
|
||||
| nearest (spatial) | närmaste |
|
||||
| expand / collapse | fäll ut / fäll ihop |
|
||||
| Street Level / Street View (Google) | Gatunivå / Street View |
|
||||
| Recent Imagery | Senaste bilder |
|
||||
| voice (control) / voice session | röststyrning / röstsession |
|
||||
| click / tap / press a key | klicka / tryck / tryck på tangenten |
|
||||
| card / label (on the map) / clear (layers) | kort / etikett / stäng av |
|
||||
| frame (weather, video) / image | bildruta / bild |
|
||||
| incident | händelse |
|
||||
@@ -679,6 +679,7 @@
|
||||
"src/voice/control.js",
|
||||
"src/voice/realtimeBackend.js",
|
||||
"src/voice/realtimeBackend.test.mjs",
|
||||
"src/voice/realtimeViewportPolicy.test.mjs",
|
||||
"src/agent/worldPacket.test.mjs",
|
||||
"src/voice/layerSummary.js",
|
||||
"src/app/sources.js",
|
||||
@@ -1307,6 +1308,7 @@
|
||||
"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",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"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",
|
||||
@@ -691,6 +692,7 @@
|
||||
"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",
|
||||
@@ -740,6 +742,7 @@
|
||||
"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"
|
||||
@@ -783,6 +786,7 @@
|
||||
"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",
|
||||
@@ -832,6 +836,7 @@
|
||||
"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"
|
||||
@@ -860,6 +865,7 @@
|
||||
"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",
|
||||
@@ -891,6 +897,7 @@
|
||||
"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",
|
||||
@@ -929,6 +936,7 @@
|
||||
"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",
|
||||
@@ -1000,6 +1008,7 @@
|
||||
"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",
|
||||
@@ -1028,6 +1037,7 @@
|
||||
"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",
|
||||
@@ -1233,6 +1243,7 @@
|
||||
"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",
|
||||
@@ -1432,6 +1443,7 @@
|
||||
"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",
|
||||
@@ -1505,6 +1517,9 @@
|
||||
"src/layers/flights/tracking.js",
|
||||
"src/data/trailContinuity.js",
|
||||
"src/layers/flights/records.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/flights/recordPolicy.js",
|
||||
"src/layers/flights/snapshotRenderer.js",
|
||||
"src/layers/flights/modelSpec.js",
|
||||
@@ -1521,6 +1536,7 @@
|
||||
"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",
|
||||
@@ -1560,6 +1576,9 @@
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
"src/layers/military/policy.js",
|
||||
@@ -1585,6 +1604,7 @@
|
||||
"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",
|
||||
@@ -1634,6 +1654,10 @@
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/time/worldClock.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
"src/layers/vessels/lifecycle.js",
|
||||
@@ -1664,6 +1688,7 @@
|
||||
"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"
|
||||
@@ -1702,6 +1727,7 @@
|
||||
"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",
|
||||
@@ -1744,6 +1770,7 @@
|
||||
"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"
|
||||
@@ -1785,6 +1812,7 @@
|
||||
"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",
|
||||
@@ -1834,6 +1862,7 @@
|
||||
"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",
|
||||
@@ -1887,6 +1916,7 @@
|
||||
"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",
|
||||
@@ -1935,6 +1965,7 @@
|
||||
"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",
|
||||
@@ -1999,6 +2030,7 @@
|
||||
"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"
|
||||
@@ -2037,6 +2069,7 @@
|
||||
"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"
|
||||
@@ -2095,6 +2128,7 @@
|
||||
"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"
|
||||
@@ -2167,6 +2201,7 @@
|
||||
"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",
|
||||
@@ -2209,6 +2244,7 @@
|
||||
"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"
|
||||
@@ -2260,6 +2296,7 @@
|
||||
"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"
|
||||
@@ -2291,6 +2328,7 @@
|
||||
"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"
|
||||
@@ -2356,6 +2394,7 @@
|
||||
"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"
|
||||
@@ -2435,6 +2474,7 @@
|
||||
"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",
|
||||
@@ -2782,6 +2822,8 @@
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
@@ -2823,6 +2865,7 @@
|
||||
"src/layers/launches/testing.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
@@ -2905,6 +2948,7 @@
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
@@ -3405,6 +3449,7 @@
|
||||
"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",
|
||||
@@ -3792,6 +3837,9 @@
|
||||
"src/layers/flights/enrichment.js",
|
||||
"src/layers/flights/evidence.js",
|
||||
"src/layers/flights/index.js",
|
||||
"src/layers/flights/clockFollow.js",
|
||||
"src/time/snapshotTimeline.js",
|
||||
"src/sources/live/contract.js",
|
||||
"src/layers/flights/ingestion.js",
|
||||
"src/layers/flights/lifecycle.js",
|
||||
"src/layers/flights/motion.js",
|
||||
@@ -3833,6 +3881,7 @@
|
||||
"src/layers/launches/testing.js",
|
||||
"src/layers/military/controller.js",
|
||||
"src/layers/military/index.js",
|
||||
"src/layers/military/clockFollow.js",
|
||||
"src/layers/military/ingestion.js",
|
||||
"src/layers/military/lifecycle.js",
|
||||
"src/layers/military/motion.js",
|
||||
@@ -3914,6 +3963,7 @@
|
||||
"src/layers/vessels/cards.js",
|
||||
"src/layers/vessels/evidence.js",
|
||||
"src/layers/vessels/index.js",
|
||||
"src/layers/vessels/motion.js",
|
||||
"src/layers/vessels/cameraArea.js",
|
||||
"src/layers/vessels/ingestion.js",
|
||||
"src/layers/vessels/viewArea.js",
|
||||
@@ -4166,6 +4216,7 @@
|
||||
"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",
|
||||
@@ -4352,6 +4403,7 @@
|
||||
"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",
|
||||
@@ -4386,6 +4438,7 @@
|
||||
"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",
|
||||
@@ -4441,6 +4494,7 @@
|
||||
"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",
|
||||
@@ -4480,6 +4534,7 @@
|
||||
"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",
|
||||
@@ -4587,6 +4642,7 @@
|
||||
"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",
|
||||
@@ -4665,6 +4721,7 @@
|
||||
"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",
|
||||
@@ -4960,6 +5017,7 @@
|
||||
"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",
|
||||
@@ -5016,6 +5074,7 @@
|
||||
"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",
|
||||
@@ -5080,6 +5139,7 @@
|
||||
"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"
|
||||
@@ -5105,6 +5165,7 @@
|
||||
"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",
|
||||
@@ -5176,6 +5237,7 @@
|
||||
"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",
|
||||
@@ -5237,6 +5299,7 @@
|
||||
"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"
|
||||
@@ -5445,6 +5508,7 @@
|
||||
"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",
|
||||
@@ -5492,6 +5556,7 @@
|
||||
"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",
|
||||
|
||||
@@ -63,7 +63,10 @@ export function createGeminiTokenHandler({
|
||||
return reply(403, 'Gemini voice requires a same-origin app request');
|
||||
apiKey = String(resolveApiKey() || '').trim();
|
||||
if (!apiKey)
|
||||
return reply(503, 'Add a Gemini API key in Provider Settings');
|
||||
return reply(503, {
|
||||
error: 'Add a Gemini API key in Provider Settings',
|
||||
code: 'API_KEY_MISSING',
|
||||
});
|
||||
model = normalizeGeminiModel(resolveModel());
|
||||
if (!model)
|
||||
return reply(503, 'The Gemini Live model configuration is invalid');
|
||||
|
||||
@@ -642,9 +642,16 @@ export function createKeyedNews({
|
||||
await firstAnswer(inFlight.get('gdelt'));
|
||||
if (!current()) return { failed: true };
|
||||
const dayAgo = new Date(now() - DAY_MS).toISOString();
|
||||
// Exports are read in parallel, so reports of one time arrive in any
|
||||
// order: the most mentioned comes first, then by id.
|
||||
const reports = [...gdelt.reports.values()]
|
||||
.filter((r) => r.publishedAt >= dayAgo)
|
||||
.sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.publishedAt.localeCompare(a.publishedAt) ||
|
||||
b.mentions - a.mentions ||
|
||||
a.id.localeCompare(b.id),
|
||||
)
|
||||
.slice(0, MAX_GDELT_REPORTS);
|
||||
if (reports.length !== gdelt.reports.size) {
|
||||
const kept = new Set(reports);
|
||||
|
||||
@@ -238,7 +238,12 @@ function createRealtimeTokenHandler({
|
||||
if (!bearer) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'OPENAI_API_KEY is not set' }));
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
error: 'OPENAI_API_KEY is not set',
|
||||
code: 'API_KEY_MISSING',
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import assert from 'node:assert/strict';
|
||||
import { mapAnalystRecord } from './aisLiveVessels.js';
|
||||
|
||||
const FULL_RECORD = {
|
||||
id: '353136000',
|
||||
mmsi: '353136000',
|
||||
name: 'EVER GIVEN',
|
||||
lat: 29.55,
|
||||
@@ -20,6 +21,7 @@ test('ais analyst record: full record maps every contract field', () => {
|
||||
const r = mapAnalystRecord(FULL_RECORD);
|
||||
assert.deepEqual(r, {
|
||||
id: 'EVER GIVEN',
|
||||
vesselId: '353136000',
|
||||
mmsi: '353136000',
|
||||
name: 'EVER GIVEN',
|
||||
lat: 29.55,
|
||||
@@ -38,6 +40,16 @@ test('ais analyst record: nameless vessel falls back to mmsi id', () => {
|
||||
assert.equal(r.name, null);
|
||||
});
|
||||
|
||||
test('ais analyst record: the key is the source id; the MMSI rides along when known', () => {
|
||||
const shared = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-a' });
|
||||
assert.equal(shared.vesselId, 'provider-a');
|
||||
assert.equal(shared.mmsi, '353136000');
|
||||
const noMmsi = mapAnalystRecord({ ...FULL_RECORD, id: 'provider-b', mmsi: '', name: '' });
|
||||
assert.equal(noMmsi.vesselId, 'provider-b');
|
||||
assert.equal(noMmsi.mmsi, null);
|
||||
assert.equal(noMmsi.id, 'provider-b', 'a nameless vessel with no MMSI is labelled by its id');
|
||||
});
|
||||
|
||||
test('ais analyst record: empty strings and NaN become null, never undefined', () => {
|
||||
const r = mapAnalystRecord({ mmsi: '', name: 'TUG', speed: NaN, type: '', destination: '' });
|
||||
assert.equal(r.mmsi, null);
|
||||
|
||||
@@ -287,7 +287,7 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
loaded: true,
|
||||
lastUpdate: 456,
|
||||
@@ -314,8 +314,8 @@ test('zero accepted rows preserve warm vessel selection, trail, and freshness ti
|
||||
lastMessageAt: 789,
|
||||
rawRowCount: 1,
|
||||
acceptedRowCount: 0,
|
||||
selectedMmsi: record.mmsi,
|
||||
trailMmsi: record.mmsi,
|
||||
selectedId: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositionCount: 1,
|
||||
sessionId: _getVesselFeedStateForTest().sessionId,
|
||||
firstConnectPhase: 'idle',
|
||||
@@ -565,7 +565,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
records: [replacement],
|
||||
selectedRecord: replacement,
|
||||
trail: replacementTrail,
|
||||
trailMmsi: replacement.mmsi,
|
||||
trailId: replacement.mmsi,
|
||||
trailPositions: [POS],
|
||||
loaded: true,
|
||||
lastUpdate: 200,
|
||||
@@ -581,7 +581,7 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
let feed = _getVesselFeedStateForTest();
|
||||
assert.equal(feed.loading, true, 'old finally must not clear replacement loading ownership');
|
||||
assert.equal(feed.lastUpdate, 200, 'old response must not advance replacement freshness');
|
||||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
||||
assert.equal(feed.selectedId, replacement.mmsi);
|
||||
|
||||
second.resolve(jsonResponse({ status: 'open', lastMessageAt: null, rows: [] }));
|
||||
await replacementLoad;
|
||||
@@ -590,8 +590,8 @@ test('superseded AIS response cannot mutate or finalize a replacement request',
|
||||
assert.equal(feed.lastUpdate, 200);
|
||||
assert.equal(feed.error, 'awaiting first AIS message…');
|
||||
assert.equal(feed.stale, true);
|
||||
assert.equal(feed.selectedMmsi, replacement.mmsi);
|
||||
assert.equal(feed.trailMmsi, replacement.mmsi);
|
||||
assert.equal(feed.selectedId, replacement.mmsi);
|
||||
assert.equal(feed.trailId, replacement.mmsi);
|
||||
assert.equal(replacementTrail.clearCalls, 0);
|
||||
} finally {
|
||||
globalThis.fetch = priorFetch;
|
||||
@@ -784,21 +784,21 @@ test('vessel focus wire restores a hidden sprite before releasing the active pas
|
||||
|
||||
test('vessel selection: empty-space click requests deselection', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: '353136000', gesture: 'click' }),
|
||||
reduceVesselSelection({ selectedId: '353136000', gesture: 'click' }),
|
||||
{ action: 'deselect' },
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel selection: no selection plus empty-space click is a no-op', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: null, pickedMmsi: null, gesture: 'click' }),
|
||||
reduceVesselSelection({ selectedId: null, pickedId: null, gesture: 'click' }),
|
||||
{ action: 'none' },
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel selection: Escape deselects only when this layer owns a selection', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({ selectedMmsi: 353136000, gesture: 'escape' }),
|
||||
reduceVesselSelection({ selectedId: 353136000, gesture: 'escape' }),
|
||||
{ action: 'deselect' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
@@ -810,16 +810,16 @@ test('vessel selection: Escape deselects only when this layer owns a selection',
|
||||
test('vessel selection: another vessel replaces selection; same vessel is a no-op', () => {
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({
|
||||
selectedMmsi: '353136000',
|
||||
pickedMmsi: '367123450',
|
||||
selectedId: '353136000',
|
||||
pickedId: '367123450',
|
||||
gesture: 'click',
|
||||
}),
|
||||
{ action: 'select' },
|
||||
);
|
||||
assert.deepEqual(
|
||||
reduceVesselSelection({
|
||||
selectedMmsi: '353136000',
|
||||
pickedMmsi: '353136000',
|
||||
selectedId: '353136000',
|
||||
pickedId: '353136000',
|
||||
gesture: 'click',
|
||||
}),
|
||||
{ action: 'none' },
|
||||
@@ -834,8 +834,9 @@ test('vessel selection: another vessel replaces selection; same vessel is a no-o
|
||||
|
||||
const POS = Cesium.Cartesian3.fromDegrees(4.05, 51.93, 3);
|
||||
|
||||
// Like the layer's records, a vessel with no id of its own is keyed by its MMSI.
|
||||
function makeRecord(overrides = {}) {
|
||||
return {
|
||||
const record = {
|
||||
name: 'EVER GIVEN',
|
||||
mmsi: '353136000',
|
||||
type: 'Container Ship',
|
||||
@@ -849,6 +850,8 @@ function makeRecord(overrides = {}) {
|
||||
billboard: null,
|
||||
...overrides,
|
||||
};
|
||||
if (!Object.hasOwn(overrides, 'id')) record.id = record.mmsi;
|
||||
return record;
|
||||
}
|
||||
|
||||
function makeTrailSpy() {
|
||||
@@ -948,7 +951,7 @@ function installWireHarness(picked, stateOverrides = {}) {
|
||||
records: [record],
|
||||
selectedRecord: record,
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
interactionHandlerFactory,
|
||||
interactionKeyTarget: keyTarget,
|
||||
@@ -983,7 +986,7 @@ test('vessel interaction wire: trail pick does not deselect', () => {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, harness.record.mmsi);
|
||||
assert.equal(harness.trail.clearCalls, 0);
|
||||
assert.equal(_getVesselStateForTest().trailMmsi, harness.record.mmsi);
|
||||
assert.equal(_getVesselStateForTest().trailId, harness.record.mmsi);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
@@ -1213,7 +1216,7 @@ test('vessel interaction lifecycle: disable detaches input and enable reinstalls
|
||||
selectedRecord: harness.record,
|
||||
enabled: false,
|
||||
trail: harness.trail,
|
||||
trailMmsi: harness.record.mmsi,
|
||||
trailId: harness.record.mmsi,
|
||||
trailPositions: [POS],
|
||||
interactionHandlerFactory: harness.interactionHandlerFactory,
|
||||
interactionKeyTarget: harness.keyTarget,
|
||||
@@ -1287,7 +1290,7 @@ test('vessel trail lifecycle: deselect clears the selected-vessel trail', () =>
|
||||
aisLiveVesselsLayer.clearSelection();
|
||||
assert.equal(harness.trail.clearCalls, 1);
|
||||
assert.deepEqual(_getVesselStateForTest(), {
|
||||
trailMmsi: null,
|
||||
trailId: null,
|
||||
trailPositionCount: 0,
|
||||
vesselCount: 1,
|
||||
});
|
||||
@@ -1302,14 +1305,14 @@ test('vessel trail lifecycle: reconciliation eviction clears an orphaned trail',
|
||||
_setVesselStateForTest({
|
||||
records: [record],
|
||||
trail,
|
||||
trailMmsi: record.mmsi,
|
||||
trailId: record.mmsi,
|
||||
trailPositions: [POS],
|
||||
});
|
||||
try {
|
||||
_reconcileVesselsForTest({}, []);
|
||||
assert.equal(trail.clearCalls, 1);
|
||||
assert.deepEqual(_getVesselStateForTest(), {
|
||||
trailMmsi: null,
|
||||
trailId: null,
|
||||
trailPositionCount: 0,
|
||||
vesselCount: 0,
|
||||
});
|
||||
@@ -1566,6 +1569,28 @@ test('vessel interaction wire: selecting a vessel by click requests a camera tra
|
||||
}
|
||||
});
|
||||
|
||||
test('vessel interaction wire: a vessel with an id and no MMSI is selected by click', () => {
|
||||
const idOnly = makeRecord({ id: 'provider-7', mmsi: '', name: 'NO MMSI' });
|
||||
const harness = installWireHarness(
|
||||
{ id: idOnly },
|
||||
{ records: [idOnly], selectedRecord: null, trailId: null },
|
||||
);
|
||||
const requests = [];
|
||||
harness.windowTarget.addEventListener(
|
||||
WORLD_FOCUS_REQUEST_EVENT,
|
||||
(event) => requests.push(event.detail),
|
||||
);
|
||||
try {
|
||||
harness.handler.click({ position: { x: 10, y: 20 } });
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.id, 'provider-7');
|
||||
assert.equal(aisLiveVesselsLayer.getSelectedInfo()?.mmsi, '');
|
||||
assert.equal(requests[0]?.id, 'provider-7');
|
||||
assert.match(harness.hud.readout.text, /MMSI: -- /);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Overlay-host recorder whose hitTest answers with one card id. The real host
|
||||
* only publishes rects for `interactive` entries, so a stub returning a hit is
|
||||
@@ -1689,11 +1714,27 @@ for (const card of ['live', 'stale', 'absent', 'covered']) {
|
||||
});
|
||||
}
|
||||
|
||||
test('vessel card policy: only MMSI-keyed cards publish a hit rect', () => {
|
||||
test('vessel card policy: only keyed cards publish a hit rect', () => {
|
||||
const keyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord()));
|
||||
assert.equal(keyed.interactive, true);
|
||||
const unkeyed = applyVesselOverlayPolicy(buildVesselCard(makeRecord({ mmsi: '' })));
|
||||
assert.equal(unkeyed.interactive, false, 'an unkeyed card has no record to select');
|
||||
const idOnly = buildVesselCard(makeRecord({ id: 'provider-7', mmsi: '' }));
|
||||
assert.equal(applyVesselOverlayPolicy(idOnly).interactive, true, 'a source id is a key');
|
||||
assert.equal(idOnly.id, 'vessel:provider-7');
|
||||
});
|
||||
|
||||
test('vessel cards are keyed by id and show the MMSI only when known', () => {
|
||||
const shared = buildSelectedVesselCard(makeRecord({ id: 'hull-b' }));
|
||||
assert.equal(shared.id, 'vessel:hull-b');
|
||||
assert.equal(shared.actionable, true);
|
||||
assert.match(shared.details.at(-1), /^MMSI 353136000 · /);
|
||||
const unknown = buildSelectedVesselCard(
|
||||
makeRecord({ id: 'provider-7', mmsi: '', name: 'provider-7' }),
|
||||
);
|
||||
assert.equal(unknown.actionable, true);
|
||||
assert.match(unknown.details.at(-1), /^MMSI -- · /);
|
||||
assert.equal(unknown.title, 'VESSEL', 'a name that is only the id is no name');
|
||||
});
|
||||
|
||||
test('vessel interaction wire: deselecting never moves the camera', () => {
|
||||
@@ -1740,7 +1781,7 @@ test('vessel selection passes the opaque source reference to optional history',
|
||||
getSnapshot: async () => ({ records: [] }),
|
||||
getTrack: async (reference, options) => { requests.push({ reference, options }); return { records: [] }; },
|
||||
});
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null, trailMmsi: null });
|
||||
const harness = installWireHarness(undefined, { selectedRecord: null, trailId: null });
|
||||
try {
|
||||
harness.record.reference = 'opaque:test-reference';
|
||||
assert.equal(aisLiveVesselsLayer.selectById(harness.record.mmsi), true);
|
||||
|
||||
@@ -276,7 +276,7 @@ test('a click on neither sprite nor card clears without moving the camera', () =
|
||||
const SIBLING_PICKS = [
|
||||
['flights', 'a1b2c3', { id: 'a1b2c3' }],
|
||||
['flights', 'a1b2c3', { primitive: { id: 'a1b2c3' } }],
|
||||
['ais-live-vessels', '353136000', { id: { mmsi: '353136000', name: 'EVER GIVEN' } }],
|
||||
['ais-live-vessels', '353136000', { id: { id: '353136000', mmsi: '353136000', name: 'EVER GIVEN' } }],
|
||||
['cctv', 'atx-cam-3', { id: { id: 'atx-cam-3', name: 'entity-like' } }],
|
||||
['satellites', '25544', { id: 25544 }],
|
||||
];
|
||||
|
||||
@@ -1807,7 +1807,7 @@ test('hasContact declines while a layer is disabled, whatever its maps still hol
|
||||
[
|
||||
'aisLiveVessels',
|
||||
readLayerSource(new URL('./aisLiveVessels.js', import.meta.url)),
|
||||
/hasContact\(\s*mmsi,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byMmsi\s*\|\|\s*state\.records\.byMmsi\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
||||
/hasContact\(\s*id,?\s*\)\s*\{\s*\n\s*if\s*\(\s*!state\.feed\.enabled\s*\|\|\s*!state\.records\.byId\s*\|\|\s*state\.records\.byId\.size\s*===\s*0,?\s*\)\s*return\s*null;/,
|
||||
],
|
||||
]) {
|
||||
assert.match(
|
||||
|
||||
@@ -61,9 +61,9 @@ export function formatAwarenessLabel(value) {
|
||||
value.callsign,
|
||||
value.name,
|
||||
value.label,
|
||||
value.mmsi,
|
||||
value.id,
|
||||
value.icao24,
|
||||
value.mmsi,
|
||||
]) {
|
||||
const label = formatAwarenessLabel(candidate);
|
||||
if (label !== '—') return label;
|
||||
@@ -154,7 +154,7 @@ export function getAwarenessNavigationTargets(
|
||||
? cohort.summary.nearest
|
||||
: [];
|
||||
for (const item of sourceItems) {
|
||||
const id = item?.icao24 || item?.mmsi || item?.id;
|
||||
const id = item?.icao24 || item?.id || item?.mmsi;
|
||||
if (!id) continue;
|
||||
const key = `${layerId}:${id}`;
|
||||
if (key === `${subject?.layerId}:${subject?.id}`) continue;
|
||||
|
||||
@@ -21,7 +21,7 @@ const _owners = new Map();
|
||||
*
|
||||
* Layers use heterogeneous pick ids: flights/military/bikeshare/CCTV use
|
||||
* strings, satellites use numeric NORAD catalog ids, live AIS vessels attach
|
||||
* the vessel record OBJECT (identity = its `mmsi`), and entity picks surface
|
||||
* the vessel record OBJECT (identity = its `id`), and entity picks surface
|
||||
* the Cesium Entity (identity = its string `id`). Everything is coerced to a
|
||||
* String so predicates match against one canonical form.
|
||||
*
|
||||
@@ -33,9 +33,7 @@ export function resolvePickId(picked) {
|
||||
const unwrap = (id) => {
|
||||
if (id === null || id === undefined) return undefined;
|
||||
if (typeof id === 'object') {
|
||||
// AIS vessel record (id object with .mmsi) or Cesium Entity (.id string)
|
||||
if (typeof id.mmsi === 'string' || typeof id.mmsi === 'number')
|
||||
return id.mmsi;
|
||||
// AIS vessel record or Cesium Entity: both are keyed by their `.id`.
|
||||
if (typeof id.id === 'string' || typeof id.id === 'number') return id.id;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -33,10 +33,12 @@ test('resolvePickId: numeric pick id (satellite NORAD numbers) → String', () =
|
||||
assert.equal(resolvePickId({ id: undefined, primitive: { id: 43013 } }), '43013');
|
||||
});
|
||||
|
||||
test('resolvePickId: AIS vessel record object → its mmsi', () => {
|
||||
const record = { mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
|
||||
test('resolvePickId: AIS vessel record object → its id, not its MMSI', () => {
|
||||
const record = { id: '367123450', mmsi: '367123450', name: 'EVER GIVEN', billboard: {} };
|
||||
assert.equal(resolvePickId({ id: record }), '367123450');
|
||||
assert.equal(resolvePickId({ primitive: { id: record } }), '367123450');
|
||||
const shared = { id: 'hull-b', mmsi: '367123450', name: 'EVER GIVEN' };
|
||||
assert.equal(resolvePickId({ id: shared }), 'hull-b');
|
||||
});
|
||||
|
||||
test('resolvePickId: Cesium-Entity-like object → its string id', () => {
|
||||
@@ -48,7 +50,7 @@ test('resolvePickId: unresolvable picks → null', () => {
|
||||
assert.equal(resolvePickId(null), null);
|
||||
assert.equal(resolvePickId(undefined), null);
|
||||
assert.equal(resolvePickId({}), null);
|
||||
assert.equal(resolvePickId({ id: {} }), null); // object with no mmsi/id
|
||||
assert.equal(resolvePickId({ id: {} }), null); // object with no id
|
||||
assert.equal(resolvePickId({ primitive: {} }), null);
|
||||
});
|
||||
|
||||
@@ -67,8 +69,8 @@ test('ownership: sibling layers recognize each other via String-coerced ids', ()
|
||||
try {
|
||||
// Flights asking about a satellite pick (raw numeric id → String upstream)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: 25544 })), true);
|
||||
// Flights asking about a vessel pick (record object → mmsi)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { mmsi: '367123450' } })), true);
|
||||
// Flights asking about a vessel pick (record object → id)
|
||||
assert.equal(isOwnedByOtherLayer('flights', resolvePickId({ id: { id: '367123450', mmsi: '367123450' } })), true);
|
||||
// A layer never owns its own pick via the sibling scan
|
||||
assert.equal(isOwnedByOtherLayer('satellites', '25544'), false);
|
||||
// Unknown ids belong to nobody
|
||||
@@ -147,7 +149,7 @@ test('touch contact pick expands only a surface miss, bounded to 8 hits in 14 CS
|
||||
test('touch contact pick returns the first registered sibling, never a caller-specific preference', (t) => {
|
||||
primaryPointer(t, true);
|
||||
contactOwners(t);
|
||||
for (const first of [{ id: 'military' }, { id: { mmsi: '123456789' } }]) {
|
||||
for (const first of [{ id: 'military' }, { id: { id: '123456789', mmsi: '123456789' } }]) {
|
||||
const scene = {
|
||||
pick: () => mapTile,
|
||||
drillPick: () => [mapTile, first, { id: 'civil' }],
|
||||
|
||||
@@ -64,8 +64,8 @@ function contextCarrierRecord(picked, dataManager) {
|
||||
function fromRecord(record) {
|
||||
const layerId = record.layerId || null;
|
||||
const id =
|
||||
layerId === 'ais-live-vessels' && record.properties?.mmsi
|
||||
? String(record.properties.mmsi)
|
||||
layerId === 'ais-live-vessels' && record.properties?.vesselId
|
||||
? String(record.properties.vesselId)
|
||||
: String(record.id);
|
||||
return {
|
||||
layerId,
|
||||
|
||||
@@ -155,7 +155,7 @@ export function applyVesselOverlayPolicy(
|
||||
edgeFade: 'keyhole',
|
||||
horizonCull: true,
|
||||
terrainOcclusion: false,
|
||||
// Only MMSI-keyed cards can resolve back to one actionable vessel.
|
||||
// Only keyed cards can resolve back to one actionable vessel.
|
||||
interactive: card?.actionable === true,
|
||||
};
|
||||
}
|
||||
@@ -251,7 +251,12 @@ test('mint locks canonical action schemas and server instructions with no perman
|
||||
|
||||
test('missing key and malformed model fail without an upstream request', async (t) => {
|
||||
const missing = await fixture(t, { resolveApiKey: () => '' });
|
||||
assert.equal((await missing.send()).status, 503);
|
||||
const missingResponse = await missing.send();
|
||||
assert.equal(missingResponse.status, 503);
|
||||
assert.deepEqual(await missingResponse.json(), {
|
||||
error: 'Add a Gemini API key in Provider Settings',
|
||||
code: 'API_KEY_MISSING',
|
||||
});
|
||||
assert.equal(missing.calls.length, 0);
|
||||
for (const model of [
|
||||
'',
|
||||
|
||||
@@ -150,7 +150,10 @@ test('the installed keyless HUD route stays successful after the voice quota is
|
||||
const firstToken = await invokeRoute(token);
|
||||
const secondToken = await invokeRoute(token);
|
||||
assert.equal(firstToken.statusCode, 503);
|
||||
assert.deepEqual(firstToken.body, { error: 'OPENAI_API_KEY is not set' });
|
||||
assert.deepEqual(firstToken.body, {
|
||||
error: 'OPENAI_API_KEY is not set',
|
||||
code: 'API_KEY_MISSING',
|
||||
});
|
||||
assert.equal(secondToken.statusCode, 429);
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import it from './locales/it.json' with { type: 'json' };
|
||||
import ja from './locales/ja.json' with { type: 'json' };
|
||||
import pt from './locales/pt.json' with { type: 'json' };
|
||||
import ru from './locales/ru.json' with { type: 'json' };
|
||||
import sv from './locales/sv.json' with { type: 'json' };
|
||||
import uk from './locales/uk.json' with { type: 'json' };
|
||||
import zhCN from './locales/zh-CN.json' with { type: 'json' };
|
||||
import zhHant from './locales/zh-Hant.json' with { type: 'json' };
|
||||
@@ -41,6 +42,7 @@ const CATALOGS = Object.freeze({
|
||||
ja,
|
||||
pt,
|
||||
ru,
|
||||
sv,
|
||||
uk,
|
||||
'zh-CN': zhCN,
|
||||
'zh-Hant': zhHant,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const LOCALES = Object.freeze([
|
||||
'ja',
|
||||
'pt',
|
||||
'ru',
|
||||
'sv',
|
||||
'uk',
|
||||
'zh-CN',
|
||||
'zh-Hant',
|
||||
@@ -36,6 +37,7 @@ export const LOCALE_NAMES = Object.freeze({
|
||||
ja: '日本語',
|
||||
pt: 'Português (Brasil)',
|
||||
ru: 'Русский',
|
||||
sv: 'Svenska',
|
||||
uk: 'Українська',
|
||||
'zh-CN': '中文(简体)',
|
||||
'zh-Hant': '中文(繁體)',
|
||||
|
||||
@@ -2676,8 +2676,6 @@
|
||||
"Shared flight is unavailable": "الرحلة المشتركة غير متاحة",
|
||||
"Shared military flight is unavailable": "الرحلة العسكرية المشتركة غير متاحة",
|
||||
"Shared satellite is unavailable": "القمر الصناعي المشترك غير متاح",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id}: لا بيانات واردة الآن — يُعرض آخر موضع معروف",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id}: لا بيانات واردة الآن — يُعرض الموضع عند {time}",
|
||||
"This share link can't be opened": "لا يمكن فتح رابط المشاركة هذا",
|
||||
"Shared vessel is unavailable": "السفينة المشتركة غير متاحة",
|
||||
"subject": "الهدف",
|
||||
@@ -3778,5 +3776,17 @@
|
||||
"Play speed": "سرعة التشغيل",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "مرّر أو اقرص للتكبير، واسحب للتنقل · انقر نقرًا مزدوجًا لرؤية الكل",
|
||||
"1 min/s": "دقيقة/ث",
|
||||
"Photo © {photographer}": "صورة © {photographer}"
|
||||
"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}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}"
|
||||
}
|
||||
@@ -2528,8 +2528,6 @@
|
||||
"Shared flight is unavailable": "Geteilter Flug ist nicht verfügbar",
|
||||
"Shared military flight is unavailable": "Geteilter Militärflug ist nicht verfügbar",
|
||||
"Shared satellite is unavailable": "Geteilter Satellit ist nicht verfügbar",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"This share link can't be opened": "Dieser Freigabelink kann nicht geöffnet werden",
|
||||
"Shared vessel is unavailable": "Geteiltes Schiff ist nicht verfügbar",
|
||||
"subject": "Objekt",
|
||||
@@ -3454,5 +3452,17 @@
|
||||
"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",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"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",
|
||||
"The shared military flight isn't reporting now — showing where it was at {time}": "Der geteilte Militärflug sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"The shared vessel isn't reporting now — showing where it was last seen": "Das geteilte Schiff sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared vessel isn't reporting now — showing where it was at {time}": "Das geteilte Schiff sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"The shared satellite isn't reporting now — showing where it was last seen": "Der geteilte Satellit sendet gerade nicht – letzte bekannte Position wird gezeigt",
|
||||
"The shared satellite isn't reporting now — showing where it was at {time}": "Der geteilte Satellit sendet gerade nicht – Position um {time} wird gezeigt",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "Schiffs-UUID: {id}"
|
||||
}
|
||||
@@ -427,8 +427,6 @@
|
||||
"Link copied!": "¡Enlace copiado!",
|
||||
"Link shared": "Enlace compartido",
|
||||
"This share link can't be opened": "Este enlace compartido no se puede abrir",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"Shared vessel is unavailable": "El buque compartido no está disponible",
|
||||
"Copy failed": "No se pudo copiar",
|
||||
"Location not found": "Ubicación no encontrada",
|
||||
@@ -3535,5 +3533,17 @@
|
||||
"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",
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "El vuelo compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "El vuelo compartido no está transmitiendo ahora; se muestra dónde estaba a las {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "El vuelo militar compartido no está transmitiendo ahora; se muestra dónde se vio por última vez",
|
||||
"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 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 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}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID del buque: {id}"
|
||||
}
|
||||
@@ -2287,8 +2287,6 @@
|
||||
"Shared {subject} follow expired": "Suivi partagé expiré : {subject}",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Suivi partagé non restauré : {subject} — flux indisponible",
|
||||
"Shared {subject} is unavailable": "Partage indisponible : {subject}",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"This share link can't be opened": "Ce lien de partage ne peut pas être ouvert",
|
||||
"Shared vessel is unavailable": "Le navire partagé est indisponible",
|
||||
"ACQUIRING": "ACQUISITION",
|
||||
@@ -3535,5 +3533,17 @@
|
||||
"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",
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Photo © {photographer}"
|
||||
"Photo © {photographer}": "Photo © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Le vol partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Le vol partagé n'émet pas actuellement — affichage de sa position à {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Le vol militaire partagé n'émet pas actuellement — affichage de sa dernière position connue",
|
||||
"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 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}"
|
||||
}
|
||||
@@ -2119,8 +2119,6 @@
|
||||
"Shared {subject} follow expired": "עקיבת {subject} המשותף פגה",
|
||||
"Shared {subject} could not be restored — feed unavailable": "לא ניתן לשחזר את {subject} המשותף — הפיד אינו זמין",
|
||||
"Shared {subject} is unavailable": "{subject} המשותף אינו זמין",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} לא מדווח עכשיו — מציג היכן נראה לאחרונה",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} לא מדווח עכשיו — מציג היכן היה ב-{time}",
|
||||
"This share link can't be opened": "לא ניתן לפתוח את קישור השיתוף הזה",
|
||||
"Shared vessel is unavailable": "האונייה המשותפת אינה זמינה",
|
||||
"ACQUIRING": "מאתר",
|
||||
@@ -3535,5 +3533,17 @@
|
||||
"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; ספינות טרופות בולטות מכל העולם מגיעות מוויקינתונים. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו."
|
||||
"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; ספינות טרופות בולטות מכל העולם מגיעות מוויקינתונים. הצבע מראה מתי הספינה טבעה; הסמן גדל ככל שמספר ההרוגים עולה. לחץ על סמן כדי לפתוח את הכרטיס שלו.",
|
||||
"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}"
|
||||
}
|
||||
@@ -2287,8 +2287,6 @@
|
||||
"Shared {subject} follow expired": "Il tracciamento condiviso di {subject} è scaduto",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Impossibile ripristinare la condivisione di {subject}: feed non disponibile",
|
||||
"Shared {subject} is unavailable": "La condivisione di {subject} non è disponibile",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} non sta trasmettendo ora: viene mostrata l'ultima posizione nota ({time})",
|
||||
"This share link can't be opened": "Impossibile aprire questo link di condivisione",
|
||||
"Shared vessel is unavailable": "La nave condivisa non è disponibile",
|
||||
"ACQUIRING": "ACQUISIZIONE",
|
||||
@@ -3535,5 +3533,17 @@
|
||||
"Play speed": "Velocità di riproduzione",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scorri o pizzica per lo zoom, trascina per spostarti · doppio clic per vedere tutto",
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "Il volo condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "Il volo condiviso non sta trasmettendo ora: viene mostrata la posizione alle {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "Il volo militare condiviso non sta trasmettendo ora: viene mostrata l'ultima posizione nota",
|
||||
"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 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 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}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID della nave: {id}"
|
||||
}
|
||||
@@ -2209,8 +2209,6 @@
|
||||
"Shared {subject} follow expired": "共有された{subject}の追従が期限切れになりました",
|
||||
"Shared {subject} could not be restored — feed unavailable": "共有された{subject}を復元できませんでした:フィードを利用できません",
|
||||
"Shared {subject} is unavailable": "共有された{subject}は利用できません",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id}は現在報告していません。最後に確認された位置を表示しています",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id}は現在報告していません。{time}時点の位置を表示しています",
|
||||
"This share link can't be opened": "この共有リンクは開けません",
|
||||
"Shared vessel is unavailable": "共有された船舶は利用できません",
|
||||
"ACQUIRING": "取得中",
|
||||
@@ -3373,5 +3371,17 @@
|
||||
"Play speed": "再生速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "スクロールまたはピンチでズーム、ドラッグで移動 · ダブルクリックで全体表示",
|
||||
"1 min/s": "1分/秒",
|
||||
"Photo © {photographer}": "写真 © {photographer}"
|
||||
"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}時点の位置を表示しています",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "船舶UUID: {id}"
|
||||
}
|
||||
@@ -2287,8 +2287,6 @@
|
||||
"Shared {subject} follow expired": "O acompanhamento compartilhado de {subject} expirou",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Não foi possível restaurar o compartilhamento de {subject} — feed indisponível",
|
||||
"Shared {subject} is unavailable": "O compartilhamento de {subject} está indisponível",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} não está transmitindo agora — mostrando onde foi visto pela última vez",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} não está transmitindo agora — mostrando onde estava em {time}",
|
||||
"This share link can't be opened": "Não é possível abrir este link de compartilhamento",
|
||||
"Shared vessel is unavailable": "A embarcação compartilhada está indisponível",
|
||||
"ACQUIRING": "ADQUIRINDO",
|
||||
@@ -3535,5 +3533,17 @@
|
||||
"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",
|
||||
"1 min/s": "1 min/s",
|
||||
"Photo © {photographer}": "Foto © {photographer}"
|
||||
"Photo © {photographer}": "Foto © {photographer}",
|
||||
"The shared flight isn't reporting now — showing where it was last seen": "O voo compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"The shared flight isn't reporting now — showing where it was at {time}": "O voo compartilhado não está transmitindo agora — mostrando onde estava às {time}",
|
||||
"The shared military flight isn't reporting now — showing where it was last seen": "O voo militar compartilhado não está transmitindo agora — mostrando a última posição conhecida",
|
||||
"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 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 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}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID da embarcação: {id}"
|
||||
}
|
||||
@@ -425,8 +425,6 @@
|
||||
"Link copied!": "Ссылка скопирована!",
|
||||
"Link shared": "Ссылка отправлена",
|
||||
"This share link can't be opened": "Не удаётся открыть эту ссылку",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} сейчас не передаёт данные — показано последнее известное место ({time})",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} сейчас не передаёт данные — показано последнее известное место",
|
||||
"Shared vessel is unavailable": "Судно по ссылке недоступно",
|
||||
"Copy failed": "Не удалось скопировать",
|
||||
"VISUAL PRESETS": "ПРЕСЕТЫ",
|
||||
@@ -3616,5 +3614,17 @@
|
||||
"Play speed": "Скорость воспроизведения",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутите или сведите пальцы для масштаба, перетащите для перемещения · двойной щелчок — показать всё",
|
||||
"1 min/s": "1 мин/с",
|
||||
"Photo © {photographer}": "Фото © {photographer}"
|
||||
"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}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID судна: {id}"
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2325,8 +2325,6 @@
|
||||
"Shared {subject} follow expired": "Посилання на стеження застаріло: {subject}",
|
||||
"Shared {subject} could not be restored — feed unavailable": "Не вдалося відновити за посиланням: {subject} — потік даних недоступний",
|
||||
"Shared {subject} is unavailable": "Недоступно за посиланням: {subject}",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} зараз не передає даних — показано останнє відоме місце",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} зараз не передає даних — показано останнє відоме місце ({time})",
|
||||
"This share link can't be opened": "Не вдається відкрити це посилання",
|
||||
"Shared vessel is unavailable": "Судно за посиланням недоступне",
|
||||
"ACQUIRING": "ОТРИМАННЯ",
|
||||
@@ -3616,5 +3614,17 @@
|
||||
"Play speed": "Швидкість відтворення",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "Прокрутіть або зведіть пальці для масштабу, перетягніть для переміщення · подвійне клацання — показати все",
|
||||
"1 min/s": "1 хв/с",
|
||||
"Photo © {photographer}": "Фото © {photographer}"
|
||||
"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}",
|
||||
"ICAO: {id}": "ICAO: {id}",
|
||||
"NORAD: {id}": "NORAD: {id}",
|
||||
"MMSI: {id}": "MMSI: {id}",
|
||||
"Vessel UUID: {id}": "UUID судна: {id}"
|
||||
}
|
||||
@@ -1605,8 +1605,6 @@
|
||||
"Link copied!": "链接已复制!",
|
||||
"Link shared": "链接已分享",
|
||||
"This share link can't be opened": "无法打开此分享链接",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前没有发送信号——显示其在 {time} 的位置",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} 目前没有发送信号——显示其最后出现的位置",
|
||||
"Shared vessel is unavailable": "分享的船只不可用",
|
||||
"Copy failed": "复制失败",
|
||||
"Return map to straight-down view": "将地图恢复为垂直俯视",
|
||||
@@ -3373,5 +3371,17 @@
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "滚动或双指缩放,拖动以移动 · 双击查看全部",
|
||||
"1 min/s": "1 分钟/秒",
|
||||
"Photo © {photographer}": "照片 © {photographer}"
|
||||
"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} 的位置",
|
||||
"ICAO: {id}": "ICAO:{id}",
|
||||
"NORAD: {id}": "NORAD:{id}",
|
||||
"MMSI: {id}": "MMSI:{id}",
|
||||
"Vessel UUID: {id}": "船舶 UUID:{id}"
|
||||
}
|
||||
@@ -2983,8 +2983,6 @@
|
||||
"Take the exit {direction}": "從出口{direction}駛出",
|
||||
"Link shared": "鏈接已分享",
|
||||
"This share link can't be opened": "無法打開此分享鏈接",
|
||||
"{id} isn't reporting now — showing where it was at {time}": "{id} 目前沒有發送信號——顯示其在 {time} 的位置",
|
||||
"{id} isn't reporting now — showing where it was last seen": "{id} 目前沒有發送信號——顯示其最後出現的位置",
|
||||
"Shared vessel is unavailable": "分享的船隻不可用",
|
||||
"Check the spelling and press Enter to search again.": "請檢查拼寫,然後按 Enter 重新搜索。",
|
||||
"Press Enter to try again.": "按 Enter 重試。",
|
||||
@@ -3373,5 +3371,17 @@
|
||||
"Play speed": "播放速度",
|
||||
"Scroll or pinch to zoom, drag to move along · double-click to see it all": "捲動或雙指縮放,拖曳以移動 · 按兩下查看全部",
|
||||
"1 min/s": "1 分鐘/秒",
|
||||
"Photo © {photographer}": "照片 © {photographer}"
|
||||
"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} 的位置",
|
||||
"ICAO: {id}": "ICAO:{id}",
|
||||
"NORAD: {id}": "NORAD:{id}",
|
||||
"MMSI: {id}": "MMSI:{id}",
|
||||
"Vessel UUID: {id}": "船舶 UUID:{id}"
|
||||
}
|
||||
@@ -44,7 +44,10 @@
|
||||
"{direction} {angle}°",
|
||||
"{direction} · {elevation}",
|
||||
"{source} HTTP {status}",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"de": [
|
||||
"1090 MHz",
|
||||
@@ -238,7 +241,10 @@
|
||||
"ΔN",
|
||||
"Ⅱ PAUSE",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"es": [
|
||||
"1 min/s",
|
||||
@@ -395,7 +401,10 @@
|
||||
"Reptiles",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
"iNaturalist",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"fr": [
|
||||
"1 min/s",
|
||||
@@ -810,7 +819,10 @@
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist",
|
||||
"SATELLITE"
|
||||
"SATELLITE",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"ja": [
|
||||
"1090 MHz",
|
||||
@@ -873,7 +885,10 @@
|
||||
"ΔE",
|
||||
"ΔN",
|
||||
"≥ {value} MW",
|
||||
"▲ {frp} MW"
|
||||
"▲ {frp} MW",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"pt": [
|
||||
"1 min/s",
|
||||
@@ -1031,7 +1046,10 @@
|
||||
"CC BY 4.0",
|
||||
"acartia.io ↗",
|
||||
"iNaturalist ↗",
|
||||
"iNaturalist"
|
||||
"iNaturalist",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"ru": [
|
||||
"1900–1945",
|
||||
@@ -1064,7 +1082,170 @@
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}",
|
||||
"{direction} {angle}°",
|
||||
"{division}, {country}"
|
||||
"{division}, {country}",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"sv": [
|
||||
"STATION",
|
||||
"NAV",
|
||||
"GEO",
|
||||
"SAR",
|
||||
"CRT",
|
||||
"NVG",
|
||||
"FLIR",
|
||||
"REC",
|
||||
"AIS: --",
|
||||
"HDG {value}°",
|
||||
"BRG {value}°",
|
||||
"BRG —",
|
||||
"CRS {value}°",
|
||||
"Station {id}",
|
||||
"Station",
|
||||
"FOV {value}°",
|
||||
"▲ {frp} MW",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"STATUS · ",
|
||||
"DESTINATION",
|
||||
"STATUS",
|
||||
"T minus {seconds}",
|
||||
"Ambient",
|
||||
"Blues",
|
||||
"Country",
|
||||
"Dance",
|
||||
"Folk",
|
||||
"Funk",
|
||||
"House",
|
||||
"Indie",
|
||||
"Jazz",
|
||||
"Latin",
|
||||
"Metal",
|
||||
"Oldies",
|
||||
"Pop",
|
||||
"Punk",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"Soul",
|
||||
"Techno",
|
||||
"Trance",
|
||||
"AIS: {name}",
|
||||
"{type} SPD: {speed} HDG: {heading}",
|
||||
"POS: LIVE",
|
||||
"POS: {time}Z",
|
||||
"Bloom",
|
||||
"DEST {bearing}",
|
||||
"Normal",
|
||||
"Anime",
|
||||
"Noir",
|
||||
"CCTV",
|
||||
"Radio",
|
||||
"DEST ---°",
|
||||
"TEMP",
|
||||
"RADIO",
|
||||
"COCKPIT",
|
||||
"Layout",
|
||||
"Minimal",
|
||||
"Scope",
|
||||
"{speed} kn",
|
||||
"NORMAL",
|
||||
"ANIME",
|
||||
"NOIR",
|
||||
"view band|GLOBAL",
|
||||
"1090 MHz",
|
||||
"978 MHz UAT",
|
||||
"POSITION {age}",
|
||||
"fire behavior|Minimal",
|
||||
"{county} County",
|
||||
"Sentinel-2",
|
||||
"Sentinel-2 via HLS",
|
||||
"Landsat 8/9",
|
||||
"Landsat 8/9 via HLS",
|
||||
"Google Street View",
|
||||
"GOOGLE STREET VIEW",
|
||||
"Hybrid",
|
||||
"RADAR · {units}",
|
||||
"REGION",
|
||||
"FILTER",
|
||||
"{source} HTTP {status}",
|
||||
"WHOT/BHOT",
|
||||
"Ironbow",
|
||||
"HDG",
|
||||
"FOV",
|
||||
"ΔN",
|
||||
"LIVE",
|
||||
"OK",
|
||||
"{value} KM/H",
|
||||
"SQUAWK {code} · {status}",
|
||||
"S2 GhostMaps",
|
||||
"NASA FIRMS · LIVE",
|
||||
"feed status|live",
|
||||
"DSP {count} blocks",
|
||||
"Austin",
|
||||
"San Francisco",
|
||||
"New York",
|
||||
"Tokyo",
|
||||
"London",
|
||||
"Paris",
|
||||
"Dubai",
|
||||
"Washington DC",
|
||||
"Tallinn",
|
||||
"Los Angeles",
|
||||
"km/h",
|
||||
"mph",
|
||||
"hPa",
|
||||
"ft",
|
||||
"km",
|
||||
"min",
|
||||
"dB",
|
||||
"STD",
|
||||
"Plan",
|
||||
"MINI",
|
||||
"standard",
|
||||
"mini",
|
||||
"STANDARD",
|
||||
"FT",
|
||||
"MIL",
|
||||
"MM",
|
||||
"SIG",
|
||||
"AUTO",
|
||||
"FM MHZ",
|
||||
"Cyber",
|
||||
"Sonar",
|
||||
"Cyan",
|
||||
"≥ {value} MW",
|
||||
"Be Whale Wise",
|
||||
"Salish Sea",
|
||||
"Acartia",
|
||||
"CC BY 4.0",
|
||||
"iNaturalist",
|
||||
"STARLINK",
|
||||
"STARLINK ···",
|
||||
"STARLINK ✕",
|
||||
"{range} NM",
|
||||
"OFF-NADIR {angle}°",
|
||||
"OFF-NADIR {angle}° · {offset}",
|
||||
"HOME NADIR",
|
||||
"ZOOM {zoom}×",
|
||||
"NORAD {id}",
|
||||
"TLE",
|
||||
"NADIR",
|
||||
"SENSOR",
|
||||
"1 min/s",
|
||||
"Live",
|
||||
"EXPORT",
|
||||
"via {source}",
|
||||
"Video",
|
||||
"Iran–Hormuz",
|
||||
"Sudan",
|
||||
"Sahel",
|
||||
"Protest",
|
||||
"{minutes} min",
|
||||
"Period",
|
||||
"ICAO: {id}",
|
||||
"NORAD: {id}",
|
||||
"MMSI: {id}"
|
||||
],
|
||||
"uk": [
|
||||
"1900–1945",
|
||||
@@ -1092,7 +1273,10 @@
|
||||
"iNaturalist ↗",
|
||||
"{direction} · {elevation}",
|
||||
"{direction} {angle}°",
|
||||
"{division}, {country}"
|
||||
"{division}, {country}",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
],
|
||||
"zh-CN": [
|
||||
"1090 MHz",
|
||||
@@ -1331,6 +1515,9 @@
|
||||
"TLE",
|
||||
"{value} KM",
|
||||
"{value} KM/S",
|
||||
"Ironbow"
|
||||
"Ironbow",
|
||||
"ICAO: {id}",
|
||||
"MMSI: {id}",
|
||||
"NORAD: {id}"
|
||||
]
|
||||
}
|
||||
@@ -150,7 +150,7 @@ export function createHistory({ state: layerState, services, parts, source }) {
|
||||
return (
|
||||
items.find(
|
||||
(item) =>
|
||||
String(item?.icao24 || item?.mmsi || item?.id) === String(subject.id),
|
||||
String(item?.icao24 || item?.id || item?.mmsi) === String(subject.id),
|
||||
) || subject
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export function createPanel({ state: layerState, parts }) {
|
||||
.slice(page, page + AWARENESS_PAGE_SIZE)
|
||||
.map((item, index) => {
|
||||
const label = formatAwarenessLabel(item);
|
||||
const targetId = item.icao24 || item.mmsi || item.id;
|
||||
const targetId = item.icao24 || item.id || item.mmsi;
|
||||
const distance = presentAwarenessDistance(item.distanceM);
|
||||
if (!targetId)
|
||||
return {
|
||||
|
||||
@@ -85,7 +85,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
.getNearby(position, AWARENESS_RADIUS_M, AWARENESS_QUERY_LIMIT)
|
||||
.filter(
|
||||
(item) =>
|
||||
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'mmsi'),
|
||||
!parts.queries.isSame(subject, item, 'ais-live-vessels', 'id'),
|
||||
);
|
||||
const coverage = installationsState.stats?.coverage;
|
||||
const installationRadius =
|
||||
@@ -519,7 +519,7 @@ export function createSubject({ state: layerState, services, parts, source }) {
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
return {
|
||||
layerId: record.layerId,
|
||||
id: record.properties?.mmsi || record.id,
|
||||
id: record.properties?.vesselId || record.id,
|
||||
label: record.label || record.id,
|
||||
memberNames: record.properties?.memberNames || [],
|
||||
position: Cesium.Cartesian3.fromDegrees(longitude, latitude, 0),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* What the layer drops when the world clock moves it to another time (its
|
||||
* source declares a timeline; see src/time/snapshotTimeline.js).
|
||||
*/
|
||||
export function createClockFollow({ flightState, parts }) {
|
||||
/**
|
||||
* Forget how every contact moved, before the snapshot at another time takes
|
||||
* the place of every contact: their fixes, courses, floors and the tracked
|
||||
* trail belong to the time the clock left.
|
||||
*/
|
||||
function resetMotion() {
|
||||
flightState.records.reset();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
flightState._displayFloorState.clear();
|
||||
flightState._drReconcileValid = false;
|
||||
flightState._cachedDRFrame = -1;
|
||||
flightState._cachedDRPosition = null;
|
||||
parts.tracking._clearTrail();
|
||||
flightState._lastCamPoseSig = '';
|
||||
}
|
||||
|
||||
/** Drop every contact: the time has none to show, or the clock went live. */
|
||||
function clear() {
|
||||
if (flightState._trackedIcao)
|
||||
parts.tracking._clearTracking(false, { evicted: true });
|
||||
resetMotion();
|
||||
for (const [icao24, billboard] of flightState._billboards) {
|
||||
flightState._billboardCollection?.remove(billboard);
|
||||
parts.rendering._releaseModel(icao24);
|
||||
}
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._detectionObjects.clear();
|
||||
flightState.feed._count = 0;
|
||||
flightState.feed._lastUpdate = null;
|
||||
}
|
||||
|
||||
return { resetMotion, clear };
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { FlightRecords } from './records.js';
|
||||
import { createIngestion } from './ingestion.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { displayDelayMs, followsClock, RENDER_DELAY_SEC } from './policy.js';
|
||||
|
||||
// Flights follow the world clock only while their source declares a timeline
|
||||
// (src/time/snapshotTimeline.js); these cover the layer's side of it.
|
||||
|
||||
const T = 1_800_000_000_000;
|
||||
const following = { timeline: { active: () => true } };
|
||||
const live = { timeline: { active: () => false } };
|
||||
|
||||
function records(now = () => T) {
|
||||
return new FlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
now,
|
||||
});
|
||||
}
|
||||
const view = () => ({
|
||||
viewerLatDeg: null,
|
||||
viewerLonDeg: null,
|
||||
trackedId: null,
|
||||
floorWarmPoints: [],
|
||||
});
|
||||
const observation = (fields = {}) => ({
|
||||
id: 'abc123',
|
||||
reference: 'abc123',
|
||||
longitude: -77,
|
||||
latitude: 39,
|
||||
callsign: 'TEST1',
|
||||
baroAltitudeM: 1000,
|
||||
onGround: false,
|
||||
speedMps: 120,
|
||||
courseDeg: 90,
|
||||
positionTimeMs: T - 5_000,
|
||||
...fields,
|
||||
});
|
||||
|
||||
test('contacts are drawn behind the live edge, and at the time following the clock', () => {
|
||||
assert.equal(displayDelayMs({ timeline: null }), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs({}), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs(live), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs(following), 0);
|
||||
assert.equal(followsClock({}), false);
|
||||
assert.equal(followsClock(following), true);
|
||||
});
|
||||
|
||||
test('civil records judge absence and receipt at the layer clock', () => {
|
||||
let now = T;
|
||||
const store = records(() => now);
|
||||
const { meta } = store.receive(observation({ positionTimeMs: null }), view());
|
||||
assert.equal(meta.observedReceiptMs, T);
|
||||
assert.equal(meta.newestFixMs, T, 'an undated fix is the clock time');
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, tracked: true }),
|
||||
'stale',
|
||||
);
|
||||
assert.equal(
|
||||
store.absence('abc123', {
|
||||
complete: true,
|
||||
tracked: true,
|
||||
nowMs: T + 11 * 60_000,
|
||||
}),
|
||||
'remove',
|
||||
'a given time judges retention',
|
||||
);
|
||||
store.receive(observation(), view());
|
||||
now = T + 11 * 60_000;
|
||||
assert.equal(
|
||||
store.absence('abc123', { complete: true, tracked: true }),
|
||||
'remove',
|
||||
'the clock judges retention when no time is given',
|
||||
);
|
||||
});
|
||||
|
||||
test('a records reset forgets where airframes were and keeps what they are', () => {
|
||||
const store = records();
|
||||
const first = store.receive(observation(), view());
|
||||
first.meta.typeCode = 'B738';
|
||||
first.meta.registration = 'N123';
|
||||
first.meta.photo = { src: 'photo.jpg' };
|
||||
store.absence('abc123', { complete: true });
|
||||
store.beginSnapshot(T);
|
||||
store.reset();
|
||||
assert.equal(store.data.size, 0);
|
||||
assert.equal(store.missingPolls.size, 0);
|
||||
assert.equal(store.lastSnapshotMs, null);
|
||||
const again = store.receive(
|
||||
observation({ positionTimeMs: T - 600_000, callsign: 'OTHER' }),
|
||||
view(),
|
||||
);
|
||||
assert.equal(again.prevMeta, undefined, 'no motion carries across');
|
||||
assert.equal(again.groundFlipped, false);
|
||||
assert.equal(again.meta.typeCode, 'B738');
|
||||
assert.equal(again.meta.registration, 'N123');
|
||||
assert.equal(again.meta.photo.src, 'photo.jpg');
|
||||
assert.equal(again.meta.newestFixMs, T - 600_000, 'an earlier fix applies');
|
||||
store.forget('abc123');
|
||||
assert.equal(store.airframes.size, 0);
|
||||
});
|
||||
|
||||
function ingestion(source, { follows = false } = {}) {
|
||||
const feed = {
|
||||
_source: source,
|
||||
_activeUpdateControllers: new Set(),
|
||||
_trackingRefreshEpoch: 0,
|
||||
_lastSource: 'Fixture',
|
||||
_retryAt: 0,
|
||||
};
|
||||
const applied = [];
|
||||
const state = { follows };
|
||||
const { methods } = createIngestion({
|
||||
feed,
|
||||
getQuery: () => ({ latitude: 39 }),
|
||||
applySnapshot: (snapshot, _viewer, options) => {
|
||||
applied.push({ snapshot, options });
|
||||
return { count: 1, ids: new Set() };
|
||||
},
|
||||
setSourceLabel: () => {},
|
||||
applyPendingTrackingRestore: () => {},
|
||||
followsClock: () => state.follows,
|
||||
});
|
||||
return { feed, applied, state, methods };
|
||||
}
|
||||
|
||||
test('civil polls carry no time; a snapshot at a time carries it and replaces', async () => {
|
||||
const queries = [];
|
||||
const probe = ingestion({
|
||||
async getSnapshot(query) {
|
||||
queries.push(query);
|
||||
return { records: [], observedAtMs: T - 60_000, freshness: 'current' };
|
||||
},
|
||||
});
|
||||
await probe.methods.update(null);
|
||||
assert.deepEqual(queries[0], { latitude: 39 });
|
||||
assert.equal(probe.applied[0].options, undefined);
|
||||
assert.equal(await probe.methods.loadAt(T - 60_000, { replace: true }), true);
|
||||
assert.deepEqual(queries[1], { latitude: 39, atMs: T - 60_000 });
|
||||
assert.deepEqual(probe.applied[1].options, { replace: true });
|
||||
assert.equal(probe.feed._lastUpdate, T - 60_000);
|
||||
});
|
||||
|
||||
test('following the clock, civil polls stop and a poll already out is dropped', async () => {
|
||||
let release;
|
||||
const probe = ingestion({
|
||||
getSnapshot: () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
});
|
||||
const out = probe.methods.update(null);
|
||||
probe.state.follows = true;
|
||||
release({ records: [], observedAtMs: T, freshness: 'current' });
|
||||
await out;
|
||||
assert.equal(probe.applied.length, 0);
|
||||
await probe.methods.update(null);
|
||||
assert.equal(probe.applied.length, 0, 'no poll while following');
|
||||
});
|
||||
|
||||
test('a failed snapshot at a time reports it without holding the live backoff', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw Object.assign(new Error('nope'), { retryAfterMs: 60_000 });
|
||||
},
|
||||
});
|
||||
assert.equal(await probe.methods.loadAt(T), false);
|
||||
assert.equal(probe.feed._retryAt, 0);
|
||||
assert.equal(probe.feed._lastError, 'Live data unavailable');
|
||||
});
|
||||
|
||||
test('a jump forgets how contacts moved; clearing drops every contact', () => {
|
||||
const calls = [];
|
||||
const flightState = {
|
||||
records: records(),
|
||||
_positionHistory: new Map([['abc123', []]]),
|
||||
_displayCourse: new Map([['abc123', 90]]),
|
||||
_groundSnap: { clear: () => calls.push('ground') },
|
||||
_displayFloorState: new Map([['abc123', {}]]),
|
||||
_drReconcileValid: true,
|
||||
_cachedDRFrame: 7,
|
||||
_billboards: new Map([['abc123', { id: 'abc123' }]]),
|
||||
_billboardCollection: { remove: () => calls.push('remove') },
|
||||
_cullPositions: new Map([['abc123', null]]),
|
||||
_detectionObjects: new Map([['abc123', {}]]),
|
||||
_trackedIcao: 'abc123',
|
||||
feed: { _count: 1, _lastUpdate: T },
|
||||
};
|
||||
const parts = {
|
||||
tracking: {
|
||||
_clearTrail: () => calls.push('trail'),
|
||||
_clearTracking: (_skip, { evicted }) => {
|
||||
calls.push(`untrack:${evicted}`);
|
||||
flightState._trackedIcao = null;
|
||||
},
|
||||
},
|
||||
rendering: { _releaseModel: (id) => calls.push(`model:${id}`) },
|
||||
};
|
||||
const follow = createClockFollow({ flightState, parts });
|
||||
flightState.records.receive(observation(), view());
|
||||
follow.resetMotion();
|
||||
assert.equal(flightState.records.data.size, 0);
|
||||
assert.equal(flightState._positionHistory.size, 0);
|
||||
assert.equal(flightState._displayCourse.size, 0);
|
||||
assert.equal(flightState._displayFloorState.size, 0);
|
||||
assert.equal(flightState._drReconcileValid, false);
|
||||
assert.equal(flightState._billboards.size, 1, 'contacts stay until replaced');
|
||||
follow.clear();
|
||||
assert.equal(flightState._billboards.size, 0);
|
||||
assert.equal(flightState._detectionObjects.size, 0);
|
||||
assert.equal(flightState.feed._count, 0);
|
||||
assert.equal(flightState.feed._lastUpdate, null);
|
||||
assert.ok(calls.includes('untrack:true'));
|
||||
assert.ok(calls.includes('model:abc123'));
|
||||
});
|
||||
|
||||
test('a snapshot at a time the source drops for a newer one sets no error', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw new DOMException('Superseded', 'AbortError');
|
||||
},
|
||||
});
|
||||
await assert.rejects(probe.methods.loadAt(T), { name: 'AbortError' });
|
||||
assert.equal(probe.feed._lastError, undefined);
|
||||
assert.equal(probe.feed._backoff, undefined);
|
||||
assert.equal(probe.feed._retryAt, 0);
|
||||
});
|
||||
@@ -10,6 +10,9 @@ import { createLifecycle } from './lifecycle.js';
|
||||
import { createEvidence } from './evidence.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
/** Compose one civil-flight layer with application-owned scene services. */
|
||||
export function createCivilFlightLayer({
|
||||
source,
|
||||
@@ -29,6 +32,7 @@ export function createCivilFlightLayer({
|
||||
parts.evidence = createEvidence(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.clockFollow = createClockFollow(context);
|
||||
const applySnapshot = createFlightSnapshotRenderer({
|
||||
flightState,
|
||||
records: flightState.records,
|
||||
@@ -45,7 +49,11 @@ export function createCivilFlightLayer({
|
||||
feed: flightState.feed,
|
||||
getQuery: (viewer) =>
|
||||
parts.controller._flightQuery(viewer || flightState._viewer),
|
||||
applySnapshot,
|
||||
applySnapshot: (snapshot, viewer, options) => {
|
||||
if (options?.replace) parts.clockFollow.resetMotion();
|
||||
return applySnapshot(snapshot, viewer, options);
|
||||
},
|
||||
followsClock: () => flightState.timeline.active(),
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
@@ -53,6 +61,19 @@ export function createCivilFlightLayer({
|
||||
parts.tracking._applyPendingTrackingRestore(),
|
||||
});
|
||||
|
||||
// Contacts follow the world clock while the source declares a timeline.
|
||||
flightState.timeline = createSnapshotTimeline({
|
||||
id: 'flights',
|
||||
clock: worldClock,
|
||||
source: () => flightState.feed._source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
parts.ingestion.methods.loadAt(atMs, { signal, replace }),
|
||||
clear: () => parts.clockFollow.clear(),
|
||||
resumeLive: () => {
|
||||
layer.update(flightState._viewer).catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
layer,
|
||||
parts.queries.methods,
|
||||
|
||||
+101
-67
@@ -11,85 +11,119 @@ export function createIngestion({
|
||||
applySnapshot,
|
||||
setSourceLabel,
|
||||
applyPendingTrackingRestore,
|
||||
followsClock = () => false,
|
||||
}) {
|
||||
const methods = {
|
||||
async update(viewer, { signal = null } = {}) {
|
||||
const nowMs = Date.now();
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
// Following the world clock, the layer shows the snapshots loadAt
|
||||
// applies; polling the live feed would put the live edge among them.
|
||||
if (followsClock()) return;
|
||||
await acquire(viewer, { signal });
|
||||
},
|
||||
|
||||
/**
|
||||
* Apply the source's snapshot at world time `atMs` (its timeline says it
|
||||
* can answer one), in place of every contact when `replace`. Resolves
|
||||
* whether it was applied.
|
||||
* @param {number} atMs
|
||||
* @param {{signal?: AbortSignal|null, replace?: boolean, viewer?: object}} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async loadAt(atMs, { signal = null, replace = false, viewer = null } = {}) {
|
||||
// Whatever polls or loads for another time no longer applies.
|
||||
if (replace)
|
||||
for (const controller of feed._activeUpdateControllers)
|
||||
controller.abort();
|
||||
return acquire(viewer, { signal, atMs, replace });
|
||||
},
|
||||
};
|
||||
|
||||
async function acquire(viewer, { signal = null, atMs, replace = false }) {
|
||||
const nowMs = Date.now();
|
||||
const timed = atMs !== undefined;
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
if (!timed && feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const query = timed ? { ...getQuery(viewer), atMs } : getQuery(viewer);
|
||||
const snapshot = await feed._source.getSnapshot(query, {
|
||||
signal: updateSignal,
|
||||
});
|
||||
updateSignal.throwIfAborted();
|
||||
// The clock left the live edge while this poll was out.
|
||||
if (!timed && followsClock()) return;
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
const sourceEpochMs = snapshot.observedAtMs;
|
||||
const sourceAgeMs = snapshot.ageMs;
|
||||
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._backoff = sourceStale;
|
||||
feed._retryAt = 0;
|
||||
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
|
||||
feed._lastError = sourceStale
|
||||
? sourceAgeMs == null
|
||||
? msg('Source snapshot time unavailable')
|
||||
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
||||
: null;
|
||||
feed._lastSource = snapshot.source;
|
||||
feed._lastCoverage = snapshot.coverage;
|
||||
setSourceLabel(feed._lastSource);
|
||||
const accepted = timed
|
||||
? applySnapshot(snapshot, viewer, { replace })
|
||||
: applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
// Freshness belongs to the source snapshot, not the moment this browser
|
||||
// received a cached 200 response.
|
||||
feed._lastUpdate = sourceEpochMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
if (feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
console.log(`[Data:Flights] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Flights update aborted', 'AbortError');
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await feed._source.getSnapshot(getQuery(viewer), {
|
||||
signal: updateSignal,
|
||||
});
|
||||
updateSignal.throwIfAborted();
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
const sourceEpochMs = snapshot.observedAtMs;
|
||||
const sourceAgeMs = snapshot.ageMs;
|
||||
const sourceStale = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._backoff = sourceStale;
|
||||
feed._retryAt = 0;
|
||||
// i18n-ignore: English data; the panel shows it through FEED_ERROR_PATTERNS (ui/statusText.js)
|
||||
feed._lastError = sourceStale
|
||||
? sourceAgeMs == null
|
||||
? msg('Source snapshot time unavailable')
|
||||
: `Source snapshot ${Math.max(2, Math.round(sourceAgeMs / 60_000))} min old`
|
||||
: null;
|
||||
feed._lastSource = snapshot.source;
|
||||
feed._lastCoverage = snapshot.coverage;
|
||||
setSourceLabel(feed._lastSource);
|
||||
const accepted = applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
// Freshness belongs to the source snapshot, not the moment this browser
|
||||
// received a cached 200 response.
|
||||
feed._lastUpdate = sourceEpochMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
coverage: feed._lastCoverage,
|
||||
};
|
||||
console.log(`[Data:Flights] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Flights update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Flights] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
console.warn('[Data:Flights] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
// A snapshot at a time is asked again by the clock's follower.
|
||||
if (!timed)
|
||||
feed._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
},
|
||||
};
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
return false;
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
}
|
||||
|
||||
return { methods };
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
restoreSpriteOrderOnEnable('flights', viewer);
|
||||
flightState.timeline?.start();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -198,6 +199,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
disable(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
parts.controller._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._billboardCollection)
|
||||
@@ -240,6 +242,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer
|
||||
*/
|
||||
destroy(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
flightState.lifetime.abort();
|
||||
flightState._enrichActive = 0;
|
||||
flightState._enrichLastDispatchMs = 0;
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import {
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
RENDER_DELAY_SEC,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
TRACKED_BILLBOARD_SCALE_BY_DISTANCE,
|
||||
@@ -39,6 +38,8 @@ import {
|
||||
DISPLAY_CORRIDOR_LOOKAHEAD_SEC,
|
||||
DISPLAY_CORRIDOR_CELL_BUDGET,
|
||||
DISPLAY_CORRIDOR_FAIR_SHARE,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
|
||||
/** Half a coarse floor cell (0.001°), less a margin so rounding keeps a
|
||||
@@ -178,7 +179,7 @@ export function createMotion({
|
||||
sampledRenderTime ??
|
||||
Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now(), flightState._scratchRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
|
||||
@@ -320,7 +321,7 @@ export function createMotion({
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
const elapsedSec =
|
||||
(flightState.now() - RENDER_DELAY_SEC * 1000 - newestMs) / 1000;
|
||||
(flightState.now() - displayDelayMs(flightState) - newestMs) / 1000;
|
||||
if (!(elapsedSec > 0)) return null;
|
||||
const { coastSec, holds } = _coastPlan(
|
||||
newest,
|
||||
@@ -415,7 +416,7 @@ export function createMotion({
|
||||
if (!history || history.length === 0) return true;
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
return Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||
@@ -499,9 +500,15 @@ export function createMotion({
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
// How far the contact may move between frames goes by the world's time,
|
||||
// which plays faster than the wall clock when following it at a rate.
|
||||
const worldMs = followsClock(flightState) ? flightState.now() : nowMs;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (sameTrack) {
|
||||
const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000);
|
||||
const dtSec = Math.max(
|
||||
0.001,
|
||||
(worldMs - flightState._drPrevWorldMs) / 1000,
|
||||
);
|
||||
const speed = (info && info.velocity) || 0;
|
||||
// Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base.
|
||||
const plausible = speed * dtSec * 4 + 25;
|
||||
@@ -549,6 +556,7 @@ export function createMotion({
|
||||
Cesium.Cartesian3.clone(raw, flightState._drPrevRaw);
|
||||
Cesium.Cartesian3.clone(display, flightState._drPrevDisplay);
|
||||
flightState._drPrevMs = nowMs;
|
||||
flightState._drPrevWorldMs = worldMs;
|
||||
flightState._drReconcileValid = true;
|
||||
flightState._cachedDRPosition = display;
|
||||
const focusSizePx = _trackedFocusSizePx(
|
||||
|
||||
@@ -147,6 +147,15 @@ export const TRAIL_MAX_POINTS = 2000;
|
||||
|
||||
export const RENDER_DELAY_SEC = 30;
|
||||
|
||||
/** Whether the layer shows the world clock's time instead of the live feed:
|
||||
* its source declares a timeline and the clock is away from live. */
|
||||
export const followsClock = (state) => state.timeline?.active() === true;
|
||||
|
||||
/** How far behind the time contacts are drawn: RENDER_DELAY_SEC live, none
|
||||
* following the clock, whose snapshots are loaded ahead of the time. */
|
||||
export const displayDelayMs = (state) =>
|
||||
followsClock(state) ? 0 : RENDER_DELAY_SEC * 1000;
|
||||
|
||||
/** @constant {number} Polls an aircraft may miss before removal (transient OpenSky dropouts). */
|
||||
|
||||
// --- Landed-plane fast cull (owner field report 2026-07-02: "phantom" planes
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
LANDED_SPEED_MAX_MPS,
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
|
||||
@@ -986,6 +987,8 @@ export function createQueries({
|
||||
retryInSec,
|
||||
source: flightState.feed._lastSource,
|
||||
coverage: flightState.feed._lastCoverage,
|
||||
// Following the world clock, the snapshot's age is told from its time.
|
||||
...(followsClock(flightState) && { atMs: flightState.now() }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -16,13 +16,22 @@ import {
|
||||
|
||||
/** Source-independent aircraft metadata and bounded missing-poll retention. */
|
||||
export class FlightRecords {
|
||||
constructor({ geoidHeight, cachedGroundFloor, floorAltitudeM }) {
|
||||
constructor({
|
||||
geoidHeight,
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
now = () => Date.now(),
|
||||
}) {
|
||||
this.services = {
|
||||
geoidHeight,
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
};
|
||||
/** The time records are judged at: the layer's clock. */
|
||||
this.now = now;
|
||||
this.data = new Map();
|
||||
// Facts about an airframe, not a moment, kept across a reset.
|
||||
this.airframes = new Map();
|
||||
this.missingPolls = new Map();
|
||||
// Contacts whose last poll re-sent a fix no newer than the one before.
|
||||
this.fixRepeated = new Set();
|
||||
@@ -90,6 +99,9 @@ export class FlightRecords {
|
||||
// regressing to the ICAO hex / a 0° (north) heading. Bounded by the
|
||||
// MISSING_POLL_LIMIT eviction below, which deletes the whole entry.
|
||||
const prevMeta = this.data.get(icao24);
|
||||
// The airframe's facts: this record's, or those kept across a reset.
|
||||
const airframe = prevMeta ?? this.airframes.get(icao24);
|
||||
this.airframes.delete(icao24);
|
||||
// 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
|
||||
@@ -244,16 +256,16 @@ export class FlightRecords {
|
||||
// A route (and its airline) belongs to the callsign it was looked up for;
|
||||
// a new callsign drops it, so an old flight's route never shows under it.
|
||||
const routeHeld =
|
||||
Boolean(prevMeta?.routeCallsign) &&
|
||||
prevMeta.routeCallsign === nextCallsign.toUpperCase();
|
||||
Boolean(airframe?.routeCallsign) &&
|
||||
airframe.routeCallsign === nextCallsign.toUpperCase();
|
||||
const fixEpochMs =
|
||||
Number.isFinite(observation.positionTimeMs) &&
|
||||
observation.positionTimeMs > 0
|
||||
? observation.positionTimeMs
|
||||
: Date.now();
|
||||
: this.now();
|
||||
const meta = {
|
||||
sourceReference: observation.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
observedReceiptMs: this.now(),
|
||||
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
|
||||
callsign: nextCallsign,
|
||||
altitude: alt,
|
||||
@@ -273,7 +285,7 @@ export class FlightRecords {
|
||||
category: cat,
|
||||
// An adsbdb-enriched type code outranks the coarse OpenSky category.
|
||||
klass: classifyAircraft({
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
typeCode: airframe?.typeCode ?? null,
|
||||
category: cat,
|
||||
}),
|
||||
turnRateDps: prevMeta?.turnRateDps || 0,
|
||||
@@ -308,14 +320,14 @@ export class FlightRecords {
|
||||
null,
|
||||
),
|
||||
// adsbdb enrichment — written by the enrichment callbacks, carried across polls:
|
||||
typeCode: prevMeta?.typeCode ?? null,
|
||||
typeName: prevMeta?.typeName ?? null,
|
||||
registration: prevMeta?.registration ?? null,
|
||||
airline: routeHeld ? (prevMeta.airline ?? null) : null,
|
||||
route: routeHeld ? (prevMeta.route ?? null) : null,
|
||||
routeCallsign: routeHeld ? prevMeta.routeCallsign : null,
|
||||
typeCode: airframe?.typeCode ?? null,
|
||||
typeName: airframe?.typeName ?? null,
|
||||
registration: airframe?.registration ?? null,
|
||||
airline: routeHeld ? (airframe.airline ?? null) : null,
|
||||
route: routeHeld ? (airframe.route ?? null) : null,
|
||||
routeCallsign: routeHeld ? airframe.routeCallsign : null,
|
||||
// Planespotters photo of the tracked airframe (Core asks it directly).
|
||||
photo: prevMeta?.photo ?? null,
|
||||
photo: airframe?.photo ?? null,
|
||||
// The RAW poll fix lat/lon (this tick's OpenSky state-vector
|
||||
// coords, pre-dead-reckon) — kept distinct from the continuously
|
||||
// dead-reckoned billboard position for any consumer that needs the
|
||||
@@ -328,7 +340,10 @@ export class FlightRecords {
|
||||
return { icao24, prevMeta, meta, groundFlipped, fixEpochMs };
|
||||
}
|
||||
|
||||
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
|
||||
absence(
|
||||
id,
|
||||
{ complete, retainIds, likelyLanded, tracked = false, nowMs = this.now() },
|
||||
) {
|
||||
const misses = (this.missingPolls.get(id) || 0) + 1;
|
||||
// The tracked contact has missed an update: it is stale from the first
|
||||
// one, and kept (so the camera keeps its target) until ten minutes after
|
||||
@@ -337,7 +352,7 @@ export class FlightRecords {
|
||||
const meta = this.data.get(id);
|
||||
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
|
||||
if (tracked && Number.isFinite(fixMs)) {
|
||||
if (Date.now() - fixMs < TRACKED_RETENTION_MS) {
|
||||
if (nowMs - fixMs < TRACKED_RETENTION_MS) {
|
||||
this.missingPolls.set(id, misses);
|
||||
return 'stale';
|
||||
}
|
||||
@@ -349,7 +364,7 @@ export class FlightRecords {
|
||||
// still takes the landed limit.
|
||||
if (
|
||||
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
nowMs - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
|
||||
@@ -361,8 +376,33 @@ export class FlightRecords {
|
||||
return 'remove';
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget every observation, as when the world clock jumps to another time:
|
||||
* what an airframe is (its type, registration, route and photo) is kept for
|
||||
* when it is seen again; where it was and when is not.
|
||||
*/
|
||||
reset() {
|
||||
for (const [id, meta] of this.data) {
|
||||
this.airframes.set(id, {
|
||||
typeCode: meta.typeCode,
|
||||
typeName: meta.typeName,
|
||||
registration: meta.registration,
|
||||
airline: meta.airline,
|
||||
route: meta.route,
|
||||
routeCallsign: meta.routeCallsign,
|
||||
photo: meta.photo,
|
||||
});
|
||||
}
|
||||
this.data.clear();
|
||||
this.missingPolls.clear();
|
||||
this.fixRepeated.clear();
|
||||
this.lastSnapshotMs = null;
|
||||
this.snapshotAdvanced = true;
|
||||
}
|
||||
|
||||
forget(id) {
|
||||
this.data.delete(id);
|
||||
this.airframes.delete(id);
|
||||
this.missingPolls.delete(id);
|
||||
this.fixRepeated.delete(id);
|
||||
this.geoidNCache.delete(id);
|
||||
|
||||
@@ -33,13 +33,13 @@ import {
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
FLEET_DR_INTERVAL_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
COURSE_SLEW_DT_MAX_SEC,
|
||||
ROTATION_REFRESH_MS,
|
||||
COURSE_MAX_DPS,
|
||||
HIDDEN_CONTACT_DR_TICKS,
|
||||
hiddenContactDrPhase,
|
||||
STALE_ALPHA,
|
||||
displayDelayMs,
|
||||
} from './policy.js';
|
||||
import { AIRCRAFT_ENVIRONMENT_MAP_OPTIONS } from '../../data/aircraftModelLighting.js';
|
||||
|
||||
@@ -895,7 +895,7 @@ export function createRendering({
|
||||
// Every contact in this pass shares the same delayed display instant.
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now(), flightState._scratchFleetRenderTime),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchFleetRenderTime,
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
import { followsClock } from './policy.js';
|
||||
|
||||
/** Apply reconciled aircraft records to Cesium resources and follow state. */
|
||||
export function createFlightSnapshotRenderer({
|
||||
@@ -25,7 +26,11 @@ export function createFlightSnapshotRenderer({
|
||||
const { refreshMilitaryRegistryIfStale, isMilitaryIcao } = militaryRegistry;
|
||||
const { warmGroundFloor, cachedGroundFloor } = groundFloor;
|
||||
const { sampleMeshFloorCells } = meshFloor;
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
/**
|
||||
* `replace`: the snapshot is the world at another time (the world clock
|
||||
* jumped), so it stands in for every contact: one it lacks is gone at once.
|
||||
*/
|
||||
return function applySnapshot(snapshot, viewer, { replace = false } = {}) {
|
||||
const currentIcaos = new Set();
|
||||
const acceptedSnapshotIcaos = new Set();
|
||||
// Field-test round 3 (2026-07-06, Austin fleet-underground): viewer
|
||||
@@ -153,7 +158,10 @@ export function createFlightSnapshotRenderer({
|
||||
});
|
||||
if (kinematicsChanged && !holds) {
|
||||
const synthetic = synthesizeForwardKinematicsFix(newest, {
|
||||
epochMs: Date.now(),
|
||||
// Following the clock, the snapshot's time is the moment it moves on from.
|
||||
epochMs: followsClock(flightState)
|
||||
? snapshot.observedAtMs
|
||||
: Date.now(),
|
||||
velocity: meta.velocity,
|
||||
track: meta.true_track,
|
||||
turnRateDps: meta.turnRateDps,
|
||||
@@ -263,12 +271,15 @@ export function createFlightSnapshotRenderer({
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
});
|
||||
const absence = replace
|
||||
? 'remove'
|
||||
: records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
nowMs: flightState.now?.(),
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
|
||||
@@ -302,6 +313,14 @@ export function createFlightSnapshotRenderer({
|
||||
flightState._displayFloorState.delete(icao24);
|
||||
}
|
||||
|
||||
// The tracked contact at the new time: its trail starts there.
|
||||
if (
|
||||
replace &&
|
||||
flightState._trackedIcao &&
|
||||
currentIcaos.has(flightState._trackedIcao)
|
||||
)
|
||||
tracking._startTrail(flightState._trackedIcao);
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
|
||||
@@ -11,10 +11,14 @@ export function createFlightState({ source, services }) {
|
||||
flightState.records = new FlightRecords({
|
||||
...services.geoid,
|
||||
...services.groundFloor,
|
||||
now: () => flightState.now(),
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||
flightState.now = worldTimeFor('flights');
|
||||
/** Follows the world clock while the source declares a timeline (set by the
|
||||
* 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
|
||||
@@ -385,6 +389,9 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._drPrevMs = 0;
|
||||
|
||||
/** World time of that frame: the wall clock live, the cursor following the clock. */
|
||||
flightState._drPrevWorldMs = 0;
|
||||
|
||||
flightState._drReconcileValid = false;
|
||||
|
||||
/** @type {string|null} icao the reconciliation state currently belongs to */
|
||||
|
||||
@@ -21,10 +21,11 @@ import {
|
||||
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
||||
TRAIL_MAX_POINTS,
|
||||
DR_CORRECTION_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
TRAIL_COLOR,
|
||||
CYAN_TRANSPARENT,
|
||||
STALE_ALPHA,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import {
|
||||
drawTrailSegments,
|
||||
@@ -376,7 +377,7 @@ export function createTracking({
|
||||
TRAIL_MAX_POINTS,
|
||||
{
|
||||
nullAltitude,
|
||||
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
displayMs: flightState.now() - displayDelayMs(flightState),
|
||||
settleMs: DR_CORRECTION_MS,
|
||||
},
|
||||
);
|
||||
@@ -392,7 +393,7 @@ export function createTracking({
|
||||
|
||||
function _trailTick() {
|
||||
if (!flightState._trackedIcao || !flightState._trail) return;
|
||||
const displayMs = flightState.now() - RENDER_DELAY_SEC * 1000;
|
||||
const displayMs = flightState.now() - displayDelayMs(flightState);
|
||||
let changed = false;
|
||||
if (
|
||||
flightState._trailFixes.length &&
|
||||
@@ -458,7 +459,7 @@ export function createTracking({
|
||||
flightState._trailFixes,
|
||||
trailBodyCount(
|
||||
flightState._trailFixes,
|
||||
flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
flightState.now() - displayDelayMs(flightState),
|
||||
),
|
||||
parts.motion._trackedModelCenterWorld(),
|
||||
parts.motion._trackedModelEnvelopeM(),
|
||||
@@ -587,15 +588,16 @@ export function createTracking({
|
||||
|
||||
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
|
||||
let path = null;
|
||||
// Following the world clock, the track ends at the world's time.
|
||||
const atMs = followsClock(flightState) ? flightState.now() : undefined;
|
||||
try {
|
||||
const signal = AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]);
|
||||
const track = await flightState.feed._source.getTrack?.(
|
||||
flightState.records.data.get(icao24)?.sourceReference ?? icao24,
|
||||
{
|
||||
signal: AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]),
|
||||
},
|
||||
atMs === undefined ? { signal } : { signal, atMs },
|
||||
);
|
||||
path = track?.records ?? null;
|
||||
} catch {
|
||||
@@ -625,6 +627,7 @@ export function createTracking({
|
||||
} = waypoint;
|
||||
const time = observedAtMs / 1000;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (atMs !== undefined && !(observedAtMs <= atMs)) continue;
|
||||
if (!Number.isFinite(time) || time >= oldestFixEpochSec) continue;
|
||||
parsed.push({
|
||||
lat,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* What the layer drops when the world clock moves it to another time (its
|
||||
* source declares a timeline; see src/time/snapshotTimeline.js).
|
||||
*/
|
||||
export function createClockFollow({ flightState, parts }) {
|
||||
/**
|
||||
* Forget how every contact moved, before the snapshot at another time takes
|
||||
* the place of every contact: their fixes, courses and the tracked trail
|
||||
* belong to the time the clock left.
|
||||
*/
|
||||
function resetMotion() {
|
||||
flightState.records.reset();
|
||||
flightState._positionHistory.clear();
|
||||
flightState._displayCourse.clear();
|
||||
flightState._groundSnap.clear();
|
||||
flightState._drReconcileValid = false;
|
||||
flightState._cachedDRFrame = -1;
|
||||
flightState._cachedDRPosition = null;
|
||||
parts.tracking._clearTrail();
|
||||
flightState._lastCamPoseSig = '';
|
||||
}
|
||||
|
||||
/** Drop every contact: the time has none to show, or the clock went live. */
|
||||
function clear() {
|
||||
if (flightState._trackedIcao)
|
||||
parts.tracking._clearTracking(false, { evicted: true });
|
||||
resetMotion();
|
||||
for (const [icao24, billboard] of flightState._billboards) {
|
||||
flightState._billboardCollection?.remove(billboard);
|
||||
parts.rendering._releaseModel(icao24);
|
||||
}
|
||||
flightState._billboards.clear();
|
||||
flightState._cullPositions.clear();
|
||||
flightState._detectionObjects.clear();
|
||||
flightState.feed._count = 0;
|
||||
flightState.feed._lastUpdate = null;
|
||||
}
|
||||
|
||||
return { resetMotion, clear };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { MilitaryFlightRecords } from './records.js';
|
||||
import { createIngestion, createMilitaryFeed } from './ingestion.js';
|
||||
import { displayDelayMs, RENDER_DELAY_SEC } from './policy.js';
|
||||
|
||||
// Military aircraft follow the world clock only while their source declares
|
||||
// a timeline (src/time/snapshotTimeline.js); these cover the layer's side.
|
||||
|
||||
const T = 1_800_000_000_000;
|
||||
|
||||
test('military contacts are drawn behind the live edge, and at the time following the clock', () => {
|
||||
assert.equal(displayDelayMs({ timeline: null }), RENDER_DELAY_SEC * 1000);
|
||||
assert.equal(displayDelayMs({ timeline: { active: () => false } }), 15_000);
|
||||
assert.equal(displayDelayMs({ timeline: { active: () => true } }), 0);
|
||||
});
|
||||
|
||||
test('military records judge absence at the layer clock and reset at a jump', () => {
|
||||
let now = T;
|
||||
const store = new MilitaryFlightRecords({
|
||||
geoidHeight: () => 0,
|
||||
cachedGroundFloor: () => null,
|
||||
floorAltitudeM: (altitude) => altitude,
|
||||
now: () => now,
|
||||
});
|
||||
const { meta } = store.receive(
|
||||
{
|
||||
id: 'ae0001',
|
||||
reference: 'ae0001',
|
||||
latitude: 39,
|
||||
longitude: -77,
|
||||
baroAltitudeM: 3000,
|
||||
onGround: false,
|
||||
positionTimeMs: T - 1_000,
|
||||
},
|
||||
{ observedAtMs: T, floorWarmPoints: [] },
|
||||
);
|
||||
assert.equal(meta.observedReceiptMs, T);
|
||||
assert.equal(
|
||||
store.absence('ae0001', { complete: true, tracked: true }),
|
||||
'stale',
|
||||
);
|
||||
now = T + 11 * 60_000;
|
||||
assert.equal(
|
||||
store.absence('ae0001', { complete: true, tracked: true }),
|
||||
'remove',
|
||||
);
|
||||
store.receive(
|
||||
{ id: 'ae0001', latitude: 39, longitude: -77, positionTimeMs: T },
|
||||
{ observedAtMs: T, floorWarmPoints: [] },
|
||||
);
|
||||
store.absence('ae0001', { complete: true });
|
||||
store.reset();
|
||||
assert.equal(store.data.size, 0);
|
||||
assert.equal(store.missingPolls.size, 0);
|
||||
assert.equal(store.lastSnapshotMs, null);
|
||||
});
|
||||
|
||||
test('military polls carry no time; a snapshot at a time carries it, and polls stop following', async () => {
|
||||
const queries = [];
|
||||
const applied = [];
|
||||
let follows = false;
|
||||
const feed = createMilitaryFeed({
|
||||
async getSnapshot(query) {
|
||||
queries.push(query);
|
||||
return { records: [], observedAtMs: T, freshness: 'current' };
|
||||
},
|
||||
});
|
||||
const { methods } = createIngestion({
|
||||
feed,
|
||||
applySnapshot: (_snapshot, _viewer, options) => {
|
||||
applied.push(options);
|
||||
return { count: 0, ids: new Set() };
|
||||
},
|
||||
setSourceLabel: () => {},
|
||||
applyPendingTrackingRestore: () => {},
|
||||
followsClock: () => follows,
|
||||
});
|
||||
await methods.update(null);
|
||||
assert.deepEqual(queries, [{}]);
|
||||
assert.deepEqual(applied, [undefined]);
|
||||
follows = true;
|
||||
await methods.update(null);
|
||||
assert.equal(queries.length, 1, 'no poll while following');
|
||||
assert.equal(await methods.loadAt(T, { replace: true }), true);
|
||||
assert.deepEqual(queries[1], { atMs: T });
|
||||
assert.deepEqual(applied[1], { replace: true });
|
||||
});
|
||||
@@ -8,6 +8,9 @@ import { createIngestion } from './ingestion.js';
|
||||
import { createLifecycle } from './lifecycle.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { createClockFollow } from './clockFollow.js';
|
||||
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
/** Compose one military-flight layer with application-owned scene services. */
|
||||
export function createMilitaryFlightLayer({
|
||||
source,
|
||||
@@ -25,6 +28,7 @@ export function createMilitaryFlightLayer({
|
||||
parts.lifecycle = createLifecycle(context);
|
||||
parts.testing = createTesting(context);
|
||||
parts.queries = createQueries(context);
|
||||
parts.clockFollow = createClockFollow(context);
|
||||
const applySnapshot = createMilitarySnapshotRenderer({
|
||||
flightState,
|
||||
records: flightState.records,
|
||||
@@ -38,7 +42,11 @@ export function createMilitaryFlightLayer({
|
||||
});
|
||||
parts.ingestion = createIngestion({
|
||||
feed: flightState.feed,
|
||||
applySnapshot,
|
||||
applySnapshot: (snapshot, viewer, options) => {
|
||||
if (options?.replace) parts.clockFollow.resetMotion();
|
||||
return applySnapshot(snapshot, viewer, options);
|
||||
},
|
||||
followsClock: () => flightState.timeline.active(),
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
@@ -46,6 +54,19 @@ export function createMilitaryFlightLayer({
|
||||
parts.tracking._applyPendingTrackingRestore(),
|
||||
});
|
||||
|
||||
// Contacts follow the world clock while the source declares a timeline.
|
||||
flightState.timeline = createSnapshotTimeline({
|
||||
id: 'military',
|
||||
clock: worldClock,
|
||||
source: () => flightState.feed._source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
parts.ingestion.methods.loadAt(atMs, { signal, replace }),
|
||||
clear: () => parts.clockFollow.clear(),
|
||||
resumeLive: () => {
|
||||
layer.update(flightState._viewer).catch(() => {});
|
||||
},
|
||||
});
|
||||
|
||||
Object.assign(
|
||||
layer,
|
||||
parts.queries.methods,
|
||||
|
||||
@@ -10,78 +10,110 @@ export function createIngestion({
|
||||
applySnapshot,
|
||||
setSourceLabel,
|
||||
applyPendingTrackingRestore,
|
||||
followsClock = () => false,
|
||||
}) {
|
||||
const methods = {
|
||||
async update(viewer, { signal = null } = {}) {
|
||||
const nowMs = Date.now();
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: feed._lastSource,
|
||||
};
|
||||
if (feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
}
|
||||
// Following the world clock, the layer shows the snapshots loadAt
|
||||
// applies; polling the live feed would put the live edge among them.
|
||||
if (followsClock()) return;
|
||||
await acquire(viewer, { signal });
|
||||
},
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await feed._source.getSnapshot(
|
||||
{},
|
||||
{ signal: updateSignal },
|
||||
);
|
||||
updateSignal.throwIfAborted();
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
feed._lastSource = snapshot.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
feed._backoff = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._retryAt = 0;
|
||||
feed._lastError =
|
||||
snapshot.reason ||
|
||||
(snapshot.freshness === 'unknown'
|
||||
? msg('Source snapshot time unavailable')
|
||||
: null);
|
||||
const accepted = applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
feed._lastUpdate = snapshot.observedAtMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
};
|
||||
console.log(`[Data:Military] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Military update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Military] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
feed._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
/**
|
||||
* Apply the source's snapshot at world time `atMs` (its timeline says it
|
||||
* can answer one), in place of every contact when `replace`. Resolves
|
||||
* whether it was applied.
|
||||
* @param {number} atMs
|
||||
* @param {{signal?: AbortSignal|null, replace?: boolean, viewer?: object}} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async loadAt(atMs, { signal = null, replace = false, viewer = null } = {}) {
|
||||
// Whatever polls or loads for another time no longer applies.
|
||||
if (replace)
|
||||
for (const controller of feed._activeUpdateControllers)
|
||||
controller.abort();
|
||||
return acquire(viewer, { signal, atMs, replace });
|
||||
},
|
||||
};
|
||||
|
||||
async function acquire(viewer, { signal = null, atMs, replace = false }) {
|
||||
const nowMs = Date.now();
|
||||
const timed = atMs !== undefined;
|
||||
const trackingRefreshEpoch = ++feed._trackingRefreshEpoch;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'source-unavailable',
|
||||
ids: new Set(),
|
||||
source: feed._lastSource,
|
||||
};
|
||||
if (!timed && feed._retryAt && nowMs < feed._retryAt) {
|
||||
feed._backoff = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const resourceController = new AbortController();
|
||||
feed._activeUpdateControllers.add(resourceController);
|
||||
const updateSignal = signal
|
||||
? AbortSignal.any([signal, resourceController.signal])
|
||||
: resourceController.signal;
|
||||
try {
|
||||
updateSignal.throwIfAborted();
|
||||
const snapshot = await feed._source.getSnapshot(timed ? { atMs } : {}, {
|
||||
signal: updateSignal,
|
||||
});
|
||||
updateSignal.throwIfAborted();
|
||||
// The clock left the live edge while this poll was out.
|
||||
if (!timed && followsClock()) return;
|
||||
feed._lastStatus = snapshot.status ?? 200;
|
||||
feed._lastSource = snapshot.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
feed._backoff = snapshot.stale || snapshot.freshness === 'unknown';
|
||||
feed._retryAt = 0;
|
||||
feed._lastError =
|
||||
snapshot.reason ||
|
||||
(snapshot.freshness === 'unknown'
|
||||
? msg('Source snapshot time unavailable')
|
||||
: null);
|
||||
const accepted = timed
|
||||
? applySnapshot(snapshot, viewer, { replace })
|
||||
: applySnapshot(snapshot, viewer);
|
||||
feed._count = accepted.count;
|
||||
feed._lastUpdate = snapshot.observedAtMs;
|
||||
feed._lastTrackingRefreshOutcome = {
|
||||
epoch: trackingRefreshEpoch,
|
||||
status: 'accepted',
|
||||
ids: accepted.ids,
|
||||
source: feed._lastSource,
|
||||
};
|
||||
console.log(`[Data:Military] Updated: ${feed._count} aircraft`);
|
||||
applyPendingTrackingRestore();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (updateSignal.aborted || e?.name === 'AbortError') {
|
||||
throw new DOMException('Military update aborted', 'AbortError');
|
||||
}
|
||||
console.warn('[Data:Military] Fetch error:', e);
|
||||
feed._backoff = true;
|
||||
// A snapshot at a time is asked again by the clock's follower.
|
||||
if (!timed)
|
||||
feed._retryAt =
|
||||
Date.now() + (e?.retryAfterMs ?? ERROR_BACKOFF_INTERVAL);
|
||||
feed._lastStatus = e?.status ?? null;
|
||||
if (e?.source) {
|
||||
feed._lastSource = e.source;
|
||||
setSourceLabel(feed._lastSource);
|
||||
}
|
||||
feed._lastError =
|
||||
e?.name === 'LiveSourceError'
|
||||
? e.message
|
||||
: msg('Live data unavailable');
|
||||
return false;
|
||||
} finally {
|
||||
feed._activeUpdateControllers.delete(resourceController);
|
||||
}
|
||||
}
|
||||
|
||||
return { methods };
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,7 @@ export function createLifecycle({
|
||||
);
|
||||
}
|
||||
restoreSpriteOrder(viewer);
|
||||
flightState.timeline?.start();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -175,6 +176,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||
*/
|
||||
disable(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
parts.controller._abortActiveUpdates();
|
||||
parts.tracking._cancelPendingTrackingRestore();
|
||||
if (flightState._billboardCollection)
|
||||
@@ -219,6 +221,7 @@ export function createLifecycle({
|
||||
* @param {Cesium.Viewer} viewer - The Cesium viewer instance
|
||||
*/
|
||||
destroy(viewer) {
|
||||
flightState.timeline?.stop();
|
||||
flightState.lifetime.abort();
|
||||
parts.controller._abortActiveUpdates();
|
||||
releaseContinuousRender('military'); // direct-destroy path (perf wave 2 fix)
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
modelAnchorWorld,
|
||||
} from '../../data/modelVisualAnchor.js';
|
||||
import {
|
||||
RENDER_DELAY_SEC,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
TRACKED_MODEL_MAX_PX,
|
||||
TRACKED_MODEL_MIN_PX,
|
||||
BILLBOARD_SCALE,
|
||||
@@ -73,7 +74,7 @@ export function createMotion({
|
||||
// two KNOWN fixes whenever possible (see RENDER_DELAY_SEC rationale).
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchRenderTime,
|
||||
);
|
||||
|
||||
@@ -211,7 +212,7 @@ export function createMotion({
|
||||
? newest.epochMs
|
||||
: Cesium.JulianDate.toDate(newest.time).getTime();
|
||||
const elapsedSec =
|
||||
(flightState.now() - RENDER_DELAY_SEC * 1000 - newestMs) / 1000;
|
||||
(flightState.now() - displayDelayMs(flightState) - newestMs) / 1000;
|
||||
if (!(elapsedSec > 0)) return null;
|
||||
const { coastSec, holds } = _coastPlan(
|
||||
newest,
|
||||
@@ -285,7 +286,7 @@ export function createMotion({
|
||||
if (!history || history.length === 0) return true;
|
||||
const renderTime = Cesium.JulianDate.addSeconds(
|
||||
julianAt(flightState.now()),
|
||||
-RENDER_DELAY_SEC,
|
||||
-displayDelayMs(flightState) / 1000,
|
||||
flightState._scratchWarmupTime,
|
||||
);
|
||||
return Cesium.JulianDate.lessThan(renderTime, history[0].time);
|
||||
@@ -370,9 +371,15 @@ export function createMotion({
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
// How far the contact may move between frames goes by the world's time,
|
||||
// which plays faster than the wall clock when following it at a rate.
|
||||
const worldMs = followsClock(flightState) ? flightState.now() : nowMs;
|
||||
const info = flightState.records.data.get(icao24);
|
||||
if (sameTrack) {
|
||||
const dtSec = Math.max(0.001, (nowMs - flightState._drPrevMs) / 1000);
|
||||
const dtSec = Math.max(
|
||||
0.001,
|
||||
(worldMs - flightState._drPrevWorldMs) / 1000,
|
||||
);
|
||||
const speed = (info && info.speedMps) || 0;
|
||||
// Plausible single-frame motion (m): real velocity × frame Δt, ×4 slack + 25 m base.
|
||||
const plausible = speed * dtSec * 4 + 25;
|
||||
@@ -403,6 +410,7 @@ export function createMotion({
|
||||
Cesium.Cartesian3.clone(raw, flightState._drPrevRaw);
|
||||
Cesium.Cartesian3.clone(display, flightState._drPrevDisplay);
|
||||
flightState._drPrevMs = nowMs;
|
||||
flightState._drPrevWorldMs = worldMs;
|
||||
flightState._drReconcileValid = true;
|
||||
flightState._cachedDRPosition = display;
|
||||
const focusSizePx = _trackedFocusSizePx(
|
||||
|
||||
@@ -186,6 +186,15 @@ export const TRAIL_MAX_POINTS = 2000;
|
||||
|
||||
export const RENDER_DELAY_SEC = 15;
|
||||
|
||||
/** Whether the layer shows the world clock's time instead of the live feed:
|
||||
* its source declares a timeline and the clock is away from live. */
|
||||
export const followsClock = (state) => state.timeline?.active() === true;
|
||||
|
||||
/** How far behind the time contacts are drawn: RENDER_DELAY_SEC live, none
|
||||
* following the clock, whose snapshots are loaded ahead of the time. */
|
||||
export const displayDelayMs = (state) =>
|
||||
followsClock(state) ? 0 : RENDER_DELAY_SEC * 1000;
|
||||
|
||||
/** @constant {number} Polls an aircraft may miss before removal (transient adsb.lol dropouts). */
|
||||
|
||||
// --- Landed-plane fast cull (mirror of flights.js; owner field report
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
contactMatchWins,
|
||||
CONTACT_MATCH_TIER,
|
||||
} from '../../data/contactMatch.js';
|
||||
import { LANDED_ALT_MAX_FT, LANDED_SPEED_MAX_MPS } from './policy.js';
|
||||
import {
|
||||
LANDED_ALT_MAX_FT,
|
||||
LANDED_SPEED_MAX_MPS,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { emergencyBroadcastPhrase } from '../../data/aircraftEmergency.js';
|
||||
|
||||
export function createQueries({
|
||||
@@ -834,6 +838,8 @@ export function createQueries({
|
||||
retryInSec,
|
||||
source: flightState.feed._lastSource,
|
||||
fallback: false,
|
||||
// Following the world clock, the snapshot's age is told from its time.
|
||||
...(followsClock(flightState) && { atMs: flightState.now() }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,8 +11,15 @@ import {
|
||||
|
||||
/** Portable military aircraft metadata and bounded missing-poll retention. */
|
||||
export class MilitaryFlightRecords {
|
||||
constructor({ geoidHeight, cachedGroundFloor, floorAltitudeM }) {
|
||||
constructor({
|
||||
geoidHeight,
|
||||
cachedGroundFloor,
|
||||
floorAltitudeM,
|
||||
now = () => Date.now(),
|
||||
}) {
|
||||
this.services = { geoidHeight, cachedGroundFloor, floorAltitudeM };
|
||||
/** The time records are judged at: the layer's clock. */
|
||||
this.now = now;
|
||||
this.data = new Map();
|
||||
this.missingPolls = new Map();
|
||||
// Contacts whose last poll re-sent a fix no newer than the one before.
|
||||
@@ -170,7 +177,7 @@ export class MilitaryFlightRecords {
|
||||
const fixEpochMs = aircraft.positionTimeMs ?? observedAtMs;
|
||||
const meta = {
|
||||
sourceReference: aircraft.reference,
|
||||
observedReceiptMs: Date.now(),
|
||||
observedReceiptMs: this.now(),
|
||||
newestFixMs: this._noteFix(icao24, prevMeta, fixEpochMs),
|
||||
callsign: stickyText(callsign, prevMeta?.callsign),
|
||||
type: stickyType,
|
||||
@@ -230,7 +237,10 @@ export class MilitaryFlightRecords {
|
||||
fixEpochMs,
|
||||
};
|
||||
}
|
||||
absence(id, { complete, retainIds, likelyLanded, tracked = false }) {
|
||||
absence(
|
||||
id,
|
||||
{ complete, retainIds, likelyLanded, tracked = false, nowMs = this.now() },
|
||||
) {
|
||||
const misses = (this.missingPolls.get(id) || 0) + 1;
|
||||
// The tracked contact has missed an update: it is stale from the first
|
||||
// one, and kept (so the camera keeps its target) until ten minutes after
|
||||
@@ -239,7 +249,7 @@ export class MilitaryFlightRecords {
|
||||
const meta = this.data.get(id);
|
||||
const fixMs = meta?.newestFixMs ?? meta?.observedReceiptMs;
|
||||
if (tracked && Number.isFinite(fixMs)) {
|
||||
if (Date.now() - fixMs < TRACKED_RETENTION_MS) {
|
||||
if (nowMs - fixMs < TRACKED_RETENTION_MS) {
|
||||
this.missingPolls.set(id, misses);
|
||||
return 'stale';
|
||||
}
|
||||
@@ -251,7 +261,7 @@ export class MilitaryFlightRecords {
|
||||
// still takes the landed limit.
|
||||
if (
|
||||
(!complete || (retainIds?.has(id) && !likelyLanded)) &&
|
||||
Date.now() - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
nowMs - (this.data.get(id)?.observedReceiptMs ?? 0) < 300000
|
||||
)
|
||||
return 'retain';
|
||||
const limit = likelyLanded ? LANDED_MISSING_POLL_LIMIT : MISSING_POLL_LIMIT;
|
||||
@@ -262,6 +272,14 @@ export class MilitaryFlightRecords {
|
||||
this.missingPolls.delete(id);
|
||||
return 'remove';
|
||||
}
|
||||
/** Forget every observation, as when the world clock jumps to another time. */
|
||||
reset() {
|
||||
this.data.clear();
|
||||
this.missingPolls.clear();
|
||||
this.fixRepeated.clear();
|
||||
this.lastSnapshotMs = null;
|
||||
this.snapshotAdvanced = true;
|
||||
}
|
||||
forget(id) {
|
||||
this.data.delete(id);
|
||||
this.missingPolls.delete(id);
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
} from '../../data/motionModel.js';
|
||||
import { aircraftIcon } from '../../data/aircraftIcons.js';
|
||||
import { POSITION_HISTORY_LIMIT } from './recordPolicy.js';
|
||||
import { BILLBOARD_SCALE, GROUND_SCALE, TRACKED_ICON_COLOR } from './policy.js';
|
||||
import {
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
TRACKED_ICON_COLOR,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
|
||||
/** Apply military record changes to Cesium resources and existing follow operations. */
|
||||
export function createMilitarySnapshotRenderer({
|
||||
@@ -27,7 +32,11 @@ export function createMilitarySnapshotRenderer({
|
||||
groundFloor;
|
||||
const { sampleMeshFloorCells } = meshFloor;
|
||||
const { registerMilitaryIcaos } = militaryRegistry;
|
||||
return function applySnapshot(snapshot, viewer) {
|
||||
/**
|
||||
* `replace`: the snapshot is the world at another time (the world clock
|
||||
* jumped), so it stands in for every contact: one it lacks is gone at once.
|
||||
*/
|
||||
return function applySnapshot(snapshot, viewer, { replace = false } = {}) {
|
||||
const currentIcaos = new Set();
|
||||
const receiptNowMs = snapshot.observedAtMs;
|
||||
records.beginSnapshot(snapshot.observedAtMs);
|
||||
@@ -120,7 +129,10 @@ export function createMilitarySnapshotRenderer({
|
||||
});
|
||||
if (kinematicsChanged && !holds) {
|
||||
const synthetic = synthesizeForwardKinematicsFix(newest, {
|
||||
epochMs: Date.now(),
|
||||
// Following the clock, the snapshot's time is the moment it moves on from.
|
||||
epochMs: followsClock(flightState)
|
||||
? snapshot.observedAtMs
|
||||
: Date.now(),
|
||||
velocity: meta.speedMps,
|
||||
track: meta.track,
|
||||
turnRateDps: meta.turnRateDps,
|
||||
@@ -272,12 +284,15 @@ export function createMilitarySnapshotRenderer({
|
||||
// feed gap, and the full grace left phantom planes parked at airports.
|
||||
for (const [icao24, bb] of flightState._billboards) {
|
||||
if (currentIcaos.has(icao24)) continue;
|
||||
const absence = records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
});
|
||||
const absence = replace
|
||||
? 'remove'
|
||||
: records.absence(icao24, {
|
||||
complete: snapshot.complete,
|
||||
retainIds: snapshot.retainIds,
|
||||
likelyLanded: queries._likelyLanded(icao24),
|
||||
tracked: icao24 === flightState._trackedIcao,
|
||||
nowMs: flightState.now?.(),
|
||||
});
|
||||
if (absence === 'retain') continue;
|
||||
if (absence === 'stale') {
|
||||
if (icao24 === flightState._trackedIcao && flightState._trackedEntity) {
|
||||
@@ -310,6 +325,14 @@ export function createMilitarySnapshotRenderer({
|
||||
flightState._groundSnap.forget(icao24);
|
||||
}
|
||||
|
||||
// The tracked contact at the new time: its trail starts there.
|
||||
if (
|
||||
replace &&
|
||||
flightState._trackedIcao &&
|
||||
currentIcaos.has(flightState._trackedIcao)
|
||||
)
|
||||
tracking._startTrail(flightState._trackedIcao);
|
||||
|
||||
// Fresh courses arrived — force a rotation pass on the next fleet tick
|
||||
flightState._lastCamPoseSig = '';
|
||||
|
||||
|
||||
@@ -10,10 +10,14 @@ export function createFlightState({ source, services }) {
|
||||
flightState.records = new MilitaryFlightRecords({
|
||||
...services.geoid,
|
||||
...services.groundFloor,
|
||||
now: () => flightState.now(),
|
||||
});
|
||||
flightState._cullPositions = new Map();
|
||||
/** The time contacts are drawn behind by the render delay, in epoch ms. */
|
||||
flightState.now = worldTimeFor('military');
|
||||
/** Follows the world clock while the source declares a timeline (set by the
|
||||
* 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
|
||||
@@ -321,6 +325,9 @@ export function createFlightState({ source, services }) {
|
||||
|
||||
flightState._drPrevMs = 0;
|
||||
|
||||
/** World time of that frame: the wall clock live, the cursor following the clock. */
|
||||
flightState._drPrevWorldMs = 0;
|
||||
|
||||
flightState._drReconcileValid = false;
|
||||
|
||||
/** @type {string|null} icao the reconciliation state currently belongs to */
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
TRACKED_MODEL_RETRY_BACKOFF_MS,
|
||||
TRAIL_MAX_POINTS,
|
||||
DR_CORRECTION_MS,
|
||||
RENDER_DELAY_SEC,
|
||||
displayDelayMs,
|
||||
followsClock,
|
||||
TRAIL_COLOR,
|
||||
BILLBOARD_SCALE,
|
||||
GROUND_SCALE,
|
||||
@@ -574,7 +575,7 @@ export function createTracking({
|
||||
TRAIL_MAX_POINTS,
|
||||
{
|
||||
nullAltitude,
|
||||
displayMs: flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
displayMs: flightState.now() - displayDelayMs(flightState),
|
||||
settleMs: DR_CORRECTION_MS,
|
||||
},
|
||||
);
|
||||
@@ -590,7 +591,7 @@ export function createTracking({
|
||||
|
||||
function _trailTick() {
|
||||
if (!flightState._trackedIcao || !flightState._trail) return;
|
||||
const displayMs = flightState.now() - RENDER_DELAY_SEC * 1000;
|
||||
const displayMs = flightState.now() - displayDelayMs(flightState);
|
||||
let changed = false;
|
||||
if (
|
||||
flightState._trailFixes.length &&
|
||||
@@ -651,7 +652,7 @@ export function createTracking({
|
||||
flightState._trailFixes,
|
||||
trailBodyCount(
|
||||
flightState._trailFixes,
|
||||
flightState.now() - RENDER_DELAY_SEC * 1000,
|
||||
flightState.now() - displayDelayMs(flightState),
|
||||
),
|
||||
parts.motion._trackedModelCenterWorld(),
|
||||
parts.motion._trackedModelEnvelopeM(),
|
||||
@@ -789,15 +790,16 @@ export function createTracking({
|
||||
|
||||
async function _backfillTrail(icao24, token, oldestFixEpochSec) {
|
||||
let trace = null;
|
||||
// Following the world clock, the track ends at the world's time.
|
||||
const atMs = followsClock(flightState) ? flightState.now() : undefined;
|
||||
try {
|
||||
const signal = AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]);
|
||||
const track = await flightState.feed._source.getTrack?.(
|
||||
flightState.records.data.get(icao24)?.sourceReference ?? icao24,
|
||||
{
|
||||
signal: AbortSignal.any([
|
||||
flightState.lifetime.signal,
|
||||
AbortSignal.timeout(8000),
|
||||
]),
|
||||
},
|
||||
atMs === undefined ? { signal } : { signal, atMs },
|
||||
);
|
||||
trace = track?.records ?? null;
|
||||
} catch {
|
||||
@@ -825,6 +827,7 @@ export function createTracking({
|
||||
lon = point.longitude;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
if (!Number.isFinite(t) || t >= oldestFixEpochSec) continue;
|
||||
if (atMs !== undefined && !(point.observedAtMs <= atMs)) continue;
|
||||
parsed.push({
|
||||
lat,
|
||||
lon,
|
||||
|
||||
@@ -60,7 +60,7 @@ export function createCards({
|
||||
parts.push(`${roundHeadingDegrees(direction)}°`);
|
||||
return {
|
||||
id: vesselOverlayEntryId(record),
|
||||
actionable: Boolean(record?.mmsi),
|
||||
actionable: Boolean(record?.id),
|
||||
position:
|
||||
components.rendering.getVisual(record).billboard?.position ||
|
||||
components.rendering.getVisual(record).position,
|
||||
@@ -101,7 +101,7 @@ export function createCards({
|
||||
);
|
||||
return {
|
||||
id: vesselOverlayEntryId(record),
|
||||
actionable: Boolean(record?.mmsi),
|
||||
actionable: Boolean(record?.id),
|
||||
position:
|
||||
components.rendering.getVisual(record).billboard?.position ||
|
||||
components.rendering.getVisual(record).position,
|
||||
@@ -114,11 +114,11 @@ export function createCards({
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable overlay identity for MMSI-keyed and source-retained unkeyed rows. */
|
||||
/** Stable overlay identity for keyed and source-retained unkeyed rows. */
|
||||
|
||||
function vesselOverlayEntryId(record) {
|
||||
const mmsi = String(record?.mmsi || '').trim();
|
||||
if (mmsi) return `vessel:${mmsi}`;
|
||||
const id = String(record?.id || '').trim();
|
||||
if (id) return `vessel:${id}`;
|
||||
const name = String(record?.name || 'VESSEL').trim() || 'VESSEL';
|
||||
const lat = Number.isFinite(record?.lat) ? record.lat.toFixed(5) : 'x';
|
||||
const lon = Number.isFinite(record?.lon) ? record.lon.toFixed(5) : 'x';
|
||||
@@ -161,7 +161,9 @@ export function createCards({
|
||||
|
||||
function displayVesselName(record) {
|
||||
const name = String(record.name || '').trim();
|
||||
if (name && name !== 'VESSEL' && name !== record.mmsi) return name;
|
||||
// A source may name a vessel by its id when it has no name.
|
||||
if (name && name !== 'VESSEL' && name !== record.mmsi && name !== record.id)
|
||||
return name;
|
||||
return record.mmsi ? `MMSI ${record.mmsi}` : t('VESSEL');
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { VesselRecords } from './records.js';
|
||||
import { createIngestion, createVesselFeed } from './ingestion.js';
|
||||
|
||||
// Vessels follow the world clock only while their source declares a
|
||||
// timeline (src/time/snapshotTimeline.js); these cover the layer's side.
|
||||
|
||||
const row = (mmsi, fields = {}) => ({
|
||||
mmsi,
|
||||
lat: 51.93,
|
||||
lon: 4.05,
|
||||
name: mmsi,
|
||||
last_position_epoch: 1_000,
|
||||
...fields,
|
||||
});
|
||||
|
||||
function store() {
|
||||
const records = new VesselRecords({ now: () => 5_000_000 });
|
||||
const events = [];
|
||||
const effects = {
|
||||
add: (record) => events.push(['add', record.mmsi]),
|
||||
beforeUpdate: () => null,
|
||||
updated: (record) => events.push(['update', record.mmsi]),
|
||||
remove: (record, evicted) => events.push(['remove', record.mmsi, evicted]),
|
||||
removed: (mmsi) => events.push(['removed', mmsi]),
|
||||
staleSelected: (record) => events.push(['stale', record.mmsi]),
|
||||
};
|
||||
return {
|
||||
records,
|
||||
events,
|
||||
reconcile: (rows, options = {}) =>
|
||||
records.reconcile(rows, options, effects),
|
||||
};
|
||||
}
|
||||
|
||||
test('rows at another time stand in for every vessel, selected or not', () => {
|
||||
const probe = store();
|
||||
probe.reconcile([row('111'), row('222')]);
|
||||
const selectedRecord = probe.records.byId.get('222');
|
||||
probe.reconcile([row('111', { last_position_epoch: 900 })], {
|
||||
complete: false,
|
||||
selectedRecord,
|
||||
replace: true,
|
||||
});
|
||||
assert.deepEqual([...probe.records.byId.keys()], ['111']);
|
||||
assert.deepEqual(probe.events.slice(-3), [
|
||||
['update', '111'],
|
||||
['remove', '222', true],
|
||||
['removed', '222'],
|
||||
]);
|
||||
assert.equal(probe.records.byId.get('111').motion, null);
|
||||
});
|
||||
|
||||
test('following the clock, a vessel keeps where its fix moved from', () => {
|
||||
const probe = store();
|
||||
probe.reconcile([row('111')]);
|
||||
probe.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })], {
|
||||
follow: true,
|
||||
});
|
||||
const record = probe.records.byId.get('111');
|
||||
assert.deepEqual(record.motion, {
|
||||
fromLat: 51.93,
|
||||
fromLon: 4.05,
|
||||
fromMs: 1_000_000,
|
||||
toLat: 52,
|
||||
toLon: 4.05,
|
||||
toMs: 1_010_000,
|
||||
});
|
||||
probe.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })], {
|
||||
follow: true,
|
||||
});
|
||||
assert.equal(record.motion.toMs, 1_010_000, 'a repeated fix keeps its move');
|
||||
const live = store();
|
||||
live.reconcile([row('111')]);
|
||||
live.reconcile([row('111', { lat: 52, last_position_epoch: 1_010 })]);
|
||||
assert.equal(
|
||||
Object.hasOwn(live.records.byId.get('111'), 'motion'),
|
||||
false,
|
||||
'live records are as before',
|
||||
);
|
||||
});
|
||||
|
||||
function ingestion(source) {
|
||||
const feed = createVesselFeed();
|
||||
feed.enabled = true;
|
||||
feed.sessionId = 1;
|
||||
const applied = [];
|
||||
const state = { follows: false, reloads: 0, radiusKm: 50 };
|
||||
const methods = createIngestion({
|
||||
feed,
|
||||
readSource: () => source,
|
||||
readViewer: () => ({}),
|
||||
readArea: () => ({ lat: 1, lon: 2, radiusKm: state.radiusKm }),
|
||||
getRowLimit: () => 500,
|
||||
readCount: () => applied.at(-1)?.rows.length ?? 0,
|
||||
now: () => 9999,
|
||||
setSourceLabel: () => {},
|
||||
applyRows: (_viewer, rows, options) => applied.push({ rows, options }),
|
||||
classifySnapshot: (payload) => ({
|
||||
acceptedRows: payload.rows,
|
||||
acceptedRowCount: payload.rows.length,
|
||||
rawRowCount: payload.rows.length,
|
||||
transportStatus: payload.status,
|
||||
lastMessageAt: payload.lastMessageAt,
|
||||
error: null,
|
||||
}),
|
||||
isDefinitiveTransportFailure: () => false,
|
||||
isGraceEligibleTransport: () => false,
|
||||
markUnavailable: () => {},
|
||||
settleFirstConnect: () => {},
|
||||
followsClock: () => state.follows,
|
||||
reloadAtClock: () => {
|
||||
state.reloads += 1;
|
||||
},
|
||||
});
|
||||
return { feed, applied, state, ...methods };
|
||||
}
|
||||
|
||||
test('vessel polls carry no time; a snapshot at a time carries it and replaces', async () => {
|
||||
const queries = [];
|
||||
const probe = ingestion({
|
||||
async getSnapshot(query) {
|
||||
queries.push(query);
|
||||
return {
|
||||
records: [{ id: '111', latitude: 1, longitude: 2, observedAtMs: 5 }],
|
||||
observedAtMs: 5,
|
||||
freshness: 'current',
|
||||
complete: true,
|
||||
transportStatus: 'live',
|
||||
};
|
||||
},
|
||||
});
|
||||
await probe.methods.update();
|
||||
assert.equal(Object.hasOwn(queries[0], 'atMs'), false);
|
||||
assert.deepEqual(probe.applied[0].options, { complete: true });
|
||||
probe.state.follows = true;
|
||||
await probe.methods.update();
|
||||
assert.equal(queries.length, 1, 'no poll while following');
|
||||
assert.equal(await probe.loadAt(5_000, { replace: true }), true);
|
||||
assert.equal(queries[1].atMs, 5_000);
|
||||
assert.deepEqual(probe.applied[1].options, { complete: true, replace: true });
|
||||
assert.equal(probe.feed.lastUpdate, 5);
|
||||
// Following the clock, a moved view reloads the time shown.
|
||||
probe.state.radiusKm = 2_000;
|
||||
await probe.refreshIfMoved({});
|
||||
assert.equal(probe.state.reloads, 1);
|
||||
assert.equal(queries.length, 2);
|
||||
});
|
||||
|
||||
test('an empty vessel snapshot at another time shows no vessels', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
return { records: [], observedAtMs: 5, freshness: 'current' };
|
||||
},
|
||||
});
|
||||
probe.state.follows = true;
|
||||
await probe.loadAt(5_000, { replace: true });
|
||||
assert.deepEqual(probe.applied, [
|
||||
{ rows: [], options: { complete: true, replace: true } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('a vessel snapshot at a time the source drops for a newer one is no failure', async () => {
|
||||
const probe = ingestion({
|
||||
async getSnapshot() {
|
||||
throw new DOMException('Superseded', 'AbortError');
|
||||
},
|
||||
});
|
||||
probe.state.follows = true;
|
||||
await assert.rejects(probe.loadAt(5_000, { replace: true }), {
|
||||
name: 'AbortError',
|
||||
});
|
||||
assert.equal(probe.feed.error, null);
|
||||
assert.equal(probe.feed.loading, false);
|
||||
assert.deepEqual(probe.applied, []);
|
||||
});
|
||||
@@ -99,7 +99,7 @@ function finiteOrNull(value) {
|
||||
* @returns {object|null} Subject info, or null without a usable fix.
|
||||
*/
|
||||
export function vesselCockpitInfo(record) {
|
||||
if (!record?.mmsi) return null;
|
||||
if (!record?.id) return null;
|
||||
const latitude = Number(record.lat);
|
||||
const longitude = Number(record.lon);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
@@ -111,9 +111,9 @@ export function vesselCockpitInfo(record) {
|
||||
return {
|
||||
kind: 'vessel',
|
||||
layerId: VESSEL_COCKPIT_LAYER_ID,
|
||||
id: String(record.mmsi),
|
||||
mmsi: String(record.mmsi),
|
||||
name: String(record.name || record.mmsi),
|
||||
id: String(record.id),
|
||||
mmsi: String(record.mmsi || ''),
|
||||
name: String(record.name || record.mmsi || record.id),
|
||||
type: String(record.type || ''),
|
||||
typeLabel: displayVesselType(record.type),
|
||||
destination: String(record.destination || '').trim(),
|
||||
|
||||
@@ -43,6 +43,7 @@ test('an unknown or missing type has no eye height of its own', () => {
|
||||
|
||||
test('vessel info faces the true heading and moves along course over ground', () => {
|
||||
const info = vesselCockpitInfo({
|
||||
id: '211000000',
|
||||
mmsi: '211000000',
|
||||
name: 'NORDIC STAR',
|
||||
type: '70',
|
||||
@@ -58,6 +59,7 @@ test('vessel info faces the true heading and moves along course over ground', ()
|
||||
assert.equal(info.kind, 'vessel');
|
||||
assert.equal(info.layerId, VESSEL_COCKPIT_LAYER_ID);
|
||||
assert.equal(info.id, '211000000');
|
||||
assert.equal(info.mmsi, '211000000');
|
||||
assert.equal(info.typeLabel, 'CARGO');
|
||||
assert.equal(info.track, 90);
|
||||
assert.equal(info.motionCourse, 95);
|
||||
@@ -70,7 +72,7 @@ test('vessel info faces the true heading and moves along course over ground', ()
|
||||
});
|
||||
|
||||
test('a missing heading or course falls back to the other', () => {
|
||||
const base = { mmsi: '1', lat: 1, lon: 1, speed: 3 };
|
||||
const base = { id: '1', mmsi: '1', lat: 1, lon: 1, speed: 3 };
|
||||
assert.equal(vesselCockpitInfo({ ...base, course: 40 }).track, 40);
|
||||
assert.equal(vesselCockpitInfo({ ...base, heading: 50 }).motionCourse, 50);
|
||||
const neither = vesselCockpitInfo(base);
|
||||
@@ -79,7 +81,7 @@ test('a missing heading or course falls back to the other', () => {
|
||||
});
|
||||
|
||||
test('the subject says whether its track is the heading or the course', () => {
|
||||
const base = { mmsi: '1', lat: 1, lon: 1, speed: 3 };
|
||||
const base = { id: '1', mmsi: '1', lat: 1, lon: 1, speed: 3 };
|
||||
assert.equal(
|
||||
vesselCockpitInfo({ ...base, heading: 50, course: 40 }).trackSource,
|
||||
'heading',
|
||||
@@ -99,11 +101,12 @@ test('the subject says whether its track is the heading or the course', () => {
|
||||
test('a vessel without identity or position is not a subject', () => {
|
||||
assert.equal(vesselCockpitInfo(null), null);
|
||||
assert.equal(vesselCockpitInfo({ lat: 1, lon: 1 }), null);
|
||||
assert.equal(vesselCockpitInfo({ mmsi: '1', lat: 'x', lon: 1 }), null);
|
||||
assert.equal(vesselCockpitInfo({ id: '1', mmsi: '1', lat: 'x', lon: 1 }), null);
|
||||
});
|
||||
|
||||
test('a vessel missing from recent refreshes reads as stale', () => {
|
||||
const info = vesselCockpitInfo({
|
||||
id: '1',
|
||||
mmsi: '1',
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
@@ -111,3 +114,20 @@ test('a vessel missing from recent refreshes reads as stale', () => {
|
||||
});
|
||||
assert.equal(info.stale, true);
|
||||
});
|
||||
|
||||
test('a vessel is the subject by its id; an MMSI is only shown when known', () => {
|
||||
const info = vesselCockpitInfo({
|
||||
id: 'b6a7c1e2-provider-id',
|
||||
mmsi: '',
|
||||
name: 'NORDIC STAR',
|
||||
lat: 54.5,
|
||||
lon: 10.2,
|
||||
});
|
||||
assert.equal(info.id, 'b6a7c1e2-provider-id');
|
||||
assert.equal(info.mmsi, '');
|
||||
assert.equal(
|
||||
vesselCockpitInfo({ mmsi: '211000000', lat: 54.5, lon: 10.2 }),
|
||||
null,
|
||||
'a record with no id is unkeyed and cannot be ridden',
|
||||
);
|
||||
});
|
||||
@@ -47,7 +47,7 @@ export function createEvidence({
|
||||
)
|
||||
: null;
|
||||
return {
|
||||
id: record.mmsi,
|
||||
id: record.id,
|
||||
show: bb?.show === true,
|
||||
alpha: bb?.color?.alpha ?? null,
|
||||
x: screen?.x ?? null,
|
||||
|
||||
@@ -10,6 +10,9 @@ import { createCards } from './cards.js';
|
||||
import { createTesting } from './testing.js';
|
||||
import { createEvidence } from './evidence.js';
|
||||
import { createQueries } from './queries.js';
|
||||
import { followsClock } from './policy.js';
|
||||
import { createSnapshotTimeline } from '../../time/snapshotTimeline.js';
|
||||
import { worldClock } from '../../time/worldClock.js';
|
||||
/** Compose one vessel layer with application-owned scene services. */
|
||||
export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
const vesselState = createVesselState({ source, services });
|
||||
@@ -31,6 +34,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
tracking: parts.tracking,
|
||||
selection: parts.selection,
|
||||
cards: parts.cards,
|
||||
followsClock: () => followsClock(vesselState),
|
||||
});
|
||||
|
||||
parts.ingestion = createIngestion({
|
||||
@@ -50,6 +54,29 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
|
||||
setSourceLabel: (source) => {
|
||||
layer.source = source;
|
||||
},
|
||||
followsClock: () => followsClock(vesselState),
|
||||
reloadAtClock: () => vesselState.timeline.reload(),
|
||||
});
|
||||
// Vessels follow the world clock while the source declares a timeline.
|
||||
vesselState.timeline = createSnapshotTimeline({
|
||||
id: 'ais-live-vessels',
|
||||
clock: worldClock,
|
||||
source: () => vesselState._source,
|
||||
load: (atMs, { signal, replace }) =>
|
||||
parts.ingestion.loadAt(atMs, { signal, replace }),
|
||||
clear: () => {
|
||||
const { state } = vesselState;
|
||||
if (state.viewer)
|
||||
parts.snapshots.reconcileVessels(state.viewer, [], {
|
||||
complete: true,
|
||||
replace: true,
|
||||
});
|
||||
state.feed.count = state.records.all.length;
|
||||
state.feed.lastUpdate = null;
|
||||
},
|
||||
resumeLive: () => {
|
||||
void parts.ingestion.loadLivePositions(vesselState.state.viewer);
|
||||
},
|
||||
});
|
||||
Object.assign(
|
||||
layer,
|
||||
|
||||
@@ -21,9 +21,36 @@ export function createIngestion({
|
||||
settleFirstConnect,
|
||||
now,
|
||||
setSourceLabel,
|
||||
followsClock = () => false,
|
||||
reloadAtClock = () => {},
|
||||
}) {
|
||||
async function loadLivePositions(viewer) {
|
||||
if (!viewer || feed.loading) return;
|
||||
// Following the world clock, the layer shows the snapshots loadAt applies.
|
||||
if (followsClock()) return;
|
||||
await loadPositions(viewer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the source's snapshot at world time `atMs` (its timeline says it
|
||||
* can answer one), in place of every vessel when `replace`. Resolves
|
||||
* whether it was applied.
|
||||
* @param {number} atMs
|
||||
* @param {{signal?: AbortSignal|null, replace?: boolean}} [options]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function loadAt(atMs, { signal = null, replace = false } = {}) {
|
||||
// Whatever polls or loads for another time no longer applies.
|
||||
if (replace && feed.loading) {
|
||||
feed.abort?.abort();
|
||||
feed.loading = false;
|
||||
feed.abort = null;
|
||||
}
|
||||
return loadPositions(readViewer(), { atMs, replace, signal });
|
||||
}
|
||||
|
||||
async function loadPositions(viewer, { atMs, replace = false, signal } = {}) {
|
||||
const timed = atMs !== undefined;
|
||||
if (!viewer || feed.loading) return false;
|
||||
feed.loading = true;
|
||||
feed.loadingLabel = feed.loaded ? 'refreshing...' : 'loading...';
|
||||
const requestController = new AbortController();
|
||||
@@ -33,21 +60,29 @@ export function createIngestion({
|
||||
try {
|
||||
// Combine the layer's teardown-abort with a hard timeout so a hung upstream
|
||||
// can't wedge the poll indefinitely (parity with the track fetch + flights).
|
||||
const signal =
|
||||
const requestSignal =
|
||||
typeof AbortSignal.any === 'function'
|
||||
? AbortSignal.any([
|
||||
requestController.signal,
|
||||
AbortSignal.timeout(10000),
|
||||
...(signal ? [signal] : []),
|
||||
])
|
||||
: requestController.signal;
|
||||
// Ask for the vessels in view; a wide view asks for every vessel.
|
||||
const area = readArea(viewer);
|
||||
feed.lastArea = area;
|
||||
const snapshot = await readSource().getSnapshot(
|
||||
{ maxRows: getRowLimit(), ...(area ? { area } : {}) },
|
||||
{ signal },
|
||||
{
|
||||
maxRows: getRowLimit(),
|
||||
...(area ? { area } : {}),
|
||||
...(timed ? { atMs } : {}),
|
||||
},
|
||||
{ signal: requestSignal },
|
||||
);
|
||||
if (!ownsAisRequest(requestController, requestSessionId)) return;
|
||||
if (!ownsAisRequest(requestController, requestSessionId)) return false;
|
||||
if (signal?.aborted) return false;
|
||||
// The clock left the live edge while this poll was out.
|
||||
if (!timed && followsClock()) return false;
|
||||
setSourceLabel(snapshot.source);
|
||||
// Map observations into the existing display store; source fields stop here.
|
||||
applyAisFeedSnapshot(viewer, {
|
||||
@@ -67,8 +102,12 @@ export function createIngestion({
|
||||
: new Date(snapshot.observedAtMs).toISOString(),
|
||||
silentForMs: snapshot.silentForMs,
|
||||
reconnectAttempt: snapshot.reconnectAttempt,
|
||||
...(replace && { replace }),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
// A snapshot at a time the source dropped for a newer one is no failure.
|
||||
if (timed && error?.name === 'AbortError') throw error;
|
||||
if (
|
||||
ownsAisRequest(requestController, requestSessionId) &&
|
||||
error?.name !== 'AbortError'
|
||||
@@ -76,6 +115,7 @@ export function createIngestion({
|
||||
markUnavailable(error?.message || msg('AIS live load failed'));
|
||||
console.warn('[Data:ais-live-vessels]', feed.error, error);
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (
|
||||
feed.abort === requestController &&
|
||||
@@ -113,6 +153,9 @@ export function createIngestion({
|
||||
feed.acceptedRowCount = snapshot.acceptedRowCount;
|
||||
feed.partial = payload?.complete === false;
|
||||
|
||||
// At another time, a snapshot with no vessels shows none, not the last.
|
||||
if (payload?.replace && snapshot.acceptedRowCount === 0)
|
||||
applyRows(viewer, [], { complete: true, replace: true });
|
||||
if (snapshot.acceptedRowCount === 0 && !snapshot.emptyCoverage) {
|
||||
feed.count = readCount();
|
||||
feed.stale = feed.count > 0 || Boolean(payload?.refreshing);
|
||||
@@ -139,6 +182,7 @@ export function createIngestion({
|
||||
settleFirstConnect('ready');
|
||||
applyRows(viewer, snapshot.acceptedRows, {
|
||||
complete: payload?.complete !== false,
|
||||
...(payload?.replace && { replace: true }),
|
||||
});
|
||||
feed.count = readCount();
|
||||
feed.stale =
|
||||
@@ -157,9 +201,11 @@ export function createIngestion({
|
||||
return { reconciled: true, ...snapshot };
|
||||
}
|
||||
|
||||
/** The layer's row for a source record: keyed by its id, MMSI shown when known. */
|
||||
function vesselDisplayRow(record) {
|
||||
return {
|
||||
mmsi: record.id,
|
||||
id: record.id,
|
||||
mmsi: String(record.mmsi ?? '').trim(),
|
||||
reference: record.reference,
|
||||
lat: record.latitude,
|
||||
lon: record.longitude,
|
||||
@@ -180,10 +226,16 @@ export function createIngestion({
|
||||
}
|
||||
/** Ask again when the view moved away from the last area asked for. */
|
||||
function refreshIfMoved(viewer) {
|
||||
if (!feed.enabled || feed.loading) return Promise.resolve();
|
||||
const following = followsClock();
|
||||
if (!feed.enabled || (feed.loading && !following)) return Promise.resolve();
|
||||
const active = viewer || readViewer();
|
||||
if (!viewAreaMovedEnough(feed.lastArea ?? null, readArea(active)))
|
||||
return Promise.resolve();
|
||||
// Following the world clock, the new area is asked for at the same time.
|
||||
if (following) {
|
||||
reloadAtClock();
|
||||
return Promise.resolve();
|
||||
}
|
||||
return loadLivePositions(active);
|
||||
}
|
||||
|
||||
@@ -196,6 +248,7 @@ export function createIngestion({
|
||||
|
||||
return {
|
||||
loadLivePositions,
|
||||
loadAt,
|
||||
refreshIfMoved,
|
||||
ownsAisRequest,
|
||||
applyAisFeedSnapshot,
|
||||
|
||||
@@ -116,7 +116,7 @@ export function createLifecycle({
|
||||
state.feed.abort = null;
|
||||
state.billboardCollection = null;
|
||||
state.records.all = [];
|
||||
state.records.byMmsi = new Map();
|
||||
state.records.byId = new Map();
|
||||
state.records.unkeyed = [];
|
||||
state.clickHandler = null;
|
||||
state.keyTarget = null;
|
||||
@@ -133,7 +133,7 @@ export function createLifecycle({
|
||||
state.selectedRecord = null;
|
||||
state.trail = null;
|
||||
state.trailPositions = [];
|
||||
state.trailMmsi = null;
|
||||
state.trailId = null;
|
||||
state.trailBackfillToken = 0;
|
||||
}
|
||||
const methods = {
|
||||
@@ -188,9 +188,9 @@ export function createLifecycle({
|
||||
});
|
||||
}
|
||||
// Pick-ownership (H2): vessel picks carry the record OBJECT as their id;
|
||||
// the registry resolver reduces it to the record's mmsi (a string key).
|
||||
// the registry resolver reduces it to the record's id (a string key).
|
||||
registerPickOwner('ais-live-vessels', (pickedId) =>
|
||||
state.records.byMmsi.has(pickedId),
|
||||
state.records.byId.has(pickedId),
|
||||
);
|
||||
restoreSpriteOrderOnEnable('ais', activeViewer);
|
||||
// Vessels are asked for by view, so ask again when the view moves away.
|
||||
@@ -200,10 +200,12 @@ export function createLifecycle({
|
||||
state.feed.removeViewListener = moveEnd.addEventListener(() => {
|
||||
components.ingestion.refreshIfMoved(activeViewer);
|
||||
});
|
||||
vesselState.timeline?.start();
|
||||
return components.ingestion.loadLivePositions(activeViewer);
|
||||
},
|
||||
|
||||
disable() {
|
||||
vesselState.timeline?.stop();
|
||||
removeViewListener();
|
||||
state.feed.enabled = false;
|
||||
invalidateAisSession();
|
||||
@@ -224,6 +226,7 @@ export function createLifecycle({
|
||||
},
|
||||
|
||||
destroy(viewer) {
|
||||
vesselState.timeline?.stop();
|
||||
removeViewListener();
|
||||
const activeViewer = viewer || state.viewer;
|
||||
invalidateAisSession();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Where a vessel is at world time `tMs` while the layer follows the world
|
||||
* clock: between the fix it moved from and the fix it moved to, along the
|
||||
* great circle; at the earlier fix before it, at the newer fix after it. A
|
||||
* vessel is never projected past its newest fix.
|
||||
* @param {{lat: number, lon: number, motion?: {fromLat: number, fromLon: number,
|
||||
* fromMs: number, toLat: number, toLon: number, toMs: number}|null}} record
|
||||
* @param {number} tMs
|
||||
* @returns {{lat: number, lon: number}}
|
||||
*/
|
||||
export function vesselPositionAt(record, tMs) {
|
||||
const motion = record?.motion;
|
||||
if (
|
||||
!motion ||
|
||||
!Number.isFinite(tMs) ||
|
||||
!(motion.toMs > motion.fromMs) ||
|
||||
tMs >= motion.toMs
|
||||
)
|
||||
return record;
|
||||
if (tMs <= motion.fromMs) return { lat: motion.fromLat, lon: motion.fromLon };
|
||||
const fraction = (tMs - motion.fromMs) / (motion.toMs - motion.fromMs);
|
||||
return greatCircleAt(
|
||||
motion.fromLat,
|
||||
motion.fromLon,
|
||||
motion.toLat,
|
||||
motion.toLon,
|
||||
fraction,
|
||||
);
|
||||
}
|
||||
|
||||
const RAD = Math.PI / 180;
|
||||
|
||||
/** The point `fraction` of the way from one position to another. */
|
||||
function greatCircleAt(lat1, lon1, lat2, lon2, fraction) {
|
||||
const p1 = unit(lat1, lon1);
|
||||
const p2 = unit(lat2, lon2);
|
||||
const dot = Math.min(
|
||||
1,
|
||||
Math.max(-1, p1[0] * p2[0] + p1[1] * p2[1] + p1[2] * p2[2]),
|
||||
);
|
||||
const angle = Math.acos(dot);
|
||||
if (angle < 1e-12) return { lat: lat2, lon: lon2 };
|
||||
const a = Math.sin((1 - fraction) * angle) / Math.sin(angle);
|
||||
const b = Math.sin(fraction * angle) / Math.sin(angle);
|
||||
const x = a * p1[0] + b * p2[0];
|
||||
const y = a * p1[1] + b * p2[1];
|
||||
const z = a * p1[2] + b * p2[2];
|
||||
return {
|
||||
lat: Math.atan2(z, Math.hypot(x, y)) / RAD,
|
||||
lon: Math.atan2(y, x) / RAD,
|
||||
};
|
||||
}
|
||||
|
||||
function unit(lat, lon) {
|
||||
const phi = lat * RAD;
|
||||
const lambda = lon * RAD;
|
||||
return [
|
||||
Math.cos(phi) * Math.cos(lambda),
|
||||
Math.cos(phi) * Math.sin(lambda),
|
||||
Math.sin(phi),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { vesselPositionAt } from './motion.js';
|
||||
|
||||
const record = {
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
motion: {
|
||||
fromLat: 0,
|
||||
fromLon: 0,
|
||||
fromMs: 1_000,
|
||||
toLat: 1,
|
||||
toLon: 1,
|
||||
toMs: 3_000,
|
||||
},
|
||||
};
|
||||
|
||||
test('a vessel holds at its fixes and moves between them along the great circle', () => {
|
||||
assert.deepEqual(vesselPositionAt(record, 500), { lat: 0, lon: 0 });
|
||||
assert.equal(vesselPositionAt(record, 3_000), record);
|
||||
assert.equal(vesselPositionAt(record, 9_000), record, 'never projected past');
|
||||
const half = vesselPositionAt(record, 2_000);
|
||||
assert.ok(Math.abs(half.lat - 0.5) < 1e-3);
|
||||
assert.ok(Math.abs(half.lon - 0.5) < 1e-3);
|
||||
});
|
||||
|
||||
test('a vessel with no move is at its record', () => {
|
||||
const still = { lat: 4, lon: 5 };
|
||||
assert.equal(vesselPositionAt(still, 2_000), still);
|
||||
assert.equal(vesselPositionAt({ ...still, motion: null }, 2_000).lat, 4);
|
||||
});
|
||||
@@ -98,7 +98,7 @@ test('vessel construction is inert and each instance owns its records, icon cach
|
||||
const two = createVesselState({ source, services: services() });
|
||||
for (const key of ['state', 'shipIconCache', '_scratchFocusScreen'])
|
||||
assert.notEqual(one[key], two[key]);
|
||||
assert.notEqual(one.state.records.byMmsi, two.state.records.byMmsi);
|
||||
assert.notEqual(one.state.records.byId, two.state.records.byId);
|
||||
assert.notEqual(one.state.feed, two.state.feed);
|
||||
assert.notEqual(one.state.records.all, two.state.records.all);
|
||||
assert.notEqual(one.state.records.unkeyed, two.state.records.unkeyed);
|
||||
@@ -272,7 +272,7 @@ test('partial-feed expiry releases a selected vessel and its pending trail', asy
|
||||
layer.selectById('222');
|
||||
current = snapshot([observation('111')], { complete: false });
|
||||
await layer.update();
|
||||
assert.equal(layer.getSelectedInfo().mmsi, '222');
|
||||
assert.equal(layer.getSelectedInfo().id, '222');
|
||||
assert.equal(signal.aborted, false);
|
||||
advance(5 * 60 * 1000);
|
||||
await layer.update();
|
||||
@@ -421,3 +421,97 @@ test('vessel stats name the provider the last snapshot reported', async () => {
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().source, 'Datalastic');
|
||||
});
|
||||
|
||||
test('two vessels sharing an MMSI under different ids stay two vessels', async () => {
|
||||
const shared = (id, longitude) => ({
|
||||
...observation(id),
|
||||
mmsi: '123456789',
|
||||
longitude,
|
||||
name: `HULL ${id}`,
|
||||
});
|
||||
const { layer, primitives } = setup({
|
||||
getSnapshot: async () =>
|
||||
snapshot([shared('hull-a', 4.05), shared('hull-b', 4.25)]),
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().count, 2);
|
||||
assert.equal(primitives.size, 2);
|
||||
assert.equal(layer.hasContact('hull-a'), true);
|
||||
assert.equal(layer.hasContact('hull-b'), true);
|
||||
// An MMSI is not a key, but a link naming one still finds a vessel with it.
|
||||
assert.equal(layer.hasContact('123456789'), true);
|
||||
assert.equal(layer.hasContact('987654321'), false);
|
||||
assert.deepEqual(
|
||||
layer
|
||||
.getAllPositions()
|
||||
.map(({ id }) => id)
|
||||
.sort(),
|
||||
['hull-a', 'hull-b'],
|
||||
);
|
||||
assert.equal(layer.selectById('hull-b'), true);
|
||||
assert.deepEqual(
|
||||
[layer.getSelectedInfo().id, layer.getSelectedInfo().mmsi],
|
||||
['hull-b', '123456789'],
|
||||
);
|
||||
assert.equal(
|
||||
layer.selectById('123456789'),
|
||||
true,
|
||||
'a link naming the MMSI selects a vessel with it',
|
||||
);
|
||||
assert.equal(layer.getSelectedInfo().mmsi, '123456789');
|
||||
assert.equal(layer.selectById('hull-b'), true);
|
||||
// A lookup by the shared MMSI still finds a vessel that has it.
|
||||
assert.equal(layer.findByQuery('123456789').mmsi, '123456789');
|
||||
});
|
||||
|
||||
test('a vessel with an id and no MMSI renders and can be selected', async () => {
|
||||
const { layer, primitives } = setup({
|
||||
getSnapshot: async () =>
|
||||
snapshot([{ ...observation('7f3c9a10-provider-id'), name: 'NO MMSI' }]),
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(primitives.size, 1);
|
||||
const [primitive] = primitives;
|
||||
assert.equal(primitive.id.id, '7f3c9a10-provider-id');
|
||||
assert.equal(primitive.id.mmsi, '');
|
||||
assert.equal(layer.selectById('7f3c9a10-provider-id'), true);
|
||||
const info = layer.getSelectedInfo();
|
||||
assert.equal(info.id, '7f3c9a10-provider-id');
|
||||
assert.equal(info.mmsi, '');
|
||||
assert.equal(layer.getCockpitSubject().id, '7f3c9a10-provider-id');
|
||||
assert.equal(layer.testing._getVesselStateForTest().trailId, info.id);
|
||||
});
|
||||
|
||||
test('a vessel keyed by a UUID is found in any case and names its drawn fix', async () => {
|
||||
const uuid = '0770e3ca-b896-c307-f499-4f9df2b8f86b';
|
||||
const { layer } = setup({
|
||||
getSnapshot: async () =>
|
||||
snapshot([{ ...observation(uuid.toUpperCase()), name: 'UUID HULL' }]),
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(layer.getSelectedFix(), null, 'nothing selected');
|
||||
// A share link carries the UUID in lowercase.
|
||||
assert.equal(layer.hasContact(uuid), true);
|
||||
assert.equal(layer.selectById(uuid), true);
|
||||
const fix = layer.getSelectedFix();
|
||||
assert.equal(fix.id, uuid.toUpperCase());
|
||||
assert.ok(fix.position, 'where the vessel is drawn');
|
||||
assert.equal(fix.observedAtMs, 500);
|
||||
assert.equal(layer.hasContact('0770e3ca-b896-c307-f499-4f9df2b8f86c'), false);
|
||||
});
|
||||
|
||||
test('AIS feed rows stay keyed by their MMSI', async () => {
|
||||
const { vesselSnapshot } = await import('../../sources/live/vessels.js');
|
||||
const row = { mmsi: '235000001', lat: 51.93, lon: 4.05, name: 'TERN' };
|
||||
const { layer } = setup({
|
||||
getSnapshot: async () =>
|
||||
vesselSnapshot({ rows: [row, { ...row, lon: 4.2 }], status: 'live' }),
|
||||
});
|
||||
await layer.update();
|
||||
assert.equal(layer.getStats().count, 1, 'one MMSI is one vessel on AIS');
|
||||
assert.equal(layer.selectById('235000001'), true);
|
||||
assert.deepEqual(
|
||||
[layer.getSelectedInfo().id, layer.getSelectedInfo().mmsi],
|
||||
['235000001', '235000001'],
|
||||
);
|
||||
});
|
||||
@@ -53,6 +53,11 @@ export const TRAIL_MAX_POINTS = 400;
|
||||
|
||||
export const TRAIL_MIN_MOVE_M = 25;
|
||||
|
||||
/** Whether the layer shows the world clock's time instead of the live feed:
|
||||
* its source declares a timeline and the clock is away from live. */
|
||||
export const followsClock = (vesselState) =>
|
||||
vesselState.timeline?.active() === true;
|
||||
|
||||
export const DEFAULT_AIS_RUNTIME = Object.freeze({
|
||||
now: () => Date.now(),
|
||||
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
||||
|
||||
+112
-48
@@ -24,9 +24,13 @@ import {
|
||||
REFRESH_MS,
|
||||
FOCUS_EVIDENCE_DEV,
|
||||
VESSEL_LIFT_M,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { VESSEL_OVERLAY_SOURCE_ID } from '../../data/vesselLabels.js';
|
||||
|
||||
const UUID_KEY =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function createQueries({
|
||||
vesselState,
|
||||
services,
|
||||
@@ -37,6 +41,28 @@ export function createQueries({
|
||||
const { state } = vesselState;
|
||||
const { formatKnots } = services.labels;
|
||||
|
||||
/**
|
||||
* The vessel keyed `id`, else the first whose MMSI is `id`: a link or a
|
||||
* caller that names a vessel by its MMSI still finds it. A share link
|
||||
* carries a UUID key in lowercase, so a UUID matches in any case.
|
||||
* @param {string} id
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function recordFor(id) {
|
||||
const record = state.records.byId?.get(id);
|
||||
if (record) return record;
|
||||
if (UUID_KEY.test(id)) {
|
||||
const wanted = id.toLowerCase();
|
||||
for (const candidate of state.records.byId?.values() ?? [])
|
||||
if (candidate.id.toLowerCase() === wanted) return candidate;
|
||||
return null;
|
||||
}
|
||||
if (!/^\d{1,10}$/.test(id)) return null;
|
||||
for (const candidate of state.records.byId?.values() ?? [])
|
||||
if (candidate.mmsi === id) return candidate;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds until the server's next reconnect attempt, or 0 when none is
|
||||
* scheduled. Mirrors the flights layer's `retryInSec` chip affordance.
|
||||
@@ -174,8 +200,9 @@ export function createQueries({
|
||||
* fields are null, never NaN/undefined. navStatus is always null: the
|
||||
* /api/vessels proxy does not surface AIS NavigationalStatus, so it
|
||||
* cannot be derived client-side.
|
||||
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
|
||||
* @returns {{id: string|null, mmsi: string|null, name: string|null,
|
||||
* @param {Object|null|undefined} record - `state.records.byId`/`state.records.all` entry.
|
||||
* @returns {{id: string|null, vesselId: string|null, mmsi: string|null,
|
||||
* name: string|null,
|
||||
* lat: number|null, lon: number|null, speedKts: number|null,
|
||||
* courseDeg: number|null, shipType: string|null, destination: string|null,
|
||||
* navStatus: null}}
|
||||
@@ -187,10 +214,14 @@ export function createQueries({
|
||||
const t = String(v ?? '').trim();
|
||||
return t || null;
|
||||
};
|
||||
const vesselId = text(record?.id);
|
||||
const mmsi = text(record?.mmsi);
|
||||
const name = text(record?.name);
|
||||
return {
|
||||
id: name || mmsi,
|
||||
// A display label, as every layer's analyst `id` is; `vesselId` is the
|
||||
// key and `mmsi` the vessel's MMSI when known.
|
||||
id: name || mmsi || vesselId,
|
||||
vesselId,
|
||||
mmsi,
|
||||
name,
|
||||
lat: num(record?.lat),
|
||||
@@ -224,32 +255,32 @@ export function createQueries({
|
||||
* trail picks as no-ops. The interaction wire also reserves sibling-owned
|
||||
* picks before this reducer so their camera action cannot mutate AIS state.
|
||||
*
|
||||
* @param {{selectedMmsi?: string|number|null, pickedMmsi?: string|number|null,
|
||||
* @param {{selectedId?: string|number|null, pickedId?: string|number|null,
|
||||
* gesture?: 'click'|'escape'}} input - Current selection plus owned pick.
|
||||
* @returns {{action: 'none'|'select'|'deselect'}}
|
||||
*/
|
||||
|
||||
function reduceVesselSelection(input = {}) {
|
||||
const selectedMmsi = normalizeSelectionMmsi(input.selectedMmsi);
|
||||
const pickedMmsi = normalizeSelectionMmsi(input.pickedMmsi);
|
||||
const selectedId = normalizeSelectionId(input.selectedId);
|
||||
const pickedId = normalizeSelectionId(input.pickedId);
|
||||
const gesture = input.gesture || 'click';
|
||||
|
||||
if (gesture === 'escape') {
|
||||
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
|
||||
return selectedId ? { action: 'deselect' } : { action: 'none' };
|
||||
}
|
||||
if (gesture !== 'click') {
|
||||
return { action: 'none' };
|
||||
}
|
||||
if (pickedMmsi) {
|
||||
if (pickedMmsi === selectedMmsi) {
|
||||
if (pickedId) {
|
||||
if (pickedId === selectedId) {
|
||||
return { action: 'none' };
|
||||
}
|
||||
return { action: 'select' };
|
||||
}
|
||||
return selectedMmsi ? { action: 'deselect' } : { action: 'none' };
|
||||
return selectedId ? { action: 'deselect' } : { action: 'none' };
|
||||
}
|
||||
|
||||
function normalizeSelectionMmsi(value) {
|
||||
function normalizeSelectionId(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
const text = String(value).trim();
|
||||
return text || null;
|
||||
@@ -274,9 +305,10 @@ export function createQueries({
|
||||
statsRefreshInterval: 1000,
|
||||
|
||||
/**
|
||||
* Find a vessel by exact MMSI or case-insensitive name substring.
|
||||
* @param {string|number} query MMSI or partial vessel name.
|
||||
* @returns {{ mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||||
* Find a vessel by its exact id, exact MMSI or case-insensitive name
|
||||
* substring. Vessels can share an MMSI; the first that has it is found.
|
||||
* @param {string|number} query Vessel id, MMSI or partial vessel name.
|
||||
* @returns {{ id: string, mmsi: string, name: string, position: Cesium.Cartesian3, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||||
*/
|
||||
findByQuery(query) {
|
||||
if (query === null || query === undefined) return null;
|
||||
@@ -285,9 +317,9 @@ export function createQueries({
|
||||
const q = String(query).trim();
|
||||
if (!q) return null;
|
||||
|
||||
let record = null;
|
||||
if (/^\d+$/.test(q)) {
|
||||
record = state.records.byMmsi.get(q) || null;
|
||||
let record = state.records.byId.get(q) || null;
|
||||
if (!record && /^\d+$/.test(q)) {
|
||||
record = records.find((r) => r.mmsi === q) || null;
|
||||
}
|
||||
if (!record) {
|
||||
const lower = q.toLowerCase();
|
||||
@@ -305,6 +337,7 @@ export function createQueries({
|
||||
components.rendering.getVisual(record).position;
|
||||
if (!position) return null;
|
||||
return {
|
||||
id: record.id,
|
||||
mmsi: record.mmsi,
|
||||
name: record.name,
|
||||
position,
|
||||
@@ -321,7 +354,7 @@ export function createQueries({
|
||||
* @param {Cesium.Cartesian3} centerCartesian Center of the search.
|
||||
* @param {number} rangeM Max distance in meters (non-finite = unbounded).
|
||||
* @param {number} [maxCount=25] Maximum entries to return.
|
||||
* @returns {Array<{ mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
|
||||
* @returns {Array<{ id: string, mmsi: string, name: string, position: Cesium.Cartesian3, distanceM: number }>}
|
||||
*/
|
||||
getNearby(centerCartesian, rangeM, maxCount = 25) {
|
||||
const records = state.records.all;
|
||||
@@ -341,6 +374,7 @@ export function createQueries({
|
||||
const distanceM = Cesium.Cartesian3.distance(centerCartesian, position);
|
||||
if (!Number.isFinite(distanceM) || distanceM > range) continue;
|
||||
entries.push({
|
||||
id: record.id,
|
||||
mmsi: record.mmsi,
|
||||
name: record.name,
|
||||
position,
|
||||
@@ -362,7 +396,7 @@ export function createQueries({
|
||||
* @returns {Array<{ id: string, name: string, latitude: number,
|
||||
* longitude: number, course: number|null, heading: number|null,
|
||||
* type: string, color: string, stale: boolean }>} Empty while the
|
||||
* layer is off. A ship with no MMSI has an `unkeyed:` id.
|
||||
* layer is off. A ship with no id has an `unkeyed:` id.
|
||||
*/
|
||||
getRadarContacts(lat, lon, rangeM, maxCount = 60) {
|
||||
const records = state.records.all;
|
||||
@@ -372,7 +406,7 @@ export function createQueries({
|
||||
const cap =
|
||||
Number.isFinite(maxCount) && maxCount > 0 ? Math.floor(maxCount) : 60;
|
||||
const center = { lat, lon };
|
||||
const subject = state.cockpitSubjectMmsi;
|
||||
const subject = state.cockpitSubjectId;
|
||||
const finite = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const number = Number(value);
|
||||
@@ -381,7 +415,7 @@ export function createQueries({
|
||||
|
||||
const entries = [];
|
||||
records.forEach((record, index) => {
|
||||
if (subject && String(record.mmsi) === subject) return;
|
||||
if (subject && String(record.id) === subject) return;
|
||||
const distanceM = greatCircleMeters(center, {
|
||||
lat: record.lat,
|
||||
lon: record.lon,
|
||||
@@ -391,8 +425,8 @@ export function createQueries({
|
||||
});
|
||||
entries.sort((a, b) => a.distanceM - b.distanceM);
|
||||
return entries.slice(0, cap).map(({ record, index }) => ({
|
||||
// A ship with no MMSI still needs a key of its own on the scope.
|
||||
id: record.mmsi ? String(record.mmsi) : `unkeyed:${index}`,
|
||||
// A ship with no id still needs a key of its own on the scope.
|
||||
id: record.id ? String(record.id) : `unkeyed:${index}`,
|
||||
name: String(record.name || record.mmsi || ''),
|
||||
latitude: record.lat,
|
||||
longitude: record.lon,
|
||||
@@ -413,22 +447,22 @@ export function createQueries({
|
||||
* Whether this layer still carries a vessel, in O(1).
|
||||
*
|
||||
* Mirror of `flights.hasContact`: presence consumers must not infer absence
|
||||
* from the capped `getAllPositions` rows. `vesselMap` is MMSI-keyed.
|
||||
* from the capped `getAllPositions` rows. Records are keyed by id.
|
||||
* A disabled layer keeps its records, so it must decline rather than answer
|
||||
* from data the user can no longer see.
|
||||
* @param {string} mmsi Vessel identifier.
|
||||
* @param {string} id Vessel id.
|
||||
* @returns {boolean|null} Presence, or null when the layer is disabled or
|
||||
* holds no data and therefore cannot answer.
|
||||
*/
|
||||
hasContact(mmsi) {
|
||||
hasContact(id) {
|
||||
if (
|
||||
!state.feed.enabled ||
|
||||
!state.records.byMmsi ||
|
||||
state.records.byMmsi.size === 0
|
||||
!state.records.byId ||
|
||||
state.records.byId.size === 0
|
||||
)
|
||||
return null;
|
||||
if (!mmsi) return false;
|
||||
return state.records.byMmsi.has(String(mmsi).trim());
|
||||
if (!id) return false;
|
||||
return Boolean(recordFor(String(id).trim()));
|
||||
},
|
||||
|
||||
getAllPositions(maxCount = 800) {
|
||||
@@ -444,8 +478,8 @@ export function createQueries({
|
||||
const position = visual.billboard?.position || visual.position;
|
||||
if (!position) continue;
|
||||
result.push({
|
||||
id: record.mmsi,
|
||||
label: record.name || record.mmsi,
|
||||
id: record.id,
|
||||
label: record.name || record.mmsi || record.id,
|
||||
position,
|
||||
latitude: record.lat,
|
||||
longitude: record.lon,
|
||||
@@ -478,16 +512,16 @@ export function createQueries({
|
||||
},
|
||||
|
||||
/**
|
||||
* Select a vessel by MMSI via the same path as a map click
|
||||
* (highlight + HUD update).
|
||||
* @param {string|number} mmsi Vessel MMSI.
|
||||
* Select a vessel by id via the same path as a map click
|
||||
* (highlight + HUD update). An AIS feed's vessels are keyed by MMSI.
|
||||
* @param {string|number} id Vessel id.
|
||||
* @returns {boolean} True if a matching vessel was selected.
|
||||
*/
|
||||
selectById(mmsi) {
|
||||
if (mmsi === null || mmsi === undefined) return false;
|
||||
const target = String(mmsi).trim();
|
||||
selectById(id) {
|
||||
if (id === null || id === undefined) return false;
|
||||
const target = String(id).trim();
|
||||
if (!target) return false;
|
||||
const record = state.records.byMmsi.get(target);
|
||||
const record = recordFor(target);
|
||||
if (!record) return false;
|
||||
components.selection.selectVessel(record);
|
||||
return true;
|
||||
@@ -504,12 +538,13 @@ export function createQueries({
|
||||
|
||||
/**
|
||||
* Get info about the currently selected vessel.
|
||||
* @returns {{ mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||||
* @returns {{ id: string, mmsi: string, name: string, latitude: number, longitude: number, speedKt: number|null, course: number|null, type: string }|null}
|
||||
*/
|
||||
getSelectedInfo() {
|
||||
const record = state.selectedRecord;
|
||||
if (!record) return null;
|
||||
return {
|
||||
id: record.id,
|
||||
mmsi: record.mmsi,
|
||||
name: record.name,
|
||||
latitude: record.lat,
|
||||
@@ -520,6 +555,33 @@ export function createQueries({
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Where the selected vessel is drawn now, for a share link that names it:
|
||||
* its id, its drawn position and the time that position stands for (the
|
||||
* world time while the layer follows the clock, else its fix's time).
|
||||
* @returns {{ id: string, position: Cesium.Cartesian3, observedAtMs: number|null }|null}
|
||||
* Null when the layer is off, nothing is selected or it is not drawn.
|
||||
*/
|
||||
getSelectedFix() {
|
||||
if (!state.feed.enabled) return null;
|
||||
const record = state.selectedRecord;
|
||||
if (!record?.id) return null;
|
||||
const visual = components.rendering.getVisual(record);
|
||||
const position = visual?.billboard?.position || visual?.position;
|
||||
if (!position) return null;
|
||||
const fixEpoch = record.lastPositionEpoch;
|
||||
const observedAtMs = followsClock(vesselState)
|
||||
? vesselState.worldNow()
|
||||
: Number.isFinite(fixEpoch)
|
||||
? fixEpoch * 1000
|
||||
: (record.fixReceivedAtMs ?? null);
|
||||
return {
|
||||
id: record.id,
|
||||
position,
|
||||
observedAtMs: Number.isFinite(observedAtMs) ? observedAtMs : null,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Describe the selected vessel for the Cockpit bridge view.
|
||||
* @returns {object|null} `vesselCockpitInfo` fields plus `seaSurfaceHeightM`
|
||||
@@ -545,16 +607,16 @@ export function createQueries({
|
||||
/**
|
||||
* Mark the vessel whose bridge the Cockpit camera occupies, or release it.
|
||||
* Its sprite, card and detection bracket stay hidden while it is held.
|
||||
* @param {string|number|null} mmsi Subject MMSI, or null to release.
|
||||
* @param {string|number|null} id Subject vessel id, or null to release.
|
||||
* @returns {boolean} Always true.
|
||||
*/
|
||||
setCockpitSubject(mmsi) {
|
||||
const next = normalizeSelectionMmsi(mmsi);
|
||||
if (state.cockpitSubjectMmsi === next) return true;
|
||||
state.cockpitSubjectMmsi = next;
|
||||
setCockpitSubject(id) {
|
||||
const next = normalizeSelectionId(id);
|
||||
if (state.cockpitSubjectId === next) return true;
|
||||
state.cockpitSubjectId = next;
|
||||
// The ridden ship's trail runs from under the eye: hidden, its history
|
||||
// kept, while the bridge holds the ship it belongs to.
|
||||
state.trail?.setVisible?.(!next || next !== state.trailMmsi);
|
||||
state.trail?.setVisible?.(!next || next !== state.trailId);
|
||||
components.rendering.updateVisibility(true);
|
||||
return true;
|
||||
},
|
||||
@@ -607,7 +669,7 @@ export function createQueries({
|
||||
if (!position) continue;
|
||||
result.push({
|
||||
position,
|
||||
sourceId: record.mmsi,
|
||||
sourceId: record.id,
|
||||
id: record.name || record.mmsi || 'VESSEL',
|
||||
type: 'SEA',
|
||||
skipLabel: record === selected,
|
||||
@@ -660,6 +722,8 @@ export function createQueries({
|
||||
retryInSec: aisRetryInSec(),
|
||||
// Same field as flights and military, so Contacts names the provider.
|
||||
source: layer.source,
|
||||
// Following the world clock, the snapshot's age is told from its time.
|
||||
...(followsClock(vesselState) && { atMs: vesselState.worldNow() }),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -673,7 +737,7 @@ export function createQueries({
|
||||
mapAnalystRecord,
|
||||
vesselDatumHeightM,
|
||||
reduceVesselSelection,
|
||||
normalizeSelectionMmsi,
|
||||
normalizeSelectionId,
|
||||
methods,
|
||||
};
|
||||
}
|
||||
@@ -134,7 +134,7 @@ test("the ridden ship's trail is hidden while the bridge holds it", () => {
|
||||
};
|
||||
const layer = layerWith([record('own', 0), record('other', 2)], {
|
||||
trail,
|
||||
trailMmsi: 'own',
|
||||
trailId: 'own',
|
||||
});
|
||||
layer.setCockpitSubject('own');
|
||||
assert.deepEqual(shown, [false]);
|
||||
|
||||
@@ -3,17 +3,25 @@ import {
|
||||
SELECTED_PIN_REFRESHES,
|
||||
} from './recordPolicy.js';
|
||||
|
||||
/** Normalize AIS display fields without allocating scene resources. */
|
||||
/**
|
||||
* Normalize AIS display fields without allocating scene resources. `id` is the
|
||||
* vessel's key, the stable id its source gives; a row that names none (an
|
||||
* AIS feed's own rows) is keyed by its MMSI. `mmsi` is only an attribute,
|
||||
* shown when known: vessels can share one. A row with neither is unkeyed.
|
||||
*/
|
||||
export function normalizeVessel(row) {
|
||||
const lat = Number(row.lat);
|
||||
const lon = Number(row.lon);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
|
||||
const mmsi = String(row.mmsi || '').trim();
|
||||
const id = String(row.id ?? mmsi).trim();
|
||||
return {
|
||||
lat,
|
||||
lon,
|
||||
name: String(row.name || row.mmsi || 'VESSEL'),
|
||||
mmsi: String(row.mmsi || '').trim(),
|
||||
reference: row.reference ?? String(row.mmsi || '').trim(),
|
||||
name: String(row.name || mmsi || 'VESSEL'),
|
||||
id,
|
||||
mmsi,
|
||||
reference: row.reference ?? id,
|
||||
imo: String(row.imo || ''),
|
||||
type: String(row.type || ''),
|
||||
destination: String(row.destination || ''),
|
||||
@@ -44,17 +52,51 @@ function finiteNumber(value) {
|
||||
return Number.isFinite(number) ? number : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The move from a record's fix to the next row's, when the fix moves on in
|
||||
* time; otherwise the move it was making, if the row repeats its fix.
|
||||
*/
|
||||
function motionBetween(record, next) {
|
||||
const fromEpoch = record.lastPositionEpoch;
|
||||
const toEpoch = next.lastPositionEpoch;
|
||||
if (fromEpoch === toEpoch) return record.motion ?? null;
|
||||
if (!Number.isFinite(fromEpoch) || !Number.isFinite(toEpoch)) return null;
|
||||
if (!(toEpoch > fromEpoch)) return null;
|
||||
return {
|
||||
fromLat: record.lat,
|
||||
fromLon: record.lon,
|
||||
fromMs: fromEpoch * 1000,
|
||||
toLat: next.lat,
|
||||
toLon: next.lon,
|
||||
toMs: toEpoch * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/** Own stable vessel records and bounded incomplete/selected retention. */
|
||||
export class VesselRecords {
|
||||
constructor({ now = () => Date.now() } = {}) {
|
||||
this.now = now;
|
||||
this.byMmsi = new Map();
|
||||
this.byId = new Map();
|
||||
this.unkeyed = [];
|
||||
this.all = [];
|
||||
}
|
||||
/**
|
||||
* Fold a refresh into the records. Following the world clock (`follow`),
|
||||
* a vessel whose fix moves on keeps where it moved from, so it can be drawn
|
||||
* between the two (see motion.js). `replace`: the rows are the world at
|
||||
* another time (the clock jumped), so they stand in for every vessel: one
|
||||
* they lack is gone at once, selected or not, and none moves from where it
|
||||
* was at the time the clock left.
|
||||
*/
|
||||
reconcile(
|
||||
rows,
|
||||
{ complete = true, selectedRecord = null, cap = Infinity },
|
||||
{
|
||||
complete = true,
|
||||
selectedRecord = null,
|
||||
cap = Infinity,
|
||||
replace = false,
|
||||
follow = false,
|
||||
},
|
||||
effects,
|
||||
) {
|
||||
const receivedAtMs = this.now();
|
||||
@@ -78,14 +120,14 @@ export class VesselRecords {
|
||||
if (!next) continue;
|
||||
next.receivedAtMs = receivedAtMs;
|
||||
stampFix(next, receivedAtMs, feedLatestEpoch);
|
||||
if (!next.mmsi) {
|
||||
if (!next.id) {
|
||||
effects.add(next);
|
||||
this.unkeyed.push(next);
|
||||
continue;
|
||||
}
|
||||
if (seen.has(next.mmsi)) continue;
|
||||
seen.add(next.mmsi);
|
||||
const record = this.byMmsi.get(next.mmsi);
|
||||
if (seen.has(next.id)) continue;
|
||||
seen.add(next.id);
|
||||
const record = this.byId.get(next.id);
|
||||
if (record) {
|
||||
const before = effects.beforeUpdate(record);
|
||||
// A feed re-serving a cached row repeats its fix: the fix keeps the
|
||||
@@ -94,7 +136,10 @@ export class VesselRecords {
|
||||
record.fixReceivedAtMs = next.fixReceivedAtMs;
|
||||
record.fixFeedEpoch = next.fixFeedEpoch;
|
||||
}
|
||||
if (replace) record.motion = null;
|
||||
else if (follow) record.motion = motionBetween(record, next);
|
||||
record.reference = next.reference;
|
||||
record.mmsi = next.mmsi;
|
||||
record.receivedAtMs = next.receivedAtMs;
|
||||
record.lat = next.lat;
|
||||
record.lon = next.lon;
|
||||
@@ -112,11 +157,17 @@ export class VesselRecords {
|
||||
effects.updated(record, before);
|
||||
} else {
|
||||
effects.add(next);
|
||||
this.byMmsi.set(next.mmsi, next);
|
||||
this.byId.set(next.id, next);
|
||||
}
|
||||
}
|
||||
for (const [mmsi, record] of this.byMmsi) {
|
||||
if (seen.has(mmsi)) continue;
|
||||
for (const [id, record] of this.byId) {
|
||||
if (seen.has(id)) continue;
|
||||
if (replace) {
|
||||
effects.remove(record, record === selectedRecord);
|
||||
this.byId.delete(id);
|
||||
effects.removed(id);
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!complete &&
|
||||
Number.isFinite(record.receivedAtMs) &&
|
||||
@@ -137,18 +188,18 @@ export class VesselRecords {
|
||||
}
|
||||
// Selection teardown still sees the record before its store entry is removed.
|
||||
effects.remove(record, record === selectedRecord);
|
||||
this.byMmsi.delete(mmsi);
|
||||
effects.removed(mmsi);
|
||||
this.byId.delete(id);
|
||||
effects.removed(id);
|
||||
}
|
||||
if (this.byMmsi.size + this.unkeyed.length > cap) {
|
||||
for (const [mmsi, record] of this.byMmsi) {
|
||||
if (this.byMmsi.size + this.unkeyed.length <= cap) break;
|
||||
if (seen.has(mmsi) || record === selectedRecord) continue;
|
||||
if (this.byId.size + this.unkeyed.length > cap) {
|
||||
for (const [id, record] of this.byId) {
|
||||
if (this.byId.size + this.unkeyed.length <= cap) break;
|
||||
if (seen.has(id) || record === selectedRecord) continue;
|
||||
effects.remove(record, false);
|
||||
this.byMmsi.delete(mmsi);
|
||||
effects.removed(mmsi);
|
||||
this.byId.delete(id);
|
||||
effects.removed(id);
|
||||
}
|
||||
}
|
||||
this.all = [...this.byMmsi.values(), ...this.unkeyed];
|
||||
this.all = [...this.byId.values(), ...this.unkeyed];
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ function setup() {
|
||||
beforeUpdate: (record) => record.type,
|
||||
updated: (record, before) => events.push(['update', record, before]),
|
||||
remove: (record, evicted) =>
|
||||
events.push(['remove', record, evicted, store.byMmsi.has(record.mmsi)]),
|
||||
removed: (id) => events.push(['removed', id, store.byMmsi.has(id)]),
|
||||
events.push(['remove', record, evicted, store.byId.has(record.id)]),
|
||||
removed: (id) => events.push(['removed', id, store.byId.has(id)]),
|
||||
staleSelected: (record) => events.push(['stale', record]),
|
||||
};
|
||||
return {
|
||||
@@ -40,14 +40,14 @@ test('vessel reconciliation preserves record identity and first duplicate while
|
||||
row('222', { name: 'duplicate' }),
|
||||
row('bad', { lat: NaN }),
|
||||
]);
|
||||
assert.equal(probe.store.byMmsi.size, 2);
|
||||
assert.equal(probe.store.byMmsi.get('222').name, '222');
|
||||
const record = probe.store.byMmsi.get('111');
|
||||
assert.equal(probe.store.byId.size, 2);
|
||||
assert.equal(probe.store.byId.get('222').name, '222');
|
||||
const record = probe.store.byId.get('111');
|
||||
probe.reconcile([row('111', { lat: 51.94, type: 'Tanker' })]);
|
||||
assert.equal(probe.store.byMmsi.get('111'), record);
|
||||
assert.equal(probe.store.byId.get('111'), record);
|
||||
assert.equal(record.lat, 51.94);
|
||||
assert.equal(probe.events.find((event) => event[0] === 'update')[2], 'Cargo');
|
||||
assert.equal(probe.store.byMmsi.has('222'), false);
|
||||
assert.equal(probe.store.byId.has('222'), false);
|
||||
for (const field of ['position', 'surfacePosition', 'normal', 'billboard'])
|
||||
assert.equal(Object.hasOwn(record, field), false);
|
||||
assert.deepEqual(structuredClone(record), record);
|
||||
@@ -56,14 +56,14 @@ test('vessel reconciliation preserves record identity and first duplicate while
|
||||
test('selected vessel remains pinned for three complete misses and teardown precedes removal', () => {
|
||||
const probe = setup();
|
||||
probe.reconcile([row('111'), row('222')]);
|
||||
const selectedRecord = probe.store.byMmsi.get('111');
|
||||
const selectedRecord = probe.store.byId.get('111');
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
probe.reconcile([row('222')], { selectedRecord });
|
||||
assert.equal(probe.store.byMmsi.get('111'), selectedRecord);
|
||||
assert.equal(probe.store.byId.get('111'), selectedRecord);
|
||||
assert.equal(selectedRecord.missedRefreshes, i);
|
||||
}
|
||||
probe.reconcile([row('222')], { selectedRecord });
|
||||
assert.equal(probe.store.byMmsi.has('111'), false);
|
||||
assert.equal(probe.store.byId.has('111'), false);
|
||||
const removal = probe.events.find(
|
||||
(event) => event[0] === 'remove' && event[1] === selectedRecord,
|
||||
);
|
||||
@@ -77,13 +77,13 @@ test('selected vessel remains pinned for three complete misses and teardown prec
|
||||
test('partial retention respects age and row cap while preserving the selected contact', () => {
|
||||
const probe = setup();
|
||||
probe.reconcile([row('111'), row('222')], { cap: 2 });
|
||||
const selectedRecord = probe.store.byMmsi.get('111');
|
||||
const selectedRecord = probe.store.byId.get('111');
|
||||
probe.reconcile([row('333')], { complete: false, selectedRecord, cap: 2 });
|
||||
assert.deepEqual([...probe.store.byMmsi.keys()], ['111', '333']);
|
||||
assert.deepEqual([...probe.store.byId.keys()], ['111', '333']);
|
||||
assert.equal(selectedRecord.missedRefreshes, 1);
|
||||
probe.advance(5 * 60 * 1000);
|
||||
probe.reconcile([row('333')], { complete: false, selectedRecord, cap: 2 });
|
||||
assert.equal(probe.store.byMmsi.has('111'), false);
|
||||
assert.equal(probe.store.byId.has('111'), false);
|
||||
assert.equal(
|
||||
probe.events.find(
|
||||
(event) => event[0] === 'remove' && event[1] === selectedRecord,
|
||||
@@ -107,7 +107,7 @@ test('unkeyed records rebuild on each refresh and numeric coercion keeps unknown
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(probe.store.byMmsi.size, 0);
|
||||
assert.equal(probe.store.byId.size, 0);
|
||||
assert.equal(probe.store.all.length, 1);
|
||||
assert.equal(normalizeVessel(row('bad', { lon: Infinity })), null);
|
||||
});
|
||||
@@ -120,14 +120,14 @@ test("each fix keeps when it was first received and its refresh's newest fix", (
|
||||
row('333'),
|
||||
]);
|
||||
for (const mmsi of ['111', '222', '333']) {
|
||||
const record = probe.store.byMmsi.get(mmsi);
|
||||
const record = probe.store.byId.get(mmsi);
|
||||
assert.equal(record.fixFeedEpoch, 1_800_000_030, mmsi);
|
||||
assert.equal(record.fixReceivedAtMs, 1000, mmsi);
|
||||
}
|
||||
// A new fix is stamped afresh.
|
||||
probe.advance(60_000);
|
||||
probe.reconcile([row('111', { last_position_epoch: 1_800_000_050 })]);
|
||||
const record = probe.store.byMmsi.get('111');
|
||||
const record = probe.store.byId.get('111');
|
||||
assert.equal(record.fixFeedEpoch, 1_800_000_050);
|
||||
assert.equal(record.fixReceivedAtMs, 61_000);
|
||||
// The same fix re-served by later polls keeps its first receipt, while
|
||||
@@ -141,3 +141,43 @@ test("each fix keeps when it was first received and its refresh's newest fix", (
|
||||
assert.equal(record.fixFeedEpoch, 1_800_000_050);
|
||||
assert.equal(record.receivedAtMs, 91_000);
|
||||
});
|
||||
|
||||
test('vessels are keyed by their source id; a shared MMSI is only an attribute', () => {
|
||||
const probe = setup();
|
||||
probe.reconcile([
|
||||
row('123456789', { id: 'hull-a', name: 'A' }),
|
||||
row('123456789', { id: 'hull-b', name: 'B' }),
|
||||
row('', { id: 'hull-c', name: 'C' }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
[...probe.store.byId.keys()],
|
||||
['hull-a', 'hull-b', 'hull-c'],
|
||||
);
|
||||
assert.equal(probe.store.unkeyed.length, 0);
|
||||
const [a, b, c] = probe.store.all;
|
||||
assert.deepEqual([a.mmsi, b.mmsi, c.mmsi], ['123456789', '123456789', '']);
|
||||
assert.equal(a.reference, 'hull-a');
|
||||
// The number moves to another hull: the vessel keeps its id.
|
||||
probe.reconcile([
|
||||
row('', { id: 'hull-a', name: 'A' }),
|
||||
row('123456789', { id: 'hull-b', name: 'B' }),
|
||||
row('123456789', { id: 'hull-c', name: 'C' }),
|
||||
]);
|
||||
assert.equal(probe.store.byId.get('hull-a'), a);
|
||||
assert.equal(a.mmsi, '');
|
||||
assert.equal(probe.store.byId.get('hull-c').mmsi, '123456789');
|
||||
});
|
||||
|
||||
test('an AIS row with no id is keyed by its MMSI; one with neither is unkeyed', () => {
|
||||
assert.deepEqual(
|
||||
[
|
||||
normalizeVessel(row('366999712')).id,
|
||||
normalizeVessel(row('366999712')).mmsi,
|
||||
],
|
||||
['366999712', '366999712'],
|
||||
);
|
||||
const probe = setup();
|
||||
probe.reconcile([row(''), row('', { id: '' })]);
|
||||
assert.equal(probe.store.byId.size, 0);
|
||||
assert.equal(probe.store.unkeyed.length, 2);
|
||||
});
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
FOCUS_UPDATE_MS,
|
||||
LABEL_GRID_PX,
|
||||
CARD_MIN_SEP_PX,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
import { vesselPositionAt } from './motion.js';
|
||||
|
||||
/** How often vessels move on as the world clock plays (wall ms). */
|
||||
const FOLLOW_POSITION_MS = 250;
|
||||
|
||||
export function createRendering({
|
||||
vesselState,
|
||||
@@ -42,20 +47,16 @@ export function createRendering({
|
||||
visual = { billboard: record.billboard || null };
|
||||
visualRecords.set(record, visual);
|
||||
}
|
||||
// Following the world clock, a vessel is drawn where it was at the time.
|
||||
const { lat, lon } = followsClock(vesselState)
|
||||
? vesselPositionAt(record, vesselState.worldNow())
|
||||
: record;
|
||||
const heightM = components.queries.vesselDatumHeightM(
|
||||
components.tracking.currentGeoidN(record.lat, record.lon),
|
||||
components.tracking.currentGeoidN(lat, lon),
|
||||
VESSEL_LIFT_M,
|
||||
);
|
||||
visual.position = Cesium.Cartesian3.fromDegrees(
|
||||
record.lon,
|
||||
record.lat,
|
||||
heightM,
|
||||
);
|
||||
visual.surfacePosition = Cesium.Cartesian3.fromDegrees(
|
||||
record.lon,
|
||||
record.lat,
|
||||
0,
|
||||
);
|
||||
visual.position = Cesium.Cartesian3.fromDegrees(lon, lat, heightM);
|
||||
visual.surfacePosition = Cesium.Cartesian3.fromDegrees(lon, lat, 0);
|
||||
visual.normal = Cesium.Ellipsoid.WGS84.geodeticSurfaceNormal(
|
||||
visual.position,
|
||||
new Cesium.Cartesian3(),
|
||||
@@ -212,9 +213,27 @@ export function createRendering({
|
||||
// own label publication requests a frame, and scheduling on that would loop.
|
||||
function installRuntime(viewer) {
|
||||
if (state.preRenderRemover || !viewer) return;
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() =>
|
||||
updateVisibility(),
|
||||
);
|
||||
state.preRenderRemover = viewer.scene.preRender.addEventListener(() => {
|
||||
if (followsClock(vesselState)) followClockPositions();
|
||||
updateVisibility();
|
||||
});
|
||||
}
|
||||
|
||||
// As the world clock plays, vessels between two fixes move on along them.
|
||||
let followedAtWallMs = -Infinity;
|
||||
let followedAtMs = null;
|
||||
function followClockPositions() {
|
||||
const wallMs = performance.now();
|
||||
if (wallMs - followedAtWallMs < FOLLOW_POSITION_MS) return;
|
||||
followedAtWallMs = wallMs;
|
||||
const atMs = vesselState.worldNow();
|
||||
if (atMs === followedAtMs) return;
|
||||
followedAtMs = atMs;
|
||||
for (const record of state.records.all) {
|
||||
if (!record.motion) continue;
|
||||
const visual = prepareRecordVisual(record);
|
||||
if (visual.billboard) visual.billboard.position = visual.position;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleTrailingVisibilityPass(now) {
|
||||
@@ -288,7 +307,7 @@ export function createRendering({
|
||||
if (visual.billboard) {
|
||||
// The bridge camera sits on this sprite; showing it would fill the view.
|
||||
visual.billboard.show =
|
||||
visible && record.mmsi !== state.cockpitSubjectMmsi;
|
||||
visible && record.id !== state.cockpitSubjectId;
|
||||
if (visible && doRotations && scene) {
|
||||
const rot = screenProjectedRotation(
|
||||
scene,
|
||||
@@ -426,8 +445,7 @@ export function createRendering({
|
||||
const selected = state.selectedRecord;
|
||||
// From the bridge, the selected vessel is the viewer; its card would be
|
||||
// pinned to the camera position.
|
||||
const selectedCarded =
|
||||
selected && selected.mmsi !== state.cockpitSubjectMmsi;
|
||||
const selectedCarded = selected && selected.id !== state.cockpitSubjectId;
|
||||
const entries = selectedCarded
|
||||
? [components.cards.buildSelectedVesselCard(selected)]
|
||||
: [];
|
||||
@@ -513,17 +531,17 @@ export function createRendering({
|
||||
VESSEL_CARD_FADE_DISTANCE_M,
|
||||
);
|
||||
if (!card.interactive) return card;
|
||||
const mmsi = String(card.id || '').startsWith('vessel:')
|
||||
const id = String(card.id || '').startsWith('vessel:')
|
||||
? card.id.slice('vessel:'.length)
|
||||
: '';
|
||||
const mmsi = state.records.byId.get(id)?.mmsi || '';
|
||||
return {
|
||||
...card,
|
||||
accessibilityLabel: t('Focus vessel {name}, MMSI {mmsi}', {
|
||||
name: card.title,
|
||||
mmsi,
|
||||
}),
|
||||
accessibilityLabel: mmsi
|
||||
? t('Focus vessel {name}, MMSI {mmsi}', { name: card.title, mmsi })
|
||||
: t('Focus {label}', { label: card.title }),
|
||||
activate: () => {
|
||||
const record = state.records.byMmsi.get(mmsi);
|
||||
const record = state.records.byId.get(id);
|
||||
if (!record) return false;
|
||||
components.selection.selectAndFocusVessel(record);
|
||||
return true;
|
||||
@@ -555,7 +573,8 @@ export function createRendering({
|
||||
text &&
|
||||
text !== 'VESSEL' &&
|
||||
!/^MMSI\s*\d+$/i.test(text) &&
|
||||
text !== record.mmsi,
|
||||
text !== record.mmsi &&
|
||||
text !== record.id,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,13 +39,15 @@ export function createSelection({
|
||||
if (!state.feed.enabled) return;
|
||||
// The bridge view owns the selection: a click on open water must not
|
||||
// deselect the vessel the camera is riding.
|
||||
if (state.cockpitSubjectMmsi) return;
|
||||
if (state.cockpitSubjectId) return;
|
||||
const picked = pickContact
|
||||
? pickContact(viewer.scene, click.position)
|
||||
: viewer.scene.pick(click.position);
|
||||
const pickedId = resolvePickId(picked);
|
||||
let record = pickedId ? state.records.byMmsi.get(pickedId) : null;
|
||||
let record = pickedId ? state.records.byId.get(pickedId) : null;
|
||||
const rawId = picked?.id ?? picked?.primitive?.id;
|
||||
// Every vessel record carries an `mmsi` field ('' when unknown); its
|
||||
// key is `id`.
|
||||
const ownRecordPick =
|
||||
rawId && typeof rawId === 'object' && Object.hasOwn(rawId, 'mmsi');
|
||||
|
||||
@@ -85,10 +87,10 @@ export function createSelection({
|
||||
if (cycloneBackground && cardHit?.sourceId !== VESSEL_OVERLAY_SOURCE_ID)
|
||||
return;
|
||||
if (!record && cardHit) {
|
||||
const mmsi = String(cardHit.entryId || '').startsWith('vessel:')
|
||||
const id = String(cardHit.entryId || '').startsWith('vessel:')
|
||||
? cardHit.entryId.slice('vessel:'.length)
|
||||
: null;
|
||||
record = mmsi ? state.records.byMmsi.get(mmsi) || null : null;
|
||||
record = id ? state.records.byId.get(id) || null : null;
|
||||
// A stale card id is not empty terrain and must not clear a newer
|
||||
// selection. The next paint will evict its hit rectangle.
|
||||
if (!record) return;
|
||||
@@ -100,8 +102,8 @@ export function createSelection({
|
||||
selectAndFocusVessel(record);
|
||||
} else {
|
||||
const transition = components.queries.reduceVesselSelection({
|
||||
selectedMmsi: state.selectedRecord?.mmsi,
|
||||
pickedMmsi: null,
|
||||
selectedId: state.selectedRecord?.id,
|
||||
pickedId: null,
|
||||
gesture: 'click',
|
||||
});
|
||||
if (transition.action === 'deselect') clearVesselInspection();
|
||||
@@ -123,17 +125,17 @@ export function createSelection({
|
||||
/** Select one live vessel and request one UI-owned camera transfer. */
|
||||
|
||||
function selectAndFocusVessel(record) {
|
||||
if (!record?.mmsi) return false;
|
||||
if (!record?.id) return false;
|
||||
const transition = components.queries.reduceVesselSelection({
|
||||
selectedMmsi: state.selectedRecord?.mmsi,
|
||||
pickedMmsi: record.mmsi,
|
||||
selectedId: state.selectedRecord?.id,
|
||||
pickedId: record.id,
|
||||
gesture: 'click',
|
||||
});
|
||||
if (transition.action === 'select') selectVessel(record);
|
||||
requestWorldFocus({
|
||||
kind: 'vessel',
|
||||
id: record.mmsi,
|
||||
label: record.name || record.mmsi,
|
||||
id: record.id,
|
||||
label: record.name || record.mmsi || record.id,
|
||||
position:
|
||||
components.rendering.getVisual(record).billboard?.position ||
|
||||
components.rendering.getVisual(record).position,
|
||||
@@ -160,7 +162,7 @@ export function createSelection({
|
||||
function onVesselKeyDown(event) {
|
||||
if (!state.feed.enabled || event.key !== 'Escape') return;
|
||||
const transition = components.queries.reduceVesselSelection({
|
||||
selectedMmsi: state.selectedRecord?.mmsi,
|
||||
selectedId: state.selectedRecord?.id,
|
||||
gesture: 'escape',
|
||||
});
|
||||
if (transition.action === 'deselect') {
|
||||
@@ -169,8 +171,8 @@ export function createSelection({
|
||||
}
|
||||
|
||||
function selectVessel(record) {
|
||||
if (!record?.mmsi) return;
|
||||
const reuseTrail = state.trailMmsi === record.mmsi;
|
||||
if (!record?.id) return;
|
||||
const reuseTrail = state.trailId === record.id;
|
||||
clearSelection({ preserveTrail: reuseTrail });
|
||||
state.selectedRecord = record;
|
||||
record.missedRefreshes = 0;
|
||||
@@ -188,7 +190,7 @@ export function createSelection({
|
||||
selectEntityContext(record);
|
||||
}
|
||||
// Track-history trail (PRD F3/F4): seed with the current position + async
|
||||
// backfill from the server-side per-MMSI ring buffer.
|
||||
// backfill from the source's recent track for this vessel.
|
||||
if (reuseTrail) {
|
||||
components.tracking.appendSelectedVesselTrailFix(record);
|
||||
} else {
|
||||
@@ -204,10 +206,10 @@ export function createSelection({
|
||||
*/
|
||||
|
||||
function registerSelectedContext(record) {
|
||||
if (!record?.mmsi) return null;
|
||||
if (!record?.id) return null;
|
||||
try {
|
||||
return registerEntityContext(record, {
|
||||
id: `ais-${record.mmsi}`,
|
||||
id: `ais-${record.id}`,
|
||||
layerId: 'ais-live-vessels',
|
||||
layerName: 'Live AIS Vessels',
|
||||
source: aisLiveVesselsLayer.source,
|
||||
@@ -215,7 +217,8 @@ export function createSelection({
|
||||
latitude: record.lat,
|
||||
longitude: record.lon,
|
||||
properties: {
|
||||
mmsi: record.mmsi,
|
||||
vesselId: record.id,
|
||||
mmsi: record.mmsi || null,
|
||||
type: record.type,
|
||||
speedKt: record.speed,
|
||||
course: record.course,
|
||||
|
||||
@@ -6,16 +6,25 @@ export function createVesselSnapshotRenderer({
|
||||
tracking,
|
||||
selection,
|
||||
cards,
|
||||
followsClock = () => false,
|
||||
}) {
|
||||
function reconcileVessels(viewer, rows, { complete = true } = {}) {
|
||||
/** `replace`: the rows are the world at another time (see VesselRecords). */
|
||||
function reconcileVessels(
|
||||
viewer,
|
||||
rows,
|
||||
{ complete = true, replace = false } = {},
|
||||
) {
|
||||
rendering.ensureCollections(viewer);
|
||||
const occluder = rendering.makeOccluder();
|
||||
const follow = followsClock();
|
||||
records.reconcile(
|
||||
rows,
|
||||
{
|
||||
complete,
|
||||
selectedRecord: state.selectedRecord,
|
||||
cap: rendering.renderRowLimit(),
|
||||
...(replace && { replace }),
|
||||
...(follow && { follow }),
|
||||
},
|
||||
{
|
||||
add(record) {
|
||||
@@ -35,7 +44,7 @@ export function createVesselSnapshotRenderer({
|
||||
const nextIcon = rendering.shipIcon(record, selected);
|
||||
if (nextIcon !== prevIcon) visual.billboard.image = nextIcon;
|
||||
}
|
||||
if (record.mmsi === state.trailMmsi)
|
||||
if (record.id === state.trailId)
|
||||
tracking.appendSelectedVesselTrailFix(record);
|
||||
if (selected) {
|
||||
cards.updateSelectedVesselHud(record);
|
||||
@@ -46,14 +55,17 @@ export function createVesselSnapshotRenderer({
|
||||
if (evicted) selection.clearVesselInspection({ evicted: true });
|
||||
rendering.removeRecordPrimitives(record);
|
||||
},
|
||||
removed(mmsi) {
|
||||
if (state.trailMmsi === mmsi) tracking.clearSelectedVesselTrail();
|
||||
removed(id) {
|
||||
if (state.trailId === id) tracking.clearSelectedVesselTrail();
|
||||
},
|
||||
staleSelected(record) {
|
||||
cards.updateSelectedVesselHud(record);
|
||||
},
|
||||
},
|
||||
);
|
||||
// The selected vessel at the new time: its trail starts there.
|
||||
if (replace && state.selectedRecord && state.trailId)
|
||||
tracking.startSelectedVesselTrail(state.selectedRecord);
|
||||
state.lastVisibilityUpdate = 0;
|
||||
rendering.updateVisibility(true);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createVesselFeed } from './ingestion.js';
|
||||
import { VesselRecords } from './records.js';
|
||||
import * as Cesium from 'cesium';
|
||||
import { DEFAULT_AIS_RUNTIME } from './policy.js';
|
||||
import { DEFAULT_AIS_RUNTIME, followsClock } from './policy.js';
|
||||
import { worldTimeFor } from '../../time/worldClock.js';
|
||||
|
||||
export function createVesselState({ source, services }) {
|
||||
const {
|
||||
@@ -31,6 +32,19 @@ export function createVesselState({ source, services }) {
|
||||
|
||||
vesselState._aisRuntime = DEFAULT_AIS_RUNTIME;
|
||||
|
||||
/** Follows the world clock while the source declares a timeline (set by the
|
||||
* layer); see followsClock in policy.js. */
|
||||
vesselState.timeline = null;
|
||||
|
||||
/** The world time the layer draws at while it follows the clock. */
|
||||
vesselState.worldNow = worldTimeFor('ais-live-vessels');
|
||||
|
||||
/** The time records are judged at: the world's while following the clock. */
|
||||
vesselState.recordsNow = () =>
|
||||
followsClock(vesselState)
|
||||
? vesselState.worldNow()
|
||||
: vesselState._aisRuntime.now();
|
||||
|
||||
vesselState._aisSessionSequence = 0;
|
||||
|
||||
/**
|
||||
@@ -48,7 +62,7 @@ export function createVesselState({ source, services }) {
|
||||
|
||||
vesselState.state = {
|
||||
feed: createVesselFeed(),
|
||||
records: new VesselRecords({ now: () => vesselState._aisRuntime.now() }),
|
||||
records: new VesselRecords({ now: () => vesselState.recordsNow() }),
|
||||
viewer: null,
|
||||
|
||||
billboardCollection: null,
|
||||
@@ -72,14 +86,14 @@ export function createVesselState({ source, services }) {
|
||||
activeFocusCount: 0,
|
||||
activeLabelCount: 0,
|
||||
selectedRecord: null,
|
||||
/** @type {string|null} MMSI whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */
|
||||
cockpitSubjectMmsi: null,
|
||||
/** @type {string|null} Id of the vessel whose bridge the Cockpit camera occupies; its sprite and card stay hidden. */
|
||||
cockpitSubjectId: null,
|
||||
/** @type {{setPositions: Function, clear: Function, destroy: Function}|null} Selected-vessel fading trail */
|
||||
trail: null,
|
||||
/** @type {Cesium.Cartesian3[]} Chronological trail vertices (oldest first) */
|
||||
trailPositions: [],
|
||||
/** @type {string|null} MMSI that owns the active selected-vessel trail. */
|
||||
trailMmsi: null,
|
||||
/** @type {string|null} Id of the vessel that owns the active selected-vessel trail. */
|
||||
trailId: null,
|
||||
/** @type {number} Monotonic token — invalidates in-flight backfill responses */
|
||||
trailBackfillToken: 0,
|
||||
trailAbort: null,
|
||||
|
||||
@@ -32,6 +32,11 @@ export function createTesting({
|
||||
function _setVesselStateForTest(options = {}) {
|
||||
components.lifecycle.resetState();
|
||||
const records = Array.isArray(options.records) ? options.records : [];
|
||||
// A test record that names no id is keyed by its MMSI, as an AIS feed's
|
||||
// own rows are (see normalizeVessel).
|
||||
for (const record of [...records, options.selectedRecord])
|
||||
if (record && typeof record === 'object' && record.id === undefined)
|
||||
record.id = String(record.mmsi || '').trim();
|
||||
state.viewer = options.viewer || null;
|
||||
state.feed.enabled = options.enabled !== false;
|
||||
state.feed.loaded = options.loaded === true;
|
||||
@@ -42,15 +47,15 @@ export function createTesting({
|
||||
state.feed.lastUpdate = options.lastUpdate ?? null;
|
||||
state.records.all = records;
|
||||
state.feed.count = records.length;
|
||||
state.records.byMmsi = new Map(
|
||||
state.records.byId = new Map(
|
||||
records
|
||||
.filter((record) => record?.mmsi)
|
||||
.map((record) => [record.mmsi, record]),
|
||||
.filter((record) => record?.id)
|
||||
.map((record) => [record.id, record]),
|
||||
);
|
||||
state.selectedRecord = options.selectedRecord || null;
|
||||
state.billboardCollection = options.billboardCollection || { remove() {} };
|
||||
state.trail = options.trail || null;
|
||||
state.trailMmsi = options.trailMmsi || null;
|
||||
state.trailId = options.trailId || null;
|
||||
state.trailPositions = Array.isArray(options.trailPositions)
|
||||
? [...options.trailPositions]
|
||||
: [];
|
||||
@@ -141,8 +146,8 @@ export function createTesting({
|
||||
lastMessageAt: state.feed.lastMessageAt,
|
||||
rawRowCount: state.feed.rawRowCount,
|
||||
acceptedRowCount: state.feed.acceptedRowCount,
|
||||
selectedMmsi: state.selectedRecord?.mmsi || null,
|
||||
trailMmsi: state.trailMmsi,
|
||||
selectedId: state.selectedRecord?.id || null,
|
||||
trailId: state.trailId,
|
||||
trailPositionCount: state.trailPositions.length,
|
||||
sessionId: state.feed.sessionId,
|
||||
firstConnectPhase: state.feed.firstConnectPhase,
|
||||
@@ -166,14 +171,14 @@ export function createTesting({
|
||||
/**
|
||||
* Read lifecycle ownership state without exposing the mutable state object.
|
||||
* Test-only seam.
|
||||
* @returns {{trailMmsi: string|null, trailPositionCount: number, vesselCount: number}}
|
||||
* @returns {{trailId: string|null, trailPositionCount: number, vesselCount: number}}
|
||||
*/
|
||||
|
||||
function _getVesselStateForTest() {
|
||||
return {
|
||||
trailMmsi: state.trailMmsi,
|
||||
trailId: state.trailId,
|
||||
trailPositionCount: state.trailPositions.length,
|
||||
vesselCount: state.records.byMmsi.size,
|
||||
vesselCount: state.records.byId.size,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
TRAIL_COLOR,
|
||||
TRAIL_MAX_POINTS,
|
||||
TRAIL_MIN_MOVE_M,
|
||||
followsClock,
|
||||
} from './policy.js';
|
||||
|
||||
export function createTracking({
|
||||
@@ -85,7 +86,7 @@ export function createTracking({
|
||||
state.trailAbort?.abort();
|
||||
state.trailAbort = new AbortController();
|
||||
state.trailBackfillToken += 1;
|
||||
state.trailMmsi = record.mmsi;
|
||||
state.trailId = record.id;
|
||||
state.trailPositions = [];
|
||||
const current = vesselTrailPosition(record);
|
||||
if (current) state.trailPositions.push(current);
|
||||
@@ -96,32 +97,33 @@ export function createTracking({
|
||||
});
|
||||
}
|
||||
if (state.trail) state.trail.setPositions(state.trailPositions);
|
||||
backfillVesselTrail(
|
||||
record.mmsi,
|
||||
state.trailBackfillToken,
|
||||
record.reference,
|
||||
);
|
||||
backfillVesselTrail(record.id, state.trailBackfillToken, record.reference);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget backfill from the server-side per-MMSI ring buffer
|
||||
* Fire-and-forget backfill from the server-side per-vessel ring buffer
|
||||
* (PRD F3 — "recent path" since server boot, not voyage history). Older
|
||||
* samples are spliced AHEAD of the live accumulation, capped at
|
||||
* TRAIL_MAX_POINTS (newest kept). Any failure (404/timeout/malformed)
|
||||
* silently keeps the live-only trail.
|
||||
* @param {string} mmsi - MMSI of the selected vessel.
|
||||
* @param {string} id - Key of the selected vessel.
|
||||
* @param {number} token - Backfill token captured at request time.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
|
||||
async function backfillVesselTrail(mmsi, token, reference = mmsi) {
|
||||
async function backfillVesselTrail(id, token, reference = id) {
|
||||
const owner = state.trailAbort;
|
||||
// Following the world clock, the track ends at the world's time.
|
||||
const atMs = followsClock(vesselState) ? vesselState.worldNow() : undefined;
|
||||
const signal = owner
|
||||
? AbortSignal.any([owner.signal, AbortSignal.timeout(8000)])
|
||||
: AbortSignal.timeout(8000);
|
||||
let samples = null;
|
||||
try {
|
||||
const track = await vesselState._source.getTrack?.(reference, { signal });
|
||||
const track = await vesselState._source.getTrack?.(
|
||||
reference,
|
||||
atMs === undefined ? { signal } : { signal, atMs },
|
||||
);
|
||||
samples = track?.records ?? null;
|
||||
} catch {
|
||||
return; // silent — keep the live-accumulated trail
|
||||
@@ -133,13 +135,19 @@ export function createTracking({
|
||||
token !== state.trailBackfillToken
|
||||
)
|
||||
return;
|
||||
if (state.trailMmsi !== mmsi) return;
|
||||
if (state.trailId !== id) return;
|
||||
|
||||
const older = [];
|
||||
for (const sample of samples) {
|
||||
const lat = Number(sample?.latitude);
|
||||
const lon = Number(sample?.longitude);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
||||
// Following the clock, an undated sample may be later than the time.
|
||||
if (
|
||||
atMs !== undefined &&
|
||||
!(Number.isFinite(sample?.observedAtMs) && sample.observedAtMs <= atMs)
|
||||
)
|
||||
continue;
|
||||
// Per-sample N (≤ TRAIL_MAX_POINTS lookups) — same sea-surface datum as
|
||||
// the live vertices so the spliced trail is height-continuous.
|
||||
const heightM = components.queries.vesselDatumHeightM(
|
||||
@@ -186,7 +194,7 @@ export function createTracking({
|
||||
state.trailAbort?.abort();
|
||||
state.trailAbort = null;
|
||||
state.trailBackfillToken += 1;
|
||||
state.trailMmsi = null;
|
||||
state.trailId = null;
|
||||
state.trailPositions = [];
|
||||
if (state.trail) state.trail.clear();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { createWeatherRendering } from './rendering.js';
|
||||
import { weatherFrameSource } from './source.js';
|
||||
import { imageryHostStatus } from './imageryHost.js';
|
||||
import { weatherDisplay, insideExtent } from './display.js';
|
||||
import {
|
||||
@@ -66,6 +67,7 @@ export function createWeatherLayer({
|
||||
} = {}) {
|
||||
if (typeof feed?.getSnapshot !== 'function')
|
||||
throw new TypeError('Weather requires a snapshot source');
|
||||
// A feed may also supply decoded frames; see weatherFrameSource.
|
||||
const radar = id === 'weather-radar';
|
||||
const lightning = id === 'weather-lightning';
|
||||
const satellite = !radar && !lightning;
|
||||
@@ -288,6 +290,7 @@ export function createWeatherLayer({
|
||||
cesium,
|
||||
getHost,
|
||||
onChange: notify,
|
||||
frameSource: weatherFrameSource(feed),
|
||||
});
|
||||
checkHost(false);
|
||||
eventTarget?.addEventListener?.(
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { readResponseBytesCapped } from '../../sources/httpBody.js';
|
||||
import { msg } from '../../i18n/index.js';
|
||||
import { weatherImageUrl } from './source.js';
|
||||
import {
|
||||
weatherImageUrl,
|
||||
weatherPixelSize,
|
||||
drawWeatherPixels,
|
||||
closeWeatherPixels,
|
||||
} from './source.js';
|
||||
import { infraredAlpha } from './infraredAlpha.js';
|
||||
|
||||
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
|
||||
@@ -20,10 +25,35 @@ export function processInfraredImage(
|
||||
if (flipY) context.setTransform(1, 0, 0, -1, 0, image.height);
|
||||
context.drawImage(image, 0, 0);
|
||||
if (flipY) context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
applyInfraredTransfer(context, canvas, mode);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function applyInfraredTransfer(context, canvas, mode) {
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
pixels.data.set(infraredAlpha(pixels.data, mode));
|
||||
context.putImageData(pixels, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/** Draw pixels a frame source supplied into a new canvas, with the display
|
||||
* transfer for infrared products, and release them. They must be `size`. */
|
||||
function drawSuppliedImage(pixels, product, mode, size, signal, createCanvas) {
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
const { width, height } = weatherPixelSize(pixels);
|
||||
if (width !== size.width || height !== size.height)
|
||||
throw new Error('Invalid weather image dimensions');
|
||||
const texture = createCanvas();
|
||||
texture.width = width;
|
||||
texture.height = height;
|
||||
const context = texture.getContext('2d');
|
||||
drawWeatherPixels(context, pixels);
|
||||
if (product === 'clouds' || product === 'clouds-regional')
|
||||
applyInfraredTransfer(context, texture, mode);
|
||||
return texture;
|
||||
} finally {
|
||||
closeWeatherPixels(pixels);
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode locally, releasing the object URL on success, failure or cancellation. */
|
||||
@@ -60,7 +90,8 @@ export function decodeInfraredImage(blob, signal) {
|
||||
/** Fetch one capped whole-extent frame, or a `bbox` detail window of it, and draw
|
||||
* it into a canvas. Infrared frames get the display transfer; a canvas also keeps
|
||||
* Cesium's texture row order, which an ImageBitmap upload would not. The decoded
|
||||
* image is always released. */
|
||||
* image is always released. With a `frameSource` that has `getImage`, the host
|
||||
* supplies the decoded pixels instead and nothing is fetched or decoded here. */
|
||||
export async function acquireWeatherImage(
|
||||
product,
|
||||
time,
|
||||
@@ -75,8 +106,30 @@ export async function acquireWeatherImage(
|
||||
decodeImage = decodeInfraredImage,
|
||||
now = () => performance.now(),
|
||||
onFetched = () => {},
|
||||
frameSource = null,
|
||||
},
|
||||
) {
|
||||
if (frameSource?.getImage) {
|
||||
signal.throwIfAborted();
|
||||
const started = now();
|
||||
const pixels = await frameSource.getImage({
|
||||
product,
|
||||
time,
|
||||
size,
|
||||
bbox,
|
||||
signal,
|
||||
});
|
||||
onFetched();
|
||||
const texture = drawSuppliedImage(
|
||||
pixels,
|
||||
product,
|
||||
mode,
|
||||
size,
|
||||
signal,
|
||||
createCanvas,
|
||||
);
|
||||
return { texture, decodeMs: now() - started };
|
||||
}
|
||||
const response = await fetchImpl(weatherImageUrl(product, time, size, bbox), {
|
||||
signal,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
acquireInfraredMosaic,
|
||||
acquireWeatherImage,
|
||||
decodeInfraredImage,
|
||||
processInfraredImage,
|
||||
MAX_MOSAIC_BYTES,
|
||||
@@ -183,3 +184,154 @@ test('bitmap decoding and Image fallback release resources on success, error and
|
||||
}
|
||||
assert.deepEqual(revoked, ['blob:test', 'blob:test', 'blob:test']);
|
||||
});
|
||||
|
||||
const pixels = (width, height) => ({
|
||||
width,
|
||||
height,
|
||||
closes: 0,
|
||||
close() {
|
||||
this.closes++;
|
||||
},
|
||||
});
|
||||
|
||||
test('a frame source supplies the image: nothing is fetched or decoded and the pixels are released', async () => {
|
||||
const controller = new AbortController();
|
||||
const requests = [];
|
||||
const bitmap = pixels(2048, 1024);
|
||||
let clock = 5,
|
||||
fetched = false;
|
||||
const result = await acquireInfraredMosaic('2026-09-15T20:00:00.000Z', {
|
||||
signal: controller.signal,
|
||||
mode: 'filtered',
|
||||
createCanvas: canvas,
|
||||
now: () => clock,
|
||||
onFetched: () => {
|
||||
fetched = true;
|
||||
},
|
||||
fetchImpl: () => assert.fail('a supplied frame is not fetched'),
|
||||
decodeImage: () => assert.fail('a supplied frame is not decoded'),
|
||||
frameSource: {
|
||||
getImage: async (request) => {
|
||||
requests.push(request);
|
||||
clock = 12;
|
||||
return bitmap;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.deepEqual(requests, [
|
||||
{
|
||||
product: 'clouds',
|
||||
time: '2026-09-15T20:00:00.000Z',
|
||||
size: { width: 2048, height: 1024 },
|
||||
bbox: null,
|
||||
signal: controller.signal,
|
||||
},
|
||||
]);
|
||||
assert.equal(fetched, true);
|
||||
assert.equal(result.decodeMs, 7);
|
||||
assert.equal(result.texture.width, 2048);
|
||||
assert.equal(result.texture.height, 1024);
|
||||
assert.equal(result.texture.image, bitmap);
|
||||
assert.deepEqual(
|
||||
[...result.texture.pixels],
|
||||
[194, 0, 0, 98],
|
||||
'infrared frames get the display transfer',
|
||||
);
|
||||
assert.equal(bitmap.closes, 1);
|
||||
});
|
||||
|
||||
test('supplied detail windows carry their bbox; ImageData is put, radar keeps its pixels', async (t) => {
|
||||
const had = Object.hasOwn(globalThis, 'ImageData');
|
||||
const previous = globalThis.ImageData;
|
||||
globalThis.ImageData = class ImageData {
|
||||
width = 4096;
|
||||
height = 2048;
|
||||
data = new Uint8ClampedArray([1, 2, 3, 4]);
|
||||
};
|
||||
t.after(() => {
|
||||
if (had) globalThis.ImageData = previous;
|
||||
else delete globalThis.ImageData;
|
||||
});
|
||||
const data = new globalThis.ImageData();
|
||||
const bbox = { west: -102, south: 34, east: -96, north: 37 };
|
||||
let request;
|
||||
const { texture } = await acquireWeatherImage('radar', 't', {
|
||||
signal: new AbortController().signal,
|
||||
size: { width: 4096, height: 2048 },
|
||||
bbox,
|
||||
createCanvas: canvas,
|
||||
fetchImpl: () => assert.fail('no fetch'),
|
||||
frameSource: {
|
||||
getImage: async (value) => {
|
||||
request = value;
|
||||
return data;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(request.bbox, bbox);
|
||||
assert.equal(
|
||||
texture.pixels,
|
||||
data.data,
|
||||
'ImageData is put as it is; radar has no transfer',
|
||||
);
|
||||
assert.equal(texture.image, undefined, 'ImageData is not drawn as an image');
|
||||
assert.equal(texture.width, 4096);
|
||||
});
|
||||
|
||||
test('a supplied frame of the wrong size is refused and still released', async () => {
|
||||
const wrong = pixels(1024, 512);
|
||||
await assert.rejects(
|
||||
acquireInfraredMosaic('t', {
|
||||
signal: new AbortController().signal,
|
||||
mode: 'filtered',
|
||||
createCanvas: canvas,
|
||||
frameSource: { getImage: async () => wrong },
|
||||
}),
|
||||
/Invalid weather image dimensions/,
|
||||
);
|
||||
assert.equal(wrong.closes, 1);
|
||||
await assert.rejects(
|
||||
acquireInfraredMosaic('t', {
|
||||
signal: new AbortController().signal,
|
||||
mode: 'filtered',
|
||||
createCanvas: canvas,
|
||||
frameSource: { getImage: async () => undefined },
|
||||
}),
|
||||
/Invalid weather image dimensions/,
|
||||
);
|
||||
});
|
||||
|
||||
test('cancellation reaches the frame source, and a frame that arrives after it is released', async () => {
|
||||
const controller = new AbortController();
|
||||
const late = pixels(2048, 1024);
|
||||
let seen;
|
||||
const pending = acquireInfraredMosaic('t', {
|
||||
signal: controller.signal,
|
||||
mode: 'filtered',
|
||||
createCanvas: () => assert.fail('a cancelled frame is not drawn'),
|
||||
frameSource: {
|
||||
getImage: async ({ signal }) => {
|
||||
seen = signal;
|
||||
await flushMicrotasks();
|
||||
return late;
|
||||
},
|
||||
},
|
||||
});
|
||||
controller.abort();
|
||||
await assert.rejects(pending, { name: 'AbortError' });
|
||||
assert.equal(seen, controller.signal);
|
||||
assert.equal(late.closes, 1);
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
await assert.rejects(
|
||||
acquireInfraredMosaic('t', {
|
||||
signal: aborted.signal,
|
||||
frameSource: { getImage: () => assert.fail('already cancelled') },
|
||||
}),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
});
|
||||
|
||||
async function flushMicrotasks() {
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
weatherPixelSize,
|
||||
drawWeatherPixels,
|
||||
closeWeatherPixels,
|
||||
} from './source.js';
|
||||
|
||||
/** Tile a bounded decoded canvas without network requests or scene state. */
|
||||
export function createRasterTileProvider({
|
||||
cesium,
|
||||
@@ -48,3 +54,95 @@ export function createRasterTileProvider({
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Tile one product time from a host frame source. Every resolved tile is a
|
||||
* canvas: supplied pixels are drawn into a new one and released, and a null
|
||||
* tile is one shared transparent canvas. Beyond `maxConcurrent` tiles in
|
||||
* flight requestImage returns undefined, Cesium's signal to ask again later. A
|
||||
* failed tile rejects, which Cesium reports through errorEvent. Aborting
|
||||
* `signal` cancels every tile in flight.
|
||||
*/
|
||||
export function createFrameTileProvider({
|
||||
cesium,
|
||||
frameSource,
|
||||
product,
|
||||
time,
|
||||
tilingScheme,
|
||||
rectangle = cesium.Rectangle.MAX_VALUE,
|
||||
maximumLevel,
|
||||
credit,
|
||||
createCanvas,
|
||||
tileSize = 256,
|
||||
maxConcurrent = 6,
|
||||
signal,
|
||||
}) {
|
||||
let inFlight = 0;
|
||||
let empty = null;
|
||||
const canvas = () => {
|
||||
const tile = createCanvas();
|
||||
tile.width = tile.height = tileSize;
|
||||
return tile;
|
||||
};
|
||||
async function load(x, y, level, request) {
|
||||
const controller = new AbortController();
|
||||
const abort = () => controller.abort(signal.reason);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
const pixels = await frameSource.getTile({
|
||||
product,
|
||||
time,
|
||||
z: level,
|
||||
x,
|
||||
y,
|
||||
size: tileSize,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (pixels === null) {
|
||||
controller.signal.throwIfAborted();
|
||||
return (empty ??= canvas());
|
||||
}
|
||||
try {
|
||||
controller.signal.throwIfAborted();
|
||||
const { width, height } = weatherPixelSize(pixels);
|
||||
if (width !== tileSize || height !== tileSize)
|
||||
throw new Error('Invalid weather tile dimensions');
|
||||
const tile = canvas();
|
||||
drawWeatherPixels(tile.getContext('2d'), pixels);
|
||||
return tile;
|
||||
} finally {
|
||||
closeWeatherPixels(pixels);
|
||||
}
|
||||
} catch (error) {
|
||||
// A cancelled tile is unloaded, not failed, so Cesium may ask again.
|
||||
if (controller.signal.aborted && request && cesium.RequestState)
|
||||
request.state = cesium.RequestState.CANCELLED;
|
||||
throw error;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
}
|
||||
}
|
||||
return {
|
||||
tilingScheme,
|
||||
rectangle,
|
||||
tileWidth: tileSize,
|
||||
tileHeight: tileSize,
|
||||
minimumLevel: 0,
|
||||
maximumLevel,
|
||||
ready: true,
|
||||
tileDiscardPolicy: undefined,
|
||||
credit,
|
||||
errorEvent: new cesium.Event(),
|
||||
hasAlphaChannel: true,
|
||||
getTileCredits: () => undefined,
|
||||
pickFeatures: () => undefined,
|
||||
requestImage(x, y, level, request) {
|
||||
if (inFlight >= maxConcurrent) return undefined;
|
||||
inFlight++;
|
||||
return load(x, y, level, request).finally(() => {
|
||||
inFlight--;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user