mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
Street Level: a landed link, a replaced photo and a retired lookup keep their state
- A link's photo step compares with the navigation token its own restore flight owned, so a navigation after the flight landed (while the layers still load) keeps the camera. - A request that switches the source keeps the open photo up until it is replaced (holdViewer), so EXPAND stays across sources. - A lookup given up while it looked leaves the open photo and the panel as they were, also when it found nothing or failed. - A failed replacement shows its error inside the expanded viewer. - A replacing lookup stops a playing Mapillary sequence before the found photo opens. - The action refuses coordinates outside the globe, keeps a name given with coordinates as its label, and fly: false keeps the camera only for coordinates (its test-only entity and query branches are gone). - Numbered entities carry their label; a view's street-level point is a pair. - Mapillary's nearest search always asks for 50 m and 100 images. - Tests: the lifecycle reproductions, PLAY on replacement, out-of-range coordinates, half a point, and the named label; three layer tests share enabledLayer; the second payload digest is gone. - Docs: the tool count, the panel freshness rule, armed: false, the photo replacement, the browser-gate comment and a shorter changelog fragment.
This commit is contained in:
1 parent
9c73939425
commit
f3996da0af
26 files changed
+470
-327
No files matched your search
+8
-2
@@ -341,10 +341,16 @@ export function installViews({
|
||||
// A restore whose camera never landed (the recipient dragged, searched or
|
||||
// opened another place during its flight) leaves the camera with them.
|
||||
const cameraLanded = (outcome) => outcome?.share?.camera === 'applied';
|
||||
// The generation the restore's own flight owned: a navigation after it
|
||||
// landed, while the layers still loaded, has moved on from it.
|
||||
const restoreGeneration = (outcome) =>
|
||||
Number.isInteger(outcome?.navigationToken)
|
||||
? outcome.navigationToken
|
||||
: shell._navigationGeneration;
|
||||
let generationAtRestore = null;
|
||||
let startSuperseded = false;
|
||||
let queue = ready.then((outcome) => {
|
||||
generationAtRestore = shell._navigationGeneration;
|
||||
generationAtRestore = restoreGeneration(outcome);
|
||||
startSuperseded =
|
||||
outcome?.status === 'superseded' ||
|
||||
(Boolean(outcome?.share) && !cameraLanded(outcome));
|
||||
@@ -414,7 +420,7 @@ export function installViews({
|
||||
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
|
||||
);
|
||||
if (!streetLevel) return;
|
||||
const generation = shell._navigationGeneration;
|
||||
const generation = restoreGeneration(event.detail);
|
||||
queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation }));
|
||||
};
|
||||
windowRef?.addEventListener?.(
|
||||
|
||||
@@ -266,8 +266,11 @@ export function createStreetLevelLayer({
|
||||
if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def);
|
||||
}
|
||||
|
||||
/** Requests replacing the open photo that switch its source keep it up. */
|
||||
let viewerHolds = 0;
|
||||
|
||||
function deactivate(entry) {
|
||||
parts.viewerHost.unmount(entry.def.id);
|
||||
if (!viewerHolds) parts.viewerHost.unmount(entry.def.id);
|
||||
entry.instance.deactivate();
|
||||
parts.credits.hide(state.viewer, entry.def);
|
||||
}
|
||||
@@ -280,6 +283,13 @@ export function createStreetLevelLayer({
|
||||
nearestLookup = null;
|
||||
}
|
||||
|
||||
/** Close the image and deselect its sequence on the map. */
|
||||
function closeViewer() {
|
||||
abortNearest();
|
||||
parts.viewerHost.close();
|
||||
parts.clearSequences();
|
||||
}
|
||||
|
||||
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */
|
||||
function screenCentreGround() {
|
||||
const canvas = state.viewer?.scene?.canvas;
|
||||
@@ -692,15 +702,17 @@ export function createStreetLevelLayer({
|
||||
* street); clicks keep the default. A caller's `signal` retires the
|
||||
* lookup: nothing opens after it fires. Providers without
|
||||
* `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
|
||||
* as the assistant's) does not use a link's armed point, and keeps it
|
||||
* while it looks; a photo it opens ends it, as any opened photo does. Why
|
||||
* nothing opened is left in `street.nearestOutcome`: 'empty' (no imagery
|
||||
* in reach), 'sky' (the screen centre is not ground), or null (an error).
|
||||
* `replace: true` (a request that takes over the panel) keeps an open
|
||||
* photo up while it looks, so the viewer and EXPAND stay, and closes it
|
||||
* when the lookup opens nothing: the panel shows this request's outcome.
|
||||
* `proceed()` is asked once the lookup has found an image and before it
|
||||
* loads; false retires the request (a photo that bills every load must
|
||||
* not load for a request its caller has given up).
|
||||
* `proceed()` is asked before a found image loads and before an empty
|
||||
* or failed lookup reports; false retires the request and leaves the
|
||||
* panel as it was (a photo that bills every load must not load for a
|
||||
* request its caller has given up).
|
||||
*/
|
||||
async openNearest(
|
||||
point,
|
||||
@@ -722,9 +734,7 @@ export function createStreetLevelLayer({
|
||||
const armed = useArmed ? state.street.armed : null;
|
||||
if (useArmed) state.street.armed = null;
|
||||
const closeReplaced = () => {
|
||||
if (!replace || !state.street.open) return;
|
||||
parts.viewerHost.close();
|
||||
parts.clearSequences();
|
||||
if (replace && state.street.open) closeViewer();
|
||||
};
|
||||
if (!point && aim === 'screen-centre' && armed) {
|
||||
// The button promises the centre of the view: the link's spot only
|
||||
@@ -812,6 +822,9 @@ export function createStreetLevelLayer({
|
||||
if (proceed && proceed() !== true) return retired();
|
||||
nearestLookup = null;
|
||||
callerSignal?.removeEventListener('abort', onCallerAbort);
|
||||
// The photo asked for stays on screen: a sequence playing in the
|
||||
// photo it replaces stops.
|
||||
if (replace && state.street.open) parts.viewerHost.stopPlayback();
|
||||
// The caller's signal still counts while the viewer mounts and loads.
|
||||
return openImage(entry.def.id, imageId, {
|
||||
frame,
|
||||
@@ -821,6 +834,9 @@ export function createStreetLevelLayer({
|
||||
callerSignal?.removeEventListener('abort', onCallerAbort);
|
||||
// Replaced while it looked: the panel is the newer lookup's.
|
||||
if (nearestLookup !== lookup) return false;
|
||||
// Given up by its caller while it looked: the open photo stays, and
|
||||
// the panel reports nothing of this lookup.
|
||||
if (proceed && proceed() !== true) return retired();
|
||||
nearestLookup = null;
|
||||
closeReplaced();
|
||||
// Switched off while it looked: nothing to open, nothing to report.
|
||||
@@ -848,11 +864,23 @@ export function createStreetLevelLayer({
|
||||
notify();
|
||||
return false;
|
||||
},
|
||||
/** Close the image and deselect its sequence on the map. */
|
||||
closeViewer() {
|
||||
abortNearest();
|
||||
parts.viewerHost.close();
|
||||
parts.clearSequences();
|
||||
closeViewer,
|
||||
/**
|
||||
* Keep the open photo up while a request that replaces it switches the
|
||||
* source (the viewer and EXPAND are replaced, not closed). The returned
|
||||
* release closes it if its source is still off by then.
|
||||
*/
|
||||
holdViewer() {
|
||||
viewerHolds += 1;
|
||||
let held = true;
|
||||
return () => {
|
||||
if (!held) return;
|
||||
held = false;
|
||||
viewerHolds -= 1;
|
||||
if (viewerHolds) return;
|
||||
for (const entry of state.providers.values())
|
||||
if (!entry.on) parts.viewerHost.unmount(entry.def.id);
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Prepare a nearest lookup for the next OPEN press without running it: a
|
||||
|
||||
@@ -366,7 +366,11 @@ function fakeViewer() {
|
||||
}
|
||||
|
||||
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
|
||||
async function enabledLayer(t, providers = [fakeProvider()]) {
|
||||
async function enabledLayer(
|
||||
t,
|
||||
providers = [fakeProvider()],
|
||||
{ services, setup } = {},
|
||||
) {
|
||||
const saved = globalThis.document;
|
||||
// A pose moves the position marker, whose glyph is drawn on a canvas.
|
||||
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
|
||||
@@ -374,7 +378,11 @@ async function enabledLayer(t, providers = [fakeProvider()]) {
|
||||
createElement: () => ({ getContext: () => drawing }),
|
||||
});
|
||||
const viewer = fakeViewer();
|
||||
const layer = createStreetLevelLayer({ providers });
|
||||
setup?.(viewer);
|
||||
const layer = createStreetLevelLayer({
|
||||
providers,
|
||||
...(services ? { services } : {}),
|
||||
});
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
t.after(() => {
|
||||
@@ -1944,7 +1952,6 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async (
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -1953,26 +1960,18 @@ test('OPEN aimed at the sky after a link armed opens nothing: never the far link
|
||||
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;
|
||||
const { layer } = await enabledLayer(t, [provider], {
|
||||
services: { scenePick },
|
||||
setup: (viewer) => {
|
||||
// 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;
|
||||
},
|
||||
});
|
||||
layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 });
|
||||
assert.equal(
|
||||
@@ -1986,7 +1985,6 @@ test('OPEN aimed at the sky after a link armed opens nothing: never the far link
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -1995,25 +1993,18 @@ test('a request of its own (armed: false) neither uses nor drops the link’s ar
|
||||
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;
|
||||
const { layer } = await enabledLayer(t, [provider], {
|
||||
services: { scenePick },
|
||||
setup: (viewer) => {
|
||||
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;
|
||||
},
|
||||
});
|
||||
// About 120 m away: an OPEN press would take the link's spot.
|
||||
layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 });
|
||||
@@ -2030,25 +2021,12 @@ test('a request of its own (armed: false) neither uses nor drops the link’s ar
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
const { layer } = await enabledLayer(t, [provider]);
|
||||
assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), false);
|
||||
const street = layer.getUIState().street;
|
||||
assert.equal(street.nearestOutcome, null);
|
||||
|
||||
@@ -532,9 +532,8 @@ export function createLineCoverage({
|
||||
}
|
||||
|
||||
/**
|
||||
* The sequence lines drawn now, as `[lon, lat]` parts per sequence: for
|
||||
* diagnostics and the browser gate, which aims its clicks at them instead
|
||||
* of scanning pixels: what each tile drew (`attachPrimitive` keeps it in
|
||||
* The sequence lines drawn now, as `[lon, lat]` parts per sequence, for
|
||||
* diagnostics: what each tile drew (`attachPrimitive` keeps it in
|
||||
* `entry.sequences`). Read-only copies.
|
||||
*/
|
||||
function drawnLines() {
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
|
||||
import { coverageZoomForHeight } from '../../tileMath.js';
|
||||
import { passesImageryFilter } from '../../filter.js';
|
||||
import { NEAREST_RADIUS_M } from '../../policy.js';
|
||||
import { metresBetween } from '../../view.js';
|
||||
import { decodeCoverageTile } from './decode.js';
|
||||
import { createMapillaryViewer } from './viewer.js';
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
MAPILLARY_NAME,
|
||||
MAPILLARY_PROVIDER_ID,
|
||||
MAPILLARY_SOURCE_METHODS,
|
||||
NEAREST_LIMIT,
|
||||
PICK_PREFIX,
|
||||
RATE_LIMITED_MESSAGE,
|
||||
} from './policy.js';
|
||||
@@ -206,10 +204,7 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
|
||||
* cone). The core aborts `signal` once a newer request overtakes it.
|
||||
*/
|
||||
async nearestImage({ lat, lon }, { signal } = {}) {
|
||||
const images = await source.nearestImages(
|
||||
{ lat, lon, radius: NEAREST_RADIUS_M, limit: NEAREST_LIMIT },
|
||||
{ signal },
|
||||
);
|
||||
const images = await source.nearestImages({ lat, lon }, { signal });
|
||||
// The API returns the images in the radius in no particular order.
|
||||
const metres = (record) => {
|
||||
const [lon2, lat2] = (record.computed_geometry || record.geometry)
|
||||
|
||||
@@ -200,11 +200,7 @@ test('nearestImage opens the closest image that passes the filter, not the first
|
||||
);
|
||||
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }, { signal }), 'near');
|
||||
assert.equal(requests[0].options.signal, signal, 'the core can abort it');
|
||||
assert.equal(requests[0].query.radius, 50);
|
||||
// The API cuts at the limit before any order: a small one would sort and
|
||||
// filter a handful of arbitrary photos (a 360° filter could find none).
|
||||
assert.equal(requests[0].query.limit, 100, "the API's most");
|
||||
assert.deepEqual([requests[0].query.lat, requests[0].query.lon], [1, 2]);
|
||||
assert.deepEqual(requests[0].query, { lat: 1, lon: 2 });
|
||||
const any = createMapillaryProvider({ source }).create(fakeContext());
|
||||
assert.equal(await any.nearestImage({ lat: 1, lon: 2 }), 'near-pano');
|
||||
const tooOld = createMapillaryProvider({ source }).create(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import '../../../../testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createStreetLevelLayer } from '../../index.js';
|
||||
import { fakeStreetLevelProvider, fakeCesiumViewer } from '../../../../testSupport/streetLevelFakes.mjs';
|
||||
import { createMapillaryViewer } from './viewer.js';
|
||||
import { showStreetLevel } from '../../../../voice/gevActions.js';
|
||||
|
||||
// Library boundary fixture: moveTo changes the image without stopping PLAY,
|
||||
// matching installed MapillaryJS Viewer.moveTo / Navigator.moveTo$.
|
||||
const fakeLibrary = `export class Viewer {
|
||||
constructor() { globalThis.__replacePlayViewer = this; this.playing = false; this.id = null; this.stops = 0; }
|
||||
on() {} remove() {} resize() {} setRenderMode() {}
|
||||
getComponent() { return { play: () => {this.playing = true;}, stop: () => {this.playing = false; this.stops++;} }; }
|
||||
async moveTo(id) { this.id = id; return { id, sequenceId: 'fixture-sequence', cameraType: 'perspective' }; }
|
||||
async getPosition() { return { lng: 2, lat: 48 }; }
|
||||
async getPointOfView() { return { bearing: 0 }; }
|
||||
}
|
||||
export class GraphDataProvider {}
|
||||
export const RenderMode = { Fill: 1, Letterbox: 0 };`;
|
||||
const hooks = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
if (specifier === 'mapillary-js') return { url: 'data:text/javascript,' + encodeURIComponent(fakeLibrary), shortCircuit: true };
|
||||
if (specifier.endsWith('mapillary.css')) return { url: 'data:text/javascript,export default {}', shortCircuit: true };
|
||||
return next(specifier, context);
|
||||
},
|
||||
});
|
||||
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
|
||||
test('a photo the action asks for stops sequence PLAY', async t => {
|
||||
const provider = fakeStreetLevelProvider({ nearestImage: async () => 'B' });
|
||||
const create = provider.create;
|
||||
provider.create = context => ({ ...create(context), viewer: createMapillaryViewer({ source: { token: '' } }) });
|
||||
const viewer = fakeCesiumViewer(); viewer.canvas = viewer.scene.canvas;
|
||||
const layer = createStreetLevelLayer({ providers: [provider] });
|
||||
layer.init(viewer); layer.enable(viewer);
|
||||
layer.attachViewerHost(document.body.appendChild(document.createElement('div')));
|
||||
t.after(() => { layer.destroy(); hooks.deregister(); delete globalThis.__replacePlayViewer; document.body.replaceChildren(); });
|
||||
await Promise.resolve();
|
||||
await layer.openImage('mapillary', 'A', { frame: false });
|
||||
const libraryViewer = globalThis.__replacePlayViewer;
|
||||
libraryViewer.getComponent('sequence').play();
|
||||
assert.equal(libraryViewer.playing, true);
|
||||
const manager = {
|
||||
layers: new Map([['street-level', { module: layer }]]), isEnabled: () => true,
|
||||
getLayerParams: () => layer.getParams(), setLayerParams: (_id, params) => layer.setParams(params),
|
||||
};
|
||||
const result = await showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, {
|
||||
viewer, dataManager: manager, styleManager: { _navigationGeneration: 1 },
|
||||
runGevAction: async () => ({ ok: true, latitude: 48, longitude: 2, label: 'fixture destination' }),
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(libraryViewer.id, 'B');
|
||||
assert.equal(layer.getUIState().street.open, true);
|
||||
assert.equal(libraryViewer.playing, false, 'requested image must stay selected rather than continue sequence PLAY');
|
||||
});
|
||||
@@ -196,18 +196,15 @@ export function createMapillarySource({
|
||||
return Array.isArray(payload?.data) ? payload.data : [];
|
||||
},
|
||||
|
||||
/** Images within `radius` metres of a point, in no particular order. */
|
||||
async nearestImages(
|
||||
{ lat, lon, radius = NEAREST_RADIUS_M, limit = NEAREST_LIMIT },
|
||||
{ signal } = {},
|
||||
) {
|
||||
/** Images within `NEAREST_RADIUS_M` of a point, in no particular order. */
|
||||
async nearestImages({ lat, lon }, { signal } = {}) {
|
||||
const payload = await graph(
|
||||
'images',
|
||||
{
|
||||
lat: String(lat),
|
||||
lng: String(lon),
|
||||
radius: String(Math.min(50, Math.max(1, radius))),
|
||||
limit: String(Math.min(100, Math.max(1, limit))),
|
||||
radius: String(NEAREST_RADIUS_M),
|
||||
limit: String(NEAREST_LIMIT),
|
||||
fields: NEAREST_IMAGE_FIELDS,
|
||||
},
|
||||
{ signal, timeoutMs: NEAREST_TIMEOUT_MS },
|
||||
|
||||
@@ -126,24 +126,21 @@ test('a rejected key and a rate limit are told apart from other tile errors', as
|
||||
);
|
||||
});
|
||||
|
||||
test('nearestImages asks the graph API around a point, its radius and limit clamped', async () => {
|
||||
test('nearestImages asks the graph API around a point, within 50 m, for at most 100 images', async () => {
|
||||
const { calls, fetchImpl } = fakeFetch(() =>
|
||||
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
|
||||
);
|
||||
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
|
||||
const images = await source.nearestImages({
|
||||
lat: 38.5,
|
||||
lon: -121.5,
|
||||
radius: 500,
|
||||
limit: 500,
|
||||
});
|
||||
const images = await source.nearestImages({ lat: 38.5, lon: -121.5 });
|
||||
assert.equal(images.length, 1);
|
||||
const url = new URL(calls[0].url);
|
||||
assert.equal(url.pathname, '/images');
|
||||
assert.equal(url.searchParams.get('lat'), '38.5');
|
||||
assert.equal(url.searchParams.get('lng'), '-121.5');
|
||||
assert.equal(url.searchParams.get('radius'), '50', 'the API maximum');
|
||||
assert.equal(url.searchParams.get('limit'), '100');
|
||||
assert.equal(url.searchParams.get('radius'), '50');
|
||||
// The API cuts at the limit before any order: a small one would sort and
|
||||
// filter a handful of arbitrary photos (a 360° filter could find none).
|
||||
assert.equal(url.searchParams.get('limit'), '100', "the API's most");
|
||||
assert.match(url.searchParams.get('fields'), /computed_geometry/);
|
||||
assert.match(url.searchParams.get('fields'), /is_pano/);
|
||||
});
|
||||
|
||||
@@ -174,6 +174,15 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
current = null;
|
||||
}
|
||||
|
||||
/** A playing sequence keeps stepping through (and downloading) images. */
|
||||
function stopPlayback() {
|
||||
try {
|
||||
viewer?.getComponent('sequence')?.stop();
|
||||
} catch {
|
||||
/* sequence component not available */
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async mount(host) {
|
||||
if (!host) throw new Error('Street Level viewer has no host element');
|
||||
@@ -195,15 +204,12 @@ export function createMapillaryViewer({ source, render } = {}) {
|
||||
close() {
|
||||
pendingOpen = null;
|
||||
current = null;
|
||||
// A playing sequence would keep stepping through (and downloading)
|
||||
// images in the hidden viewer.
|
||||
try {
|
||||
viewer?.getComponent('sequence')?.stop();
|
||||
} catch {
|
||||
/* sequence component not available */
|
||||
}
|
||||
// Hidden, the viewer must not keep playing.
|
||||
stopPlayback();
|
||||
},
|
||||
|
||||
stopPlayback,
|
||||
|
||||
unmount() {
|
||||
pendingOpen = null;
|
||||
// A build still waiting for the library gives up when it arrives.
|
||||
|
||||
@@ -51,7 +51,7 @@ export function filtersOf(provider) {
|
||||
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
|
||||
* @property {() => void} [clearSequence]
|
||||
* @property {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats]
|
||||
* @property {() => Array<{id: string, parts: Array<Array<[number, number]>>}>} [coverageLines] The coverage lines drawn now, as [lon, lat] parts (diagnostics and the browser gate).
|
||||
* @property {() => Array<{id: string, parts: Array<Array<[number, number]>>}>} [coverageLines] The coverage lines drawn now, as [lon, lat] parts (diagnostics).
|
||||
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal, radiusM?: number}) => Promise<string|null>} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError). `radiusM` asks for a wider search than NEAREST_RADIUS_M; a provider that cannot search wider ignores it.
|
||||
* @property {ViewerAdapter} viewer
|
||||
*
|
||||
|
||||
@@ -175,6 +175,11 @@ export function createViewerHost({ state, parts }) {
|
||||
active?.adapter.resize();
|
||||
}
|
||||
|
||||
/** Stop a sequence playing in the open photo (adapters that play). */
|
||||
function stopPlayback() {
|
||||
active?.adapter.stopPlayback?.();
|
||||
}
|
||||
|
||||
function setRenderMode(mode) {
|
||||
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
|
||||
active?.adapter.setRenderMode?.(state.street.renderMode);
|
||||
@@ -197,5 +202,5 @@ export function createViewerHost({ state, parts }) {
|
||||
active = null;
|
||||
}
|
||||
|
||||
return { attach, open, close, resize, setRenderMode, unmount };
|
||||
return { attach, open, close, resize, setRenderMode, stopPlayback, unmount };
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import './testSupport/registerDom.mjs';
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStreetLevelLayer } from './layers/streetLevel/index.js';
|
||||
import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetLevelFakes.mjs';
|
||||
import { showStreetLevel } from './voice/gevActions.js';
|
||||
import { installViews } from './app/embed.js';
|
||||
import { ShareRestoration } from './ui/shareRestoration.js';
|
||||
import { createView } from './view/index.js';
|
||||
import { StreetLevelControls } from './ui/streetLevelControls.js';
|
||||
import { createApp, nextTick } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import StreetLevelPanel from './ui/views/panels/StreetLevelPanel.vue';
|
||||
import { useStreetLevelStore } from './ui/stores/streetLevel.js';
|
||||
|
||||
// Street-level request lifecycle across the action, the layer, links and the
|
||||
// panel. Offline: providers and camera flights are fixtures.
|
||||
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise(done => { resolve = done; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
async function harness(t, { nearestImage = async () => 'A', failImage = null, billed = false, extraProviders = [] } = {}) {
|
||||
document.body.className = '';
|
||||
document.body.replaceChildren();
|
||||
const provider = fakeStreetLevelProvider({ nearestImage });
|
||||
provider.billedPerImage = billed;
|
||||
const originalCreate = provider.create;
|
||||
const opened = [];
|
||||
provider.create = context => {
|
||||
const instance = originalCreate(context);
|
||||
let pose;
|
||||
instance.viewer.onPose = listener => { pose = listener; return () => { pose = null; }; };
|
||||
instance.viewer.open = async id => {
|
||||
opened.push(id);
|
||||
if (id === failImage) throw new Error('Fixture image load failed');
|
||||
pose?.({ imageId: id });
|
||||
};
|
||||
return instance;
|
||||
};
|
||||
const viewer = fakeCesiumViewer();
|
||||
viewer.canvas = viewer.scene.canvas;
|
||||
viewer.canvas.setPointerCapture = () => {};
|
||||
const layer = createStreetLevelLayer({ providers: [provider, ...extraProviders] });
|
||||
layer.init(viewer);
|
||||
layer.enable(viewer);
|
||||
const host = document.body.appendChild(document.createElement('div'));
|
||||
layer.attachViewerHost(host);
|
||||
t.after(() => { layer.destroy(); document.body.className = ''; document.body.replaceChildren(); });
|
||||
await settle();
|
||||
assert.equal(layer.providerStatus('mapillary').billed, billed);
|
||||
const dataManager = {
|
||||
layers: new Map([['street-level', { module: layer }]]),
|
||||
getAll: () => [{ id: 'street-level', enabled: true }],
|
||||
isEnabled: () => true,
|
||||
getLayerParams: () => ({ mapillary: true }),
|
||||
setLayerParams: (_id, params) => layer.setParams(params),
|
||||
};
|
||||
const styleManager = { _navigationGeneration: 1 };
|
||||
const flights = [];
|
||||
const context = {
|
||||
viewer, dataManager, styleManager,
|
||||
runOptions: { signal: new AbortController().signal },
|
||||
runGevAction: async (name, args) => {
|
||||
assert.equal(name, 'fly_to_location');
|
||||
flights.push(args);
|
||||
return { ok: true, latitude: 48, longitude: 2, label: 'fixture point' };
|
||||
},
|
||||
};
|
||||
let result;
|
||||
const run = async (name, args) => {
|
||||
if (name !== 'show_street_level') return { ok: true };
|
||||
result = await showStreetLevel(args, context);
|
||||
return result;
|
||||
};
|
||||
const view = createView({ camera: { lat: 48, lon: 2 }, layers: ['street-level'], street_level: { provider: 'mapillary', lat: 48, lon: 2 } });
|
||||
return { layer, viewer, dataManager, context, run, view, opened, flights, result: () => result };
|
||||
}
|
||||
|
||||
test('a landed link does not reclaim a camera the user moved while its layers load', async t => {
|
||||
const h = await harness(t);
|
||||
const location = { search: '', hash: '' };
|
||||
const shell = h.context.styleManager;
|
||||
shell.initialRestorePromise = Promise.resolve();
|
||||
const remove = installViews({ ...h, shell, location, windowRef: window });
|
||||
t.after(remove);
|
||||
await settle();
|
||||
location.hash = '#sl=mapillary,48,2';
|
||||
const layers = deferred();
|
||||
const landed = deferred();
|
||||
const restoration = Object.assign(Object.create(ShareRestoration.prototype), {
|
||||
_shareRestoreGeneration: 1, _disposed: false,
|
||||
shareLinkManager: {
|
||||
applyState: async () => { landed.resolve(); return { camera: 'applied' }; },
|
||||
completeInitialRestore() {},
|
||||
},
|
||||
_sharedVessel: () => null,
|
||||
});
|
||||
const restoring = restoration._runShareRestore({}, {
|
||||
applyCamera: true, navigationToken: 1, layersPromise: layers.promise,
|
||||
settle: detail => window.dispatchEvent(new CustomEvent('gev:share-navigation-restore-settled', { detail })),
|
||||
});
|
||||
await landed.promise;
|
||||
shell._navigationGeneration++;
|
||||
layers.resolve([]);
|
||||
await restoring;
|
||||
await settle(); await settle();
|
||||
assert.equal(h.flights.length, 0, 'an old landed link must not fly after later navigation');
|
||||
});
|
||||
|
||||
test('a replacement from another source keeps EXPAND', async t => {
|
||||
const h = await harness(t, {
|
||||
extraProviders: [fakeStreetLevelProvider({ id: 'panoramax', pickPrefix: 'pm:', nearestImage: async () => 'B' })],
|
||||
});
|
||||
const root = document.body.appendChild(document.createElement('div'));
|
||||
root.innerHTML = '<button id="sl-status"></button><div id="sl-viewer-wrap"><div id="sl-viewer"></div></div><button id="sl-viewer-expand"></button>';
|
||||
const wrap = root.querySelector('#sl-viewer-wrap');
|
||||
let fullscreen = null;
|
||||
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen });
|
||||
const oldExit = document.exitFullscreen;
|
||||
// As a browser does: the change is announced after the switch.
|
||||
const announce = () => setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0);
|
||||
document.exitFullscreen = async () => { fullscreen = null; announce(); };
|
||||
wrap.requestFullscreen = async () => { fullscreen = wrap; announce(); };
|
||||
const store = { attach: () => 1, detach() {}, publish() {}, setExpandAvailable() {}, setExpanded() {}, setEmbedOverlay() {} };
|
||||
const controls = new StreetLevelControls({ root, layer: h.layer, actions: {}, store });
|
||||
const off = h.layer.subscribe(state => controls.render(state));
|
||||
t.after(() => { off(); controls.destroy(); delete document.fullscreenElement; document.exitFullscreen = oldExit; });
|
||||
assert.equal(await h.layer.openImage('mapillary', 'previous', { frame: false }), true);
|
||||
await controls._toggleExpanded();
|
||||
assert.equal(controls.isViewerExpanded(), true);
|
||||
const result = await showStreetLevel({ provider: 'panoramax', latitude: 48, longitude: 2 }, h.context);
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(h.layer.getUIState().street.providerId, 'panoramax');
|
||||
assert.equal(controls.isViewerExpanded(), true, 'replacement must preserve the expanded viewing surface');
|
||||
});
|
||||
|
||||
for (const outcome of ['empty', 'error', 'image']) {
|
||||
test(`a replacement retired by a navigation keeps the open photo (${outcome})`, async t => {
|
||||
const lookup = deferred();
|
||||
const entered = deferred();
|
||||
const h = await harness(t, { nearestImage: async () => {
|
||||
entered.resolve(); await lookup.promise;
|
||||
if (outcome === 'error') throw new Error('Fixture lookup failure');
|
||||
return outcome === 'image' ? 'B' : null;
|
||||
} });
|
||||
await h.layer.openImage('mapillary', 'A', { frame: false });
|
||||
const action = showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, h.context);
|
||||
await entered.promise;
|
||||
h.context.styleManager._navigationGeneration++;
|
||||
lookup.resolve();
|
||||
const result = await action;
|
||||
assert.ok(result.cancelled || result.superseded);
|
||||
assert.deepEqual(h.opened, ['A']);
|
||||
assert.equal(h.layer.getUIState().street.open, true, 'retired lookup must preserve A');
|
||||
assert.equal(h.layer.getUIState().street.error, null, 'retired lookup must not publish an error');
|
||||
});
|
||||
}
|
||||
|
||||
test('a failed replacement shows its error inside the expanded viewer', async t => {
|
||||
const h = await harness(t, { nearestImage: async () => 'B', failImage: 'B' });
|
||||
const root = document.body.appendChild(document.createElement('div'));
|
||||
const pinia = createPinia();
|
||||
const app = createApp(StreetLevelPanel); app.use(pinia); app.mount(root);
|
||||
const store = useStreetLevelStore(pinia);
|
||||
const wrap = root.querySelector('#sl-viewer-wrap');
|
||||
let fullscreen = null;
|
||||
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen });
|
||||
const oldExit = document.exitFullscreen;
|
||||
// As a browser does: the change is announced after the switch.
|
||||
const announce = () => setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0);
|
||||
document.exitFullscreen = async () => { fullscreen = null; announce(); };
|
||||
wrap.requestFullscreen = async () => { fullscreen = wrap; announce(); };
|
||||
const controls = new StreetLevelControls({ root, layer: h.layer, actions: {}, store });
|
||||
const off = h.layer.subscribe(state => controls.render(state));
|
||||
t.after(() => { off(); controls.destroy(); app.unmount(); delete document.fullscreenElement; document.exitFullscreen = oldExit; });
|
||||
await h.layer.openImage('mapillary', 'A', { frame: false });
|
||||
await nextTick();
|
||||
await controls._toggleExpanded();
|
||||
await settle();
|
||||
const result = await showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, h.context);
|
||||
await nextTick();
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(controls.isViewerExpanded(), true);
|
||||
assert.equal(document.fullscreenElement, wrap);
|
||||
assert.equal(h.layer.getUIState().street.imageId, 'A');
|
||||
assert.match(h.layer.getUIState().street.error, /Fixture image load failed/);
|
||||
assert.equal(wrap.contains(root.querySelector('#sl-error')), false);
|
||||
const error = wrap.querySelector('#sl-viewer-error');
|
||||
assert.match(error.textContent, /Fixture image load failed/);
|
||||
assert.equal(error.hidden, false, 'error must be visible inside fullscreen');
|
||||
});
|
||||
@@ -263,7 +263,9 @@ export class ShareRestoration {
|
||||
// the shared flight having landed.
|
||||
const vessel = this._sharedVessel(state);
|
||||
if (vessel) void this._restoreSharedVessel(vessel, current);
|
||||
settle({ status: 'settled', share, layers, tracking });
|
||||
// The token tells listeners whether anyone navigated after the
|
||||
// restore's flight landed (while the layers were still loading).
|
||||
settle({ status: 'settled', share, layers, tracking, navigationToken });
|
||||
} catch (error) {
|
||||
if (!current()) return;
|
||||
this.shareLinkManager.completeInitialRestore(state.restoreToken);
|
||||
|
||||
@@ -142,7 +142,7 @@ const vThumb = {
|
||||
<button id="sl-viewer-close" class="sl-viewer-x" type="button" :title="t('Close the image and deselect it on the map')" :aria-label="t('Close the street-level image')">×</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sl-viewer-error" class="sl-error" role="alert" :hidden="!embedOverlay || !view?.error"><span aria-hidden="true">⚠</span> {{ view?.error || '' }}</div>
|
||||
<div id="sl-viewer-error" class="sl-error" role="alert" :hidden="!(embedOverlay || expanded) || !view?.error"><span aria-hidden="true">⚠</span> {{ view?.error || '' }}</div>
|
||||
<div class="sl-viewer-stage">
|
||||
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" :hidden="!viewer.loading"></div>
|
||||
<div id="sl-viewer" class="sl-viewer" :aria-label="t('Street-level viewer')"></div>
|
||||
@@ -159,7 +159,7 @@ const vThumb = {
|
||||
<span class="cctv-summary-label">{{ t('PROVIDERS') }}</span>
|
||||
<div id="sl-provider-chips" class="sl-chips"><RailChips :chips="view ? view.providers : []" /></div>
|
||||
</div>
|
||||
<div id="sl-error" class="sl-error" role="alert" :hidden="embedOverlay || !view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
|
||||
<div id="sl-error" class="sl-error" role="alert" :hidden="embedOverlay || expanded || !view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
|
||||
<!-- A missing key gates the filters only: the chips above stay
|
||||
focusable so their tooltip can say how to add the key. -->
|
||||
<fieldset id="sl-controls" class="sl-controls" :disabled="Boolean(view?.controlsDisabled)" :hidden="!shows.pano && !shows.since">
|
||||
|
||||
+4
-3
@@ -354,10 +354,11 @@ export function createView({
|
||||
);
|
||||
if (needs) map = needs;
|
||||
// The point defaults to where the camera looks: a view "of X" opens
|
||||
// photos at X, also when the camera sits behind it.
|
||||
// photos at X, also when the camera sits behind it. It is a pair: half
|
||||
// of one is not mixed with the camera's other half.
|
||||
const given = [latitude(streetLevel.lat), longitude(streetLevel.lon)];
|
||||
const seen = lookAtPoint(camera);
|
||||
const lat = latitude(streetLevel.lat) ?? seen.lat;
|
||||
const lon = longitude(streetLevel.lon) ?? seen.lon;
|
||||
const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given;
|
||||
street = Object.freeze({
|
||||
...(provider ? { provider } : {}),
|
||||
lat: round(clamp(lat, -90, 90), 5),
|
||||
|
||||
@@ -208,6 +208,16 @@ test('street-level photos travel in the link, light their source and bring their
|
||||
assert.equal(streetLevelFromParams(new URLSearchParams('sl=bing,1,2')), null);
|
||||
assert.equal(streetLevelFromParams(new URLSearchParams('lat=1')), null);
|
||||
assert.equal(createView({ camera: { lat: 1, lon: 2 } }).street_level, null);
|
||||
// Half a point is not mixed with the camera's other half.
|
||||
const half = createView({
|
||||
camera: { lat: 1, lon: 2 },
|
||||
street_level: { provider: 'mapillary', lat: 40 },
|
||||
});
|
||||
assert.deepEqual(half.street_level, {
|
||||
provider: 'mapillary',
|
||||
lat: 1,
|
||||
lon: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('street-level photos: a tilted camera opens them where it looks, a link cannot pair them with the wrong map, and follow is refused', async () => {
|
||||
|
||||
@@ -58,32 +58,11 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// control_cockpit states the own-layer entry rule (Contacts not required).
|
||||
// move_camera tilt takes an optional pitchDeg angle, described as
|
||||
// absolute with its -45 default, with steps sent as a direction.
|
||||
// show_street_level (map, source, photo) is the only change on top of main.
|
||||
SHOW_STREET_LEVEL_PAYLOAD_DIGEST,
|
||||
);
|
||||
// Every shipped tool is byte-identical to the previous release: the new
|
||||
// tool is the whole difference, so a later edit cannot hide in a re-pin.
|
||||
const shipped = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify(
|
||||
stable(
|
||||
GEV_REALTIME_TOOLS.filter(
|
||||
(tool) =>
|
||||
!['set_cyber_sonar', 'show_street_level'].includes(tool.name),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.digest('hex');
|
||||
assert.equal(
|
||||
shipped,
|
||||
'6a0d154e68beb787c4e20f12d29a5c86577dc343f3e71d8b5651998ea45d8c3c',
|
||||
// show_street_level (map, source, photo) adds one tool.
|
||||
'0c27f62062001e6a07241cd301c3d357f55e9b74f66cd2904d16590510337468',
|
||||
);
|
||||
});
|
||||
|
||||
const SHOW_STREET_LEVEL_PAYLOAD_DIGEST =
|
||||
'0c27f62062001e6a07241cd301c3d357f55e9b74f66cd2904d16590510337468';
|
||||
|
||||
test('descriptions customize wording without changing immutable shared arguments', () => {
|
||||
const descriptions = {
|
||||
fly_to_location: {
|
||||
|
||||
@@ -143,6 +143,8 @@ function resolveFly(args, { pointer, referents }) {
|
||||
entity: {
|
||||
layerId: target.layerId,
|
||||
id: target.id,
|
||||
// The answer names it, not its id ("near cam-9").
|
||||
...(target.label ? { label: target.label } : {}),
|
||||
...(hasPosition(target) ? { lat: target.lat, lon: target.lon } : {}),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -104,7 +104,7 @@ test('referents: numbered items resolve for track, fly and context', () => {
|
||||
const last = resolveDeicticArgs('track_entity', { query: 'x', referent: -1 }, { referents });
|
||||
assert.equal(last.args.query, 'ccc333', '-1 is the last one');
|
||||
const fly = resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents });
|
||||
assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111' } }, 'moving contacts are located at run time');
|
||||
assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111', label: 'SWA12' } }, 'moving contacts are located at run time');
|
||||
const context = resolveDeicticArgs('get_entity_context', { referent: 3 }, { referents });
|
||||
assert.equal(context.args.scope, 'target');
|
||||
assert.equal(context.args.target.label, 'AAL9');
|
||||
|
||||
+32
-40
@@ -3813,24 +3813,26 @@ function streetLevelPanelHidden() {
|
||||
}
|
||||
|
||||
/**
|
||||
* "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
|
||||
* layer on and open the nearest image. One source at a time, as the panel
|
||||
* does; the choice persists like a chip click. The key is checked before
|
||||
* anything changes, and each stage reports its own failure so speech can say
|
||||
* which part did not happen. With no place at all, the view centre is used.
|
||||
* `autoOpen: false` (links, not the model) stops short of loading a photo
|
||||
* from a source that bills every load: the panel is armed and the OPEN
|
||||
* button is one click away.
|
||||
* "Show me <place> on Street View / on Mapillary": fly there, light the source
|
||||
* (and its map) and open the nearest image. Each stage reports its own
|
||||
* failure, so speech can say which part did not happen. `autoOpen: false`
|
||||
* (links) stops short of a billed load: the panel is armed for OPEN.
|
||||
*/
|
||||
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);
|
||||
// A photo held up while the request switches its source is let go however
|
||||
// the request ends: replaced by then, or closed if its source stayed off.
|
||||
let releaseViewer = null;
|
||||
const holdViewer = (layerModule) => {
|
||||
releaseViewer ??= layerModule.holdViewer?.() ?? null;
|
||||
};
|
||||
try {
|
||||
return await requestStreetLevel(args, { ...context, owner });
|
||||
return await requestStreetLevel(args, { ...context, owner, holdViewer });
|
||||
} finally {
|
||||
releaseViewer?.();
|
||||
owner.stop();
|
||||
}
|
||||
}
|
||||
@@ -3840,9 +3842,9 @@ async function requestStreetLevel(
|
||||
{
|
||||
dataManager,
|
||||
styleManager,
|
||||
placeSearch,
|
||||
runGevAction,
|
||||
owner,
|
||||
holdViewer = () => {},
|
||||
runOptions = {},
|
||||
current = () => true,
|
||||
reportProgress = () => {},
|
||||
@@ -3868,11 +3870,14 @@ async function requestStreetLevel(
|
||||
const query = String(args.query || '').trim();
|
||||
const latitude = Number(args.latitude);
|
||||
const longitude = Number(args.longitude);
|
||||
const coordinatesGiven = args.latitude != null && args.longitude != null;
|
||||
const hasCoordinates =
|
||||
args.latitude != null &&
|
||||
args.longitude != null &&
|
||||
Number.isFinite(latitude) &&
|
||||
Number.isFinite(longitude);
|
||||
coordinatesGiven && Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180;
|
||||
if (coordinatesGiven && !hasCoordinates)
|
||||
return fail(
|
||||
'location',
|
||||
'Latitude must be within ±90° and longitude within ±180°',
|
||||
);
|
||||
// A referent that is a layer entity (deixis): located when the flight starts.
|
||||
const entity =
|
||||
args.entity && typeof args.entity === 'object' && args.entity.id
|
||||
@@ -3952,29 +3957,11 @@ async function requestStreetLevel(
|
||||
atViewCentre = !query && !hasCoordinates && !entity;
|
||||
let point = null;
|
||||
let location = null;
|
||||
if (args.fly === false || atViewCentre) {
|
||||
// No place at all: the layer looks at the ground under the screen centre,
|
||||
// as the OPEN button does. `fly: false` (tests) keeps the camera for a
|
||||
// point given by its coordinates.
|
||||
if (atViewCentre || (args.fly === false && hasCoordinates)) {
|
||||
if (hasCoordinates) point = { lat: latitude, lon: longitude };
|
||||
else if (entity) {
|
||||
// Where it is now, as the flying path does: the coordinates a list
|
||||
// saved go stale as soon as the item moves or unloads.
|
||||
const located = locateLayerEntity(dataManager, entity);
|
||||
if (!located) return fail('location', `${label} is no longer loaded`);
|
||||
point = { lat: located.lat, lon: located.lon };
|
||||
} else if (query) {
|
||||
reportProgress('search', query);
|
||||
const outcome = await placeSearch.geocode(query, {
|
||||
signal: runOptions.signal,
|
||||
});
|
||||
const place = outcome?.place;
|
||||
if (!current())
|
||||
return fail('location', 'Street-level request was cancelled');
|
||||
if (!Number.isFinite(place?.lat) || !Number.isFinite(place?.lng))
|
||||
return fail('location', `Could not find ${query}`);
|
||||
point = { lat: place.lat, lon: place.lng };
|
||||
label = place.label || query;
|
||||
}
|
||||
// No place at all: the layer looks at the ground under the screen
|
||||
// centre, as the OPEN button does.
|
||||
} else {
|
||||
location = await runGevAction(
|
||||
'fly_to_location',
|
||||
@@ -3995,8 +3982,10 @@ async function requestStreetLevel(
|
||||
location?.error || `Could not arrive at ${location?.label || label}`,
|
||||
{ cancelled: !current() || Boolean(location?.cancelled), location },
|
||||
);
|
||||
// An item keeps its name: the flight to it reports only coordinates.
|
||||
label = entity ? label : location.label || label;
|
||||
// An item, or a named place flown to by its coordinates, keeps its name:
|
||||
// such a flight reports only coordinates.
|
||||
label =
|
||||
entity || (query && hasCoordinates) ? label : location.label || label;
|
||||
if (
|
||||
!Number.isFinite(location.latitude) ||
|
||||
!Number.isFinite(location.longitude)
|
||||
@@ -4037,7 +4026,10 @@ async function requestStreetLevel(
|
||||
if (stop) return stop;
|
||||
}
|
||||
|
||||
// Light the source the way a chip click does, so the choice is kept.
|
||||
// Light the source the way a chip click does, so the choice is kept. An
|
||||
// open photo of another source stays up until this request replaces it.
|
||||
if (layerModule.getUIState?.()?.street?.open === true)
|
||||
holdViewer(layerModule);
|
||||
const switches = Object.fromEntries(
|
||||
providerIds.map((id) => [id, id === provider]),
|
||||
);
|
||||
|
||||
@@ -6660,11 +6660,11 @@ function createStreetLevelHarness({
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Like the layer: a replacing lookup that opens nothing closes the photo.
|
||||
// Like the layer: a lookup its caller gave up leaves the panel alone,
|
||||
// and a replacing lookup that opens nothing closes the photo.
|
||||
if (options.proceed && options.proceed() !== true) return false;
|
||||
const closeReplaced = () => {
|
||||
if (!options.replace || !street.open) return;
|
||||
order.push('close');
|
||||
Object.assign(street, { open: false, imageId: null, error: null, nearestOutcome: null });
|
||||
if (options.replace && street.open) layer.closeViewer();
|
||||
};
|
||||
if (imageryError) {
|
||||
closeReplaced();
|
||||
@@ -6678,8 +6678,6 @@ function createStreetLevelHarness({
|
||||
street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`;
|
||||
return false;
|
||||
}
|
||||
// Found: it loads only if the request still wants it.
|
||||
if (options.proceed && options.proceed() !== true) return false;
|
||||
street.open = true;
|
||||
street.imageId = nearest;
|
||||
street.title = '10th St, Sacramento';
|
||||
@@ -6762,6 +6760,14 @@ test('show_street_level flies first, then switches the map, lights the source, t
|
||||
assert.equal(result.imageId, 'pano-a');
|
||||
assert.equal(result.title, '10th St, Sacramento');
|
||||
assert.equal(result.flew, true);
|
||||
// A name given with its coordinates is what the answer says.
|
||||
const named = createStreetLevelHarness();
|
||||
const naming = named.runner('show_street_level', {
|
||||
query: 'Capitol Mall', latitude: 38.5799, longitude: -121.4937, provider: 'google',
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
named.finishFlight();
|
||||
assert.equal((await naming).label, 'Capitol Mall');
|
||||
});
|
||||
|
||||
test('show_street_level on Mapillary keeps the map, searches at the default radius and skips what is already on', async () => {
|
||||
@@ -6799,6 +6805,11 @@ test('show_street_level reports each stage it could not pass', async () => {
|
||||
assert.equal(none.label, 'the view centre');
|
||||
assert.deepEqual(centre.order, ['params:google:voice', 'open:google:250:false:view-centre']);
|
||||
|
||||
const offGlobe = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
|
||||
const outOfRange = await offGlobe.runner('show_street_level', { latitude: 95, longitude: 2, provider: 'google' });
|
||||
assert.deepEqual([outOfRange.ok, outOfRange.stage], [false, 'location']);
|
||||
assert.deepEqual(offGlobe.order, [], 'nothing flies, switches or looks up');
|
||||
|
||||
const unknown = await createStreetLevelHarness().runner('show_street_level', {
|
||||
latitude: 1, longitude: 2, provider: 'openstreetcam', fly: false,
|
||||
});
|
||||
@@ -6865,15 +6876,6 @@ test('show_street_level opens photos of a numbered item or a pointed spot throug
|
||||
assert.equal(result.ok, true, result.error);
|
||||
assert.equal(result.label, 'Camera 9');
|
||||
assert.deepEqual(order, ['fly', 'params:google:voice', 'open:google:250:false']);
|
||||
// Without a flight the entity's own position is used.
|
||||
const still = withCamera(createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }));
|
||||
const kept = await still.runner('show_street_level', {
|
||||
provider: 'google',
|
||||
fly: false,
|
||||
entity: { layerId: 'cctv', id: 'cam-9', lat: 38.58, lon: -121.5 },
|
||||
});
|
||||
assert.equal(kept.ok, true, kept.error);
|
||||
assert.deepEqual(still.order, ['params:google:voice', 'open:google:250:false']);
|
||||
});
|
||||
|
||||
test('show_street_level hands the point from a named place, a preset and a curated POI to the lookup', async () => {
|
||||
@@ -7003,43 +7005,9 @@ test('show_street_level tells a failed enable, a delisted source, a key refused
|
||||
assert.deepEqual(unconfigured.order, [], 'nothing changed');
|
||||
});
|
||||
|
||||
test('show_street_level without a flight geocodes a name, locates an item, and reports what it cannot place', async () => {
|
||||
test('show_street_level reports a flight that lands without a position', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const hit = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
|
||||
const points = [];
|
||||
const open = hit.layer.openNearest;
|
||||
hit.layer.openNearest = (point, options) => {
|
||||
points.push(point);
|
||||
return open(point, options);
|
||||
};
|
||||
const runner = createGevActionRunner({
|
||||
viewer: createVoiceNavigationHarness().viewer,
|
||||
styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) },
|
||||
dataManager: hit.dataManager,
|
||||
placeSearch: {
|
||||
geocode: async (query) =>
|
||||
query === 'Capitol Mall'
|
||||
? { place: { lat: 38.5792, lng: -121.4958, label: 'Capitol Mall, Sacramento' } }
|
||||
: { place: null, answered: true },
|
||||
},
|
||||
});
|
||||
const found = await runner('show_street_level', { query: 'Capitol Mall', provider: 'mapillary', fly: false });
|
||||
assert.equal(found.ok, true, found.error);
|
||||
assert.equal(found.label, 'Capitol Mall, Sacramento');
|
||||
assert.deepEqual(points.at(-1), { lat: 38.5792, lon: -121.4958 });
|
||||
assert.equal(found.flew, false);
|
||||
|
||||
const miss = await runner('show_street_level', { query: 'Atlantis Boulevard', provider: 'mapillary', fly: false });
|
||||
assert.deepEqual([miss.ok, miss.stage], [false, 'location']);
|
||||
assert.equal(miss.say, "Couldn't find Atlantis Boulevard.");
|
||||
assert.equal(points.length, 1, 'a miss asks for no imagery');
|
||||
|
||||
const gone = await runner('show_street_level', {
|
||||
provider: 'mapillary', fly: false, entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7' },
|
||||
});
|
||||
assert.deepEqual([gone.ok, gone.stage], [false, 'location']);
|
||||
assert.equal(gone.say, "Couldn't find Camera 7.");
|
||||
|
||||
// A flight that reports no coordinates cannot be followed by a lookup.
|
||||
const noPoint = createGevActionRunner({
|
||||
viewer: createVoiceNavigationHarness().viewer,
|
||||
@@ -7105,20 +7073,6 @@ test('show_street_level says "refused its key" when Google already refused it, a
|
||||
assert.match(dark.error, /could not be switched on/);
|
||||
});
|
||||
|
||||
test('show_street_level without a flight needs the item where it is now, not where a list saw it', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const harness = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
|
||||
// The referent carries the coordinates the list saved, but it is no longer loaded.
|
||||
const stale = await harness.runner('show_street_level', {
|
||||
provider: 'google',
|
||||
fly: false,
|
||||
entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7', lat: 38.58, lon: -121.5 },
|
||||
});
|
||||
assert.deepEqual([stale.ok, stale.stage], [false, 'location']);
|
||||
assert.match(stale.error, /no longer loaded/);
|
||||
assert.ok(!harness.order.some((entry) => entry.startsWith('open:')), 'nothing looked up at the stale point');
|
||||
});
|
||||
|
||||
test('activeImageryFilter reports only the narrowing filters the lit source honours', async () => {
|
||||
const { activeImageryFilter } = await import('./gevActions.js');
|
||||
const both = { pano: true, since: true };
|
||||
@@ -7281,32 +7235,6 @@ test('a partial analyst card shows one partial chip', () => {
|
||||
assert.equal(new Set(labels).size, labels.length, JSON.stringify(labels));
|
||||
});
|
||||
|
||||
test('show_street_level superseded during its geocode changes nothing', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const h = createStreetLevelHarness({ enabled: true });
|
||||
let current = true;
|
||||
const runner = createGevActionRunner({
|
||||
viewer: createVoiceNavigationHarness().viewer,
|
||||
styleManager: {
|
||||
mapStackController: { getActiveId: () => 'osm' },
|
||||
setMapStack: async (id) => {
|
||||
h.order.push(`map:${id}`);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
dataManager: h.dataManager,
|
||||
placeSearch: {
|
||||
geocode: async () => {
|
||||
current = false;
|
||||
return { place: { lat: 1, lng: 2, label: 'X' } };
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await runner('show_street_level', { query: 'X', provider: 'google', fly: false }, { isCurrent: () => current });
|
||||
assert.deepEqual(h.order.filter((step) => /^(map|params|open|arm)/.test(step)), [], 'the stale turn switched nothing');
|
||||
assert.deepEqual([result.ok, result.stage, result.cancelled], [false, 'location', true]);
|
||||
});
|
||||
|
||||
test('show_street_level superseded during its flight switches nothing', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const h = createStreetLevelHarness();
|
||||
|
||||
@@ -343,17 +343,9 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in
|
||||
tool('annotate_map').annotations.items.properties.target.description,
|
||||
/"pointer"/,
|
||||
);
|
||||
// Budget: the whole Realtime prompt (instructions + tools), measured in
|
||||
// characters. It was 56,308 after the area/imagery/OSM merge; stating each
|
||||
// policy once (WHERE precedence, AREAS routing, AFTER TOOLS speech) brought
|
||||
// it to 51,646. Voice geometry (resolve_area, find_imagery, osm_query and
|
||||
// the analyst area scopes) raised the ceiling to 55,500; tightening the
|
||||
// AREAS rule and the area tool descriptions left it at 55,103 as measured
|
||||
// below (every tool, osm_query included); main's Sea Marks and Ski Pistes
|
||||
// layers bring it to 55,210, and the tilt pitchDeg argument (with its -45
|
||||
// default) and main's cockpit entry rule to 55,339. show_street_level (the
|
||||
// tool and its routing rule, ~940) raises it to 56,500. A new tool or rule
|
||||
// has to fit under this ceiling.
|
||||
// Budget: the whole Realtime prompt (instructions + every tool, osm_query
|
||||
// included), in characters. Every session pays for it in latency and cost,
|
||||
// so a new tool or rule has to fit under this ceiling or earn a raise.
|
||||
const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length;
|
||||
assert.ok(size <= 56_500, `prompt size ${size}`);
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user