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

+1 -1
View File
@@ -131,7 +131,7 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp`
- **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller. - **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller.
- **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`. - **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`.
- **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed. - **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed.
- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,<lat>,<lon>` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image. - **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,<lat>,<lon>` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The OPEN press uses the link's point only while the ground under the screen centre is within 1 km of it; with the sky in the middle it opens nothing. The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link pasted into an open tab follows the same rule, and a link's source switch and layer are never saved as the recipient's own preferences. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image.
## Scope & expectations ## Scope & expectations
+8 -1
View File
@@ -21,5 +21,12 @@
honours is on, the answer names the filter. Cancelled lookups clear their honours is on, the answer names the filter. Cancelled lookups clear their
loading state over an existing photo and release their caller abort listeners. loading state over an existing photo and release their caller abort listeners.
Embedded photos show loading errors inside the overlay, and the OPEN prompt Embedded photos show loading errors inside the overlay, and the OPEN prompt
uses the recipient's selected language (Said Turksever, @asturksever, uses the recipient's selected language. A link's photos never save its
layers as the recipient's preferences, and a link pasted into an open tab
opens or arms them too. With no place named, the action looks under the
screen centre as OPEN does; an armed OPEN aimed at the sky opens nothing; a
failing source is reported as an error, not as missing imagery; and
Mapillary's nearest search gives up after 12 seconds. On a phone the panel
scrolls into view, and in embed mode EXPAND stays open while cockpit,
recording and scene playback hide the photo (Said Turksever, @asturksever,
#1113). #1113).
+25 -10
View File
@@ -4483,26 +4483,35 @@ app.
(voice and typed; `src/voice/gevActions.js`) is one call: it checks the (voice and typed; `src/voice/gevActions.js`) is one call: it checks the
source has its key (a keyless source explains itself and changes nothing, as source has its key (a keyless source explains itself and changes nothing, as
a chip does), flies to the place at street framing and waits for arrival (a a chip does), flies to the place at street framing and waits for arrival (a
name, coordinates, a pointed spot, a numbered item, or nothing for the view name, coordinates, a pointed spot, a numbered item, or nothing for the
centre), switches to the map the source needs (Google Street View: Google 3D), ground under the screen centre, as OPEN uses: `aim: 'screen-centre'` with
`armed: false`, so sky refuses and a link's armed point is neither used nor
dropped), switches to the map the source needs (Google Street View: Google 3D),
lights that source through `setLayerParams` with origin `voice` (one source at lights that source through `setLayerParams` with origin `voice` (one source at
a time, kept in the share link like a chip click), turns the layer on and a time, kept in the share link like a chip click; a link runs the action with
origin `programmatic`, for the source switch and the layer, so it never writes
the recipient's saved layer state), turns the layer on and
calls `openNearest`. `provider` is `google` for "Street View", `mapillary`, calls `openNearest`. `provider` is `google` for "Street View", `mapillary`,
`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `view`, `map`, `kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `view`, `map`,
`provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view, `provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view,
recording or scene playback hides the panel, checked after the flight and recording or scene playback hides the panel, checked after the flight and
before anything switches or loads; embed mode exempts its own clean-view before anything switches or loads; embed mode exempts its own clean-view
class because the photo is lifted above the globe; an empty `imagery` result names a class because the photo is lifted above the globe; an empty `imagery` result names a
narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a narrowing 360°/flat or SINCE filter the source honours, as `filter`;
`noImagery` marks an empty result, `imageryError` a source or viewer failure
and `aim: 'sky'` a sky centre, from the layer's `street.nearestOutcome`) and a
newer turn retires the lookup newer turn retires the lookup
(`openNearest`'s `signal` option), so nothing opens after the user moved on. (`openNearest`'s `signal` option), so nothing opens after the user moved on.
For a source without a coverage layer the lookup widens to 250 m For a source without a coverage layer the lookup widens to 250 m
(`NEAREST_RADIUS_MAX_M`; Google honours it, Mapillary's API caps at 50 m); (`NEAREST_RADIUS_MAX_M`; Google honours it, Mapillary's API caps at 50 m);
clicks keep 50 m. Mapillary's search asks for 100 photos, the API's most: it clicks keep 50 m. Mapillary's search gives up after 12 s, body included
(`NEAREST_TIMEOUT_MS`), and asks for 100 photos, the API's most: it
returns the radius's photos in no particular order and cuts them at the limit returns the radius's photos in no particular order and cuts them at the limit
first, so the distance sort and the imagery filter run over the spot's photos. first, so the distance sort and the imagery filter run over the spot's photos.
On a phone the panel sits in the Cameras tab sheet: a photo that opens, and a On a phone the panel sits in the Cameras tab sheet: a photo that opens, and a
link that arms, open that sheet (`revealPanel`). The layer exposes `requiredMapStack(id)` and link that arms, open that sheet and scroll the panel into view
(`revealPanel`); every photo the action opens reveals the panel again, also
one the user had collapsed. The layer exposes `requiredMapStack(id)` and
`providerStatus(id)` for callers that must decide before the panel lists a `providerStatus(id)` for callers that must decide before the panel lists a
source, and `coverageLines(id)` (the lines a provider draws now, current and source, and `coverageLines(id)` (the lines a provider draws now, current and
stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate, stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate,
@@ -4513,10 +4522,15 @@ source and point, defaulting to where the camera looks; a `google` source
brings `map: photoreal` and refuses another map; not with `follow`), links as brings `map: photoreal` and refuses another map; not with `follow`), links as
`sl=<source>,<lat>,<lon>`; `show_in_gods_eye_view` accepts it, the globe panel `sl=<source>,<lat>,<lon>`; `show_in_gods_eye_view` accepts it, the globe panel
applies it through the action, and a plain link runs the action once restored applies it through the action, and a plain link runs the action once restored
(one shot: the rewritten hash drops `sl`). In embed mode the rail holding the (one shot: the rewritten hash drops `sl`). A link pasted into the open tab
runs it after its restore settles (`gev:share-navigation-restore-settled`),
and a start link whose restore a newer one superseded never runs. In embed mode the rail holding the
panel is hidden, so an open photo moves to an overlay on the globe 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 (`sl-viewer-wrap-embed`) and back when it closes; Esc closes it (in full
screen, Esc leaves full screen first). Image-loading failures render inside screen, Esc leaves full screen first). EXPAND stays open under embed mode's
own clean view; cockpit, recording and scene playback hide the overlay. A
posted view whose photo step fails closes the earlier photo before its
camera flies. Image-loading failures render inside
the embedded overlay, since the panel's error region is hidden with the rail. the embedded overlay, since the panel's error region is hidden with the rail.
A caller's abort is A caller's abort is
honoured through the viewer's mount and load, not just the lookup, and only honoured through the viewer's mount and load, not just the lookup, and only
@@ -4529,8 +4543,9 @@ link never loads a photo from a source that bills every load
(`billedPerImage` on the provider; Google): the action runs with (`billedPerImage` on the provider; Google): the action runs with
`autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's `autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's
point and radius, consumed only by the next OPEN press (`aim: point and radius, consumed only by the next OPEN press (`aim:
'screen-centre'`), and only while the view centre is within 1 km of the point 'screen-centre'`), and only while the ground under the screen centre is within
(`ARMED_REACH_M`; a user who moved on opens what the view shows); any other lookup, open, close, disable, its source 1 km of the point (`ARMED_REACH_M`; a user who moved on opens what the view
shows) or cannot be picked; a sky centre refuses; any other lookup, open, close, disable, its source
switched off or its map leaving drops it), opens the panel and a toast switched off or its map leaving drops it), opens the panel and a toast
points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline
embed) for a tool result issued in the last 5 minutes, and posted views, open embed) for a tool result issued in the last 5 minutes, and posted views, open
+74 -29
View File
@@ -198,6 +198,8 @@ function streetLevelArgs(streetLevel, { autoOpen = true } = {}) {
} }
const STREET_LEVEL_PANEL_ID = 'street-level-panel'; 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. */ /** The armed link's toast, naming the OPEN button as the panel shows it. */
function armedToast() { function armedToast() {
@@ -258,8 +260,12 @@ export async function applyView(
'show_street_level', 'show_street_level',
streetLevelArgs(view.street_level), streetLevelArgs(view.street_level),
); );
if (result?.ok !== true && !result?.location) if (result?.ok !== true) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); // 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; return steps;
} }
if (!view.follow) { if (!view.follow) {
@@ -317,13 +323,16 @@ export function installViews({
// The camera's owner at restore: anyone who navigates while the link's // 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 // steps wait (a gesture, a search, the assistant) takes it, and a passive
// link must not fly over them. // 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 generationAtRestore = null;
let queue = ready.then(() => { let startSuperseded = false;
let queue = ready.then((outcome) => {
generationAtRestore = shell._navigationGeneration; generationAtRestore = shell._navigationGeneration;
startSuperseded = outcome?.status === 'superseded';
}); });
const navigatedSinceRestore = () => const navigatedSince = (generation) =>
Number.isFinite(generationAtRestore) && Number.isFinite(generation) && shell._navigationGeneration !== generation;
shell._navigationGeneration !== generationAtRestore;
const linkStep = (work) => const linkStep = (work) =>
queue.then(async () => { queue.then(async () => {
if (signal?.aborted) return; 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 // 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). // the client's request (a host remounting the panel hands back old ones).
// A link that follows an entity keeps the camera on it. // A link that follows an entity keeps the camera on it.
const streetLevel = followFromParams(hashParams) const linkedStreetLevel = (params) =>
? null followFromParams(params) ? null : streetLevelFromParams(params);
: streetLevelFromParams(hashParams); const openLinkedStreetLevel = async (
if (streetLevel) streetLevel,
queue = linkStep(async () => { { generation, autoOpen = false },
if (navigatedSinceRestore()) return; ) => {
const result = await run( if (navigatedSince(generation)) return;
'show_street_level', // The link replays the sender's view: nothing it switches becomes the
streetLevelArgs(streetLevel, { autoOpen: isFreshPanelResult() }), // recipient's own preference.
); const result = await run(
// The restore window keeps the panel collapsed; OPEN must be in view. 'show_street_level',
// Embed mode hides the rail that holds it, so there the link only arms streetLevelArgs(streetLevel, { autoOpen }),
// (a posted view is the way to open a photo in a frame). { origin: 'programmatic' },
if (result?.armed === true && !isEmbedded(location)) { );
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, { // The restore window keeps the panel collapsed; OPEN must be in view.
explicit: true, // 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).
// On a phone the panel sits in a tab sheet: open it so OPEN shows. if (result?.armed === true && !isEmbedded(location)) {
shell.revealPanel?.(STREET_LEVEL_PANEL_ID); shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
shell._showToast?.(armedToast()); explicit: true,
} });
}); // On a phone the panel sits in a tab sheet: open it so OPEN shows.
if (!isEmbedded(location)) return () => {}; 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'); windowRef.document.body.classList.add('ui-embed');
shell.setCleanView?.(true); shell.setCleanView?.(true);
@@ -423,6 +467,7 @@ export function installViews({
}); });
return () => { return () => {
stopRendering(); stopRendering();
stopNavigatedRestores();
windowRef.removeEventListener('message', onMessage); windowRef.removeEventListener('message', onMessage);
}; };
} }
+128 -2
View File
@@ -32,14 +32,16 @@ const fakeLayers = (enabled) => ({
}); });
const recorder = (answers = {}) => { const recorder = (answers = {}) => {
const calls = []; const calls = [];
const run = async (name, args) => { const options = [];
const run = async (name, args, runOptions) => {
calls.push([name, args]); calls.push([name, args]);
options.push([name, runOptions]);
const answer = answers[name]; const answer = answers[name];
return typeof answer === 'function' return typeof answer === 'function'
? answer(calls) ? answer(calls)
: (answer ?? { ok: true }); : (answer ?? { ok: true });
}; };
return { calls, run }; return { calls, run, options };
}; };
test('embed mode is opt-in by query parameter', () => { 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)); await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(quiet, ['annotate_map', 'show_street_level']); 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, shell: styleManager,
viewer, viewer,
dataManager, dataManager,
run: (name, args) => voiceCommands.runner(name, args, { signal }), run: (name, args, options = {}) =>
voiceCommands.runner(name, args, { ...options, signal }),
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 * providers that can (a geocoded landmark is rarely within 50 m of a
* street); clicks keep the default. A caller's `signal` retires the * street); clicks keep the default. A caller's `signal` retires the
* lookup: nothing opens after it fires. Providers without * 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( async openNearest(
point, point,
@@ -678,18 +681,25 @@ export function createStreetLevelLayer({
aim = 'view', aim = 'view',
radiusM = null, radiusM = null,
signal: callerSignal = null, signal: callerSignal = null,
armed: useArmed = true,
} = {}, } = {},
) { ) {
if (callerSignal?.aborted) return false; if (callerSignal?.aborted) return false;
state.street.nearestOutcome = null;
// An OPEN press after a link armed a lookup opens what the link asked; // 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. // any other lookup is a new request and drops the armed one.
const armed = state.street.armed; const armed = useArmed ? state.street.armed : null;
state.street.armed = null; if (useArmed) state.street.armed = null;
if (!point && aim === 'screen-centre' && armed) { if (!point && aim === 'screen-centre' && armed) {
// The button promises the centre of the view: the link's spot only // 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; 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; point = armed.point;
radiusM ??= armed.radiusM; radiusM ??= armed.radiusM;
} }
@@ -704,6 +714,7 @@ export function createStreetLevelLayer({
abortNearest(); abortNearest();
state.street.loading = false; state.street.loading = false;
state.street.error = AIM_AT_GROUND_MESSAGE; state.street.error = AIM_AT_GROUND_MESSAGE;
state.street.nearestOutcome = 'sky';
notify(); notify();
return false; return false;
} }
@@ -779,6 +790,8 @@ export function createStreetLevelLayer({
); );
// A provider's own refusal is English data that the panel translates; // 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. // 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) if (state.enabled && stillOn)
state.street.error = state.street.error =
lastError?.message || 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()); const view = presentStreetLevelPanel(layer.getUIState());
assert.match(view.meta, /Google Street View needs the Google 3D map/); 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. * the imagery filter then choose among the spot's photos, not a few of them.
*/ */
export const NEAREST_LIMIT = 100; 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. */ /** Fields a nearest-image search needs: where each is, and the filter's. */
export const NEAREST_IMAGE_FIELDS = export const NEAREST_IMAGE_FIELDS =
'id,captured_at,geometry,computed_geometry,is_pano'; 'id,captured_at,geometry,computed_geometry,is_pano';
@@ -3,6 +3,7 @@ import {
MAPILLARY_GRAPH_HOST, MAPILLARY_GRAPH_HOST,
NEAREST_IMAGE_FIELDS, NEAREST_IMAGE_FIELDS,
NEAREST_LIMIT, NEAREST_LIMIT,
NEAREST_TIMEOUT_MS,
SEQUENCE_IMAGE_FIELDS, SEQUENCE_IMAGE_FIELDS,
SEQUENCE_IMAGES_LIMIT, SEQUENCE_IMAGES_LIMIT,
} from './policy.js'; } from './policy.js';
@@ -69,7 +70,47 @@ export function createMapillarySource({
...endpoints, ...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) { if (graphBase) {
const search = new URLSearchParams(params); const search = new URLSearchParams(params);
const response = await fetchImpl(`${graphBase}${path}?${search}`, { const response = await fetchImpl(`${graphBase}${path}?${search}`, {
@@ -165,7 +206,7 @@ export function createMapillarySource({
limit: String(Math.min(100, Math.max(1, limit))), limit: String(Math.min(100, Math.max(1, limit))),
fields: NEAREST_IMAGE_FIELDS, fields: NEAREST_IMAGE_FIELDS,
}, },
{ signal }, { signal, timeoutMs: NEAREST_TIMEOUT_MS },
); );
return Array.isArray(payload?.data) ? payload.data : []; 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, (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, capturedAt: null,
/** 'day', or 'month' for providers that date images by month (Google). */ /** 'day', or 'month' for providers that date images by month (Google). */
capturedAtPrecision: 'day', 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. */ /** A place name for the caption (Google's address), or null. */
title: null, title: null,
/** Whether the open viewer has FIT/FILL render modes. */ /** 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'); assert.ok(overlay, 'embed styles the viewer it moves over the globe');
for (const rule of ['position: fixed', 'inset: 12px', 'z-index: 1000']) for (const rule of ['position: fixed', 'inset: 12px', 'z-index: 1000'])
assert.ok(overlay[1].includes(rule), rule); 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. // The photo takes the overlay's height, as it does full screen.
assert.match( assert.match(
css, css,
+2
View File
@@ -1222,6 +1222,8 @@ export class StyleManager extends ShellFacade {
revealPanel(panelId) { revealPanel(panelId) {
const sheet = sheetForPanelId(panelId); const sheet = sheetForPanelId(panelId);
if (sheet) this._mobileShell?.setSheet(sheet); 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; this._modesObserver = null;
return; 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 = () => { 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(); this._exitExpanded();
}; };
if (!this._modesObserver && typeof MutationObserver === 'function') { 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 ───────────────────────────────────────── */ /* ── Restores and keyboard rules ───────────────────────────────────────── */
test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () => 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; 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-stage,
body.ui-embed .sl-viewer-wrap-embed .sl-viewer { body.ui-embed .sl-viewer-wrap-embed .sl-viewer {
flex: 1 1 auto; flex: 1 1 auto;
+45 -5
View File
@@ -465,7 +465,7 @@ export function createGevActionRunner({
const pending = dataManager.getAll?.().find((l) => l.id === layerId); const pending = dataManager.getAll?.().find((l) => l.id === layerId);
reportProgress('layer', pending?.name || layerId); reportProgress('layer', pending?.name || layerId);
} }
const changeOptions = { origin: 'voice' }; const changeOptions = { origin: actionIntentOrigin(runOptions) };
if (runOptions.signal) changeOptions.signal = runOptions.signal; if (runOptions.signal) changeOptions.signal = runOptions.signal;
let changed = false; let changed = false;
let changeError = null; let changeError = null;
@@ -3446,6 +3446,18 @@ function normalizeStyle(value) {
return null; 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 * "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 * 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 } : {}), ...(provider ? { provider } : {}),
...(providerName ? { providerName } : {}), ...(providerName ? { providerName } : {}),
...(label ? { label } : {}), ...(label ? { label } : {}),
...(atViewCentre ? { atViewCentre } : {}),
...extra, ...extra,
}); });
let atViewCentre = false;
const query = String(args.query || '').trim(); const query = String(args.query || '').trim();
const latitude = Number(args.latitude); const latitude = Number(args.latitude);
const longitude = Number(args.longitude); const longitude = Number(args.longitude);
@@ -3540,7 +3554,7 @@ export async function showStreetLevel(
(entity ? String(entity.label || entity.id) : '') || (entity ? String(entity.label || entity.id) : '') ||
(hasCoordinates ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : '') || (hasCoordinates ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : '') ||
'the view centre'; 'the view centre';
const atViewCentre = !query && !hasCoordinates && !entity; atViewCentre = !query && !hasCoordinates && !entity;
let point = null; let point = null;
let location = null; let location = null;
if (args.fly === false || atViewCentre) { if (args.fly === false || atViewCentre) {
@@ -3564,7 +3578,8 @@ export async function showStreetLevel(
point = { lat: place.lat, lon: place.lng }; point = { lat: place.lat, lon: place.lng };
label = place.label || query; 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 { } else {
location = await runGevAction( location = await runGevAction(
'fly_to_location', 'fly_to_location',
@@ -3646,7 +3661,7 @@ export async function showStreetLevel(
if (typeof dataManager.setLayerParams === 'function') if (typeof dataManager.setLayerParams === 'function')
accepted = accepted =
dataManager.setLayerParams(STREET_LEVEL_LAYER_ID, switches, { dataManager.setLayerParams(STREET_LEVEL_LAYER_ID, switches, {
origin: 'voice', origin: actionIntentOrigin(runOptions),
}) !== false; }) !== false;
else layerModule.setProviderEnabled?.(provider, true); else layerModule.setProviderEnabled?.(provider, true);
if (!accepted) if (!accepted)
@@ -3712,6 +3727,9 @@ export async function showStreetLevel(
const opened = await layerModule.openNearest(point, { const opened = await layerModule.openNearest(point, {
providerIds: [provider], providerIds: [provider],
frame: false, 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 } : {}), ...(runOptions.signal ? { signal: runOptions.signal } : {}),
...(snapshot.groundClick === true ...(snapshot.groundClick === true
? { radiusM: STREET_LEVEL_WIDE_RADIUS_M } ? { radiusM: STREET_LEVEL_WIDE_RADIUS_M }
@@ -3744,6 +3762,19 @@ export async function showStreetLevel(
street.error || 'Street Level changed while it looked for imagery', street.error || 'Street Level changed while it looked for imagery',
{ cancelled: true, location }, { 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 // 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. // so, or the answer reads as "no coverage" when one switch would do.
const filter = activeImageryFilter(layerModule.getUIState?.()); const filter = activeImageryFilter(layerModule.getUIState?.());
@@ -3751,9 +3782,17 @@ export async function showStreetLevel(
return fail( return fail(
'imagery', 'imagery',
filter ? `${reason} with the ${filter.label} on` : reason, 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 { return {
ok: true, ok: true,
action, action,
@@ -3767,6 +3806,7 @@ export async function showStreetLevel(
position: street.position ?? null, position: street.position ?? null,
externalUrl: street.externalUrl ?? null, externalUrl: street.externalUrl ?? null,
flew: location !== null, flew: location !== null,
...(atViewCentre ? { atViewCentre } : {}),
...(location ? { location: location.label } : {}), ...(location ? { location: location.label } : {}),
}; };
} }
+80 -2
View File
@@ -5715,8 +5715,13 @@ function createStreetLevelHarness({
duringLookup = null, duringLookup = null,
keyRejectedAtStart = false, keyRejectedAtStart = false,
lightFails = false, lightFails = false,
imageryError = null,
sky = false,
} = {}) { } = {}) {
const order = []; const order = [];
const lookups = [];
const origins = [];
const revealed = [];
const { viewer, styleManager } = createVoiceNavigationHarness(); const { viewer, styleManager } = createVoiceNavigationHarness();
let completeFlight = null; let completeFlight = null;
viewer.camera.lookAt = () => {}; 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'}`); order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`);
}, },
async openNearest(point, options) { async openNearest(point, options) {
lookups.push({ point, ...options });
order.push(`open:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${options.frame}${point ? '' : ':view-centre'}`); order.push(`open:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${options.frame}${point ? '' : ':view-centre'}`);
if (options.signal?.aborted) return false; if (options.signal?.aborted) return false;
if (duringLookup) { if (duringLookup) {
@@ -5790,7 +5796,13 @@ function createStreetLevelHarness({
return false; return false;
} }
} }
if (imageryError) {
street.error = imageryError;
street.nearestOutcome = null;
return false;
}
if (!nearest) { 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'}`; street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`;
return false; return false;
} }
@@ -5805,12 +5817,14 @@ function createStreetLevelHarness({
isEnabled: () => enabled, isEnabled: () => enabled,
getLayerParams: () => ({ ...params }), getLayerParams: () => ({ ...params }),
setLayerParams(id, next, { origin }) { setLayerParams(id, next, { origin }) {
origins.push(origin);
order.push(`params:${Object.entries(next).filter(([, on]) => on).map(([k]) => k).join('+')}:${origin}`); order.push(`params:${Object.entries(next).filter(([, on]) => on).map(([k]) => k).join('+')}:${origin}`);
if (paramsAccepted === false) return false; if (paramsAccepted === false) return false;
if (!lightFails) params = { ...params, ...next }; if (!lightFails) params = { ...params, ...next };
return true; return true;
}, },
async setEnabled(id) { async setEnabled(id, _on, options) {
origins.push(options?.origin);
order.push(`enable:${id}`); order.push(`enable:${id}`);
if (enableResult === false) return false; if (enableResult === false) return false;
enabled = true; enabled = true;
@@ -5818,8 +5832,10 @@ function createStreetLevelHarness({
}, },
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }], 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 }); 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. */ /** 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; 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 const shownSource = result?.providerName
? spokenLabel(result.providerName) ? spokenLabel(result.providerName)
: t('Street Level'); : 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?.cancelled) return null;
if (!result?.ok) { if (!result?.ok) {
let say; 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 === 'layer') say = "Couldn't turn on Street Level.";
else if (result?.stage === 'view') else if (result?.stage === 'view')
say = 'Street-level photos are hidden in this view. Leave it first.'; 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') else if (result?.stage === 'imagery')
say = result?.filter?.label say = `${source} could not open imagery near ${place}.`;
? `No ${source} imagery near ${place} with the ${result.filter.label} on.`
: `No ${source} imagery near ${place}.`;
else say = `Couldn't open ${source}.`; else say = `Couldn't open ${source}.`;
return { return {
say, 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', () => { test('street-level speech names the source and the place, and says which step failed', () => {
const ok = envelope('show_street_level', { const ok = envelope('show_street_level', {
ok: true, 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.", map: "Google Street View needs the Google 3D map, which isn't available.",
key: 'Google Street View needs its key.', key: 'Google Street View needs its key.',
layer: "Couldn't turn on Street Level.", 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)) { for (const [stage, say] of Object.entries(stages)) {
const out = envelope('show_street_level', { 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); assertSpeakable(out.say);
assert.deepEqual(out.display.lines, ['detail']); 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', { const armed = envelope('show_street_level', {
ok: true, ok: true,
armed: true, armed: true,