mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22: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
+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