mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +00:00
fix(street-level): re-check after every await; close the old photo; fresh panel views
Address samehkhamis's review on #1113: - show_street_level checks the turn, the camera's owner (navigation stamp, re-taken where its own flight lands, or a press/wheel on the globe) and the hiding modes after the flight, the map switch, the layer switch-on and the lookup; a photo that opened after either is closed again. The hidden panel is also refused before the flight. - An open photo closes before a new request arms or looks up, so a place with no imagery shows its own error. - The panel posts every view with `fresh`; the inline app opens a billed photo only for a fresh one. - A passed-through view's street_level point moves with a new camera position; revealPanel does nothing above 720px, scrolling included. - Document that the app's own links do not carry `sl`.
This commit is contained in:
1 parent
14f7326dbf
commit
30e6cd39c7
15 files changed
+486
-57
No files matched your search
+1
-1
@@ -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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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, and so does any later result on the same mount issued more than five minutes ago; 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
|
||||
|
||||
|
||||
@@ -28,5 +28,9 @@
|
||||
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).
|
||||
recording and scene playback hide the photo. The action checks again after
|
||||
every wait: a navigation or a drag of the globe, or a mode that hides the
|
||||
panel, stops it before a photo loads for a place the user left or in a
|
||||
panel nobody sees, and a request that finds nothing closes the previous
|
||||
photo. The panel's later results open a billed photo only when they are
|
||||
fresh too (Said Turksever, @asturksever, #1113).
|
||||
+16
-4
@@ -4569,8 +4569,9 @@ 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.
|
||||
On a phone the panel sits in the Cameras tab sheet: a photo that opens, and a
|
||||
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
|
||||
(`revealPanel`, which does nothing above 720px, scrolling included); 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
|
||||
source, and `coverageLines(id)` (the lines a provider draws now, current and
|
||||
stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate,
|
||||
@@ -4597,7 +4598,16 @@ the lookup that still owns the panel may change its spinner or error. Cancelling
|
||||
a lookup clears its loading state even when an existing photo stays open;
|
||||
internally retired lookups also release the caller's abort listener. A
|
||||
passive link's photos stand down if anyone navigated (a gesture, a search,
|
||||
the assistant) while the link waited behind its own marks. A plain
|
||||
the assistant) while the link waited behind its own marks. The action itself
|
||||
checks again after every await (flight, map switch, layer on, lookup): a
|
||||
newer turn, a shell navigation stamp (`_navigationGeneration`, re-taken where
|
||||
its own flight lands) or a press or wheel on the globe stops the remaining
|
||||
stages (`superseded: true`), and cockpit, clean view, recording or scene
|
||||
playback entered meanwhile ends it at the `view` stage; a photo that opened
|
||||
after either is closed again. The hidden-panel check also runs before the
|
||||
flight, so a recording's camera never moves for it. A photo already open
|
||||
closes before the new request arms or looks up, so a place with no imagery
|
||||
shows its own error, not the previous place's photo. A plain
|
||||
link never loads a photo from a source that bills every load
|
||||
(`billedPerImage` on the provider; Google): the action runs with
|
||||
`autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's
|
||||
@@ -4610,7 +4620,9 @@ 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
|
||||
the photo: `show_in_gods_eye_view` stamps a street-level result `issued_at`,
|
||||
and a host that mounts the panel again later hands back the old result, which
|
||||
only arms; `?embed=1` on a published link only
|
||||
only arms. The panel checks every result, not just the first: each `gev:view`
|
||||
it posts carries `fresh`, and the inline app applies a view without it with
|
||||
`autoOpen: false` (a framing page's posted views still open); `?embed=1` on a published link only
|
||||
arms, without the panel nudge or toast (the rail is hidden in a frame). A link that follows an entity ignores `sl`.
|
||||
The look-at default applies below 20 km, at a pitch steeper than -5° and
|
||||
within 10 km ahead; otherwise the camera's own point is used. A view's
|
||||
|
||||
@@ -51,6 +51,16 @@ military aircraft or satellite. A selected vessel is not followed (the camera
|
||||
stays where the user puts it), so a link made then is a place view; vessel
|
||||
thing views open, but the app does not make them yet.
|
||||
|
||||
## Street-level photos (`sl`)
|
||||
|
||||
Views built by `show_in_gods_eye_view` or `src/view/index.js` can add
|
||||
`sl=<source>,<lat>,<lon>` to the long form. Once the link has restored, the
|
||||
app opens the nearest photo there, or, for a source that bills every load,
|
||||
arms the panel so OPEN loads it (see `docs/TOOLS.md`). The app's own links do
|
||||
not carry it: the address bar's long form drops `sl` when the camera next
|
||||
moves, and the short form has no field for it. A reload or the app's Copy
|
||||
Link keeps the lit source, the layer and the map, but not the photo request.
|
||||
|
||||
## Payload
|
||||
|
||||
The code is unpadded base64url (`A–Z a–z 0–9 - _`). The decoded bytes are:
|
||||
|
||||
+5
-2
@@ -120,8 +120,11 @@ photo at once; none of them bills per image. A link that follows an entity ignor
|
||||
Tools take a view as `VIEW_ARGUMENTS`: an `area` to frame from above, or a
|
||||
`camera`, plus `layers`, `style`, `map`, `follow`, `annotations` and
|
||||
`street_level`. A tilted camera over an area looks at its center from behind;
|
||||
camera fields given with an area override its framing. `resolveViewArguments`
|
||||
turns them into a view.
|
||||
camera fields given with an area override its framing. A passed-through
|
||||
view's `street_level` point moves with a new `area` or camera position (a
|
||||
turned camera keeps it). `resolveViewArguments` turns them into a view. The
|
||||
app's own links (the address bar, Copy Link, `/share/<code>`) do not carry
|
||||
`sl`; see `docs/SHARE-LINK-FORMAT.md`.
|
||||
|
||||
### The God's Eye View panel
|
||||
|
||||
|
||||
+19
-3
@@ -216,7 +216,14 @@ function armedToast() {
|
||||
*/
|
||||
export async function applyView(
|
||||
view,
|
||||
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
|
||||
{
|
||||
viewer,
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
autoOpen = true,
|
||||
retryMs = FOLLOW_RETRY_MS,
|
||||
},
|
||||
) {
|
||||
const steps = [];
|
||||
const act = async (name, args) => {
|
||||
@@ -258,7 +265,7 @@ export async function applyView(
|
||||
// before flying, the view's own camera still applies.
|
||||
const result = await act(
|
||||
'show_street_level',
|
||||
streetLevelArgs(view.street_level),
|
||||
streetLevelArgs(view.street_level, { autoOpen }),
|
||||
);
|
||||
if (result?.ok !== true) {
|
||||
// An earlier view's photo must not stay over a place this view left.
|
||||
@@ -447,9 +454,18 @@ export function installViews({
|
||||
);
|
||||
return;
|
||||
}
|
||||
// A panel's host can hand back old results on a remount: only one issued
|
||||
// just now may open a billed photo. A framing page's view is its own.
|
||||
const autoOpen = !isEmbeddedInline() || event.data.fresh === true;
|
||||
queue = queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
const steps = await applyView(view, { viewer, dataManager, run, signal });
|
||||
const steps = await applyView(view, {
|
||||
viewer,
|
||||
dataManager,
|
||||
run,
|
||||
signal,
|
||||
autoOpen,
|
||||
});
|
||||
post(
|
||||
{
|
||||
type: EMBED_APPLIED_MESSAGE,
|
||||
|
||||
@@ -188,6 +188,59 @@ test('an embedded page takes views only from its parent and answers it', async (
|
||||
remove();
|
||||
});
|
||||
|
||||
test('an inline panel opens a billed photo only for a view whose result is fresh', async () => {
|
||||
const windowRef = new EventTarget();
|
||||
windowRef.parent = windowRef;
|
||||
windowRef.postMessage = () => {};
|
||||
windowRef.document = { body: { classList: new Set() } };
|
||||
windowRef.document.body.classList.add = Set.prototype.add;
|
||||
Object.assign(windowRef, {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
const { calls, run } = recorder();
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
const remove = installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve() },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run,
|
||||
location: { search: '', hash: '' },
|
||||
windowRef,
|
||||
});
|
||||
const view = {
|
||||
camera: { lat: 48.8584, lon: 2.2945 },
|
||||
map: 'photoreal',
|
||||
street_level: { provider: 'google', lat: 48.8584, lon: 2.2945 },
|
||||
};
|
||||
for (const [id, fresh] of [
|
||||
[1, true],
|
||||
[2, false],
|
||||
[3, undefined],
|
||||
]) {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, {
|
||||
source: windowRef,
|
||||
data: { type: 'gev:view', id, view, fresh },
|
||||
});
|
||||
windowRef.dispatchEvent(event);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
assert.deepEqual(
|
||||
calls
|
||||
.filter(([name]) => name === 'show_street_level')
|
||||
.map(([, args]) => args.autoOpen ?? true),
|
||||
[true, false, false],
|
||||
);
|
||||
remove();
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
}
|
||||
});
|
||||
|
||||
test('an app loaded inline into a panel page talks through its own window', async () => {
|
||||
const posted = [];
|
||||
const windowRef = new EventTarget();
|
||||
|
||||
@@ -560,9 +560,15 @@ export function panelRuntime(config) {
|
||||
let started = false;
|
||||
let ready = false;
|
||||
let queued = null;
|
||||
let queuedFresh = false;
|
||||
let currentUrl = null;
|
||||
const postView = (view) =>
|
||||
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
|
||||
// Every view carries whether its result was issued just now: only those may
|
||||
// open a billed photo, whichever result on this mount they came in.
|
||||
const postView = (view, fresh) =>
|
||||
window.postMessage(
|
||||
{ type: 'gev:view', id: nextId++, view, fresh: fresh === true },
|
||||
'*',
|
||||
);
|
||||
function show(result) {
|
||||
const data = result?.structuredContent;
|
||||
const view = data?.view;
|
||||
@@ -570,16 +576,17 @@ export function panelRuntime(config) {
|
||||
if (!view || !url) return;
|
||||
currentUrl = url;
|
||||
open.hidden = false;
|
||||
if (ready) return postView(view);
|
||||
queued = view;
|
||||
if (started) return;
|
||||
started = true;
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say("Loading God's Eye View…");
|
||||
const issuedAt = Number(data.issued_at);
|
||||
const fresh =
|
||||
Number.isFinite(issuedAt) &&
|
||||
Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS;
|
||||
if (ready) return postView(view, fresh);
|
||||
queued = view;
|
||||
queuedFresh = fresh;
|
||||
if (started) return;
|
||||
started = true;
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say("Loading God's Eye View…");
|
||||
startApp(url, view, fresh).catch((error) =>
|
||||
// i18n-ignore: host-panel script, no app catalogs
|
||||
say(`God's Eye View could not load here: ${error?.message || error}.`),
|
||||
@@ -601,7 +608,7 @@ export function panelRuntime(config) {
|
||||
if (message?.type === 'gev:ready') {
|
||||
ready = true;
|
||||
status.remove();
|
||||
if (queued) postView(queued);
|
||||
if (queued) postView(queued, queuedFresh);
|
||||
queued = null;
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { panelRuntime } from './globePanelRuntime.js';
|
||||
|
||||
const element = () => ({
|
||||
isConnected: true,
|
||||
hidden: true,
|
||||
textContent: '',
|
||||
addEventListener() {},
|
||||
remove() {
|
||||
this.isConnected = false;
|
||||
},
|
||||
});
|
||||
|
||||
test('every view the panel posts says whether its result was issued just now', async () => {
|
||||
const savedWindow = globalThis.window;
|
||||
const savedDocument = globalThis.document;
|
||||
const views = [];
|
||||
const parent = { postMessage() {} };
|
||||
const windowRef = new EventTarget();
|
||||
Object.assign(windowRef, {
|
||||
parent,
|
||||
postMessage: (message) => {
|
||||
if (message?.type === 'gev:view') views.push(message);
|
||||
},
|
||||
});
|
||||
const elements = { status: element(), open: element(), expand: element() };
|
||||
globalThis.window = windowRef;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => elements[id] ?? null,
|
||||
addEventListener() {},
|
||||
baseURI: 'https://host.example/panel',
|
||||
body: { clientWidth: 600 },
|
||||
};
|
||||
const deliver = (source, data) => {
|
||||
const event = new Event('message');
|
||||
Object.assign(event, { source, data });
|
||||
windowRef.dispatchEvent(event);
|
||||
};
|
||||
const result = (issuedAt) => ({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/tool-result',
|
||||
params: {
|
||||
structuredContent: {
|
||||
url: 'http://localhost:4173/#sl=google,48.85840,2.29450',
|
||||
issued_at: issuedAt,
|
||||
view: { camera: { lat: 48.8584, lon: 2.2945 } },
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
panelRuntime({
|
||||
appBaseUrl: 'http://localhost:4173/',
|
||||
panelBase: '/panel/',
|
||||
protocolVersion: '2025-06-18',
|
||||
panelHeight: 480,
|
||||
});
|
||||
deliver(windowRef, { type: 'gev:ready' });
|
||||
deliver(parent, result(Date.now()));
|
||||
// A host replaying an old result on the same mount.
|
||||
deliver(parent, result(Date.now() - 60 * 60 * 1000));
|
||||
deliver(parent, result(undefined));
|
||||
} finally {
|
||||
globalThis.window = savedWindow;
|
||||
globalThis.document = savedDocument;
|
||||
}
|
||||
assert.deepEqual(
|
||||
views.map((message) => message.fresh),
|
||||
[true, false, false],
|
||||
);
|
||||
});
|
||||
@@ -77,12 +77,17 @@ export async function resolveViewArguments(args, { services, signal, tools }) {
|
||||
: { ...base.camera, ...args.camera };
|
||||
let label = null;
|
||||
// Street-level photos open at the area's centre unless a point is given;
|
||||
// a new area moves a passed-through view's photos with it.
|
||||
// a new area or camera position moves a passed-through view's photos with
|
||||
// it.
|
||||
const moved =
|
||||
Boolean(args.area) ||
|
||||
args.camera?.lat !== undefined ||
|
||||
args.camera?.lon !== undefined;
|
||||
let streetLevel =
|
||||
args.street_level && typeof args.street_level === 'object'
|
||||
? { ...args.street_level }
|
||||
: base.street_level && typeof base.street_level === 'object'
|
||||
? args.area
|
||||
? moved
|
||||
? { provider: base.street_level.provider }
|
||||
: { ...base.street_level }
|
||||
: null;
|
||||
|
||||
@@ -158,7 +158,7 @@ test('links can open street-level photos at the area, on the map the source need
|
||||
);
|
||||
});
|
||||
|
||||
test('a new area moves a passed-through view’s photos, and a tilted area still opens them at its centre', async () => {
|
||||
test('a new area or camera position moves a passed-through view’s photos, and a tilted area still opens them at its centre', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { app: { baseUrl: 'http://localhost:4173/' } },
|
||||
@@ -176,6 +176,21 @@ test('a new area moves a passed-through view’s photos, and a tilted area still
|
||||
lat: 42.3314,
|
||||
lon: -83.0458,
|
||||
});
|
||||
// A new camera position moves them too; turning the camera keeps them.
|
||||
const moved = await catalog.call('show_in_gods_eye_view', {
|
||||
view: first.data.view,
|
||||
camera: { lat: 40.758, lon: -73.9855, altitude_m: 800, pitch_deg: -90 },
|
||||
});
|
||||
assert.deepEqual(moved.data.view.street_level, {
|
||||
provider: 'google',
|
||||
lat: 40.758,
|
||||
lon: -73.9855,
|
||||
});
|
||||
const turned = await catalog.call('show_in_gods_eye_view', {
|
||||
view: first.data.view,
|
||||
camera: { heading_deg: 90 },
|
||||
});
|
||||
assert.deepEqual(turned.data.view.street_level, first.data.view.street_level);
|
||||
const tilted = await catalog.call('show_in_gods_eye_view', {
|
||||
area: { lat: 48.8584, lon: 2.2945, radius_km: 1 },
|
||||
camera: { pitch_deg: -30 },
|
||||
|
||||
@@ -4,7 +4,11 @@ import { ShellFacade } from './shellFacade.js';
|
||||
import { AircraftDisplay } from './aircraftDisplay.js';
|
||||
import { LayerBindings } from './layerBindings.js';
|
||||
import { PanelChrome } from './panelChrome.js';
|
||||
import { initMobileShell, sheetForPanelId } from './mobileShell.js';
|
||||
import {
|
||||
initMobileShell,
|
||||
MOBILE_SHELL_MEDIA_QUERY,
|
||||
sheetForPanelId,
|
||||
} from './mobileShell.js';
|
||||
import { VisualSettings } from './visualSettings.js';
|
||||
import { NavigationController } from './navigationController.js';
|
||||
import { ShareRestoration } from './shareRestoration.js';
|
||||
@@ -1221,6 +1225,7 @@ export class StyleManager extends ShellFacade {
|
||||
*/
|
||||
revealPanel(panelId) {
|
||||
const sheet = sheetForPanelId(panelId);
|
||||
if (!globalThis.matchMedia?.(MOBILE_SHELL_MEDIA_QUERY)?.matches) return;
|
||||
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' });
|
||||
|
||||
@@ -2,14 +2,18 @@ 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', () => {
|
||||
function reveal({ phone }) {
|
||||
const sheets = [];
|
||||
const scrolls = [];
|
||||
const panel = { scrollIntoView: (options) => scrolls.push(options) };
|
||||
const saved = globalThis.document;
|
||||
const savedMatchMedia = globalThis.matchMedia;
|
||||
globalThis.document = {
|
||||
getElementById: (id) => (id === 'street-level-panel' ? panel : null),
|
||||
};
|
||||
globalThis.matchMedia = (query) => ({
|
||||
matches: phone && query === '(max-width: 720px)',
|
||||
});
|
||||
try {
|
||||
StyleManager.prototype.revealPanel.call(
|
||||
{ _mobileShell: { setSheet: (sheet) => sheets.push(sheet) } },
|
||||
@@ -17,8 +21,18 @@ test('revealing a panel opens its phone sheet and scrolls the panel into view',
|
||||
);
|
||||
} finally {
|
||||
globalThis.document = saved;
|
||||
globalThis.matchMedia = savedMatchMedia;
|
||||
}
|
||||
return { sheets, scrolls };
|
||||
}
|
||||
|
||||
test('revealing a panel opens its phone sheet and scrolls the panel into view', () => {
|
||||
const { sheets, scrolls } = reveal({ phone: true });
|
||||
assert.deepEqual(sheets, ['cameras']);
|
||||
// The panel sits below the sheet's first screen at 375×812.
|
||||
assert.deepEqual(scrolls, [{ block: 'nearest' }]);
|
||||
});
|
||||
|
||||
test('revealing a panel above 720px neither opens a sheet nor scrolls the rail', () => {
|
||||
assert.deepEqual(reveal({ phone: false }), { sheets: [], scrolls: [] });
|
||||
});
|
||||
+116
-28
@@ -760,6 +760,7 @@ export function createGevActionRunner({
|
||||
|
||||
if (name === 'show_street_level') {
|
||||
return showStreetLevel(args, {
|
||||
viewer,
|
||||
dataManager,
|
||||
styleManager,
|
||||
placeSearch,
|
||||
@@ -3458,6 +3459,55 @@ function actionIntentOrigin(runOptions) {
|
||||
return runOptions?.origin === 'programmatic' ? 'programmatic' : 'voice';
|
||||
}
|
||||
|
||||
/** Gestures on the globe that take the camera from a pending request. */
|
||||
const CAMERA_GESTURES = ['pointerdown', 'wheel'];
|
||||
|
||||
/**
|
||||
* Whether the camera is still a request's: a navigation the shell stamps (a
|
||||
* search, the assistant, a link) or a press or wheel on the globe hands it to
|
||||
* someone else. `claim()` makes the camera as it is now the request's (after
|
||||
* its own flight); `stop()` removes the gesture listeners.
|
||||
*/
|
||||
function watchCameraOwner(viewer, navigation) {
|
||||
let generation = navigation?._navigationGeneration;
|
||||
const canvas = viewer?.canvas ?? viewer?.scene?.canvas ?? null;
|
||||
let taken = false;
|
||||
const onGesture = () => {
|
||||
taken = true;
|
||||
};
|
||||
for (const type of CAMERA_GESTURES)
|
||||
canvas?.addEventListener?.(type, onGesture, { passive: true });
|
||||
return {
|
||||
claim: () => {
|
||||
generation = navigation?._navigationGeneration;
|
||||
taken = false;
|
||||
},
|
||||
taken: () =>
|
||||
taken ||
|
||||
(Number.isFinite(generation) &&
|
||||
navigation._navigationGeneration !== generation),
|
||||
stop: () => {
|
||||
for (const type of CAMERA_GESTURES)
|
||||
canvas?.removeEventListener?.(type, onGesture);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether cockpit, clean view, recording or scene playback hides the panel a
|
||||
* street-level photo opens in. Embed mode uses clean view for its globe but
|
||||
* lifts the photo out of the hidden rail.
|
||||
*/
|
||||
function streetLevelPanelHidden() {
|
||||
const body = globalThis.document?.body;
|
||||
const embedded = body?.classList?.contains('ui-embed') === true;
|
||||
return EXCLUSIVE_SURFACE_CLASSES.some(
|
||||
(mode) =>
|
||||
body?.classList?.contains(mode) &&
|
||||
!(embedded && mode === 'ui-clean-view'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "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
|
||||
@@ -3469,13 +3519,26 @@ function actionIntentOrigin(runOptions) {
|
||||
* from a source that bills every load: the panel is armed and the OPEN
|
||||
* button is one click away.
|
||||
*/
|
||||
export async function showStreetLevel(
|
||||
export async function showStreetLevel(args, context) {
|
||||
// Every await can hand the request's moment to someone else: a newer turn,
|
||||
// a navigation or gesture that takes the camera, or a mode that hides the
|
||||
// panel. Each stage after the place is settled checks again.
|
||||
const owner = watchCameraOwner(context.viewer, context.styleManager);
|
||||
try {
|
||||
return await requestStreetLevel(args, { ...context, owner });
|
||||
} finally {
|
||||
owner.stop();
|
||||
}
|
||||
}
|
||||
|
||||
async function requestStreetLevel(
|
||||
args,
|
||||
{
|
||||
dataManager,
|
||||
styleManager,
|
||||
placeSearch,
|
||||
runGevAction,
|
||||
owner,
|
||||
runOptions = {},
|
||||
current = () => true,
|
||||
reportProgress = () => {},
|
||||
@@ -3548,6 +3611,34 @@ export async function showStreetLevel(
|
||||
keyAlternatives: layerModule.getUIState?.()?.keyAlternatives || [],
|
||||
});
|
||||
|
||||
// A hidden panel refuses before the camera leaves a recording or scene.
|
||||
const hidden = (extra = {}) =>
|
||||
fail(
|
||||
'view',
|
||||
msg(
|
||||
'Street-level photos are hidden in the current view. Leave it first.',
|
||||
),
|
||||
extra,
|
||||
);
|
||||
if (streetLevelPanelHidden()) return hidden();
|
||||
const interrupted = (stage, extra = {}) => {
|
||||
if (!current())
|
||||
return fail(stage, 'Street-level request was cancelled', {
|
||||
cancelled: true,
|
||||
location,
|
||||
...extra,
|
||||
});
|
||||
if (owner.taken())
|
||||
return fail(stage, 'The camera moved on before the photo opened', {
|
||||
cancelled: true,
|
||||
superseded: true,
|
||||
location,
|
||||
...extra,
|
||||
});
|
||||
if (streetLevelPanelHidden()) return hidden({ location, ...extra });
|
||||
return null;
|
||||
};
|
||||
|
||||
// Then the place: a name that geocodes to nothing changes nothing else.
|
||||
label =
|
||||
query ||
|
||||
@@ -3615,24 +3706,11 @@ export async function showStreetLevel(
|
||||
}
|
||||
|
||||
// Cockpit, clean view, recording and scene playback hide the panel the
|
||||
// photo opens in: say so before anything loads unseen. Embed mode uses
|
||||
// clean view for its globe but lifts the photo out of the hidden rail.
|
||||
const body = globalThis.document?.body;
|
||||
const embedded = body?.classList?.contains('ui-embed') === true;
|
||||
if (
|
||||
EXCLUSIVE_SURFACE_CLASSES.some(
|
||||
(mode) =>
|
||||
body?.classList?.contains(mode) &&
|
||||
!(embedded && mode === 'ui-clean-view'),
|
||||
)
|
||||
)
|
||||
return fail(
|
||||
'view',
|
||||
msg(
|
||||
'Street-level photos are hidden in the current view. Leave it first.',
|
||||
),
|
||||
{ location },
|
||||
);
|
||||
// photo opens in, and may have started during the flight.
|
||||
// The camera is the request's from where its own flight landed.
|
||||
if (location) owner.claim();
|
||||
const arrived = interrupted('view');
|
||||
if (arrived) return arrived;
|
||||
|
||||
// The source's map, when it needs one (Street View: Google 3D only).
|
||||
const requiredStack = layerModule.requiredMapStack?.(provider) || null;
|
||||
@@ -3644,13 +3722,15 @@ export async function showStreetLevel(
|
||||
{ stack: requiredStack },
|
||||
runOptions,
|
||||
);
|
||||
if (map?.ok !== true || !current())
|
||||
if (map?.ok !== true && current())
|
||||
return fail(
|
||||
'map',
|
||||
map?.error ||
|
||||
`${providerName} needs the ${requiredStack} map, which is not available`,
|
||||
{ map, location },
|
||||
);
|
||||
const stop = interrupted('map', { map });
|
||||
if (stop) return stop;
|
||||
}
|
||||
|
||||
// Light the source the way a chip click does, so the choice is kept.
|
||||
@@ -3675,12 +3755,14 @@ export async function showStreetLevel(
|
||||
{ layerId: STREET_LEVEL_LAYER_ID, enabled: true },
|
||||
runOptions,
|
||||
);
|
||||
if (layer?.ok !== true || !current())
|
||||
if (layer?.ok !== true && current())
|
||||
return fail(
|
||||
'layer',
|
||||
layer?.error || 'Street Level could not be enabled',
|
||||
{ cancelled: !current() || Boolean(layer?.cancelled), layer, location },
|
||||
{ cancelled: Boolean(layer?.cancelled), layer, location },
|
||||
);
|
||||
const stop = interrupted('layer', { layer });
|
||||
if (stop) return stop;
|
||||
}
|
||||
|
||||
// Listed now that its map is up, and lit.
|
||||
@@ -3698,6 +3780,11 @@ export async function showStreetLevel(
|
||||
location,
|
||||
});
|
||||
|
||||
// A photo of another place must not stay up beside this request's answer,
|
||||
// armed or opened, found or not: the panel shows this request's outcome.
|
||||
if (layerModule.getUIState?.()?.street?.open === true)
|
||||
layerModule.closeViewer?.();
|
||||
|
||||
// A billed source never loads from a link without a click: everything is
|
||||
// ready and OPEN is one press away at the view centre.
|
||||
if (args.autoOpen === false && status?.billed === true) {
|
||||
@@ -3737,12 +3824,13 @@ export async function showStreetLevel(
|
||||
});
|
||||
const street = layerModule.getUIState?.()?.street || {};
|
||||
// A cancelled request opens nothing (the lookup and the viewer both honour
|
||||
// the turn's signal); a photo that did open is reported as open.
|
||||
if (opened !== true && !current())
|
||||
return fail('imagery', 'Street-level request was cancelled', {
|
||||
cancelled: true,
|
||||
location,
|
||||
});
|
||||
// the turn's signal). A photo that opened after the camera moved on, or
|
||||
// into a panel hidden meanwhile, is closed again: nobody asked to see it.
|
||||
const late = interrupted('imagery');
|
||||
if (late) {
|
||||
if (opened === true) layerModule.closeViewer?.();
|
||||
return late;
|
||||
}
|
||||
if (opened !== true) {
|
||||
// Why nothing opened: the key, a change under the lookup, or no imagery.
|
||||
const after = layerModule.providerStatus?.(provider) || status || {};
|
||||
|
||||
@@ -5717,12 +5717,21 @@ function createStreetLevelHarness({
|
||||
lightFails = false,
|
||||
imageryError = null,
|
||||
sky = false,
|
||||
photoOpen = false,
|
||||
duringMapSwitch = null,
|
||||
duringEnable = null,
|
||||
} = {}) {
|
||||
const order = [];
|
||||
const lookups = [];
|
||||
const origins = [];
|
||||
const revealed = [];
|
||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||
const { viewer, styleManager, currentGeneration } = createVoiceNavigationHarness();
|
||||
// Who owns the camera: the shell's navigation stamps and globe gestures.
|
||||
let userNavigations = 0;
|
||||
Object.defineProperty(styleManager, '_navigationGeneration', {
|
||||
get: () => currentGeneration() + userNavigations,
|
||||
});
|
||||
viewer.scene.canvas = Object.assign(new EventTarget(), { clientWidth: 1200, clientHeight: 800 });
|
||||
let completeFlight = null;
|
||||
viewer.camera.lookAt = () => {};
|
||||
viewer.camera.flyToBoundingSphere = (_sphere, options) => {
|
||||
@@ -5736,11 +5745,14 @@ function createStreetLevelHarness({
|
||||
styleManager.setMapStack = async (id) => {
|
||||
order.push(`map:${id}`);
|
||||
stack = id;
|
||||
duringMapSwitch?.();
|
||||
return { ok: true, activeStack: id, error: null };
|
||||
};
|
||||
let params = { ...lit };
|
||||
let keyRejected = keyRejectedAtStart;
|
||||
const street = { imageId: null, error: null };
|
||||
const street = photoOpen
|
||||
? { open: true, imageId: 'eiffel-photo', error: null }
|
||||
: { open: false, imageId: null, error: null };
|
||||
const names = { google: 'Google Street View', mapillary: 'Mapillary' };
|
||||
const layer = {
|
||||
providerIds: ['mapillary', 'google'],
|
||||
@@ -5777,6 +5789,10 @@ function createStreetLevelHarness({
|
||||
coverage: { hint: 'not on this map' },
|
||||
street: { ...street },
|
||||
}),
|
||||
closeViewer() {
|
||||
order.push('close');
|
||||
Object.assign(street, { open: false, imageId: null, error: null, nearestOutcome: null });
|
||||
},
|
||||
armNearest(point, options) {
|
||||
order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`);
|
||||
},
|
||||
@@ -5806,6 +5822,7 @@ function createStreetLevelHarness({
|
||||
street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`;
|
||||
return false;
|
||||
}
|
||||
street.open = true;
|
||||
street.imageId = nearest;
|
||||
street.title = '10th St, Sacramento';
|
||||
street.position = point;
|
||||
@@ -5828,6 +5845,7 @@ function createStreetLevelHarness({
|
||||
order.push(`enable:${id}`);
|
||||
if (enableResult === false) return false;
|
||||
enabled = true;
|
||||
duringEnable?.();
|
||||
return true;
|
||||
},
|
||||
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }],
|
||||
@@ -5835,7 +5853,22 @@ function createStreetLevelHarness({
|
||||
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, lookups, origins, revealed };
|
||||
return {
|
||||
runner,
|
||||
order,
|
||||
finishFlight: () => completeFlight?.(),
|
||||
layer,
|
||||
dataManager,
|
||||
lookups,
|
||||
origins,
|
||||
revealed,
|
||||
street,
|
||||
// The user searches or flies elsewhere, or drags the globe.
|
||||
navigate: () => {
|
||||
userNavigations += 1;
|
||||
},
|
||||
gesture: () => viewer.scene.canvas.dispatchEvent(new Event('pointerdown')),
|
||||
};
|
||||
}
|
||||
|
||||
/** A loaded CCTV camera the entity branch can locate. */
|
||||
@@ -6398,6 +6431,99 @@ test('show_street_level in a view that hides its panel stops before anything loa
|
||||
}
|
||||
});
|
||||
|
||||
test('show_street_level checks the panel again after every wait: a mode entered meanwhile loads nothing unseen', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const saved = globalThis.document;
|
||||
const classes = new Set();
|
||||
globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } };
|
||||
try {
|
||||
for (const [mode, when] of [
|
||||
['ui-clean-view', 'duringEnable'],
|
||||
['cockpit-mode', 'duringMapSwitch'],
|
||||
]) {
|
||||
classes.clear();
|
||||
const h = createStreetLevelHarness({ [when]: () => classes.add(mode) });
|
||||
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
|
||||
assert.deepEqual([result.ok, result.stage], [false, 'view'], mode);
|
||||
assert.deepEqual(h.order.filter((step) => /^(open|arm)/.test(step)), [], `${mode}: no billed load`);
|
||||
assert.deepEqual(h.revealed, [], `${mode}: nothing revealed`);
|
||||
}
|
||||
// Entered while the photo loaded: it is closed again, not reported as shown.
|
||||
classes.clear();
|
||||
const late = createStreetLevelHarness({ enabled: true });
|
||||
const open = late.layer.openNearest;
|
||||
late.layer.openNearest = async (...call) => {
|
||||
const opened = await open(...call);
|
||||
classes.add('recording-mode');
|
||||
return opened;
|
||||
};
|
||||
const result = await late.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
|
||||
assert.deepEqual([result.ok, result.stage], [false, 'view']);
|
||||
assert.equal(late.order.at(-1), 'close');
|
||||
assert.deepEqual(late.revealed, []);
|
||||
} finally {
|
||||
globalThis.document = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('show_street_level refuses a hidden panel before the camera leaves a recording', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const saved = globalThis.document;
|
||||
globalThis.document = { body: { classList: { contains: (name) => name === 'recording-mode' } } };
|
||||
try {
|
||||
const { runner, order } = createStreetLevelHarness();
|
||||
const result = await runner('show_street_level', { query: 'Eiffel Tower', provider: 'mapillary' });
|
||||
assert.deepEqual([result.ok, result.stage], [false, 'view']);
|
||||
assert.deepEqual(order, [], 'no flight');
|
||||
} finally {
|
||||
globalThis.document = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test('show_street_level stops once the camera is someone else’s: a navigation or a gesture during the map switch', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
for (const take of ['navigate', 'gesture']) {
|
||||
let h;
|
||||
h = createStreetLevelHarness({ duringMapSwitch: () => h[take]() });
|
||||
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
|
||||
assert.equal(result.ok, true, `${take}: Mapillary needs no map switch`);
|
||||
h = createStreetLevelHarness({ duringMapSwitch: () => h[take]() });
|
||||
const google = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
|
||||
assert.deepEqual([google.ok, google.stage, google.cancelled, google.superseded], [false, 'map', true, true], take);
|
||||
assert.deepEqual(h.order.filter((step) => /^(params|enable|open|arm)/.test(step)), [], `${take}: nothing lit for the old place`);
|
||||
}
|
||||
// During the layer switch-on: the photo lookup never runs.
|
||||
let h;
|
||||
h = createStreetLevelHarness({ duringEnable: () => h.navigate() });
|
||||
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
|
||||
assert.deepEqual([result.ok, result.stage, result.superseded], [false, 'layer', true]);
|
||||
assert.ok(!h.order.some((step) => step.startsWith('open:')));
|
||||
});
|
||||
|
||||
test('show_street_level owns the camera from where its own flight lands', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const h = createStreetLevelHarness();
|
||||
const pending = h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary' });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
h.finishFlight();
|
||||
const result = await pending;
|
||||
assert.equal(result.ok, true, result.error);
|
||||
assert.ok(h.order.some((step) => step.startsWith('open:mapillary')));
|
||||
});
|
||||
|
||||
test('show_street_level closes the previous photo, so one that finds nothing leaves no other place on screen', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const empty = createStreetLevelHarness({ enabled: true, photoOpen: true, nearest: null });
|
||||
const result = await empty.runner('show_street_level', { latitude: 23.4, longitude: 12.5, provider: 'mapillary', fly: false });
|
||||
assert.deepEqual([result.ok, result.noImagery], [false, true]);
|
||||
assert.equal(empty.street.open, false, 'the Paris photo is gone');
|
||||
assert.match(empty.street.error, /No street-level imagery/, 'the panel shows why');
|
||||
// A link that only arms a billed source closes it too.
|
||||
const armed = createStreetLevelHarness({ enabled: true, photoOpen: true, activeStack: 'photoreal' });
|
||||
await armed.runner('show_street_level', { latitude: 23.4, longitude: 12.5, provider: 'google', fly: false, autoOpen: false });
|
||||
assert.deepEqual(armed.order.filter((step) => /^(close|arm)/.test(step)).map((step) => step.split(':')[0]), ['close', 'arm']);
|
||||
});
|
||||
|
||||
test('show_street_level opens over the clean embedded globe but respects its other exclusive modes', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const saved = globalThis.document;
|
||||
|
||||
Reference in new issue
Block a user