From 98de41c94a5b36b1a8c01565c94f3c58f109d56f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 18:27:43 +0000 Subject: [PATCH 01/28] feat(street-level): "show me X on Street View" action, street_level view field and sl= links show_street_level (voice and typed) flies to a place and opens its nearest street-level photo from Google Street View, Mapillary, KartaView or Panoramax; Street View switches to Google 3D first. The view model gains a street_level field, share links carry sl=,,, and show_in_gods_eye_view can open a photo. A plain sl=google link only arms the point and waits for OPEN, so it never bills a key without a click. Embed pages show an open photo in an overlay that Esc closes. On-screen strings are translated into es, ru, zh-CN and zh-Hant. Covered by unit tests and three new steps in the Street Level browser suite. --- README.md | 2 +- SECURITY.md | 1 + changelog.d/1113-show-street-level.md | 14 + docs/CURRENT-STATE.md | 51 +- docs/TOOLS.md | 25 +- scripts/qa-street-level.mjs | 244 +++++++ scripts/qa-voice-routing.mjs | 1 + scripts/voice-bench/phrases.mjs | 8 + server/providers/openai/instructions.js | 3 +- server/providers/openai/toolDescriptions.js | 22 + src/app/embed.js | 95 ++- src/app/embed.test.mjs | 474 ++++++++++++- src/firstRunExperience.test.mjs | 3 +- src/i18n/locales/ar.json | 10 +- src/i18n/locales/de.json | 10 +- src/i18n/locales/es.json | 8 + src/i18n/locales/fr.json | 10 +- src/i18n/locales/it.json | 10 +- src/i18n/locales/ja.json | 10 +- src/i18n/locales/pt.json | 10 +- src/i18n/locales/ru.json | 8 + src/i18n/locales/uk.json | 10 +- src/i18n/locales/zh-CN.json | 8 + src/i18n/locales/zh-Hant.json | 10 +- src/layers/streetLevel/index.js | 132 +++- src/layers/streetLevel/index.test.mjs | 671 ++++++++++++++++++ src/layers/streetLevel/lineCoverage.js | 20 + src/layers/streetLevel/lineCoverage.test.mjs | 64 ++ src/layers/streetLevel/policy.js | 8 + .../streetLevel/providers/google/index.js | 10 +- .../streetLevel/providers/google/policy.js | 2 +- .../providers/google/provider.test.mjs | 10 + .../streetLevel/providers/kartaview/index.js | 1 + .../streetLevel/providers/mapillary/index.js | 1 + .../streetLevel/providers/panoramax/index.js | 3 + .../providers/panoramax/provider.test.mjs | 6 + src/layers/streetLevel/registry.js | 4 +- src/layers/streetLevel/state.js | 2 + src/layers/streetLevel/viewerHost.js | 18 +- src/layers/streetLevel/viewerHost.test.mjs | 45 ++ src/locations.js | 6 + src/locations.test.mjs | 32 + src/radioMarkup.test.mjs | 9 +- src/streetLevelMarkup.test.mjs | 14 + src/tools/queries/share.js | 21 +- src/tools/queries/share.test.mjs | 101 +++ src/ui/applicationShell.js | 5 +- src/ui/setMapStackReason.test.mjs | 33 + src/ui/streetLevelControls.js | 44 ++ src/ui/streetLevelControls.test.mjs | 100 +++ src/ui/styles/embed.css | 16 + src/view/index.js | 171 ++++- src/view/index.test.mjs | 373 ++++++++++ src/voice/actionSchemas.js | 30 + src/voice/actionSchemas.test.mjs | 40 +- src/voice/deixis.js | 1 + src/voice/deixis.test.mjs | 7 + src/voice/gevActions.js | 364 ++++++++++ src/voice/gevActions.test.mjs | 533 ++++++++++++++ src/voice/layerManifest.js | 7 +- src/voice/layerManifest.test.mjs | 1 + src/voice/pointerReticle.js | 1 + src/voice/speech.js | 112 +++ src/voice/speech.test.mjs | 148 ++++ 64 files changed, 4147 insertions(+), 66 deletions(-) create mode 100644 changelog.d/1113-show-street-level.md create mode 100644 src/ui/setMapStackReason.test.mjs diff --git a/README.md b/README.md index 47cdf9535..cbc136b83 100644 --- a/README.md +++ b/README.md @@ -382,7 +382,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-two tools, 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 4423eead3..8c43bcee5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -129,6 +129,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, are that client's own request and open the photo; 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 armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. 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..36cf415df --- /dev/null +++ b/changelog.d/1113-show-street-level.md @@ -0,0 +1,14 @@ +- "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, reporting which step failed. + Views and share links carry the same request (`street_level`, `sl=`), so + `show_in_gods_eye_view` and a link can open a panorama at a place. Street + View lookups from a geocoded place search up to 250 m; clicks keep 50 m. A + plain link with `sl=google` arms Street View and leaves the billed load to a + press of OPEN GOOGLE STREET VIEW; embedded views open it. The source the + action lights stays lit like a chip click. An unavailable map stack now + reports the controller's own reason instead of always asking for a Cesium + ion token. When nothing is found and a 360°, flat or SINCE filter the source + honours is on, the answer names the filter (Said Turksever, @asturksever, + #1113). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 35d81dd05..fe1083dd3 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -828,7 +828,7 @@ Voice controls bind to a protocol-independent session factory. The default WebRT The standalone app offers OpenAI and Gemini Live through an explicit provider chooser. Gemini keeps its permanent API key on the local server and mints a short-lived, single-use credential with server-owned instructions and the standalone action catalog. Its native WebSocket adapter supports open microphone and push-to-talk input, streaming audio, captions and the existing action runner. In push-to-talk, a tool call that arrives with no input, reply or tool result outstanding is answered with a failure and never runs, and neither does any other call in the reply it starts; open-microphone turns start on Gemini's server-side activity detection, which reports no boundary to check. Switching providers ends the old conversation and releases its microphone and connection; only the input mode carries over. Gemini uses bounded shutdown draining and explicit manual restart, without automatic reconnection or a promise of uninterrupted session duration. Hosts that do not opt into the provider facade retain their existing default or custom adapter. -Voice action argument schemas have one portable owner under `src/voice/actionSchemas.js`. The Realtime provider builds 30 tools using separate description-only metadata. 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 32 tools using separate description-only metadata. Description customization cannot replace argument types, enum values or required fields. `createGevActionRunner` routes `control_cctv` enable/disable through the shared layer visibility lifecycle. Results distinguish successful changes from @@ -4328,6 +4328,53 @@ 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."** The `show_street_level` assistant action +(voice and typed; `src/voice/gevActions.js`) is one call: it checks the +source has its key (a keyless source explains itself and changes nothing, as +a chip does), flies to the place at street framing and waits for arrival (a +name, coordinates, a pointed spot, a numbered item, or nothing for the view +centre), switches to the map the source needs (Google Street View: Google 3D), +lights that source through `setLayerParams` with origin `voice` (one source at +a time, kept in the share link like a chip click), turns the layer on and +calls `openNearest`. `provider` is `google` for "Street View", `mapillary`, +`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `map`, +`provider`, `layer`, `key`, `imagery`; an empty `imagery` result names a +narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a +newer turn retires the lookup +(`openNearest`'s `signal` option), so nothing opens after the user moved on. +For a source without a coverage layer the lookup widens to 250 m +(`NEAREST_RADIUS_MAX_M`; Google honours it, Mapillary's API caps at 50 m); +clicks keep 50 m. The layer exposes `requiredMapStack(id)` and +`providerStatus(id)` for callers that must decide before the panel lists a +source, and `coverageLines(id)` (the lines a provider draws now, current and +stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate, +which aims its line clicks at that geometry, waits for the asynchronous +ground primitives to be ready, and clicks in the same JavaScript turn as the +pick so a rebuilt frame cannot come between them. Views carry the same request as `street_level` (`src/view/index.js`: +source and point, defaulting to where the camera looks; a `google` source +brings `map: photoreal` and refuses another map; not with `follow`), links as +`sl=,,`; `show_in_gods_eye_view` accepts it, the globe panel +applies it through the action, and a plain link runs the action once restored +(one shot: the rewritten hash drops `sl`). In embed mode the rail holding the +panel 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). A caller's abort is +honoured through the viewer's mount and load, not just the lookup, and only +the lookup that still owns the panel may change its spinner or error. A +passive link's photos stand down if anyone navigated (a gesture, a search, +the assistant) while the link waited behind its own marks. A plain +link never loads a photo from a source that bills every load +(`billedPerImage` on the provider; Google): the action runs with +`autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's +point and radius, consumed only by the next OPEN press (`aim: +'screen-centre'`); any other lookup, open, close, disable, its source +switched off or its map leaving drops it), opens the panel and a toast +points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline +embed) and posted views open the photo; `?embed=1` on a published link only +arms, without the panel nudge or toast (the rail is hidden in a frame). A link that follows an entity ignores `sl`. +The look-at default applies below 20 km, at a pitch steeper than -5° and +within 10 km ahead; otherwise the camera's own point is used. + 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 @@ -5361,7 +5408,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. - **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-two 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`, `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`, `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/TOOLS.md b/docs/TOOLS.md index 80fd80b13..2effba947 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -102,11 +102,26 @@ 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 +whichever the panel has lit) and a point, which defaults to the spot the camera looks at. Street View +shows only on the Google 3D map, so a `google` source sets `map` to +`photoreal` and any other map is refused; a view cannot both follow an entity +and open photos. The `street-level` layer is turned on. Links carry it as +`sl=,,`; once the link has restored, the app runs its +`show_street_level` action, which flies to the point at street framing and +opens the nearest photo. A plain link with a `google` source stops before the +billed load: the panel is armed and OPEN GOOGLE STREET VIEW is one click away. +A view posted into the embedded app, or the panel page's own opening link, +opens the photo, since it is the client's own request; `?embed=1` on a +published link does not. Mapillary, KartaView and Panoramax links open the +photo at once; none of them bills per image. 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. `resolveViewArguments` +turns them into a view. ### The God's Eye View panel @@ -299,5 +314,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/scripts/qa-street-level.mjs b/scripts/qa-street-level.mjs index 0dd841acf..12e350212 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( @@ -750,6 +781,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( 'keyless server (second page): KEY REQUIRED gates the controls', async () => { diff --git a/scripts/qa-voice-routing.mjs b/scripts/qa-voice-routing.mjs index 0d89b525a..cb75b32ba 100644 --- a/scripts/qa-voice-routing.mjs +++ b/scripts/qa-voice-routing.mjs @@ -889,6 +889,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 9d1bcc8ba..6fb6f5de8 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 acf576d13..471ee21ad 100644 --- a/server/providers/openai/instructions.js +++ b/server/providers/openai/instructions.js @@ -28,7 +28,7 @@ function realtimeInstructions( '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. "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, 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, 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 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.', @@ -55,6 +55,7 @@ function realtimeInstructions( '"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 23d5da9de..5225511be 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 21e685a91..a3dec5b84 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -13,12 +13,18 @@ import * as Cesium from 'cesium'; import { announceNavigationAuthority } from '../navigationPolicy.js'; -import { annotationsFromParams, createView } from '../view/index.js'; +import { + annotationsFromParams, + createView, + followFromParams, + 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', @@ -165,6 +171,26 @@ function flyTo(viewer, camera) { }); } +/** + * The `show_street_level` arguments for a view's street-level photos. A plain + * link (`autoOpen: false`) never loads a photo from a source that bills every + * load: the app arms the panel and the OPEN button is one click away. 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'; +const ARMED_TOAST = + 'Street View is ready: press OPEN GOOGLE STREET VIEW to load it'; + /** * 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 @@ -198,6 +224,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 }); } @@ -207,6 +235,18 @@ 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: the action flies to the point at street + // level, lights the source and opens the nearest photo. When it fails + // before flying, the view's own camera still applies. + const result = await act( + 'show_street_level', + streetLevelArgs(view.street_level), + ); + if (result?.ok !== true && !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; @@ -254,12 +294,56 @@ export function installViews({ 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(/^#/, ''), ); + // What the link itself asks for runs first, one thing at a time; posted + // views queue behind it. One failing step does not take the next with it. + // The camera's owner at restore: anyone who navigates while the link's + // steps wait (a gesture, a search, the assistant) takes it, and a passive + // link must not fly over them. + let generationAtRestore = null; + let queue = ready.then(() => { + generationAtRestore = shell._navigationGeneration; + }); + const navigatedSinceRestore = () => + Number.isFinite(generationAtRestore) && + shell._navigationGeneration !== generationAtRestore; + const linkStep = (work) => + queue.then(async () => { + if (signal?.aborted) return; + try { + await work(); + } catch { + /* reported by the action itself */ + } + }); + const linked = annotationsFromParams(hashParams); if (linked.length) - void ready.then(() => { - if (!signal?.aborted) void run('annotate_map', { annotations: linked }); + queue = linkStep(() => run('annotate_map', { annotations: linked })); + // A link to street-level photos opens them once the link has restored. A + // link anyone can publish stops short of a billed load and leaves it to a + // click; only the panel page's own link (inline embed) is the client's + // request. A link that follows an entity keeps the camera on it. + const streetLevel = followFromParams(hashParams) + ? null + : streetLevelFromParams(hashParams); + if (streetLevel) + queue = linkStep(async () => { + if (navigatedSinceRestore()) return; + const result = await run( + 'show_street_level', + streetLevelArgs(streetLevel, { autoOpen: isEmbeddedInline() }), + ); + // The restore window keeps the panel collapsed; OPEN must be in view. + // Embed mode hides the rail that holds it, so there the link only arms + // (a posted view is the way to open a photo in a frame). + if (result?.armed === true && !isEmbedded(location)) { + shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { + explicit: true, + }); + shell._showToast?.(ARMED_TOAST); + } }); if (!isEmbedded(location)) return () => {}; @@ -278,7 +362,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; diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 5fa949342..f7aae019e 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 = []; @@ -393,3 +393,475 @@ 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. + 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], [[], []]); +}); + +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 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 { 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]), + }, + 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 }]], + 'the panel opens so OPEN is in view', + ); + // 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 (inline embed) is the client's request. + globalThis.GEV_EMBED_INLINE = true; + try { + 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)); + assert.equal('autoOpen' in inline.calls[0][1], false); + stop(); + } finally { + delete globalThis.GEV_EMBED_INLINE; + } + // 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']); +}); diff --git a/src/firstRunExperience.test.mjs b/src/firstRunExperience.test.mjs index 87229e5c5..6603382c4 100644 --- a/src/firstRunExperience.test.mjs +++ b/src/firstRunExperience.test.mjs @@ -982,8 +982,9 @@ 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) => tool.name !== 'set_cyber_sonar', + (tool) => !['set_cyber_sonar', 'show_street_level'].includes(tool.name), ); const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud') .parameters.properties.layout; diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index e2e05348d..faed66510 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3222,5 +3222,13 @@ "Audio": "الصوت", "UTILITIES": "الأدوات", "Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم", - "Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة" + "Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة", + "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": "جارٍ فتح الصور" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 0abaa4ddf..d98a33930 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2942,5 +2942,13 @@ "Audio": "Audio", "UTILITIES": "WERKZEUGE", "Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen", - "Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien" + "Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien", + "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" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 65997812d..8d530a7b4 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -906,6 +906,8 @@ "Bike Share": "Bicis compartidas", "Cameras": "Cámaras", "Street Level": "A pie de calle", + "Press OPEN to load the photo": "Pulse ABRIR para cargar la foto", + "Captured {month}": "Capturada en {month}", "Mapped ALPR Cameras": "Cámaras ALPR mapeadas", "Data Centers": "Centros de datos", "Active Fires": "Incendios activos", @@ -1745,6 +1747,10 @@ "Mark {targets}": "Marcar: {targets}", "Fly to {place}": "Volar a {place}", "Nearest aircraft to {place}": "Aeronave más cercana a {place}", + "{source} at {place}": "{source} en {place}", + "Street level at {place}": "A pie de calle en {place}", + "this spot": "este punto", + "the view centre": "el centro de la vista", "Read the view": "Consultar la vista", "Check view state": "Comprobar el estado de la vista", "Frame {target}": "Encuadrar {target}", @@ -1752,6 +1758,8 @@ "Tracing outline": "Trazando contorno", "Looking up place": "Buscando lugar", "Flying": "Volando", + "Switching map": "Cambiando de mapa", + "Opening imagery": "Abriendo imágenes", "Turning on layer": "Activando capa", "Loading aircraft": "Cargando aeronaves", "Picking nearest": "Eligiendo la más cercana", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0d1460e1d..5e7457e76 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3012,5 +3012,13 @@ "Audio": "Audio", "UTILITIES": "OUTILS", "Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin", - "Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers" + "Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers", + "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" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index d460440e2..cf642ae4e 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3012,5 +3012,13 @@ "Audio": "Audio", "UTILITIES": "STRUMENTI", "Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno", - "Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file" + "Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file", + "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" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 237c55f6c..e41b78411 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2872,5 +2872,13 @@ "Audio": "オーディオ", "UTILITIES": "ユーティリティ", "Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください", - "Directions, drawing and your own files": "ルート案内、描画、自分のファイル" + "Directions, drawing and your own files": "ルート案内、描画、自分のファイル", + "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": "画像を開いています" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 25f53f162..e6ccfd9f8 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3012,5 +3012,13 @@ "Audio": "Áudio", "UTILITIES": "UTILITÁRIOS", "Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho", - "Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos" + "Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos", + "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" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index f3217b079..aa5f47c26 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -915,6 +915,8 @@ "Bike Share": "Прокат велосипедов", "Cameras": "Камеры", "Street Level": "Уличные снимки", + "Press OPEN to load the photo": "Нажмите ОТКРЫТЬ, чтобы загрузить снимок", + "Captured {month}": "Снято: {month}", "Mapped ALPR Cameras": "Камеры ALPR на карте", "Data Centers": "Дата-центры", "Active Fires": "Активные пожары", @@ -1778,6 +1780,10 @@ "Mark {targets}": "Отметить {targets}", "Fly to {place}": "Перелёт: {place}", "Nearest aircraft to {place}": "Ближайший самолёт: {place}", + "{source} at {place}": "{source}: {place}", + "Street level at {place}": "Уличные снимки: {place}", + "this spot": "эта точка", + "the view centre": "центр вида", "Read the view": "Осмотреть видимую область", "Check view state": "Проверить состояние вида", "Frame {target}": "Показать в кадре: {target}", @@ -1785,6 +1791,8 @@ "Tracing outline": "Построение контура", "Looking up place": "Поиск места", "Flying": "Перелёт", + "Switching map": "Смена карты", + "Opening imagery": "Открытие снимков", "Turning on layer": "Включение слоя", "Loading aircraft": "Загрузка самолётов", "Picking nearest": "Выбор ближайших", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 720b0a7bd..2fbc3db48 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3082,5 +3082,13 @@ "Audio": "Аудіо", "UTILITIES": "ІНСТРУМЕНТИ", "Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка", - "Directions, drawing and your own files": "Маршрути, малювання та ваші файли" + "Directions, drawing and your own files": "Маршрути, малювання та ваші файли", + "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": "Відкриття знімків" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b9d8799c4..7978bff4d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1737,6 +1737,8 @@ "Bike Share": "共享单车", "Cameras": "摄像头", "Street Level": "街景", + "Press OPEN to load the photo": "点击“打开”加载照片", + "Captured {month}": "拍摄于 {month}", "Mapped ALPR Cameras": "地图标注 ALPR 摄像头", "Data Centers": "数据中心", "Active Fires": "活跃火点", @@ -2416,6 +2418,10 @@ "Fly to {place}": "飞往 {place}", "Fly to Location": "飞往地点", "Nearest aircraft to {place}": "距离 {place} 最近的飞机", + "{source} at {place}": "{place} 的 {source}", + "Street level at {place}": "{place} 的街景", + "this spot": "此处", + "the view centre": "视图中心", "Nearest aircraft to Here": "距离此处最近的飞机", "Layer on": "图层已开启", "Layer off": "图层已关闭", @@ -2428,6 +2434,8 @@ "Tracing outline": "正在绘制轮廓", "Looking up place": "正在查询地点", "Flying": "飞行中", + "Switching map": "正在切换地图", + "Opening imagery": "正在打开影像", "Turning on layer": "正在开启图层", "Loading aircraft": "正在加载飞机", "Picking nearest": "正在选取最近目标", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 06ce0fcaa..a0760dc1b 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2872,5 +2872,13 @@ "Right panels": "右側面板", "UTILITIES": "工具", "Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置", - "Directions, drawing and your own files": "路線導航、繪製和你自己的文件" + "Directions, drawing and your own files": "路線導航、繪製和你自己的文件", + "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": "正在打開影像" } diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 1f7b0f7f0..409fdf384 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -21,6 +21,7 @@ import { AIM_AT_GROUND_MESSAGE, keyStatusLabel, NEAREST_RADIUS_M, + NEAREST_RADIUS_MAX_M, POSITION_PICK_ID, STREET_LEVEL_LAYER_ID, } from './policy.js'; @@ -157,6 +158,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)) @@ -275,8 +282,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; @@ -284,7 +297,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) { @@ -330,6 +343,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); @@ -466,6 +482,37 @@ 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; + }, + /** The coverage lines a provider draws now (see `coverageLines`). */ + coverageLines(providerId) { + return state.providers.get(providerId)?.instance.coverageLines?.() ?? []; + }, + /** + * 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 { @@ -612,13 +659,34 @@ 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 + * under the screen centre (sky: nothing). `radiusM` widens the search for + * providers that can (a geocoded landmark is rarely within 50 m of a + * street); clicks keep the default. A caller's `signal` retires the + * lookup: nothing opens after it fires. Providers without * `nearestImage` are skipped. */ async openNearest( point, - { providerIds = null, frame = true, aim = 'view' } = {}, + { + providerIds = null, + frame = true, + aim = 'view', + radiusM = null, + signal: callerSignal = null, + } = {}, ) { + if (callerSignal?.aborted) return false; + // 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 = state.street.armed; + state.street.armed = null; + if (!point && aim === 'screen-centre' && armed) { + 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(); @@ -637,6 +705,21 @@ 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); + // Only the lookup that still owns the panel may touch it: a newer one + // has its own spinner and error. + if (nearestLookup !== lookup) return false; + nearestLookup = null; + // The caller moved on: the panel shows nothing of this lookup. + if (state.street.loading && !state.street.open) { + state.street.loading = false; + notify(); + } + return false; + }; state.street.loading = true; state.street.error = null; notify(); @@ -651,23 +734,35 @@ 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; // One provider failing (no key, offline) must not hide the others. lastError = error; continue; } + if (callerSignal?.aborted) return retired(); if (signal.aborted) return false; if (!state.enabled) break; // Switched off, or its map left, while it looked: try the next. if (!imageId || !entry.on || !providerAvailable(entry)) continue; nearestLookup = null; - return openImage(entry.def.id, imageId, { frame }); + callerSignal?.removeEventListener('abort', onCallerAbort); + // 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; + nearestLookup = null; // Switched off while it looked: nothing to open, nothing to report. const stillOn = entries.some( (entry) => entry.on && providerAvailable(entry), @@ -679,11 +774,13 @@ export function createStreetLevelLayer({ 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(); @@ -695,6 +792,23 @@ export function createStreetLevelLayer({ parts.viewerHost.close(); parts.clearSequences(); }, + /** + * Prepare a nearest lookup for the next OPEN press without running it: a + * link to a source that bills every load stops here. The press opens at + * this point with this radius; 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 6c79a52ca..a82dc53c5 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -12,6 +12,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 @@ -1118,6 +1121,674 @@ 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 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('coverageLines hands back what a provider draws, and nothing for providers without lines', async (t) => { + const drawing = fakeProvider(); + const create = drawing.create; + drawing.create = (context) => ({ + ...create(context), + coverageLines: () => [ + { + id: 's1', + parts: [ + [ + [1, 2], + [1, 2.001], + ], + ], + }, + ], + }); + const { layer } = await enabledLayer(t, [drawing]); + assert.deepEqual(layer.coverageLines(drawing.id), [ + { + id: 's1', + parts: [ + [ + [1, 2], + [1, 2.001], + ], + ], + }, + ]); + assert.deepEqual(layer.coverageLines('nope'), []); +}); + +test('coverageLines is empty for a provider that draws no coverage', async (t) => { + const { layer } = await enabledLayer(t, [fakeProvider()]); + assert.deepEqual(layer.coverageLines(fakeProvider().id), []); +}); + +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); +}); + +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'); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index bab1af46d..b71fe7cd7 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -531,6 +531,25 @@ export function createLineCoverage({ }; } + /** + * The sequence lines drawn now, as `[lon, lat]` parts per sequence: for + * diagnostics and the browser gate, which aims its clicks at them instead + * of scanning pixels: what each tile drew (`attachPrimitive` keeps it in + * `entry.sequences`). Read-only copies. + */ + function drawnLines() { + const lines = []; + for (const entry of state.coverage.tiles.values()) + for (const sequence of entry.sequences.values()) + lines.push({ + id: sequence.id, + parts: (sequence.parts || []).map((part) => + part.map(([lon, lat]) => [lon, lat]), + ), + }); + return lines; + } + return { attach, detach, @@ -543,5 +562,6 @@ export function createLineCoverage({ highlight, sequenceCount, stats, + drawnLines, }; } diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index 60bae7eb3..dd9282119 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -654,3 +654,67 @@ test('the selected sequence is drawn and highlighted even past the per-tile cap' assert.ok(state.coverage.highlight, 'the selected line is highlighted'); coverage.clear(); }); + +test('drawnLines reports the lines on the globe as [lon, lat] copies, filtered like the drawing', async () => { + const { source, state, coverage, centre } = setup(); + assert.deepEqual(coverage.drawnLines(), [], 'nothing before a tile'); + const sequences = [ + { + id: 'flat-1', + parts: [ + [ + [centre.lon - 0.001, centre.lat], + [centre.lon + 0.001, centre.lat], + ], + ], + capturedAt: 2_000_000_000_000, + }, + { + id: 'pano-1', + parts: [ + [ + [centre.lon, centre.lat - 0.001], + [centre.lon, centre.lat], + [centre.lon, centre.lat + 0.001], + ], + ], + capturedAt: 1_000_000_000_000, + isPano: true, + }, + ]; + state.filter = { pano: 'pano', sinceDays: 0 }; + coverage.refresh(); + source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences })); + await settle(); + const lines = coverage.drawnLines(); + assert.deepEqual( + lines.map((line) => line.id), + ['pano-1'], + 'only the drawn (filtered) lines', + ); + assert.equal(lines[0].parts.length, 1); + assert.equal(lines[0].parts[0].length, 3); + // Tile coordinates are quantised to the tile grid (a few metres at z14). + const [midLon, midLat] = lines[0].parts[0][1]; + assert.ok( + Math.abs(midLon - centre.lon) < 1e-4 && + Math.abs(midLat - centre.lat) < 1e-4, + `coordinates as [lon, lat]: ${midLon}, ${midLat} vs ${centre.lon}, ${centre.lat}`, + ); + assert.ok( + lines[0].parts[0][0][1] < lines[0].parts[0][2][1], + 'vertex order kept: south to north', + ); + // Copies: a caller cannot bend the drawn line. + lines[0].parts[0][1][0] = 0; + assert.notEqual(coverage.drawnLines()[0].parts[0][1][0], 0); + state.filter = { pano: 'all', sinceDays: 0 }; + coverage.rebuild(); + assert.deepEqual( + coverage + .drawnLines() + .map((line) => line.id) + .sort(), + ['flat-1', 'pano-1'], + ); +}); 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/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 6e75f967a..523f6920c 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -535,6 +535,7 @@ export function createKartaViewProvider({ loading: state.sequence.loading, error: state.sequence.error, }), + coverageLines: () => coverage.drawnLines(), /** * The nearest photo within the search radius that can be shown, or diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 928922195..901df886c 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -191,6 +191,7 @@ export function createMapillaryProvider({ source, photoViewer = null }) { loading: state.sequence.loading, error: state.sequence.error, }), + coverageLines: () => parts.coverage.drawnLines(), viewer: photoViewer || diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index e855c9c72..805d304b9 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -526,6 +526,9 @@ export function createPanoramaxProvider({ cones.clearSelection(); drawn = null; }, + // The lines drawn now (current and stale tiles), as [lon, lat] + // parts, for diagnostics and the browser gate's line clicks. + coverageLines: () => coverage.drawnLines(), sequenceStats: () => ({ selectedId: state.sequence.selectedId, images: state.sequence.images.length, diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index 628805ce1..4ad4e9655 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -1182,3 +1182,9 @@ test('a busy or timed-out tile keeps its own message; only a body that is not a assert.equal(env.instance.sequenceStats().error, failure.message); } }); + +test('coverageLines exposes the drawn lines, none before any tile', () => { + const { instance } = setup(); + assert.equal(typeof instance.coverageLines, 'function'); + assert.deepEqual(instance.coverageLines(), []); +}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index 5e51929a6..2c3fe42ca 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 {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin. * @property {Array<'pano'|'since'>} [filters] Filters it honours (default both). * @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos"). @@ -48,7 +49,8 @@ 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 {() => Array<{id: string, parts: Array>}>} [coverageLines] The coverage lines drawn now, as [lon, lat] parts (diagnostics and the browser gate). + * @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..7408b7dee 100644 --- a/src/layers/streetLevel/state.js +++ b/src/layers/streetLevel/state.js @@ -29,6 +29,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..f56d2487f 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -99,12 +99,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 +120,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 +147,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(); } 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/radioMarkup.test.mjs b/src/radioMarkup.test.mjs index 74a44e77d..552d3b417 100644 --- a/src/radioMarkup.test.mjs +++ b/src/radioMarkup.test.mjs @@ -25,11 +25,12 @@ const css = readStylesheet(new URL('../style.css', import.meta.url)); function realtimeTools() { return GEV_REALTIME_TOOLS; } -test('Realtime schema exposes the authoritative 31-tool inventory', () => { +test('Realtime schema exposes the authoritative 32-tool inventory', () => { const tools = realtimeTools(); - assert.equal(tools.length, 31); + assert.equal(tools.length, 32); const names = tools.map((tool) => tool.name); - assert.equal(new Set(names).size, 31, 'tool names are unique'); + assert.equal(new Set(names).size, 32, 'tool names are unique'); + 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')); @@ -199,6 +200,8 @@ test('no unchanged Realtime tool definition drifts silently', () => { 'track_entity', // Point-and-ask: 'pointer' sentinels and referent arguments. 'annotate_map', + // 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/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 7e2e32314..602a3b065 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -115,3 +115,17 @@ 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); + // 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/tools/queries/share.js b/src/tools/queries/share.js index 5284e8a06..3de7902b0 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -47,10 +47,26 @@ 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 moves a passed-through view's photos with it. + let streetLevel = + args.street_level && typeof args.street_level === 'object' + ? { ...args.street_level } + : base.street_level && typeof base.street_level === 'object' + ? args.area + ? { 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 = @@ -84,6 +100,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 +130,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, diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs index 7be532150..aac74a776 100644 --- a/src/tools/queries/share.test.mjs +++ b/src/tools/queries/share.test.mjs @@ -113,3 +113,104 @@ 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); + + 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 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, + }); + 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, + }); + 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 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 c3ad27c14..2dd75cd3b 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1318,9 +1318,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/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/streetLevelControls.js b/src/ui/streetLevelControls.js index d680bc00f..001157f7c 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') @@ -293,6 +307,32 @@ export class StreetLevelControls { this._requestResize(); } + /** + * Embed mode hides the right rail, panel and viewer with it: an open photo + * moves to as an overlay on the globe (`sl-viewer-wrap-embed`, see + * embed.css) and returns to its place in the panel when it closes. The + * panel view keeps patching the wrap where it is: its siblings are static, + * so Vue never inserts next to it. + */ + _syncEmbedOverlay(open) { + const wrap = this._elements.viewerWrap; + if (!wrap || !document.body) return; + const lift = open === true && isEmbedPage(); + 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; @@ -350,6 +390,9 @@ export class StreetLevelControls { const open = view.viewer.open; if (open && !this._wasOpen) this.setCollapsed(false, { persist: false }); this._wasOpen = open; + // Embed mode hides the rail the panel sits in: an open photo is shown over + // the globe instead, and goes back into the panel when it closes. + this._syncEmbedOverlay(open); } destroy() { @@ -362,6 +405,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..9a38c0b98 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -921,3 +921,103 @@ 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('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); + })); diff --git a/src/ui/styles/embed.css b/src/ui/styles/embed.css index 5528a76da..a376ac5da 100644 --- a/src/ui/styles/embed.css +++ b/src/ui/styles/embed.css @@ -13,3 +13,19 @@ 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; +} + +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/view/index.js b/src/view/index.js index 022914277..5caecdd70 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,63 @@ 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. + */ +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), + lon: lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon, + }; +} + /** Annotation kinds the app draws, as in its annotate_map action. */ export const VIEW_ANNOTATION_TYPES = Object.freeze([ 'pin', @@ -162,6 +220,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 +324,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 +338,37 @@ 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. + const seen = lookAtPoint(camera); + const lat = latitude(streetLevel.lat) ?? seen.lat; + const lon = longitude(streetLevel.lon) ?? seen.lon; + 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 +397,7 @@ export function createView({ .map(annotationOf) .filter(Boolean), ), + street_level: street, }); } @@ -321,8 +423,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 +468,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 +512,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 +532,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..1389b058b 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -127,6 +127,379 @@ 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); +}); + +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 9ec47028c..3e19d25a0 100644 --- a/src/voice/actionSchemas.js +++ b/src/voice/actionSchemas.js @@ -103,6 +103,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 d15a7c25e..9bf2caaaa 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -38,21 +38,38 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea // consolidated tool wording (each policy stated once); model-facing tools // omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves // requested radius and center authority in descriptions only. - // Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts. - // Air quality (ECCC AQHI) extends the manifest. - // Globe rotation adds one motion; visual keyhole fit and explicit - // reset guidance/defaults update only move_camera and mode descriptions. - // S2 GhostMaps extends the generated layer enums. - // The Utilities panel (Directions, Draw, Import) adds one panel target. - // Panel adds named voice targets and timeout; shared Panel/Cockpit guidance - // is consolidated without changing either feature's arguments. - // Snow leaves the set_visual_style enum. - // The Operator HUD layout leaves set_hud's layout enum. - // The layer alias hint leaves out aliases the layer id already spells. + // Street Level, GNSS and air quality (ECCC AQHI) extend the manifest; + // tracked and analyst aircraft describe broadcasts; globe rotation adds + // one motion; S2 GhostMaps, the Utilities panel target and the voice panel + // (named targets, timeout) follow; Snow leaves the set_visual_style enum, the Operator HUD layout + // leaves set_hud's layout enum and the layer alias hint leaves out aliases + // the layer id already spells; show_street_level (map, source, photo) is + // the only change on top of main. + SHOW_STREET_LEVEL_PAYLOAD_DIGEST, + ); + // Every shipped tool is byte-identical to the previous release: the new + // tool is the whole difference, so a later edit cannot hide in a re-pin. + const shipped = createHash('sha256') + .update( + JSON.stringify( + stable( + GEV_REALTIME_TOOLS.filter( + (tool) => + !['set_cyber_sonar', 'show_street_level'].includes(tool.name), + ), + ), + ), + ) + .digest('hex'); + assert.equal( + shipped, 'dc2bac1be93a503dc88d637beea2f1bc457e1f9167422bd91548b44756a8aca5', ); }); +const SHOW_STREET_LEVEL_PAYLOAD_DIGEST = + '2198c1f95cfbc19e89ba8814ad0fd8303a74a7b254703f2e7a4893f5798d9aed'; + test('descriptions customize wording without changing immutable shared arguments', () => { const descriptions = { fly_to_location: { @@ -112,6 +129,7 @@ test('all legacy action arguments are byte-identical after removing the delibera 'next_satellite_pass', 'set_cyber_sonar', 'set_voice_inactivity_timeout', + '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 25d7939eb..0c21f297e 100644 --- a/src/voice/deixis.js +++ b/src/voice/deixis.js @@ -330,6 +330,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..cd7f7374d 100644 --- a/src/voice/deixis.test.mjs +++ b/src/voice/deixis.test.mjs @@ -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 70636dd4e..e2c4cf964 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -34,6 +34,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, @@ -731,6 +735,18 @@ export function createGevActionRunner({ }; } + if (name === 'show_street_level') { + return showStreetLevel(args, { + dataManager, + styleManager, + placeSearch, + runGevAction, + runOptions, + current, + reportProgress, + }); + } + if (name === 'set_visual_style') { const style = normalizeStyle(args.style); if (!style) @@ -2582,6 +2598,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. @@ -2589,6 +2628,7 @@ function stopAllTracking(viewer, dataManager) { * detection so the framed entities are labeled, and reports * detectionEnabled so the voice agent can mention labels are on. */ + async function frameOverhead(viewer, dataManager, styleManager, args = {}) { const targetRaw = String(args.target || 'flights').toLowerCase(); const layerId = @@ -3245,6 +3285,311 @@ function normalizeStyle(value) { return null; } +/** + * "Show me on Street View / on Mapillary": fly to the place, make sure + * the imagery source's map is up, light that source in Street Level, turn the + * layer on and open the nearest image. One source at a time, as the panel + * does; the choice persists like a chip click. The key is checked before + * anything changes, and each stage reports its own failure so speech can say + * which part did not happen. With no place at all, the view centre is used. + * `autoOpen: false` (links, not the model) stops short of loading a photo + * from a source that bills every load: the panel is armed and the OPEN + * button is one click away. + */ +export async function showStreetLevel( + args, + { + dataManager, + styleManager, + placeSearch, + runGevAction, + runOptions = {}, + current = () => true, + reportProgress = () => {}, + }, +) { + const action = 'show_street_level'; + 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 } : {}), + ...extra, + }); + const query = String(args.query || '').trim(); + const latitude = Number(args.latitude); + const longitude = Number(args.longitude); + const hasCoordinates = + args.latitude != null && + args.longitude != null && + Number.isFinite(latitude) && + Number.isFinite(longitude); + // 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, on any map: a chip without a key explains it and switches + // nothing, and so does this. + 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 || [], + }); + + // 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'; + const atViewCentre = !query && !hasCoordinates && !entity; + let point = null; + let location = null; + if (args.fly === false || atViewCentre) { + if (hasCoordinates) point = { lat: latitude, lon: longitude }; + else if (entity) { + // Where it is now, as the flying path does: the coordinates a list + // saved go stale as soon as the item moves or unloads. + const located = locateLayerEntity(dataManager, entity); + if (!located) return fail('location', `${label} is no longer loaded`); + point = { lat: located.lat, lon: located.lon }; + } else if (query) { + reportProgress('search', query); + const outcome = await placeSearch.geocode(query, { + signal: runOptions.signal, + }); + const place = outcome?.place; + if (!current()) + return fail('location', 'Street-level request was cancelled'); + if (!Number.isFinite(place?.lat) || !Number.isFinite(place?.lng)) + return fail('location', `Could not find ${query}`); + point = { lat: place.lat, lon: place.lng }; + label = place.label || query; + } + // No place at all: the layer looks under the view centre. + } else { + location = await runGevAction( + 'fly_to_location', + { + ...(entity + ? { entity } + : hasCoordinates + ? { latitude, longitude } + : { query }), + viewMode: 'close', + waitForArrival: true, + }, + runOptions, + ); + 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 keeps its name: the flight to it reports only coordinates. + label = entity ? 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 }; + } + + // 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 }, + ); + } + + // Light the source the way a chip click does, so the choice is kept. + 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: 'voice', + }) !== 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: !current() || Boolean(layer?.cancelled), layer, location }, + ); + } + + // 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 billed source never loads from a link without a click: everything is + // ready and OPEN is one press away at the view centre. + if (args.autoOpen === false && status?.billed === true) { + // The next OPEN press loads exactly what the link asked for. + 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 } : {}), + }; + } + + // A source with no coverage to click (Street View) is searched wider: a + // geocoded landmark is rarely within 50 m of a street. A newer turn + // retires the lookup: nothing opens after the user moved on. + reportProgress('imagery', label); + const opened = await layerModule.openNearest(point, { + providerIds: [provider], + frame: false, + ...(runOptions.signal ? { signal: runOptions.signal } : {}), + ...(snapshot.groundClick === true + ? { radiusM: STREET_LEVEL_WIDE_RADIUS_M } + : {}), + }); + const street = layerModule.getUIState?.()?.street || {}; + // A cancelled request opens nothing (the lookup and the viewer both honour + // the turn's signal); a photo that did open is reported as open. + if (opened !== true && !current()) + return fail('imagery', 'Street-level request was cancelled', { + cancelled: true, + location, + }); + 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 }, + ); + // 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, ...(filter ? { filter } : {}) }, + ); + } + 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, + ...(location ? { location: location.label } : {}), + }; +} + async function flyToRequestedLocation( viewer, args, @@ -3309,11 +3654,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' @@ -3374,6 +3733,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, }; @@ -3404,6 +3764,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 f11abc5ae..ec0c76e5d 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -20,6 +20,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'; import { attachVoiceResult } from './speech.js'; @@ -3987,6 +3988,538 @@ 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, +} = {}) { + const order = []; + const { viewer, styleManager } = createVoiceNavigationHarness(); + 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; + return { ok: true, activeStack: id, error: null }; + }; + let params = { ...lit }; + let keyRejected = keyRejectedAtStart; + const street = { 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 }, + }), + armNearest(point, options) { + order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`); + }, + async openNearest(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; + } + } + if (!nearest) { + street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`; + return false; + } + 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 }) { + 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) { + order.push(`enable:${id}`); + if (enableResult === false) return false; + enabled = true; + return true; + }, + getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }], + }; + const runner = createGevActionRunner({ viewer, styleManager, dataManager }); + return { runner, order, finishFlight: () => completeFlight?.(), layer, dataManager }; +} + +/** 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); +}); + +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 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']); + // Without a flight the entity's own position is used. + const still = withCamera(createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' })); + const kept = await still.runner('show_street_level', { + provider: 'google', + fly: false, + entity: { layerId: 'cctv', id: 'cam-9', lat: 38.58, lon: -121.5 }, + }); + assert.equal(kept.ok, true, kept.error); + assert.deepEqual(still.order, ['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 without a flight geocodes a name, locates an item, and reports what it cannot place', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const hit = createStreetLevelHarness({ enabled: true, activeStack: 'osm' }); + const points = []; + const open = hit.layer.openNearest; + hit.layer.openNearest = (point, options) => { + points.push(point); + return open(point, options); + }; + const runner = createGevActionRunner({ + viewer: createVoiceNavigationHarness().viewer, + styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) }, + dataManager: hit.dataManager, + placeSearch: { + geocode: async (query) => + query === 'Capitol Mall' + ? { place: { lat: 38.5792, lng: -121.4958, label: 'Capitol Mall, Sacramento' } } + : { place: null, answered: true }, + }, + }); + const found = await runner('show_street_level', { query: 'Capitol Mall', provider: 'mapillary', fly: false }); + assert.equal(found.ok, true, found.error); + assert.equal(found.label, 'Capitol Mall, Sacramento'); + assert.deepEqual(points.at(-1), { lat: 38.5792, lon: -121.4958 }); + assert.equal(found.flew, false); + + const miss = await runner('show_street_level', { query: 'Atlantis Boulevard', provider: 'mapillary', fly: false }); + assert.deepEqual([miss.ok, miss.stage], [false, 'location']); + assert.equal(miss.say, "Couldn't find Atlantis Boulevard."); + assert.equal(points.length, 1, 'a miss asks for no imagery'); + + const gone = await runner('show_street_level', { + provider: 'mapillary', fly: false, entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7' }, + }); + assert.deepEqual([gone.ok, gone.stage], [false, 'location']); + assert.equal(gone.say, "Couldn't find Camera 7."); + + // 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('show_street_level without a flight needs the item where it is now, not where a list saw it', async () => { + globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; + const harness = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); + // The referent carries the coordinates the list saved, but it is no longer loaded. + const stale = await harness.runner('show_street_level', { + provider: 'google', + fly: false, + entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7', lat: 38.58, lon: -121.5 }, + }); + assert.deepEqual([stale.ok, stale.stage], [false, 'location']); + assert.match(stale.error, /no longer loaded/); + assert.ok(!harness.order.some((entry) => entry.startsWith('open:')), 'nothing looked up at the stale point'); +}); + +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('set_panel_open accepts the Authoring name for the scene panel', async () => { const calls = []; const runner = createGevActionRunner({ diff --git a/src/voice/layerManifest.js b/src/voice/layerManifest.js index 962bf6949..44801dc0a 100644 --- a/src/voice/layerManifest.js +++ b/src/voice/layerManifest.js @@ -352,7 +352,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/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 a0f65b573..f481d1338 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -24,6 +24,7 @@ import { analystHeadline } from './resultDisplay.js'; import { + formatDate, formatNumber, getLocale, msg, @@ -564,6 +565,87 @@ 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'); + const shown = 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 === 'imagery') + say = result?.filter?.label + ? `No ${source} imagery near ${place} with the ${result.filter.label} on.` + : `No ${source} 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}`, @@ -771,6 +853,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, @@ -826,6 +909,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) { @@ -897,6 +988,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 = @@ -940,6 +1042,8 @@ export function narrationLabel(name, args = {}) { presetPlaceName(args.locationQuery || args.locationId || ''), 32, ); + case 'show_street_level': + return shownPlace(args.query || '', 32); default: return ''; } @@ -954,6 +1058,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'), }); /** On-screen label for a progress step. */ @@ -988,6 +1094,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 04b04d9b4..eaaaed4cb 100644 --- a/src/voice/speech.test.mjs +++ b/src/voice/speech.test.mjs @@ -489,6 +489,154 @@ test('labels are bounded and cleaned before they are spoken', () => { ); }); +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.", + imagery: 'No Google Street View 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 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 { From ed2703d345e88f702f0d59e07f821ff82ee4b60b Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 18:27:43 +0000 Subject: [PATCH 02/28] feat(street-level): restore Mapillary's nearest-image lookup for show_street_level and sl= links The slim Street Level core (#974) removed nearestImage from the Mapillary provider. show_street_level and sl=mapillary links need it to open the photo closest to a point, so this restores it on the slim core: Graph API /images within 50 m, sorted by distance, first one that passes the imagery filter. Kept as its own commit so it can be dropped if #974's removal should stand. --- .../fixtures/street-level/mapillaryGraph.mjs | 12 ++++++ .../streetLevel/providers/mapillary/index.js | 36 +++++++++++++++++ .../streetLevel/providers/mapillary/policy.js | 10 +++++ .../providers/mapillary/provider.test.mjs | 39 +++++++++++++++++++ .../streetLevel/providers/mapillary/source.js | 22 +++++++++++ .../providers/mapillary/source.test.mjs | 22 +++++++++++ src/testSupport/streetLevelFakes.mjs | 1 + 7 files changed, 142 insertions(+) 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/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 901df886c..c47280e9e 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -5,6 +5,9 @@ import { } from '../../lineCoverage.js'; import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { coverageZoomForHeight } from '../../tileMath.js'; +import { passesImageryFilter } from '../../filter.js'; +import { NEAREST_RADIUS_M } from '../../policy.js'; +import { metresBetween } from '../../view.js'; import { decodeCoverageTile } from './decode.js'; import { createMapillaryViewer } from './viewer.js'; import { @@ -17,6 +20,7 @@ import { MAPILLARY_NAME, MAPILLARY_PROVIDER_ID, MAPILLARY_SOURCE_METHODS, + NEAREST_LIMIT, PICK_PREFIX, RATE_LIMITED_MESSAGE, } from './policy.js'; @@ -193,6 +197,38 @@ export function createMapillaryProvider({ source, photoViewer = null }) { }), coverageLines: () => parts.coverage.drawnLines(), + /** + * 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, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT }, + { 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..b1609983a 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,12 @@ 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 (show_street_level and links + * open the photo nearest a point; the graph API caps the radius at 50 m). + */ +export const NEAREST_LIMIT = 8; +/** 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..ae406df32 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -170,3 +170,42 @@ 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.equal(requests[0].query.radius, 50); + assert.deepEqual([requests[0].query.lat, requests[0].query.lon], [1, 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/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 38a8b9c39..022a14dac 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -1,5 +1,8 @@ +import { NEAREST_RADIUS_M } from '../../policy.js'; import { MAPILLARY_GRAPH_HOST, + NEAREST_IMAGE_FIELDS, + NEAREST_LIMIT, SEQUENCE_IMAGE_FIELDS, SEQUENCE_IMAGES_LIMIT, } from './policy.js'; @@ -147,5 +150,24 @@ export function createMapillarySource({ ); return Array.isArray(payload?.data) ? payload.data : []; }, + + /** Images within `radius` metres of a point, in no particular order. */ + async nearestImages( + { lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT }, + { signal } = {}, + ) { + const payload = await graph( + 'images', + { + lat: String(lat), + lng: String(lon), + radius: String(Math.min(50, Math.max(1, radius))), + limit: String(Math.min(100, Math.max(1, limit))), + fields: NEAREST_IMAGE_FIELDS, + }, + { signal }, + ); + 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..883918f4f 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -126,6 +126,28 @@ 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, its radius and limit clamped', 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, + radius: 500, + limit: 500, + }); + 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 maximum'); + assert.equal(url.searchParams.get('limit'), '100'); + 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] } }] }), 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, }; } From fcf240e7890ea55f4c9c72105d86621aba4a22a8 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 18:27:43 +0000 Subject: [PATCH 03/28] test(voice): raise the Realtime prompt budget to 53,000 for show_street_level Main's prompt sits 28 characters under the 52,000 ceiling, and the street-level tool needs 337 even with no descriptions. Kept as its own commit so the budget decision can be reverted on its own. --- src/voice/pointAndAsk.test.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index 10af85166..612c71133 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -343,9 +343,11 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in // 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. A new tool or rule has to fit under this ceiling. + // it to 51,646. A new tool or rule has to fit under this ceiling. Raised + // to 53,000 for show_street_level: globe rotation left 28 characters, and + // the street-level tool alone needs 337 with no descriptions at all. const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length; - assert.ok(size <= 52000, `prompt size ${size}`); + assert.ok(size <= 53000, `prompt size ${size}`); }); From 0ec57057fa2bf6692cdbe8f608984b2729c27906 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 3 Nov 2026 15:30:48 +0000 Subject: [PATCH 04/28] fix(street-level): review fixes for show_street_level and sl= links P1: on a phone the Street Level panel sits in the Cameras tab sheet, so the action and sl= links opened photos nobody could see and still reported success. A photo that opens, and a link that arms, now open that sheet (revealPanel). In cockpit, clean view, recording or scene playback the action stops at a new `view` stage before anything switches or loads. P2s: - Mapillary's nearest search asks for 100 photos, the API's most. It cuts the radius's photos at the limit in no particular order, so 8 left the distance sort and the imagery filter a few arbitrary photos. - The MCP globe panel opens a billed photo only for a result issued in the last five minutes (show_in_gods_eye_view stamps issued_at); a host that mounts the panel again hands back the old result, which only arms. - OPEN uses an armed link's point only while the view centre is within 1 km of it; a user who moved on opens what the view shows. - show_in_gods_eye_view measures a camera's altitude from the terrain under it before taking its look-at point, so high cities no longer put the photos kilometres too far ahead. - The armed-link toast is translated, and the new view-stage message too, in all 11 catalogs. - Tests: a turn superseded during the geocode, flight or map switch changes nothing; an unknown key state lets a cold sl= link through; a wide tilted area still opens photos at its centre. --- SECURITY.md | 2 +- changelog.d/1113-show-street-level.md | 9 +- docs/CURRENT-STATE.md | 25 ++++-- src/app/embed.js | 30 +++++-- src/app/embed.test.mjs | 66 ++++++++------ src/app/globePanelRuntime.js | 15 +++- src/i18n/locales/ar.json | 4 +- src/i18n/locales/de.json | 4 +- src/i18n/locales/es.json | 4 +- src/i18n/locales/fr.json | 4 +- src/i18n/locales/it.json | 4 +- src/i18n/locales/ja.json | 4 +- src/i18n/locales/pt.json | 4 +- src/i18n/locales/ru.json | 4 +- src/i18n/locales/uk.json | 4 +- src/i18n/locales/zh-CN.json | 4 +- src/i18n/locales/zh-Hant.json | 4 +- src/layers/streetLevel/index.js | 16 +++- src/layers/streetLevel/index.test.mjs | 64 ++++++++++++++ .../streetLevel/providers/mapillary/policy.js | 5 +- .../providers/mapillary/provider.test.mjs | 3 + src/tools/queries/share.js | 48 ++++++++++- src/tools/queries/share.test.mjs | 70 +++++++++++++++ src/ui/applicationShell.js | 14 ++- src/ui/streetLevelControls.js | 7 +- src/ui/streetLevelControls.test.mjs | 17 ++++ src/view/index.js | 2 +- src/voice/gevActions.js | 13 +++ src/voice/gevActions.test.mjs | 86 +++++++++++++++++++ src/voice/speech.js | 2 + 30 files changed, 473 insertions(+), 65 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 8c43bcee5..797793cf0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -129,7 +129,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, are that client's own request and open the photo; 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 armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image. +- **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; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms; 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 armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. 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 index 36cf415df..76bd35de3 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -6,7 +6,14 @@ `show_in_gods_eye_view` and a link can open a panorama at a place. Street View lookups from a geocoded place search up to 250 m; clicks keep 50 m. A plain link with `sl=google` arms Street View and leaves the billed load to a - press of OPEN GOOGLE STREET VIEW; embedded views open it. The source the + press of OPEN GOOGLE STREET VIEW, which aims at the view centre again once + the visitor has moved more than 1 km away; embedded views open it, and the + God's Eye View panel only for a result issued in the last five minutes. On + a phone, a photo that opens or a link that arms opens the Cameras sheet; + in cockpit, clean view, recording or scene playback the action says the + panel is hidden instead of loading a photo nobody sees. Mapillary's + nearest-photo search asks for 100 photos, so the nearest and the filter's + pick come from the spot's photos rather than an arbitrary eight. The source the action lights stays lit like a chip click. An unavailable map stack now reports the controller's own reason instead of always asking for a Cesium ion token. When nothing is found and a 360°, flat or SINCE filter the source diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 11b1bd16e..437f32c33 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4339,14 +4339,20 @@ centre), switches to the map the source needs (Google Street View: Google 3D), lights that source through `setLayerParams` with origin `voice` (one source at a time, kept in the share link like a chip click), turns the layer on and calls `openNearest`. `provider` is `google` for "Street View", `mapillary`, -`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `map`, -`provider`, `layer`, `key`, `imagery`; an empty `imagery` result names a +`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `view`, `map`, +`provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view, +recording or scene playback hides the panel, checked after the flight and +before anything switches or loads; an empty `imagery` result names a narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a newer turn retires the lookup (`openNearest`'s `signal` option), so nothing opens after the user moved on. For a source without a coverage layer the lookup widens to 250 m (`NEAREST_RADIUS_MAX_M`; Google honours it, Mapillary's API caps at 50 m); -clicks keep 50 m. The layer exposes `requiredMapStack(id)` and +clicks keep 50 m. Mapillary's search asks for 100 photos, the API's most: it +returns the radius's photos in no particular order and cuts them at the limit +first, so the distance sort and the imagery filter run over the spot's photos. +On a phone the panel sits in the Cameras tab sheet: a photo that opens, and a +link that arms, open that sheet (`revealPanel`). The layer exposes `requiredMapStack(id)` and `providerStatus(id)` for callers that must decide before the panel lists a source, and `coverageLines(id)` (the lines a provider draws now, current and stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate, @@ -4369,13 +4375,20 @@ link never loads a photo from a source that bills every load (`billedPerImage` on the provider; Google): the action runs with `autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's point and radius, consumed only by the next OPEN press (`aim: -'screen-centre'`); any other lookup, open, close, disable, its source +'screen-centre'`), and only while the view centre is within 1 km of the point +(`ARMED_REACH_M`; a user who moved on opens what the view shows); any other lookup, open, close, disable, its source switched off or its map leaving drops it), opens the panel and a toast points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline -embed) and posted views open the photo; `?embed=1` on a published link only +embed) for a tool result issued in the last 5 minutes, and posted views, open +the photo: `show_in_gods_eye_view` stamps a street-level result `issued_at`, +and a host that mounts the panel again later hands back the old result, which +only arms; `?embed=1` on a published link only arms, without the panel nudge or toast (the rail is hidden in a frame). A link that follows an entity ignores `sl`. The look-at default applies below 20 km, at a pitch steeper than -5° and -within 10 km ahead; otherwise the camera's own point is used. +within 10 km ahead; otherwise the camera's own point is used. A view's +altitude is above the ellipsoid, so `show_in_gods_eye_view` measures it from +the terrain under the camera (`services.terrain`) before taking the look-at +point; without a terrain answer it keeps the view's own estimate. Street Level has two browser gates. `npm run qa:street-level -- --url ` runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run diff --git a/src/app/embed.js b/src/app/embed.js index a3dec5b84..8f439bf41 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -13,6 +13,8 @@ import * as Cesium from 'cesium'; import { announceNavigationAuthority } from '../navigationPolicy.js'; +import { t, translateKnown } from '../i18n/index.js'; +import { GOOGLE_LABEL } from '../layers/streetLevel/providers/google/policy.js'; import { annotationsFromParams, createView, @@ -51,6 +53,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 @@ -188,8 +198,13 @@ function streetLevelArgs(streetLevel, { autoOpen = true } = {}) { } const STREET_LEVEL_PANEL_ID = 'street-level-panel'; -const ARMED_TOAST = - 'Street View is ready: press OPEN GOOGLE STREET VIEW to load it'; + +/** 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) }), + }); +} /** * Make the app show a view: style and map, exactly the view's layers, @@ -323,8 +338,9 @@ export function installViews({ queue = linkStep(() => run('annotate_map', { annotations: linked })); // A link to street-level photos opens them once the link has restored. A // link anyone can publish stops short of a billed load and leaves it to a - // click; only the panel page's own link (inline embed) is the client's - // request. A link that follows an entity keeps the camera on it. + // click; only the panel page's own link for a result issued just now is + // the client's request (a host remounting the panel hands back old ones). + // A link that follows an entity keeps the camera on it. const streetLevel = followFromParams(hashParams) ? null : streetLevelFromParams(hashParams); @@ -333,7 +349,7 @@ export function installViews({ if (navigatedSinceRestore()) return; const result = await run( 'show_street_level', - streetLevelArgs(streetLevel, { autoOpen: isEmbeddedInline() }), + streetLevelArgs(streetLevel, { autoOpen: isFreshPanelResult() }), ); // The restore window keeps the panel collapsed; OPEN must be in view. // Embed mode hides the rail that holds it, so there the link only arms @@ -342,7 +358,9 @@ export function installViews({ shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { explicit: true, }); - shell._showToast?.(ARMED_TOAST); + // On a phone the panel sits in a tab sheet: open it so OPEN shows. + shell.revealPanel?.(STREET_LEVEL_PANEL_ID); + shell._showToast?.(armedToast()); } }); if (!isEmbedded(location)) return () => {}; diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index f7aae019e..fb70315ff 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -540,6 +540,7 @@ test('a failing link step does not take the next one with it', async () => { 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 }), }); @@ -549,6 +550,7 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s _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()), @@ -569,6 +571,7 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s [['street-level-panel', false, { explicit: true }]], 'the panel opens so OPEN is in view', ); + 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 = []; @@ -599,38 +602,45 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s 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 (inline embed) is the client's request. + // 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 { - 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)); - assert.equal('autoOpen' in inline.calls[0][1], false); - stop(); + 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(); diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index d10e5717d..a19724aa2 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,7 +554,9 @@ 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; @@ -571,7 +576,11 @@ export function panelRuntime(config) { started = true; // i18n-ignore: host-panel script, no app catalogs say("Loading God's Eye View…"); - startApp(url, view).catch((error) => + const issuedAt = Number(data.issued_at); + const fresh = + Number.isFinite(issuedAt) && + Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS; + 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}.`), ); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index beb7d81d9..04db610d5 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3232,5 +3232,7 @@ "{source} at {place}": "{source} في {place}", "Street level at {place}": "مستوى الشارع في {place}", "Switching map": "جارٍ تبديل الخريطة", - "Opening imagery": "جارٍ فتح الصور" + "Opening imagery": "جارٍ فتح الصور", + "Street View is ready: press {open} to load it": "Street View جاهز: اضغط «{open}» لتحميله", + "Street-level photos are hidden in the current view. Leave it first.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا." } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f419eb6bb..06a65043b 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2952,5 +2952,7 @@ "{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" + "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", + "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." } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 9f90a6bf0..63c2b1380 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3022,5 +3022,7 @@ "Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo", "Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos", "Sea Marks": "Señales marítimas", - "Ski Pistes": "Pistas de esquí" + "Ski Pistes": "Pistas de esquí", + "Street View is ready: press {open} to load it": "Street View está listo: pulsa {open} para cargarlo", + "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." } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 3f5e83efd..d8dbfb58b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3022,5 +3022,7 @@ "{source} at {place}": "{source} à {place}", "Street level at {place}": "Niveau rue à {place}", "Switching map": "Changement de carte", - "Opening imagery": "Ouverture des images" + "Opening imagery": "Ouverture des images", + "Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger", + "Street-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." } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 64cd39b5b..d81be9678 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3022,5 +3022,7 @@ "{source} at {place}": "{source} a {place}", "Street level at {place}": "Vista stradale a {place}", "Switching map": "Cambio mappa", - "Opening imagery": "Apertura delle immagini" + "Opening imagery": "Apertura delle immagini", + "Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo", + "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." } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 5df8426c6..50e87e4e5 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2882,5 +2882,7 @@ "{source} at {place}": "{place}の{source}", "Street level at {place}": "{place}のストリートレベル", "Switching map": "地図を切り替え中", - "Opening imagery": "画像を開いています" + "Opening imagery": "画像を開いています", + "Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます", + "Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9b8f92221..b7b246c4c 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3022,5 +3022,7 @@ "{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" + "Opening imagery": "Abrindo imagens", + "Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar", + "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." } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 302b725a6..9491622ed 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3092,5 +3092,7 @@ "Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку", "Directions, drawing and your own files": "Маршруты, рисование и ваши файлы", "Sea Marks": "Морские знаки", - "Ski Pistes": "Горнолыжные трассы" + "Ski Pistes": "Горнолыжные трассы", + "Street View is ready: press {open} to load it": "Просмотр улиц готов: нажмите {open}, чтобы загрузить", + "Street-level photos are hidden in the current view. Leave it first.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него." } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index b1c477f46..6c4489195 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3092,5 +3092,7 @@ "{source} at {place}": "{source}: {place}", "Street level at {place}": "Вуличні знімки: {place}", "Switching map": "Перемикання карти", - "Opening imagery": "Відкриття знімків" + "Opening imagery": "Відкриття знімків", + "Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити", + "Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього." } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 0a519ce21..9b6080233 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -2882,5 +2882,7 @@ "Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置", "Directions, drawing and your own files": "路线导航、绘制和你自己的文件", "Sea Marks": "航标", - "Ski Pistes": "滑雪道" + "Ski Pistes": "滑雪道", + "Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载", + "Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index a1e92fcdb..1792ef17a 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -2882,5 +2882,7 @@ "{source} at {place}": "{place} 的 {source}", "Street level at {place}": "{place} 的街景", "Switching map": "正在切換地圖", - "Opening imagery": "正在打開影像" + "Opening imagery": "正在打開影像", + "Street View is ready: press {open} to load it": "街景已就緒:按 {open} 載入", + "Street-level photos are hidden in the current view. Leave it first.": "目前的檢視隱藏了街景照片。請先離開此檢視。" } diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 409fdf384..4583b944c 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -14,7 +14,7 @@ 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 { @@ -33,6 +33,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). @@ -681,8 +686,13 @@ export function createStreetLevelLayer({ const armed = state.street.armed; state.street.armed = null; if (!point && aim === 'screen-centre' && armed) { - point = armed.point; - radiusM ??= armed.radiusM; + // The button promises the centre of the view: the link's spot only + // while the view still shows it. + const centre = armed.point ? screenCentreGround() : null; + if (!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)) diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index a82dc53c5..49b2819eb 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -947,6 +947,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'); @@ -1342,6 +1390,22 @@ test('providerStatus reports a keyless source on any map', async (t) => { 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(); diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index b1609983a..64b5d8073 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -77,8 +77,11 @@ export const SEQUENCE_IMAGE_FIELDS = /** * Images asked for in a nearest-image search (show_street_level and links * open the photo nearest a point; the graph API caps the radius at 50 m). + * The API returns the radius's images in no particular order and cuts them + * at the limit first, so this asks for its most (100): the distance sort and + * the imagery filter then choose among the spot's photos, not a few of them. */ -export const NEAREST_LIMIT = 8; +export const NEAREST_LIMIT = 100; /** 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 ae406df32..31ec665ad 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -201,6 +201,9 @@ test('nearestImage opens the closest image that passes the filter, not the first assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }, { signal }), 'near'); assert.equal(requests[0].options.signal, signal, 'the core can abort it'); assert.equal(requests[0].query.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(requests[0].query.limit, 100, "the API's most"); assert.deepEqual([requests[0].query.lat, requests[0].query.lon], [1, 2]); const any = createMapillaryProvider({ source }).create(fakeContext()); assert.equal(await any.nearestImage({ lat: 1, lon: 2 }), 'near-pano'); diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 3de7902b0..c612f908b 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, + lookAtPoint, + 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'; @@ -36,6 +41,30 @@ async function followedAircraftPosition(follow, tools) { } } +/** + * 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. Null when the terrain cannot say. + */ +async function groundedLookAt(camera, services, signal) { + if (typeof services?.terrain?.getHeights !== 'function') return null; + if (!Number.isFinite(camera.altitude_m) || !(camera.pitch_deg > -89)) + return null; + let heights; + try { + heights = await services.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 }); +} + /** * The view described by tool arguments. An area frames the camera straight * down over it; camera fields given alongside it override the framing. A @@ -91,6 +120,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, signal); + if (point) streetLevel = { ...streetLevel, ...point }; + } let view; try { view = createView({ @@ -150,7 +188,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 aac74a776..6b1c55f4a 100644 --- a/src/tools/queries/share.test.mjs +++ b/src/tools/queries/share.test.mjs @@ -136,6 +136,8 @@ test('links can open street-level photos at the area, on the map the source need 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, @@ -189,6 +191,19 @@ test('a new area moves a passed-through view’s photos, and a tilted area still 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 }, @@ -199,6 +214,61 @@ test('a new area moves a passed-through view’s photos, and a tilted area still ); }); +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, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 2dd75cd3b..80d20731e 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -4,7 +4,7 @@ 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, sheetForPanelId } from './mobileShell.js'; import { VisualSettings } from './visualSettings.js'; import { NavigationController } from './navigationController.js'; import { ShareRestoration } from './shareRestoration.js'; @@ -1098,6 +1098,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) => @@ -1212,6 +1213,17 @@ 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 (sheet) this._mobileShell?.setSheet(sheet); + } + /** * Toggles "clean view" mode which hides all UI panels via a CSS body class. * @param {boolean} [forceEnabled] - Explicit on/off. Omit to toggle. diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index 001157f7c..2610109e6 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -388,7 +388,12 @@ 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; // Embed mode hides the rail the panel sits in: an open photo is shown over // the globe instead, and goes back into the panel when it closes. diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 9a38c0b98..80e7c184a 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -967,6 +967,23 @@ test('outside embed mode a photo stays in the panel', () => 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'); diff --git a/src/view/index.js b/src/view/index.js index 5caecdd70..c22f2c3f6 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -96,7 +96,7 @@ const LOOK_AT_MAX_DISTANCE_M = 10_000; * further than 10 km ahead gives its own point. Longitude wraps at the * antimeridian. */ -function lookAtPoint(camera) { +export function lookAtPoint(camera) { const pitch = Number(camera.pitch_deg ?? -90); const altitude = Number(camera.altitude_m); if ( diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index e2c4cf964..c57a386a7 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -14,6 +14,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, @@ -3438,6 +3439,18 @@ export async function showStreetLevel( point = { lat: location.latitude, lon: location.longitude }; } + // Cockpit, clean view, recording and scene playback hide the panel the + // photo opens in: say so before anything loads unseen. + const body = globalThis.document?.body; + if (EXCLUSIVE_SURFACE_CLASSES.some((mode) => body?.classList?.contains(mode))) + return fail( + 'view', + msg( + 'Street-level photos are hidden in the current view. Leave it first.', + ), + { location }, + ); + // 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; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index ec0c76e5d..035f84509 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -4582,3 +4582,89 @@ 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 geocode changes nothing', 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}`); + return { ok: true }; + }, + }, + dataManager: h.dataManager, + placeSearch: { + geocode: async () => { + current = false; + return { place: { lat: 1, lng: 2, label: 'X' } }; + }, + }, + }); + const result = await runner('show_street_level', { query: 'X', provider: 'google', fly: false }, { isCurrent: () => current }); + assert.deepEqual(h.order.filter((step) => /^(map|params|open|arm)/.test(step)), [], 'the stale turn switched nothing'); + assert.deepEqual([result.ok, result.stage, result.cancelled], [false, 'location', true]); +}); + +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; + } + } +}); diff --git a/src/voice/speech.js b/src/voice/speech.js index f481d1338..90ddfdc3c 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -608,6 +608,8 @@ function showStreetLevel(result) { ? `${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') say = result?.filter?.label ? `No ${source} imagery near ${place} with the ${result.filter.label} on.` From 7703090d906f58fc53f4a7caa5d128fc1a62e1ab Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 3 Nov 2026 14:59:47 +0000 Subject: [PATCH 05/28] fix(street-level): retire lookups and preserve embedded photos Clear cancelled lookup loading over an existing photo, release caller abort listeners on internal retirement, surface embedded errors, and permit embedded photos over the clean globe. Add seven regression cases and update runtime documentation. Preserve the published localization and other review fixes. --- changelog.d/1113-show-street-level.md | 8 ++- docs/CURRENT-STATE.md | 11 +++- src/layers/streetLevel/index.js | 6 +- src/layers/streetLevel/index.test.mjs | 80 ++++++++++++++++++++++++ src/ui/stores/streetLevel.js | 9 +++ src/ui/streetLevelControls.js | 1 + src/ui/streetLevelControls.test.mjs | 33 ++++++++++ src/ui/views/panels/StreetLevelPanel.vue | 5 +- src/voice/gevActions.js | 12 +++- src/voice/gevActions.test.mjs | 22 +++++++ 10 files changed, 175 insertions(+), 12 deletions(-) diff --git a/changelog.d/1113-show-street-level.md b/changelog.d/1113-show-street-level.md index 76bd35de3..dd9cdf6fd 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -11,11 +11,15 @@ God's Eye View panel only for a result issued in the last five minutes. On a phone, a photo that opens or a link that arms opens the Cameras sheet; in cockpit, clean view, recording or scene playback the action says the - panel is hidden instead of loading a photo nobody sees. Mapillary's + panel is hidden instead of loading a photo nobody sees. Embedded photos + remain available over the clean globe. Mapillary's nearest-photo search asks for 100 photos, so the nearest and the filter's pick come from the spot's photos rather than an arbitrary eight. The source the action lights stays lit like a chip click. An unavailable map stack now reports the controller's own reason instead of always asking for a Cesium ion token. When nothing is found and a 360°, flat or SINCE filter the source - honours is on, the answer names the filter (Said Turksever, @asturksever, + honours is on, the answer names the filter. Cancelled lookups clear their + loading state over an existing photo and release their caller abort listeners. + Embedded photos show loading errors inside the overlay, and the OPEN prompt + uses the recipient's selected language (Said Turksever, @asturksever, #1113). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 437f32c33..6f3172089 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4342,7 +4342,8 @@ calls `openNearest`. `provider` is `google` for "Street View", `mapillary`, `kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `view`, `map`, `provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view, recording or scene playback hides the panel, checked after the flight and -before anything switches or loads; an empty `imagery` result names a +before anything switches or loads; embed mode exempts its own clean-view +class because the photo is lifted above the globe; an empty `imagery` result names a narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a newer turn retires the lookup (`openNearest`'s `signal` option), so nothing opens after the user moved on. @@ -4366,9 +4367,13 @@ applies it through the action, and a plain link runs the action once restored (one shot: the rewritten hash drops `sl`). In embed mode the rail holding the panel 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). A caller's abort is +screen, Esc leaves full screen first). Image-loading failures render inside +the embedded overlay, since the panel's error region is hidden with the rail. +A caller's abort is honoured through the viewer's mount and load, not just the lookup, and only -the lookup that still owns the panel may change its spinner or error. A +the lookup that still owns the panel may change its spinner or error. Cancelling +a lookup clears its loading state even when an existing photo stays open; +internally retired lookups also release the caller's abort listener. A passive link's photos stand down if anyone navigated (a gesture, a search, the assistant) while the link waited behind its own marks. A plain link never loads a photo from a source that bills every load diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 4583b944c..ad24da557 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -724,7 +724,7 @@ export function createStreetLevelLayer({ if (nearestLookup !== lookup) return false; nearestLookup = null; // The caller moved on: the panel shows nothing of this lookup. - if (state.street.loading && !state.street.open) { + if (state.street.loading) { state.street.loading = false; notify(); } @@ -751,13 +751,13 @@ export function createStreetLevelLayer({ } 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 (callerSignal?.aborted) return retired(); - if (signal.aborted) return false; + 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; diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 49b2819eb..191246d92 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'; @@ -1666,6 +1667,85 @@ test('openNearest keeps honouring the caller after the lookup: an abort while th 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({ 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 2610109e6..fd2fdaee3 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -318,6 +318,7 @@ export class StreetLevelControls { 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); diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index 80e7c184a..d6d3927b8 100644 --- a/src/ui/streetLevelControls.test.mjs +++ b/src/ui/streetLevelControls.test.mjs @@ -1038,3 +1038,36 @@ test('destroying the panel in embed mode puts an open photo back in the panel', 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/views/panels/StreetLevelPanel.vue b/src/ui/views/panels/StreetLevelPanel.vue index 0e3c87962..1661574bb 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/voice/gevActions.js b/src/voice/gevActions.js index c57a386a7..5f339673e 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -3440,9 +3440,17 @@ export async function showStreetLevel( } // Cockpit, clean view, recording and scene playback hide the panel the - // photo opens in: say so before anything loads unseen. + // photo opens in: say so before anything loads unseen. Embed mode uses + // clean view for its globe but lifts the photo out of the hidden rail. const body = globalThis.document?.body; - if (EXCLUSIVE_SURFACE_CLASSES.some((mode) => body?.classList?.contains(mode))) + const embedded = body?.classList?.contains('ui-embed') === true; + if ( + EXCLUSIVE_SURFACE_CLASSES.some( + (mode) => + body?.classList?.contains(mode) && + !(embedded && mode === 'ui-clean-view'), + ) + ) return fail( 'view', msg( diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 035f84509..2211a9509 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -4668,3 +4668,25 @@ test('show_street_level in a view that hides its panel stops before anything loa } } }); + +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; + } +}); From fed397a764ca736f409918d982f54e506e0f2e56 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 3 Nov 2026 18:07:59 +0000 Subject: [PATCH 06/28] test(voice): raise the Realtime size budget to 56,500 for show_street_level --- src/voice/integrationContract.test.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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`); }); From 451f3e41cf97e4de690baa1fb64ba243e08c086a Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Thu, 5 Nov 2026 18:25:21 +0000 Subject: [PATCH 07/28] fix(street-level): links keep the recipient's preferences; aim under the screen centre Review fixes for gekh's #1113 round: - a link runs show_street_level with origin 'programmatic', so its source switch and layer are not saved as the recipient's preferences; - with no place named, the action looks under the screen centre (aim 'screen-centre', armed false), keeps the sky refusal and leaves a link's armed point alone; - an armed OPEN aimed at the sky opens nothing instead of the far link point; - an imagery-stage failure is spoken as an error, missing imagery as missing (street.nearestOutcome); the card says "the view centre" in the interface language; - every photo the action opens reveals its panel, and revealPanel scrolls the panel into view in the phone sheet; - embed: EXPAND survives embed's own clean view, the lifted photo hides in cockpit, recording and scene playback, and a failed photo step closes the earlier photo before the view flies; - links pasted into an open tab run their street-level step after the restore settles; a superseded start link does not; - Mapillary's nearest search gives up after 12 s, body included. --- SECURITY.md | 2 +- changelog.d/1113-show-street-level.md | 9 +- docs/CURRENT-STATE.md | 35 +++-- src/app/embed.js | 103 ++++++++++---- src/app/embed.test.mjs | 130 +++++++++++++++++- src/app/tools.js | 3 +- src/layers/streetLevel/index.js | 23 +++- src/layers/streetLevel/index.test.mjs | 112 +++++++++++++++ .../streetLevel/providers/mapillary/policy.js | 6 + .../streetLevel/providers/mapillary/source.js | 45 +++++- .../providers/mapillary/source.test.mjs | 41 ++++++ src/layers/streetLevel/state.js | 2 + src/streetLevelMarkup.test.mjs | 9 ++ src/ui/applicationShell.js | 2 + src/ui/revealPanel.test.mjs | 24 ++++ src/ui/streetLevelControls.js | 10 +- src/ui/streetLevelControls.test.mjs | 30 ++++ src/ui/styles/embed.css | 8 ++ src/voice/gevActions.js | 50 ++++++- src/voice/gevActions.test.mjs | 82 ++++++++++- src/voice/speech.js | 17 ++- src/voice/speech.test.mjs | 37 ++++- 22 files changed, 716 insertions(+), 64 deletions(-) create mode 100644 src/ui/revealPanel.test.mjs diff --git a/SECURITY.md b/SECURITY.md index 2223c2fda..4f1714e16 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -131,7 +131,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; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms; 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 armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image. +- **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; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms; 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; 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 index dd9cdf6fd..cdb83575f 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -21,5 +21,12 @@ honours is on, the answer names the filter. Cancelled lookups clear their loading state over an existing photo and release their caller abort listeners. Embedded photos show loading errors inside the overlay, and the OPEN prompt - uses the recipient's selected language (Said Turksever, @asturksever, + uses the recipient's selected language. A link's photos never save its + layers as the recipient's preferences, and a link pasted into an open tab + opens or arms them too. With no place named, the action looks under the + screen centre as OPEN does; an armed OPEN aimed at the sky opens nothing; a + failing source is reported as an error, not as missing imagery; and + Mapillary's nearest search gives up after 12 seconds. On a phone the panel + scrolls into view, and in embed mode EXPAND stays open while cockpit, + recording and scene playback hide the photo (Said Turksever, @asturksever, #1113). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index a998c6edc..2b1c3c1c1 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4483,26 +4483,35 @@ app. (voice and typed; `src/voice/gevActions.js`) is one call: it checks the source has its key (a keyless source explains itself and changes nothing, as a chip does), flies to the place at street framing and waits for arrival (a -name, coordinates, a pointed spot, a numbered item, or nothing for the view -centre), switches to the map the source needs (Google Street View: Google 3D), +name, coordinates, a pointed spot, a numbered item, or nothing for the +ground under the screen centre, as OPEN uses: `aim: 'screen-centre'` with +`armed: false`, so sky refuses and a link's armed point is neither used nor +dropped), switches to the map the source needs (Google Street View: Google 3D), lights that source through `setLayerParams` with origin `voice` (one source at -a time, kept in the share link like a chip click), turns the layer on and +a time, kept in the share link like a chip click; a link runs the action with +origin `programmatic`, for the source switch and the layer, 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. Stages fail separately (`location`, `view`, `map`, `provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view, recording or scene playback hides the panel, checked after the flight and before anything switches or loads; embed mode exempts its own clean-view class because the photo is lifted above the globe; an empty `imagery` result names a -narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a +narrowing 360°/flat or SINCE filter the source honours, as `filter`; +`noImagery` marks an empty result, `imageryError` a source or viewer failure +and `aim: 'sky'` a sky centre, from the layer's `street.nearestOutcome`) and a newer turn retires the lookup (`openNearest`'s `signal` option), so nothing opens after the user moved on. For a source without a coverage layer the lookup widens to 250 m (`NEAREST_RADIUS_MAX_M`; Google honours it, Mapillary's API caps at 50 m); -clicks keep 50 m. Mapillary's search asks for 100 photos, the API's most: it +clicks keep 50 m. Mapillary's search gives up after 12 s, body included +(`NEAREST_TIMEOUT_MS`), and asks for 100 photos, the API's most: it returns the radius's photos in no particular order and cuts them at the limit first, so the distance sort and the imagery filter run over the spot's photos. On a phone the panel sits in the Cameras tab sheet: a photo that opens, and a -link that arms, open that sheet (`revealPanel`). The layer exposes `requiredMapStack(id)` and +link that arms, open that sheet and scroll the panel into view +(`revealPanel`); every photo the action opens reveals the panel again, also +one the user had collapsed. The layer exposes `requiredMapStack(id)` and `providerStatus(id)` for callers that must decide before the panel lists a source, and `coverageLines(id)` (the lines a provider draws now, current and stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate, @@ -4513,10 +4522,15 @@ source and point, defaulting to where the camera looks; a `google` source brings `map: photoreal` and refuses another map; not with `follow`), links as `sl=,,`; `show_in_gods_eye_view` accepts it, the globe panel applies it through the action, and a plain link runs the action once restored -(one shot: the rewritten hash drops `sl`). In embed mode the rail holding the +(one shot: the rewritten hash drops `sl`). A link pasted into the open tab +runs it after its restore settles (`gev:share-navigation-restore-settled`), +and a start link whose restore a newer one superseded never runs. In embed mode the rail holding the panel 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). Image-loading failures render inside +screen, Esc leaves full screen first). EXPAND stays open under embed mode's +own clean view; cockpit, recording and scene playback hide the overlay. A +posted view whose photo step fails closes the earlier photo before its +camera flies. Image-loading failures render inside the embedded overlay, since the panel's error region is hidden with the rail. A caller's abort is honoured through the viewer's mount and load, not just the lookup, and only @@ -4529,8 +4543,9 @@ link never loads a photo from a source that bills every load (`billedPerImage` on the provider; Google): the action runs with `autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's point and radius, consumed only by the next OPEN press (`aim: -'screen-centre'`), and only while the view centre is within 1 km of the point -(`ARMED_REACH_M`; a user who moved on opens what the view shows); any other lookup, open, close, disable, its source +'screen-centre'`), and only while the ground under the screen centre is within +1 km of the point (`ARMED_REACH_M`; a user who moved on opens what the view +shows) or cannot be picked; a sky centre refuses; any other lookup, open, close, disable, its source switched off or its map leaving drops it), opens the panel and a toast points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline embed) for a tool result issued in the last 5 minutes, and posted views, open diff --git a/src/app/embed.js b/src/app/embed.js index 8f439bf41..87ecbb386 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -198,6 +198,8 @@ function streetLevelArgs(streetLevel, { autoOpen = true } = {}) { } 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() { @@ -258,8 +260,12 @@ export async function applyView( 'show_street_level', streetLevelArgs(view.street_level), ); - if (result?.ok !== true && !result?.location) - steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + if (result?.ok !== true) { + // An earlier view's photo must not stay over a place this view left. + 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) { @@ -317,13 +323,16 @@ export function installViews({ // The camera's owner at restore: anyone who navigates while the link's // steps wait (a gesture, a search, the assistant) takes it, and a passive // link must not fly over them. + // A start-up restore that a newer link superseded hands the camera to that + // link: its own steps never run. let generationAtRestore = null; - let queue = ready.then(() => { + let startSuperseded = false; + let queue = ready.then((outcome) => { generationAtRestore = shell._navigationGeneration; + startSuperseded = outcome?.status === 'superseded'; }); - const navigatedSinceRestore = () => - Number.isFinite(generationAtRestore) && - shell._navigationGeneration !== generationAtRestore; + const navigatedSince = (generation) => + Number.isFinite(generation) && shell._navigationGeneration !== generation; const linkStep = (work) => queue.then(async () => { if (signal?.aborted) return; @@ -341,29 +350,64 @@ export function installViews({ // click; only the panel page's own link for a result issued just now is // the client's request (a host remounting the panel hands back old ones). // A link that follows an entity keeps the camera on it. - const streetLevel = followFromParams(hashParams) - ? null - : streetLevelFromParams(hashParams); - if (streetLevel) - queue = linkStep(async () => { - if (navigatedSinceRestore()) return; - const result = await run( - 'show_street_level', - streetLevelArgs(streetLevel, { autoOpen: isFreshPanelResult() }), - ); - // The restore window keeps the panel collapsed; OPEN must be in view. - // Embed mode hides the rail that holds it, so there the link only arms - // (a posted view is the way to open a photo in a frame). - if (result?.armed === true && !isEmbedded(location)) { - shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { - explicit: true, - }); - // On a phone the panel sits in a tab sheet: open it so OPEN shows. - shell.revealPanel?.(STREET_LEVEL_PANEL_ID); - shell._showToast?.(armedToast()); - } - }); - if (!isEmbedded(location)) return () => {}; + 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' }, + ); + // The restore window keeps the panel collapsed; OPEN must be in view. + // Embed mode hides the rail that holds it, so there the link only arms + // (a posted view is the way to open a photo in a frame). + if (result?.armed === true && !isEmbedded(location)) { + shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { + explicit: true, + }); + // 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 restores camera and layers on its own; + // its photos follow once that restore settles, under the same rule: a plain + // link never loads a billed photo by itself. + const onNavigatedRestore = (event) => { + if (event?.detail?.status !== 'settled') return; + const streetLevel = linkedStreetLevel( + new URLSearchParams(String(location?.hash || '').replace(/^#/, '')), + ); + if (!streetLevel) return; + const generation = shell._navigationGeneration; + 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); @@ -423,6 +467,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 fb70315ff..a7cf6b61e 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -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', () => { @@ -875,3 +877,127 @@ test('a passive link stands down when someone navigated while it waited behind i 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 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) => + windowRef.dispatchEvent( + Object.assign(new Event('gev:share-navigation-restore-settled'), { + detail: { status }, + }), + ); + 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'); + 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 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/tools.js b/src/app/tools.js index 9d91f70a9..39456d3eb 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -223,7 +223,8 @@ 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, }), ); diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index ad24da557..1c380b7e3 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -668,7 +668,10 @@ export function createStreetLevelLayer({ * providers that can (a geocoded landmark is rarely within 50 m of a * street); clicks keep the default. A caller's `signal` retires the * lookup: nothing opens after it fires. Providers without - * `nearestImage` are skipped. + * `nearestImage` are skipped. `armed: false` (a request of its own, such + * as the assistant's) neither uses nor drops a link's armed point. Why + * nothing opened is left in `street.nearestOutcome`: 'empty' (no imagery + * in reach), 'sky' (the screen centre is not ground), or null (an error). */ async openNearest( point, @@ -678,18 +681,25 @@ export function createStreetLevelLayer({ aim = 'view', radiusM = null, signal: callerSignal = null, + armed: useArmed = true, } = {}, ) { 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 = state.street.armed; - state.street.armed = null; + const armed = useArmed ? state.street.armed : null; + if (useArmed) state.street.armed = null; if (!point && aim === 'screen-centre' && armed) { // The button promises the centre of the view: the link's spot only - // while the view still shows it. + // while the view still shows it, or when the view cannot be picked. + // Sky refuses below, as any OPEN press does. const centre = armed.point ? screenCentreGround() : null; - if (!centre || metresBetween(centre, armed.point) <= ARMED_REACH_M) { + if (!armed.point) radiusM ??= armed.radiusM; + else if ( + centre === undefined || + (centre && metresBetween(centre, armed.point) <= ARMED_REACH_M) + ) { point = armed.point; radiusM ??= armed.radiusM; } @@ -704,6 +714,7 @@ export function createStreetLevelLayer({ abortNearest(); state.street.loading = false; state.street.error = AIM_AT_GROUND_MESSAGE; + state.street.nearestOutcome = 'sky'; notify(); return false; } @@ -779,6 +790,8 @@ export function createStreetLevelLayer({ ); // 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 || diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 191246d92..c0f6797e8 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1942,3 +1942,115 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async ( const view = presentStreetLevelPanel(layer.getUIState()); 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 { createStreetLevelLayer: create } = await import('./index.js'); + const scenePick = await import('../../data/scenePick.js'); + const lookups = []; + const provider = fakeProvider({ + nearestImage: async (point) => { + lookups.push(point); + return null; + }, + }); + const saved = globalThis.document; + const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); + globalThis.document = Object.assign(new EventTarget(), { + createElement: () => ({ getContext: () => drawing }), + }); + const viewer = fakeViewer(); + // 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; + const layer = create({ providers: [provider], services: { scenePick } }); + layer.init(viewer); + layer.enable(viewer); + t.after(() => { + layer.destroy(); + globalThis.document = saved; + }); + 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 { 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 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 { createStreetLevelLayer: create } = await import('./index.js'); + const provider = fakeProvider({ + nearestImage: async () => { + throw new Error('Mapillary graph HTTP 500'); + }, + }); + const saved = globalThis.document; + const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); + globalThis.document = Object.assign(new EventTarget(), { + createElement: () => ({ getContext: () => drawing }), + }); + const viewer = fakeViewer(); + const layer = create({ providers: [provider] }); + layer.init(viewer); + layer.enable(viewer); + t.after(() => { + layer.destroy(); + globalThis.document = saved; + }); + 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'); +}); diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 64b5d8073..b582b8cdd 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -82,6 +82,12 @@ export const SEQUENCE_IMAGE_FIELDS = * the imagery filter then choose among the spot's photos, not a few of them. */ export const NEAREST_LIMIT = 100; +/** + * A nearest-image search that has not answered (headers and body) in this + * long is given up: a link or a posted view waits on it, and so does every + * view queued behind that one. + */ +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/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 022a14dac..4830d54e2 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -3,6 +3,7 @@ import { MAPILLARY_GRAPH_HOST, NEAREST_IMAGE_FIELDS, NEAREST_LIMIT, + NEAREST_TIMEOUT_MS, SEQUENCE_IMAGE_FIELDS, SEQUENCE_IMAGES_LIMIT, } from './policy.js'; @@ -69,7 +70,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('Mapillary did not answer in time', { + 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}`, { @@ -165,7 +206,7 @@ export function createMapillarySource({ limit: String(Math.min(100, Math.max(1, limit))), fields: NEAREST_IMAGE_FIELDS, }, - { signal }, + { 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 883918f4f..dfa733a7f 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -179,3 +179,44 @@ 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'); +}); + +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/state.js b/src/layers/streetLevel/state.js index 7408b7dee..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. */ diff --git a/src/streetLevelMarkup.test.mjs b/src/streetLevelMarkup.test.mjs index 602a3b065..c7e848c3d 100644 --- a/src/streetLevelMarkup.test.mjs +++ b/src/streetLevelMarkup.test.mjs @@ -123,6 +123,15 @@ test('embed mode shows an open photo over the globe, filling the overlay', () => 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, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index 80d20731e..fd509cf51 100644 --- a/src/ui/applicationShell.js +++ b/src/ui/applicationShell.js @@ -1222,6 +1222,8 @@ export class StyleManager extends ShellFacade { revealPanel(panelId) { const sheet = sheetForPanelId(panelId); 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' }); } /** diff --git a/src/ui/revealPanel.test.mjs b/src/ui/revealPanel.test.mjs new file mode 100644 index 000000000..5ed7696ac --- /dev/null +++ b/src/ui/revealPanel.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { StyleManager } from './applicationShell.js'; + +test('revealing a panel opens its phone sheet and scrolls the panel into view', () => { + const sheets = []; + const scrolls = []; + const panel = { scrollIntoView: (options) => scrolls.push(options) }; + const saved = globalThis.document; + globalThis.document = { + getElementById: (id) => (id === 'street-level-panel' ? panel : null), + }; + try { + StyleManager.prototype.revealPanel.call( + { _mobileShell: { setSheet: (sheet) => sheets.push(sheet) } }, + 'street-level-panel', + ); + } finally { + globalThis.document = saved; + } + assert.deepEqual(sheets, ['cameras']); + // The panel sits below the sheet's first screen at 375×812. + assert.deepEqual(scrolls, [{ block: 'nearest' }]); +}); diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index fd2fdaee3..b84c8b9e1 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -285,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') { diff --git a/src/ui/streetLevelControls.test.mjs b/src/ui/streetLevelControls.test.mjs index d6d3927b8..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)', () => diff --git a/src/ui/styles/embed.css b/src/ui/styles/embed.css index a376ac5da..7810ad1be 100644 --- a/src/ui/styles/embed.css +++ b/src/ui/styles/embed.css @@ -22,6 +22,14 @@ body.ui-embed .sl-viewer-wrap-embed { 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; diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 7f18ed0cd..ec68cffa1 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -465,7 +465,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; @@ -3446,6 +3446,18 @@ 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'; +} + /** * "Show me on Street View / on Mapillary": fly to the place, make sure * the imagery source's map is up, light that source in Street Level, turn the @@ -3482,8 +3494,10 @@ export async function showStreetLevel( ...(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); @@ -3540,7 +3554,7 @@ export async function showStreetLevel( (entity ? String(entity.label || entity.id) : '') || (hasCoordinates ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : '') || 'the view centre'; - const atViewCentre = !query && !hasCoordinates && !entity; + atViewCentre = !query && !hasCoordinates && !entity; let point = null; let location = null; if (args.fly === false || atViewCentre) { @@ -3564,7 +3578,8 @@ export async function showStreetLevel( point = { lat: place.lat, lon: place.lng }; label = place.label || query; } - // No place at all: the layer looks under the view centre. + // No place at all: the layer looks at the ground under the screen + // centre, as the OPEN button does. } else { location = await runGevAction( 'fly_to_location', @@ -3646,7 +3661,7 @@ export async function showStreetLevel( if (typeof dataManager.setLayerParams === 'function') accepted = dataManager.setLayerParams(STREET_LEVEL_LAYER_ID, switches, { - origin: 'voice', + origin: actionIntentOrigin(runOptions), }) !== false; else layerModule.setProviderEnabled?.(provider, true); if (!accepted) @@ -3712,6 +3727,9 @@ export async function showStreetLevel( const opened = await layerModule.openNearest(point, { providerIds: [provider], frame: false, + // 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 } @@ -3744,6 +3762,19 @@ export async function showStreetLevel( 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?.()); @@ -3751,9 +3782,17 @@ export async function showStreetLevel( return fail( 'imagery', filter ? `${reason} with the ${filter.label} on` : reason, - { location, ...(filter ? { filter } : {}) }, + { 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, @@ -3767,6 +3806,7 @@ export async function showStreetLevel( position: street.position ?? null, externalUrl: street.externalUrl ?? null, flew: location !== null, + ...(atViewCentre ? { atViewCentre } : {}), ...(location ? { location: location.label } : {}), }; } diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index f059a24b6..775aea8d2 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -5715,8 +5715,13 @@ function createStreetLevelHarness({ duringLookup = null, keyRejectedAtStart = false, lightFails = false, + imageryError = null, + sky = false, } = {}) { const order = []; + const lookups = []; + const origins = []; + const revealed = []; const { viewer, styleManager } = createVoiceNavigationHarness(); let completeFlight = null; viewer.camera.lookAt = () => {}; @@ -5776,6 +5781,7 @@ function createStreetLevelHarness({ 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) { @@ -5790,7 +5796,13 @@ function createStreetLevelHarness({ return false; } } + if (imageryError) { + street.error = imageryError; + street.nearestOutcome = null; + return false; + } if (!nearest) { + 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; } @@ -5805,12 +5817,14 @@ function createStreetLevelHarness({ 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) { + async setEnabled(id, _on, options) { + origins.push(options?.origin); order.push(`enable:${id}`); if (enableResult === false) return false; enabled = true; @@ -5818,8 +5832,10 @@ function createStreetLevelHarness({ }, 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 }; + return { runner, order, finishFlight: () => completeFlight?.(), layer, dataManager, lookups, origins, revealed }; } /** A loaded CCTV camera the entity branch can locate. */ @@ -6403,3 +6419,65 @@ test('show_street_level opens over the clean embedded globe but respects its oth 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/speech.js b/src/voice/speech.js index cd52adaa1..edb36e8a6 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -626,7 +626,11 @@ function showStreetLevel(result) { const shownSource = result?.providerName ? spokenLabel(result.providerName) : t('Street Level'); - const shown = spokenLabel(localizePlaceName(result?.label || '')) || place; + // 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; @@ -640,10 +644,15 @@ function showStreetLevel(result) { 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 = result?.filter?.label - ? `No ${source} imagery near ${place} with the ${result.filter.label} on.` - : `No ${source} imagery near ${place}.`; + say = `${source} could not open imagery near ${place}.`; else say = `Couldn't open ${source}.`; return { say, diff --git a/src/voice/speech.test.mjs b/src/voice/speech.test.mjs index e12c0feed..84d6f8d62 100644 --- a/src/voice/speech.test.mjs +++ b/src/voice/speech.test.mjs @@ -536,6 +536,22 @@ 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 speech names the source and the place, and says which step failed', () => { const ok = envelope('show_street_level', { ok: true, @@ -556,7 +572,8 @@ test('street-level speech names the source and the place, and says which step fa 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.", - imagery: 'No Google Street View imagery near Nowhere.', + // 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', { @@ -570,6 +587,24 @@ test('street-level speech names the source and the place, and says which step fa 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, From 30e6cd39c7482159142e36d7fd9e3288ff4a8142 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Fri, 6 Nov 2026 00:40:20 +0000 Subject: [PATCH 08/28] fix(street-level): re-check after every await; close the old photo; fresh panel views Address samehkhamis's review on #1113: - show_street_level checks the turn, the camera's owner (navigation stamp, re-taken where its own flight lands, or a press/wheel on the globe) and the hiding modes after the flight, the map switch, the layer switch-on and the lookup; a photo that opened after either is closed again. The hidden panel is also refused before the flight. - An open photo closes before a new request arms or looks up, so a place with no imagery shows its own error. - The panel posts every view with `fresh`; the inline app opens a billed photo only for a fresh one. - A passed-through view's street_level point moves with a new camera position; revealPanel does nothing above 720px, scrolling included. - Document that the app's own links do not carry `sl`. --- SECURITY.md | 2 +- changelog.d/1113-show-street-level.md | 8 +- docs/CURRENT-STATE.md | 20 +++- docs/SHARE-LINK-FORMAT.md | 10 ++ docs/TOOLS.md | 7 +- src/app/embed.js | 22 +++- src/app/embed.test.mjs | 53 ++++++++++ src/app/globePanelRuntime.js | 25 +++-- src/app/globePanelRuntime.test.mjs | 71 +++++++++++++ src/tools/queries/share.js | 9 +- src/tools/queries/share.test.mjs | 17 ++- src/ui/applicationShell.js | 7 +- src/ui/revealPanel.test.mjs | 16 ++- src/voice/gevActions.js | 144 +++++++++++++++++++++----- src/voice/gevActions.test.mjs | 132 ++++++++++++++++++++++- 15 files changed, 486 insertions(+), 57 deletions(-) create mode 100644 src/app/globePanelRuntime.test.mjs diff --git a/SECURITY.md b/SECURITY.md index 4f1714e16..6f34022dd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -131,7 +131,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; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms; 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; 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. +- **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; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms, and so does any later result on the same mount issued more than five minutes ago; 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; 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 index cdb83575f..076879a52 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -28,5 +28,9 @@ failing source is reported as an error, not as missing imagery; and Mapillary's nearest search gives up after 12 seconds. On a phone the panel scrolls into view, and in embed mode EXPAND stays open while cockpit, - recording and scene playback hide the photo (Said Turksever, @asturksever, - #1113). + recording and scene playback hide the photo. The action checks again after + every wait: a navigation or a drag of the globe, or a mode that hides the + panel, stops it before a photo loads for a place the user left or in a + panel nobody sees, and a request that finds nothing closes the previous + photo. The panel's later results open a billed photo only when they are + fresh too (Said Turksever, @asturksever, #1113). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 8f1dceaa9..ca551c06d 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4569,8 +4569,9 @@ returns the radius's photos in no particular order and cuts them at the limit first, so the distance sort and the imagery filter run over the spot's photos. On a phone the panel sits in the Cameras tab sheet: a photo that opens, and a link that arms, open that sheet and scroll the panel into view -(`revealPanel`); every photo the action opens reveals the panel again, also -one the user had collapsed. The layer exposes `requiredMapStack(id)` and +(`revealPanel`, which does nothing above 720px, scrolling included); every +photo the action opens reveals the panel again, also one the user had +collapsed. The layer exposes `requiredMapStack(id)` and `providerStatus(id)` for callers that must decide before the panel lists a source, and `coverageLines(id)` (the lines a provider draws now, current and stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate, @@ -4597,7 +4598,16 @@ the lookup that still owns the panel may change its spinner or error. Cancelling a lookup clears its loading state even when an existing photo stays open; internally retired lookups also release the caller's abort listener. A passive link's photos stand down if anyone navigated (a gesture, a search, -the assistant) while the link waited behind its own marks. A plain +the assistant) while the link waited behind its own marks. The action itself +checks again 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 the remaining +stages (`superseded: true`), and cockpit, clean view, recording or scene +playback entered meanwhile ends it at the `view` stage; a photo that opened +after either is closed again. The hidden-panel check also runs before the +flight, so a recording's camera never moves for it. A photo already open +closes before the new request arms or looks up, so a place with no imagery +shows its own error, not the previous place's photo. A plain link never loads a photo from a source that bills every load (`billedPerImage` on the provider; Google): the action runs with `autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's @@ -4610,7 +4620,9 @@ points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline embed) for a tool result issued in the last 5 minutes, and posted views, open the photo: `show_in_gods_eye_view` stamps a street-level result `issued_at`, and a host that mounts the panel again later hands back the old result, which -only arms; `?embed=1` on a published link only +only arms. The panel checks every result, not just the first: each `gev:view` +it 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 (the rail is hidden in a frame). A link that follows an entity ignores `sl`. The look-at default applies below 20 km, at a pitch steeper than -5° and within 10 km ahead; otherwise the camera's own point is used. A view's diff --git a/docs/SHARE-LINK-FORMAT.md b/docs/SHARE-LINK-FORMAT.md index 5728d1ec3..059d5e7b0 100644 --- a/docs/SHARE-LINK-FORMAT.md +++ b/docs/SHARE-LINK-FORMAT.md @@ -51,6 +51,16 @@ military aircraft or satellite. A selected vessel is not followed (the camera stays where the user puts it), so a link made then is a place view; vessel thing views open, but the app does not make them yet. +## 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 2effba947..738ccd541 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -120,8 +120,11 @@ photo at once; none of them bills per image. A link that follows an entity ignor Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a `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. `resolveViewArguments` -turns them into a view. +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 diff --git a/src/app/embed.js b/src/app/embed.js index 87ecbb386..3b9df2a9c 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -216,7 +216,14 @@ function armedToast() { */ 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) => { @@ -258,7 +265,7 @@ export async function applyView( // before flying, the view's own camera still applies. const result = await act( 'show_street_level', - streetLevelArgs(view.street_level), + streetLevelArgs(view.street_level, { autoOpen }), ); if (result?.ok !== true) { // An earlier view's photo must not stay over a place this view left. @@ -447,9 +454,18 @@ export function installViews({ ); return; } + // 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; queue = queue.then(async () => { if (signal?.aborted) return; - const steps = await applyView(view, { viewer, dataManager, run, signal }); + const steps = await applyView(view, { + viewer, + dataManager, + run, + signal, + autoOpen, + }); post( { type: EMBED_APPLIED_MESSAGE, diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index a7cf6b61e..45f85a625 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -188,6 +188,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(); diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index a19724aa2..280eff063 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -560,9 +560,15 @@ export function panelRuntime(config) { 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; @@ -570,16 +576,17 @@ export function panelRuntime(config) { if (!view || !url) return; currentUrl = url; open.hidden = false; - if (ready) return postView(view); - queued = view; - if (started) return; - started = true; - // i18n-ignore: host-panel script, no app catalogs - say("Loading God's Eye 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, fresh).catch((error) => // i18n-ignore: host-panel script, no app catalogs say(`God's Eye View could not load here: ${error?.message || error}.`), @@ -601,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/tools/queries/share.js b/src/tools/queries/share.js index c612f908b..bdb9d9267 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -77,12 +77,17 @@ export async function resolveViewArguments(args, { services, signal, tools }) { : { ...base.camera, ...args.camera }; let label = null; // Street-level photos open at the area's centre unless a point is given; - // a new area moves a passed-through view's photos with it. + // 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' - ? args.area + ? moved ? { provider: base.street_level.provider } : { ...base.street_level } : null; diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs index 6b1c55f4a..19458e0e7 100644 --- a/src/tools/queries/share.test.mjs +++ b/src/tools/queries/share.test.mjs @@ -158,7 +158,7 @@ test('links can open street-level photos at the area, on the map the source need ); }); -test('a new area moves a passed-through view’s photos, and a tilted area still opens them at its centre', async () => { +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/' } }, @@ -176,6 +176,21 @@ test('a new area moves a passed-through view’s photos, and a tilted area still 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 }, diff --git a/src/ui/applicationShell.js b/src/ui/applicationShell.js index fd509cf51..407cc05a3 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, sheetForPanelId } 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'; @@ -1221,6 +1225,7 @@ export class StyleManager extends ShellFacade { */ 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' }); diff --git a/src/ui/revealPanel.test.mjs b/src/ui/revealPanel.test.mjs index 5ed7696ac..e1cab5047 100644 --- a/src/ui/revealPanel.test.mjs +++ b/src/ui/revealPanel.test.mjs @@ -2,14 +2,18 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { StyleManager } from './applicationShell.js'; -test('revealing a panel opens its phone sheet and scrolls the panel into view', () => { +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) } }, @@ -17,8 +21,18 @@ test('revealing a panel opens its phone sheet and scrolls the panel into view', ); } 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/voice/gevActions.js b/src/voice/gevActions.js index ec68cffa1..1bd7802f0 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -760,6 +760,7 @@ export function createGevActionRunner({ if (name === 'show_street_level') { return showStreetLevel(args, { + viewer, dataManager, styleManager, placeSearch, @@ -3458,6 +3459,55 @@ function actionIntentOrigin(runOptions) { return runOptions?.origin === 'programmatic' ? 'programmatic' : 'voice'; } +/** 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()` makes the camera as it is now the request's (after + * its own flight); `stop()` removes the gesture listeners. + */ +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 = false; + }, + taken: () => + taken || + (Number.isFinite(generation) && + navigation._navigationGeneration !== generation), + stop: () => { + for (const type of CAMERA_GESTURES) + canvas?.removeEventListener?.(type, onGesture); + }, + }; +} + +/** + * 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 to the place, make sure * the imagery source's map is up, light that source in Street Level, turn the @@ -3469,13 +3519,26 @@ function actionIntentOrigin(runOptions) { * from a source that bills every load: the panel is armed and the OPEN * button is one click away. */ -export async function showStreetLevel( +export async function showStreetLevel(args, context) { + // Every await can hand the request's moment to someone else: a newer turn, + // a navigation or gesture that takes the camera, or a mode that hides the + // panel. Each stage after the place is settled checks again. + const owner = watchCameraOwner(context.viewer, context.styleManager); + try { + return await requestStreetLevel(args, { ...context, owner }); + } finally { + owner.stop(); + } +} + +async function requestStreetLevel( args, { dataManager, styleManager, placeSearch, runGevAction, + owner, runOptions = {}, current = () => true, reportProgress = () => {}, @@ -3548,6 +3611,34 @@ export async function showStreetLevel( 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 || @@ -3615,24 +3706,11 @@ export async function showStreetLevel( } // Cockpit, clean view, recording and scene playback hide the panel the - // photo opens in: say so before anything loads unseen. Embed mode uses - // clean view for its globe but lifts the photo out of the hidden rail. - const body = globalThis.document?.body; - const embedded = body?.classList?.contains('ui-embed') === true; - if ( - EXCLUSIVE_SURFACE_CLASSES.some( - (mode) => - body?.classList?.contains(mode) && - !(embedded && mode === 'ui-clean-view'), - ) - ) - return fail( - 'view', - msg( - 'Street-level photos are hidden in the current view. Leave it first.', - ), - { location }, - ); + // photo opens in, and may have started during the flight. + // The camera is the request's from where its own flight landed. + if (location) 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; @@ -3644,13 +3722,15 @@ export async function showStreetLevel( { stack: requiredStack }, runOptions, ); - if (map?.ok !== true || !current()) + 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. @@ -3675,12 +3755,14 @@ export async function showStreetLevel( { layerId: STREET_LEVEL_LAYER_ID, enabled: true }, runOptions, ); - if (layer?.ok !== true || !current()) + if (layer?.ok !== true && current()) return fail( 'layer', layer?.error || 'Street Level could not be enabled', - { cancelled: !current() || Boolean(layer?.cancelled), layer, location }, + { cancelled: Boolean(layer?.cancelled), layer, location }, ); + const stop = interrupted('layer', { layer }); + if (stop) return stop; } // Listed now that its map is up, and lit. @@ -3698,6 +3780,11 @@ export async function showStreetLevel( location, }); + // A photo of another place must not stay up beside this request's answer, + // armed or opened, found or not: the panel shows this request's outcome. + if (layerModule.getUIState?.()?.street?.open === true) + layerModule.closeViewer?.(); + // A billed source never loads from a link without a click: everything is // ready and OPEN is one press away at the view centre. if (args.autoOpen === false && status?.billed === true) { @@ -3737,12 +3824,13 @@ export async function showStreetLevel( }); const street = layerModule.getUIState?.()?.street || {}; // A cancelled request opens nothing (the lookup and the viewer both honour - // the turn's signal); a photo that did open is reported as open. - if (opened !== true && !current()) - return fail('imagery', 'Street-level request was cancelled', { - cancelled: true, - location, - }); + // the turn's signal). A photo that opened after the camera moved on, or + // into a panel hidden meanwhile, is closed again: nobody asked to see it. + 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 || {}; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 775aea8d2..17867657b 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -5717,12 +5717,21 @@ function createStreetLevelHarness({ lightFails = false, imageryError = null, sky = false, + photoOpen = false, + duringMapSwitch = null, + duringEnable = null, } = {}) { const order = []; const lookups = []; const origins = []; const revealed = []; - const { viewer, styleManager } = createVoiceNavigationHarness(); + const { viewer, styleManager, currentGeneration } = createVoiceNavigationHarness(); + // Who owns the camera: the shell's navigation stamps and globe gestures. + let userNavigations = 0; + Object.defineProperty(styleManager, '_navigationGeneration', { + get: () => currentGeneration() + userNavigations, + }); + viewer.scene.canvas = Object.assign(new EventTarget(), { clientWidth: 1200, clientHeight: 800 }); let completeFlight = null; viewer.camera.lookAt = () => {}; viewer.camera.flyToBoundingSphere = (_sphere, options) => { @@ -5736,11 +5745,14 @@ function createStreetLevelHarness({ 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 = { imageId: null, error: null }; + 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'], @@ -5777,6 +5789,10 @@ function createStreetLevelHarness({ 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'}`); }, @@ -5806,6 +5822,7 @@ function createStreetLevelHarness({ 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; @@ -5828,6 +5845,7 @@ function createStreetLevelHarness({ order.push(`enable:${id}`); if (enableResult === false) return false; enabled = true; + duringEnable?.(); return true; }, getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }], @@ -5835,7 +5853,22 @@ function createStreetLevelHarness({ 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 }; + return { + runner, + order, + finishFlight: () => completeFlight?.(), + layer, + dataManager, + lookups, + origins, + revealed, + street, + // The user searches or flies elsewhere, or drags the globe. + navigate: () => { + userNavigations += 1; + }, + gesture: () => viewer.scene.canvas.dispatchEvent(new Event('pointerdown')), + }; } /** A loaded CCTV camera the entity branch can locate. */ @@ -6398,6 +6431,99 @@ test('show_street_level in a view that hides its panel stops before anything loa } }); +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 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'))); +}); + +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; From fbd74bb80a5a3ab677d9ce16df72a807b7b4b737 Mon Sep 17 00:00:00 2001 From: Kaj Obst <217931875+Kajjan7610@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:31:12 +0100 Subject: [PATCH 09/28] i18n: add Swedish (sv) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a complete Swedish catalog (2,961 messages) and registers sv in the locale list, catalogs, language picker and package boundaries. Browsers set to Swedish get it at once; ?lang=sv or the language picker choose it elsewhere. Numbers and dates come out Swedish from Intl. - docs/translations/sv.md: style (imperative without a pronoun, closed compounds, decimal comma, space before units and %) and the glossary. - Entries that stay identical to English on purpose (codes such as HDG and FOV, product, place and source names) are listed under sv in same-as-english.json. - The picker-order test places Svenska after Português. - docs/CURRENT-STATE.md and docs/TRANSLATORS.md list the new language. The translation comes from God's Eye View Sverige, a Swedish fork, and was brought up to date with main (wildlife sightings, event age filter, traffic styles and the rest). Co-Authored-By: Claude Opus 5.5 --- docs/CURRENT-STATE.md | 2 +- docs/TRANSLATORS.md | 4 +- docs/translations/sv.md | 84 + scripts/package-boundaries.json | 46 + src/i18n/index.js | 2 + src/i18n/locale.js | 2 + src/i18n/locales/sv.json | 3188 +++++++++++++++++++++++++++++++ src/i18n/same-as-english.json | 163 ++ src/ui/localePicker.test.mjs | 4 + 9 files changed, 3492 insertions(+), 3 deletions(-) create mode 100644 docs/translations/sv.md create mode 100644 src/i18n/locales/sv.json diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 3bf6b1051..3531e3d0c 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4285,7 +4285,7 @@ test:track` 43 tracking invariants · headless QA harnesses under ### Interface localization (October 2026) -Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **it**, **ja**, **pt** (Brazilian Portuguese), **ru**, **uk**, **zh-CN** and **zh-Hant**. The code lives in +Shipped locales: **en** (source and fallback), **ar**, **de**, **es**, **fr**, **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). diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index f83316c7a..36c37b62f 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`), **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`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`), **Traditional Chinese** (`zh-Hant`) and **Swedish** (`sv`). ## How it works @@ -63,4 +63,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), [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), [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), [Swedish](./translations/sv.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..13d557e96 --- /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., JMF., KAL. +- 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.», «{count} HÖRDA». + +## 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 | +| road condition / incident / roadwork | väglag / händelse / vägarbete | diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index e224cd87e..47cb94689 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -73,6 +73,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", @@ -640,6 +641,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", @@ -688,6 +690,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" @@ -729,6 +732,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", @@ -772,6 +776,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" @@ -799,6 +804,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", @@ -829,6 +835,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", @@ -866,6 +873,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", @@ -915,6 +923,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", @@ -942,6 +951,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", @@ -1122,6 +1132,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", @@ -1311,6 +1322,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", @@ -1396,6 +1408,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", @@ -1454,6 +1467,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" @@ -1526,6 +1540,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" @@ -1563,6 +1578,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", @@ -1604,6 +1620,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" @@ -1642,6 +1659,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", @@ -1683,6 +1701,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" @@ -1733,6 +1752,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", @@ -1780,6 +1800,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", @@ -1842,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" @@ -1879,6 +1901,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" @@ -1936,6 +1959,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" @@ -2007,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", @@ -2045,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" @@ -2094,6 +2120,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" @@ -2124,6 +2151,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" @@ -2188,6 +2216,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" @@ -2266,6 +2295,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", @@ -3197,6 +3227,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", @@ -3922,6 +3953,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", @@ -4098,6 +4130,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", @@ -4130,6 +4163,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" @@ -4175,6 +4209,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", @@ -4213,6 +4248,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", @@ -4319,6 +4355,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", @@ -4392,6 +4429,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", @@ -4672,6 +4710,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", @@ -4726,6 +4765,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" @@ -4788,6 +4828,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" @@ -4812,6 +4853,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", @@ -4880,6 +4922,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", @@ -4929,6 +4972,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" @@ -5127,6 +5171,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", @@ -5173,6 +5218,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/src/i18n/index.js b/src/i18n/index.js index 8bffca8bc..83172772f 100644 --- a/src/i18n/index.js +++ b/src/i18n/index.js @@ -26,6 +26,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' }; @@ -39,6 +40,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 7356ea8f8..d249d90d6 100644 --- a/src/i18n/locale.js +++ b/src/i18n/locale.js @@ -18,6 +18,7 @@ export const LOCALES = Object.freeze([ 'ja', 'pt', 'ru', + 'sv', 'uk', 'zh-CN', 'zh-Hant', @@ -34,6 +35,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/sv.json b/src/i18n/locales/sv.json new file mode 100644 index 000000000..b6921bac8 --- /dev/null +++ b/src/i18n/locales/sv.json @@ -0,0 +1,3188 @@ +{ + "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": "PASSAGERARE", + "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 datan ä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", + "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 {count} stadsflöden …", + "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": "{count} KAMEROR I BEREDSKAP · 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} säkerhet", + "{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}", + "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": "+{count} ytterligare nyttolastposter", + "PAYLOAD DATA UNAVAILABLE": "NYTTOLASTDATA SAKNAS", + "{value} KM DOWNRANGE": "{value} KM MARKAVSTÅND", + "POSITION UNAVAILABLE": "POSITION SAKNAS", + "FLIGHT {count}": "FLYGNING {count}", + "REUSED": "ÅTERANVÄND", + "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 STARTEN", + "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 ···", + "DENSE ✕": "TÄT ✕", + "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": "JUSTERA 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", + "DRIZZLE": "DUGGREGN", + "RAIN": "REGN", + "RAIN SHOWERS": "REGNSKURAR", + "SNOW SHOWERS": "SNÖBYAR", + "THUNDERSTORM": "ÅSKA", + "MIXED CONDITIONS": "VÄXLANDE VÄDER", + "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", + "RESOLVING REGION": "FASTSTÄLLER REGION", + "REGION UNAVAILABLE": "REGION OTILLGÄNGLIG", + "NO RECENT LOCATION MATCHES": "INGA AKTUELLA PLATSTRÄFFAR", + "{domain} · {age}": "{domain} · {age}", + "CLOUD {pct}%": "MOLN {pct} %", + "CLOUD UNKNOWN": "MOLN OKÄNT", + "{source} · LOCATION QUERY": "{source} · PLATSSÖKNING", + "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 visningsstil 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} · KURS {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": "OKLART", + "{name}: {state}": "{name}: {state}", + "{count}s ago": "{count} s sedan", + "CONNECTING": "ANSLUTER", + "📍 Location: --": "📍 Plats: --", + "Landmark: --": "Landmärke: --", + "Searched location": "Sökt plats", + "ORBIT": "KRETSAR", + "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 GEV MIC 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 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.": "Pekaren används av {owner} – stäng det verktyget 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": "Olaglig inblandning", + "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äddad media 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": "Ⅱ PAUS", + "↺ 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örd 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 är en tillåten referent", + "Street View did not answer — check the network, and that the Maps JavaScript API is enabled for GOOGLE_MAPS_API_KEY": "Street View svarade inte – kontrollera nätverket och att Maps JavaScript API är aktiverat för GOOGLE_MAPS_API_KEY", + "click a street to open Street View": "klicka på en gata för att öppna Street View", + "Street View has no imagery for this place": "Street View har inga bilder för den här platsen", + "KartaView has no position for this photo": "KartaView har ingen position för det här fotot", + "The KartaView photo could not be loaded": "KartaView-fotot kunde inte laddas", + "zoom in to see KartaView coverage": "zooma in för att se KartaView-täckningen", + "no KartaView photos in view": "inga KartaView-foton i vyn", + "KartaView is rate-limiting requests — they resume on their own": "KartaView begränsar antalet förfrågningar – de återupptas automatiskt", + "KartaView’s photos here are archived and cannot be shown": "KartaViews foton här är arkiverade och kan inte visas", + "KartaView did not filter this area in time — move the map to ask again": "KartaView hann inte filtrera området – flytta kartan för att fråga igen", + "None of KartaView’s photos here pass the imagery filter": "Inga av KartaViews foton här klarar bildfiltret", + "KartaView did not answer in time — try again": "KartaView svarade inte i tid – försök igen", + "KartaView could not be reached — check the network": "KartaView kunde inte nås – kontrollera nätverket", + "KartaView sent an answer that could not be read": "KartaView skickade ett svar som inte kunde läsas", + "KartaView is rate-limiting requests": "KartaView begränsar antalet förfrågningar", + "KartaView sent too much data": "KartaView skickade för mycket data", + "KartaView is rate-limiting requests — try again in a moment": "KartaView begränsar antalet förfrågningar – försök igen om en stund", + "KartaView has no such photo": "KartaView har inget sådant foto", + "Mapillary rejected MAPILLARY_CLIENT_TOKEN — replace it in Provider Settings": "Mapillary avvisade MAPILLARY_CLIENT_TOKEN – byt ut den under Leverantörsinställningar", + "Coverage requests are being rate-limited — they resume on their own": "Täckningsförfrågningarna begränsas – de återupptas automatiskt", + "The Panoramax photo could not be loaded": "Panoramax-fotot kunde inte laddas", + "Panoramax is busy — coverage resumes on its own": "Panoramax har hög belastning – täckningen återupptas automatiskt", + "no Panoramax photos in view": "inga Panoramax-foton i vyn", + "no Panoramax photos here match the filters": "inga Panoramax-foton här matchar filtren", + "Panoramax sent a map tile that could not be read": "Panoramax skickade en kartruta som inte kunde läsas", + "This Panoramax photo is no longer available": "Det här Panoramax-fotot är inte längre tillgängligt", + "zoom in to see Panoramax coverage": "zooma in för att se Panoramax-täckningen", + "Panoramax did not answer in time — try again": "Panoramax svarade inte i tid – försök igen", + "Panoramax could not be reached — check the connection": "Panoramax kunde inte nås – kontrollera anslutningen", + "Panoramax sent too much data": "Panoramax skickade för mycket data", + "Panoramax is busy": "Panoramax har hög belastning", + "Panoramax sent an unreadable answer": "Panoramax skickade ett oläsbart svar", + "Sequence images unavailable": "Sekvensbilder otillgängliga", + "No street-level imagery providers configured": "Inga leverantörer av bilder på gatunivå har konfigurerats", + "(by upload date)": "(efter uppladdningsdatum)", + "Open the Street Level panel to view imagery": "Öppna panelen ”Gatunivå” för att visa bilder", + "Image could not be opened": "Bilden kunde inte öppnas", + "TeleGeography load failed": "TeleGeography kunde inte laddas", + "UNAVAILABLE · {source} · Roads unavailable": "OTILLGÄNGLIG · {source} · Vägar otillgängliga", + "Roads: {source}": "Vägar: {source}", + "TomTom roads unavailable while the traffic service is unreachable": "TomTom-vägar är otillgängliga så länge trafiktjänsten inte kan nås", + "Hybrid unavailable while the traffic service is unreachable": "Läget Hybrid är otillgängligt så länge trafiktjänsten inte kan nås", + "TomTom roads need a TomTom key": "TomTom-vägar kräver en TomTom-nyckel", + "Hybrid needs a TomTom key": "Läget Hybrid kräver en TomTom-nyckel", + "Local surface still loading": "Lokal yta laddas fortfarande", + "Partial coverage": "Delvis täckning", + "Detailed roads unavailable": "Detaljerade vägar otillgängliga", + "Reduced detail coverage": "Minskad detaljtäckning", + "TomTom roads only, each with its live flow": "Endast TomTom-vägar, var och en med trafikflöde i realtid", + "TomTom roads only, each with its live flow (needs a TomTom key)": "Endast TomTom-vägar, var och en med trafikflöde i realtid (kräver en TomTom-nyckel)", + "OpenStreetMap roads, with TomTom flow matched when available": "OpenStreetMap-vägar, med matchat TomTom-trafikflöde när det finns", + "TomTom roads first; OpenStreetMap fills the rest, simulated": "TomTom-vägar först; OpenStreetMap fyller i resten, simulerat", + "TomTom roads first; OpenStreetMap fills the rest, simulated (needs a TomTom key)": "TomTom-vägar först; OpenStreetMap fyller i resten, simulerat (kräver en TomTom-nyckel)", + "Hybrid": "Hybrid", + "Lines": "Linjer", + "Dots": "Punkter", + "Road lines colored by live flow: green to red": "Vägar färgade efter trafikflödet live: grönt till rött", + "Moving dots colored by live flow": "Rörliga punkter färgade efter trafikflödet live", + "Moving dots": "Rörliga punkter", + "TomTom key unavailable": "TomTom-nyckel otillgänglig", + "TomTom daily budget reached": "TomTom-dagsbudgeten är förbrukad", + "Monthly traffic allowance used": "Månadens trafikkvot är förbrukad", + "Traffic rate limit reached": "Trafiktjänsten har nått gränsen för förfrågningar", + "Traffic service unavailable": "Trafiktjänsten är otillgänglig", + "TomTom upstream unreachable": "TomTom-tjänsten går inte att nå", + "TomTom flow unavailable": "TomTom-trafikflöde otillgängligt", + "SIMULATED — {reason}": "SIMULERAT – {reason}", + "OpenFreeMap tiles rate-limited": "OpenFreeMap-kartrutor: för många förfrågningar", + "OpenFreeMap tiles timed out": "OpenFreeMap-kartrutor: tidsgränsen överskreds", + "OpenFreeMap tiles unavailable": "OpenFreeMap-kartrutor otillgängliga", + "occupancy|empty": "tomt", + "occupancy|many seats available": "många lediga sittplatser", + "occupancy|few seats available": "få lediga sittplatser", + "occupancy|standing room only": "endast ståplatser", + "occupancy|crushed standing room only": "endast ståplatser, mycket trångt", + "occupancy|full": "fullt", + "occupancy|not accepting passengers": "tar inte emot resenärer", + "occupancy|not boardable": "går inte att stiga på", + "Route {route}": "Linje {route}", + "Vehicle {vehicle}": "Fordon {vehicle}", + "vehicle motion|Stopped": "Står still", + "No recent fix": "Ingen aktuell position", + "Waiting for update": "Väntar på uppdatering", + "Moving": "I rörelse", + "reported {speed} km/h": "rapporterad fart {speed} km/h", + "reported hdg {heading}°": "rapporterad kurs {heading}°", + "Last report: stopped at stop {stop}": "Senaste rapport: står vid hållplats {stop}", + "Last report: arriving at stop {stop}": "Senaste rapport: ankommer till hållplats {stop}", + "Last report: next stop {stop}": "Senaste rapport: nästa hållplats {stop}", + "Shown at street level · depth not in the feed": "Visas på gatunivå · djup saknas i flödet", + "Reported {seconds} s ago": "Rapporterad för {seconds} s sedan", + "Reported {minutes} min ago": "Rapporterad för {minutes} min sedan", + "shown {seconds} s behind": "visas med {seconds} s fördröjning", + "{reported} (feed time)": "{reported} (flödestid)", + "Available trail: {minutes} min (limited)": "Tillgängligt spår: {minutes} min (begränsat)", + "Available trail: {minutes} min": "Tillgängligt spår: {minutes} min", + "Bus": "Buss", + "Tram": "Spårvagn", + "Subway": "Tunnelbana", + "Train": "Tåg", + "Ferry": "Färja", + "Transit vehicle": "Kollektivtrafikfordon", + "{speed}KT": "{speed} KN", + "{heading}DEG": "{heading}°", + "AIS live load failed": "AIS-livedata kunde inte laddas", + "AISSTREAM_API_KEY not set": "AISSTREAM_API_KEY är inte angiven", + "live feed unsupported": "liveflöde stöds inte", + "connecting to feed…": "ansluter till flödet …", + "feed disconnected": "flödet frånkopplat", + "feed down": "flödet nere", + "feed idle": "flödet inaktivt", + "API key rejected — check AISSTREAM_API_KEY": "API-nyckeln avvisades – kontrollera AISSTREAM_API_KEY", + "feed silent {seconds}s — no AIS data": "flödet tyst i {seconds} s – inga AIS-data", + "feed silent — no AIS data": "flödet tyst – inga AIS-data", + "feed down — retrying slowly (attempt {attempt})": "flödet nere – försöker igen med längre intervall (försök {attempt})", + "reconnecting to feed… (attempt {attempt})": "återansluter till flödet … (försök {attempt})", + "feed down — retrying slowly": "flödet nere – försöker igen med längre intervall", + "reconnecting to feed…": "återansluter till flödet …", + "Focus vessel {name}, MMSI {mmsi}": "Fokusera på fartyget {name}, MMSI {mmsi}", + "Hidden by this map source · choose a globe map": "Dolt av den här kartkällan · välj en globkarta", + "Weather image HTTP {status}": "Väderbild HTTP {status}", + "Weather HTTP {status}": "Väder HTTP {status}", + "Frame unavailable; previous observation retained": "Bildruta otillgänglig; föregående observation behålls", + "Weather imagery unavailable": "Väderbilder otillgängliga", + "Weather source unavailable; previous observation retained": "Väderkälla otillgänglig; föregående observation behålls", + "Weather unavailable": "Väder otillgängligt", + "No frame within {gap} min of {time}": "Ingen bildruta inom {gap} min från {time}", + "No frame within {gap} h of {time}": "Ingen bildruta inom {gap} h från {time}", + "{hours}h {minutes}m ago": "för {hours} h {minutes} min sedan", + "{minutes}m ago": "för {minutes} min sedan", + "{state} · {time} · {lag}": "{state} · {time} · {lag}", + "Observed": "Observerat", + "History": "Historik", + "Observation unavailable": "Observation otillgänglig", + "Waiting for observation": "Väntar på observation", + "{detail} · synced": "{detail} · synkroniserad", + "{detail} · nearest": "{detail} · närmast i tid", + "RADAR REFLECTIVITY · dBZ": "RADARREFLEKTIVITET · dBZ", + "LIGHTNING DENSITY · 15 min accumulation": "BLIXTTÄTHET · summerat över 15 min", + "GLOBAL INFRARED · hourly": "GLOBALT INFRARÖTT · varje timme", + "GOES INFRARED · ~5 min": "GOES-INFRARÖTT · ~5 min", + "{state}: {time}": "{state}: {time}", + "Latest observation": "Senaste observation", + "frame {index}/{total}": "bildruta {index}/{total}", + "loading": "laddar", + "Observation: unavailable": "Observation: otillgänglig", + "Contiguous US · gaps ≠ no rain": "Kontinentala USA · luckor ≠ inget regn", + "Americas + Pacific · not individual strikes": "Amerika + Stilla havet · inte enskilda nedslag", + "Color: strikes/km²/min ×10³": "Färg: nedslag/km²/min ×10³", + "Color: {units}": "Färg: {units}", + "RADAR · {units}": "RADAR · {units}", + "60°S–60°N · typically 2–3 h delayed": "60° S–60° N · oftast 2–3 h fördröjning", + "North America · infrared imagery": "Nordamerika · infraröda bilder", + "Reduced motion · history playback unavailable": "Minskad rörelse · uppspelning av historik otillgänglig", + "Reduced motion · manual history available": "Minskad rörelse · manuell historik tillgänglig", + "Rain radar · US": "Regnradar · USA", + "Lightning density · 15 min": "Blixttäthet · 15 min", + "Satellite clouds": "Satellitmoln", + "Americas + Pacific": "Amerika + Stilla havet", + "Global · 60°S–60°N": "Globalt · 60° S–60° N", + "North America": "Nordamerika", + "Source observations delayed": "Källans observationer är fördröjda", + "Stale source": "Inaktuell källa", + "Loading next frame…": "Laddar nästa bildruta …", + "Map center outside coverage": "Kartmitten utanför täckningsområdet", + "strikes/km²/min ×10³": "nedslag/km²/min ×10³", + "N. America": "Nordamerika", + "Global": "Globalt", + "Hourly global mosaic; usually 2–3 hours delayed": "Global mosaik varje timme; vanligtvis 2–3 timmars fördröjning", + "GOES regional clouds; approximately 5-minute updates": "Regionala GOES-molnbilder; uppdateras ungefär var 5:e minut", + "Clouds only": "Endast moln", + "Apply the full cloud brightness key; 100% filter strength": "Använd hela ljusstyrkeskalan för moln; 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ågig infraröd från GOES-19/18, band 14; globalt: långvågig 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": "Felaktigt 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": "Felaktigt vindmanifest", + "Malformed wind grid": "Felaktigt 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 FRITT FRAM", + "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}", + "{source} · LOCATION QUERY · RECENT": "{source} · PLATSSÖKNING · SENASTE", + "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": "inaktiv", + "running": "körs", + "suspended": "pausad", + "closed": "stängd", + "feed status|live": "live", + "feed status|stale": "inaktuell", + "feed status|unreachable": "går inte att nå", + "feed status|invalid": "ogiltig", + "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ÖRDA", + "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": "PAUS", + "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": "{owner} använder pekaren – stäng den 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": "JMF. 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ÖRHANDSVISNING", + "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örhandsvisning", + "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": "JMF.", + "Not set": "Inte vald", + "Basemap": "Bakgrundskarta", + "Source off": "Källa av", + "Unpin {slot}": "Lossa {slot}", + "Unpin the image": "Lossa bilden", + "EXPORT {slot}": "EXPORTERA {slot}", + "EXPORT": "EXPORTERA", + "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?": "Ta bort 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", + "{id} isn't reporting now — showing where it was last seen": "{id} rapporterar inte just nu – visar senast kända position", + "{id} isn't reporting now — showing where it was at {time}": "{id} rapporterar inte just nu – visar positionen kl. {time}", + "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ÄNGLIG", + "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": "Felaktiga luftfartygsrader från {source}", + "Malformed {source} response": "Felaktigt 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örda", + "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 – slutför det pågående svaret", + "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" + }, + "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": "{layer} är aktiverat med flödet {source}, men inget luftfartyg i luften har laddats i vyn över {place} än", + "{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": "Ändra kartstack", + "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 AVSLUTA", + "C TOGGLE": "C VÄXLA", + "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 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 kartvy rakt uppifrån och lutad kartvy", + "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": "Felaktig ö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 ner 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 ner 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 ner 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 säkerhet", + "Nominal and high confidence detections": "Detekteringar med normal och hög säkerhet", + "High confidence detections only": "Bara detekteringar med hög säkerhet", + "Fire intensity (FRP)": "Brandintensitet (FRP)", + "≥ {value} MW": "≥ {value} MW", + "Confidence": "Säkerhet", + "Minimum fire radiative power, megawatts": "Lägsta strålningseffekt från branden, megawatt", + "fire intensity filter|All": "Alla", + "Detection confidence": "Detekteringens säkerhet", + "{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 cockpit 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.", + "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 djurobservationer ä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": "Djurobservationer", + "Exit cockpit to fly to a sighting": "Lämna cockpit för att flyga till en observation", + "Exit cockpit to fly to a sighting area": "Lämna cockpit för att flyga till ett observationsområde", + "Wildlife": "Vilda djur", + "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": "{count} observationer i vyn", + "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.": "Vilda djur 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 kartbilden", + "The map still uses a separate request and may show a different moment.": "Kartbilden 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." +} diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 0dc9841f7..b0714ee2a 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -944,6 +944,169 @@ "iNaturalist", "iNaturalist ↗" ], + "sv": [ + "STATION", + "NAV", + "GEO", + "SAR", + "CRT", + "NVG", + "FLIR", + "REC", + "AIS: --", + "HDG {value}°", + "BRG {value}°", + "BRG —", + "CRS {value}°", + "Station {id}", + "Station", + "FOV {value}°", + "▲ {frp} MW", + "T−{time} · {mission}", + "{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", + "R&B", + "Reggae", + "Rock", + "Soul", + "Techno", + "Trance", + "AIS: {name}", + "{type} SPD: {speed} HDG: {heading}", + "POS: LIVE", + "POS: {time}Z", + "Bloom", + "{domain} · {age}", + "{cohort} · {contact}", + "{side} {angle}", + "DEST {bearing}", + "Normal", + "Anime", + "Noir", + "{aircraftClass} · {distance}", + "CCTV", + "Radio", + "{state} · {source}", + "{state} · {source} · {detail}", + "{source} · {loadingLabel}", + "{state} · {source} · {detail} · {ago}", + "{state} · {source} · {ago}", + "{source} · {ago}", + "{name}: {state}", + "DEST ---°", + "TEMP", + "RADIO", + "COCKPIT", + "Layout", + "Minimal", + "Scope", + "{speed} kn", + "NORMAL", + "ANIME", + "NOIR", + "view band|GLOBAL", + "{distance} · {duration} · {mode}", + "S2 · {name}", + "1090 MHz", + "978 MHz UAT", + "V/S {rate}", + "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", + "{state} · {time} · {lag}", + "{state}: {time}", + "RADAR · {units}", + "REGION", + "FILTER", + "{source} HTTP {status}", + "N", + "S", + "WHOT/BHOT", + "Ironbow", + "HDG", + "FOV", + "ΔN", + "LIVE", + "OK", + "{value} KM/H", + "SQUAWK {code} · {status}", + "{aircraftClass} · {emergency} · {feedState}", + "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", + "{category} ({count})", + "A / B", + "km/h", + "m/s", + "mph", + "hPa", + "ft", + "km", + "m", + "h", + "min", + "s", + "dB", + "STD", + "Plan", + "MINI", + "standard", + "mini", + "STANDARD", + "{items} {scope}", + "FT", + "MIL", + "MM", + "SIG", + "AUTO", + "FM MHZ", + "Cyber", + "Sonar", + "Cyan", + "≥ {value} MW", + "{count} {kind}", + "Be Whale Wise", + "Salish Sea", + "Acartia", + "CC BY 4.0", + "iNaturalist" + ], "uk": [ "AIS: --", "AIS: {name}", diff --git a/src/ui/localePicker.test.mjs b/src/ui/localePicker.test.mjs index d59c9926b..09939c339 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', 'ar', 'zh-CN', 'zh-Hant', @@ -73,6 +74,7 @@ test('the current language comes first, the rest by their own names', () => { 'fr', 'it', 'pt', + 'sv', 'ru', 'uk', 'ar', @@ -96,6 +98,7 @@ test('the current language comes first, the rest by their own names', () => { 'fr', 'it', 'pt', + 'sv', 'ru', 'uk', 'ar', @@ -120,6 +123,7 @@ test('the current language comes first, the rest by their own names', () => { 'fr', 'it', 'pt', + 'sv', 'ru', 'uk', 'zh-CN', From 0f9592c77accb7d5ec213e00067f507b7c8c123b Mon Sep 17 00:00:00 2001 From: Kaj Obst <217931875+Kajjan7610@users.noreply.github.com> Date: Sun, 8 Nov 2026 13:47:03 +0100 Subject: [PATCH 10/28] changelog: Swedish (sv), #1630 Co-Authored-By: Claude Opus 5.5 --- changelog.d/1630-swedish.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/1630-swedish.md 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). From dd02f8093d2a24876bef9c2e13def54d7b0d3bd5 Mon Sep 17 00:00:00 2001 From: Kaj Obst <217931875+Kajjan7610@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:56:16 +0100 Subject: [PATCH 11/28] i18n(sv): translate the strings added since the Swedish catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the Swedish catalog up to date with main: 122 new strings from the satellite classes, the ship bridge and orbital cockpit views, the world timeline and the place-name layer, translated per docs/translations/sv.md. Drops the three entries main no longer uses (DENSE ···, DENSE ✕, C TOGGLE). Brand names, codes and units that read the same in Swedish (STARLINK, NADIR, TLE, NORAD, NM, Live, …) are listed in same-as-english.json. `npm run i18n -- --check` reports sv complete; the i18n tests pass. Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/sv.json | 130 ++++++++++++++++++++++++++++++++-- src/i18n/same-as-english.json | 16 ++++- 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index b6921bac8..32eb4f99a 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -278,8 +278,6 @@ "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 ···", - "DENSE ✕": "TÄT ✕", "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", @@ -2835,7 +2833,6 @@ "SIG": "SIG", "LOCAL": "LOKALT", "ESC EXIT": "ESC AVSLUTA", - "C TOGGLE": "C VÄXLA", "Active weather products": "Aktiva väderprodukter", "RECENT IMAGERY": "SENASTE BILDER", "Imagery days in the box": "Bilddagar i området", @@ -3184,5 +3181,130 @@ "Close camera preview": "Stäng förhandsvisningen av kameran", "Refresh map still": "Uppdatera kartbilden", "The map still uses a separate request and may show a different moment.": "Kartbilden 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." + "Video plays from Québec 511, which sees your IP address.": "Videon spelas upp från Québec 511, som ser din IP-adress.", + "STATIONS": "STATIONER", + "Crewed stations and the craft that visit them": "Bemannade rymdstationer och farkosterna som besöker dem", + "IMAGING": "AVBILDNING", + "Earth observation: optical, radar, weather and science": "Jordobservation: optisk, radar, väder och forskning", + "TELECOM": "TELEKOM", + "Communications and navigation satellites, Starlink excepted": "Kommunikations- och navigeringssatelliter, utom Starlink", + "STARLINK": "STARLINK", + "The Starlink constellation (thousands of extra points)": "Starlink-konstellationen (tusentals extra punkter)", + "PROBES": "SONDER", + "Space science, technology demonstration and other payloads": "Rymdforskning, teknikdemonstration och övriga nyttolaster", + "DEBRIS": "SKROT", + "Rocket bodies, fragments and dead satellites": "Raketsteg, fragment och döda satelliter", + "Classes may lag: SATCAT is a cached copy": "Klasserna kan släpa efter: SATCAT är en sparad kopia", + "Classes may lag: SATCAT unavailable": "Klasserna kan släpa efter: SATCAT otillgänglig", + "CelesTrak SATCAT gives each object's type and status. Without a current copy the classes rest on the UCS database, names and CelesTrak groups, so a satellite that has died since may still show as working.": "CelesTraks SATCAT anger varje objekts typ och status. Utan en aktuell kopia vilar klasserna på UCS-databasen, namn och CelesTraks grupper, så en satellit som har slutat fungera sedan dess kan fortfarande visas som aktiv.", + "STARLINK ···": "STARLINK ···", + "STARLINK ✕": "STARLINK ✕", + "LOOK STBD {bearing}°": "TITTA STYRB. {bearing}°", + "LOOK PORT {bearing}°": "TITTA BABORD {bearing}°", + "LOOK ASTERN": "TITTA AKTERUT", + "UP {angle}°": "UPP {angle}°", + "DOWN {angle}°": "NED {angle}°", + "{direction} · {elevation}": "{direction} · {elevation}", + "LOOK {elevation}": "TITTA {elevation}", + "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ÖGA {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ÄNTELÄGE", + "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "SLÅ PÅ ”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} · 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 · KNOP", + "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 TITTAR", + "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", + "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/same-as-english.json b/src/i18n/same-as-english.json index d0339d809..4f09c40e8 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -1185,7 +1185,21 @@ "Salish Sea", "Acartia", "CC BY 4.0", - "iNaturalist" + "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" ], "uk": [ "AIS: --", From 315627534af920eb40546c1fc5a282b6988afe1c Mon Sep 17 00:00:00 2001 From: Kaj Obst <217931875+Kajjan7610@users.noreply.github.com> Date: Mon, 9 Nov 2026 13:36:47 +0100 Subject: [PATCH 12/28] i18n(sv): ORBIT as OMLOPP, which also fits the satellite orbit entry Co-Authored-By: Claude Opus 5.5 --- src/i18n/locales/sv.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 32eb4f99a..35b89bdcd 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -534,7 +534,7 @@ "📍 Location: --": "📍 Plats: --", "Landmark: --": "Landmärke: --", "Searched location": "Sökt plats", - "ORBIT": "KRETSAR", + "ORBIT": "OMLOPP", "Location not found": "Platsen hittades inte", "Search failed": "Sökningen misslyckades", "Fly to a POI first": "Flyg till en intressepunkt först", From cdccf88bbba3a3926213d54eb33d1222775e8117 Mon Sep 17 00:00:00 2001 From: Kaj Obst <217931875+Kajjan7610@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:25:24 +0100 Subject: [PATCH 13/28] i18n(sv): address review (export buttons, singular, consistent terms) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EXP. {slot} and EXPORT so the Recent Imagery export buttons fit and A and B read apart; EXPORT listed as same-as-English. - {count} HÖRT / hört for one aircraft; the plural rule in sv.md no longer lists HÖRDA. - INTE FARA ÖVER throughout; VÄ/HÖ for left/right (V is west on the compass); STYRD KURS vs KURS Ö. GRUND; MIK in the first-run tip. - Label-first counts for sightings and standby cameras; neuter audio states; OSÄKER on chip and status line; KN for knots; the ellipsis character; Ladda ned and VÄNTLÄGE throughout; Olagligt ingripande; FÖRHANDSVY for the preview flag and authoring heading. - sv.json in the format scope; Swedish in language-code order in TRANSLATORS.md; glossary row reduced to incident → händelse; 29 letterless entries dropped from same-as-english.json. Co-Authored-By: Claude Opus 5.5 --- docs/TRANSLATORS.md | 4 +-- docs/translations/sv.md | 4 +-- scripts/format-scope.json | 1 + src/i18n/locales/sv.json | 50 +++++++++++++++++------------------ src/i18n/same-as-english.json | 32 ++-------------------- 5 files changed, 32 insertions(+), 59 deletions(-) diff --git a/docs/TRANSLATORS.md b/docs/TRANSLATORS.md index 36c37b62f..db196fb88 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`), **Italian** (`it`), **Japanese** (`ja`), **Brazilian Portuguese** (`pt`), **Russian** (`ru`), **Ukrainian** (`uk`), **Simplified Chinese** (`zh-CN`), **Traditional Chinese** (`zh-Hant`) and **Swedish** (`sv`). +Shipped languages: **English** (source and fallback), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), **French** (`fr`), **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 @@ -63,4 +63,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), [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), [Swedish](./translations/sv.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), [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 index 13d557e96..12d3a8b80 100644 --- a/docs/translations/sv.md +++ b/docs/translations/sv.md @@ -32,7 +32,7 @@ A `tn()` entry needs two forms. `Intl.PluralRules('sv')` picks: | `one` | 1 | 1 kamera | | `other` | everything else: 0, 2, 1,5… | 3 kameror | -Abbreviated units and codes do not change: «{count} MEDD.», «{count} HÖRDA». +Abbreviated units and codes do not change: «{count} MEDD.». ## Glossary @@ -81,4 +81,4 @@ Abbreviated units and codes do not change: «{count} MEDD.», «{count} HÖRDA» | 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 | -| road condition / incident / roadwork | väglag / händelse / vägarbete | +| incident | händelse | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index 2bb7a20ae..ca69f6195 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -1295,6 +1295,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/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 35b89bdcd..081afdf7d 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -132,7 +132,7 @@ "⚠️ 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": "{count} KAMEROR I BEREDSKAP · INGEN KAMERA VALD · KLICKA PÅ EN KAMERA FÖR ATT AKTIVERA DEN", + "{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}°", @@ -415,8 +415,8 @@ "{cohort}, Unavailable": "{cohort}, otillgänglig", "AHEAD": "RAKT FRAM", "{side} {angle}": "{side} {angle}", - "L": "V", - "R": "H", + "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" @@ -474,7 +474,7 @@ "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} · KURS {heading}°", + "{label} · COURSE {heading}°": "{label} · KURS Ö. GRUND {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": { @@ -527,7 +527,7 @@ "{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": "OKLART", + "UNCERTAIN": "OSÄKER", "{name}: {state}": "{name}: {state}", "{count}s ago": "{count} s sedan", "CONNECTING": "ANSLUTER", @@ -740,7 +740,7 @@ "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 ...", + "Search any location...": "Sök valfri plats …", "WEATHER": "VÄDER", "CONTEXT": "KONTEXT", "Context mode": "Kontextläge", @@ -816,7 +816,7 @@ "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 ...", + "Initializing photorealistic world...": "Initierar fotorealistisk värld …", "DATA LAYERS": "DATALAGER", "NEAREST": "NÄRMASTE", "CCTV camera": "CCTV-kamera", @@ -859,7 +859,7 @@ "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 GEV MIC i dockan går det att prata med kartan.", + "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.", @@ -892,7 +892,7 @@ "Business jet": "Affärsjet", "Drone": "Drönare", "General emergency": "Allmänt nödläge", - "Unlawful interference": "Olaglig inblandning", + "Unlawful interference": "Olagligt ingripande", "Radio failure": "Radiobortfall", "Downed aircraft": "Nedstörtat luftfartyg", "Minimum fuel": "Minimum fuel (låg bränslemängd)", @@ -2097,7 +2097,7 @@ "{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 FRITT FRAM", + "NEARBY AIRCRAFT INCOMPLETE · NOT AN ALL-CLEAR": "FLYGPLAN I NÄRHETEN OFULLSTÄNDIGA · INTE FARA ÖVER", "SQUAWK {code} · {status}": "SQUAWK {code} · {status}", "ADS-B PRIORITY · {status}": "ADS-B-PRIORITET · {status}", "ADS-B EMERGENCY · {status}": "ADS-B-NÖDLÄGE · {status}", @@ -2171,10 +2171,10 @@ "TUNING": "STÄLLER IN", "STREAMING": "STRÖMMAR", "UNSUPPORTED": "STÖDS INTE", - "idle": "inaktiv", + "idle": "inaktivt", "running": "körs", - "suspended": "pausad", - "closed": "stängd", + "suspended": "pausat", + "closed": "stängt", "feed status|live": "live", "feed status|stale": "inaktuell", "feed status|unreachable": "går inte att nå", @@ -2193,7 +2193,7 @@ "audio signal --": "ljudsignal --", "audio {state}": "ljud {state}", "{count} HEARD": { - "one": "{count} HÖRDA", + "one": "{count} HÖRT", "other": "{count} HÖRDA" }, "DISCONNECT": "KOPPLA FRÅN", @@ -2292,7 +2292,7 @@ "Forget the box and its images": "Glöm området och dess bilder", "START HERE": "BÖRJA HÄR", "Checking": "Kontrollerar", - "PREVIEW": "FÖRHANDSVISNING", + "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}", @@ -2311,8 +2311,8 @@ "Source off": "Källa av", "Unpin {slot}": "Lossa {slot}", "Unpin the image": "Lossa bilden", - "EXPORT {slot}": "EXPORTERA {slot}", - "EXPORT": "EXPORTERA", + "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", @@ -2412,7 +2412,7 @@ }, "Loading {feeds}": "Laddar {feeds}", "{count} heard": { - "one": "{count} hörda", + "one": "{count} hört", "other": "{count} hörda" }, "USB {rate} msg/s": "USB {rate} medd./s", @@ -2947,11 +2947,11 @@ "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 ner alla scener som en JSON-fil som kan importeras senare", + "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 ner tidsloggen från senaste uppspelningen", + "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 ner scenen som JSON eller som resurspaket", + "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", @@ -3163,7 +3163,7 @@ "Photo of {name}": "Foto av {name}", "Acartia": "Acartia", "CC BY 4.0": "CC BY 4.0", - "{count} sightings in view": "{count} observationer i vyn", + "{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.": "Vilda djur 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", @@ -3213,7 +3213,7 @@ "{range} NM": "{range} NM", "AIS DESTINATION · CREW REPORTED": "AIS-DESTINATION · ANGIVEN AV BESÄTTNINGEN", "{label} · NADIR VIEW": "{label} · NADIRVY", - "SITES STANDBY": "PLATSER I VÄNTELÄGE", + "SITES STANDBY": "PLATSER I VÄNTLÄGE", "ENABLE MAPPED INSTALLATIONS FOR SITE PINGS": "SLÅ PÅ ”KARTLAGDA ANLÄGGNINGAR” FÖR PLATSPINGAR", "Unnamed site": "Namnlös plats", "OFF-NADIR {angle}°": "OFF-NADIR {angle}°", @@ -3231,7 +3231,7 @@ "{direction} {angle}°": "{direction} {angle}°", "OFF-NADIR {angle}° · {offset}": "OFF-NADIR {angle}° · {offset}", "BRIDGE": "BRYGGA", - "{label} · HEADING {heading}°": "{label} · KURS {heading}°", + "{label} · HEADING {heading}°": "{label} · STYRD KURS {heading}°", "{label} · NO HEADING": "{label} · INGEN KURS", "Place names": "Ortnamn", "Ship bridge view": "Fartygets bryggvy", @@ -3244,7 +3244,7 @@ "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 · KNOP", + "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)", diff --git a/src/i18n/same-as-english.json b/src/i18n/same-as-english.json index 4f09c40e8..c96b35a40 100644 --- a/src/i18n/same-as-english.json +++ b/src/i18n/same-as-english.json @@ -1042,7 +1042,6 @@ "Station", "FOV {value}°", "▲ {frp} MW", - "T−{time} · {mission}", "{value} KM", "{value} KM/S", "STATUS · ", @@ -1063,7 +1062,6 @@ "Oldies", "Pop", "Punk", - "R&B", "Reggae", "Rock", "Soul", @@ -1074,23 +1072,12 @@ "POS: LIVE", "POS: {time}Z", "Bloom", - "{domain} · {age}", - "{cohort} · {contact}", - "{side} {angle}", "DEST {bearing}", "Normal", "Anime", "Noir", - "{aircraftClass} · {distance}", "CCTV", "Radio", - "{state} · {source}", - "{state} · {source} · {detail}", - "{source} · {loadingLabel}", - "{state} · {source} · {detail} · {ago}", - "{state} · {source} · {ago}", - "{source} · {ago}", - "{name}: {state}", "DEST ---°", "TEMP", "RADIO", @@ -1103,11 +1090,8 @@ "ANIME", "NOIR", "view band|GLOBAL", - "{distance} · {duration} · {mode}", - "S2 · {name}", "1090 MHz", "978 MHz UAT", - "V/S {rate}", "POSITION {age}", "fire behavior|Minimal", "{county} County", @@ -1118,14 +1102,10 @@ "Google Street View", "GOOGLE STREET VIEW", "Hybrid", - "{state} · {time} · {lag}", - "{state}: {time}", "RADAR · {units}", "REGION", "FILTER", "{source} HTTP {status}", - "N", - "S", "WHOT/BHOT", "Ironbow", "HDG", @@ -1135,7 +1115,6 @@ "OK", "{value} KM/H", "SQUAWK {code} · {status}", - "{aircraftClass} · {emergency} · {feedState}", "S2 GhostMaps", "NASA FIRMS · LIVE", "feed status|live", @@ -1150,18 +1129,12 @@ "Washington DC", "Tallinn", "Los Angeles", - "{category} ({count})", - "A / B", "km/h", - "m/s", "mph", "hPa", "ft", "km", - "m", - "h", "min", - "s", "dB", "STD", "Plan", @@ -1169,7 +1142,6 @@ "standard", "mini", "STANDARD", - "{items} {scope}", "FT", "MIL", "MM", @@ -1180,7 +1152,6 @@ "Sonar", "Cyan", "≥ {value} MW", - "{count} {kind}", "Be Whale Wise", "Salish Sea", "Acartia", @@ -1199,7 +1170,8 @@ "NADIR", "SENSOR", "1 min/s", - "Live" + "Live", + "EXPORT" ], "uk": [ "AIS: --", From 9c73939425c1317773c6478854ccc8ae60b22c25 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 10 Nov 2026 15:12:50 +0000 Subject: [PATCH 14/28] Street Level: leave an interrupted link and an overtaken view alone (gekh review) - A pasted or start-up link opens its photos only when its restore's camera landed: a drag or search during the restore flight keeps the camera. - A posted view whose photo step was cancelled or superseded no longer closes the viewer or flies; one that found a photo it could not load keeps the error. - openNearest gains `replace` (keep the open photo while it looks, close it only when nothing opens, so EXPAND stays) and `proceed()` (asked before a found photo loads); the action uses both, so a navigation, a gesture or a hiding mode during the lookup loads no billed panorama. - Mapillary's timeout message is a msg() string with all eleven translations. --- changelog.d/1113-show-street-level.md | 8 +- docs/CURRENT-STATE.md | 15 ++- scripts/package-boundaries.json | 1 + src/app/embed.js | 18 ++- src/app/embed.test.mjs | 112 +++++++++++++++++- src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/uk.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-Hant.json | 1 + src/layers/streetLevel/index.js | 16 +++ src/layers/streetLevel/index.test.mjs | 80 +++++++++++++ .../streetLevel/providers/mapillary/source.js | 6 +- .../providers/mapillary/source.test.mjs | 19 +++ src/voice/gevActions.js | 16 ++- src/voice/gevActions.test.mjs | 44 +++++++ 22 files changed, 329 insertions(+), 17 deletions(-) diff --git a/changelog.d/1113-show-street-level.md b/changelog.d/1113-show-street-level.md index 076879a52..ca56834ec 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -31,6 +31,10 @@ recording and scene playback hide the photo. The action checks again after every wait: a navigation or a drag of the globe, or a mode that hides the panel, stops it before a photo loads for a place the user left or in a - panel nobody sees, and a request that finds nothing closes the previous - photo. The panel's later results open a billed photo only when they are + panel nobody sees, also between finding a photo and loading it. A request + that finds nothing closes the previous photo, and one that finds a photo + replaces it in place, so EXPAND stays. A link whose restore flight the + visitor interrupted, or a posted view the user overtook, leaves their + camera and photo alone, and a photo found but not loaded keeps its error. + Mapillary's timeout message is translated. The panel's later results open a billed photo only when they are fresh too (Said Turksever, @asturksever, #1113). diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index ce9fe507b..6983e1a88 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5119,13 +5119,22 @@ brings `map: photoreal` and refuses another map; not with `follow`), links as applies it through the action, and a plain link runs the action once restored (one shot: the rewritten hash drops `sl`). A link pasted into the open tab runs it after its restore settles (`gev:share-navigation-restore-settled`), -and a start link whose restore a newer one superseded never runs. In embed mode the rail holding the +and only when the restore's camera landed (`share.camera === 'applied'`): a +link whose restore flight the visitor stopped with a drag or a search, or +whose restore a newer link superseded, never runs and never pulls the camera +back. In embed mode the rail holding the panel 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 open under embed mode's own clean view; cockpit, recording and scene playback hide the overlay. A -posted view whose photo step fails closes the earlier photo before its -camera flies. Image-loading failures render inside +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, and one that +found a photo it could not load keeps that error in view. The action's +lookup replaces an open photo in place (`openNearest` `replace: true`), so +the viewer and EXPAND stay up, and closes it only when it opens nothing; once +an image is found, `proceed()` asks whether the camera and the panel are +still the request's before a billed photo loads. Image-loading failures render inside the embedded overlay, since the panel's error region is hidden with the rail. A caller's abort is honoured through the viewer's mount and load, not just the lookup, and only diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 9b9f880e5..428df110d 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -2087,6 +2087,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", diff --git a/src/app/embed.js b/src/app/embed.js index 3b9df2a9c..1f18db5e0 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -268,8 +268,14 @@ export async function applyView( streetLevelArgs(view.street_level, { autoOpen }), ); if (result?.ok !== true) { + // Overtaken (a click on the map, a newer request): the panel and the + // camera are someone else's now. + if (result?.cancelled || result?.superseded) return steps; // An earlier view's photo must not stay over a place this view left. - dataManager.layers?.get?.(STREET_LEVEL_LAYER)?.module?.closeViewer?.(); + // From the imagery stage on the action has settled the panel itself: + // a photo it found but could not load keeps its error in view. + 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) }); } @@ -332,11 +338,16 @@ export function installViews({ // link must not fly over them. // A start-up restore that a newer link superseded hands the camera to that // link: its own steps never run. + // A restore whose camera never landed (the recipient dragged, searched or + // opened another place during its flight) leaves the camera with them. + const cameraLanded = (outcome) => outcome?.share?.camera === 'applied'; let generationAtRestore = null; let startSuperseded = false; let queue = ready.then((outcome) => { generationAtRestore = shell._navigationGeneration; - startSuperseded = outcome?.status === 'superseded'; + startSuperseded = + outcome?.status === 'superseded' || + (Boolean(outcome?.share) && !cameraLanded(outcome)); }); const navigatedSince = (generation) => Number.isFinite(generation) && shell._navigationGeneration !== generation; @@ -397,7 +408,8 @@ export function installViews({ // its photos follow once that restore settles, under the same rule: a plain // link never loads a billed photo by itself. const onNavigatedRestore = (event) => { - if (event?.detail?.status !== 'settled') return; + if (event?.detail?.status !== 'settled' || !cameraLanded(event.detail)) + return; const streetLevel = linkedStreetLevel( new URLSearchParams(String(location?.hash || '').replace(/^#/, '')), ); diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 45f85a625..d81831727 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -972,6 +972,36 @@ test('a start link whose restore a newer link superseded opens nothing', async ( 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(); @@ -990,10 +1020,10 @@ test('a link pasted into the open tab opens or arms its photos once its restore }); await new Promise((resolve) => setTimeout(resolve, 0)); assert.deepEqual(calls, [], 'the start link has no photos'); - const settled = (status) => + const settled = (status, camera = 'applied') => windowRef.dispatchEvent( Object.assign(new Event('gev:share-navigation-restore-settled'), { - detail: { status }, + detail: { status, share: { camera } }, }), ); location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950'; @@ -1001,6 +1031,19 @@ test('a link pasted into the open tab opens or arms its photos once its restore 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)); @@ -1027,6 +1070,71 @@ test('a link pasted into the open tab opens or arms its photos once its restore 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 = { diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index cabf81a0e..03322bd85 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -1787,6 +1787,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 معدل الطلبات", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index e9cdaae47..d63183854 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -1701,6 +1701,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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index d8584bb51..0502b598f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -2320,6 +2320,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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b61570c0d..334594ce2 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -1093,6 +1093,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", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 1d68f1c2a..d6f2e7af0 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -1093,6 +1093,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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c43ffcf42..8d6f8579a 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1061,6 +1061,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がリクエストをレート制限中です", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index bd885f741..a64b883b2 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -1091,6 +1091,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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index c8b053306..62630ca21 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -2358,6 +2358,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 ограничивает частоту запросов", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 382284595..b3c2f8b43 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -1108,6 +1108,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 обмежує частоту запитів", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 9f350e442..64bf6a3bd 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -1038,6 +1038,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 已被限流", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index d9cbd34c1..4ae48fb2f 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -1033,6 +1033,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 已被限流", diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index c5705103f..96992e347 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -695,6 +695,12 @@ export function createStreetLevelLayer({ * as the assistant's) neither uses nor drops a link's armed point. Why * nothing opened is left in `street.nearestOutcome`: 'empty' (no imagery * in reach), 'sky' (the screen centre is not ground), or null (an error). + * `replace: true` (a request that takes over the panel) keeps an open + * photo up while it looks, so the viewer and EXPAND stay, and closes it + * when the lookup opens nothing: the panel shows this request's outcome. + * `proceed()` is asked once the lookup has found an image and before it + * loads; false retires the request (a photo that bills every load must + * not load for a request its caller has given up). */ async openNearest( point, @@ -705,6 +711,8 @@ export function createStreetLevelLayer({ radiusM = null, signal: callerSignal = null, armed: useArmed = true, + replace = false, + proceed = null, } = {}, ) { if (callerSignal?.aborted) return false; @@ -713,6 +721,11 @@ export function createStreetLevelLayer({ // 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) return; + parts.viewerHost.close(); + parts.clearSequences(); + }; if (!point && aim === 'screen-centre' && armed) { // The button promises the centre of the view: the link's spot only // while the view still shows it, or when the view cannot be picked. @@ -735,6 +748,7 @@ export function createStreetLevelLayer({ view = screenCentreGround(); if (view === null) { abortNearest(); + closeReplaced(); state.street.loading = false; state.street.error = AIM_AT_GROUND_MESSAGE; state.street.nearestOutcome = 'sky'; @@ -795,6 +809,7 @@ export function createStreetLevelLayer({ 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; callerSignal?.removeEventListener('abort', onCallerAbort); // The caller's signal still counts while the viewer mounts and loads. @@ -807,6 +822,7 @@ export function createStreetLevelLayer({ // Replaced while it looked: the panel is the newer lookup's. if (nearestLookup !== lookup) return false; nearestLookup = null; + closeReplaced(); // Switched off while it looked: nothing to open, nothing to report. const stillOn = entries.some( (entry) => entry.on && providerAvailable(entry), diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index a75f8524e..ce6a91abc 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -2110,3 +2110,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/providers/mapillary/source.js b/src/layers/streetLevel/providers/mapillary/source.js index 4830d54e2..156771de6 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -1,3 +1,4 @@ +import { msg } from '../../../../i18n/marker.js'; import { NEAREST_RADIUS_M } from '../../policy.js'; import { MAPILLARY_GRAPH_HOST, @@ -8,6 +9,9 @@ import { 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 } = {}) { @@ -81,7 +85,7 @@ export function createMapillarySource({ const timer = setTimeout( () => controller.abort( - new MapillarySourceError('Mapillary did not answer in time', { + new MapillarySourceError(NO_ANSWER, { status: 504, }), ), diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index dfa733a7f..d7fb8b7ee 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -201,6 +201,25 @@ test('a nearest-image search that never answers is given up, body included', asy 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 () => { diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 9c2051a4e..f8a906dc2 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -4084,14 +4084,12 @@ async function requestStreetLevel( location, }); - // A photo of another place must not stay up beside this request's answer, - // armed or opened, found or not: the panel shows this request's outcome. - if (layerModule.getUIState?.()?.street?.open === true) - layerModule.closeViewer?.(); - // A billed source never loads from a link without a click: everything is // ready and OPEN is one press away at the view centre. 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?.(); // The next OPEN press loads exactly what the link asked for. layerModule.armNearest?.(point, { providerIds: [provider], @@ -4113,11 +4111,17 @@ async function requestStreetLevel( // A source with no coverage to click (Street View) is searched wider: a // geocoded landmark is rarely within 50 m of a street. A newer turn - // retires the lookup: nothing opens after the user moved on. + // retires the lookup: nothing opens after the user moved on. A photo of + // another place stays up while it looks (the viewer, EXPAND included, is + // replaced, not closed) and closes if this request opens nothing: the + // panel shows this request's outcome. Once an image is found it loads + // only if 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 }), diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 1a0a74808..cf4cbff6f 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -6660,16 +6660,26 @@ function createStreetLevelHarness({ return false; } } + // Like the layer: a replacing lookup that opens nothing closes the photo. + const closeReplaced = () => { + if (!options.replace || !street.open) return; + order.push('close'); + Object.assign(street, { open: false, imageId: null, error: null, nearestOutcome: null }); + }; 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; } + // Found: it loads only if the request still wants it. + if (options.proceed && options.proceed() !== true) return false; street.open = true; street.imageId = nearest; street.title = '10th St, Sacramento'; @@ -7426,6 +7436,40 @@ test('show_street_level stops once the camera is someone else’s: a navigation 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(); From f3996da0af03143a4466db39abdf29ee24c5fd96 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 23:48:57 +0400 Subject: [PATCH 15/28] Street Level: a landed link, a replaced photo and a retired lookup keep their state - A link's photo step compares with the navigation token its own restore flight owned, so a navigation after the flight landed (while the layers still load) keeps the camera. - A request that switches the source keeps the open photo up until it is replaced (holdViewer), so EXPAND stays across sources. - A lookup given up while it looked leaves the open photo and the panel as they were, also when it found nothing or failed. - A failed replacement shows its error inside the expanded viewer. - A replacing lookup stops a playing Mapillary sequence before the found photo opens. - The action refuses coordinates outside the globe, keeps a name given with coordinates as its label, and fly: false keeps the camera only for coordinates (its test-only entity and query branches are gone). - Numbered entities carry their label; a view's street-level point is a pair. - Mapillary's nearest search always asks for 50 m and 100 images. - Tests: the lifecycle reproductions, PLAY on replacement, out-of-range coordinates, half a point, and the named label; three layer tests share enabledLayer; the second payload digest is gone. - Docs: the tool count, the panel freshness rule, armed: false, the photo replacement, the browser-gate comment and a shorter changelog fragment. --- SECURITY.md | 2 +- changelog.d/1113-show-street-level.md | 49 ++--- docs/CURRENT-STATE.md | 21 +- src/app/embed.js | 10 +- src/layers/streetLevel/index.js | 54 +++-- src/layers/streetLevel/index.test.mjs | 90 +++----- src/layers/streetLevel/lineCoverage.js | 5 +- .../streetLevel/providers/mapillary/index.js | 7 +- .../providers/mapillary/provider.test.mjs | 6 +- .../providers/mapillary/replacePlay.test.mjs | 56 +++++ .../streetLevel/providers/mapillary/source.js | 11 +- .../providers/mapillary/source.test.mjs | 15 +- .../streetLevel/providers/mapillary/viewer.js | 20 +- src/layers/streetLevel/registry.js | 2 +- src/layers/streetLevel/viewerHost.js | 7 +- src/streetLevelLifecycle.test.mjs | 194 ++++++++++++++++++ src/ui/shareRestoration.js | 4 +- src/ui/views/panels/StreetLevelPanel.vue | 4 +- src/view/index.js | 7 +- src/view/index.test.mjs | 10 + src/voice/actionSchemas.test.mjs | 25 +-- src/voice/deixis.js | 2 + src/voice/deixis.test.mjs | 2 +- src/voice/gevActions.js | 72 +++---- src/voice/gevActions.test.mjs | 108 ++-------- src/voice/pointAndAsk.test.mjs | 14 +- 26 files changed, 470 insertions(+), 327 deletions(-) create mode 100644 src/layers/streetLevel/providers/mapillary/replacePlay.test.mjs create mode 100644 src/streetLevelLifecycle.test.mjs diff --git a/SECURITY.md b/SECURITY.md index 6f3dc4ce6..21affbcab 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -132,7 +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; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms, and so does any later result on the same mount issued more than five minutes ago; 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; 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. +- **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; 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 index ca56834ec..89f47726e 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -1,40 +1,15 @@ - "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, reporting which step failed. - Views and share links carry the same request (`street_level`, `sl=`), so - `show_in_gods_eye_view` and a link can open a panorama at a place. Street - View lookups from a geocoded place search up to 250 m; clicks keep 50 m. A - plain link with `sl=google` arms Street View and leaves the billed load to a - press of OPEN GOOGLE STREET VIEW, which aims at the view centre again once - the visitor has moved more than 1 km away; embedded views open it, and the - God's Eye View panel only for a result issued in the last five minutes. On - a phone, a photo that opens or a link that arms opens the Cameras sheet; - in cockpit, clean view, recording or scene playback the action says the - panel is hidden instead of loading a photo nobody sees. Embedded photos - remain available over the clean globe. Mapillary's - nearest-photo search asks for 100 photos, so the nearest and the filter's - pick come from the spot's photos rather than an arbitrary eight. The source the - action lights stays lit like a chip click. An unavailable map stack now - reports the controller's own reason instead of always asking for a Cesium - ion token. When nothing is found and a 360°, flat or SINCE filter the source - honours is on, the answer names the filter. Cancelled lookups clear their - loading state over an existing photo and release their caller abort listeners. - Embedded photos show loading errors inside the overlay, and the OPEN prompt - uses the recipient's selected language. A link's photos never save its - layers as the recipient's preferences, and a link pasted into an open tab - opens or arms them too. With no place named, the action looks under the - screen centre as OPEN does; an armed OPEN aimed at the sky opens nothing; a - failing source is reported as an error, not as missing imagery; and - Mapillary's nearest search gives up after 12 seconds. On a phone the panel - scrolls into view, and in embed mode EXPAND stays open while cockpit, - recording and scene playback hide the photo. The action checks again after - every wait: a navigation or a drag of the globe, or a mode that hides the - panel, stops it before a photo loads for a place the user left or in a - panel nobody sees, also between finding a photo and loading it. A request - that finds nothing closes the previous photo, and one that finds a photo - replaces it in place, so EXPAND stays. A link whose restore flight the - visitor interrupted, or a posted view the user overtook, leaves their - camera and photo alone, and a photo found but not loaded keeps its error. - Mapillary's timeout message is translated. The panel's later results open a billed photo only when they are - fresh too (Said Turksever, @asturksever, #1113). + 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 request stops when the user navigates or hides the panel before + its photo loads; 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. 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 6983e1a88..32494ab44 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -1094,7 +1094,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. 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 34 tools using separate description-only metadata; a session is offered 33 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. @@ -5079,8 +5079,8 @@ source has its key (a keyless source explains itself and changes nothing, as a chip does), flies to the place at street framing and waits for arrival (a name, coordinates, a pointed spot, a numbered item, or nothing for the ground under the screen centre, as OPEN uses: `aim: 'screen-centre'` with -`armed: false`, so sky refuses and a link's armed point is neither used nor -dropped), switches to the map the source needs (Google Street View: Google 3D), +`armed: false`, so sky refuses and a link's armed point is not used and is +kept until a photo opens), switches to the map the source needs (Google Street View: Google 3D), lights that source through `setLayerParams` with origin `voice` (one source at a time, kept in the share link like a chip click; a link runs the action with origin `programmatic`, for the source switch and the layer, so it never writes @@ -5147,11 +5147,16 @@ checks again 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 the remaining stages (`superseded: true`), and cockpit, clean view, recording or scene -playback entered meanwhile ends it at the `view` stage; a photo that opened -after either is closed again. The hidden-panel check also runs before the -flight, so a recording's camera never moves for it. A photo already open -closes before the new request arms or looks up, so a place with no imagery -shows its own error, not the previous place's photo. A plain +playback entered meanwhile ends it at the `view` stage. During the lookup the +same checks run before a found photo loads (`proceed`), so nothing billed +loads for a request that was given up, and a lookup given up leaves the +panel as it was; a photo that opened after either is closed again. The +hidden-panel check also runs before the flight, so a recording's camera never +moves for it. A photo already open stays up while the new request looks (the +viewer is replaced, not closed, so EXPAND stays, also when the request +switches the source) and closes if the request opens nothing, so a place with +no imagery shows its own error, not the previous place's photo; a link that +only arms closes it at once. A plain link never loads a photo from a source that bills every load (`billedPerImage` on the provider; Google): the action runs with `autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's diff --git a/src/app/embed.js b/src/app/embed.js index 1f18db5e0..d1ad4653c 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -341,10 +341,16 @@ export function installViews({ // A restore whose camera never landed (the recipient dragged, searched or // opened another place during its flight) leaves the camera with them. const cameraLanded = (outcome) => outcome?.share?.camera === 'applied'; + // The generation the restore's own flight owned: a navigation after it + // landed, while the layers still loaded, has moved on from it. + const restoreGeneration = (outcome) => + Number.isInteger(outcome?.navigationToken) + ? outcome.navigationToken + : shell._navigationGeneration; let generationAtRestore = null; let startSuperseded = false; let queue = ready.then((outcome) => { - generationAtRestore = shell._navigationGeneration; + generationAtRestore = restoreGeneration(outcome); startSuperseded = outcome?.status === 'superseded' || (Boolean(outcome?.share) && !cameraLanded(outcome)); @@ -414,7 +420,7 @@ export function installViews({ new URLSearchParams(String(location?.hash || '').replace(/^#/, '')), ); if (!streetLevel) return; - const generation = shell._navigationGeneration; + const generation = restoreGeneration(event.detail); queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation })); }; windowRef?.addEventListener?.( diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 96992e347..5e13e647d 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -266,8 +266,11 @@ export function createStreetLevelLayer({ if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def); } + /** Requests replacing the open photo that switch its source keep it up. */ + let viewerHolds = 0; + function deactivate(entry) { - parts.viewerHost.unmount(entry.def.id); + if (!viewerHolds) parts.viewerHost.unmount(entry.def.id); entry.instance.deactivate(); parts.credits.hide(state.viewer, entry.def); } @@ -280,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; @@ -692,15 +702,17 @@ export function createStreetLevelLayer({ * street); clicks keep the default. A caller's `signal` retires the * lookup: nothing opens after it fires. Providers without * `nearestImage` are skipped. `armed: false` (a request of its own, such - * as the assistant's) neither uses nor drops a link's armed point. Why + * as the assistant's) does not use a link's armed point, and keeps it + * while it looks; a photo it opens ends it, as any opened photo does. Why * nothing opened is left in `street.nearestOutcome`: 'empty' (no imagery * in reach), 'sky' (the screen centre is not ground), or null (an error). * `replace: true` (a request that takes over the panel) keeps an open * photo up while it looks, so the viewer and EXPAND stay, and closes it * when the lookup opens nothing: the panel shows this request's outcome. - * `proceed()` is asked once the lookup has found an image and before it - * loads; false retires the request (a photo that bills every load must - * not load for a request its caller has given up). + * `proceed()` is asked before a found image loads and before an empty + * or failed lookup reports; false retires the request and leaves the + * panel as it was (a photo that bills every load must not load for a + * request its caller has given up). */ async openNearest( point, @@ -722,9 +734,7 @@ export function createStreetLevelLayer({ const armed = useArmed ? state.street.armed : null; if (useArmed) state.street.armed = null; const closeReplaced = () => { - if (!replace || !state.street.open) return; - parts.viewerHost.close(); - parts.clearSequences(); + if (replace && state.street.open) closeViewer(); }; if (!point && aim === 'screen-centre' && armed) { // The button promises the centre of the view: the link's spot only @@ -812,6 +822,9 @@ export function createStreetLevelLayer({ if (proceed && proceed() !== true) return retired(); nearestLookup = null; callerSignal?.removeEventListener('abort', onCallerAbort); + // The photo asked for stays on screen: a sequence playing in the + // photo it replaces stops. + 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, @@ -821,6 +834,9 @@ export function createStreetLevelLayer({ 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 while it looked: the open photo stays, and + // the panel reports nothing of this lookup. + if (proceed && proceed() !== true) return retired(); nearestLookup = null; closeReplaced(); // Switched off while it looked: nothing to open, nothing to report. @@ -848,11 +864,23 @@ export function createStreetLevelLayer({ 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 the + * source (the viewer and EXPAND are replaced, not closed). The returned + * release closes it if its source is still off by then. + */ + holdViewer() { + viewerHolds += 1; + let held = true; + return () => { + if (!held) return; + held = false; + viewerHolds -= 1; + if (viewerHolds) return; + for (const entry of state.providers.values()) + if (!entry.on) parts.viewerHost.unmount(entry.def.id); + }; }, /** * Prepare a nearest lookup for the next OPEN press without running it: a diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index ce6a91abc..92cb2338e 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -366,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() {} }) }); @@ -374,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(() => { @@ -1944,7 +1952,6 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async ( }); test('OPEN aimed at the sky after a link armed opens nothing: never the far link point', async (t) => { - const { createStreetLevelLayer: create } = await import('./index.js'); const scenePick = await import('../../data/scenePick.js'); const lookups = []; const provider = fakeProvider({ @@ -1953,26 +1960,18 @@ test('OPEN aimed at the sky after a link armed opens nothing: never the far link return null; }, }); - const saved = globalThis.document; - const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); - globalThis.document = Object.assign(new EventTarget(), { - createElement: () => ({ getContext: () => drawing }), - }); - const viewer = fakeViewer(); - // 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; - const layer = create({ providers: [provider], services: { scenePick } }); - layer.init(viewer); - layer.enable(viewer); - t.after(() => { - layer.destroy(); - globalThis.document = saved; + 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( @@ -1986,7 +1985,6 @@ test('OPEN aimed at the sky after a link armed opens nothing: never the far link }); test('a request of its own (armed: false) neither uses nor drops the link’s armed point', async (t) => { - const { createStreetLevelLayer: create } = await import('./index.js'); const scenePick = await import('../../data/scenePick.js'); const lookups = []; const provider = fakeProvider({ @@ -1995,25 +1993,18 @@ test('a request of its own (armed: false) neither uses nor drops the link’s ar 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; + 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 }); @@ -2030,25 +2021,12 @@ test('a request of its own (armed: false) neither uses nor drops the link’s ar }); test('a failing source is not reported as missing imagery', async (t) => { - const { createStreetLevelLayer: create } = await import('./index.js'); const provider = fakeProvider({ nearestImage: async () => { throw new Error('Mapillary graph HTTP 500'); }, }); - const saved = globalThis.document; - const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) }); - globalThis.document = Object.assign(new EventTarget(), { - createElement: () => ({ getContext: () => drawing }), - }); - const viewer = fakeViewer(); - const layer = create({ providers: [provider] }); - layer.init(viewer); - layer.enable(viewer); - t.after(() => { - layer.destroy(); - globalThis.document = saved; - }); + 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); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index b71fe7cd7..80a86836c 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -532,9 +532,8 @@ export function createLineCoverage({ } /** - * The sequence lines drawn now, as `[lon, lat]` parts per sequence: for - * diagnostics and the browser gate, which aims its clicks at them instead - * of scanning pixels: what each tile drew (`attachPrimitive` keeps it in + * The sequence lines drawn now, as `[lon, lat]` parts per sequence, for + * diagnostics: what each tile drew (`attachPrimitive` keeps it in * `entry.sequences`). Read-only copies. */ function drawnLines() { diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index 97d3460b3..c40bed62c 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -6,7 +6,6 @@ import { import { createSequenceCones, thinImages } from '../../sequenceCones.js'; import { coverageZoomForHeight } from '../../tileMath.js'; import { passesImageryFilter } from '../../filter.js'; -import { NEAREST_RADIUS_M } from '../../policy.js'; import { metresBetween } from '../../view.js'; import { decodeCoverageTile } from './decode.js'; import { createMapillaryViewer } from './viewer.js'; @@ -20,7 +19,6 @@ import { MAPILLARY_NAME, MAPILLARY_PROVIDER_ID, MAPILLARY_SOURCE_METHODS, - NEAREST_LIMIT, PICK_PREFIX, RATE_LIMITED_MESSAGE, } from './policy.js'; @@ -206,10 +204,7 @@ export function createMapillaryProvider({ source, photoViewer = null }) { * cone). The core aborts `signal` once a newer request overtakes it. */ async nearestImage({ lat, lon }, { signal } = {}) { - const images = await source.nearestImages( - { lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT }, - { 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) diff --git a/src/layers/streetLevel/providers/mapillary/provider.test.mjs b/src/layers/streetLevel/providers/mapillary/provider.test.mjs index 31ec665ad..aa1f2463f 100644 --- a/src/layers/streetLevel/providers/mapillary/provider.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/provider.test.mjs @@ -200,11 +200,7 @@ test('nearestImage opens the closest image that passes the filter, not the first ); assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }, { signal }), 'near'); assert.equal(requests[0].options.signal, signal, 'the core can abort it'); - assert.equal(requests[0].query.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(requests[0].query.limit, 100, "the API's most"); - assert.deepEqual([requests[0].query.lat, requests[0].query.lon], [1, 2]); + 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( 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 156771de6..032240e7b 100644 --- a/src/layers/streetLevel/providers/mapillary/source.js +++ b/src/layers/streetLevel/providers/mapillary/source.js @@ -196,18 +196,15 @@ export function createMapillarySource({ return Array.isArray(payload?.data) ? payload.data : []; }, - /** Images within `radius` metres of a point, in no particular order. */ - async nearestImages( - { lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT }, - { signal } = {}, - ) { + /** 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(Math.min(50, Math.max(1, radius))), - limit: String(Math.min(100, Math.max(1, limit))), + radius: String(NEAREST_RADIUS_M), + limit: String(NEAREST_LIMIT), fields: NEAREST_IMAGE_FIELDS, }, { signal, timeoutMs: NEAREST_TIMEOUT_MS }, diff --git a/src/layers/streetLevel/providers/mapillary/source.test.mjs b/src/layers/streetLevel/providers/mapillary/source.test.mjs index d7fb8b7ee..9afc41291 100644 --- a/src/layers/streetLevel/providers/mapillary/source.test.mjs +++ b/src/layers/streetLevel/providers/mapillary/source.test.mjs @@ -126,24 +126,21 @@ 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, its radius and limit clamped', async () => { +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, - radius: 500, - limit: 500, - }); + 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 maximum'); - assert.equal(url.searchParams.get('limit'), '100'); + 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/); }); 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 4abf39412..a79cbd675 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -51,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 {() => Array<{id: string, parts: Array>}>} [coverageLines] The coverage lines drawn now, as [lon, lat] parts (diagnostics and the browser gate). + * @property {() => Array<{id: string, parts: Array>}>} [coverageLines] The coverage lines drawn now, as [lon, lat] parts (diagnostics). * @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 * diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index f56d2487f..acaba443e 100644 --- a/src/layers/streetLevel/viewerHost.js +++ b/src/layers/streetLevel/viewerHost.js @@ -175,6 +175,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); @@ -197,5 +202,5 @@ export function createViewerHost({ state, parts }) { active = null; } - return { attach, open, close, resize, setRenderMode, unmount }; + return { attach, open, close, resize, setRenderMode, stopPlayback, unmount }; } diff --git a/src/streetLevelLifecycle.test.mjs b/src/streetLevelLifecycle.test.mjs new file mode 100644 index 000000000..3a08a44c3 --- /dev/null +++ b/src/streetLevelLifecycle.test.mjs @@ -0,0 +1,194 @@ +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 { createView } from './view/index.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, billed = false, extraProviders = [] } = {}) { + document.body.className = ''; + document.body.replaceChildren(); + const provider = fakeStreetLevelProvider({ nearestImage }); + provider.billedPerImage = billed; + 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(); + assert.equal(layer.providerStatus('mapillary').billed, billed); + 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' }; + }, + }; + let result; + const run = async (name, args) => { + if (name !== 'show_street_level') return { ok: true }; + result = await showStreetLevel(args, context); + return result; + }; + const view = createView({ camera: { lat: 48, lon: 2 }, layers: ['street-level'], street_level: { provider: 'mapillary', lat: 48, lon: 2 } }); + return { layer, viewer, dataManager, context, run, view, opened, flights, result: () => result }; +} + +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/ui/shareRestoration.js b/src/ui/shareRestoration.js index 9fc4c6f45..4921d991e 100644 --- a/src/ui/shareRestoration.js +++ b/src/ui/shareRestoration.js @@ -263,7 +263,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/views/panels/StreetLevelPanel.vue b/src/ui/views/panels/StreetLevelPanel.vue index 1661574bb..fa3c593a5 100644 --- a/src/ui/views/panels/StreetLevelPanel.vue +++ b/src/ui/views/panels/StreetLevelPanel.vue @@ -142,7 +142,7 @@ const vThumb = { - +
@@ -159,7 +159,7 @@ const vThumb = { {{ t('PROVIDERS') }}
- +
diff --git a/src/view/index.js b/src/view/index.js index c22f2c3f6..11c3b8c7d 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -354,10 +354,11 @@ export function createView({ ); 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. + // 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 = latitude(streetLevel.lat) ?? seen.lat; - const lon = longitude(streetLevel.lon) ?? seen.lon; + const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given; street = Object.freeze({ ...(provider ? { provider } : {}), lat: round(clamp(lat, -90, 90), 5), diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 1389b058b..9fda7d149 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -208,6 +208,16 @@ test('street-level photos travel in the link, light their source and bring their 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 () => { diff --git a/src/voice/actionSchemas.test.mjs b/src/voice/actionSchemas.test.mjs index c2324067c..c2f084d06 100644 --- a/src/voice/actionSchemas.test.mjs +++ b/src/voice/actionSchemas.test.mjs @@ -58,32 +58,11 @@ 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. - // show_street_level (map, source, photo) is the only change on top of main. - SHOW_STREET_LEVEL_PAYLOAD_DIGEST, - ); - // Every shipped tool is byte-identical to the previous release: the new - // tool is the whole difference, so a later edit cannot hide in a re-pin. - const shipped = createHash('sha256') - .update( - JSON.stringify( - stable( - GEV_REALTIME_TOOLS.filter( - (tool) => - !['set_cyber_sonar', 'show_street_level'].includes(tool.name), - ), - ), - ), - ) - .digest('hex'); - assert.equal( - shipped, - '6a0d154e68beb787c4e20f12d29a5c86577dc343f3e71d8b5651998ea45d8c3c', + // show_street_level (map, source, photo) adds one tool. + '0c27f62062001e6a07241cd301c3d357f55e9b74f66cd2904d16590510337468', ); }); -const SHOW_STREET_LEVEL_PAYLOAD_DIGEST = - '0c27f62062001e6a07241cd301c3d357f55e9b74f66cd2904d16590510337468'; - test('descriptions customize wording without changing immutable shared arguments', () => { const descriptions = { fly_to_location: { diff --git a/src/voice/deixis.js b/src/voice/deixis.js index c78ccb54a..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 } : {}), }, }, diff --git a/src/voice/deixis.test.mjs b/src/voice/deixis.test.mjs index cd7f7374d..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'); diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index f8a906dc2..a4cb18a16 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -3813,24 +3813,26 @@ function streetLevelPanelHidden() { } /** - * "Show me on Street View / on Mapillary": fly to the place, make sure - * the imagery source's map is up, light that source in Street Level, turn the - * layer on and open the nearest image. One source at a time, as the panel - * does; the choice persists like a chip click. The key is checked before - * anything changes, and each stage reports its own failure so speech can say - * which part did not happen. With no place at all, the view centre is used. - * `autoOpen: false` (links, not the model) stops short of loading a photo - * from a source that bills every load: the panel is armed and the OPEN - * button is one click away. + * "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) { // Every await can hand the request's moment to someone else: a newer turn, // a navigation or gesture that takes the camera, or a mode that hides the // panel. Each stage after the place is settled 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 }); + return await requestStreetLevel(args, { ...context, owner, holdViewer }); } finally { + releaseViewer?.(); owner.stop(); } } @@ -3840,9 +3842,9 @@ async function requestStreetLevel( { dataManager, styleManager, - placeSearch, runGevAction, owner, + holdViewer = () => {}, runOptions = {}, current = () => true, reportProgress = () => {}, @@ -3868,11 +3870,14 @@ async function requestStreetLevel( 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 = - args.latitude != null && - args.longitude != null && - Number.isFinite(latitude) && - Number.isFinite(longitude); + 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 @@ -3952,29 +3957,11 @@ async function requestStreetLevel( atViewCentre = !query && !hasCoordinates && !entity; let point = null; let location = null; - if (args.fly === false || atViewCentre) { + // 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 if (entity) { - // Where it is now, as the flying path does: the coordinates a list - // saved go stale as soon as the item moves or unloads. - const located = locateLayerEntity(dataManager, entity); - if (!located) return fail('location', `${label} is no longer loaded`); - point = { lat: located.lat, lon: located.lon }; - } else if (query) { - reportProgress('search', query); - const outcome = await placeSearch.geocode(query, { - signal: runOptions.signal, - }); - const place = outcome?.place; - if (!current()) - return fail('location', 'Street-level request was cancelled'); - if (!Number.isFinite(place?.lat) || !Number.isFinite(place?.lng)) - return fail('location', `Could not find ${query}`); - point = { lat: place.lat, lon: place.lng }; - label = place.label || query; - } - // No place at all: the layer looks at the ground under the screen - // centre, as the OPEN button does. } else { location = await runGevAction( 'fly_to_location', @@ -3995,8 +3982,10 @@ async function requestStreetLevel( location?.error || `Could not arrive at ${location?.label || label}`, { cancelled: !current() || Boolean(location?.cancelled), location }, ); - // An item keeps its name: the flight to it reports only coordinates. - label = entity ? label : location.label || label; + // 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) @@ -4037,7 +4026,10 @@ async function requestStreetLevel( if (stop) return stop; } - // Light the source the way a chip click does, so the choice is kept. + // 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. + if (layerModule.getUIState?.()?.street?.open === true) + holdViewer(layerModule); const switches = Object.fromEntries( providerIds.map((id) => [id, id === provider]), ); diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index cf4cbff6f..7ccaabc8d 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -6660,11 +6660,11 @@ function createStreetLevelHarness({ return false; } } - // Like the layer: a replacing lookup that opens nothing closes the photo. + // 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) return; - order.push('close'); - Object.assign(street, { open: false, imageId: null, error: null, nearestOutcome: null }); + if (options.replace && street.open) layer.closeViewer(); }; if (imageryError) { closeReplaced(); @@ -6678,8 +6678,6 @@ function createStreetLevelHarness({ street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`; return false; } - // Found: it loads only if the request still wants it. - if (options.proceed && options.proceed() !== true) return false; street.open = true; street.imageId = nearest; street.title = '10th St, Sacramento'; @@ -6762,6 +6760,14 @@ test('show_street_level flies first, then switches the map, lights the source, t 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 () => { @@ -6799,6 +6805,11 @@ test('show_street_level reports each stage it could not pass', async () => { 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, }); @@ -6865,15 +6876,6 @@ test('show_street_level opens photos of a numbered item or a pointed spot throug assert.equal(result.ok, true, result.error); assert.equal(result.label, 'Camera 9'); assert.deepEqual(order, ['fly', 'params:google:voice', 'open:google:250:false']); - // Without a flight the entity's own position is used. - const still = withCamera(createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' })); - const kept = await still.runner('show_street_level', { - provider: 'google', - fly: false, - entity: { layerId: 'cctv', id: 'cam-9', lat: 38.58, lon: -121.5 }, - }); - assert.equal(kept.ok, true, kept.error); - assert.deepEqual(still.order, ['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 () => { @@ -7003,43 +7005,9 @@ test('show_street_level tells a failed enable, a delisted source, a key refused assert.deepEqual(unconfigured.order, [], 'nothing changed'); }); -test('show_street_level without a flight geocodes a name, locates an item, and reports what it cannot place', async () => { +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' }); - const points = []; - const open = hit.layer.openNearest; - hit.layer.openNearest = (point, options) => { - points.push(point); - return open(point, options); - }; - const runner = createGevActionRunner({ - viewer: createVoiceNavigationHarness().viewer, - styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) }, - dataManager: hit.dataManager, - placeSearch: { - geocode: async (query) => - query === 'Capitol Mall' - ? { place: { lat: 38.5792, lng: -121.4958, label: 'Capitol Mall, Sacramento' } } - : { place: null, answered: true }, - }, - }); - const found = await runner('show_street_level', { query: 'Capitol Mall', provider: 'mapillary', fly: false }); - assert.equal(found.ok, true, found.error); - assert.equal(found.label, 'Capitol Mall, Sacramento'); - assert.deepEqual(points.at(-1), { lat: 38.5792, lon: -121.4958 }); - assert.equal(found.flew, false); - - const miss = await runner('show_street_level', { query: 'Atlantis Boulevard', provider: 'mapillary', fly: false }); - assert.deepEqual([miss.ok, miss.stage], [false, 'location']); - assert.equal(miss.say, "Couldn't find Atlantis Boulevard."); - assert.equal(points.length, 1, 'a miss asks for no imagery'); - - const gone = await runner('show_street_level', { - provider: 'mapillary', fly: false, entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7' }, - }); - assert.deepEqual([gone.ok, gone.stage], [false, 'location']); - assert.equal(gone.say, "Couldn't find Camera 7."); - // A flight that reports no coordinates cannot be followed by a lookup. const noPoint = createGevActionRunner({ viewer: createVoiceNavigationHarness().viewer, @@ -7105,20 +7073,6 @@ test('show_street_level says "refused its key" when Google already refused it, a assert.match(dark.error, /could not be switched on/); }); -test('show_street_level without a flight needs the item where it is now, not where a list saw it', async () => { - globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; - const harness = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }); - // The referent carries the coordinates the list saved, but it is no longer loaded. - const stale = await harness.runner('show_street_level', { - provider: 'google', - fly: false, - entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7', lat: 38.58, lon: -121.5 }, - }); - assert.deepEqual([stale.ok, stale.stage], [false, 'location']); - assert.match(stale.error, /no longer loaded/); - assert.ok(!harness.order.some((entry) => entry.startsWith('open:')), 'nothing looked up at the stale point'); -}); - test('activeImageryFilter reports only the narrowing filters the lit source honours', async () => { const { activeImageryFilter } = await import('./gevActions.js'); const both = { pano: true, since: true }; @@ -7281,32 +7235,6 @@ test('a partial analyst card shows one partial chip', () => { assert.equal(new Set(labels).size, labels.length, JSON.stringify(labels)); }); -test('show_street_level superseded during its geocode changes nothing', 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}`); - return { ok: true }; - }, - }, - dataManager: h.dataManager, - placeSearch: { - geocode: async () => { - current = false; - return { place: { lat: 1, lng: 2, label: 'X' } }; - }, - }, - }); - const result = await runner('show_street_level', { query: 'X', provider: 'google', fly: false }, { isCurrent: () => current }); - assert.deepEqual(h.order.filter((step) => /^(map|params|open|arm)/.test(step)), [], 'the stale turn switched nothing'); - assert.deepEqual([result.ok, result.stage, result.cancelled], [false, 'location', true]); -}); - test('show_street_level superseded during its flight switches nothing', async () => { globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null }; const h = createStreetLevelHarness(); diff --git a/src/voice/pointAndAsk.test.mjs b/src/voice/pointAndAsk.test.mjs index 290fc2537..b1942c85f 100644 --- a/src/voice/pointAndAsk.test.mjs +++ b/src/voice/pointAndAsk.test.mjs @@ -343,17 +343,9 @@ 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. show_street_level (the - // tool and its routing rule, ~940) raises it to 56,500. 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 <= 56_500, `prompt size ${size}`); }); From e34b5103c64b27b63a6c046d476b270bba04c38e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 23:54:16 +0400 Subject: [PATCH 16/28] Street Level: shorter comments and docs - Comments in the action, the layer, embed, the Mapillary policy and the controls say why in fewer words; restatements of the code are gone. - CURRENT-STATE's "Show me X on Street View" section is a list by topic and no longer says the browser gate aims at coverageLines. - TOOLS.md's street-level view paragraph is shorter. --- docs/CURRENT-STATE.md | 202 +++++++++--------- docs/TOOLS.md | 20 +- src/app/embed.js | 50 ++--- src/layers/streetLevel/index.js | 54 ++--- .../streetLevel/providers/mapillary/policy.js | 13 +- src/ui/streetLevelControls.js | 10 +- src/voice/gevActions.js | 33 ++- 7 files changed, 167 insertions(+), 215 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 32494ab44..7863c09fa 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5073,111 +5073,103 @@ 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."** The `show_street_level` assistant action -(voice and typed; `src/voice/gevActions.js`) is one call: it checks the -source has its key (a keyless source explains itself and changes nothing, as -a chip does), flies to the place at street framing and waits for arrival (a -name, coordinates, a pointed spot, a numbered item, or nothing for the -ground under the screen centre, as OPEN uses: `aim: 'screen-centre'` with -`armed: false`, so sky refuses and a link's armed point is not used and is -kept until a photo opens), switches to the map the source needs (Google Street View: Google 3D), -lights that source through `setLayerParams` with origin `voice` (one source at -a time, kept in the share link like a chip click; a link runs the action with -origin `programmatic`, for the source switch and the layer, 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. Stages fail separately (`location`, `view`, `map`, -`provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view, -recording or scene playback hides the panel, checked after the flight and -before anything switches or loads; embed mode exempts its own clean-view -class because the photo is lifted above the globe; an empty `imagery` result names a -narrowing 360°/flat or SINCE filter the source honours, as `filter`; -`noImagery` marks an empty result, `imageryError` a source or viewer failure -and `aim: 'sky'` a sky centre, from the layer's `street.nearestOutcome`) and a -newer turn retires the lookup -(`openNearest`'s `signal` option), so nothing opens after the user moved on. -For a source without a coverage layer the lookup widens to 250 m -(`NEAREST_RADIUS_MAX_M`; Google honours it, Mapillary's API caps at 50 m); -clicks keep 50 m. Mapillary's search gives up after 12 s, body included -(`NEAREST_TIMEOUT_MS`), and asks for 100 photos, the API's most: it -returns the radius's photos in no particular order and cuts them at the limit -first, so the distance sort and the imagery filter run over the spot's photos. -On a phone the panel sits in the Cameras tab sheet: a photo that opens, and a -link that arms, open that sheet and scroll the panel into view -(`revealPanel`, which does nothing above 720px, scrolling included); every -photo the action opens reveals the panel again, also one the user had -collapsed. The layer exposes `requiredMapStack(id)` and -`providerStatus(id)` for callers that must decide before the panel lists a -source, and `coverageLines(id)` (the lines a provider draws now, current and -stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate, -which aims its line clicks at that geometry, waits for the asynchronous -ground primitives to be ready, and clicks in the same JavaScript turn as the -pick so a rebuilt frame cannot come between them. Views carry the same request as `street_level` (`src/view/index.js`: -source and point, defaulting to where the camera looks; a `google` source -brings `map: photoreal` and refuses another map; not with `follow`), links as -`sl=,,`; `show_in_gods_eye_view` accepts it, the globe panel -applies it through the action, and a plain link runs the action once restored -(one shot: the rewritten hash drops `sl`). A link pasted into the open tab -runs it after its restore settles (`gev:share-navigation-restore-settled`), -and only when the restore's camera landed (`share.camera === 'applied'`): a -link whose restore flight the visitor stopped with a drag or a search, or -whose restore a newer link superseded, never runs and never pulls the camera -back. In embed mode the rail holding the -panel 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 open under embed mode's -own clean view; cockpit, recording and scene playback hide the overlay. 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, and one that -found a photo it could not load keeps that error in view. The action's -lookup replaces an open photo in place (`openNearest` `replace: true`), so -the viewer and EXPAND stay up, and closes it only when it opens nothing; once -an image is found, `proceed()` asks whether the camera and the panel are -still the request's before a billed photo loads. Image-loading failures render inside -the embedded overlay, since the panel's error region is hidden with the rail. -A caller's abort is -honoured through the viewer's mount and load, not just the lookup, and only -the lookup that still owns the panel may change its spinner or error. Cancelling -a lookup clears its loading state even when an existing photo stays open; -internally retired lookups also release the caller's abort listener. A -passive link's photos stand down if anyone navigated (a gesture, a search, -the assistant) while the link waited behind its own marks. The action itself -checks again 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 the remaining -stages (`superseded: true`), and cockpit, clean view, recording or scene -playback entered meanwhile ends it at the `view` stage. During the lookup the -same checks run before a found photo loads (`proceed`), so nothing billed -loads for a request that was given up, and a lookup given up leaves the -panel as it was; a photo that opened after either is closed again. The -hidden-panel check also runs before the flight, so a recording's camera never -moves for it. A photo already open stays up while the new request looks (the -viewer is replaced, not closed, so EXPAND stays, also when the request -switches the source) and closes if the request opens nothing, so a place with -no imagery shows its own error, not the previous place's photo; a link that -only arms closes it at once. A plain -link never loads a photo from a source that bills every load -(`billedPerImage` on the provider; Google): the action runs with -`autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's -point and radius, consumed only by the next OPEN press (`aim: -'screen-centre'`), and only while the ground under the screen centre is within -1 km of the point (`ARMED_REACH_M`; a user who moved on opens what the view -shows) or cannot be picked; a sky centre refuses; any other lookup, open, close, disable, its source -switched off or its map leaving drops it), opens the panel and a toast -points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline -embed) for a tool result issued in the last 5 minutes, and posted views, open -the photo: `show_in_gods_eye_view` stamps a street-level result `issued_at`, -and a host that mounts the panel again later hands back the old result, which -only arms. The panel checks every result, not just the first: each `gev:view` -it 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 (the rail is hidden in a frame). A link that follows an entity ignores `sl`. -The look-at default applies below 20 km, at a pitch steeper than -5° and -within 10 km ahead; otherwise the camera's own point is used. A view's -altitude is above the ellipsoid, so `show_in_gods_eye_view` measures it from -the terrain under the camera (`services.terrain`) before taking the look-at -point; without a terrain answer it keeps the view's own estimate. +**"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, and a + lookup given up leaves the panel as it was; 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`) 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 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, `holdViewer()`, and +`coverageLines(id)` (the lines a provider draws now, as `[lon, lat]` parts) +for diagnostics. 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, also +for half a point). A `google` source brings `map: photoreal` and refuses +another map; `street_level` does not combine with `follow`. +`show_in_gods_eye_view` measures a view's altitude from the terrain under the +camera (`services.terrain`) before taking the look-at point, and keeps the +view's own estimate without a terrain answer. 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. +- **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 diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 738ccd541..99dfee5ec 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -104,18 +104,16 @@ 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 -whichever the panel has lit) and a point, which defaults to the spot the camera looks at. Street View +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 any other map is refused; a view cannot both follow an entity -and open photos. The `street-level` layer is turned on. Links carry it as -`sl=,,`; once the link has restored, the app runs its -`show_street_level` action, which flies to the point at street framing and -opens the nearest photo. A plain link with a `google` source stops before the -billed load: the panel is armed and OPEN GOOGLE STREET VIEW is one click away. -A view posted into the embedded app, or the panel page's own opening link, -opens the photo, since it is the client's own request; `?embed=1` on a -published link does not. Mapillary, KartaView and Panoramax links open the -photo at once; none of them bills per image. A link that follows an entity ignores `sl`. +`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, or the panel page's own opening +link, opens it, and `?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`, `annotations` and diff --git a/src/app/embed.js b/src/app/embed.js index d1ad4653c..ed196b0b6 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -183,8 +183,7 @@ function flyTo(viewer, camera) { /** * The `show_street_level` arguments for a view's street-level photos. A plain - * link (`autoOpen: false`) never loads a photo from a source that bills every - * load: the app arms the panel and the OPEN button is one click away. A view + * 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 } = {}) { @@ -260,20 +259,18 @@ export async function applyView( if (view.annotations.length) await act('annotate_map', { annotations: [...view.annotations] }); if (view.street_level) { - // The photos frame the camera: the action flies to the point at street - // level, lights the source and opens the nearest photo. When it fails - // before flying, the view's own camera still applies. + // The photos frame the camera; when the action fails before flying, the + // view's own camera still applies. const result = await act( 'show_street_level', streetLevelArgs(view.street_level, { autoOpen }), ); if (result?.ok !== true) { - // Overtaken (a click on the map, a newer request): the panel and the - // camera are someone else's now. + // 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 found but could not load keeps its error in view. + // 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) @@ -331,18 +328,13 @@ export function installViews({ const hashParams = new URLSearchParams( String(location?.hash || '').replace(/^#/, ''), ); - // What the link itself asks for runs first, one thing at a time; posted - // views queue behind it. One failing step does not take the next with it. - // The camera's owner at restore: anyone who navigates while the link's - // steps wait (a gesture, a search, the assistant) takes it, and a passive - // link must not fly over them. - // A start-up restore that a newer link superseded hands the camera to that - // link: its own steps never run. - // A restore whose camera never landed (the recipient dragged, searched or - // opened another place during its flight) leaves the camera with them. + // 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'; - // The generation the restore's own flight owned: a navigation after it - // landed, while the layers still loaded, has moved on from it. const restoreGeneration = (outcome) => Number.isInteger(outcome?.navigationToken) ? outcome.navigationToken @@ -369,11 +361,9 @@ export function installViews({ const linked = annotationsFromParams(hashParams); if (linked.length) queue = linkStep(() => run('annotate_map', { annotations: linked })); - // A link to street-level photos opens them once the link has restored. A - // link anyone can publish stops short of a billed load and leaves it to a - // click; only the panel page's own link for a result issued just now is - // the client's request (a host remounting the panel hands back old ones). - // A link that follows an entity keeps the camera on it. + // 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 ( @@ -388,9 +378,8 @@ export function installViews({ streetLevelArgs(streetLevel, { autoOpen }), { origin: 'programmatic' }, ); - // The restore window keeps the panel collapsed; OPEN must be in view. - // Embed mode hides the rail that holds it, so there the link only arms - // (a posted view is the way to open a photo in a frame). + // 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)) { shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { explicit: true, @@ -410,9 +399,8 @@ export function installViews({ autoOpen: isFreshPanelResult(), }), ); - // A link pasted into the open tab restores camera and layers on its own; - // its photos follow once that restore settles, under the same rule: a plain - // link never loads a billed photo by itself. + // 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; diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 5e13e647d..c2b36509b 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -697,22 +697,19 @@ 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). `radiusM` widens the search for - * providers that can (a geocoded landmark is rarely within 50 m of a - * street); clicks keep the default. A caller's `signal` retires the - * lookup: nothing opens after it fires. Providers without - * `nearestImage` are skipped. `armed: false` (a request of its own, such - * as the assistant's) does not use a link's armed point, and keeps it - * while it looks; a photo it opens ends it, as any opened photo does. Why - * nothing opened is left in `street.nearestOutcome`: 'empty' (no imagery - * in reach), 'sky' (the screen centre is not ground), or null (an error). - * `replace: true` (a request that takes over the panel) keeps an open - * photo up while it looks, so the viewer and EXPAND stay, and closes it - * when the lookup opens nothing: the panel shows this request's outcome. - * `proceed()` is asked before a found image loads and before an empty - * or failed lookup reports; false retires the request and leaves the - * panel as it was (a photo that bills every load must not load for a - * request its caller has given up). + * under the screen centre (sky: nothing). Providers without + * `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, @@ -737,9 +734,8 @@ export function createStreetLevelLayer({ if (replace && state.street.open) closeViewer(); }; if (!point && aim === 'screen-centre' && armed) { - // The button promises the centre of the view: the link's spot only - // while the view still shows it, or when the view cannot be picked. - // Sky refuses below, as any OPEN press does. + // 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 ( @@ -777,11 +773,9 @@ export function createStreetLevelLayer({ callerSignal?.addEventListener('abort', onCallerAbort, { once: true }); const retired = () => { callerSignal?.removeEventListener('abort', onCallerAbort); - // Only the lookup that still owns the panel may touch it: a newer one - // has its own spinner and error. + // A newer lookup owns the panel, its spinner and its error. if (nearestLookup !== lookup) return false; nearestLookup = null; - // The caller moved on: the panel shows nothing of this lookup. if (state.street.loading) { state.street.loading = false; notify(); @@ -822,8 +816,7 @@ export function createStreetLevelLayer({ if (proceed && proceed() !== true) return retired(); nearestLookup = null; callerSignal?.removeEventListener('abort', onCallerAbort); - // The photo asked for stays on screen: a sequence playing in the - // photo it replaces stops. + // 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, { @@ -834,8 +827,7 @@ export function createStreetLevelLayer({ 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 while it looked: the open photo stays, and - // the panel reports nothing of this lookup. + // Given up by its caller: the panel stays as it was. if (proceed && proceed() !== true) return retired(); nearestLookup = null; closeReplaced(); @@ -867,8 +859,7 @@ export function createStreetLevelLayer({ closeViewer, /** * Keep the open photo up while a request that replaces it switches the - * source (the viewer and EXPAND are replaced, not closed). The returned - * release closes it if its source is still off by then. + * source, so EXPAND stays. Release closes it if its source is still off. */ holdViewer() { viewerHolds += 1; @@ -883,10 +874,9 @@ export function createStreetLevelLayer({ }; }, /** - * Prepare a nearest lookup for the next OPEN press without running it: a - * link to a source that bills every load stops here. The press opens at - * this point with this radius; any other lookup, open or close, the - * source switched off, or its map leaving clears it. + * 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 = { diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index b582b8cdd..78ca14a49 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -75,17 +75,14 @@ export const SEQUENCE_IMAGE_FIELDS = 'id,captured_at,compass_angle,geometry,is_pano'; /** - * Images asked for in a nearest-image search (show_street_level and links - * open the photo nearest a point; the graph API caps the radius at 50 m). - * The API returns the radius's images in no particular order and cuts them - * at the limit first, so this asks for its most (100): the distance sort and - * the imagery filter then choose among the spot's photos, not a few of them. + * Images asked for in a nearest-image search: the API cuts at the limit + * before any order, so this is its most, and the distance sort and the + * imagery filter choose among all of the spot's photos. */ export const NEAREST_LIMIT = 100; /** - * A nearest-image search that has not answered (headers and body) in this - * long is given up: a link or a posted view waits on it, and so does every - * view queued behind that one. + * 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. */ diff --git a/src/ui/streetLevelControls.js b/src/ui/streetLevelControls.js index b84c8b9e1..fbb73eb15 100644 --- a/src/ui/streetLevelControls.js +++ b/src/ui/streetLevelControls.js @@ -316,11 +316,9 @@ export class StreetLevelControls { } /** - * Embed mode hides the right rail, panel and viewer with it: an open photo - * moves to as an overlay on the globe (`sl-viewer-wrap-embed`, see - * embed.css) and returns to its place in the panel when it closes. The - * panel view keeps patching the wrap where it is: its siblings are static, - * so Vue never inserts next to it. + * 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; @@ -404,8 +402,6 @@ export class StreetLevelControls { if (!isEmbedPage()) this.actions.revealPanel?.(); } this._wasOpen = open; - // Embed mode hides the rail the panel sits in: an open photo is shown over - // the globe instead, and goes back into the panel when it closes. this._syncEmbedOverlay(open); } diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index a4cb18a16..09390f309 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -3819,9 +3819,8 @@ function streetLevelPanelHidden() { * (links) stops short of a billed load: the panel is armed for OPEN. */ export async function showStreetLevel(args, context) { - // Every await can hand the request's moment to someone else: a newer turn, - // a navigation or gesture that takes the camera, or a mode that hides the - // panel. Each stage after the place is settled checks again. + // 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. @@ -3912,8 +3911,7 @@ async function requestStreetLevel( const status = layerModule.providerStatus?.(provider) || null; providerName = status?.name || provider; - // The key first, on any map: a chip without a key explains it and switches - // nothing, and so does this. + // 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, @@ -3998,9 +3996,8 @@ async function requestStreetLevel( point = { lat: location.latitude, lon: location.longitude }; } - // Cockpit, clean view, recording and scene playback hide the panel the - // photo opens in, and may have started during the flight. - // The camera is the request's from where its own flight landed. + // A mode that hides the panel may have started during the flight. The + // camera is the request's from where its flight landed. if (location) owner.claim(); const arrived = interrupted('view'); if (arrived) return arrived; @@ -4076,13 +4073,11 @@ async function requestStreetLevel( location, }); - // A billed source never loads from a link without a click: everything is - // ready and OPEN is one press away at the view centre. + // 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?.(); - // The next OPEN press loads exactly what the link asked for. layerModule.armNearest?.(point, { providerIds: [provider], ...(snapshot.groundClick === true @@ -4101,13 +4096,10 @@ async function requestStreetLevel( }; } - // A source with no coverage to click (Street View) is searched wider: a - // geocoded landmark is rarely within 50 m of a street. A newer turn - // retires the lookup: nothing opens after the user moved on. A photo of - // another place stays up while it looks (the viewer, EXPAND included, is - // replaced, not closed) and closes if this request opens nothing: the - // panel shows this request's outcome. Once an image is found it loads - // only if the camera and the panel are still the request's. + // 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], @@ -4123,9 +4115,8 @@ async function requestStreetLevel( : {}), }); const street = layerModule.getUIState?.()?.street || {}; - // A cancelled request opens nothing (the lookup and the viewer both honour - // the turn's signal). A photo that opened after the camera moved on, or - // into a panel hidden meanwhile, is closed again: nobody asked to see it. + // 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?.(); From c76832ca694705c69d62e99de4f3ba9076ffc18c Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Tue, 10 Nov 2026 23:58:03 +0400 Subject: [PATCH 17/28] Street Level: drop the unused coverageLines diagnostics Nothing reads the drawn coverage lines: the browser gate aims elsewhere, so lineCoverage's drawnLines, the providers' coverageLines and the layer's coverageLines go, with their tests and the doc mention. --- docs/CURRENT-STATE.md | 5 +- src/layers/streetLevel/index.js | 4 -- src/layers/streetLevel/index.test.mjs | 37 ----------- src/layers/streetLevel/lineCoverage.js | 19 ------ src/layers/streetLevel/lineCoverage.test.mjs | 64 ------------------- .../streetLevel/providers/kartaview/index.js | 1 - .../streetLevel/providers/mapillary/index.js | 1 - .../streetLevel/providers/panoramax/index.js | 3 - .../providers/panoramax/provider.test.mjs | 6 -- src/layers/streetLevel/registry.js | 1 - 10 files changed, 2 insertions(+), 139 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 7863c09fa..3253efc79 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5121,9 +5121,8 @@ app. (`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, `holdViewer()`, and -`coverageLines(id)` (the lines a provider draws now, as `[lon, lat]` parts) -for diagnostics. A caller's abort is honoured through the viewer's mount and +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. diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index c2b36509b..6b0aa2d27 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -515,10 +515,6 @@ export function createStreetLevelLayer({ const def = defs.find((entry) => entry.id === providerId); return def?.requiresMapStack || null; }, - /** The coverage lines a provider draws now (see `coverageLines`). */ - coverageLines(providerId) { - return state.providers.get(providerId)?.instance.coverageLines?.() ?? []; - }, /** * 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 diff --git a/src/layers/streetLevel/index.test.mjs b/src/layers/streetLevel/index.test.mjs index 92cb2338e..1f019e3d8 100644 --- a/src/layers/streetLevel/index.test.mjs +++ b/src/layers/streetLevel/index.test.mjs @@ -1604,43 +1604,6 @@ test('the action over the real layer: a billed link arms without a lookup, and o assert.equal(fake.lookups.length, before + 2); }); -test('coverageLines hands back what a provider draws, and nothing for providers without lines', async (t) => { - const drawing = fakeProvider(); - const create = drawing.create; - drawing.create = (context) => ({ - ...create(context), - coverageLines: () => [ - { - id: 's1', - parts: [ - [ - [1, 2], - [1, 2.001], - ], - ], - }, - ], - }); - const { layer } = await enabledLayer(t, [drawing]); - assert.deepEqual(layer.coverageLines(drawing.id), [ - { - id: 's1', - parts: [ - [ - [1, 2], - [1, 2.001], - ], - ], - }, - ]); - assert.deepEqual(layer.coverageLines('nope'), []); -}); - -test('coverageLines is empty for a provider that draws no coverage', async (t) => { - const { layer } = await enabledLayer(t, [fakeProvider()]); - assert.deepEqual(layer.coverageLines(fakeProvider().id), []); -}); - 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' }); diff --git a/src/layers/streetLevel/lineCoverage.js b/src/layers/streetLevel/lineCoverage.js index 80a86836c..bab1af46d 100644 --- a/src/layers/streetLevel/lineCoverage.js +++ b/src/layers/streetLevel/lineCoverage.js @@ -531,24 +531,6 @@ export function createLineCoverage({ }; } - /** - * The sequence lines drawn now, as `[lon, lat]` parts per sequence, for - * diagnostics: what each tile drew (`attachPrimitive` keeps it in - * `entry.sequences`). Read-only copies. - */ - function drawnLines() { - const lines = []; - for (const entry of state.coverage.tiles.values()) - for (const sequence of entry.sequences.values()) - lines.push({ - id: sequence.id, - parts: (sequence.parts || []).map((part) => - part.map(([lon, lat]) => [lon, lat]), - ), - }); - return lines; - } - return { attach, detach, @@ -561,6 +543,5 @@ export function createLineCoverage({ highlight, sequenceCount, stats, - drawnLines, }; } diff --git a/src/layers/streetLevel/lineCoverage.test.mjs b/src/layers/streetLevel/lineCoverage.test.mjs index dd9282119..60bae7eb3 100644 --- a/src/layers/streetLevel/lineCoverage.test.mjs +++ b/src/layers/streetLevel/lineCoverage.test.mjs @@ -654,67 +654,3 @@ test('the selected sequence is drawn and highlighted even past the per-tile cap' assert.ok(state.coverage.highlight, 'the selected line is highlighted'); coverage.clear(); }); - -test('drawnLines reports the lines on the globe as [lon, lat] copies, filtered like the drawing', async () => { - const { source, state, coverage, centre } = setup(); - assert.deepEqual(coverage.drawnLines(), [], 'nothing before a tile'); - const sequences = [ - { - id: 'flat-1', - parts: [ - [ - [centre.lon - 0.001, centre.lat], - [centre.lon + 0.001, centre.lat], - ], - ], - capturedAt: 2_000_000_000_000, - }, - { - id: 'pano-1', - parts: [ - [ - [centre.lon, centre.lat - 0.001], - [centre.lon, centre.lat], - [centre.lon, centre.lat + 0.001], - ], - ], - capturedAt: 1_000_000_000_000, - isPano: true, - }, - ]; - state.filter = { pano: 'pano', sinceDays: 0 }; - coverage.refresh(); - source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences })); - await settle(); - const lines = coverage.drawnLines(); - assert.deepEqual( - lines.map((line) => line.id), - ['pano-1'], - 'only the drawn (filtered) lines', - ); - assert.equal(lines[0].parts.length, 1); - assert.equal(lines[0].parts[0].length, 3); - // Tile coordinates are quantised to the tile grid (a few metres at z14). - const [midLon, midLat] = lines[0].parts[0][1]; - assert.ok( - Math.abs(midLon - centre.lon) < 1e-4 && - Math.abs(midLat - centre.lat) < 1e-4, - `coordinates as [lon, lat]: ${midLon}, ${midLat} vs ${centre.lon}, ${centre.lat}`, - ); - assert.ok( - lines[0].parts[0][0][1] < lines[0].parts[0][2][1], - 'vertex order kept: south to north', - ); - // Copies: a caller cannot bend the drawn line. - lines[0].parts[0][1][0] = 0; - assert.notEqual(coverage.drawnLines()[0].parts[0][1][0], 0); - state.filter = { pano: 'all', sinceDays: 0 }; - coverage.rebuild(); - assert.deepEqual( - coverage - .drawnLines() - .map((line) => line.id) - .sort(), - ['flat-1', 'pano-1'], - ); -}); diff --git a/src/layers/streetLevel/providers/kartaview/index.js b/src/layers/streetLevel/providers/kartaview/index.js index 523f6920c..6e75f967a 100644 --- a/src/layers/streetLevel/providers/kartaview/index.js +++ b/src/layers/streetLevel/providers/kartaview/index.js @@ -535,7 +535,6 @@ export function createKartaViewProvider({ loading: state.sequence.loading, error: state.sequence.error, }), - coverageLines: () => coverage.drawnLines(), /** * The nearest photo within the search radius that can be shown, or diff --git a/src/layers/streetLevel/providers/mapillary/index.js b/src/layers/streetLevel/providers/mapillary/index.js index c40bed62c..e3e92d6dd 100644 --- a/src/layers/streetLevel/providers/mapillary/index.js +++ b/src/layers/streetLevel/providers/mapillary/index.js @@ -196,7 +196,6 @@ export function createMapillaryProvider({ source, photoViewer = null }) { loading: state.sequence.loading, error: state.sequence.error, }), - coverageLines: () => parts.coverage.drawnLines(), /** * The nearest image that passes the imagery filter, or null: what diff --git a/src/layers/streetLevel/providers/panoramax/index.js b/src/layers/streetLevel/providers/panoramax/index.js index 805d304b9..e855c9c72 100644 --- a/src/layers/streetLevel/providers/panoramax/index.js +++ b/src/layers/streetLevel/providers/panoramax/index.js @@ -526,9 +526,6 @@ export function createPanoramaxProvider({ cones.clearSelection(); drawn = null; }, - // The lines drawn now (current and stale tiles), as [lon, lat] - // parts, for diagnostics and the browser gate's line clicks. - coverageLines: () => coverage.drawnLines(), sequenceStats: () => ({ selectedId: state.sequence.selectedId, images: state.sequence.images.length, diff --git a/src/layers/streetLevel/providers/panoramax/provider.test.mjs b/src/layers/streetLevel/providers/panoramax/provider.test.mjs index 4ad4e9655..628805ce1 100644 --- a/src/layers/streetLevel/providers/panoramax/provider.test.mjs +++ b/src/layers/streetLevel/providers/panoramax/provider.test.mjs @@ -1182,9 +1182,3 @@ test('a busy or timed-out tile keeps its own message; only a body that is not a assert.equal(env.instance.sequenceStats().error, failure.message); } }); - -test('coverageLines exposes the drawn lines, none before any tile', () => { - const { instance } = setup(); - assert.equal(typeof instance.coverageLines, 'function'); - assert.deepEqual(instance.coverageLines(), []); -}); diff --git a/src/layers/streetLevel/registry.js b/src/layers/streetLevel/registry.js index a79cbd675..ff4e84c3d 100644 --- a/src/layers/streetLevel/registry.js +++ b/src/layers/streetLevel/registry.js @@ -51,7 +51,6 @@ 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 {() => Array<{id: string, parts: Array>}>} [coverageLines] The coverage lines drawn now, as [lon, lat] parts (diagnostics). * @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 * From 5b7e917f942474398a5974f9a5f6cf68b94e2a4c Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 11 Nov 2026 08:55:55 +0000 Subject: [PATCH 18/28] Street Level: Hebrew strings for the show_street_level action --- src/i18n/locales/he.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index b1c523a7a..b8de90c97 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,16 @@ "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} כדי לטעון אותו", + "Street-level photos are hidden in the current view. Leave it first.": "תמונות ברמת הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.", "Ship bridge view": "תצוגת גשר האונייה", "Satellite orbital view": "התצוגה המסלולית של הלוויין", "Exit bridge view": "יציאה מתצוגת הגשר", From e0fedc13dd6d4b5621606491098493b47fa7cc96 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 11 Nov 2026 09:21:06 +0000 Subject: [PATCH 19/28] Street Level: a link whose source has no working key says why --- changelog.d/1113-show-street-level.md | 6 +-- docs/CURRENT-STATE.md | 3 +- src/app/embed.js | 14 ++++++ src/app/embed.test.mjs | 61 ++++++++++++++++++++++++++- src/i18n/locales/ar.json | 1 + src/i18n/locales/de.json | 1 + src/i18n/locales/es.json | 1 + src/i18n/locales/fr.json | 1 + src/i18n/locales/he.json | 1 + src/i18n/locales/it.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/pt.json | 1 + src/i18n/locales/ru.json | 1 + src/i18n/locales/uk.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/i18n/locales/zh-Hant.json | 1 + 16 files changed, 90 insertions(+), 6 deletions(-) diff --git a/changelog.d/1113-show-street-level.md b/changelog.d/1113-show-street-level.md index 89f47726e..523fe5773 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -7,9 +7,9 @@ `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 request stops when the user navigates or hides the panel before - its photo loads; 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 + minutes; a link whose source has no working key says so. A request stops + when the user navigates or hides the panel before its photo loads; 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. 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 1485104e4..0e92438e5 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5329,7 +5329,8 @@ and a plain link runs it once restored (one shot: the rewritten hash drops 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. + 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 diff --git a/src/app/embed.js b/src/app/embed.js index ed196b0b6..04da9cbc4 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -207,6 +207,14 @@ function armedToast() { }); } +/** 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 @@ -378,6 +386,12 @@ export function installViews({ 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)) { diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index d81831727..4fe754a92 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -518,7 +518,7 @@ test('a link to street-level photos opens them after it restores', async () => { [[], []], 'an opened photo needs no toast or panel nudge', ); - // A failure (no key) is not an armed state either. + // A failure (no key) is not an armed state either: it says why instead. const failed = recorder({ show_street_level: () => ({ ok: false, @@ -542,7 +542,16 @@ test('a link to street-level photos opens them after it restores', async () => { windowRef: new EventTarget(), }); await new Promise((resolve) => setTimeout(resolve, 0)); - assert.deepEqual([toasts, panels], [[], []]); + 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 () => { @@ -592,6 +601,54 @@ test('a failing link step does not take the next one with it', async () => { 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 = []; diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 625089ce2..8b30df99a 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3744,6 +3744,7 @@ "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": "المحطات المأهولة والمركبات التي تزورها", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 6fb789666..f5eb48d11 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3420,6 +3420,7 @@ "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", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 5be183101..c5018af73 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3501,6 +3501,7 @@ "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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index f2b0e1f3e..e0a22f57f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3501,6 +3501,7 @@ "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", diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index b8de90c97..1aaa9377a 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3302,6 +3302,7 @@ "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": "התצוגה המסלולית של הלוויין", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 850b4d032..d3f081052 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3501,6 +3501,7 @@ "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", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 7e7ec1495..97ad588ac 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3339,6 +3339,7 @@ "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": "有人宇宙ステーションと、そこを訪れる宇宙機", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 50de8251b..9895adb6d 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3501,6 +3501,7 @@ "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", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 78865e65b..46e8ad63a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3582,6 +3582,7 @@ "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": "Пилотируемые станции и прилетающие к ним корабли", diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index 23454af62..a0ff6bce7 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3582,6 +3582,7 @@ "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": "Пілотовані станції та кораблі, що до них прилітають", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3e17fa256..3bd7c0bbd 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3339,6 +3339,7 @@ "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": "载人空间站及来访飞行器", diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 4d45b08fd..3fcd98932 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3339,6 +3339,7 @@ "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": "載人太空站及來訪飛行器", From e91e5a9da9f6528ef9972e5c37c2d5ddc1ad3166 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 13:37:26 +0400 Subject: [PATCH 20/28] i18n(sv): apply the second translation review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The cloud brightness key is a filter, not a brightness scale. - Counted lines without a plural form read "label: count", so 1 agrees. - Fire confidence is «tillförlitlighet», not «säkerhet». - Agreement with «flöde» and «luftfartyg»; course on the tracking line is «FÄRDRIKTNING», which fits both aircraft and ships. - Wildlife is «Artobservationer», since the layer also shows plants and fungi. - Bridge readouts are nouns (BLICK, ÖGONHÖJD), cockpit key hints share one verb form, tool names in pointer messages are quoted, and «jfr» replaces «jmf.»; the guide's example follows. - Smaller wording and consistency fixes across the catalog. --- docs/translations/sv.md | 2 +- src/i18n/locales/sv.json | 126 +++++++++++++++++++-------------------- 2 files changed, 64 insertions(+), 64 deletions(-) diff --git a/docs/translations/sv.md b/docs/translations/sv.md index 12d3a8b80..39d7d5aeb 100644 --- a/docs/translations/sv.md +++ b/docs/translations/sv.md @@ -10,7 +10,7 @@ Translation source: [God's Eye View Sverige](https://github.com/Kajjan7610/Gods- - 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., JMF., KAL. +- 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». diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 081afdf7d..014c6e5e1 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -50,7 +50,7 @@ "LAW ENFORCE": "POLIS", "MEDICAL": "SJUKTRANSPORT", "HIGH-SPEED": "SNABBGÅENDE", - "PASSENGER": "PASSAGERARE", + "PASSENGER": "PASSAGERARFARTYG", "CARGO": "LASTFARTYG", "TANKER": "TANKFARTYG", "OTHER": "ÖVRIGT", @@ -61,7 +61,7 @@ "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 datan är äkta.", + "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.", @@ -123,7 +123,7 @@ "Military flights": "Militärflyg", "AIS vessels": "AIS-fartyg", "Mapped installations": "Kartlagda anläggningar", - "syncing {count} city feeds...": "synkar {count} stadsflöden …", + "syncing {count} city feeds...": "synkar stadsflöden: {count} …", "scanning nearby systems...": "söker efter system i närheten …", "Station {id}": "Station {id}", "Station": "Station", @@ -146,7 +146,7 @@ "SRC {value}": "KÄLLA {value}", "{value} CONTEXT": "KONTEXT {value}", "Focus fire detection {title}, {details}": "Fokusera på branddetektering {title}, {details}", - "{value} conf": "{value} säkerhet", + "{value} conf": "{value} tillförlitlighet", "{value} ago": "{value} sedan", "sensor n/a": "sensor saknas", "FIRE · {frp} MW": "BRAND · {frp} MW", @@ -177,12 +177,12 @@ "ORBIT REPLAY · {mission}": "UPPSPELNING AV OMLOPPSBANA · {mission}", "PAUSED · {title}": "PAUSAD · {title}", "UNSPECIFIED": "EJ ANGIVET", - "+{count} additional payload records": "+{count} ytterligare nyttolastposter", + "+{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ÄND", + "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", @@ -212,7 +212,7 @@ "FINAL POSITION": "SLUTPOSITION", "REPLAY SPEED": "UPPSPELNINGSHAST.", "Replay speed multiplier": "Faktor för uppspelningshastighet", - "REPLAY ASCENT": "SPELA UPP STARTEN", + "REPLAY ASCENT": "SPELA UPP UPPSTIGNINGEN", "Previous mission": "Föregående uppdrag", "PREV": "FÖRRA", "Next mission": "Nästa uppdrag", @@ -337,7 +337,7 @@ "Copy failed": "Kunde inte kopiera", "CCTV calibration saved": "CCTV-kalibreringen sparades", "CCTV calibration reset": "CCTV-kalibreringen återställdes", - "ADJUST ON": "JUSTERA PÅ", + "ADJUST ON": "JUSTERING PÅ", "ADJUST": "JUSTERA", "UNKNOWN": "OKÄND", "ERROR": "FEL", @@ -440,7 +440,7 @@ "Thermal": "Värmekamera", "Anime": "Anime", "Noir": "Noir", - "Current cockpit vision style: {style}. Activate for next style.": "Aktuell visningsstil i cockpit: {style}. Aktivera för nästa stil.", + "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", @@ -474,7 +474,7 @@ "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} · KURS Ö. GRUND {heading}°", + "{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": { @@ -580,7 +580,7 @@ "PLAY": "SPELA", "selected": "vald", "nearest": "närmaste", - "{action} {target} radio station": "{action} {target} radiostation", + "{action} {target} radio station": "{action}: {target} radiostation", "SYNCING": "SYNKAR", "READY": "REDO", "station": "stationen", @@ -870,13 +870,13 @@ "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 Enter för att avsluta, Backsteg ångrar, Esc avbryter.{full}", + "{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.": "Pekaren används av {owner} – stäng det verktyget först.", + "{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", @@ -917,7 +917,7 @@ "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 - Trishuli bridge": "Bidur – Trishulibron", "Bidur bridge": "Bron vid Bidur", "Devighat / Taadi Khola Bridge": "Devighat / Taadi Khola-bron", "Devighat bridge": "Bron vid Devighat", @@ -939,7 +939,7 @@ "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äddad media från {provider} för {source}", + "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", @@ -1002,7 +1002,7 @@ "NEPAL SCENE CLOCK": "SCENKLOCKA NEPAL", "… PLAYING SHOT": "… SPELAR TAGNING", "▶ PLAY SHOT": "▶ SPELA TAGNING", - "Ⅱ PAUSE": "Ⅱ PAUS", + "Ⅱ PAUSE": "Ⅱ PAUSA", "↺ REPLAY CINEMATIC": "↺ SPELA FILMLÄGET IGEN", "↺ REPLAY": "↺ SPELA IGEN", "… PLAYING SCENE": "… SPELAR SCEN", @@ -1484,7 +1484,7 @@ "Unknown": "Okänd", "Selected Space Mission": "Valt rymduppdrag", "Malformed launch snapshot": "Felformaterad ögonblicksbild av uppskjutningar", - "Heard by your receiver": "Hörd av den egna mottagaren", + "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", @@ -1610,7 +1610,7 @@ "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 är en tillåten referent", + "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", @@ -1782,7 +1782,7 @@ "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 hela ljusstyrkeskalan för moln; filterstyrka 100 %", + "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", @@ -1797,7 +1797,7 @@ "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ågig infraröd från GOES-19/18, band 14; globalt: långvågig 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.", + "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", @@ -1807,7 +1807,7 @@ "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": "Felaktigt vädermanifest", + "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", @@ -1883,8 +1883,8 @@ "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": "Felaktigt vindmanifest", - "Malformed wind grid": "Felaktigt vindrutnät", + "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", @@ -2176,9 +2176,9 @@ "suspended": "pausat", "closed": "stängt", "feed status|live": "live", - "feed status|stale": "inaktuell", + "feed status|stale": "inaktuellt", "feed status|unreachable": "går inte att nå", - "feed status|invalid": "ogiltig", + "feed status|invalid": "ogiltigt", "waiting for IQ": "väntar på IQ", "{count} messages": { "one": "{count} meddelande", @@ -2228,7 +2228,7 @@ "CH {index} / {total}": "KAN. {index} / {total}", "Station unavailable after directory refresh": "Stationen är otillgänglig efter uppdatering av katalogen", "{category} ({count})": "{category} ({count})", - "PAUSE": "PAUS", + "PAUSE": "PAUSA", "RESUME": "FORTSÄTT", "Clear reading": "Rensa avläsningen", "Earlier observation": "Tidigare observation", @@ -2241,10 +2241,10 @@ "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": "{owner} använder pekaren – stäng den först", + "{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": "JMF. KARTA", + "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", @@ -2299,13 +2299,13 @@ "Show {day}": "Visa {day}", "pinned {slot}": "fäst som {slot}", "shown": "visas", - "preview": "förhandsvisning", + "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": "JMF.", + "VS": "JFR", "Not set": "Inte vald", "Basemap": "Bakgrundskarta", "Source off": "Källa av", @@ -2335,7 +2335,7 @@ "{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?": "Ta bort scenen ”{scene}” och alla tagningar?", + "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", @@ -2367,7 +2367,7 @@ "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ÄNGLIG", + "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", @@ -2395,8 +2395,8 @@ "{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": "Felaktiga luftfartygsrader från {source}", - "Malformed {source} response": "Felaktigt svar från {source}", + "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", @@ -2554,7 +2554,7 @@ "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 – slutför det pågående svaret", + "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.", @@ -2702,7 +2702,7 @@ "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": "{layer} är aktiverat med flödet {source}, men inget luftfartyg i luften har laddats i vyn över {place} än", + "{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", @@ -2775,7 +2775,7 @@ "Adjust HUD": "Justera HUD", "Adjust cyber sonar": "Justera cybersonar", "Adjust detection overlay": "Justera detekteringsöverlägg", - "Change map stack": "Ändra kartstack", + "Change map stack": "Byt kartuppsättning", "Adjust post-processing": "Justera efterbehandling", "Control scene": "Styr scen", "Control CCTV": "Styr CCTV", @@ -2832,7 +2832,7 @@ "Cockpit briefing pages": "Genomgångssidor i cockpit", "SIG": "SIG", "LOCAL": "LOKALT", - "ESC EXIT": "ESC AVSLUTA", + "ESC EXIT": "ESC AVSLUTAR", "Active weather products": "Aktiva väderprodukter", "RECENT IMAGERY": "SENASTE BILDER", "Imagery days in the box": "Bilddagar i området", @@ -2872,7 +2872,7 @@ "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 Enter för att avsluta, Esc för att avbryta", + "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", @@ -2890,7 +2890,7 @@ "Imported files": "Importerade filer", "ACQUIRING FRAME": "HÄMTAR BILDRUTA", "STREET LEVEL": "GATUNIVÅ", - "Turn Street Level on or off": "Slå på eller av 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", @@ -2909,11 +2909,11 @@ "ALL": "ALLA", "FLAT": "PLATTA", "SINCE": "SEDAN", - "Toggle straight-down and tilted map views": "Växla mellan kartvy rakt uppifrån och lutad kartvy", + "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": "Felaktig ögonblicksbild av cykloner", + "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", @@ -2959,15 +2959,15 @@ "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 säkerhet", - "Nominal and high confidence detections": "Detekteringar med normal och hög säkerhet", - "High confidence detections only": "Bara detekteringar med hög säkerhet", + "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": "Säkerhet", + "Confidence": "Tillförlitlighet", "Minimum fire radiative power, megawatts": "Lägsta strålningseffekt från branden, megawatt", "fire intensity filter|All": "Alla", - "Detection confidence": "Detekteringens säkerhet", + "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}", @@ -2976,7 +2976,7 @@ "Drawn shapes": "Ritade former", "Audio": "Ljud", "UTILITIES": "VERKTYG", - "Exit cockpit to fly to a drawing": "Lämna cockpit för att flyga till en ritning", + "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", @@ -3135,7 +3135,7 @@ "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 djurobservationer är otillgänglig", + "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", @@ -3152,10 +3152,10 @@ "Show only plants": "Visa bara växter", "All rights reserved": "Alla rättigheter förbehållna", "Cetacean": "Valdjur", - "Wildlife Sightings": "Djurobservationer", - "Exit cockpit to fly to a sighting": "Lämna cockpit för att flyga till en observation", - "Exit cockpit to fly to a sighting area": "Lämna cockpit för att flyga till ett observationsområde", - "Wildlife": "Vilda djur", + "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", @@ -3165,7 +3165,7 @@ "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.": "Vilda djur 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.", + "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", @@ -3179,8 +3179,8 @@ "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 kartbilden", - "The map still uses a separate request and may show a different moment.": "Kartbilden hämtas separat och kan visa ett annat ögonblick.", + "Refresh map still": "Uppdatera stillbilden på kartan", + "The map still uses a separate request and may show a different moment.": "Stillbilden på kartan hämtas separat och kan visa ett annat ögonblick.", "Video plays from Québec 511, which sees your IP address.": "Videon spelas upp från Québec 511, som ser din IP-adress.", "STATIONS": "STATIONER", "Crewed stations and the craft that visit them": "Bemannade rymdstationer och farkosterna som besöker dem", @@ -3199,14 +3199,14 @@ "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}°": "TITTA STYRB. {bearing}°", - "LOOK PORT {bearing}°": "TITTA BABORD {bearing}°", - "LOOK ASTERN": "TITTA AKTERUT", + "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}": "TITTA {elevation}", - "{vesselType} · {feedState} · EYE {height}": "{vesselType} · {feedState} · ÖGA {height}", + "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", @@ -3214,7 +3214,7 @@ "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": "SLÅ PÅ ”KARTLAGDA ANLÄGGNINGAR” FÖR PLATSPINGAR", + "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}", @@ -3252,7 +3252,7 @@ "Current vessel heading": "Fartygets aktuella kurs", "SPEED OVER GROUND": "FART ÖVER GRUND", "COURSE OVER GROUND": "KURS ÖVER GRUND", - "ARROWS LOOK": "PILAR TITTAR", + "ARROWS LOOK": "PILAR VRIDER VYN", "HOME RESET": "HOME ÅTERSTÄLLER", "+/− ZOOM": "+/− ZOOMAR", "ARROWS TILT": "PILAR LUTAR", From 83342621093c3bc394e97e31e118221c0f6a807f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 13:39:08 +0400 Subject: [PATCH 21/28] Street Level: the action's failure cards in the interface language The voice card showed English for show_street_level failures the catalog did not know: a missing key, a map or source that could not be switched on, the layer, invalid coordinates, an imagery fallback and the filter clause. They now translate like the other action errors (exact messages and patterns in speech.js), in all twelve languages; the spoken line stays English, and the filter clause names the filters as their chips do. --- src/i18n/locales/ar.json | 17 +++++++- src/i18n/locales/de.json | 17 +++++++- src/i18n/locales/es.json | 17 +++++++- src/i18n/locales/fr.json | 17 +++++++- src/i18n/locales/he.json | 17 +++++++- src/i18n/locales/it.json | 17 +++++++- src/i18n/locales/ja.json | 17 +++++++- src/i18n/locales/pt.json | 17 +++++++- src/i18n/locales/ru.json | 17 +++++++- src/i18n/locales/uk.json | 17 +++++++- src/i18n/locales/zh-CN.json | 17 +++++++- src/i18n/locales/zh-Hant.json | 17 +++++++- src/voice/speech.js | 81 +++++++++++++++++++++++++++++++++++ src/voice/speech.test.mjs | 49 +++++++++++++++++++++ 14 files changed, 322 insertions(+), 12 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 8b30df99a..05c3dbeee 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -3800,5 +3800,20 @@ "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "معرّف UUID للسفينة: {id}" + "Vessel UUID: {id}": "معرّف UUID للسفينة: {id}", + "Latitude must be within ±90° and longitude within ±180°": "يجب أن يكون خط العرض ضمن ±90° وخط الطول ضمن ±180°", + "Street Level is unavailable": "مستوى الشارع غير متاح", + "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})" } diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index f5eb48d11..8effb49dd 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -3476,5 +3476,20 @@ "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "Schiffs-UUID: {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})" } diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c5018af73..6e3d4df5f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3557,5 +3557,20 @@ "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "UUID del buque: {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})" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e0a22f57f..c9bba95fd 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3557,5 +3557,20 @@ "ICAO: {id}": "ICAO : {id}", "NORAD: {id}": "NORAD : {id}", "MMSI: {id}": "MMSI : {id}", - "Vessel UUID: {id}": "UUID du navire : {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})" } diff --git a/src/i18n/locales/he.json b/src/i18n/locales/he.json index 1aaa9377a..77f532a8a 100644 --- a/src/i18n/locales/he.json +++ b/src/i18n/locales/he.json @@ -3557,5 +3557,20 @@ "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "UUID של כלי השיט: {id}" + "Vessel UUID: {id}": "UUID של כלי השיט: {id}", + "Latitude must be within ±90° and longitude within ±180°": "קו הרוחב חייב להיות בטווח ±90° וקו האורך בטווח ±180°", + "Street Level is unavailable": "Street Level אינו זמין", + "No street-level imagery source is configured": "לא הוגדר מקור לצילומי רחוב", + "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})" } diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index d3f081052..7c52e051d 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -3557,5 +3557,20 @@ "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "UUID della nave: {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})" } diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 97ad588ac..20f962285 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -3395,5 +3395,20 @@ "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "船舶UUID: {id}" + "Vessel UUID: {id}": "船舶UUID: {id}", + "Latitude must be within ±90° and longitude within ±180°": "緯度は±90°、経度は±180°の範囲内で指定してください", + "Street Level is unavailable": "ストリートレベルは利用できません", + "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})" } diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index 9895adb6d..698777b32 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3557,5 +3557,20 @@ "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "UUID da embarcação: {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})" } diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 46e8ad63a..031f2c429 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3638,5 +3638,20 @@ "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "UUID судна: {id}" + "Vessel UUID: {id}": "UUID судна: {id}", + "Latitude must be within ±90° and longitude within ±180°": "Широта должна быть в пределах ±90°, а долгота — в пределах ±180°", + "Street Level is unavailable": "Уличные снимки недоступны", + "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})" } diff --git a/src/i18n/locales/uk.json b/src/i18n/locales/uk.json index a0ff6bce7..561f753b2 100644 --- a/src/i18n/locales/uk.json +++ b/src/i18n/locales/uk.json @@ -3638,5 +3638,20 @@ "ICAO: {id}": "ICAO: {id}", "NORAD: {id}": "NORAD: {id}", "MMSI: {id}": "MMSI: {id}", - "Vessel UUID: {id}": "UUID судна: {id}" + "Vessel UUID: {id}": "UUID судна: {id}", + "Latitude must be within ±90° and longitude within ±180°": "Широта має бути в межах ±90°, а довгота — у межах ±180°", + "Street Level is unavailable": "Вуличні знімки недоступні", + "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})" } diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 3bd7c0bbd..fae3e5ca8 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -3395,5 +3395,20 @@ "ICAO: {id}": "ICAO:{id}", "NORAD: {id}": "NORAD:{id}", "MMSI: {id}": "MMSI:{id}", - "Vessel UUID: {id}": "船舶 UUID:{id}" + "Vessel UUID: {id}": "船舶 UUID:{id}", + "Latitude must be within ±90° and longitude within ±180°": "纬度须在 ±90° 以内,经度须在 ±180° 以内", + "Street Level is unavailable": "街景不可用", + "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})" } diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index 3fcd98932..e9c6c300d 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3395,5 +3395,20 @@ "ICAO: {id}": "ICAO:{id}", "NORAD: {id}": "NORAD:{id}", "MMSI: {id}": "MMSI:{id}", - "Vessel UUID: {id}": "船舶 UUID:{id}" + "Vessel UUID: {id}": "船舶 UUID:{id}", + "Latitude must be within ±90° and longitude within ±180°": "緯度須在 ±90° 以內,經度須在 ±180° 以內", + "Street Level is unavailable": "街景不可用", + "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})" } diff --git a/src/voice/speech.js b/src/voice/speech.js index 3e0227160..3ecb19114 100644 --- a/src/voice/speech.js +++ b/src/voice/speech.js @@ -179,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': @@ -259,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. */ diff --git a/src/voice/speech.test.mjs b/src/voice/speech.test.mjs index 84d6f8d62..e1aa0c3ec 100644 --- a/src/voice/speech.test.mjs +++ b/src/voice/speech.test.mjs @@ -552,6 +552,55 @@ test('the street-level card says "the view centre" in the interface language', ( } }); +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, From a8970c1f18123d0fbc50580b8f583c7c466dafc3 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 13:40:12 +0400 Subject: [PATCH 22/28] Street Level: a geocoder that throws fails show_street_level at its location stage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider that threw while geocoding (offline, an upstream down) escaped the action as a bare error. show_street_level now reports it as its location stage ("Could not arrive at …", the cause in detail), and nothing is switched or looked up; fly_to_location itself is unchanged. --- src/voice/gevActions.js | 35 ++++++++++++++++++++++------------- src/voice/gevActions.test.mjs | 19 +++++++++++++++++++ 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 5d021ef6e..14956c6c9 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -3961,19 +3961,28 @@ async function requestStreetLevel( if (atViewCentre || (args.fly === false && hasCoordinates)) { if (hasCoordinates) point = { lat: latitude, lon: longitude }; } else { - location = await runGevAction( - 'fly_to_location', - { - ...(entity - ? { entity } - : hasCoordinates - ? { latitude, longitude } - : { query }), - viewMode: 'close', - waitForArrival: true, - }, - runOptions, - ); + 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', diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 7ccaabc8d..64e36c42b 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -7005,6 +7005,25 @@ test('show_street_level tells a failed enable, a delisted source, a key refused 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' }); From f612fc218022863c35fdf7e1d713388198828e3e Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 13:43:58 +0400 Subject: [PATCH 23/28] Street Level: a posted view without a photo point grounds its look-at too show_in_gods_eye_view measured a view's altitude from the terrain before taking the point the camera looks at; a view posted straight into the embedded app used the ellipsoid altitude, so a camera over high ground opened photos kilometres ahead. groundedLookAt moves to the view module, and the embedded app uses it with the app's terrain heights for a posted street_level that names no point. --- src/app/embed.js | 21 ++++++++++++++ src/app/embed.test.mjs | 59 ++++++++++++++++++++++++++++++++++++++ src/app/tools.js | 1 + src/tools/queries/share.js | 28 ++---------------- src/view/index.js | 24 ++++++++++++++++ 5 files changed, 107 insertions(+), 26 deletions(-) diff --git a/src/app/embed.js b/src/app/embed.js index 04da9cbc4..f4954e720 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -19,6 +19,7 @@ import { annotationsFromParams, createView, followFromParams, + groundedLookAt, streetLevelFromParams, } from '../view/index.js'; @@ -329,6 +330,7 @@ export function installViews({ dataManager, run, signal, + terrain = null, location = globalThis.location, windowRef = globalThis.window, }) { @@ -477,8 +479,27 @@ export function installViews({ // 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) { + const point = await groundedLookAt(view.camera, terrain, signal).catch( + () => null, + ); + if (signal?.aborted) return; + if (point) + view = createView({ + ...event.data.view, + street_level: { ...asked, ...point }, + }); + } const steps = await applyView(view, { viewer, dataManager, diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 4fe754a92..cb34287a5 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -104,6 +104,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 = []; diff --git a/src/app/tools.js b/src/app/tools.js index b9a35976d..cffae32b2 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -249,6 +249,7 @@ export function createApplicationTools({ 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/tools/queries/share.js b/src/tools/queries/share.js index bdb9d9267..2e13a98d7 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -3,7 +3,7 @@ import { VIEW_PROPERTIES, createView, - lookAtPoint, + groundedLookAt, viewUrl, } from '../../view/index.js'; import { defineTool, ToolError } from '../catalog.js'; @@ -41,30 +41,6 @@ async function followedAircraftPosition(follow, tools) { } } -/** - * 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. Null when the terrain cannot say. - */ -async function groundedLookAt(camera, services, signal) { - if (typeof services?.terrain?.getHeights !== 'function') return null; - if (!Number.isFinite(camera.altitude_m) || !(camera.pitch_deg > -89)) - return null; - let heights; - try { - heights = await services.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 }); -} - /** * The view described by tool arguments. An area frames the camera straight * down over it; camera fields given alongside it override the framing. A @@ -131,7 +107,7 @@ export async function resolveViewArguments(args, { services, signal, tools }) { streetLevel.lat === undefined && streetLevel.lon === undefined ) { - const point = await groundedLookAt(camera, services, signal); + const point = await groundedLookAt(camera, services?.terrain, signal); if (point) streetLevel = { ...streetLevel, ...point }; } let view; diff --git a/src/view/index.js b/src/view/index.js index 11c3b8c7d..7d70e1c8c 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -123,6 +123,30 @@ export function lookAtPoint(camera) { }; } +/** + * 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; + if (!Number.isFinite(camera?.altitude_m) || !(camera.pitch_deg > -89)) + 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', From 45a33b54df3ee368e69a00ce21ad7d5996f74979 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 13:44:49 +0400 Subject: [PATCH 24/28] Street Level: docs for the OPEN exception, panel freshness and what stops a request - SECURITY.md: OPEN also takes the link's point when the screen centre cannot be picked at all. - TOOLS.md: the panel's own link opens Street View only for a result issued in the last five minutes; an older one only arms. - Changelog: the hiding modes stop a request, collapsing the panel does not; failure cards are in the interface language. - CURRENT-STATE: a posted view without a point grounds its look-at too. --- SECURITY.md | 2 +- changelog.d/1113-show-street-level.md | 9 ++++++--- docs/CURRENT-STATE.md | 7 ++++--- docs/TOOLS.md | 6 ++++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 4316f522f..43ee7b6bd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -132,7 +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; 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. +- **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 index 523fe5773..233a6bd16 100644 --- a/changelog.d/1113-show-street-level.md +++ b/changelog.d/1113-show-street-level.md @@ -8,8 +8,11 @@ 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 hides the panel before its photo loads; 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. An unavailable map stack now reports the + 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0e92438e5..0c3577644 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5315,9 +5315,10 @@ 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, also for half a point). A `google` source brings `map: photoreal` and refuses another map; `street_level` does not combine with `follow`. -`show_in_gods_eye_view` measures a view's altitude from the terrain under the -camera (`services.terrain`) before taking the look-at point, and keeps the -view's own estimate without a terrain answer. Links carry +`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. 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`. diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 1a95d2656..a6d501c8c 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -110,8 +110,10 @@ shows only on the Google 3D map, so a `google` source sets `map` to 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, or the panel page's own opening -link, opens it, and `?embed=1` on a published link does not. Mapillary, +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`. From 4fd36c44e0571c703daba4696202a56b40d7097d Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 16:05:26 +0400 Subject: [PATCH 25/28] Street Level: a replacement keeps its own state, and a posted view stands down - Releasing a held viewer frees the old source's adapter without resetting the panel, so a replacement's error, outcome and armed OPEN survive; a cross-source replacement drops the old photo's details and marker. - The action holds the open photo only for its own switch-off: a source switched by hand during the load stops it and reports cancelled. - A posted view waiting on terrain watches the camera and stands down after a navigation or a globe press; a flat or straight-down camera asks no terrain. - An arming link opens the panel without persisting the layout, and a posted view's street-level step runs as programmatic. - An off-globe street_level point throws TypeError. - zh-Hant uses the file's wording for the new strings; README and CURRENT-STATE count 35 tools. --- README.md | 2 +- docs/CURRENT-STATE.md | 23 +- src/app/embed.js | 35 ++- src/app/embed.test.mjs | 17 +- src/i18n/locales/zh-Hant.json | 8 +- src/layers/streetLevel/index.js | 30 ++- .../streetLevel/providers/mapillary/policy.js | 4 +- src/layers/streetLevel/viewerHost.js | 37 +++- src/navigationPolicy.js | 34 +++ src/streetLevelLifecycle.test.mjs | 16 +- src/streetLevelReplacement.test.mjs | 201 ++++++++++++++++++ src/view/index.js | 14 +- src/view/index.test.mjs | 15 ++ src/voice/gevActions.js | 39 +--- 14 files changed, 391 insertions(+), 84 deletions(-) create mode 100644 src/streetLevelReplacement.test.mjs diff --git a/README.md b/README.md index dc119497a..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-two 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/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 0c3577644..e93e21d79 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5282,14 +5282,19 @@ app. 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, and a - lookup given up leaves the panel as it was; a photo that opened after either - is closed again. + (`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`) 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 stops a playing Mapillary sequence. + 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 @@ -5312,8 +5317,8 @@ 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, also -for half a point). A `google` source brings `map: photoreal` and refuses +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 @@ -6411,7 +6416,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-two 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`, `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`, `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/src/app/embed.js b/src/app/embed.js index f4954e720..13e928df1 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -12,7 +12,10 @@ */ import * as Cesium from 'cesium'; -import { announceNavigationAuthority } from '../navigationPolicy.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 { @@ -234,10 +237,10 @@ export async function applyView( }, ) { 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) }; } @@ -270,9 +273,12 @@ export async function applyView( 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. @@ -397,8 +403,10 @@ export function installViews({ // 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); @@ -490,10 +498,25 @@ export function installViews({ queue = queue.then(async () => { if (signal?.aborted) return; if (unplaced && view.street_level) { - const point = await groundedLookAt(view.camera, terrain, signal).catch( - () => null, - ); + // Whoever takes the camera while the terrain answers (a drag, a + // search, the assistant) keeps it: the view stands down. + const owner = watchCameraOwner(viewer, shell); + const point = await groundedLookAt(view.camera, terrain, signal) + .catch(() => null) + .finally(() => owner.stop()); if (signal?.aborted) return; + 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, diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index cb34287a5..bcc5ada9b 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -85,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({ @@ -739,8 +752,8 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s ]); assert.deepEqual( panels, - [['street-level-panel', false, { explicit: true }]], - 'the panel opens so OPEN is in view', + [['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 diff --git a/src/i18n/locales/zh-Hant.json b/src/i18n/locales/zh-Hant.json index e9c6c300d..b10c726b2 100644 --- a/src/i18n/locales/zh-Hant.json +++ b/src/i18n/locales/zh-Hant.json @@ -3330,7 +3330,7 @@ "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": "點擊“打開”載入照片", + "Press OPEN to load the photo": "點擊“打開”加載照片", "Captured {month}": "拍攝於 {month}", "this spot": "此處", "the view centre": "視圖中心", @@ -3338,9 +3338,9 @@ "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.": "目前的檢視隱藏了街景照片。請先離開此檢視。", + "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": "成像", diff --git a/src/layers/streetLevel/index.js b/src/layers/streetLevel/index.js index 6b0aa2d27..bee453af8 100644 --- a/src/layers/streetLevel/index.js +++ b/src/layers/streetLevel/index.js @@ -266,11 +266,11 @@ export function createStreetLevelLayer({ if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def); } - /** Requests replacing the open photo that switch its source keep it up. */ - let viewerHolds = 0; + /** Sources whose open photo a replacing request keeps up while it switches them off. */ + const heldViewers = new Map(); function deactivate(entry) { - if (!viewerHolds) 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); } @@ -854,19 +854,29 @@ export function createStreetLevelLayer({ }, closeViewer, /** - * Keep the open photo up while a request that replaces it switches the - * source, so EXPAND stays. Release closes it if its source is still off. + * 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() { - viewerHolds += 1; + 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; - viewerHolds -= 1; - if (viewerHolds) return; - for (const entry of state.providers.values()) - if (!entry.on) parts.viewerHost.unmount(entry.def.id); + 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); }; }, /** diff --git a/src/layers/streetLevel/providers/mapillary/policy.js b/src/layers/streetLevel/providers/mapillary/policy.js index 78ca14a49..e885d377c 100644 --- a/src/layers/streetLevel/providers/mapillary/policy.js +++ b/src/layers/streetLevel/providers/mapillary/policy.js @@ -76,8 +76,8 @@ export const SEQUENCE_IMAGE_FIELDS = /** * Images asked for in a nearest-image search: the API cuts at the limit - * before any order, so this is its most, and the distance sort and the - * imagery filter choose among all of the spot's photos. + * 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; /** diff --git a/src/layers/streetLevel/viewerHost.js b/src/layers/streetLevel/viewerHost.js index acaba443e..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 () => { @@ -202,5 +215,27 @@ export function createViewerHost({ state, parts }) { active = null; } - return { attach, open, close, resize, setRenderMode, stopPlayback, 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/navigationPolicy.js b/src/navigationPolicy.js index 2980b21e6..0813b773e 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()` makes the camera as it is now the request's (after + * its own flight); `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 = false; + }, + taken: () => + taken || + (Number.isFinite(generation) && + navigation._navigationGeneration !== generation), + stop: () => { + for (const type of CAMERA_GESTURES) + canvas?.removeEventListener?.(type, onGesture); + }, + }; +} diff --git a/src/streetLevelLifecycle.test.mjs b/src/streetLevelLifecycle.test.mjs index 3a08a44c3..b64ef9ea0 100644 --- a/src/streetLevelLifecycle.test.mjs +++ b/src/streetLevelLifecycle.test.mjs @@ -6,7 +6,6 @@ import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetL import { showStreetLevel } from './voice/gevActions.js'; import { installViews } from './app/embed.js'; import { ShareRestoration } from './ui/shareRestoration.js'; -import { createView } from './view/index.js'; import { StreetLevelControls } from './ui/streetLevelControls.js'; import { createApp, nextTick } from 'vue'; import { createPinia } from 'pinia'; @@ -22,11 +21,10 @@ function deferred() { const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; } -async function harness(t, { nearestImage = async () => 'A', failImage = null, billed = false, extraProviders = [] } = {}) { +async function harness(t, { nearestImage = async () => 'A', failImage = null, extraProviders = [] } = {}) { document.body.className = ''; document.body.replaceChildren(); const provider = fakeStreetLevelProvider({ nearestImage }); - provider.billedPerImage = billed; const originalCreate = provider.create; const opened = []; provider.create = context => { @@ -50,7 +48,6 @@ async function harness(t, { nearestImage = async () => 'A', failImage = null, bi layer.attachViewerHost(host); t.after(() => { layer.destroy(); document.body.className = ''; document.body.replaceChildren(); }); await settle(); - assert.equal(layer.providerStatus('mapillary').billed, billed); const dataManager = { layers: new Map([['street-level', { module: layer }]]), getAll: () => [{ id: 'street-level', enabled: true }], @@ -69,14 +66,9 @@ async function harness(t, { nearestImage = async () => 'A', failImage = null, bi return { ok: true, latitude: 48, longitude: 2, label: 'fixture point' }; }, }; - let result; - const run = async (name, args) => { - if (name !== 'show_street_level') return { ok: true }; - result = await showStreetLevel(args, context); - return result; - }; - const view = createView({ camera: { lat: 48, lon: 2 }, layers: ['street-level'], street_level: { provider: 'mapillary', lat: 48, lon: 2 } }); - return { layer, viewer, dataManager, context, run, view, opened, flights, result: () => result }; + 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 => { diff --git a/src/streetLevelReplacement.test.mjs b/src/streetLevelReplacement.test.mjs new file mode 100644 index 000000000..2d931d707 --- /dev/null +++ b/src/streetLevelReplacement.test.mjs @@ -0,0 +1,201 @@ +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'; + +// Replacing a street-level photo, across sources and against the user's own +// moves; posted views waiting on terrain. 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'); +}); diff --git a/src/view/index.js b/src/view/index.js index 7d70e1c8c..cd5f20f31 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -131,7 +131,13 @@ export function lookAtPoint(camera) { */ export async function groundedLookAt(camera, terrain, signal) { if (typeof terrain?.getHeights !== 'function') return null; - if (!Number.isFinite(camera?.altitude_m) || !(camera.pitch_deg > -89)) + // 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 { @@ -381,6 +387,12 @@ export function createView({ // 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)]; + // A coordinate given off the globe is an error, not a reason to guess. + for (const [index, key] of ['lat', 'lon'].entries()) + if (streetLevel[key] != null && given[index] === undefined) + throw new TypeError( + `Street-level ${key} is not a valid coordinate: ${streetLevel[key]}`, + ); const seen = lookAtPoint(camera); const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given; street = Object.freeze({ diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 9fda7d149..046d76b3c 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -208,6 +208,21 @@ test('street-level photos travel in the link, light their source and bring their 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); + // A point off the globe is refused, like an unknown source. + for (const bad of [ + { lat: 95, lon: 2 }, + { lat: 48, lon: 200 }, + { lat: Number.NaN, lon: 2 }, + ]) + assert.throws( + () => + createView({ + camera: { lat: 1, lon: 2 }, + street_level: { provider: 'mapillary', ...bad }, + }), + TypeError, + JSON.stringify(bad), + ); // Half a point is not mixed with the camera's other half. const half = createView({ camera: { lat: 1, lon: 2 }, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 14956c6c9..14b8102c3 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -60,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'; @@ -2984,7 +2985,6 @@ export function activeImageryFilter(ui) { * detection so the framed entities are labeled, and reports * detectionEnabled so the voice agent can mention labels are on. */ - async function frameOverhead(viewer, dataManager, styleManager, args = {}) { const targetRaw = String(args.target || 'flights').toLowerCase(); const layerId = @@ -3763,40 +3763,6 @@ function actionIntentOrigin(runOptions) { return runOptions?.origin === 'programmatic' ? 'programmatic' : 'voice'; } -/** 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()` makes the camera as it is now the request's (after - * its own flight); `stop()` removes the gesture listeners. - */ -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 = false; - }, - taken: () => - taken || - (Number.isFinite(generation) && - navigation._navigationGeneration !== generation), - stop: () => { - for (const type of CAMERA_GESTURES) - canvas?.removeEventListener?.(type, onGesture); - }, - }; -} - /** * 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 @@ -4034,7 +4000,8 @@ async function requestStreetLevel( // 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. - if (layerModule.getUIState?.()?.street?.open === true) + const openPhoto = layerModule.getUIState?.()?.street; + if (openPhoto?.open === true && openPhoto.providerId !== provider) holdViewer(layerModule); const switches = Object.fromEntries( providerIds.map((id) => [id, id === provider]), From ae66d075754ea578779a536649922727fba11b46 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 11 Nov 2026 12:16:58 +0000 Subject: [PATCH 26/28] i18n(sv): the PR's 27 Street Level strings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main added a Swedish catalog in #1630, so the catalog test failed on the strings this PR adds. They use the catalog's terms (Gatunivå, nyckel, bilder på gatunivå, vyns mitt). --- src/i18n/locales/sv.json | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/i18n/locales/sv.json b/src/i18n/locales/sv.json index 7d2828954..367025e4e 100644 --- a/src/i18n/locales/sv.json +++ b/src/i18n/locales/sv.json @@ -1713,6 +1713,7 @@ "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", @@ -3327,6 +3328,17 @@ "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", @@ -3457,6 +3469,21 @@ "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", From 0ebc09079d48b69b902444d93164b0fb64f56be1 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 18:02:15 +0400 Subject: [PATCH 27/28] Street Level: polar look-at, a bounded terrain wait, takeovers during the flight - The look-at longitude wraps fully, so a tilted camera at a pole gives a point on the globe. The strict off-globe check on street_level is undone: points are clamped again, as before. - A posted view that fails is answered, and the views behind it still apply. - A posted view waits at most 2 s for the terrain under its camera, then keeps its own estimate. - A camera taken during that wait stays where it is: the rest of the view applies, and only its photos stand down. - show_street_level keeps a search or a press on the globe made during its own flight: it adopts only its own navigation stamps on landing, and a gesture is never forgiven. --- docs/CURRENT-STATE.md | 4 +- src/app/embed.js | 106 +++++++++++++++++----------- src/navigationPolicy.js | 6 +- src/streetLevelReplacement.test.mjs | 89 ++++++++++++++++++++++- src/view/index.js | 9 +-- src/view/index.test.mjs | 15 ---- src/voice/gevActions.js | 9 ++- src/voice/gevActions.test.mjs | 34 +++++++-- 8 files changed, 193 insertions(+), 79 deletions(-) diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index 038f59627..b9585de17 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5323,7 +5323,9 @@ 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. Links carry +point, and keep the view's own estimate without a terrain answer (the embedded +app waits 2 s for one). A camera taken during that wait stays where it is: +the rest of the view applies and its photos do not open. 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`. diff --git a/src/app/embed.js b/src/app/embed.js index 13e928df1..5a3119da9 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -39,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 @@ -495,51 +497,71 @@ export function installViews({ typeof asked === 'object' && asked.lat == null && asked.lon == null; - queue = queue.then(async () => { - if (signal?.aborted) return; - if (unplaced && view.street_level) { - // Whoever takes the camera while the terrain answers (a drag, a - // search, the assistant) keeps it: the view stands down. - const owner = watchCameraOwner(viewer, shell); - const point = await groundedLookAt(view.camera, terrain, signal) - .catch(() => null) - .finally(() => owner.stop()); + queue = queue + .then(async () => { if (signal?.aborted) return; - if (owner.taken()) { - post( - { - type: EMBED_APPLIED_MESSAGE, - id, - ok: false, - steps: [{ step: 'street_level', ok: false, superseded: true }], - }, - replyOrigin, - ); - return; + let apply = run; + 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 meanwhile (a drag, a search, the + // assistant) keeps it: the photos stand down, as when the camera + // is taken during the action, and the rest of the view applies. + if (owner.taken()) + apply = (name, ...rest) => + name === 'show_street_level' + ? { + ok: false, + cancelled: true, + superseded: true, + error: 'The camera moved on before the photo opened', + } + : run(name, ...rest); + else if (point) + view = createView({ + ...event.data.view, + street_level: { ...asked, ...point }, + }); } - 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, + const steps = await applyView(view, { + viewer, + dataManager, + run: apply, + 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(() => { diff --git a/src/navigationPolicy.js b/src/navigationPolicy.js index 0813b773e..63e489ed8 100644 --- a/src/navigationPolicy.js +++ b/src/navigationPolicy.js @@ -188,8 +188,9 @@ 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()` makes the camera as it is now the request's (after - * its own flight); `stop()` removes the gesture listeners. + * 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; @@ -203,7 +204,6 @@ export function watchCameraOwner(viewer, navigation) { return { claim: () => { generation = navigation?._navigationGeneration; - taken = false; }, taken: () => taken || diff --git a/src/streetLevelReplacement.test.mjs b/src/streetLevelReplacement.test.mjs index 2d931d707..84fe3cf30 100644 --- a/src/streetLevelReplacement.test.mjs +++ b/src/streetLevelReplacement.test.mjs @@ -5,10 +5,12 @@ 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. Offline: providers, lookups, terrain -// and flights are fixtures. +// 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() { @@ -142,6 +144,11 @@ for (const takeover of ['navigation', 'gesture', 'none']) { 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'); + // Only the photos stand down: the rest of the view still applies, and + // the camera stays with whoever took it. + const steps = reply.steps.map(step => step.step); + assert.ok(steps.includes('clear_annotations'), JSON.stringify(steps)); + assert.ok(!steps.includes('camera'), JSON.stringify(steps)); }); } @@ -199,3 +206,81 @@ test('a failed replacement from another source does not keep the old photo\'s de 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/view/index.js b/src/view/index.js index cd5f20f31..be7324fe0 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -119,7 +119,8 @@ export function lookAtPoint(camera) { const lon = camera.lon + dLon; return { lat: clamp(camera.lat + dLat, -90, 90), - lon: lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon, + // Near a pole one step ahead can be several turns of the parallel. + lon: ((((lon + 180) % 360) + 360) % 360) - 180, }; } @@ -387,12 +388,6 @@ export function createView({ // 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)]; - // A coordinate given off the globe is an error, not a reason to guess. - for (const [index, key] of ['lat', 'lon'].entries()) - if (streetLevel[key] != null && given[index] === undefined) - throw new TypeError( - `Street-level ${key} is not a valid coordinate: ${streetLevel[key]}`, - ); const seen = lookAtPoint(camera); const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given; street = Object.freeze({ diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 046d76b3c..9fda7d149 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -208,21 +208,6 @@ test('street-level photos travel in the link, light their source and bring their 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); - // A point off the globe is refused, like an unknown source. - for (const bad of [ - { lat: 95, lon: 2 }, - { lat: 48, lon: 200 }, - { lat: Number.NaN, lon: 2 }, - ]) - assert.throws( - () => - createView({ - camera: { lat: 1, lon: 2 }, - street_level: { provider: 'mapillary', ...bad }, - }), - TypeError, - JSON.stringify(bad), - ); // Half a point is not mixed with the camera's other half. const half = createView({ camera: { lat: 1, lon: 2 }, diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js index 14b8102c3..9a38408b2 100644 --- a/src/voice/gevActions.js +++ b/src/voice/gevActions.js @@ -812,6 +812,7 @@ export function createGevActionRunner({ runOptions, current, reportProgress, + otherHandoffs, }); } @@ -3813,9 +3814,12 @@ async function requestStreetLevel( 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; @@ -3972,8 +3976,9 @@ async function requestStreetLevel( } // A mode that hides the panel may have started during the flight. The - // camera is the request's from where its flight landed. - if (location) owner.claim(); + // 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; diff --git a/src/voice/gevActions.test.mjs b/src/voice/gevActions.test.mjs index 64e36c42b..98bc786db 100644 --- a/src/voice/gevActions.test.mjs +++ b/src/voice/gevActions.test.mjs @@ -6573,11 +6573,10 @@ function createStreetLevelHarness({ const lookups = []; const origins = []; const revealed = []; - const { viewer, styleManager, currentGeneration } = createVoiceNavigationHarness(); + const { viewer, styleManager, currentGeneration, panelSearch } = createVoiceNavigationHarness(); // Who owns the camera: the shell's navigation stamps and globe gestures. - let userNavigations = 0; Object.defineProperty(styleManager, '_navigationGeneration', { - get: () => currentGeneration() + userNavigations, + get: () => currentGeneration(), }); viewer.scene.canvas = Object.assign(new EventTarget(), { clientWidth: 1200, clientHeight: 800 }); let completeFlight = null; @@ -6719,10 +6718,9 @@ function createStreetLevelHarness({ origins, revealed, street, - // The user searches or flies elsewhere, or drags the globe. - navigate: () => { - userNavigations += 1; - }, + // 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')), }; } @@ -7428,6 +7426,28 @@ test('show_street_level owns the camera from where its own flight lands', async 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 }); From 71797427d619df1938372cc1e25dcc92640e4ce4 Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 19:01:58 +0400 Subject: [PATCH 28/28] Street Level: a posted view whose camera was taken stands down whole again Applying the rest of a posted view after the camera was taken during its terrain wait still let it change the camera: stop_tracking dropped a follow the user had just started, and annotate_map framed the view's marks. A camera taken during the wait now ends the view again, as before. The 2 s terrain timeout and the per-view catch on the queue stay. --- docs/CURRENT-STATE.md | 3 +- src/app/embed.js | 32 +++---- src/app/embedTerrainTakeover.test.mjs | 118 ++++++++++++++++++++++++++ src/streetLevelReplacement.test.mjs | 5 -- 4 files changed, 135 insertions(+), 23 deletions(-) create mode 100644 src/app/embedTerrainTakeover.test.mjs diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b9585de17..943458e5e 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -5324,8 +5324,7 @@ another map; `street_level` does not combine with `follow`. 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). A camera taken during that wait stays where it is: -the rest of the view applies and its photos do not open. Links carry +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`. diff --git a/src/app/embed.js b/src/app/embed.js index 5a3119da9..85c4f0752 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -500,7 +500,6 @@ export function installViews({ queue = queue .then(async () => { if (signal?.aborted) return; - let apply = run; if (unplaced && view.street_level) { // A terrain service that does not answer soon leaves the view's // own estimate. @@ -514,20 +513,21 @@ export function installViews({ .catch(() => null) .finally(() => owner.stop()); if (signal?.aborted) return; - // Whoever takes the camera meanwhile (a drag, a search, the - // assistant) keeps it: the photos stand down, as when the camera - // is taken during the action, and the rest of the view applies. - if (owner.taken()) - apply = (name, ...rest) => - name === 'show_street_level' - ? { - ok: false, - cancelled: true, - superseded: true, - error: 'The camera moved on before the photo opened', - } - : run(name, ...rest); - else if (point) + // 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 }, @@ -536,7 +536,7 @@ export function installViews({ const steps = await applyView(view, { viewer, dataManager, - run: apply, + run, signal, autoOpen, }); 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/streetLevelReplacement.test.mjs b/src/streetLevelReplacement.test.mjs index 84fe3cf30..f0aa97ec0 100644 --- a/src/streetLevelReplacement.test.mjs +++ b/src/streetLevelReplacement.test.mjs @@ -144,11 +144,6 @@ for (const takeover of ['navigation', 'gesture', 'none']) { 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'); - // Only the photos stand down: the rest of the view still applies, and - // the camera stays with whoever took it. - const steps = reply.steps.map(step => step.step); - assert.ok(steps.includes('clear_annotations'), JSON.stringify(steps)); - assert.ok(!steps.includes('camera'), JSON.stringify(steps)); }); }