mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 15:22:21 +00:00
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:
1 parent
8ce01cf48e
commit
0ec57057fa
30 files changed
+473
-65
No files matched your search
+24
-6
@@ -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
@@ -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();
|
||||
|
||||
@@ -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}.`),
|
||||
);
|
||||
|
||||
@@ -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.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。"
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него."
|
||||
}
|
||||
@@ -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.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього."
|
||||
}
|
||||
@@ -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.": "当前视图隐藏了街景照片。请先退出该视图。"
|
||||
}
|
||||
@@ -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.": "目前的檢視隱藏了街景照片。請先離開此檢視。"
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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() } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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.`
|
||||
|
||||
Reference in new issue
Block a user