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

+6 -2
View File
@@ -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).
+12 -3
View File
@@ -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
+1
View File
@@ -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",
+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();