Street Level: a replacement keeps its own state, and a posted view stands down

- Releasing a held viewer frees the old source's adapter without resetting
  the panel, so a replacement's error, outcome and armed OPEN survive; a
  cross-source replacement drops the old photo's details and marker.
- The action holds the open photo only for its own switch-off: a source
  switched by hand during the load stops it and reports cancelled.
- A posted view waiting on terrain watches the camera and stands down after a
  navigation or a globe press; a flat or straight-down camera asks no terrain.
- An arming link opens the panel without persisting the layout, and a posted
  view's street-level step runs as programmatic.
- An off-globe street_level point throws TypeError.
- zh-Hant uses the file's wording for the new strings; README and
  CURRENT-STATE count 35 tools.
This commit is contained in:
German Khokhlov committed 2026-11-11 16:05:26 +04:00
1 parent 45a33b54df
commit 4fd36c44e0
14 files changed
+391 -84

No files matched your search

+1 -1
View File
@@ -384,7 +384,7 @@ Click **GEV MIC**, grant the microphone, and just talk. This is more than a voic
- **🎬 Cinematic framing.** _"Show me the planes overhead"_ pulls the camera back, angles it, and frames the live traffic like a director.
- **🔒 Honest and secure.** The agent only confirms actions that succeeded. Your `OPENAI_API_KEY` never touches the browser; the client only gets a short-lived session token. If you choose **USE CHATGPT OAUTH** in Provider Settings, the local server uses that sign-in only to mint the same kind of short-lived Realtime credential, and refuses the OAuth path for non-loopback clients.
Thirty-two tools, four jobs — the commands below come straight from the product's voice test suite and tool playbook:
Thirty-five tools (thirty-four unless an Overpass server is set), four jobs — the commands below come straight from the product's voice test suite and tool playbook:
**🎥 Direct it** — drone-operator camera verbs:
+14 -9
View File
@@ -5282,14 +5282,19 @@ app.
newer turn, a shell navigation stamp (`_navigationGeneration`, re-taken where
its own flight lands) or a press or wheel on the globe stops it
(`superseded: true`). The same checks run before a found photo loads
(`proceed`), so nothing billed loads for a request that was given up, and a
lookup given up leaves the panel as it was; a photo that opened after either
is closed again.
(`proceed`), so nothing billed loads for a request that was given up. A
lookup given up leaves the panel as it was when the request kept the photo's
source; one that had switched the source off closes that source's photo with
it. A photo that opened after either is closed again.
- **Replacing a photo.** An open photo stays up while the new request looks
(`openNearest` `replace: true`; the viewer is replaced, not closed, so EXPAND
stays, also across sources through `holdViewer`) and closes if nothing
opens, so a place with no imagery shows its own error; a link that only arms
closes it at once. A replacement stops a playing Mapillary sequence.
stays, also across sources through `holdViewer`, which holds only the action's
own switch-off of the photo's source: a source switched by hand meanwhile
still stops the load) and closes if nothing opens, so a place with no imagery
shows its own error; a link that only arms closes it at once. A replacement
from another source drops the old photo's details, and the request's error or
armed OPEN outlives the old viewer. A replacement stops a playing Mapillary
sequence.
- **Outcome.** `noImagery` marks an empty result, `imageryError` a source or
viewer failure and `aim: 'sky'` a sky centre (from `street.nearestOutcome`);
an empty result names a narrowing 360°/flat or SINCE filter the source
@@ -5312,8 +5317,8 @@ error.
Views carry the request as `street_level` (`src/view/index.js`): a source and
a point, which defaults to where the camera looks (below 20 km, at a pitch
steeper than -5° and within 10 km ahead; else the camera's own point, also
for half a point). A `google` source brings `map: photoreal` and refuses
steeper than -5° and within 10 km ahead; else the camera's own point); half a
point is not mixed with the camera's: both coordinates come from that default. A `google` source brings `map: photoreal` and refuses
another map; `street_level` does not combine with `follow`.
`show_in_gods_eye_view`, and the embedded app for a posted view with no
point, measure the view's altitude from the terrain under the camera
@@ -6411,7 +6416,7 @@ and unreachable upstream (502/504) separately from road geometry.
- **Token flow**: browser fetches a short-lived client secret from `/api/realtime/token`; the Vite middleware uses `OPENAI_API_KEY` by default, or the signed-in local ChatGPT/Codex OAuth access token when OAuth was explicitly selected in Provider Settings, and posts the full session config (instructions, tool schemas, VAD, truncation) to `api.openai.com/v1/realtime/client_secrets`. Both long-lived credentials stay server-side; OAuth minting is loopback-only. SDP exchange goes directly to `api.openai.com/v1/realtime/calls` with the ephemeral token. With no OpenAI key the token route answers `503` with code `API_KEY_MISSING`, and the voice control shows `Needs OPENAI_API_KEY — add it in Provider Settings` in the interface language, reworded on a language change, without the microphone-permission hint.
- **Session defaults** (env-tunable): model `gpt-realtime-2` (or `gpt-realtime-2.1-mini` when the MINI tier is selected — see the model-tier entry below), voice `marin`, reasoning effort `low`, semantic VAD with low eagerness, no response interruption, context window truncated to ~3,000 post-instruction tokens with 0.5 retention ratio — the conversational window stays short because map state is fetched live per turn.
- **Thirty-two tools** (argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `show_street_level`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_voice_inactivity_timeout`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `next_iss_pass`, and `next_satellite_pass`.
- **Thirty-five tools** (thirty-four in a session without `OVERPASS_UPSTREAMS`, which adds `osm_query`; argument schemas in `src/voice/actionSchemas.js`, served with their descriptions by `server/providers/openai/tools.js`, executed client-side in `src/voice/gevActions.js`): `fly_to_location`, `select_nearest_aircraft`, `show_street_level`, `adjust_camera_zoom`, `zoom_to_globe`, `set_layer_visibility`, `show_data_layers_menu`, `set_panel_open`, `set_voice_inactivity_timeout`, `set_visual_style`, `get_entity_context`, `get_current_view_state`, `set_hud`, `set_cyber_sonar`, `set_detection`, `set_map_stack`, `set_post_processing`, `control_scene`, `control_cctv`, `set_context_mode`, `control_cockpit`, `control_radio`, `track_entity`, `stop_tracking`, `frame_overhead`, `annotate_map`, `clear_annotations`, `move_camera`, `fly_route`, `analyst_query`, `resolve_area`, `find_imagery`, `osm_query`, `next_iss_pass`, and `next_satellite_pass`.
> **Reading `npm test` totals:** the count depends on the Node major. The two
> GC-bracketed allocation microbenchmarks (`src/data/focusAllocations.test.mjs`
+29 -6
View File
@@ -12,7 +12,10 @@
*/
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import {
announceNavigationAuthority,
watchCameraOwner,
} from '../navigationPolicy.js';
import { t, translateKnown } from '../i18n/index.js';
import { GOOGLE_LABEL } from '../layers/streetLevel/providers/google/policy.js';
import {
@@ -234,10 +237,10 @@ export async function applyView(
},
) {
const steps = [];
const act = async (name, args) => {
const act = async (name, args, options) => {
let result = null;
try {
result = await run(name, args);
result = await run(name, args, options);
} catch (error) {
result = { ok: false, error: error?.message || String(error) };
}
@@ -270,9 +273,12 @@ export async function applyView(
if (view.street_level) {
// The photos frame the camera; when the action fails before flying, the
// view's own camera still applies.
// A posted view replays someone else's view, as a link does: the source
// it lights is not saved as the visitor's own choice.
const result = await act(
'show_street_level',
streetLevelArgs(view.street_level, { autoOpen }),
{ origin: 'programmatic' },
);
if (result?.ok !== true) {
// Overtaken (a click on the map, a newer request): hands off.
@@ -397,8 +403,10 @@ export function installViews({
// OPEN must be in view, but embed mode hides the rail that holds it: in
// a frame a posted view is the way to open a photo.
if (result?.armed === true && !isEmbedded(location)) {
// Opened for this link only: the recipient's saved layout stays theirs.
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
explicit: true,
persist: false,
});
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
@@ -490,10 +498,25 @@ export function installViews({
queue = queue.then(async () => {
if (signal?.aborted) return;
if (unplaced && view.street_level) {
const point = await groundedLookAt(view.camera, terrain, signal).catch(
() => null,
);
// Whoever takes the camera while the terrain answers (a drag, a
// search, the assistant) keeps it: the view stands down.
const owner = watchCameraOwner(viewer, shell);
const point = await groundedLookAt(view.camera, terrain, signal)
.catch(() => null)
.finally(() => owner.stop());
if (signal?.aborted) return;
if (owner.taken()) {
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
steps: [{ step: 'street_level', ok: false, superseded: true }],
},
replyOrigin,
);
return;
}
if (point)
view = createView({
...event.data.view,
+15 -2
View File
@@ -85,6 +85,19 @@ test('a view is applied through the app actions in order', async () => {
assert.ok(steps.some((step) => step.step === 'camera'));
});
test("a view's street-level step replays the sender's view: nothing it switches is the visitor's choice", async () => {
const { options, run } = recorder();
await applyView(
createView({
camera: { lat: 48.86, lon: 2.29 },
street_level: { provider: 'panoramax', lat: 48.8584, lon: 2.2945 },
}),
{ viewer: fakeViewer(), dataManager: fakeLayers(new Set()), run },
);
const [, runOptions] = options.find(([name]) => name === 'show_street_level');
assert.equal(runOptions?.origin, 'programmatic');
});
test('a followed entity is retried until its layer has it', async () => {
let tries = 0;
const { run } = recorder({
@@ -739,8 +752,8 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s
]);
assert.deepEqual(
panels,
[['street-level-panel', false, { explicit: true }]],
'the panel opens so OPEN is in view',
[['street-level-panel', false, { explicit: true, persist: false }]],
"the panel opens so OPEN is in view, without saving the layout as the recipient's",
);
assert.deepEqual(revealed, ['street-level-panel'], 'its phone sheet too');
// Adding ?embed=1 to a published link does not lift the gate: the page is
+4 -4
View File
@@ -3330,7 +3330,7 @@
"Refresh map still": "重新整理地圖靜態畫面",
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。",
"Press OPEN to load the photo": "點擊“打開”載入照片",
"Press OPEN to load the photo": "點擊“打開”加載照片",
"Captured {month}": "拍攝於 {month}",
"this spot": "此處",
"the view centre": "視圖中心",
@@ -3338,9 +3338,9 @@
"Street level at {place}": "{place} 的街景",
"Switching map": "正在切換地圖",
"Opening imagery": "正在打開影像",
"Street View is ready: press {open} to load it": "街景已就緒:按 {open} 載入",
"{source} cannot open this link's street-level photo without a working key": "沒有有效金鑰,{source} 無法開啟此連結的街景照片",
"Street-level photos are hidden in the current view. Leave it first.": "目前的檢視隱藏了街景照片。請先離開此檢視。",
"Street View is ready: press {open} to load it": "街景已就緒:按 {open} 加載",
"{source} cannot open this link's street-level photo without a working key": "沒有有效密鑰,{source} 無法打開此鏈接的街景照片",
"Street-level photos are hidden in the current view. Leave it first.": "當前視圖隱藏了街景照片。請先退出該視圖。",
"STATIONS": "太空站",
"Crewed stations and the craft that visit them": "載人太空站及來訪飛行器",
"IMAGING": "成像",
+20 -10
View File
@@ -266,11 +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;
/** Sources whose open photo a replacing request keeps up while it switches them off. */
const heldViewers = new Map();
function deactivate(entry) {
if (!viewerHolds) parts.viewerHost.unmount(entry.def.id);
if (!heldViewers.has(entry.def.id)) parts.viewerHost.unmount(entry.def.id);
entry.instance.deactivate();
parts.credits.hide(state.viewer, entry.def);
}
@@ -854,19 +854,29 @@ export function createStreetLevelLayer({
},
closeViewer,
/**
* Keep the open photo up while a request that replaces it switches the
* source, so EXPAND stays. Release closes it if its source is still off.
* Keep the open photo up while a request that replaces it switches its
* source off, so EXPAND stays; only that source's own switch-off is held,
* a source switched by hand meanwhile still stops a load. Release frees
* the old viewer.
*/
holdViewer() {
viewerHolds += 1;
const id = state.street.open ? state.street.providerId : null;
if (!id) return () => {};
heldViewers.set(id, (heldViewers.get(id) || 0) + 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);
const left = heldViewers.get(id) - 1;
if (left > 0) return void heldViewers.set(id, left);
heldViewers.delete(id);
if (state.providers.get(id)?.on !== false) return;
// Its photo still up (the request gave up) closes with its source;
// otherwise the request replaced or closed it, and the panel keeps
// what that request left (an error, an armed OPEN).
if (state.street.open && state.street.providerId === id)
parts.viewerHost.unmount(id);
else parts.viewerHost.release(id);
};
},
/**
@@ -76,8 +76,8 @@ export const SEQUENCE_IMAGE_FIELDS =
/**
* Images asked for in a nearest-image search: the API cuts at the limit
* before any order, so this is its most, and the distance sort and the
* imagery filter choose among all of the spot's photos.
* before any order, so this is its most; the distance sort and the imagery
* filter choose among the photos this one request returns.
*/
export const NEAREST_LIMIT = 100;
/**
+36 -1
View File
@@ -73,6 +73,19 @@ export function createViewerHost({ state, parts }) {
active.unsubscribe();
active.adapter.unmount();
active = null;
// The photo being replaced belonged to that source: none of its details
// (id, place, author, link) describe the one loading now.
const { open, loading, error, providerId, providerName, providerLabel } =
state.street;
Object.assign(state.street, freshStreet(), {
open,
loading,
error,
providerId,
providerName,
providerLabel,
});
parts.marker.clear();
}
const adapter = entry.instance.viewer;
const promise = (async () => {
@@ -202,5 +215,27 @@ export function createViewerHost({ state, parts }) {
active = null;
}
return { attach, open, close, resize, setRenderMode, stopPlayback, unmount };
/**
* Destroy `providerId`'s viewer and leave the panel's state alone: what a
* request leaves behind once it replaced or closed that source's photo.
*/
function release(providerId) {
if (active?.id !== providerId && mounting?.id !== providerId) return;
const pending = mounting;
mounting = null;
active?.unsubscribe();
(active?.adapter ?? pending?.adapter)?.unmount();
active = null;
}
return {
attach,
open,
close,
resize,
setRenderMode,
stopPlayback,
unmount,
release,
};
}
+34
View File
@@ -181,3 +181,37 @@ export function reassertNavigationHandoff({
release?.();
return true;
}
/** 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.
*/
export 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);
},
};
}
+4 -12
View File
@@ -6,7 +6,6 @@ import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetL
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';
@@ -22,11 +21,10 @@ function deferred() {
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
}
async function harness(t, { nearestImage = async () => 'A', failImage = null, billed = false, extraProviders = [] } = {}) {
async function harness(t, { nearestImage = async () => 'A', failImage = null, extraProviders = [] } = {}) {
document.body.className = '';
document.body.replaceChildren();
const provider = fakeStreetLevelProvider({ nearestImage });
provider.billedPerImage = billed;
const originalCreate = provider.create;
const opened = [];
provider.create = context => {
@@ -50,7 +48,6 @@ async function harness(t, { nearestImage = async () => 'A', failImage = null, bi
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 }],
@@ -69,14 +66,9 @@ async function harness(t, { nearestImage = async () => 'A', failImage = null, bi
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 };
const run = (name, args) =>
name === 'show_street_level' ? showStreetLevel(args, context) : Promise.resolve({ ok: true });
return { layer, viewer, dataManager, context, run, opened, flights };
}
test('a landed link does not reclaim a camera the user moved while its layers load', async t => {
+201
View File
@@ -0,0 +1,201 @@
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';
// Replacing a street-level photo, across sources and against the user's own
// moves; posted views waiting on terrain. Offline: providers, lookups, terrain
// and 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 };
}
function provider(id, { nearest = async () => 'B', load = async () => {}, billed = false } = {}) {
const def = fakeStreetLevelProvider({ id, pickPrefix: id + ':', nearestImage: nearest });
def.name = id;
def.label = id.toUpperCase();
def.billedPerImage = billed;
const create = def.create;
def.create = context => {
const instance = create(context);
let pose;
instance.viewer.onPose = listener => { pose = listener; return () => { pose = null; }; };
instance.viewer.open = async imageId => {
def.calls.open.push(imageId);
await load(imageId);
pose?.({ imageId, position: { lat: 48, lon: 2 }, creator: 'fixture photographer',
externalUrl: 'https://www.mapillary.com/app/?pKey=' + imageId });
};
return instance;
};
return def;
}
async function harness(t, defs) {
document.body.className = '';
document.body.replaceChildren();
const viewer = fakeCesiumViewer();
viewer.canvas = viewer.scene.canvas;
viewer.canvas.setPointerCapture = () => {};
const layer = createStreetLevelLayer({ providers: defs });
layer.init(viewer);
layer.enable(viewer);
layer.attachViewerHost(document.body.appendChild(document.createElement('div')));
t.after(() => { layer.destroy(); document.body.className = ''; document.body.replaceChildren(); });
await settle();
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 shell = { _navigationGeneration: 1, initialRestorePromise: Promise.resolve(), setCleanView() {} };
const flights = [];
const context = { viewer, dataManager, styleManager: shell,
runOptions: { signal: new AbortController().signal },
runGevAction: async (name, args) => {
assert.equal(name, 'fly_to_location');
flights.push(args);
return { ok: true, latitude: args.latitude, longitude: args.longitude, label: 'fixture point' };
},
};
const run = (name, args) => name === 'show_street_level' ? showStreetLevel(args, context) : Promise.resolve({ ok: true });
return { layer, viewer, dataManager, shell, context, run, flights };
}
async function embed(t, h, terrain) {
const replies = [];
const peer = { postMessage: message => replies.push(message) };
const windowRef = new EventTarget();
windowRef.parent = peer;
windowRef.document = document;
const remove = installViews({ ...h, terrain, windowRef, location: { search: '?embed=1', hash: '' } });
t.after(remove);
await settle();
const post = (id, view) => {
const event = new Event('message');
Object.assign(event, { source: peer, origin: 'https://host.example', data: { type: 'gev:view', id, view } });
windowRef.dispatchEvent(event);
};
return { post, replies };
}
const args = provider => ({ provider, latitude: 48, longitude: 2, fly: false });
const view = pitch => ({ camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: pitch }, street_level: { provider: 'mapillary' } });
for (const outcome of ['empty', 'error']) {
test('a replacement from another source that opens nothing keeps its explanation (' + outcome + ')', async t => {
const a = provider('mapillary');
const b = provider('panoramax', { nearest: async () => {
if (outcome === 'error') throw new Error('Fixture lookup failure');
return null;
} });
const h = await harness(t, [a, b]);
assert.equal(await h.layer.openImage('mapillary', 'A', { frame: false }), true);
const result = await showStreetLevel(args('panoramax'), h.context);
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ result, error: street.error, nearestOutcome: street.nearestOutcome }));
assert.equal(result.ok, false);
assert.equal(street.open, false);
assert.ok(street.error, 'replacement explanation must survive release');
if (outcome === 'empty') assert.equal(street.nearestOutcome, 'empty');
});
}
test('an arming link from another source keeps its armed point', async t => {
const h = await harness(t, [provider('mapillary'), provider('google', { billed: true })]);
assert.equal(await h.layer.openImage('mapillary', 'A', { frame: false }), true);
const result = await showStreetLevel({ ...args('google'), autoOpen: false }, h.context);
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ result, armed: street.armed }));
assert.equal(result.armed, true);
assert.deepEqual(street.armed?.point, { lat: 48, lon: 2 });
});
test('a same-source lookup that finds nothing keeps its explanation', async t => {
const h = await harness(t, [provider('mapillary', { nearest: async () => null })]);
await h.layer.openImage('mapillary', 'A', { frame: false });
const result = await showStreetLevel(args('mapillary'), h.context);
assert.equal(result.noImagery, true);
assert.ok(h.layer.getUIState().street.error);
assert.equal(h.layer.getUIState().street.nearestOutcome, 'empty');
});
for (const takeover of ['navigation', 'gesture', 'none']) {
test('a posted view waiting on terrain yields to the user (' + takeover + ')', async t => {
const a = provider('mapillary');
const h = await harness(t, [a]);
const entered = deferred();
const terrain = deferred();
const e = await embed(t, h, { getHeights: async () => { entered.resolve(); return terrain.promise; } });
e.post(1, view(-45));
await entered.promise;
if (takeover === 'navigation') h.shell._navigationGeneration++;
if (takeover === 'gesture') h.viewer.canvas.dispatchEvent(new Event('pointerdown'));
terrain.resolve([{ ellipsoid: 200 }]);
for (let n = 0; n < 8 && !e.replies.some(r => r.id === 1); n++) await settle();
const reply = e.replies.find(r => r.id === 1);
t.diagnostic(JSON.stringify({ takeover, flights: h.flights, opened: a.calls.open, reply }));
assert.ok(reply, 'host receives completion');
assert.equal(h.flights.length, takeover === 'none' ? 1 : 0, 'superseded view must not reclaim the camera');
assert.equal(a.calls.open.length, takeover === 'none' ? 1 : 0);
assert.equal(reply.ok, takeover === 'none');
});
}
test('a posted view looking at the horizon does not wait on terrain or hold the queue', async t => {
const h = await harness(t, [provider('mapillary')]);
const terrain = deferred();
let terrainCalls = 0;
const e = await embed(t, h, { getHeights: () => { terrainCalls++; return terrain.promise; } });
e.post(1, view(0));
e.post(2, { ...view(-90), street_level: { provider: 'mapillary', lat: 49, lon: 3 } });
for (let n = 0; n < 8; n++) await settle();
const before = e.replies.filter(r => r.id).map(r => r.id);
terrain.resolve([{ ellipsoid: 200 }]);
for (let n = 0; n < 8 && !e.replies.some(r => r.id === 2); n++) await settle();
const after = e.replies.filter(r => r.id).map(r => r.id);
t.diagnostic(JSON.stringify({ terrainCalls, before, after, flights: h.flights }));
assert.deepEqual(after, [1, 2], 'both complete after irrelevant terrain is released');
assert.deepEqual(before, [1, 2], 'both should complete without any terrain response');
assert.equal(terrainCalls, 0);
});
for (const initial of [true, false]) {
test('a source switched by hand during the load stops the request (photo open before: ' + initial + ')', async t => {
const entered = deferred();
const load = deferred();
const a = provider('mapillary', { load: async id => { if (id === 'B') { entered.resolve(); await load.promise; } } });
const h = await harness(t, [a, provider('panoramax')]);
if (initial) await h.layer.openImage('mapillary', 'A', { frame: false });
const request = showStreetLevel(args('mapillary'), h.context);
await entered.promise;
h.layer.setProviderEnabled('panoramax', true);
load.resolve();
const result = await request;
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ initial, result, open: street.open, sourceOn: h.layer.providerStatus('mapillary').on }));
assert.equal(h.layer.providerStatus('mapillary').on, false);
assert.equal(street.open, false);
assert.equal(result.ok, false, 'request for the disabled source must not report success');
assert.equal(result.cancelled, true);
});
}
test('a failed replacement from another source does not keep the old photo\'s details', async t => {
const h = await harness(t, [provider('mapillary'), provider('panoramax', { load: async () => { throw new Error('Fixture image load failure'); } })]);
await h.layer.openImage('mapillary', 'A', { frame: false });
const before = h.layer.getUIState().street;
assert.equal(before.imageId, 'A');
assert.ok(before.externalUrl);
const result = await showStreetLevel(args('panoramax'), h.context);
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ result, street }));
assert.equal(result.ok, false);
assert.equal(street.providerId, 'panoramax');
assert.match(street.error, /Fixture image load failure/);
assert.deepEqual({ imageId: street.imageId, position: street.position, creator: street.creator, externalUrl: street.externalUrl },
{ imageId: null, position: null, creator: null, externalUrl: null }, 'destroyed provider photo must not be attributed to replacement');
});
+13 -1
View File
@@ -131,7 +131,13 @@ export function lookAtPoint(camera) {
*/
export async function groundedLookAt(camera, terrain, signal) {
if (typeof terrain?.getHeights !== 'function') return null;
if (!Number.isFinite(camera?.altitude_m) || !(camera.pitch_deg > -89))
// Looking down or at the horizon the point is the camera's own: the ground
// under it changes nothing, so it is not asked for.
if (
!Number.isFinite(camera?.altitude_m) ||
!(camera.pitch_deg > -89) ||
camera.pitch_deg > LOOK_AT_MIN_PITCH_DEG
)
return null;
let heights;
try {
@@ -381,6 +387,12 @@ export function createView({
// 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)];
// A coordinate given off the globe is an error, not a reason to guess.
for (const [index, key] of ['lat', 'lon'].entries())
if (streetLevel[key] != null && given[index] === undefined)
throw new TypeError(
`Street-level ${key} is not a valid coordinate: ${streetLevel[key]}`,
);
const seen = lookAtPoint(camera);
const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given;
street = Object.freeze({
+15
View File
@@ -208,6 +208,21 @@ 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);
// A point off the globe is refused, like an unknown source.
for (const bad of [
{ lat: 95, lon: 2 },
{ lat: 48, lon: 200 },
{ lat: Number.NaN, lon: 2 },
])
assert.throws(
() =>
createView({
camera: { lat: 1, lon: 2 },
street_level: { provider: 'mapillary', ...bad },
}),
TypeError,
JSON.stringify(bad),
);
// Half a point is not mixed with the camera's other half.
const half = createView({
camera: { lat: 1, lon: 2 },
+3 -36
View File
@@ -60,6 +60,7 @@ import {
import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { watchCameraOwner } from '../navigationPolicy.js';
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
@@ -2984,7 +2985,6 @@ export function activeImageryFilter(ui) {
* detection so the framed entities are labeled, and reports
* detectionEnabled so the voice agent can mention labels are on.
*/
async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
const targetRaw = String(args.target || 'flights').toLowerCase();
const layerId =
@@ -3763,40 +3763,6 @@ 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
@@ -4034,7 +4000,8 @@ async function requestStreetLevel(
// 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)
const openPhoto = layerModule.getUIState?.()?.street;
if (openPhoto?.open === true && openPhoto.providerId !== provider)
holdViewer(layerModule);
const switches = Object.fromEntries(
providerIds.map((id) => [id, id === provider]),