mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +00:00
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:
1 parent
45a33b54df
commit
4fd36c44e0
14 files changed
+391
-84
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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": "成像",
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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
@@ -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]),
|
||||
|
||||
Reference in new issue
Block a user