From 9c73939425c1317773c6478854ccc8ae60b22c25 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Tue, 10 Nov 2026 15:12:50 +0000 Subject: [PATCH] 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();