diff --git a/changelog.d/1599-keyless-voice-key-hint.md b/changelog.d/1599-keyless-voice-key-hint.md new file mode 100644 index 000000000..dfa2c5bb1 --- /dev/null +++ b/changelog.d/1599-keyless-voice-key-hint.md @@ -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). diff --git a/changelog.d/1630-swedish.md b/changelog.d/1630-swedish.md new file mode 100644 index 000000000..1855d501a --- /dev/null +++ b/changelog.d/1630-swedish.md @@ -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). diff --git a/changelog.d/1660-keyless-gemini-mic-hint.md b/changelog.d/1660-keyless-gemini-mic-hint.md new file mode 100644 index 000000000..f539e0e5a --- /dev/null +++ b/changelog.d/1660-keyless-gemini-mic-hint.md @@ -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). diff --git a/changelog.d/1661-gemini-voice-message-locale.md b/changelog.d/1661-gemini-voice-message-locale.md new file mode 100644 index 000000000..c53270375 --- /dev/null +++ b/changelog.d/1661-gemini-voice-message-locale.md @@ -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). diff --git a/changelog.d/1662-contacts-follow-world-clock.md b/changelog.d/1662-contacts-follow-world-clock.md new file mode 100644 index 000000000..16f611d52 --- /dev/null +++ b/changelog.d/1662-contacts-follow-world-clock.md @@ -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). diff --git a/changelog.d/1665-weather-frame-source.md b/changelog.d/1665-weather-frame-source.md new file mode 100644 index 000000000..9a14f1f01 --- /dev/null +++ b/changelog.d/1665-weather-frame-source.md @@ -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). diff --git a/changelog.d/835-viewport-escalation-reasons.md b/changelog.d/835-viewport-escalation-reasons.md new file mode 100644 index 000000000..b533533eb --- /dev/null +++ b/changelog.d/835-viewport-escalation-reasons.md @@ -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). diff --git a/docs/APPLICATION.md b/docs/APPLICATION.md index 8848601d1..d3ca6ffce 100644 --- a/docs/APPLICATION.md +++ b/docs/APPLICATION.md @@ -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 diff --git a/docs/CODE-BOUNDARIES.md b/docs/CODE-BOUNDARIES.md index 591db02dc..b30c3aba1 100644 --- a/docs/CODE-BOUNDARIES.md +++ b/docs/CODE-BOUNDARIES.md @@ -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. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 32c73a58d..c4514ceb9 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1145,7 +1145,7 @@ Source factories have dedicated `layers//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 \" 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. diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index bf69653f9..8d2e99383 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -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` diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 80fd80b13..7fd4c0937 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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 | diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index ed94fbcbe..a1e108379 100644 --- a/docs/TRANSLATORS.md +++ b/docs/TRANSLATORS.md @@ -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/.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/.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. diff --git a/docs/translations/sv.md b/docs/translations/sv.md new file mode 100644 index 000000000..39d7d5aeb --- /dev/null +++ b/docs/translations/sv.md @@ -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 | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 10cf1e588..7d3962cce 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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", diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 7d41b8def..6335d7fa7 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.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", diff --git a/server/providers/gemini/realtime.js b/server/providers/gemini/realtime.js index fa8821afa..ec2c87cd3 100644 --- a/server/providers/gemini/realtime.js +++ b/server/providers/gemini/realtime.js @@ -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'); diff --git a/server/providers/news/keyed.js b/server/providers/news/keyed.js index 7bd5ca464..574e44e5b 100644 --- a/server/providers/news/keyed.js +++ b/server/providers/news/keyed.js @@ -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); diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index 4412eb331..f88171e4a 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -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; } } diff --git a/src/data/aisLiveVessels.analyst.test.mjs b/src/data/aisLiveVessels.analyst.test.mjs index 636f84e31..5a015be50 100644 --- a/src/data/aisLiveVessels.analyst.test.mjs +++ b/src/data/aisLiveVessels.analyst.test.mjs @@ -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); diff --git a/src/data/aisLiveVessels.test.mjs b/src/data/aisLiveVessels.test.mjs index 193136264..e427a5412 100644 --- a/src/data/aisLiveVessels.test.mjs +++ b/src/data/aisLiveVessels.test.mjs @@ -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); diff --git a/src/data/firmsInteraction.test.mjs b/src/data/firmsInteraction.test.mjs index 0251738f3..384c72d6d 100644 --- a/src/data/firmsInteraction.test.mjs +++ b/src/data/firmsInteraction.test.mjs @@ -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 }], ]; diff --git a/src/data/militaryAwareness.test.mjs b/src/data/militaryAwareness.test.mjs index 430ca3362..71d22b49b 100644 --- a/src/data/militaryAwareness.test.mjs +++ b/src/data/militaryAwareness.test.mjs @@ -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( diff --git a/src/data/militaryAwarenessEngine.js b/src/data/militaryAwarenessEngine.js index 136e9d2fa..459cda6db 100644 --- a/src/data/militaryAwarenessEngine.js +++ b/src/data/militaryAwarenessEngine.js @@ -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; diff --git a/src/data/pickRegistry.js b/src/data/pickRegistry.js index ef3d8074e..a01e80d36 100644 --- a/src/data/pickRegistry.js +++ b/src/data/pickRegistry.js @@ -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; } diff --git a/src/data/pickRegistry.test.mjs b/src/data/pickRegistry.test.mjs index 43f11260f..3fec66d00 100644 --- a/src/data/pickRegistry.test.mjs +++ b/src/data/pickRegistry.test.mjs @@ -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' }], diff --git a/src/data/pickedContext.js b/src/data/pickedContext.js index 28c303564..e54ab69c5 100644 --- a/src/data/pickedContext.js +++ b/src/data/pickedContext.js @@ -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, diff --git a/src/data/vesselLabels.js b/src/data/vesselLabels.js index fbf209040..0a8a164f7 100644 --- a/src/data/vesselLabels.js +++ b/src/data/vesselLabels.js @@ -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, }; } diff --git a/src/geminiServer.test.mjs b/src/geminiServer.test.mjs index e6d8d2de9..de8211f98 100644 --- a/src/geminiServer.test.mjs +++ b/src/geminiServer.test.mjs @@ -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 [ '', diff --git a/src/hudSummaryResponse.test.mjs b/src/hudSummaryResponse.test.mjs index 097d7133d..6effd2eee 100644 --- a/src/hudSummaryResponse.test.mjs +++ b/src/hudSummaryResponse.test.mjs @@ -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) { diff --git a/src/i18n/index.js b/src/i18n/index.js index 9d697e72a..1d060a378 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -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, diff --git a/src/i18n/locale.js b/src/i18n/locale.js index 7e8f37ffc..7868310c1 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -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': '中文(繁體)', diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index d2f662b5d..1f3692a60 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -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}" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 2648668c4..88ab65f22 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -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}" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 4fc5e30b6..a16074c79 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -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}" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 536203664..49e9b6387 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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}" } diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index 5a6fe9404..b1c523a7a 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -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}" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 329a1fd87..528964948 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -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}" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d7164c2e6..347156b7a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -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}" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index e4b376011..233a94f20 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -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}" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 78622d3e3..3e5a78ef8 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -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}" } diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json new file mode 100644 index 000000000..7d2828954 --- /dev/null +++ b/src/i18n/locales/sv.json @@ -0,0 +1,3468 @@ +{ + "News feed unavailable": "Nyhetsflödet är otillgängligt", + "Developing: {name}": "Läget utvecklas: {name}", + "{count} events today": { + "one": "{count} händelse i dag", + "other": "{count} händelser i dag" + }, + "{count} reports today": { + "one": "{count} rapport i dag", + "other": "{count} rapporter i dag" + }, + "Disputed: {name}": "Omtvistat: {name}", + "Video": "Video", + "Photos": "Foton", + "Claimed by {who}": "Påstående från {who}", + "Claimed": "Påstående", + "Not yet confirmed by others": "Ännu inte bekräftat av andra", + "Reported by {side} sources": "Rapporterat av källor: {side}", + "Reported by {sides}": "Rapporterat av källor: {sides}", + "{side} sources": "{side}", + "Official": "Officiell", + "Reported by both sides": "Rapporterat av båda sidor", + "Independently reported": "Rapporterat av oberoende källor", + "Single source": "En enda källa", + "International": "Internationella", + "Independent": "Oberoende", + "Unattributed": "Okänd sida", + "Outside party": "Utomstående part", + "Ukraine–Russia": "Ukraina–Ryssland", + "Israel–Gaza–Lebanon": "Israel–Gaza–Libanon", + "Iran–Hormuz": "Iran–Hormuz", + "Yemen–Red Sea": "Jemen–Röda havet", + "Sudan": "Sudan", + "Sahel": "Sahel", + "Elsewhere": "Övriga världen", + "Strike": "Attack", + "Alert": "Larm", + "Air defence": "Luftvärn", + "Ground fighting": "Markstrider", + "Casualties": "Döda och skadade", + "Humanitarian": "Humanitärt", + "Disaster": "Katastrof", + "Protest": "Protest", + "Political": "Politik", + "No physical event": "Ingen fysisk händelse", + "Minor incident": "Mindre händelse", + "Significant": "Betydande", + "Major": "Allvarlig", + "age|now": "nu", + "{minutes} min": "{minutes} min", + "{hours} h": "{hours} h", + "{count} sources": { + "one": "{count} källa", + "other": "{count} källor" + }, + "News feed not configured on this server": "Nyhetsflödet är inte konfigurerat på den här servern", + "Malformed news hotspots response": "Felformaterat svar med oroshärdar för nyheter", + "Malformed news feed response": "Felformaterat svar från nyhetsflödet", + "Exit cockpit to fly to a news report": "Lämna cockpiten för att flyga till en nyhetsrapport", + "{place} (placed by model)": "{place} (plats enligt modellen)", + "English headline by the model": "Engelsk rubrik från modellen", + "Not placed on the map": "Inte placerad på kartan", + "Close": "Stäng", + "Conflicts": "Konflikter", + "Local": "Lokalt", + "Any severity": "Alla allvarlighetsgrader", + "{severity} and above": "{severity} och uppåt", + "{severity} only": "Endast ”{severity}”", + "All categories": "Alla kategorier", + "Last {hours} h": "Senaste {hours} h", + "Reading the news…": "Läser in nyheterna …", + "News, last {hours} hours": "Nyheter, senaste {hours} h", + "Time window": "Tidsfönster", + "News sections": "Nyhetssektioner", + "Hotspots": "Oroshärdar", + "Severity": "Allvarlighetsgrad", + "Category": "Kategori", + "Zoom in to see local news": "Zooma in för att se lokala nyheter", + "Nothing reported near here in the last {hours} h": "Inget rapporterat i närheten senaste {hours} h", + "Latest news": "Senaste nyheterna", + "No news matches these filters in the last {hours} h": "Inga nyheter matchar filtren senaste {hours} h", + "Load older news": "Ladda äldre nyheter", + "How to read the map": "Så läses kartan", + "Colour: severity · icon: type of event": "Färg: allvarlighetsgrad · ikon: typ av händelse", + "Solid rim: reported by both sides or independently. Dashed rim: a claim. Thin rim: one source, or one side’s own press.": "Heldragen kant: rapporterat av båda sidor eller av oberoende källor. Streckad kant: ett påstående. Tunn kant: en enda källa, eller ena sidans egen press.", + "Larger: more sources · fainter: older. Picture card: a major or fast-growing event with two or more sources.": "Större: fler källor · blekare: äldre. Bildkort: en allvarlig eller snabbt växande händelse med två eller fler källor.", + "Side labels describe the source, not whether a report is true.": "Etiketter för sidtillhörighet beskriver källan, inte om en rapport är sann.", + "News items in the feed": "Nyheter i flödet", + "Flying to Austin, TX...": "Flyger till Austin, TX …", + "Restoring shared view...": "Återställer delad vy …", + "Configuring viewer...": "Konfigurerar vyn …", + "Loading Google 3D Tiles...": "Laddar Google 3D Tiles …", + "Loading the keyless globe...": "Laddar globen utan nyckel …", + "Google 3D Tiles unavailable ({detail}). Loading the keyless globe...": "Google 3D Tiles är otillgängligt ({detail}). Laddar globen utan nyckel …", + "Initializing systems...": "Initierar system …", + "Close data attribution": "Stäng källhänvisningar", + "UNAVAILABLE": "OTILLGÄNGLIG", + "SOURCE": "KÄLLA", + "Overpass rate-limited": "Overpass har nått anropsgränsen", + "Overpass timed out": "Overpass svarade inte i tid", + "Overpass could not complete the query": "Overpass kunde inte slutföra frågan", + "Overpass temporarily unavailable": "Overpass är tillfälligt otillgängligt", + "Retrying mapped sites…": "Nytt försök att hämta kartlagda platser …", + "Fetching mapped sites…": "Hämtar kartlagda platser …", + "{reason} — retrying in {seconds}s": "{reason} – nytt försök om {seconds} s", + "{reason} — retry pending": "{reason} – nytt försök väntar", + "Zoom in to search mapped installations": "Zooma in för att söka efter kartlagda anläggningar", + "Showing cached mapped sites": "Visar kartlagda platser från cachen", + "Mapped sites not loaded": "Kartlagda platser har inte laddats", + "Mapped sites loaded": "Kartlagda platser har laddats", + "feed unavailable": "flödet är otillgängligt", + "feed stale": "flödet är inaktuellt", + "observed or mapped nearby context": "observerad eller kartlagd kontext i närheten", + "no observed or mapped objects in current feeds": "inga observerade eller kartlagda objekt i aktuella flöden", + "STATION": "STATION", + "Crewed stations and their visiting vehicles": "Bemannade stationer och besökande farkoster", + "NAV": "NAV", + "GNSS navigation — GPS, GLONASS, Galileo": "GNSS-navigering – GPS, GLONASS, Galileo", + "GEO": "GEO", + "Geostationary belt — comms and weather, fixed over the equator": "Geostationära bältet – kommunikation och väder, fast över ekvatorn", + "VISUAL": "SYNLIGA", + "Brightest naked-eye objects — CelesTrak visual group": "Ljusstarkaste objekten för blotta ögat – CelesTraks grupp ”visual”", + "COMMS": "KOMM.", + "Broadband constellation shell — shown only in DENSE mode": "Skal i bredbandskonstellationen – visas bara i läget TÄT", + "FISHING": "FISKE", + "TOWING": "BOGSERING", + "DREDGER": "MUDDERVERK", + "DIVE OPS": "DYKNING", + "MILITARY": "MILITÄR", + "SAILING": "SEGLING", + "PLEASURE": "FRITIDSBÅT", + "PILOT": "LOTS", + "SAR": "SAR", + "TUG": "BOGSERBÅT", + "PORT TENDER": "HAMNBÅT", + "ANTI-POLLUTION": "MILJÖSKYDD", + "LAW ENFORCE": "POLIS", + "MEDICAL": "SJUKTRANSPORT", + "HIGH-SPEED": "SNABBGÅENDE", + "PASSENGER": "PASSAGERARFARTYG", + "CARGO": "LASTFARTYG", + "TANKER": "TANKFARTYG", + "OTHER": "ÖVRIGT", + "ENVIRONMENTAL": "MILJÖ", + "EARTH WATCH": "JORDBEVAKNING", + "ACTIVE EVENTS": "AKTIVA HÄNDELSER", + "Starting live contacts…": "Startar livekontakter …", + "Opening space missions…": "Öppnar rymduppdrag …", + "Scanning active events…": "Söker efter aktiva händelser …", + "Live earthquakes and active fires, from USGS and NASA": "Jordbävningar och aktiva bränder i realtid, från USGS och NASA", + "It feels like a forbidden cockpit—then you realize the sources are public and the data is real.": "Det känns som en förbjuden cockpit – tills man inser att källorna är offentliga och att uppgifterna är äkta.", + "Working…": "Arbetar …", + "Could not open that mission{detail}. Retry or explore manually.": "Det gick inte att öppna uppdraget{detail}. Försök igen eller utforska manuellt.", + "This browser is blocking storage, so that could not be saved.": "Webbläsaren blockerar lagring, så det gick inte att spara.", + "CRT": "CRT", + "NVG": "NVG", + "FLIR": "FLIR", + "SNOW": "SNÖ", + "LOADING": "LADDAR", + "DEGRADED": "BEGRÄNSAD", + "STALE": "INAKTUELL", + "SIGNAL LOST · {time}": "SIGNAL FÖRLORAD · {time}", + "PARTIAL": "DELVIS", + "FALLBACK": "RESERV", + "SUMMARY": "SAMMANFATTNING", + "Awaiting telemetry...": "Väntar på telemetri …", + "REC": "REC", + "AIS: --": "AIS: --", + "POWER UP · {count} KEYS WAITING": { + "one": "KOPPLA PÅ · {count} NYCKEL VÄNTAR", + "other": "KOPPLA PÅ · {count} NYCKLAR VÄNTAR" + }, + "POWERED UP": "PÅKOPPLAD", + "Save failed ({status}).": "Det gick inte att spara ({status}).", + "REMOVE": "TA BORT", + "Remove {title} from this app's saved keys": "Ta bort {title} från appens sparade nycklar", + "your app configuration": "appens konfiguration", + "your local .env": "den lokala .env-filen", + "Saving…": "Sparar …", + "Save failed: {detail}": "Det gick inte att spara: {detail}", + "Paste at least one key first.": "Klistra in minst en nyckel först.", + "Saved to {store}. Restarting — this page reloads itself.": "Sparat i {store}. Startar om – sidan laddas om automatiskt.", + "Remove this key from your saved configuration?": "Ta bort nyckeln från den sparade konfigurationen?", + "Removed from {store}. Restarting — this page reloads itself.": "Borttaget från {store}. Startar om – sidan laddas om automatiskt.", + "Places context + Street View fallback; optional separate key": "Places-kontext + Street View-reserv; valfri separat nyckel", + "Live ships, worldwide": "Fartyg i realtid, över hela världen", + "Live active-fire detections": "Detekterade aktiva bränder i realtid", + "Real live traffic (keyless runs a simulation)": "Verklig trafik i realtid (utan nyckel körs en simulering)", + "Bing imagery map stacks + world terrain": "Kartuppsättningar med Bing-bilder + global terräng", + "More flight-polling credits (anonymous works without)": "Fler anropskrediter för flygdata (fungerar även anonymt)", + "CONTEXT READY": "KONTEXT REDO", + "GLOBAL CONTEXT OFF": "GLOBAL KONTEXT AV", + "SELECT A FLIGHT, VESSEL, OR MAPPED INSTALLATION": "VÄLJ EN FLYGNING, ETT FARTYG ELLER EN KARTLAGD ANLÄGGNING", + "ENABLE TO LOAD OBSERVED / MAPPED PROXIMITY": "AKTIVERA FÖR ATT LADDA OBSERVERADE / KARTLAGDA OBJEKT I NÄRHETEN", + "Unavailable": "Otillgänglig", + "Focus {label}": "Fokusera på {label}", + "Global Context navigation": "Navigering i Global kontext", + "Previous — prior visited contact in the 250 km window": "Förra – senast besökta kontakt inom 250 km", + "PREVIOUS": "FÖRRA", + "FOCUS": "FOKUS", + "Next — nearest unvisited contact in the 250 km window": "Nästa – närmaste obesökta kontakt inom 250 km", + "NEXT": "NÄSTA", + "Open-source mapped/observed context. Missing broadcasts, unloaded map areas, or unmapped sites are not evidence of absence.": "Kartlagd/observerad kontext från öppna källor. Uteblivna sändningar, kartområden som inte har laddats eller platser som inte är kartlagda bevisar inte att något saknas.", + "HDG {value}°": "HDG {value}°", + "BRG {value}°": "BRG {value}°", + "BRG —": "BRG —", + "CRS {value}°": "CRS {value}°", + "CURRENT VIEWPORT ONLY": "ENDAST AKTUELL VY", + "WITHIN {km} KM": "INOM {km} KM", + "Flights": "Flygningar", + "Military flights": "Militärflyg", + "AIS vessels": "AIS-fartyg", + "Mapped installations": "Kartlagda anläggningar", + "syncing {count} city feeds...": "synkar stadsflöden: {count} …", + "scanning nearby systems...": "söker efter system i närheten …", + "Station {id}": "Station {id}", + "Station": "Station", + "🚲 {bikes} avail · {docks} docks · {capacity} cap": "🚲 {bikes} lediga · {docks} platser · {capacity} kap.", + "⚠️ Not installed": "⚠️ Inte installerad", + "⚠️ Not renting": "⚠️ Ingen uthyrning", + "⚠️ Not returning": "⚠️ Ingen återlämning", + "HDG {value}° (ESTIMATED)": "HDG {value}° (UPPSKATTAD)", + "{count} CAMERAS STANDING BY · NO CAMERA SELECTED · CLICK A CAMERA TO ACTIVATE": "KAMEROR I BEREDSKAP: {count} · INGEN KAMERA VALD · KLICKA PÅ EN KAMERA FÖR ATT AKTIVERA DEN", + "No cameras available in catalog.": "Inga kameror finns i katalogen.", + "{city} CCTV": "CCTV {city}", + "FOV {value}°": "FOV {value}°", + "COVERAGE {value}km²": "TÄCKNING {value} km²", + "OVERLAP {count} cams": "ÖVERLAPP {count} kam.", + "ISOLATED VIEW": "ISOLERAD VY", + "PROJ MONITOR": "PROJ. SKÄRM", + "PROJ OFF": "PROJ. AV", + "VIEWSHED": "SIKTOMRÅDE", + "CAL {value}": "KAL. {value}", + "SRC {value}": "KÄLLA {value}", + "{value} CONTEXT": "KONTEXT {value}", + "Focus fire detection {title}, {details}": "Fokusera på branddetektering {title}, {details}", + "{value} conf": "{value} tillförlitlighet", + "{value} ago": "{value} sedan", + "sensor n/a": "sensor saknas", + "FIRE · {frp} MW": "BRAND · {frp} MW", + "NIGHT": "NATT", + "▲ {frp} MW": "▲ {frp} MW", + "FIRE": "BRAND", + "max {value} MW": "max. {value} MW", + "new {value}": "senast {value} sedan", + "just now": "nyss", + "<1m ago": "<1 min sedan", + "{count}m ago": "{count} min sedan", + "{count}h ago": "{count} h sedan", + "STALE · cached {age}": "INAKTUELL · cachad {age}", + "refreshing...": "uppdaterar …", + "loading...": "laddar …", + "KEY REQUIRED": "NYCKEL KRÄVS", + "LIVE · updated {age}": "LIVE · uppdaterad {age}", + "24 h to {day} {time} UTC": "24 h till {day} {time} UTC", + "loading mapped installation context": "laddar kontext för kartlagd anläggning", + "MAPPED INSTALLATION": "KARTLAGD ANLÄGGNING", + "LAUNCH SITE · {site}": "UPPSKJUTNINGSPLATS · {site}", + "LAUNCH SITE": "UPPSKJUTNINGSPLATS", + "T−{time} · {mission}": "T−{time} · {mission}", + "LAUNCH STANDBY": "VÄNTAR PÅ START", + "LIFTOFF · {mission}": "LYFT · {mission}", + "ASCENT REPLAY · {mission}": "UPPSPELNING AV UPPSTIGNING · {mission}", + "ASCENT ESTIMATE · {mission}": "UPPSKATTAD UPPSTIGNING · {mission}", + "STAGE RE-ENTRY / RECOVERY · {mission}": "ÅTERINTRÄDE / BÄRGNING AV STEG · {mission}", + "ORBIT REPLAY · {mission}": "UPPSPELNING AV OMLOPPSBANA · {mission}", + "PAUSED · {title}": "PAUSAD · {title}", + "UNSPECIFIED": "EJ ANGIVET", + "+{count} additional payload records": "Ytterligare nyttolastposter: +{count}", + "PAYLOAD DATA UNAVAILABLE": "NYTTOLASTDATA SAKNAS", + "{value} KM DOWNRANGE": "{value} KM MARKAVSTÅND", + "POSITION UNAVAILABLE": "POSITION SAKNAS", + "FLIGHT {count}": "FLYGNING {count}", + "REUSED": "ÅTERANVÄNT", + "NO STAGE RE-ENTRY / RECOVERY DATA": "INGA DATA OM ÅTERINTRÄDE / BÄRGNING AV STEG", + "{count} / 30D": "{count} / 30 D", + "NO MISSIONS AVAILABLE IN THE CURRENT 30-DAY WINDOW": "INGA UPPDRAG I AKTUELLT 30-DAGARSFÖNSTER", + "DATE UNAVAILABLE": "DATUM SAKNAS", + "UNSPECIFIED OPERATOR": "OPERATÖR EJ ANGIVEN", + "Select {mission}": "Välj {mission}", + "{value} KM": "{value} KM", + "{value} KM/S": "{value} KM/S", + "SELECTED SPACE MISSION": "VALT RYMDUPPDRAG", + "Show all missions": "Visa alla uppdrag", + "Deselect mission": "Avmarkera uppdrag", + "MISSION": "UPPDRAG", + "STATUS · ": "STATUS · ", + "LAUNCH SITE · ": "UPPSKJUTNINGSPLATS · ", + "LAUNCH TIME · ": "UPPSKJUTNINGSTID · ", + "ORBIT · ": "OMLOPPSBANA · ", + "ASCENT PATH · ": "UPPSTIGNINGSBANA · ", + "CURRENT DISTANCE FROM EARTH · ": "AKTUELLT AVSTÅND FRÅN JORDEN · ", + "SATELLITE SPEED · ": "SATELLITHASTIGHET · ", + "PAYLOAD": "NYTTOLAST", + "NAME": "NAMN", + "TYPE": "TYP", + "DESTINATION": "DESTINATION", + "STAGE / RE-ENTRY / RECOVERY": "STEG / ÅTERINTRÄDE / BÄRGNING", + "STAGE": "STEG", + "STATUS": "STATUS", + "FINAL POSITION": "SLUTPOSITION", + "REPLAY SPEED": "UPPSPELNINGSHAST.", + "Replay speed multiplier": "Faktor för uppspelningshastighet", + "REPLAY ASCENT": "SPELA UPP UPPSTIGNINGEN", + "Previous mission": "Föregående uppdrag", + "PREV": "FÖRRA", + "Next mission": "Nästa uppdrag", + "SHOW ALL / DESELECT": "VISA ALLA / AVMARKERA", + "Pause replay": "Pausa uppspelningen", + "Cancel replay": "Avbryt uppspelningen", + "EST. DOWNRANGE": "UPPSK. MARKAVSTÅND", + "PLANNED · ": "PLANERAD · ", + "SUPPLIED TRAJECTORY POINTS": "LEVERERADE BANPUNKTER", + "RECONSTRUCTED ESTIMATE": "REKONSTRUERAD UPPSKATTNING", + "Replay the estimated ascent with a following camera": "Spela upp den uppskattade uppstigningen med en medföljande kamera", + "Resume replay": "Återuppta uppspelningen", + "T minus {seconds}": "T minus {seconds}", + "Preparing launch site": "Förbereder uppskjutningsplatsen", + "Liftoff": "Lyft", + "Ascent replay": "Uppspelning av uppstigning", + "Orbit replay": "Uppspelning av omloppsbana", + ", paused": ", pausad", + "Alt unknown": "Höjd okänd", + "Type unknown": "Typ okänd", + "Reg unknown": "Reg. okänd", + "Operator unknown": "Operatör okänd", + "News": "Nyheter", + "Talk": "Prat", + "Weather / Emergency": "Väder / Nödläge", + "Public Safety": "Blåljus", + "Aviation / Marine": "Flyg / Sjöfart", + "Traffic / Transit": "Trafik / Kollektivtrafik", + "Music": "Musik", + "Other": "Övrigt", + "radio|TALK": "PRAT", + "radio|SAFETY": "BLÅLJUS", + "radio|AIR / SEA": "FLYG / SJÖ", + "radio|TRANSIT": "TRAFIK", + "radio|MUSIC": "MUSIK", + "Alternative": "Alternativ", + "Ambient": "Ambient", + "Blues": "Blues", + "Classical": "Klassiskt", + "Country": "Country", + "Dance": "Dance", + "Electronic": "Elektroniskt", + "Folk": "Folk", + "Funk": "Funk", + "Hip-Hop": "Hiphop", + "House": "House", + "Indie": "Indie", + "Jazz": "Jazz", + "Latin": "Latin", + "Metal": "Metal", + "Oldies": "Oldies", + "Pop": "Pop", + "Punk": "Punk", + "R&B": "R&B", + "Reggae": "Reggae", + "Rock": "Rock", + "Soul": "Soul", + "Techno": "Techno", + "Trance": "Trance", + "World": "Världsmusik", + "Add the full Starlink broadband shell (thousands of extra points)": "Lägg till hela Starlinks bredbandsskal (tusentals extra punkter)", + "Loading the Starlink shell…": "Laddar Starlink-skalet …", + "Starlink {detail} — click to retry": "Starlink: {detail} – klicka för att försöka igen", + "load failed": "laddningen misslyckades", + "Showing the full Starlink shell — click for the core catalog only": "Visar hela Starlink-skalet – klicka för att bara visa kärnkatalogen", + "DENSE": "TÄT", + "Syncing flow · Roads: {source}": "Synkar trafikflöde · Vägar: {source}", + "LIVE · Roads: {source} · Roads without flow hidden": "LIVE · Vägar: {source} · Vägar utan flöde dolda", + "LIVE · Roads: {source} · No flow roads in view": "LIVE · Vägar: {source} · Inga vägar med flöde i vyn", + "LIVE · Roads: {source} · Flow {percent}%": "LIVE · Vägar: {source} · Flöde {percent} %", + "SIMULATED · Roads: {source} · Flow {percent}%": "SIMULERAT · Vägar: {source} · Flöde {percent} %", + "Syncing flow · Roads: OpenStreetMap · Flow: TomTom · Unmatched: simulated": "Synkar trafikflöde · Vägar: OpenStreetMap · Flöde: TomTom · Utan matchning: simulerat", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov · Unmatched: simulated": "LIVE · Vägar: OpenStreetMap · Flöde: TomTom · {percent} % täckn. · Utan matchning: simulerat", + "LIVE · Roads: OpenStreetMap · Flow: TomTom · {percent}% cov": "LIVE · Vägar: OpenStreetMap · Flöde: TomTom · {percent} % täckn.", + "SIMULATED · Roads: OpenStreetMap · Flow: TomTom (no matches)": "SIMULERAT · Vägar: OpenStreetMap · Flöde: TomTom (inga träffar)", + "SIMULATED — traffic service unreachable": "SIMULERAT – trafiktjänsten kan inte nås", + "SIMULATED — add TomTom key for live": "SIMULERAT – lägg till TomTom-nyckel för livedata", + "AIS: {name}": "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}": "{type} SPD: {speed} HDG: {heading}", + "VESSEL": "FARTYG", + "POS: LIVE": "POS: LIVE", + "POS: {time}Z": "POS: {time}Z", + "awaiting first AIS position…": "väntar på första AIS-positionen …", + "awaiting usable AIS positions…": "väntar på användbara AIS-positioner …", + "awaiting first AIS message…": "väntar på första AIS-meddelandet …", + "Resume": "Fortsätt", + "Pause": "Pausa", + "Error: {message}": "Fel: {message}", + "Needs {envVars} — add it in Provider Settings": "Kräver {envVars} – lägg till den i Leverantörsinställningar", + "{label} is unavailable": "{label} är otillgänglig", + "This map stack": "Den här kartuppsättningen", + "{label} unavailable: {hint}": "{label} otillgänglig: {hint}", + "Visible map targets": "Synliga kartmål", + "Ready": "Redo", + "Loaded: {scene} / {shot}": "Laddat: {scene} / {shot}", + "Cannot capture shot: camera not ready": "Kan inte spara tagning: kameran är inte redo", + "Captured: {scene} / {shot}": "Sparat: {scene} / {shot}", + "Select a shot first": "Välj en tagning först", + "Updated: {scene} / {shot}": "Uppdaterat: {scene} / {shot}", + "Camera unavailable — exit cockpit first": "Kameran är otillgänglig – lämna cockpit först", + "No shots to run": "Inga tagningar att köra", + "Project exported": "Projektet har exporterats", + "Could not exit {mode} — scene layers may be refused": "Kunde inte lämna {mode} – scenens lager kan nekas", + "Running {index}/{total}: {scene} / {shot}": "Kör {index}/{total}: {scene} / {shot}", + "Scene run complete": "Scenkörningen är klar", + "Global Flights Radar": "Global flygradar", + "Orbital Watch": "Omloppsbevakning", + "Thermal Threat Board": "Termisk hotbild", + "City Overload": "Överbelastad stad", + "Omniscience Pullback": "Allseende utzoomning", + "Shot {n}": "Tagning {n}", + "Density": "Täthet", + "Bloom": "Bloom", + "VISUAL PRESETS": "VISUELLA FÖRVAL", + "LOCATION": "PLATS", + "CONTACTS — nearest planes · vessels · sites": "KONTAKTER – närmaste flygplan · fartyg · platser", + "SPACE MISSIONS — launches & orbital assets": "RYMDUPPDRAG – uppskjutningar och objekt i omloppsbana", + "LIVE SIGNALS": "LIVESIGNALER", + "Contacts could not complete the requested transition; try again": "Läget ”Kontakter” kunde inte slutföra övergången – försök igen", + "CCTV layer unavailable": "CCTV-lagret är otillgängligt", + "Link copied!": "Länken har kopierats!", + "Copy failed": "Kunde inte kopiera", + "CCTV calibration saved": "CCTV-kalibreringen sparades", + "CCTV calibration reset": "CCTV-kalibreringen återställdes", + "ADJUST ON": "JUSTERING PÅ", + "ADJUST": "JUSTERA", + "UNKNOWN": "OKÄND", + "ERROR": "FEL", + "SOURCE · UNKNOWN": "KÄLLA · OKÄND", + "FRAME · LOADING": "BILDRUTA · LADDAR", + "FRAME · UNAVAILABLE": "BILDRUTA · OTILLGÄNGLIG", + "CALIBRATED": "KALIBRERAD", + "CURATED": "GRANSKAD", + "RAW PRIOR": "RÅ UPPSKATTNING", + "CCTV ON": "CCTV PÅ", + "CCTV OFF": "CCTV AV", + "VIEWSHED ON": "SIKTOMRÅDE PÅ", + "COVERAGE ON": "TÄCKNING PÅ", + "COVERAGE OFF": "TÄCKNING AV", + "AUTO HOP ON": "AUTOBYTE PÅ", + "AUTO HOP OFF": "AUTOBYTE AV", + "PROJECTION ON": "PROJEKTION PÅ", + "PROJECTION OFF": "PROJEKTION AV", + "CAL · EDITED (UNSAVED)": "KAL. · ÄNDRAD (EJ SPARAD)", + "CAL · {badge}": "KAL. · {badge}", + "MONITOR": "SKÄRM", + "OFF": "AV", + "{city} · HDG {heading} · FOV {fov} · RANGE {range}m · {projection}{calBadge} · {provider}{status}": "{city} · HDG {heading} · FOV {fov} · RÄCKV. {range} m · {projection}{calBadge} · {provider}{status}", + "{count} cameras loaded · click a camera to activate": { + "one": "{count} kamera laddad · klicka på en kamera för att aktivera den", + "other": "{count} kameror laddade · klicka på en kamera för att aktivera den" + }, + "{count} cameras loaded · enable CCTV to activate": { + "one": "{count} kamera laddad · aktivera CCTV för att använda den", + "other": "{count} kameror laddade · aktivera CCTV för att använda dem" + }, + "{count} more cameras outside this view · pan or zoom out": { + "one": "{count} kamera till utanför vyn · panorera eller zooma ut", + "other": "{count} kameror till utanför vyn · panorera eller zooma ut" + }, + "Enable CCTV to load camera intersections": "Aktivera CCTV för att ladda korsningar med kameror", + "Enable CCTV to start camera-linked intelligence summaries.": "Aktivera CCTV för att starta kamerakopplade lägessammanfattningar.", + "No summary available.": "Ingen sammanfattning tillgänglig.", + "loading frames": "laddar bildrutor", + "camera grid ready": "kamerarutnätet är redo", + "Clearing selected data layers": "Stänger av valda datalager", + "Clear selected data layers": "Stäng av valda datalager", + "CONDITIONS UNKNOWN": "VÄDER OKÄNT", + "CLEAR": "KLART", + "PARTLY CLOUDY": "HALVKLART", + "OVERCAST": "MULET", + "FOG": "DIMMA", + "NO NEWS IN THE LAST {hours} H": "INGA NYHETER SENASTE {hours} H", + "DRIZZLE": "DUGGREGN", + "RAIN": "REGN", + "RAIN SHOWERS": "REGNSKURAR", + "SNOW SHOWERS": "SNÖBYAR", + "THUNDERSTORM": "ÅSKA", + "MIXED CONDITIONS": "VÄXLANDE VÄDER", + "{source} · CACHED {age}": "{source} · CACHAT {age}", + "CYCLE ON": "VÄXLING PÅ", + "CYCLE OFF": "VÄXLING AV", + "Stop automatic page cycling. Previous, Next, and the SIG/NEWS/LOCAL tabs stay available.": "Stoppa automatisk sidväxling. Förra, Nästa och flikarna SIG/NYHETER/LOKALT finns kvar.", + "Cycle briefing pages automatically every 9 seconds (Signals → News → Local). Pauses while you hover or focus the panel. Live signal data refreshes continuously either way.": "Växla genomgångens sidor automatiskt var 9:e sekund (Signaler → Nyheter → Lokalt). Pausar när pekaren är över panelen eller när den har fokus. Livesignaldata uppdateras löpande i båda fallen.", + "ACQUIRING REGIONAL NEWS": "HÄMTAR REGIONALA NYHETER", + "REGIONAL NEWS UNAVAILABLE": "REGIONALA NYHETER OTILLGÄNGLIGA", + "CACHED · REFRESH FAILED": "CACHAT · UPPDATERING MISSLYCKADES", + "RESOLVING REGION": "FASTSTÄLLER REGION", + "REGION UNAVAILABLE": "REGION OTILLGÄNGLIG", + "{domain} · {age}": "{domain} · {age}", + "CLOUD {pct}%": "MOLN {pct} %", + "CLOUD UNKNOWN": "MOLN OKÄNT", + "REGIONAL NEWS": "REGIONALA NYHETER", + "CONTEXT STANDBY": "KONTEXT I VÄNTLÄGE", + "ENABLE GLOBAL CONTEXT FOR PROXIMITY PINGS": "AKTIVERA GLOBAL KONTEXT FÖR NÄRHETSPINGAR", + "CONTACT LOST · LAST KNOWN READOUT · NOT AN ALL-CLEAR": "KONTAKT FÖRLORAD · SENAST KÄNDA VÄRDEN · INTE FARA ÖVER", + "CONTACT LOST · {subject}": "KONTAKT FÖRLORAD · {subject}", + "SUBJECT LEFT ITS FEED · READOUT HOLDING LAST KNOWN": "OBJEKTET HAR LÄMNAT FLÖDET · VISAR SENAST KÄNDA VÄRDEN", + "{cohort} · {contact}": "{cohort} · {contact}", + "NO AVAILABLE EXAMPLE": "INGET EXEMPEL TILLGÄNGLIGT", + "{cohort}, Unavailable": "{cohort}, otillgänglig", + "AHEAD": "RAKT FRAM", + "{side} {angle}": "{side} {angle}", + "L": "VÄ", + "R": "HÖ", + "{count} INPUTS UNKNOWN · NOT AN ALL-CLEAR": { + "one": "{count} KÄLLA OKÄND · INTE FARA ÖVER", + "other": "{count} KÄLLOR OKÄNDA · INTE FARA ÖVER" + }, + "AVAILABLE INPUTS CURRENT · NOT AN ALL-CLEAR": "TILLGÄNGLIGA KÄLLOR AKTUELLA · INTE FARA ÖVER", + "AIRCRAFT": "FLYGPLAN", + "ACQUIRING SURFACE": "HÄMTAR MARKYTA", + "SURFACE FALLBACK": "RESERVMARKYTA", + "STALE FEED": "INAKTUELLT FLÖDE", + "LIVE TRACK": "LIVESPÅR", + "COMMERCIAL": "CIVIL", + "{aircraftClass} · {feedState} · COURSE ALIGNED": "{aircraftClass} · {feedState} · I KURSRIKTNING", + "ARROW · ESTIMATED DIRECTION": "PIL · UPPSKATTAD RIKTNING", + "ROUTE DATA UNAVAILABLE": "RUTTDATA SAKNAS", + "DEST {bearing}": "DEST {bearing}", + "Disable cockpit weather effects": "Inaktivera vädereffekter i cockpit", + "Enable cockpit weather effects": "Aktivera vädereffekter i cockpit", + "ON": "PÅ", + "Normal": "Normal", + "Night vision": "Mörkerseende", + "Thermal": "Värmekamera", + "Anime": "Anime", + "Noir": "Noir", + "Current cockpit vision style: {style}. Activate for next style.": "Aktuell bildstil i cockpit: {style}. Aktivera för nästa stil.", + "Current style: {style} — click for next": "Aktuell stil: {style} – klicka för nästa", + "Collapse Contact panel": "Fäll ihop kontaktpanelen", + "Expand Contact panel": "Fäll ut kontaktpanelen", + "Collapse contact panel": "Fäll ihop kontaktpanelen", + "Expand contact panel": "Fäll ut kontaktpanelen", + "Collapse cockpit briefing panel": "Fäll ihop cockpitens genomgångspanel", + "Expand cockpit briefing panel": "Fäll ut cockpitens genomgångspanel", + "Collapse briefing panel": "Fäll ihop genomgångspanelen", + "Expand briefing panel": "Fäll ut genomgångspanelen", + "OBSERVED / MAPPED PINGS": "OBSERVERADE / KARTLAGDA PINGAR", + "SOURCE-BACKED EVENTS · NO SYNTHETIC NEWS": "KÄLLBELAGDA HÄNDELSER · INGA SYNTETISKA NYHETER", + "LATEST LOCATION-MATCHED REPORTING": "SENASTE PLATSMATCHADE RAPPORTERING", + "LOCAL INFO": "LOKAL INFO", + "PLACE / CONDITIONS / POSITION": "PLATS / VÄDER / POSITION", + "TIME UNKNOWN": "TID OKÄND", + "{count}M AGO": "{count} MIN SEDAN", + "{count}H AGO": "{count} H SEDAN", + "{count}D AGO": "{count} D SEDAN", + "DIR UNKNOWN": "RIKTN. OKÄND", + "Select flight {title}": "Välj flygning {title}", + "{aircraftClass} · CURRENT": "{aircraftClass} · AKTUELL", + "MILITARY FLIGHT": "MILITÄRFLYG", + "COMMERCIAL FLIGHT": "CIVILFLYG", + "{aircraftClass} · {distance}": "{aircraftClass} · {distance}", + "DISTANCE UNKNOWN": "AVSTÅND OKÄNT", + "{count} INPUTS UNKNOWN": { + "one": "{count} KÄLLA OKÄND", + "other": "{count} KÄLLOR OKÄNDA" + }, + "SOURCE STATUS UNAVAILABLE": "KÄLLSTATUS OTILLGÄNGLIG", + "Restore real aircraft": "Återställ verkligt flygplan", + "Reclassify as TR-3B": "Klassa om som TR-3B", + "TRACK ACQUIRED": "SPÅR LÅST", + "{label} · COURSE {heading}°": "{label} · FÄRDRIKTNING {heading}°", + "Context could not restore every layer; try again": "Läget ”Kontext” kunde inte återställa alla lager – försök igen", + "No selected data layers": "Inga valda datalager", + "{count} data layers could not be cleared": { + "one": "{count} datalager kunde inte stängas av", + "other": "{count} datalager kunde inte stängas av" + }, + "Cleared {count} data layers": { + "one": "Stängde av {count} datalager", + "other": "Stängde av {count} datalager" + }, + "Selected data layers could not be cleared": "De valda datalagren kunde inte stängas av", + "Space Missions could not complete the requested transition; try again": "Läget ”Rymduppdrag” kunde inte slutföra övergången – försök igen", + "Nearby installations refreshed": "Anläggningar i närheten har uppdaterats", + "Nearby installations could not be refreshed; try again": "Det gick inte att uppdatera anläggningar i närheten – försök igen", + "Space Missions cancellation could not restore the previous layer state": "Lagrens tidigare läge kunde inte återställas när ”Rymduppdrag” avbröts", + "That layer is unavailable in the current Context mode": "Lagret är otillgängligt i det aktuella läget ”Kontext”", + "{layerId} could not {action} cleanly": "{layerId} kunde inte {action} korrekt", + "start": "startas", + "stop": "stoppas", + "{mode} could not start because another layer did not stop cleanly": "{mode} kunde inte startas eftersom ett annat lager inte stoppades korrekt", + "Space Missions": "Rymduppdrag", + "Context": "Kontext", + "Live Flights": "Liveflyg", + "Military Flights": "Militärflyg", + "Earthquakes (24h)": "Jordbävningar (24 h)", + "Satellites": "Satelliter", + "Space Missions (30d)": "Rymduppdrag (30 d)", + "Street Traffic": "Gatutrafik", + "CCTV": "CCTV", + "Radio": "Radio", + "Bikeshare": "Hyrcyklar", + "Live AIS Vessels": "AIS-fartyg live", + "Mapped Installations": "Kartlagda anläggningar", + "Global Context": "Global kontext", + "Datacenters": "Datacenter", + "Dams": "Dammar", + "Submarine Cables": "Sjökablar", + "FIRMS Active Fires": "Aktiva bränder (FIRMS)", + "ENABLING": "AKTIVERAR", + "DISABLING": "INAKTIVERAR", + "{state} · {source}": "{state} · {source}", + "UNCERTAIN · {source} · lifecycle state requires reconciliation": "OSÄKER · {source} · livscykelstatus behöver stämmas av", + "{state} · {source} · {detail} · retry {seconds}s": "{state} · {source} · {detail} · nytt försök om {seconds} s", + "{state} · {source} · {detail}": "{state} · {source} · {detail}", + "never": "aldrig", + "{source} · {loadingLabel}": "{source} · {loadingLabel}", + "{accepted} of {raw} records accepted": "{accepted} av {raw} poster godtogs", + "incomplete snapshot": "ofullständig ögonblicksbild", + "{state} · {source} · {detail} · {ago}": "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago} · retrying in {seconds}s": "{state} · {source} · {ago} · nytt försök om {seconds} s", + "{state} · {source} · {ago}": "{state} · {source} · {ago}", + "{source} · {ago}": "{source} · {ago}", + "UNCERTAIN": "OSÄKER", + "{name}: {state}": "{name}: {state}", + "{count}s ago": "{count} s sedan", + "CONNECTING": "ANSLUTER", + "📍 Location: --": "📍 Plats: --", + "Landmark: --": "Landmärke: --", + "Searched location": "Sökt plats", + "ORBIT": "OMLOPP", + "Location not found": "Platsen hittades inte", + "Search failed": "Sökningen misslyckades", + "Fly to a POI first": "Flyg till en intressepunkt först", + "Reset to full globe view": "Återställ till vy över hela globen", + "Reset cockpit to full globe view": "Återställ cockpit till vy över hela globen", + "Resetting to full globe view": "Återställer till vy över hela globen", + "Resetting cockpit to full globe view": "Återställer cockpit till vy över hela globen", + "MAP": "KARTA", + "panel": "panelen", + "Expand {name}": "Fäll ut {name}", + "Collapse {name}": "Fäll ihop {name}", + "Expand Radio": "Fäll ut Radio", + "Collapse Radio": "Fäll ihop Radio", + "Expand Radio section": "Fäll ut radiosektionen", + "Collapse Radio section": "Fäll ihop radiosektionen", + "Panel layout updated — positions reset to new defaults": "Panellayouten har uppdaterats – positionerna har återställts till nya standardvärden", + "Collapse Cockpit display options": "Fäll ihop cockpitens visningsalternativ", + "Expand Cockpit display options": "Fäll ut cockpitens visningsalternativ", + "Collapse Cockpit Radio controls": "Fäll ihop cockpitens radioreglage", + "Expand Cockpit Radio controls": "Fäll ut cockpitens radioreglage", + "NO STATIONS": "INGA STATIONER", + "NO STATION AVAILABLE": "INGEN STATION TILLGÄNGLIG", + "{name}, station {index} of {total}": "{name}, station {index} av {total}", + "No station available": "Ingen station tillgänglig", + "OFF AIR": "SÄNDER INTE", + "STATION UNAVAILABLE": "STATION OTILLGÄNGLIG", + "Radio could not {action} cleanly": "Radion kunde inte {action} korrekt", + "SYNC": "SYNK", + "RECONCILE": "STÄM AV", + "DISABLE": "INAKTIVERA", + "ENABLE": "AKTIVERA", + "Reconcile Radio — lifecycle uncertain": "Stäm av radion – livscykeln är oklar", + "Disable Radio": "Inaktivera radion", + "Enable Radio": "Aktivera radion", + "DIRECTORY BAND": "STATIONSSKALA", + "{category} BAND": "SKALA · {category}", + "NO STATION SELECTED": "INGEN STATION VALD", + "Directory metadata only": "Endast katalogmetadata", + "Loading station directory…": "Laddar stationskatalogen …", + "Choose a globe marker or use next.": "Välj en markör på globen eller använd Nästa.", + "TAGS · {tags}": "TAGGAR · {tags}", + "Play": "Spela upp", + "PLAY": "SPELA", + "selected": "vald", + "nearest": "närmaste", + "{action} {target} radio station": "{action}: {target} radiostation", + "SYNCING": "SYNKAR", + "READY": "REDO", + "station": "stationen", + " · stale/degraded directory": " · inaktuell/begränsad katalog", + " · degraded directory": " · begränsad katalog", + " · stale directory": " · inaktuell katalog", + " · outside current filter": " · utanför aktuellt filter", + "Ready — playback starts only from your action": "Redo – uppspelningen startar bara manuellt", + "Radio off": "Radio av", + "Connecting directly to broadcaster…": "Ansluter direkt till stationen …", + "Buffering broadcaster stream…": "Buffrar stationens ström …", + "Playing {station}": "Spelar {station}", + "Paused {station}": "Pausad: {station}", + "Broadcaster stream unavailable": "Stationens ström är otillgänglig", + " · muted during voice interaction": " · tystad under röststyrning", + " · restoring volume after voice": " · återställer volymen efter röststyrning", + " · static indicates no broadcaster audio": " · brus betyder att stationen inte sänder ljud", + " · tuning static until broadcaster starts": " · brus tills stationen börjar sända", + "Station unavailable after directory refresh — choose another channel": "Stationen är otillgänglig efter att katalogen uppdaterats – välj en annan kanal", + "Radio is enabling…": "Radion aktiveras …", + "Radio is disabling…": "Radion inaktiveras …", + "Radio lifecycle is uncertain — use Enable or Disable to reconcile": "Radions livscykel är oklar – använd Aktivera eller Inaktivera för att stämma av", + "Scene action failed": "Scenåtgärden misslyckades", + "Delete shot \"{shot}\"?": "Ta bort tagningen ”{shot}”?", + "SPARSE": "GLES", + "BALANCED": "BALANSERAD", + "DEL": "TA BORT", + "entity": "objekt", + "Shared {subject} follow expired": "Delad följning av {subject} har gått ut", + "Shared {subject} could not be restored — feed unavailable": "Kunde inte återställa {subject} från delad länk – flödet är otillgängligt", + "Shared {subject} is unavailable": "Kan inte visa {subject} från delad länk", + "ACQUIRING": "SÖKER", + "SHARED {subject}": "DELAD LÄNK: {subject}", + "LOADING LIVE DATA": "LADDAR LIVEDATA", + "LOAD COMPLETE": "LADDNING KLAR", + "LOAD CANCELLED": "LADDNING AVBRUTEN", + "LOAD FAILED": "LADDNINGSFEL", + "LIVE DATA OFF": "LIVEDATA AV", + "MAPPED SITES LOADED": "KARTLAGDA PLATSER LADDADE", + "RETRYING MAPPED SITES": "KARTLAGDA PLATSER: NYTT FÖRSÖK", + "FETCHING MAPPED SITES": "HÄMTAR KARTLAGDA PLATSER", + "TURNING OFF LIVE DATA": "STÄNGER AV LIVEDATA", + "REFRESHING LIVE DATA": "UPPDATERAR LIVEDATA", + "syncing road network": "synkroniserar vägnätet", + "retry pending": "nytt försök väntar", + "retrying in {seconds}s": "nytt försök om {seconds} s", + "Celestial ring — reveal the full globe": "Himmelsring – visa hela globen", + "Celestial ring — available in Normal style": "Himmelsring – tillgänglig i stilen ”Normal”", + "Detection overlay: {mode}": "Detekteringsöverlägg: {mode}", + "Detection overlay: off": "Detekteringsöverlägg: av", + "DETECT": "DETEKT.", + "voice|LISTENING": "LYSSNAR", + "voice|EXECUTING": "UTFÖR", + "AI AGENT": "AI-AGENT", + "Voice model tier — applies next session": "Röstmodellens nivå – gäller från nästa session", + "Estimated session cost": "Uppskattad sessionskostnad", + "Voice control — activate to toggle voice; hold Space to speak": "Röststyrning – aktivera för att slå på eller av rösten; håll ned Mellanslag för att tala", + "VOICE STANDBY": "RÖST I VÄNTLÄGE", + "VOICE CONTROL": "RÖSTSTYRNING", + "Hold Space to speak · tap Space to activate focused controls": "Håll ned Mellanslag för att tala · tryck kort på Mellanslag för att aktivera kontrollen i fokus", + "VOICE SYSTEM ERROR": "FEL I RÖSTSYSTEMET", + "DISMISS": "STÄNG", + "Check microphone permission and network access, then try again.": "Kontrollera mikrofonbehörighet och nätverksåtkomst och försök sedan igen.", + "Release Space to send": "Släpp Mellanslag för att skicka", + "Hold Space to talk": "Håll ned Mellanslag för att tala", + "Requesting microphone": "Begär åtkomst till mikrofonen", + "Ask or command": "Fråga eller ge ett kommando", + "Running command": "Kör kommando", + "Voice off": "Röst av", + "WebRTC microphone support unavailable": "Mikrofonstöd för WebRTC saknas", + "VOICE UNAVAILABLE": "RÖST OTILLGÄNGLIG", + "VOICE ACTIVE": "RÖST AKTIV", + "Voice session could not be started.": "Röstsessionen kunde inte startas.", + "Next session: {pendingId} — this session stays on {modelId}": "Nästa session: {pendingId} – den här sessionen fortsätter med {modelId}", + "Voice model: {pendingId} — click to switch to {target}; applies next session": "Röstmodell: {pendingId} – klicka för att byta till {target}; gäller från nästa session", + "Estimated session cost on {modelId} — {responses} response(s). Warns at {warn}, ends the session at {cap}.": "Uppskattad sessionskostnad med {modelId} – {responses} svar. Varnar vid {warn}, avslutar sessionen vid {cap}.", + "{tier} applies next session": "{tier} gäller från nästa session", + "Session ended — cost cap {cost}": "Sessionen avslutades – kostnadstak {cost}", + "Radio did not start": "Radion startade inte", + "Aircraft cockpit view": "Cockpitvy för luftfartyg", + "LEVEL": "HORISONT", + "Estimated destination direction": "Uppskattad riktning mot destinationen", + "DEST ---°": "DEST ---°", + "OPTICAL PLANE · 01": "OPTISKT PLAN · 01", + "VISOR LOCK · ACTIVE": "VISIRLÅS · AKTIVT", + "GROUND SPEED · KTS": "MARKFART · KN", + "ALTITUDE · FT": "HÖJD · FT", + "FIRST PERSON": "FÖRSTAPERSONSVY", + "LIVE TRACK · COURSE ALIGNED": "LIVESPÅR · I KURSRIKTNING", + "Cockpit vision style": "Bildstil i cockpit", + "Previous cockpit vision style": "Föregående bildstil i cockpit", + "Previous vision style": "Föregående bildstil", + "CURRENT": "AKTUELL", + "Next cockpit vision style": "Nästa bildstil i cockpit", + "Next vision style": "Nästa bildstil", + "Current aircraft heading": "Luftfartygets aktuella kurs", + "GROUND SPEED": "MARKFART", + "ALTITUDE": "HÖJD", + "Contact cockpit summary": "Cockpitsammanfattning för kontakten", + "CONTACT": "KONTAKT", + "CONTACTS · 250 KM": "KONTAKTER · 250 KM", + "Contact navigation": "Kontaktnavigering", + "CONTEXT ONLY": "ENDAST KONTEXT", + "Nearby cohort counts": "Antal kontakter i närheten", + "NEAREST OBSERVED / MAPPED": "NÄRMASTE OBSERVERADE / KARTLAGDA", + "AVAILABLE INPUTS ONLY · NOT AN ALL-CLEAR": "ENDAST TILLGÄNGLIGA KÄLLOR · INTE FARA ÖVER", + "Cockpit briefing carousel": "Karusell för cockpitgenomgången", + "Estimated flight plan": "Uppskattad färdplan", + "ESTIMATED FLIGHT PLAN": "UPPSKATTAD FÄRDPLAN", + "FROM": "FRÅN", + "TO": "TILL", + "Cockpit briefing controls": "Reglage för cockpitgenomgången", + "Previous briefing page": "Föregående genomgångssida", + "Next briefing page": "Nästa genomgångssida", + "Live signals": "Livesignaler", + "Latest regional news": "Senaste regionala nyheterna", + "Location-based information": "Platsbaserad information", + "TEMP": "TEMP", + "WIND": "VIND", + "SKY": "HIMMEL", + "PRECIP": "NEDERB.", + "Show Live Signals": "Visa livesignaler", + "NEWS": "NYHETER", + "Show Regional News": "Visa regionala nyheter", + "Show Local Info": "Visa lokal information", + "Cockpit display and Radio controls": "Cockpitens visnings- och radioreglage", + "DISPLAY": "VISNING", + "Cockpit display options": "Cockpitens visningsalternativ", + "RADIO": "RADIO", + "Cockpit compact Radio controls": "Kompakta radioreglage i cockpit", + "Previous filtered radio station": "Föregående filtrerade radiostation", + "Play selected radio station": "Spela upp vald radiostation", + "Next filtered radio station": "Nästa filtrerade radiostation", + "VOLUME": "VOLYM", + "Cockpit Radio volume": "Radiovolym i cockpit", + "View switcher": "Vyväxlare", + "Exit cockpit and return to full globe view": "Lämna cockpit och återgå till vy över hela globen", + "RESET": "ÅTERSTÄLL", + "Exit cockpit view": "Lämna cockpitvyn", + "EXIT COCKPIT": "LÄMNA COCKPIT", + "Navigation, voice, and visual preset controls": "Reglage för navigering, röststyrning och visuella förval", + "Expand Visual Presets": "Fäll ut visuella förval", + "Pin visual presets": "Fäst visuella förval", + "Keep visual presets open": "Håll visuella förval öppna", + "Show the globe without a visual filter.": "Visa globen utan visuellt filter.", + "Emulate a green phosphor CRT with scanlines and screen curvature.": "Efterlikna en grön fosfor-CRT med sveplinjer och skärmkrökning.", + "Simulate night-vision goggles with green intensification and a tube vignette.": "Simulera mörkerglasögon med grön ljusförstärkning och rörvinjett.", + "Simulate FLIR-style thermal contrast. Turn up Ironbow for color.": "Simulera FLIR-liknande värmekontrast. Höj Ironbow för färg.", + "Apply bright cel-shaded color and illustrated outlines.": "Använd klara färger med cel shading och tecknade konturer.", + "Apply high-contrast monochrome film-noir grading.": "Använd kontrastrik monokrom färgsättning i film noir-stil.", + "MAP SOURCE": "KARTKÄLLA", + "Map source": "Kartkälla", + "Style": "Stil", + "Expand LOCATION": "Fäll ut PLATS", + "Collapse panel": "Fäll ihop panelen", + "Pin location tray": "Fäst platsfältet", + "Keep location tray open": "Håll platsfältet öppet", + "Search any location": "Sök valfri plats", + "Search location by name or coordinates": "Sök plats efter namn eller koordinater", + "Search any location...": "Sök valfri plats …", + "WEATHER": "VÄDER", + "CONTEXT": "KONTEXT", + "Context mode": "Kontextläge", + "CONTACTS": "KONTAKTER", + "Cycles the nearest contacts of whatever type you select — planes, vessels, installations. Satellites track independently.": "Växlar mellan de närmaste kontakterna av den typ som väljs – flygplan, fartyg, anläggningar. Satelliter följs separat.", + "SPACE MISSIONS": "RYMDUPPDRAG", + "SELECT CONTEXT": "VÄLJ KONTEXT", + "Contact Context actions": "Åtgärder för kontaktkontext", + "COCKPIT": "COCKPIT", + "SEARCH NEARBY SITES": "SÖK PLATSER I NÄRHETEN", + "Reclassify tracked contact as TR-3B": "Klassa om den följda kontakten som TR-3B", + "CONTACTS CONTEXT OFF": "KONTAKTKONTEXT AV", + "SELECT CONTACTS TO LOAD OBSERVED / MAPPED PROXIMITY": "VÄLJ KONTAKTER FÖR ATT LADDA OBSERVERAT / KARTLAGT I NÄRHETEN", + "Available Space Missions": "Tillgängliga rymduppdrag", + "AVAILABLE MISSIONS": "TILLGÄNGLIGA UPPDRAG", + "SELECT A MISSION TO INSPECT": "VÄLJ ETT UPPDRAG ATT GRANSKA", + "LOADING 30-DAY MISSION INDEX": "LADDAR UPPDRAGSINDEX FÖR 30 DAGAR", + "Space Missions unavailable: the mission index could not load. Try again later.": "Rymduppdrag otillgängliga: uppdragsindexet kunde inte laddas. Försök igen senare.", + "30-DAY MISSION INDEX UNAVAILABLE": "UPPDRAGSINDEX FÖR 30 DAGAR OTILLGÄNGLIGT", + "TAB PREVIEWS · ENTER / SPACE SELECTS": "TAB FÖRHANDSVISAR · ENTER / MELLANSLAG VÄLJER", + "Internet radio and local SDR": "Internetradio och lokal SDR", + "STATION TAG": "STATIONSTAGG", + "Filter stations by station tag": "Filtrera stationer efter stationstagg", + "All": "Alla", + "Enable Radio, then choose a globe marker or use next.": "Aktivera radion och välj sedan en markör på globen eller använd Nästa.", + "DRAG TO TUNE": "DRA OCH STÄLL IN", + "ALL · DRAG THE NEEDLE": "ALLA · DRA I VISAREN", + "SNAPS TO AVAILABLE STATIONS": "FÄSTER VID TILLGÄNGLIGA STATIONER", + "Radio playback": "Radiouppspelning", + "Previous filtered station": "Föregående filtrerade station", + "Play selected station": "Spela upp vald station", + "Next filtered station": "Nästa filtrerade station", + "STOP": "STOPP", + "Stop radio playback": "Stoppa radiouppspelningen", + "Radio volume": "Radiovolym", + "STATION SITE": "WEBBPLATS", + "Audio connects directly to the broadcaster when you press play, choose a station on the globe or ask voice to play. Your IP is visible to that broadcaster.": "Ljudet ansluts direkt till stationen vid uppspelning, när en station väljs på globen eller när röststyrningen ombeds spela. Stationen ser din IP-adress.", + "Intelligence HUD (H)": "Lägesbilds-HUD (H)", + "Layout": "Layout", + "HUD layout": "HUD-layout", + "Tactical": "Taktisk", + "Minimal": "Minimal", + "Detection Overlay (D)": "Detekteringsöverlägg (D)", + "Detection overlay": "Detekteringsöverlägg", + "Detection label density": "Etikettäthet för detektering", + "Allocation": "Fördelning", + "Detection label allocation": "Etikettfördelning för detektering", + "Elastic": "Elastisk", + "Weighted": "Viktad", + "Fade": "Uttoning", + "Detection fade distance": "Uttoningsavstånd för detektering", + "World-overlay fade distance outside the keyhole as a percentage of its radius": "Uttoningsavstånd för världsöverlägget utanför nyckelhålet, i procent av dess radie", + "Outside": "Utanför", + "Detection opacity outside the keyhole": "Detekteringens opacitet utanför nyckelhålet", + "World-overlay label and card opacity beyond the fade distance": "Opacitet för etiketter och kort i världsöverlägget bortom uttoningsavståndet", + "PARAMETERS": "PARAMETRAR", + "3D aircraft — flat icons zoomed out, 3D models up close": "3D-flygplan – platta ikoner på avstånd, 3D-modeller på nära håll", + "Models": "Modeller", + "3D model coverage": "Täckning med 3D-modeller", + "Proximity": "Närhet", + "Scope — the circular viewport mask": "Scope – den cirkulära vymasken", + "Scope": "Scope", + "Feather": "Mjuk kant", + "Scope edge feather": "Mjuk kant på Scope", + "Scope edge feather as a percentage of the keyhole radius": "Mjuk kant på Scope i procent av nyckelhålets radie", + "Celestial": "Himmelsring", + "Hide UI chrome": "Dölj gränssnittet", + "Clean UI": "Rent gränssnitt", + "Bloom / Glow": "Bloom / Glöd", + "Bloom intensity": "Bloom-styrka", + "Sharpening": "Skärpning", + "Sharpen": "Skärpa", + "Sharpen intensity": "Skärpestyrka", + "EXIT CLEAN VIEW": "LÄMNA RENT GRÄNSSNITT", + "Return UI controls": "Visa gränssnittet igen", + "Initializing photorealistic world...": "Initierar fotorealistisk värld …", + "DATA LAYERS": "DATALAGER", + "NEAREST": "NÄRMASTE", + "CCTV camera": "CCTV-kamera", + "CALIBRATION": "KALIBRERING", + "Drag the camera in the world: rings rotate, arrows move, handles set range/FOV": "Dra kameran i världen: ringar roterar, pilar flyttar, handtag ställer in räckvidd/FOV", + "Camera pose — click a value to type": "Kameraläge – klicka på ett värde för att skriva in det", + "Heading (compass °) — click to type": "Riktning (kompass °) – klicka för att skriva in", + "Pitch (° up/down) — click to type": "Lutning (° upp/ned) – klicka för att skriva in", + "Horizontal FOV (°) — click to type": "Horisontell FOV (°) – klicka för att skriva in", + "Range / monitor-plane distance (m) — click to type": "Räckvidd / avstånd till bildplanet (m) – klicka för att skriva in", + "Mount height above ground (m) — click to type": "Monteringshöjd över mark (m) – klicka för att skriva in", + "North offset from catalog position (m) — click to type": "Förskjutning norrut från katalogpositionen (m) – klicka för att skriva in", + "East offset from catalog position (m) — click to type": "Förskjutning österut från katalogpositionen (m) – klicka för att skriva in", + "SAVE CAL": "SPARA KAL.", + "RESET CAL": "ÅTERST. KAL.", + "SCENE SUMMARY": "SCENÖVERSIKT", + "UPDATE SHOT": "UPPDATERA TAGNING", + "IMPORT": "IMPORTERA", + "RUN LOG": "KÖRLOGG", + "POWER UP": "KOPPLA PÅ", + "GROUND STATION · PROVIDER SETTINGS": "MARKSTATION · LEVERANTÖRSINSTÄLLNINGAR", + "Close key setup": "Stäng nyckelinställningen", + "Power up the globe": "Koppla på globen", + "The globe already flies keyless. Every key below switches on another real feed — paste one and it's saved into this app's local configuration, then the server restarts itself. Server-side keys stay on this machine; Google Maps and Cesium ion run in the browser and must be provider-restricted. Keys you configured elsewhere are shown but never touched.": "Globen flyger redan utan nycklar. Varje nyckel nedan kopplar på ännu ett riktigt flöde – klistra in en så sparas den i appens lokala konfiguration, och sedan startar servern om sig själv. Nycklar på serversidan stannar på den här datorn; Google Maps och Cesium ion körs i webbläsaren och måste begränsas hos leverantören. Nycklar som konfigurerats på annat håll visas men ändras aldrig.", + "SAVE KEYS": "SPARA NYCKLAR", + "ESC to close": "ESC för att stänga", + "The Google Maps key buys the photorealistic planet — everything else stacks on top.": "Google Maps-nyckeln ger den fotorealistiska planeten – allt annat läggs ovanpå.", + "NO PLACE LEFT BEHIND": "INGEN PLATS LÄMNAS KVAR", + "ACTIVE STYLE": "AKTIV STIL", + "Globe actions": "Globåtgärder", + "Turn off all selected data layers": "Stäng av alla valda datalager", + "Copy share link": "Kopiera delningslänk", + "Reset camera and return to full globe view": "Återställ kameran och återgå till vy över hela globen", + "MISSION CONTROL · FIRST LAUNCH": "UPPDRAGSLEDNING · FÖRSTA START", + "Choose your first view": "Välj första vy", + "LIVE CONTACTS": "LIVEKONTAKTER", + "Aircraft, vessels and nearby intelligence": "Flygplan, fartyg och lägesbild i närheten", + "Launches, spacecraft and orbital context": "Uppskjutningar, rymdfarkoster och omloppsbanor", + "EXPLORE MANUALLY": "UTFORSKA MANUELLT", + "Begin with a clean globe": "Börja med en tom glob", + "Don't show this again": "Visa inte igen", + "ESC to dismiss": "ESC för att stänga", + "Tip: the GEV MIC button in the dock lets you talk to the map.": "Tips: med knappen MIK i dockan går det att prata med kartan.", + "Pick a shape, then click the map.": "Välj en form och klicka sedan på kartan.", + "Enter to place the pin, Esc to cancel.": "Enter placerar nålen, Esc avbryter.", + "Click where the pin goes.": "Klicka där nålen ska sitta.", + "Click {count} more points.": { + "one": "Klicka på {count} punkt till.", + "other": "Klicka på {count} punkter till." + }, + "Those points are in a line — move one off it to enclose an area.": "Punkterna ligger på en linje – flytta en av dem för att innesluta en yta.", + "That line has no length — click somewhere further away.": "Linjen har ingen längd – klicka någonstans längre bort.", + " · {max}-point limit reached": " · gränsen på {max} punkter är nådd", + "{measure} · double-click or Enter to finish, Backspace undoes, Esc cancels.{full}": "{measure} · dubbelklicka eller tryck på Enter för att avsluta, Backsteg ångrar, Esc avbryter.{full}", + "That shape already has {count} points — finish it or press Backspace.": "Formen har redan {count} punkter – avsluta den eller tryck på Backsteg.", + "That point is off the globe — click on the world.": "Punkten ligger utanför globen – klicka på jorden.", + "That shape could not be placed.": "Formen kunde inte placeras.", + "Could not place the shape: {error}": "Kunde inte placera formen: {error}", + "Board cleared.": "Ritytan rensad.", + "{owner} is using the pointer — close it first.": "Verktyget ”{owner}” använder pekaren – stäng det först.", + "{label} · Detailed outline unavailable": "{label} · Detaljerad kontur otillgänglig", + "Space Missions isolates replay data. Exit the mode to enable {layer}.": "Läget ”Rymduppdrag” isolerar uppspelningsdata. Lämna läget för att aktivera {layer}.", + "Data attribution": "Datakällor", + "Data provided by:": "Data från:", + "Light aircraft": "Lätt flygplan", + "Glider": "Segelflygplan", + "Turboprop": "Turbopropplan", + "Airliner": "Trafikflygplan", + "Widebody": "Bredkroppsplan", + "Four-engine jet": "Fyrmotorigt jetplan", + "Helicopter": "Helikopter", + "Fast jet": "Stridsflygplan", + "Business jet": "Affärsjet", + "Drone": "Drönare", + "General emergency": "Allmänt nödläge", + "Unlawful interference": "Olagligt ingripande", + "Radio failure": "Radiobortfall", + "Downed aircraft": "Nedstörtat luftfartyg", + "Minimum fuel": "Minimum fuel (låg bränslemängd)", + "Lifeguard (medical priority)": "Lifeguard (medicinsk prioritet)", + "Rasuwagadhi witness video": "Ögonvittnesvideo från Rasuwagadhi", + "Bhote Koshi valley footage": "Filmmaterial från Bhote Koshi-dalen", + "Flood footage": "Filmmaterial från översvämningen", + "Compilation and casualty claims": "Sammanställning och uppgifter om offer", + "SOURCE GEOCONFIRMED · WORLD-PINNED": "KÄLLA GEOBEKRÄFTAD · FÄST PÅ GLOBEN", + "SOURCE CREDITED · LOCATION UNRESOLVED": "KÄLLA ANGIVEN · PLATS OKLAR", + "REFERENCE ONLY · CLAIMS UNVERIFIED": "ENDAST REFERENS · UPPGIFTER OVERIFIERADE", + "SOURCE GEOCONFIRMED": "KÄLLA GEOBEKRÄFTAD", + "2021 historical reference versus 2026 post-event imagery; changes are cumulative and include the July 2025 Rasuwagadhi flood. Schematic downstream progression, not modeled arrival time; not an official hazard model.": "Historisk referens från 2021 jämfört med bilder från 2026 efter händelsen; förändringarna är kumulativa och omfattar översvämningen i Rasuwagadhi i juli 2025. Schematiskt förlopp nedströms, inte modellerad ankomsttid; ingen officiell riskmodell.", + "Immediate collapse viewpoint": "Utsiktspunkt direkt vid raset", + "Collapse viewpoint": "Utsiktspunkt vid raset", + "Debris-dammed lake": "Sjö uppdämd av rasmassor", + "Dammed lake": "Uppdämd sjö", + "Second landslide": "Andra jordskredet", + "Gyirong border gate": "Gränsövergången Gyirong", + "Border gate": "Gränsövergång", + "Timure evidence cluster": "Samlade belägg vid Timure", + "Mailung / Upper Trishuli bridge": "Mailung / bron över Upper Trishuli", + "Dandagaun viewpoint restaurant": "Utsiktsrestaurangen i Dandagaun", + "Dandagaun viewpoint": "Utsiktspunkten Dandagaun", + "Bidur - Trishuli bridge": "Bidur – Trishulibron", + "Bidur bridge": "Bron vid Bidur", + "Devighat / Taadi Khola Bridge": "Devighat / Taadi Khola-bron", + "Devighat bridge": "Bron vid Devighat", + "Facebook player API did not initialize.": "Facebooks spelar-API initierades inte.", + "Facebook player API could not load.": "Facebooks spelar-API kunde inte laddas.", + "Facebook player API timed out.": "Tidsgränsen för Facebooks spelar-API överskreds.", + "X embed API did not initialize.": "X:s inbäddnings-API initierades inte.", + "X embed could not load.": "X-inbäddningen kunde inte laddas.", + "X embed timed out.": "Tidsgränsen för X-inbäddningen överskreds.", + "TIMEOUT": "TIDSGRÄNS", + "BLOCKED": "BLOCKERAD", + "Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.": "Facebook-inbäddningen är otillgänglig. Inlägget kan vara privat, borttaget, regionbegränsat eller blockerat.", + "Preloading {provider} media": "Förladdar media från {provider}", + "LOADING X POST…": "LADDAR X-INLÄGG …", + "The X post is unavailable or cannot be embedded.": "X-inlägget är otillgängligt eller kan inte bäddas in.", + "X POST · PROVIDER CONTROLS": "X-INLÄGG · PLATTFORMENS REGLAGE", + "{reason} OPEN ORIGINAL.": "{reason} ÖPPNA ORIGINALET.", + "X embed unavailable": "X-inbäddning otillgänglig", + "FACEBOOK · PROVIDER CONTROLS": "FACEBOOK · PLATTFORMENS REGLAGE", + "Facebook embed unavailable": "Facebook-inbäddning otillgänglig", + "AUTOPLAY BLOCKED · USE PROVIDER CONTROLS": "AUTOUPPSPELNING BLOCKERAD · ANVÄND PLATTFORMENS REGLAGE", + "Embedded {provider} media for {source}": "Inbäddat medieinnehåll från {provider} för {source}", + "witness source": "ögonvittneskälla", + "Witness source": "Ögonvittneskälla", + "{provider} · EMBEDDED SOURCE": "{provider} · INBÄDDAD KÄLLA", + "OPEN ORIGINAL ↗": "ÖPPNA ORIGINALET ↗", + "{provider} media: {source}": "Media från {provider}: {source}", + "{provider} · PROVIDER CONTROLS": "{provider} · PLATTFORMENS REGLAGE", + "YOUTUBE {state} · OPEN ORIGINAL": "YOUTUBE {state} · ÖPPNA ORIGINALET", + "INITIATION": "UTLÖSNING", + "DAMMING": "UPPDÄMNING", + "SECONDARY FAILURE": "SEKUNDÄRT RAS", + "BORDER ARRIVAL": "ANKOMST TILL GRÄNSEN", + "PROPAGATION": "UTBREDNING", + "DOWNSTREAM PASSAGE": "PASSAGE NEDSTRÖMS", + "CONSEQUENCE": "KONSEKVENS", + "DOWNSTREAM CONSEQUENCE": "KONSEKVENS NEDSTRÖMS", + "EVIDENCE": "BELÄGG", + "{year} HISTORICAL REFERENCE": "HISTORISK REFERENS {year}", + "{year} POST-EVENT": "EFTER HÄNDELSEN {year}", + "{count} GEOLOCATED SOURCE-MAP PLACEMENTS": { + "one": "{count} GEOLOKALISERAD PLACERING PÅ KÄLLKARTAN", + "other": "{count} GEOLOKALISERADE PLACERINGAR PÅ KÄLLKARTAN" + }, + "NO PREVIEW": "INGEN BILD", + "YouTube source": "YouTube-källa", + "Facebook source": "Facebook-källa", + "SOURCE LINK": "KÄLL-LÄNK", + "{publisher} / OPEN": "{publisher} / ÖPPNA", + "Bhote Koshi flood reconstruction controls": "Reglage för rekonstruktionen av översvämningen i Bhote Koshi", + "EVENT RECONSTRUCTION · 26 AUG 2026": "REKONSTRUKTION AV HÄNDELSEN · 26 AUG. 2026", + "BHOTE KOSHI OUTBURST FLOOD": "FLODVÅGEN I BHOTE KOSHI", + "Close event layer": "Stäng händelselagret", + "OBSERVED IMAGERY": "OBSERVERADE BILDER", + "SCHEMATIC CORRIDOR": "SCHEMATISK KORRIDOR", + "2021 HISTORICAL REFERENCE / 2026 POST-EVENT": "HISTORISK REFERENS 2021 / EFTER HÄNDELSEN 2026", + "Historical reference and post-event image split": "Delad vy: historisk referens och bild efter händelsen", + "RECONSTRUCTION CLOCK": "REKONSTRUKTIONSKLOCKA", + "Schematic downstream progression": "Schematiskt förlopp nedströms", + "Story beat navigation": "Navigering mellan händelsesteg", + "Previous story beat": "Föregående händelsesteg", + "CAUSE": "ORSAK", + "CAPTURE TIME UNVERIFIED": "INSPELNINGSTID OVERIFIERAD", + "Next story beat": "Nästa händelsesteg", + "▶ PLAY": "▶ SPELA", + "Play the next shot and continue through this scene": "Spela nästa tagning och fortsätt genom scenen", + "▶ PLAY SCENE": "▶ SPELA SCEN", + "◉ CINEMATIC": "◉ FILMLÄGE", + "↺ FULL STORY": "↺ HELA FÖRLOPPET", + "↗ OPEN ORIGINAL": "↗ ÖPPNA ORIGINALET", + "⌖ LOWER GORGE": "⌖ NEDRE RAVINEN", + "FIELD REPORTS · {count}": "FÄLTRAPPORTER · {count}", + "GEOLOCATIONS · {credit}": "GEOLOKALISERINGAR · {credit}", + "↗ OPEN PUBLIC MAP": "↗ ÖPPNA OFFENTLIG KARTA", + " Clouds are preserved from the source imagery.": " Molnen kommer från källbilderna.", + "Rasuwagadhi witness": "Ögonvittne i Rasuwagadhi", + "Open original source for {source}; capture time unverified": "Öppna originalkällan för {source}; inspelningstid overifierad", + "Historical reference and post-event image divider": "Avdelare mellan historisk referens och bild efter händelsen", + "A historical reference {before} percent, B post-event {after} percent": "A historisk referens {before} procent, B efter händelsen {after} procent", + "■ RELEASE CAMERA": "■ SLÄPP KAMERAN", + "Seek Nepal scene clock": "Spola i scenklockan för Nepal", + "NEPAL SCENE CLOCK": "SCENKLOCKA NEPAL", + "… PLAYING SHOT": "… SPELAR TAGNING", + "▶ PLAY SHOT": "▶ SPELA TAGNING", + "Ⅱ PAUSE": "Ⅱ PAUSA", + "↺ REPLAY CINEMATIC": "↺ SPELA FILMLÄGET IGEN", + "↺ REPLAY": "↺ SPELA IGEN", + "… PLAYING SCENE": "… SPELAR SCEN", + "Continue {scene} from next shot": "Fortsätt {scene} från nästa tagning", + "current scene": "aktuell scen", + "Replay the full reconstruction": "Spela hela rekonstruktionen igen", + "SCENE SHOT": "SCENTAGNING", + "TIME UNVERIFIED": "TID OVERIFIERAD", + "{count} SOURCES": { + "one": "{count} KÄLLA", + "other": "{count} KÄLLOR" + }, + "OUTSIDE IMAGERY SWIPE": "UTANFÖR BILDJÄMFÖRELSEN", + "{scene} / AUTHORED SHOT": "{scene} / KOMPONERAD TAGNING", + "NEPAL FLOOD INCIDENT": "ÖVERSVÄMNINGEN I NEPAL", + "Play {shot}": "Spela {shot}", + "current scene shot": "aktuell scentagning", + "Play reconstruction": "Spela upp rekonstruktionen", + "Load previous scene shot": "Ladda föregående scentagning", + "Load next scene shot": "Ladda nästa scentagning", + "SCHEMATIC CORRIDOR, NOT MODELED ARRIVAL TIME.": "SCHEMATISK KORRIDOR, INTE MODELLERAD ANKOMSTTID.", + "Rasuwa · 26 Aug 2026": "Rasuwa · 26 aug. 2026", + "Event reconstruction": "Rekonstruktion av händelsen", + "BHOTE KOSHI FLOOD INCIDENT": "ÖVERSVÄMNINGEN I BHOTE KOSHI", + "NEPAL · 26 AUG 2026": "NEPAL · 26 AUG. 2026", + "USGS INITIAL REPORT": "FÖRSTA RAPPORT FRÅN USGS", + "26 AUG 2026 · 08:37 NPT": "26 AUG. 2026 · 08:37 NPT", + "28.2710° N · 85.5150° E": "28,2710° N · 85,5150° O", + "INITIAL M4.4 · RECLASSIFIED M5.2 LANDSLIDE": "FÖRST M4,4 · OMKLASSAD TILL M5,2-JORDSKRED", + "USGS EVENT us7000tbwb · OPEN SOURCE": "USGS-HÄNDELSE us7000tbwb · ÖPPNA KÄLLAN", + "Open the USGS initial report for the 26 August 2026 Nepal landslide": "Öppna USGS:s första rapport om jordskredet i Nepal den 26 augusti 2026", + "Show the Nepal context callout without a border or country fill": "Visa kontextrutan om Nepal utan gränslinje eller färgad landyta", + "Introduce Nepal, draw its border, then reveal the Bhote Koshi incident": "Presentera Nepal, rita gränsen och visa sedan händelsen vid Bhote Koshi", + "CITIES": "STÄDER", + "Keep the regional incident context and introduce nearby city labels": "Behåll den regionala händelsekontexten och visa etiketter för närliggande städer", + "PLACES": "PLATSER", + "Introduce the reconstruction scene key places from trigger to downstream": "Presentera rekonstruktionens viktigaste platser, från utlösningspunkten och nedströms", + "PATH": "STRÄCKA", + "Animate the sourced cyan river-centerline route to Trishuli Bazaar": "Animera den källbelagda cyanfärgade rutten längs flodens mittlinje till Trishuli Bazaar", + "OVERVIEW": "ÖVERSIKT", + "Keep the completed flood path and location labels visible": "Låt den färdiga översvämningsbanan och platsetiketterna synas", + "RECORD": "RAPPORT", + "Reveal the trigger record while making a slow camera approach": "Visa rapporten om den utlösande händelsen medan kameran långsamt närmar sig", + "{speed} kn": "{speed} kn", + "That file is not valid JSON.": "Filen är inte giltig JSON.", + "That JSON file is not GeoJSON.": "JSON-filen är inte GeoJSON.", + "That FeatureCollection has no features list.": "Denna FeatureCollection saknar en features-lista.", + "Every GeoJSON feature must have type Feature.": "Varje GeoJSON-objekt måste ha typen Feature.", + "That GeoJSON has invalid geometries.": "GeoJSON-filen har ogiltiga geometrier.", + "That GeoJSON contains an unsupported geometry.": "GeoJSON-filen innehåller en geometri som inte stöds.", + "That GeoJSON has invalid coordinates.": "GeoJSON-filen har ogiltiga koordinater.", + "That file creates more than {limit} map objects (the limit).": "Filen skapar fler än tillåtna {limit} kartobjekt.", + "That file has more than {limit} style definitions and image links after cleanup (the limit).": "Filen har efter rensning fler än tillåtna {limit} stildefinitioner och bildlänkar.", + "That file has more than {limit} features (the limit).": "Filen har fler än tillåtna {limit} objekt.", + "That file has more than {limit} points (the limit) across {features} features.": "Filen har fler än tillåtna {limit} punkter fördelade på {features} objekt.", + "That file has more than {limit} points (the limit).": "Filen har fler än tillåtna {limit} punkter.", + "That KMZ unpacks to more than the import limit.": "KMZ-filen blir större än importgränsen när den packas upp.", + "That KMZ is damaged and could not be unpacked.": "KMZ-filen är skadad och kunde inte packas upp.", + "That KMZ is not a readable zip archive.": "KMZ-filen är inte ett läsbart zip-arkiv.", + "That KMZ uses a zip format this import does not read.": "KMZ-filen använder ett zip-format som importen inte kan läsa.", + "That KMZ is encrypted.": "KMZ-filen är krypterad.", + "That KMZ uses a compression method this import does not read.": "KMZ-filen använder en komprimeringsmetod som importen inte kan läsa.", + "That KML has invalid geometry bounds.": "KML-filen har ogiltiga geometrigränser.", + "That KML has invalid coordinates.": "KML-filen har ogiltiga koordinater.", + "That GPX file has no document.": "GPX-filen saknar ett -dokument.", + "That {format} file is not valid XML.": "{format}-filen är inte giltig XML.", + "That file has an unreadable image or exceeds the 16-megapixel image limit.": "Filen har en oläsbar bild eller överskrider bildgränsen på 16 megapixel.", + "That KML could not be read.": "KML-filen kunde inte läsas.", + "That file has invalid point coordinates.": "Filen har ogiltiga punktkoordinater.", + "Ground heights are not available for that file. Try again when the map has loaded.": "Markhöjder är inte tillgängliga för filen. Försök igen när kartan har laddats.", + "That file took too long to read.": "Det tog för lång tid att läsa filen.", + "Nothing to import.": "Inget att importera.", + "That file is larger than {size} MiB.": "Filen är större än {size} MiB.", + "Only GeoJSON, KML, KMZ and GPX files can be imported.": "Endast GeoJSON-, KML-, KMZ- och GPX-filer kan importeras.", + "That file has nothing to draw.": "Filen innehåller inget att rita.", + "That KMZ holds no KML document.": "KMZ-filen innehåller inget KML-dokument.", + "That {format} file could not be read.": "{format}-filen kunde inte läsas.", + "That file has {count} map objects; the limit is {limit}.": "Filen har {count} kartobjekt; gränsen är {limit}.", + "Ground heights could not be read. Try that file again.": "Markhöjderna kunde inte läsas. Försök med filen igen.", + "That file has invalid point graphics.": "Filen har ogiltig punktgrafik.", + "Map tiles temporarily unavailable": "Kartrutor tillfälligt otillgängliga", + "Mapped names temporarily unavailable": "Kartlagda namn tillfälligt otillgängliga", + "Military airfield": "Militär flygplats", + "Naval base": "Marinbas", + "Firing range": "Skjutfält", + "Military land": "Militär mark", + "Mapped installation": "Kartlagd anläggning", + "Military area": "Militärt område", + "flight": "flygning", + "military flight": "militärflygning", + "satellite": "satellit", + "Draw": "Rita", + "Directions": "Vägbeskrivning", + "Recent imagery": "Senaste bilder", + "Included in bundle": "Ingår i paketet", + "Missing bundle file — reimport its bundle": "Paketfil saknas – importera paketet igen", + "Source configured; file checked when loaded": "Källa konfigurerad; filen kontrolleras vid laddning", + "Source unavailable": "Källa otillgänglig", + "NORMAL": "NORMAL", + "ANIME": "ANIME", + "NOIR": "NOIR", + "ARCTIC": "ARKTIS", + "ANTARCTIC": "ANTARKTIS", + "NORTH AMERICA": "NORDAMERIKA", + "SOUTH AMERICA": "SYDAMERIKA", + "EUROPE": "EUROPA", + "AFRICA": "AFRIKA", + "ASIA": "ASIEN", + "OCEANIA": "OCEANIEN", + "NORTHERN OCEANIC GRID": "NORRA HAVSRUTNÄTET", + "SOUTHERN OCEANIC GRID": "SÖDRA HAVSRUTNÄTET", + "view band|STREET": "GATA", + "view band|CITY": "STAD", + "view band|METRO": "STORSTAD", + "view band|REGIONAL": "REGION", + "view band|GLOBAL": "GLOBAL", + "N/A": "SAKNAS", + "{mode} {band} {locality} | {region} | ALT {alt} | WINDOW {window} | SUN {sun}° | ONA {ona}° | {zone}": "{mode} {band} {locality} | {region} | ALT {alt} | FÖNSTER {window} | SOL {sun}° | ONA {ona}° | {zone}", + "NEAR {poi} ({city}) {distance}": "NÄRA {poi} ({city}) {distance}", + "SECTOR {lat} {lon}": "SEKTOR {lat} {lon}", + "hemisphere|N": "N", + "hemisphere|S": "S", + "hemisphere|E": "O", + "hemisphere|W": "V", + "Provider Settings does not answer proxied requests": "Leverantörsinställningarna svarar inte på förfrågningar via proxy", + "Provider Settings is disabled while sharing is enabled": "Leverantörsinställningarna är inaktiverade medan delning är aktiverad", + "Provider Settings answers only the machine running the server": "Leverantörsinställningarna svarar bara datorn som kör servern", + "Provider Settings answers only local hostnames": "Leverantörsinställningarna svarar bara lokala värdnamn", + "Provider Settings requires an exact local Origin": "Leverantörsinställningarna kräver ett exakt lokalt Origin-värde", + "Unrecognized Origin refused": "Okänt Origin-värde avvisades", + "Cross-origin requests are refused": "Cross-origin-förfrågningar avvisas", + "Content-Type must be application/json": "Content-Type måste vara application/json", + "Method not allowed": "Metoden är inte tillåten", + "Request too large": "Förfrågan är för stor", + "Invalid JSON": "Ogiltig JSON", + "Body must be a JSON object of {ENV_VAR: value}": "Innehållet måste vara ett JSON-objekt på formen {ENV_VAR: value}", + "No keys provided": "Inga nycklar angavs", + "At most {limit} keys per save": "Högst {limit} nycklar per sparning", + "Unknown key: {name}": "Okänd nyckel: {name}", + "{name} must be a string": "{name} måste vara en sträng", + "{name} is empty": "{name} är tom", + "{name} is longer than any real key ({limit} max)": "{name} är längre än någon verklig nyckel (högst {limit})", + "{name} may only contain printable characters with no spaces": "{name} får bara innehålla utskrivbara tecken utan blanksteg", + "{name} contains a character that is not valid in a key (#, quotes, $, \\, or backtick)": "{name} innehåller ett tecken som inte är giltigt i en nyckel (#, citattecken, $, \\ eller grav accent)", + "{name} is configured outside Provider Settings and can only be changed where it was set": "{name} är konfigurerad utanför Leverantörsinställningarna och kan bara ändras där den angavs", + "The key was not saved: could not restrict the credential file to your account; nothing was saved": "Nyckeln sparades inte: det gick inte att begränsa åtkomsten till nyckelfilen till det egna användarkontot; inget sparades", + "The key was not saved: the existing configuration could not be read, so nothing was changed": "Nyckeln sparades inte: den befintliga konfigurationen kunde inte läsas, så inget ändrades", + "Could not write the {store} store": "Det gick inte att skriva till lagringsplatsen {store}", + "OAuth check failed: {detail}": "OAuth-kontrollen misslyckades: {detail}", + "Could not check ChatGPT sign-in. Try again.": "Det gick inte att kontrollera ChatGPT-inloggningen. Försök igen.", + "VOICE AUTH · CHATGPT OAUTH": "RÖSTINLOGGNING · CHATGPT OAUTH", + "VOICE AUTH · API KEY": "RÖSTINLOGGNING · API-NYCKEL", + "USE API KEY": "ANVÄND API-NYCKEL", + "USE CHATGPT OAUTH": "ANVÄND CHATGPT OAUTH", + "Use OPENAI_API_KEY for the next cloud voice session": "Använd OPENAI_API_KEY för nästa röstsession i molnet", + "Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session": "Använd den inloggade lokala ChatGPT/Codex-OAuth-sessionen för nästa röstsession i molnet", + "Metered — a billing-enabled account": "Avgiftsbelagd – kräver ett konto med aktiverad fakturering", + "Optional paid token — public access works without one": "Valfri betald token – öppen åtkomst fungerar utan", + "Free key — register, paste, done": "Gratis nyckel – registrera, klistra in, klart", + "browser-side": "i webbläsaren", + "This key runs in the browser by design — restrict it at the provider (see SECURITY.md)": "Nyckeln körs avsiktligt i webbläsaren – begränsa den hos leverantören (se SECURITY.md)", + "configured externally": "konfigurerad externt", + "Supplied by your environment, Keychain, or launcher — change it where it was set": "Kommer från miljön, nyckelringen eller startprogrammet – ändra den där den angavs", + "MANAGE ↗": "HANTERA ↗", + "GET KEY ↗": "HÄMTA NYCKEL ↗", + "{envVar} saved — paste to replace": "{envVar} sparad – klistra in för att ersätta", + "paste {envVar}": "klistra in {envVar}", + "Project keys: {status}": "Projektnycklar: {status}", + "Cloud voice will use OPENAI_API_KEY on the next session.": "Röststyrningen i molnet använder OPENAI_API_KEY från nästa session.", + "Checking local ChatGPT OAuth sign-in…": "Kontrollerar lokal OAuth-inloggning för ChatGPT …", + "Could not check ChatGPT sign-in.": "Det gick inte att kontrollera ChatGPT-inloggningen.", + "ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.": "ChatGPT OAuth har valts för röststyrning i molnet. API-nyckeln förblir sparad och tillgänglig.", + "ChatGPT sign-in expired. Opening sign-in again…": "ChatGPT-inloggningen har gått ut. Öppnar inloggningen igen …", + "Opening ChatGPT sign-in in your browser…": "Öppnar ChatGPT-inloggningen i webbläsaren …", + "Could not start ChatGPT sign-in on this machine.": "Det gick inte att starta ChatGPT-inloggningen på den här datorn.", + "Finish ChatGPT sign-in in the browser. Waiting for it to complete…": "Slutför ChatGPT-inloggningen i webbläsaren. Väntar på att den ska bli klar …", + "ChatGPT sign-in timed out. Click {button} to try again.": "Tidsgränsen för ChatGPT-inloggningen överskreds. Klicka på {button} för att försöka igen.", + "ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.": "ChatGPT-inloggningen är klar. OAuth används för nästa röstsession i molnet.", + "The photorealistic 3D planet, place search + Street View": "Den fotorealistiska 3D-planeten, platssökning + Street View", + "Voice control — API key or ChatGPT OAuth": "Röststyrning – API-nyckel eller ChatGPT OAuth", + "Gemini Live voice control": "Röststyrning med Gemini Live", + "Street-level imagery and coverage in the Street Level layer. Free: register an app in the Mapillary developer dashboard and paste its Client Token": "Bilder och täckning på gatunivå i lagret ”Gatunivå”. Gratis: registrera en app i Mapillarys utvecklarpanel och klistra in dess Client Token", + "Public space-missions access needs no key. Leave blank for standard use; paid tokens only raise request limits.": "Öppen åtkomst till rymduppdrag kräver ingen nyckel. Lämna tomt för normal användning; betalda tokens höjer bara gränsen för antal förfrågningar.", + "Air quality request failed": "Förfrågan om luftkvalitet misslyckades", + "No readings available": "Inga mätvärden tillgängliga", + "No current readings": "Inga aktuella mätvärden", + "{count} stations reporting": { + "one": "{count} station rapporterar", + "other": "{count} stationer rapporterar" + }, + "Highest air-quality readings in view": "Högsta luftkvalitetsvärdena i vyn", + "Low": "Låg", + "Low health risk": "Låg hälsorisk", + "Moderate": "Måttlig", + "Moderate health risk": "Måttlig hälsorisk", + "High": "Hög", + "High health risk": "Hög hälsorisk", + "Very High": "Mycket hög", + "Very High health risk": "Mycket hög hälsorisk", + "Air quality": "Luftkvalitet", + "Canada only · ECCC AQHI network": "Endast Kanada · ECCC:s AQHI-mätnät", + "ECCC request timed out": "Tidsgränsen för ECCC-förfrågan överskreds", + "Malformed AQHI station response": "Felformaterat svar med AQHI-stationer", + "No AQHI stations available": "Inga AQHI-stationer tillgängliga", + "Malformed AQHI observation response": "Felformaterat svar med AQHI-observationer", + "Camera source temporarily unavailable": "Kamerakälla tillfälligt otillgänglig", + "Mapped camera locations": "Kartlagda kameraplatser", + "SHOW NEAREST": "VISA NÄRMASTE", + "Stop following the current object before navigating to a camera": "Sluta följa det aktuella objektet för att kunna gå till en kamera", + "Move to the nearest loaded camera and show its details": "Gå till närmaste laddade kamera och visa dess detaljer", + "Camera badges": "Kameramärken", + "Cyan cameras turn coral when selected. Wedges illustrate mapped direction, not measured coverage. Nearby cameras may be outside the screen.": "Cyanfärgade kameror blir korallröda när de väljs. Kilarna visar kartlagd riktning, inte uppmätt täckning. Närliggande kameror kan ligga utanför skärmen.", + "{count} nearby · {onScreen} on screen": { + "one": "{count} i närheten · {onScreen} på skärmen", + "other": "{count} i närheten · {onScreen} på skärmen" + }, + "{count} nearby": { + "one": "{count} i närheten", + "other": "{count} i närheten" + }, + "retrying mapped ALPR cameras": "försöker hämta kartlagda ALPR-kameror igen", + "loading mapped ALPR cameras": "laddar kartlagda ALPR-kameror", + "No ALPR data for this area — US and Canada only": "Inga ALPR-data för området – endast USA och Kanada", + "Zoom in to load mapped cameras": "Zooma in för att ladda kartlagda kameror", + "Showing cached locations": "Visar cachade platser", + "Coverage limited — zoom in": "Begränsad täckning – zooma in", + "No ALPR data for this area": "Inga ALPR-data för området", + "None on screen — nearby cameras are outside the view": "Inga på skärmen – närliggande kameror ligger utanför vyn", + "ALPR camera": "ALPR-kamera", + "Camera coverage unavailable": "Kameratäckning otillgänglig", + "OpenStreetMap · community mapped": "OpenStreetMap · kartlagt av frivilliga", + "Overpass returned an incomplete camera response": "Overpass gav ett ofullständigt svar om kameror", + "At least {count}": "Minst {count}", + "Named areas ({n})": "Namngivna områden ({n})", + "{distance} FLIGHT / VESSEL WINDOW": "{distance} FLYG-/FARTYGSFÖNSTER", + "mapped matches within {km} km of the subject": "kartlagda träffar inom {km} km från objektet", + "none mapped within {km} km; not a complete 250 km survey": "inga kartlagda inom {km} km; ingen fullständig sökning inom 250 km", + "mapped matches from the loaded viewport": "kartlagda träffar från den laddade vyn", + "viewport feed is not a complete 250 km survey": "vyns flöde är ingen fullständig sökning inom 250 km", + "GBFS fetch error": "GBFS-hämtningsfel", + "FOV {value}° (DATASHEET)": "FOV {value}° (DATABLAD)", + "SPEC FOV {span}° (CAPABILITY)": "SPEC. FOV {span}° (FÖRMÅGA)", + "{spec} · SPECS: CCTV-DATABASE.COM": "{spec} · SPEC.: CCTV-DATABASE.COM", + "Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.": "Atlanten och östra/centrala norra Stilla havet; ingen global täckning av cykloner.", + "Cyclone advisories unavailable": "Cyklonbulletiner otillgängliga", + "Cyclone data unavailable": "Cyklondata otillgängliga", + "Potential tropical cyclone": "Potentiell tropisk cyklon", + "Post-tropical cyclone": "Posttropisk cyklon", + "Hurricane": "Orkan", + "Tropical storm": "Tropisk storm", + "Tropical depression": "Tropisk depression", + "Subtropical storm": "Subtropisk storm", + "Subtropical depression": "Subtropisk depression", + "Track and cone match this advisory": "Bana och kon stämmer med denna bulletin", + "Track/cone awaiting advisory {number}": "Bana/kon väntar på bulletin {number}", + "Track/cone unavailable": "Bana/kon otillgänglig", + "Cached advisory · stale source": "Cachad bulletin · inaktuell källa", + "Loading advisories…": "Laddar bulletiner …", + "{count} active storms": { + "one": "{count} aktiv storm", + "other": "{count} aktiva stormar" + }, + "Position as of {time}": "Position per {time}", + "Maximum sustained wind: {wind} · Pressure: {pressure}": "Högsta medelvind: {wind} · Lufttryck: {pressure}", + "{name} · {classification} · Advisory {number} · issued {time}": "{name} · {classification} · Bulletin {number} · utfärdad {time}", + "No active NHC/CPHC systems": "Inga aktiva NHC/CPHC-system", + "Advisories unavailable": "Bulletiner otillgängliga", + "Cyclones · NHC / CPHC": "Cykloner · NHC / CPHC", + "Atlantic · E/C Pacific": "Atlanten · östra/centrala Stilla havet", + "{storms} · {name} selected": "{storms} · {name} vald", + "Official advisory ↗": "Officiell bulletin ↗", + "Active NHC and CPHC cyclone advisories": "Aktiva cyklonbulletiner från NHC och CPHC", + "Wind unavailable": "Vind otillgänglig", + "Advisory center / forecast track": "Centrum enligt bulletin / prognosbana", + "Center-track uncertainty cone": "Osäkerhetskon för centrumbanan", + "Select a storm on the map, or choose a storm in the list to select it and move the camera. Click empty map space to clear the selection. NOAA NHC/CPHC advisory context. The cone describes forecast center-track uncertainty, not storm size or the full hazard area. Forecast point labels are source lead hours, not times computed from advisory issuance. Geometry follows the surface; height is not weather altitude. Consult the official advisory.": "Välj en storm på kartan, eller välj en storm i listan för att markera den och flytta kameran dit. Klicka på en tom yta på kartan för att avmarkera. Underlag: bulletiner från NOAA NHC/CPHC. Konen beskriver osäkerheten i den prognostiserade centrumbanan, inte stormens storlek eller hela riskområdet. Etiketterna vid prognospunkterna är källans prognostimmar, inte tider beräknade från bulletinens utfärdande. Geometrin följer ytan; höjden motsvarar ingen höjd i atmosfären. Läs den officiella bulletinen.", + "DRIVE": "BIL", + "Drive": "Bil", + "WALK": "GÅNG", + "Walk": "Gång", + "BIKE": "CYKEL", + "Bike": "Cykel", + "rate limited": "för många förfrågningar", + "routing service is rate limited": "ruttjänsten har nått gränsen för förfrågningar", + "route leg too long": "delsträckan är för lång", + "route too long": "rutten är för lång", + "{error} — try again in a moment": "{error} – försök igen om en stund", + "Routing is rate limited — try again in a moment": "För många ruttförfrågningar – försök igen om en stund", + "Routing unavailable (HTTP {status})": "Ruttberäkning otillgänglig (HTTP {status})", + "Routing unavailable": "Ruttberäkning otillgänglig", + "direction|left": "vänster", + "direction|right": "höger", + "direction|slightly left": "svagt vänster", + "direction|slightly right": "svagt höger", + "direction|sharply left": "skarpt vänster", + "direction|sharply right": "skarpt höger", + "direction|straight": "rakt fram", + "direction|around": "i motsatt riktning", + "fork direction|left": "vänster", + "fork direction|right": "höger", + "fork direction|slightly left": "svagt vänster", + "fork direction|slightly right": "svagt höger", + "fork direction|sharply left": "skarpt vänster", + "fork direction|sharply right": "skarpt höger", + "fork direction|straight": "rakt fram", + "lane direction|left": "vänster", + "lane direction|right": "höger", + "lane direction|slightly left": "svagt vänstersvängande", + "lane direction|slightly right": "svagt högersvängande", + "lane direction|sharply left": "skarpt vänstersvängande", + "lane direction|sharply right": "skarpt högersvängande", + "lane direction|straight": "rakt framgående", + "ordinal|{number}st": "{number}:a", + "ordinal|{number}nd": "{number}:a", + "ordinal|{number}rd": "{number}:e", + "ordinal|{number}th": "{number}:e", + "Head out on {road}": "Starta på {road}", + "Head out": "Starta", + "Arrive at your destination, on the left": "Framme vid målet, på vänster sida", + "Arrive at your destination, on the right": "Framme vid målet, på höger sida", + "Arrive at your destination": "Framme vid målet", + "Make a U-turn onto {road}": "Gör en U-sväng in på {road}", + "Make a U-turn": "Gör en U-sväng", + "Continue straight onto {road}": "Fortsätt rakt fram in på {road}", + "Continue straight": "Fortsätt rakt fram", + "Turn {direction} onto {road}": "Sväng {direction} in på {road}", + "Turn {direction}": "Sväng {direction}", + "Turn onto {road}": "Sväng in på {road}", + "maneuver|Turn": "Sväng", + "Continue {direction} onto {road}": "Fortsätt {direction} in på {road}", + "Continue {direction}": "Fortsätt {direction}", + "At the end of the road, turn {direction} onto {road}": "Sväng {direction} in på {road} vid vägens slut", + "At the end of the road, turn {direction}": "Sväng {direction} vid vägens slut", + "At the end of the road, continue onto {road}": "Fortsätt in på {road} vid vägens slut", + "At the end of the road, continue": "Fortsätt vid vägens slut", + "Keep {direction} at the fork onto {road}": "Håll {direction} vid vägskälet in på {road}", + "Keep {direction} at the fork": "Håll {direction} vid vägskälet", + "Continue at the fork onto {road}": "Fortsätt vid vägskälet in på {road}", + "Continue at the fork": "Fortsätt vid vägskälet", + "Merge {direction} onto {road}": "Håll {direction} och kör in på {road}", + "Merge {direction}": "Håll {direction} och kör in i trafiken", + "Merge onto {road}": "Kör in på {road}", + "maneuver|Merge": "Kör in i trafiken", + "Take the ramp on the {direction} onto {road}": "Ta påfarten på {direction} sida till {road}", + "Take the ramp on the {direction}": "Ta påfarten på {direction} sida", + "Take the ramp {direction} onto {road}": "Håll {direction} och ta påfarten till {road}", + "Take the ramp {direction}": "Håll {direction} och ta påfarten", + "Take the ramp onto {road}": "Ta påfarten till {road}", + "Take the ramp": "Ta påfarten", + "Take the exit on the {direction} onto {road}": "Ta avfarten på {direction} sida till {road}", + "Take the exit on the {direction}": "Ta avfarten på {direction} sida", + "Take the exit {direction} onto {road}": "Håll {direction} och ta avfarten till {road}", + "Take the exit {direction}": "Håll {direction} och ta avfarten", + "Take the exit onto {road}": "Ta avfarten till {road}", + "Take the exit": "Ta avfarten", + "At the roundabout, take the {exit} exit onto {road}": "Ta {exit} avfarten i rondellen in på {road}", + "At the roundabout, take the {exit} exit": "Ta {exit} avfarten i rondellen", + "Enter the roundabout onto {road}": "Kör in i rondellen mot {road}", + "Enter the roundabout": "Kör in i rondellen", + "At the roundabout, turn {direction} onto {road}": "Sväng {direction} i rondellen in på {road}", + "At the roundabout, turn {direction}": "Sväng {direction} i rondellen", + "At the roundabout, continue onto {road}": "Fortsätt i rondellen in på {road}", + "At the roundabout, continue": "Fortsätt i rondellen", + "Exit the roundabout onto {road}": "Lämna rondellen och fortsätt på {road}", + "Exit the roundabout": "Lämna rondellen", + "Use the {direction} lane onto {road}": "Använd {direction} körfält mot {road}", + "Use the {direction} lane": "Använd {direction} körfält", + "Continue onto {road}": "Fortsätt in på {road}", + "maneuver|Continue": "Fortsätt", + "{mode} — reroute for {modeLower}": "{mode} – beräkna om rutten för {modeLower}", + "CLICK MAP": "KLICKA PÅ KARTAN", + "SET A": "VÄLJ A", + "Click a spot on the globe to place A (click again to cancel)": "Klicka på en plats på globen för att placera A (klicka igen för att avbryta)", + "Then click the globe to place the start": "Klicka sedan på globen för att placera startpunkten", + "SET B": "VÄLJ B", + "Click a spot on the globe to place B (click again to cancel)": "Klicka på en plats på globen för att placera B (klicka igen för att avbryta)", + "Then click the globe to place the destination": "Klicka sedan på globen för att placera målet", + "Swap A and B": "Byt plats på A och B", + "FLY ···": "FLYG ···", + "FLYING": "FLYGER", + "FLY": "FLYG", + "Fly the camera along the route": "Flyg kameran längs rutten", + "Place A and B first": "Placera A och B först", + "action|CLEAR": "RENSA", + "Remove the route and both markers": "Ta bort rutten och båda markörerna", + "press Escape twice to leave Draw": "tryck två gånger på Esc för att lämna ”Rita”", + "{name} is active — turn {name} off, then set {endpoint}": "”{name}” är aktivt – stäng av ”{name}” och välj sedan {endpoint}", + "{name} is active — {leave}, then set {endpoint}": "”{name}” är aktivt – {leave} och välj sedan {endpoint}", + "Only the first {count} turns of this route are shown": "Endast de första {count} svängarna på rutten visas", + "Turn-by-turn directions": "Vägbeskrivning steg för steg", + "Another map tool is using clicks — close it, then SET A again": "Ett annat kartverktyg använder klicken – stäng det och tryck sedan på VÄLJ A igen", + "Routing…": "Beräknar rutt …", + "No route found": "Ingen rutt hittades", + "{distance} · {duration} · {mode} · first {count} turns": "{distance} · {duration} · {mode} · första {count} svängarna", + "{distance} · {duration} · {mode}": "{distance} · {duration} · {mode}", + "Click the globe to place {endpoint}": "Klicka på globen för att placera {endpoint}", + "SET B, then click the globe": "VÄLJ B och klicka sedan på globen", + "SET A, then click the globe": "VÄLJ A och klicka sedan på globen", + "Step {step} of {total} · then {leg}": "Steg {step} av {total} · sedan {leg}", + "Step {step} of {total}": "Steg {step} av {total}", + "Then: {instruction}": "Sedan: {instruction}", + "No route found between A and B": "Ingen rutt hittades mellan A och B", + "Routing failed: {error}": "Ruttberäkningen misslyckades: {error}", + "Routing timed out": "Tidsgränsen för ruttberäkningen överskreds", + "Routing unavailable — no response from the routing service": "Ruttberäkning otillgänglig – inget svar från ruttjänsten", + "Earthquake source unavailable": "Jordbävningskälla otillgänglig", + "live feed unavailable": "liveflödet otillgängligt", + "{value}°N": "{value}° N", + "{value}°S": "{value}° S", + "{value}°E": "{value}° O", + "{value}°W": "{value}° V", + "fire confidence|high": "hög", + "fire confidence|nominal": "normal", + "fire confidence|low": "låg", + "age|<1h": "<1 h", + "age|{count}h": "{count} h", + "age|{count}d": "{count} d", + "Source snapshot time unavailable": "Tidpunkt för källans ögonblicksbild saknas", + "Live data unavailable": "Livedata otillgängliga", + "Intel Picture": "Lägesbild", + "Kinetic 30d": "Kinetiskt 30 d", + "Border Crisis": "Gränskris", + "Iran Picture": "Lägesbild Iran", + "Iran Kinetic": "Iran kinetiskt", + "Yemen FLOT": "Jemen frontlinje", + "Latest Drop": "Senaste utgåvan", + "GhostMaps file could not be read": "GhostMaps-filen kunde inte läsas", + "{count} source links ↗ · click card to open the first": { + "one": "{count} källa ↗ · klicka på kortet för att öppna den", + "other": "{count} källor ↗ · klicka på kortet för att öppna den första" + }, + "S2 · {name}": "S2 · {name}", + "Report": "Rapport", + "Open the source for {title}": "Öppna källan för {title}", + "GhostMaps catalog unavailable": "GhostMaps-katalogen otillgänglig", + "LIVE · GitHub {commit} · {message} · checked every {seconds}s": "LIVE · GitHub {commit} · {message} · kontrolleras med {seconds} s intervall", + "GitHub {commit} · {message} · reconnecting for live updates": "GitHub {commit} · {message} · återansluter för liveuppdateringar", + "Connecting to the S2 GhostMaps repository…": "Ansluter till GhostMaps-repot från S2 …", + "{count} placemarks": { + "one": "{count} platsmarkör", + "other": "{count} platsmarkörer" + }, + "S2 Underground publishes these KMZ products to GitHub. New uploads are detected within seconds and replace the previous edition automatically. Click a marker for its report.": "S2 Underground publicerar dessa KMZ-produkter på GitHub. Nya uppladdningar upptäcks inom några sekunder och ersätter automatiskt den föregående utgåvan. Klicka på en markör för att se dess rapport.", + "Malformed GhostMaps catalog": "Felformaterad GhostMaps-katalog", + "Low-accuracy score < 2%": "Värde för låg noggrannhet < 2 %", + "Low-accuracy score 2–10%": "Värde för låg noggrannhet 2–10 %", + "Low-accuracy score > 10%": "Värde för låg noggrannhet > 10 %", + "GNSS integrity source unavailable": "GNSS-integritetskälla otillgänglig", + "Regional sample · 250 nm radius · cells from visited views over {minutes} min": "Regionalt urval · radie 250 NM · celler från besökta vyer under {minutes} min", + "Retrying automatically at {time} UTC": "Försöker igen automatiskt kl. {time} UTC", + "Retrying automatically after provider cooldown": "Försöker igen automatiskt när leverantörens spärrtid är över", + "Updates automatically after pan or zoom and once a minute. The public feed covers a 250-nautical-mile circle around the view centre; zooming out does not make it a worldwide snapshot. Blank cells are outside sampled coverage or have insufficient observations.": "Uppdateras automatiskt efter panorering eller zoomning och en gång i minuten. Det öppna flödet täcker en cirkel på 250 nautiska mil runt vyns mitt; att zooma ut gör det inte till en global ögonblicksbild. Tomma celler ligger utanför den samplade täckningen eller har för få observationer.", + "Low-accuracy score = 100 × max(0, bad − 1) / total: one aircraft is discounted, so this is not the raw share. Cells need {aircraft} aircraft over {minutes} minutes. Navigation-integrity anomalies are not a detection of jamming or spoofing.": "Värdet för låg noggrannhet = 100 × max(0, dåliga − 1) / totalt: ett luftfartyg räknas bort, så detta är inte den råa andelen. Cellerna kräver {aircraft} luftfartyg under {minutes} minuter. Avvikelser i navigeringsintegriteten innebär inte att störsändning eller vilseledning (jamming eller spoofing) har upptäckts.", + "GNSS snapshot needs a view anchor": "GNSS-ögonblicksbilden behöver ett vyankare", + "GNSS feed rate limited (HTTP 429)": "GNSS-flödet har nått gränsen för förfrågningar (HTTP 429)", + "Malformed GNSS integrity snapshot": "Felformaterad ögonblicksbild av GNSS-integritet", + "GNSS integrity snapshot has no observation time": "Ögonblicksbilden av GNSS-integritet saknar observationstid", + "installation class|AIRFIELD": "FLYGFÄLT", + "installation class|NAVAL BASE": "MARINBAS", + "installation class|RANGE": "SKJUTFÄLT", + "installation class|MILITARY LAND": "MILITÄR MARK", + "installation class|BASE": "BAS", + "installation class|BARRACKS": "KASERN", + "installation class|TRAINING AREA": "ÖVNINGSFÄLT", + "installation class|PLACES CANDIDATE": "FRÅN GOOGLE PLACES", + "installation class|INSTALLATION": "ANLÄGGNING", + "No mapped sites within {km} km of the contact": "Inga kartlagda platser inom {km} km från kontakten", + "{count} mapped sites within {km} km of the contact": { + "one": "{count} kartlagd plats inom {km} km från kontakten", + "other": "{count} kartlagda platser inom {km} km från kontakten" + }, + "No mapped sites in view": "Inga kartlagda platser i vyn", + "{count} mapped sites in view": { + "one": "{count} kartlagd plats i vyn", + "other": "{count} kartlagda platser i vyn" + }, + "Too many mapped sites in view to list them all": "För många kartlagda platser i vyn för att lista alla", + "Installation context unavailable": "Anläggningskontext otillgänglig", + "Unknown": "Okänd", + "Selected Space Mission": "Valt rymduppdrag", + "Malformed launch snapshot": "Felformaterad ögonblicksbild av uppskjutningar", + "Heard by your receiver": "Hört av den egna mottagaren", + "1090 MHz": "1090 MHz", + "978 MHz UAT": "978 MHz UAT", + "browser SDR": "SDR i webbläsaren", + "decoder feed": "avkodarflöde", + "Your RTL-SDR receiver and decoder feed": "Egen RTL-SDR-mottagare och eget avkodarflöde", + "Your decoder feed": "Eget avkodarflöde", + "Your RTL-SDR receiver": "Egen RTL-SDR-mottagare", + "NO CALLSIGN": "INGEN ANROPSSIGNAL", + "ALT {altitude} FT · GS {speed} KT · TRK {track}": "ALT {altitude} FT · GS {speed} KN · TRK {track}", + "V/S {rate}": "V/S {rate}", + "V/S {rate} FPM": "V/S {rate} FT/MIN", + "POSITION {age}": "POSITION {age}", + "POSITION {seconds} S AGO": "POSITION FÖR {seconds} S SEDAN", + "{count} MSGS": { + "one": "{count} MEDD.", + "other": "{count} MEDD." + }, + "Decoder feeds": "Avkodarflöden", + "WebUSB + decoder feeds": "WebUSB + avkodarflöden", + "opening receiver": "öppnar mottagaren", + "checking decoder feeds": "kontrollerar avkodarflöden", + "WebUSB needs desktop Chrome or Edge": "WebUSB kräver Chrome eller Edge på dator", + "connect a receiver in Radio": "anslut en mottagare i ”Radio”", + "receiver is in FM mode": "mottagaren är i FM-läge", + "listening": "lyssnar", + "USB error": "USB-fel", + "${amount}K": "{amount} tusen $", + "${amount}M": "{amount} milj. $", + "${amount}B": "{amount} mdr $", + "fire cause|Human": "Mänsklig", + "fire cause|Natural": "Naturlig", + "fire cause|Undetermined": "Oklar", + "fire cause|Unknown": "Okänd", + "fire behavior|Minimal": "Minimal", + "fire behavior|Moderate": "Måttlig", + "fire behavior|Active": "Aktiv", + "fire behavior|Extreme": "Extrem", + "Type {level} Incident": "Händelse typ {level}", + "age|{count}m": "{count} min", + "{acres} ac": { + "one": "{acres} acre", + "other": "{acres} acre" + }, + "{percent}% contained": "{percent} % under kontroll", + "containment unknown": "kontrollgrad okänd", + "{cause} cause": "{cause} orsak", + "{personnel} personnel": { + "one": "{personnel} person i insatsen", + "other": "{personnel} personer i insatsen" + }, + "{county} County": "{county} County", + "{cost} to date": "{cost} hittills", + "discovered {age} ago": "upptäckt för {age} sedan", + "updated {age} ago": "uppdaterad för {age} sedan", + "part of {complex}": "del av {complex}", + "InciWeb ↗ · click card to open": "InciWeb ↗ · klicka på kortet för att öppna", + "FIRE · {name}": "BRAND · {name}", + "Unnamed incident": "Namnlös händelse", + "Open {title} on InciWeb": "Öppna {title} på InciWeb", + "Perimeter source unavailable": "Källa för brandgränser otillgänglig", + "Not contained or unknown": "Inte under kontroll eller okänt", + "Under 50% contained": "Mindre än 50 % under kontroll", + "50–99% contained": "50–99 % under kontroll", + "Fully contained": "Helt under kontroll", + "Colour shows reported containment. Perimeters are simplified to about 100 m.": "Färgen visar rapporterad kontrollgrad. Brandgränserna är förenklade till cirka 100 m.", + "Malformed perimeter snapshot": "Felformaterad ögonblicksbild av brandgränser", + "radio tag|All": "Alla", + "Directory refresh degraded; showing the previous station catalog.": "Uppdateringen av katalogen är begränsad; visar den tidigare stationskatalogen.", + "Radio directory coverage is degraded.": "Radiokatalogens täckning är begränsad.", + "Directory refresh failed; showing the previous station catalog.": "Uppdateringen av katalogen misslyckades; visar den tidigare stationskatalogen.", + "Radio directory is temporarily unavailable.": "Radiokatalogen är tillfälligt otillgänglig.", + "radio|NEWS": "NYHETER", + "radio|WEATHER": "VÄDER", + "radio|OTHER": "ÖVR.", + "Broadcaster stream is unavailable or blocked by the browser.": "Stationens ström är otillgänglig eller blockeras av webbläsaren.", + "Playback requires a direct click or tap.": "Uppspelning kräver ett direkt klick eller tryck.", + "Broadcaster stream could not be started.": "Stationens ström kunde inte startas.", + "overview": "översikt", + "cloud unknown": "molnighet okänd", + "{percent}% scene cloud": "{percent} % molntäcke i scenen", + "{min}–{max}% scene cloud": "{min}–{max} % molntäcke i scenen", + "Comparison in use by another scene": "Jämförelsen används av en annan scen", + "Esri map unavailable · no swipe": "Esri-kartan otillgänglig · ingen svepjämförelse", + "Swipe needs a globe map": "Svepjämförelse kräver en globkarta", + "Imagery stays on Esri · CLEAR to use Google 3D": "Bilderna ligger kvar på Esri · RENSA för att använda Google 3D", + "Imagery catalog unavailable": "Bildkatalogen otillgänglig", + "Catalog truncated · a newer clear day may exist": "Katalogen avkortad · det kan finnas en nyare molnfri dag", + "{label} catalog unavailable · {detail}": "{label}-katalogen otillgänglig · {detail}", + "Daily overview for today may still be empty until the pass": "Dagens dagliga översikt kan vara tom tills satelliten har passerat", + "Box and images cleared": "Området och bilderna rensade", + "MORE DETAIL · 30 m": "MER DETALJER · 30 m", + "Sentinel-2 and Landsat via HLS, 30 m, every 2–6 days": "Sentinel-2 och Landsat via HLS, 30 m, med 2–6 dagars intervall", + "DAILY OVERVIEW · 250 m": "DAGLIG ÖVERSIKT · 250 m", + "VIIRS daily mosaic, 250 m, follows broad changes; little detail under 25 km": "VIIRS-dagsmosaik, 250 m, visar storskaliga förändringar; få detaljer under 25 km", + "Sentinel-2": "Sentinel-2", + "Sentinel-2 via HLS": "Sentinel-2 via HLS", + "Landsat 8/9": "Landsat 8/9", + "Landsat 8/9 via HLS": "Landsat 8/9 via HLS", + "Daily overview": "Daglig översikt", + "Box edges must be finite": "Områdets kanter måste vara ändliga", + "Longitudes must be within ±180°": "Longituderna måste ligga inom ±180°", + "Select one side of the dateline": "Välj en sida av datumgränsen", + "Box must enclose an area": "Området måste omsluta en yta", + "CelesTrak unreachable": "CelesTrak går inte att nå", + "DOCKED · {name} · +{extra}": "DOCKAD · {name} · +{extra}", + "DOCKED · {name}": "DOCKAD · {name}", + "{name} needs the Google 3D map: choose it under MAP SOURCE": "{name} kräver Google 3D-kartan: välj den under KARTKÄLLA", + "zoom in to a street to open {name}": "zooma in på en gata för att öppna {name}", + "loading coverage...": "laddar täckning …", + "No street-level imagery within {radius} m of that point": "Inga bilder på gatunivå inom {radius} m från den punkten", + "No street-level imagery within {radius} m of the view centre": "Inga bilder på gatunivå inom {radius} m från vyns mitt", + "Zoom in to see street-level coverage": "Zooma in för att se täckningen på gatunivå", + "Point the camera at the ground for street-level coverage": "Rikta kameran mot marken för att se täckningen på gatunivå", + "Coverage tile failed": "Täckningsrutan kunde inte laddas", + "The photo could not be loaded": "Fotot kunde inte laddas", + "{name} photo. ← and → step along the sequence. On a 360° photo, drag or use Shift+← →, ↑ ↓ to look around, and + − to zoom": "{name}-foto. ← och → stegar genom sekvensen. På ett 360°-foto: dra eller använd Skift+← →, ↑ ↓ för att titta runt och + − för att zooma", + "{name} street-level photo": "{name}-foto på gatunivå", + "Previous photo in this sequence (←)": "Föregående foto i sekvensen (←)", + "Next photo in this sequence (→)": "Nästa foto i sekvensen (→)", + "Aim the middle of the view at a street to look for imagery there": "Rikta vyns mitt mot en gata för att söka efter bilder där", + "KEY REJECTED": "NYCKEL AVVISAD", + "GOOGLE_MAPS_API_KEY is not set": "GOOGLE_MAPS_API_KEY är inte angiven", + "The Google Maps JavaScript API could not load": "Google Maps JavaScript API kunde inte laddas", + "Google Street View": "Google Street View", + "GOOGLE STREET VIEW": "GOOGLE STREET VIEW", + "Google refused GOOGLE_MAPS_API_KEY for Street View — check that the Maps JavaScript API is enabled for it and this site is an allowed referrer": "Google avvisade GOOGLE_MAPS_API_KEY för Street View – kontrollera att Maps JavaScript API är aktiverat för nyckeln och att den här webbplatsen finns bland nyckelns tillåtna HTTP-hänvisningsadresser", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "Street View svarade inte – kontrollera nätverket och att Maps JavaScript API är aktiverat för GOOGLE_MAPS_API_KEY", + "click a street to open Street View": "klicka på en gata för att öppna Street View", + "Street View has no imagery for this place": "Street View har inga bilder för den här platsen", + "KartaView has no position for this photo": "KartaView har ingen position för det här fotot", + "The KartaView photo could not be loaded": "KartaView-fotot kunde inte laddas", + "zoom in to see KartaView coverage": "zooma in för att se KartaView-täckningen", + "no KartaView photos in view": "inga KartaView-foton i vyn", + "KartaView is rate-limiting requests — they resume on their own": "KartaView begränsar antalet förfrågningar – de återupptas automatiskt", + "KartaView’s photos here are archived and cannot be shown": "KartaViews foton här är arkiverade och kan inte visas", + "KartaView did not filter this area in time — move the map to ask again": "KartaView hann inte filtrera området – flytta kartan för att fråga igen", + "None of KartaView’s photos here pass the imagery filter": "Inga av KartaViews foton här klarar bildfiltret", + "KartaView did not answer in time — try again": "KartaView svarade inte i tid – försök igen", + "KartaView could not be reached — check the network": "KartaView kunde inte nås – kontrollera nätverket", + "KartaView sent an answer that could not be read": "KartaView skickade ett svar som inte kunde läsas", + "KartaView is rate-limiting requests": "KartaView begränsar antalet förfrågningar", + "KartaView sent too much data": "KartaView skickade för mycket data", + "KartaView is rate-limiting requests — try again in a moment": "KartaView begränsar antalet förfrågningar – försök igen om en stund", + "KartaView has no such photo": "KartaView har inget sådant foto", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary avvisade MAPILLARY_CLIENT_TOKEN – byt ut den under Leverantörsinställningar", + "Coverage requests are being rate-limited — they resume on their own": "Täckningsförfrågningarna begränsas – de återupptas automatiskt", + "The Panoramax photo could not be loaded": "Panoramax-fotot kunde inte laddas", + "Panoramax is busy — coverage resumes on its own": "Panoramax har hög belastning – täckningen återupptas automatiskt", + "no Panoramax photos in view": "inga Panoramax-foton i vyn", + "no Panoramax photos here match the filters": "inga Panoramax-foton här matchar filtren", + "Panoramax sent a map tile that could not be read": "Panoramax skickade en kartruta som inte kunde läsas", + "This Panoramax photo is no longer available": "Det här Panoramax-fotot är inte längre tillgängligt", + "zoom in to see Panoramax coverage": "zooma in för att se Panoramax-täckningen", + "Panoramax did not answer in time — try again": "Panoramax svarade inte i tid – försök igen", + "Panoramax could not be reached — check the connection": "Panoramax kunde inte nås – kontrollera anslutningen", + "Panoramax sent too much data": "Panoramax skickade för mycket data", + "Panoramax is busy": "Panoramax har hög belastning", + "Panoramax sent an unreadable answer": "Panoramax skickade ett oläsbart svar", + "Sequence images unavailable": "Sekvensbilder otillgängliga", + "No street-level imagery providers configured": "Inga leverantörer av bilder på gatunivå har konfigurerats", + "(by upload date)": "(efter uppladdningsdatum)", + "Open the Street Level panel to view imagery": "Öppna panelen ”Gatunivå” för att visa bilder", + "Image could not be opened": "Bilden kunde inte öppnas", + "TeleGeography load failed": "TeleGeography kunde inte laddas", + "UNAVAILABLE · {source} · Roads unavailable": "OTILLGÄNGLIG · {source} · Vägar otillgängliga", + "Roads: {source}": "Vägar: {source}", + "TomTom roads unavailable while the traffic service is unreachable": "TomTom-vägar är otillgängliga så länge trafiktjänsten inte kan nås", + "Hybrid unavailable while the traffic service is unreachable": "Läget Hybrid är otillgängligt så länge trafiktjänsten inte kan nås", + "TomTom roads need a TomTom key": "TomTom-vägar kräver en TomTom-nyckel", + "Hybrid needs a TomTom key": "Läget Hybrid kräver en TomTom-nyckel", + "Local surface still loading": "Lokal yta laddas fortfarande", + "Partial coverage": "Delvis täckning", + "Detailed roads unavailable": "Detaljerade vägar otillgängliga", + "Reduced detail coverage": "Minskad detaljtäckning", + "TomTom roads only, each with its live flow": "Endast TomTom-vägar, var och en med trafikflöde i realtid", + "TomTom roads only, each with its live flow (needs a TomTom key)": "Endast TomTom-vägar, var och en med trafikflöde i realtid (kräver en TomTom-nyckel)", + "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap-vägar, med matchat TomTom-trafikflöde när det finns", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTom-vägar först; OpenStreetMap fyller i resten, simulerat", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTom-vägar först; OpenStreetMap fyller i resten, simulerat (kräver en TomTom-nyckel)", + "Hybrid": "Hybrid", + "Lines": "Linjer", + "Dots": "Punkter", + "Road lines colored by live flow: green to red": "Vägar färgade efter trafikflödet live: grönt till rött", + "Moving dots colored by live flow": "Rörliga punkter färgade efter trafikflödet live", + "Moving dots": "Rörliga punkter", + "TomTom key unavailable": "TomTom-nyckel otillgänglig", + "TomTom daily budget reached": "TomTom-dagsbudgeten är förbrukad", + "Monthly traffic allowance used": "Månadens trafikkvot är förbrukad", + "Traffic rate limit reached": "Trafiktjänsten har nått gränsen för förfrågningar", + "Traffic service unavailable": "Trafiktjänsten är otillgänglig", + "TomTom upstream unreachable": "TomTom-tjänsten går inte att nå", + "TomTom flow unavailable": "TomTom-trafikflöde otillgängligt", + "SIMULATED — {reason}": "SIMULERAT – {reason}", + "OpenFreeMap tiles rate-limited": "OpenFreeMap-kartrutor: för många förfrågningar", + "OpenFreeMap tiles timed out": "OpenFreeMap-kartrutor: tidsgränsen överskreds", + "OpenFreeMap tiles unavailable": "OpenFreeMap-kartrutor otillgängliga", + "occupancy|empty": "tomt", + "occupancy|many seats available": "många lediga sittplatser", + "occupancy|few seats available": "få lediga sittplatser", + "occupancy|standing room only": "endast ståplatser", + "occupancy|crushed standing room only": "endast ståplatser, mycket trångt", + "occupancy|full": "fullt", + "occupancy|not accepting passengers": "tar inte emot resenärer", + "occupancy|not boardable": "går inte att stiga på", + "Route {route}": "Linje {route}", + "Vehicle {vehicle}": "Fordon {vehicle}", + "vehicle motion|Stopped": "Står still", + "No recent fix": "Ingen aktuell position", + "Waiting for update": "Väntar på uppdatering", + "Moving": "I rörelse", + "reported {speed} km/h": "rapporterad fart {speed} km/h", + "reported hdg {heading}°": "rapporterad kurs {heading}°", + "Last report: stopped at stop {stop}": "Senaste rapport: står vid hållplats {stop}", + "Last report: arriving at stop {stop}": "Senaste rapport: ankommer till hållplats {stop}", + "Last report: next stop {stop}": "Senaste rapport: nästa hållplats {stop}", + "Shown at street level · depth not in the feed": "Visas på gatunivå · djup saknas i flödet", + "Reported {seconds} s ago": "Rapporterad för {seconds} s sedan", + "Reported {minutes} min ago": "Rapporterad för {minutes} min sedan", + "shown {seconds} s behind": "visas med {seconds} s fördröjning", + "{reported} (feed time)": "{reported} (flödestid)", + "Available trail: {minutes} min (limited)": "Tillgängligt spår: {minutes} min (begränsat)", + "Available trail: {minutes} min": "Tillgängligt spår: {minutes} min", + "Bus": "Buss", + "Tram": "Spårvagn", + "Subway": "Tunnelbana", + "Train": "Tåg", + "Ferry": "Färja", + "Transit vehicle": "Kollektivtrafikfordon", + "{speed}KT": "{speed} KN", + "{heading}DEG": "{heading}°", + "AIS live load failed": "AIS-livedata kunde inte laddas", + "AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY är inte angiven", + "live feed unsupported": "liveflöde stöds inte", + "connecting to feed…": "ansluter till flödet …", + "feed disconnected": "flödet frånkopplat", + "feed down": "flödet nere", + "feed idle": "flödet inaktivt", + "API key rejected — check AISSTREAM_API_KEY": "API-nyckeln avvisades – kontrollera AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "flödet tyst i {seconds} s – inga AIS-data", + "feed silent — no AIS data": "flödet tyst – inga AIS-data", + "feed down — retrying slowly (attempt {attempt})": "flödet nere – försöker igen med längre intervall (försök {attempt})", + "reconnecting to feed… (attempt {attempt})": "återansluter till flödet … (försök {attempt})", + "feed down — retrying slowly": "flödet nere – försöker igen med längre intervall", + "reconnecting to feed…": "återansluter till flödet …", + "Focus vessel {name}, MMSI {mmsi}": "Fokusera på fartyget {name}, MMSI {mmsi}", + "Hidden by this map source · choose a globe map": "Dolt av den här kartkällan · välj en globkarta", + "Weather image HTTP {status}": "Väderbild HTTP {status}", + "Weather HTTP {status}": "Väder HTTP {status}", + "Frame unavailable; previous observation retained": "Bildruta otillgänglig; föregående observation behålls", + "Weather imagery unavailable": "Väderbilder otillgängliga", + "Weather source unavailable; previous observation retained": "Väderkälla otillgänglig; föregående observation behålls", + "Weather unavailable": "Väder otillgängligt", + "No frame within {gap} min of {time}": "Ingen bildruta inom {gap} min från {time}", + "No frame within {gap} h of {time}": "Ingen bildruta inom {gap} h från {time}", + "{hours}h {minutes}m ago": "för {hours} h {minutes} min sedan", + "{minutes}m ago": "för {minutes} min sedan", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "Observerat", + "History": "Historik", + "Observation unavailable": "Observation otillgänglig", + "Waiting for observation": "Väntar på observation", + "{detail} · synced": "{detail} · synkroniserad", + "{detail} · nearest": "{detail} · närmast i tid", + "RADAR REFLECTIVITY · dBZ": "RADARREFLEKTIVITET · dBZ", + "LIGHTNING DENSITY · 15 min accumulation": "BLIXTTÄTHET · summerat över 15 min", + "GLOBAL INFRARED · hourly": "GLOBALT INFRARÖTT · varje timme", + "GOES INFRARED · ~5 min": "GOES-INFRARÖTT · ~5 min", + "{state}: {time}": "{state}: {time}", + "Latest observation": "Senaste observation", + "frame {index}/{total}": "bildruta {index}/{total}", + "loading": "laddar", + "Observation: unavailable": "Observation: otillgänglig", + "Contiguous US · gaps ≠ no rain": "Kontinentala USA · luckor ≠ inget regn", + "Americas + Pacific · not individual strikes": "Amerika + Stilla havet · inte enskilda nedslag", + "Color: strikes/km²/min ×10³": "Färg: nedslag/km²/min ×10³", + "Color: {units}": "Färg: {units}", + "RADAR · {units}": "RADAR · {units}", + "60°S–60°N · typically 2–3 h delayed": "60° S–60° N · oftast 2–3 h fördröjning", + "North America · infrared imagery": "Nordamerika · infraröda bilder", + "Reduced motion · history playback unavailable": "Minskad rörelse · uppspelning av historik otillgänglig", + "Reduced motion · manual history available": "Minskad rörelse · manuell historik tillgänglig", + "Rain radar · US": "Regnradar · USA", + "Lightning density · 15 min": "Blixttäthet · 15 min", + "Satellite clouds": "Satellitmoln", + "Americas + Pacific": "Amerika + Stilla havet", + "Global · 60°S–60°N": "Globalt · 60° S–60° N", + "North America": "Nordamerika", + "Source observations delayed": "Källans observationer är fördröjda", + "Stale source": "Inaktuell källa", + "Loading next frame…": "Laddar nästa bildruta …", + "Map center outside coverage": "Kartmitten utanför täckningsområdet", + "strikes/km²/min ×10³": "nedslag/km²/min ×10³", + "N. America": "Nordamerika", + "Global": "Globalt", + "Hourly global mosaic; usually 2–3 hours delayed": "Global mosaik varje timme; vanligtvis 2–3 timmars fördröjning", + "GOES regional clouds; approximately 5-minute updates": "Regionala GOES-molnbilder; uppdateras ungefär var 5:e minut", + "Clouds only": "Endast moln", + "Apply the full cloud brightness key; 100% filter strength": "Använd ljusstyrkefiltret för moln fullt ut; filterstyrka 100 %", + "Full": "Hel bild", + "Show the complete infrared image; 0% filter strength": "Visa hela den infraröda bilden; filterstyrka 0 %", + "Soft": "Svag", + "Vivid": "Kraftig", + "Image opacity; does not alter the observed values": "Bildens opacitet; ändrar inte de observerade värdena", + "View US radar": "Visa USA-radar", + "View Americas & Pacific": "Visa Amerika och Stilla havet", + "View coverage": "Visa täckning", + "{value} strikes/km²/min ×10³ (15-minute density)": "{value} nedslag/km²/min ×10³ (täthet över 15 min)", + "{value} dBZ radar reflectivity": "{value} dBZ radarreflektivitet", + "STALE · cached source metadata": "INAKTUELL · cachade källmetadata", + "Map center is outside source coverage": "Kartmitten ligger utanför källans täckning", + "NOAA/NWS 15-minute lightning density derived from Vaisala NLDN/GLD360. Coverage 110°E across the Pacific/Americas to 0°, 25°S–80°N. Not a live strike count, global coverage or a safety warning.": "Blixttäthet över 15 minuter från NOAA/NWS, härledd från Vaisala NLDN/GLD360. Täckning från 110° O över Stilla havet/Amerika till 0°, 25° S–80° N. Ingen liveräkning av nedslag, ingen global täckning och ingen säkerhetsvarning.", + "NOAA MRMS radar echoes indicate precipitation patterns, not rain rate, a storm warning or a future forecast. Native source approximately 1 km; display is limited to level 6. Frames use exact advertised observation times.": "Radarekon från NOAA MRMS visar nederbördsmönster, inte regnintensitet, ovädersvarning eller prognos. Källans egen upplösning är ungefär 1 km; visningen är begränsad till nivå 6. Bildrutorna använder exakt angivna observationstider.", + "GOES-19/18 longwave infrared Band 14 regional; NESDIS global longwave mosaic. Clouds only dims everything but bright, cold cloud tops; a brightness filter, not a cloud mask. Coverage and freshness differ by region.": "Regionalt: långvågigt infrarött från GOES-19/18, band 14; globalt: långvågig IR-mosaik från NESDIS. ”Endast moln” dämpar allt utom ljusa, kalla molntoppar; ett ljusstyrkefilter, inte en molnmask. Täckning och aktualitet varierar mellan regioner.", + "REGION": "REGION", + "IMAGE": "BILD", + "FILTER": "FILTER", + "Cloud filter strength": "Molnfiltrets styrka", + "OPACITY": "OPACITET", + "Infrared decode failed": "Avkodning av infraröd bild misslyckades", + "Weather prefetch unavailable": "Förhämtning av väder otillgänglig", + "Weather tiles unavailable · previous frame retained": "Väderrutor otillgängliga · föregående bildruta behålls", + "Some weather tiles unavailable": "Vissa väderrutor otillgängliga", + "Malformed weather manifest": "Felformaterat vädermanifest", + "Weather request timed out": "Väderförfrågan överskred tidsgränsen", + "Temperature field unavailable": "Temperaturfält otillgängligt", + "Mean sea level pressure field unavailable": "Fält för lufttryck vid havsytan otillgängligt", + "Wind upstream unavailable": "Vindtjänsten otillgänglig", + "{source} HTTP {status}": "{source} HTTP {status}", + "Wind": "Vind", + "Wind source unavailable": "Vindkälla otillgänglig", + "Forecast · does not follow history": "Prognos · följer inte historiken", + "Wind motion": "Vindrörelse", + "Wind speed": "Vindhastighet", + "Sea-level pressure": "Lufttryck vid havsytan", + "Air temperature · 2 m": "Lufttemperatur · 2 m", + "Global · 1° grid": "Globalt · 1°-rutnät", + "{model} forecast · {time}": "{model}-prognos · {time}", + "Loading forecast": "Laddar prognos", + "Preparing flow": "Förbereder vindflöde", + "Selected field unavailable": "Valt fält otillgängligt", + "Cached forecast · stale": "Cachad prognos · inaktuell", + "ECMWF IFS surface forecast": "Markprognos från ECMWF IFS", + "NOAA GFS surface forecast": "Markprognos från NOAA GFS", + "Reduced motion": "Minskad rörelse", + "Pause visual flow; forecast time does not advance with animation": "Pausa det visuella flödet; prognostiden går inte framåt med animeringen", + "None": "Ingen", + "Speed": "Hastighet", + "Pressure": "Lufttryck", + "Temperature": "Temperatur", + "Wind trails with no field shading": "Vindspår utan fältskuggning", + "How hard the surface wind is blowing": "Hur hårt det blåser vid marken", + "Sea-level air pressure: broad highs and lows": "Lufttryck vid havsytan: storskaliga högtryck och lågtryck", + "Air temperature two meters above the surface": "Lufttemperatur två meter över marken", + "Wind speed units": "Enheter för vindhastighet", + "Read wind at map center": "Läs av vinden i kartans mitt", + "Read the forecast at the center of the map without changing selection": "Läs av prognosen i kartans mitt utan att ändra urvalet", + "{model} forecast · {field} ({unit})": "{model}-prognos · {field} ({unit})", + "Valid: {time}": "Giltig: {time}", + "preparing": "förbereder", + "Issued: {time}": "Utfärdad: {time}", + "Selected field unavailable · wind remains visible": "Valt fält otillgängligt · vinden visas fortfarande", + "{error} · wind remains visible": "{error} · vinden visas fortfarande", + "Surface wind at 10 m. Approximately 1° global grid. Curves follow the 10 m wind field, lifted 12 km for visibility; display height is not weather altitude. View lighting is for readability. Animation shows flow through one fixed forecast; it does not advance time. Color fields drape the globe basemap or the active photorealistic 3D Tiles.": "Vind vid marken på 10 m höjd. Globalt rutnät på ungefär 1°. Kurvorna följer vindfältet på 10 m, lyfta 12 km för att synas; visningshöjden är ingen väderhöjd. Belysningen finns för läsbarhetens skull. Animeringen visar flödet i en enda fast prognos; tiden går inte framåt. Färgfälten läggs över globens bakgrundskarta eller de aktiva fotorealistiska 3D Tiles.", + "MODEL": "MODELL", + "FIELD": "FÄLT", + "UNITS": "ENHETER", + "MOTION": "RÖRELSE", + "Forecast": "Prognos", + "N": "N", + "NNE": "NNO", + "NE": "NO", + "ENE": "ONO", + "E": "O", + "ESE": "OSO", + "SE": "SO", + "SSE": "SSO", + "S": "S", + "SSW": "SSV", + "SW": "SV", + "WSW": "VSV", + "W": "V", + "WNW": "VNV", + "NW": "NV", + "NNW": "NNV", + "No surface reading": "Inget värde vid marken", + "Aim the map center at Earth": "Rikta kartans mitt mot jorden", + "A location reading needs a loaded forecast and the Earth at the center of the view.": "En avläsning på platsen kräver en laddad prognos och jorden i vyns mitt.", + "Interpolated model forecast on an approximately 1° grid. Broad weather patterns, not a street-level measurement.": "Interpolerad modellprognos på ett rutnät på ungefär 1°. Storskaliga vädermönster, inte en mätning på gatunivå.", + " · calm": " · vindstilla", + " from {direction}": " från {direction}", + "WIND AT {coordinates}": "VIND VID {coordinates}", + "{model} · valid {time}": "{model} · giltig {time}", + "Wind speed field unavailable": "Vindhastighetsfält otillgängligt", + "Globe imagery unavailable": "Globbilder otillgängliga", + "Globe field image unavailable": "Fältbild för globen otillgänglig", + "Unknown wind model": "Okänd vindmodell", + "Unknown weather overlay": "Okänt väderöverlägg", + "Wind request timed out": "Vindförfrågan överskred tidsgränsen", + "Malformed wind manifest": "Felformaterat vindmanifest", + "Malformed wind grid": "Felformaterat vindrutnät", + "Google 3D tiles unavailable — check the key's API restrictions, quota, or network": "Google 3D Tiles otillgängliga – kontrollera nyckelns API-begränsningar, kvot eller nätverk", + "Needs {envVars} — add it in Provider Settings — or a Cesium ion token for the ion-hosted route": "Kräver {envVars} – lägg till under Leverantörsinställningar – eller en Cesium ion-token för varianten som ion är värd för", + "Exit cockpit to fly to a camera": "Lämna cockpiten för att flyga till en kamera", + "Exit cockpit to fly to a location": "Lämna cockpiten för att flyga till en plats", + "Exit cockpit to fly to a photo": "Lämna cockpiten för att flyga till ett foto", + "Exit cockpit to fly to a shared view": "Lämna cockpiten för att flyga till en delad vy", + "Exit cockpit to fly to a target": "Lämna cockpiten för att flyga till ett mål", + "Exit cockpit to fly to a vessel": "Lämna cockpiten för att flyga till ett fartyg", + "Exit cockpit to fly to a fire": "Lämna cockpiten för att flyga till en brand", + "Exit cockpit to fly to a route": "Lämna cockpiten för att flyga till en rutt", + "Exit cockpit to fly to a file": "Lämna cockpiten för att flyga till en fil", + "Exit cockpit to fly to a scene": "Lämna cockpiten för att flyga till en scen", + "Exit cockpit to view weather coverage": "Lämna cockpiten för att visa vädertäckningen", + "Exit cockpit to fly to a cyclone": "Lämna cockpiten för att flyga till en cyklon", + "Exit cockpit to fly to an aircraft": "Lämna cockpiten för att flyga till ett luftfartyg", + "Exit cockpit to fly to a satellite": "Lämna cockpiten för att flyga till en satellit", + "Exit cockpit to frame contacts": "Lämna cockpiten för att rama in kontakter", + "Focusing {label}": "Fokuserar på {label}", + "Source": "Källa", + "Camera ownership changed": "Kamerastyrningen har bytt ägare", + "Saved project could not be read; storage preserved. Import a valid file to resume saving.": "Det sparade projektet kunde inte läsas; lagringen har bevarats. Importera en giltig fil för att kunna spara igen.", + "Scene layer turned off": "Scenlagret avstängt", + "Stopped": "Stoppad", + "Scene not saved — existing saved project could not be read. Export edits or import a valid file.": "Scenen sparades inte – det befintliga sparade projektet kunde inte läsas. Exportera ändringarna eller importera en giltig fil.", + "Scene not saved — {reason}": "Scenen sparades inte – {reason}", + "Scene not saved — browser storage unavailable": "Scenen sparades inte – webbläsarens lagring är otillgänglig", + "Cannot update {scene}: shot inventory changed": "Kan inte uppdatera {scene}: uppsättningen tagningar har ändrats", + "Cannot update {scene}: evidence beats changed": "Kan inte uppdatera {scene}: bevismomenten har ändrats", + "Cannot update {scene}: shot bindings are incomplete": "Kan inte uppdatera {scene}: tagningarnas kopplingar är ofullständiga", + "Updated {count} shots: {scene}": { + "one": "{count} tagning uppdaterad: {scene}", + "other": "{count} tagningar uppdaterade: {scene}" + }, + "Appended {count} shots: {scene}": { + "one": "{count} tagning tillagd: {scene}", + "other": "{count} tagningar tillagda: {scene}" + }, + "Could not leave scene: {scene}": "Kunde inte lämna scenen: {scene}", + "Seeked: {scene} / {shot}": "Hoppade till: {scene} / {shot}", + "Seeking scene clock": "Spolar scenklockan", + "Scene transition limit reached — load a shot to reset": "Gränsen för scenövergångar har nåtts – ladda en tagning för att återställa", + "Export failed: {error}": "Exporten misslyckades: {error}", + "Importing project": "Importerar projekt", + "Imported {file}": "{file} importerad", + "Import failed: {error}": "Importen misslyckades: {error}", + "Import failed (could not read JSON file)": "Importen misslyckades (JSON-filen kunde inte läsas)", + "Layers refused: {layers}": "Avvisade lager: {layers}", + "Camera move interrupted": "Kameraåkningen avbröts", + "Scene media timed out": "Tidsgränsen överskreds för scenmedia", + "Action complete": "Åtgärden slutförd", + "Action unavailable or cancelled": "Åtgärden otillgänglig eller avbruten", + "Scene actions": "Scenåtgärder", + "Select a feature or use Tab and Enter to choose an action.": "Välj ett objekt eller använd Tab och Enter för att välja en åtgärd.", + "Selected feature: {feature}. Choose an action.": "Valt objekt: {feature}. Välj en åtgärd.", + "Bhote Koshi Evidence Sequence": "Bhote Koshi: bevissekvens", + "Nepal Flood Incident": "Översvämningen i Nepal", + "Global Incident Context": "Händelsen i ett globalt sammanhang", + "Nepal-Focused Globe Rotation": "Globrotation med fokus på Nepal", + "Bhote Koshi Regional Approach": "Bhote Koshi: inflygning över regionen", + "Bhote Koshi Nearby Cities": "Bhote Koshi: närliggande städer", + "Bhote Koshi Incident Corridor": "Bhote Koshi: händelsekorridoren", + "Bhote Koshi Flood Path": "Bhote Koshi: flodvågens väg", + "Bhote Koshi Corridor Overview": "Bhote Koshi: översikt över korridoren", + "Bhote Koshi Upper Valley": "Bhote Koshi: övre dalen", + "Upper Valley Collapse": "Raset i övre dalen", + "Debris-Dammed Lake": "Sjö uppdämd av rasmassor", + "Nepal-China Border Gate": "Gränsövergången Nepal–Kina", + "Timure Evidence Cluster": "Bevisklustret vid Timure", + "Syabru Besi Passage": "Passagen vid Syabru Besi", + "Bidur / Trishuli Consequence": "Följderna i Bidur / Trishuli", + "Final view": "Slutvy", + "Scene {n}": "Scen {n}", + "the title of scene {scene} is empty or longer than {max} characters": "titeln på scen {scene} är tom eller längre än {max} tecken", + "the title of shot {shot} in scene {scene} is empty or longer than {max} characters": "titeln på tagning {shot} i scen {scene} är tom eller längre än {max} tecken", + "the project is larger than 5 MiB": "projektet är större än 5 MiB", + "the project has too many shots": "projektet har för många tagningar", + "the scene data failed validation": "scendata klarade inte valideringen", + "Could not complete this action. Check the file, references and selected assets.": "Åtgärden kunde inte slutföras. Kontrollera filen, referenserna och de valda resurserna.", + "{count} scenes": { + "one": "{count} scen", + "other": "{count} scener" + }, + "{count} shots": { + "one": "{count} tagning", + "other": "{count} tagningar" + }, + "{count} data packs": { + "one": "{count} datapaket", + "other": "{count} datapaket" + }, + "{scene} / {pack}: {status}. File: {path}. {attribution} · {license}": "{scene} / {pack}: {status}. Fil: {path}. {attribution} · {license}", + "Unavailable layers: {layers}": "Otillgängliga lager: {layers}", + "Uses registered content or linked media. Those external files are not included in a scene bundle. Their original notices still apply.": "Använder registrerat innehåll eller länkade medier. De externa filerna ingår inte i ett scenpaket. Deras ursprungliga upphovs- och licensmeddelanden gäller fortfarande.", + "{bytes} bundled bytes verified. Files stay in memory for this session. Reimport the bundle after reloading the app.": "{bytes} byte i paketet verifierade. Filerna finns kvar i minnet under den här sessionen. Importera paketet igen när appen har laddats om.", + "Review scene import": "Granska scenimport", + "Nothing is loaded or changed until you apply this file.": "Inget laddas eller ändras förrän filen tillämpas.", + "Ready to import": "Redo att importera", + "Apply replaces the current project. Export your current project first if you want to keep both.": "”Tillämpa” ersätter det aktuella projektet. Exportera det aktuella projektet först för att behålla båda.", + "Apply import": "Tillämpa import", + "Import was not applied. The current project may have changed.": "Importen tillämpades inte. Det aktuella projektet kan ha ändrats.", + "Select a scene and shot first": "Välj en scen och en tagning först", + "Edit scene details": "Redigera scendetaljer", + "Camera positions use degrees and meters above the ellipsoid. Drafts are validated before they replace your saved scene.": "Kamerapositioner anges i grader och meter över ellipsoiden. Utkast valideras innan de ersätter den sparade scenen.", + "Anchors and data packs": "Ankare och datapaket", + "Shot camera, timing, packs and actions": "Tagningens kamera, tidsförlopp, paket och åtgärder", + "New anchor ID": "Nytt ankar-ID", + "Capture camera as anchor": "Spara kameran som ankare", + "Anchor added to draft": "Ankare tillagt i utkastet", + "Set move start to current camera": "Använd aktuell kamera som start för kameraåkningen", + "Move added to draft; destination remains the shot camera": "Kameraåkning tillagd i utkastet; målet är fortfarande tagningens kamera", + "Use ordinary flight": "Använd vanlig flygning", + "Invalid shot JSON": "Ogiltig JSON för tagning", + "Apply details": "Tillämpa detaljer", + "Select a scene first": "Välj en scen först", + "Share selected scene": "Dela vald scen", + "Scene JSON preserves authored settings and attribution. It does not include asset files. Bundles include only pack files you choose here or previously imported bundle files.": "Scenens JSON bevarar de skapade inställningarna och källangivelserna. Resursfiler ingår inte. Paket innehåller bara datapaketfiler som väljs här eller tidigare importerade paketfiler.", + "Download scene JSON": "Ladda ned scenens JSON", + "Choose data-pack files": "Välj datapaketfiler", + "Or choose a data-pack folder": "Eller välj en datapaketmapp", + "Download asset bundle": "Ladda ned resurspaket", + "Asset bundle downloaded": "Resurspaketet har laddats ned", + "No RTL-SDR device selected": "Ingen RTL-SDR-enhet vald", + "WebUSB requires desktop Chrome or Edge on localhost/HTTPS": "WebUSB kräver Chrome eller Edge på dator via localhost/HTTPS", + "RTL-SDR access failed; close other SDR apps and check USB permissions": "Åtkomst till RTL-SDR misslyckades; stäng andra SDR-appar och kontrollera USB-behörigheterna", + "RTL-SDR connection failed": "RTL-SDR-anslutningen misslyckades", + "Receiving 1090 MHz ADS-B": "Tar emot ADS-B på 1090 MHz", + "Receiving broadcast FM": "Tar emot FM-radio", + "Connect an RTL-SDR to begin.": "Anslut en RTL-SDR för att börja.", + "WebUSB is unavailable in this browser": "WebUSB är inte tillgängligt i den här webbläsaren", + "USB work abandoned after a stalled transfer": "USB-åtgärden avbröts efter en överföring som hängde sig", + "SDR decoder worker failed": "Bakgrundsprocessen för SDR-avkodaren misslyckades", + "SDR decoder did not start; receiver stopped": "SDR-avkodaren startade inte; mottagaren stoppad", + "SDR decoder stopped responding; receiver stopped": "SDR-avkodaren slutade svara; mottagaren stoppad", + "SDR decoder fell behind; receiver stopped": "SDR-avkodaren hann inte med; mottagaren stoppad", + "WebUSB requires desktop Chrome or Edge": "WebUSB kräver Chrome eller Edge på dator", + "Waiting for an RTL-SDR device…": "Väntar på en RTL-SDR-enhet …", + "Browser audio is suspended; press Connect again to resume it": "Webbläsarens ljud är pausat; tryck på Anslut igen för att återuppta det", + "RTL-SDR sample stream stopped": "RTL-SDR-samplingsströmmen stoppades", + "Switching to {mode}…": "Byter till {mode} …", + "Browser audio is suspended; select FM again to resume it": "Webbläsarens ljud är pausat; välj FM igen för att återuppta det", + "RTL-SDR gain change failed": "Det gick inte att ändra förstärkningen på RTL-SDR", + "Tuned to {mhz} MHz FM": "Inställd på {mhz} MHz FM", + "Seeking up…": "Söker uppåt …", + "Seeking down…": "Söker nedåt …", + "Seek stopped": "Sökningen stoppad", + "Scanning {mhz} MHz…": "Skannar {mhz} MHz …", + "Found {mhz} MHz · {snr} dB": "Hittade {mhz} MHz · {snr} dB", + "FM signal found at {mhz} MHz": "FM-signal hittad på {mhz} MHz", + "No FM signal found": "Ingen FM-signal hittades", + "Browser location is unavailable": "Webbläsarens position är otillgänglig", + "Receiver location was not granted": "Åtkomst till mottagarens position beviljades inte", + "RTL-SDR disconnected": "RTL-SDR frånkopplad", + "Upstream response too large": "Svaret från uppströmskällan är för stort", + "Vector tile request timed out": "Förfrågan om vektorrutor överskred tidsgränsen", + "Vector tiles unavailable": "Vektorrutor otillgängliga", + "Vector tile metadata unavailable": "Metadata för vektorrutor otillgängliga", + "Zoom in for vector tile coverage": "Zooma in för täckning med vektorrutor", + "Saturation": "Mättnad", + "Edge Thickness": "Kanttjocklek", + "Contrast": "Kontrast", + "Grain": "Korn", + "Vignette": "Vinjett", + "Pixelation": "Pixelering", + "Distortion": "Förvrängning", + "Instability": "Instabilitet", + "Gain": "Förstärkning", + "Scanlines": "Sveplinjer", + "Sensitivity": "Känslighet", + "WHOT/BHOT": "WHOT/BHOT", + "Ironbow": "Ironbow", + "Link shared": "Länk delad", + "Return map to straight-down view": "Återställ kartan till vy rakt ovanifrån", + "Tilt map to oblique view": "Luta kartan till snedvy", + "Reset map to north up. Current heading {heading} degrees": "Återställ kartan med norr uppåt. Aktuell kurs {heading} grader", + "Tilted view": "Snedvy", + "Straight-down view": "Vy rakt ovanifrån", + "North up": "Norr uppåt", + "HDG": "HDG", + "PITCH": "LUTN.", + "FOV": "FOV", + "RANGE": "RÄCKV.", + "HGT": "HÖJD", + "ΔN": "ΔN", + "ΔE": "ΔO", + "{value}m": "{value} m", + "SNAPSHOT": "STILLBILD", + "LIVE": "LIVE", + "UPSTREAM": "UPPSTRÖMS", + "SYNTHETIC": "SYNTETISK", + "CONFIGURED": "KONFIGURERAD", + "SEED": "KATALOG", + "OK": "OK", + "Live HLS connected": "Live-HLS ansluten", + "Live HLS unavailable": "Live-HLS otillgänglig", + "No stream URL configured": "Ingen ström-URL konfigurerad", + "Live stream connected": "Liveström ansluten", + "Snapshot feed connected": "Stillbildsflöde anslutet", + "Media fetch failed": "Hämtning av media misslyckades", + "Upstream snapshot active": "Stillbild från uppströmskällan aktiv", + "Fallback Street View frame": "Street View-reservbild", + "Upstream unavailable": "Uppströmskällan otillgänglig", + "No source configured": "Ingen källa konfigurerad", + "Configured source": "Konfigurerad källa", + "Configured Source": "Konfigurerad källa", + "Synthetic fallback": "Syntetisk reserv", + "Upstream HTTP {status}": "Uppströms HTTP {status}", + "Unexpected media type {type}": "Oväntad medietyp {type}", + "{value} KM/H": "{value} KM/H", + "{label} · {radius} AIR/SEA WINDOW": "{label} · LUFT/SJÖ INOM {radius}", + "{scope} · INSTALLATIONS {coverage}": "{scope} · ANLÄGGNINGAR {coverage}", + "MILITARY FEED OFF · PARTIAL PICTURE": "MILITÄRFLÖDET AV · OFULLSTÄNDIG BILD", + "FLIGHTS FEED OFF · PARTIAL PICTURE": "FLYGFLÖDET AV · OFULLSTÄNDIG BILD", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLYGPLAN I NÄRHETEN OFULLSTÄNDIGA · INTE FARA ÖVER", + "{source} · LAST {hours} H": "{source} · SENASTE {hours} H", + "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", + "ADS-B PRIORITY · {status}": "ADS-B-PRIORITET · {status}", + "ADS-B EMERGENCY · {status}": "ADS-B-NÖDLÄGE · {status}", + "{aircraftClass} · {emergency} · {feedState}": "{aircraftClass} · {emergency} · {feedState}", + "Rendered globe frames per second · toggle with `": "Renderade bildrutor per sekund för globen · växla med `", + "Ground heights could not be refreshed. Try the file again when the map has loaded.": "Markhöjderna kunde inte uppdateras. Försök med filen igen när kartan har laddats.", + "{count} map objects": { + "one": "{count} kartobjekt", + "other": "{count} kartobjekt" + }, + ", drawn with its own styles": ", ritas med egna stilar", + "Hide {name}": "Dölj {name}", + "Show {name}": "Visa {name}", + "Fly to {name}": "Flyg till {name}", + "Remove {name}": "Ta bort {name}", + "At most {count} files at once — remove one first.": "Högst {count} filer åt gången – ta bort en först.", + "Reading {name}…": "Läser {name} …", + "That file could not be read.": "Filen kunde inte läsas.", + "Added {count} files.": { + "one": "Lade till {count} fil.", + "other": "Lade till {count} filer." + }, + "{count} files could not be added ({name}: {error})": { + "one": "{count} fil kunde inte läggas till ({name}: {error})", + "other": "{count} filer kunde inte läggas till ({name}: {error})" + }, + "Some KML content was left out.": "En del KML-innehåll utelämnades.", + "Press on the ground, not the sky": "Tryck på marken, inte på himlen", + "Drag to size the box": "Dra för att ändra områdets storlek", + "light {source} to use your {name} key": "slå på {source} för att använda egen {name}-nyckel", + "light {source}": "slå på {source}", + ", or ": " eller ", + "{requirement}, or {offer}": "{requirement} eller {offer}", + "Local ADS-B": "Lokal ADS-B", + "Transit": "Kollektivtrafik", + "Recent Imagery": "Senaste bilder", + "Fire Perimeters": "Brandgränser", + "GNSS Integrity": "GNSS-integritet", + "S2 GhostMaps": "S2 GhostMaps", + "Rain radar": "Regnradar", + "Lightning density": "Blixttäthet", + "Cyclone advisories": "Cyklonbulletiner", + "ALPR Cameras": "ALPR-kameror", + "Live Vessels": "Livefartyg", + "Bike Share": "Hyrcyklar", + "Cameras": "Kameror", + "Street Level": "Gatunivå", + "Mapped ALPR Cameras": "Kartlagda ALPR-kameror", + "Data Centers": "Datacenter", + "Active Fires": "Aktiva bränder", + "Military aircraft": "Militära luftfartyg", + "Vessels": "Fartyg", + "CCTV + Street View fallback": "CCTV + Street View-reserv", + "OpenStreetMap + optional Google Maps Places": "OpenStreetMap + valfritt Google Maps Places", + "GFS / ECMWF IFS · FORECAST": "GFS / ECMWF IFS · PROGNOS", + "NOAA nowCOAST · OBSERVED": "NOAA nowCOAST · OBSERVERAT", + "NASA FIRMS · LIVE": "NASA FIRMS · LIVE", + "OSM routing": "OSM-ruttplanering", + "data source|Local": "Lokal", + "Movement": "Rörelse", + "Infrastructure": "Infrastruktur", + "Events": "Händelser", + "Weather": "Väder", + "Utilities": "Verktyg", + "Other layers": "Övriga lager", + "status chip|UNAVAILABLE": "OTILLGÄNGLIG", + "Data source unavailable": "Datakällan är otillgänglig", + "Local FM stopped: internet radio started": "Lokal FM-radio stoppad: internetradio startad", + "IDLE": "INAKTIV", + "TUNING": "STÄLLER IN", + "STREAMING": "STRÖMMAR", + "UNSUPPORTED": "STÖDS INTE", + "idle": "inaktivt", + "running": "körs", + "suspended": "pausat", + "closed": "stängt", + "feed status|live": "live", + "feed status|stale": "inaktuellt", + "feed status|unreachable": "går inte att nå", + "feed status|invalid": "ogiltigt", + "waiting for IQ": "väntar på IQ", + "{count} messages": { + "one": "{count} meddelande", + "other": "{count} meddelanden" + }, + "DSP {count} blocks": { + "one": "DSP {count} block", + "other": "DSP {count} block" + }, + "DSP waiting": "DSP väntar", + "{level} dBFS audio": "ljud {level} dBFS", + "audio signal --": "ljudsignal --", + "audio {state}": "ljud {state}", + "{count} HEARD": { + "one": "{count} HÖRT", + "other": "{count} HÖRDA" + }, + "DISCONNECT": "KOPPLA FRÅN", + "CONNECT": "ANSLUT", + "LOCATING…": "LOKALISERAR …", + "LOCATED": "LOKALISERAD", + "LOCATE": "LOKALISERA", + " · read while Local ADS-B is on": " · läses medan ”Lokal ADS-B” är på", + "Decoder feeds: {feeds}": "Avkodarflöden: {feeds}", + "Language: {language}": "Språk: {language}", + "Change the interface language": "Byt språk för gränssnittet", + "Interface language": "Gränssnittsspråk", + "Search languages": "Sök språk", + "No matching languages": "Inga matchande språk", + "Check the spelling and press Enter to search again.": "Kontrollera stavningen och tryck på Enter för att söka igen.", + "Press Enter to try again.": "Tryck på Enter för att försöka igen.", + "Detailed outline unavailable": "Detaljerad kontur otillgänglig", + "Layers": "Lager", + "Search": "Sök", + "More": "Mer", + "Panels": "Paneler", + "Austin": "Austin", + "San Francisco": "San Francisco", + "New York": "New York", + "Tokyo": "Tokyo", + "London": "London", + "Paris": "Paris", + "Dubai": "Dubai", + "Washington DC": "Washington DC", + "Tallinn": "Tallinn", + "Los Angeles": "Los Angeles", + "CH {index} / {total}": "KAN. {index} / {total}", + "Station unavailable after directory refresh": "Stationen är otillgänglig efter uppdatering av katalogen", + "{category} ({count})": "{category} ({count})", + "PAUSE": "PAUSA", + "RESUME": "FORTSÄTT", + "Clear reading": "Rensa avläsningen", + "Earlier observation": "Tidigare observation", + "Observed history": "Observerad historik", + "Later observation": "Senare observation", + "Latest": "Senaste", + "Newest frame per product": "Senaste bildruta per produkt", + "Replay observed history": "Spela upp observerad historik", + "Imagery on Esri · Google 3D returns when cleared": "Bilder på Esri · Google 3D återkommer när de rensas", + "Point the camera at the ground to use the view": "Rikta kameran mot marken för att använda vyn", + "That point cannot anchor a box": "Den punkten kan inte förankra ett område", + "Imagery stops at ±{limit}° latitude": "Bilderna slutar vid ±{limit}° latitud", + "{owner} is using the pointer — close it first": "Verktyget ”{owner}” använder pekaren – stäng det först", + "Box is {size} wide · limit {limit}": "Området är {size} brett · gräns {limit}", + "View is {size} wide · limit {limit}": "Vyn är {size} bred · gräns {limit}", + "VS BASEMAP": "JFR KARTA", + "A / B": "A / B", + "Newest low-cloud day for this box · scene cloud, not box cloud": "Senaste dag med lite moln för området · scenens molnighet, inte områdets", + "Newest day covering this box · cloudier than 20%": "Senaste dag som täcker området · mer än 20 % moln", + "Newest day with imagery for this box · partial coverage": "Senaste dag med bilder för området · delvis täckning", + "Newest daily overview for this box": "Senaste dagliga översikt för området", + "newest clear day": "senaste klara dag", + "newest day (cloudy)": "senaste dag (molnig)", + "newest day (partial coverage)": "senaste dag (delvis täckning)", + "newest overview": "senaste översikt", + "No imagery": "Inga bilder", + "Loading": "Laddar", + "coverage|full": "fullständig", + "coverage|partial": "delvis", + "unknown": "okänd", + "no imagery": "inga bilder", + "{percent}% cloud": "{percent} % moln", + "{min}–{max}% cloud": "{min}–{max} % moln", + "NO BOX": "INGET OMRÅDE", + "SEARCHING": "SÖKER", + "{count} DAYS": { + "one": "{count} DAG", + "other": "{count} DAGAR" + }, + "Zoom in or draw a smaller box": "Zooma in eller rita ett mindre område", + "Select a box or use the view": "Välj ett område eller använd vyn", + "Searching the last 30 days": "Söker igenom de senaste 30 dagarna", + "No days to show for this box": "Inga dagar att visa för området", + "Drag the divider · SWAP trades sides": "Dra i skiljelinjen · BYT byter sida", + "← → preview · A or B pins the focused day": "← → förhandsvisa · A eller B fäster vald dag", + "← → preview the other side · A or B pins it": "← → förhandsvisa andra sidan · A eller B fäster den", + "S on another day replaces it · × unpins": "S på en annan dag ersätter den · × lossar", + "← → preview · S shows the focused day": "← → förhandsvisa · S visar vald dag", + "ZOOM IN": "ZOOMA IN", + "Fly in until the view fits the 1,000 km limit": "Flyg närmare tills vyn ryms inom gränsen på 1 000 km", + "Imagery days, newest first · arrows move, S or A and B pin": "Bilddagar, senaste först · pilarna flyttar, S eller A och B fäster", + "On the map": "På kartan", + "Mode": "Läge", + "Imagery opacity": "Bildernas opacitet", + "SWAP": "BYT", + "Trade the two sides of the divider": "Byt plats på skiljelinjens två sidor", + "SELECT BOX": "VÄLJ OMRÅDE", + "Drag a box on the map (Esc cancels)": "Dra upp ett område på kartan (Esc avbryter)", + "USE VIEW": "ANVÄND VYN", + "Use the current view as the box": "Använd aktuell vy som område", + "Forget the box and its images": "Glöm området och dess bilder", + "START HERE": "BÖRJA HÄR", + "Checking": "Kontrollerar", + "PREVIEW": "FÖRHANDSVY", + "Newest day with imagery for this box": "Senaste dag med bilder för området", + "SHOW": "VISA", + "Pin {day} as {slot}": "Fäst {day} som {slot}", + "Show {day}": "Visa {day}", + "pinned {slot}": "fäst som {slot}", + "shown": "visas", + "preview": "förhandsvy", + "start here": "börja här", + "No box": "Inget område", + "Searching": "Söker", + "Every day is empty here": "Alla dagar saknar bilder här", + "Sources off": "Källor av", + "VS": "JFR", + "Not set": "Inte vald", + "Basemap": "Bakgrundskarta", + "Source off": "Källa av", + "Unpin {slot}": "Lossa {slot}", + "Unpin the image": "Lossa bilden", + "EXPORT {slot}": "EXP. {slot}", + "EXPORT": "EXPORT", + "Download {slot} as a PNG": "Ladda ned {slot} som PNG", + "Download the image as a PNG": "Ladda ned bilden som PNG", + "Box {width} × {height} km": "Område {width} × {height} km", + "Acquired {time}": "Tagen {time}", + "Coverage {coverage}": "Täckning {coverage}", + "START HERE · {reason}": "BÖRJA HÄR · {reason}", + "Daily overview shows little detail in a box this small": "Den dagliga översikten visar få detaljer i ett så litet område", + "Imagery: NASA GIBS and Worldview · HLS (Sentinel-2, Landsat 8/9) and VIIRS": "Bilder: NASA GIBS och Worldview · HLS (Sentinel-2, Landsat 8/9) och VIIRS", + "Details": "Detaljer", + "Times, coverage, sources": "Tider, täckning, källor", + "HIDE EMPTY DAYS": "DÖLJ TOMMA DAGAR", + "SHOW EMPTY DAYS · {count}": "VISA TOMMA DAGAR · {count}", + "Days the probe found empty in this box": "Dagar då kontrollen inte hittade några bilder i området", + "image": "bild", + "BASEMAP": "BAKGRUNDSKARTA", + "The basemap": "Bakgrundskartan", + "basemap": "bakgrundskarta", + "Recent imagery against the basemap divider": "Skiljelinje mellan senaste bilder och bakgrundskarta", + "Recent imagery A and B divider": "Skiljelinje mellan senaste bilder A och B", + "{before} {left} percent, {after} {right} percent": "{before} {left} procent, {after} {right} procent", + "Export failed · {error}": "Exporten misslyckades · {error}", + "New scene name": "Namn på ny scen", + "Delete scene \"{scene}\" and all shots?": "Radera scenen ”{scene}” och alla tagningar?", + "Stopped (Esc)": "Stoppad (Esc)", + "No shots yet. Use CAPTURE SHOT to save current look.": "Inga tagningar än. Använd SPARA TAGNING för att spara nuvarande vy.", + "Double-click to rename": "Dubbelklicka för att byta namn", + "Shot name": "Namn på tagning", + "LOAD": "LADDA", + "{duration}s + {hold}s": "{duration} s + {hold} s", + "Choose files": "Välj filer", + "{count} files selected": { + "one": "{count} fil vald", + "other": "{count} filer valda" + }, + "No file chosen": "Ingen fil vald", + "Cancel": "Avbryt", + "EDIT DETAILS": "REDIGERA DETALJER", + "SHARE SCENE": "DELA SCEN", + "Shared flight follow expired": "Följningen av den delade flygningen har gått ut", + "Shared military flight follow expired": "Följningen av den delade militärflygningen har gått ut", + "Shared satellite follow expired": "Följningen av den delade satelliten har gått ut", + "Shared flight could not be restored — feed unavailable": "Den delade flygningen kunde inte återställas – flödet är otillgängligt", + "Shared military flight could not be restored — feed unavailable": "Den delade militärflygningen kunde inte återställas – flödet är otillgängligt", + "Shared satellite could not be restored — feed unavailable": "Den delade satelliten kunde inte återställas – flödet är otillgängligt", + "Shared flight is unavailable": "Den delade flygningen är otillgänglig", + "Shared military flight is unavailable": "Den delade militärflygningen är otillgänglig", + "Shared satellite is unavailable": "Den delade satelliten är otillgänglig", + "This share link can't be opened": "Delningslänken kan inte öppnas", + "Shared vessel is unavailable": "Det delade fartyget är otillgängligt", + "subject": "objekt", + "RETRYING ALPR CAMERAS": "ALPR-KAMEROR: FÖRSÖKER IGEN", + "FETCHING ALPR CAMERAS": "HÄMTAR ALPR-KAMEROR", + "OVERPASS TEMPORARILY UNAVAILABLE": "OVERPASS TILLFÄLLIGT OTILLGÄNGLIGT", + "OVERPASS RATE-LIMITED": "OVERPASS: FÖRFRÅGNINGSGRÄNS NÅDD", + "OVERPASS TIMED OUT": "OVERPASS: TIDSGRÄNSEN ÖVERSKREDS", + "OVERPASS COULD NOT COMPLETE THE QUERY": "OVERPASS KUNDE INTE SLUTFÖRA FRÅGAN", + "MAP TILES TEMPORARILY UNAVAILABLE": "KARTRUTOR TILLFÄLLIGT OTILLGÄNGLIGA", + "MAPPED NAMES TEMPORARILY UNAVAILABLE": "KARTLAGDA NAMN TILLFÄLLIGT OTILLGÄNGLIGA", + "ALPR cameras": "ALPR-kameror", + "OpenSky OAuth client missing/invalid": "OAuth-klienten för OpenSky saknas eller är ogiltig", + "OpenSky OAuth rejected credentials": "OpenSky OAuth avvisade inloggningsuppgifterna", + "OpenSky username/password rejected": "OpenSky avvisade användarnamn/lösenord", + "OpenSky auth missing": "Autentisering för OpenSky saknas", + "OpenSky auth required": "OpenSky kräver autentisering", + "OpenSky auth invalid": "Ogiltig autentisering för OpenSky", + "OpenSky auth failed": "Autentisering mot OpenSky misslyckades", + "KartaView answered HTTP {status}": "KartaView svarade HTTP {status}", + "Panoramax answered HTTP {status}": "Panoramax svarade HTTP {status}", + "KartaView refused the request (code {code})": "KartaView avvisade begäran (kod {code})", + "Vector tiles unavailable (HTTP {status})": "Vektorrutor otillgängliga (HTTP {status})", + "GNSS feed unavailable (HTTP {status})": "GNSS-flödet är otillgängligt (HTTP {status})", + "{count} CelesTrak groups unavailable": { + "one": "{count} CelesTrak-grupp otillgänglig", + "other": "{count} CelesTrak-grupper otillgängliga" + }, + "{feed} feed unavailable": "Flödet {feed} är otillgängligt", + "Source snapshot {minutes} min old": "Källans ögonblicksbild är {minutes} min gammal", + "{source} rate limited (anonymous)": "{source}: förfrågningsgräns nådd (anonym)", + "{source} rate limited": "{source}: förfrågningsgräns nådd", + "{source} network error": "{source}: nätverksfel", + "Malformed {source} aircraft rows": "Felformaterade luftfartygsrader från {source}", + "Malformed {source} response": "Felformaterat svar från {source}", + "entry {n}": "post {n}", + "feeds {names} stale": { + "one": "flödet {names} inaktuellt", + "other": "flödena {names} inaktuella" + }, + "feeds {names} unreachable": { + "one": "flödet {names} går inte att nå", + "other": "flödena {names} går inte att nå" + }, + "feeds {names} invalid": { + "one": "flödet {names} ogiltigt", + "other": "flödena {names} ogiltiga" + }, + "Loading {feeds}": "Laddar {feeds}", + "{count} heard": { + "one": "{count} hört", + "other": "{count} hörda" + }, + "USB {rate} msg/s": "USB {rate} medd./s", + "{rate} msg/s": "{rate} medd./s", + "{count} feeds live": { + "one": "{count} flöde live", + "other": "{count} flöden live" + }, + "{feed} {count} ({seconds}s old)": "{feed} {count} ({seconds} s sedan)", + "Street Level toggle failed": "Det gick inte att växla ”Gatunivå”", + "Street Level could not start": "”Gatunivå” kunde inte starta", + "SHRINK": "FÖRMINSKA", + "EXPAND": "FÖRSTORA", + "Shrink": "Förminska", + "Expand": "Förstora", + "ANY DATE": "ALLA DATUM", + "LAST 10 YEARS": "SENASTE 10 ÅREN", + "LAST 5 YEARS": "SENASTE 5 ÅREN", + "LAST 3 YEARS": "SENASTE 3 ÅREN", + "LAST 2 YEARS": "SENASTE 2 ÅREN", + "LAST YEAR": "SENASTE 12 MÅN.", + "LAST 6 MONTHS": "SENASTE 6 MÅN.", + "LAST 3 MONTHS": "SENASTE 3 MÅN.", + "LAST MONTH": "SENASTE 30 DAGARNA", + "LAST {count} DAYS": { + "one": "SENASTE {count} DAGEN", + "other": "SENASTE {count} DAGARNA" + }, + "{window} · SINCE {date}": "{window} · SEDAN {date}", + "Turn Street Level off": "Stäng av ”Gatunivå”", + "Turn Street Level on": "Slå på ”Gatunivå”", + "{name} imagery on": "{name}-bilder på", + "{name} imagery off": "{name}-bilder av", + "Image by {creator}": "Bild: {creator}", + " or ": " eller ", + "OPEN {labels}": "ÖPPNA {labels}", + "Open {names} at the centre of the view": "Öppna {names} i mitten av vyn", + "Zoom in to a street to open {names} here": "Zooma in på en gata för att öppna {names} här", + "Switch Street Level on to draw its coverage.": "Slå på ”Gatunivå” för att visa täckningen.", + "Every provider is switched off: light a chip to draw its coverage.": "Alla leverantörer är avstängda: slå på en för att visa dess täckning.", + "Loading this sequence…": "Laddar sekvensen …", + "{n} images in this sequence · Esc clears": { + "one": "{n} bild i sekvensen · Esc rensar", + "other": "{n} bilder i sekvensen · Esc rensar" + }, + "No sequences in view match the filter": "Inga sekvenser i vyn matchar filtret", + "No coverage in view": "Ingen täckning i vyn", + "{n} streets in view · click a line for its nearest photo": { + "one": "{n} gata i vyn · klicka på en linje för närmaste foto", + "other": "{n} gator i vyn · klicka på en linje för närmaste foto" + }, + "{n} sequences in view · click a line for its nearest photo": { + "one": "{n} sekvens i vyn · klicka på en linje för närmaste foto", + "other": "{n} sekvenser i vyn · klicka på en linje för närmaste foto" + }, + "{n} sequences in view · click a line for its photos": { + "one": "{n} sekvens i vyn · klicka på en linje för dess foton", + "other": "{n} sekvenser i vyn · klicka på en linje för dess foton" + }, + "km/h": "km/h", + "m/s": "m/s", + "mph": "mph", + "hPa": "hPa", + "kt": "kn", + "kts": "kn", + "ft": "ft", + "km": "km", + "m": "m", + "h": "h", + "min": "min", + "s": "s", + "dB": "dB", + "{time} · {minutes} min ago": "{time} · för {minutes} min sedan", + "Active weather": "Aktuellt väder", + "LATEST · newest per product": "SENASTE · nyaste per produkt", + "Forecast · valid {valid} · issued {issued}": "Prognos · gäller {valid} · utfärdad {issued}", + "{detail} · Does not follow history": "{detail} · Följer inte historiken", + "Forecast · valid {valid}": "Prognos · gäller {valid}", + "voice|OFF": "AV", + "voice|CONNECTING": "ANSLUTER", + "voice|ERROR": "FEL", + "Voice provider": "Leverantör för röststyrning", + "Changing provider reconnects voice if it is on.": "Byte av leverantör återansluter röststyrningen om den är på.", + "Voice Settings": "Inställningar för röststyrning", + "STD": "STD", + "ON/OFF": "PÅ/AV", + "VOICE": "RÖST", + "Preview": "Förhandsvisning", + "Settings": "Inställningar", + "Close voice panel": "Stäng röstpanelen", + "Voice replies appear here.": "Röstsvar visas här.", + "Voice assistant": "Röstassistent", + "Dismiss voice card": "Stäng röstkortet", + "YOU": "DU", + "THIS": "DETTA", + "Plan": "Plan", + "Referenced items": "Nämnda objekt", + "NOTES & SOURCES": "NOTER & KÄLLOR", + "Show Voice Preview": "Visa röstförhandsvisning", + "Voice inactivity timeout": "Tidsgräns vid röstinaktivitet", + "Minutes": "Minuter", + "Custom": "Anpassad", + "These preferences stay in this browser.": "Inställningarna sparas bara i den här webbläsaren.", + "Gemini returned an oversized audio chunk": "Gemini returnerade ett för stort ljudblock", + "Gemini returned an unsupported audio format": "Gemini returnerade ett ljudformat som inte stöds", + "Gemini returned an invalid audio sample rate": "Gemini returnerade en ogiltig samplingsfrekvens för ljud", + "Gemini returned incomplete PCM audio": "Gemini returnerade ofullständigt PCM-ljud", + "This browser does not support Gemini microphone audio": "Den här webbläsaren stöder inte mikrofonljud för Gemini", + "Microphone audio processing failed": "Bearbetningen av mikrofonljudet misslyckades", + "Gemini audio playback fell behind. Reconnect to continue.": "Gemini-ljuduppspelningen hann inte med. Anslut igen för att fortsätta.", + "Gemini token response did not include an ephemeral credential": "Gemini-tokensvaret innehöll ingen tillfällig inloggningsuppgift", + "Invalid Gemini connection endpoint": "Ogiltig anslutningsslutpunkt för Gemini", + "Gemini voice could not connect. Try again.": "Gemini-röststyrningen kunde inte ansluta. Försök igen.", + "Gemini voice requires a same-origin app request": "Gemini-röststyrningen kräver en appbegäran från samma ursprung", + "Add a Gemini API key in Provider Settings": "Lägg till en Gemini-API-nyckel i Leverantörsinställningar", + "The Gemini Live model configuration is invalid": "Modellkonfigurationen för Gemini Live är ogiltig", + "Gemini voice is starting too often. Try again shortly.": "Gemini-röststyrningen startas för ofta. Försök igen om en stund.", + "Gemini quota is unavailable. Try again later.": "Gemini-kvoten är otillgänglig. Försök igen senare.", + "Gemini access was refused. Check the API key and model access.": "Gemini nekade åtkomst. Kontrollera API-nyckeln och modellåtkomsten.", + "Gemini Live is unavailable for the configured model.": "Gemini Live är otillgängligt för den konfigurerade modellen.", + "Token request is too large": "Tokenbegäran är för stor", + "Use an empty JSON token request": "Använd en tom JSON-tokenbegäran", + "Voice command context is too large": "Kontexten för röstkommandot är för stor", + "Gemini reliable transport exceeded its queue limit. Start voice again.": "Gemini-sändkön är full. Starta röststyrningen igen.", + "Gemini reliable transport did not drain. Start voice again.": "Gemini-meddelandena kunde inte skickas i tid. Starta röststyrningen igen.", + "Gemini token request failed (HTTP {status})": "Gemini-tokenbegäran misslyckades (HTTP {status})", + "Gemini token response did not include a valid model": "Gemini-tokensvaret innehöll ingen giltig modell", + "Gemini token response did not include session settings": "Gemini-tokensvaret innehöll inga sessionsinställningar", + "Gemini session settings do not match the input mode": "Gemini-sessionsinställningarna matchar inte inmatningsläget", + "Gemini could not submit this input. Start voice again.": "Gemini kunde inte skicka den här inmatningen. Starta röststyrningen igen.", + "Connecting to Gemini": "Ansluter till Gemini", + "Gemini returned an oversized message": "Gemini returnerade ett för stort meddelande", + "Gemini Live connection failed. Check network access, then start voice again.": "Anslutningen till Gemini Live misslyckades. Kontrollera nätverksåtkomsten och starta sedan röststyrningen igen.", + "Gemini Live connection ended. Start voice again for a new conversation.": "Anslutningen till Gemini Live avslutades. Starta röststyrningen igen för ett nytt samtal.", + "Gemini Live could not start. Start voice again to request a fresh session.": "Gemini Live kunde inte starta. Starta röststyrningen igen för att begära en ny session.", + "Start voice again for a fresh token and a new conversation.": "Starta röststyrningen igen för en ny token och ett nytt samtal.", + "Start voice again to request a fresh session credential.": "Starta röststyrningen igen för att begära nya inloggningsuppgifter för sessionen.", + "Microphone permission was denied. Allow microphone access and try again.": "Åtkomst till mikrofonen nekades. Tillåt mikrofonen och försök igen.", + "Gemini connection timed out. Try again.": "Tidsgränsen för Gemini-anslutningen överskreds. Försök igen.", + "Gemini voice could not start": "Gemini-röststyrningen kunde inte starta", + "Gemini did not finish the interrupted reply. Start voice again.": "Gemini slutförde inte det avbrutna svaret. Starta röststyrningen igen.", + "Gemini session expiring; finishing the current reply": "Gemini-sessionen löper snart ut – det pågående svaret slutförs", + "Gemini rejected the voice session. Start voice again to request a fresh session.": "Gemini avvisade röstsessionen. Starta röststyrningen igen för att begära en ny session.", + "Start voice again for a fresh session credential. If it repeats, check Gemini model and API access.": "Starta röststyrningen igen för nya inloggningsuppgifter för sessionen. Om det upprepas, kontrollera Gemini-modellen och API-åtkomsten.", + "Gemini action history is full. Start voice again for a fresh session.": "Gemini-åtgärdshistoriken är full. Starta röststyrningen igen för en ny session.", + "Gemini requested too many simultaneous actions": "Gemini begärde för många samtidiga åtgärder", + "Voice text command is too long": "Textkommandot för röststyrningen är för långt", + "Gemini voice is not connected": "Gemini-röststyrningen är inte ansluten", + "Gemini interrupted input exceeded its buffer or wait limit. Start voice again.": "Den avbrutna Gemini-inmatningen överskred sin buffert- eller väntegräns. Starta röststyrningen igen.", + "Gemini Live connection is expiring. Start voice again for a new conversation.": "Anslutningen till Gemini Live löper snart ut. Starta röststyrningen igen för ett nytt samtal.", + "Gemini voice sessions have a time limit. When a session expires, start voice again for a new conversation.": "Gemini-röstsessioner har en tidsgräns. När en session går ut, starta röststyrningen igen för ett nytt samtal.", + "Stopped by voice": "Stoppad via röststyrning", + "Automatic stop is unavailable for this voice connection.": "Automatiskt stopp är inte tillgängligt för den här röstanslutningen.", + "Preview is off.": "Förhandsvisningen är av.", + "Your choice applies now, but could not be saved.": "Valet gäller nu men kunde inte sparas.", + "MINI": "MINI", + "standard": "standard", + "mini": "mini", + "COST UNKNOWN": "OKÄND KOSTNAD", + "ChatGPT OAuth session: {responses} response(s), {input} input and {output} output tokens, {captions} caption transcription(s) reported.": "ChatGPT-OAuth-session: {responses} svar, {input} indata- och {output} utdatatoken, {captions} transkribering(ar) av undertexter rapporterade.", + "USD cost and the API spend cap are unavailable for this auth mode.": "USD-kostnad och API-utgiftstak är inte tillgängliga i det här autentiseringsläget.", + "Usage is incomplete because a response was still in flight when the session ended.": "Användningen är ofullständig eftersom ett svar fortfarande var på väg när sessionen avslutades.", + "Estimate is incomplete — a response was still in flight when the session ended, so its usage was never reported.": "Uppskattningen är ofullständig – ett svar var fortfarande på väg när sessionen avslutades, så dess användning rapporterades aldrig.", + "STANDARD": "STANDARD", + "MIC": "MIK", + "{n} aircraft": { + "one": "{n} luftfartyg", + "other": "{n} luftfartyg" + }, + "{n} military aircraft": { + "one": "{n} militärt luftfartyg", + "other": "{n} militära luftfartyg" + }, + "{n} ships": { + "one": "{n} fartyg", + "other": "{n} fartyg" + }, + "{n} satellites": { + "one": "{n} satellit", + "other": "{n} satelliter" + }, + "{n} launches": { + "one": "{n} uppskjutning", + "other": "{n} uppskjutningar" + }, + "{n} earthquakes": { + "one": "{n} jordbävning", + "other": "{n} jordbävningar" + }, + "{n} fires": { + "one": "{n} brand", + "other": "{n} bränder" + }, + "{n} fire perimeters": { + "one": "{n} brandgräns", + "other": "{n} brandgränser" + }, + "{n} cyclones": { + "one": "{n} cyklon", + "other": "{n} cykloner" + }, + "{n} buses": { + "one": "{n} buss", + "other": "{n} bussar" + }, + "{n} bike share": { + "one": "{n} hyrcykelstation", + "other": "{n} hyrcykelstationer" + }, + "{n} flock cameras": { + "one": "{n} ALPR-kamera", + "other": "{n} ALPR-kameror" + }, + "{n} military bases": { + "one": "{n} militärbas", + "other": "{n} militärbaser" + }, + "{n} datacenters": { + "one": "{n} datacenter", + "other": "{n} datacenter" + }, + "{n} dams": { + "one": "{n} damm", + "other": "{n} dammar" + }, + "{n} air quality": { + "one": "{n} luftkvalitetsstation", + "other": "{n} luftkvalitetsstationer" + }, + "{n} results": { + "one": "{n} resultat", + "other": "{n} resultat" + }, + "anywhere in the loaded data": "var som helst i laddade data", + "in view": "i vyn", + "within {radius} of the view centre": "inom {radius} från vyns mitt", + "within {radius} of {place} (Contacts window)": "inom {radius} från {place} (fönstret Kontakter)", + "within {radius} of {place}": "inom {radius} från {place}", + "within {radius}": "inom {radius}", + "over {region}": "över {region}", + "At least {items}": "Minst {items}", + "{items} {scope}": "{items} {scope}", + "last 24 h": "senaste 24 h", + "past 30 d and upcoming": "senaste 30 d och kommande", + "the flights layer loads by viewport": "lagret ”Liveflyg” laddar data för aktuell vy", + "distance is ground distance, not overhead": "avståndet mäts längs marken, höjden räknas inte", + "the count is a lower bound because at least one layer reached its retained limit.": "antalet är ett minimum eftersom minst ett lager har nått sin lagringsgräns.", + "{label} off": "{label} av", + "{label} loading": "{label} laddas", + "{label} error": "{label}: fel", + "counted the first {returned} loaded records": "räknade de första {returned} laddade posterna", + "counted {returned} of {total} loaded records": "räknade {returned} av {total} laddade poster", + "{layers} just turned on — still loading, counts will rise": "{layers} har just aktiverats – laddar fortfarande, antalen kommer att öka", + "Contacts panel snapshot at {time} — the same retained cohort supplies the card, speech, and follow-up memory": "Ögonblicksbild av panelen Kontakter kl. {time} – samma sparade urval används för kortet, talet och minnet för följdfrågor", + "Contacts panel snapshot — the same retained cohort supplies the card, speech, and follow-up memory": "Ögonblicksbild av panelen Kontakter – samma sparade urval används för kortet, talet och minnet för följdfrågor", + "Fly below {km} km to a covered region": "Flyg under {km} km höjd till en region med täckning", + "No feed here yet · {n} regions available": { + "one": "Inget flöde här än · {n} region tillgänglig", + "other": "Inget flöde här än · {n} regioner tillgängliga" + }, + "Fly below {km} km to a covered city": "Flyg under {km} km höjd till en stad med täckning", + "No bike share here yet · {n} systems available": { + "one": "Inga hyrcyklar här än · {n} system tillgängligt", + "other": "Inga hyrcyklar här än · {n} system tillgängliga" + }, + "degraded": "begränsad", + "stale": "inaktuell", + "partial": "delvis", + "fallback": "reserv", + "unavailable": "otillgänglig", + "Not answered: {layers}": "Inget svar: {layers}", + "Toggle voice control": "Slå på/av röststyrning", + "Activate to toggle voice": "Aktivera för att slå på/av röststyrningen", + "Voice active": "Röststyrning aktiv", + "Voice could not be started.": "Röststyrningen kunde inte startas.", + "{value} feet": "{value} fot", + "Fire · FRP {frp} MW": "Brand · FRP {frp} MW", + "The flights layer is not enabled": "Lagret ”Liveflyg” är inte aktiverat", + "The military layer is not enabled": "Lagret ”Militärflyg” är inte aktiverat", + "The satellites layer is not enabled": "Lagret ”Satelliter” är inte aktiverat", + "The ais-live-vessels layer is not enabled": "Lagret ”Livefartyg” är inte aktiverat", + "Could not enable the requested layer": "Det begärda lagret kunde inte aktiveras", + "Could not disable the requested layer": "Det begärda lagret kunde inte inaktiveras", + "Radio layer unavailable": "Radiolagret är otillgängligt", + "Camera navigation is unavailable in the current view": "Kameranavigering är inte tillgänglig i aktuell vy", + "Camera navigation policy unavailable": "Policy för kameranavigering otillgänglig", + "Nearest-aircraft selection supports Flights or Military Flights only": "Val av närmaste luftfartyg stöder bara ”Liveflyg” eller ”Militärflyg”", + "Nearest-aircraft selection needs a preset, place name, or latitude and longitude": "Val av närmaste luftfartyg kräver ett förval, ett ortnamn eller latitud och longitud", + "Could not arrive at the requested place": "Kunde inte nå den begärda platsen", + "The nearest airborne aircraft could not be selected": "Närmaste luftfartyg i luften kunde inte väljas", + "No flights within {radius} of the current view": "Inga flygningar inom {radius} från aktuell vy", + "No military within {radius} of the current view": "Inget militärflyg inom {radius} från aktuell vy", + "No satellites within {radius} of the current view": "Inga satelliter inom {radius} från aktuell vy", + "No vessels within {radius} of the current view": "Inga fartyg inom {radius} från aktuell vy", + "{layer} is enabled on the {source} feed, but no airborne aircraft is loaded in the {place} view yet": "Lagret ”{layer}” är aktiverat med flödet {source}, men inga luftfartyg i luften har laddats i vyn över {place} ännu", + "{layer} is enabled, but no airborne aircraft is loaded in the {place} view yet": "Lagret ”{layer}” är aktiverat, men inga luftfartyg i luften har laddats i vyn över {place} ännu", + "{layer} is enabled, but its aircraft feed is unavailable": "Lagret ”{layer}” är aktiverat, men flödet med luftfartyg är otillgängligt", + "{layer} is enabled, but its destination refresh did not complete": "Lagret ”{layer}” är aktiverat, men uppdateringen vid målet blev inte klar", + "{layer} is enabled, but {source} is unavailable": "Lagret ”{layer}” är aktiverat, men {source} är otillgänglig", + "Could not arrive at {place}": "Kunde inte nå {place}", + "{name} is no longer loaded": "{name} är inte längre laddad", + "Unknown target layer: {target}": "Okänt mållager: {target}", + "nominal": "normal", + "{label} on": "{label} på", + "Frame aircraft": "Rama in luftfartyg", + "Frame military aircraft": "Rama in militära luftfartyg", + "Frame ships": "Rama in fartyg", + "Frame satellites": "Rama in satelliter", + "Frame contacts": "Rama in kontakter", + "{count} aircraft in frame": { + "one": "{count} luftfartyg i bild", + "other": "{count} luftfartyg i bild" + }, + "{count} military aircraft in frame": { + "one": "{count} militärt luftfartyg i bild", + "other": "{count} militära luftfartyg i bild" + }, + "{count} ships in frame": { + "one": "{count} fartyg i bild", + "other": "{count} fartyg i bild" + }, + "{count} satellites in frame": { + "one": "{count} satellit i bild", + "other": "{count} satelliter i bild" + }, + "{count} contacts in frame": { + "one": "{count} kontakt i bild", + "other": "{count} kontakter i bild" + }, + "Nearest military aircraft": "Närmaste militära luftfartyg", + "Nearest ships": "Närmaste fartyg", + "Nearest satellites": "Närmaste satelliter", + "Nearest aircraft": "Närmaste luftfartyg", + "{first} and {second}": "{first} och {second}", + "Layer": "Lager", + "there": "platsen", + "Camera range {range}": "Kameraavstånd {range}", + "Within {radius} of the view": "Inom {radius} från vyns mitt", + "Labels on": "Etiketter på", + "Framing uses the nearest 80 loaded contacts": "Inramningen använder de 80 närmaste laddade kontakterna", + "{distance} from {location}": "{distance} från {location}", + "Altitude {altitude}": "Höjd {altitude}", + "{origin} to {destination}": "{origin} till {destination}", + "from {origin}": "från {origin}", + "to {destination}": "till {destination}", + "Registration {registration}": "Registrering {registration}", + "View state": "Vystatus", + "Marked {count} places": { + "one": "{count} plats markerad", + "other": "{count} platser markerade" + }, + "Nothing marked": "Inget markerat", + "Not found: {places}": "Hittades inte: {places}", + "Tracing outlines": "Tar fram konturer", + "Frame flights": "Rama in flygningar", + "Frame military": "Rama in militärflyg", + "Frame vessels": "Rama in fartyg", + "Frame {target}": "Rama in {target}", + "Zoom out to globe": "Zooma ut till globen", + "Open data layers": "Öppna datalager", + "Open or close a panel": "Öppna eller stäng en panel", + "Change context mode": "Byt kontextläge", + "Control cockpit": "Styr cockpit", + "Change visual style": "Byt visuell stil", + "Adjust HUD": "Justera HUD", + "Adjust cyber sonar": "Justera cybersonar", + "Adjust detection overlay": "Justera detekteringsöverlägg", + "Change map stack": "Byt kartuppsättning", + "Adjust post-processing": "Justera efterbehandling", + "Control scene": "Styr scen", + "Control CCTV": "Styr CCTV", + "Control radio": "Styr radio", + "Track contact": "Följ kontakt", + "Stop tracking": "Avbryt följning", + "Clear marks": "Rensa markeringar", + "Move camera": "Flytta kameran", + "Fly route": "Flyg rutt", + "Analyst query": "Analysfråga", + "Next ISS pass": "Nästa ISS-passage", + "Next satellite pass": "Nästa satellitpassage", + "Mark the map": "Markera på kartan", + "Mark {targets} +{more}": "Markera {targets} +{more}", + "Mark {targets}": "Markera {targets}", + "Fly to {place}": "Flyg till {place}", + "Fly to Location": "Flyg till plats", + "Nearest aircraft to {place}": "Närmaste luftfartyg vid {place}", + "Nearest aircraft to Here": "Närmaste luftfartyg här", + "Layer on": "Lager på", + "Layer off": "Lager av", + "Zoom out": "Zooma ut", + "Zoom in": "Zooma in", + "Read the view": "Läs av vyn", + "Check view state": "Kontrollera vystatus", + "Action": "Åtgärd", + "Finding places": "Söker platser", + "Tracing outline": "Tar fram kontur", + "Looking up place": "Slår upp plats", + "Flying": "Flyger", + "Turning on layer": "Aktiverar lager", + "Loading aircraft": "Laddar luftfartyg", + "Picking nearest": "Väljer närmaste", + "Working": "Arbetar", + "in progress": "pågår", + "done": "klar", + "failed": "misslyckades", + "cancelled": "avbröts", + "Outline traced": "Kontur framtagen", + "Outline unavailable": "Kontur otillgänglig", + "Source: {label}": "Källa: {label}", + "WORKING": "ARBETAR", + "DONE": "KLAR", + "Recent speech": "Senast sagt", + "HERE": "HÄR", + "KTS": "KN", + "FT": "FT", + "FLT": "FLYG", + "MIL": "MIL", + "SITE": "ANL.", + "WX": "VÄDER", + "MM": "MM", + "Weather data by Open-Meteo.com": "Väderdata från Open-Meteo.com", + "Cockpit briefing pages": "Genomgångssidor i cockpit", + "SIG": "SIG", + "LOCAL": "LOKALT", + "ESC EXIT": "ESC AVSLUTAR", + "Active weather products": "Aktiva väderprodukter", + "RECENT IMAGERY": "SENASTE BILDER", + "Imagery days in the box": "Bilddagar i området", + "Tune available internet radio stations": "Ställ in tillgängliga internetradiostationer", + "DIRECTORY: RADIO BROWSER": "KATALOG: RADIO BROWSER", + "Local RTL-SDR receiver": "Lokal RTL-SDR-mottagare", + "LOCAL RTL-SDR": "LOKAL RTL-SDR", + "Chrome/Edge · WebUSB · one local tuner shared by FM and the Local ADS-B layer.": "Chrome/Edge · WebUSB · en lokal tuner som delas av FM-radion och lagret ”Lokal ADS-B”.", + "Use your location for faster ADS-B position decoding": "Använd egen plats för snabbare avkodning av ADS-B-positioner", + "CHANGE DEVICE": "BYT ENHET", + "Choose a different RTL-SDR or receiver channel": "Välj en annan RTL-SDR eller mottagarkanal", + "RTL-SDR receiver mode": "RTL-SDR-mottagarläge", + "GAIN": "FÖRST.", + "RTL-SDR tuner gain for the current mode": "RTL-SDR-tunerns förstärkning i aktuellt läge", + "AUTO": "AUTO", + "MSG/S": "MEDD./S", + "HEARD": "HÖRDA", + "POSITIONED": "MED POS.", + "FM MHZ": "FM MHZ", + "TUNE": "STÄLL IN", + "Seek broadcast FM stations": "Sök FM-radiostationer", + "◀ SEEK": "◀ SÖK", + "SEEK ▶": "SÖK ▶", + "SDR VOL": "SDR-VOL.", + "Local SDR volume": "Volym för lokal SDR", + "Cyber": "Cyber", + "Sonar": "Sonar", + "Cyber sonar contact scanner": "Kontaktskanner med cybersonar", + "Rings": "Ringar", + "Sonar ring count": "Antal sonarringar", + "Range": "Räckvidd", + "Sonar ring range": "Sonarringarnas räckvidd", + "Power": "Styrka", + "Sonar intensity": "Sonarintensitet", + "Opacity": "Opacitet", + "Cyber contact and label opacity": "Opacitet för cyberkontakter och etiketter", + "Minimum opacity for map dots, icons, models, brackets, and labels between sonar passes": "Lägsta opacitet för kartpunkter, ikoner, modeller, ramar och etiketter mellan sonarsvepen", + "Sector": "Sektor", + "Sonar sector width": "Sonarsektorns bredd", + "Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel": "Rita på världen – klicka ut hörnpunkter, dubbelklicka eller tryck på Enter för att avsluta, Esc för att avbryta", + "Shape to draw": "Form att rita", + "Area": "Område", + "Line": "Linje", + "Pin": "Nål", + "Primary": "Primär", + "Amber": "Bärnsten", + "Cyan": "Cyan", + "Green": "Grön", + "Red": "Röd", + "Clear": "Rensa", + "Remove every mark from the board": "Ta bort alla markeringar från ritytan", + "Show your own GeoJSON, KML, KMZ or GPX files on the globe (or drop them onto it). File bytes stay on this device; overlays can appear in map screenshots shared with voice.": "Visa egna GeoJSON-, KML-, KMZ- eller GPX-filer på globen (eller släpp dem på den). Filinnehållet stannar på den här enheten; överlägg kan synas i kartskärmbilder som delas med röststyrningen.", + "Import": "Importera", + "Choose GeoJSON, KML, KMZ or GPX files": "Välj GeoJSON-, KML-, KMZ- eller GPX-filer", + "Imported files": "Importerade filer", + "ACQUIRING FRAME": "HÄMTAR BILDRUTA", + "STREET LEVEL": "GATUNIVÅ", + "Turn Street Level on or off": "Slå på eller av ”Gatunivå”", + "Show the street-level view full screen (Esc shrinks it)": "Visa gatunivåvyn i helskärm (Esc förminskar den)", + "Image fit": "Bildanpassning", + "FIT": "ANPASSA", + "Fit: show the whole photo, with dark bars where its shape differs from the frame": "Anpassa: visa hela fotot, med mörka fält där dess form skiljer sig från ramen", + "FILL": "FYLL", + "Fill: fill the frame edge to edge, cropping the photo": "Fyll: fyll ramen kant till kant och beskär fotot", + "Close the image and deselect it on the map": "Stäng bilden och avmarkera den på kartan", + "Close the street-level image": "Stäng gatunivåbilden", + "Street-level viewer": "Gatunivåvisare", + "Open this image on the provider's site": "Öppna bilden på leverantörens webbplats", + "Imagery providers": "Bildleverantörer", + "PROVIDERS": "LEVERANTÖRER", + "Imagery filters": "Bildfilter", + "IMAGERY": "BILDER", + "Panorama filter": "Panoramafilter", + "ALL": "ALLA", + "FLAT": "PLATTA", + "SINCE": "SEDAN", + "Toggle straight-down and tilted map views": "Växla mellan vy rakt ovanifrån och snedvy", + "Reset map to north up": "Återställ kartan med norr uppåt", + "Reset map bearing to north": "Återställ kartans bäring till norr", + "Cyclone request timed out": "Tidsgränsen för cyklonförfrågan överskreds", + "Malformed cyclone snapshot": "Felformaterad ögonblicksbild av cykloner", + "STALE · Roads: {source} · Flow: cached TomTom tiles": "INAKTUELL · Vägar: {source} · Trafikflöde: cachade TomTom-kartrutor", + "routing busy — too many routes at once": "ruttplaneringen är upptagen – för många rutter samtidigt", + "route proxy error": "fel i ruttproxyn", + "{lower} to {upper}": "{lower} till {upper}", + "Up to {upper}": "Upp till {upper}", + "From {lower}": "Från {lower}", + "Informational only · not for flight planning": "Endast som information · inte för flygplanering", + "Unnamed zone": "Namnlös zon", + "Restricted airspace source unavailable": "Källan för restriktionsområden är otillgänglig", + "Prohibited (P)": "Förbjudet område (P)", + "Restricted (R)": "Restriktionsområde (R)", + "FAA prohibited and restricted special-use airspace only, not the UAS Facility Map or UAS flight restrictions. Informational only — not a substitute for official UAS flight-planning tools (B4UFLY / LAANC).": "Endast FAA:s förbjudna områden och restriktionsområden (luftrum för särskild användning), inte UAS Facility Map eller UAS-flygrestriktioner. Endast som information – ersätter inte officiella verktyg för UAS-flygplanering (B4UFLY / LAANC).", + "Prohibited": "Förbjudet område", + "Restricted": "Restriktionsområde", + "Prohibited & Restricted Airspace": "Förbjudna områden och restriktionsområden", + "scene|PLAY": "SPELA UPP", + "AUTHORING": "REDIGERING", + "Create and edit scenes": "Skapa och redigera scener", + "SCENE": "SCEN", + "Scene": "Scen", + "NEW SCENE": "NY SCEN", + "Start a new, empty scene": "Börja en ny, tom scen", + "DELETE": "RADERA", + "Delete this scene and all of its shots": "Radera scenen och alla dess tagningar", + "SHOTS": "TAGNINGAR", + "+ CAPTURE SHOT": "+ SPARA TAGNING", + "Add the current view, style and layers as the next shot": "Lägg till aktuell vy, stil och lager som nästa tagning", + "Replace the selected shot with the current view": "Ersätt vald tagning med aktuell vy", + "Click a shot to select it, double-click to rename.": "Klicka på en tagning för att välja den, dubbelklicka för att byta namn.", + "Play from this scene through the rest of the project": "Spela upp från den här scenen genom resten av projektet", + "Stop playback (Esc)": "Stoppa uppspelningen (Esc)", + "Play the shot after the selected one, moving on to the next scene when needed": "Spela upp tagningen efter den valda och gå vidare till nästa scen vid behov", + "SAVE & SHARE": "SPARA & DELA", + "Download every scene as a JSON file you can import later": "Ladda ned alla scener som en JSON-fil som kan importeras senare", + "Load scenes from an exported JSON file": "Läs in scener från en exporterad JSON-fil", + "Download the timing log of the last play": "Ladda ned tidsloggen från senaste uppspelningen", + "Edit anchors, data packs and shot details": "Redigera ankare, datapaket och tagningsdetaljer", + "Download this scene as JSON or an asset bundle": "Ladda ned scenen som JSON eller som resurspaket", + "Left panels": "Vänstra paneler", + "Right panels": "Högra paneler", + "CONFIRMED": "BEKRÄFTAD", + "PAD / RTLS": "RAMP / RTLS", + "fire confidence filter|All": "Alla", + "fire confidence filter|Nominal+": "Normal+", + "fire confidence filter|High": "Hög", + "Every detection — many of the strongest fires are rated low confidence": "Alla detekteringar – många av de kraftigaste bränderna har låg tillförlitlighet", + "Nominal and high confidence detections": "Detekteringar med normal och hög tillförlitlighet", + "High confidence detections only": "Bara detekteringar med hög tillförlitlighet", + "Fire intensity (FRP)": "Brandintensitet (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Tillförlitlighet", + "Minimum fire radiative power, megawatts": "Lägsta strålningseffekt från branden, megawatt", + "fire intensity filter|All": "Alla", + "Detection confidence": "Detekteringens tillförlitlighet", + "{shown} of {total} shown": "{shown} av {total} visas", + "Name of {name}": "Namn på {name}", + "Change the colour of {name}": "Ändra färg på {name}", + "Colour of {name}": "Färg på {name}", + "Colour of the next shape": "Färg på nästa form", + "Drawn shapes": "Ritade former", + "Audio": "Ljud", + "UTILITIES": "VERKTYG", + "Exit cockpit to fly to a drawing": "Lämna cockpiten för att flyga till en ritning", + "Directions, drawing and your own files": "Vägbeskrivning, ritning och egna filer", + "Voice place search (needs an Overpass server)": "Platssökning med röst (kräver en Overpass-server)", + "OSM Places": "OSM-platser", + "state or province": "delstat eller provins", + "county or district": "län eller distrikt", + "neighbourhood": "stadsdel", + "natural region": "naturområde", + "Which {place}?": "Vilken plats menas med {place}?", + "Which place?": "Vilken plats?", + "Not drawn: {parts}": "Inte ritat: {parts}", + "approximate": "ungefärlig", + "{pct}% cloud": "{pct} % molnighet", + "Over {area}": "Över {area}", + "{count} of {total} acquisitions match": { + "one": "{count} av {total} upptagning matchar", + "other": "{count} av {total} upptagningar matchar" + }, + "{n} acquisitions searched": { + "one": "{n} upptagning genomsökt", + "other": "{n} upptagningar genomsökta" + }, + "No matching imagery": "Inga matchande bilder", + "Unknown number of {kind}": "Okänt antal {kind}", + "About {count} {kind}": "Cirka {count} {kind}", + "At least {count} {kind}": "Minst {count} {kind}", + "{count} {kind}": "{count} {kind}", + "In {area}": "I {area}", + "Area around {place}": "Område runt {place}", + "Area around landmark": "Område runt landmärke", + "Outline {place}": "Rita konturen av {place}", + "Outline area": "Rita områdets kontur", + "Find {place}": "Hitta {place}", + "Find area": "Hitta område", + "Find recent imagery": "Hitta senaste bilder", + "Find {what}": "Hitta {what}", + "Find places": "Hitta platser", + "Drawing outline": "Ritar kontur", + "Searching imagery catalog": "Söker i bildkatalogen", + "Loading imagery": "Laddar bilder", + "Searching OpenStreetMap": "Söker i OpenStreetMap", + "voice area|Area": "Område", + "buffer sized to the place": "buffert anpassad efter platsen", + "enclosing mapped area": "omslutande kartlagt område", + "the map was cleared while looking it up": "kartan rensades under sökningen", + "OpenStreetMap coverage varies by place": "OpenStreetMap har olika täckning på olika platser", + "the catalog covers the last 30 days": "katalogen omfattar de senaste 30 dagarna", + "there are more": "det finns fler", + "{radius} m around the landmark": "{radius} m runt landmärket", + "{count} parts": { + "one": "{count} del", + "other": "{count} delar" + }, + "{drawn} of {total} parts drawn": { + "one": "{drawn} av {total} del ritad", + "other": "{drawn} av {total} delar ritade" + }, + "{count} duplicate mappings merged": { + "one": "{count} dubbelkartlagt objekt sammanslaget", + "other": "{count} dubbelkartlagda objekt sammanslagna" + }, + "listed the first {limit}": "visar de första {limit}", + "counted over the bounding box of {area}, not its outline": "räknat inom den omslutande rektangeln för {area}, inte dess kontur", + "Search cancelled": "Sökningen avbröts", + "Sea Marks": "Sjömärken", + "Ski Pistes": "Skidbackar", + "Fly to Tokyo": "Flyg till Tokyo", + "Show live flights": "Visa liveflyg", + "Reset to globe view": "Återgå till globvy", + "Show earthquakes": "Visa jordbävningar", + "AI chat": "AI-chatt", + "GEV CHAT": "GEV-CHATT", + "Ask about the map. Tell it where to go.": "Fråga om kartan. Säg vart den ska flyga.", + "Close chat": "Stäng chatten", + "Conversation": "Konversation", + "Explore with a question": "Utforska med en fråga", + "Find a place, explore live data, or change your view.": "Hitta en plats, utforska livedata eller ändra vyn.", + "Attachments": "Bilagor", + "Remove attachment": "Ta bort bilaga", + "Attach a map layer": "Bifoga ett kartlager", + "Choose a layer…": "Välj ett lager …", + "Attach a file": "Bifoga en fil", + "TXT, Markdown, CSV, JSON or GeoJSON · up to 10 KB in total. Attachments are sent to your AI provider with your message.": "TXT, Markdown, CSV, JSON eller GeoJSON · högst 10 kB totalt. Bilagor skickas till vald AI-leverantör tillsammans med meddelandet.", + "Latest AI response": "Senaste AI-svaret", + "Expand conversation": "Fäll ut konversationen", + "Command suggestions": "Kommandoförslag", + "Expand chat": "Fäll ut chatten", + "Add attachment": "Lägg till bilaga", + "Message GEV": "Meddelande till GEV", + "Ask GEV anything about the map…": "Fråga GEV vad som helst om kartan …", + "Stop reply": "Stoppa svaret", + "Send message": "Skicka meddelande", + "Panel position": "Panelposition", + "Move above the dock": "Flytta ovanför dockan", + "Dock to the right": "Docka till höger", + "Expand panel": "Fäll ut panelen", + "Minimize panel": "Minimera panelen", + "You can attach up to four items.": "Högst fyra objekt kan bifogas.", + "Attachments are too large together. Use smaller files.": "Bilagorna är tillsammans för stora. Använd mindre filer.", + "Choose a TXT, Markdown, CSV, JSON or GeoJSON file.": "Välj en TXT-, Markdown-, CSV-, JSON- eller GeoJSON-fil.", + "Each file must be 10 KB or smaller.": "Varje fil får vara högst 10 kB.", + "Choose a non-empty UTF-8 text file.": "Välj en UTF-8-textfil som inte är tom.", + "Connecting…": "Ansluter …", + "Thinking…": "Tänker …", + "Working on the map…": "Arbetar med kartan …", + "Silent chat · microphone off": "Tyst chatt · mikrofon av", + "Reply interrupted. You can try again.": "Svaret avbröts. Försök igen.", + "Could not connect. Try again.": "Kunde inte ansluta. Försök igen.", + "Session ended. Send a message to start a new conversation.": "Sessionen avslutades. Skicka ett meddelande för att starta en ny konversation.", + "Connection timed out. Try again.": "Tidsgränsen för anslutningen överskreds. Försök igen.", + "Chat closed.": "Chatten stängdes.", + "Connection stopped.": "Anslutningen avbröts.", + "Message and attachments are too large. Remove an attachment or shorten your message.": "Meddelandet och bilagorna är för stora. Ta bort en bilaga eller korta ner meddelandet.", + "Message was not sent. Try again.": "Meddelandet skickades inte. Försök igen.", + "Reply stopped.": "Svaret stoppades.", + "Suggestions": "Förslag", + "Recent": "Senaste", + "Quick commands": "Snabbkommandon", + "Open AI chat": "Öppna AI-chatten", + "CHAT": "CHATT", + "Back to chat": "Tillbaka till chatten", + "Back to voice": "Tillbaka till röststyrning", + "Latest voice exchange": "Senaste röstdialogen", + "Try saying": "Prova att säga", + "What am I looking at?": "Vad tittar jag på?", + "AI Provider": "AI-leverantör", + "The selected AI provider is shared by voice and chat.": "Vald AI-leverantör används för både röststyrning och chatt.", + "Show quick commands": "Visa snabbkommandon", + "Chat replies are silent. No microphone access is needed.": "Chattsvar är tysta. Ingen mikrofonåtkomst behövs.", + "Chat": "Chatt", + "Attach layer": "Bifoga lager", + "Typed chat uses your OpenAI API key. ChatGPT sign-in covers voice only.": "Textchatten använder den egna OpenAI-API-nyckeln. Inloggning med ChatGPT gäller bara röststyrning.", + "Typed chat is unavailable while GEV_PREFER_CODEX_OAUTH requires ChatGPT OAuth.": "Textchatten är otillgänglig så länge GEV_PREFER_CODEX_OAUTH kräver ChatGPT OAuth.", + "That request needed too many steps. Changes already made to the map stay in place.": "Förfrågan krävde för många steg. Ändringar som redan gjorts på kartan ligger kvar.", + "That request needed too many steps. Try a simpler request.": "Förfrågan krävde för många steg. Prova en enklare förfrågan.", + "The provider rejected the API key. Check it in Provider Settings.": "Leverantören avvisade API-nyckeln. Kontrollera den i Leverantörsinställningar.", + "Too many chat requests. Try again in {seconds} s.": "För många chattförfrågningar. Försök igen om {seconds} s.", + "The provider refused access for this key. Check the key's project, region or permissions.": "Leverantören nekade åtkomst för den här nyckeln. Kontrollera nyckelns projekt, region eller behörigheter.", + "The provider account is out of credit. Check its billing, or choose another AI Provider in Settings.": "Leverantörskontot har slut på krediter. Kontrollera faktureringen eller välj en annan AI-leverantör i Inställningar.", + "Precise conflict points in the News layer, beside GDELT’s articles": "Exakta konfliktpunkter i lagret ”Nyheter”, utöver GDELT:s artiklar", + "Local in the News layer: the day’s news from the outlets of the country in view": "”Lokalt” i lagret ”Nyheter”: dagens nyheter från medierna i det land som visas", + "{provider} limit reached for today": "Dagens gräns för {provider} är nådd", + "News resumes tomorrow, or with a paid plan": "Nyheterna återkommer i morgon, eller med ett betalabonnemang", + "{provider} refused its key": "{provider} avvisade nyckeln", + "Check {envVars} in Provider Settings": "Kontrollera {envVars} i Leverantörsinställningar", + "{provider} key required": "{provider}-nyckel krävs", + "Last 24 h of news. Each report links to its publisher; places and categories are read from the text and data automatically and can be wrong. Side labels, where shown, describe the source, not whether a report is true.": "Nyheter från de senaste 24 h. Varje rapport länkar till sin utgivare; platser och kategorier läses automatiskt ut ur text och data och kan vara fel. Etiketter för sidtillhörighet, där de visas, beskriver källan, inte om en rapport är sann.", + "via {source}": "via {source}", + "Worldwide": "Hela världen", + "{region}, {country}": "{region}, {country}", + "Economy": "Ekonomi", + "News key required": "Nyhetsnyckel krävs", + "Before 1900": "Före 1900", + "1900–1945": "1900–1945", + "After 1945": "Efter 1945", + "Date unknown": "Datum okänt", + "Position: centre of the protected area": "Position: mitten av det skyddade området", + "Position approximate (within about {km} km)": "Ungefärlig position (inom cirka {km} km)", + "Position surveyed (within about {m} m)": "Inmätt position (inom cirka {m} m)", + "Shipwreck": "Skeppsvrak", + "Fatalities: {count}": "Omkomna: {count}", + "Survivors: {count}": "Överlevande: {count}", + "Vessel: {value}": "Fartyg: {value}", + "What happened: {value}": "Vad som hände: {value}", + "Depth: {m} m ({quality})": "Djup: {m} m ({quality})", + "Depth: {m} m": "Djup: {m} m", + "Notes: {value}": "Anmärkningar: {value}", + "Cargo or finds: {value}": "Last eller fynd: {value}", + "Protected wreck": "Skyddat vrak", + "Source: {value}": "Källa: {value}", + "Click the card to open EMODnet ↗": "Klicka på kortet för att öppna EMODnet ↗", + "Click the card to open the article ↗": "Klicka på kortet för att öppna artikeln ↗", + "Click the card to open the source ↗": "Klicka på kortet för att öppna källan ↗", + "All years": "Alla år", + "Period": "Period", + "From": "Från", + "First year of the shipwreck period": "Första året i perioden för skeppsvrak", + "To": "Till", + "Last year of the shipwreck period": "Sista året i perioden för skeppsvrak", + "Wikidata unavailable; showing EMODnet only": "Wikidata är otillgängligt; visar bara EMODnet", + "EMODnet unavailable; showing Wikidata only": "EMODnet är otillgängligt; visar bara Wikidata", + "Shipwrecks unavailable": "Skeppsvrak otillgängliga", + "{count} wrecks": { + "one": "{count} vrak", + "other": "{count} vrak" + }, + "EMODnet (European waters) · Wikidata (worldwide)": "EMODnet (europeiska vatten) · Wikidata (hela världen)", + "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.": "Kulturhistoriska vrak i Europa kommer från EMODnet Human Activities; kända vrak i hela världen kommer från Wikidata. Färgen visar när fartyget sjönk; markörens storlek ökar med antalet omkomna. Klicka på en markör för att visa dess kort.", + "Shipwrecks": "Skeppsvrak", + "News from {area}": "Nyheter från {area}", + "Within {km} km of the view centre": "Inom {km} km från vyns mitt", + "SIGHTING": "OBSERVATION", + "Show {name} sighting": "Visa observationen av {name}", + "seen {age} ago": "sedd för {age} sedan", + "observed {age} ago": "observerad för {age} sedan", + "Be Whale Wise": "Be Whale Wise", + "Reported by {name} ({entity})": "Rapporterad av {name} ({entity})", + "Reported by {name}": "Rapporterad av {name}", + "Salish Sea": "Salish Sea", + "Birds": "Fåglar", + "Mammals": "Däggdjur", + "Reptiles": "Kräldjur", + "Amphibians": "Groddjur", + "Ray-finned fishes": "Strålfeniga fiskar", + "Insects": "Insekter", + "Arachnids": "Spindeldjur", + "Plants": "Växter", + "Fungi": "Svampar", + "Other animals": "Övriga djur", + "Other or unknown": "Övrigt eller okänt", + "{label} {detail} — click to retry": "{label}: {detail} – klicka för att försöka igen", + "Wildlife sightings source unavailable": "Källan för artobservationer är otillgänglig", + "Show only community-confirmed (research-grade) sightings": "Visa bara observationer som bekräftats av andra användare (forskningskvalitet)", + "No recent sightings in view": "Inga aktuella observationer i vyn", + "Acartia feed unavailable": "Acartia-flödet är otillgängligt", + "Show all taxa": "Visa alla artgrupper", + "BIRDS": "FÅGLAR", + "Show only birds": "Visa bara fåglar", + "MAMMALS": "DÄGGDJUR", + "Show only mammals": "Visa bara däggdjur", + "WHALES": "VALAR", + "Show only whales and dolphins (7 days)": "Visa bara valar och delfiner (7 dygn)", + "INSECTS": "INSEKTER", + "Show only insects": "Visa bara insekter", + "PLANTS": "VÄXTER", + "Show only plants": "Visa bara växter", + "All rights reserved": "Alla rättigheter förbehållna", + "Cetacean": "Valdjur", + "Wildlife Sightings": "Artobservationer", + "Exit cockpit to fly to a sighting": "Lämna cockpiten för att flyga till en observation", + "Exit cockpit to fly to a sighting area": "Lämna cockpiten för att flyga till ett observationsområde", + "Wildlife": "Djur och växter", + "Zoom in for local sightings": "Zooma in för lokala observationer", + "Loading photo...": "Laddar foto …", + "Photo unavailable": "Foto otillgängligt", + "Location obscured to a 0.2° cell": "Platsen är avrundad till en ruta på 0,2°", + "Photo of {name}": "Foto av {name}", + "Acartia": "Acartia", + "CC BY 4.0": "CC BY 4.0", + "{count} sightings in view": "Observationer i vyn: {count}", + "Close sighting": "Stäng observationen", + "Wildlife observed within 48 h (whales 7 d). Color and badge glyph show the taxon group. Sensitive species are shown only as 0.2° cells; Salish Sea marine mammal reports via Acartia are rounded to 0.01°. Not a census.": "Djur, växter och svampar observerade de senaste 48 h (valar 7 d). Färg och symbol visar artgruppen. Känsliga arter visas bara som rutor på 0,2°; rapporter om marina däggdjur i Salish Sea via Acartia avrundas till 0,01°. Ingen inventering.", + "Open {name} on iNaturalist": "Öppna {name} på iNaturalist", + "Open {name} on Acartia": "Öppna {name} på Acartia", + "iNaturalist": "iNaturalist", + "iNaturalist contributors · licences per record": "iNaturalists bidragsgivare · licens per observation", + "iNaturalist contributors · Acartia {count} (Salish Sea) · licences per record": "iNaturalists bidragsgivare · Acartia {count} (Salish Sea) · licens per observation", + "{days}d": "{days} d", + "All markers": "Alla markörer", + "Show markers of every age": "Visa markörer oavsett ålder", + "Maximum event age: {days}d; undated markers are kept": "Högsta ålder på händelser: {days} d; markörer utan datum behålls", + "event age filter|All": "Alla", + "Event age": "Händelsernas ålder", + "Event age: {days}d · undated kept": "Händelsernas ålder: {days} d · utan datum behålls", + "Close camera preview": "Stäng förhandsvisningen av kameran", + "Refresh map still": "Uppdatera stillbilden på kartan", + "The map still uses a separate request and may show a different moment.": "Stillbilden på kartan hämtas separat och kan visa ett annat ögonblick.", + "Video plays from Québec 511, which sees your IP address.": "Videon spelas upp från Québec 511, som ser din IP-adress.", + "STATIONS": "STATIONER", + "Crewed stations and the craft that visit them": "Bemannade rymdstationer och farkosterna som besöker dem", + "IMAGING": "AVBILDNING", + "Earth observation: optical, radar, weather and science": "Jordobservation: optisk, radar, väder och forskning", + "TELECOM": "TELEKOM", + "Communications and navigation satellites, Starlink excepted": "Kommunikations- och navigeringssatelliter, utom Starlink", + "STARLINK": "STARLINK", + "The Starlink constellation (thousands of extra points)": "Starlink-konstellationen (tusentals extra punkter)", + "PROBES": "SONDER", + "Space science, technology demonstration and other payloads": "Rymdforskning, teknikdemonstration och övriga nyttolaster", + "DEBRIS": "SKROT", + "Rocket bodies, fragments and dead satellites": "Raketsteg, fragment och döda satelliter", + "Classes may lag: SATCAT is a cached copy": "Klasserna kan släpa efter: SATCAT är en sparad kopia", + "Classes may lag: SATCAT unavailable": "Klasserna kan släpa efter: SATCAT otillgänglig", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTraks SATCAT anger varje objekts typ och status. Utan en aktuell kopia vilar klasserna på UCS-databasen, namn och CelesTraks grupper, så en satellit som har slutat fungera sedan dess kan fortfarande visas som aktiv.", + "STARLINK ···": "STARLINK ···", + "STARLINK ✕": "STARLINK ✕", + "LOOK STBD {bearing}°": "BLICK STYRB. {bearing}°", + "LOOK PORT {bearing}°": "BLICK BABORD {bearing}°", + "LOOK ASTERN": "BLICK AKTERUT", + "UP {angle}°": "UPP {angle}°", + "DOWN {angle}°": "NED {angle}°", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "BLICK {elevation}", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ÖGONHÖJD {height}", + "LIVE AIS": "LIVE-AIS", + "CONTACT NAVIGATION OFF": "KONTAKTNAVIGERING AV", + "THE BRIDGE STAYS WITH THE SELECTED VESSEL": "BRYGGAN STANNAR PÅ DET VALDA FARTYGET", + "{range} NM": "{range} NM", + "AIS DESTINATION · CREW REPORTED": "AIS-DESTINATION · ANGIVEN AV BESÄTTNINGEN", + "{label} · NADIR VIEW": "{label} · NADIRVY", + "SITES STANDBY": "PLATSER I VÄNTLÄGE", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "AKTIVERA ”KARTLAGDA ANLÄGGNINGAR” FÖR PLATSPINGAR", + "Unnamed site": "Namnlös plats", + "OFF-NADIR {angle}°": "OFF-NADIR {angle}°", + "Release the camera from {name}": "Släpp kameran från {name}", + "Keep the camera on {name}": "Håll kameran på {name}", + "RETURN · {name}": "TILLBAKA · {name}", + "PREVIOUS SATELLITE": "FÖREGÅENDE SATELLIT", + "Return to {name}": "Tillbaka till {name}", + "SATELLITE": "SATELLIT", + "Ride {name}": "Åk med {name}", + "FWD": "FRAM", + "AFT": "AKTER", + "RIGHT": "HÖGER", + "LEFT": "VÄNSTER", + "{direction} {angle}°": "{direction} {angle}°", + "OFF-NADIR {angle}° · {offset}": "OFF-NADIR {angle}° · {offset}", + "BRIDGE": "BRYGGA", + "{label} · HEADING {heading}°": "{label} · STYRD KURS {heading}°", + "{label} · NO HEADING": "{label} · INGEN KURS", + "Place names": "Ortnamn", + "Ship bridge view": "Fartygets bryggvy", + "Satellite orbital view": "Satellitens omloppsvy", + "Exit bridge view": "Lämna bryggvyn", + "Exit orbital view": "Lämna omloppsvyn", + "EXIT BRIDGE": "LÄMNA BRYGGAN", + "EXIT ORBIT": "LÄMNA OMLOPPET", + "BRIDGE WINDOW · FWD": "BRYGGFÖNSTER · FRAM", + "COURSE HOLD · AIS": "KURS ÖVER GRUND · AIS", + "HEADING HOLD · AIS": "STYRD KURS · AIS", + "NO HEADING · AIS": "INGEN KURS · AIS", + "SPEED OVER GROUND · KTS": "FART ÖVER GRUND · KN", + "BRIDGE VIEW": "BRYGGVY", + "Look ahead: turn the bridge view back to the bow": "Titta framåt: vrid bryggvyn tillbaka mot fören", + "Look ahead (Home or 0)": "Titta framåt (Home eller 0)", + "LOOK AHEAD": "TITTA FRAMÅT", + "Current vessel heading": "Fartygets aktuella kurs", + "SPEED OVER GROUND": "FART ÖVER GRUND", + "COURSE OVER GROUND": "KURS ÖVER GRUND", + "ARROWS LOOK": "PILAR VRIDER VYN", + "HOME RESET": "HOME ÅTERSTÄLLER", + "+/− ZOOM": "+/− ZOOMAR", + "ARROWS TILT": "PILAR LUTAR", + "HOME NADIR": "HOME NADIR", + "ZOOM {zoom}×": "ZOOM {zoom}×", + "SENSOR SWATH ≈ {value} KM": "SENSORSTRÅK ≈ {value} KM", + "SENSOR SWATH {value} KM": "SENSORSTRÅK {value} KM", + "SWATH ≈ {value} KM": "STRÅK ≈ {value} KM", + "SWATH {value} KM": "STRÅK {value} KM", + "{division}, {country}": "{division}, {country}", + "{region} · near {place}": "{region} · nära {place}", + "NORAD {id}": "NORAD {id}", + "EPOCH {time}": "EPOK {time}", + "<1 H OLD": "<1 H GAMMAL", + "{value} H OLD": "{value} H GAMMAL", + "{value} D OLD": "{value} D GAMMAL", + "FOCUS · {name}": "FOKUS · {name}", + "Orbital view": "Omloppsvy", + "CURRENTLY OVER": "JUST NU ÖVER", + "ORBITAL VIEW · OFF-NADIR": "OMLOPPSVY · OFF-NADIR", + "ORBITAL VIEW · NADIR": "OMLOPPSVY · NADIR", + "Hide the two-line element set": "Dölj banelementen (TLE)", + "Show the two-line element set": "Visa banelementen (TLE)", + "TLE": "TLE", + "Two-line element set": "Banelement (TLE)", + "ORBITAL VELOCITY": "BANHASTIGHET", + "SUB-SATELLITE POINT": "PUNKT UNDER SATELLITEN", + "TRACK UP": "BANAN UPPÅT", + "NORTH UP": "NORR UPPÅT", + "Return the view to nadir": "Rikta vyn mot nadir igen", + "Return to nadir (Home or 0)": "Tillbaka till nadir (Home eller 0)", + "NADIR": "NADIR", + "Zoom out (−)": "Zooma ut (−)", + "Zoom in (+)": "Zooma in (+)", + "Zoom to the imaging sensor swath": "Zooma till sensorns avbildningsstråk", + "SENSOR": "SENSOR", + "RINGS {spacing} NM": "RINGAR {spacing} NM", + "{count} CONTACTS": { + "one": "{count} KONTAKT", + "other": "{count} KONTAKTER" + }, + "Radar range {range}. Select for the next range.": "Radarräckvidd {range}. Välj för nästa räckvidd.", + "Bridge radar": "Bryggradar", + "Reported voyage": "Angiven resa", + "REPORTED VOYAGE": "ANGIVEN RESA", + "1 min/s": "1 min/s", + "Photo © {photographer}": "Foto © {photographer}", + "The shared flight isn't reporting now — showing where it was last seen": "Den delade flygningen rapporterar inte just nu – visar senast kända position", + "The shared flight isn't reporting now — showing where it was at {time}": "Den delade flygningen rapporterar inte just nu – visar positionen från {time}", + "The shared military flight isn't reporting now — showing where it was last seen": "Den delade militärflygningen rapporterar inte just nu – visar senast kända position", + "The shared military flight isn't reporting now — showing where it was at {time}": "Den delade militärflygningen rapporterar inte just nu – visar positionen från {time}", + "The shared vessel isn't reporting now — showing where it was last seen": "Det delade fartyget rapporterar inte just nu – visar senast kända position", + "The shared vessel isn't reporting now — showing where it was at {time}": "Det delade fartyget rapporterar inte just nu – visar positionen från {time}", + "The shared satellite isn't reporting now — showing where it was last seen": "Den delade satelliten rapporterar inte just nu – visar senast kända position", + "The shared satellite isn't reporting now — showing where it was at {time}": "Den delade satelliten rapporterar inte just nu – visar positionen från {time}", + "ICAO: {id}": "ICAO: {id}", + "NORAD: {id}": "NORAD: {id}", + "MMSI: {id}": "MMSI: {id}", + "Vessel UUID: {id}": "Fartygs-UUID: {id}", + "1 h/s": "1 h/s", + "1 d/s": "1 d/s", + "1 wk/s": "1 v/s", + "World time": "Världstid", + "Play speed": "Uppspelningshastighet", + "Scroll or pinch to zoom, drag to move along · double-click to see it all": "Scrolla eller nyp för att zooma, dra för att flytta · dubbelklicka för att se allt", + "Live": "Live", + "Back to live": "Tillbaka till live" +} diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 08d6417df..317c7da03 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -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}" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 503a6716e..54a106fe1 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -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}" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index c2a279684..93170fa7f 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -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}" } diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 0e5988d11..ed788cebc 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -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}" ] } diff --git a/src/layers/awareness/history.js b/src/layers/awareness/history.js index e8e05505a..b7aa50e22 100644 --- a/src/layers/awareness/history.js +++ b/src/layers/awareness/history.js @@ -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 ); } diff --git a/src/layers/awareness/panel.js b/src/layers/awareness/panel.js index 182baa6e1..bd5078499 100644 --- a/src/layers/awareness/panel.js +++ b/src/layers/awareness/panel.js @@ -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 { diff --git a/src/layers/awareness/subject.js b/src/layers/awareness/subject.js index 050d144c2..e8bb26a8c 100644 --- a/src/layers/awareness/subject.js +++ b/src/layers/awareness/subject.js @@ -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), diff --git a/src/layers/flights/clockFollow.js b/src/layers/flights/clockFollow.js new file mode 100644 index 000000000..5413d99c4 --- /dev/null +++ b/src/layers/flights/clockFollow.js @@ -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 }; +} diff --git a/src/layers/flights/clockFollow.test.mjs b/src/layers/flights/clockFollow.test.mjs new file mode 100644 index 000000000..a997c4ade --- /dev/null +++ b/src/layers/flights/clockFollow.test.mjs @@ -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); +}); diff --git a/src/layers/flights/index.js b/src/layers/flights/index.js index f778c98b1..0261299dc 100644 --- a/src/layers/flights/index.js +++ b/src/layers/flights/index.js @@ -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, diff --git a/src/layers/flights/ingestion.js b/src/layers/flights/ingestion.js index 0e537a66f..5558c7b6f 100644 --- a/src/layers/flights/ingestion.js +++ b/src/layers/flights/ingestion.js @@ -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} + */ + 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 }; } diff --git a/src/layers/flights/lifecycle.js b/src/layers/flights/lifecycle.js index 87e3e356a..adfb11cc4 100644 --- a/src/layers/flights/lifecycle.js +++ b/src/layers/flights/lifecycle.js @@ -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; diff --git a/src/layers/flights/motion.js b/src/layers/flights/motion.js index ecfb26d7f..637da2bb6 100644 --- a/src/layers/flights/motion.js +++ b/src/layers/flights/motion.js @@ -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( diff --git a/src/layers/flights/policy.js b/src/layers/flights/policy.js index 2e450d6d5..58d6a86ea 100644 --- a/src/layers/flights/policy.js +++ b/src/layers/flights/policy.js @@ -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 diff --git a/src/layers/flights/queries.js b/src/layers/flights/queries.js index a696238dc..3c00b1824 100644 --- a/src/layers/flights/queries.js +++ b/src/layers/flights/queries.js @@ -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() }), }; }, }; diff --git a/src/layers/flights/records.js b/src/layers/flights/records.js index 2636ed47a..63cc6f5f5 100644 --- a/src/layers/flights/records.js +++ b/src/layers/flights/records.js @@ -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); diff --git a/src/layers/flights/rendering.js b/src/layers/flights/rendering.js index dfc46a553..bf8dbb5ee 100644 --- a/src/layers/flights/rendering.js +++ b/src/layers/flights/rendering.js @@ -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, ); diff --git a/src/layers/flights/snapshotRenderer.js b/src/layers/flights/snapshotRenderer.js index 1ef928562..5219dcb09 100644 --- a/src/layers/flights/snapshotRenderer.js +++ b/src/layers/flights/snapshotRenderer.js @@ -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 = ''; diff --git a/src/layers/flights/state.js b/src/layers/flights/state.js index 4b58f54fb..fd77dc113 100644 --- a/src/layers/flights/state.js +++ b/src/layers/flights/state.js @@ -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 */ diff --git a/src/layers/flights/tracking.js b/src/layers/flights/tracking.js index a4faa8329..f01ccc45a 100644 --- a/src/layers/flights/tracking.js +++ b/src/layers/flights/tracking.js @@ -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, diff --git a/src/layers/military/clockFollow.js b/src/layers/military/clockFollow.js new file mode 100644 index 000000000..f120a54ce --- /dev/null +++ b/src/layers/military/clockFollow.js @@ -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 }; +} diff --git a/src/layers/military/clockFollow.test.mjs b/src/layers/military/clockFollow.test.mjs new file mode 100644 index 000000000..2c9e335a9 --- /dev/null +++ b/src/layers/military/clockFollow.test.mjs @@ -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 }); +}); diff --git a/src/layers/military/index.js b/src/layers/military/index.js index 53a1a25b4..acc975358 100644 --- a/src/layers/military/index.js +++ b/src/layers/military/index.js @@ -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, diff --git a/src/layers/military/ingestion.js b/src/layers/military/ingestion.js index 9777b1831..af4697db2 100644 --- a/src/layers/military/ingestion.js +++ b/src/layers/military/ingestion.js @@ -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} + */ + 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 }; } diff --git a/src/layers/military/lifecycle.js b/src/layers/military/lifecycle.js index c9e658e98..695286803 100644 --- a/src/layers/military/lifecycle.js +++ b/src/layers/military/lifecycle.js @@ -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) diff --git a/src/layers/military/motion.js b/src/layers/military/motion.js index 6a01e6c49..6828b788e 100644 --- a/src/layers/military/motion.js +++ b/src/layers/military/motion.js @@ -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( diff --git a/src/layers/military/policy.js b/src/layers/military/policy.js index da67bc9b6..cafbaada2 100644 --- a/src/layers/military/policy.js +++ b/src/layers/military/policy.js @@ -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 diff --git a/src/layers/military/queries.js b/src/layers/military/queries.js index d23ad621a..4af14b3ba 100644 --- a/src/layers/military/queries.js +++ b/src/layers/military/queries.js @@ -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() }), }; }, }; diff --git a/src/layers/military/records.js b/src/layers/military/records.js index 7b48e6f8b..b18e946b8 100644 --- a/src/layers/military/records.js +++ b/src/layers/military/records.js @@ -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); diff --git a/src/layers/military/snapshotRenderer.js b/src/layers/military/snapshotRenderer.js index e674cc456..94e1921a5 100644 --- a/src/layers/military/snapshotRenderer.js +++ b/src/layers/military/snapshotRenderer.js @@ -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 = ''; diff --git a/src/layers/military/state.js b/src/layers/military/state.js index 23f8f053c..4d9febafd 100644 --- a/src/layers/military/state.js +++ b/src/layers/military/state.js @@ -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 */ diff --git a/src/layers/military/tracking.js b/src/layers/military/tracking.js index a834c2446..9e2441f45 100644 --- a/src/layers/military/tracking.js +++ b/src/layers/military/tracking.js @@ -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, diff --git a/src/layers/vessels/cards.js b/src/layers/vessels/cards.js index bcf3459f1..49c0264e2 100644 --- a/src/layers/vessels/cards.js +++ b/src/layers/vessels/cards.js @@ -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'); } diff --git a/src/layers/vessels/clockFollow.test.mjs b/src/layers/vessels/clockFollow.test.mjs new file mode 100644 index 000000000..84e652bf0 --- /dev/null +++ b/src/layers/vessels/clockFollow.test.mjs @@ -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, []); +}); diff --git a/src/layers/vessels/cockpitSubject.js b/src/layers/vessels/cockpitSubject.js index d92689a64..52f4412a3 100644 --- a/src/layers/vessels/cockpitSubject.js +++ b/src/layers/vessels/cockpitSubject.js @@ -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(), diff --git a/src/layers/vessels/cockpitSubject.test.mjs b/src/layers/vessels/cockpitSubject.test.mjs index 572ff199d..784749b37 100644 --- a/src/layers/vessels/cockpitSubject.test.mjs +++ b/src/layers/vessels/cockpitSubject.test.mjs @@ -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', + ); +}); diff --git a/src/layers/vessels/evidence.js b/src/layers/vessels/evidence.js index c5e593e26..3a8c1b9ce 100644 --- a/src/layers/vessels/evidence.js +++ b/src/layers/vessels/evidence.js @@ -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, diff --git a/src/layers/vessels/index.js b/src/layers/vessels/index.js index f5ee9b497..bd8282e84 100644 --- a/src/layers/vessels/index.js +++ b/src/layers/vessels/index.js @@ -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, diff --git a/src/layers/vessels/ingestion.js b/src/layers/vessels/ingestion.js index dca9b81ca..735f32daa 100644 --- a/src/layers/vessels/ingestion.js +++ b/src/layers/vessels/ingestion.js @@ -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} + */ + 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, diff --git a/src/layers/vessels/lifecycle.js b/src/layers/vessels/lifecycle.js index 1c1f611cc..4c457c198 100644 --- a/src/layers/vessels/lifecycle.js +++ b/src/layers/vessels/lifecycle.js @@ -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(); diff --git a/src/layers/vessels/motion.js b/src/layers/vessels/motion.js new file mode 100644 index 000000000..f4efb7d18 --- /dev/null +++ b/src/layers/vessels/motion.js @@ -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), + ]; +} diff --git a/src/layers/vessels/motion.test.mjs b/src/layers/vessels/motion.test.mjs new file mode 100644 index 000000000..f5370ae85 --- /dev/null +++ b/src/layers/vessels/motion.test.mjs @@ -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); +}); diff --git a/src/layers/vessels/ownership.test.mjs b/src/layers/vessels/ownership.test.mjs index 2404434a0..92c4ec493 100644 --- a/src/layers/vessels/ownership.test.mjs +++ b/src/layers/vessels/ownership.test.mjs @@ -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'], + ); +}); diff --git a/src/layers/vessels/policy.js b/src/layers/vessels/policy.js index 9ab06fbb8..1981cdcdc 100644 --- a/src/layers/vessels/policy.js +++ b/src/layers/vessels/policy.js @@ -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), diff --git a/src/layers/vessels/queries.js b/src/layers/vessels/queries.js index 523e16510..d927def6d 100644 --- a/src/layers/vessels/queries.js +++ b/src/layers/vessels/queries.js @@ -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, }; } diff --git a/src/layers/vessels/radarContacts.test.mjs b/src/layers/vessels/radarContacts.test.mjs index d89d7cf5d..001f266e3 100644 --- a/src/layers/vessels/radarContacts.test.mjs +++ b/src/layers/vessels/radarContacts.test.mjs @@ -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]); diff --git a/src/layers/vessels/records.js b/src/layers/vessels/records.js index 2a00fca3c..17296cc62 100644 --- a/src/layers/vessels/records.js +++ b/src/layers/vessels/records.js @@ -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]; } } diff --git a/src/layers/vessels/records.test.mjs b/src/layers/vessels/records.test.mjs index 9efa91130..d531ff532 100644 --- a/src/layers/vessels/records.test.mjs +++ b/src/layers/vessels/records.test.mjs @@ -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); +}); diff --git a/src/layers/vessels/rendering.js b/src/layers/vessels/rendering.js index aefc43bed..7a6aa6739 100644 --- a/src/layers/vessels/rendering.js +++ b/src/layers/vessels/rendering.js @@ -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, ); } diff --git a/src/layers/vessels/selection.js b/src/layers/vessels/selection.js index 4b801b9c9..99f3b674a 100644 --- a/src/layers/vessels/selection.js +++ b/src/layers/vessels/selection.js @@ -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, diff --git a/src/layers/vessels/snapshotRenderer.js b/src/layers/vessels/snapshotRenderer.js index 03abb81dc..584b17417 100644 --- a/src/layers/vessels/snapshotRenderer.js +++ b/src/layers/vessels/snapshotRenderer.js @@ -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); } diff --git a/src/layers/vessels/state.js b/src/layers/vessels/state.js index 3cd769c3e..8875d706e 100644 --- a/src/layers/vessels/state.js +++ b/src/layers/vessels/state.js @@ -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, diff --git a/src/layers/vessels/testing.js b/src/layers/vessels/testing.js index 6cab17086..436c451d4 100644 --- a/src/layers/vessels/testing.js +++ b/src/layers/vessels/testing.js @@ -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 { diff --git a/src/layers/vessels/tracking.js b/src/layers/vessels/tracking.js index 2c11ff463..dfc2aae55 100644 --- a/src/layers/vessels/tracking.js +++ b/src/layers/vessels/tracking.js @@ -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} */ - 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(); } diff --git a/src/layers/weather/index.js b/src/layers/weather/index.js index fb1447827..cab2d2b98 100644 --- a/src/layers/weather/index.js +++ b/src/layers/weather/index.js @@ -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?.( diff --git a/src/layers/weather/infraredImage.js b/src/layers/weather/infraredImage.js index 0957f9ce4..de57ab3b9 100644 --- a/src/layers/weather/infraredImage.js +++ b/src/layers/weather/infraredImage.js @@ -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, }); diff --git a/src/layers/weather/infraredImage.test.mjs b/src/layers/weather/infraredImage.test.mjs index 61e532564..fb06607e4 100644 --- a/src/layers/weather/infraredImage.test.mjs +++ b/src/layers/weather/infraredImage.test.mjs @@ -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(); +} diff --git a/src/layers/weather/rasterTiles.js b/src/layers/weather/rasterTiles.js index cba2b7a0c..e095345e6 100644 --- a/src/layers/weather/rasterTiles.js +++ b/src/layers/weather/rasterTiles.js @@ -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--; + }); + }, + }; +} diff --git a/src/layers/weather/rasterTiles.test.mjs b/src/layers/weather/rasterTiles.test.mjs index 5efcf11c0..6c654ff2a 100644 --- a/src/layers/weather/rasterTiles.test.mjs +++ b/src/layers/weather/rasterTiles.test.mjs @@ -1,7 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import * as Cesium from 'cesium'; -import { createRasterTileProvider } from './rasterTiles.js'; +import { + createRasterTileProvider, + createFrameTileProvider, +} from './rasterTiles.js'; import { createFieldRaster } from '../wind/fields.js'; // A small canvas double exposes the uploaded raster and exact drawImage crop. @@ -154,3 +157,161 @@ test('512-pixel crops preserve source extent at root and clamped draping levels' assert.deepEqual(tile.crop, crop); } }); + +const time = '2026-09-15T20:00:00.000Z'; +function tileCanvas() { + const canvas = { width: 0, height: 0, drawn: [] }; + canvas.getContext = () => ({ + drawImage: (source, ...args) => canvas.drawn.push([source, ...args]), + putImageData: (source, ...args) => canvas.drawn.push([source, ...args]), + }); + return canvas; +} +const tilePixels = (size = 256) => ({ + width: size, + height: size, + closes: 0, + close() { + this.closes++; + }, +}); +const deferredTile = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; +function frameProvider(getTile, options = {}) { + return createFrameTileProvider({ + cesium: Cesium, + frameSource: { getTile }, + product: 'radar', + time, + tilingScheme: new Cesium.GeographicTilingScheme(), + rectangle: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55), + maximumLevel: 6, + credit: new Cesium.Credit('NOAA'), + createCanvas: tileCanvas, + ...options, + }); +} + +test('a frame tile provider draws each supplied tile into its own canvas and releases it', async () => { + const requests = []; + const supplied = tilePixels(); + const provider = frameProvider(async (request) => { + requests.push(request); + return supplied; + }); + assert.equal(provider.tileWidth, 256); + assert.equal(provider.maximumLevel, 6); + assert.equal(provider.hasAlphaChannel, true); + const tile = await provider.requestImage(3, 1, 2); + assert.equal(tile.width, 256); + assert.equal(tile.height, 256); + assert.deepEqual(tile.drawn, [[supplied, 0, 0]]); + assert.equal(supplied.closes, 1); + assert.equal(requests.length, 1); + const { signal, ...rest } = requests[0]; + assert.deepEqual(rest, { + product: 'radar', + time, + z: 2, + x: 3, + y: 1, + size: 256, + }); + assert.ok(signal instanceof AbortSignal); +}); + +test('a null tile is one shared transparent canvas', async () => { + const provider = frameProvider(async () => null); + const first = await provider.requestImage(0, 0, 0); + const second = await provider.requestImage(1, 0, 0); + assert.equal(first, second); + assert.equal(first.width, 256); + assert.deepEqual(first.drawn, [], 'nothing is drawn on it'); +}); + +test('beyond the concurrency limit a tile is deferred to Cesium', async () => { + const gates = []; + const provider = frameProvider( + () => { + const gate = deferredTile(); + gates.push(gate); + return gate.promise; + }, + { maxConcurrent: 2 }, + ); + const first = provider.requestImage(0, 0, 1); + const second = provider.requestImage(1, 0, 1); + assert.equal(provider.requestImage(2, 0, 1), undefined); + gates[0].resolve(null); + await first; + const third = provider.requestImage(2, 0, 1); + assert.ok(third instanceof Promise, 'a finished tile frees its slot'); + gates[1].resolve(null); + gates[2].resolve(null); + await Promise.all([second, third]); +}); + +test('a failed tile rejects, which Cesium reports through errorEvent', async () => { + const provider = frameProvider(async () => { + throw new Error('frame unavailable'); + }); + await assert.rejects(provider.requestImage(0, 0, 0), /frame unavailable/); + const wrong = tilePixels(512); + const sized = frameProvider(async () => wrong); + await assert.rejects( + sized.requestImage(0, 0, 0), + /Invalid weather tile dimensions/, + ); + assert.equal(wrong.closes, 1, 'a refused tile is still released'); + const layer = new Cesium.ImageryLayer(provider); + const reported = []; + provider.errorEvent.addEventListener((error) => { + reported.push(error); + error.retry = false; + }); + const imagery = { x: 0, y: 0, level: 0, request: undefined }; + layer._requestImagery(imagery); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(reported.length, 1); + assert.match(reported[0].error.message, /frame unavailable/); + assert.equal(imagery.state, Cesium.ImageryState.FAILED); + layer.destroy(); +}); + +test('aborting the provider signal cancels tiles in flight and releases late pixels', async () => { + const controller = new AbortController(); + const gate = deferredTile(); + let seen; + const provider = frameProvider( + ({ signal }) => { + seen = signal; + return gate.promise; + }, + { signal: controller.signal }, + ); + const request = new Cesium.Request(); + const pending = provider.requestImage(0, 0, 0, request); + assert.equal(seen.aborted, false); + controller.abort(); + assert.equal(seen.aborted, true, 'the abort reaches the frame source'); + const late = tilePixels(); + gate.resolve(late); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(late.closes, 1); + assert.equal( + request.state, + Cesium.RequestState.CANCELLED, + 'Cesium unloads a cancelled tile instead of failing it', + ); + await assert.rejects( + provider.requestImage(1, 0, 0), + { name: 'AbortError' }, + 'a closed frame asks the source for nothing more', + ); +}); diff --git a/src/layers/weather/rendering.js b/src/layers/weather/rendering.js index 629101693..d7c2527ab 100644 --- a/src/layers/weather/rendering.js +++ b/src/layers/weather/rendering.js @@ -1,7 +1,10 @@ -import { weatherTileUrl } from './source.js'; +import { weatherTileUrl, closeWeatherPixels } from './source.js'; import { orderWeatherImagery } from './imageryOrder.js'; import { imageryHostStatus } from './imageryHost.js'; -import { createRasterTileProvider } from './rasterTiles.js'; +import { + createRasterTileProvider, + createFrameTileProvider, +} from './rasterTiles.js'; import { createWeatherShell } from './shellRendering.js'; import { weatherDisplay, escapeCreditText } from './display.js'; import { msg } from '../../i18n/index.js'; @@ -30,6 +33,7 @@ function createGlobeRendering({ fetchImpl, decodeImage, createCanvas, + frameSource, }) { let current = null; let incoming = null; @@ -72,6 +76,7 @@ function createGlobeRendering({ decodeImage, now, onFetched, + frameSource, }); // An aborted decode may still finish; never repopulate a cleared instance. signal.throwIfAborted(); @@ -82,7 +87,8 @@ function createGlobeRendering({ return { ...result, cached: false }; } - function prefetchTiles(snapshot, time) { + /** Level 0–1 tiles of the visible product bounds, at most eight. */ + function prefetchTiles(snapshot) { const { west, south, east, north } = snapshot.bounds; const bounds = cesium.Rectangle.fromDegrees(west, south, east, north); const view = viewer.camera?.computeViewRectangle?.( @@ -93,10 +99,7 @@ function createGlobeRendering({ : bounds; if (!coverage) return []; const scheme = new cesium.GeographicTilingScheme(); - const template = weatherTileUrl(snapshot.product, time, { - size: profile(snapshot.product).tileSize, - }); - const urls = []; + const tiles = []; for (let z = 0; z <= 1; z++) { for (let y = 0; y < scheme.getNumberOfYTilesAtLevel(z); y++) { for (let x = 0; x < scheme.getNumberOfXTilesAtLevel(z); x++) { @@ -107,14 +110,39 @@ function createGlobeRendering({ ) ) continue; - urls.push( - template.replace('{z}', z).replace('{x}', x).replace('{y}', y), - ); - if (urls.length === MAX_PREFETCH_TILES) return urls; + tiles.push({ z, x, y }); + if (tiles.length === MAX_PREFETCH_TILES) return tiles; } } } - return urls; + return tiles; + } + function tileUrls(snapshot, time, tiles) { + const template = weatherTileUrl(snapshot.product, time, { + size: profile(snapshot.product).tileSize, + }); + return tiles.map(({ z, x, y }) => + template.replace('{z}', z).replace('{x}', x).replace('{y}', y), + ); + } + /** Warm tiles through the frame source; the pixels themselves are released. */ + function prefetchFrameTiles(snapshot, time, tiles, signal) { + const size = profile(snapshot.product).tileSize; + return Promise.all( + tiles.map(async ({ z, x, y }) => + closeWeatherPixels( + await frameSource.getTile({ + product: snapshot.product, + time, + z, + x, + y, + size, + signal, + }), + ), + ), + ); } function creditText(snapshot) { @@ -225,8 +253,14 @@ function createGlobeRendering({ ) return false; const global = snapshot.product === 'clouds'; - const urls = global ? [] : prefetchTiles(snapshot, time); - const key = global ? `${time}|${infrared}` : urls.join('|'); + const tiles = global ? [] : prefetchTiles(snapshot); + const supplied = !global && Boolean(frameSource?.getTile); + const urls = global || supplied ? [] : tileUrls(snapshot, time, tiles); + const key = global + ? `${time}|${infrared}` + : supplied + ? `${snapshot.product}|${time}|${tiles.map(({ z, x, y }) => `${z}/${x}/${y}`).join('|')}` + : urls.join('|'); if (prefetchJob?.key === key || prefetchedKey === key) return false; cancelPrefetch(); job = { key, controller: new AbortController() }; @@ -234,6 +268,8 @@ function createGlobeRendering({ const { signal } = job.controller; job.timeout = setTimeout(() => job.controller.abort(), timeoutMs); if (global) await mosaic(time, infrared, signal); + else if (supplied) + await prefetchFrameTiles(snapshot, time, tiles, signal); else await Promise.all( urls.map(async (url) => { @@ -396,18 +432,32 @@ function createGlobeRendering({ credit, createCanvas, }) - : new cesium.UrlTemplateImageryProvider({ - url: weatherTileUrl(snapshot.product, time, { - size: nextProfile.tileSize, - }), - tilingScheme, - rectangle, - tileWidth: nextProfile.tileSize, - tileHeight: nextProfile.tileSize, - maximumLevel: nextProfile.maximumLevel, - enablePickFeatures: false, - credit, - }); + : frameSource?.getTile + ? createFrameTileProvider({ + cesium, + frameSource, + product: snapshot.product, + time, + tilingScheme, + rectangle, + maximumLevel: nextProfile.maximumLevel, + tileSize: nextProfile.tileSize, + credit, + createCanvas, + signal: frame.controller.signal, + }) + : new cesium.UrlTemplateImageryProvider({ + url: weatherTileUrl(snapshot.product, time, { + size: nextProfile.tileSize, + }), + tilingScheme, + rectangle, + tileWidth: nextProfile.tileSize, + tileHeight: nextProfile.tileSize, + maximumLevel: nextProfile.maximumLevel, + enablePickFeatures: false, + credit, + }); // Cesium reads a provider's credit every frame; a refreshed manifest // can replace it without restaging the imagery. Object.defineProperty(provider, 'credit', { @@ -597,6 +647,7 @@ export function createWeatherRendering({ decodeImage, createCanvas = () => document.createElement('canvas'), createShell = createWeatherShell, + frameSource = null, }) { const shared = { viewer, @@ -608,6 +659,7 @@ export function createWeatherRendering({ fetchImpl, decodeImage, createCanvas, + frameSource, }; const globe = createGlobeRendering(shared); let shell = null; diff --git a/src/layers/weather/shellRendering.js b/src/layers/weather/shellRendering.js index 9def82c43..fdcb8ca3b 100644 --- a/src/layers/weather/shellRendering.js +++ b/src/layers/weather/shellRendering.js @@ -391,7 +391,8 @@ export function detailWindow(footprint, bounds, previous = null) { /** Observed weather on 3D Tiles: one raised shell per product and one * full-extent image per frame. The previous image stays until the next is ready. * Inside a window around the view the same surface samples a sharper image of - * that area, so the two images share one mesh and never blend. */ + * that area, so the two images share one mesh and never blend. A `frameSource` + * with `getImage` supplies both images, the detail window through its `bbox`. */ export function createWeatherShell({ viewer, cesium, @@ -405,6 +406,7 @@ export function createWeatherShell({ decodeImage, createCanvas = () => document.createElement('canvas'), cacheBytes = WEATHER_SHELL_CACHE_BYTES, + frameSource = null, }) { if (!Object.hasOwn(WEATHER_IMAGE_SIZES, product)) throw new TypeError('Unknown weather product'); @@ -475,6 +477,7 @@ export function createWeatherShell({ decodeImage, now, onFetched, + frameSource, }); // An aborted decode may still finish; never repopulate a cleared instance. signal.throwIfAborted(); diff --git a/src/layers/weather/shellRendering.test.mjs b/src/layers/weather/shellRendering.test.mjs index ad65dcef2..f756fd7b9 100644 --- a/src/layers/weather/shellRendering.test.mjs +++ b/src/layers/weather/shellRendering.test.mjs @@ -1502,3 +1502,101 @@ test('global infrared keeps one full-extent image; detail images halve for small }); small.shell.clear(); }); + +test('a frame source supplies the full-extent and detail images, the detail through its bbox', async () => { + const view = camera(-100, 35, -98, 36); + const requests = []; + const supplied = []; + const h = harness({ + camera: view, + fetchImpl: () => assert.fail('supplied images are not fetched'), + decodeImage: () => assert.fail('supplied images are not decoded'), + frameSource: { + getImage: async (request) => { + requests.push(request); + const image = { + ...request.size, + closed: 0, + close() { + this.closed++; + }, + }; + supplied.push(image); + return image; + }, + }, + }); + const pending = h.shell.setFrame(snapshot(), times[0]); + await flush(); + h.render(); + assert.equal(await pending, true); + await flush(); + assert.deepEqual( + requests.map(({ product, time, size, bbox }) => ({ + product, + time, + size, + bbox, + })), + [ + { + product: 'radar', + time: times[0], + size: { width: 4096, height: 2048 }, + bbox: null, + }, + { + product: 'radar', + time: times[0], + size: { width: 4096, height: 2048 }, + bbox: box(-102, 34, -96, 37), + }, + ], + ); + assert.ok(supplied.every(({ closed }) => closed === 1)); + const [shell] = h.primitives(); + const { uniforms } = shell.appearance.material; + assert.equal(uniforms.image.source, supplied[0]); + assert.equal(uniforms.detail.source, supplied[1]); + h.render(); + assert.deepEqual(windowOf(shell), windowFor(box(-102, 34, -96, 37))); + h.shell.clear(); +}); + +test('cancelling a supplied frame aborts the frame source request', async () => { + const gate = deferred(); + let signal; + const h = harness({ + frameSource: { + getImage: (request) => { + signal = request.signal; + return gate.promise; + }, + }, + }); + const abort = new AbortController(); + const pending = h.shell.setFrame(snapshot(), times[0], { + signal: abort.signal, + }); + await flush(); + abort.abort(); + assert.equal(signal.aborted, true); + assert.equal(await pending, false); + const late = { + width: 4096, + height: 2048, + closed: 0, + close() { + this.closed++; + }, + }; + gate.resolve(late); + await flush(); + assert.equal( + late.closed, + 1, + 'a frame arriving after cancellation is released', + ); + assert.equal(h.primitives().length, 0); + h.shell.clear(); +}); diff --git a/src/layers/weather/source.js b/src/layers/weather/source.js index 5386bfad7..276e9bf2e 100644 --- a/src/layers/weather/source.js +++ b/src/layers/weather/source.js @@ -104,6 +104,75 @@ export function weatherTileUrl(product, time, { size } = {}) { return `/api/weather/tile?product=${product}&time=${encodeURIComponent(time)}&z={z}&x={x}&y={y}${size === undefined ? '' : `&size=${size}`}`; } +/** + * Decoded pixels a host hands the weather layer. The layer takes ownership: + * it draws them once and closes a VideoFrame or ImageBitmap afterwards, so a + * host returns a fresh object for every call. + * @typedef {ImageBitmap|ImageData|HTMLCanvasElement|OffscreenCanvas|VideoFrame} WeatherPixels + */ + +/** + * A weather feed may also be a frame source: a host can supply decoded frames + * in place of the bounded image and tile routes. Both methods are optional and + * independent; a product or path without one keeps the routes. + * + * `getFrameImage` returns one image exactly `size` pixels, covering the + * product's whole extent, or with `bbox` ({ west, south, east, north } + * degrees) a detail window of it. `getFrameTile` returns one `size`-pixel tile + * of the geographic tiling scheme, or null for a transparent tile. Rows run + * top-down (north first), as an image does. Both honour `signal`. + * @typedef {object} WeatherFrameFeed + * @property {(request: { product: string, time: string, + * size: { width: number, height: number }, + * bbox: { west: number, south: number, east: number, north: number } | null, + * signal: AbortSignal }) => Promise} [getFrameImage] + * @property {(request: { product: string, time: string, z: number, x: number, + * y: number, size: number, signal: AbortSignal }) + * => Promise} [getFrameTile] + */ + +/** + * The frame source a feed provides, or null when it has neither method. A + * missing method is left undefined, so that path keeps the routes. + * @param {WeatherFrameFeed | undefined} feed + * @returns {{ getImage?: Function, getTile?: Function } | null} + */ +export function weatherFrameSource(feed) { + const image = typeof feed?.getFrameImage === 'function'; + const tile = typeof feed?.getFrameTile === 'function'; + if (!image && !tile) return null; + return { + getImage: image + ? ({ product, time, size, bbox = null, signal }) => + feed.getFrameImage({ product, time, size, bbox, signal }) + : undefined, + getTile: tile + ? ({ product, time, z, x, y, size, signal }) => + feed.getFrameTile({ product, time, z, x, y, size, signal }) + : undefined, + }; +} + +/** Width and height of supplied pixels; a VideoFrame draws at its display size. */ +export function weatherPixelSize(pixels) { + return { + width: pixels?.displayWidth ?? pixels?.width, + height: pixels?.displayHeight ?? pixels?.height, + }; +} + +/** Draw supplied pixels at the canvas origin, at their own size. */ +export function drawWeatherPixels(context, pixels) { + if (typeof ImageData !== 'undefined' && pixels instanceof ImageData) + context.putImageData(pixels, 0, 0); + else context.drawImage(pixels, 0, 0); +} + +/** Release supplied pixels that hold decoder or GPU memory. */ +export function closeWeatherPixels(pixels) { + if (typeof pixels?.close === 'function') pixels.close(); +} + /** Acquisition is lazy and shares the application's existing source contract. */ export function createWeatherSource({ fetchImpl = (...args) => globalThis.fetch(...args), diff --git a/src/layers/weather/source.test.mjs b/src/layers/weather/source.test.mjs index 83ab24e76..ff69fce2f 100644 --- a/src/layers/weather/source.test.mjs +++ b/src/layers/weather/source.test.mjs @@ -5,6 +5,10 @@ import { validateWeatherSnapshot, weatherImageUrl, weatherTileUrl, + weatherFrameSource, + weatherPixelSize, + drawWeatherPixels, + closeWeatherPixels, WEATHER_DETAIL_SIZE, WEATHER_IMAGE_SIZES, } from './source.js'; @@ -199,3 +203,103 @@ test('image frames are read through the bounded image route', async () => { /Weather image HTTP 409/, ); }); + +test('a feed without frame methods is no frame source', () => { + assert.equal(weatherFrameSource(undefined), null); + assert.equal(weatherFrameSource({ getSnapshot: async () => ({}) }), null); + assert.equal(weatherFrameSource({ getFrameImage: 'no' }), null); +}); + +test('a frame source forwards exactly the documented requests to its feed', async () => { + const calls = []; + const signal = new AbortController().signal; + const feed = { + getFrameImage: async (request) => { + calls.push(['image', request]); + return 'image'; + }, + getFrameTile: async (request) => { + calls.push(['tile', request]); + return null; + }, + }; + const source = weatherFrameSource(feed); + const size = { width: 4096, height: 2048 }; + assert.equal( + await source.getImage({ product: 'radar', time: 't', size, signal }), + 'image', + ); + const bbox = { west: -102, south: 34, east: -96, north: 37 }; + await source.getImage({ product: 'radar', time: 't', size, bbox, signal }); + assert.equal( + await source.getTile({ + product: 'lightning', + time: 't', + z: 1, + x: 2, + y: 0, + size: 256, + signal, + extra: true, + }), + null, + ); + assert.deepEqual(calls, [ + ['image', { product: 'radar', time: 't', size, bbox: null, signal }], + ['image', { product: 'radar', time: 't', size, bbox, signal }], + [ + 'tile', + { product: 'lightning', time: 't', z: 1, x: 2, y: 0, size: 256, signal }, + ], + ]); + const imagesOnly = weatherFrameSource({ getFrameImage: feed.getFrameImage }); + assert.equal(typeof imagesOnly.getImage, 'function'); + assert.equal(imagesOnly.getTile, undefined, 'tiles keep the tile route'); + const tilesOnly = weatherFrameSource({ getFrameTile: feed.getFrameTile }); + assert.equal(tilesOnly.getImage, undefined, 'images keep the image route'); +}); + +test('supplied pixels report their drawn size, draw at the origin and close once', (t) => { + assert.deepEqual(weatherPixelSize({ width: 4, height: 2 }), { + width: 4, + height: 2, + }); + assert.deepEqual( + weatherPixelSize({ + codedWidth: 8, + codedHeight: 8, + displayWidth: 4, + displayHeight: 2, + }), + { width: 4, height: 2 }, + 'a VideoFrame draws at its display size', + ); + const had = Object.hasOwn(globalThis, 'ImageData'); + const previous = globalThis.ImageData; + globalThis.ImageData = class ImageData { + width = 1; + height = 1; + }; + t.after(() => { + if (had) globalThis.ImageData = previous; + else delete globalThis.ImageData; + }); + const calls = []; + const context = { + drawImage: (...args) => calls.push(['drawImage', ...args]), + putImageData: (...args) => calls.push(['putImageData', ...args]), + }; + const bitmap = { width: 1, height: 1 }; + const data = new globalThis.ImageData(); + drawWeatherPixels(context, bitmap); + drawWeatherPixels(context, data); + assert.deepEqual(calls, [ + ['drawImage', bitmap, 0, 0], + ['putImageData', data, 0, 0], + ]); + let closes = 0; + closeWeatherPixels({ close: () => closes++ }); + closeWeatherPixels(data); + closeWeatherPixels(null); + assert.equal(closes, 1); +}); diff --git a/src/layers/weather/weather.test.mjs b/src/layers/weather/weather.test.mjs index a42485ed0..b86219ae4 100644 --- a/src/layers/weather/weather.test.mjs +++ b/src/layers/weather/weather.test.mjs @@ -2400,3 +2400,240 @@ test('disabled weather releases its camera listener and re-enabling binds only o h.layer.destroy(); assert.equal(h.moveEnd.size, 0); }); + +const suppliedTile = (size = 256) => ({ + width: size, + height: size, + closes: 0, + close() { + this.closes++; + }, +}); + +test('a frame source supplies regional tiles in place of the tile route, through the readiness wrapper', async () => { + let now = 0; + const requests = []; + const supplied = []; + const h = renderingHarness({ + now: () => now, + fetchImpl: () => assert.fail('supplied tiles are not fetched'), + frameSource: { + getTile: async (request) => { + requests.push(request); + const tile = suppliedTile(); + supplied.push(tile); + return tile; + }, + }, + }); + h.viewer.scene.globe.tilesLoaded = false; + const loaded = h.rendering.setFrame(snapshot, times[0]); + assert.equal(h.providers.length, 1); + const [provider] = h.providers; + assert.equal(provider.options, undefined, 'no URL template provider'); + assert.equal(provider.maximumLevel, 6); + const tile = await provider.requestImage(1, 0, 1, {}); + assert.equal(tile.width, 256); + assert.equal(tile.source, supplied[0]); + assert.equal(supplied[0].closes, 1); + assert.equal(requests[0].product, 'radar'); + assert.equal(requests[0].time, times[0]); + assert.deepEqual( + [requests[0].z, requests[0].x, requests[0].y, requests[0].size], + [1, 1, 0, 256], + ); + assert.equal(h.rendering.getDiagnostics().loadedTiles, 1); + now = 500; + h.settle(); + assert.equal(await loaded, true); + + const gate = deferred(); + let signal; + const next = renderingHarness({ + frameSource: { + getTile: (request) => { + signal = request.signal; + return gate.promise; + }, + }, + }); + const staged = next.rendering.setFrame(snapshot, times[1]); + const pending = next.providers[0].requestImage(0, 0, 0, {}); + next.rendering.clear(); + assert.equal(signal.aborted, true, 'clearing a frame aborts its tiles'); + const late = suppliedTile(); + gate.resolve(late); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(late.closes, 1); + assert.equal(await staged, false); + h.rendering.clear(); +}); + +test('a failed supplied tile fails the staged frame through errorEvent', async () => { + const h = renderingHarness({ + frameSource: { + getTile: async () => { + throw new Error('frame unavailable'); + }, + }, + }); + const staged = h.rendering.setFrame(snapshot, times[0]); + const [provider] = h.providers; + await assert.rejects(provider.requestImage(0, 0, 0, {})); + provider.errorEvent.raiseEvent({ x: 0, y: 0, level: 0, error: {} }); + h.settle(); + assert.equal(await staged, false); + assert.match(h.rendering.getDiagnostics().error, /Weather tiles unavailable/); + h.rendering.clear(); +}); + +test('supplied regional infrared tiles are canvases, so they get the transfer without a flip', async () => { + const hadBitmap = Object.hasOwn(globalThis, 'ImageBitmap'); + const previous = globalThis.ImageBitmap; + globalThis.ImageBitmap = class ImageBitmap { + width = 256; + height = 256; + close() {} + }; + try { + const h = renderingHarness({ + frameSource: { getTile: async () => new globalThis.ImageBitmap() }, + }); + void h.rendering.setFrame( + { ...snapshot, product: 'clouds-regional' }, + times[0], + ); + const tile = await h.providers[0].requestImage(0, 0, 0, {}); + assert.equal(tile.drawnWith, null, 'drawn upright, not flipped'); + assert.ok(tile.pixels, 'the infrared transfer ran'); + const empty = renderingHarness({ + frameSource: { getTile: async () => null }, + }); + void empty.rendering.setFrame( + { ...snapshot, product: 'clouds-regional' }, + times[0], + ); + const transparent = await empty.providers[0].requestImage(0, 0, 0, {}); + assert.equal(transparent.width, 256, 'a null tile is still a canvas'); + h.rendering.clear(); + empty.rendering.clear(); + } finally { + if (hadBitmap) globalThis.ImageBitmap = previous; + else delete globalThis.ImageBitmap; + } +}); + +test('tiled prefetch asks the frame source for the same level 0/1 tiles and releases them', async (t) => { + const requests = []; + const supplied = []; + const h = renderingHarness({ + fetchImpl: () => assert.fail('supplied tiles are not fetched'), + frameSource: { + getTile: async (request) => { + requests.push(request); + if (request.z === 0) return null; + const tile = suppliedTile(); + supplied.push(tile); + return tile; + }, + }, + }); + t.after(() => h.rendering.clear()); + h.viewer.camera = { + computeViewRectangle: () => + Cesium.Rectangle.fromDegrees(-120, 25, -100, 50), + }; + assert.equal(await h.rendering.prefetch(snapshot, times[1]), true); + assert.deepEqual( + requests.map(({ product, time, z, x, y, size }) => [ + product, + time, + z, + x, + y, + size, + ]), + [ + ['radar', times[1], 0, 0, 0, 256], + ['radar', times[1], 1, 0, 0, 256], + ], + ); + assert.ok(requests.every(({ signal }) => signal instanceof AbortSignal)); + assert.equal(supplied[0].closes, 1); + assert.equal( + await h.rendering.prefetch(snapshot, times[1]), + false, + 'same completed work is not repeated', + ); + assert.equal(requests.length, 2); + + const gate = deferred(); + let signal; + const slow = renderingHarness({ + frameSource: { + getTile: ({ signal: value }) => { + signal = value; + return gate.promise; + }, + }, + }); + t.after(() => slow.rendering.clear()); + slow.viewer.camera = h.viewer.camera; + const warm = slow.rendering.prefetch(snapshot, times[1]); + slow.rendering.cancelPrefetch(); + assert.equal(signal.aborted, true, 'cancelling a prefetch aborts its tiles'); + gate.resolve(null); + assert.equal(await warm, false); +}); + +test('the global mosaic comes from the frame source image when present', async (t) => { + const requests = []; + const h = renderingHarness({ + fetchImpl: () => assert.fail('a supplied mosaic is not fetched'), + decodeImage: () => assert.fail('a supplied mosaic is not decoded'), + frameSource: { + getImage: async (request) => { + requests.push(request); + return { width: 2048, height: 1024, close() {} }; + }, + }, + }); + t.after(() => h.rendering.clear()); + await showMosaic(h, times[0]); + assert.equal(requests.length, 1); + assert.equal(requests[0].product, 'clouds'); + assert.deepEqual(requests[0].size, { width: 2048, height: 1024 }); + assert.equal(requests[0].bbox, null); + assert.equal(h.rendering.getDiagnostics().mosaic.fetched, true); +}); + +test('the layer hands rendering a frame source only when its feed is one', () => { + const seen = []; + const createRendering = (args) => { + seen.push(args.frameSource); + return { + setAlpha() {}, + rehome() {}, + clear() {}, + getDiagnostics: () => ({}), + }; + }; + const options = { + documentRef: target({ hidden: false }), + eventTarget: target(), + matchMedia: () => target({ matches: false }), + createRendering, + }; + createWeatherLayer({ + ...options, + feed: { getSnapshot: async () => snapshot }, + }).init({}); + const getFrameTile = async () => null; + createWeatherLayer({ + ...options, + feed: { getSnapshot: async () => snapshot, getFrameTile }, + }).init({}); + assert.equal(seen[0], null); + assert.equal(typeof seen[1].getTile, 'function'); + assert.equal(seen[1].getImage, undefined); +}); diff --git a/src/loadingFeedback.test.mjs b/src/loadingFeedback.test.mjs index d892ddf1c..ab26f6a3e 100644 --- a/src/loadingFeedback.test.mjs +++ b/src/loadingFeedback.test.mjs @@ -1,43 +1,124 @@ import { ShareRestoration } from './ui/shareRestoration.js'; -import { readShellSource, shellMethod } from './testSupport/readShellSource.mjs'; +import { + readShellSource, + shellMethod, +} from './testSupport/readShellSource.mjs'; import { renderApplicationHtml } from './testSupport/applicationHtml.mjs'; import { readStylesheet } from './testSupport/readStylesheet.mjs'; import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const retrySite = (stats = {}) => ({ id: 'military-installations', name: 'Mapped Installations', enabled: true, - stats: { status: 'unavailable', error: 'Unavailable', retryAt: Date.now() + 30000, ...stats } }); +const retrySite = (stats = {}) => ({ + id: 'military-installations', + name: 'Mapped Installations', + enabled: true, + stats: { + status: 'unavailable', + error: 'Unavailable', + retryAt: Date.now() + 30000, + ...stats, + }, +}); test('installation retry remains visible after failure dwell without a false spinner', () => { const summary = aggregateLayerLoading([retrySite()]); - const view = presentLoadingFeedback(createLoadingFeedbackState(), summary, 100); + const view = presentLoadingFeedback( + createLoadingFeedbackState(), + summary, + 100, + ); assert.equal(view.state, 'retry'); assert.equal(view.label, 'OVERPASS TEMPORARILY UNAVAILABLE'); assert.match(view.detail, /retrying in 30s/); - assert.equal(presentLoadingFeedback(createLoadingFeedbackState(), aggregateLayerLoading([{ ...retrySite(), enabled: false }]), 100), null); + assert.equal( + presentLoadingFeedback( + createLoadingFeedbackState(), + aggregateLayerLoading([{ ...retrySite(), enabled: false }]), + 100, + ), + null, + ); }); test('an installation retry never conceals another participant failure', () => { - const state = { visible: true, phase: 'terminal', terminal: 'error', activeIds: ['military-installations', 'flights'] }; - const summary = aggregateLayerLoading([retrySite(), { id: 'flights', enabled: true, stats: { error: 'Failed' } }]); - assert.equal(presentLoadingFeedback(state, summary, 100).label, 'LOAD FAILED'); + const state = { + visible: true, + phase: 'terminal', + terminal: 'error', + activeIds: ['military-installations', 'flights'], + }; + const summary = aggregateLayerLoading([ + retrySite(), + { id: 'flights', enabled: true, stats: { error: 'Failed' } }, + ]); + assert.equal( + presentLoadingFeedback(state, summary, 100).label, + 'LOAD FAILED', + ); const healthyNow = aggregateLayerLoading([retrySite()]); - assert.equal(presentLoadingFeedback({ ...state, failedEventIds: ['flights'] }, healthyNow, 100).label, 'LOAD FAILED'); + assert.equal( + presentLoadingFeedback( + { ...state, failedEventIds: ['flights'] }, + healthyNow, + 100, + ).label, + 'LOAD FAILED', + ); }); test('a fresh installation retry can finish successfully without inheriting the old error', () => { - let state = { ...createLoadingFeedbackState(), phase: 'terminal', terminal: 'error', visible: true, activeIds: ['military-installations'] }; - const loading = aggregateLayerLoading([retrySite({ status: 'loading', error: null, loading: true, retryAt: 0, retrying: true })]); + let state = { + ...createLoadingFeedbackState(), + phase: 'terminal', + terminal: 'error', + visible: true, + activeIds: ['military-installations'], + }; + const loading = aggregateLayerLoading([ + retrySite({ + status: 'loading', + error: null, + loading: true, + retryAt: 0, + retrying: true, + }), + ]); state = reduceLoadingFeedback(state, loading, 1000); state = reduceLoadingFeedback(state, loading, 1200); - assert.equal(presentLoadingFeedback(state, loading, 1200).label, 'RETRYING MAPPED SITES'); - const done = aggregateLayerLoading([retrySite({ status: 'ready', error: null, loading: false, retryAt: 0, retrying: false, count: 3 })]); + assert.equal( + presentLoadingFeedback(state, loading, 1200).label, + 'RETRYING MAPPED SITES', + ); + const done = aggregateLayerLoading([ + retrySite({ + status: 'ready', + error: null, + loading: false, + retryAt: 0, + retrying: false, + count: 3, + }), + ]); state = reduceLoadingFeedback(state, done, 1500); - assert.equal(presentLoadingFeedback(state, done, 1500).label, 'MAPPED SITES LOADED'); + assert.equal( + presentLoadingFeedback(state, done, 1500).label, + 'MAPPED SITES LOADED', + ); }); test('turning off a retrying installation layer does not report the old fetch failure as a disable failure', () => { - const stopping = aggregateLayerLoading([{ ...retrySite(), lifecycleState: 'disabling' }]); - let state = reduceLoadingFeedback(createLoadingFeedbackState(), stopping, 1000); + const stopping = aggregateLayerLoading([ + { ...retrySite(), lifecycleState: 'disabling' }, + ]); + let state = reduceLoadingFeedback( + createLoadingFeedbackState(), + stopping, + 1000, + ); state = reduceLoadingFeedback(state, stopping, 1200); - const off = aggregateLayerLoading([{ ...retrySite({ status: 'idle', error: null, retryAt: 0 }), enabled: false }]); + const off = aggregateLayerLoading([ + { + ...retrySite({ status: 'idle', error: null, retryAt: 0 }), + enabled: false, + }, + ]); state = reduceLoadingFeedback(state, off, 1400); assert.equal(presentLoadingFeedback(state, off, 1400).label, 'LIVE DATA OFF'); }); @@ -58,9 +139,19 @@ import { } from './loadingFeedback.js'; test('universal status notices reuse the standard failure dwell', () => { - const notice = createGlobalStatusNotice('Shared satellite is unavailable', 1000); - assert.equal(notice.hideAt, null, 'finite dwell waits until first presentation'); - assert.equal(presentGlobalStatusNotice(notice, 1001).label, 'Shared satellite is unavailable'); + const notice = createGlobalStatusNotice( + 'Shared satellite is unavailable', + 1000, + ); + assert.equal( + notice.hideAt, + null, + 'finite dwell waits until first presentation', + ); + assert.equal( + presentGlobalStatusNotice(notice, 1001).label, + 'Shared satellite is unavailable', + ); assert.equal(notice.hideAt, 1001 + LOADING_FAILURE_DWELL_MS); assert.deepEqual(presentGlobalStatusNotice(notice, notice.hideAt - 1), { state: 'error', @@ -86,10 +177,16 @@ test('acquiring notices persist without a dwell until explicitly cleared', () => test('deferred terminal notices lose ownership to newer acquisition epochs and disposal', () => { assert.equal(canPresentDeferredStatusNotice(4, 4, false), true); - assert.equal(canPresentDeferredStatusNotice(4, 5, false), false, - 'a newer ACQUIRING epoch blocks the older deferred failure'); - assert.equal(canPresentDeferredStatusNotice(5, 5, true), false, - 'disposal blocks even the current deferred notice'); + assert.equal( + canPresentDeferredStatusNotice(4, 5, false), + false, + 'a newer ACQUIRING epoch blocks the older deferred failure', + ); + assert.equal( + canPresentDeferredStatusNotice(5, 5, true), + false, + 'disposal blocks even the current deferred notice', + ); }); test('share-follow failures use the universal top-center status instead of the bottom toast', () => { @@ -97,15 +194,22 @@ test('share-follow failures use the universal top-center status instead of the b const present = shellMethod('_presentAfterStartupCover').toString(); assert.match( handler, - /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(message, noticeGeneration\),?\s*\)/, + /this\.initialRestorePromise\.then\(\(\) =>\s*this\._presentAfterStartupCover\(notice, noticeGeneration\),?\s*\)/, ); - assert.match(present, /this\.showStatus\(message\)/); + assert.match(present, /this\.showStatus\(notice\.message\)/); + assert.match(present, /state: 'last-known'/); assert.match(present, /this\._lifetime\.frame\(\(\) => \{/); - assert.match(present, /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/); - assert.match(present, /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/); + assert.match( + present, + /this\._lifetime\.listen\(\s*startupCover,\s*'transitionend',\s*showOnce,\s*\{ once: true \},?\s*\)/, + ); + assert.match( + present, + /fallbackTimer = this\._lifetime\.timeout\(showOnce, 1000\)/, + ); assert.match(present, /canPresentDeferredStatusNotice\(/); for (const source of [handler, present]) { - assert.doesNotMatch(source, /this\._showToast\(message\)/); + assert.doesNotMatch(source, /this\._showToast\(/); assert.doesNotMatch(source, /pushCockpitSignal/); } assert.match(handler, /result\.classification === 'pending'/); @@ -118,51 +222,94 @@ test('share-follow failures use the universal top-center status instead of the b }); test('universal notice masks active loading only for its own fixed dwell', () => { - const summary = aggregateLayerLoading([{ id: 'satellites', name: 'Satellites', lifecycleState: 'enabling' }]); + const summary = aggregateLayerLoading([ + { id: 'satellites', name: 'Satellites', lifecycleState: 'enabling' }, + ]); let loading = reduceLoadingFeedback(createLoadingFeedbackState(), summary, 0); loading = reduceLoadingFeedback(loading, summary, 200); - const notice = createGlobalStatusNotice('Shared satellite is unavailable', 300); + const notice = createGlobalStatusNotice( + 'Shared satellite is unavailable', + 300, + ); - assert.equal(presentGlobalLoadingStatus(notice, loading, summary, 301).label, 'Shared satellite is unavailable'); - assert.equal(presentGlobalLoadingStatus(notice, loading, summary, notice.hideAt).label, 'LOADING LIVE DATA'); + assert.equal( + presentGlobalLoadingStatus(notice, loading, summary, 301).label, + 'Shared satellite is unavailable', + ); + assert.equal( + presentGlobalLoadingStatus(notice, loading, summary, notice.hideAt).label, + 'LOADING LIVE DATA', + ); }); test('terminal failure preempts a finite notice, whose full dwell starts afterward', () => { - const active = aggregateLayerLoading([{ id: 'satellites', name: 'Satellites', lifecycleState: 'enabling' }]); + const active = aggregateLayerLoading([ + { id: 'satellites', name: 'Satellites', lifecycleState: 'enabling' }, + ]); let loading = reduceLoadingFeedback(createLoadingFeedbackState(), active, 0); loading = reduceLoadingFeedback(loading, active, 200); loading = reduceLoadingFeedback(loading, aggregateLayerLoading([]), 300, { - type: 'visibility-failed', layerId: 'satellites', error: new Error('offline'), + type: 'visibility-failed', + layerId: 'satellites', + error: new Error('offline'), }); - const notice = createGlobalStatusNotice('Shared satellite is unavailable', 400); + const notice = createGlobalStatusNotice( + 'Shared satellite is unavailable', + 400, + ); assert.equal( - presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), 401).label, + presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), 401) + .label, 'LOAD FAILED', ); - assert.equal(notice.hideAt, null, 'masked finite notice has not started its dwell'); + assert.equal( + notice.hideAt, + null, + 'masked finite notice has not started its dwell', + ); loading = reduceLoadingFeedback(loading, aggregateLayerLoading([]), 5300); assert.equal( - presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), 5300).label, + presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), 5300) + .label, 'Shared satellite is unavailable', ); assert.equal(notice.hideAt, 5300 + LOADING_FAILURE_DWELL_MS); assert.equal( - presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), notice.hideAt - 1).label, + presentGlobalLoadingStatus( + notice, + loading, + aggregateLayerLoading([]), + notice.hideAt - 1, + ).label, 'Shared satellite is unavailable', ); - assert.equal(presentGlobalLoadingStatus(notice, loading, aggregateLayerLoading([]), notice.hideAt), null); + assert.equal( + presentGlobalLoadingStatus( + notice, + loading, + aggregateLayerLoading([]), + notice.hideAt, + ), + null, + ); }); test('persistent acquisition never hides an unrelated manager failure', () => { - const active = aggregateLayerLoading([{ - id: 'traffic', name: 'Street Traffic', lifecycleState: 'enabling', - }]); + const active = aggregateLayerLoading([ + { + id: 'traffic', + name: 'Street Traffic', + lifecycleState: 'enabling', + }, + ]); const idle = aggregateLayerLoading([]); let loading = reduceLoadingFeedback(createLoadingFeedbackState(), active, 0); loading = reduceLoadingFeedback(loading, active, 200); loading = reduceLoadingFeedback(loading, idle, 300, { - type: 'visibility-failed', layerId: 'traffic', error: new Error('offline'), + type: 'visibility-failed', + layerId: 'traffic', + error: new Error('offline'), }); const acquiring = createGlobalStatusNotice('ACQUIRING', 100, { state: 'acquiring', @@ -176,71 +323,155 @@ test('persistent acquisition never hides an unrelated manager failure', () => { detail: '', }); assert.equal( - presentGlobalLoadingStatus(acquiring, loading, idle, 300 + LOADING_FAILURE_DWELL_MS - 1).label, + presentGlobalLoadingStatus( + acquiring, + loading, + idle, + 300 + LOADING_FAILURE_DWELL_MS - 1, + ).label, 'LOAD FAILED', ); - loading = reduceLoadingFeedback(loading, idle, 300 + LOADING_FAILURE_DWELL_MS); + loading = reduceLoadingFeedback( + loading, + idle, + 300 + LOADING_FAILURE_DWELL_MS, + ); assert.equal( - presentGlobalLoadingStatus(acquiring, loading, idle, 300 + LOADING_FAILURE_DWELL_MS).label, + presentGlobalLoadingStatus( + acquiring, + loading, + idle, + 300 + LOADING_FAILURE_DWELL_MS, + ).label, 'ACQUIRING', 'the still-owned acquisition resumes only after the full failure dwell is visible', ); }); test('replacement, repetition, and hidden-tab elapsed time use the newest fixed deadline', () => { - const first = createGlobalStatusNotice('Shared satellite is unavailable', 100); - const repeated = createGlobalStatusNotice('Shared satellite is unavailable', 200); - const replacement = createGlobalStatusNotice('Shared satellite follow expired', 300); + const first = createGlobalStatusNotice( + 'Shared satellite is unavailable', + 100, + ); + const repeated = createGlobalStatusNotice( + 'Shared satellite is unavailable', + 200, + ); + const replacement = createGlobalStatusNotice( + 'Shared satellite follow expired', + 300, + ); presentGlobalStatusNotice(first, 100); presentGlobalStatusNotice(repeated, 200); presentGlobalStatusNotice(replacement, 300); - assert.ok(repeated.hideAt > first.hideAt, 'a repeated event is a new accessible notice epoch'); - assert.equal(presentGlobalStatusNotice(replacement, replacement.hideAt - 1).label, 'Shared satellite follow expired'); - assert.equal(presentGlobalStatusNotice(replacement, replacement.hideAt), null, - 'elapsed wall time while hidden expires the notice instead of replaying it'); + assert.ok( + repeated.hideAt > first.hideAt, + 'a repeated event is a new accessible notice epoch', + ); + assert.equal( + presentGlobalStatusNotice(replacement, replacement.hideAt - 1).label, + 'Shared satellite follow expired', + ); + assert.equal( + presentGlobalStatusNotice(replacement, replacement.hideAt), + null, + 'elapsed wall time while hidden expires the notice instead of replaying it', + ); }); test('universal notice lifecycle clears on dispose and uses the one top-center live region', async () => { const ui = readShellSource(); - const html = await renderApplicationHtml(readFileSync(new URL('../index.html', import.meta.url), 'utf8')); + const html = await renderApplicationHtml( + readFileSync(new URL('../index.html', import.meta.url), 'utf8'), + ); const disposeStart = ui.indexOf(' async dispose() {'); const disposeEnd = ui.indexOf('\n }\n', disposeStart); const dispose = ui.slice(disposeStart, disposeEnd); assert.match(dispose, /this\._feedback\.clearLoadingStatus\(\);/); assert.match(dispose, /this\._shareRestoration\.destroy\(\)/); - assert.match(ShareRestoration.prototype.destroy.toString(), /this\._shareTrackingNoticeGeneration \+= 1;/); - assert.match(html, / diff --git a/src/voice/control.js b/src/voice/control.js index 61da65bfc..58cf08f2f 100644 --- a/src/voice/control.js +++ b/src/voice/control.js @@ -107,7 +107,7 @@ function initialState(panel) { t('Hold Space to speak · tap Space to activate focused controls'), }, // A typed chat error reads as chat's, without the microphone hint. - error: { text: '', typed: false }, + error: { text: '', typed: false, hint: true }, // Connection and turn state beside the selected mode's indicator. phase: { text: '' }, voicePanel: { diff --git a/src/voice/geminiSession.js b/src/voice/geminiSession.js index 8996774b3..572db471a 100644 --- a/src/voice/geminiSession.js +++ b/src/voice/geminiSession.js @@ -71,7 +71,9 @@ export function geminiSocketUrl({ token, websocketUrl }) { return url.href; } -// Known local-server failures are translated; unexpected diagnostics stay intact. +// The local server's known failures, marked for translation. Each is kept as +// its English catalog text, which the status words when it is shown, so a +// language change words it again; unexpected diagnostics stay intact. function tokenErrorMessage(error) { switch (error) { case msg('Gemini voice could not connect. Try again.'): @@ -85,7 +87,6 @@ function tokenErrorMessage(error) { case msg('Token request is too large'): case msg('Use an empty JSON token request'): case msg('Method not allowed'): - return t(error); default: return error; } @@ -359,18 +360,35 @@ export class GeminiSessionController { ); } - setStatus(status, detail, recovery) { + /** + * @param {{missingKey?: boolean, params?: object}} [options] `missingKey` + * is true when the server has no Gemini key: the control then drops its + * microphone hint. `params` fill the placeholders of `detail`. + */ + setStatus( + status, + detail, + recovery, + { missingKey = false, params = null } = {}, + ) { this.status = status; if (status === 'listening' && this.input.pushToTalkMode) detail = this.input.pushToTalkKeyHeld ? msg('Release Space to send') : msg('Hold Space to talk'); + // `detail` is worded in the current language; `sourceDetail` keeps the + // message as given (English catalog text, with `sourceParams` for its + // placeholders, when it is ours), so a view can word it again after a + // language change. const event = { type: 'state', state: status, - detail: translateKnown(detail), + detail: params ? t(detail, params) : translateKnown(detail), + ...(typeof detail === 'string' && { sourceDetail: detail }), + ...(params && { sourceParams: params }), }; if (recovery) event.recovery = translateKnown(recovery); + if (missingKey) event.missingKey = true; this.emit(event); this.input.updateVoiceButtonLabel(); if (['idle', 'connecting', 'error'].includes(status)) @@ -584,12 +602,24 @@ export class GeminiSessionController { const data = await readResponseJsonCapped(response, 256 * 1024, signal); signal.throwIfAborted(); if (!response.ok) - throw new Error( + throw Object.assign( + new Error( + typeof data?.error === 'string' + ? tokenErrorMessage(data.error) + : t('Gemini token request failed (HTTP {status})', { + status: response.status, + }), + ), + typeof data?.code === 'string' ? { code: data.code } : {}, + // The status words this again in the current language when shown. typeof data?.error === 'string' - ? tokenErrorMessage(data.error) - : t('Gemini token request failed (HTTP {status})', { - status: response.status, - }), + ? {} + : { + wording: { + text: msg('Gemini token request failed (HTTP {status})'), + params: { status: response.status }, + }, + }, ); if ( typeof data?.model !== 'string' || @@ -963,10 +993,15 @@ export class GeminiSessionController { ) : error?.name === 'TimeoutError' ? msg('Gemini connection timed out. Try again.') - : error?.message || msg('Gemini voice could not start'); - this.lastError = detail; + : error?.wording?.text || + error?.message || + msg('Gemini voice could not start'); + this.lastError = error?.wording ? error.message : detail; this.stop({ preserveStatus: true }); - this.setStatus('error', detail, recovery); + this.setStatus('error', detail, recovery, { + missingKey: error?.code === 'API_KEY_MISSING', + params: error?.wording?.params, + }); } abortTools() { diff --git a/src/voice/geminiSession.test.mjs b/src/voice/geminiSession.test.mjs index 82f403ef7..e441e6915 100644 --- a/src/voice/geminiSession.test.mjs +++ b/src/voice/geminiSession.test.mjs @@ -4,6 +4,7 @@ import { geminiSocketUrl } from './geminiSession.js'; import { createVoiceSession } from './session.js'; import { createPointerTracker } from './pointerContext.js'; import { withToolCatalog } from './gevRealtime.js'; +import { setLocale, t } from '../i18n/index.js'; import { harness, tick, @@ -3638,3 +3639,73 @@ test('cancelling a queued globe reset also skips its dependent rotation', async h.c.stop(); } }); + +test('a missing server key reaches the error state flagged, other token failures do not', async () => { + for (const [body, missingKey] of [ + [ + { + error: 'Add a Gemini API key in Provider Settings', + code: 'API_KEY_MISSING', + }, + true, + ], + [{ error: 'Gemini quota is unavailable. Try again later.' }, undefined], + ]) { + const h = harness({ + fetchImpl: async () => Response.json(body, { status: 503 }), + }); + await h.c.start(); + const error = h.events + .filter((event) => event.type === 'state' && event.state === 'error') + .at(-1); + assert.equal(error?.detail, body.error); + assert.equal(error?.missingKey, missingKey); + assert.equal(h.sockets.length, 0); + } +}); + +test('an error keeps its English catalog text beside the worded detail', async (t2) => { + t2.after(() => setLocale('en')); + setLocale('es'); + const h = harness({ + fetchImpl: async () => + Response.json( + { error: 'Gemini quota is unavailable. Try again later.' }, + { status: 503 }, + ), + }); + await h.c.start(); + const error = h.events + .filter((event) => event.type === 'state' && event.state === 'error') + .at(-1); + assert.equal( + error.sourceDetail, + 'Gemini quota is unavailable. Try again later.', + ); + assert.equal( + error.detail, + t('Gemini quota is unavailable. Try again later.'), + ); + assert.notEqual(error.detail, error.sourceDetail); +}); + +test('a token failure without a message keeps its status for the next language', async (t2) => { + t2.after(() => setLocale('en')); + setLocale('es'); + const h = harness({ + fetchImpl: async () => Response.json({}, { status: 502 }), + }); + await h.c.start(); + const error = h.events + .filter((event) => event.type === 'state' && event.state === 'error') + .at(-1); + assert.equal( + error.sourceDetail, + 'Gemini token request failed (HTTP {status})', + ); + assert.deepEqual(error.sourceParams, { status: 502 }); + assert.equal( + error.detail, + t('Gemini token request failed (HTTP {status})', { status: 502 }), + ); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 2d9334bc8..fdb5dc90c 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -2824,7 +2824,7 @@ async function trackEntity( () => { let trackedOk = false; if (family.kind === 'vessel') { - trackedOk = !!module.selectById?.(found.mmsi); + trackedOk = !!module.selectById?.(found.id ?? found.mmsi); flyToLandmark(viewer, found.latitude, found.longitude, { range: 6000, pitch: -45, @@ -4003,8 +4003,13 @@ function locateLayerEntity(dataManager, { layerId, id, lat, lon }) { ?.module?.getAnalystRecords?.() .filter( (item) => - String(item.icao24 ?? item.mmsi ?? item.noradId ?? item.id) === - String(id), + String( + item.icao24 ?? + item.vesselId ?? + item.mmsi ?? + item.noradId ?? + item.id, + ) === String(id), ) || []; // Bikeshare uses station names as IDs: retain the coordinate identity // even if a namesake disappears. Moving records use their fresh fix. @@ -5344,6 +5349,7 @@ function cameraViewBox(viewer) { /** Identity fields every compact analyst item keeps when present. */ const ANALYST_IDENTITY_FIELDS = Object.freeze([ 'icao24', + 'vesselId', 'mmsi', 'registration', 'label', @@ -5361,6 +5367,7 @@ const ANALYST_CANONICAL_FIELDS = new Set([ 'layerKey', 'id', 'icao24', + 'vesselId', 'mmsi', 'noradId', 'registration', @@ -5373,7 +5380,7 @@ const ANALYST_CANONICAL_FIELDS = new Set([ * follow-up can fly to it), distance, and that layer's manifest fields with * times as ISO text. The full record set stays engine-side for follow-ups. * - * `icao24`/`mmsi` ride along because the tool instructions tell the model to + * `icao24`/`vesselId`/`mmsi` ride along because the tool instructions tell the model to * hand this result straight to track_entity, and `id` is a DISPLAY label * (callsign, else registration, else hex). A callsign-less contact therefore * handed track_entity a tail number the lookup could not resolve, and the diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index 954f4f993..752dbf9c8 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -35,6 +35,7 @@ import { } from './gevRealtime.js'; import { createVoiceCostTracker } from './voiceCost.js'; import { createTestVoiceUi } from '../testSupport/voiceUi.mjs'; +import { setLocale, t } from '../i18n/index.js'; test('push-to-talk recognizes Space by code or key', () => { assert.equal(PUSH_TO_TALK_HOLD_DELAY_MS, 500); @@ -3708,3 +3709,76 @@ test('voice runs app actions itself and other tools through the catalog', async assert.deepEqual(disposals, [['removed']]); assert.equal(withToolCatalog(runner, undefined), runner); }); + +test('the controller forwards viewport escalation diagnostics to its debug log', async () => { + const records = []; + const controller = new GevRealtimeController({ + ui: createTestVoiceUi(), + runner: async () => ({ ok: true }), + radioLayer: { subscribePlaybackControls: () => () => {}, setVoiceDucked() {} }, + }); + controller.debugLog = (event, detail) => records.push({ event, detail }); + controller.dc = { readyState: 'open', send() {}, close() {} }; + + assert.equal( + await controller.sendVisualContextIfUseful({ + action: 'get_entity_context', + scene: { basemap: { viewScale: 'regional' } }, + }), + false, + ); + assert.deepEqual( + records.filter((record) => record.event.startsWith('viewport_context.')), + [{ + event: 'viewport_context.decision', + detail: { contextId: 1, send: false, reason: 'view-scale-not-local', viewScale: 'regional' }, + }], + ); +}); + +test('a missing server key says where to add it, without the microphone hint, in every language', () => { + const ui = createTestVoiceUi(); + const controller = new GevRealtimeController({ + ui, + runner: async () => ({ ok: true }), + radioLayer: { subscribePlaybackControls: () => () => {}, setVoiceDucked() {} }, + }); + try { + controller.reportError( + 'Realtime connection', + Object.assign(new Error('OPENAI_API_KEY is not set'), { code: 'API_KEY_MISSING' }), + ); + assert.equal(ui.state.error.text, 'Needs OPENAI_API_KEY — add it in Provider Settings'); + assert.equal(ui.state.error.hint, false); + + setLocale('es'); + assert.notEqual(ui.state.error.text, 'Needs OPENAI_API_KEY — add it in Provider Settings'); + assert.match(ui.state.error.text, /OPENAI_API_KEY/); + assert.equal(ui.state.error.hint, false); + setLocale('en'); + + controller.reportError('Realtime connection', new Error('ICE failed')); + assert.match(ui.state.error.text, /ICE failed/); + assert.equal(ui.state.error.hint, true); + } finally { + setLocale('en'); + } +}); + +test('a status from a shared helper is worded again after a language change', () => { + const ui = createTestVoiceUi(); + const controller = new GevRealtimeController({ + ui, + runner: async () => ({ ok: true }), + radioLayer: { subscribePlaybackControls: () => () => {}, setVoiceDucked() {} }, + }); + try { + controller.setStatus('listening', 'Radio did not start'); + assert.equal(ui.state.detail.text, 'Radio did not start'); + setLocale('es'); + assert.equal(ui.state.detail.text, t('Radio did not start')); + assert.notEqual(ui.state.detail.text, 'Radio did not start'); + } finally { + setLocale('en'); + } +}); diff --git a/src/voice/pointAndAskReview.test.mjs b/src/voice/pointAndAskReview.test.mjs index c6eba9eda..205e56f32 100644 --- a/src/voice/pointAndAskReview.test.mjs +++ b/src/voice/pointAndAskReview.test.mjs @@ -245,6 +245,31 @@ test('P2-7: duplicate callsigns and names resolve by canonical identity', () => ); }); +test('vessels sharing an MMSI resolve by their vessel id', () => { + const referents = createReferentRegistry(); + referents.recordResult('analyst_query', { + ok: true, + items: ['hull-a', 'hull-b'].map((vesselId) => ({ + layerKey: 'ais-live-vessels', + id: 'EVER GIVEN', + vesselId, + mmsi: '353136000', + name: 'EVER GIVEN', + })), + }); + assert.deepEqual( + [1, 2].map( + (n) => + resolveDeicticArgs( + 'track_entity', + { query: 'x', referent: n }, + { referents }, + ).args.query, + ), + ['hull-a', 'hull-b'], + ); +}); + test('P1-1: a result from before the last clear cannot repopulate the registry', () => { const referents = createReferentRegistry(); const since = referents.generation(); diff --git a/src/voice/providerCommands.test.mjs b/src/voice/providerCommands.test.mjs index a9acd2804..dc358834d 100644 --- a/src/voice/providerCommands.test.mjs +++ b/src/voice/providerCommands.test.mjs @@ -11,6 +11,7 @@ import { createGevActionRunner } from './gevActions.js'; import { createPointerTracker } from './pointerContext.js'; import { createReferentRegistry } from './referents.js'; import { createTestVoiceUi } from '../testSupport/voiceUi.mjs'; +import { setLocale, t } from '../i18n/index.js'; const PROVIDER_KEY = 'gev.voice.provider'; const settle = () => new Promise((resolve) => setImmediate(resolve)); @@ -871,3 +872,83 @@ test('a provider switch hands chat Settings to the new mount', async (t) => { assert.deepEqual(f.mounts[1].options.presentation, shown); assert.equal(f.facade.provider, next); }); + +test('a missing-key error hides the microphone hint; other errors keep it', async (t) => { + const f = compositionFixture(t, {}); + f.select('gemini'); + await settle(); + const instance = f.instances.at(-1); + const ui = f.views.at(-1); + instance.hooks.emit({ + type: 'state', + state: 'error', + detail: 'Add a Gemini API key in Provider Settings', + missingKey: true, + }); + assert.equal( + ui.state.error.text, + 'Add a Gemini API key in Provider Settings', + ); + assert.equal(ui.state.error.hint, false); + instance.hooks.emit({ + type: 'state', + state: 'error', + detail: 'Socket closed', + }); + assert.equal(ui.state.error.text, 'Socket closed'); + assert.equal(ui.state.error.hint, true); +}); + +test('a provider message is worded again after a language change', async (t2) => { + t2.after(() => setLocale('en')); + const f = compositionFixture(t2, {}); + f.select('gemini'); + await settle(); + const instance = f.instances.at(-1); + const ui = f.views.at(-1); + const quota = 'Gemini quota is unavailable. Try again later.'; + instance.hooks.emit({ + type: 'state', + state: 'error', + detail: quota, + sourceDetail: quota, + }); + assert.equal(ui.state.error.text, quota); + setLocale('es'); + assert.equal(ui.state.error.text, t(quota)); + assert.notEqual(ui.state.error.text, quota); + setLocale('en'); + instance.hooks.emit({ + type: 'state', + state: 'listening', + detail: 'Ask or command', + sourceDetail: 'Ask or command', + }); + setLocale('es'); + assert.equal(ui.state.detail.text, t('Ask or command')); + assert.notEqual(ui.state.detail.text, 'Ask or command'); +}); + +test('a provider message with placeholders is worded again with them', async (t2) => { + t2.after(() => setLocale('en')); + const f = compositionFixture(t2, {}); + f.select('gemini'); + await settle(); + const ui = f.views.at(-1); + const text = 'Gemini token request failed (HTTP {status})'; + f.instances.at(-1).hooks.emit({ + type: 'state', + state: 'error', + detail: t(text, { status: 502 }), + sourceDetail: text, + sourceParams: { status: 502 }, + }); + assert.equal(ui.state.error.text, 'Gemini token request failed (HTTP 502)'); + setLocale('es'); + assert.equal(ui.state.error.text, t(text, { status: 502 })); + assert.match(ui.state.error.text, /502/); + assert.notEqual( + ui.state.error.text, + 'Gemini token request failed (HTTP 502)', + ); +}); diff --git a/src/voice/realtimeBackend.js b/src/voice/realtimeBackend.js index ca38432ee..a9c10f2af 100644 --- a/src/voice/realtimeBackend.js +++ b/src/voice/realtimeBackend.js @@ -39,8 +39,9 @@ export function createRealtimeBackend({ if (!response.ok) { const reason = typeof data?.error === 'string' ? data.error : data?.error?.message; - throw new Error( - reason || `Realtime token failed: HTTP ${response.status}`, + throw Object.assign( + new Error(reason || `Realtime token failed: HTTP ${response.status}`), + typeof data?.code === 'string' ? { code: data.code } : {}, ); } const token = diff --git a/src/voice/realtimeBackend.test.mjs b/src/voice/realtimeBackend.test.mjs index ac96e2374..b60bf8406 100644 --- a/src/voice/realtimeBackend.test.mjs +++ b/src/voice/realtimeBackend.test.mjs @@ -143,3 +143,17 @@ test('controller lifetime stops pending transport and releases resources through assert.equal(controller.stream, null); assert.equal(controller.isActive(), false); }); + +test('a missing server key keeps its code, so the UI can point to Provider Settings', async () => { + const backend = createRealtimeBackend({ + tokenTransport: async () => + Response.json( + { error: 'OPENAI_API_KEY is not set', code: 'API_KEY_MISSING' }, + { status: 503 }, + ), + }); + await assert.rejects(backend.requestToken(), { + message: 'OPENAI_API_KEY is not set', + code: 'API_KEY_MISSING', + }); +}); diff --git a/src/voice/realtimeController.js b/src/voice/realtimeController.js index f545ae787..b85439759 100644 --- a/src/voice/realtimeController.js +++ b/src/voice/realtimeController.js @@ -18,7 +18,7 @@ import { } from './pointerContext.js'; import { shouldPauseRadioForVoice } from './realtimeProtocol.js'; -import { postDebugLog } from './realtimeDiagnostics.js'; +import { missingVoiceKeyText, postDebugLog } from './realtimeDiagnostics.js'; export { readStoredVoiceTier, @@ -54,7 +54,7 @@ export { import { createRealtimeBackend } from './realtimeBackend.js'; -import { t } from '../i18n/index.js'; +import { t, translateKnown } from '../i18n/index.js'; import { voiceStatusLabel } from './control.js'; // Machine states (dataset.status, CSS); voiceStatusLabel() translates labels. @@ -101,6 +101,7 @@ export class GevRealtimeController extends RealtimeFacade { operations: { sendRealtimeEvent: (...args) => this.sendRealtimeEvent(...args), + debugLog: (...args) => this.debugLog(...args), }, }); this._diagnostics = new RealtimeDiagnostics({ @@ -406,9 +407,24 @@ export class GevRealtimeController extends RealtimeFacade { } } - setStatus(status, detail) { + /** + * @param {string} status + * @param {string} [detail] + * @param {{missingKey?: boolean}} [options] True when the error is a + * missing server key: it is worded in the current language and shown + * without the microphone hint. + */ + setStatus(status, detail, { missingKey = false } = {}) { this.status = status; - this.emitSessionEvent({ type: 'state', state: status, detail }); + // A detail may be English catalog text (from the helpers this controller + // shares with Gemini): it is worded in the current language when shown. + this.emitSessionEvent({ + type: 'state', + state: status, + detail: translateKnown(detail), + ...(typeof detail === 'string' && { sourceDetail: detail }), + ...(missingKey && { missingKey: true }), + }); this.ui.update('root', { status, ...(status === 'error' && { errorDismissed: false }), @@ -424,7 +440,7 @@ export class GevRealtimeController extends RealtimeFacade { ? held ? t('Release Space to send') : t('Hold Space to talk') - : detail; + : translateKnown(detail); const primaryDetail = () => status === 'error' ? t('VOICE UNAVAILABLE') @@ -434,8 +450,12 @@ export class GevRealtimeController extends RealtimeFacade { this.ui.update('error', { text: () => status === 'error' - ? resolvedDetail() || t('Voice session could not be started.') + ? (missingKey && missingVoiceKeyText()) || + resolvedDetail() || + t('Voice session could not be started.') : '', + // "Check microphone permission" is wrong advice for a missing key. + hint: () => !(status === 'error' && missingKey), }); if (status === 'idle' || status === 'connecting' || status === 'error') { this.setVoiceSpeaker('idle'); diff --git a/src/voice/realtimeDiagnostics.js b/src/voice/realtimeDiagnostics.js index 55e078280..6efa71dfc 100644 --- a/src/voice/realtimeDiagnostics.js +++ b/src/voice/realtimeDiagnostics.js @@ -1,3 +1,11 @@ +import { t } from '../i18n/index.js'; + +/** The voice error when the server has no OpenAI key: where to add it. */ +export const missingVoiceKeyText = () => + t('Needs {envVars} — add it in Provider Settings', { + envVars: 'OPENAI_API_KEY', + }); + const ERROR_LOG_LIMIT = 30; const ERROR_STORAGE_KEY = 'gev-realtime-errors'; @@ -83,6 +91,7 @@ function createErrorRecord(source, error, extra = {}) { String(error?.message || '').trim() || 'No browser error message supplied', errorDetail: rtcError?.errorDetail || null, + code: typeof rtcError?.code === 'string' ? rtcError.code : null, sctpCauseCode: rtcError?.sctpCauseCode ?? null, receivedAlert: rtcError?.receivedAlert ?? null, sentAlert: rtcError?.sentAlert ?? null, @@ -91,6 +100,8 @@ function createErrorRecord(source, error, extra = {}) { } function formatErrorForDisplay(record) { + // A missing key is a setup step, not a connection fault: say where to add it. + if (record.code === 'API_KEY_MISSING') return missingVoiceKeyText(); const primary = [record.source, record.message].filter(Boolean).join(': '); const state = [ record.errorDetail && `detail=${record.errorDetail}`, @@ -172,7 +183,11 @@ export class RealtimeDiagnostics { storeErrors(this.errors); console.error('[GEV Realtime]', record); this.debugLog('error', record); - this.setStatus('error', formatErrorForDisplay(record)); + this.setStatus( + 'error', + formatErrorForDisplay(record), + record.code === 'API_KEY_MISSING' ? { missingKey: true } : undefined, + ); return record; } diff --git a/src/voice/realtimeInput.js b/src/voice/realtimeInput.js index 9c9fd85e7..0a43fa6ec 100644 --- a/src/voice/realtimeInput.js +++ b/src/voice/realtimeInput.js @@ -1,4 +1,4 @@ -import { t } from '../i18n/index.js'; +import { msg, t } from '../i18n/index.js'; import { PUSH_TO_TALK_HOLD_DELAY_MS, MICROPHONE_VISUALIZER_GATE, @@ -124,7 +124,7 @@ export class RealtimeInput { this.ui.update('root', { pushToTalk: 'held' }); this.setMicrophoneEnabled(true); if (this.status === 'listening') - this.setStatus('listening', t('Release Space to send')); + this.setStatus('listening', msg('Release Space to send')); } else { this.start({ pushToTalk: true }); // start() performs a controlled stop() before connecting. Restore the @@ -201,7 +201,7 @@ export class RealtimeInput { if (!this.pushToTalkMode) return; this.setMicrophoneEnabled(false); if (this.status === 'listening') - this.setStatus('listening', t('Hold Space to talk')); + this.setStatus('listening', msg('Hold Space to talk')); else this.updateVoiceButtonLabel(); } diff --git a/src/voice/realtimeRadio.js b/src/voice/realtimeRadio.js index 065c55c3b..90abce9e8 100644 --- a/src/voice/realtimeRadio.js +++ b/src/voice/realtimeRadio.js @@ -1,4 +1,4 @@ -import { t } from '../i18n/index.js'; +import { msg } from '../i18n/index.js'; import { silenceRadioForVoice, startPreparedRadioAfterPlaybackReady, @@ -261,7 +261,7 @@ export class RealtimeRadio { if (radioHandoff.result?.ok || radioHandoff.cancelled || !stillCurrent) return; if (this.dc?.readyState === 'open' && !this.userTurnPending) { - this.setStatus('listening', t('Radio did not start')); + this.setStatus('listening', msg('Radio did not start')); this.queueResponseCreate( 'Say exactly one short correction: “The Radio station could not start. Voice is still on.”', ); diff --git a/src/voice/realtimeViewport.js b/src/voice/realtimeViewport.js index e229b6cc2..edfe9ab11 100644 --- a/src/voice/realtimeViewport.js +++ b/src/voice/realtimeViewport.js @@ -72,6 +72,43 @@ const POINTER_CROP_MAX_AGE_MS = 20000; /** View scales where a crop shows buildings and terrain, not a smear. */ const POINTER_CROP_SCALES = new Set(['local', 'city']); +/** + * Reason-coded explanation of the existing visual escalation policy, for an + * entity-context answer on an open channel (the caller checks both). + * Pointer crops remain a separate path from full-frame screenshots. + * These reasons are static diagnostics, never model-generated claims. + */ +export function viewportContextDecision(result) { + const viewScale = result?.scene?.basemap?.viewScale ?? null; + if (result?.scope === 'pointer') { + if (result.selected) + return { send: false, reason: 'pointer-identity-present', viewScale }; + if (!result.pointer) + return { send: false, reason: 'pointer-missing', viewScale }; + // The crop path checks ground, freshness and scale itself, and logs why. + return { + send: true, + kind: 'crop', + reason: 'pointer-crop-eligible', + viewScale, + }; + } + if (!shouldSendViewportImage(viewScale)) + return { send: false, reason: 'view-scale-not-local', viewScale }; + if (hasStructuredViewIdentity(result)) + return { + send: false, + reason: 'structured-identity-sufficient', + viewScale, + }; + return { + send: true, + kind: 'viewport', + reason: 'structured-identity-missing', + viewScale, + }; +} + /** * Square source rectangle around a canvas point, clamped inside the canvas, * in drawing-buffer pixels, plus where the point lands in the output image. @@ -299,6 +336,16 @@ export function isBenignViewportDeleteError(payload, pendingDeleteIds = null) { return code === 'item_not_found'; } +/** Pairs one visual-context request's outcome with its decision. */ +function diagnosticsRecord(contextId = null) { + return { contextId, logged: false }; +} + +function reportOutcome(owner, diag, event, kind, reason) { + diag.logged = true; + owner.debugLog?.(event, { contextId: diag.contextId, kind, reason }); +} + /** Own the one retained viewport image and bounded pending deletion identities. */ export class RealtimeViewport { constructor({ @@ -319,6 +366,8 @@ export class RealtimeViewport { // Bumped by every capture request and every new user turn: only the // newest request of the current turn may publish. this.captureSeq = 0; + // Diagnostics only: pairs each visual-context decision with its outcome. + this.contextSeq = 0; this.lastPointerCrop = null; // What the retained image shows: `{kind:'viewport'|'crop', rect, cameraKey}`. // Pixel targeting (screenX/screenY) is read against it. @@ -330,28 +379,51 @@ export class RealtimeViewport { return this.readChannel(); } + /** + * Escalate an entity-context answer to an image when the gate allows it. + * Logs one `viewport_context.decision`, then, for a send decision, exactly + * one `.skipped` or `.result` outcome, even when a step throws. Every + * event carries the same `contextId` and the image `kind`, so overlapping + * requests pair up. + */ async sendVisualContextIfUseful(result) { + // Runs after every tool output. Only entity context on an open channel + // can escalate, so anything else logs nothing and leaves the shared + // debug-log budget alone. if ( result?.action !== 'get_entity_context' || !this.dc || this.dc.readyState !== 'open' ) return false; - const viewScale = result.scene?.basemap?.viewScale; - // Point-and-ask on bare ground: the crop around the pointer replaces the - // whole-frame screenshot, and is pulled only when nothing was named. - if (result.scope === 'pointer') { - if (result.selected || !result.pointer) return false; - return this.sendPointerCrop(this.readPointer(), { viewScale }); + const contextId = ++this.contextSeq; + const decision = viewportContextDecision(result); + this.debugLog?.('viewport_context.decision', { contextId, ...decision }); + if (!decision.send) return false; + const diag = diagnosticsRecord(contextId); + try { + // #837's pointer crop is separate from the full viewport: preserve it. + if (decision.kind === 'crop') + return await this.sendPointerCrop(this.readPointer(), { + viewScale: decision.viewScale, + diag, + }); + return await this.sendRetainedImage( + () => this.capture(), + "Current God's Eye View viewport screenshot. Read any clearly visible street, building, and place labels in the image and combine them with the structured nearbyPlaces, streetLabels, and scene context. Do not invent labels that are not legible.", + 'client.viewport_context', + { kind: 'viewport', diag }, + ); + } catch (error) { + // The caller logs the error itself; close the pair, then rethrow. + if (!diag.logged) + this.debugLog?.('viewport_context.skipped', { + contextId, + kind: decision.kind, + reason: 'error', + }); + throw error; } - if (!shouldSendViewportImage(viewScale)) return false; - if (hasStructuredViewIdentity(result)) return false; - return this.sendRetainedImage( - () => this.capture(), - "Current God's Eye View viewport screenshot. Read any clearly visible street, building, and place labels in the image and combine them with the structured nearbyPlaces, streetLabels, and scene context. Do not invent labels that are not legible.", - 'client.viewport_context', - { kind: 'viewport' }, - ); } /** A new user turn: captures still in flight from the last one are dropped. */ @@ -365,27 +437,39 @@ export class RealtimeViewport { * @param {object|null} snapshot Fresh pointer snapshot on bare ground. * @returns {Promise} */ - async sendPointerCrop(snapshot, { viewScale } = {}) { - if (!snapshot?.fresh || snapshot.target !== 'ground' || !snapshot.screenPx) + async sendPointerCrop( + snapshot, + { viewScale, diag = diagnosticsRecord() } = {}, + ) { + const skip = (reason) => { + reportOutcome(this, diag, 'viewport_context.skipped', 'crop', reason); return false; + }; + if (!snapshot?.fresh) return skip('pointer-not-fresh'); + if (snapshot.target !== 'ground') return skip('pointer-not-ground'); + if (!snapshot.screenPx) return skip('pointer-off-screen'); // Too far out, the crop is a smear: every path shares this guard. - if (!POINTER_CROP_SCALES.has(viewScale)) return false; - if (snapshot === this.lastPointerCrop) return false; + if (!POINTER_CROP_SCALES.has(viewScale)) + return skip('pointer-scale-not-useful'); + // Set before the capture finishes, so this also covers one in flight. + if (snapshot === this.lastPointerCrop) + return skip('pointer-already-requested'); const capturedAt = Number(snapshot.capturedAt) || 0; - if (this.now() - capturedAt > POINTER_CROP_MAX_AGE_MS) return false; + if (this.now() - capturedAt > POINTER_CROP_MAX_AGE_MS) + return skip('pointer-expired'); // The snapshot's pixels only name its own camera pose. const snapshotCameraKey = snapshot.cameraKey; - if (!snapshotCameraKey) return false; + if (!snapshotCameraKey) return skip('pointer-pose-unknown'); const samePose = () => snapshotCameraKey === this.readCameraKey() && this.now() - capturedAt <= POINTER_CROP_MAX_AGE_MS; - if (!samePose()) return false; + if (!samePose()) return skip('pose-changed'); this.lastPointerCrop = snapshot; const sent = await this.sendRetainedImage( () => this.capturePointer(snapshot.screenPx), "Crop of the map around the user's pointer; the ring marks the exact spot they mean by this or here. Describe only what is clearly visible.", 'client.pointer_crop', - { kind: 'crop', valid: samePose }, + { kind: 'crop', valid: samePose, diag }, ); if (!sent && this.lastPointerCrop === snapshot) this.lastPointerCrop = null; return sent; @@ -393,22 +477,40 @@ export class RealtimeViewport { /** Capture one image and make it the single retained image in context. */ async sendRetainedImage(captureImage, text, label, frame = {}) { - if (!this.dc || this.dc.readyState !== 'open') return false; + const kind = frame.kind || 'viewport'; + const diag = frame.diag || diagnosticsRecord(); + const report = (event, reason) => + reportOutcome(this, diag, event, kind, reason); + if (!this.dc || this.dc.readyState !== 'open') { + report('viewport_context.skipped', 'channel-lost'); + return false; + } const generation = this.generation; const request = ++this.captureSeq; const channel = this.dc; const captured = await captureImage(); const imageUrl = typeof captured === 'string' ? captured : captured?.dataUrl || null; - if ( - !imageUrl || - generation !== this.generation || - request !== this.captureSeq || - this.dc !== channel || - channel.readyState !== 'open' || - (typeof frame.valid === 'function' && !frame.valid()) - ) + if (!imageUrl) { + report('viewport_context.skipped', 'capture-unavailable'); return false; + } + // Same guards as before, checked in order so each drop names its cause: + // reset() bumps both counters, so a session reset is tested first. + const invalidated = + generation !== this.generation + ? 'session-reset' + : request !== this.captureSeq + ? 'superseded' + : this.dc !== channel || channel.readyState !== 'open' + ? 'channel-lost' + : typeof frame.valid === 'function' && !frame.valid() + ? 'frame-stale' + : null; + if (invalidated) { + report('viewport_context.skipped', invalidated); + return false; + } // Keep at most one viewport screenshot in context. Images are the single // most expensive item (re-billed every turn they linger), so we proactively @@ -461,10 +563,16 @@ export class RealtimeViewport { // the item we just deleted as null and fall through so the caller still // issues queueResponseCreate WITHOUT the image, instead of stranding the turn. const sent = this.sendRealtimeEvent(contextEvent, label); + // Report before the bookkeeping below, so a later throw cannot turn a + // real send into a logged "error". + report( + 'viewport_context.result', + sent ? 'sent' : 'data-channel-send-failed', + ); this.lastViewportItemId = sent ? newItemId : null; this.retainedFrame = sent ? { - kind: frame.kind || 'viewport', + kind, rect: captured?.rect || null, cameraKey: this.readCameraKey(), } diff --git a/src/voice/realtimeViewportPolicy.test.mjs b/src/voice/realtimeViewportPolicy.test.mjs new file mode 100644 index 000000000..94f8c9379 --- /dev/null +++ b/src/voice/realtimeViewportPolicy.test.mjs @@ -0,0 +1,615 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + RealtimeViewport, + viewportContextDecision, +} from './realtimeViewport.js'; + +const localContext = (overrides = {}) => ({ + action: 'get_entity_context', + scene: { basemap: { viewScale: 'local' } }, + ...overrides, +}); + +test('viewport context reasons preserve the existing escalation gate', () => { + assert.deepEqual( + viewportContextDecision({ + action: 'get_entity_context', + scene: { basemap: { viewScale: 'regional' } }, + }), + { + send: false, + reason: 'view-scale-not-local', + viewScale: 'regional', + }, + ); + assert.deepEqual( + viewportContextDecision( + localContext({ + selected: { + id: 'earthquake:1', + layerId: 'earthquakes', + }, + }), + ), + { + send: false, + reason: 'structured-identity-sufficient', + viewScale: 'local', + }, + ); + assert.deepEqual(viewportContextDecision(localContext()), { + send: true, + kind: 'viewport', + reason: 'structured-identity-missing', + viewScale: 'local', + }); +}); + +test('nearby structured place identity avoids visual escalation too', () => { + assert.deepEqual( + viewportContextDecision( + localContext({ + scene: { + basemap: { + viewScale: 'local', + nearbyPlaces: [{ name: 'Austin' }], + }, + }, + }), + ), + { + send: false, + reason: 'structured-identity-sufficient', + viewScale: 'local', + }, + ); +}); + +test('pointer ground selects the #837 crop path, not a full viewport', () => { + const pointerContext = localContext({ + scope: 'pointer', + pointer: { target: 'ground' }, + scene: { basemap: { viewScale: 'city' } }, + }); + assert.deepEqual(viewportContextDecision(pointerContext), { + send: true, + kind: 'crop', + reason: 'pointer-crop-eligible', + viewScale: 'city', + }); + assert.deepEqual( + viewportContextDecision({ + ...pointerContext, + scene: { basemap: { viewScale: 'global' } }, + }), + { + send: true, + kind: 'crop', + reason: 'pointer-crop-eligible', + viewScale: 'global', + }, + ); + assert.deepEqual( + viewportContextDecision({ + ...pointerContext, + selected: { id: 'building' }, + }), + { + send: false, + reason: 'pointer-identity-present', + viewScale: 'city', + }, + ); + assert.deepEqual( + viewportContextDecision({ ...pointerContext, pointer: null }), + { send: false, reason: 'pointer-missing', viewScale: 'city' }, + ); +}); + +test('runtime dispatch preserves pointer crop and full image separation', async () => { + const channel = { readyState: 'open' }; + const pointer = { fresh: true, target: 'ground' }; + const calls = []; + const viewport = new RealtimeViewport({ + readChannel: () => channel, + readPointer: () => pointer, + operations: { + debugLog: (event, detail) => calls.push({ event, detail }), + }, + capture: async () => 'unused', + }); + viewport.sendPointerCrop = async (snapshot, options) => { + calls.push({ kind: 'crop', snapshot, options }); + return true; + }; + viewport.sendRetainedImage = async () => { + calls.push({ kind: 'viewport' }); + return true; + }; + + assert.equal( + await viewport.sendVisualContextIfUseful( + localContext({ + scope: 'pointer', + pointer: { target: 'ground' }, + scene: { basemap: { viewScale: 'city' } }, + }), + ), + true, + ); + assert.equal(calls.filter((call) => call.kind === 'crop').length, 1); + assert.deepEqual( + calls.find((call) => call.kind === 'crop').snapshot, + pointer, + ); + assert.equal( + calls.find((call) => call.kind === 'crop').options.viewScale, + 'city', + ); + assert.equal(calls.filter((call) => call.kind === 'viewport').length, 0); + + assert.equal(await viewport.sendVisualContextIfUseful(localContext()), true); + assert.equal(calls.filter((call) => call.kind === 'viewport').length, 1); + assert.equal( + await viewport.sendVisualContextIfUseful( + localContext({ selected: { id: 'existing' } }), + ), + false, + ); + assert.equal(calls.filter((call) => call.kind === 'viewport').length, 1); + assert.ok( + calls.some( + (call) => + call.event === 'viewport_context.decision' && + call.detail.reason === 'pointer-crop-eligible', + ), + ); +}); + +test('closed channel never captures pointer or full viewport', async () => { + const viewport = new RealtimeViewport({ + readChannel: () => ({ readyState: 'closed' }), + readPointer: () => { + throw new Error('should not inspect pointer when channel is closed'); + }, + operations: {}, + }); + assert.equal( + await viewport.sendVisualContextIfUseful( + localContext({ scope: 'pointer', pointer: { target: 'ground' } }), + ), + false, + ); + assert.equal(await viewport.sendVisualContextIfUseful(localContext()), false); +}); + +test('capture failures and invalidated turns are reason-coded without sending', async () => { + const channel = { readyState: 'open' }; + const diagnostics = []; + const viewport = new RealtimeViewport({ + readChannel: () => channel, + operations: { + sendRealtimeEvent: () => { + throw new Error('should not send unavailable image'); + }, + debugLog: (event, detail) => diagnostics.push({ event, detail }), + }, + capture: async () => null, + }); + assert.equal(await viewport.sendVisualContextIfUseful(localContext()), false); + assert.ok( + diagnostics.some( + (item) => + item.event === 'viewport_context.skipped' && + item.detail.reason === 'capture-unavailable', + ), + ); + let release; + viewport.capture = () => + new Promise((resolve) => { + release = resolve; + }); + const pending = viewport.sendVisualContextIfUseful(localContext()); + viewport.reset(); + release('data:image/jpeg;base64,AA=='); + assert.equal(await pending, false); + assert.ok( + diagnostics.some( + (item) => + item.event === 'viewport_context.skipped' && + item.detail.reason === 'session-reset', + ), + ); +}); + +test('successful retained send is logged without recording image contents', async () => { + const channel = { readyState: 'open' }; + const diagnostics = []; + const viewport = new RealtimeViewport({ + readChannel: () => channel, + readCameraKey: () => 'camera:fixture', + operations: { + sendRealtimeEvent: () => true, + debugLog: (event, detail) => diagnostics.push({ event, detail }), + }, + capture: async () => 'data:image/jpeg;base64,AA==', + }); + assert.equal(await viewport.sendVisualContextIfUseful(localContext()), true); + assert.ok( + diagnostics.some( + (item) => + item.event === 'viewport_context.result' && + item.detail.reason === 'sent', + ), + ); + assert.equal(JSON.stringify(diagnostics).includes('data:image/jpeg'), false); +}); + +const outcomeEvents = new Set([ + 'viewport_context.skipped', + 'viewport_context.result', +]); + +function recordingViewport(options = {}) { + const events = []; + const channel = options.channel ?? { readyState: 'open' }; + const viewport = new RealtimeViewport({ + readChannel: () => channel, + readCameraKey: () => 'camera:fixture', + now: () => 1000, + capturePointer: async () => ({ + dataUrl: 'data:image/jpeg;base64,AA==', + rect: { x: 0, y: 0, width: 1, height: 1 }, + }), + capture: async () => 'data:image/jpeg;base64,AA==', + ...options, + operations: { + sendRealtimeEvent: () => true, + debugLog: (event, detail) => events.push({ event, detail }), + ...options.operations, + }, + }); + return { viewport, events }; +} + +const pointerGround = (overrides = {}) => + localContext({ + scope: 'pointer', + pointer: { target: 'ground' }, + scene: { basemap: { viewScale: 'city' } }, + ...overrides, + }); + +const groundSnapshot = (overrides = {}) => ({ + fresh: true, + target: 'ground', + screenPx: { x: 10, y: 10 }, + capturedAt: 900, + cameraKey: 'camera:fixture', + ...overrides, +}); + +test('tool results other than entity context log nothing', async () => { + const { viewport, events } = recordingViewport(); + assert.equal( + await viewport.sendVisualContextIfUseful({ action: 'fly_to' }), + false, + ); + assert.equal(await viewport.sendVisualContextIfUseful(null), false); + assert.deepEqual(events, []); +}); + +test('a crop decision that cannot send logs why, under the same id', async () => { + const cases = [ + [{ fresh: false }, 'pointer-not-fresh'], + [{ target: 'building' }, 'pointer-not-ground'], + [{ screenPx: null }, 'pointer-off-screen'], + [{ capturedAt: -50000 }, 'pointer-expired'], + [{ cameraKey: null }, 'pointer-pose-unknown'], + [{ cameraKey: 'camera:moved' }, 'pose-changed'], + ]; + for (const [override, reason] of cases) { + const snapshot = groundSnapshot(override); + const { viewport, events } = recordingViewport({ + readPointer: () => snapshot, + }); + assert.equal( + await viewport.sendVisualContextIfUseful(pointerGround()), + false, + reason, + ); + assert.equal(events.length, 2, reason); + const [decision, outcome] = events; + assert.equal(decision.event, 'viewport_context.decision'); + assert.equal(decision.detail.reason, 'pointer-crop-eligible'); + assert.deepEqual(outcome, { + event: 'viewport_context.skipped', + detail: { contextId: decision.detail.contextId, kind: 'crop', reason }, + }); + } +}); + +test('a repeated pointer snapshot is logged as already requested', async () => { + const snapshot = groundSnapshot(); + const { viewport, events } = recordingViewport({ + readPointer: () => snapshot, + }); + assert.equal(await viewport.sendVisualContextIfUseful(pointerGround()), true); + assert.equal( + await viewport.sendVisualContextIfUseful(pointerGround()), + false, + ); + const outcomes = events.filter((item) => outcomeEvents.has(item.event)); + assert.deepEqual( + outcomes.map((item) => [item.detail.kind, item.detail.reason]), + [ + ['crop', 'sent'], + ['crop', 'pointer-already-requested'], + ], + ); +}); + +test('overlapping requests pair each outcome with its own decision', async () => { + const releases = []; + const { viewport, events } = recordingViewport({ + capture: () => new Promise((resolve) => releases.push(resolve)), + }); + const first = viewport.sendVisualContextIfUseful(localContext()); + const second = viewport.sendVisualContextIfUseful(localContext()); + await Promise.resolve(); + releases[1]('data:image/jpeg;base64,AA=='); + releases[0]('data:image/jpeg;base64,AA=='); + assert.equal(await first, false); + assert.equal(await second, true); + const [firstId, secondId] = events + .filter((item) => item.event === 'viewport_context.decision') + .map((item) => item.detail.contextId); + assert.notEqual(firstId, secondId); + const outcomeFor = (id) => + events.filter( + (item) => outcomeEvents.has(item.event) && item.detail.contextId === id, + ); + assert.deepEqual( + outcomeFor(firstId).map((item) => item.detail), + [{ contextId: firstId, kind: 'viewport', reason: 'superseded' }], + ); + assert.deepEqual( + outcomeFor(secondId).map((item) => item.detail), + [{ contextId: secondId, kind: 'viewport', reason: 'sent' }], + ); +}); + +test('dropped captures name their cause', async () => { + const reasonFor = async (context, interrupt, extra = {}) => { + let release; + const pending = () => new Promise((resolve) => (release = resolve)); + const { viewport, events } = recordingViewport({ + capture: pending, + capturePointer: pending, + ...extra, + }); + const sent = viewport.sendVisualContextIfUseful(context); + await Promise.resolve(); + interrupt(viewport); + release('data:image/jpeg;base64,AA=='); + assert.equal(await sent, false); + return events + .filter((item) => outcomeEvents.has(item.event)) + .map((item) => item.detail.reason); + }; + + assert.deepEqual( + await reasonFor(localContext(), (viewport) => viewport.beginTurn()), + ['superseded'], + ); + assert.deepEqual( + await reasonFor(localContext(), (viewport) => viewport.reset()), + ['session-reset'], + ); + const channel = { readyState: 'open' }; + assert.deepEqual( + await reasonFor(localContext(), () => (channel.readyState = 'closed'), { + channel, + }), + ['channel-lost'], + ); + let cameraKey = 'camera:fixture'; + const snapshot = groundSnapshot(); + assert.deepEqual( + await reasonFor(pointerGround(), () => (cameraKey = 'camera:moved'), { + readPointer: () => snapshot, + readCameraKey: () => cameraKey, + }), + ['frame-stale'], + ); +}); + +test('every send decision is followed by exactly one outcome', async () => { + const snapshots = [ + null, + groundSnapshot(), + groundSnapshot({ fresh: false }), + groundSnapshot({ cameraKey: 'camera:moved' }), + groundSnapshot({ capturedAt: -50000 }), + ]; + const contexts = [ + localContext(), + localContext({ selected: { id: 'x', layerId: 'y' } }), + pointerGround(), + pointerGround({ scene: { basemap: { viewScale: 'global' } } }), + pointerGround({ pointer: null }), + ]; + const captures = [ + async () => 'data:image/jpeg;base64,AA==', + async () => null, + ]; + for (const snapshot of snapshots) + for (const context of contexts) + for (const capture of captures) + for (const sendResult of [true, false]) { + const { viewport, events } = recordingViewport({ + readPointer: () => snapshot, + capture, + capturePointer: capture, + operations: { sendRealtimeEvent: () => sendResult }, + }); + await viewport.sendVisualContextIfUseful(context); + const [decision, ...rest] = events; + assert.equal(decision.event, 'viewport_context.decision'); + const outcomes = rest.filter((item) => outcomeEvents.has(item.event)); + assert.equal(outcomes.length, decision.detail.send ? 1 : 0); + for (const outcome of outcomes) { + assert.equal(outcome.detail.contextId, decision.detail.contextId); + assert.equal(outcome.detail.kind, decision.detail.kind); + if (outcome.event === 'viewport_context.result') + assert.equal( + outcome.detail.reason, + sendResult ? 'sent' : 'data-channel-send-failed', + ); + } + } +}); + +test('a failed data-channel send is never logged as sent', async () => { + for (const [sendResult, reason] of [ + [true, 'sent'], + [false, 'data-channel-send-failed'], + ]) { + const { viewport, events } = recordingViewport({ + operations: { sendRealtimeEvent: () => sendResult }, + }); + assert.equal( + await viewport.sendVisualContextIfUseful(localContext()), + sendResult, + ); + const outcomes = events.filter((item) => outcomeEvents.has(item.event)); + assert.deepEqual( + outcomes.map((item) => [item.event, item.detail.reason]), + [['viewport_context.result', reason]], + ); + } +}); + +test('a step that throws still closes its decision, then rethrows', async () => { + for (const [context, extra] of [ + [ + localContext(), + { + capture: async () => { + throw new Error('capture boom'); + }, + }, + ], + [ + pointerGround(), + { + readPointer: () => { + throw new Error('pointer boom'); + }, + }, + ], + [ + pointerGround(), + { + readPointer: () => groundSnapshot(), + capturePointer: async () => { + throw new Error('crop boom'); + }, + }, + ], + ]) { + const { viewport, events } = recordingViewport(extra); + await assert.rejects(viewport.sendVisualContextIfUseful(context), /boom/); + const [decision, ...rest] = events; + assert.deepEqual(rest, [ + { + event: 'viewport_context.skipped', + detail: { + contextId: decision.detail.contextId, + kind: decision.detail.kind, + reason: 'error', + }, + }, + ]); + } +}); + +test('direct calls log their defensive skips without a decision id', async () => { + const open = recordingViewport(); + assert.equal( + await open.viewport.sendPointerCrop(groundSnapshot(), { + viewScale: 'global', + }), + false, + ); + const closed = recordingViewport({ channel: { readyState: 'closed' } }); + assert.equal( + await closed.viewport.sendPointerCrop(groundSnapshot(), { + viewScale: 'city', + }), + false, + ); + assert.equal( + await closed.viewport.sendRetainedImage( + async () => 'data:image/jpeg;base64,AA==', + 'text', + 'client.viewport_context', + ), + false, + ); + assert.deepEqual( + [...open.events, ...closed.events].map((item) => item.detail), + [ + { contextId: null, kind: 'crop', reason: 'pointer-scale-not-useful' }, + { contextId: null, kind: 'crop', reason: 'channel-lost' }, + { contextId: null, kind: 'viewport', reason: 'channel-lost' }, + ], + ); +}); + +test('a throw after a real send still logs it as sent', async () => { + let reads = 0; + const { viewport, events } = recordingViewport({ + // The first read happens only after the image was handed to the channel. + readCameraKey: () => { + reads += 1; + throw new Error('camera gone'); + }, + }); + await assert.rejects( + viewport.sendVisualContextIfUseful(localContext()), + /camera gone/, + ); + assert.equal(reads, 1); + const outcomes = events.filter((item) => outcomeEvents.has(item.event)); + assert.deepEqual( + outcomes.map((item) => [item.event, item.detail.reason]), + [['viewport_context.result', 'sent']], + ); +}); + +test('a second request for a snapshot still being captured is not sent twice', async () => { + const releases = []; + const snapshot = groundSnapshot(); + const { viewport, events } = recordingViewport({ + readPointer: () => snapshot, + capturePointer: () => new Promise((resolve) => releases.push(resolve)), + }); + const first = viewport.sendVisualContextIfUseful(pointerGround()); + const second = viewport.sendVisualContextIfUseful(pointerGround()); + await Promise.resolve(); + for (const release of releases) + release({ dataUrl: 'data:image/jpeg;base64,AA==', rect: {} }); + assert.deepEqual([await first, await second], [true, false]); + assert.equal(releases.length, 1); + const outcomes = events.filter((item) => outcomeEvents.has(item.event)); + assert.deepEqual( + outcomes.map((item) => [item.detail.contextId, item.detail.reason]), + [ + [2, 'pointer-already-requested'], + [1, 'sent'], + ], + ); +}); diff --git a/src/voice/referents.js b/src/voice/referents.js index 590cff67d..49d7ef057 100644 --- a/src/voice/referents.js +++ b/src/voice/referents.js @@ -23,10 +23,17 @@ function finite(value) { function normalize(entry, index) { if (!entry || typeof entry !== 'object') return null; - // Identity is the canonical key (transponder hex, MMSI, NORAD id); a + // Identity is the canonical key (transponder hex, vessel id, NORAD id); a // display `id` such as a callsign is only a label, and two contacts can - // share one. - const id = entry.icao24 ?? entry.mmsi ?? entry.noradId ?? entry.id ?? null; + // share one. Vessels can share an MMSI too, so a vessel's key is its + // `vesselId` when the row names one. + const id = + entry.icao24 ?? + entry.vesselId ?? + entry.mmsi ?? + entry.noradId ?? + entry.id ?? + null; const label = String( entry.label ?? entry.callsign ?? entry.name ?? entry.id ?? id ?? '', ).trim(); diff --git a/src/voice/sessionCommands.js b/src/voice/sessionCommands.js index 242a2126b..2add30636 100644 --- a/src/voice/sessionCommands.js +++ b/src/voice/sessionCommands.js @@ -7,7 +7,7 @@ import { createVoiceSession, isStoppable } from './session.js'; import { VoiceCardControls } from './voiceCard.js'; import { PointerReticle } from './pointerReticle.js'; import { VOICE_PROVIDERS } from './providerCapabilities.js'; -import { t } from '../i18n/index.js'; +import { t, translateKnown } from '../i18n/index.js'; function createDefaultReticle(pointer) { const element = pointer?.element; @@ -114,11 +114,20 @@ export function createVoiceCommands({ // States are machine values; only the label is translated. ui.update('status', { text: () => voiceStatusLabel(event.state) }); // The portable session reports 'Voice off' in English: show the idle - // label instead. Other details are provider messages, shown as they come. + // label instead. Other details are provider messages; one that keeps its + // English catalog text is worded again in the current language. const idle = event.state === 'idle'; - const detail = event.detail === 'Voice off' ? '' : event.detail; + const source = event.sourceDetail ?? event.detail; + const detail = () => + source === 'Voice off' + ? '' + : event.sourceDetail + ? event.sourceParams + ? t(event.sourceDetail, event.sourceParams) + : translateKnown(event.sourceDetail) + : event.detail; ui.update('detail', { - text: () => detail || (idle ? t('Voice off') : t('Voice active')), + text: () => detail() || (idle ? t('Voice off') : t('Voice active')), }); // A typed chat session is not the microphone. ui.update('button', { @@ -127,8 +136,10 @@ export function createVoiceCommands({ ui.update('error', { text: () => event.state === 'error' - ? event.detail || t('Voice could not be started.') + ? detail() || t('Voice could not be started.') : '', + // "Check microphone permission" is wrong advice for a missing key. + hint: () => !(event.state === 'error' && event.missingKey === true), }); }); // Captions, plan and result card nested in the control; it only reads diff --git a/src/voice/speech.js b/src/voice/speech.js index 158c37b78..dbff4ad4d 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -501,7 +501,7 @@ function frameOverhead(result) { .filter((entry) => entry?.label || entry?.id) .map((entry, index) => ({ n: index + 1, - id: entry.icao24 || entry.mmsi || entry.id || null, + id: entry.icao24 || entry.id || entry.mmsi || null, label: spokenLabel(entry.label || entry.id), layerId: result.layerId, }));