From 98de41c94a5b36b1a8c01565c94f3c58f109d56f Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 28 Oct 2026 18:27:43 +0000 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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 9c73939425c1317773c6478854ccc8ae60b22c25 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 10 Nov 2026 15:12:50 +0000 Subject: [PATCH 09/22] 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 10/22] 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 11/22] 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 12/22] 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 13/22] 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 14/22] 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 83342621093c3bc394e97e31e118221c0f6a807f Mon Sep 17 00:00:00 2001 From: German Khokhlov Date: Wed, 11 Nov 2026 13:39:08 +0400 Subject: [PATCH 15/22] 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 16/22] 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 17/22] 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 18/22] 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 19/22] 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 20/22] 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 21/22] 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 22/22] 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)); }); }