fix(street-level): review fixes for show_street_level and sl= links

P1: on a phone the Street Level panel sits in the Cameras tab sheet, so the
action and sl= links opened photos nobody could see and still reported
success. A photo that opens, and a link that arms, now open that sheet
(revealPanel). In cockpit, clean view, recording or scene playback the action
stops at a new `view` stage before anything switches or loads.

P2s:
- Mapillary's nearest search asks for 100 photos, the API's most. It cuts
  the radius's photos at the limit in no particular order, so 8 left the
  distance sort and the imagery filter a few arbitrary photos.
- The MCP globe panel opens a billed photo only for a result issued in the
  last five minutes (show_in_gods_eye_view stamps issued_at); a host that
  mounts the panel again hands back the old result, which only arms.
- OPEN uses an armed link's point only while the view centre is within 1 km
  of it; a user who moved on opens what the view shows.
- show_in_gods_eye_view measures a camera's altitude from the terrain under
  it before taking its look-at point, so high cities no longer put the
  photos kilometres too far ahead.
- The armed-link toast is translated, and the new view-stage message too,
  in all 11 catalogs.
- Tests: a turn superseded during the geocode, flight or map switch changes
  nothing; an unknown key state lets a cold sl= link through; a wide tilted
  area still opens photos at its centre.
This commit is contained in:
Said Turksever committed 2026-11-03 15:30:48 +00:00
1 parent 8ce01cf48e
commit 0ec57057fa
30 files changed
+473 -65

No files matched your search

