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,