mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02: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
+1
-1
@@ -129,7 +129,7 @@ God's Eye View's tools are also served to MCP clients: over stdio (`npm run mcp`
|
||||
- **What the panel's requests can reach.** In clients that display MCP Apps, the panel loads the app through the `panel_request` tool, which requests paths on the app's server. It is marked for the panel only and requires a key that each MCP server puts in its panel page, which keeps it from clients that list tools to the model without loading the panel. That key is not access control: any MCP client can read the panel page, key included, and then reach the app's files and data routes through `panel_request`, much as its other tools reach the data. Provider Settings, credential and model endpoints, `/mcp` and the development server's internal routes are refused to every caller.
|
||||
- **The panel carries the two browser keys.** The panel build (`npm run build:panel`) includes the same Google Maps key and Cesium ion token as the app, and they run inside the client's panel page, on the client's site. A key restricted by HTTP referrer must also allow that page, or the panel cannot load tiles. To keep the app's own keys narrower, build the panel with separately restricted keys in the environment of `npm run build:panel`.
|
||||
- **Embed mode is not framable by default.** `?embed=1` documents keep `X-Frame-Options: DENY` and `frame-ancestors 'none'` unless `GEV_EMBED_FRAME_ANCESTORS` names the pages that may frame them; a framing page can change what the app shows. Other documents can never be framed.
|
||||
- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,<lat>,<lon>` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link, are that client's own request and open the photo; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image.
|
||||
- **A plain link never loads a billed Street View panorama without a click.** A share link with `sl=google,<lat>,<lon>` flies there, switches to Google 3D and lights Street View, then stops: the panel is armed and the photo loads only when the visitor presses OPEN GOOGLE STREET VIEW (one lookup and one panorama load on the browser Maps key, no retries, no geocoding or server call). A view posted into the embedded app (the God's Eye View panel, or a page `GEV_EMBED_FRAME_ANCESTORS` allows), and the panel page's own opening link for a tool result issued in the last five minutes, are that client's own request and open the photo; a client that mounts the panel again later (reopening a conversation) hands it the old result, which only arms; adding `?embed=1` to a published link does not lift the gate (in a frame the link only arms; the panel that holds OPEN is hidden there, so a posted view is the way to open a photo). The armed lookup dies when its source is switched off, when its map leaves, or when any other photo opens or closes. A link that follows an entity ignores `sl`. Mapillary, KartaView and Panoramax links open their photo at once; none of them bills per image.
|
||||
|
||||
## Scope & expectations
|
||||
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
`show_in_gods_eye_view` and a link can open a panorama at a place. Street
|
||||
View lookups from a geocoded place search up to 250 m; clicks keep 50 m. A
|
||||
plain link with `sl=google` arms Street View and leaves the billed load to a
|
||||
press of OPEN GOOGLE STREET VIEW; embedded views open it. The source the
|
||||
press of OPEN GOOGLE STREET VIEW, which aims at the view centre again once
|
||||
the visitor has moved more than 1 km away; embedded views open it, and the
|
||||
God's Eye View panel only for a result issued in the last five minutes. On
|
||||
a phone, a photo that opens or a link that arms opens the Cameras sheet;
|
||||
in cockpit, clean view, recording or scene playback the action says the
|
||||
panel is hidden instead of loading a photo nobody sees. Mapillary's
|
||||
nearest-photo search asks for 100 photos, so the nearest and the filter's
|
||||
pick come from the spot's photos rather than an arbitrary eight. The source the
|
||||
action lights stays lit like a chip click. An unavailable map stack now
|
||||
reports the controller's own reason instead of always asking for a Cesium
|
||||
ion token. When nothing is found and a 360°, flat or SINCE filter the source
|
||||
|
||||
+19
-6
@@ -4339,14 +4339,20 @@ centre), switches to the map the source needs (Google Street View: Google 3D),
|
||||
lights that source through `setLayerParams` with origin `voice` (one source at
|
||||
a time, kept in the share link like a chip click), turns the layer on and
|
||||
calls `openNearest`. `provider` is `google` for "Street View", `mapillary`,
|
||||
`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `map`,
|
||||
`provider`, `layer`, `key`, `imagery`; an empty `imagery` result names a
|
||||
`kartaview` or `panoramax` by name, else the lit source. Stages fail separately (`location`, `view`, `map`,
|
||||
`provider`, `layer`, `key`, `imagery`; `view` when cockpit, clean view,
|
||||
recording or scene playback hides the panel, checked after the flight and
|
||||
before anything switches or loads; an empty `imagery` result names a
|
||||
narrowing 360°/flat or SINCE filter the source honours, as `filter`) and a
|
||||
newer turn retires the lookup
|
||||
(`openNearest`'s `signal` option), so nothing opens after the user moved on.
|
||||
For a source without a coverage layer the lookup widens to 250 m
|
||||
(`NEAREST_RADIUS_MAX_M`; Google honours it, Mapillary's API caps at 50 m);
|
||||
clicks keep 50 m. The layer exposes `requiredMapStack(id)` and
|
||||
clicks keep 50 m. Mapillary's search asks for 100 photos, the API's most: it
|
||||
returns the radius's photos in no particular order and cuts them at the limit
|
||||
first, so the distance sort and the imagery filter run over the spot's photos.
|
||||
On a phone the panel sits in the Cameras tab sheet: a photo that opens, and a
|
||||
link that arms, open that sheet (`revealPanel`). The layer exposes `requiredMapStack(id)` and
|
||||
`providerStatus(id)` for callers that must decide before the panel lists a
|
||||
source, and `coverageLines(id)` (the lines a provider draws now, current and
|
||||
stale tiles, as `[lon, lat]` parts) for diagnostics and the browser gate,
|
||||
@@ -4369,13 +4375,20 @@ link never loads a photo from a source that bills every load
|
||||
(`billedPerImage` on the provider; Google): the action runs with
|
||||
`autoOpen: false`, arms the lookup on the layer (`armNearest`: the link's
|
||||
point and radius, consumed only by the next OPEN press (`aim:
|
||||
'screen-centre'`); any other lookup, open, close, disable, its source
|
||||
'screen-centre'`), and only while the view centre is within 1 km of the point
|
||||
(`ARMED_REACH_M`; a user who moved on opens what the view shows); any other lookup, open, close, disable, its source
|
||||
switched off or its map leaving drops it), opens the panel and a toast
|
||||
points at OPEN GOOGLE STREET VIEW. Only the panel page's own link (inline
|
||||
embed) and posted views open the photo; `?embed=1` on a published link only
|
||||
embed) for a tool result issued in the last 5 minutes, and posted views, open
|
||||
the photo: `show_in_gods_eye_view` stamps a street-level result `issued_at`,
|
||||
and a host that mounts the panel again later hands back the old result, which
|
||||
only arms; `?embed=1` on a published link only
|
||||
arms, without the panel nudge or toast (the rail is hidden in a frame). A link that follows an entity ignores `sl`.
|
||||
The look-at default applies below 20 km, at a pitch steeper than -5° and
|
||||
within 10 km ahead; otherwise the camera's own point is used.
|
||||
within 10 km ahead; otherwise the camera's own point is used. A view's
|
||||
altitude is above the ellipsoid, so `show_in_gods_eye_view` measures it from
|
||||
the terrain under the camera (`services.terrain`) before taking the look-at
|
||||
point; without a terrain answer it keeps the view's own estimate.
|
||||
|
||||
Street Level has two browser gates. `npm run qa:street-level -- --url <server>`
|
||||
runs against real Mapillary and needs `MAPILLARY_CLIENT_TOKEN`. `npm run
|
||||
|
||||
+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