fix(street-level): links keep the recipient's preferences; aim under the screen centre

Review fixes for gekh's #1113 round:
- a link runs show_street_level with origin 'programmatic', so its source
  switch and layer are not saved as the recipient's preferences;
- with no place named, the action looks under the screen centre
  (aim 'screen-centre', armed false), keeps the sky refusal and leaves a
  link's armed point alone;
- an armed OPEN aimed at the sky opens nothing instead of the far link point;
- an imagery-stage failure is spoken as an error, missing imagery as missing
  (street.nearestOutcome); the card says "the view centre" in the interface
  language;
- every photo the action opens reveals its panel, and revealPanel scrolls the
  panel into view in the phone sheet;
- embed: EXPAND survives embed's own clean view, the lifted photo hides in
  cockpit, recording and scene playback, and a failed photo step closes the
  earlier photo before the view flies;
- links pasted into an open tab run their street-level step after the
  restore settles; a superseded start link does not;
- Mapillary's nearest search gives up after 12 s, body included.
This commit is contained in:
Said Turksever committed 2026-11-05 18:25:21 +00:00
1 parent 3f55077c2c
commit 451f3e41cf
22 files changed
+716 -64

No files matched your search

+74 -29
View File
@@ -198,6 +198,8 @@ function streetLevelArgs(streetLevel, { autoOpen = true } = {}) {
}
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
/** Dispatched on the window when a link opened after start-up has restored. */
const SHARE_NAVIGATION_RESTORED_EVENT = 'gev:share-navigation-restore-settled';
/** The armed link's toast, naming the OPEN button as the panel shows it. */
function armedToast() {
@@ -258,8 +260,12 @@ export async function applyView(
'show_street_level',
streetLevelArgs(view.street_level),
);
if (result?.ok !== true && !result?.location)
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
if (result?.ok !== true) {
// An earlier view's photo must not stay over a place this view left.
dataManager.layers?.get?.(STREET_LEVEL_LAYER)?.module?.closeViewer?.();
if (!result?.location)
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
}
return steps;
}
if (!view.follow) {
@@ -317,13 +323,16 @@ export function installViews({
// The camera's owner at restore: anyone who navigates while the link's
// steps wait (a gesture, a search, the assistant) takes it, and a passive
// link must not fly over them.
// A start-up restore that a newer link superseded hands the camera to that
// link: its own steps never run.
let generationAtRestore = null;
let queue = ready.then(() => {
let startSuperseded = false;
let queue = ready.then((outcome) => {
generationAtRestore = shell._navigationGeneration;
startSuperseded = outcome?.status === 'superseded';
});
const navigatedSinceRestore = () =>
Number.isFinite(generationAtRestore) &&
shell._navigationGeneration !== generationAtRestore;
const navigatedSince = (generation) =>
Number.isFinite(generation) && shell._navigationGeneration !== generation;
const linkStep = (work) =>
queue.then(async () => {
if (signal?.aborted) return;
@@ -341,29 +350,64 @@ export function installViews({
// click; only the panel page's own link for a result issued just now is
// the client's request (a host remounting the panel hands back old ones).
// A link that follows an entity keeps the camera on it.
const streetLevel = followFromParams(hashParams)
? null
: streetLevelFromParams(hashParams);
if (streetLevel)
queue = linkStep(async () => {
if (navigatedSinceRestore()) return;
const result = await run(
'show_street_level',
streetLevelArgs(streetLevel, { autoOpen: isFreshPanelResult() }),
);
// The restore window keeps the panel collapsed; OPEN must be in view.
// Embed mode hides the rail that holds it, so there the link only arms
// (a posted view is the way to open a photo in a frame).
if (result?.armed === true && !isEmbedded(location)) {
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
explicit: true,
});
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
shell._showToast?.(armedToast());
}
});
if (!isEmbedded(location)) return () => {};
const linkedStreetLevel = (params) =>
followFromParams(params) ? null : streetLevelFromParams(params);
const openLinkedStreetLevel = async (
streetLevel,
{ generation, autoOpen = false },
) => {
if (navigatedSince(generation)) return;
// The link replays the sender's view: nothing it switches becomes the
// recipient's own preference.
const result = await run(
'show_street_level',
streetLevelArgs(streetLevel, { autoOpen }),
{ origin: 'programmatic' },
);
// The restore window keeps the panel collapsed; OPEN must be in view.
// Embed mode hides the rail that holds it, so there the link only arms
// (a posted view is the way to open a photo in a frame).
if (result?.armed === true && !isEmbedded(location)) {
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
explicit: true,
});
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
shell._showToast?.(armedToast());
}
};
const startStreetLevel = linkedStreetLevel(hashParams);
if (startStreetLevel)
queue = linkStep(() =>
startSuperseded
? undefined
: openLinkedStreetLevel(startStreetLevel, {
generation: generationAtRestore,
autoOpen: isFreshPanelResult(),
}),
);
// A link pasted into the open tab restores camera and layers on its own;
// its photos follow once that restore settles, under the same rule: a plain
// link never loads a billed photo by itself.
const onNavigatedRestore = (event) => {
if (event?.detail?.status !== 'settled') return;
const streetLevel = linkedStreetLevel(
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
);
if (!streetLevel) return;
const generation = shell._navigationGeneration;
queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation }));
};
windowRef?.addEventListener?.(
SHARE_NAVIGATION_RESTORED_EVENT,
onNavigatedRestore,
);
const stopNavigatedRestores = () =>
windowRef?.removeEventListener?.(
SHARE_NAVIGATION_RESTORED_EVENT,
onNavigatedRestore,
);
if (!isEmbedded(location)) return stopNavigatedRestores;
windowRef.document.body.classList.add('ui-embed');
shell.setCleanView?.(true);
@@ -423,6 +467,7 @@ export function installViews({
});
return () => {
stopRendering();
stopNavigatedRestores();
windowRef.removeEventListener('message', onMessage);
};
}
+128 -2
View File
@@ -32,14 +32,16 @@ const fakeLayers = (enabled) => ({
});
const recorder = (answers = {}) => {
const calls = [];
const run = async (name, args) => {
const options = [];
const run = async (name, args, runOptions) => {
calls.push([name, args]);
options.push([name, runOptions]);
const answer = answers[name];
return typeof answer === 'function'
? answer(calls)
: (answer ?? { ok: true });
};
return { calls, run };
return { calls, run, options };
};
test('embed mode is opt-in by query parameter', () => {
@@ -875,3 +877,127 @@ test('a passive link stands down when someone navigated while it waited behind i
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(quiet, ['annotate_map', 'show_street_level']);
});
test('a link replays the sender’s view: its street-level step never writes the recipient’s preferences', async () => {
const { calls, run, options } = recorder();
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls.length, 1);
assert.deepEqual(options, [
['show_street_level', { origin: 'programmatic' }],
]);
});
test('a start link whose restore a newer link superseded opens nothing', async () => {
const { calls, run } = recorder();
installViews({
shell: {
initialRestorePromise: Promise.resolve({ status: 'superseded' }),
_navigationGeneration: 4,
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [], 'start link A does not pull the camera from B');
});
test('a link pasted into the open tab opens or arms its photos once its restore settles', async () => {
const { calls, run, options } = recorder();
const windowRef = new EventTarget();
const location = { search: '', hash: '#v=2&lat=1&lon=2' };
const shell = {
initialRestorePromise: Promise.resolve(),
_navigationGeneration: 1,
};
const remove = installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location,
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [], 'the start link has no photos');
const settled = (status) =>
windowRef.dispatchEvent(
Object.assign(new Event('gev:share-navigation-restore-settled'), {
detail: { status },
}),
);
location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950';
shell._navigationGeneration = 2; // the restore's own flight
settled('failed');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [], 'a failed restore opens nothing');
settled('settled');
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
[
'show_street_level',
{
latitude: 48.8874,
longitude: 2.3495,
provider: 'mapillary',
fly: true,
autoOpen: false,
},
],
]);
assert.deepEqual(options.at(-1), [
'show_street_level',
{ origin: 'programmatic' },
]);
remove();
location.hash = '#v=2&lat=5&lon=6&sl=mapillary,1.00000,2.00000';
settled('settled');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls.length, 1, 'removed with the install');
});
test('a view whose photos fail closes the photo an earlier view opened', async () => {
const closed = [];
const dataManager = {
...fakeLayers(new Set()),
layers: new Map([
['street-level', { module: { closeViewer: () => closed.push(true) } }],
]),
};
const { run } = recorder({
show_street_level: () => ({
ok: false,
stage: 'key',
error: 'needs its key',
}),
});
const view = createView({
camera: { lat: 1, lon: 2, altitude_m: 500 },
street_level: { provider: 'google' },
});
const viewer = fakeViewer();
await applyView(view, { viewer, dataManager, run });
assert.equal(closed.length, 1, 'closed before the view flies away');
assert.equal(viewer.flights.length, 1);
const opened = recorder({ show_street_level: () => ({ ok: true }) });
await applyView(view, { viewer, dataManager, run: opened.run });
assert.equal(closed.length, 1, 'an opened photo stays');
});
+2 -1
View File
@@ -223,7 +223,8 @@ export function createApplicationTools({
shell: styleManager,
viewer,
dataManager,
run: (name, args) => voiceCommands.runner(name, args, { signal }),
run: (name, args, options = {}) =>
voiceCommands.runner(name, args, { ...options, signal }),
signal,
}),
);
+18 -5
View File
@@ -668,7 +668,10 @@ export function createStreetLevelLayer({
* providers that can (a geocoded landmark is rarely within 50 m of a
* street); clicks keep the default. A caller's `signal` retires the
* lookup: nothing opens after it fires. Providers without
* `nearestImage` are skipped.
* `nearestImage` are skipped. `armed: false` (a request of its own, such
* as the assistant's) neither uses nor drops a link's armed point. Why
* nothing opened is left in `street.nearestOutcome`: 'empty' (no imagery
* in reach), 'sky' (the screen centre is not ground), or null (an error).
*/
async openNearest(
point,
@@ -678,18 +681,25 @@ export function createStreetLevelLayer({
aim = 'view',
radiusM = null,
signal: callerSignal = null,
armed: useArmed = true,
} = {},
) {
if (callerSignal?.aborted) return false;
state.street.nearestOutcome = null;
// An OPEN press after a link armed a lookup opens what the link asked;
// any other lookup is a new request and drops the armed one.
const armed = state.street.armed;
state.street.armed = null;
const armed = useArmed ? state.street.armed : null;
if (useArmed) state.street.armed = null;
if (!point && aim === 'screen-centre' && armed) {
// The button promises the centre of the view: the link's spot only
// while the view still shows it.
// while the view still shows it, or when the view cannot be picked.
// Sky refuses below, as any OPEN press does.
const centre = armed.point ? screenCentreGround() : null;
if (!centre || metresBetween(centre, armed.point) <= ARMED_REACH_M) {
if (!armed.point) radiusM ??= armed.radiusM;
else if (
centre === undefined ||
(centre && metresBetween(centre, armed.point) <= ARMED_REACH_M)
) {
point = armed.point;
radiusM ??= armed.radiusM;
}
@@ -704,6 +714,7 @@ export function createStreetLevelLayer({
abortNearest();
state.street.loading = false;
state.street.error = AIM_AT_GROUND_MESSAGE;
state.street.nearestOutcome = 'sky';
notify();
return false;
}
@@ -779,6 +790,8 @@ export function createStreetLevelLayer({
);
// A provider's own refusal is English data that the panel translates;
// the core's refusal is built here, for the panel only, in the language.
if (state.enabled && stillOn && !lastError)
state.street.nearestOutcome = 'empty';
if (state.enabled && stillOn)
state.street.error =
lastError?.message ||
+112
View File
@@ -1942,3 +1942,115 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async (
const view = presentStreetLevelPanel(layer.getUIState());
assert.match(view.meta, /Google Street View needs the Google 3D map/);
});
test('OPEN aimed at the sky after a link armed opens nothing: never the far link point', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point) => {
lookups.push(point);
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
// Every pick misses the ground: the screen centre is sky.
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: () => undefined,
});
viewer.globe = { pick: () => undefined };
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 });
assert.equal(
await layer.openNearest(undefined, { aim: 'screen-centre' }),
false,
);
assert.deepEqual(lookups, [], 'no paid Paris lookup from another city’s sky');
const street = layer.getUIState().street;
assert.equal(street.nearestOutcome, 'sky');
assert.match(street.error, /Aim the middle of the view/);
});
test('a request of its own (armed: false) neither uses nor drops the link’s armed point', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point, options) => {
lookups.push({ point, radiusM: options?.radiusM ?? null });
return null;
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: (at) => (at.x === 500 && at.y === 300 ? centre : undefined),
});
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
const layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
// About 120 m away: an OPEN press would take the link's spot.
layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 });
await layer.openNearest(undefined, { aim: 'screen-centre', armed: false });
const asked = lookups.at(-1);
assert.ok(Math.abs(asked.point.lat - 38.5799) < 1e-9, 'the screen centre');
assert.equal(asked.radiusM, null, 'its own radius');
assert.equal(layer.getUIState().street.nearestOutcome, 'empty');
assert.deepEqual(
layer.getUIState().street.armed?.point,
{ lat: 38.5808, lon: -121.494 },
'still armed for the OPEN press',
);
});
test('a failing source is not reported as missing imagery', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const provider = fakeProvider({
nearestImage: async () => {
throw new Error('Mapillary graph HTTP 500');
},
});
const saved = globalThis.document;
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
globalThis.document = Object.assign(new EventTarget(), {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = create({ providers: [provider] });
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
layer.destroy();
globalThis.document = saved;
});
assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), false);
const street = layer.getUIState().street;
assert.equal(street.nearestOutcome, null);
assert.equal(street.error, 'Mapillary graph HTTP 500');
});
@@ -82,6 +82,12 @@ export const SEQUENCE_IMAGE_FIELDS =
* the imagery filter then choose among the spot's photos, not a few of them.
*/
export const NEAREST_LIMIT = 100;
/**
* A nearest-image search that has not answered (headers and body) in this
* long is given up: a link or a posted view waits on it, and so does every
* view queued behind that one.
*/
export const NEAREST_TIMEOUT_MS = 12_000;
/** Fields a nearest-image search needs: where each is, and the filter's. */
export const NEAREST_IMAGE_FIELDS =
'id,captured_at,geometry,computed_geometry,is_pano';
@@ -3,6 +3,7 @@ import {
MAPILLARY_GRAPH_HOST,
NEAREST_IMAGE_FIELDS,
NEAREST_LIMIT,
NEAREST_TIMEOUT_MS,
SEQUENCE_IMAGE_FIELDS,
SEQUENCE_IMAGES_LIMIT,
} from './policy.js';
@@ -69,7 +70,47 @@ export function createMapillarySource({
...endpoints,
};
async function graph(path, params = {}, { signal } = {}) {
async function graph(path, params = {}, { signal, timeoutMs = 0 } = {}) {
if (!timeoutMs) return graphOnce(path, params, { signal });
// The whole answer, body included, within `timeoutMs`; the caller's
// abort still wins.
if (signal?.aborted) throw signal.reason;
const controller = new AbortController();
const onAbort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(
() =>
controller.abort(
new MapillarySourceError('Mapillary did not answer in time', {
status: 504,
}),
),
timeoutMs,
);
try {
// A body that never ends is given up too, whatever the fetch does.
const gaveUp = new Promise((_, reject) =>
controller.signal.addEventListener(
'abort',
() => reject(controller.signal.reason),
{ once: true },
),
);
return await Promise.race([
graphOnce(path, params, { signal: controller.signal }),
gaveUp,
]);
} catch (error) {
if (signal?.aborted) throw signal.reason;
if (controller.signal.aborted) throw controller.signal.reason;
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
async function graphOnce(path, params, { signal }) {
if (graphBase) {
const search = new URLSearchParams(params);
const response = await fetchImpl(`${graphBase}${path}?${search}`, {
@@ -165,7 +206,7 @@ export function createMapillarySource({
limit: String(Math.min(100, Math.max(1, limit))),
fields: NEAREST_IMAGE_FIELDS,
},
{ signal },
{ signal, timeoutMs: NEAREST_TIMEOUT_MS },
);
return Array.isArray(payload?.data) ? payload.data : [];
},
@@ -179,3 +179,44 @@ test('Graph endpoint errors keep their status', async () => {
(error) => error.status === 503 && error.keyRequired === true,
);
});
test('a nearest-image search that never answers is given up, body included', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { NEAREST_TIMEOUT_MS } = await import('./policy.js');
// Headers arrive; the body never ends, and the fetch ignores its signal.
const { fetchImpl } = fakeFetch(() => ({
ok: true,
status: 200,
json: () => new Promise(() => {}),
}));
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const pending = source.nearestImages({ lat: 1, lon: 2 });
const outcome = pending.then(
() => 'answered',
(error) => error,
);
await Promise.resolve();
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
const error = await outcome;
assert.ok(error instanceof MapillarySourceError, String(error));
assert.equal(error.status, 504);
assert.equal(error.keyRequired, false, 'not a key problem');
});
test('the caller’s abort still ends a nearest-image search before its deadline', async () => {
const { fetchImpl } = fakeFetch(
(_url, init) =>
new Promise((_, reject) =>
init.signal.addEventListener('abort', () => reject(init.signal.reason)),
),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const controller = new AbortController();
const pending = source.nearestImages(
{ lat: 1, lon: 2 },
{ signal: controller.signal },
);
const reason = new Error('the turn moved on');
controller.abort(reason);
await assert.rejects(pending, (error) => error === reason);
});
+2
View File
@@ -19,6 +19,8 @@ export function freshStreet() {
capturedAt: null,
/** 'day', or 'month' for providers that date images by month (Google). */
capturedAtPrecision: 'day',
/** Why the last nearest lookup opened nothing: 'empty', 'sky' or null. */
nearestOutcome: null,
/** A place name for the caption (Google's address), or null. */
title: null,
/** Whether the open viewer has FIT/FILL render modes. */
+9
View File
@@ -123,6 +123,15 @@ test('embed mode shows an open photo over the globe, filling the overlay', () =>
assert.ok(overlay, 'embed styles the viewer it moves over the globe');
for (const rule of ['position: fixed', 'inset: 12px', 'z-index: 1000'])
assert.ok(overlay[1].includes(rule), rule);
// Cockpit, recording and scene playback own the screen: the photo hides.
for (const mode of ['cockpit-mode', 'recording-mode', 'scene-playback-mode'])
assert.match(
css,
new RegExp(
`body\\.ui-embed\\.${mode} \\.sl-viewer-wrap-embed[^{]*\\{[^}]*display: none`,
),
mode,
);
// The photo takes the overlay's height, as it does full screen.
assert.match(
css,
+2
View File
@@ -1222,6 +1222,8 @@ export class StyleManager extends ShellFacade {
revealPanel(panelId) {
const sheet = sheetForPanelId(panelId);
if (sheet) this._mobileShell?.setSheet(sheet);
// The sheet opens at its top; a panel lower in the rail is scrolled to.
document.getElementById(panelId)?.scrollIntoView?.({ block: 'nearest' });
}
/**
+24
View File
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { StyleManager } from './applicationShell.js';
test('revealing a panel opens its phone sheet and scrolls the panel into view', () => {
const sheets = [];
const scrolls = [];
const panel = { scrollIntoView: (options) => scrolls.push(options) };
const saved = globalThis.document;
globalThis.document = {
getElementById: (id) => (id === 'street-level-panel' ? panel : null),
};
try {
StyleManager.prototype.revealPanel.call(
{ _mobileShell: { setSheet: (sheet) => sheets.push(sheet) } },
'street-level-panel',
);
} finally {
globalThis.document = saved;
}
assert.deepEqual(sheets, ['cameras']);
// The panel sits below the sheet's first screen at 375×812.
assert.deepEqual(scrolls, [{ block: 'nearest' }]);
});
+9 -1
View File
@@ -285,8 +285,16 @@ export class StreetLevelControls {
this._modesObserver = null;
return;
}
// Embed mode keeps Clean View on for its globe while the photo is lifted
// over it: there only recording and the cockpit end EXPAND.
const leaveIfHiding = () => {
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
if (
PANEL_HIDING_MODES.some(
(mode) =>
body.classList.contains(mode) &&
!(mode === 'ui-clean-view' && isEmbedPage()),
)
)
this._exitExpanded();
};
if (!this._modesObserver && typeof MutationObserver === 'function') {
+30
View File
@@ -622,6 +622,36 @@ for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
}
}));
test('in embed mode, which keeps Clean View on for its globe, EXPAND stays; the cockpit still ends it', () =>
withDom(async (dom) => {
const saved = globalThis.MutationObserver;
const watch = fakeMutationObserver();
globalThis.MutationObserver = watch.FakeObserver;
try {
dom.document.body.classList.add('ui-embed');
dom.document.body.classList.add('ui-clean-view');
const { layer, controls } = await stubPanel(
dom,
uiState({ open: false }),
);
await layer.publish(uiState({ open: true }));
dom.document.querySelector('#sl-viewer-expand').click();
await settle();
await settle();
watch.fire();
await settle();
assert.equal(controls.isViewerExpanded(), true, 'EXPAND stays open');
dom.document.body.classList.toggle('cockpit-mode', true);
watch.fire();
await settle();
await settle();
assert.equal(controls.isViewerExpanded(), false);
controls.destroy();
} finally {
globalThis.MutationObserver = saved;
}
}));
/* ── Restores and keyboard rules ───────────────────────────────────────── */
test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () =>
+8
View File
@@ -22,6 +22,14 @@ body.ui-embed .sl-viewer-wrap-embed {
z-index: 1000;
}
/* Cockpit, recording and scene playback own the screen, as they hide the
panel elsewhere: the lifted photo steps out of their way. */
body.ui-embed.cockpit-mode .sl-viewer-wrap-embed,
body.ui-embed.recording-mode .sl-viewer-wrap-embed,
body.ui-embed.scene-playback-mode .sl-viewer-wrap-embed {
display: none;
}
body.ui-embed .sl-viewer-wrap-embed .sl-viewer-stage,
body.ui-embed .sl-viewer-wrap-embed .sl-viewer {
flex: 1 1 auto;
+45 -5
View File
@@ -465,7 +465,7 @@ export function createGevActionRunner({
const pending = dataManager.getAll?.().find((l) => l.id === layerId);
reportProgress('layer', pending?.name || layerId);
}
const changeOptions = { origin: 'voice' };
const changeOptions = { origin: actionIntentOrigin(runOptions) };
if (runOptions.signal) changeOptions.signal = runOptions.signal;
let changed = false;
let changeError = null;
@@ -3446,6 +3446,18 @@ function normalizeStyle(value) {
return null;
}
/** The application panel that holds the street-level photo. */
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
/**
* The layer-state origin of an action's changes. The assistant's are the
* user's own choices and persist; a link replays someone else's view
* (`origin: 'programmatic'`) and never writes the recipient's preferences.
*/
function actionIntentOrigin(runOptions) {
return runOptions?.origin === 'programmatic' ? 'programmatic' : 'voice';
}
/**
* "Show me <place> on Street View / on Mapillary": fly to the place, make sure
* the imagery source's map is up, light that source in Street Level, turn the
@@ -3482,8 +3494,10 @@ export async function showStreetLevel(
...(provider ? { provider } : {}),
...(providerName ? { providerName } : {}),
...(label ? { label } : {}),
...(atViewCentre ? { atViewCentre } : {}),
...extra,
});
let atViewCentre = false;
const query = String(args.query || '').trim();
const latitude = Number(args.latitude);
const longitude = Number(args.longitude);
@@ -3540,7 +3554,7 @@ export async function showStreetLevel(
(entity ? String(entity.label || entity.id) : '') ||
(hasCoordinates ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : '') ||
'the view centre';
const atViewCentre = !query && !hasCoordinates && !entity;
atViewCentre = !query && !hasCoordinates && !entity;
let point = null;
let location = null;
if (args.fly === false || atViewCentre) {
@@ -3564,7 +3578,8 @@ export async function showStreetLevel(
point = { lat: place.lat, lon: place.lng };
label = place.label || query;
}
// No place at all: the layer looks under the view centre.
// No place at all: the layer looks at the ground under the screen
// centre, as the OPEN button does.
} else {
location = await runGevAction(
'fly_to_location',
@@ -3646,7 +3661,7 @@ export async function showStreetLevel(
if (typeof dataManager.setLayerParams === 'function')
accepted =
dataManager.setLayerParams(STREET_LEVEL_LAYER_ID, switches, {
origin: 'voice',
origin: actionIntentOrigin(runOptions),
}) !== false;
else layerModule.setProviderEnabled?.(provider, true);
if (!accepted)
@@ -3712,6 +3727,9 @@ export async function showStreetLevel(
const opened = await layerModule.openNearest(point, {
providerIds: [provider],
frame: false,
// Under the screen centre, not the middle of a tilted view's footprint;
// this request is the user's own, not a link's armed one.
...(point ? {} : { aim: 'screen-centre', armed: false }),
...(runOptions.signal ? { signal: runOptions.signal } : {}),
...(snapshot.groundClick === true
? { radiusM: STREET_LEVEL_WIDE_RADIUS_M }
@@ -3744,6 +3762,19 @@ export async function showStreetLevel(
street.error || 'Street Level changed while it looked for imagery',
{ cancelled: true, location },
);
// The screen centre is sky: nothing was looked up.
if (street.nearestOutcome === 'sky')
return fail('imagery', street.error || 'Aim the view at a street', {
location,
aim: 'sky',
});
// The source or the viewer failed: an error, not missing coverage.
if (street.nearestOutcome !== 'empty')
return fail(
'imagery',
street.error || `${providerName} imagery could not be opened`,
{ location, imageryError: true },
);
// An imagery filter the source honours can hide every photo here; say
// so, or the answer reads as "no coverage" when one switch would do.
const filter = activeImageryFilter(layerModule.getUIState?.());
@@ -3751,9 +3782,17 @@ export async function showStreetLevel(
return fail(
'imagery',
filter ? `${reason} with the ${filter.label} on` : reason,
{ location, ...(filter ? { filter } : {}) },
{ location, noImagery: true, ...(filter ? { filter } : {}) },
);
}
// Every explicit request shows its photo, also one replacing a photo whose
// panel or phone sheet the user had closed. Embed mode lifts the photo.
if (!globalThis.document?.body?.classList?.contains('ui-embed')) {
styleManager?.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
persist: false,
});
styleManager?.revealPanel?.(STREET_LEVEL_PANEL_ID);
}
return {
ok: true,
action,
@@ -3767,6 +3806,7 @@ export async function showStreetLevel(
position: street.position ?? null,
externalUrl: street.externalUrl ?? null,
flew: location !== null,
...(atViewCentre ? { atViewCentre } : {}),
...(location ? { location: location.label } : {}),
};
}
+80 -2
View File
@@ -5715,8 +5715,13 @@ function createStreetLevelHarness({
duringLookup = null,
keyRejectedAtStart = false,
lightFails = false,
imageryError = null,
sky = false,
} = {}) {
const order = [];
const lookups = [];
const origins = [];
const revealed = [];
const { viewer, styleManager } = createVoiceNavigationHarness();
let completeFlight = null;
viewer.camera.lookAt = () => {};
@@ -5776,6 +5781,7 @@ function createStreetLevelHarness({
order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`);
},
async openNearest(point, options) {
lookups.push({ point, ...options });
order.push(`open:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${options.frame}${point ? '' : ':view-centre'}`);
if (options.signal?.aborted) return false;
if (duringLookup) {
@@ -5790,7 +5796,13 @@ function createStreetLevelHarness({
return false;
}
}
if (imageryError) {
street.error = imageryError;
street.nearestOutcome = null;
return false;
}
if (!nearest) {
street.nearestOutcome = options.aim === 'screen-centre' && sky ? 'sky' : 'empty';
street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`;
return false;
}
@@ -5805,12 +5817,14 @@ function createStreetLevelHarness({
isEnabled: () => enabled,
getLayerParams: () => ({ ...params }),
setLayerParams(id, next, { origin }) {
origins.push(origin);
order.push(`params:${Object.entries(next).filter(([, on]) => on).map(([k]) => k).join('+')}:${origin}`);
if (paramsAccepted === false) return false;
if (!lightFails) params = { ...params, ...next };
return true;
},
async setEnabled(id) {
async setEnabled(id, _on, options) {
origins.push(options?.origin);
order.push(`enable:${id}`);
if (enableResult === false) return false;
enabled = true;
@@ -5818,8 +5832,10 @@ function createStreetLevelHarness({
},
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }],
};
styleManager.setPanelCollapsed = (id, collapsed) => revealed.push(`collapse:${id}:${collapsed}`);
styleManager.revealPanel = (id) => revealed.push(`reveal:${id}`);
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
return { runner, order, finishFlight: () => completeFlight?.(), layer, dataManager };
return { runner, order, finishFlight: () => completeFlight?.(), layer, dataManager, lookups, origins, revealed };
}
/** A loaded CCTV camera the entity branch can locate. */
@@ -6403,3 +6419,65 @@ test('show_street_level opens over the clean embedded globe but respects its oth
globalThis.document = saved;
}
});
test('show_street_level from a link keeps the recipient’s preferences: its switches are not the user’s', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const link = createStreetLevelHarness();
const linked = await link.runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'mapillary', fly: false },
{ origin: 'programmatic' },
);
assert.equal(linked.ok, true, linked.error);
assert.deepEqual(link.origins, ['programmatic', 'programmatic'], 'source switch and layer on');
const asked = createStreetLevelHarness();
await asked.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
assert.deepEqual(asked.origins, ['voice', 'voice'], 'the assistant’s request is the user’s choice');
});
test('show_street_level with no place looks under the screen centre, not a link’s armed point', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const here = createStreetLevelHarness({ enabled: true });
const result = await here.runner('show_street_level', { provider: 'mapillary' });
assert.equal(result.ok, true, result.error);
assert.equal(result.atViewCentre, true);
assert.equal(here.lookups[0].point, null);
assert.equal(here.lookups[0].aim, 'screen-centre');
assert.equal(here.lookups[0].armed, false);
const placed = createStreetLevelHarness({ enabled: true });
await placed.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
assert.equal(placed.lookups[0].aim, undefined, 'a place is looked up where it is');
const sky = createStreetLevelHarness({ enabled: true, nearest: null, sky: true });
const refused = await sky.runner('show_street_level', { provider: 'mapillary' });
assert.deepEqual([refused.ok, refused.stage, refused.aim], [false, 'imagery', 'sky']);
});
test('show_street_level tells missing imagery from a failing source', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const empty = await createStreetLevelHarness({ enabled: true, nearest: null }).runner('show_street_level', {
latitude: 1, longitude: 2, provider: 'mapillary', fly: false,
});
assert.deepEqual([empty.stage, empty.noImagery, empty.imageryError], ['imagery', true, undefined]);
const broken = await createStreetLevelHarness({ enabled: true, imageryError: 'Mapillary graph HTTP 500' }).runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'mapillary', fly: false },
);
assert.deepEqual([broken.stage, broken.noImagery, broken.imageryError], ['imagery', undefined, true]);
assert.equal(broken.error, 'Mapillary graph HTTP 500');
});
test('show_street_level shows every photo it opens, also over a panel the user had closed', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const harness = createStreetLevelHarness({ enabled: true });
await harness.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
await harness.runner('show_street_level', { latitude: 3, longitude: 4, provider: 'mapillary', fly: false });
assert.deepEqual(harness.revealed, [
'collapse:street-level-panel:false',
'reveal:street-level-panel',
'collapse:street-level-panel:false',
'reveal:street-level-panel',
]);
const missing = createStreetLevelHarness({ enabled: true, nearest: null });
await missing.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
assert.deepEqual(missing.revealed, [], 'nothing opened, nothing revealed');
});
+13 -4
View File
@@ -626,7 +626,11 @@ function showStreetLevel(result) {
const shownSource = result?.providerName
? spokenLabel(result.providerName)
: t('Street Level');
const shown = spokenLabel(localizePlaceName(result?.label || '')) || place;
// The view centre is a phrase, not a place name: the card says it in the
// interface language.
const shown = result?.atViewCentre
? t('the view centre')
: spokenLabel(localizePlaceName(result?.label || '')) || place;
if (result?.cancelled) return null;
if (!result?.ok) {
let say;
@@ -640,10 +644,15 @@ function showStreetLevel(result) {
else if (result?.stage === 'layer') say = "Couldn't turn on Street Level.";
else if (result?.stage === 'view')
say = 'Street-level photos are hidden in this view. Leave it first.';
else if (result?.stage === 'imagery' && result?.aim === 'sky')
say = 'Aim the middle of the view at a street first.';
else if (result?.stage === 'imagery' && result?.filter?.label)
say = `No ${source} imagery near ${place} with the ${result.filter.label} on.`;
else if (result?.stage === 'imagery' && result?.noImagery === true)
say = `No ${source} imagery near ${place}.`;
// A service or viewer failure is not missing coverage.
else if (result?.stage === 'imagery')
say = result?.filter?.label
? `No ${source} imagery near ${place} with the ${result.filter.label} on.`
: `No ${source} imagery near ${place}.`;
say = `${source} could not open imagery near ${place}.`;
else say = `Couldn't open ${source}.`;
return {
say,
+36 -1
View File
@@ -536,6 +536,22 @@ test('labels are bounded and cleaned before they are spoken', () => {
);
});
test('the street-level card says "the view centre" in the interface language', () => {
setLocale('de');
try {
const out = envelope('show_street_level', {
ok: true,
providerName: 'Mapillary',
label: 'the view centre',
atViewCentre: true,
});
assert.equal(out.display.lines[0], 'die Bildmitte');
assert.match(out.say, /the view centre/, 'speech stays English');
} finally {
setLocale('en');
}
});
test('street-level speech names the source and the place, and says which step failed', () => {
const ok = envelope('show_street_level', {
ok: true,
@@ -556,7 +572,8 @@ test('street-level speech names the source and the place, and says which step fa
map: "Google Street View needs the Google 3D map, which isn't available.",
key: 'Google Street View needs its key.',
layer: "Couldn't turn on Street Level.",
imagery: 'No Google Street View imagery near Nowhere.',
// A service or viewer failure, not missing coverage.
imagery: 'Google Street View could not open imagery near Nowhere.',
};
for (const [stage, say] of Object.entries(stages)) {
const out = envelope('show_street_level', {
@@ -570,6 +587,24 @@ test('street-level speech names the source and the place, and says which step fa
assertSpeakable(out.say);
assert.deepEqual(out.display.lines, ['detail']);
}
const empty = envelope('show_street_level', {
ok: false,
stage: 'imagery',
noImagery: true,
label: 'Nowhere',
providerName: 'Mapillary',
});
assert.equal(empty.say, 'No Mapillary imagery near Nowhere.');
const sky = envelope('show_street_level', {
ok: false,
stage: 'imagery',
aim: 'sky',
atViewCentre: true,
label: 'the view centre',
providerName: 'Mapillary',
});
assert.equal(sky.say, 'Aim the middle of the view at a street first.');
assertSpeakable(sky.say);
const armed = envelope('show_street_level', {
ok: true,
armed: true,