+24 -6
View File
@@ -13,6 +13,8 @@
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { t, translateKnown } from '../i18n/index.js';
import { GOOGLE_LABEL } from '../layers/streetLevel/providers/google/policy.js';
import {
annotationsFromParams,
createView,
@@ -51,6 +53,14 @@ export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true;
}
/**
* Whether the panel page started the app for a tool result issued just now,
* not one its host handed back on mounting the panel again.
*/
function isFreshPanelResult() {
return isEmbeddedInline() && globalThis.GEV_EMBED_FRESH === true;
}
/**
* `doc`'s visibility for work that pauses in a hidden page. A panel's host
* may report it hidden while it is on screen (see keepPanelRendering), so an
@@ -188,8 +198,13 @@ function streetLevelArgs(streetLevel, { autoOpen = true } = {}) {
}
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
const ARMED_TOAST =
'Street View is ready: press OPEN GOOGLE STREET VIEW to load it';
/** The armed link's toast, naming the OPEN button as the panel shows it. */
function armedToast() {
return t('Street View is ready: press {open} to load it', {
open: t('OPEN {labels}', { labels: translateKnown(GOOGLE_LABEL) }),
});
}
/**
* Make the app show a view: style and map, exactly the view's layers,
@@ -323,8 +338,9 @@ export function installViews({
queue = linkStep(() => run('annotate_map', { annotations: linked }));
// A link to street-level photos opens them once the link has restored. A
// link anyone can publish stops short of a billed load and leaves it to a
// click; only the panel page's own link (inline embed) is the client's
// request. A link that follows an entity keeps the camera on it.
// click; only the panel page's own link for a result issued just now is
// the client's request (a host remounting the panel hands back old ones).
// A link that follows an entity keeps the camera on it.
const streetLevel = followFromParams(hashParams)
? null
: streetLevelFromParams(hashParams);
@@ -333,7 +349,7 @@ export function installViews({
if (navigatedSinceRestore()) return;
const result = await run(
'show_street_level',
streetLevelArgs(streetLevel, { autoOpen: isEmbeddedInline() }),
streetLevelArgs(streetLevel, { autoOpen: isFreshPanelResult() }),
);
// The restore window keeps the panel collapsed; OPEN must be in view.
// Embed mode hides the rail that holds it, so there the link only arms
@@ -342,7 +358,9 @@ export function installViews({
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
explicit: true,
});
shell._showToast?.(ARMED_TOAST);
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
shell._showToast?.(armedToast());
}
});
if (!isEmbedded(location)) return () => {};
+38 -28
View File
@@ -540,6 +540,7 @@ test('a failing link step does not take the next one with it', async () => {
test('a plain link never auto-loads a billed photo: it arms the panel and says so; an embedded view opens', async () => {
const toasts = [];
const panels = [];
const revealed = [];
const { calls, run } = recorder({
show_street_level: () => ({ ok: true, armed: true }),
});
@@ -549,6 +550,7 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s
_showToast: (text) => toasts.push(text),
setPanelCollapsed: (id, collapsed, options) =>
panels.push([id, collapsed, options]),
revealPanel: (id) => revealed.push(id),
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
@@ -569,6 +571,7 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s
[['street-level-panel', false, { explicit: true }]],
'the panel opens so OPEN is in view',
);
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
// still a plain top-level load.
const framedToasts = [];
@@ -599,38 +602,45 @@ test('a plain link never auto-loads a billed photo: it arms the panel and says s
assert.equal(framed.calls[0][1].autoOpen, false);
// In a frame the rail that holds OPEN is hidden: no nudge, no toast.
assert.deepEqual([framedToasts, framedPanels], [[], []]);
// Only the panel page's own link (inline embed) is the client's request.
// Only the panel page's own link for a result issued just now is the
// client's request: a host that mounts the panel again hands back an old
// result, and that one only arms, like any link.
globalThis.GEV_EMBED_INLINE = true;
try {
const inline = recorder({ show_street_level: () => ({ ok: true }) });
const inlineWindow = new EventTarget();
inlineWindow.document = { body: { classList: { add() {} } } };
inlineWindow.postMessage = () => {};
// The inline panel keeps rendering from a timer; give it inert clocks.
Object.assign(inlineWindow, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setTimeout: () => 1,
clearTimeout() {},
setInterval: () => 1,
clearInterval() {},
});
const stop = installViews({
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: inline.run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
},
windowRef: inlineWindow,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal('autoOpen' in inline.calls[0][1], false);
stop();
for (const fresh of [true, false]) {
globalThis.GEV_EMBED_FRESH = fresh;
const inline = recorder({ show_street_level: () => ({ ok: true }) });
const inlineWindow = new EventTarget();
inlineWindow.document = { body: { classList: { add() {} } } };
inlineWindow.postMessage = () => {};
// The inline panel keeps rendering from a timer; give it inert clocks.
Object.assign(inlineWindow, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setTimeout: () => 1,
clearTimeout() {},
setInterval: () => 1,
clearInterval() {},
});
const stop = installViews({
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: inline.run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
},
windowRef: inlineWindow,
});
await new Promise((resolve) => setTimeout(resolve, 0));
if (fresh) assert.equal('autoOpen' in inline.calls[0][1], false);
else assert.equal(inline.calls[0][1].autoOpen, false, 'a remount arms');
stop();
}
} finally {
delete globalThis.GEV_EMBED_INLINE;
delete globalThis.GEV_EMBED_FRESH;
}
// applyView (a posted view) opens too.
const posted = recorder();
+12 -3
View File
@@ -462,8 +462,11 @@ export function panelRuntime(config) {
* Load God's Eye View into this page from the app's panel build, in
* inline embed mode, starting at the first view as its link would.
*/
async function startApp(url, view) {
async function startApp(url, view, fresh = false) {
window.GEV_EMBED_INLINE = true;
// Whether this result was just issued, not handed back by a host that
// mounted the panel again: only then may its link open a billed photo.
window.GEV_EMBED_FRESH = fresh;
window.GEV_APP_BASE_URL = config.appBaseUrl;
try {
history.replaceState(
@@ -551,7 +554,9 @@ export function panelRuntime(config) {
);
}
// Tool results: the first view starts the app, later ones move it.
// Tool results: the first view starts the app, later ones move it. A
// result issued longer ago than this was handed back on a remount.
const FRESH_RESULT_MS = 5 * 60 * 1000;
let started = false;
let ready = false;
let queued = null;
@@ -571,7 +576,11 @@ export function panelRuntime(config) {
started = true;
// i18n-ignore: host-panel script, no app catalogs
say("Loading God's Eye View…");
startApp(url, view).catch((error) =>
const issuedAt = Number(data.issued_at);
const fresh =
Number.isFinite(issuedAt) &&
Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS;
startApp(url, view, fresh).catch((error) =>
// i18n-ignore: host-panel script, no app catalogs
say(`God's Eye View could not load here: ${error?.message || error}.`),
);
+3 -1
View File
@@ -3232,5 +3232,7 @@
"{source} at {place}": "{source} في {place}",
"Street level at {place}": "مستوى الشارع في {place}",
"Switching map": "جارٍ تبديل الخريطة",
"Opening imagery": "جارٍ فتح الصور"
"Opening imagery": "جارٍ فتح الصور",
"Street View is ready: press {open} to load it": "Street View جاهز: اضغط «{open}» لتحميله",
"Street-level photos are hidden in the current view. Leave it first.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا."
}
+3 -1
View File
@@ -2952,5 +2952,7 @@
"{source} at {place}": "{source} bei {place}",
"Street level at {place}": "Straßenebene bei {place}",
"Switching map": "Karte wird gewechselt",
"Opening imagery": "Bilder werden geöffnet"
"Opening imagery": "Bilder werden geöffnet",
"Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden",
"Street-level photos are hidden in the current view. Leave it first.": "Fotos auf Straßenebene sind in dieser Ansicht ausgeblendet. Zuerst die Ansicht verlassen."
}
+3 -1
View File
@@ -3022,5 +3022,7 @@
"Exit cockpit to fly to a drawing": "Sal de la cabina para volar a un dibujo",
"Directions, drawing and your own files": "Indicaciones, dibujo y tus propios archivos",
"Sea Marks": "Señales marítimas",
"Ski Pistes": "Pistas de esquí"
"Ski Pistes": "Pistas de esquí",
"Street View is ready: press {open} to load it": "Street View está listo: pulsa {open} para cargarlo",
"Street-level photos are hidden in the current view. Leave it first.": "Las fotos a pie de calle están ocultas en la vista actual. Sal de ella primero."
}
+3 -1
View File
@@ -3022,5 +3022,7 @@
"{source} at {place}": "{source} à {place}",
"Street level at {place}": "Niveau rue à {place}",
"Switching map": "Changement de carte",
"Opening imagery": "Ouverture des images"
"Opening imagery": "Ouverture des images",
"Street View is ready: press {open} to load it": "Street View est prêt : appuyez sur {open} pour le charger",
"Street-level photos are hidden in the current view. Leave it first.": "Les photos au niveau de la rue sont masquées dans la vue actuelle. Quittez-la d’abord."
}
+3 -1
View File
@@ -3022,5 +3022,7 @@
"{source} at {place}": "{source} a {place}",
"Street level at {place}": "Vista stradale a {place}",
"Switching map": "Cambio mappa",
"Opening imagery": "Apertura delle immagini"
"Opening imagery": "Apertura delle immagini",
"Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo",
"Street-level photos are hidden in the current view. Leave it first.": "Le foto a livello stradale sono nascoste nella vista attuale. Esci prima da questa vista."
}
+3 -1
View File
@@ -2882,5 +2882,7 @@
"{source} at {place}": "{place}の{source}",
"Street level at {place}": "{place}のストリートレベル",
"Switching map": "地図を切り替え中",
"Opening imagery": "画像を開いています"
"Opening imagery": "画像を開いています",
"Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます",
"Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。"
}
+3 -1
View File
@@ -3022,5 +3022,7 @@
"{source} at {place}": "{source} em {place}",
"Street level at {place}": "Nível da rua em {place}",
"Switching map": "Mudando de mapa",
"Opening imagery": "Abrindo imagens"
"Opening imagery": "Abrindo imagens",
"Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar",
"Street-level photos are hidden in the current view. Leave it first.": "As fotos ao nível da rua estão ocultas na vista atual. Saia dela primeiro."
}
+3 -1
View File
@@ -3092,5 +3092,7 @@
"Exit cockpit to fly to a drawing": "Выйдите из кабины, чтобы перелететь к рисунку",
"Directions, drawing and your own files": "Маршруты, рисование и ваши файлы",
"Sea Marks": "Морские знаки",
"Ski Pistes": "Горнолыжные трассы"
"Ski Pistes": "Горнолыжные трассы",
"Street View is ready: press {open} to load it": "Просмотр улиц готов: нажмите {open}, чтобы загрузить",
"Street-level photos are hidden in the current view. Leave it first.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него."
}
+3 -1
View File
@@ -3092,5 +3092,7 @@
"{source} at {place}": "{source}: {place}",
"Street level at {place}": "Вуличні знімки: {place}",
"Switching map": "Перемикання карти",
"Opening imagery": "Відкриття знімків"
"Opening imagery": "Відкриття знімків",
"Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити",
"Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього."
}
+3 -1
View File
@@ -2882,5 +2882,7 @@
"Exit cockpit to fly to a drawing": "请先退出座舱,再飞往绘制位置",
"Directions, drawing and your own files": "路线导航、绘制和你自己的文件",
"Sea Marks": "航标",
"Ski Pistes": "滑雪道"
"Ski Pistes": "滑雪道",
"Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载",
"Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。"
}
+3 -1
View File
@@ -2882,5 +2882,7 @@
"{source} at {place}": "{place} 的 {source}",
"Street level at {place}": "{place} 的街景",
"Switching map": "正在切換地圖",
"Opening imagery": "正在打開影像"
"Opening imagery": "正在打開影像",
"Street View is ready: press {open} to load it": "街景已就緒:按 {open} 載入",
"Street-level photos are hidden in the current view. Leave it first.": "目前的檢視隱藏了街景照片。請先離開此檢視。"
}
+13 -3
View File
@@ -14,7 +14,7 @@ import {
keyAlternatives,
summarizeCoverage,
} from './uiState.js';
import { groundUnderCamera, viewCentre } from './view.js';
import { groundUnderCamera, metresBetween, viewCentre } from './view.js';
import { createGroundClick, groundClickHeightOk } from './groundClick.js';
import { msg, t, translateKnown } from '../../i18n/index.js';
import {
@@ -33,6 +33,11 @@ const KEY_STATUS_TIMEOUT_MS = 10_000;
/** Re-measures of a ground not loaded yet (Google 3D), a second apart. */
const GROUND_RETRY_LIMIT = 5;
const GROUND_RETRY_MS = 1000;
/**
* An armed point stays the OPEN button's target while the view centre is
* this close to it (m); a user who moved on opens what the view shows now.
*/
const ARMED_REACH_M = 1000;
/**
* The Street Level layer over imagery providers (contract in registry.js).
@@ -681,8 +686,13 @@ export function createStreetLevelLayer({
const armed = state.street.armed;
state.street.armed = null;
if (!point && aim === 'screen-centre' && armed) {
point = armed.point;
radiusM ??= armed.radiusM;
// The button promises the centre of the view: the link's spot only
// while the view still shows it.
const centre = armed.point ? screenCentreGround() : null;
if (!centre || metresBetween(centre, armed.point) <= ARMED_REACH_M) {
point = armed.point;
radiusM ??= armed.radiusM;
}
}
const radius = Number.isFinite(radiusM)
? Math.min(NEAREST_RADIUS_MAX_M, Math.max(1, radiusM))
+64
View File
@@ -947,6 +947,54 @@ test('the button’s “centre of the view” is the ground under the middle of
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
});
test('OPEN after a link armed opens at its point only while the view centre is still near it', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point, options) => {
lookups.push({ point, radiusM: options?.radiusM ?? null });
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;
});
// About 120 m from the view centre: the link's spot is still in view.
layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 });
await layer.openNearest(undefined, { aim: 'screen-centre' });
assert.deepEqual(lookups.at(-1), {
point: { lat: 38.5808, lon: -121.494 },
radiusM: 250,
});
// The user flew to Paris after the link armed in Sacramento: OPEN looks
// under the view centre, and the link's radius goes with its point.
layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 });
await layer.openNearest(undefined, { aim: 'screen-centre' });
const press = lookups.at(-1);
assert.ok(Math.abs(press.point.lat - 38.5799) < 1e-9, 'the view centre');
assert.ok(Math.abs(press.point.lon - -121.4937) < 1e-9);
assert.equal(press.radiusM, null, 'the default radius');
assert.equal(layer.getUIState().street.armed, null, 'dropped');
});
test('the button aimed at the sky opens nothing, and says to aim at a street', async (t) => {
const { createStreetLevelLayer: create } = await import('./index.js');
const scenePick = await import('../../data/scenePick.js');
@@ -1342,6 +1390,22 @@ test('providerStatus reports a keyless source on any map', async (t) => {
assert.equal(mapillary.keyRejected, false);
});
test('providerStatus reports the key state as unknown until the status call answers', async (t) => {
// A freshly loaded sl= link meets its source in this state: unknown is not
// "needs its key", or the link would fail on a cold load.
const pending = fakeProvider();
pending.requiresKeyId = 'mapillary';
const create = pending.create;
pending.create = (context) => ({
...create(context),
status: () => new Promise(() => {}),
});
const { layer } = await enabledLayer(t, [pending]);
const status = layer.providerStatus(pending.id);
assert.equal(status.configured, null);
assert.equal(status.keyRequired, false);
});
test('providerStatus reports a refused key on any map', async (t) => {
const refused = await withStreetView(t, { mapStack: 'osm' });
refused.loader.refuseKey();
@@ -77,8 +77,11 @@ export const SEQUENCE_IMAGE_FIELDS =
/**
* Images asked for in a nearest-image search (show_street_level and links
* open the photo nearest a point; the graph API caps the radius at 50 m).
* The API returns the radius's images in no particular order and cuts them
* at the limit first, so this asks for its most (100): the distance sort and
* the imagery filter then choose among the spot's photos, not a few of them.
*/
export const NEAREST_LIMIT = 8;
export const NEAREST_LIMIT = 100;
/** Fields a nearest-image search needs: where each is, and the filter's. */
export const NEAREST_IMAGE_FIELDS =
'id,captured_at,geometry,computed_geometry,is_pano';
@@ -201,6 +201,9 @@ 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]);
const any = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await any.nearestImage({ lat: 1, lon: 2 }), 'near-pano');
+46 -2
View File
@@ -1,6 +1,11 @@
/** Links that open God's Eye View at a view. */
import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js';
import {
VIEW_PROPERTIES,
createView,
lookAtPoint,
viewUrl,
} from '../../view/index.js';
import { defineTool, ToolError } from '../catalog.js';
import { GLOBE_PANEL_URI } from '../globePanel.js';
import { AREA_SCHEMA, resolveArea } from '../area.js';
@@ -36,6 +41,30 @@ async function followedAircraftPosition(follow, tools) {
}
}
/**
* Where a tilted camera looks, measured from the ground under it: a view's
* altitude is above the ellipsoid, and a city a mile up would otherwise put
* the point kilometres too far ahead. Null when the terrain cannot say.
*/
async function groundedLookAt(camera, services, signal) {
if (typeof services?.terrain?.getHeights !== 'function') return null;
if (!Number.isFinite(camera.altitude_m) || !(camera.pitch_deg > -89))
return null;
let heights;
try {
heights = await services.terrain.getHeights(
[{ lat: camera.lat, lon: camera.lon }],
{ signal },
);
} catch (error) {
if (signal?.aborted) throw error;
return null;
}
const ground = Number(heights?.[0]?.ellipsoid);
if (!Number.isFinite(ground)) return null;
return lookAtPoint({ ...camera, altitude_m: camera.altitude_m - ground });
}
/**
* The view described by tool arguments. An area frames the camera straight
* down over it; camera fields given alongside it override the framing. A
@@ -91,6 +120,15 @@ export async function resolveViewArguments(args, { services, signal, tools }) {
camera = { altitude_m: FOLLOW_ALTITUDE_M, ...camera, ...position };
label = `${follow.kind === 'military_aircraft' ? 'military aircraft' : 'aircraft'} ${follow.id}`;
}
// Photos for a camera with no point open where it looks, from its ground.
if (
streetLevel &&
streetLevel.lat === undefined &&
streetLevel.lon === undefined
) {
const point = await groundedLookAt(camera, services, signal);
if (point) streetLevel = { ...streetLevel, ...point };
}
let view;
try {
view = createView({
@@ -150,7 +188,13 @@ export const showInGodsEyeView = defineTool({
const url = viewUrl(base, view);
return {
summary: `Open ${label} in God's Eye View: ${url}`,
data: { url, view },
// A panel opens a billed photo only for a result it receives fresh;
// hosts hand a panel its result again whenever they mount it.
data: {
url,
view,
...(view.street_level ? { issued_at: Date.now() } : {}),
},
};
},
});
+70
View File
@@ -136,6 +136,8 @@ test('links can open street-level photos at the area, on the map the source need
assert.equal(params.get('sl'), 'google,38.57990,-121.49370');
assert.equal(params.get('map'), 'photoreal');
assert.deepEqual(viewFromParams(params), view);
// The panel opens a billed photo only for a result it gets fresh.
assert.ok(Math.abs(Date.now() - result.data.issued_at) < 60_000);
const point = await catalog.call('show_in_gods_eye_view', {
view,
@@ -189,6 +191,19 @@ test('a new area moves a passed-through view’s photos, and a tilted area still
lat: 48.8584,
lon: 2.2945,
});
// A wide area frames the camera tens of km up, past where a camera's
// look-at point is trusted: the photos still open at the area's centre.
const wide = await catalog.call('show_in_gods_eye_view', {
area: { lat: 48.8584, lon: 2.2945, radius_km: 15 },
camera: { pitch_deg: -30 },
street_level: { provider: 'mapillary' },
});
assert.ok(wide.data.view.camera.altitude_m > 20_000);
assert.deepEqual(wide.data.view.street_level, {
provider: 'mapillary',
lat: 48.8584,
lon: 2.2945,
});
await assert.rejects(
catalog.call('show_in_gods_eye_view', {
area: { lat: 0, lon: 0, radius_km: 1 },
@@ -199,6 +214,61 @@ test('a new area moves a passed-through view’s photos, and a tilted area still
);
});
test('a camera with no photo point opens photos where it looks, measured from its ground', async () => {
const { lookAtPoint } = await import('../../view/index.js');
const asked = [];
const catalog = composeCatalog({
tools: coreTools,
services: {
app: { baseUrl: 'http://localhost:4173/' },
terrain: {
getHeights: async (points) => {
asked.push(points);
return [{ ellipsoid: 1590 }];
},
},
},
});
// Denver: 1,909 m above the ellipsoid is about 320 m above its streets.
const camera = {
lat: 39.7392,
lon: -104.9903,
altitude_m: 1909,
pitch_deg: -30,
heading_deg: 0,
};
const result = await catalog.call('show_in_gods_eye_view', {
camera,
street_level: { provider: 'mapillary' },
});
assert.deepEqual(asked, [[{ lat: 39.7392, lon: -104.9903 }]]);
const grounded = lookAtPoint({ ...camera, altitude_m: 319 });
const { street_level: street } = result.data.view;
assert.ok(Math.abs(street.lat - grounded.lat) < 1e-5, 'about 550 m ahead');
assert.ok(
street.lat - camera.lat < 0.006,
'not the 3.3 km a sea-level guess gives',
);
// No terrain answer: the view's own estimate, as before.
const offline = composeCatalog({
tools: coreTools,
services: {
app: { baseUrl: 'http://localhost:4173/' },
terrain: {
getHeights: async () => {
throw new Error('offline');
},
},
},
});
const fallback = await offline.call('show_in_gods_eye_view', {
camera,
street_level: { provider: 'mapillary' },
});
const guess = lookAtPoint(camera);
assert.ok(Math.abs(fallback.data.view.street_level.lat - guess.lat) < 1e-5);
});
test('a view passed through alone keeps its street-level photos', async () => {
const catalog = composeCatalog({
tools: coreTools,
+13 -1
View File
@@ -4,7 +4,7 @@ import { ShellFacade } from './shellFacade.js';
import { AircraftDisplay } from './aircraftDisplay.js';
import { LayerBindings } from './layerBindings.js';
import { PanelChrome } from './panelChrome.js';
import { initMobileShell } from './mobileShell.js';
import { initMobileShell, sheetForPanelId } from './mobileShell.js';
import { VisualSettings } from './visualSettings.js';
import { NavigationController } from './navigationController.js';
import { ShareRestoration } from './shareRestoration.js';
@@ -1098,6 +1098,7 @@ export class StyleManager extends ShellFacade {
this._dataManager?.setLayerParams('street-level', params, options),
setPanelCollapsed: (collapsed, options) =>
this.setPanelCollapsed('street-level-panel', collapsed, options),
revealPanel: () => this.revealPanel('street-level-panel'),
showToast: (message) => this._showToast(message),
// Lets the panel open for a user, voice or tool switch-on, not a restore.
subscribeEnableRequests: (listener) =>
@@ -1212,6 +1213,17 @@ export class StyleManager extends ShellFacade {
return this._panelChrome.setPanelCollapsed(...arguments);
}
/**
* Bring a panel into view on a phone, where the rails show only as tab
* sheets: opens the sheet that holds it. Does nothing above 720px.
* @param {string} panelId - DOM id of the panel.
* @returns {void}
*/
revealPanel(panelId) {
const sheet = sheetForPanelId(panelId);
if (sheet) this._mobileShell?.setSheet(sheet);
}
/**
* Toggles "clean view" mode which hides all UI panels via a CSS body class.
* @param {boolean} [forceEnabled] - Explicit on/off. Omit to toggle.
+6 -1
View File
@@ -388,7 +388,12 @@ export class StreetLevelControls {
// Other size changes reach the viewer through the ResizeObserver; opening
// asks for one resize because the element may not have a size yet.
const open = view.viewer.open;
if (open && !this._wasOpen) this.setCollapsed(false, { persist: false });
if (open && !this._wasOpen) {
this.setCollapsed(false, { persist: false });
// A phone shows the rail only as a tab sheet: open it, or the photo
// loads where nobody sees it. Embed mode lifts the photo instead.
if (!isEmbedPage()) this.actions.revealPanel?.();
}
this._wasOpen = open;
// Embed mode hides the rail the panel sits in: an open photo is shown over
// the globe instead, and goes back into the panel when it closes.
+17
View File
@@ -967,6 +967,23 @@ test('outside embed mode a photo stays in the panel', () =>
controls.destroy();
}));
test('an opening photo brings its panel into view (a phone’s tab sheet); embed mode lifts it instead', () =>
withDom(async (dom) => {
const revealed = [];
const { layer, controls } = await stubPanel(dom, uiState({ open: false }), {
revealPanel: () => revealed.push('street-level'),
});
await layer.publish(uiState({ open: true }));
assert.deepEqual(revealed, ['street-level'], 'once, as it opens');
await layer.publish(uiState({ open: true, renderMode: 'fill' }));
assert.equal(revealed.length, 1, 'not on every update');
await layer.publish(uiState({ open: false }));
dom.document.body.classList.add('ui-embed');
await layer.publish(uiState({ open: true }));
assert.equal(revealed.length, 1, 'embed mode shows it over the globe');
controls.destroy();
}));
test('in embed mode Esc closes the photo, and the next photo is shown over the globe again', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
+1 -1
View File
@@ -96,7 +96,7 @@ const LOOK_AT_MAX_DISTANCE_M = 10_000;
* further than 10 km ahead gives its own point. Longitude wraps at the
* antimeridian.
*/
function lookAtPoint(camera) {
export function lookAtPoint(camera) {
const pitch = Number(camera.pitch_deg ?? -90);
const altitude = Number(camera.altitude_m);
if (
+13
View File
@@ -14,6 +14,7 @@ import { applicationPinia } from '../ui/vueRuntime.js';
import * as Cesium from 'cesium';
import { CITY_POIS } from '../cityPois.js';
import { greatCircleKm } from '../geoDistance.js';
import { EXCLUSIVE_SURFACE_CLASSES } from '../exclusiveSurfaces.js';
import {
hudViewportSamples,
placeLookupView,
@@ -3438,6 +3439,18 @@ export async function showStreetLevel(
point = { lat: location.latitude, lon: location.longitude };
}
// Cockpit, clean view, recording and scene playback hide the panel the
// photo opens in: say so before anything loads unseen.
const body = globalThis.document?.body;
if (EXCLUSIVE_SURFACE_CLASSES.some((mode) => body?.classList?.contains(mode)))
return fail(
'view',
msg(
'Street-level photos are hidden in the current view. Leave it first.',
),
{ location },
);
// The source's map, when it needs one (Street View: Google 3D only).
const requiredStack = layerModule.requiredMapStack?.(provider) || null;
const activeStack = styleManager?.mapStackController?.getActiveId?.() ?? null;
+86
View File
@@ -4582,3 +4582,89 @@ test('a partial analyst card shows one partial chip', () => {
const labels = card.display.chips.map((chip) => chip.label);
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();
let current = true;
const pending = h.runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google' }, { isCurrent: () => current });
await new Promise((resolve) => setImmediate(resolve));
current = false;
h.finishFlight();
const result = await pending;
assert.deepEqual(h.order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], 'the stale turn switched nothing');
assert.equal(result.cancelled, true);
});
test('show_street_level superseded during its map switch lights no source', 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}`);
current = false;
return { ok: true, activeStack: id };
},
},
dataManager: h.dataManager,
});
const result = await runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false }, { isCurrent: () => current });
assert.deepEqual(h.order.filter((step) => /^(params|open|arm)/.test(step)), [], 'the stale turn lit nothing');
assert.deepEqual([result.stage, result.cancelled], ['map', true]);
});
test('show_street_level goes ahead while a source’s key status is still unknown', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
// A freshly loaded sl= link meets the source before its status call answers.
const { runner, order } = createStreetLevelHarness({ enabled: true, configured: null });
const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.equal(result.ok, true);
assert.ok(order.some((step) => step.startsWith('open:mapillary')));
});
test('show_street_level in a view that hides its panel stops before anything loads unseen', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
for (const mode of ['cockpit-mode', 'ui-clean-view', 'recording-mode', 'scene-playback-mode']) {
globalThis.document = { body: { classList: { contains: (name) => name === mode } } };
try {
const { runner, order } = createStreetLevelHarness({ enabled: true });
const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage], [false, 'view'], mode);
assert.match(result.error, /hidden in the current view/);
assert.deepEqual(order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], `${mode}: nothing switched`);
} finally {
globalThis.document = saved;
}
}
});
+2
View File
@@ -608,6 +608,8 @@ function showStreetLevel(result) {
? `${source} refused its key.`
: `${source} needs its key.`;
else if (result?.stage === 'layer') say = "Couldn't turn on Street Level.";
else if (result?.stage === 'view')
say = 'Street-level photos are hidden in this view. Leave it first.';
else if (result?.stage === 'imagery')
say = result?.filter?.label
? `No ${source} imagery near ${place} with the ${result.filter.label} on.`