diff --git a/README.md b/README.md index f70bd2f81..4d5998aca 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic - **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director. - **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. If you choose **USE CHATGPT OAUTH** in Provider Settings, the local server uses that sign-in only to mint the same kind of short-lived Realtime credential, and refuses the OAuth path for non-loopback clients. -Thirty tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook: +Thirty-five tools (thirty-four unless an Overpass server is set), four jobs — the commands below come straight from the product's voice test suite and tool playbook: **🎥 Direct it** — drone-operator camera verbs: diff --git a/SECURITY.md b/SECURITY.md index e12a77025..43ee7b6bd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -132,6 +132,7 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp` - **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller. - **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`. - **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed. +- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,,` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; freshness is judged only by the result's `issued_at`, so a result older than five minutes only arms, whether the panel was mounted again (reopening a conversation) or got it later on the same mount, while one re-sent or remounted within five minutes of issue opens the photo again; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The OPEN press uses the link's point only while the ground under the screen centre is within 1 km of it, or when the screen centre cannot be picked at all (no ground picking, or a globe with no size yet); with the sky in the middle it opens nothing. The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link pasted into an open tab follows the same rule, and a link's source switch and layer are never saved as the recipient's own preferences. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image. ## Scope & expectations diff --git a/changelog.d/1113-show-street-level.md b/changelog.d/1113-show-street-level.md new file mode 100644 index 000000000..233a6bd16 --- /dev/null +++ b/changelog.d/1113-show-street-level.md @@ -0,0 +1,18 @@ +- "Show me X on Street View" (or on Mapillary, KartaView or Panoramax): a + `show_street_level` voice and typed action flies to the place, switches to + Google 3D when the source needs it, lights that source in Street Level, + turns the layer on and opens the nearest photo, saying which step failed. + With no place named it looks under the screen centre, as OPEN does. Views + and share links carry the same request (`street_level`, `sl=`). A plain + `sl=google` link never loads a billed panorama by itself: it arms Street + View and leaves the load to OPEN GOOGLE STREET VIEW; embedded views open it, + and so does the God's Eye View panel for a result issued in the last five + minutes; a link whose source has no working key says so. A request stops + when the user navigates, or enters cockpit, clean view, recording or scene + playback, before its photo loads (collapsing the panel does not stop it: the + photo reveals it); a new photo replaces the open one in place, so EXPAND + stays. When nothing is found and a 360°, flat or SINCE filter is on, the + answer names the filter. Failure cards are in the interface language. An + unavailable map stack now reports the + controller's own reason instead of always asking for a Cesium ion token + (Said Turksever, @asturksever, #1113). 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 06ead3ffb..9af3fed2e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1137,7 +1137,7 @@ Voice controls bind to a protocol-independent session factory. The default WebRT 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. +Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 35 tools using separate description-only metadata; a session is offered 34 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. Voice areas live in a store owned by the action runner for the page (`src/data/areaStore.js`); the model gets an `areaId`, a rounded bounding box and a summary, never geometry. `resolve_area` (`src/voice/areaActions.js`, `src/data/areaResolver.js`) checks the store, bundled Natural Earth regions, then bundled countries and states by name — a name several prominent units share returns up to four candidates unless the view is inside exactly one — and then the annotation outline ladder (`resolveAnnotationTarget`: bundled counties, Census places and neighborhoods, the guarded `/api/geocode/outline`, and Overpass only when configured). Administrative geometry is stored only when its source identity agrees with the requested unit, by name or by a form the bundled matcher reads as that unit at its level ("State of Texas", "Province of Bagmati", "USA", "UK"; not "Great Britain" or "Korea"); stale, mismatched, synthesized or otherwise unverified administrative boundaries are refused rather than relabelled. Clarification IDs use the bounded `ne::` grammar and are accepted only from the current resolver's candidate memory. A lookup past 8 s answers `AREA_TIMEOUT` and keeps filling the caches. `around=true` asks the same ladder for the grounds enclosing a landmark (Nominatim, then the OpenFreeMap area holding it) and otherwise stores a 250 m buffer marked approximate. `analyst_query` scopes add `area`, `drawn` (newest draw-tool outline) and `annotation` (newest or named outline); region scopes use the same resolver within 3 s. Drawn outlines are a simplified copy (≤1,200 vertices); counting reads the stored geometry (`src/data/areaGeometry.js`). `find_imagery` (`src/voice/imageryActions.js`) sets the Recent Imagery box from an area, the view or the turn's pointer snapshot and pins the best acquisition; any hand change to the panel or explicit layer OFF while it searches ends the request (`DISPLACED`) without pinning or success narration. `osm_query` asks `/api/osm/features` for one curated preset in a bounded box; the route answers `OVERPASS_NOT_CONFIGURED` without an operator Overpass. Results go to the session-only `osm-places` layer and become the analyst's last answer; a list capped at its limit stays incomplete in follow-ups, which keep its "listed the first N" caveat and report counts as floors. An explicit OSM Places OFF while the provider is pending aborts the work and prevents re-enable, result, credit and follow-up-memory commits. The voice answer deadline also aborts the client provider request, so the caller does not leave a timed-out request without a cancellation owner. The server's whole search (22 s across every configured endpoint, with Overpass told 20 s) ends before that 25 s deadline, because a browser that stops waiting cancels the upstream query and leaves nothing cached for a retry. @@ -4883,7 +4883,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). @@ -5321,6 +5321,111 @@ core's lookups alone. The MCP globe panel allows the meta-catalogue and the three biggest instances; a picture on another instance shows only in the app. +**"Show me X on Street View."** `show_street_level` (voice and typed; +`src/voice/gevActions.js`) is one call whose stages fail separately +(`location`, `view`, `map`, `provider`, `layer`, `key`, `imagery`): + +- **Key first.** A keyless source explains itself and changes nothing, as a + chip does. +- **Place.** A name, coordinates, a pointed spot or a numbered item: it flies + there at street framing and waits for arrival. With no place it looks at the + ground under the screen centre, as OPEN does (`aim: 'screen-centre'`, + `armed: false`: sky refuses, and a link's armed point is not used and is kept + until a photo opens). Coordinates off the globe are refused. +- **Map and source.** It switches to the map the source needs (Google Street + View: Google 3D), lights the source through `setLayerParams`, one at a time + and kept like a chip click (origin `voice`; a link runs the action with + origin `programmatic`, so it never writes the recipient's saved layer state), + turns the layer on and calls `openNearest`. `provider` is `google` for + "Street View", `mapillary`, `kartaview` or `panoramax` by name, else the lit + source. +- **Hidden panel.** Cockpit, clean view, recording or scene playback end it at + `view`, before the flight and after every await; embed mode's own clean view + is exempt, since the photo is lifted over the globe. +- **Superseded.** After every await (flight, map switch, layer on, lookup) a + newer turn, a shell navigation stamp (`_navigationGeneration`, re-taken where + its own flight lands) or a press or wheel on the globe stops it + (`superseded: true`). The same checks run before a found photo loads + (`proceed`), so nothing billed loads for a request that was given up. A + lookup given up leaves the panel as it was when the request kept the photo's + source; one that had switched the source off closes that source's photo with + it. A photo that opened after either is closed again. +- **Replacing a photo.** An open photo stays up while the new request looks + (`openNearest` `replace: true`; the viewer is replaced, not closed, so EXPAND + stays, also across sources through `holdViewer`, which holds only the action's + own switch-off of the photo's source: a source switched by hand meanwhile + still stops the load) and closes if nothing opens, so a place with no imagery + shows its own error; a link that only arms closes it at once. A replacement + from another source drops the old photo's details, and the request's error or + armed OPEN outlives the old viewer. A replacement stops a playing Mapillary + sequence. +- **Outcome.** `noImagery` marks an empty result, `imageryError` a source or + viewer failure and `aim: 'sky'` a sky centre (from `street.nearestOutcome`); + an empty result names a narrowing 360°/flat or SINCE filter the source + honours, as `filter`. Every photo the action opens reveals the panel, also + one the user had collapsed. +- **Search.** A source without a coverage layer searches up to 250 m + (`NEAREST_RADIUS_MAX_M`; Google honours it); clicks keep 50 m. Mapillary + asks for 100 photos within 50 m, its API's most (the API returns them + unordered and cuts at the limit), and gives up after 12 s, body included + (`NEAREST_TIMEOUT_MS`). +- **Phone.** The panel sits in the Cameras tab sheet: a photo that opens, or a + link that arms, opens the sheet and scrolls the panel into view + (`revealPanel`, which does nothing above 720px). + +The layer also exposes `requiredMapStack(id)` and `providerStatus(id)` for +callers that decide before the panel lists a source, and `holdViewer()`. A +caller's abort is honoured through the viewer's mount and +load, and only the lookup that still owns the panel changes its spinner or +error. + +Views carry the request as `street_level` (`src/view/index.js`): a source and +a point, which defaults to where the camera looks (below 20 km, at a pitch +steeper than -5° and within 10 km ahead; else the camera's own point); half a +point is not mixed with the camera's: both coordinates come from that default. A `google` source brings `map: photoreal` and refuses +another map; `street_level` does not combine with `follow`. +`show_in_gods_eye_view`, and the embedded app for a posted view with no +point, measure the view's altitude from the terrain under the camera +(`groundedLookAt` with the terrain heights service) before taking the look-at +point, and keep the view's own estimate without a terrain answer (the embedded +app waits 2 s for one). Links carry +`sl=,,`; the globe panel applies views through the action, +and a plain link runs it once restored (one shot: the rewritten hash drops +`sl`). A link that follows an entity ignores `sl`. + +- **Billing gate.** A plain link never loads a photo from a source that bills + every load (`billedPerImage`; Google): the action runs with + `autoOpen: false` and arms the layer (`armNearest`: the link's point and + radius, for the next OPEN press only, and only while the ground under the + screen centre is within 1 km of it (`ARMED_REACH_M`) or cannot be picked; a + sky centre refuses; any other lookup, open, close, disable, the source + switched off or its map leaving drops it), opens the panel and shows a toast + pointing at OPEN GOOGLE STREET VIEW. A link whose source has no working + key here changes nothing and says so in a toast (not in a frame). +- **Who may open.** Posted views, and the panel page's own link (inline embed) + for a tool result issued in the last 5 minutes, open the photo: + `show_in_gods_eye_view` stamps a street-level result `issued_at`, every + `gev:view` the panel posts carries `fresh`, and the inline app applies a + view without it with `autoOpen: false` (a framing page's posted views still + open). `?embed=1` on a published link only arms, without the panel nudge or + toast. +- **Who owns the camera.** A link's photo step is skipped when a newer link + superseded its restore, when its restore flight never landed + (`share.camera !== 'applied'`: the visitor dragged or searched), or after + any navigation since the generation that flight owned (`navigationToken` on + the settled result), also while its layers still load. A link pasted into + the open tab runs its step after its restore settles + (`gev:share-navigation-restore-settled`). +- **Embed.** The rail is hidden, so an open photo moves to an overlay on the + globe (`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in + full screen, Esc leaves full screen first). EXPAND stays under embed mode's + clean view; cockpit, recording and scene playback hide the overlay. Loading + errors render inside the overlay, and inside the viewer while EXPAND is on. + A posted view whose photo step fails before the imagery stage closes the + earlier photo before its camera flies; one the user overtook (a click on the + map, a newer request) leaves their photo and camera alone; one that found a + photo it could not load keeps that error. + Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run qa:street-level:fixtures -- --url ` answers every Mapillary request @@ -6376,7 +6481,7 @@ and unreachable upstream (502/504) separately from road geometry. - **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`. +- **Thirty-five tools** (thirty-four in a session without `OVERPASS_UPSTREAMS`, which adds `osm_query`; 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`, `show_street_level`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_voice_inactivity_timeout`, `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`, `resolve_area`, `find_imagery`, `osm_query`, `next_iss_pass`, and `next_satellite_pass`. > **Reading `npm test` totals:** the count depends on the Node major. The two > GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs` diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index 8d2e99383..9c61fc7cd 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -68,6 +68,16 @@ 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. +## Street-level photos (`sl`) + +Views built by `show_in_gods_eye_view` or `src/view/index.js` can add +`sl=,,` to the long form. Once the link has restored, the +app opens the nearest photo there, or, for a source that bills every load, +arms the panel so OPEN loads it (see `docs/TOOLS.md`). The app's own links do +not carry it: the address bar's long form drops `sl` when the camera next +moves, and the short form has no field for it. A reload or the app's Copy +Link keeps the lit source, the layer and the map, but not the photo request. + ## Payload The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are: diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 7fd4c0937..a6d501c8c 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -102,11 +102,29 @@ action draws (pins, highlights, areas, arrows, routes and labels at a place name or coordinates). Links carry them in the `an` parameter, and the app draws them once the link has been restored. +A view can open street-level photos too: `street_level` names a source +(`google` for Street View, `mapillary`, `kartaview`, `panoramax`, or none for +the lit one) and a point, by default the spot the camera looks at. Street View +shows only on the Google 3D map, so a `google` source sets `map` to +`photoreal` and refuses another; a view cannot both follow an entity and open +photos. Links carry it as `sl=,,`: once restored, the app +runs `show_street_level`, which flies there and opens the nearest photo. A +plain link with a `google` source only arms the panel for OPEN GOOGLE STREET +VIEW; a view posted into the embedded app opens it, and so does the panel +page's own opening link, but only for a tool result issued in the last five +minutes (an older one, such as a host remounting the panel, only arms); +`?embed=1` on a published link does not. Mapillary, +KartaView and Panoramax, which do not bill per image, open at once. A link +that follows an entity ignores `sl`. + Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a -`camera`, plus `layers`, `style`, `map`, `follow` and `annotations`. A -tilted camera over an area looks at its center from behind; camera fields -given with an area override its framing. `resolveViewArguments` turns them -into a view. +`camera`, plus `layers`, `style`, `map`, `follow`, `annotations` and +`street_level`. A tilted camera over an area looks at its center from behind; +camera fields given with an area override its framing. A passed-through +view's `street_level` point moves with a new `area` or camera position (a +turned camera keeps it). `resolveViewArguments` turns them into a view. The +app's own links (the address bar, Copy Link, `/share/`) do not carry +`sl`; see `docs/SHARE-LINK-FORMAT.md`. ### The God's Eye View panel @@ -299,5 +317,5 @@ and the development server's internal routes. See SECURITY.md. | `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section | | `military_awareness` | `military` | Military and other aircraft, ships and military installations within 250 km of a point, by section | | `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area | -| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks and something to follow | +| `show_in_gods_eye_view` | `app` | A view in God's Eye View: the live panel in clients with MCP Apps, and a link everywhere; takes another answer's view or an area or camera, layers, style, map, marks, something to follow, or street-level photos (Street View or Mapillary) at a place | | `panel_request` | `app` | Panel only: loads a path from the app's server for the God's Eye View panel | 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/fixtures/street-level/mapillaryGraph.mjs b/scripts/fixtures/street-level/mapillaryGraph.mjs index a18da379f..a978a0539 100644 --- a/scripts/fixtures/street-level/mapillaryGraph.mjs +++ b/scripts/fixtures/street-level/mapillaryGraph.mjs @@ -181,6 +181,18 @@ export function answerMapillaryRequest({ method, url, images }) { : []; return { ...json(200, { data }), known: true }; } + if (q.has('lat') && q.has('lng')) { + const at = { lat: Number(q.get('lat')), lon: Number(q.get('lng')) }; + const radius = Math.min(50, Number(q.get('radius')) || 50); + const limit = Number(q.get('limit')) || 10; + // In no particular order, as the real API answers. + const data = photos + .filter((image) => metresApart(at, image) <= radius) + .reverse() + .slice(0, limit) + .map((image) => withFields(imageRecord(image), fields)); + return { ...json(200, { data }), known: true }; + } if (q.has('s2')) return { ...json(200, { data: [] }), known: true }; return { ...json(400, { error: 'unsupported images query' }), diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 8227f7b25..88a0493ee 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1314,6 +1314,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 006de6b61..42b8a3ab5 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", @@ -692,6 +693,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", @@ -741,6 +743,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" @@ -784,6 +787,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", @@ -833,6 +837,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" @@ -861,6 +866,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", @@ -892,6 +898,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", @@ -930,6 +937,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", @@ -1001,6 +1009,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", @@ -1029,6 +1038,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", @@ -1234,6 +1244,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", @@ -1433,6 +1444,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", @@ -1525,6 +1537,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", @@ -1592,6 +1605,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", @@ -1675,6 +1689,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" @@ -1713,6 +1728,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", @@ -1755,6 +1771,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" @@ -1796,6 +1813,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", @@ -1845,6 +1863,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", @@ -1898,6 +1917,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", @@ -1946,6 +1966,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", @@ -2010,6 +2031,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" @@ -2048,6 +2070,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" @@ -2106,6 +2129,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" @@ -2169,6 +2193,7 @@ "scripts/qa-street-level.mjs", "src/i18n/index.js", "src/i18n/locale.js", + "src/i18n/marker.js", "src/i18n/locales/ar.json", "src/i18n/locales/de.json", "src/i18n/locales/es.json", @@ -2178,6 +2203,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", @@ -2220,6 +2246,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" @@ -2271,6 +2298,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" @@ -2302,6 +2330,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" @@ -2367,6 +2396,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" @@ -2446,6 +2476,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", @@ -3421,6 +3452,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", @@ -4187,6 +4219,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", @@ -4373,6 +4406,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", @@ -4407,6 +4441,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", @@ -4462,6 +4497,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", @@ -4501,6 +4537,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", @@ -4608,6 +4645,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", @@ -4686,6 +4724,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", @@ -4981,6 +5020,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", @@ -5037,6 +5077,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", @@ -5101,6 +5142,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" @@ -5126,6 +5168,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", @@ -5197,6 +5240,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", @@ -5258,6 +5302,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" @@ -5466,6 +5511,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", @@ -5513,6 +5559,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/qa-street-level.mjs b/scripts/qa-street-level.mjs index 982c4e581..18616b4ce 100644 --- a/scripts/qa-street-level.mjs +++ b/scripts/qa-street-level.mjs @@ -46,6 +46,15 @@ const VIEWPORT = { width: 1440, height: 900 }; /** Over downtown Sacramento, above the fixture photo line, looking down. */ const PARK = { lon: PHOTO_LINE.lon, lat: 38.5816, height: 900, pitch: -1.3 }; const DAY_MS = 86_400_000; +/** A Sacramento street with Street View, for the sl=google link step. */ +const STREET_VIEW_STREET = Object.freeze({ lon: -121.49372, lat: 38.57986 }); +/** + * What a Street View lookup and load cost: the Maps JS API's + * SingleImageSearch (the nearest lookup), GetMetadata and the panorama tiles. + * Script loads are not counted. + */ +const STREET_VIEW_REQUEST = + /MapsJsInternalService\/(SingleImageSearch|GetMetadata)|GeoPhotoService|streetviewpixels-pa\.googleapis\.com/; /** * Coverage for any street-zoom tile: a grid of 360°/flat, recent/old @@ -234,6 +243,28 @@ async function boot(page, url) { }); } +/** + * Open a share link as a fresh document. Changing only the hash of a live + * page and reloading races the app's share-link writer, which can put its own + * state back in the hash before the reload reads it. The page's fixtures (a + * CDP session on the page) carry over. + */ +async function bootLink(page, link) { + await page.goto('about:blank'); + await boot(page, link); +} + +/** The camera's position in degrees and metres. */ +const cameraAt = (page) => + page.evaluate(() => { + const c = window.__godsEyeView.viewer.camera.positionCartographic; + return { + lon: (c.longitude * 180) / Math.PI, + lat: (c.latitude * 180) / Math.PI, + height: c.height, + }; + }); + /** Run `test(uiState, arg)` in the page until it is true. */ const uiUntil = (page, test, arg = null, timeout = 30_000) => page.waitForFunction( @@ -780,6 +811,219 @@ async function main() { await uiUntil(page, litAlone, 'mapillary'); await park(page); + const base = url.split('#')[0]; + await fixtureStep( + 'a link to street-level photos (sl=) opens the photo at its point once the link restores', + async () => { + const photo = fixture.images[0]; + // The camera sits ~330 m north of the photo: the lookup must use + // the link's point, not the view centre. + const cameraLat = Number((photo.lat + 0.003).toFixed(6)); + await bootLink( + page, + `${base}#v=2&lat=${cameraLat}&lon=${photo.lon}&alt=600&heading=0&pitch=-60&roll=0&sl=mapillary,${photo.lat},${photo.lon}`, + ); + await uiUntil( + page, + (u) => (u.street.imageId && !u.street.loading) || u.street.error, + null, + 90_000, + ); + const { street } = await getUI(page); + assert.equal(street.error, null, `viewer error: ${street.error}`); + assert.equal(street.providerId, 'mapillary'); + assert.equal( + street.imageId, + photo.id, + `the photo at the link's point, not the one nearest the camera: ${street.imageId}`, + ); + assert.equal( + await page.evaluate(() => location.hash.includes('sl=')), + false, + 'the rewritten link no longer carries the one-shot request', + ); + }, + ); + + await fixtureStep( + 'the show_street_level action opens the nearest photo at a point without a pointer', + async () => { + await page.evaluate(() => + window.__godsEyeView.dataManager.layers + .get('street-level') + .module.closeViewer(), + ); + const photo = fixture.images[0]; + const result = await page.evaluate( + (at) => + window.__godsEyeView.voiceCommands.runner('show_street_level', { + latitude: at.lat, + longitude: at.lon, + provider: 'mapillary', + fly: false, + }), + { lat: photo.lat, lon: photo.lon }, + ); + assert.equal( + result.ok, + true, + `action failed: ${result.stage} ${result.error}`, + ); + assert.equal(result.provider, 'mapillary'); + assert.ok( + fixture.images.some((image) => image.id === result.imageId), + `a fixture photo opened: ${result.imageId}`, + ); + assert.match(result.say || '', /^Mapillary at /); + }, + ); + + await step( + 'an sl=google link never loads a photo by itself: keyless it stops at the key, keyed it arms and waits for OPEN', + async ({ skipped }) => { + const point = STREET_VIEW_STREET; + // ~330 m north of the street: a flight would move the camera, and a + // press that ignored the armed point would open something else. + const cameraLat = Number((point.lat + 0.003).toFixed(6)); + const googleRequests = []; + const onRequest = (request) => { + if (STREET_VIEW_REQUEST.test(request.url())) + googleRequests.push(request.url()); + }; + page.on('request', onRequest); + try { + await bootLink( + page, + `${base}#v=2&lat=${cameraLat}&lon=${point.lon}&alt=600&heading=0&pitch=-60&roll=0&map=osm&sl=google,${point.lat},${point.lon}`, + ); + await page.waitForFunction( + (want) => { + const c = + window.__godsEyeView.viewer.camera.positionCartographic; + return Math.abs((c.latitude * 180) / Math.PI - want) < 0.0005; + }, + { timeout: 60_000 }, + cameraLat, + ); + const googleKey = await page.evaluate(() => + Boolean(window.__GOOGLE_MAPS_API_KEY__), + ); + if (!googleKey) { + // Without a key the action stops at the key, before any flight, + // map switch or source change, and the direct call says why. + const direct = await page.evaluate( + (at) => + window.__godsEyeView.voiceCommands.runner( + 'show_street_level', + { + latitude: at.lat, + longitude: at.lon, + provider: 'google', + fly: false, + autoOpen: false, + }, + ), + point, + ); + assert.equal(direct.ok, false); + assert.equal( + direct.stage, + 'key', + `${direct.stage} ${direct.error}`, + ); + const after = await page.evaluate(() => { + const m = + window.__godsEyeView.dataManager.layers.get( + 'street-level', + ).module; + const u = m.getUIState(); + return { + stack: window.__godsEyeView.mapStackController.getActiveId(), + google: m.getParams().google === true, + armed: u.street.armed, + open: u.street.open, + }; + }); + assert.deepEqual( + after, + { stack: 'osm', google: false, armed: null, open: false }, + 'a keyless Street View link changes nothing', + ); + const still = await cameraAt(page); + assert.ok( + Math.abs(still.lat - cameraLat) < 0.0005, + `the camera did not fly: ${still.lat}`, + ); + assert.deepEqual(googleRequests, [], 'no Street View request'); + return; + } + const photoreal = await page.evaluate(() => + window.__godsEyeView.mapStackController.isStackAvailable( + 'photoreal', + ), + ); + if (!photoreal) return skipped('no Google 3D'); + await uiUntil( + page, + (u) => + u.street.armed !== null && + u.providers.some((p) => p.id === 'google' && p.on), + null, + 90_000, + ); + const beforePress = googleRequests.length; + const waiting = await getUI(page); + assert.equal( + waiting.street.open, + false, + 'nothing open without a click', + ); + assert.equal(waiting.street.imageId, null); + assert.deepEqual( + waiting.street.armed.point, + { lat: point.lat, lon: point.lon }, + 'armed at the link point', + ); + assert.equal( + beforePress, + 0, + 'no Street View request before the press', + ); + await expandPanel(page); + await press(page, '#sl-nearest-btn'); + await uiUntil( + page, + (u) => (u.street.imageId && !u.street.loading) || u.street.error, + null, + 90_000, + ); + const { street } = await getUI(page); + assert.equal(street.error, null, `viewer error: ${street.error}`); + assert.equal(street.providerId, 'google'); + assert.ok( + googleRequests.length >= 1, + 'the press made the Street View request', + ); + const metres = Math.hypot( + (street.position.lat - point.lat) * 111_320, + (street.position.lon - point.lon) * + 111_320 * + Math.cos((point.lat * Math.PI) / 180), + ); + assert.ok( + metres <= 250, + `within 250 m of the link point: ${Math.round(metres)} m`, + ); + // Back to Mapillary: the keyless page restores the saved layer + // and expects its KEY REQUIRED. + await press(page, '#sl-provider-chips [data-chip-id="mapillary"]'); + await uiUntil(page, litAlone, 'mapillary'); + } finally { + page.off('request', onRequest); + } + }, + ); + await fixtureStep( // The fixture bundle carries a token, so this covers a keyless // server behind a keyed bundle; the keyless default is checked above. diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 0b0a5213a..e3e5b33c4 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -904,6 +904,7 @@ const SLOW_TOOLS = new Set([ 'annotate_map', 'fly_to_location', 'select_nearest_aircraft', + 'show_street_level', 'analyst_query', 'fly_route', 'frame_overhead', diff --git a/scripts/voice-bench/phrases.mjs b/scripts/voice-bench/phrases.mjs index 89d531b71..6ea53f012 100644 --- a/scripts/voice-bench/phrases.mjs +++ b/scripts/voice-bench/phrases.mjs @@ -35,6 +35,14 @@ export const CORE_PHRASES = Object.freeze([ { phrase: 'Show me the whole earth', expect: 'zoom_to_globe' }, { phrase: 'Frame the aircraft near us from overhead', expect: 'frame_overhead', typedArgs: [where('frame_overhead', (a) => a.target === 'flights')] }, + // — street-level photos: one composite action, never fly + basemap — + { phrase: 'Show me the Eiffel Tower on Street View', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google' && /eiffel/i.test(a.query || ''))] }, + { phrase: 'Street view of 10th Street in Sacramento', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google')] }, + { phrase: 'Show me Times Square on Mapillary', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'mapillary')] }, + { phrase: 'Switch to Google Street View', expect: 'show_street_level', typedArgs: [where('show_street_level', (a) => a.provider === 'google' && !a.query)] }, + { phrase: 'Show me the Eiffel Tower', expect: 'fly_to_location' }, + { phrase: 'Turn on street level', expect: 'set_layer_visibility', typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'street-level' && a.enabled === true)] }, + // — the satellites trap: data layer, never basemap — { phrase: 'Show me the satellites', expect: { oneOf: ['set_layer_visibility', 'frame_overhead'] } }, { phrase: 'Turn off the satellites', expect: 'set_layer_visibility', args: { layerId: 'satellites' }, typedArgs: [where('set_layer_visibility', (a) => a.layerId === 'satellites' && a.enabled === false)] }, diff --git a/server/providers/openai/instructions.js b/server/providers/openai/instructions.js index 8f802ac55..f9ac49244 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -38,7 +38,7 @@ function realtimeDirectives( 'READING THE VIEW: with no selected object or overlay entities, answer from get_entity_context\'s basemap context (tiles source, centre target, reverse-geocoded place, altitude, style, enabled layers); say there is nothing only when that is unavailable too. Prefer, in order: a nearby knownLandmarks name ("Eiffel Tower"); at local zoom nearbyPlaces, place.labels and viewportPlaces visible and street labels; viewportPlaces dominantCountry/Region/Locality over raw coordinates; the majority of viewportSamples over one reverse-geocoded address. Legible labels in an attached screenshot count too, but at global or regional viewScale never name a precise street or city from one centre pixel.', 'Do not mention disabled layers or stale selections.', 'POINTING: pointer_context says what the cursor is on (pointing:: resolved by the tools; target "none": not pointing). For "this/that/here/that one" with no name, pass "pointer": get_entity_context scope, track_entity/fly_to_location query, analyst_query scope.kind ("around here" is pointer, not view), annotate_map target, find_imagery area. "The second/last one" is referent:n from the last numbered list. Without a pointer, ask or follow WHERE.', - 'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.', + 'PREAMBLES: for instant commands (layers, styles, HUD, detection, zoom, panels, basemap, radio controls, camera nudges, and lookups such as get_entity_context) call the tool without speaking first. Only for slow or multi-step work (annotate_map, resolve_area, find_imagery, a searched fly_to_location, select_nearest_aircraft, show_street_level, analyst_query over a named place, or two or more tools) you may first say ONE sentence of at most 12 words naming the actual steps ("Finding the Capitol, then outlining its grounds."), then call the tools at once in the same response.', 'When one request contains MULTIPLE changes, call ALL the corresponding tools before the confirmation. Never confirm a partial subset; if one fails, say which.', 'AFTER TOOLS: speak the result\'s say line once, lightly rephrased at most, never repeating your preamble. For a requested analyst list or ranking, also name up to three returned items with the relevant returned values; keep their order. A count alone does not answer that request; if say is null, finish any remaining requested tools before confirming. Never add a count, list or action claim that the executed tool results do not support. Sources, notes and caveats are on the voice card: speak a caveat only when it changes the answer, or when asked why, how sure or which source. Lower bounds ("at least 500"), partial answers, approximate outlines and stale, degraded or unavailable feed states in say are part of the answer — always preserve them. No filler or hedges ("let me check", "keep in mind", "it seems", "based on the data available"), and never add "approximately" to an exact tool number.', 'For "show/open/turn on" layer requests, enable the matching layer. For "hide/close/turn off", disable it.', @@ -65,6 +65,7 @@ function realtimeDirectives( '"Track/follow " (a callsign, ship name, satellite name) uses track_entity. "Take me to the biggest fire" uses track_entity with query "biggest fire" (the fires layer must be enabled). Bare "orbit" means camera orbit of the current landmark. "Stop following/tracking" uses stop_tracking.', '"Show me which planes are overhead"/"frame the ships"/"show me the satellites above" use frame_overhead with the matching target.', 'Confirmations echo the RESULTING state, never the request: "HUD minimal layout", "Density twenty-five percent", "Bing aerial imagery", "Tracking UAL428", "Framed fourteen aircraft". On ok=false, state the failure plainly: "Nothing matched UAL999", "No ships within 120 kilometers". Never claim an action without ok=true in the tool result.', + 'STREET LEVEL: "X on Street View/Mapillary/KartaView/Panoramax", "street view of X" → show_street_level once, not fly_to_location; no place = view centre. Street View is never a basemap.', 'For destination requests such as "take me to Italy", "go to NYC", or "show me the Eiffel Tower", call fly_to_location. Prefer known city IDs when available; otherwise pass the plain place query.', 'Navigation-only requests ("take me to X", "go to X", "fly to X") are NOT descriptions: call fly_to_location alone and do NOT also call annotate_map, unless the user explicitly asks to mark the place or you go on to explain specific places there. Never drop a point pin on a region-scale natural feature (a mountain range, desert, sea, or forest) — a single point in the middle of the Rockies is meaningless. If the user explicitly asks to mark such a region, prefer type=area.', 'For country and city destinations, omit rangeM so GEV frames the whole country or city in view. For landmarks and buildings, omit rangeM so GEV chooses a close landmark view.', diff --git a/server/providers/openai/toolDescriptions.js b/server/providers/openai/toolDescriptions.js index c7e78c7d6..d3116f06a 100644 --- a/server/providers/openai/toolDescriptions.js +++ b/server/providers/openai/toolDescriptions.js @@ -65,6 +65,28 @@ export const ACTION_DESCRIPTIONS = { }, }, }, + show_street_level: { + description: + 'Fly to a place and open its nearest street-level photo; Street View switches to Google 3D (billed). No query: view centre.', + $position: 1, + parameters: { + properties: { + query: { + description: 'Place or address; "pointer" = under the cursor.', + $position: 1, + }, + provider: { + description: + 'google = Street View; others by name; omit: the lit source.', + $position: 1, + }, + referent: { + description: 'Numbered item n from the last result list.', + $position: 1, + }, + }, + }, + }, adjust_camera_zoom: { description: 'Move the current Cesium camera closer to or farther from what it is presently looking at. Use for relative zoom requests without changing location.', diff --git a/src/app/embed.js b/src/app/embed.js index fbb746909..fb608d365 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -12,13 +12,25 @@ */ import * as Cesium from 'cesium'; -import { announceNavigationAuthority } from '../navigationPolicy.js'; -import { annotationsFromParams, createView } from '../view/index.js'; +import { + announceNavigationAuthority, + watchCameraOwner, +} from '../navigationPolicy.js'; +import { t, translateKnown } from '../i18n/index.js'; +import { GOOGLE_LABEL } from '../layers/streetLevel/providers/google/policy.js'; +import { + annotationsFromParams, + createView, + followFromParams, + groundedLookAt, + streetLevelFromParams, +} from '../view/index.js'; const EMBED_VIEW_MESSAGE = 'gev:view'; const EMBED_APPLIED_MESSAGE = 'gev:view-applied'; const EMBED_READY_MESSAGE = 'gev:ready'; +const STREET_LEVEL_LAYER = 'street-level'; const FOLLOW_LAYERS = { aircraft: 'flights', military_aircraft: 'military', @@ -27,6 +39,8 @@ const FOLLOW_LAYERS = { const FOLLOW_ATTEMPTS = 20; const FOLLOW_RETRY_MS = 1000; const FLIGHT_SECONDS = 2; +/** How long a posted view waits for the terrain under its camera. */ +const TERRAIN_WAIT_MS = 2000; /** * Whether this page shows the app embedded: `?embed=1` when another page @@ -45,6 +59,14 @@ export function isEmbeddedInline() { return globalThis.GEV_EMBED_INLINE === true; } +/** + * Whether the panel page started the app for a tool result issued just now, + * not one its host handed back on mounting the panel again. + */ +function isFreshPanelResult() { + return isEmbeddedInline() && globalThis.GEV_EMBED_FRESH === true; +} + /** * `doc`'s visibility for work that pauses in a hidden page. A panel's host * may report it hidden while it is on screen (see keepPanelRendering), so an @@ -167,6 +189,40 @@ function flyTo(viewer, camera) { }); } +/** + * The `show_street_level` arguments for a view's street-level photos. A plain + * link (`autoOpen: false`) only arms a source that bills every load; a view + * posted into the embedded app is the client's own request and opens. + */ +function streetLevelArgs(streetLevel, { autoOpen = true } = {}) { + return { + latitude: streetLevel.lat, + longitude: streetLevel.lon, + ...(streetLevel.provider ? { provider: streetLevel.provider } : {}), + fly: true, + ...(autoOpen ? {} : { autoOpen: false }), + }; +} + +const STREET_LEVEL_PANEL_ID = 'street-level-panel'; +/** Dispatched on the window when a link opened after start-up has restored. */ +const SHARE_NAVIGATION_RESTORED_EVENT = 'gev:share-navigation-restore-settled'; + +/** The armed link's toast, naming the OPEN button as the panel shows it. */ +function armedToast() { + return t('Street View is ready: press {open} to load it', { + open: t('OPEN {labels}', { labels: translateKnown(GOOGLE_LABEL) }), + }); +} + +/** Why a link's photo stays shut: its source has no working key here. */ +function keyToast(result) { + return t( + "{source} cannot open this link's street-level photo without a working key", + { source: translateKnown(result.providerName || 'Street Level') }, + ); +} + /** * Make the app show a view: style and map, exactly the view's layers, * annotations, the camera, and the followed entity. Each step runs through the @@ -175,13 +231,20 @@ function flyTo(viewer, camera) { */ export async function applyView( view, - { viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS }, + { + viewer, + dataManager, + run, + signal, + autoOpen = true, + retryMs = FOLLOW_RETRY_MS, + }, ) { const steps = []; - const act = async (name, args) => { + const act = async (name, args, options) => { let result = null; try { - result = await run(name, args); + result = await run(name, args, options); } catch (error) { result = { ok: false, error: error?.message || String(error) }; } @@ -200,6 +263,8 @@ export async function applyView( await act('set_layer_visibility', { layerId: layer.id, enabled: false }); } for (const layerId of view.layers) { + // The street-level action lights its source first, then the layer. + if (view.street_level && layerId === STREET_LEVEL_LAYER) continue; if (!dataManager.isEnabled(layerId)) await act('set_layer_visibility', { layerId, enabled: true }); } @@ -209,6 +274,29 @@ export async function applyView( await act('clear_annotations', {}); if (view.annotations.length) await act('annotate_map', { annotations: [...view.annotations] }); + if (view.street_level) { + // The photos frame the camera; when the action fails before flying, the + // view's own camera still applies. + // A posted view replays someone else's view, as a link does: the source + // it lights is not saved as the visitor's own choice. + const result = await act( + 'show_street_level', + streetLevelArgs(view.street_level, { autoOpen }), + { origin: 'programmatic' }, + ); + if (result?.ok !== true) { + // Overtaken (a click on the map, a newer request): hands off. + if (result?.cancelled || result?.superseded) return steps; + // An earlier view's photo must not stay over a place this view left. + // From the imagery stage on the action has settled the panel itself + // (a photo it could not load keeps its error). + if (result?.stage !== 'imagery') + dataManager.layers?.get?.(STREET_LEVEL_LAYER)?.module?.closeViewer?.(); + if (!result?.location) + steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + } + return steps; + } if (!view.follow) { steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); return steps; @@ -252,18 +340,115 @@ export function installViews({ dataManager, run, signal, + terrain = null, location = globalThis.location, windowRef = globalThis.window, }) { const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {}); - const linked = annotationsFromParams( - new URLSearchParams(String(location?.hash || '').replace(/^#/, '')), + const hashParams = new URLSearchParams( + String(location?.hash || '').replace(/^#/, ''), ); - if (linked.length) - void ready.then(() => { - if (!signal?.aborted) void run('annotate_map', { annotations: linked }); + // The link's own steps run first, one at a time, and posted views queue + // behind them; one failing step does not take the next with it. A passive + // link never flies over someone who took the camera: its steps are skipped + // when a newer link superseded it, when its flight never landed (the + // recipient dragged or searched), or after any navigation since the + // generation its restore flight owned. + const cameraLanded = (outcome) => outcome?.share?.camera === 'applied'; + const restoreGeneration = (outcome) => + Number.isInteger(outcome?.navigationToken) + ? outcome.navigationToken + : shell._navigationGeneration; + let generationAtRestore = null; + let startSuperseded = false; + let queue = ready.then((outcome) => { + generationAtRestore = restoreGeneration(outcome); + startSuperseded = + outcome?.status === 'superseded' || + (Boolean(outcome?.share) && !cameraLanded(outcome)); + }); + const navigatedSince = (generation) => + Number.isFinite(generation) && shell._navigationGeneration !== generation; + const linkStep = (work) => + queue.then(async () => { + if (signal?.aborted) return; + try { + await work(); + } catch { + /* reported by the action itself */ + } }); - if (!isEmbedded(location)) return () => {}; + const linked = annotationsFromParams(hashParams); + if (linked.length) + queue = linkStep(() => run('annotate_map', { annotations: linked })); + // A link's street-level photos open once it has restored. A billed source + // only arms, except for the panel page's own link to a result issued just + // now. A link that follows an entity keeps the camera on it. + const linkedStreetLevel = (params) => + followFromParams(params) ? null : streetLevelFromParams(params); + const openLinkedStreetLevel = async ( + streetLevel, + { generation, autoOpen = false }, + ) => { + if (navigatedSince(generation)) return; + // The link replays the sender's view: nothing it switches becomes the + // recipient's own preference. + const result = await run( + 'show_street_level', + streetLevelArgs(streetLevel, { autoOpen }), + { origin: 'programmatic' }, + ); + // A source without a working key changes nothing: say why, or the link + // looks broken. In a frame the host reads the result instead. + if (result?.stage === 'key' && !isEmbedded(location)) { + shell._showToast?.(keyToast(result)); + return; + } + // OPEN must be in view, but embed mode hides the rail that holds it: in + // a frame a posted view is the way to open a photo. + if (result?.armed === true && !isEmbedded(location)) { + // Opened for this link only: the recipient's saved layout stays theirs. + shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { + explicit: true, + persist: false, + }); + // On a phone the panel sits in a tab sheet: open it so OPEN shows. + shell.revealPanel?.(STREET_LEVEL_PANEL_ID); + shell._showToast?.(armedToast()); + } + }; + const startStreetLevel = linkedStreetLevel(hashParams); + if (startStreetLevel) + queue = linkStep(() => + startSuperseded + ? undefined + : openLinkedStreetLevel(startStreetLevel, { + generation: generationAtRestore, + autoOpen: isFreshPanelResult(), + }), + ); + // A link pasted into the open tab: its photos follow once its restore + // settles, under the same rules. + const onNavigatedRestore = (event) => { + if (event?.detail?.status !== 'settled' || !cameraLanded(event.detail)) + return; + const streetLevel = linkedStreetLevel( + new URLSearchParams(String(location?.hash || '').replace(/^#/, '')), + ); + if (!streetLevel) return; + const generation = restoreGeneration(event.detail); + queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation })); + }; + windowRef?.addEventListener?.( + SHARE_NAVIGATION_RESTORED_EVENT, + onNavigatedRestore, + ); + const stopNavigatedRestores = () => + windowRef?.removeEventListener?.( + SHARE_NAVIGATION_RESTORED_EVENT, + onNavigatedRestore, + ); + if (!isEmbedded(location)) return stopNavigatedRestores; windowRef.document.body.classList.add('ui-embed'); shell.setCleanView?.(true); @@ -280,7 +465,6 @@ export function installViews({ peer.postMessage(message, origin); }; // Views apply one at a time, in the order they arrive. - let queue = ready; const onMessage = (event) => { if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE) return; @@ -304,19 +488,82 @@ export function installViews({ ); return; } - queue = queue.then(async () => { - if (signal?.aborted) return; - const steps = await applyView(view, { viewer, dataManager, run, signal }); - post( - { - type: EMBED_APPLIED_MESSAGE, - id, - ok: steps.every((step) => step.ok), - steps, - }, - replyOrigin, + // A panel's host can hand back old results on a remount: only one issued + // just now may open a billed photo. A framing page's view is its own. + const autoOpen = !isEmbeddedInline() || event.data.fresh === true; + // Photos asked for without a point open where the camera looks, measured + // from the ground under it, as show_in_gods_eye_view places them. + const asked = event.data.view?.street_level; + const unplaced = + asked && + typeof asked === 'object' && + asked.lat == null && + asked.lon == null; + queue = queue + .then(async () => { + if (signal?.aborted) return; + if (unplaced && view.street_level) { + // A terrain service that does not answer soon leaves the view's + // own estimate. + const wait = AbortSignal.timeout(TERRAIN_WAIT_MS); + const owner = watchCameraOwner(viewer, shell); + const point = await groundedLookAt( + view.camera, + terrain, + signal ? AbortSignal.any([signal, wait]) : wait, + ) + .catch(() => null) + .finally(() => owner.stop()); + if (signal?.aborted) return; + // Whoever takes the camera while the terrain answers (a drag, a + // search, the assistant) keeps it: the view stands down. + if (owner.taken()) { + post( + { + type: EMBED_APPLIED_MESSAGE, + id, + ok: false, + steps: [{ step: 'street_level', ok: false, superseded: true }], + }, + replyOrigin, + ); + return; + } + if (point) + view = createView({ + ...event.data.view, + street_level: { ...asked, ...point }, + }); + } + const steps = await applyView(view, { + viewer, + dataManager, + run, + signal, + autoOpen, + }); + post( + { + type: EMBED_APPLIED_MESSAGE, + id, + ok: steps.every((step) => step.ok), + steps, + }, + replyOrigin, + ); + }) + // A view that fails is answered, and the views behind it still apply. + .catch((error) => + post( + { + type: EMBED_APPLIED_MESSAGE, + id, + ok: false, + error: error?.message || String(error), + }, + replyOrigin, + ), ); - }); }; windowRef.addEventListener('message', onMessage); void ready.then(() => { @@ -324,6 +571,7 @@ export function installViews({ }); return () => { stopRendering(); + stopNavigatedRestores(); windowRef.removeEventListener('message', onMessage); }; } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 1d11c0745..cee064278 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -8,7 +8,7 @@ import { panelAwareVisibility, } from './embed.js'; import { LayerLifecycle } from '../data/lifecycle.js'; -import { createView } from '../view/index.js'; +import { createView, viewToParams } from '../view/index.js'; const fakeViewer = () => { const flights = []; @@ -32,14 +32,16 @@ const fakeLayers = (enabled) => ({ }); const recorder = (answers = {}) => { const calls = []; - const run = async (name, args) => { + const options = []; + const run = async (name, args, runOptions) => { calls.push([name, args]); + options.push([name, runOptions]); const answer = answers[name]; return typeof answer === 'function' ? answer(calls) : (answer ?? { ok: true }); }; - return { calls, run }; + return { calls, run, options }; }; test('embed mode is opt-in by query parameter', () => { @@ -83,6 +85,19 @@ test('a view is applied through the app actions in order', async () => { assert.ok(steps.some((step) => step.step === 'camera')); }); +test("a view's street-level step replays the sender's view: nothing it switches is the visitor's choice", async () => { + const { options, run } = recorder(); + await applyView( + createView({ + camera: { lat: 48.86, lon: 2.29 }, + street_level: { provider: 'panoramax', lat: 48.8584, lon: 2.2945 }, + }), + { viewer: fakeViewer(), dataManager: fakeLayers(new Set()), run }, + ); + const [, runOptions] = options.find(([name]) => name === 'show_street_level'); + assert.equal(runOptions?.origin, 'programmatic'); +}); + test('a followed entity is retried until its layer has it', async () => { let tries = 0; const { run } = recorder({ @@ -102,6 +117,65 @@ test('a followed entity is retried until its layer has it', async () => { assert.deepEqual(steps.at(-1), { step: 'follow', ok: true }); }); +test('a posted view without a photo point opens its photos where the camera looks over the ground', async () => { + const parent = { postMessage() {} }; + const windowRef = new EventTarget(); + windowRef.parent = parent; + windowRef.document = { body: { classList: new Set() } }; + windowRef.document.body.classList.add = Set.prototype.add; + const shell = { initialRestorePromise: Promise.resolve(), setCleanView() {} }; + const { calls, run } = recorder(); + const asked = []; + // Denver: the ground is 1,590 m above the ellipsoid under a camera at 1,909 m. + const terrain = { + getHeights: async (points) => { + asked.push(points); + return points.map(() => ({ ellipsoid: 1590 })); + }, + }; + const remove = installViews({ + shell, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + terrain, + location: { search: '?embed=1', hash: '' }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const event = new Event('message'); + Object.assign(event, { + source: parent, + origin: 'https://host.example', + data: { + type: 'gev:view', + id: 1, + view: { + camera: { + lat: 39.74, + lon: -104.99, + altitude_m: 1909, + pitch_deg: -30, + heading_deg: 0, + }, + street_level: { provider: 'mapillary' }, + }, + }, + }); + windowRef.dispatchEvent(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + remove(); + const [, args] = calls.find(([name]) => name === 'show_street_level'); + // 319 m above the ground at -30° looks ~550 m ahead, not the ~3.3 km the + // ellipsoid altitude would give. + assert.deepEqual(asked, [[{ lat: 39.74, lon: -104.99 }]]); + assert.ok( + args.latitude > 39.744 && args.latitude < 39.746, + String(args.latitude), + ); + assert.equal(args.longitude, -104.99); +}); + test('an embedded page takes views only from its parent and answers it', async () => { const posted = []; const targets = []; @@ -186,6 +260,59 @@ test('an embedded page takes views only from its parent and answers it', async ( remove(); }); +test('an inline panel opens a billed photo only for a view whose result is fresh', async () => { + const windowRef = new EventTarget(); + windowRef.parent = windowRef; + windowRef.postMessage = () => {}; + windowRef.document = { body: { classList: new Set() } }; + windowRef.document.body.classList.add = Set.prototype.add; + Object.assign(windowRef, { + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + setInterval: () => 1, + clearInterval() {}, + }); + const { calls, run } = recorder(); + globalThis.GEV_EMBED_INLINE = true; + try { + const remove = installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { search: '', hash: '' }, + windowRef, + }); + const view = { + camera: { lat: 48.8584, lon: 2.2945 }, + map: 'photoreal', + street_level: { provider: 'google', lat: 48.8584, lon: 2.2945 }, + }; + for (const [id, fresh] of [ + [1, true], + [2, false], + [3, undefined], + ]) { + const event = new Event('message'); + Object.assign(event, { + source: windowRef, + data: { type: 'gev:view', id, view, fresh }, + }); + windowRef.dispatchEvent(event); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.deepEqual( + calls + .filter(([name]) => name === 'show_street_level') + .map(([, args]) => args.autoOpen ?? true), + [true, false, false], + ); + remove(); + } finally { + delete globalThis.GEV_EMBED_INLINE; + } +}); + test('an app loaded inline into a panel page talks through its own window', async () => { const posted = []; const windowRef = new EventTarget(); @@ -395,3 +522,774 @@ test('a followed aircraft owns the camera, and cockpit view is entered after it' /no cockpit view/, ); }); + +test('a view with street-level photos lets the action fly and open them', async () => { + const viewer = fakeViewer(); + const { calls, run } = recorder(); + const view = createView({ + camera: { lat: 38.5799, lon: -121.4937, altitude_m: 500 }, + street_level: { provider: 'google' }, + }); + const steps = await applyView(view, { + viewer, + dataManager: fakeLayers(new Set()), + run, + }); + assert.deepEqual( + calls.map(([name]) => name), + [ + 'set_map_stack', + 'stop_tracking', + 'clear_annotations', + 'show_street_level', + ], + ); + assert.deepEqual(calls.at(-1)[1], { + latitude: 38.5799, + longitude: -121.4937, + provider: 'google', + fly: true, + }); + assert.equal(viewer.flights.length, 0, 'the action frames the camera'); + assert.ok(steps.every((step) => step.ok)); + assert.equal(steps.at(-1).step, 'show_street_level'); +}); + +test('a link to street-level photos opens them after it restores', async () => { + const { calls, run } = recorder(); + const toasts = []; + const panels = []; + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + _showToast: (text) => toasts.push(text), + setPanelCollapsed: (...args) => panels.push(args), + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [ + [ + 'show_street_level', + { + latitude: 38.58, + longitude: -121.49, + provider: 'mapillary', + fly: true, + autoOpen: false, + }, + ], + ]); + assert.deepEqual( + [toasts, panels], + [[], []], + 'an opened photo needs no toast or panel nudge', + ); + // A failure (no key) is not an armed state either: it says why instead. + const failed = recorder({ + show_street_level: () => ({ + ok: false, + stage: 'key', + error: 'needs its key', + }), + }); + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + _showToast: (text) => toasts.push(text), + setPanelCollapsed: (...args) => panels.push(args), + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: failed.run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual( + [toasts, panels], + [ + [ + "Street Level cannot open this link's street-level photo without a working key", + ], + [], + ], + 'it says why, and nothing to press opens', + ); +}); + +test('a link that follows an entity keeps the camera: its photos are not opened', async () => { + const { calls, run } = recorder(); + const params = viewToParams( + createView({ + camera: { lat: 1, lon: 2 }, + follow: { kind: 'satellite', id: '25544' }, + }), + ); + params.set('sl', 'mapillary,1.00000,2.00000'); + installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { search: '', hash: `#${params}` }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, []); +}); + +test('a failing link step does not take the next one with it', async () => { + const calls = []; + const run = async (name, args) => { + calls.push(name); + if (name === 'annotate_map') throw new Error('marks failed'); + return { ok: true, armed: false, ...args }; + }; + const an = encodeURIComponent( + JSON.stringify([{ type: 'pin', target: 'Austin' }]), + ); + installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`, + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, ['annotate_map', 'show_street_level']); +}); + +test('a link whose source has no working key says why instead of doing nothing; a frame stays quiet', async () => { + for (const embed of [false, true]) { + const toasts = []; + const { calls, run } = recorder({ + show_street_level: () => ({ + ok: false, + stage: 'key', + provider: 'google', + providerName: 'Google Street View', + error: 'Google Street View needs its key', + }), + }); + const windowRef = new EventTarget(); + if (embed) { + windowRef.document = { body: { classList: { add() {} } } }; + windowRef.parent = { postMessage() {} }; + } + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + setCleanView() {}, + _showToast: (text) => toasts.push(text), + setPanelCollapsed() { + throw new Error('nothing to arm'); + }, + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: embed ? '?embed=1' : '', + hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372', + }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls[0][0], 'show_street_level'); + assert.deepEqual( + toasts, + embed + ? [] + : [ + "Google Street View cannot open this link's street-level photo without a working key", + ], + ); + } +}); + +test('a plain link never auto-loads a billed photo: it arms the panel and says so; an embedded view opens', async () => { + const toasts = []; + const panels = []; + const revealed = []; + const { calls, run } = recorder({ + show_street_level: () => ({ ok: true, armed: true }), + }); + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + _showToast: (text) => toasts.push(text), + setPanelCollapsed: (id, collapsed, options) => + panels.push([id, collapsed, options]), + revealPanel: (id) => revealed.push(id), + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls[0][1].autoOpen, false); + assert.deepEqual(toasts, [ + 'Street View is ready: press OPEN GOOGLE STREET VIEW to load it', + ]); + assert.deepEqual( + panels, + [['street-level-panel', false, { explicit: true, persist: false }]], + "the panel opens so OPEN is in view, without saving the layout as the recipient's", + ); + assert.deepEqual(revealed, ['street-level-panel'], 'its phone sheet too'); + // Adding ?embed=1 to a published link does not lift the gate: the page is + // still a plain top-level load. + const framedToasts = []; + const framedPanels = []; + const framed = recorder({ + show_street_level: () => ({ ok: true, armed: true }), + }); + const windowRef = new EventTarget(); + windowRef.document = { body: { classList: { add() {} } } }; + windowRef.parent = { postMessage() {} }; + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + setCleanView() {}, + _showToast: (text) => framedToasts.push(text), + setPanelCollapsed: (...args) => framedPanels.push(args), + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: framed.run, + location: { + search: '?embed=1', + hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372', + }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(framed.calls[0][1].autoOpen, false); + // In a frame the rail that holds OPEN is hidden: no nudge, no toast. + assert.deepEqual([framedToasts, framedPanels], [[], []]); + // Only the panel page's own link for a result issued just now is the + // client's request: a host that mounts the panel again hands back an old + // result, and that one only arms, like any link. + globalThis.GEV_EMBED_INLINE = true; + try { + for (const fresh of [true, false]) { + globalThis.GEV_EMBED_FRESH = fresh; + const inline = recorder({ show_street_level: () => ({ ok: true }) }); + const inlineWindow = new EventTarget(); + inlineWindow.document = { body: { classList: { add() {} } } }; + inlineWindow.postMessage = () => {}; + // The inline panel keeps rendering from a timer; give it inert clocks. + Object.assign(inlineWindow, { + requestAnimationFrame: () => 1, + cancelAnimationFrame() {}, + setTimeout: () => 1, + clearTimeout() {}, + setInterval: () => 1, + clearInterval() {}, + }); + const stop = installViews({ + shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: inline.run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372', + }, + windowRef: inlineWindow, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + if (fresh) assert.equal('autoOpen' in inline.calls[0][1], false); + else assert.equal(inline.calls[0][1].autoOpen, false, 'a remount arms'); + stop(); + } + } finally { + delete globalThis.GEV_EMBED_INLINE; + delete globalThis.GEV_EMBED_FRESH; + } + // applyView (a posted view) opens too. + const posted = recorder(); + await applyView( + createView({ + camera: { lat: 1, lon: 2 }, + street_level: { provider: 'google' }, + }), + { + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: posted.run, + }, + ); + assert.equal('autoOpen' in posted.calls.at(-1)[1], false); +}); + +test('a view whose photos fail before any flight still gets its camera', async () => { + const viewer = fakeViewer(); + const { calls, run } = recorder({ + show_street_level: () => ({ + ok: false, + stage: 'key', + error: 'needs its key', + }), + }); + const view = createView({ + camera: { lat: 1, lon: 2, altitude_m: 500 }, + street_level: { provider: 'google' }, + }); + const steps = await applyView(view, { + viewer, + dataManager: fakeLayers(new Set()), + run, + }); + assert.equal(viewer.flights.length, 1, 'the view flies itself'); + assert.deepEqual( + steps.map((step) => [step.step, step.ok]), + [ + ['set_map_stack', true], + ['stop_tracking', true], + ['clear_annotations', true], + ['show_street_level', false], + ['camera', true], + ], + ); + assert.ok(calls.some(([name]) => name === 'show_street_level')); + // A flight that happened (the action reports where) is not repeated. + const flown = fakeViewer(); + const { run: flownRun } = recorder({ + show_street_level: () => ({ + ok: false, + stage: 'imagery', + location: 'Somewhere', + error: 'no imagery', + }), + }); + await applyView(view, { + viewer: flown, + dataManager: fakeLayers(new Set()), + run: flownRun, + }); + assert.equal(flown.flights.length, 0); +}); + +test('a link runs its marks and photos in order, and posted views wait behind them', async () => { + const log = []; + let releaseStreetLevel = null; + let releaseMarks = null; + const run = async (name, args) => { + log.push(`start:${name}`); + if (name === 'annotate_map') + await new Promise((resolve) => { + releaseMarks = resolve; + }); + if (name === 'show_street_level') + await new Promise((resolve) => { + releaseStreetLevel = resolve; + }); + log.push(`end:${name}`); + return { + ok: true, + ...(name === 'show_street_level' ? { location: 'x' } : {}), + }; + }; + const windowRef = new EventTarget(); + windowRef.document = { body: { classList: { add() {} } } }; + // A framing page: answers go to it, not to the app's own window. + windowRef.parent = { + postMessage: (message) => log.push(`post:${message.type}`), + }; + const an = encodeURIComponent( + JSON.stringify([{ type: 'pin', target: 'Austin' }]), + ); + installViews({ + shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '?embed=1', + hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`, + }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + // A view arrives while the link's photos are still opening. + windowRef.dispatchEvent( + Object.assign(new Event('message'), { + source: windowRef.parent, + origin: 'null', + data: { + type: 'gev:view', + id: 'v1', + view: { camera: { lat: 3, lon: 4 } }, + }, + }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + const actions = () => log.filter((entry) => !entry.startsWith('post:')); + assert.deepEqual( + actions(), + ['start:annotate_map'], + 'the photos wait for the marks', + ); + releaseMarks(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(actions().slice(0, 3), [ + 'start:annotate_map', + 'end:annotate_map', + 'start:show_street_level', + ]); + assert.ok( + !log.includes('start:stop_tracking'), + 'the posted view waits for the link', + ); + releaseStreetLevel(); + for ( + let tick = 0; + tick < 20 && !log.includes('post:gev:view-applied'); + tick++ + ) + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal( + log.indexOf('end:show_street_level') < log.indexOf('start:stop_tracking'), + true, + ); + assert.ok(log.includes('post:gev:view-applied'), log.join(' ')); +}); + +test('an aborted install never runs the link photos', async () => { + const { calls, run } = recorder(); + const controller = new AbortController(); + controller.abort(); + installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + signal: controller.signal, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, []); +}); + +test('a passive link stands down when someone navigated while it waited behind its marks', async () => { + const calls = []; + let releaseMarks = null; + const shell = { + initialRestorePromise: Promise.resolve(), + _navigationGeneration: 7, + }; + const run = async (name) => { + calls.push(name); + if (name === 'annotate_map') + await new Promise((resolve) => { + releaseMarks = resolve; + }); + return { ok: true }; + }; + const an = encodeURIComponent( + JSON.stringify([{ type: 'pin', target: 'Austin' }]), + ); + installViews({ + shell, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`, + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, ['annotate_map']); + shell._navigationGeneration = 8; // the recipient searched, or the assistant flew + releaseMarks(); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual( + calls, + ['annotate_map'], + 'the link did not fly over the newer navigation', + ); + + // Unchanged navigation: the photos open as before. + const quiet = []; + installViews({ + shell: { + initialRestorePromise: Promise.resolve(), + _navigationGeneration: 3, + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run: async (name) => { + quiet.push(name); + return { ok: true }; + }, + location: { + search: '', + hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`, + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(quiet, ['annotate_map', 'show_street_level']); +}); + +test('a link replays the sender’s view: its street-level step never writes the recipient’s preferences', async () => { + const { calls, run, options } = recorder(); + installViews({ + shell: { initialRestorePromise: Promise.resolve() }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls.length, 1); + assert.deepEqual(options, [ + ['show_street_level', { origin: 'programmatic' }], + ]); +}); + +test('a start link whose restore a newer link superseded opens nothing', async () => { + const { calls, run } = recorder(); + installViews({ + shell: { + initialRestorePromise: Promise.resolve({ status: 'superseded' }), + _navigationGeneration: 4, + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [], 'start link A does not pull the camera from B'); +}); + +test('a start link whose restore flight the recipient interrupted leaves the camera with them', async () => { + for (const [camera, expected] of [ + ['cancelled', 0], + ['skipped', 0], + ['applied', 1], + ]) { + const { calls, run } = recorder(); + installViews({ + shell: { + initialRestorePromise: Promise.resolve({ + status: 'settled', + share: { camera }, + }), + _navigationGeneration: 4, + }, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location: { + search: '', + hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000', + }, + windowRef: new EventTarget(), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls.length, expected, camera); + } +}); + +test('a link pasted into the open tab opens or arms its photos once its restore settles', async () => { + const { calls, run, options } = recorder(); + const windowRef = new EventTarget(); + const location = { search: '', hash: '#v=2&lat=1&lon=2' }; + const shell = { + initialRestorePromise: Promise.resolve(), + _navigationGeneration: 1, + }; + const remove = installViews({ + shell, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + location, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [], 'the start link has no photos'); + const settled = (status, camera = 'applied') => + windowRef.dispatchEvent( + Object.assign(new Event('gev:share-navigation-restore-settled'), { + detail: { status, share: { camera } }, + }), + ); + location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950'; + shell._navigationGeneration = 2; // the restore's own flight + settled('failed'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [], 'a failed restore opens nothing'); + // The recipient dragged or searched during the restore flight: the camera + // is theirs, and the link does not pull it back. + shell._navigationGeneration = 3; + for (const camera of ['cancelled', 'superseded']) { + settled('settled', camera); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual( + calls, + [], + `an interrupted restore (${camera}) opens nothing`, + ); + } + settled('settled'); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(calls, [ + [ + 'show_street_level', + { + latitude: 48.8874, + longitude: 2.3495, + provider: 'mapillary', + fly: true, + autoOpen: false, + }, + ], + ]); + assert.deepEqual(options.at(-1), [ + 'show_street_level', + { origin: 'programmatic' }, + ]); + remove(); + location.hash = '#v=2&lat=5&lon=6&sl=mapillary,1.00000,2.00000'; + settled('settled'); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal(calls.length, 1, 'removed with the install'); +}); + +test('a view whose photo step the user overtook leaves their photo and camera alone', async () => { + // Photo B, opened by a click while the posted view looked up its photo. + const street = { open: false, imageId: null }; + const dataManager = { + ...fakeLayers(new Set()), + layers: new Map([ + [ + 'street-level', + { + module: { + closeViewer: () => + Object.assign(street, { open: false, imageId: null }), + }, + }, + ], + ]), + }; + const { run } = recorder({ + show_street_level: () => { + Object.assign(street, { open: true, imageId: 'b' }); + return { + ok: false, + stage: 'imagery', + cancelled: true, + superseded: true, + error: 'The camera moved on before the photo opened', + }; + }, + }); + const view = createView({ + camera: { lat: 1, lon: 2, altitude_m: 500 }, + street_level: { provider: 'mapillary' }, + }); + const viewer = fakeViewer(); + const steps = await applyView(view, { viewer, dataManager, run }); + assert.deepEqual(street, { open: true, imageId: 'b' }, 'photo B stays'); + assert.equal(viewer.flights.length, 0, 'the camera stays with the user'); + assert.equal(steps.at(-1).ok, false, 'reported as not applied'); +}); + +test('a view whose photo was found but could not load keeps its error in view', async () => { + const closed = []; + const dataManager = { + ...fakeLayers(new Set()), + layers: new Map([ + ['street-level', { module: { closeViewer: () => closed.push(true) } }], + ]), + }; + const { run } = recorder({ + show_street_level: () => ({ + ok: false, + stage: 'imagery', + imageryError: true, + location: '1.00000, 2.00000', + error: 'Image could not be opened', + }), + }); + const view = createView({ + camera: { lat: 1, lon: 2, altitude_m: 500 }, + street_level: { provider: 'mapillary' }, + }); + await applyView(view, { viewer: fakeViewer(), dataManager, run }); + assert.equal(closed.length, 0, 'the viewer keeps the new photo’s error'); +}); + +test('a view whose photos fail closes the photo an earlier view opened', async () => { + const closed = []; + const dataManager = { + ...fakeLayers(new Set()), + layers: new Map([ + ['street-level', { module: { closeViewer: () => closed.push(true) } }], + ]), + }; + const { run } = recorder({ + show_street_level: () => ({ + ok: false, + stage: 'key', + error: 'needs its key', + }), + }); + const view = createView({ + camera: { lat: 1, lon: 2, altitude_m: 500 }, + street_level: { provider: 'google' }, + }); + const viewer = fakeViewer(); + await applyView(view, { viewer, dataManager, run }); + assert.equal(closed.length, 1, 'closed before the view flies away'); + assert.equal(viewer.flights.length, 1); + const opened = recorder({ show_street_level: () => ({ ok: true }) }); + await applyView(view, { viewer, dataManager, run: opened.run }); + assert.equal(closed.length, 1, 'an opened photo stays'); +}); diff --git a/src/app/embedTerrainTakeover.test.mjs b/src/app/embedTerrainTakeover.test.mjs new file mode 100644 index 000000000..60ead319d --- /dev/null +++ b/src/app/embedTerrainTakeover.test.mjs @@ -0,0 +1,118 @@ +import '../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as Cesium from 'cesium'; +import { installViews } from './embed.js'; +import { createGevActionRunner } from '../voice/gevActions.js'; +import { createAnnotationEngine } from '../annotations/annotationEngine.js'; +import { fakeCesiumViewer } from '../testSupport/streetLevelFakes.mjs'; + +// A camera taken while a posted view waits on terrain stays with whoever took +// it: the stale view stands down whole, so it neither stops a newer follow nor +// frames its own marks. Offline: real installViews, action runner and +// annotation engine; the viewer, renderer, flights data and terrain are fixtures. +globalThis.fetch = async () => { throw new Error('Unexpected network request in offline reproduction'); }; +globalThis.requestAnimationFrame = () => 1; +globalThis.cancelAnimationFrame = () => {}; +const tick = () => new Promise(resolve => setTimeout(resolve, 0)); +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +async function scenario(t, effect) { + document.body.className = ''; + const entered = deferred(); + const terrain = deferred(); + const viewer = fakeCesiumViewer(); + viewer.clock = { onTick: new Cesium.Event() }; + viewer.canvas = viewer.scene.canvas; + // A real perspective frustum looking down at 0,0; the old view's mark + // at 48N,2E is outside it. No override of the engine's visibility logic. + Object.assign(viewer.camera, { + position: Cesium.Cartesian3.fromDegrees(0, 0, 1000), + direction: new Cesium.Cartesian3(-1, 0, 0), + up: new Cesium.Cartesian3(0, 0, 1), + heading: 0, + frustum: new Cesium.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1, near: 1, far: 20000000 }), + }); + const entity = { id: 'fixture-aircraft' }; + const aircraft = { icao24: 'fixture', callsign: 'Fixture', latitude: 0, longitude: 0 }; + let stopCalls = 0; + const flights = { + findByQuery: () => aircraft, + trackById: () => { viewer.trackedEntity = entity; return true; }, + getTrackedInfo: () => viewer.trackedEntity ? aircraft : null, + stopTracking: () => { stopCalls++; viewer.trackedEntity = undefined; return true; }, + }; + const dataManager = { + layers: new Map([['flights', { module: flights }]]), + getAll: () => [{ id: 'flights', enabled: true }], + isEnabled: id => id === 'flights', + setLayerParams: () => true, + }; + const shell = { + _navigationGeneration: 1, + initialRestorePromise: Promise.resolve(), + setCleanView() {}, + runImmediateNavigation(_noun, navigate) { this._navigationGeneration++; return navigate(); }, + }; + const annotations = createAnnotationEngine({ viewer, + renderer: { add() {}, update() {}, remove() {}, sync() {}, destroy() {} }, + }); + const runner = createGevActionRunner({ viewer, dataManager, styleManager: shell, annotations }); + const calls = []; + const run = async (name, args, options) => { + calls.push(name); + return runner(name, args, options); + }; + const replies = []; + const peer = { postMessage: message => replies.push(message) }; + const windowRef = new EventTarget(); + windowRef.parent = peer; + windowRef.document = document; + const remove = installViews({ shell, viewer, dataManager, run, windowRef, + location: { search: '?embed=1', hash: '' }, + terrain: { getHeights: () => { entered.resolve(); return terrain.promise; } }, + }); + t.after(() => { remove(); runner.dispose(); annotations.destroy(); document.body.className = ''; }); + await tick(); + const event = new Event('message'); + Object.assign(event, { source: peer, origin: 'https://host.example', data: { + type: 'gev:view', id: 1, view: { + camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 }, + layers: ['flights'], + street_level: { provider: 'mapillary' }, + annotations: effect === 'annotations' + ? [{ type: 'pin', latitude: 48, longitude: 2, label: 'Old view' }] : [], + }, + } }); + windowRef.dispatchEvent(event); + await entered.promise; + if (effect === 'follow') { + const follow = await runner('track_entity', { query: 'Fixture', layerId: 'flights' }); + assert.equal(follow.ok, true, 'new follow starts through the real runner'); + assert.equal(viewer.trackedEntity, entity); + } else { + viewer.canvas.dispatchEvent(new Event('pointerdown')); + } + terrain.resolve([{ ellipsoid: 200 }]); + for (let n = 0; n < 50 && !replies.some(reply => reply.id === 1); n++) await tick(); + const reply = replies.find(reply => reply.id === 1); + t.diagnostic(JSON.stringify({ effect, calls, stopCalls, followed: viewer.trackedEntity === entity, + annotationCount: annotations.count(), annotationFlights: viewer.flights.started, reply })); + assert.ok(reply, 'host receives completion'); + assert.equal(reply.ok, false, 'superseded view is acknowledged as not applied'); + assert.ok(!calls.includes('show_street_level'), 'superseded request never reaches photo handler'); + if (effect === 'follow') { + assert.equal(stopCalls, 0, 'stale view must not stop the newer follow'); + assert.equal(viewer.trackedEntity, entity, 'the new tracked entity stays selected'); + } else { + assert.equal(viewer.flights.started, 0, 'stale annotations must not fly the camera'); + } +} + +for (const effect of ['follow', 'annotations']) { + test(`a camera taken during the terrain wait keeps the user's ${effect}`, { timeout: 5000 }, t => scenario(t, effect)); +} diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index d10e5717d..280eff063 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -462,8 +462,11 @@ export function panelRuntime(config) { * Load God's Eye View into this page from the app's panel build, in * inline embed mode, starting at the first view as its link would. */ - async function startApp(url, view) { + async function startApp(url, view, fresh = false) { window.GEV_EMBED_INLINE = true; + // Whether this result was just issued, not handed back by a host that + // mounted the panel again: only then may its link open a billed photo. + window.GEV_EMBED_FRESH = fresh; window.GEV_APP_BASE_URL = config.appBaseUrl; try { history.replaceState( @@ -551,13 +554,21 @@ export function panelRuntime(config) { ); } - // Tool results: the first view starts the app, later ones move it. + // Tool results: the first view starts the app, later ones move it. A + // result issued longer ago than this was handed back on a remount. + const FRESH_RESULT_MS = 5 * 60 * 1000; let started = false; let ready = false; let queued = null; + let queuedFresh = false; let currentUrl = null; - const postView = (view) => - window.postMessage({ type: 'gev:view', id: nextId++, view }, '*'); + // Every view carries whether its result was issued just now: only those may + // open a billed photo, whichever result on this mount they came in. + const postView = (view, fresh) => + window.postMessage( + { type: 'gev:view', id: nextId++, view, fresh: fresh === true }, + '*', + ); function show(result) { const data = result?.structuredContent; const view = data?.view; @@ -565,13 +576,18 @@ export function panelRuntime(config) { if (!view || !url) return; currentUrl = url; open.hidden = false; - if (ready) return postView(view); + const issuedAt = Number(data.issued_at); + const fresh = + Number.isFinite(issuedAt) && + Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS; + if (ready) return postView(view, fresh); queued = view; + queuedFresh = fresh; if (started) return; started = true; // i18n-ignore: host-panel script, no app catalogs say("Loading God's Eye View…"); - startApp(url, view).catch((error) => + startApp(url, view, fresh).catch((error) => // i18n-ignore: host-panel script, no app catalogs say(`God's Eye View could not load here: ${error?.message || error}.`), ); @@ -592,7 +608,7 @@ export function panelRuntime(config) { if (message?.type === 'gev:ready') { ready = true; status.remove(); - if (queued) postView(queued); + if (queued) postView(queued, queuedFresh); queued = null; } return; diff --git a/src/app/globePanelRuntime.test.mjs b/src/app/globePanelRuntime.test.mjs new file mode 100644 index 000000000..186d2e743 --- /dev/null +++ b/src/app/globePanelRuntime.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { panelRuntime } from './globePanelRuntime.js'; + +const element = () => ({ + isConnected: true, + hidden: true, + textContent: '', + addEventListener() {}, + remove() { + this.isConnected = false; + }, +}); + +test('every view the panel posts says whether its result was issued just now', async () => { + const savedWindow = globalThis.window; + const savedDocument = globalThis.document; + const views = []; + const parent = { postMessage() {} }; + const windowRef = new EventTarget(); + Object.assign(windowRef, { + parent, + postMessage: (message) => { + if (message?.type === 'gev:view') views.push(message); + }, + }); + const elements = { status: element(), open: element(), expand: element() }; + globalThis.window = windowRef; + globalThis.document = { + getElementById: (id) => elements[id] ?? null, + addEventListener() {}, + baseURI: 'https://host.example/panel', + body: { clientWidth: 600 }, + }; + const deliver = (source, data) => { + const event = new Event('message'); + Object.assign(event, { source, data }); + windowRef.dispatchEvent(event); + }; + const result = (issuedAt) => ({ + jsonrpc: '2.0', + method: 'ui/notifications/tool-result', + params: { + structuredContent: { + url: 'http://localhost:4173/#sl=google,48.85840,2.29450', + issued_at: issuedAt, + view: { camera: { lat: 48.8584, lon: 2.2945 } }, + }, + }, + }); + try { + panelRuntime({ + appBaseUrl: 'http://localhost:4173/', + panelBase: '/panel/', + protocolVersion: '2025-06-18', + panelHeight: 480, + }); + deliver(windowRef, { type: 'gev:ready' }); + deliver(parent, result(Date.now())); + // A host replaying an old result on the same mount. + deliver(parent, result(Date.now() - 60 * 60 * 1000)); + deliver(parent, result(undefined)); + } finally { + globalThis.window = savedWindow; + globalThis.document = savedDocument; + } + assert.deepEqual( + views.map((message) => message.fresh), + [true, false, false], + ); +}); diff --git a/src/app/tools.js b/src/app/tools.js index 9da848c78..cffae32b2 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -246,8 +246,10 @@ export function createApplicationTools({ shell: styleManager, viewer, dataManager, - run: (name, args) => voiceCommands.runner(name, args, { signal }), + run: (name, args, options = {}) => + voiceCommands.runner(name, args, { ...options, signal }), signal, + terrain: operations.requests.terrain, }), ); // An application's own tool takes the pointer as Draw does, and its own diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 8224cecbf..e81e4651e 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -982,6 +982,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping // Canonical serialization pins every tool name, description, property and // ordering while allowing source formatting. Derived from the unchanged // release schema before formatting (the previous source-byte pin passed). + // show_street_level (street-level photos) is a later, separate tool. const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter( (tool) => ![ @@ -989,6 +990,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping 'resolve_area', 'find_imagery', 'osm_query', + 'show_street_level', ].includes(tool.name), ); const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud') 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 b8a27ffec..e79b3f0ec 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1889,6 +1889,7 @@ "KartaView did not filter this area in time — move the map to ask again": "لم يُصفِّ KartaView هذه المنطقة في الوقت المحدد — حرّك الخريطة لإعادة الطلب", "None of KartaView’s photos here pass the imagery filter": "لا تجتاز أي من صور KartaView هنا مرشح الصور", "KartaView did not answer in time — try again": "لم يستجب KartaView في الوقت المحدد — أعد المحاولة", + "Mapillary did not answer in time — try again": "لم يستجب Mapillary في الوقت المحدد — أعد المحاولة", "KartaView could not be reached — check the network": "تعذّر الوصول إلى KartaView — تحقّق من الشبكة", "KartaView sent an answer that could not be read": "أرسل KartaView استجابة تعذّرت قراءتها", "KartaView is rate-limiting requests": "يقيّد KartaView معدل الطلبات", @@ -3734,6 +3735,17 @@ "Refresh map still": "تحديث صورة الخريطة الثابتة", "The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.", "Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.", + "Press OPEN to load the photo": "اضغط «فتح» لتحميل الصورة", + "Captured {month}": "التُقطت في {month}", + "this spot": "هذا الموضع", + "the view centre": "مركز العرض", + "{source} at {place}": "{source} في {place}", + "Street level at {place}": "مستوى الشارع في {place}", + "Switching map": "جارٍ تبديل الخريطة", + "Opening imagery": "جارٍ فتح الصور", + "Street View is ready: press {open} to load it": "Street View جاهز: اضغط «{open}» لتحميله", + "{source} cannot open this link's street-level photo without a working key": "لا يمكن لـ {source} فتح صورة مستوى الشارع في هذا الرابط بدون مفتاح صالح", + "Street-level photos are hidden in the current view. Leave it first.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا.", "STATIONS": "المحطات", "Crewed stations and the craft that visit them": "المحطات المأهولة والمركبات التي تزورها", "IMAGING": "التصوير", @@ -3789,6 +3801,21 @@ "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "معرّف UUID للسفينة: {id}", + "Latitude must be within ±90° and longitude within ±180°": "يجب أن يكون خط العرض ضمن ±90° وخط الطول ضمن ±180°", + "Street Level is unavailable": "مستوى الشارع غير متاح", + "No street-level imagery source is configured": "لم يُعَدّ أي مصدر لصور مستوى الشارع", + "Street Level could not be enabled": "تعذّر تفعيل مستوى الشارع", + "Street Level changed while it looked for imagery": "تغيّر مستوى الشارع أثناء البحث عن الصور", + "Aim the view at a street": "وجّه العرض نحو شارع", + "{source} is not a street-level imagery source here": "{source} ليس مصدرًا لصور مستوى الشارع هنا", + "{source} needs its key": "يحتاج {source} إلى مفتاحه", + "{source} needs the {map} map, which is not available": "يحتاج {source} إلى خريطة {map}، وهي غير متاحة", + "{source} could not be switched on": "تعذّر تفعيل {source}", + "{source} is not available on this map": "{source} غير متاح على هذه الخريطة", + "{source} imagery could not be opened": "تعذّر فتح صور {source}", + "{place} has no position to look for imagery at": "لا يوجد لـ{place} موضع للبحث عن الصور فيه", + "No {source} imagery near {place}": "لا توجد صور {source} قرب {place}", + "{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})", "The globe stopped drawing": "توقفت الكرة الأرضية عن الرسم", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "استعاد المتصفح رسومات الكرة الأرضية ثلاثية الأبعاد. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لن تُرسم حتى تُعاد تحميل الصفحة.", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "توقف العرض بعد خطأ. لا تزال الطبقات وعناصر التحكم تعمل، لكن الخريطة لا يتم تحديثها.", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 0569d2642..d12e6a478 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1791,6 +1791,7 @@ "KartaView did not filter this area in time — move the map to ask again": "KartaView hat dieses Gebiet nicht rechtzeitig gefiltert – Karte verschieben, um erneut anzufragen", "None of KartaView’s photos here pass the imagery filter": "Keines der KartaView-Fotos hier passt zum Bildfilter", "KartaView did not answer in time — try again": "KartaView hat nicht rechtzeitig geantwortet – erneut versuchen", + "Mapillary did not answer in time — try again": "Mapillary hat nicht rechtzeitig geantwortet – erneut versuchen", "KartaView could not be reached — check the network": "KartaView ist nicht erreichbar – Netzwerk prüfen", "KartaView sent an answer that could not be read": "KartaView hat eine unlesbare Antwort gesendet", "KartaView is rate-limiting requests": "KartaView begrenzt die Anfragen", @@ -3410,6 +3411,17 @@ "Refresh map still": "Kartenstandbild aktualisieren", "The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.", "Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.", + "Press OPEN to load the photo": "Auf ÖFFNEN klicken, um das Foto zu laden", + "Captured {month}": "Aufgenommen im {month}", + "this spot": "diese Stelle", + "the view centre": "die Bildmitte", + "{source} at {place}": "{source} bei {place}", + "Street level at {place}": "Straßenebene bei {place}", + "Switching map": "Karte wird gewechselt", + "Opening imagery": "Bilder werden geöffnet", + "Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden", + "{source} cannot open this link's street-level photo without a working key": "{source} kann das Straßenfoto dieses Links ohne gültigen Schlüssel nicht öffnen", + "Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen.", "STATIONS": "STATIONEN", "Crewed stations and the craft that visit them": "Bemannte Stationen und die Raumfahrzeuge, die sie anfliegen", "IMAGING": "BILDGEBUNG", @@ -3465,6 +3477,21 @@ "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "Schiffs-UUID: {id}", + "Latitude must be within ±90° and longitude within ±180°": "Der Breitengrad muss innerhalb von ±90° und der Längengrad innerhalb von ±180° liegen", + "Street Level is unavailable": "Straßenebene ist nicht verfügbar", + "No street-level imagery source is configured": "Keine Quelle für Bilder auf Straßenebene konfiguriert", + "Street Level could not be enabled": "Straßenebene konnte nicht eingeschaltet werden", + "Street Level changed while it looked for imagery": "Straßenebene hat sich während der Bildersuche geändert", + "Aim the view at a street": "Die Ansicht auf eine Straße richten", + "{source} is not a street-level imagery source here": "{source} ist hier keine Quelle für Bilder auf Straßenebene", + "{source} needs its key": "{source} braucht seinen Schlüssel", + "{source} needs the {map} map, which is not available": "{source} braucht die {map}-Karte, die nicht verfügbar ist", + "{source} could not be switched on": "{source} konnte nicht eingeschaltet werden", + "{source} is not available on this map": "{source} ist auf dieser Karte nicht verfügbar", + "{source} imagery could not be opened": "Bilder von {source} konnten nicht geöffnet werden", + "{place} has no position to look for imagery at": "{place} hat keine Position, an der nach Bildern gesucht werden kann", + "No {source} imagery near {place}": "Keine Bilder von {source} in der Nähe von {place}", + "{reason} (filters on: {filters})": "{reason} (aktive Filter: {filters})", "The globe stopped drawing": "Der Globus zeichnet nicht mehr", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Der Browser hat dem Globus die 3D-Grafik entzogen. Ebenen und Bedienelemente funktionieren weiter, aber die Karte kann erst nach dem Neuladen der Seite wieder zeichnen.", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Das Rendern wurde nach einem Fehler angehalten. Ebenen und Bedienelemente funktionieren weiter, aber die Karte wird nicht aktualisiert.", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c94012faf..e61ac8216 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2411,6 +2411,7 @@ "KartaView did not filter this area in time — move the map to ask again": "KartaView no filtró esta zona a tiempo — mueve el mapa para pedirlo de nuevo", "None of KartaView’s photos here pass the imagery filter": "Ninguna foto de KartaView de aquí pasa el filtro de imágenes", "KartaView did not answer in time — try again": "KartaView no respondió a tiempo — inténtalo de nuevo", + "Mapillary did not answer in time — try again": "Mapillary no respondió a tiempo — inténtalo de nuevo", "KartaView could not be reached — check the network": "No se pudo conectar con KartaView — comprueba la red", "KartaView sent an answer that could not be read": "KartaView envió una respuesta que no se pudo leer", "KartaView is rate-limiting requests": "KartaView está limitando las peticiones", @@ -3491,6 +3492,17 @@ "Refresh map still": "Actualizar imagen fija del mapa", "The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.", "Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.", + "Press OPEN to load the photo": "Pulse ABRIR para cargar la foto", + "Captured {month}": "Capturada en {month}", + "this spot": "este punto", + "the view centre": "el centro de la vista", + "{source} at {place}": "{source} en {place}", + "Street level at {place}": "A pie de calle en {place}", + "Switching map": "Cambiando de mapa", + "Opening imagery": "Abriendo imágenes", + "Street View is ready: press {open} to load it": "Street View está listo: pulsa {open} para cargarlo", + "{source} cannot open this link's street-level photo without a working key": "{source} no puede abrir la foto a pie de calle de este enlace sin una clave válida", + "Street-level photos are hidden in the current view. Leave it first.": "Las fotos a pie de calle están ocultas en la vista actual. Sal de ella primero.", "STATIONS": "ESTACIONES", "Crewed stations and the craft that visit them": "Estaciones tripuladas y las naves que las visitan", "IMAGING": "IMÁGENES", @@ -3546,6 +3558,21 @@ "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID del buque: {id}", + "Latitude must be within ±90° and longitude within ±180°": "La latitud debe estar dentro de ±90° y la longitud dentro de ±180°", + "Street Level is unavailable": "A pie de calle no está disponible", + "No street-level imagery source is configured": "No hay ninguna fuente de imágenes a pie de calle configurada", + "Street Level could not be enabled": "No se pudo activar A pie de calle", + "Street Level changed while it looked for imagery": "A pie de calle cambió mientras buscaba imágenes", + "Aim the view at a street": "Apunta la vista a una calle", + "{source} is not a street-level imagery source here": "{source} no es una fuente de imágenes a pie de calle aquí", + "{source} needs its key": "{source} necesita su clave", + "{source} needs the {map} map, which is not available": "{source} requiere el mapa {map}, que no está disponible", + "{source} could not be switched on": "No se pudo activar {source}", + "{source} is not available on this map": "{source} no está disponible en este mapa", + "{source} imagery could not be opened": "No se pudieron abrir las imágenes de {source}", + "{place} has no position to look for imagery at": "{place} no tiene una posición donde buscar imágenes", + "No {source} imagery near {place}": "No hay imágenes de {source} cerca de {place}", + "{reason} (filters on: {filters})": "{reason} (filtros activos: {filters})", "The globe stopped drawing": "El globo dejó de dibujarse", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "El navegador retiró los gráficos 3D del globo. Las capas y los controles siguen funcionando, pero el mapa no se dibujará hasta que se recargue la página.", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "El renderizado se detuvo tras un error. Las capas y los controles siguen funcionando, pero el mapa no se actualiza.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c7e0266a9..87512a64d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1185,6 +1185,7 @@ "KartaView did not filter this area in time — move the map to ask again": "KartaView n'a pas filtré cette zone à temps — déplacez la carte pour relancer la demande", "None of KartaView’s photos here pass the imagery filter": "Aucune photo KartaView de cet endroit ne passe le filtre d'images", "KartaView did not answer in time — try again": "KartaView n'a pas répondu à temps — réessayez", + "Mapillary did not answer in time — try again": "Mapillary n'a pas répondu à temps — réessayez", "KartaView could not be reached — check the network": "KartaView est injoignable — vérifiez le réseau", "KartaView sent an answer that could not be read": "KartaView a envoyé une réponse illisible", "KartaView is rate-limiting requests": "KartaView limite les requêtes", @@ -3491,6 +3492,17 @@ "Refresh map still": "Actualiser l'image fixe sur la carte", "The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.", "Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.", + "Press OPEN to load the photo": "Appuyez sur OUVRIR pour charger la photo", + "Captured {month}": "Prise en {month}", + "this spot": "cet endroit", + "the view centre": "le centre de la vue", + "{source} at {place}": "{source} à {place}", + "Street level at {place}": "Niveau rue à {place}", + "Switching map": "Changement de carte", + "Opening imagery": "Ouverture des images", + "Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger", + "{source} cannot open this link's street-level photo without a working key": "{source} ne peut pas ouvrir la photo au niveau de la rue de ce lien sans clé valide", + "Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord.", "STATIONS": "STATIONS", "Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent", "IMAGING": "IMAGERIE", @@ -3546,6 +3558,21 @@ "NORAD: {id}": "NORAD : {id}", "MMSI: {id}": "MMSI : {id}", "Vessel UUID: {id}": "UUID du navire : {id}", + "Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise dans ±90° et la longitude dans ±180°", + "Street Level is unavailable": "Niveau rue n'est pas disponible", + "No street-level imagery source is configured": "Aucune source d'images au niveau de la rue n'est configurée", + "Street Level could not be enabled": "Impossible d'activer Niveau rue", + "Street Level changed while it looked for imagery": "Niveau rue a changé pendant la recherche d'images", + "Aim the view at a street": "Orientez la vue vers une rue", + "{source} is not a street-level imagery source here": "{source} n'est pas une source d'images au niveau de la rue ici", + "{source} needs its key": "{source} a besoin de sa clé", + "{source} needs the {map} map, which is not available": "{source} nécessite la carte {map}, qui n'est pas disponible", + "{source} could not be switched on": "Impossible d'activer {source}", + "{source} is not available on this map": "{source} n'est pas disponible sur cette carte", + "{source} imagery could not be opened": "Impossible d'ouvrir les images de {source}", + "{place} has no position to look for imagery at": "{place} n'a pas de position où chercher des images", + "No {source} imagery near {place}": "Aucune image {source} près de {place}", + "{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})", "The globe stopped drawing": "Le globe ne s’affiche plus", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Le navigateur a retiré au globe ses graphismes 3D. Les calques et les commandes fonctionnent toujours, mais la carte ne pourra s’afficher qu’après le rechargement de la page.", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Le rendu s’est arrêté après une erreur. Les calques et les commandes fonctionnent toujours, mais la carte ne se met plus à jour.", diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index 2b3063a83..b16aca225 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -1051,6 +1051,7 @@ "KartaView did not filter this area in time — move the map to ask again": "KartaView לא סינן את האזור בזמן — הזז את המפה כדי לשאול שוב", "None of KartaView’s photos here pass the imagery filter": "אף תמונת KartaView כאן לא עוברת את מסנן הצילומים", "KartaView did not answer in time — try again": "KartaView לא ענה בזמן — נסה שוב", + "Mapillary did not answer in time — try again": "Mapillary לא ענה בזמן — נסה שוב", "KartaView could not be reached — check the network": "KartaView לא נגיש — בדוק את הרשת", "KartaView sent an answer that could not be read": "KartaView שלח תשובה שלא ניתן לקרוא", "KartaView is rate-limiting requests": "KartaView מגביל את קצב הבקשות", @@ -3292,6 +3293,17 @@ "Refresh map still": "רענון תמונת המפה", "The map still uses a separate request and may show a different moment.": "תמונת המפה נשלפת בבקשה נפרדת ועשויה להראות רגע שונה.", "Video plays from Québec 511, which sees your IP address.": "הווידאו זורם מ-Québec 511, שרואה את כתובת ה-IP שלך.", + "Press OPEN to load the photo": "לחץ על פתח כדי לטעון את התמונה", + "Captured {month}": "צולם ב-{month}", + "this spot": "המקום הזה", + "the view centre": "מרכז התצוגה", + "{source} at {place}": "{source} ב-{place}", + "Street level at {place}": "רמת רחוב ב-{place}", + "Switching map": "מחליף מפה", + "Opening imagery": "פותח צילומים", + "Street View is ready: press {open} to load it": "Street View מוכן: לחץ על {open} כדי לטעון אותו", + "{source} cannot open this link's street-level photo without a working key": "{source} לא יכול לפתוח את תמונת רמת הרחוב של הקישור הזה בלי מפתח תקף", + "Street-level photos are hidden in the current view. Leave it first.": "תמונות ברמת הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.", "Ship bridge view": "תצוגת גשר האונייה", "Satellite orbital view": "התצוגה המסלולית של הלוויין", "Exit bridge view": "יציאה מתצוגת הגשר", @@ -3546,6 +3558,21 @@ "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID של כלי השיט: {id}", + "Latitude must be within ±90° and longitude within ±180°": "קו הרוחב חייב להיות בטווח ±90° וקו האורך בטווח ±180°", + "Street Level is unavailable": "Street Level אינו זמין", + "No street-level imagery source is configured": "לא הוגדר מקור לצילומי רחוב", + "Street Level could not be enabled": "לא ניתן היה להפעיל את Street Level", + "Street Level changed while it looked for imagery": "Street Level השתנה בזמן החיפוש אחר צילומים", + "Aim the view at a street": "כוון את התצוגה אל רחוב", + "{source} is not a street-level imagery source here": "{source} אינו מקור לצילומי רחוב כאן", + "{source} needs its key": "{source} דורש את המפתח שלו", + "{source} needs the {map} map, which is not available": "{source} דורש את מפת {map}, שאינה זמינה", + "{source} could not be switched on": "לא ניתן היה להפעיל את {source}", + "{source} is not available on this map": "{source} אינו זמין במפה הזו", + "{source} imagery could not be opened": "לא ניתן היה לפתוח את הצילומים של {source}", + "{place} has no position to look for imagery at": "ל-{place} אין מיקום לחיפוש צילומים", + "No {source} imagery near {place}": "אין צילומים של {source} ליד {place}", + "{reason} (filters on: {filters})": "{reason} (מסננים פעילים: {filters})", "The globe stopped drawing": "הגלובוס הפסיק להצטייר", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "הדפדפן לקח בחזרה את הגרפיקה התלת-ממדית של הגלובוס. השכבות והפקדים עדיין פועלים, אבל המפה לא תוכל להצטייר עד שהעמוד ייטען מחדש.", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "הרינדור נעצר בעקבות שגיאה. השכבות והפקדים עדיין פועלים, אבל המפה אינה מתעדכנת.", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index a9b566adf..5a48948c1 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1185,6 +1185,7 @@ "KartaView did not filter this area in time — move the map to ask again": "KartaView non ha filtrato quest'area in tempo: sposta la mappa per riprovare", "None of KartaView’s photos here pass the imagery filter": "Nessuna delle foto KartaView qui supera il filtro delle immagini", "KartaView did not answer in time — try again": "KartaView non ha risposto in tempo: riprova", + "Mapillary did not answer in time — try again": "Mapillary non ha risposto in tempo: riprova", "KartaView could not be reached — check the network": "KartaView non è raggiungibile: controlla la rete", "KartaView sent an answer that could not be read": "KartaView ha inviato una risposta illeggibile", "KartaView is rate-limiting requests": "KartaView sta limitando le richieste", @@ -3491,6 +3492,17 @@ "Refresh map still": "Aggiorna immagine fissa sulla mappa", "The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.", "Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.", + "Press OPEN to load the photo": "Premi APRI per caricare la foto", + "Captured {month}": "Scattata a {month}", + "this spot": "questo punto", + "the view centre": "il centro della vista", + "{source} at {place}": "{source} a {place}", + "Street level at {place}": "Vista stradale a {place}", + "Switching map": "Cambio mappa", + "Opening imagery": "Apertura delle immagini", + "Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo", + "{source} cannot open this link's street-level photo without a working key": "{source} non può aprire la foto a livello stradale di questo link senza una chiave valida", + "Street-level photos are hidden in the current view. Leave it first.": "Le foto a livello stradale sono nascoste nella vista attuale. Esci prima da questa vista.", "STATIONS": "STAZIONI", "Crewed stations and the craft that visit them": "Stazioni con equipaggio e i veicoli che le visitano", "IMAGING": "IMMAGINI", @@ -3546,6 +3558,21 @@ "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID della nave: {id}", + "Latitude must be within ±90° and longitude within ±180°": "La latitudine deve essere entro ±90° e la longitudine entro ±180°", + "Street Level is unavailable": "Vista stradale non è disponibile", + "No street-level imagery source is configured": "Nessuna fonte di immagini a livello stradale configurata", + "Street Level could not be enabled": "Impossibile attivare Vista stradale", + "Street Level changed while it looked for imagery": "Vista stradale è cambiata durante la ricerca delle immagini", + "Aim the view at a street": "Punta la vista su una strada", + "{source} is not a street-level imagery source here": "{source} non è una fonte di immagini a livello stradale qui", + "{source} needs its key": "{source} richiede la sua chiave", + "{source} needs the {map} map, which is not available": "{source} richiede la mappa {map}, che non è disponibile", + "{source} could not be switched on": "Impossibile attivare {source}", + "{source} is not available on this map": "{source} non è disponibile su questa mappa", + "{source} imagery could not be opened": "Impossibile aprire le immagini di {source}", + "{place} has no position to look for imagery at": "{place} non ha una posizione in cui cercare immagini", + "No {source} imagery near {place}": "Nessuna immagine di {source} vicino a {place}", + "{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})", "The globe stopped drawing": "Il globo ha smesso di disegnare", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Il browser ha ritirato la grafica 3D del globo. Livelli e controlli funzionano ancora, ma la mappa non può essere disegnata finché la pagina non viene ricaricata.", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Il rendering si è fermato dopo un errore. Livelli e controlli funzionano ancora, ma la mappa non si aggiorna.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 9bf395e69..5ca7cfbca 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1147,6 +1147,7 @@ "KartaView did not filter this area in time — move the map to ask again": "KartaViewがこの範囲を時間内に絞り込めませんでした。地図を動かして再度リクエストしてください", "None of KartaView’s photos here pass the imagery filter": "この場所のKartaViewの写真はいずれも画像フィルターを通過しません", "KartaView did not answer in time — try again": "KartaViewから時間内に応答がありませんでした。もう一度お試しください", + "Mapillary did not answer in time — try again": "Mapillaryから時間内に応答がありませんでした。もう一度お試しください", "KartaView could not be reached — check the network": "KartaViewに接続できません。ネットワークを確認してください", "KartaView sent an answer that could not be read": "KartaViewから読み取れない応答が返されました", "KartaView is rate-limiting requests": "KartaViewがリクエストをレート制限中です", @@ -3329,6 +3330,17 @@ "Refresh map still": "地図上の静止画を更新", "The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。", "Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。", + "Press OPEN to load the photo": "「開く」を押して写真を読み込む", + "Captured {month}": "{month}撮影", + "this spot": "この地点", + "the view centre": "表示中心", + "{source} at {place}": "{place}の{source}", + "Street level at {place}": "{place}のストリートレベル", + "Switching map": "地図を切り替え中", + "Opening imagery": "画像を開いています", + "Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます", + "{source} cannot open this link's street-level photo without a working key": "{source} は有効なキーがないため、このリンクのストリートレベル写真を開けません", + "Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。", "STATIONS": "ステーション", "Crewed stations and the craft that visit them": "有人宇宙ステーションと、そこを訪れる宇宙機", "IMAGING": "地球観測", @@ -3384,6 +3396,21 @@ "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "船舶UUID: {id}", + "Latitude must be within ±90° and longitude within ±180°": "緯度は±90°、経度は±180°の範囲内で指定してください", + "Street Level is unavailable": "ストリートレベルは利用できません", + "No street-level imagery source is configured": "ストリートレベル画像のソースが設定されていません", + "Street Level could not be enabled": "ストリートレベルをオンにできませんでした", + "Street Level changed while it looked for imagery": "画像の検索中にストリートレベルが変更されました", + "Aim the view at a street": "表示を通りに向けてください", + "{source} is not a street-level imagery source here": "{source}はここではストリートレベル画像のソースではありません", + "{source} needs its key": "{source}にはキーが必要です", + "{source} needs the {map} map, which is not available": "{source}には{map}マップが必要ですが、利用できません", + "{source} could not be switched on": "{source}をオンにできませんでした", + "{source} is not available on this map": "{source}はこのマップでは利用できません", + "{source} imagery could not be opened": "{source}の画像を開けませんでした", + "{place} has no position to look for imagery at": "{place}には画像を探す位置がありません", + "No {source} imagery near {place}": "{place}の近くに{source}の画像はありません", + "{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})", "The globe stopped drawing": "地球儀の描画が止まりました", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "ブラウザーが地球儀の 3D グラフィックスを解放しました。レイヤーと操作は引き続き使えますが、ページを再読み込みするまで地図は描画されません。", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "エラーのため描画が停止しました。レイヤーと操作は引き続き使えますが、地図は更新されません。", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 3090da94e..b7b09adcf 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1183,6 +1183,7 @@ "KartaView did not filter this area in time — move the map to ask again": "O KartaView não filtrou esta área a tempo — mova o mapa para pedir de novo", "None of KartaView’s photos here pass the imagery filter": "Nenhuma foto do KartaView aqui passa no filtro de imagens", "KartaView did not answer in time — try again": "O KartaView não respondeu a tempo — tente novamente", + "Mapillary did not answer in time — try again": "O Mapillary não respondeu a tempo — tente novamente", "KartaView could not be reached — check the network": "Não foi possível acessar o KartaView — verifique a rede", "KartaView sent an answer that could not be read": "O KartaView enviou uma resposta que não pôde ser lida", "KartaView is rate-limiting requests": "O KartaView está limitando as requisições", @@ -3491,6 +3492,17 @@ "Refresh map still": "Atualizar imagem fixa no mapa", "The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.", "Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.", + "Press OPEN to load the photo": "Clique em ABRIR para carregar a foto", + "Captured {month}": "Capturada em {month}", + "this spot": "este ponto", + "the view centre": "o centro da vista", + "{source} at {place}": "{source} em {place}", + "Street level at {place}": "Nível da rua em {place}", + "Switching map": "Mudando de mapa", + "Opening imagery": "Abrindo imagens", + "Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar", + "{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto ao nível da rua deste link sem uma chave válida", + "Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro.", "STATIONS": "ESTAÇÕES", "Crewed stations and the craft that visit them": "Estações tripuladas e as naves que as visitam", "IMAGING": "IMAGENS", @@ -3546,6 +3558,21 @@ "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID da embarcação: {id}", + "Latitude must be within ±90° and longitude within ±180°": "A latitude deve estar dentro de ±90° e a longitude dentro de ±180°", + "Street Level is unavailable": "Nível da rua não está disponível", + "No street-level imagery source is configured": "Nenhuma fonte de imagens no nível da rua configurada", + "Street Level could not be enabled": "Não foi possível ativar Nível da rua", + "Street Level changed while it looked for imagery": "Nível da rua mudou enquanto procurava imagens", + "Aim the view at a street": "Aponte a vista para uma rua", + "{source} is not a street-level imagery source here": "{source} não é uma fonte de imagens no nível da rua aqui", + "{source} needs its key": "{source} precisa da sua chave", + "{source} needs the {map} map, which is not available": "{source} requer o mapa {map}, que não está disponível", + "{source} could not be switched on": "Não foi possível ativar {source}", + "{source} is not available on this map": "{source} não está disponível neste mapa", + "{source} imagery could not be opened": "Não foi possível abrir as imagens de {source}", + "{place} has no position to look for imagery at": "{place} não tem uma posição para buscar imagens", + "No {source} imagery near {place}": "Nenhuma imagem de {source} perto de {place}", + "{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})", "The globe stopped drawing": "O globo parou de desenhar", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "O navegador retirou os gráficos 3D do globo. As camadas e os controles continuam funcionando, mas o mapa só volta a ser desenhado depois de recarregar a página.", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "A renderização parou após um erro. As camadas e os controles continuam funcionando, mas o mapa não está sendo atualizado.", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 9040a122e..4eefe2802 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2452,6 +2452,7 @@ "KartaView did not filter this area in time — move the map to ask again": "KartaView не успел отфильтровать эту область — сдвиньте карту, чтобы запросить снова", "None of KartaView’s photos here pass the imagery filter": "Ни одна фотография KartaView здесь не проходит фильтр снимков", "KartaView did not answer in time — try again": "KartaView не ответил вовремя — попробуйте снова", + "Mapillary did not answer in time — try again": "Mapillary не ответил вовремя — попробуйте снова", "KartaView could not be reached — check the network": "Не удалось связаться с KartaView — проверьте сеть", "KartaView sent an answer that could not be read": "KartaView прислал ответ, который не удалось прочитать", "KartaView is rate-limiting requests": "KartaView ограничивает частоту запросов", @@ -3572,6 +3573,17 @@ "Refresh map still": "Обновить кадр на карте", "The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.", "Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.", + "Press OPEN to load the photo": "Нажмите ОТКРЫТЬ, чтобы загрузить снимок", + "Captured {month}": "Снято: {month}", + "this spot": "эта точка", + "the view centre": "центр вида", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Уличные снимки: {place}", + "Switching map": "Смена карты", + "Opening imagery": "Открытие снимков", + "Street View is ready: press {open} to load it": "Просмотр улиц готов: нажмите {open}, чтобы загрузить", + "{source} cannot open this link's street-level photo without a working key": "{source} не может открыть уличное фото по этой ссылке без рабочего ключа", + "Street-level photos are hidden in the current view. Leave it first.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него.", "STATIONS": "СТАНЦИИ", "Crewed stations and the craft that visit them": "Пилотируемые станции и прилетающие к ним корабли", "IMAGING": "СЪЁМКА", @@ -3627,6 +3639,21 @@ "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID судна: {id}", + "Latitude must be within ±90° and longitude within ±180°": "Широта должна быть в пределах ±90°, а долгота — в пределах ±180°", + "Street Level is unavailable": "Уличные снимки недоступны", + "No street-level imagery source is configured": "Источник уличных снимков не настроен", + "Street Level could not be enabled": "Не удалось включить уличные снимки", + "Street Level changed while it looked for imagery": "Уличные снимки изменились во время поиска", + "Aim the view at a street": "Наведите вид на улицу", + "{source} is not a street-level imagery source here": "{source} здесь не является источником уличных снимков", + "{source} needs its key": "{source}: нужен ключ", + "{source} needs the {map} map, which is not available": "{source}: нужна карта {map}, но она недоступна", + "{source} could not be switched on": "Не удалось включить {source}", + "{source} is not available on this map": "{source}: недоступно на этой карте", + "{source} imagery could not be opened": "Не удалось открыть снимки {source}", + "{place} has no position to look for imagery at": "У места «{place}» нет координат для поиска снимков", + "No {source} imagery near {place}": "Нет снимков {source} рядом с {place}", + "{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})", "The globe stopped drawing": "Глобус перестал отрисовываться", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер отключил 3D-графику глобуса. Слои и элементы управления работают, но карта не отрисуется, пока страница не будет перезагружена.", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Отрисовка остановилась из-за ошибки. Слои и элементы управления работают, но карта не обновляется.", diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json new file mode 100644 index 000000000..367025e4e --- /dev/null +++ b/src/i18n/locales/sv.json @@ -0,0 +1,3495 @@ +{ + "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", + "Mapillary did not answer in time — try again": "Mapillary 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.", + "Press OPEN to load the photo": "Tryck på ÖPPNA för att läsa in bilden", + "Captured {month}": "Tagen {month}", + "this spot": "den här platsen", + "the view centre": "vyns mitt", + "{source} at {place}": "{source} vid {place}", + "Street level at {place}": "Gatunivå vid {place}", + "Switching map": "Byter karta", + "Opening imagery": "Öppnar bilder", + "Street View is ready: press {open} to load it": "Street View är redo: tryck på {open} för att läsa in den", + "{source} cannot open this link's street-level photo without a working key": "{source} kan inte öppna länkens bild på gatunivå utan en fungerande nyckel", + "Street-level photos are hidden in the current view. Leave it first.": "Bilder på gatunivå är dolda i den aktuella vyn. Lämna den först.", + "STATIONS": "STATIONER", + "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}", + "Latitude must be within ±90° and longitude within ±180°": "Latituden måste ligga inom ±90° och longituden inom ±180°", + "Street Level is unavailable": "”Gatunivå” är inte tillgängligt", + "No street-level imagery source is configured": "Ingen källa för bilder på gatunivå är konfigurerad", + "Street Level could not be enabled": "”Gatunivå” kunde inte aktiveras", + "Street Level changed while it looked for imagery": "”Gatunivå” ändrades medan bilder söktes", + "Aim the view at a street": "Rikta vyn mot en gata", + "{source} is not a street-level imagery source here": "{source} är inte en källa för bilder på gatunivå här", + "{source} needs its key": "{source} behöver sin nyckel", + "{source} needs the {map} map, which is not available": "{source} behöver kartan {map}, som inte är tillgänglig", + "{source} could not be switched on": "{source} kunde inte slås på", + "{source} is not available on this map": "{source} är inte tillgänglig på den här kartan", + "{source} imagery could not be opened": "Bilder från {source} kunde inte öppnas", + "{place} has no position to look for imagery at": "{place} har ingen position att söka efter bilder vid", + "No {source} imagery near {place}": "Inga bilder från {source} nära {place}", + "{reason} (filters on: {filters})": "{reason} (aktiva filter: {filters})", + "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 e1dcc1447..1c50fefb0 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1203,6 +1203,7 @@ "KartaView did not filter this area in time — move the map to ask again": "KartaView не встиг відфільтрувати цю область — зсуньте карту, щоб запитати знову", "None of KartaView’s photos here pass the imagery filter": "Жодне фото KartaView тут не проходить фільтр знімків", "KartaView did not answer in time — try again": "KartaView не відповів вчасно — спробуйте знову", + "Mapillary did not answer in time — try again": "Mapillary не відповів вчасно — спробуйте знову", "KartaView could not be reached — check the network": "Не вдалося звʼязатися з KartaView — перевірте мережу", "KartaView sent an answer that could not be read": "KartaView надіслав відповідь, яку не вдалося прочитати", "KartaView is rate-limiting requests": "KartaView обмежує частоту запитів", @@ -3572,6 +3573,17 @@ "Refresh map still": "Оновити кадр на карті", "The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.", "Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.", + "Press OPEN to load the photo": "Натисніть «ВІДКРИТИ», щоб завантажити фото", + "Captured {month}": "Знято: {month}", + "this spot": "це місце", + "the view centre": "центр карти", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Вуличні знімки: {place}", + "Switching map": "Перемикання карти", + "Opening imagery": "Відкриття знімків", + "Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити", + "{source} cannot open this link's street-level photo without a working key": "{source} не може відкрити вуличне фото за цим посиланням без робочого ключа", + "Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього.", "STATIONS": "СТАНЦІЇ", "Crewed stations and the craft that visit them": "Пілотовані станції та кораблі, що до них прилітають", "IMAGING": "ЗЙОМКА", @@ -3627,6 +3639,21 @@ "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", "Vessel UUID: {id}": "UUID судна: {id}", + "Latitude must be within ±90° and longitude within ±180°": "Широта має бути в межах ±90°, а довгота — у межах ±180°", + "Street Level is unavailable": "Вуличні знімки недоступні", + "No street-level imagery source is configured": "Джерело вуличних знімків не налаштовано", + "Street Level could not be enabled": "Не вдалося увімкнути вуличні знімки", + "Street Level changed while it looked for imagery": "Вуличні знімки змінилися під час пошуку", + "Aim the view at a street": "Наведіть вигляд на вулицю", + "{source} is not a street-level imagery source here": "{source} тут не є джерелом вуличних знімків", + "{source} needs its key": "{source}: потрібен ключ", + "{source} needs the {map} map, which is not available": "{source}: потрібна карта {map}, але вона недоступна", + "{source} could not be switched on": "Не вдалося увімкнути {source}", + "{source} is not available on this map": "{source}: недоступно на цій карті", + "{source} imagery could not be opened": "Не вдалося відкрити знімки {source}", + "{place} has no position to look for imagery at": "Для місця «{place}» немає координат для пошуку знімків", + "No {source} imagery near {place}": "Немає знімків {source} поблизу {place}", + "{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})", "The globe stopped drawing": "Глобус перестав відмальовуватися", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "Браузер вимкнув 3D-графіку глобуса. Шари та елементи керування працюють, але карта не відмалюється, доки сторінку не буде перезавантажено.", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "Відмальовування зупинилося через помилку. Шари та елементи керування працюють, але карта не оновлюється.", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index efc5870fe..4e5cbd0f0 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1124,6 +1124,7 @@ "KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及时筛选此区域——移动地图以重新请求", "None of KartaView’s photos here pass the imagery filter": "此处没有 KartaView 照片通过影像筛选", "KartaView did not answer in time — try again": "KartaView 未及时响应——请重试", + "Mapillary did not answer in time — try again": "Mapillary 未及时响应——请重试", "KartaView could not be reached — check the network": "无法连接 KartaView——请检查网络", "KartaView sent an answer that could not be read": "KartaView 返回的响应无法读取", "KartaView is rate-limiting requests": "KartaView 已被限流", @@ -3329,6 +3330,17 @@ "Refresh map still": "刷新地图静止画面", "The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。", "Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。", + "Press OPEN to load the photo": "点击“打开”加载照片", + "Captured {month}": "拍摄于 {month}", + "this spot": "此处", + "the view centre": "视图中心", + "{source} at {place}": "{place} 的 {source}", + "Street level at {place}": "{place} 的街景", + "Switching map": "正在切换地图", + "Opening imagery": "正在打开影像", + "Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载", + "{source} cannot open this link's street-level photo without a working key": "没有有效密钥,{source} 无法打开此链接的街景照片", + "Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。", "STATIONS": "空间站", "Crewed stations and the craft that visit them": "载人空间站及来访飞行器", "IMAGING": "成像", @@ -3384,6 +3396,21 @@ "NORAD: {id}": "NORAD:{id}", "MMSI: {id}": "MMSI:{id}", "Vessel UUID: {id}": "船舶 UUID:{id}", + "Latitude must be within ±90° and longitude within ±180°": "纬度须在 ±90° 以内,经度须在 ±180° 以内", + "Street Level is unavailable": "街景不可用", + "No street-level imagery source is configured": "未配置街景影像来源", + "Street Level could not be enabled": "无法开启街景", + "Street Level changed while it looked for imagery": "查找影像时街景已更改", + "Aim the view at a street": "请将视野对准街道", + "{source} is not a street-level imagery source here": "{source} 在此处不是街景影像来源", + "{source} needs its key": "{source} 需要其密钥", + "{source} needs the {map} map, which is not available": "{source} 需要 {map} 地图,但该地图不可用", + "{source} could not be switched on": "无法开启 {source}", + "{source} is not available on this map": "{source} 在此地图上不可用", + "{source} imagery could not be opened": "无法打开 {source} 影像", + "{place} has no position to look for imagery at": "{place} 没有可查找影像的位置", + "No {source} imagery near {place}": "{place} 附近没有 {source} 影像", + "{reason} (filters on: {filters})": "{reason}(已开启筛选:{filters})", "The globe stopped drawing": "地球已停止绘制", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "浏览器收回了地球的 3D 图形。图层和控件仍可使用,但在重新加载页面之前地图无法绘制。", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因错误而停止。图层和控件仍可使用,但地图不再更新。", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 0a5f7b2ba..f4a210143 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1119,6 +1119,7 @@ "KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及時篩選此區域——移動地圖以重新請求", "None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片通過影像篩選", "KartaView did not answer in time — try again": "KartaView 未及時響應——請重試", + "Mapillary did not answer in time — try again": "Mapillary 未及時響應——請重試", "KartaView could not be reached — check the network": "無法連接 KartaView——請檢查網絡", "KartaView sent an answer that could not be read": "KartaView 返回的響應無法讀取", "KartaView is rate-limiting requests": "KartaView 已被限流", @@ -3329,6 +3330,17 @@ "Refresh map still": "重新整理地圖靜態畫面", "The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。", "Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。", + "Press OPEN to load the photo": "點擊“打開”加載照片", + "Captured {month}": "拍攝於 {month}", + "this spot": "此處", + "the view centre": "視圖中心", + "{source} at {place}": "{place} 的 {source}", + "Street level at {place}": "{place} 的街景", + "Switching map": "正在切換地圖", + "Opening imagery": "正在打開影像", + "Street View is ready: press {open} to load it": "街景已就緒:按 {open} 加載", + "{source} cannot open this link's street-level photo without a working key": "沒有有效密鑰,{source} 無法打開此鏈接的街景照片", + "Street-level photos are hidden in the current view. Leave it first.": "當前視圖隱藏了街景照片。請先退出該視圖。", "STATIONS": "太空站", "Crewed stations and the craft that visit them": "載人太空站及來訪飛行器", "IMAGING": "成像", @@ -3384,6 +3396,21 @@ "NORAD: {id}": "NORAD:{id}", "MMSI: {id}": "MMSI:{id}", "Vessel UUID: {id}": "船舶 UUID:{id}", + "Latitude must be within ±90° and longitude within ±180°": "緯度須在 ±90° 以內,經度須在 ±180° 以內", + "Street Level is unavailable": "街景不可用", + "No street-level imagery source is configured": "未配置街景影像來源", + "Street Level could not be enabled": "無法開啟街景", + "Street Level changed while it looked for imagery": "查找影像時街景已更改", + "Aim the view at a street": "請將視野對準街道", + "{source} is not a street-level imagery source here": "{source} 在此處不是街景影像來源", + "{source} needs its key": "{source} 需要其密鑰", + "{source} needs the {map} map, which is not available": "{source} 需要 {map} 地圖,但該地圖不可用", + "{source} could not be switched on": "無法開啟 {source}", + "{source} is not available on this map": "{source} 在此地圖上不可用", + "{source} imagery could not be opened": "無法打開 {source} 影像", + "{place} has no position to look for imagery at": "{place} 沒有可查找影像的位置", + "No {source} imagery near {place}": "{place} 附近沒有 {source} 影像", + "{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})", "The globe stopped drawing": "地球已停止繪製", "The browser took back the globe's 3D graphics. Layers and controls still work, but the map cannot draw until the page reloads.": "瀏覽器收回了地球的 3D 圖形。圖層和控件仍可使用,但在重新加載頁面之前地圖無法繪製。", "Rendering stopped after an error. Layers and controls still work, but the map is not updating.": "渲染因錯誤而停止。圖層和控件仍可使用,但地圖不再更新。", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index c2e5080e0..ed788cebc 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -1087,6 +1087,166 @@ "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", "AIS: --", diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index f3b421991..bee453af8 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -15,13 +15,14 @@ import { keyAlternatives, summarizeCoverage, } from './uiState.js'; -import { groundUnderCamera, viewCentre } from './view.js'; +import { groundUnderCamera, metresBetween, viewCentre } from './view.js'; import { createGroundClick, groundClickHeightOk } from './groundClick.js'; import { msg, t, translateKnown } from '../../i18n/index.js'; import { AIM_AT_GROUND_MESSAGE, keyStatusLabel, NEAREST_RADIUS_M, + NEAREST_RADIUS_MAX_M, POSITION_PICK_ID, STREET_LEVEL_LAYER_ID, } from './policy.js'; @@ -33,6 +34,11 @@ const KEY_STATUS_TIMEOUT_MS = 10_000; /** Re-measures of a ground not loaded yet (Google 3D), a second apart. */ const GROUND_RETRY_LIMIT = 5; const GROUND_RETRY_MS = 1000; +/** + * An armed point stays the OPEN button's target while the view centre is + * this close to it (m); a user who moved on opens what the view shows now. + */ +const ARMED_REACH_M = 1000; /** * The Street Level layer over imagery providers (contract in registry.js). @@ -170,6 +176,12 @@ export function createStreetLevelLayer({ const owner = state.providers.get(state.street.providerId); if (state.street.open && owner && !providerAvailable(owner)) layer.closeViewer(); + // An armed lookup for a source whose map left dies too. + const armedFor = state.street.armed?.providerIds?.map((id) => + state.providers.get(id), + ); + if (armedFor?.some((entry) => entry && !providerAvailable(entry))) + state.street.armed = null; for (const entry of state.providers.values()) { if (!entry.def.requiresMapStack) continue; if (state.enabled && entry.on && providerAvailable(entry)) @@ -254,8 +266,11 @@ export function createStreetLevelLayer({ if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def); } + /** Sources whose open photo a replacing request keeps up while it switches them off. */ + const heldViewers = new Map(); + function deactivate(entry) { - parts.viewerHost.unmount(entry.def.id); + if (!heldViewers.has(entry.def.id)) parts.viewerHost.unmount(entry.def.id); entry.instance.deactivate(); parts.credits.hide(state.viewer, entry.def); } @@ -268,6 +283,13 @@ export function createStreetLevelLayer({ nearestLookup = null; } + /** Close the image and deselect its sequence on the map. */ + function closeViewer() { + abortNearest(); + parts.viewerHost.close(); + parts.clearSequences(); + } + /** Ground under the screen centre; null for sky, undefined when it cannot aim. */ function screenCentreGround() { const canvas = state.viewer?.scene?.canvas; @@ -288,8 +310,14 @@ export function createStreetLevelLayer({ } /** `frame: false` keeps the camera where it is (the user picked the spot). */ - function openImage(providerId, imageId, { frame = true } = {}) { + function openImage( + providerId, + imageId, + { frame = true, signal = null } = {}, + ) { + if (signal?.aborted) return Promise.resolve(false); abortNearest(); + state.street.armed = null; const entry = state.providers.get(providerId); if (entry && !providerAvailable(entry)) { state.street.loading = false; @@ -297,7 +325,7 @@ export function createStreetLevelLayer({ notify(); return Promise.resolve(false); } - return parts.viewerHost.open(providerId, imageId, { frame }); + return parts.viewerHost.open(providerId, imageId, { frame, signal }); } function unavailableReason(entry) { @@ -343,6 +371,9 @@ export function createStreetLevelLayer({ function switchProvider(entry, on) { if (entry.on === on) return; entry.on = on; + // A lookup armed for this source dies with it. + if (!on && state.street.armed?.providerIds?.includes(entry.def.id)) + state.street.armed = null; if (!state.enabled) return; if (on) activate(entry); else deactivate(entry); @@ -479,6 +510,33 @@ export function createStreetLevelLayer({ }, /** Registered providers, in chip order. */ providerIds: defs.map((def) => def.id), + /** The map stack a provider needs (`requiresMapStack`), or null for any. */ + requiredMapStack(providerId) { + const def = defs.find((entry) => entry.id === providerId); + return def?.requiresMapStack || null; + }, + /** + * A provider's name and key state on any map (the panel lists a provider + * only on the map it needs). `configured` is null until its status call + * answers; `keyRequired` covers a missing or refused key. + */ + providerStatus(providerId) { + const entry = state.providers.get(providerId); + if (!entry) return null; + const stats = entry.instance.coverageStats(); + return { + id: entry.def.id, + name: entry.def.name, + on: entry.on, + configured: entry.status ? entry.status.configured === true : null, + keyRequired: stats.keyRequired === true || stats.keyRejected === true, + keyRejected: stats.keyRejected === true, + groundClick: entry.def.groundClick === true, + billed: entry.def.billedPerImage === true, + available: providerAvailable(entry), + error: stats.error || null, + }; + }, getSourceAvailability() { const available = defs.length > 0; return { @@ -636,19 +694,66 @@ export function createStreetLevelLayer({ * Open the nearest image of the active providers (or `providerIds`) near * a point, else the view centre, or with `aim: 'screen-centre'` the ground * under the screen centre (sky: nothing). Providers without - * `nearestImage` are skipped. + * `nearestImage` are skipped. Options: + * - `radiusM` widens the search where a provider can (a geocoded landmark + * is rarely within 50 m of a street); + * - `signal` retires the lookup: nothing opens after it fires; + * - `armed: false` (the assistant's own request) ignores a link's armed + * point and keeps it until a photo opens; + * - `replace: true` keeps an open photo (and EXPAND) up while it looks and + * closes it if nothing opens; + * - `proceed()` is asked before a found photo loads or an empty lookup + * reports; false retires the request and leaves the panel as it was. + * Why nothing opened is in `street.nearestOutcome`: 'empty', 'sky' or + * null (an error). */ async openNearest( point, - { providerIds = null, frame = true, aim = 'view' } = {}, + { + providerIds = null, + frame = true, + aim = 'view', + radiusM = null, + signal: callerSignal = null, + armed: useArmed = true, + replace = false, + proceed = null, + } = {}, ) { + if (callerSignal?.aborted) return false; + state.street.nearestOutcome = null; + // An OPEN press after a link armed a lookup opens what the link asked; + // any other lookup is a new request and drops the armed one. + const armed = useArmed ? state.street.armed : null; + if (useArmed) state.street.armed = null; + const closeReplaced = () => { + if (replace && state.street.open) closeViewer(); + }; + if (!point && aim === 'screen-centre' && armed) { + // OPEN promises the view centre: the link's spot only while the view + // still shows it, or when the view cannot be picked. + const centre = armed.point ? screenCentreGround() : null; + if (!armed.point) radiusM ??= armed.radiusM; + else if ( + centre === undefined || + (centre && metresBetween(centre, armed.point) <= ARMED_REACH_M) + ) { + point = armed.point; + radiusM ??= armed.radiusM; + } + } + const radius = Number.isFinite(radiusM) + ? Math.min(NEAREST_RADIUS_MAX_M, Math.max(1, radiusM)) + : NEAREST_RADIUS_M; let view = point; if (!view && aim === 'screen-centre') { view = screenCentreGround(); if (view === null) { abortNearest(); + closeReplaced(); state.street.loading = false; state.street.error = AIM_AT_GROUND_MESSAGE; + state.street.nearestOutcome = 'sky'; notify(); return false; } @@ -660,6 +765,19 @@ export function createStreetLevelLayer({ const lookup = new AbortController(); nearestLookup = lookup; const { signal } = lookup; + const onCallerAbort = () => lookup.abort(); + callerSignal?.addEventListener('abort', onCallerAbort, { once: true }); + const retired = () => { + callerSignal?.removeEventListener('abort', onCallerAbort); + // A newer lookup owns the panel, its spinner and its error. + if (nearestLookup !== lookup) return false; + nearestLookup = null; + if (state.street.loading) { + state.street.loading = false; + notify(); + } + return false; + }; state.street.loading = true; state.street.error = null; notify(); @@ -674,49 +792,108 @@ export function createStreetLevelLayer({ try { imageId = await entry.instance.nearestImage( { lat: view.lat, lon: view.lon }, - { signal }, + radius === NEAREST_RADIUS_M + ? { signal } + : { signal, radiusM: radius }, ); } catch (error) { + if (callerSignal?.aborted) return retired(); // Overtaken: whatever replaced it owns the panel, errors included. - if (signal.aborted) return false; + if (signal.aborted) return retired(); // One provider failing (no key, offline) must not hide the others. lastError = error; continue; } - if (signal.aborted) return false; + if (callerSignal?.aborted) return retired(); + if (signal.aborted) return retired(); if (!state.enabled) break; // Switched off, or its map left, while it looked: try the next. if (!imageId || !entry.on || !providerAvailable(entry)) continue; + if (proceed && proceed() !== true) return retired(); nearestLookup = null; - return openImage(entry.def.id, imageId, { frame }); + callerSignal?.removeEventListener('abort', onCallerAbort); + // A sequence playing in the replaced photo would step past this one. + if (replace && state.street.open) parts.viewerHost.stopPlayback(); + // The caller's signal still counts while the viewer mounts and loads. + return openImage(entry.def.id, imageId, { + frame, + signal: callerSignal, + }); } - if (nearestLookup === lookup) nearestLookup = null; + callerSignal?.removeEventListener('abort', onCallerAbort); + // Replaced while it looked: the panel is the newer lookup's. + if (nearestLookup !== lookup) return false; + // Given up by its caller: the panel stays as it was. + if (proceed && proceed() !== true) return retired(); + nearestLookup = null; + closeReplaced(); // Switched off while it looked: nothing to open, nothing to report. const stillOn = entries.some( (entry) => entry.on && providerAvailable(entry), ); // A provider's own refusal is English data that the panel translates; // the core's refusal is built here, for the panel only, in the language. + if (state.enabled && stillOn && !lastError) + state.street.nearestOutcome = 'empty'; if (state.enabled && stillOn) state.street.error = lastError?.message || (point ? t('No street-level imagery within {radius} m of that point', { - radius: NEAREST_RADIUS_M, + radius, }) : t( 'No street-level imagery within {radius} m of the view centre', - { radius: NEAREST_RADIUS_M }, + { + radius, + }, )); state.street.loading = false; notify(); return false; }, - /** Close the image and deselect its sequence on the map. */ - closeViewer() { - abortNearest(); - parts.viewerHost.close(); - parts.clearSequences(); + closeViewer, + /** + * Keep the open photo up while a request that replaces it switches its + * source off, so EXPAND stays; only that source's own switch-off is held, + * a source switched by hand meanwhile still stops a load. Release frees + * the old viewer. + */ + holdViewer() { + const id = state.street.open ? state.street.providerId : null; + if (!id) return () => {}; + heldViewers.set(id, (heldViewers.get(id) || 0) + 1); + let held = true; + return () => { + if (!held) return; + held = false; + const left = heldViewers.get(id) - 1; + if (left > 0) return void heldViewers.set(id, left); + heldViewers.delete(id); + if (state.providers.get(id)?.on !== false) return; + // Its photo still up (the request gave up) closes with its source; + // otherwise the request replaced or closed it, and the panel keeps + // what that request left (an error, an armed OPEN). + if (state.street.open && state.street.providerId === id) + parts.viewerHost.unmount(id); + else parts.viewerHost.release(id); + }; + }, + /** + * Arm a nearest lookup for the next OPEN press without running it (a link + * to a source that bills every load). Any other lookup, open or close, the + * source switched off or its map leaving clears it. + */ + armNearest(point, { providerIds = null, radiusM = null } = {}) { + state.street.armed = { + point: + Number.isFinite(point?.lat) && Number.isFinite(point?.lon) + ? { lat: point.lat, lon: point.lon } + : null, + providerIds: Array.isArray(providerIds) ? [...providerIds] : null, + radiusM: Number.isFinite(radiusM) ? radiusM : null, + }; + notify(); }, setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode), resizeViewer: () => parts.viewerHost.resize(), diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 0a26df1f1..1f019e3d8 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { getEventListeners } from 'node:events'; import * as Cesium from 'cesium'; import { createStreetLevelLayer } from './index.js'; import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js'; @@ -12,6 +13,9 @@ import { fakeStreetLevelProvider as fakeProvider, } from '../../testSupport/streetLevelFakes.mjs'; import { setLocale } from '../../i18n/index.js'; +// Loaded up front: it brings Vue, which reads `document` once when it loads, +// and several tests here stand a bare fake document in for the page. +import { createGevActionRunner } from '../../voice/gevActions.js'; /** * A layer over `source` and `photoViewer`, initialised on a stand-in viewer @@ -362,7 +366,11 @@ function fakeViewer() { } /** A layer over `providers`, initialised and enabled on a stand-in viewer. */ -async function enabledLayer(t, providers = [fakeProvider()]) { +async function enabledLayer( + t, + providers = [fakeProvider()], + { services, setup } = {}, +) { const saved = globalThis.document; // A pose moves the position marker, whose glyph is drawn on a canvas. const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); @@ -370,7 +378,11 @@ async function enabledLayer(t, providers = [fakeProvider()]) { createElement: () => ({ getContext: () => drawing }), }); const viewer = fakeViewer(); - const layer = createStreetLevelLayer({ providers }); + setup?.(viewer); + const layer = createStreetLevelLayer({ + providers, + ...(services ? { services } : {}), + }); layer.init(viewer); layer.enable(viewer); t.after(() => { @@ -944,6 +956,54 @@ test('the button’s “centre of the view” is the ground under the middle of assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9); }); +test('OPEN after a link armed opens at its point only while the view centre is still near it', async (t) => { + const { createStreetLevelLayer: create } = await import('./index.js'); + const scenePick = await import('../../data/scenePick.js'); + const lookups = []; + const provider = fakeProvider({ + nearestImage: async (point, options) => { + lookups.push({ point, radiusM: options?.radiusM ?? null }); + return null; + }, + }); + const saved = globalThis.document; + const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); + globalThis.document = Object.assign(new EventTarget(), { + createElement: () => ({ getContext: () => drawing }), + }); + const viewer = fakeViewer(); + const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0); + Object.assign(viewer.scene, { + pickPositionSupported: true, + pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined), + }); + viewer.scene.canvas.clientWidth = 1000; + viewer.scene.canvas.clientHeight = 600; + const layer = create({ providers: [provider], services: { scenePick } }); + layer.init(viewer); + layer.enable(viewer); + t.after(() => { + layer.destroy(); + globalThis.document = saved; + }); + // About 120 m from the view centre: the link's spot is still in view. + layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 }); + await layer.openNearest(undefined, { aim: 'screen-centre' }); + assert.deepEqual(lookups.at(-1), { + point: { lat: 38.5808, lon: -121.494 }, + radiusM: 250, + }); + // The user flew to Paris after the link armed in Sacramento: OPEN looks + // under the view centre, and the link's radius goes with its point. + layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 }); + await layer.openNearest(undefined, { aim: 'screen-centre' }); + const press = lookups.at(-1); + assert.ok(Math.abs(press.point.lat - 38.5799) < 1e-9, 'the view centre'); + assert.ok(Math.abs(press.point.lon - -121.4937) < 1e-9); + assert.equal(press.radiusM, null, 'the default radius'); + assert.equal(layer.getUIState().street.armed, null, 'dropped'); +}); + test('the button aimed at the sky opens nothing, and says to aim at a street', async (t) => { const { createStreetLevelLayer: create } = await import('./index.js'); const scenePick = await import('../../data/scenePick.js'); @@ -1118,6 +1178,732 @@ test('switching source by params clears a failed lookup’s error; reapplying th assert.equal(second.getUIState().street.error, kept); }); +test('openNearest searches wider on request, never past the cap, and says how far it looked', async (t) => { + const { layer, fake } = await withStreetView(t, { nearest: () => null }); + layer.setProviderEnabled('google', true); + await settle(); + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false, radiusM: 250 }, + ); + assert.equal(fake.lookups.at(-1).radius, 250); + assert.equal( + layer.getUIState().street.error, + 'No street-level imagery within 250 m of that point', + ); + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false, radiusM: 5000 }, + ); + assert.equal(fake.lookups.at(-1).radius, 250, 'capped'); + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false }, + ); + assert.equal(fake.lookups.at(-1).radius, 50, 'a click keeps the default'); +}); + +test('a caller’s signal retires a nearest lookup: nothing opens after it fires', async (t) => { + let answer = null; + const { layer, fake } = await withStreetView(t, { + nearest: () => 'pano-a', + }); + const lib = fake.library; + const service = lib.StreetViewService.prototype.getPanorama; + lib.StreetViewService.prototype.getPanorama = function (request) { + return new Promise((resolve, reject) => { + answer = () => service.call(this, request).then(resolve, reject); + }); + }; + layer.setProviderEnabled('google', true); + await settle(); + const controller = new AbortController(); + const pending = layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false, signal: controller.signal }, + ); + await settle(); + controller.abort(); + answer?.(); + assert.equal(await pending, false); + const street = layer.getUIState().street; + assert.equal(street.open, false, 'the retired lookup opened nothing'); + assert.equal(street.loading, false, 'no stuck spinner'); + assert.equal(street.error, null, 'and reports no missing imagery'); + const refused = await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false, signal: controller.signal }, + ); + assert.equal(refused, false, 'an already-aborted signal asks nothing'); +}); + +test('providerStatus reports name, key state and billing on any map', async (t) => { + const { layer } = await withStreetView(t, { mapStack: 'osm' }); + const google = layer.providerStatus('google'); + assert.equal(google.name, 'Google Street View'); + assert.equal(google.billed, true, 'every Street View load is billed'); + assert.equal(google.available, false, 'not on this map'); + assert.equal(google.keyRequired, false); + assert.equal(layer.providerStatus('mapillary').billed, false); + assert.equal(layer.providerStatus('nope'), null); + assert.equal(layer.requiredMapStack('google'), 'photoreal'); + assert.equal(layer.requiredMapStack('mapillary'), null); +}); + +test('an armed lookup asks nothing until the OPEN press, then opens at its point and radius once', async (t) => { + const { layer, fake, mapillaryLookups } = await withStreetView(t); + layer.setProviderEnabled('google', true); + await settle(); + const before = fake.lookups.length; + layer.armNearest( + { lat: 38.57986, lon: -121.49372 }, + { providerIds: ['google'], radiusM: 250 }, + ); + await settle(); + await new Promise((resolve) => setTimeout(resolve, 20)); + // The click gate: arming is free. No provider was asked, nothing opened. + assert.equal( + fake.lookups.length, + before, + 'no Google lookup before the press', + ); + assert.deepEqual(mapillaryLookups, [], 'no Mapillary lookup either'); + assert.equal(layer.getUIState().street.open, false); + assert.equal(layer.getUIState().street.loading, false); + assert.deepEqual(layer.getUIState().street.armed, { + point: { lat: 38.57986, lon: -121.49372 }, + providerIds: ['google'], + radiusM: 250, + }); + // The OPEN button: no point, aimed at the screen centre. + const opened = await layer.openNearest(undefined, { + providerIds: ['google'], + frame: false, + aim: 'screen-centre', + }); + assert.equal(opened, true); + assert.equal( + fake.lookups.length, + before + 1, + 'exactly one lookup for the press', + ); + const request = fake.lookups.at(-1); + assert.deepEqual( + request.location, + { lat: 38.57986, lng: -121.49372 }, + 'the armed point, not the screen centre', + ); + assert.equal(request.radius, 250); + assert.equal(layer.getUIState().street.armed, null, 'used once'); + layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] }); + layer.closeViewer(); + assert.equal(layer.getUIState().street.armed, null, 'closing clears it'); + layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] }); + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false }, + ); + assert.deepEqual( + fake.lookups.at(-1).location, + { lat: 38.58, lng: -121.49 }, + 'an explicit point wins', + ); + assert.equal(layer.getUIState().street.armed, null); + // Only the OPEN press consumes the armed point: "street view here" after a + // pan is a new request at the view centre, and it drops the armed one. + layer.closeViewer(); + layer.armNearest( + { lat: 10, lon: 20 }, + { providerIds: ['google'], radiusM: 250 }, + ); + await layer.openNearest(null, { providerIds: ['google'], frame: false }); + const centre = fake.lookups.at(-1); + assert.notDeepEqual( + centre.location, + { lat: 10, lng: 20 }, + 'the view centre, not the armed point', + ); + assert.equal(centre.radius, 50, 'and the default radius'); + assert.equal(layer.getUIState().street.armed, null, 'dropped'); + // Opening any image, or switching the layer off, clears it too. + layer.armNearest({ lat: 10, lon: 20 }, { providerIds: ['google'] }); + await layer.openImage('google', 'pano-a'); + assert.equal(layer.getUIState().street.armed, null, 'cleared by an open'); + layer.armNearest({ lat: 10, lon: 20 }, { providerIds: ['google'] }); + layer.disable(); + assert.equal(layer.getUIState().street.armed, null, 'cleared by disable'); +}); + +test('a caller’s signal: an aborted one asks nothing, and a provider that ignores the abort still opens nothing', async (t) => { + const { layer, fake } = await withStreetView(t); + layer.setProviderEnabled('google', true); + await settle(); + const before = fake.lookups.length; + const done = new AbortController(); + done.abort(); + assert.equal( + await layer.openNearest( + { lat: 38.58, lon: -121.49 }, + { providerIds: ['google'], frame: false, signal: done.signal }, + ), + false, + ); + assert.equal( + fake.lookups.length, + before, + 'an already-aborted signal asks nothing', + ); +}); + +test('a provider that ignores the abort and answers late still opens nothing', async (t) => { + let seen = null; + let release = null; + const slow = fakeProvider({ + nearestImage: async (_point, { signal } = {}) => { + seen = signal; + await new Promise((resolve) => { + release = resolve; + }); + return 'late-image'; + }, + }); + const { layer: ignoring } = await enabledLayer(t, [slow]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + ignoring.attachViewerHost(fakeHost()); + const controller = new AbortController(); + const pending = ignoring.openNearest( + { lat: 1, lon: 2 }, + { frame: false, signal: controller.signal }, + ); + await settle(); + controller.abort(); + assert.equal(seen?.aborted, true, 'the provider sees the abort'); + release(); + assert.equal(await pending, false); + const street = ignoring.getUIState().street; + assert.equal(street.open, false, 'the late answer opened nothing'); + assert.equal(street.loading, false, 'no stuck spinner'); + assert.equal(street.error, null); +}); + +test('providerStatus reports a keyless source on any map', async (t) => { + const keyless = await withStreetView(t, { + mapStack: 'osm', + mapillaryKeyless: true, + }); + await settle(); + const mapillary = keyless.layer.providerStatus('mapillary'); + assert.equal(mapillary.configured, false); + assert.equal(mapillary.keyRequired, true); + assert.equal(mapillary.keyRejected, false); +}); + +test('providerStatus reports the key state as unknown until the status call answers', async (t) => { + // A freshly loaded sl= link meets its source in this state: unknown is not + // "needs its key", or the link would fail on a cold load. + const pending = fakeProvider(); + pending.requiresKeyId = 'mapillary'; + const create = pending.create; + pending.create = (context) => ({ + ...create(context), + status: () => new Promise(() => {}), + }); + const { layer } = await enabledLayer(t, [pending]); + const status = layer.providerStatus(pending.id); + assert.equal(status.configured, null); + assert.equal(status.keyRequired, false); +}); + +test('providerStatus reports a refused key on any map', async (t) => { + const refused = await withStreetView(t, { mapStack: 'osm' }); + refused.loader.refuseKey(); + await settle(); + const google = refused.layer.providerStatus('google'); + assert.equal( + google.keyRequired, + true, + 'a refused key gates like a missing one', + ); + assert.equal(google.keyRejected, true); + assert.equal(google.available, false, 'still not on this map'); +}); + +test('an armed lookup dies with its source: switched off, or its map gone', async (t) => { + const { layer, stack } = await withStreetView(t); + layer.setProviderEnabled('google', true); + await settle(); + layer.armNearest( + { lat: 1, lon: 2 }, + { providerIds: ['google'], radiusM: 250 }, + ); + layer.setProviderEnabled('google', false); + assert.equal( + layer.getUIState().street.armed, + null, + 'switching the source off clears it', + ); + layer.setProviderEnabled('google', true); + layer.armNearest( + { lat: 1, lon: 2 }, + { providerIds: ['google'], radiusM: 250 }, + ); + // Lighting Mapillary instead (one source at a time) turns Google off too. + layer.setProviderEnabled('mapillary', true); + assert.equal( + layer.getUIState().street.armed, + null, + 'another source lit clears it', + ); + layer.setProviderEnabled('google', true); + layer.armNearest( + { lat: 1, lon: 2 }, + { providerIds: ['google'], radiusM: 250 }, + ); + // A map-stack notify that keeps Google 3D (a silent re-settle) keeps it. + stack.switchTo('photoreal'); + assert.notEqual( + layer.getUIState().street.armed, + null, + 'a notify on the same map keeps it', + ); + stack.switchTo('osm'); + assert.equal( + layer.getUIState().street.armed, + null, + 'leaving Google 3D clears it', + ); + // Arming notifies, so a panel can show the state. + let notified = 0; + const unsubscribe = layer.subscribe(() => notified++); + stack.switchTo('photoreal'); + await settle(); + const before = notified; + layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] }); + await settle(); + assert.ok(notified > before, 'subscribers hear about the armed lookup'); + unsubscribe(); +}); + +test('a lookup armed with no point opens under the middle of the screen on the press', async (t) => { + const { createStreetLevelLayer: create } = await import('./index.js'); + const scenePick = await import('../../data/scenePick.js'); + const lookups = []; + const provider = fakeProvider({ + nearestImage: async (point, options) => { + lookups.push({ ...point, radiusM: options?.radiusM ?? null }); + return null; + }, + }); + const saved = globalThis.document; + const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); + globalThis.document = Object.assign(new EventTarget(), { + createElement: () => ({ getContext: () => drawing }), + }); + const viewer = fakeViewer(); + const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0); + Object.assign(viewer.scene, { + pickPositionSupported: true, + pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined), + }); + viewer.scene.canvas.clientWidth = 1000; + viewer.scene.canvas.clientHeight = 600; + // Registered before the layer exists, so a throwing create() still + // restores the document; destroy() runs first, while the stub is in place. + let layer = null; + t.after(() => { + layer?.destroy(); + globalThis.document = saved; + }); + layer = create({ providers: [provider], services: { scenePick } }); + layer.init(viewer); + layer.enable(viewer); + layer.armNearest(null, { providerIds: [provider.id], radiusM: 250 }); + assert.equal( + layer.getUIState().street.armed.point, + null, + 'no point stored, not 0,0', + ); + await layer.openNearest(undefined, { aim: 'screen-centre' }); + assert.equal(lookups.length, 1); + assert.ok( + Math.abs(lookups[0].lat - 38.5799) < 1e-9, + 'the screen centre, not Null Island', + ); + assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9); + assert.equal(lookups[0].radiusM, 250, 'with the armed radius'); +}); + +test('the action over the real layer: a billed link arms without a lookup, and one press makes exactly one at the point', async (t) => { + const { layer, fake, viewer } = await withStreetView(t); + // The runner wires pointer tracking to the clock and camera. + viewer.clock ??= { onTick: { addEventListener: () => () => {} } }; + viewer.camera.moveEnd ??= { addEventListener() {} }; + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + isEnabled: () => true, + getLayerParams: () => layer.getParams(), + setLayerParams: (_id, params) => { + layer.setParams(params); + return true; + }, + getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }], + }; + const styleManager = { + mapStackController: { getActiveId: () => 'photoreal' }, + setMapStack: async () => ({ ok: true, activeStack: 'photoreal' }), + }; + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + const before = fake.lookups.length; + const armed = await runner('show_street_level', { + latitude: 38.57986, + longitude: -121.49372, + provider: 'google', + fly: false, + autoOpen: false, + }); + assert.equal(armed.ok, true, armed.error); + assert.equal(armed.armed, true); + assert.equal(armed.providerName, 'Google Street View'); + await settle(); + assert.equal(fake.lookups.length, before, 'arming made no Google request'); + assert.deepEqual( + layer + .getUIState() + .providers.filter((p) => p.on) + .map((p) => p.id), + ['google'], + ); + assert.equal(layer.getUIState().street.open, false); + // The OPEN button. + assert.equal( + await layer.openNearest(undefined, { + providerIds: ['google'], + frame: false, + aim: 'screen-centre', + }), + true, + ); + assert.equal(fake.lookups.length, before + 1, 'one request for the press'); + assert.deepEqual(fake.lookups.at(-1).location, { + lat: 38.57986, + lng: -121.49372, + }); + assert.equal(fake.lookups.at(-1).radius, 250); + assert.equal(layer.getUIState().street.imageId, 'pano-a'); + // The same call without the gate opens at once, with one request. + layer.closeViewer(); + const opened = await runner('show_street_level', { + latitude: 38.57986, + longitude: -121.49372, + provider: 'google', + fly: false, + }); + assert.equal(opened.ok, true, opened.error); + assert.equal(opened.imageId, 'pano-a'); + assert.equal(fake.lookups.length, before + 2); +}); + +test('openNearest keeps honouring the caller after the lookup: an abort while the viewer loads opens nothing', async (t) => { + let releaseMount = null; + const provider = fakeProvider({ nearestImage: async () => 'img-1' }); + const create = provider.create; + provider.create = (context) => { + const instance = create(context); + const mount = instance.viewer.mount; + instance.viewer.mount = async (...args) => { + await new Promise((resolve) => { + releaseMount = resolve; + }); + return mount(...args); + }; + return instance; + }; + const { layer } = await enabledLayer(t, [provider]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + layer.attachViewerHost(fakeHost()); + const controller = new AbortController(); + const pending = layer.openNearest( + { lat: 1, lon: 2 }, + { frame: false, signal: controller.signal }, + ); + for (let i = 0; i < 20 && !releaseMount; i++) await settle(); + assert.ok(releaseMount, 'the lookup answered and the viewer is mounting'); + controller.abort(); + releaseMount(); + assert.equal(await pending, false); + assert.deepEqual(provider.calls.open, [], 'the photo was never opened'); + assert.equal(layer.getUIState().street.open, false); + assert.equal(layer.getUIState().street.loading, false); +}); + +for (const rejectsOnAbort of [true, false]) { + test(`cancelling a lookup over an open photo clears its spinner (provider rejects: ${rejectsOnAbort})`, async (t) => { + const answer = deferred(); + const provider = fakeProvider({ + nearestImage: (_point, { signal }) => { + if (rejectsOnAbort) + signal.addEventListener('abort', () => answer.reject(signal.reason), { + once: true, + }); + return answer.promise; + }, + }); + const { layer } = await enabledLayer(t, [provider]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + layer.attachViewerHost(fakeHost()); + assert.equal( + await layer.openImage('mapillary', 'existing', { frame: false }), + true, + ); + const controller = new AbortController(); + const pending = layer.openNearest( + { lat: 1, lon: 2 }, + { frame: false, signal: controller.signal }, + ); + assert.equal(layer.getUIState().street.loading, true); + controller.abort(); + if (!rejectsOnAbort) answer.resolve('late-image'); + assert.equal(await pending, false); + const street = layer.getUIState().street; + assert.equal( + street.loading, + false, + 'the cancelled lookup owns and clears the spinner', + ); + assert.equal(street.open, true, 'the existing photo remains open'); + assert.equal(street.error, null); + assert.deepEqual( + provider.calls.open, + ['existing'], + 'no replacement photo opens', + ); + }); +} + +for (const rejectsOnAbort of [true, false]) { + test(`retired lookups release the caller listener (provider rejects: ${rejectsOnAbort})`, async (t) => { + let answer; + const provider = fakeProvider({ + nearestImage: (_point, { signal }) => { + answer = deferred(); + if (rejectsOnAbort) + signal.addEventListener('abort', () => answer.reject(signal.reason), { + once: true, + }); + return answer.promise; + }, + }); + const { layer } = await enabledLayer(t, [provider]); + const lifetime = new AbortController(); + for (let i = 0; i < 3; i++) { + const pending = layer.openNearest( + { lat: 1, lon: 2 }, + { frame: false, signal: lifetime.signal }, + ); + assert.equal(getEventListeners(lifetime.signal, 'abort').length, 1); + layer.closeViewer(); + if (!rejectsOnAbort) answer.resolve('late-image'); + assert.equal(await pending, false); + assert.equal( + getEventListeners(lifetime.signal, 'abort').length, + 0, + 'closing the lookup releases its listener on the application lifetime', + ); + } + assert.equal(lifetime.signal.aborted, false); + }); +} + +test('an aborted older lookup leaves its replacement’s spinner alone', async (t) => { + const answers = []; + const provider = fakeProvider({ + nearestImage: (_point, { signal } = {}) => + new Promise((resolve, reject) => { + answers.push(resolve); + signal?.addEventListener('abort', () => + reject(Object.assign(new Error('aborted'), { name: 'AbortError' })), + ); + }), + }); + const { layer } = await enabledLayer(t, [provider]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + layer.attachViewerHost(fakeHost()); + const older = new AbortController(); + const first = layer.openNearest( + { lat: 1, lon: 2 }, + { frame: false, signal: older.signal }, + ); + await settle(); + // A newer request replaces it, and its caller withdraws the old one too. + const second = layer.openNearest({ lat: 3, lon: 4 }, { frame: false }); + older.abort(); + assert.equal(await first, false); + assert.equal( + layer.getUIState().street.loading, + true, + 'the newer lookup is still loading', + ); + answers.at(-1)(null); + assert.equal(await second, false); + assert.equal(layer.getUIState().street.loading, false); + assert.match( + layer.getUIState().street.error, + /No street-level imagery/, + 'the newer lookup reports its own result', + ); +}); + +test('show_street_level opens a KartaView photo, and a plain link opens it at once since it is not billed', async (t) => { + const mapillary = fakeProvider(); + let nearest = 'kv-1'; + const kartaview = fakeProvider({ + id: 'kartaview', + pickPrefix: 'kv:', + nearestImage: async () => nearest, + }); + kartaview.name = 'KartaView'; + kartaview.label = 'KARTAVIEW'; + kartaview.defaultOn = false; + // As the real provider: 360° / flat and SINCE (the setFilter log stays in + // the fake's closure). + kartaview.filters = Object.freeze(['pano', 'since']); + const { layer, viewer } = await enabledLayer(t, [mapillary, kartaview]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + layer.attachViewerHost(fakeHost()); + viewer.clock ??= { onTick: { addEventListener: () => () => {} } }; + viewer.camera.moveEnd ??= { addEventListener() {} }; + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + isEnabled: () => true, + getLayerParams: () => layer.getParams(), + setLayerParams: (_id, params) => { + layer.setParams(params); + return true; + }, + getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }], + }; + const styleManager = { + mapStackController: { getActiveId: () => 'osm' }, + setMapStack: async () => ({ ok: false, error: 'no map switch expected' }), + }; + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + // The way a plain sl=kartaview link calls it: autoOpen false. + const result = await runner('show_street_level', { + latitude: 52.52, + longitude: 13.405, + provider: 'kartaview', + fly: false, + autoOpen: false, + }); + assert.equal(result.ok, true, result.error); + assert.equal(result.armed, undefined, 'not armed: KartaView bills nothing'); + assert.equal(result.provider, 'kartaview'); + assert.equal(result.providerName, 'KartaView'); + assert.deepEqual( + layer + .getUIState() + .providers.filter((p) => p.on) + .map((p) => p.id), + ['kartaview'], + ); + assert.deepEqual(kartaview.calls.open, ['kv-1']); + assert.equal(layer.providerStatus('kartaview').billed, false); + assert.equal(layer.requiredMapStack('kartaview'), null); + + // With 360° only on, an empty search names the filter, not just "no imagery". + layer.closeViewer?.(); + layer.setParams({ pano: 'pano' }); + nearest = null; + const empty = await runner('show_street_level', { + latitude: 52.52, + longitude: 13.405, + provider: 'kartaview', + fly: false, + }); + assert.deepEqual([empty.ok, empty.stage], [false, 'imagery']); + assert.equal(empty.filter?.pano, 'pano'); + assert.match(empty.error, /with the 360° filter on$/); + layer.setParams({ pano: 'all' }); + const plain = await runner('show_street_level', { + latitude: 52.52, + longitude: 13.405, + provider: 'kartaview', + fly: false, + }); + assert.equal(plain.ok, false); + assert.equal(plain.filter, undefined, 'no filter, no hint'); + assert.doesNotMatch(plain.error, /filter/); +}); + +test('show_street_level switches from KartaView to Panoramax and opens its photo at once, unbilled', async (t) => { + const mapillary = fakeProvider(); + const kartaview = fakeProvider({ id: 'kartaview', pickPrefix: 'kv:' }); + kartaview.name = 'KartaView'; + kartaview.defaultOn = false; + const panoramax = fakeProvider({ + id: 'panoramax', + pickPrefix: 'px:', + nearestImage: async () => 'px-1', + }); + panoramax.name = 'Panoramax'; + panoramax.label = 'PANORAMAX'; + panoramax.defaultOn = false; + const { layer, viewer } = await enabledLayer(t, [ + mapillary, + kartaview, + panoramax, + ]); + const { fakeHost } = + await import('../../testSupport/googleStreetViewFakes.mjs'); + layer.attachViewerHost(fakeHost()); + viewer.clock ??= { onTick: { addEventListener: () => () => {} } }; + viewer.camera.moveEnd ??= { addEventListener() {} }; + layer.setParams({ mapillary: false, kartaview: true, panoramax: false }); + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + isEnabled: () => true, + getLayerParams: () => layer.getParams(), + setLayerParams: (_id, params) => { + layer.setParams(params); + return true; + }, + getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }], + }; + const styleManager = { + mapStackController: { getActiveId: () => 'osm' }, + setMapStack: async () => ({ ok: false, error: 'no map switch expected' }), + }; + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + // The way a plain sl=panoramax link calls it: autoOpen false. + const result = await runner('show_street_level', { + latitude: 48.8874, + longitude: 2.3495, + provider: 'panoramax', + fly: false, + autoOpen: false, + }); + assert.equal(result.ok, true, result.error); + assert.equal(result.armed, undefined, 'not armed: Panoramax bills nothing'); + assert.equal(result.providerName, 'Panoramax'); + assert.deepEqual( + layer + .getUIState() + .providers.filter((p) => p.on) + .map((p) => p.id), + ['panoramax'], + 'KartaView switched off', + ); + assert.deepEqual(panoramax.calls.open, ['px-1']); + assert.deepEqual(kartaview.calls.open, [], 'nothing asked of KartaView'); + assert.equal(layer.providerStatus('panoramax').billed, false); + assert.equal(layer.requiredMapStack('panoramax'), null); +}); + test('the panel says why a lit Street View draws nothing off Google 3D', async (t) => { const { presentStreetLevelPanel } = await import('../../ui/streetLevelPresentation.js'); @@ -1128,6 +1914,88 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async ( assert.match(view.meta, /Google Street View needs the Google 3D map/); }); +test('OPEN aimed at the sky after a link armed opens nothing: never the far link point', async (t) => { + const scenePick = await import('../../data/scenePick.js'); + const lookups = []; + const provider = fakeProvider({ + nearestImage: async (point) => { + lookups.push(point); + return null; + }, + }); + const { layer } = await enabledLayer(t, [provider], { + services: { scenePick }, + setup: (viewer) => { + // Every pick misses the ground: the screen centre is sky. + Object.assign(viewer.scene, { + pickPositionSupported: true, + pickPosition: () => undefined, + }); + viewer.globe = { pick: () => undefined }; + viewer.scene.canvas.clientWidth = 1000; + viewer.scene.canvas.clientHeight = 600; + }, + }); + layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 }); + assert.equal( + await layer.openNearest(undefined, { aim: 'screen-centre' }), + false, + ); + assert.deepEqual(lookups, [], 'no paid Paris lookup from another city’s sky'); + const street = layer.getUIState().street; + assert.equal(street.nearestOutcome, 'sky'); + assert.match(street.error, /Aim the middle of the view/); +}); + +test('a request of its own (armed: false) neither uses nor drops the link’s armed point', async (t) => { + const scenePick = await import('../../data/scenePick.js'); + const lookups = []; + const provider = fakeProvider({ + nearestImage: async (point, options) => { + lookups.push({ point, radiusM: options?.radiusM ?? null }); + return null; + }, + }); + const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0); + const { layer } = await enabledLayer(t, [provider], { + services: { scenePick }, + setup: (viewer) => { + Object.assign(viewer.scene, { + pickPositionSupported: true, + pickPosition: (at) => + at.x === 500 && at.y === 300 ? centre : undefined, + }); + viewer.scene.canvas.clientWidth = 1000; + viewer.scene.canvas.clientHeight = 600; + }, + }); + // About 120 m away: an OPEN press would take the link's spot. + layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 }); + await layer.openNearest(undefined, { aim: 'screen-centre', armed: false }); + const asked = lookups.at(-1); + assert.ok(Math.abs(asked.point.lat - 38.5799) < 1e-9, 'the screen centre'); + assert.equal(asked.radiusM, null, 'its own radius'); + assert.equal(layer.getUIState().street.nearestOutcome, 'empty'); + assert.deepEqual( + layer.getUIState().street.armed?.point, + { lat: 38.5808, lon: -121.494 }, + 'still armed for the OPEN press', + ); +}); + +test('a failing source is not reported as missing imagery', async (t) => { + const provider = fakeProvider({ + nearestImage: async () => { + throw new Error('Mapillary graph HTTP 500'); + }, + }); + const { layer } = await enabledLayer(t, [provider]); + assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), false); + const street = layer.getUIState().street; + assert.equal(street.nearestOutcome, null); + assert.equal(street.error, 'Mapillary graph HTTP 500'); +}); + /** Mapillary (keyless unless `mapillary` says otherwise) plus KartaView. */ function keylessLayer(t, mapillary = { hasToken: () => false }) { const layer = createStreetLevelLayer({ @@ -1183,3 +2051,83 @@ test('filter-only updates keep the lit source, even a chosen keyless Mapillary', ); } }); + +test('a replacing lookup keeps the viewer open while it looks, so EXPAND stays with the new photo', async (t) => { + const answers = ['img-1', 'img-2']; + const provider = posingProvider({ + nearestImage: async () => answers.shift(), + }); + const { layer } = await enabledLayer(t, [provider]); + layer.attachViewerHost({}); + assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), true); + const seen = []; + const unsubscribe = layer.subscribe(() => + seen.push(layer.getUIState().street.open), + ); + t.after(unsubscribe); + assert.equal( + await layer.openNearest({ lat: 3, lon: 4 }, { replace: true }), + true, + ); + assert.ok(seen.length > 0); + assert.ok(!seen.includes(false), 'never published closed on the way'); + assert.deepEqual(provider.calls.open, ['img-1', 'img-2']); +}); + +test('a replacing lookup that finds nothing closes the old photo and keeps its own message', async (t) => { + const answers = ['img-1', null]; + const provider = posingProvider({ + nearestImage: async () => answers.shift(), + }); + const { layer } = await enabledLayer(t, [provider]); + layer.attachViewerHost({}); + await layer.openNearest({ lat: 1, lon: 2 }); + assert.equal( + await layer.openNearest({ lat: 3, lon: 4 }, { replace: true }), + false, + ); + const street = layer.getUIState().street; + assert.equal(street.open, false, 'no photo of another place'); + assert.equal(street.nearestOutcome, 'empty'); + assert.match(street.error, /No street-level imagery within 50 m/); +}); + +test('a found photo the viewer cannot load keeps its error on the open viewer', async (t) => { + const provider = posingProvider({ nearestImage: async () => 'img-1' }); + const create = provider.create; + provider.create = (context) => { + const instance = create(context); + instance.viewer.open = async () => { + throw new Error('Image could not be opened'); + }; + return instance; + }; + const { layer } = await enabledLayer(t, [provider]); + layer.attachViewerHost({}); + assert.equal( + await layer.openNearest({ lat: 1, lon: 2 }, { replace: true }), + false, + ); + const street = layer.getUIState().street; + assert.equal(street.open, true); + assert.equal(street.error, 'Image could not be opened'); +}); + +test('proceed() is asked after the lookup: false loads nothing and clears the spinner', async (t) => { + const provider = posingProvider({ nearestImage: async () => 'img-1' }); + const { layer } = await enabledLayer(t, [provider]); + layer.attachViewerHost({}); + let asked = 0; + const opened = await layer.openNearest( + { lat: 1, lon: 2 }, + { proceed: () => (asked++, false) }, + ); + assert.equal(opened, false); + assert.equal(asked, 1); + assert.deepEqual(provider.calls.open, [], 'the viewer never loads it'); + assert.equal(layer.getUIState().street.loading, false); + assert.equal( + await layer.openNearest({ lat: 1, lon: 2 }, { proceed: () => true }), + true, + ); +}); diff --git a/src/layers/streetLevel/policy.js b/src/layers/streetLevel/policy.js index 4bf7f953d..e135307e7 100644 --- a/src/layers/streetLevel/policy.js +++ b/src/layers/streetLevel/policy.js @@ -40,6 +40,14 @@ export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 }); /** Nearest-image search radius in metres. */ export const NEAREST_RADIUS_M = 50; +/** + * Widest nearest-image radius a caller may ask for, in metres. A geocoded + * landmark sits at its centroid, often further than 50 m from the nearest + * street; providers that can search wider (Google) honour it, the rest keep + * their own cap. + */ +export const NEAREST_RADIUS_MAX_M = 250; + /** The Google 3D map stack: Street View may be shown only on it. */ export const GOOGLE_3D_MAP_STACK_ID = 'photoreal'; diff --git a/src/layers/streetLevel/providers/google/index.js b/src/layers/streetLevel/providers/google/index.js index d32ee95f6..8af24275c 100644 --- a/src/layers/streetLevel/providers/google/index.js +++ b/src/layers/streetLevel/providers/google/index.js @@ -12,6 +12,7 @@ import { GROUND_CLICK_HINT, KEY_REJECTED_MESSAGE, NEAREST_RADIUS_M, + NEAREST_RADIUS_MAX_M, NEAREST_TIMEOUT_MS, NO_ANSWER_MESSAGE, PICK_PREFIX, @@ -67,6 +68,7 @@ export function createGoogleProvider({ requiresMapStack: GOOGLE_3D_MAP_STACK_ID, // Every panorama is billed to the Maps key. defaultOn: false, + billedPerImage: true, // All 360°, and no coverage for a date filter to thin. filters: [], marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }), @@ -136,8 +138,12 @@ export function createGoogleProvider({ * Nearest Google-collected outdoor panorama, or null. The imagery * filters do not apply (see `filters`). */ - async nearestImage({ lat, lon }, { signal } = {}) { + async nearestImage({ lat, lon }, { signal, radiusM } = {}) { if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE); + // Wider than the shared default only on request, never past the cap. + const radius = Number.isFinite(radiusM) + ? Math.min(NEAREST_RADIUS_MAX_M, Math.max(1, radiusM)) + : NEAREST_RADIUS_M; signal?.throwIfAborted(); // Set once the caller has its answer (aborted, timed out, refused): // a lookup still waiting on Google's script then asks nothing. @@ -150,7 +156,7 @@ export function createGoogleProvider({ try { const { data } = await service.getPanorama({ location: { lat, lng: lon }, - radius: NEAREST_RADIUS_M, + radius, // Both: Google's own imagery, outdoors (sources intersect). sources: [GOOGLE, OUTDOOR], preference: lib.StreetViewPreference.NEAREST, diff --git a/src/layers/streetLevel/providers/google/policy.js b/src/layers/streetLevel/providers/google/policy.js index e0fa40cfb..d8ec0bb69 100644 --- a/src/layers/streetLevel/providers/google/policy.js +++ b/src/layers/streetLevel/providers/google/policy.js @@ -1,7 +1,7 @@ import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js'; import { msg } from '../../../../i18n/index.js'; -export { NEAREST_RADIUS_M } from '../../policy.js'; +export { NEAREST_RADIUS_M, NEAREST_RADIUS_MAX_M } from '../../policy.js'; export const GOOGLE_PROVIDER_ID = 'google'; export const GOOGLE_NAME = msg('Google Street View'); diff --git a/src/layers/streetLevel/providers/google/provider.test.mjs b/src/layers/streetLevel/providers/google/provider.test.mjs index 3314f0048..a78d9201f 100644 --- a/src/layers/streetLevel/providers/google/provider.test.mjs +++ b/src/layers/streetLevel/providers/google/provider.test.mjs @@ -291,3 +291,13 @@ test('the panorama a lookup found is dated without a second lookup', async () => 'no getPanorama({pano}) for a panorama the lookup already described', ); }); + +test('a wider lookup radius is honoured up to the cap', async () => { + const { instance, fake } = setup(); + await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: 250 }); + assert.equal(fake.lookups.at(-1).radius, 250); + await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: 9999 }); + assert.equal(fake.lookups.at(-1).radius, 250); + await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: NaN }); + assert.equal(fake.lookups.at(-1).radius, 50); +}); diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index b930405f1..e3e92d6dd 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -5,6 +5,8 @@ import { } from '../../lineCoverage.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { coverageZoomForHeight } from '../../tileMath.js'; +import { passesImageryFilter } from '../../filter.js'; +import { metresBetween } from '../../view.js'; import { decodeCoverageTile } from './decode.js'; import { createMapillaryViewer } from './viewer.js'; import { @@ -195,6 +197,35 @@ export function createMapillaryProvider({ source, photoViewer = null }) { error: state.sequence.error, }), + /** + * The nearest image that passes the imagery filter, or null: what + * show_street_level and sl= links open (clicks open a line, then a + * cone). The core aborts `signal` once a newer request overtakes it. + */ + async nearestImage({ lat, lon }, { signal } = {}) { + const images = await source.nearestImages({ lat, lon }, { signal }); + // The API returns the images in the radius in no particular order. + const metres = (record) => { + const [lon2, lat2] = (record.computed_geometry || record.geometry) + ?.coordinates || [NaN, NaN]; + const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 }); + return Number.isFinite(d) ? d : Infinity; + }; + const filter = context.getFilter(); + const hit = [...images] + .sort((a, b) => metres(a) - metres(b)) + .find((record) => + passesImageryFilter( + { + isPano: record.is_pano === true, + capturedAt: Number(record.captured_at) || 0, + }, + filter, + ), + ); + return hit ? String(hit.id) : null; + }, + viewer: photoViewer || createMapillaryViewer({ source, render: context.services?.render }), diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 183082934..e885d377c 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -16,6 +16,7 @@ export const MAPILLARY_SOURCE_METHODS = Object.freeze([ 'getStatus', 'getTile', 'getSequenceImages', + 'nearestImages', ]); /** Stable id prefixes for picked primitives; every one starts with `mly:`. */ @@ -72,3 +73,18 @@ export function mapillaryImageUrl(imageId) { /** Fields requested for a sequence's images. */ export const SEQUENCE_IMAGE_FIELDS = 'id,captured_at,compass_angle,geometry,is_pano'; + +/** + * Images asked for in a nearest-image search: the API cuts at the limit + * before any order, so this is its most; the distance sort and the imagery + * filter choose among the photos this one request returns. + */ +export const NEAREST_LIMIT = 100; +/** + * A nearest-image search not answered in full (body included) by then is + * given up: a link or posted view, and every view queued behind it, waits. + */ +export const NEAREST_TIMEOUT_MS = 12_000; +/** Fields a nearest-image search needs: where each is, and the filter's. */ +export const NEAREST_IMAGE_FIELDS = + 'id,captured_at,geometry,computed_geometry,is_pano'; diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs index ce2f0bb0c..aa1f2463f 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -170,3 +170,41 @@ test('a status the server never answers falls back to the bundled token once the assert.deepEqual(await status, { configured: false }); assert.equal(instance.coverageStats().keyRequired, true); }); + +/** A graph image record at a point. */ +const imageAt = (id, lon, lat, extra = {}) => ({ + id, + is_pano: false, + captured_at: 10, + geometry: { type: 'Point', coordinates: [lon, lat] }, + ...extra, +}); + +test('nearestImage opens the closest image that passes the filter, not the first the API returned', async () => { + const requests = []; + const answer = [ + imageAt('far', 2.0004, 1), // ~45 m east + imageAt('near-pano', 2.00002, 1, { is_pano: true }), // ~2 m, a panorama + imageAt('near', 2.00005, 1), // ~5 m east + imageAt('mid', 2, 1.0002), // ~22 m north + ]; + const source = fakeMapillarySource({ + nearestImages: async (query, options) => { + requests.push({ query, options }); + return answer; + }, + }); + const { signal } = new AbortController(); + const flat = createMapillaryProvider({ source }).create( + fakeContext({ pano: 'flat', sinceMs: null }), + ); + assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }, { signal }), 'near'); + assert.equal(requests[0].options.signal, signal, 'the core can abort it'); + assert.deepEqual(requests[0].query, { lat: 1, lon: 2 }); + const any = createMapillaryProvider({ source }).create(fakeContext()); + assert.equal(await any.nearestImage({ lat: 1, lon: 2 }), 'near-pano'); + const tooOld = createMapillaryProvider({ source }).create( + fakeContext({ pano: 'all', sinceMs: 100 }), + ); + assert.equal(await tooOld.nearestImage({ lat: 1, lon: 2 }), null); +}); diff --git a/src/layers/streetLevel/providers/mapillary/replacePlay.test.mjs b/src/layers/streetLevel/providers/mapillary/replacePlay.test.mjs new file mode 100644 index 000000000..a8a2984c7 --- /dev/null +++ b/src/layers/streetLevel/providers/mapillary/replacePlay.test.mjs @@ -0,0 +1,56 @@ +import '../../../../testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { registerHooks } from 'node:module'; +import { createStreetLevelLayer } from '../../index.js'; +import { fakeStreetLevelProvider, fakeCesiumViewer } from '../../../../testSupport/streetLevelFakes.mjs'; +import { createMapillaryViewer } from './viewer.js'; +import { showStreetLevel } from '../../../../voice/gevActions.js'; + +// Library boundary fixture: moveTo changes the image without stopping PLAY, +// matching installed MapillaryJS Viewer.moveTo / Navigator.moveTo$. +const fakeLibrary = `export class Viewer { + constructor() { globalThis.__replacePlayViewer = this; this.playing = false; this.id = null; this.stops = 0; } + on() {} remove() {} resize() {} setRenderMode() {} + getComponent() { return { play: () => {this.playing = true;}, stop: () => {this.playing = false; this.stops++;} }; } + async moveTo(id) { this.id = id; return { id, sequenceId: 'fixture-sequence', cameraType: 'perspective' }; } + async getPosition() { return { lng: 2, lat: 48 }; } + async getPointOfView() { return { bearing: 0 }; } +} +export class GraphDataProvider {} +export const RenderMode = { Fill: 1, Letterbox: 0 };`; +const hooks = registerHooks({ + resolve(specifier, context, next) { + if (specifier === 'mapillary-js') return { url: 'data:text/javascript,' + encodeURIComponent(fakeLibrary), shortCircuit: true }; + if (specifier.endsWith('mapillary.css')) return { url: 'data:text/javascript,export default {}', shortCircuit: true }; + return next(specifier, context); + }, +}); +globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); }; +test('a photo the action asks for stops sequence PLAY', async t => { + const provider = fakeStreetLevelProvider({ nearestImage: async () => 'B' }); + const create = provider.create; + provider.create = context => ({ ...create(context), viewer: createMapillaryViewer({ source: { token: '' } }) }); + const viewer = fakeCesiumViewer(); viewer.canvas = viewer.scene.canvas; + const layer = createStreetLevelLayer({ providers: [provider] }); + layer.init(viewer); layer.enable(viewer); + layer.attachViewerHost(document.body.appendChild(document.createElement('div'))); + t.after(() => { layer.destroy(); hooks.deregister(); delete globalThis.__replacePlayViewer; document.body.replaceChildren(); }); + await Promise.resolve(); + await layer.openImage('mapillary', 'A', { frame: false }); + const libraryViewer = globalThis.__replacePlayViewer; + libraryViewer.getComponent('sequence').play(); + assert.equal(libraryViewer.playing, true); + const manager = { + layers: new Map([['street-level', { module: layer }]]), isEnabled: () => true, + getLayerParams: () => layer.getParams(), setLayerParams: (_id, params) => layer.setParams(params), + }; + const result = await showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, { + viewer, dataManager: manager, styleManager: { _navigationGeneration: 1 }, + runGevAction: async () => ({ ok: true, latitude: 48, longitude: 2, label: 'fixture destination' }), + }); + assert.equal(result.ok, true); + assert.equal(libraryViewer.id, 'B'); + assert.equal(layer.getUIState().street.open, true); + assert.equal(libraryViewer.playing, false, 'requested image must stay selected rather than continue sequence PLAY'); +}); diff --git a/src/layers/streetLevel/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 38a8b9c39..032240e7b 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -1,9 +1,17 @@ +import { msg } from '../../../../i18n/marker.js'; +import { NEAREST_RADIUS_M } from '../../policy.js'; import { MAPILLARY_GRAPH_HOST, + NEAREST_IMAGE_FIELDS, + NEAREST_LIMIT, + NEAREST_TIMEOUT_MS, SEQUENCE_IMAGE_FIELDS, SEQUENCE_IMAGES_LIMIT, } from './policy.js'; +/** English data: the Street Level panel translates it. */ +const NO_ANSWER = msg('Mapillary did not answer in time — try again'); + /** HTTP error flagged `keyRequired`, `keyRejected` or `retryAfterSec` (429). */ export class MapillarySourceError extends Error { constructor(message, { status = 0, payload = null, retryAfterSec } = {}) { @@ -66,7 +74,47 @@ export function createMapillarySource({ ...endpoints, }; - async function graph(path, params = {}, { signal } = {}) { + async function graph(path, params = {}, { signal, timeoutMs = 0 } = {}) { + if (!timeoutMs) return graphOnce(path, params, { signal }); + // The whole answer, body included, within `timeoutMs`; the caller's + // abort still wins. + if (signal?.aborted) throw signal.reason; + const controller = new AbortController(); + const onAbort = () => controller.abort(signal.reason); + signal?.addEventListener('abort', onAbort, { once: true }); + const timer = setTimeout( + () => + controller.abort( + new MapillarySourceError(NO_ANSWER, { + status: 504, + }), + ), + timeoutMs, + ); + try { + // A body that never ends is given up too, whatever the fetch does. + const gaveUp = new Promise((_, reject) => + controller.signal.addEventListener( + 'abort', + () => reject(controller.signal.reason), + { once: true }, + ), + ); + return await Promise.race([ + graphOnce(path, params, { signal: controller.signal }), + gaveUp, + ]); + } catch (error) { + if (signal?.aborted) throw signal.reason; + if (controller.signal.aborted) throw controller.signal.reason; + throw error; + } finally { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + } + } + + async function graphOnce(path, params, { signal }) { if (graphBase) { const search = new URLSearchParams(params); const response = await fetchImpl(`${graphBase}${path}?${search}`, { @@ -147,5 +195,21 @@ export function createMapillarySource({ ); return Array.isArray(payload?.data) ? payload.data : []; }, + + /** Images within `NEAREST_RADIUS_M` of a point, in no particular order. */ + async nearestImages({ lat, lon }, { signal } = {}) { + const payload = await graph( + 'images', + { + lat: String(lat), + lng: String(lon), + radius: String(NEAREST_RADIUS_M), + limit: String(NEAREST_LIMIT), + fields: NEAREST_IMAGE_FIELDS, + }, + { signal, timeoutMs: NEAREST_TIMEOUT_MS }, + ); + return Array.isArray(payload?.data) ? payload.data : []; + }, }; } diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index b502a7fd8..9afc41291 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -126,6 +126,25 @@ test('a rejected key and a rate limit are told apart from other tile errors', as ); }); +test('nearestImages asks the graph API around a point, within 50 m, for at most 100 images', async () => { + const { calls, fetchImpl } = fakeFetch(() => + jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), + ); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + const images = await source.nearestImages({ lat: 38.5, lon: -121.5 }); + assert.equal(images.length, 1); + const url = new URL(calls[0].url); + assert.equal(url.pathname, '/images'); + assert.equal(url.searchParams.get('lat'), '38.5'); + assert.equal(url.searchParams.get('lng'), '-121.5'); + assert.equal(url.searchParams.get('radius'), '50'); + // The API cuts at the limit before any order: a small one would sort and + // filter a handful of arbitrary photos (a 360° filter could find none). + assert.equal(url.searchParams.get('limit'), '100', "the API's most"); + assert.match(url.searchParams.get('fields'), /computed_geometry/); + assert.match(url.searchParams.get('fields'), /is_pano/); +}); + test('with a Graph endpoint, sequence lookups go there without a token', async () => { const { calls, fetchImpl } = fakeFetch(() => jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }), @@ -157,3 +176,63 @@ test('Graph endpoint errors keep their status', async () => { (error) => error.status === 503 && error.keyRequired === true, ); }); + +test('a nearest-image search that never answers is given up, body included', async (t) => { + t.mock.timers.enable({ apis: ['setTimeout'] }); + const { NEAREST_TIMEOUT_MS } = await import('./policy.js'); + // Headers arrive; the body never ends, and the fetch ignores its signal. + const { fetchImpl } = fakeFetch(() => ({ + ok: true, + status: 200, + json: () => new Promise(() => {}), + })); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + const pending = source.nearestImages({ lat: 1, lon: 2 }); + const outcome = pending.then( + () => 'answered', + (error) => error, + ); + await Promise.resolve(); + t.mock.timers.tick(NEAREST_TIMEOUT_MS); + const error = await outcome; + assert.ok(error instanceof MapillarySourceError, String(error)); + assert.equal(error.status, 504); + assert.equal(error.keyRequired, false, 'not a key problem'); + // English data, shown in the interface language like the other sources'. + const { setLocale } = await import('../../../../i18n/index.js'); + const { translateStreetLevelError } = + await import('../../../../ui/streetLevelPresentation.js'); + assert.equal( + translateStreetLevelError(error.message), + 'Mapillary did not answer in time — try again', + ); + for (const [locale, expected] of [ + ['de', /nicht rechtzeitig/], + ['ja', /時間内に応答/], + ]) { + setLocale(locale); + try { + assert.match(translateStreetLevelError(error.message), expected, locale); + } finally { + setLocale('en'); + } + } +}); + +test('the caller’s abort still ends a nearest-image search before its deadline', async () => { + const { fetchImpl } = fakeFetch( + (_url, init) => + new Promise((_, reject) => + init.signal.addEventListener('abort', () => reject(init.signal.reason)), + ), + ); + const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl }); + const controller = new AbortController(); + const pending = source.nearestImages( + { lat: 1, lon: 2 }, + { signal: controller.signal }, + ); + const reason = new Error('the turn moved on'); + controller.abort(reason); + await assert.rejects(pending, (error) => error === reason); +}); diff --git a/src/layers/streetLevel/providers/mapillary/viewer.js b/src/layers/streetLevel/providers/mapillary/viewer.js index 4ec2654e4..c270ba796 100644 --- a/src/layers/streetLevel/providers/mapillary/viewer.js +++ b/src/layers/streetLevel/providers/mapillary/viewer.js @@ -174,6 +174,15 @@ export function createMapillaryViewer({ source, render } = {}) { current = null; } + /** A playing sequence keeps stepping through (and downloading) images. */ + function stopPlayback() { + try { + viewer?.getComponent('sequence')?.stop(); + } catch { + /* sequence component not available */ + } + } + return { async mount(host) { if (!host) throw new Error('Street Level viewer has no host element'); @@ -195,15 +204,12 @@ export function createMapillaryViewer({ source, render } = {}) { close() { pendingOpen = null; current = null; - // A playing sequence would keep stepping through (and downloading) - // images in the hidden viewer. - try { - viewer?.getComponent('sequence')?.stop(); - } catch { - /* sequence component not available */ - } + // Hidden, the viewer must not keep playing. + stopPlayback(); }, + stopPlayback, + unmount() { pendingOpen = null; // A build still waiting for the library gives up when it arrives. diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 4ba1763ab..ff4e84c3d 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -20,6 +20,7 @@ export function filtersOf(provider) { * @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image. * @property {string} [requiresMapStack] Usable only on this map stack. * @property {boolean} [defaultOn] Default true; the first that defaults on is lit. + * @property {boolean} [billedPerImage] Every image load costs the key's owner: a link never opens one without a click. * @property {() => boolean} [keyReady] False when its key is missing now; the * first keyless provider is lit instead of a default that cannot draw. * @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. @@ -50,7 +51,7 @@ export function filtersOf(provider) { * @property {(sequenceId: string) => Promise} [selectSequence] * @property {() => void} [clearSequence] * @property {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats] - * @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError). + * @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal, radiusM?: number}) => Promise} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError). `radiusM` asks for a wider search than NEAREST_RADIUS_M; a provider that cannot search wider ignores it. * @property {ViewerAdapter} viewer * * @typedef {object} ViewerAdapter diff --git a/src/layers/streetLevel/state.js b/src/layers/streetLevel/state.js index f9e5e4c88..283e5eff6 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -19,6 +19,8 @@ export function freshStreet() { capturedAt: null, /** 'day', or 'month' for providers that date images by month (Google). */ capturedAtPrecision: 'day', + /** Why the last nearest lookup opened nothing: 'empty', 'sky' or null. */ + nearestOutcome: null, /** A place name for the caption (Google's address), or null. */ title: null, /** Whether the open viewer has FIT/FILL render modes. */ @@ -29,6 +31,8 @@ export function freshStreet() { externalUrl: null, loading: false, error: null, + // A lookup a link prepared but left to a click: {point, providerIds, radiusM}. + armed: null, }; } diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index 16c992afd..9e3b18901 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -73,6 +73,19 @@ export function createViewerHost({ state, parts }) { active.unsubscribe(); active.adapter.unmount(); active = null; + // The photo being replaced belonged to that source: none of its details + // (id, place, author, link) describe the one loading now. + const { open, loading, error, providerId, providerName, providerLabel } = + state.street; + Object.assign(state.street, freshStreet(), { + open, + loading, + error, + providerId, + providerName, + providerLabel, + }); + parts.marker.clear(); } const adapter = entry.instance.viewer; const promise = (async () => { @@ -99,12 +112,17 @@ export function createViewerHost({ state, parts }) { } /** - * Open an image: true once its first pose is in, false if it failed or was - * overtaken. `frame: false` leaves the camera where it is. + * Open an image: true once its first pose is in, false if it failed, was + * overtaken, or `signal` fired first. An abort while the viewer mounts or + * loads closes the photo: nothing opens for a request that was withdrawn. */ - async function open(providerId, imageId, { frame = true } = {}) { + async function open( + providerId, + imageId, + { frame = true, signal = null } = {}, + ) { const entry = state.providers.get(providerId); - if (!entry || !imageId) return false; + if (!entry || !imageId || signal?.aborted) return false; if (!state.street.host) { // A nearest-image lookup may have set it; nothing is loading now. state.street.loading = false; @@ -115,6 +133,10 @@ export function createViewerHost({ state, parts }) { } const seq = ++openSeq; const current = () => seq === openSeq; + const onAbort = () => { + if (current()) close(); + }; + signal?.addEventListener('abort', onAbort, { once: true }); // Claimed now, honoured after loading only if nothing newer took the camera. const ticket = frame ? parts.framing.begin() : null; Object.assign(state.street, { @@ -138,6 +160,7 @@ export function createViewerHost({ state, parts }) { if (current()) state.street.error = error?.message || msg('Image could not be opened'); } finally { + signal?.removeEventListener('abort', onAbort); if (current()) state.street.loading = false; notify(); } @@ -165,6 +188,11 @@ export function createViewerHost({ state, parts }) { active?.adapter.resize(); } + /** Stop a sequence playing in the open photo (adapters that play). */ + function stopPlayback() { + active?.adapter.stopPlayback?.(); + } + function setRenderMode(mode) { state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox'; active?.adapter.setRenderMode?.(state.street.renderMode); @@ -187,5 +215,27 @@ export function createViewerHost({ state, parts }) { active = null; } - return { attach, open, close, resize, setRenderMode, unmount }; + /** + * Destroy `providerId`'s viewer and leave the panel's state alone: what a + * request leaves behind once it replaced or closed that source's photo. + */ + function release(providerId) { + if (active?.id !== providerId && mounting?.id !== providerId) return; + const pending = mounting; + mounting = null; + active?.unsubscribe(); + (active?.adapter ?? pending?.adapter)?.unmount(); + active = null; + } + + return { + attach, + open, + close, + resize, + setRenderMode, + stopPlayback, + unmount, + release, + }; } diff --git a/src/layers/streetLevel/viewerHost.test.mjs b/src/layers/streetLevel/viewerHost.test.mjs index fdcb74e21..c7e7d0007 100644 --- a/src/layers/streetLevel/viewerHost.test.mjs +++ b/src/layers/streetLevel/viewerHost.test.mjs @@ -557,6 +557,51 @@ test('an adapter that can frame but says this image has no frame offers no FIT / assert.equal(state.street.renderModes, true, 'a flat image can be framed'); }); +test('a caller that withdraws while the viewer is still mounting gets no photo', async () => { + const gate = deferred(); + const adapter = fakeAdapter({ gate }); + const { state, host } = harness(adapter); + const controller = new AbortController(); + const pending = host.open('mapillary', 'a', { signal: controller.signal }); + assert.equal(state.street.open, true, 'opening'); + controller.abort(); // the library is still loading + gate.resolve(); + assert.equal(await pending, false); + assert.deepEqual(adapter.calls.open, [], 'the image was never opened'); + assert.equal(state.street.open, false, 'the photo is closed'); + assert.equal(state.street.loading, false, 'no stuck spinner'); + assert.equal(state.street.imageId, null); +}); + +test('a caller that withdraws while the photo is loading gets it closed', async () => { + const openGate = deferred(); + const adapter = fakeAdapter({ openGate }); + const { state, host } = harness(adapter); + const controller = new AbortController(); + const pending = host.open('mapillary', 'a', { signal: controller.signal }); + await settle(); + assert.deepEqual(adapter.calls.open, ['a'], 'loading'); + controller.abort(); + openGate.resolve(); + assert.equal(await pending, false); + assert.equal(state.street.open, false, 'the withdrawn photo is closed'); + assert.equal(state.street.imageId, null); + // An already-withdrawn request asks nothing at all. + const done = new AbortController(); + done.abort(); + assert.equal( + await host.open('mapillary', 'b', { signal: done.signal }), + false, + ); + assert.deepEqual(adapter.calls.open, ['a']); + // A signal that never fires changes nothing. + assert.equal( + await host.open('mapillary', 'c', { signal: new AbortController().signal }), + true, + ); + assert.equal(state.street.imageId, 'c'); +}); + test('a pose’s licence reaches the caption state, and a closed viewer forgets it', async () => { const { state, host, google } = twoProviders(); await host.open('google', 'g-1'); diff --git a/src/locations.js b/src/locations.js index d17186905..5da9432c4 100644 --- a/src/locations.js +++ b/src/locations.js @@ -380,6 +380,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) { }); return { label, + latitude: lat, + longitude: lng, navigationMode: 'natural-region-swath', rangeM: swath.rangeM, }; @@ -415,6 +417,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) { if (flight) { return { label, + latitude: lat, + longitude: lng, navigationMode, rangeM: null, }; @@ -468,6 +472,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) { }); return { label, + latitude: lat, + longitude: lng, navigationMode: requestedRange ? 'explicit-range' : options.forceClose diff --git a/src/locations.test.mjs b/src/locations.test.mjs index 8d2a9503c..10e3b53fb 100644 --- a/src/locations.test.mjs +++ b/src/locations.test.mjs @@ -778,3 +778,35 @@ test('a precise search without an outline frames against the resolved ground, no const eye = target.height + flight.offset.range * Math.sin(-flight.offset.pitch); assert.ok(eye > 171 + 100, `eye ${eye} must clear the 171 m surface`); }); + +test('searchAndFlyTo reports the place it flew to, whichever framing it chose', async () => { + // A city frames its viewport. + const city = await runSearch(stubViewer(), {}); + assert.equal(city.latitude, 30.2672); + assert.equal(city.longitude, -97.7431); + // A precise place flies to a landmark. + const place = await runSearch(stubViewer(), {}, { + query: 'Texas State Capitol', + result: { + formatted_address: '1100 Congress Ave, Austin, TX', + types: ['establishment', 'point_of_interest'], + geometry: { location: { lat: 30.2747, lng: -97.7403 } }, + }, + }); + assert.equal(place.latitude, 30.2747); + assert.equal(place.longitude, -97.7403); + // A natural region frames a swath. + const region = await runSearch(stubViewer(), {}, { + query: 'Rocky Mountains', + result: { + formatted_address: 'Rocky Mountains', + types: ['natural_feature', 'establishment'], + geometry: { + location: { lat: 43.7, lng: -110.7 }, + viewport: { southwest: { lat: 35, lng: -118 }, northeast: { lat: 52, lng: -104 } }, + }, + }, + }); + assert.equal(region.latitude, 43.7); + assert.equal(region.longitude, -110.7); +}); diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index 2980b21e6..63e489ed8 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -181,3 +181,37 @@ export function reassertNavigationHandoff({ release?.(); return true; } + +/** Gestures on the globe that take the camera from a pending request. */ +const CAMERA_GESTURES = ['pointerdown', 'wheel']; + +/** + * Whether the camera is still a request's: a navigation the shell stamps (a + * search, the assistant, a link) or a press or wheel on the globe hands it to + * someone else. `claim()` takes the navigation stamped so far as the + * request's (its own flight); a gesture is never forgiven. `stop()` removes + * the gesture listeners. + */ +export function watchCameraOwner(viewer, navigation) { + let generation = navigation?._navigationGeneration; + const canvas = viewer?.canvas ?? viewer?.scene?.canvas ?? null; + let taken = false; + const onGesture = () => { + taken = true; + }; + for (const type of CAMERA_GESTURES) + canvas?.addEventListener?.(type, onGesture, { passive: true }); + return { + claim: () => { + generation = navigation?._navigationGeneration; + }, + taken: () => + taken || + (Number.isFinite(generation) && + navigation._navigationGeneration !== generation), + stop: () => { + for (const type of CAMERA_GESTURES) + canvas?.removeEventListener?.(type, onGesture); + }, + }; +} diff --git a/src/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 324adceb5..6dec02b14 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -25,14 +25,15 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 34-tool inventory', () => { +test('Realtime schema exposes the authoritative 35-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 34); + assert.equal(tools.length, 35); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 34, 'tool names are unique'); + assert.equal(new Set(names).size, 35, 'tool names are unique'); assert.ok(names.includes('resolve_area')); assert.ok(names.includes('find_imagery')); assert.ok(names.includes('osm_query')); + assert.ok(names.includes('show_street_level')); assert.ok(names.includes('set_context_mode')); assert.ok(names.includes('control_cockpit')); assert.ok(names.includes('set_voice_inactivity_timeout')); @@ -206,6 +207,8 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'resolve_area', 'find_imagery', 'osm_query', + // Street-level photos: a new composite tool, not an edit to a shipped one. + 'show_street_level', ]); const unchanged = realtimeTools() .filter((tool) => !TOUCHED.has(tool.name)) diff --git a/src/streetLevelLifecycle.test.mjs b/src/streetLevelLifecycle.test.mjs new file mode 100644 index 000000000..b64ef9ea0 --- /dev/null +++ b/src/streetLevelLifecycle.test.mjs @@ -0,0 +1,186 @@ +import './testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createStreetLevelLayer } from './layers/streetLevel/index.js'; +import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetLevelFakes.mjs'; +import { showStreetLevel } from './voice/gevActions.js'; +import { installViews } from './app/embed.js'; +import { ShareRestoration } from './ui/shareRestoration.js'; +import { StreetLevelControls } from './ui/streetLevelControls.js'; +import { createApp, nextTick } from 'vue'; +import { createPinia } from 'pinia'; +import StreetLevelPanel from './ui/views/panels/StreetLevelPanel.vue'; +import { useStreetLevelStore } from './ui/stores/streetLevel.js'; + +// Street-level request lifecycle across the action, the layer, links and the +// panel. Offline: providers and camera flights are fixtures. +globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); }; +const settle = () => new Promise(resolve => setTimeout(resolve, 0)); +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +async function harness(t, { nearestImage = async () => 'A', failImage = null, extraProviders = [] } = {}) { + document.body.className = ''; + document.body.replaceChildren(); + const provider = fakeStreetLevelProvider({ nearestImage }); + const originalCreate = provider.create; + const opened = []; + provider.create = context => { + const instance = originalCreate(context); + let pose; + instance.viewer.onPose = listener => { pose = listener; return () => { pose = null; }; }; + instance.viewer.open = async id => { + opened.push(id); + if (id === failImage) throw new Error('Fixture image load failed'); + pose?.({ imageId: id }); + }; + return instance; + }; + const viewer = fakeCesiumViewer(); + viewer.canvas = viewer.scene.canvas; + viewer.canvas.setPointerCapture = () => {}; + const layer = createStreetLevelLayer({ providers: [provider, ...extraProviders] }); + layer.init(viewer); + layer.enable(viewer); + const host = document.body.appendChild(document.createElement('div')); + layer.attachViewerHost(host); + t.after(() => { layer.destroy(); document.body.className = ''; document.body.replaceChildren(); }); + await settle(); + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + getAll: () => [{ id: 'street-level', enabled: true }], + isEnabled: () => true, + getLayerParams: () => ({ mapillary: true }), + setLayerParams: (_id, params) => layer.setParams(params), + }; + const styleManager = { _navigationGeneration: 1 }; + const flights = []; + const context = { + viewer, dataManager, styleManager, + runOptions: { signal: new AbortController().signal }, + runGevAction: async (name, args) => { + assert.equal(name, 'fly_to_location'); + flights.push(args); + return { ok: true, latitude: 48, longitude: 2, label: 'fixture point' }; + }, + }; + const run = (name, args) => + name === 'show_street_level' ? showStreetLevel(args, context) : Promise.resolve({ ok: true }); + return { layer, viewer, dataManager, context, run, opened, flights }; +} + +test('a landed link does not reclaim a camera the user moved while its layers load', async t => { + const h = await harness(t); + const location = { search: '', hash: '' }; + const shell = h.context.styleManager; + shell.initialRestorePromise = Promise.resolve(); + const remove = installViews({ ...h, shell, location, windowRef: window }); + t.after(remove); + await settle(); + location.hash = '#sl=mapillary,48,2'; + const layers = deferred(); + const landed = deferred(); + const restoration = Object.assign(Object.create(ShareRestoration.prototype), { + _shareRestoreGeneration: 1, _disposed: false, + shareLinkManager: { + applyState: async () => { landed.resolve(); return { camera: 'applied' }; }, + completeInitialRestore() {}, + }, + _sharedVessel: () => null, + }); + const restoring = restoration._runShareRestore({}, { + applyCamera: true, navigationToken: 1, layersPromise: layers.promise, + settle: detail => window.dispatchEvent(new CustomEvent('gev:share-navigation-restore-settled', { detail })), + }); + await landed.promise; + shell._navigationGeneration++; + layers.resolve([]); + await restoring; + await settle(); await settle(); + assert.equal(h.flights.length, 0, 'an old landed link must not fly after later navigation'); +}); + +test('a replacement from another source keeps EXPAND', async t => { + const h = await harness(t, { + extraProviders: [fakeStreetLevelProvider({ id: 'panoramax', pickPrefix: 'pm:', nearestImage: async () => 'B' })], + }); + const root = document.body.appendChild(document.createElement('div')); + root.innerHTML = '
'; + const wrap = root.querySelector('#sl-viewer-wrap'); + let fullscreen = null; + Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen }); + const oldExit = document.exitFullscreen; + // As a browser does: the change is announced after the switch. + const announce = () => setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0); + document.exitFullscreen = async () => { fullscreen = null; announce(); }; + wrap.requestFullscreen = async () => { fullscreen = wrap; announce(); }; + const store = { attach: () => 1, detach() {}, publish() {}, setExpandAvailable() {}, setExpanded() {}, setEmbedOverlay() {} }; + const controls = new StreetLevelControls({ root, layer: h.layer, actions: {}, store }); + const off = h.layer.subscribe(state => controls.render(state)); + t.after(() => { off(); controls.destroy(); delete document.fullscreenElement; document.exitFullscreen = oldExit; }); + assert.equal(await h.layer.openImage('mapillary', 'previous', { frame: false }), true); + await controls._toggleExpanded(); + assert.equal(controls.isViewerExpanded(), true); + const result = await showStreetLevel({ provider: 'panoramax', latitude: 48, longitude: 2 }, h.context); + assert.equal(result.ok, true); + assert.equal(h.layer.getUIState().street.providerId, 'panoramax'); + assert.equal(controls.isViewerExpanded(), true, 'replacement must preserve the expanded viewing surface'); +}); + +for (const outcome of ['empty', 'error', 'image']) { + test(`a replacement retired by a navigation keeps the open photo (${outcome})`, async t => { + const lookup = deferred(); + const entered = deferred(); + const h = await harness(t, { nearestImage: async () => { + entered.resolve(); await lookup.promise; + if (outcome === 'error') throw new Error('Fixture lookup failure'); + return outcome === 'image' ? 'B' : null; + } }); + await h.layer.openImage('mapillary', 'A', { frame: false }); + const action = showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, h.context); + await entered.promise; + h.context.styleManager._navigationGeneration++; + lookup.resolve(); + const result = await action; + assert.ok(result.cancelled || result.superseded); + assert.deepEqual(h.opened, ['A']); + assert.equal(h.layer.getUIState().street.open, true, 'retired lookup must preserve A'); + assert.equal(h.layer.getUIState().street.error, null, 'retired lookup must not publish an error'); + }); +} + +test('a failed replacement shows its error inside the expanded viewer', async t => { + const h = await harness(t, { nearestImage: async () => 'B', failImage: 'B' }); + const root = document.body.appendChild(document.createElement('div')); + const pinia = createPinia(); + const app = createApp(StreetLevelPanel); app.use(pinia); app.mount(root); + const store = useStreetLevelStore(pinia); + const wrap = root.querySelector('#sl-viewer-wrap'); + let fullscreen = null; + Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen }); + const oldExit = document.exitFullscreen; + // As a browser does: the change is announced after the switch. + const announce = () => setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0); + document.exitFullscreen = async () => { fullscreen = null; announce(); }; + wrap.requestFullscreen = async () => { fullscreen = wrap; announce(); }; + const controls = new StreetLevelControls({ root, layer: h.layer, actions: {}, store }); + const off = h.layer.subscribe(state => controls.render(state)); + t.after(() => { off(); controls.destroy(); app.unmount(); delete document.fullscreenElement; document.exitFullscreen = oldExit; }); + await h.layer.openImage('mapillary', 'A', { frame: false }); + await nextTick(); + await controls._toggleExpanded(); + await settle(); + const result = await showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, h.context); + await nextTick(); + assert.equal(result.ok, false); + assert.equal(controls.isViewerExpanded(), true); + assert.equal(document.fullscreenElement, wrap); + assert.equal(h.layer.getUIState().street.imageId, 'A'); + assert.match(h.layer.getUIState().street.error, /Fixture image load failed/); + assert.equal(wrap.contains(root.querySelector('#sl-error')), false); + const error = wrap.querySelector('#sl-viewer-error'); + assert.match(error.textContent, /Fixture image load failed/); + assert.equal(error.hidden, false, 'error must be visible inside fullscreen'); +}); diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 7e2e32314..c7e848c3d 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -115,3 +115,26 @@ test('OPEN STREET VIEW keeps its width beside a long coverage line', () => { ); assert.match(panelCss, /\.sl-meta-row \.sl-meta \{[^}]*min-width: 0;/); }); + +test('embed mode shows an open photo over the globe, filling the overlay', () => { + const overlay = css.match( + /body\.ui-embed \.sl-viewer-wrap-embed \{([^}]*)\}/, + ); + assert.ok(overlay, 'embed styles the viewer it moves over the globe'); + for (const rule of ['position: fixed', 'inset: 12px', 'z-index: 1000']) + assert.ok(overlay[1].includes(rule), rule); + // Cockpit, recording and scene playback own the screen: the photo hides. + for (const mode of ['cockpit-mode', 'recording-mode', 'scene-playback-mode']) + assert.match( + css, + new RegExp( + `body\\.ui-embed\\.${mode} \\.sl-viewer-wrap-embed[^{]*\\{[^}]*display: none`, + ), + mode, + ); + // The photo takes the overlay's height, as it does full screen. + assert.match( + css, + /body\.ui-embed \.sl-viewer-wrap-embed \.sl-viewer \{[^}]*flex: 1 1 auto/, + ); +}); diff --git a/src/streetLevelReplacement.test.mjs b/src/streetLevelReplacement.test.mjs new file mode 100644 index 000000000..f0aa97ec0 --- /dev/null +++ b/src/streetLevelReplacement.test.mjs @@ -0,0 +1,281 @@ +import './testSupport/registerDom.mjs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createStreetLevelLayer } from './layers/streetLevel/index.js'; +import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetLevelFakes.mjs'; +import { showStreetLevel } from './voice/gevActions.js'; +import { installViews } from './app/embed.js'; +import { groundedLookAt } from './view/index.js'; +import { composeCatalog, coreTools } from './tools/index.js'; + +// Replacing a street-level photo, across sources and against the user's own +// moves; posted views waiting on terrain, and their look-at near a pole. +// Offline: providers, lookups, terrain and flights are fixtures. +globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); }; +const settle = () => new Promise(resolve => setTimeout(resolve, 0)); +function deferred() { + let resolve; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +function provider(id, { nearest = async () => 'B', load = async () => {}, billed = false } = {}) { + const def = fakeStreetLevelProvider({ id, pickPrefix: id + ':', nearestImage: nearest }); + def.name = id; + def.label = id.toUpperCase(); + def.billedPerImage = billed; + const create = def.create; + def.create = context => { + const instance = create(context); + let pose; + instance.viewer.onPose = listener => { pose = listener; return () => { pose = null; }; }; + instance.viewer.open = async imageId => { + def.calls.open.push(imageId); + await load(imageId); + pose?.({ imageId, position: { lat: 48, lon: 2 }, creator: 'fixture photographer', + externalUrl: 'https://www.mapillary.com/app/?pKey=' + imageId }); + }; + return instance; + }; + return def; +} +async function harness(t, defs) { + document.body.className = ''; + document.body.replaceChildren(); + const viewer = fakeCesiumViewer(); + viewer.canvas = viewer.scene.canvas; + viewer.canvas.setPointerCapture = () => {}; + const layer = createStreetLevelLayer({ providers: defs }); + layer.init(viewer); + layer.enable(viewer); + layer.attachViewerHost(document.body.appendChild(document.createElement('div'))); + t.after(() => { layer.destroy(); document.body.className = ''; document.body.replaceChildren(); }); + await settle(); + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + getAll: () => [{ id: 'street-level', enabled: true }], + isEnabled: () => true, + getLayerParams: () => ({ mapillary: true }), + setLayerParams: (_id, params) => layer.setParams(params), + }; + const shell = { _navigationGeneration: 1, initialRestorePromise: Promise.resolve(), setCleanView() {} }; + const flights = []; + const context = { viewer, dataManager, styleManager: shell, + runOptions: { signal: new AbortController().signal }, + runGevAction: async (name, args) => { + assert.equal(name, 'fly_to_location'); + flights.push(args); + return { ok: true, latitude: args.latitude, longitude: args.longitude, label: 'fixture point' }; + }, + }; + const run = (name, args) => name === 'show_street_level' ? showStreetLevel(args, context) : Promise.resolve({ ok: true }); + return { layer, viewer, dataManager, shell, context, run, flights }; +} +async function embed(t, h, terrain) { + const replies = []; + const peer = { postMessage: message => replies.push(message) }; + const windowRef = new EventTarget(); + windowRef.parent = peer; + windowRef.document = document; + const remove = installViews({ ...h, terrain, windowRef, location: { search: '?embed=1', hash: '' } }); + t.after(remove); + await settle(); + const post = (id, view) => { + const event = new Event('message'); + Object.assign(event, { source: peer, origin: 'https://host.example', data: { type: 'gev:view', id, view } }); + windowRef.dispatchEvent(event); + }; + return { post, replies }; +} +const args = provider => ({ provider, latitude: 48, longitude: 2, fly: false }); +const view = pitch => ({ camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: pitch }, street_level: { provider: 'mapillary' } }); + +for (const outcome of ['empty', 'error']) { + test('a replacement from another source that opens nothing keeps its explanation (' + outcome + ')', async t => { + const a = provider('mapillary'); + const b = provider('panoramax', { nearest: async () => { + if (outcome === 'error') throw new Error('Fixture lookup failure'); + return null; + } }); + const h = await harness(t, [a, b]); + assert.equal(await h.layer.openImage('mapillary', 'A', { frame: false }), true); + const result = await showStreetLevel(args('panoramax'), h.context); + const street = h.layer.getUIState().street; + t.diagnostic(JSON.stringify({ result, error: street.error, nearestOutcome: street.nearestOutcome })); + assert.equal(result.ok, false); + assert.equal(street.open, false); + assert.ok(street.error, 'replacement explanation must survive release'); + if (outcome === 'empty') assert.equal(street.nearestOutcome, 'empty'); + }); +} +test('an arming link from another source keeps its armed point', async t => { + const h = await harness(t, [provider('mapillary'), provider('google', { billed: true })]); + assert.equal(await h.layer.openImage('mapillary', 'A', { frame: false }), true); + const result = await showStreetLevel({ ...args('google'), autoOpen: false }, h.context); + const street = h.layer.getUIState().street; + t.diagnostic(JSON.stringify({ result, armed: street.armed })); + assert.equal(result.armed, true); + assert.deepEqual(street.armed?.point, { lat: 48, lon: 2 }); +}); +test('a same-source lookup that finds nothing keeps its explanation', async t => { + const h = await harness(t, [provider('mapillary', { nearest: async () => null })]); + await h.layer.openImage('mapillary', 'A', { frame: false }); + const result = await showStreetLevel(args('mapillary'), h.context); + assert.equal(result.noImagery, true); + assert.ok(h.layer.getUIState().street.error); + assert.equal(h.layer.getUIState().street.nearestOutcome, 'empty'); +}); + +for (const takeover of ['navigation', 'gesture', 'none']) { + test('a posted view waiting on terrain yields to the user (' + takeover + ')', async t => { + const a = provider('mapillary'); + const h = await harness(t, [a]); + const entered = deferred(); + const terrain = deferred(); + const e = await embed(t, h, { getHeights: async () => { entered.resolve(); return terrain.promise; } }); + e.post(1, view(-45)); + await entered.promise; + if (takeover === 'navigation') h.shell._navigationGeneration++; + if (takeover === 'gesture') h.viewer.canvas.dispatchEvent(new Event('pointerdown')); + terrain.resolve([{ ellipsoid: 200 }]); + for (let n = 0; n < 8 && !e.replies.some(r => r.id === 1); n++) await settle(); + const reply = e.replies.find(r => r.id === 1); + t.diagnostic(JSON.stringify({ takeover, flights: h.flights, opened: a.calls.open, reply })); + assert.ok(reply, 'host receives completion'); + assert.equal(h.flights.length, takeover === 'none' ? 1 : 0, 'superseded view must not reclaim the camera'); + assert.equal(a.calls.open.length, takeover === 'none' ? 1 : 0); + assert.equal(reply.ok, takeover === 'none'); + }); +} + +test('a posted view looking at the horizon does not wait on terrain or hold the queue', async t => { + const h = await harness(t, [provider('mapillary')]); + const terrain = deferred(); + let terrainCalls = 0; + const e = await embed(t, h, { getHeights: () => { terrainCalls++; return terrain.promise; } }); + e.post(1, view(0)); + e.post(2, { ...view(-90), street_level: { provider: 'mapillary', lat: 49, lon: 3 } }); + for (let n = 0; n < 8; n++) await settle(); + const before = e.replies.filter(r => r.id).map(r => r.id); + terrain.resolve([{ ellipsoid: 200 }]); + for (let n = 0; n < 8 && !e.replies.some(r => r.id === 2); n++) await settle(); + const after = e.replies.filter(r => r.id).map(r => r.id); + t.diagnostic(JSON.stringify({ terrainCalls, before, after, flights: h.flights })); + assert.deepEqual(after, [1, 2], 'both complete after irrelevant terrain is released'); + assert.deepEqual(before, [1, 2], 'both should complete without any terrain response'); + assert.equal(terrainCalls, 0); +}); + +for (const initial of [true, false]) { + test('a source switched by hand during the load stops the request (photo open before: ' + initial + ')', async t => { + const entered = deferred(); + const load = deferred(); + const a = provider('mapillary', { load: async id => { if (id === 'B') { entered.resolve(); await load.promise; } } }); + const h = await harness(t, [a, provider('panoramax')]); + if (initial) await h.layer.openImage('mapillary', 'A', { frame: false }); + const request = showStreetLevel(args('mapillary'), h.context); + await entered.promise; + h.layer.setProviderEnabled('panoramax', true); + load.resolve(); + const result = await request; + const street = h.layer.getUIState().street; + t.diagnostic(JSON.stringify({ initial, result, open: street.open, sourceOn: h.layer.providerStatus('mapillary').on })); + assert.equal(h.layer.providerStatus('mapillary').on, false); + assert.equal(street.open, false); + assert.equal(result.ok, false, 'request for the disabled source must not report success'); + assert.equal(result.cancelled, true); + }); +} + +test('a failed replacement from another source does not keep the old photo\'s details', async t => { + const h = await harness(t, [provider('mapillary'), provider('panoramax', { load: async () => { throw new Error('Fixture image load failure'); } })]); + await h.layer.openImage('mapillary', 'A', { frame: false }); + const before = h.layer.getUIState().street; + assert.equal(before.imageId, 'A'); + assert.ok(before.externalUrl); + const result = await showStreetLevel(args('panoramax'), h.context); + const street = h.layer.getUIState().street; + t.diagnostic(JSON.stringify({ result, street })); + assert.equal(result.ok, false); + assert.equal(street.providerId, 'panoramax'); + assert.match(street.error, /Fixture image load failure/); + assert.deepEqual({ imageId: street.imageId, position: street.position, creator: street.creator, externalUrl: street.externalUrl }, + { imageId: null, position: null, creator: null, externalUrl: null }, 'destroyed provider photo must not be attributed to replacement'); +}); + +const rejections = []; +process.on('unhandledRejection', reason => { rejections.push(String(reason?.message || reason)); }); +// 111 m from the North Pole, looking east and down: 1 km ahead is more than a +// full turn of that parallel. +const north = { camera: { lat: 89.999, lon: 170, altitude_m: 1000, pitch_deg: -45, heading_deg: 90 }, street_level: { provider: 'mapillary' } }; +const placed = { camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 }, street_level: { provider: 'mapillary', lat: 48, lon: 2 } }; + +test('a grounded look-at near a pole stays on the globe', async () => { + const point = await groundedLookAt(north.camera, { getHeights: async () => [{ ellipsoid: 0 }] }); + assert.ok(Math.abs(point.lat) <= 90 && Math.abs(point.lon) <= 180, JSON.stringify(point)); +}); + +test('show_in_gods_eye_view frames the South Pole with a tilted camera', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' }, terrain: { getHeights: async () => [{ ellipsoid: 2835 }] } }, + }); + const result = await catalog.call('show_in_gods_eye_view', { + camera: { lat: -90, lon: 0, altitude_m: 3500, pitch_deg: -30, heading_deg: 45 }, + street_level: { provider: 'mapillary' }, + }); + const street = result.data?.view?.street_level; + assert.ok(street && Math.abs(street.lat) <= 90 && Math.abs(street.lon) <= 180, JSON.stringify(result)); +}); + +test('a posted polar view is answered and the next view still applies', async t => { + rejections.length = 0; + const h = await harness(t, [provider('mapillary')]); + const e = await embed(t, h, { getHeights: async () => [{ ellipsoid: 0 }] }); + e.post(1, north); + e.post(2, placed); + for (let n = 0; n < 20 && !e.replies.some(r => r.id === 2); n++) await settle(); + t.diagnostic(JSON.stringify({ replies: e.replies.filter(r => r.id), rejections })); + assert.ok(e.replies.some(r => r.id === 1), 'the polar view gets an answer'); + assert.ok(e.replies.some(r => r.id === 2 && r.ok === true), 'the next view still applies'); + assert.deepEqual(rejections, [], 'no rejection escapes the views queue'); +}); + +test('a stalled terrain answer does not hold posted views', { timeout: 15000 }, async t => { + const h = await harness(t, [provider('mapillary')]); + let asked = 0; + // Hangs like a stalled upstream; gives up only when its signal aborts. + const terrain = { + getHeights: (_points, { signal } = {}) => { + asked++; + return new Promise((_, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true })); + }, + }; + const e = await embed(t, h, terrain); + e.post(1, view(-45)); + e.post(2, placed); + const started = Date.now(); + while (Date.now() - started < 5000 && !e.replies.some(r => r.id === 2)) await new Promise(resolve => setTimeout(resolve, 100)); + t.diagnostic(JSON.stringify({ asked, waitedMs: Date.now() - started, replies: e.replies.filter(r => r.id).map(r => r.id) })); + assert.equal(asked, 1); + assert.ok(e.replies.some(r => r.id === 1), 'the view falls back to its own estimate within 5 s'); + assert.ok(e.replies.some(r => r.id === 2), 'the next view is not held behind it'); +}); + +test('a posted view that throws is answered and does not hold the views behind it', async t => { + rejections.length = 0; + const h = await harness(t, [provider('mapillary')]); + const getAll = h.dataManager.getAll; + let calls = 0; + h.dataManager.getAll = () => { + if (calls++ === 0) throw new Error('Fixture layer list failure'); + return getAll(); + }; + const e = await embed(t, h, null); + e.post(1, placed); + e.post(2, placed); + for (let n = 0; n < 20 && !e.replies.some(r => r.id === 2); n++) await settle(); + t.diagnostic(JSON.stringify({ replies: e.replies.filter(r => r.id), rejections })); + assert.deepEqual(e.replies.find(r => r.id === 1), { type: 'gev:view-applied', id: 1, ok: false, error: 'Fixture layer list failure' }); + assert.ok(e.replies.some(r => r.id === 2 && r.ok === true), 'the next view still applies'); + assert.deepEqual(rejections, []); +}); diff --git a/src/testSupport/streetLevelFakes.mjs b/src/testSupport/streetLevelFakes.mjs index f6d5a6d70..a14825d80 100644 --- a/src/testSupport/streetLevelFakes.mjs +++ b/src/testSupport/streetLevelFakes.mjs @@ -100,6 +100,7 @@ export function fakeMapillarySource(overrides = {}) { return new Uint8Array(0); }, getSequenceImages: async () => [], + nearestImages: async () => [], ...overrides, }; } diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 5284e8a06..2e13a98d7 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -1,6 +1,11 @@ /** Links that open God's Eye View at a view. */ -import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js'; +import { + VIEW_PROPERTIES, + createView, + groundedLookAt, + viewUrl, +} from '../../view/index.js'; import { defineTool, ToolError } from '../catalog.js'; import { GLOBE_PANEL_URI } from '../globePanel.js'; import { AREA_SCHEMA, resolveArea } from '../area.js'; @@ -47,10 +52,31 @@ export async function resolveViewArguments(args, { services, signal, tools }) { ? { ...args.camera } : { ...base.camera, ...args.camera }; let label = null; + // Street-level photos open at the area's centre unless a point is given; + // a new area or camera position moves a passed-through view's photos with + // it. + const moved = + Boolean(args.area) || + args.camera?.lat !== undefined || + args.camera?.lon !== undefined; + let streetLevel = + args.street_level && typeof args.street_level === 'object' + ? { ...args.street_level } + : base.street_level && typeof base.street_level === 'object' + ? moved + ? { provider: base.street_level.provider } + : { ...base.street_level } + : null; if (args.area) { const area = await resolveArea(args.area, { services, signal }); label = area.label; const framed = cameraForArea(area); + if ( + streetLevel && + streetLevel.lat === undefined && + streetLevel.lon === undefined + ) + streetLevel = { ...streetLevel, lat: framed.lat, lon: framed.lon }; // A tilted view of an area looks at its center from behind, unless the // caller placed the camera itself. camera = @@ -75,6 +101,15 @@ export async function resolveViewArguments(args, { services, signal, tools }) { camera = { altitude_m: FOLLOW_ALTITUDE_M, ...camera, ...position }; label = `${follow.kind === 'military_aircraft' ? 'military aircraft' : 'aircraft'} ${follow.id}`; } + // Photos for a camera with no point open where it looks, from its ground. + if ( + streetLevel && + streetLevel.lat === undefined && + streetLevel.lon === undefined + ) { + const point = await groundedLookAt(camera, services?.terrain, signal); + if (point) streetLevel = { ...streetLevel, ...point }; + } let view; try { view = createView({ @@ -84,6 +119,7 @@ export async function resolveViewArguments(args, { services, signal, tools }) { map: args.map ?? base.map ?? null, follow, annotations: args.annotations ?? base.annotations ?? [], + street_level: streetLevel, }); } catch (error) { // A view passed through can hold anything; report it as bad arguments. @@ -113,7 +149,9 @@ export const showInGodsEyeView = defineTool({ 'it. Pass the view another answer returned, or describe one: an area ' + 'framed from above or a camera position, with chosen data layers, visual ' + 'style and map, optionally following an aircraft (or riding in its ' + - 'cockpit view) or a satellite, and with marks drawn on the map.', + 'cockpit view) or a satellite, with marks drawn on the map, or opening ' + + 'street-level photos (Google Street View, Mapillary, KartaView or ' + + 'Panoramax) at a place.', inputSchema: { type: 'object', properties: VIEW_ARGUMENTS, @@ -131,7 +169,13 @@ export const showInGodsEyeView = defineTool({ const url = viewUrl(base, view); return { summary: `Open ${label} in God's Eye View: ${url}`, - data: { url, view }, + // A panel opens a billed photo only for a result it receives fresh; + // hosts hand a panel its result again whenever they mount it. + data: { + url, + view, + ...(view.street_level ? { issued_at: Date.now() } : {}), + }, }; }, }); diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs index 7be532150..19458e0e7 100644 --- a/src/tools/queries/share.test.mjs +++ b/src/tools/queries/share.test.mjs @@ -113,3 +113,189 @@ test('links carry a camera, style, map and an entity to follow', async () => { /Give a view, an area, or a camera with lat and lon/, ); }); + +test('links can open street-level photos at the area, on the map the source needs', async () => { + const { viewFromParams } = await import('../../view/index.js'); + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' } }, + }); + const result = await catalog.call('show_in_gods_eye_view', { + area: { lat: 38.5799, lon: -121.4937, radius_km: 1 }, + street_level: { provider: 'google' }, + }); + const { view } = result.data; + assert.equal(view.map, 'photoreal'); + assert.deepEqual(view.layers, ['street-level']); + assert.deepEqual(view.street_level, { + provider: 'google', + lat: 38.5799, + lon: -121.4937, + }); + const params = new URLSearchParams(new URL(result.data.url).hash.slice(1)); + assert.equal(params.get('sl'), 'google,38.57990,-121.49370'); + assert.equal(params.get('map'), 'photoreal'); + assert.deepEqual(viewFromParams(params), view); + // The panel opens a billed photo only for a result it gets fresh. + assert.ok(Math.abs(Date.now() - result.data.issued_at) < 60_000); + + const point = await catalog.call('show_in_gods_eye_view', { + view, + street_level: { provider: 'mapillary', lat: 38.58, lon: -121.49 }, + }); + assert.deepEqual(point.data.view.street_level, { + provider: 'mapillary', + lat: 38.58, + lon: -121.49, + }); + await assert.rejects( + catalog.call('show_in_gods_eye_view', { + area: { lat: 0, lon: 0, radius_km: 1 }, + map: 'osm', + street_level: { provider: 'google' }, + }), + /photoreal/, + ); +}); + +test('a new area or camera position moves a passed-through view’s photos, and a tilted area still opens them at its centre', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' } }, + }); + const first = await catalog.call('show_in_gods_eye_view', { + area: { lat: 38.5799, lon: -121.4937, radius_km: 1 }, + street_level: { provider: 'google' }, + }); + const second = await catalog.call('show_in_gods_eye_view', { + view: first.data.view, + area: { lat: 42.3314, lon: -83.0458, radius_km: 1 }, + }); + assert.deepEqual(second.data.view.street_level, { + provider: 'google', + lat: 42.3314, + lon: -83.0458, + }); + // A new camera position moves them too; turning the camera keeps them. + const moved = await catalog.call('show_in_gods_eye_view', { + view: first.data.view, + camera: { lat: 40.758, lon: -73.9855, altitude_m: 800, pitch_deg: -90 }, + }); + assert.deepEqual(moved.data.view.street_level, { + provider: 'google', + lat: 40.758, + lon: -73.9855, + }); + const turned = await catalog.call('show_in_gods_eye_view', { + view: first.data.view, + camera: { heading_deg: 90 }, + }); + assert.deepEqual(turned.data.view.street_level, first.data.view.street_level); + const tilted = await catalog.call('show_in_gods_eye_view', { + area: { lat: 48.8584, lon: 2.2945, radius_km: 1 }, + camera: { pitch_deg: -30 }, + street_level: { provider: 'mapillary' }, + }); + assert.notEqual( + tilted.data.view.camera.lat, + 48.8584, + 'the camera sits behind the target', + ); + assert.deepEqual(tilted.data.view.street_level, { + provider: 'mapillary', + lat: 48.8584, + lon: 2.2945, + }); + // A wide area frames the camera tens of km up, past where a camera's + // look-at point is trusted: the photos still open at the area's centre. + const wide = await catalog.call('show_in_gods_eye_view', { + area: { lat: 48.8584, lon: 2.2945, radius_km: 15 }, + camera: { pitch_deg: -30 }, + street_level: { provider: 'mapillary' }, + }); + assert.ok(wide.data.view.camera.altitude_m > 20_000); + assert.deepEqual(wide.data.view.street_level, { + provider: 'mapillary', + lat: 48.8584, + lon: 2.2945, + }); + await assert.rejects( + catalog.call('show_in_gods_eye_view', { + area: { lat: 0, lon: 0, radius_km: 1 }, + follow: { kind: 'aircraft', id: 'AE1234' }, + street_level: {}, + }), + /cannot both follow/, + ); +}); + +test('a camera with no photo point opens photos where it looks, measured from its ground', async () => { + const { lookAtPoint } = await import('../../view/index.js'); + const asked = []; + const catalog = composeCatalog({ + tools: coreTools, + services: { + app: { baseUrl: 'http://localhost:4173/' }, + terrain: { + getHeights: async (points) => { + asked.push(points); + return [{ ellipsoid: 1590 }]; + }, + }, + }, + }); + // Denver: 1,909 m above the ellipsoid is about 320 m above its streets. + const camera = { + lat: 39.7392, + lon: -104.9903, + altitude_m: 1909, + pitch_deg: -30, + heading_deg: 0, + }; + const result = await catalog.call('show_in_gods_eye_view', { + camera, + street_level: { provider: 'mapillary' }, + }); + assert.deepEqual(asked, [[{ lat: 39.7392, lon: -104.9903 }]]); + const grounded = lookAtPoint({ ...camera, altitude_m: 319 }); + const { street_level: street } = result.data.view; + assert.ok(Math.abs(street.lat - grounded.lat) < 1e-5, 'about 550 m ahead'); + assert.ok( + street.lat - camera.lat < 0.006, + 'not the 3.3 km a sea-level guess gives', + ); + // No terrain answer: the view's own estimate, as before. + const offline = composeCatalog({ + tools: coreTools, + services: { + app: { baseUrl: 'http://localhost:4173/' }, + terrain: { + getHeights: async () => { + throw new Error('offline'); + }, + }, + }, + }); + const fallback = await offline.call('show_in_gods_eye_view', { + camera, + street_level: { provider: 'mapillary' }, + }); + const guess = lookAtPoint(camera); + assert.ok(Math.abs(fallback.data.view.street_level.lat - guess.lat) < 1e-5); +}); + +test('a view passed through alone keeps its street-level photos', async () => { + const catalog = composeCatalog({ + tools: coreTools, + services: { app: { baseUrl: 'http://localhost:4173/' } }, + }); + const first = await catalog.call('show_in_gods_eye_view', { + area: { lat: 38.5799, lon: -121.4937, radius_km: 1 }, + street_level: { provider: 'mapillary' }, + }); + const again = await catalog.call('show_in_gods_eye_view', { + view: first.data.view, + }); + assert.deepEqual(again.data.view.street_level, first.data.view.street_level); + assert.deepEqual(again.data.view, first.data.view); +}); diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index be253a3c2..0adedd7a5 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -4,7 +4,11 @@ import { ShellFacade } from './shellFacade.js'; import { AircraftDisplay } from './aircraftDisplay.js'; import { LayerBindings } from './layerBindings.js'; import { PanelChrome } from './panelChrome.js'; -import { initMobileShell } from './mobileShell.js'; +import { + initMobileShell, + MOBILE_SHELL_MEDIA_QUERY, + sheetForPanelId, +} from './mobileShell.js'; import { VisualSettings } from './visualSettings.js'; import { NavigationController } from './navigationController.js'; import { ShareRestoration } from './shareRestoration.js'; @@ -1143,6 +1147,7 @@ export class StyleManager extends ShellFacade { this._dataManager?.setLayerParams('street-level', params, options), setPanelCollapsed: (collapsed, options) => this.setPanelCollapsed('street-level-panel', collapsed, options), + revealPanel: () => this.revealPanel('street-level-panel'), showToast: (message) => this._showToast(message), // Lets the panel open for a user, voice or tool switch-on, not a restore. subscribeEnableRequests: (listener) => @@ -1258,6 +1263,20 @@ export class StyleManager extends ShellFacade { return this._panelChrome.setPanelCollapsed(...arguments); } + /** + * Bring a panel into view on a phone, where the rails show only as tab + * sheets: opens the sheet that holds it. Does nothing above 720px. + * @param {string} panelId - DOM id of the panel. + * @returns {void} + */ + revealPanel(panelId) { + const sheet = sheetForPanelId(panelId); + if (!globalThis.matchMedia?.(MOBILE_SHELL_MEDIA_QUERY)?.matches) return; + if (sheet) this._mobileShell?.setSheet(sheet); + // The sheet opens at its top; a panel lower in the rail is scrolled to. + document.getElementById(panelId)?.scrollIntoView?.({ block: 'nearest' }); + } + /** * Toggles "clean view" mode which hides all UI panels via a CSS body class. * @param {boolean} [forceEnabled] - Explicit on/off. Omit to toggle. @@ -1364,9 +1383,12 @@ export class StyleManager extends ShellFacade { }; } if (!target.available) { + // The controller knows why (Google 3D: no Maps key; others: no ion token). return { ok: false, - error: `${target.label} requires a Cesium ion token`, + error: + target.unavailableReason || + `${target.label} requires a Cesium ion token`, activeStack: this.mapStackController.getActiveId(), }; } diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index 5e25fb01f..4990b9383 100644 --- a/src/ui/localePicker.test.mjs +++ b/src/ui/localePicker.test.mjs @@ -52,6 +52,7 @@ test('the current language comes first, the rest by their own names', () => { 'fr', 'it', 'pt', + 'sv', 'he', 'ar', 'zh-CN', @@ -74,6 +75,7 @@ test('the current language comes first, the rest by their own names', () => { 'fr', 'it', 'pt', + 'sv', 'ru', 'uk', 'he', @@ -98,6 +100,7 @@ test('the current language comes first, the rest by their own names', () => { 'fr', 'it', 'pt', + 'sv', 'ru', 'uk', 'he', @@ -123,6 +126,7 @@ test('the current language comes first, the rest by their own names', () => { 'fr', 'it', 'pt', + 'sv', 'ru', 'uk', 'he', diff --git a/src/ui/revealPanel.test.mjs b/src/ui/revealPanel.test.mjs new file mode 100644 index 000000000..e1cab5047 --- /dev/null +++ b/src/ui/revealPanel.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { StyleManager } from './applicationShell.js'; + +function reveal({ phone }) { + const sheets = []; + const scrolls = []; + const panel = { scrollIntoView: (options) => scrolls.push(options) }; + const saved = globalThis.document; + const savedMatchMedia = globalThis.matchMedia; + globalThis.document = { + getElementById: (id) => (id === 'street-level-panel' ? panel : null), + }; + globalThis.matchMedia = (query) => ({ + matches: phone && query === '(max-width: 720px)', + }); + try { + StyleManager.prototype.revealPanel.call( + { _mobileShell: { setSheet: (sheet) => sheets.push(sheet) } }, + 'street-level-panel', + ); + } finally { + globalThis.document = saved; + globalThis.matchMedia = savedMatchMedia; + } + return { sheets, scrolls }; +} + +test('revealing a panel opens its phone sheet and scrolls the panel into view', () => { + const { sheets, scrolls } = reveal({ phone: true }); + assert.deepEqual(sheets, ['cameras']); + // The panel sits below the sheet's first screen at 375×812. + assert.deepEqual(scrolls, [{ block: 'nearest' }]); +}); + +test('revealing a panel above 720px neither opens a sheet nor scrolls the rail', () => { + assert.deepEqual(reveal({ phone: false }), { sheets: [], scrolls: [] }); +}); diff --git a/src/ui/setMapStackReason.test.mjs b/src/ui/setMapStackReason.test.mjs new file mode 100644 index 000000000..ca412116c --- /dev/null +++ b/src/ui/setMapStackReason.test.mjs @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { StyleManager } from './applicationShell.js'; + +test('an unavailable map stack reports the controller’s own reason, not always an ion token', async () => { + const controller = { + getStacks: () => [ + { + id: 'photoreal', + label: 'Google 3D', + available: false, + unavailableReason: 'Google 3D needs GOOGLE_MAPS_API_KEY', + }, + { id: 'bing-aerial', label: 'Bing aerial', available: false }, + ], + getActiveId: () => 'osm', + }; + const shell = { mapStackController: controller }; + const photoreal = await StyleManager.prototype.setMapStack.call( + shell, + 'photoreal', + ); + assert.deepEqual(photoreal, { + ok: false, + error: 'Google 3D needs GOOGLE_MAPS_API_KEY', + activeStack: 'osm', + }); + const bing = await StyleManager.prototype.setMapStack.call( + shell, + 'bing-aerial', + ); + assert.equal(bing.error, 'Bing aerial requires a Cesium ion token'); +}); diff --git a/src/ui/shareRestoration.js b/src/ui/shareRestoration.js index b0bd471c5..5b10cd7b7 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -317,7 +317,9 @@ export class ShareRestoration { // the shared flight having landed. const vessel = this._sharedVessel(state); if (vessel) void this._restoreSharedVessel(vessel, current); - settle({ status: 'settled', share, layers, tracking }); + // The token tells listeners whether anyone navigated after the + // restore's flight landed (while the layers were still loading). + settle({ status: 'settled', share, layers, tracking, navigationToken }); } catch (error) { if (!current()) return; this.shareLinkManager.completeInitialRestore(state.restoreToken); diff --git a/src/ui/stores/streetLevel.js b/src/ui/stores/streetLevel.js index 9beab3d64..4fcb2e6d9 100644 --- a/src/ui/stores/streetLevel.js +++ b/src/ui/stores/streetLevel.js @@ -19,6 +19,8 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => { const expanded = shallowRef(null); /** False on a page with neither element fullscreen nor popovers. */ const expandAvailable = shallowRef(true); + /** Whether the photo is shown over the globe on an embedded page. */ + const embedOverlay = shallowRef(false); /** @type {{handlers: StreetLevelHandlers}|null} */ let owner = null; @@ -47,6 +49,11 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => { expandAvailable.value = Boolean(available); } + /** @param {boolean} on */ + function setEmbedOverlay(on) { + embedOverlay.value = Boolean(on); + } + /** * Stop forwarding actions to the owner holding `token`. The panel keeps * its last render, as the page did when its controls were wired by hand. @@ -69,10 +76,12 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => { view, expanded, expandAvailable, + embedOverlay, attach, publish, setExpanded, setExpandAvailable, + setEmbedOverlay, detach, act, describeSinceStop, diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index d680bc00f..fbb73eb15 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -11,6 +11,11 @@ const RENDER_MODE_KEY = 'gev:street-level:render-mode'; /** classes of the modes that hide every panel; an expanded photo leaves with them. */ const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode']; +/** True on an embedded page (`?embed=1` or the inline panel). */ +function isEmbedPage() { + return globalThis.document?.body?.classList?.contains('ui-embed') === true; +} + /** * Drives the Street Level panel body: provider chips, filters and viewer. * The panel view renders what this presents (`gev-street-level` store); the @@ -137,6 +142,15 @@ export class StreetLevelControls { }); } this.store.setExpandAvailable(Boolean(this._expandMode)); + // Embed mode: Esc closes an open photo, since the rail it would go back to + // is hidden. Full screen keeps its own Esc (above), which leaves it first. + this.listen(document, 'keydown', (event) => { + if (event.key !== 'Escape' || !this._embedHome || this.isViewerExpanded()) + return; + event.preventDefault(); + event.stopPropagation(); + this.layer.closeViewer?.(); + }); try { const stored = localStorage.getItem(RENDER_MODE_KEY); if (stored === 'fill' || stored === 'letterbox') @@ -271,8 +285,16 @@ export class StreetLevelControls { this._modesObserver = null; return; } + // Embed mode keeps Clean View on for its globe while the photo is lifted + // over it: there only recording and the cockpit end EXPAND. const leaveIfHiding = () => { - if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode))) + if ( + PANEL_HIDING_MODES.some( + (mode) => + body.classList.contains(mode) && + !(mode === 'ui-clean-view' && isEmbedPage()), + ) + ) this._exitExpanded(); }; if (!this._modesObserver && typeof MutationObserver === 'function') { @@ -293,6 +315,31 @@ export class StreetLevelControls { this._requestResize(); } + /** + * Embed mode hides the right rail: an open photo moves to as an + * overlay on the globe (`sl-viewer-wrap-embed`, embed.css) and goes back + * when it closes. Vue keeps patching the wrap there: its siblings are static. + */ + _syncEmbedOverlay(open) { + const wrap = this._elements.viewerWrap; + if (!wrap || !document.body) return; + const lift = open === true && isEmbedPage(); + this.store.setEmbedOverlay(lift); + if (lift && !this._embedHome) { + this._embedHome = { parent: wrap.parentNode, next: wrap.nextSibling }; + document.body.appendChild(wrap); + wrap.classList.add('sl-viewer-wrap-embed'); + this._requestResize(); + } else if (!lift && this._embedHome) { + wrap.classList.remove('sl-viewer-wrap-embed'); + const { parent, next } = this._embedHome; + this._embedHome = null; + if (parent?.isConnected) + parent.insertBefore(wrap, next?.parentNode === parent ? next : null); + this._requestResize(); + } + } + connect() { this._unsubscribe?.(); this._unsubscribe = null; @@ -348,8 +395,14 @@ export class StreetLevelControls { // Other size changes reach the viewer through the ResizeObserver; opening // asks for one resize because the element may not have a size yet. const open = view.viewer.open; - if (open && !this._wasOpen) this.setCollapsed(false, { persist: false }); + if (open && !this._wasOpen) { + this.setCollapsed(false, { persist: false }); + // A phone shows the rail only as a tab sheet: open it, or the photo + // loads where nobody sees it. Embed mode lifts the photo instead. + if (!isEmbedPage()) this.actions.revealPanel?.(); + } this._wasOpen = open; + this._syncEmbedOverlay(open); } destroy() { @@ -362,6 +415,7 @@ export class StreetLevelControls { if (this._expandMode === 'popover') this._elements.viewerWrap?.removeAttribute('popover'); this._watchPanelHidingModes(false); + this._syncEmbedOverlay(false); this.listeners.abort(); this._resizeObserver?.disconnect(); this._resizeObserver = null; diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 89b3c0964..e375b8413 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -622,6 +622,36 @@ for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode']) } })); +test('in embed mode, which keeps Clean View on for its globe, EXPAND stays; the cockpit still ends it', () => + withDom(async (dom) => { + const saved = globalThis.MutationObserver; + const watch = fakeMutationObserver(); + globalThis.MutationObserver = watch.FakeObserver; + try { + dom.document.body.classList.add('ui-embed'); + dom.document.body.classList.add('ui-clean-view'); + const { layer, controls } = await stubPanel( + dom, + uiState({ open: false }), + ); + await layer.publish(uiState({ open: true })); + dom.document.querySelector('#sl-viewer-expand').click(); + await settle(); + await settle(); + watch.fire(); + await settle(); + assert.equal(controls.isViewerExpanded(), true, 'EXPAND stays open'); + dom.document.body.classList.toggle('cockpit-mode', true); + watch.fire(); + await settle(); + await settle(); + assert.equal(controls.isViewerExpanded(), false); + controls.destroy(); + } finally { + globalThis.MutationObserver = saved; + } + })); + /* ── Restores and keyboard rules ───────────────────────────────────────── */ test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () => @@ -921,3 +951,153 @@ test('a window changed elsewhere under a focused SINCE thumb leaves its readout assert.equal(since.getAttribute('aria-valuetext'), label()); controls.destroy(); })); + +/** In embed mode, the viewer moved out of the hidden rail onto the globe. */ +const overGlobe = (dom) => + dom.wrap.parentNode === dom.document.body && + dom.wrap.classList.contains('sl-viewer-wrap-embed'); + +const escape = () => + document.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), + ); + +test('in embed mode an open photo leaves the hidden rail for an overlay on the globe, and goes back when it closes', () => + withDom(async (dom) => { + dom.document.body.classList.add('ui-embed'); + const home = dom.wrap.parentNode; + const after = dom.wrap.nextSibling; + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + assert.equal(overGlobe(dom), false, 'nothing to show yet'); + await layer.publish(uiState({ open: true })); + assert.equal(overGlobe(dom), true, 'shown over the globe'); + assert.equal(dom.wrap.hidden, false, 'the panel view still renders it'); + await layer.publish(uiState({ open: true, renderMode: 'fill' })); + assert.equal(overGlobe(dom), true, 'an update keeps it there'); + assert.equal( + dom.wrap.querySelector('[aria-checked="true"]')?.textContent.trim(), + 'FILL', + 'and the panel view keeps patching it there', + ); + await layer.publish(uiState({ open: false })); + assert.equal(overGlobe(dom), false); + assertSameNode(dom.wrap.parentNode, home, 'back in the panel'); + assertSameNode(dom.wrap.nextSibling, after, 'in its own place'); + assert.equal(dom.wrap.hidden, true); + controls.destroy(); + })); + +test('outside embed mode a photo stays in the panel', () => + withDom(async (dom) => { + const home = dom.wrap.parentNode; + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + await layer.publish(uiState({ open: true })); + assert.equal(overGlobe(dom), false); + assertSameNode(dom.wrap.parentNode, home); + controls.destroy(); + })); + +test('an opening photo brings its panel into view (a phone’s tab sheet); embed mode lifts it instead', () => + withDom(async (dom) => { + const revealed = []; + const { layer, controls } = await stubPanel(dom, uiState({ open: false }), { + revealPanel: () => revealed.push('street-level'), + }); + await layer.publish(uiState({ open: true })); + assert.deepEqual(revealed, ['street-level'], 'once, as it opens'); + await layer.publish(uiState({ open: true, renderMode: 'fill' })); + assert.equal(revealed.length, 1, 'not on every update'); + await layer.publish(uiState({ open: false })); + dom.document.body.classList.add('ui-embed'); + await layer.publish(uiState({ open: true })); + assert.equal(revealed.length, 1, 'embed mode shows it over the globe'); + controls.destroy(); + })); + +test('in embed mode Esc closes the photo, and the next photo is shown over the globe again', () => + withDom(async (dom) => { + dom.document.body.classList.add('ui-embed'); + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + let closed = 0; + layer.closeViewer = () => { + closed++; + layer.publish(uiState({ open: false })); + }; + await layer.publish(uiState({ open: true })); + escape(); + await nextTick(); + assert.equal(closed, 1, 'Esc closed the photo'); + assert.equal(overGlobe(dom), false); + await layer.publish(uiState({ open: true })); + assert.equal(overGlobe(dom), true, 'the next photo is shown'); + controls.destroy(); + })); + +test('in embed mode Esc in full screen leaves full screen and keeps the photo', () => + withDom(async (dom) => { + dom.document.body.classList.add('ui-embed'); + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + let closed = 0; + layer.closeViewer = () => closed++; + await layer.publish(uiState({ open: true })); + await dom.wrap.requestFullscreen(); + await settle(); + assert.equal(controls.isViewerExpanded(), true); + escape(); + assert.equal(closed, 0, 'the photo stays open'); + controls.destroy(); + })); + +test('outside embed mode Esc does not close a photo in the panel', () => + withDom(async (dom) => { + const { layer, controls } = await stubPanel(dom, uiState({ open: true })); + let closed = 0; + layer.closeViewer = () => closed++; + escape(); + assert.equal(closed, 0); + controls.destroy(); + })); + +test('destroying the panel in embed mode puts an open photo back in the panel', () => + withDom(async (dom) => { + dom.document.body.classList.add('ui-embed'); + const home = dom.wrap.parentNode; + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + await layer.publish(uiState({ open: true })); + controls.destroy(); + assert.equal(overGlobe(dom), false); + assertSameNode(dom.wrap.parentNode, home); + })); + +test('an embedded photo failure is shown inside the overlay, and only there', () => + withDom(async (dom) => { + dom.document.body.classList.add('ui-embed'); + const { layer, controls } = await stubPanel(dom, uiState({ open: false })); + const failed = uiState({ open: true }); + Object.assign(failed.street, { + imageId: null, + error: 'Image could not be opened', + }); + await layer.publish(failed); + assert.equal(overGlobe(dom), true); + const error = dom.wrap.querySelector('#sl-viewer-error'); + assert.ok(error, 'the overlay contains an error region'); + assert.equal(error.hidden, false); + assert.match(error.textContent, /Image could not be opened/); + assert.equal(dom.root.querySelector('#sl-error').hidden, true); + await layer.publish(uiState({ open: true })); + assert.equal(error.hidden, true, 'a successful retry clears the error'); + controls.destroy(); + })); + +test('outside embed mode a photo failure stays in the panel error region', () => + withDom(async (dom) => { + const failed = uiState({ open: true }); + failed.street.error = 'Image could not be opened'; + const { controls } = await stubPanel(dom, failed); + const error = dom.wrap.querySelector('#sl-viewer-error'); + assert.ok(error); + assert.equal(error.hidden, true, 'no duplicate error inside the photo'); + assert.equal(dom.root.querySelector('#sl-error').hidden, false); + controls.destroy(); + })); diff --git a/src/ui/styles/embed.css b/src/ui/styles/embed.css index 5528a76da..7810ad1be 100644 --- a/src/ui/styles/embed.css +++ b/src/ui/styles/embed.css @@ -13,3 +13,27 @@ body.ui-embed #world-overlay-actions, body.ui-embed #view-switcher { display: none !important; } + +/* Street-level photos: the rail that holds the panel is hidden here, so an + open photo shows over the globe (streetLevelControls moves the viewer). */ +body.ui-embed .sl-viewer-wrap-embed { + position: fixed; + inset: 12px; + z-index: 1000; +} + +/* Cockpit, recording and scene playback own the screen, as they hide the + panel elsewhere: the lifted photo steps out of their way. */ +body.ui-embed.cockpit-mode .sl-viewer-wrap-embed, +body.ui-embed.recording-mode .sl-viewer-wrap-embed, +body.ui-embed.scene-playback-mode .sl-viewer-wrap-embed { + display: none; +} + +body.ui-embed .sl-viewer-wrap-embed .sl-viewer-stage, +body.ui-embed .sl-viewer-wrap-embed .sl-viewer { + flex: 1 1 auto; + height: auto; + min-height: 0; + aspect-ratio: auto; +} diff --git a/src/ui/views/panels/StreetLevelPanel.vue b/src/ui/views/panels/StreetLevelPanel.vue index 0e3c87962..fa3c593a5 100644 --- a/src/ui/views/panels/StreetLevelPanel.vue +++ b/src/ui/views/panels/StreetLevelPanel.vue @@ -13,7 +13,7 @@ import UiSlider from '../../kit/UiSlider.js'; // Until it renders, the panel shows its initial markup. The viewer element // belongs to the layer and EXPAND to the controls. const store = useStreetLevelStore(); -const { view, expanded, expandAvailable } = storeToRefs(store); +const { view, expanded, expandAvailable, embedOverlay } = storeToRefs(store); const status = computed( () => @@ -142,6 +142,7 @@ const vThumb = { +
@@ -158,7 +159,7 @@ const vThumb = { {{ t('PROVIDERS') }}
- +
diff --git a/src/view/index.js b/src/view/index.js index 022914277..be7324fe0 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -11,6 +11,7 @@ import { decodeLayerStateParams, encodeLayerStateParams, } from '../data/layerState.js'; +import { encodeIdSwitches } from '../data/idSwitches.js'; import { RETIRED_STYLES } from '../visualStylePolicy.js'; /** Visual styles by internal name, with the name share links use. */ @@ -65,6 +66,94 @@ const FOLLOWABLE = Object.freeze({ export const VIEW_FOLLOW_KINDS = Object.freeze(Object.keys(FOLLOWABLE)); +/** + * Street-level imagery sources a view can open, the layer that shows them and + * the map a source needs (Google's terms allow Street View beside Google maps + * only). Literal here: this module stays portable. + */ +const STREET_LEVEL_LAYER = 'street-level'; +export const VIEW_STREET_LEVEL_PROVIDERS = Object.freeze([ + 'google', + 'mapillary', + 'kartaview', + 'panoramax', +]); +const STREET_LEVEL_MAP = Object.freeze({ google: 'photoreal' }); +const STREET_LEVEL_PARAM = 'sl'; +const EARTH_RADIUS_M = 6_371_000; + +/** Above this a camera frames a region, not a street: photos open under it. */ +const LOOK_AT_MAX_ALTITUDE_M = 20_000; +/** Flatter than this the look-at point is at the horizon: use the camera's. */ +const LOOK_AT_MIN_PITCH_DEG = -5; +/** Further ahead than this the camera frames a region: use its own point. */ +const LOOK_AT_MAX_DISTANCE_M = 10_000; + +/** + * The ground point a street-scale camera looks at: straight below it when it + * looks down, else ahead along its heading by altitude / tan(pitch). A camera + * with no altitude, above street scale, aimed at the horizon, or looking + * further than 10 km ahead gives its own point. Longitude wraps at the + * antimeridian. + */ +export function lookAtPoint(camera) { + const pitch = Number(camera.pitch_deg ?? -90); + const altitude = Number(camera.altitude_m); + if ( + !(altitude > 0) || + altitude > LOOK_AT_MAX_ALTITUDE_M || + !(pitch > -89) || + pitch > LOOK_AT_MIN_PITCH_DEG + ) + return { lat: camera.lat, lon: camera.lon }; + const ground = altitude / Math.tan((-pitch * Math.PI) / 180); + if (ground > LOOK_AT_MAX_DISTANCE_M) + return { lat: camera.lat, lon: camera.lon }; + const heading = ((Number(camera.heading_deg) || 0) * Math.PI) / 180; + const dLat = + ((ground * Math.cos(heading)) / EARTH_RADIUS_M) * (180 / Math.PI); + const cosLat = Math.cos((camera.lat * Math.PI) / 180) || 1e-6; + const dLon = + ((ground * Math.sin(heading)) / (EARTH_RADIUS_M * cosLat)) * + (180 / Math.PI); + const lon = camera.lon + dLon; + return { + lat: clamp(camera.lat + dLat, -90, 90), + // Near a pole one step ahead can be several turns of the parallel. + lon: ((((lon + 180) % 360) + 360) % 360) - 180, + }; +} + +/** + * Where a tilted camera looks, measured from the ground under it: a view's + * altitude is above the ellipsoid, and a city a mile up would otherwise put + * the point kilometres too far ahead. `terrain` is a heights service + * (`getHeights`); null when it is missing or cannot say. + */ +export async function groundedLookAt(camera, terrain, signal) { + if (typeof terrain?.getHeights !== 'function') return null; + // Looking down or at the horizon the point is the camera's own: the ground + // under it changes nothing, so it is not asked for. + if ( + !Number.isFinite(camera?.altitude_m) || + !(camera.pitch_deg > -89) || + camera.pitch_deg > LOOK_AT_MIN_PITCH_DEG + ) + return null; + let heights; + try { + heights = await terrain.getHeights([{ lat: camera.lat, lon: camera.lon }], { + signal, + }); + } catch (error) { + if (signal?.aborted) throw error; + return null; + } + const ground = Number(heights?.[0]?.ellipsoid); + if (!Number.isFinite(ground)) return null; + return lookAtPoint({ ...camera, altitude_m: camera.altitude_m - ground }); +} + /** Annotation kinds the app draws, as in its annotate_map action. */ export const VIEW_ANNOTATION_TYPES = Object.freeze([ 'pin', @@ -162,6 +251,21 @@ export const VIEW_PROPERTIES = Object.freeze({ additionalProperties: false, }, }), + street_level: Object.freeze({ + type: 'object', + description: + 'Open street-level photos at a point: Google Street View (google; the ' + + 'map becomes Google 3D), Mapillary, KartaView or Panoramax. lat/lon ' + + 'default to the spot the camera looks at. The app flies there and ' + + 'opens the nearest photo; a plain link with google only arms it, ' + + 'leaving the billed load to a click. Not with follow.', + properties: { + provider: { type: 'string', enum: [...VIEW_STREET_LEVEL_PROVIDERS] }, + lat: { type: 'number', minimum: -90, maximum: 90 }, + lon: { type: 'number', minimum: -180, maximum: 180 }, + }, + additionalProperties: false, + }), follow: Object.freeze({ type: 'object', description: @@ -251,6 +355,7 @@ export function createView({ map = null, follow = null, annotations = [], + street_level: streetLevel = null, } = {}) { if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon)) throw new TypeError('A view needs a camera lat and lon'); @@ -264,10 +369,38 @@ export function createView({ throw new TypeError(`Cannot follow a ${follow.kind}`); if (follow?.cockpit === true && !followed.cockpit) throw new TypeError(`A ${follow.kind} has no cockpit view`); + let street = null; + if (streetLevel) { + if (follow) + throw new TypeError( + 'A view cannot both follow an entity and open street-level photos', + ); + const provider = streetLevel.provider ?? null; + if (provider !== null && !VIEW_STREET_LEVEL_PROVIDERS.includes(provider)) + throw new TypeError(`Unknown street-level source: ${provider}`); + const needs = provider ? STREET_LEVEL_MAP[provider] || null : null; + if (needs && map !== null && map !== needs) + throw new TypeError( + `${provider} street-level imagery shows only on the ${needs} map`, + ); + if (needs) map = needs; + // The point defaults to where the camera looks: a view "of X" opens + // photos at X, also when the camera sits behind it. It is a pair: half + // of one is not mixed with the camera's other half. + const given = [latitude(streetLevel.lat), longitude(streetLevel.lon)]; + const seen = lookAtPoint(camera); + const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given; + street = Object.freeze({ + ...(provider ? { provider } : {}), + lat: round(clamp(lat, -90, 90), 5), + lon: round(clamp(lon, -180, 180), 5), + }); + } const enabled = new Set( layers.filter((layer) => REGISTERED_LAYER_IDS.includes(layer)), ); if (followed) enabled.add(followed.layer); + if (street) enabled.add(STREET_LEVEL_LAYER); return Object.freeze({ camera: Object.freeze({ lat: round(clamp(camera.lat, -90, 90), 4), @@ -296,6 +429,7 @@ export function createView({ .map(annotationOf) .filter(Boolean), ), + street_level: street, }); } @@ -321,8 +455,29 @@ export function viewToParams(view) { ...state.options[followed.owner], [followed.key]: view.follow.id, }; + // The restored layer lights the source the photos come from. + const provider = view.street_level?.provider; + if (provider) + state.options[STREET_LEVEL_LAYER] = { + ...state.options[STREET_LEVEL_LAYER], + mapillary: provider === 'mapillary', + providerSwitches: encodeIdSwitches( + VIEW_STREET_LEVEL_PROVIDERS.filter((id) => id !== 'mapillary').map( + (id) => [id, id === provider], + ), + ), + }; encodeLayerStateParams(params, state); } + if (view.street_level) + params.set( + STREET_LEVEL_PARAM, + [ + view.street_level.provider ?? '', + view.street_level.lat.toFixed(5), + view.street_level.lon.toFixed(5), + ].join(','), + ); if (view.annotations.length) { const encoded = JSON.stringify(view.annotations); if (encoded.length <= MAX_ANNOTATION_PARAM_CHARS) @@ -345,6 +500,40 @@ export function annotationsFromParams(params) { } } +/** + * The street-level photos a share link opens (`sl=,,`, the + * source optional), or null. + */ +export function streetLevelFromParams(params) { + const raw = params.get(STREET_LEVEL_PARAM); + if (!raw) return null; + // Three fields, plain decimal degrees: that alone bounds the value. + const parts = raw.split(','); + if (parts.length !== 3) return null; + const [provider, lat, lon] = parts; + if (provider && !VIEW_STREET_LEVEL_PROVIDERS.includes(provider)) return null; + // Plain decimal degrees only: no exponents, hex or blanks. + const DECIMAL = /^-?\d{1,3}(\.\d{1,8})?$/; + if (!DECIMAL.test(lat) || !DECIMAL.test(lon)) return null; + const point = { lat: latitude(Number(lat)), lon: longitude(Number(lon)) }; + if (point.lat === undefined || point.lon === undefined) return null; + return { ...(provider ? { provider } : {}), ...point }; +} + +/** The entity a decoded layer state follows, or null. */ +function followFromState(state) { + for (const [kind, followed] of Object.entries(FOLLOWABLE)) { + const id = state?.options?.[followed.owner]?.[followed.key]; + if (id !== null && id !== undefined) return { kind, id: String(id) }; + } + return null; +} + +/** The entity a share link follows, or null. */ +export function followFromParams(params) { + return followFromState(decodeLayerStateParams(params)); +} + /** The view a set of share-link hash parameters restores, or null. */ export function viewFromParams(params) { const lat = Number.parseFloat(params.get('lat')); @@ -355,13 +544,12 @@ export function viewFromParams(params) { return Number.isFinite(value) ? value : undefined; }; const state = decodeLayerStateParams(params); - let follow = null; - for (const [kind, followed] of Object.entries(FOLLOWABLE)) { - const id = state?.options?.[followed.owner]?.[followed.key]; - if (id !== null && id !== undefined && !follow) - follow = { kind, id: String(id) }; - } + const follow = followFromState(state); const map = params.get('map'); + // A link is untrusted input: its photos win over a map their source cannot + // show on, and a followed entity keeps the camera. + const street = follow ? null : streetLevelFromParams(params); + const needs = street?.provider ? STREET_LEVEL_MAP[street.provider] : null; return createView({ camera: { lat, @@ -376,9 +564,10 @@ export function viewFromParams(params) { : Object.hasOwn(STYLES_BY_URL_NAME, params.get('style')) ? STYLES_BY_URL_NAME[params.get('style')] : null, - map: VIEW_MAPS.includes(map) ? map : null, + map: needs ? needs : VIEW_MAPS.includes(map) ? map : null, follow, annotations: annotationsFromParams(params), + street_level: street, }); } diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 66630e6a0..9fda7d149 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -127,6 +127,389 @@ test('annotations travel in the link, keeping only what the app draws', async () assert.equal(viewToParams(many).has('an'), false); }); +test('street-level photos travel in the link, light their source and bring their map', async () => { + const { streetLevelFromParams, VIEW_STREET_LEVEL_PROVIDERS } = + await import('./index.js'); + const { decodeLayerStateParams } = await import('../data/layerState.js'); + assert.deepEqual(VIEW_STREET_LEVEL_PROVIDERS, [ + 'google', + 'mapillary', + 'kartaview', + 'panoramax', + ]); + const view = createView({ + camera: { lat: 38.5799, lon: -121.4937, altitude_m: 400 }, + street_level: { provider: 'google' }, + }); + assert.equal(view.map, 'photoreal', 'Street View brings Google 3D'); + assert.deepEqual(view.layers, ['street-level']); + assert.deepEqual(view.street_level, { + provider: 'google', + lat: 38.5799, + lon: -121.4937, + }); + const params = viewToParams(view); + assert.equal(params.get('sl'), 'google,38.57990,-121.49370'); + assert.deepEqual(viewFromParams(params), view); + const state = decodeLayerStateParams(params); + assert.deepEqual(state.enabledLayerIds, ['street-level']); + assert.equal(state.options['street-level'].mapillary, false); + assert.equal( + state.options['street-level'].providerSwitches, + 'google-1*kartaview-0*panoramax-0', + 'Street View on, KartaView off', + ); + + const mapillary = createView({ + camera: { lat: 1, lon: 2 }, + map: 'osm', + street_level: { provider: 'mapillary', lat: 1.00001, lon: 2.00001 }, + }); + assert.equal(mapillary.map, 'osm', 'Mapillary shows on any map'); + assert.deepEqual(viewFromParams(viewToParams(mapillary)), mapillary); + const lit = decodeLayerStateParams(viewToParams(mapillary)).options[ + 'street-level' + ]; + assert.equal(lit.mapillary, true); + assert.equal( + lit.providerSwitches, + 'google-0*kartaview-0*panoramax-0', + 'a Mapillary link lights Mapillary only', + ); + const anySource = createView({ + camera: { lat: 1, lon: 2 }, + street_level: {}, + }); + assert.deepEqual(anySource.street_level, { lat: 1, lon: 2 }); + assert.equal(viewToParams(anySource).get('sl'), ',1.00000,2.00000'); + assert.deepEqual(viewFromParams(viewToParams(anySource)), anySource); + + assert.throws( + () => + createView({ + camera: { lat: 1, lon: 2 }, + map: 'osm', + street_level: { provider: 'google' }, + }), + /photoreal/, + ); + assert.throws( + () => + createView({ + camera: { lat: 1, lon: 2 }, + street_level: { provider: 'openstreetcam' }, + }), + /Unknown street-level source/, + ); + assert.equal( + streetLevelFromParams(new URLSearchParams('sl=google,x,y')), + null, + ); + assert.equal(streetLevelFromParams(new URLSearchParams('sl=bing,1,2')), null); + assert.equal(streetLevelFromParams(new URLSearchParams('lat=1')), null); + assert.equal(createView({ camera: { lat: 1, lon: 2 } }).street_level, null); + // Half a point is not mixed with the camera's other half. + const half = createView({ + camera: { lat: 1, lon: 2 }, + street_level: { provider: 'mapillary', lat: 40 }, + }); + assert.deepEqual(half.street_level, { + provider: 'mapillary', + lat: 1, + lon: 2, + }); +}); + +test('street-level photos: a tilted camera opens them where it looks, a link cannot pair them with the wrong map, and follow is refused', async () => { + const { streetLevelFromParams } = await import('./index.js'); + // Looking north from 1,000 m at 45°: the ground point is ~1 km north. + const tilted = createView({ + camera: { + lat: 48.8494, + lon: 2.2945, + altitude_m: 1000, + heading_deg: 0, + pitch_deg: -45, + }, + street_level: { provider: 'google' }, + }); + assert.ok( + tilted.street_level.lat > 48.858 && tilted.street_level.lat < 48.859, + String(tilted.street_level.lat), + ); + assert.equal(tilted.street_level.lon, 2.2945); + const down = createView({ + camera: { lat: 1, lon: 2, altitude_m: 1000 }, + street_level: {}, + }); + assert.deepEqual(down.street_level, { lat: 1, lon: 2 }); + // A hand-edited link: the photos win over the map. + const link = viewFromParams( + new URLSearchParams('v=2&lat=1&lon=2&map=osm&sl=google,1,2'), + ); + assert.equal(link.map, 'photoreal'); + assert.deepEqual(link.street_level, { provider: 'google', lat: 1, lon: 2 }); + assert.throws( + () => + createView({ + camera: { lat: 1, lon: 2 }, + follow: { kind: 'aircraft', id: 'a1' }, + street_level: {}, + }), + /cannot both follow/, + ); + // A followed link drops its photos rather than fight over the camera. + const followed = viewFromParams( + viewToParams( + createView({ + camera: { lat: 1, lon: 2 }, + follow: { kind: 'satellite', id: '25544' }, + }), + ), + ); + assert.equal(followed.street_level, null); + // Strict parsing: three fields, real numbers, in range, bounded length. + for (const bad of [ + 'google,1', + 'google,1,2,3', + 'google,1abc,2', + 'google,91,0', + 'google,,2', + 'google,1,2'.padEnd(65, '0'), + ]) + assert.equal( + streetLevelFromParams(new URLSearchParams({ sl: bad })), + null, + bad, + ); +}); + +test('the look-at default is street-scale only, follows the heading, and wraps at the antimeridian', async () => { + const { followFromParams, streetLevelFromParams } = + await import('./index.js'); + const at = (camera) => + createView({ camera, street_level: { provider: 'mapillary' } }) + .street_level; + // Looking east from 1,000 m at 45° in Paris: ~1 km east is ~0.0137° of longitude. + const east = at({ + lat: 48.85, + lon: 2.2945, + altitude_m: 1000, + heading_deg: 90, + pitch_deg: -45, + }); + assert.ok(Math.abs(east.lon - (2.2945 + 0.01365)) < 0.0005, String(east.lon)); + assert.ok(Math.abs(east.lat - 48.85) < 0.0002, String(east.lat)); + // Looking west from the same place lands west. + assert.ok( + at({ + lat: 48.85, + lon: 2.2945, + altitude_m: 1000, + heading_deg: 270, + pitch_deg: -45, + }).lon < 2.2945, + ); + // At the horizon, with no altitude, or above street scale: the camera's own point. + assert.deepEqual(at({ lat: 1, lon: 2, altitude_m: 1000, pitch_deg: 0 }), { + provider: 'mapillary', + lat: 1, + lon: 2, + }); + assert.deepEqual(at({ lat: 1, lon: 2, pitch_deg: -30 }), { + provider: 'mapillary', + lat: 1, + lon: 2, + }); + assert.deepEqual(at({ lat: 1, lon: 2, altitude_m: 50_000, pitch_deg: -30 }), { + provider: 'mapillary', + lat: 1, + lon: 2, + }); + // Across the antimeridian the longitude wraps instead of clamping. + const wrapped = at({ + lat: 0, + lon: 179.999, + altitude_m: 1000, + heading_deg: 90, + pitch_deg: -45, + }); + assert.ok(wrapped.lon < -179.9, String(wrapped.lon)); + + // A followed link keeps its camera: the photos are dropped even when both are in the hash. + const followed = viewToParams( + createView({ + camera: { lat: 1, lon: 2 }, + follow: { kind: 'satellite', id: '25544' }, + }), + ); + followed.set('sl', 'google,1.00000,2.00000'); + assert.deepEqual(followFromParams(followed), { + kind: 'satellite', + id: '25544', + }); + assert.equal(viewFromParams(followed).street_level, null); + assert.equal(followFromParams(new URLSearchParams('v=2&lat=1&lon=2')), null); + + // Strict decimal degrees: no exponents, hex, blanks or over-long values. + for (const bad of [ + 'google,1e1,2', + 'google,0x10,2', + 'google, 1,2', + 'google,1.,2', + `google,${'1.0000000'.padEnd(60, '0')},2`, + ]) + assert.equal( + streetLevelFromParams(new URLSearchParams({ sl: bad })), + null, + bad, + ); + assert.deepEqual( + streetLevelFromParams(new URLSearchParams({ sl: ',-1.5,2' })), + { lat: -1.5, lon: 2 }, + ); +}); + +test('the look-at thresholds are pinned: 20 km up, -5° of pitch, 10 km ahead', () => { + const at = (camera) => createView({ camera, street_level: {} }).street_level; + const own = { lat: 1, lon: 2 }; + const moved = (point) => point.lat !== own.lat || point.lon !== own.lon; + // Altitude ceiling, at a steep pitch that keeps the distance under 10 km. + assert.ok( + moved(at({ ...own, altitude_m: 19_999, pitch_deg: -70 })), + '19,999 m applies', + ); + assert.ok( + !moved(at({ ...own, altitude_m: 20_001, pitch_deg: -70 })), + '20,001 m does not', + ); + // Pitch cutoff, at 100 m so the distance cap cannot decide it (1.4 km ahead). + assert.ok( + moved(at({ ...own, altitude_m: 100, pitch_deg: -6 })), + '-6° applies', + ); + assert.ok( + !moved(at({ ...own, altitude_m: 100, pitch_deg: -4 })), + '-4° does not', + ); + // Distance cap, pinned either side of 10 km at 1,000 m: -5.8° looks 9.84 km + // ahead, -5.6° 10.2 km. + assert.ok( + moved(at({ ...own, altitude_m: 1000, pitch_deg: -5.8 })), + '9.84 km ahead applies', + ); + assert.ok( + !moved(at({ ...own, altitude_m: 1000, pitch_deg: -5.6 })), + '10.2 km ahead does not', + ); + assert.ok( + !moved(at({ ...own, altitude_m: 10_000, pitch_deg: -30 })), + '17 km ahead does not', + ); + assert.ok( + moved(at({ ...own, altitude_m: 5_000, pitch_deg: -30 })), + '8.7 km ahead applies', + ); + // Near-vertical stays put (no tan blow-up). + assert.ok(!moved(at({ ...own, altitude_m: 1000, pitch_deg: -89.5 }))); + // Western wrap and the pole clamp. + const west = at({ + lat: 0, + lon: -179.999, + altitude_m: 1000, + heading_deg: 270, + pitch_deg: -45, + }); + assert.ok(west.lon > 179.9, String(west.lon)); + const pole = at({ + lat: 89.9995, + lon: 0, + altitude_m: 1000, + heading_deg: 0, + pitch_deg: -45, + }); + assert.ok(pole.lat <= 90, String(pole.lat)); +}); + +test('sl rejects a bad longitude slot and over-precise values, and no length bound hides the regex', async () => { + const { streetLevelFromParams } = await import('./index.js'); + for (const bad of [ + 'google,1,1e1', + 'google,1,0x10', + 'google,1, 2', + 'google,1,2.', + 'google,1.123456789,2', + 'google,1,2.123456789', + ]) + assert.equal( + streetLevelFromParams(new URLSearchParams({ sl: bad })), + null, + bad, + ); + assert.deepEqual( + streetLevelFromParams( + new URLSearchParams({ sl: 'google,-89.12345678,179.12345678' }), + ), + { provider: 'google', lat: -89.12345678, lon: 179.12345678 }, + ); +}); + +test('KartaView photos travel in a link and light KartaView alone, on any map', async () => { + const { streetLevelFromParams } = await import('./index.js'); + const { decodeLayerStateParams } = await import('../data/layerState.js'); + const view = createView({ + camera: { lat: 52.52, lon: 13.405, altitude_m: 400 }, + map: 'osm', + street_level: { provider: 'kartaview' }, + }); + assert.equal(view.map, 'osm', 'KartaView needs no particular map'); + assert.deepEqual(view.layers, ['street-level']); + const params = viewToParams(view); + assert.equal(params.get('sl'), 'kartaview,52.52000,13.40500'); + assert.deepEqual(viewFromParams(params), view); + const lit = decodeLayerStateParams(params).options['street-level']; + assert.equal(lit.mapillary, false); + assert.equal( + lit.providerSwitches, + 'google-0*kartaview-1*panoramax-0', + 'KartaView on, the others off', + ); + assert.deepEqual( + streetLevelFromParams(new URLSearchParams({ sl: 'kartaview,1,2' })), + { + provider: 'kartaview', + lat: 1, + lon: 2, + }, + ); +}); + +test('Panoramax photos travel in a link and light Panoramax alone, on any map', async () => { + const { streetLevelFromParams } = await import('./index.js'); + const { decodeLayerStateParams } = await import('../data/layerState.js'); + const view = createView({ + camera: { lat: 48.8874, lon: 2.3495, altitude_m: 400 }, + map: 'osm', + street_level: { provider: 'panoramax' }, + }); + assert.equal(view.map, 'osm', 'Panoramax needs no particular map'); + assert.deepEqual(view.layers, ['street-level']); + const params = viewToParams(view); + assert.equal(params.get('sl'), 'panoramax,48.88740,2.34950'); + assert.deepEqual(viewFromParams(params), view); + const lit = decodeLayerStateParams(params).options['street-level']; + assert.equal(lit.mapillary, false); + assert.equal( + lit.providerSwitches, + 'google-0*kartaview-0*panoramax-1', + 'Panoramax on, the others off', + ); + assert.deepEqual( + streetLevelFromParams(new URLSearchParams({ sl: 'panoramax,1,2' })), + { provider: 'panoramax', lat: 1, lon: 2 }, + ); +}); + test('a view naming the retired Snow style opens on Normal', () => { const camera = { lat: 1, lon: 2 }; assert.equal(createView({ camera, style: 'snow' }).style, 'normal'); diff --git a/src/voice/actionSchemas.js b/src/voice/actionSchemas.js index ef122985f..03ae69f8a 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -107,6 +107,36 @@ const schemas = [ required: ['layerId'], }, }, + { + name: 'show_street_level', + parameters: { + type: 'object', + additionalProperties: false, + properties: { + query: { + type: 'string', + maxLength: 160, + }, + latitude: { + type: 'number', + minimum: -90, + maximum: 90, + }, + longitude: { + type: 'number', + minimum: -180, + maximum: 180, + }, + provider: { + type: 'string', + enum: ['google', 'mapillary', 'kartaview', 'panoramax'], + }, + referent: { + type: 'integer', + }, + }, + }, + }, { name: 'adjust_camera_zoom', parameters: { diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index 296ae0251..c2f084d06 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -58,7 +58,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // control_cockpit states the own-layer entry rule (Contacts not required). // move_camera tilt takes an optional pitchDeg angle, described as // absolute with its -45 default, with steps sent as a direction. - '6a0d154e68beb787c4e20f12d29a5c86577dc343f3e71d8b5651998ea45d8c3c', + // show_street_level (map, source, photo) adds one tool. + '0c27f62062001e6a07241cd301c3d357f55e9b74f66cd2904d16590510337468', ); }); @@ -155,6 +156,7 @@ test('all legacy action arguments are byte-identical after removing the delibera 'resolve_area', 'find_imagery', 'osm_query', + 'show_street_level', ].includes(tool.name), ); // Layer enums are generated from the voice layer manifest and pinned by diff --git a/src/voice/deixis.js b/src/voice/deixis.js index 70d1120ab..d224eb661 100644 --- a/src/voice/deixis.js +++ b/src/voice/deixis.js @@ -143,6 +143,8 @@ function resolveFly(args, { pointer, referents }) { entity: { layerId: target.layerId, id: target.id, + // The answer names it, not its id ("near cam-9"). + ...(target.label ? { label: target.label } : {}), ...(hasPosition(target) ? { lat: target.lat, lon: target.lon } : {}), }, }, @@ -345,6 +347,7 @@ function resolveCropPixels(args, { imageFrame, cameraKey }) { const RESOLVERS = Object.freeze({ track_entity: resolveTrack, fly_to_location: resolveFly, + show_street_level: resolveFly, get_entity_context: resolveContext, analyst_query: resolveAnalyst, annotate_map: resolveAnnotate, diff --git a/src/voice/deixis.test.mjs b/src/voice/deixis.test.mjs index bd2e66f1b..20ae0530f 100644 --- a/src/voice/deixis.test.mjs +++ b/src/voice/deixis.test.mjs @@ -104,7 +104,7 @@ test('referents: numbered items resolve for track, fly and context', () => { const last = resolveDeicticArgs('track_entity', { query: 'x', referent: -1 }, { referents }); assert.equal(last.args.query, 'ccc333', '-1 is the last one'); const fly = resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents }); - assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111' } }, 'moving contacts are located at run time'); + assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111', label: 'SWA12' } }, 'moving contacts are located at run time'); const context = resolveDeicticArgs('get_entity_context', { referent: 3 }, { referents }); assert.equal(context.args.scope, 'target'); assert.equal(context.args.target.label, 'AAL9'); @@ -180,3 +180,10 @@ test('numbered custom-labeled pins retain their resolved coordinates', () => { assert.deepEqual(resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents }).args, { latitude: 30.27, longitude: -97.74 }); }); + +test('show_street_level: "street view of this" opens photos at the pointed ground point', () => { + const out = resolveDeicticArgs('show_street_level', { query: 'pointer', provider: 'google' }, { pointer: ground }); + assert.deepEqual(out.args, { provider: 'google', latitude: 30.27, longitude: -97.74 }); + assert.equal(out.used.source, 'pointer'); + assert.deepEqual(resolveDeicticArgs('show_street_level', { query: 'Eiffel Tower' }, { pointer: ground }).args, { query: 'Eiffel Tower' }); +}); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index fdb5dc90c..9a38408b2 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -15,6 +15,7 @@ import { applicationPinia } from '../ui/vueRuntime.js'; import * as Cesium from 'cesium'; import { CITY_POIS } from '../cityPois.js'; import { greatCircleKm } from '../geoDistance.js'; +import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js'; import { hudViewportSamples, placeLookupView, @@ -35,6 +36,10 @@ import { isContextRecordActive, } from '../data/contextStore.js'; import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js'; +import { + NEAREST_RADIUS_MAX_M as STREET_LEVEL_WIDE_RADIUS_M, + STREET_LEVEL_LAYER_ID, +} from '../layers/streetLevel/policy.js'; import { createAnalystEngine, normalizeAnalystSpec, @@ -55,6 +60,7 @@ import { import * as defaultFloorServices from '../data/groundFloor.js'; import { isPickedWorldPosition } from '../data/scenePick.js'; import { unavailablePlaceSearch } from '../search/placeSearch.js'; +import { watchCameraOwner } from '../navigationPolicy.js'; import * as defaultAnnotationResolver from '../annotations/annotationResolver.js'; import { normalizeRadioCountryInput } from '../data/radioCountry.js'; import { TR3B_CLASS } from '../data/tr3bRegistry.js'; @@ -503,7 +509,7 @@ export function createGevActionRunner({ const pending = dataManager.getAll?.().find((l) => l.id === layerId); reportProgress('layer', pending?.name || layerId); } - const changeOptions = { origin: 'voice' }; + const changeOptions = { origin: actionIntentOrigin(runOptions) }; if (runOptions.signal) changeOptions.signal = runOptions.signal; let changed = false; let changeError = null; @@ -796,6 +802,20 @@ export function createGevActionRunner({ }; } + if (name === 'show_street_level') { + return showStreetLevel(args, { + viewer, + dataManager, + styleManager, + placeSearch, + runGevAction, + runOptions, + current, + reportProgress, + otherHandoffs, + }); + } + if (name === 'set_visual_style') { const style = normalizeStyle(args.style); if (!style) @@ -2936,6 +2956,29 @@ function stopAllTracking(viewer, dataManager) { return { ok: true, action: 'stop_tracking', released }; } +/** + * The imagery filter the lit source honours when it is narrower than the + * default (360° or flat only, or SINCE), or null. + * @returns {{ pano?: string, sinceDays?: number, label: string } | null} + */ +export function activeImageryFilter(ui) { + const filter = ui?.filter || {}; + const controls = ui?.filterControls || {}; + const out = {}; + const names = []; + if (controls.pano && (filter.pano === 'pano' || filter.pano === 'flat')) { + out.pano = filter.pano; + names.push(filter.pano === 'pano' ? '360°' : 'flat-photo'); + } + if (controls.since && Number(filter.sinceDays) > 0) { + out.sinceDays = Number(filter.sinceDays); + names.push('SINCE'); + } + if (!names.length) return null; + out.label = `${names.join(' and ')} filter${names.length > 1 ? 's' : ''}`; + return out; +} + /** * Frames entities near the current view target with a cinematic pull-back: * oblique high pitch for aircraft/ships, shallow wide pitch for satellites. @@ -3709,6 +3752,425 @@ function normalizeStyle(value) { return null; } +/** The application panel that holds the street-level photo. */ +const STREET_LEVEL_PANEL_ID = 'street-level-panel'; + +/** + * The layer-state origin of an action's changes. The assistant's are the + * user's own choices and persist; a link replays someone else's view + * (`origin: 'programmatic'`) and never writes the recipient's preferences. + */ +function actionIntentOrigin(runOptions) { + return runOptions?.origin === 'programmatic' ? 'programmatic' : 'voice'; +} + +/** + * Whether cockpit, clean view, recording or scene playback hides the panel a + * street-level photo opens in. Embed mode uses clean view for its globe but + * lifts the photo out of the hidden rail. + */ +function streetLevelPanelHidden() { + const body = globalThis.document?.body; + const embedded = body?.classList?.contains('ui-embed') === true; + return EXCLUSIVE_SURFACE_CLASSES.some( + (mode) => + body?.classList?.contains(mode) && + !(embedded && mode === 'ui-clean-view'), + ); +} + +/** + * "Show me on Street View / on Mapillary": fly there, light the source + * (and its map) and open the nearest image. Each stage reports its own + * failure, so speech can say which part did not happen. `autoOpen: false` + * (links) stops short of a billed load: the panel is armed for OPEN. + */ +export async function showStreetLevel(args, context) { + // After any await a newer turn, a navigation, a globe gesture or a mode + // that hides the panel may have taken over: each later stage checks again. + const owner = watchCameraOwner(context.viewer, context.styleManager); + // A photo held up while the request switches its source is let go however + // the request ends: replaced by then, or closed if its source stayed off. + let releaseViewer = null; + const holdViewer = (layerModule) => { + releaseViewer ??= layerModule.holdViewer?.() ?? null; + }; + try { + return await requestStreetLevel(args, { ...context, owner, holdViewer }); + } finally { + releaseViewer?.(); + owner.stop(); + } +} + +async function requestStreetLevel( + args, + { + dataManager, + styleManager, + runGevAction, + owner, + holdViewer = () => {}, + runOptions = {}, + current = () => true, + reportProgress = () => {}, + otherHandoffs = () => 0, + }, +) { + const action = 'show_street_level'; + // Navigations other than voice fly-tos (this request's own flight) so far. + const handoffs = otherHandoffs(); + let provider = null; + let providerName = null; + let label = null; + const fail = (stage, error, extra = {}) => ({ + ok: false, + action, + stage, + cancelled: !current(), + error, + ...(provider ? { provider } : {}), + ...(providerName ? { providerName } : {}), + ...(label ? { label } : {}), + ...(atViewCentre ? { atViewCentre } : {}), + ...extra, + }); + let atViewCentre = false; + const query = String(args.query || '').trim(); + const latitude = Number(args.latitude); + const longitude = Number(args.longitude); + const coordinatesGiven = args.latitude != null && args.longitude != null; + const hasCoordinates = + coordinatesGiven && Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180; + if (coordinatesGiven && !hasCoordinates) + return fail( + 'location', + 'Latitude must be within ±90° and longitude within ±180°', + ); + // A referent that is a layer entity (deixis): located when the flight starts. + const entity = + args.entity && typeof args.entity === 'object' && args.entity.id + ? args.entity + : null; + const layerModule = + dataManager?.layers?.get?.(STREET_LEVEL_LAYER_ID)?.module || null; + if (!layerModule || typeof layerModule.openNearest !== 'function') + return fail('provider', 'Street Level is unavailable'); + const providerIds = Array.isArray(layerModule.providerIds) + ? layerModule.providerIds + : []; + + // The source: asked for, else the one lit now, else the first registered. + const requested = String(args.provider || '') + .trim() + .toLowerCase(); + if (requested) { + if (!providerIds.includes(requested)) + return fail( + 'provider', + `${requested} is not a street-level imagery source here`, + ); + provider = requested; + } else { + const params = dataManager.getLayerParams?.(STREET_LEVEL_LAYER_ID) || {}; + provider = + providerIds.find((id) => params[id] === true) || providerIds[0] || null; + } + if (!provider) + return fail('provider', 'No street-level imagery source is configured'); + const status = layerModule.providerStatus?.(provider) || null; + providerName = status?.name || provider; + + // The key first: without one nothing changes, as with a chip. + if (status && (status.keyRequired === true || status.configured === false)) + return fail('key', status.error || `${providerName} needs its key`, { + keyRejected: status.keyRejected === true, + keyAlternatives: layerModule.getUIState?.()?.keyAlternatives || [], + }); + + // A hidden panel refuses before the camera leaves a recording or scene. + const hidden = (extra = {}) => + fail( + 'view', + msg( + 'Street-level photos are hidden in the current view. Leave it first.', + ), + extra, + ); + if (streetLevelPanelHidden()) return hidden(); + const interrupted = (stage, extra = {}) => { + if (!current()) + return fail(stage, 'Street-level request was cancelled', { + cancelled: true, + location, + ...extra, + }); + if (owner.taken()) + return fail(stage, 'The camera moved on before the photo opened', { + cancelled: true, + superseded: true, + location, + ...extra, + }); + if (streetLevelPanelHidden()) return hidden({ location, ...extra }); + return null; + }; + + // Then the place: a name that geocodes to nothing changes nothing else. + label = + query || + (entity ? String(entity.label || entity.id) : '') || + (hasCoordinates ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : '') || + 'the view centre'; + atViewCentre = !query && !hasCoordinates && !entity; + let point = null; + let location = null; + // No place at all: the layer looks at the ground under the screen centre, + // as the OPEN button does. `fly: false` (tests) keeps the camera for a + // point given by its coordinates. + if (atViewCentre || (args.fly === false && hasCoordinates)) { + if (hasCoordinates) point = { lat: latitude, lon: longitude }; + } else { + try { + location = await runGevAction( + 'fly_to_location', + { + ...(entity + ? { entity } + : hasCoordinates + ? { latitude, longitude } + : { query }), + viewMode: 'close', + waitForArrival: true, + }, + runOptions, + ); + } catch (error) { + // A geocoder that throws (offline, a provider down) is this stage's + // failure too, not an error past the action. + return fail('location', `Could not arrive at ${label}`, { + cancelled: !current(), + detail: String(error?.message || error), + }); + } + if (location?.ok !== true || !current()) + return fail( + 'location', + location?.error || `Could not arrive at ${location?.label || label}`, + { cancelled: !current() || Boolean(location?.cancelled), location }, + ); + // An item, or a named place flown to by its coordinates, keeps its name: + // such a flight reports only coordinates. + label = + entity || (query && hasCoordinates) ? label : location.label || label; + if ( + !Number.isFinite(location.latitude) || + !Number.isFinite(location.longitude) + ) + return fail( + 'location', + `${label} has no position to look for imagery at`, + { location }, + ); + point = { lat: location.latitude, lon: location.longitude }; + } + + // A mode that hides the panel may have started during the flight. The + // camera is the request's from where its flight landed, unless another + // navigation took it during the flight. + if (location && otherHandoffs() === handoffs) owner.claim(); + const arrived = interrupted('view'); + if (arrived) return arrived; + + // The source's map, when it needs one (Street View: Google 3D only). + const requiredStack = layerModule.requiredMapStack?.(provider) || null; + const activeStack = styleManager?.mapStackController?.getActiveId?.() ?? null; + if (requiredStack && activeStack !== requiredStack) { + reportProgress('map', requiredStack); + const map = await runGevAction( + 'set_map_stack', + { stack: requiredStack }, + runOptions, + ); + if (map?.ok !== true && current()) + return fail( + 'map', + map?.error || + `${providerName} needs the ${requiredStack} map, which is not available`, + { map, location }, + ); + const stop = interrupted('map', { map }); + if (stop) return stop; + } + + // Light the source the way a chip click does, so the choice is kept. An + // open photo of another source stays up until this request replaces it. + const openPhoto = layerModule.getUIState?.()?.street; + if (openPhoto?.open === true && openPhoto.providerId !== provider) + holdViewer(layerModule); + const switches = Object.fromEntries( + providerIds.map((id) => [id, id === provider]), + ); + let accepted = true; + if (typeof dataManager.setLayerParams === 'function') + accepted = + dataManager.setLayerParams(STREET_LEVEL_LAYER_ID, switches, { + origin: actionIntentOrigin(runOptions), + }) !== false; + else layerModule.setProviderEnabled?.(provider, true); + if (!accepted) + return fail('provider', `${providerName} could not be switched on`, { + location, + }); + let layer = null; + if (!dataManager.isEnabled?.(STREET_LEVEL_LAYER_ID)) { + layer = await runGevAction( + 'set_layer_visibility', + { layerId: STREET_LEVEL_LAYER_ID, enabled: true }, + runOptions, + ); + if (layer?.ok !== true && current()) + return fail( + 'layer', + layer?.error || 'Street Level could not be enabled', + { cancelled: Boolean(layer?.cancelled), layer, location }, + ); + const stop = interrupted('layer', { layer }); + if (stop) return stop; + } + + // Listed now that its map is up, and lit. + const ui = layerModule.getUIState?.() || {}; + const snapshot = + (ui.providers || []).find((entry) => entry.id === provider) || null; + if (!snapshot) + return fail( + 'provider', + ui.coverage?.hint || `${providerName} is not available on this map`, + { location }, + ); + if (snapshot.on !== true) + return fail('provider', `${providerName} could not be switched on`, { + location, + }); + + // A link never loads a billed photo by itself: it arms OPEN instead. + if (args.autoOpen === false && status?.billed === true) { + // A photo of another place must not stay up beside the armed request. + if (layerModule.getUIState?.()?.street?.open === true) + layerModule.closeViewer?.(); + layerModule.armNearest?.(point, { + providerIds: [provider], + ...(snapshot.groundClick === true + ? { radiusM: STREET_LEVEL_WIDE_RADIUS_M } + : {}), + }); + return { + ok: true, + action, + provider, + providerName, + label, + armed: true, + flew: location !== null, + ...(location ? { location: location.label } : {}), + }; + } + + // Street View has no coverage to click, so it searches wider: a geocoded + // landmark is rarely within 50 m of a street. An open photo stays up while + // this looks and closes if nothing opens; a found photo loads only while + // the camera and the panel are still the request's. + reportProgress('imagery', label); + const opened = await layerModule.openNearest(point, { + providerIds: [provider], + frame: false, + replace: true, + proceed: () => current() && !owner.taken() && !streetLevelPanelHidden(), + // Under the screen centre, not the middle of a tilted view's footprint; + // this request is the user's own, not a link's armed one. + ...(point ? {} : { aim: 'screen-centre', armed: false }), + ...(runOptions.signal ? { signal: runOptions.signal } : {}), + ...(snapshot.groundClick === true + ? { radiusM: STREET_LEVEL_WIDE_RADIUS_M } + : {}), + }); + const street = layerModule.getUIState?.()?.street || {}; + // A photo that opened after the camera moved on, or into a panel hidden + // meanwhile, is closed again. + const late = interrupted('imagery'); + if (late) { + if (opened === true) layerModule.closeViewer?.(); + return late; + } + if (opened !== true) { + // Why nothing opened: the key, a change under the lookup, or no imagery. + const after = layerModule.providerStatus?.(provider) || status || {}; + if (after.keyRequired === true || after.keyRejected === true) + return fail( + 'key', + street.error || after.error || `${providerName} needs its key`, + { keyRejected: after.keyRejected === true, location }, + ); + if ( + !dataManager.isEnabled?.(STREET_LEVEL_LAYER_ID) || + after.on === false || + after.available === false + ) + return fail( + 'imagery', + street.error || 'Street Level changed while it looked for imagery', + { cancelled: true, location }, + ); + // The screen centre is sky: nothing was looked up. + if (street.nearestOutcome === 'sky') + return fail('imagery', street.error || 'Aim the view at a street', { + location, + aim: 'sky', + }); + // The source or the viewer failed: an error, not missing coverage. + if (street.nearestOutcome !== 'empty') + return fail( + 'imagery', + street.error || `${providerName} imagery could not be opened`, + { location, imageryError: true }, + ); + // An imagery filter the source honours can hide every photo here; say + // so, or the answer reads as "no coverage" when one switch would do. + const filter = activeImageryFilter(layerModule.getUIState?.()); + const reason = street.error || `No ${providerName} imagery near ${label}`; + return fail( + 'imagery', + filter ? `${reason} with the ${filter.label} on` : reason, + { location, noImagery: true, ...(filter ? { filter } : {}) }, + ); + } + // Every explicit request shows its photo, also one replacing a photo whose + // panel or phone sheet the user had closed. Embed mode lifts the photo. + if (!globalThis.document?.body?.classList?.contains('ui-embed')) { + styleManager?.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { + persist: false, + }); + styleManager?.revealPanel?.(STREET_LEVEL_PANEL_ID); + } + return { + ok: true, + action, + provider, + providerName, + label, + imageId: street.imageId ?? null, + title: street.title ?? null, + capturedAt: street.capturedAt ?? null, + capturedAtPrecision: street.capturedAtPrecision ?? null, + position: street.position ?? null, + externalUrl: street.externalUrl ?? null, + flew: location !== null, + ...(atViewCentre ? { atViewCentre } : {}), + ...(location ? { location: location.label } : {}), + }; +} + async function flyToRequestedLocation( viewer, args, @@ -3781,11 +4243,25 @@ async function flyToRequestedLocation( ); if (result === false) return cancelled(CITY_POIS[locationId]?.name || locationId); + // Where the camera went: the first POI for a close view, else the middle + // of the city's overview bounds. + const city = CITY_POIS[locationId]; + const overview = + !(rangeM || args.viewMode === 'close') && Boolean(city?.viewBounds); + const centre = overview + ? { + lat: + (city.viewBounds.southwest.lat + city.viewBounds.northeast.lat) / 2, + lon: + (city.viewBounds.southwest.lng + city.viewBounds.northeast.lng) / 2, + } + : city?.pois?.[0] || null; const response = { ok: Boolean(result), action: 'fly_to_location', locationId, label: CITY_POIS[locationId]?.name || locationId, + ...(centre ? { latitude: centre.lat, longitude: centre.lon } : {}), rangeM: result?.range ? Math.round(result.range) : rangeM || null, navigationMode: rangeM ? 'explicit-range' @@ -3846,6 +4322,7 @@ async function flyToRequestedLocation( action: 'fly_to_location', query, label: poi?.name || query, + ...(poi ? { latitude: poi.lat, longitude: poi.lon } : {}), navigationMode: rangeM ? 'preset-poi-range' : 'preset-poi', rangeM: result?.range ? Math.round(result.range) : rangeM || null, }; @@ -3876,6 +4353,10 @@ async function flyToRequestedLocation( action: 'fly_to_location', query, label: destination?.label || query, + ...(Number.isFinite(destination?.latitude) && + Number.isFinite(destination?.longitude) + ? { latitude: destination.latitude, longitude: destination.longitude } + : {}), navigationMode: destination?.navigationMode || null, rangeM: destination?.rangeM || rangeM || null, }; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 802f20126..98bc786db 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -30,6 +30,7 @@ import { normalizeStackId, } from './gevActions.js'; import { MAP_STACKS } from '../mapStackController.js'; +import { KEY_REJECTED_MESSAGE } from '../layers/streetLevel/providers/google/policy.js'; import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js'; import { presentResult } from './resultDisplay.js'; @@ -6548,6 +6549,568 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates'); }); +/** A Street Level layer stub, a map stack and a data manager for show_street_level. */ +function createStreetLevelHarness({ + activeStack = 'osm', + lit = { mapillary: true, google: false }, + enabled = false, + keyRequired = false, + configured = !keyRequired, + nearest = 'pano-a', + paramsAccepted = true, + enableResult = true, + listGoogle = true, + duringLookup = null, + keyRejectedAtStart = false, + lightFails = false, + imageryError = null, + sky = false, + photoOpen = false, + duringMapSwitch = null, + duringEnable = null, +} = {}) { + const order = []; + const lookups = []; + const origins = []; + const revealed = []; + const { viewer, styleManager, currentGeneration, panelSearch } = createVoiceNavigationHarness(); + // Who owns the camera: the shell's navigation stamps and globe gestures. + Object.defineProperty(styleManager, '_navigationGeneration', { + get: () => currentGeneration(), + }); + viewer.scene.canvas = Object.assign(new EventTarget(), { clientWidth: 1200, clientHeight: 800 }); + let completeFlight = null; + viewer.camera.lookAt = () => {}; + viewer.camera.flyToBoundingSphere = (_sphere, options) => { + order.push('fly'); + completeFlight = options.complete; + }; + styleManager.runImmediateLocationNavigation = (navigate) => + styleManager.runImmediateNavigation('location', navigate); + let stack = activeStack; + styleManager.mapStackController = { getActiveId: () => stack }; + styleManager.setMapStack = async (id) => { + order.push(`map:${id}`); + stack = id; + duringMapSwitch?.(); + return { ok: true, activeStack: id, error: null }; + }; + let params = { ...lit }; + let keyRejected = keyRejectedAtStart; + const street = photoOpen + ? { open: true, imageId: 'eiffel-photo', error: null } + : { open: false, imageId: null, error: null }; + const names = { google: 'Google Street View', mapillary: 'Mapillary' }; + const layer = { + providerIds: ['mapillary', 'google'], + requiredMapStack: (id) => (id === 'google' ? 'photoreal' : null), + providerStatus: (id) => + names[id] + ? { + id, + name: names[id], + on: params[id] === true, + configured, + keyRequired: keyRequired || keyRejected, + keyRejected, + error: keyRejected ? KEY_REJECTED_MESSAGE : keyRequired ? `${names[id]} needs its key` : null, + groundClick: id === 'google', + billed: id === 'google', + available: id !== 'google' || stack === 'photoreal', + } + : null, + getUIState: () => ({ + providers: ['mapillary', 'google'] + .filter((id) => id !== 'google' || (stack === 'photoreal' && listGoogle)) + .map((id) => ({ + id, + name: id === 'google' ? 'Google Street View' : 'Mapillary', + on: params[id] === true, + configured: !keyRequired, + keyRequired, + groundClick: id === 'google', + hint: keyRequired ? `${id} needs its key` : '', + error: null, + })), + keyAlternatives: [], + coverage: { hint: 'not on this map' }, + street: { ...street }, + }), + closeViewer() { + order.push('close'); + Object.assign(street, { open: false, imageId: null, error: null, nearestOutcome: null }); + }, + armNearest(point, options) { + order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`); + }, + async openNearest(point, options) { + lookups.push({ point, ...options }); + order.push(`open:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${options.frame}${point ? '' : ':view-centre'}`); + if (options.signal?.aborted) return false; + if (duringLookup) { + const effect = duringLookup(); + if (effect === 'key-rejected') { + keyRejected = true; + street.error = KEY_REJECTED_MESSAGE; + return false; + } + if (effect === 'layer-off') { + enabled = false; + return false; + } + } + // Like the layer: a lookup its caller gave up leaves the panel alone, + // and a replacing lookup that opens nothing closes the photo. + if (options.proceed && options.proceed() !== true) return false; + const closeReplaced = () => { + if (options.replace && street.open) layer.closeViewer(); + }; + if (imageryError) { + closeReplaced(); + street.error = imageryError; + street.nearestOutcome = null; + return false; + } + if (!nearest) { + closeReplaced(); + street.nearestOutcome = options.aim === 'screen-centre' && sky ? 'sky' : 'empty'; + street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`; + return false; + } + street.open = true; + street.imageId = nearest; + street.title = '10th St, Sacramento'; + street.position = point; + return true; + }, + }; + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + isEnabled: () => enabled, + getLayerParams: () => ({ ...params }), + setLayerParams(id, next, { origin }) { + origins.push(origin); + order.push(`params:${Object.entries(next).filter(([, on]) => on).map(([k]) => k).join('+')}:${origin}`); + if (paramsAccepted === false) return false; + if (!lightFails) params = { ...params, ...next }; + return true; + }, + async setEnabled(id, _on, options) { + origins.push(options?.origin); + order.push(`enable:${id}`); + if (enableResult === false) return false; + enabled = true; + duringEnable?.(); + return true; + }, + getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }], + }; + styleManager.setPanelCollapsed = (id, collapsed) => revealed.push(`collapse:${id}:${collapsed}`); + styleManager.revealPanel = (id) => revealed.push(`reveal:${id}`); + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + return { + runner, + order, + finishFlight: () => completeFlight?.(), + layer, + dataManager, + lookups, + origins, + revealed, + street, + // The user searches or flies elsewhere (a stamp, as the shell's), or + // drags the globe. + navigate: panelSearch, + gesture: () => viewer.scene.canvas.dispatchEvent(new Event('pointerdown')), + }; +} + +/** A loaded CCTV camera the entity branch can locate. */ +function withCamera(harness) { + harness.dataManager.layers.set('cctv', { + module: { + findByQuery: (id) => (id === 'cam-9' ? { latitude: 38.58, longitude: -121.5 } : null), + }, + }); + const wasEnabled = harness.dataManager.isEnabled; + harness.dataManager.isEnabled = (id) => id === 'cctv' || wasEnabled(id); + return harness; +} + +test('show_street_level flies first, then switches the map, lights the source, turns the layer on and opens the nearest photo', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { runner, order, finishFlight } = createStreetLevelHarness(); + const pending = runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google' }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(order, ['fly'], 'nothing changes until the place is reached'); + finishFlight(); + const result = await pending; + assert.equal(result.ok, true, result.error); + assert.deepEqual(order, [ + 'fly', + 'map:photoreal', + 'params:google:voice', + 'enable:street-level', + 'open:google:250:false', + ]); + assert.equal(result.provider, 'google'); + assert.equal(result.providerName, 'Google Street View'); + assert.equal(result.imageId, 'pano-a'); + assert.equal(result.title, '10th St, Sacramento'); + assert.equal(result.flew, true); + // A name given with its coordinates is what the answer says. + const named = createStreetLevelHarness(); + const naming = named.runner('show_street_level', { + query: 'Capitol Mall', latitude: 38.5799, longitude: -121.4937, provider: 'google', + }); + await new Promise((resolve) => setImmediate(resolve)); + named.finishFlight(); + assert.equal((await naming).label, 'Capitol Mall'); +}); + +test('show_street_level on Mapillary keeps the map, searches at the default radius and skips what is already on', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { runner, order } = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const result = await runner('show_street_level', { + latitude: 38.58, + longitude: -121.49, + provider: 'mapillary', + fly: false, + }); + assert.equal(result.ok, true, result.error); + assert.deepEqual(order, ['params:mapillary:voice', 'open:mapillary:default:false']); + assert.equal(result.flew, false); +}); + +test('show_street_level without a source uses the one lit in the panel', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { runner, order } = createStreetLevelHarness({ + activeStack: 'photoreal', + lit: { mapillary: false, google: true }, + enabled: true, + }); + const result = await runner('show_street_level', { latitude: 1, longitude: 2, fly: false }); + assert.equal(result.ok, true, result.error); + assert.equal(result.provider, 'google'); + assert.deepEqual(order, ['params:google:voice', 'open:google:250:false']); +}); + +test('show_street_level reports each stage it could not pass', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const centre = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + const none = await centre.runner('show_street_level', { provider: 'google' }); + assert.equal(none.ok, true, 'no place at all means the view centre'); + assert.equal(none.label, 'the view centre'); + assert.deepEqual(centre.order, ['params:google:voice', 'open:google:250:false:view-centre']); + + const offGlobe = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + const outOfRange = await offGlobe.runner('show_street_level', { latitude: 95, longitude: 2, provider: 'google' }); + assert.deepEqual([outOfRange.ok, outOfRange.stage], [false, 'location']); + assert.deepEqual(offGlobe.order, [], 'nothing flies, switches or looks up'); + + const unknown = await createStreetLevelHarness().runner('show_street_level', { + latitude: 1, longitude: 2, provider: 'openstreetcam', fly: false, + }); + assert.deepEqual([unknown.ok, unknown.stage], [false, 'provider']); + assert.match(unknown.error, /openstreetcam/); + + const keylessHarness = createStreetLevelHarness({ enabled: true, keyRequired: true }); + const keyless = await keylessHarness.runner('show_street_level', { + latitude: 1, longitude: 2, provider: 'google', fly: false, + }); + assert.deepEqual([keyless.ok, keyless.stage], [false, 'key']); + assert.match(keyless.error, /needs its key/); + assert.equal(keyless.providerName, 'Google Street View'); + assert.deepEqual(keylessHarness.order, [], 'a keyless source changes nothing: no flight, no map, no switch'); + + const refusedSwitch = await createStreetLevelHarness({ enabled: true, paramsAccepted: false }).runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'google', fly: false }, + ); + assert.deepEqual([refusedSwitch.ok, refusedSwitch.stage], [false, 'provider']); + assert.match(refusedSwitch.error, /could not be switched on/); + + const empty = await createStreetLevelHarness({ enabled: true, nearest: null }).runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'google', fly: false }, + ); + assert.deepEqual([empty.ok, empty.stage], [false, 'imagery']); + assert.match(empty.error, /within 250 m/); +}); + +test('show_street_level stops at the map when the source’s map is refused, before touching the panel', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { layer, order } = createStreetLevelHarness({ enabled: true }); + const reason = 'Google 3D needs GOOGLE_MAPS_API_KEY'; + const dataManager = { + layers: new Map([['street-level', { module: layer }]]), + isEnabled: () => true, + getLayerParams: () => ({ mapillary: true, google: false }), + setLayerParams: () => order.push('params'), + getAll: () => [], + }; + const { viewer, styleManager } = createVoiceNavigationHarness(); + styleManager.mapStackController = { getActiveId: () => 'osm' }; + styleManager.setMapStack = async () => ({ ok: false, activeStack: 'osm', error: reason }); + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + const result = await runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false }); + assert.deepEqual([result.ok, result.stage, result.error], [false, 'map', reason]); + assert.deepEqual(order, [], 'the source is not switched when its map is refused'); +}); + +test('show_street_level opens photos of a numbered item or a pointed spot through its entity', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { runner, order, finishFlight } = withCamera( + createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }), + ); + const pending = runner('show_street_level', { + provider: 'google', + entity: { layerId: 'cctv', id: 'cam-9', label: 'Camera 9', lat: 38.58, lon: -121.5 }, + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.deepEqual(order, ['fly'], 'the entity position is flown to'); + finishFlight(); + const result = await pending; + assert.equal(result.ok, true, result.error); + assert.equal(result.label, 'Camera 9'); + assert.deepEqual(order, ['fly', 'params:google:voice', 'open:google:250:false']); +}); + +test('show_street_level hands the point from a named place, a preset and a curated POI to the lookup', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const points = []; + const make = () => { + const harness = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const open = harness.layer.openNearest; + harness.layer.openNearest = async (point, options) => { + points.push(point); + return open(point, options); + }; + return harness; + }; + // A geocoded name: the flight reports where it landed. + const named = make(); + const viewerFor = () => ({ ...createVoiceNavigationHarness().viewer }); + const runnerWithSearch = createGevActionRunner({ + viewer: viewerFor(), + styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) }, + dataManager: { + layers: new Map([['street-level', { module: named.layer }]]), + isEnabled: () => true, + getLayerParams: () => ({ mapillary: true, google: false }), + setLayerParams: () => true, + getAll: () => [], + }, + searchNavigation: async (_viewer, query, options) => { + options.onComplete?.(); + return { label: `${query}, CA`, latitude: 38.5799, longitude: -121.4937, navigationMode: 'precise-place', rangeM: 250 }; + }, + }); + const geocoded = await runnerWithSearch('show_street_level', { query: '10th Street, Sacramento', provider: 'mapillary' }); + assert.equal(geocoded.ok, true, geocoded.error); + assert.equal(geocoded.label, '10th Street, Sacramento, CA'); + assert.deepEqual(points.at(-1), { lat: 38.5799, lon: -121.4937 }); + // A curated POI and a city preset fly to their hand-tuned poses and report them. + for (const [args, expected] of [ + [{ query: 'Golden Gate Bridge', provider: 'mapillary' }, { lat: 37.8199, lon: -122.4783 }], + [{ query: 'austin', provider: 'mapillary' }, null], + ]) { + const harness = make(); + const pending = harness.runner('show_street_level', args); + await new Promise((resolve) => setImmediate(resolve)); + harness.finishFlight(); + const result = await pending; + assert.equal(result.ok, true, `${args.query}: ${result.error}`); + const point = points.at(-1); + if (expected) assert.deepEqual(point, expected); + else assert.ok(Number.isFinite(point.lat) && Number.isFinite(point.lon), 'a preset flight reports its point'); + } +}); + +test('show_street_level retires its lookup when the turn is superseded, and says so', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { runner, order, layer } = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + const controller = new AbortController(); + let currentTurn = true; + layer.openNearest = async (_point, options) => { + order.push(`open:signal:${Boolean(options.signal)}`); + controller.abort(); + currentTurn = false; + return false; + }; + const result = await runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'google', fly: false }, + { signal: controller.signal, isCurrent: () => currentTurn }, + ); + assert.deepEqual([result.ok, result.cancelled, result.stage], [false, true, 'imagery']); + assert.doesNotMatch(result.error, /No .* imagery/); + assert.ok(order.includes('open:signal:true'), 'the lookup gets the turn signal'); +}); + +test('show_street_level with autoOpen:false arms a billed source and leaves the load to a click; a free source still opens', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const armedHarness = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const armed = await armedHarness.runner('show_street_level', { + latitude: 38.5799, longitude: -121.4937, provider: 'google', fly: false, autoOpen: false, + }); + assert.equal(armed.ok, true, armed.error); + assert.equal(armed.armed, true); + assert.equal(armed.imageId, undefined); + assert.deepEqual( + armedHarness.order, + ['map:photoreal', 'params:google:voice', 'arm:google:250:38.5799,-121.4937'], + 'map and source are ready, the lookup is armed at the point, nothing is loaded', + ); + assert.match(armed.say, /^Google Street View is ready at .*press OPEN/); + assert.deepEqual( + { ...armed, say: undefined, display: undefined }, + { ok: true, action: 'show_street_level', provider: 'google', providerName: 'Google Street View', label: '38.5799, -121.4937', armed: true, flew: false, say: undefined, display: undefined }, + ); + const free = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const opened = await free.runner('show_street_level', { + latitude: 38.5799, longitude: -121.4937, provider: 'mapillary', fly: false, autoOpen: false, + }); + assert.equal(opened.ok, true, opened.error); + assert.equal(opened.armed, undefined); + assert.equal(opened.imageId, 'pano-a'); +}); + +test('show_street_level tells a failed enable, a delisted source, a key refused mid-lookup and a layer switched off apart', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const at = { latitude: 1, longitude: 2, provider: 'google', fly: false }; + const notEnabled = await createStreetLevelHarness({ activeStack: 'photoreal', enableResult: false }).runner('show_street_level', at); + assert.deepEqual([notEnabled.ok, notEnabled.stage, notEnabled.cancelled], [false, 'layer', false]); + assert.match(notEnabled.say, /Couldn't turn on Street Level/); + + const delisted = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', listGoogle: false }).runner('show_street_level', at); + assert.deepEqual([delisted.ok, delisted.stage], [false, 'provider']); + assert.equal(delisted.error, 'not on this map'); + + const refused = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => 'key-rejected' }).runner('show_street_level', at); + assert.deepEqual([refused.ok, refused.stage, refused.keyRejected], [false, 'key', true]); + assert.equal(refused.say, 'Google Street View refused its key.'); + + const switchedOff = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => 'layer-off' }).runner('show_street_level', at); + assert.deepEqual([switchedOff.ok, switchedOff.stage, switchedOff.cancelled], [false, 'imagery', true]); + assert.equal(switchedOff.say, undefined, 'a request the user withdrew is not spoken'); + + // A source whose status call says it is not configured is key-gated even + // before its coverage reports a missing key. + const unconfigured = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', configured: false }); + const gated = await unconfigured.runner('show_street_level', at); + assert.deepEqual([gated.ok, gated.stage], [false, 'key']); + assert.deepEqual(unconfigured.order, [], 'nothing changed'); +}); + +test('show_street_level reports a geocoder that throws as its location stage', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const h = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const runner = createGevActionRunner({ + viewer: createVoiceNavigationHarness().viewer, + styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) }, + dataManager: h.dataManager, + placeSearch: { + geocode: async () => { + throw new Error('geocoder unreachable'); + }, + }, + }); + const result = await runner('show_street_level', { query: 'Capitol Mall', provider: 'mapillary' }); + assert.deepEqual([result.ok, result.stage], [false, 'location']); + assert.equal(result.error, 'Could not arrive at Capitol Mall'); + assert.deepEqual(h.order.filter((step) => /^(params|open|arm)/.test(step)), [], 'nothing switched or looked up'); +}); + +test('show_street_level reports a flight that lands without a position', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const hit = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + // A flight that reports no coordinates cannot be followed by a lookup. + const noPoint = createGevActionRunner({ + viewer: createVoiceNavigationHarness().viewer, + styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) }, + dataManager: hit.dataManager, + searchNavigation: async (_viewer, query, options) => { + options.onComplete?.(); + return { label: query, navigationMode: 'city-overview', rangeM: null }; + }, + }); + const unplaced = await noPoint('show_street_level', { query: 'Somewhere', provider: 'mapillary' }); + assert.deepEqual([unplaced.ok, unplaced.stage], [false, 'location']); + assert.match(unplaced.error, /no position/); +}); + +test('fly_to_location reports the overview centre for a city framed as a whole, and the first POI for a close view', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const { viewer, styleManager } = createVoiceNavigationHarness(); + viewer.camera.lookAt = () => {}; + viewer.camera.flyTo = (options) => options.complete?.(); + viewer.camera.flyToBoundingSphere = (_s, options) => options.complete?.(); + styleManager.runImmediateLocationNavigation = (navigate) => styleManager.runImmediateNavigation('location', navigate); + const runner = createGevActionRunner({ viewer, styleManager, dataManager: { layers: new Map(), getAll: () => [] } }); + const overview = await runner('fly_to_location', { locationId: 'austin' }); + assert.equal(overview.ok, true); + assert.ok(Math.abs(overview.latitude - 30.31) < 0.001, String(overview.latitude)); + assert.ok(Math.abs(overview.longitude - -97.75) < 0.001, String(overview.longitude)); + const close = await runner('fly_to_location', { locationId: 'austin', viewMode: 'close' }); + assert.deepEqual([close.latitude, close.longitude], [30.2747, -97.7403]); +}); + +test('show_street_level arms the view centre when a billed link names no place, and keeps the flight label when it flew', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const centre = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + const armed = await centre.runner('show_street_level', { provider: 'google', autoOpen: false }); + assert.equal(armed.ok, true, armed.error); + assert.equal(armed.armed, true); + assert.deepEqual(centre.order, ['params:google:voice', 'arm:google:250:view-centre'], 'armed at the view centre, nothing opened'); + const flown = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + const pending = flown.runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google', autoOpen: false }); + await new Promise((resolve) => setImmediate(resolve)); + flown.finishFlight(); + const result = await pending; + assert.equal(result.armed, true); + assert.equal(result.flew, true); + assert.equal(result.location, '38.5799, -121.4937'); + assert.ok(!flown.order.some((entry) => entry.startsWith('open:')), 'no load after the flight'); +}); + +test('show_street_level says "refused its key" when Google already refused it, and fails when the source will not light', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const refusedHarness = createStreetLevelHarness({ enabled: true, keyRejectedAtStart: true }); + const refused = await refusedHarness.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false }); + assert.deepEqual([refused.ok, refused.stage, refused.keyRejected], [false, 'key', true]); + assert.equal(refused.error, KEY_REJECTED_MESSAGE); + assert.equal(refused.say, 'Google Street View refused its key.'); + assert.deepEqual(refusedHarness.order, [], 'nothing changed'); + const dark = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', lightFails: true }).runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'google', fly: false }, + ); + assert.deepEqual([dark.ok, dark.stage], [false, 'provider']); + assert.match(dark.error, /could not be switched on/); +}); + +test('activeImageryFilter reports only the narrowing filters the lit source honours', async () => { + const { activeImageryFilter } = await import('./gevActions.js'); + const both = { pano: true, since: true }; + assert.equal(activeImageryFilter({ filter: { pano: 'all', sinceDays: 0 }, filterControls: both }), null); + assert.deepEqual(activeImageryFilter({ filter: { pano: 'pano', sinceDays: 0 }, filterControls: both }), { + pano: 'pano', + label: '360° filter', + }); + assert.deepEqual(activeImageryFilter({ filter: { pano: 'flat', sinceDays: 365 }, filterControls: both }), { + pano: 'flat', + sinceDays: 365, + label: 'flat-photo and SINCE filters', + }); + assert.equal( + activeImageryFilter({ filter: { pano: 'pano', sinceDays: 365 }, filterControls: { pano: false, since: false } }), + null, + 'Street View honours neither', + ); + assert.equal(activeImageryFilter(undefined), null); +}); + test('analyst item display strings are single-line and bounded for voice', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const rows = [{ id: 'stable-id', name: 'Şehir\n' + 'x'.repeat(3000), label: 'Town\tStation', operator: 'City\nPD', lat: 30, lon: -97 }]; @@ -6688,3 +7251,296 @@ test('a partial analyst card shows one partial chip', () => { const labels = card.display.chips.map((chip) => chip.label); assert.equal(new Set(labels).size, labels.length, JSON.stringify(labels)); }); + +test('show_street_level superseded during its flight switches nothing', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const h = createStreetLevelHarness(); + let current = true; + const pending = h.runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google' }, { isCurrent: () => current }); + await new Promise((resolve) => setImmediate(resolve)); + current = false; + h.finishFlight(); + const result = await pending; + assert.deepEqual(h.order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], 'the stale turn switched nothing'); + assert.equal(result.cancelled, true); +}); + +test('show_street_level superseded during its map switch lights no source', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const h = createStreetLevelHarness({ enabled: true }); + let current = true; + const runner = createGevActionRunner({ + viewer: createVoiceNavigationHarness().viewer, + styleManager: { + mapStackController: { getActiveId: () => 'osm' }, + setMapStack: async (id) => { + h.order.push(`map:${id}`); + current = false; + return { ok: true, activeStack: id }; + }, + }, + dataManager: h.dataManager, + }); + const result = await runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false }, { isCurrent: () => current }); + assert.deepEqual(h.order.filter((step) => /^(params|open|arm)/.test(step)), [], 'the stale turn lit nothing'); + assert.deepEqual([result.stage, result.cancelled], ['map', true]); +}); + +test('show_street_level goes ahead while a source’s key status is still unknown', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + // A freshly loaded sl= link meets the source before its status call answers. + const { runner, order } = createStreetLevelHarness({ enabled: true, configured: null }); + const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false }); + assert.equal(result.ok, true); + assert.ok(order.some((step) => step.startsWith('open:mapillary'))); +}); + +test('show_street_level in a view that hides its panel stops before anything loads unseen', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const saved = globalThis.document; + for (const mode of ['cockpit-mode', 'ui-clean-view', 'recording-mode', 'scene-playback-mode']) { + globalThis.document = { body: { classList: { contains: (name) => name === mode } } }; + try { + const { runner, order } = createStreetLevelHarness({ enabled: true }); + const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false }); + assert.deepEqual([result.ok, result.stage], [false, 'view'], mode); + assert.match(result.error, /hidden in the current view/); + assert.deepEqual(order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], `${mode}: nothing switched`); + } finally { + globalThis.document = saved; + } + } +}); + +test('show_street_level checks the panel again after every wait: a mode entered meanwhile loads nothing unseen', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const saved = globalThis.document; + const classes = new Set(); + globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } }; + try { + for (const [mode, when] of [ + ['ui-clean-view', 'duringEnable'], + ['cockpit-mode', 'duringMapSwitch'], + ]) { + classes.clear(); + const h = createStreetLevelHarness({ [when]: () => classes.add(mode) }); + const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false }); + assert.deepEqual([result.ok, result.stage], [false, 'view'], mode); + assert.deepEqual(h.order.filter((step) => /^(open|arm)/.test(step)), [], `${mode}: no billed load`); + assert.deepEqual(h.revealed, [], `${mode}: nothing revealed`); + } + // Entered while the photo loaded: it is closed again, not reported as shown. + classes.clear(); + const late = createStreetLevelHarness({ enabled: true }); + const open = late.layer.openNearest; + late.layer.openNearest = async (...call) => { + const opened = await open(...call); + classes.add('recording-mode'); + return opened; + }; + const result = await late.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false }); + assert.deepEqual([result.ok, result.stage], [false, 'view']); + assert.equal(late.order.at(-1), 'close'); + assert.deepEqual(late.revealed, []); + } finally { + globalThis.document = saved; + } +}); + +test('show_street_level refuses a hidden panel before the camera leaves a recording', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const saved = globalThis.document; + globalThis.document = { body: { classList: { contains: (name) => name === 'recording-mode' } } }; + try { + const { runner, order } = createStreetLevelHarness(); + const result = await runner('show_street_level', { query: 'Eiffel Tower', provider: 'mapillary' }); + assert.deepEqual([result.ok, result.stage], [false, 'view']); + assert.deepEqual(order, [], 'no flight'); + } finally { + globalThis.document = saved; + } +}); + +test('show_street_level stops once the camera is someone else’s: a navigation or a gesture during the map switch', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + for (const take of ['navigate', 'gesture']) { + let h; + h = createStreetLevelHarness({ duringMapSwitch: () => h[take]() }); + const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false }); + assert.equal(result.ok, true, `${take}: Mapillary needs no map switch`); + h = createStreetLevelHarness({ duringMapSwitch: () => h[take]() }); + const google = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false }); + assert.deepEqual([google.ok, google.stage, google.cancelled, google.superseded], [false, 'map', true, true], take); + assert.deepEqual(h.order.filter((step) => /^(params|enable|open|arm)/.test(step)), [], `${take}: nothing lit for the old place`); + } + // During the layer switch-on: the photo lookup never runs. + let h; + h = createStreetLevelHarness({ duringEnable: () => h.navigate() }); + const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false }); + assert.deepEqual([result.ok, result.stage, result.superseded], [false, 'layer', true]); + assert.ok(!h.order.some((step) => step.startsWith('open:'))); +}); + +test('show_street_level loads no photo it found once the camera or the panel moved on during the lookup', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + for (const take of ['navigate', 'gesture']) { + let h; + h = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => h[take]() }); + const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false }); + assert.deepEqual([result.ok, result.stage, result.superseded], [false, 'imagery', true], take); + assert.equal(h.lookups.length, 1); + assert.equal(h.street.open, false, `${take}: the billed panorama never loads`); + assert.ok(!h.order.includes('close'), `${take}: nothing opened, nothing to close`); + } + const saved = globalThis.document; + const classes = new Set(); + globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } }; + try { + const h = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => classes.add('ui-clean-view') }); + const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false }); + assert.deepEqual([result.ok, result.stage], [false, 'view']); + assert.equal(h.street.open, false, 'nothing loads into the hidden panel'); + } finally { + globalThis.document = saved; + } +}); + +test('show_street_level replaces an open photo in place: the viewer (and EXPAND) stays up while it looks', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const h = createStreetLevelHarness({ enabled: true, photoOpen: true, nearest: 'pano-b' }); + const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false }); + assert.equal(result.ok, true); + assert.equal(h.lookups[0].replace, true, 'the lookup takes over the open photo'); + assert.ok(!h.order.includes('close'), 'never closed on the way'); + assert.deepEqual([h.street.open, h.street.imageId], [true, 'pano-b']); +}); + +test('show_street_level owns the camera from where its own flight lands', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const h = createStreetLevelHarness(); + const pending = h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + h.finishFlight(); + const result = await pending; + assert.equal(result.ok, true, result.error); + assert.ok(h.order.some((step) => step.startsWith('open:mapillary'))); +}); + +// A camera taken during the action's own flight. 'navigate' is a +// location-panel search: a stamp that leaves the flight flying. 'gesture' is a +// press on the globe, which does not cancel a Cesium flight either. +for (const take of ['navigate', 'gesture']) { + test(`show_street_level stands down when the camera is taken during its own flight (${take})`, async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const h = createStreetLevelHarness(); + const pending = h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.deepEqual(h.order, ['fly'], 'the flight is under way'); + h[take](); + h.finishFlight(); + const result = await pending; + assert.deepEqual([result.ok, result.superseded], [false, true], result.error); + assert.deepEqual( + h.order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), + [], + 'no map switch, no source, no billed photo for the place the user left', + ); + }); +} + +test('show_street_level closes the previous photo, so one that finds nothing leaves no other place on screen', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const empty = createStreetLevelHarness({ enabled: true, photoOpen: true, nearest: null }); + const result = await empty.runner('show_street_level', { latitude: 23.4, longitude: 12.5, provider: 'mapillary', fly: false }); + assert.deepEqual([result.ok, result.noImagery], [false, true]); + assert.equal(empty.street.open, false, 'the Paris photo is gone'); + assert.match(empty.street.error, /No street-level imagery/, 'the panel shows why'); + // A link that only arms a billed source closes it too. + const armed = createStreetLevelHarness({ enabled: true, photoOpen: true, activeStack: 'photoreal' }); + await armed.runner('show_street_level', { latitude: 23.4, longitude: 12.5, provider: 'google', fly: false, autoOpen: false }); + assert.deepEqual(armed.order.filter((step) => /^(close|arm)/.test(step)).map((step) => step.split(':')[0]), ['close', 'arm']); +}); + +test('show_street_level opens over the clean embedded globe but respects its other exclusive modes', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const saved = globalThis.document; + try { + for (const mode of [null, 'cockpit-mode', 'recording-mode', 'scene-playback-mode']) { + const classes = new Set(['ui-embed', 'ui-clean-view', mode]); + globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } }; + const { runner, order } = createStreetLevelHarness({ enabled: true }); + const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false }); + if (mode) { + assert.deepEqual([result.ok, result.stage], [false, 'view'], mode); + assert.deepEqual(order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], `${mode}: nothing switched`); + } else { + assert.equal(result.ok, true, 'embed clean view lifts the photo over the globe'); + assert.ok(order.some((step) => step.startsWith('open:mapillary'))); + } + } + } finally { + globalThis.document = saved; + } +}); + +test('show_street_level from a link keeps the recipient’s preferences: its switches are not the user’s', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const link = createStreetLevelHarness(); + const linked = await link.runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }, + { origin: 'programmatic' }, + ); + assert.equal(linked.ok, true, linked.error); + assert.deepEqual(link.origins, ['programmatic', 'programmatic'], 'source switch and layer on'); + const asked = createStreetLevelHarness(); + await asked.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }); + assert.deepEqual(asked.origins, ['voice', 'voice'], 'the assistant’s request is the user’s choice'); +}); + +test('show_street_level with no place looks under the screen centre, not a link’s armed point', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const here = createStreetLevelHarness({ enabled: true }); + const result = await here.runner('show_street_level', { provider: 'mapillary' }); + assert.equal(result.ok, true, result.error); + assert.equal(result.atViewCentre, true); + assert.equal(here.lookups[0].point, null); + assert.equal(here.lookups[0].aim, 'screen-centre'); + assert.equal(here.lookups[0].armed, false); + const placed = createStreetLevelHarness({ enabled: true }); + await placed.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }); + assert.equal(placed.lookups[0].aim, undefined, 'a place is looked up where it is'); + const sky = createStreetLevelHarness({ enabled: true, nearest: null, sky: true }); + const refused = await sky.runner('show_street_level', { provider: 'mapillary' }); + assert.deepEqual([refused.ok, refused.stage, refused.aim], [false, 'imagery', 'sky']); +}); + +test('show_street_level tells missing imagery from a failing source', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const empty = await createStreetLevelHarness({ enabled: true, nearest: null }).runner('show_street_level', { + latitude: 1, longitude: 2, provider: 'mapillary', fly: false, + }); + assert.deepEqual([empty.stage, empty.noImagery, empty.imageryError], ['imagery', true, undefined]); + const broken = await createStreetLevelHarness({ enabled: true, imageryError: 'Mapillary graph HTTP 500' }).runner( + 'show_street_level', + { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }, + ); + assert.deepEqual([broken.stage, broken.noImagery, broken.imageryError], ['imagery', undefined, true]); + assert.equal(broken.error, 'Mapillary graph HTTP 500'); +}); + +test('show_street_level shows every photo it opens, also over a panel the user had closed', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const harness = createStreetLevelHarness({ enabled: true }); + await harness.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }); + await harness.runner('show_street_level', { latitude: 3, longitude: 4, provider: 'mapillary', fly: false }); + assert.deepEqual(harness.revealed, [ + 'collapse:street-level-panel:false', + 'reveal:street-level-panel', + 'collapse:street-level-panel:false', + 'reveal:street-level-panel', + ]); + const missing = createStreetLevelHarness({ enabled: true, nearest: null }); + await missing.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false }); + assert.deepEqual(missing.revealed, [], 'nothing opened, nothing revealed'); +}); diff --git a/src/voice/integrationContract.test.mjs b/src/voice/integrationContract.test.mjs index 64d1e394e..ceb8cee67 100644 --- a/src/voice/integrationContract.test.mjs +++ b/src/voice/integrationContract.test.mjs @@ -714,13 +714,13 @@ test('without an operator Overpass the prompt never offers place search', () => }); test('the Realtime prompt stays within its size budget', () => { - // Instructions plus tool schemas, in characters, under the same 55,500 + // Instructions plus tool schemas, in characters, under the same 56,500 // ceiling as the deixis prompt lint. Voice geometry adds resolve_area, // find_imagery and the analyst area scopes (osm_query only with an - // operator Overpass). + // operator Overpass); show_street_level adds its tool and routing rule. const size = (overpass) => realtimeInstructions(undefined, { overpass }).length + JSON.stringify(realtimeTools({ overpass })).length; - assert.ok(size(false) <= 55_500, `default prompt ${size(false)} chars`); - assert.ok(size(true) <= 55_500, `with Overpass ${size(true)} chars`); + assert.ok(size(false) <= 56_500, `default prompt ${size(false)} chars`); + assert.ok(size(true) <= 56_500, `with Overpass ${size(true)} chars`); }); diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index da277293f..28723195a 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -364,7 +364,12 @@ const layers = [ }, { id: 'street-level', - aliases: ['street level', 'street-level imagery', 'mapillary'], + aliases: [ + 'street level', + 'street-level imagery', + 'mapillary', + 'street view', + ], context: false, noQuery: 'street-level imagery; no countable records', }, diff --git a/src/voice/layerManifest.test.mjs b/src/voice/layerManifest.test.mjs index ab382c460..b35d83cc5 100644 --- a/src/voice/layerManifest.test.mjs +++ b/src/voice/layerManifest.test.mjs @@ -119,6 +119,7 @@ test('spoken aliases resolve to real layers, keep every legacy phrase and never 'weather radar': 'weather-radar', lightning: 'weather-lightning', 'military bases': 'military-installations', 'recent imagery': 'recent-imagery', 'bike share': 'bikeshare', 'rocket launches': 'rocket-launches', 'street level': 'street-level', mapillary: 'street-level', + 'street view': 'street-level', wind: 'wind', directions: 'directions', 'fire perimeters': 'fire-perimeters', 'wildfire perimeters': 'fire-perimeters', 'burn areas': 'fire-perimeters', }; diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index 5d6d0fdd1..b1942c85f 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -343,19 +343,11 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in tool('annotate_map').annotations.items.properties.target.description, /"pointer"/, ); - // Budget: the whole Realtime prompt (instructions + tools), measured in - // characters. It was 56,308 after the area/imagery/OSM merge; stating each - // policy once (WHERE precedence, AREAS routing, AFTER TOOLS speech) brought - // it to 51,646. Voice geometry (resolve_area, find_imagery, osm_query and - // the analyst area scopes) raised the ceiling to 55,500; tightening the - // AREAS rule and the area tool descriptions left it at 55,103 as measured - // below (every tool, osm_query included); main's Sea Marks and Ski Pistes - // layers bring it to 55,210, and the tilt pitchDeg argument (with its -45 - // default) and main's cockpit entry rule to 55,339. integrationContract's - // Overpass prompt is the tightest, at 55,499 (1 under). A new tool or rule - // has to fit under this ceiling. + // Budget: the whole Realtime prompt (instructions + every tool, osm_query + // included), in characters. Every session pays for it in latency and cost, + // so a new tool or rule has to fit under this ceiling or earn a raise. const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length; - assert.ok(size <= 55_500, `prompt size ${size}`); + assert.ok(size <= 56_500, `prompt size ${size}`); }); test('after-tool speech allows returned items to answer requested lists and rankings', () => { diff --git a/src/voice/pointerReticle.js b/src/voice/pointerReticle.js index 5a3a64fdc..2a5dae764 100644 --- a/src/voice/pointerReticle.js +++ b/src/voice/pointerReticle.js @@ -27,6 +27,7 @@ const CAMERA_TOOLS = new Set([ 'fly_route', 'frame_overhead', 'select_nearest_aircraft', + 'show_street_level', 'control_cockpit', ]); diff --git a/src/voice/speech.js b/src/voice/speech.js index dbff4ad4d..3ecb19114 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -24,6 +24,7 @@ import { voiceLayer } from './layerManifest.js'; import { + formatDate, formatNumber, getLocale, msg, @@ -178,9 +179,27 @@ const KNOWN_ERRORS = new Map( ), msg('Could not arrive at the requested place'), msg('The nearest airborne aircraft could not be selected'), + // show_street_level + msg('Latitude must be within ±90° and longitude within ±180°'), + msg('Street Level is unavailable'), + msg('No street-level imagery source is configured'), + msg('Street Level could not be enabled'), + msg('Street Level changed while it looked for imagery'), + msg('Aim the view at a street'), ].map((text) => [text, text]), ); +/** The imagery filters an empty street-level result names, as its chips say them. */ +function shownImageryFilters(label) { + return [ + label.includes('360°') ? '360°' : null, + label.includes('flat-photo') ? t('FLAT') : null, + label.includes('SINCE') ? t('SINCE') : null, + ] + .filter(Boolean) + .join(', '); +} + function noneWithinView(target, radius) { switch (target) { case 'flights': @@ -258,6 +277,69 @@ const ERROR_PATTERNS = [ /^No (\S+) within (\d+(?:\.\d+)?) km of the current view$/, ([, target, km]) => noneWithinView(target, shownKm(km)), ], + // show_street_level: the filter clause first, around the reason it extends. + [ + /^(.+) with the (.+ filters?) on$/, + ([, reason, filters]) => + t('{reason} (filters on: {filters})', { + reason: shownError(reason), + filters: shownImageryFilters(filters), + }), + ], + [ + /^(.+) is not a street-level imagery source here$/, + ([, source]) => + t('{source} is not a street-level imagery source here', { source }), + ], + [ + /^(.+) needs its key$/, + ([, source]) => + t('{source} needs its key', { source: translateKnown(source) }), + ], + [ + /^(.+) needs the (.+) map, which is not available$/, + ([, source, map]) => + t('{source} needs the {map} map, which is not available', { + source: translateKnown(source), + map: map === 'photoreal' ? 'Google 3D' : map, + }), + ], + [ + /^(.+) could not be switched on$/, + ([, source]) => + t('{source} could not be switched on', { + source: translateKnown(source), + }), + ], + [ + /^(.+) is not available on this map$/, + ([, source]) => + t('{source} is not available on this map', { + source: translateKnown(source), + }), + ], + [ + /^(.+) imagery could not be opened$/, + ([, source]) => + t('{source} imagery could not be opened', { + source: translateKnown(source), + }), + ], + [ + /^(.+) has no position to look for imagery at$/, + ([, place]) => + t('{place} has no position to look for imagery at', { + place: localizePlaceName(place), + }), + ], + [ + /^No (.+) imagery near (.+)$/, + ([, source, place]) => + t('No {source} imagery near {place}', { + source: translateKnown(source), + place: localizePlaceName(place), + }), + ], ]; /** A tool error as the card shows it. */ @@ -594,6 +676,98 @@ function selectNearestAircraft(result) { }; } +/** Month and year an image was captured, or ''. */ +function capturedMonth(capturedAt) { + const ms = Number(capturedAt); + if (!Number.isFinite(ms) || ms <= 0) return ''; + try { + // Dates are UTC months (Google: the first of the month). + return formatDate(new Date(ms), { + month: 'short', + year: 'numeric', + timeZone: 'UTC', + }); + } catch { + return ''; + } +} + +function showStreetLevel(result) { + // `location` is the flight's own result on a failed flight; its label is + // the place, never the object. + const place = spokenLabel( + result?.label || + (typeof result?.location === 'string' + ? result.location + : result?.location?.label) || + 'there', + ); + const source = spokenLabel(result?.providerName || 'Street Level'); + // The card in the interface language; the spoken line stays English. + const shownSource = result?.providerName + ? spokenLabel(result.providerName) + : t('Street Level'); + // The view centre is a phrase, not a place name: the card says it in the + // interface language. + const shown = result?.atViewCentre + ? t('the view centre') + : spokenLabel(localizePlaceName(result?.label || '')) || place; + if (result?.cancelled) return null; + if (!result?.ok) { + let say; + if (result?.stage === 'location') say = `Couldn't find ${place}.`; + else if (result?.stage === 'map') + say = `${source} needs the Google 3D map, which isn't available.`; + else if (result?.stage === 'key') + say = result?.keyRejected + ? `${source} refused its key.` + : `${source} needs its key.`; + else if (result?.stage === 'layer') say = "Couldn't turn on Street Level."; + else if (result?.stage === 'view') + say = 'Street-level photos are hidden in this view. Leave it first.'; + else if (result?.stage === 'imagery' && result?.aim === 'sky') + say = 'Aim the middle of the view at a street first.'; + else if (result?.stage === 'imagery' && result?.filter?.label) + say = `No ${source} imagery near ${place} with the ${result.filter.label} on.`; + else if (result?.stage === 'imagery' && result?.noImagery === true) + say = `No ${source} imagery near ${place}.`; + // A service or viewer failure is not missing coverage. + else if (result?.stage === 'imagery') + say = `${source} could not open imagery near ${place}.`; + else say = `Couldn't open ${source}.`; + return { + say, + display: { + title: shownSource, + lines: errorLines(result), + sources: [], + }, + }; + } + if (result.armed === true) + return { + say: sentence(`${source} is ready at ${place}: press OPEN to load it`), + display: { + title: shownSource, + lines: [shown, t('Press OPEN to load the photo')], + sources: [{ label: shownSource }], + }, + }; + const title = spokenLabel(result.title || '', 60); + const month = capturedMonth(result.capturedAt); + return { + say: sentence(`${source} at ${title || place}`), + display: { + title: shownSource, + lines: [ + title || shown, + month ? t('Captured {month}', { month }) : null, + ].filter(Boolean), + sources: [{ label: shownSource }], + }, + }; +} + const SPOKEN_ROUTE = Object.freeze({ between: (origin, destination) => `${origin} to ${destination}`, from: (origin) => `from ${origin}`, @@ -801,6 +975,7 @@ export const SPEECH_BUILDERS = Object.freeze({ fly_to_location: flyToLocation, frame_overhead: frameOverhead, select_nearest_aircraft: selectNearestAircraft, + show_street_level: showStreetLevel, get_entity_context: getEntityContext, get_current_view_state: getCurrentViewState, annotate_map: annotateMap, @@ -856,6 +1031,14 @@ function annotationTargets(args) { .filter(Boolean); } +/** Street-level sources as a plan step names them. */ +const STREET_LEVEL_SOURCE_NAMES = Object.freeze({ + google: 'Street View', + mapillary: 'Mapillary', + kartaview: 'KartaView', + panoramax: 'Panoramax', +}); + // frame_overhead targets as the schema names them. function frameStepLabel(target) { switch (target) { @@ -927,6 +1110,17 @@ export function planStepLabel(name, args = {}) { ? t('Nearest aircraft to {place}', { place: shownPlace(place, 32) }) : t('Nearest aircraft to Here'); } + case 'show_street_level': { + const source = STREET_LEVEL_SOURCE_NAMES[args.provider]; + const place = args.query + ? shownPlace(args.query, 32) + : args.latitude != null + ? t('this spot') + : t('the view centre'); + return source + ? t('{source} at {place}', { source, place }) + : t('Street level at {place}', { place }); + } case 'set_layer_visibility': { const on = args.enabled !== false; const label = @@ -988,6 +1182,8 @@ export function narrationLabel(name, args = {}) { presetPlaceName(args.locationQuery || args.locationId || ''), 32, ); + case 'show_street_level': + return shownPlace(args.query || '', 32); case 'resolve_area': return displayName(args.query || '', 32); case 'find_imagery': @@ -1008,6 +1204,8 @@ const STEP_LABELS = Object.freeze({ layer: msg('Turning on layer'), refresh: msg('Loading aircraft'), nearest: msg('Picking nearest'), + map: msg('Switching map'), + imagery: msg('Opening imagery'), draw: msg('Drawing outline'), catalog: msg('Searching imagery catalog'), drape: msg('Loading imagery'), @@ -1056,6 +1254,12 @@ export function progressLine(step, label) { return 'Loading aircraft there.'; case 'nearest': return 'Picking the nearest aircraft.'; + case 'map': + return place === 'photoreal' + ? 'Switching to Google 3D.' + : 'Switching the map.'; + case 'imagery': + return place ? `Looking for imagery at ${place}.` : null; case 'still': return place ? `Still working on ${place}.` : 'Still working on it.'; default: diff --git a/src/voice/speech.test.mjs b/src/voice/speech.test.mjs index d00e2d8bb..e1aa0c3ec 100644 --- a/src/voice/speech.test.mjs +++ b/src/voice/speech.test.mjs @@ -536,6 +536,238 @@ test('labels are bounded and cleaned before they are spoken', () => { ); }); +test('the street-level card says "the view centre" in the interface language', () => { + setLocale('de'); + try { + const out = envelope('show_street_level', { + ok: true, + providerName: 'Mapillary', + label: 'the view centre', + atViewCentre: true, + }); + assert.equal(out.display.lines[0], 'die Bildmitte'); + assert.match(out.say, /the view centre/, 'speech stays English'); + } finally { + setLocale('en'); + } +}); + +test('street-level failure cards are in the interface language; speech stays English', () => { + setLocale('de'); + try { + const card = (error, extra = {}) => + envelope('show_street_level', { + ok: false, + action: 'show_street_level', + providerName: 'Google Street View', + label: 'Paris', + error, + ...extra, + }); + const lines = (error, extra) => card(error, extra).display.lines; + assert.deepEqual(lines('Google Street View needs its key', { stage: 'key' }), [ + 'Google Street View braucht seinen Schlüssel', + ]); + assert.deepEqual( + lines('Google Street View needs the photoreal map, which is not available', { stage: 'map' }), + ['Google Street View braucht die Google 3D-Karte, die nicht verfügbar ist'], + ); + assert.deepEqual(lines('Mapillary could not be switched on', { stage: 'provider' }), [ + 'Mapillary konnte nicht eingeschaltet werden', + ]); + assert.deepEqual(lines('Street Level could not be enabled', { stage: 'layer' }), [ + 'Straßenebene konnte nicht eingeschaltet werden', + ]); + assert.deepEqual( + lines('Latitude must be within ±90° and longitude within ±180°', { stage: 'location' }), + ['Der Breitengrad muss innerhalb von ±90° und der Längengrad innerhalb von ±180° liegen'], + ); + assert.deepEqual(lines('Mapillary imagery could not be opened', { stage: 'imagery' }), [ + 'Bilder von Mapillary konnten nicht geöffnet werden', + ]); + // The filter clause wraps the reason, already in the interface language, + // and names the filters as their chips do. + const filtered = card('No Mapillary imagery near Paris with the 360° and SINCE filters on', { + stage: 'imagery', + noImagery: true, + filter: { label: '360° and SINCE filters' }, + }); + assert.deepEqual(filtered.display.lines, [ + 'Keine Bilder von Mapillary in der Nähe von Paris (aktive Filter: 360°, SEIT)', + ]); + assert.match(filtered.say, /with the 360° and SINCE filters on/, 'speech stays English'); + } finally { + setLocale('en'); + } +}); + +test('street-level speech names the source and the place, and says which step failed', () => { + const ok = envelope('show_street_level', { + ok: true, + action: 'show_street_level', + provider: 'google', + providerName: 'Google Street View', + label: '10th Street, Sacramento', + title: '1000 10th St, Sacramento, CA', + capturedAt: Date.UTC(2024, 4, 1), // Google dates: the first of the month, UTC + }); + assert.equal(ok.say, 'Google Street View at 1000 10th St, Sacramento, CA.'); + assertSpeakable(ok.say); + assert.equal(ok.display.title, 'Google Street View'); + assert.equal(ok.display.lines[1], 'Captured May 2024'); + + const stages = { + location: "Couldn't find Nowhere.", + map: "Google Street View needs the Google 3D map, which isn't available.", + key: 'Google Street View needs its key.', + layer: "Couldn't turn on Street Level.", + // A service or viewer failure, not missing coverage. + imagery: 'Google Street View could not open imagery near Nowhere.', + }; + for (const [stage, say] of Object.entries(stages)) { + const out = envelope('show_street_level', { + ok: false, + stage, + label: 'Nowhere', + providerName: 'Google Street View', + error: 'detail', + }); + assert.equal(out.say, say, stage); + assertSpeakable(out.say); + assert.deepEqual(out.display.lines, ['detail']); + } + const empty = envelope('show_street_level', { + ok: false, + stage: 'imagery', + noImagery: true, + label: 'Nowhere', + providerName: 'Mapillary', + }); + assert.equal(empty.say, 'No Mapillary imagery near Nowhere.'); + const sky = envelope('show_street_level', { + ok: false, + stage: 'imagery', + aim: 'sky', + atViewCentre: true, + label: 'the view centre', + providerName: 'Mapillary', + }); + assert.equal(sky.say, 'Aim the middle of the view at a street first.'); + assertSpeakable(sky.say); + const armed = envelope('show_street_level', { + ok: true, + armed: true, + providerName: 'Google Street View', + label: '10th St, Sacramento', + }); + assert.equal(armed.say, 'Google Street View is ready at 10th St, Sacramento: press OPEN to load it.'); + assertSpeakable(armed.say); + assert.deepEqual(armed.display.lines, ['10th St, Sacramento', 'Press OPEN to load the photo']); + const unfound = envelope('show_street_level', { + ok: false, + stage: 'location', + location: { ok: false, action: 'fly_to_location', label: 'Nowhere Town' }, + }); + assert.equal(unfound.say, "Couldn't find Nowhere Town."); + assert.equal(unfound.display.title, 'Street Level'); + const refused = envelope('show_street_level', { + ok: false, + stage: 'key', + keyRejected: true, + providerName: 'Google Street View', + }); + assert.equal(refused.say, 'Google Street View refused its key.'); + assert.equal( + envelope('show_street_level', { ok: false, cancelled: true }).say, + undefined, + ); + assert.equal( + planStepLabel('show_street_level', { + query: 'Eiffel Tower', + provider: 'google', + }), + 'Street View at Eiffel Tower', + ); + assert.equal( + planStepLabel('show_street_level', { + query: 'Times Square', + provider: 'mapillary', + }), + 'Mapillary at Times Square', + ); + assert.equal( + progressStepLabel('imagery', 'Eiffel Tower'), + 'Opening imagery: Eiffel Tower', + ); + assert.equal(progressLine('map', 'photoreal'), 'Switching to Google 3D.'); + assert.equal( + progressLine('imagery', 'Eiffel Tower'), + 'Looking for imagery at Eiffel Tower.', + ); +}); + +test('the capture month is a UTC month whatever the timezone, and labels name the place', async () => { + const { execFileSync } = await import('node:child_process'); + const speech = new URL('./speech.js', import.meta.url).href; + // Google dates a panorama 2024-05 as the first of the month, UTC. West of + // Greenwich that instant is still April; the card must still say May. + const out = execFileSync( + process.execPath, + [ + '--input-type=module', + '-e', + `import { attachVoiceResult } from ${JSON.stringify(speech)}; + const r = attachVoiceResult('show_street_level', { ok: true, providerName: 'Google Street View', label: 'x', capturedAt: Date.UTC(2024, 4, 1) }); + process.stdout.write(r.display.lines[1]);`, + ], + { env: { ...process.env, TZ: 'America/Los_Angeles' }, encoding: 'utf8' }, + ); + assert.equal(out, 'Captured May 2024'); + const { narrationLabel } = await import('./speech.js'); + assert.equal(narrationLabel('show_street_level', { query: 'Eiffel Tower' }), 'Eiffel Tower'); + assert.equal(narrationLabel('show_street_level', {}), ''); + assert.equal( + planStepLabel('show_street_level', { latitude: 1, longitude: 2 }), + 'Street level at this spot', + ); + assert.equal(planStepLabel('show_street_level', {}), 'Street level at the view centre'); +}); + +test('the plan step names KartaView as a source', () => { + assert.equal( + planStepLabel('show_street_level', { + query: 'Alexanderplatz', + provider: 'kartaview', + }), + 'KartaView at Alexanderplatz', + ); +}); + +test('an empty street-level search names the imagery filter that hid the photos', () => { + const out = envelope('show_street_level', { + ok: false, + stage: 'imagery', + label: 'Alexanderplatz', + providerName: 'KartaView', + filter: { pano: 'pano', label: '360° filter' }, + }); + assert.equal( + out.say, + 'No KartaView imagery near Alexanderplatz with the 360° filter on.', + ); + assertSpeakable(out.say); +}); + +test('the plan step names Panoramax as a source', () => { + assert.equal( + planStepLabel('show_street_level', { + query: 'Montmartre', + provider: 'panoramax', + }), + 'Panoramax at Montmartre', + ); +}); + test('progress step labels join through the catalog colon', () => { setLocale('fr'); try {