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.
This commit is contained in:
Said Turksever committed 2026-11-10 15:12:50 +00:00
1 parent 5370b8a268
commit 9c73939425
22 files changed
+329 -17

No files matched your search

+15 -3
View File
@@ -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(/^#/, '')),
);
+110 -2
View File
@@ -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 = {
+1
View File
@@ -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 معدل الطلبات",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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がリクエストをレート制限中です",
+1
View File
@@ -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",
+1
View File
@@ -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 ограничивает частоту запросов",
+1
View File
@@ -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 обмежує частоту запитів",
+1
View File
@@ -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 已被限流",
+1
View File
@@ -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 已被限流",
+16
View File
@@ -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),
+80
View File
@@ -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,
);
});
@@ -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,
}),
),
@@ -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 () => {
+10 -6
View File
@@ -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 }),
+44
View File
@@ -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();