Merge upstream main into denis/rail-layer

This commit is contained in:
Denis Groshev committed 2026-11-11 18:02:52 +01:00
commit 13818d0b9f
87 files changed
+7438 -144

No files matched your search

+272 -24
View File
@@ -12,13 +12,25 @@
*/
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { annotationsFromParams, createView } from '../view/index.js';
import {
announceNavigationAuthority,
watchCameraOwner,
} from '../navigationPolicy.js';
import { t, translateKnown } from '../i18n/index.js';
import { GOOGLE_LABEL } from '../layers/streetLevel/providers/google/policy.js';
import {
annotationsFromParams,
createView,
followFromParams,
groundedLookAt,
streetLevelFromParams,
} from '../view/index.js';
const EMBED_VIEW_MESSAGE = 'gev:view';
const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
const EMBED_READY_MESSAGE = 'gev:ready';
const STREET_LEVEL_LAYER = 'street-level';
const FOLLOW_LAYERS = {
aircraft: 'flights',
military_aircraft: 'military',
@@ -27,6 +39,8 @@ const FOLLOW_LAYERS = {
const FOLLOW_ATTEMPTS = 20;
const FOLLOW_RETRY_MS = 1000;
const FLIGHT_SECONDS = 2;
/** How long a posted view waits for the terrain under its camera. */
const TERRAIN_WAIT_MS = 2000;
/**
* Whether this page shows the app embedded: `?embed=1` when another page
@@ -45,6 +59,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
@@ -165,6 +187,40 @@ function flyTo(viewer, camera) {
});
}
/**
* The `show_street_level` arguments for a view's street-level photos. A plain
* link (`autoOpen: false`) only arms a source that bills every load; a view
* posted into the embedded app is the client's own request and opens.
*/
function streetLevelArgs(streetLevel, { autoOpen = true } = {}) {
return {
latitude: streetLevel.lat,
longitude: streetLevel.lon,
...(streetLevel.provider ? { provider: streetLevel.provider } : {}),
fly: true,
...(autoOpen ? {} : { autoOpen: false }),
};
}
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
/** Dispatched on the window when a link opened after start-up has restored. */
const SHARE_NAVIGATION_RESTORED_EVENT = 'gev:share-navigation-restore-settled';
/** 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) }),
});
}
/** Why a link's photo stays shut: its source has no working key here. */
function keyToast(result) {
return t(
"{source} cannot open this link's street-level photo without a working key",
{ source: translateKnown(result.providerName || 'Street Level') },
);
}
/**
* Make the app show a view: style and map, exactly the view's layers,
* annotations, the camera, and the followed entity. Each step runs through the
@@ -173,13 +229,20 @@ function flyTo(viewer, camera) {
*/
export async function applyView(
view,
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
{
viewer,
dataManager,
run,
signal,
autoOpen = true,
retryMs = FOLLOW_RETRY_MS,
},
) {
const steps = [];
const act = async (name, args) => {
const act = async (name, args, options) => {
let result = null;
try {
result = await run(name, args);
result = await run(name, args, options);
} catch (error) {
result = { ok: false, error: error?.message || String(error) };
}
@@ -198,6 +261,8 @@ export async function applyView(
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
}
for (const layerId of view.layers) {
// The street-level action lights its source first, then the layer.
if (view.street_level && layerId === STREET_LEVEL_LAYER) continue;
if (!dataManager.isEnabled(layerId))
await act('set_layer_visibility', { layerId, enabled: true });
}
@@ -207,6 +272,29 @@ export async function applyView(
await act('clear_annotations', {});
if (view.annotations.length)
await act('annotate_map', { annotations: [...view.annotations] });
if (view.street_level) {
// The photos frame the camera; when the action fails before flying, the
// view's own camera still applies.
// A posted view replays someone else's view, as a link does: the source
// it lights is not saved as the visitor's own choice.
const result = await act(
'show_street_level',
streetLevelArgs(view.street_level, { autoOpen }),
{ origin: 'programmatic' },
);
if (result?.ok !== true) {
// Overtaken (a click on the map, a newer request): hands off.
if (result?.cancelled || result?.superseded) return steps;
// An earlier view's photo must not stay over a place this view left.
// From the imagery stage on the action has settled the panel itself
// (a photo it could not load keeps its error).
if (result?.stage !== 'imagery')
dataManager.layers?.get?.(STREET_LEVEL_LAYER)?.module?.closeViewer?.();
if (!result?.location)
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
}
return steps;
}
if (!view.follow) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
return steps;
@@ -250,18 +338,115 @@ export function installViews({
dataManager,
run,
signal,
terrain = null,
location = globalThis.location,
windowRef = globalThis.window,
}) {
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
const linked = annotationsFromParams(
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
const hashParams = new URLSearchParams(
String(location?.hash || '').replace(/^#/, ''),
);
if (linked.length)
void ready.then(() => {
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
// The link's own steps run first, one at a time, and posted views queue
// behind them; one failing step does not take the next with it. A passive
// link never flies over someone who took the camera: its steps are skipped
// when a newer link superseded it, when its flight never landed (the
// recipient dragged or searched), or after any navigation since the
// generation its restore flight owned.
const cameraLanded = (outcome) => outcome?.share?.camera === 'applied';
const restoreGeneration = (outcome) =>
Number.isInteger(outcome?.navigationToken)
? outcome.navigationToken
: shell._navigationGeneration;
let generationAtRestore = null;
let startSuperseded = false;
let queue = ready.then((outcome) => {
generationAtRestore = restoreGeneration(outcome);
startSuperseded =
outcome?.status === 'superseded' ||
(Boolean(outcome?.share) && !cameraLanded(outcome));
});
const navigatedSince = (generation) =>
Number.isFinite(generation) && shell._navigationGeneration !== generation;
const linkStep = (work) =>
queue.then(async () => {
if (signal?.aborted) return;
try {
await work();
} catch {
/* reported by the action itself */
}
});
if (!isEmbedded(location)) return () => {};
const linked = annotationsFromParams(hashParams);
if (linked.length)
queue = linkStep(() => run('annotate_map', { annotations: linked }));
// A link's street-level photos open once it has restored. A billed source
// only arms, except for the panel page's own link to a result issued just
// now. A link that follows an entity keeps the camera on it.
const linkedStreetLevel = (params) =>
followFromParams(params) ? null : streetLevelFromParams(params);
const openLinkedStreetLevel = async (
streetLevel,
{ generation, autoOpen = false },
) => {
if (navigatedSince(generation)) return;
// The link replays the sender's view: nothing it switches becomes the
// recipient's own preference.
const result = await run(
'show_street_level',
streetLevelArgs(streetLevel, { autoOpen }),
{ origin: 'programmatic' },
);
// A source without a working key changes nothing: say why, or the link
// looks broken. In a frame the host reads the result instead.
if (result?.stage === 'key' && !isEmbedded(location)) {
shell._showToast?.(keyToast(result));
return;
}
// OPEN must be in view, but embed mode hides the rail that holds it: in
// a frame a posted view is the way to open a photo.
if (result?.armed === true && !isEmbedded(location)) {
// Opened for this link only: the recipient's saved layout stays theirs.
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
explicit: true,
persist: false,
});
// On a phone the panel sits in a tab sheet: open it so OPEN shows.
shell.revealPanel?.(STREET_LEVEL_PANEL_ID);
shell._showToast?.(armedToast());
}
};
const startStreetLevel = linkedStreetLevel(hashParams);
if (startStreetLevel)
queue = linkStep(() =>
startSuperseded
? undefined
: openLinkedStreetLevel(startStreetLevel, {
generation: generationAtRestore,
autoOpen: isFreshPanelResult(),
}),
);
// A link pasted into the open tab: its photos follow once its restore
// settles, under the same rules.
const onNavigatedRestore = (event) => {
if (event?.detail?.status !== 'settled' || !cameraLanded(event.detail))
return;
const streetLevel = linkedStreetLevel(
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
);
if (!streetLevel) return;
const generation = restoreGeneration(event.detail);
queue = linkStep(() => openLinkedStreetLevel(streetLevel, { generation }));
};
windowRef?.addEventListener?.(
SHARE_NAVIGATION_RESTORED_EVENT,
onNavigatedRestore,
);
const stopNavigatedRestores = () =>
windowRef?.removeEventListener?.(
SHARE_NAVIGATION_RESTORED_EVENT,
onNavigatedRestore,
);
if (!isEmbedded(location)) return stopNavigatedRestores;
windowRef.document.body.classList.add('ui-embed');
shell.setCleanView?.(true);
@@ -278,7 +463,6 @@ export function installViews({
peer.postMessage(message, origin);
};
// Views apply one at a time, in the order they arrive.
let queue = ready;
const onMessage = (event) => {
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
return;
@@ -302,19 +486,82 @@ export function installViews({
);
return;
}
queue = queue.then(async () => {
if (signal?.aborted) return;
const steps = await applyView(view, { viewer, dataManager, run, signal });
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
// A panel's host can hand back old results on a remount: only one issued
// just now may open a billed photo. A framing page's view is its own.
const autoOpen = !isEmbeddedInline() || event.data.fresh === true;
// Photos asked for without a point open where the camera looks, measured
// from the ground under it, as show_in_gods_eye_view places them.
const asked = event.data.view?.street_level;
const unplaced =
asked &&
typeof asked === 'object' &&
asked.lat == null &&
asked.lon == null;
queue = queue
.then(async () => {
if (signal?.aborted) return;
if (unplaced && view.street_level) {
// A terrain service that does not answer soon leaves the view's
// own estimate.
const wait = AbortSignal.timeout(TERRAIN_WAIT_MS);
const owner = watchCameraOwner(viewer, shell);
const point = await groundedLookAt(
view.camera,
terrain,
signal ? AbortSignal.any([signal, wait]) : wait,
)
.catch(() => null)
.finally(() => owner.stop());
if (signal?.aborted) return;
// Whoever takes the camera while the terrain answers (a drag, a
// search, the assistant) keeps it: the view stands down.
if (owner.taken()) {
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
steps: [{ step: 'street_level', ok: false, superseded: true }],
},
replyOrigin,
);
return;
}
if (point)
view = createView({
...event.data.view,
street_level: { ...asked, ...point },
});
}
const steps = await applyView(view, {
viewer,
dataManager,
run,
signal,
autoOpen,
});
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
);
})
// A view that fails is answered, and the views behind it still apply.
.catch((error) =>
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
error: error?.message || String(error),
},
replyOrigin,
),
);
});
};
windowRef.addEventListener('message', onMessage);
void ready.then(() => {
@@ -322,6 +569,7 @@ export function installViews({
});
return () => {
stopRendering();
stopNavigatedRestores();
windowRef.removeEventListener('message', onMessage);
};
}
+901 -3
View File
@@ -8,7 +8,7 @@ import {
panelAwareVisibility,
} from './embed.js';
import { LayerLifecycle } from '../data/lifecycle.js';
import { createView } from '../view/index.js';
import { createView, viewToParams } from '../view/index.js';
const fakeViewer = () => {
const flights = [];
@@ -32,14 +32,16 @@ const fakeLayers = (enabled) => ({
});
const recorder = (answers = {}) => {
const calls = [];
const run = async (name, args) => {
const options = [];
const run = async (name, args, runOptions) => {
calls.push([name, args]);
options.push([name, runOptions]);
const answer = answers[name];
return typeof answer === 'function'
? answer(calls)
: (answer ?? { ok: true });
};
return { calls, run };
return { calls, run, options };
};
test('embed mode is opt-in by query parameter', () => {
@@ -83,6 +85,19 @@ test('a view is applied through the app actions in order', async () => {
assert.ok(steps.some((step) => step.step === 'camera'));
});
test("a view's street-level step replays the sender's view: nothing it switches is the visitor's choice", async () => {
const { options, run } = recorder();
await applyView(
createView({
camera: { lat: 48.86, lon: 2.29 },
street_level: { provider: 'panoramax', lat: 48.8584, lon: 2.2945 },
}),
{ viewer: fakeViewer(), dataManager: fakeLayers(new Set()), run },
);
const [, runOptions] = options.find(([name]) => name === 'show_street_level');
assert.equal(runOptions?.origin, 'programmatic');
});
test('a followed entity is retried until its layer has it', async () => {
let tries = 0;
const { run } = recorder({
@@ -102,6 +117,65 @@ test('a followed entity is retried until its layer has it', async () => {
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
});
test('a posted view without a photo point opens its photos where the camera looks over the ground', async () => {
const parent = { postMessage() {} };
const windowRef = new EventTarget();
windowRef.parent = parent;
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
const shell = { initialRestorePromise: Promise.resolve(), setCleanView() {} };
const { calls, run } = recorder();
const asked = [];
// Denver: the ground is 1,590 m above the ellipsoid under a camera at 1,909 m.
const terrain = {
getHeights: async (points) => {
asked.push(points);
return points.map(() => ({ ellipsoid: 1590 }));
},
};
const remove = installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
terrain,
location: { search: '?embed=1', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
const event = new Event('message');
Object.assign(event, {
source: parent,
origin: 'https://host.example',
data: {
type: 'gev:view',
id: 1,
view: {
camera: {
lat: 39.74,
lon: -104.99,
altitude_m: 1909,
pitch_deg: -30,
heading_deg: 0,
},
street_level: { provider: 'mapillary' },
},
},
});
windowRef.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 10));
remove();
const [, args] = calls.find(([name]) => name === 'show_street_level');
// 319 m above the ground at -30° looks ~550 m ahead, not the ~3.3 km the
// ellipsoid altitude would give.
assert.deepEqual(asked, [[{ lat: 39.74, lon: -104.99 }]]);
assert.ok(
args.latitude > 39.744 && args.latitude < 39.746,
String(args.latitude),
);
assert.equal(args.longitude, -104.99);
});
test('an embedded page takes views only from its parent and answers it', async () => {
const posted = [];
const targets = [];
@@ -186,6 +260,59 @@ test('an embedded page takes views only from its parent and answers it', async (
remove();
});
test('an inline panel opens a billed photo only for a view whose result is fresh', async () => {
const windowRef = new EventTarget();
windowRef.parent = windowRef;
windowRef.postMessage = () => {};
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
Object.assign(windowRef, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: () => 1,
clearInterval() {},
});
const { calls, run } = recorder();
globalThis.GEV_EMBED_INLINE = true;
try {
const remove = installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '', hash: '' },
windowRef,
});
const view = {
camera: { lat: 48.8584, lon: 2.2945 },
map: 'photoreal',
street_level: { provider: 'google', lat: 48.8584, lon: 2.2945 },
};
for (const [id, fresh] of [
[1, true],
[2, false],
[3, undefined],
]) {
const event = new Event('message');
Object.assign(event, {
source: windowRef,
data: { type: 'gev:view', id, view, fresh },
});
windowRef.dispatchEvent(event);
}
await new Promise((resolve) => setTimeout(resolve, 20));
assert.deepEqual(
calls
.filter(([name]) => name === 'show_street_level')
.map(([, args]) => args.autoOpen ?? true),
[true, false, false],
);
remove();
} finally {
delete globalThis.GEV_EMBED_INLINE;
}
});
test('an app loaded inline into a panel page talks through its own window', async () => {
const posted = [];
const windowRef = new EventTarget();
@@ -393,3 +520,774 @@ test('a followed aircraft owns the camera, and cockpit view is entered after it'
/no cockpit view/,
);
});
test('a view with street-level photos lets the action fly and open them', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const view = createView({
camera: { lat: 38.5799, lon: -121.4937, altitude_m: 500 },
street_level: { provider: 'google' },
});
const steps = await applyView(view, {
viewer,
dataManager: fakeLayers(new Set()),
run,
});
assert.deepEqual(
calls.map(([name]) => name),
[
'set_map_stack',
'stop_tracking',
'clear_annotations',
'show_street_level',
],
);
assert.deepEqual(calls.at(-1)[1], {
latitude: 38.5799,
longitude: -121.4937,
provider: 'google',
fly: true,
});
assert.equal(viewer.flights.length, 0, 'the action frames the camera');
assert.ok(steps.every((step) => step.ok));
assert.equal(steps.at(-1).step, 'show_street_level');
});
test('a link to street-level photos opens them after it restores', async () => {
const { calls, run } = recorder();
const toasts = [];
const panels = [];
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
_showToast: (text) => toasts.push(text),
setPanelCollapsed: (...args) => panels.push(args),
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
[
'show_street_level',
{
latitude: 38.58,
longitude: -121.49,
provider: 'mapillary',
fly: true,
autoOpen: false,
},
],
]);
assert.deepEqual(
[toasts, panels],
[[], []],
'an opened photo needs no toast or panel nudge',
);
// A failure (no key) is not an armed state either: it says why instead.
const failed = recorder({
show_street_level: () => ({
ok: false,
stage: 'key',
error: 'needs its key',
}),
});
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
_showToast: (text) => toasts.push(text),
setPanelCollapsed: (...args) => panels.push(args),
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: failed.run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
[toasts, panels],
[
[
"Street Level cannot open this link's street-level photo without a working key",
],
[],
],
'it says why, and nothing to press opens',
);
});
test('a link that follows an entity keeps the camera: its photos are not opened', async () => {
const { calls, run } = recorder();
const params = viewToParams(
createView({
camera: { lat: 1, lon: 2 },
follow: { kind: 'satellite', id: '25544' },
}),
);
params.set('sl', 'mapillary,1.00000,2.00000');
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '', hash: `#${params}` },
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, []);
});
test('a failing link step does not take the next one with it', async () => {
const calls = [];
const run = async (name, args) => {
calls.push(name);
if (name === 'annotate_map') throw new Error('marks failed');
return { ok: true, armed: false, ...args };
};
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['annotate_map', 'show_street_level']);
});
test('a link whose source has no working key says why instead of doing nothing; a frame stays quiet', async () => {
for (const embed of [false, true]) {
const toasts = [];
const { calls, run } = recorder({
show_street_level: () => ({
ok: false,
stage: 'key',
provider: 'google',
providerName: 'Google Street View',
error: 'Google Street View needs its key',
}),
});
const windowRef = new EventTarget();
if (embed) {
windowRef.document = { body: { classList: { add() {} } } };
windowRef.parent = { postMessage() {} };
}
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
setCleanView() {},
_showToast: (text) => toasts.push(text),
setPanelCollapsed() {
throw new Error('nothing to arm');
},
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: embed ? '?embed=1' : '',
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
},
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls[0][0], 'show_street_level');
assert.deepEqual(
toasts,
embed
? []
: [
"Google Street View cannot open this link's street-level photo without a working key",
],
);
}
});
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 }),
});
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
_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()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls[0][1].autoOpen, false);
assert.deepEqual(toasts, [
'Street View is ready: press OPEN GOOGLE STREET VIEW to load it',
]);
assert.deepEqual(
panels,
[['street-level-panel', false, { explicit: true, persist: false }]],
"the panel opens so OPEN is in view, without saving the layout as the recipient's",
);
assert.deepEqual(revealed, ['street-level-panel'], 'its phone sheet too');
// Adding ?embed=1 to a published link does not lift the gate: the page is
// still a plain top-level load.
const framedToasts = [];
const framedPanels = [];
const framed = recorder({
show_street_level: () => ({ ok: true, armed: true }),
});
const windowRef = new EventTarget();
windowRef.document = { body: { classList: { add() {} } } };
windowRef.parent = { postMessage() {} };
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
setCleanView() {},
_showToast: (text) => framedToasts.push(text),
setPanelCollapsed: (...args) => framedPanels.push(args),
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: framed.run,
location: {
search: '?embed=1',
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
},
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
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 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 {
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();
await applyView(
createView({
camera: { lat: 1, lon: 2 },
street_level: { provider: 'google' },
}),
{
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: posted.run,
},
);
assert.equal('autoOpen' in posted.calls.at(-1)[1], false);
});
test('a view whose photos fail before any flight still gets its camera', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder({
show_street_level: () => ({
ok: false,
stage: 'key',
error: 'needs its key',
}),
});
const view = createView({
camera: { lat: 1, lon: 2, altitude_m: 500 },
street_level: { provider: 'google' },
});
const steps = await applyView(view, {
viewer,
dataManager: fakeLayers(new Set()),
run,
});
assert.equal(viewer.flights.length, 1, 'the view flies itself');
assert.deepEqual(
steps.map((step) => [step.step, step.ok]),
[
['set_map_stack', true],
['stop_tracking', true],
['clear_annotations', true],
['show_street_level', false],
['camera', true],
],
);
assert.ok(calls.some(([name]) => name === 'show_street_level'));
// A flight that happened (the action reports where) is not repeated.
const flown = fakeViewer();
const { run: flownRun } = recorder({
show_street_level: () => ({
ok: false,
stage: 'imagery',
location: 'Somewhere',
error: 'no imagery',
}),
});
await applyView(view, {
viewer: flown,
dataManager: fakeLayers(new Set()),
run: flownRun,
});
assert.equal(flown.flights.length, 0);
});
test('a link runs its marks and photos in order, and posted views wait behind them', async () => {
const log = [];
let releaseStreetLevel = null;
let releaseMarks = null;
const run = async (name, args) => {
log.push(`start:${name}`);
if (name === 'annotate_map')
await new Promise((resolve) => {
releaseMarks = resolve;
});
if (name === 'show_street_level')
await new Promise((resolve) => {
releaseStreetLevel = resolve;
});
log.push(`end:${name}`);
return {
ok: true,
...(name === 'show_street_level' ? { location: 'x' } : {}),
};
};
const windowRef = new EventTarget();
windowRef.document = { body: { classList: { add() {} } } };
// A framing page: answers go to it, not to the app's own window.
windowRef.parent = {
postMessage: (message) => log.push(`post:${message.type}`),
};
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '?embed=1',
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
},
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
// A view arrives while the link's photos are still opening.
windowRef.dispatchEvent(
Object.assign(new Event('message'), {
source: windowRef.parent,
origin: 'null',
data: {
type: 'gev:view',
id: 'v1',
view: { camera: { lat: 3, lon: 4 } },
},
}),
);
await new Promise((resolve) => setTimeout(resolve, 0));
const actions = () => log.filter((entry) => !entry.startsWith('post:'));
assert.deepEqual(
actions(),
['start:annotate_map'],
'the photos wait for the marks',
);
releaseMarks();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(actions().slice(0, 3), [
'start:annotate_map',
'end:annotate_map',
'start:show_street_level',
]);
assert.ok(
!log.includes('start:stop_tracking'),
'the posted view waits for the link',
);
releaseStreetLevel();
for (
let tick = 0;
tick < 20 && !log.includes('post:gev:view-applied');
tick++
)
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
log.indexOf('end:show_street_level') < log.indexOf('start:stop_tracking'),
true,
);
assert.ok(log.includes('post:gev:view-applied'), log.join(' '));
});
test('an aborted install never runs the link photos', async () => {
const { calls, run } = recorder();
const controller = new AbortController();
controller.abort();
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
signal: controller.signal,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=google,1.00000,2.00000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, []);
});
test('a passive link stands down when someone navigated while it waited behind its marks', async () => {
const calls = [];
let releaseMarks = null;
const shell = {
initialRestorePromise: Promise.resolve(),
_navigationGeneration: 7,
};
const run = async (name) => {
calls.push(name);
if (name === 'annotate_map')
await new Promise((resolve) => {
releaseMarks = resolve;
});
return { ok: true };
};
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, ['annotate_map']);
shell._navigationGeneration = 8; // the recipient searched, or the assistant flew
releaseMarks();
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
calls,
['annotate_map'],
'the link did not fly over the newer navigation',
);
// Unchanged navigation: the photos open as before.
const quiet = [];
installViews({
shell: {
initialRestorePromise: Promise.resolve(),
_navigationGeneration: 3,
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: async (name) => {
quiet.push(name);
return { ok: true };
},
location: {
search: '',
hash: `#v=2&lat=1&lon=2&an=${an}&sl=mapillary,1.00000,2.00000`,
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(quiet, ['annotate_map', 'show_street_level']);
});
test('a link replays the sender’s view: its street-level step never writes the recipient’s preferences', async () => {
const { calls, run, options } = recorder();
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls.length, 1);
assert.deepEqual(options, [
['show_street_level', { origin: 'programmatic' }],
]);
});
test('a start link whose restore a newer link superseded opens nothing', async () => {
const { calls, run } = recorder();
installViews({
shell: {
initialRestorePromise: Promise.resolve({ status: 'superseded' }),
_navigationGeneration: 4,
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [], 'start link A does not pull the camera from B');
});
test('a start link whose restore flight the recipient interrupted leaves the camera with them', async () => {
for (const [camera, expected] of [
['cancelled', 0],
['skipped', 0],
['applied', 1],
]) {
const { calls, run } = recorder();
installViews({
shell: {
initialRestorePromise: Promise.resolve({
status: 'settled',
share: { camera },
}),
_navigationGeneration: 4,
},
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: {
search: '',
hash: '#v=2&lat=1&lon=2&sl=mapillary,38.58000,-121.49000',
},
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls.length, expected, camera);
}
});
test('a link pasted into the open tab opens or arms its photos once its restore settles', async () => {
const { calls, run, options } = recorder();
const windowRef = new EventTarget();
const location = { search: '', hash: '#v=2&lat=1&lon=2' };
const shell = {
initialRestorePromise: Promise.resolve(),
_navigationGeneration: 1,
};
const remove = installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location,
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [], 'the start link has no photos');
const settled = (status, camera = 'applied') =>
windowRef.dispatchEvent(
Object.assign(new Event('gev:share-navigation-restore-settled'), {
detail: { status, share: { camera } },
}),
);
location.hash = '#v=2&lat=3&lon=4&sl=mapillary,48.88740,2.34950';
shell._navigationGeneration = 2; // the restore's own flight
settled('failed');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [], 'a failed restore opens nothing');
// The recipient dragged or searched during the restore flight: the camera
// is theirs, and the link does not pull it back.
shell._navigationGeneration = 3;
for (const camera of ['cancelled', 'superseded']) {
settled('settled', camera);
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
calls,
[],
`an interrupted restore (${camera}) opens nothing`,
);
}
settled('settled');
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
[
'show_street_level',
{
latitude: 48.8874,
longitude: 2.3495,
provider: 'mapillary',
fly: true,
autoOpen: false,
},
],
]);
assert.deepEqual(options.at(-1), [
'show_street_level',
{ origin: 'programmatic' },
]);
remove();
location.hash = '#v=2&lat=5&lon=6&sl=mapillary,1.00000,2.00000';
settled('settled');
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(calls.length, 1, 'removed with the install');
});
test('a view whose photo step the user overtook leaves their photo and camera alone', async () => {
// Photo B, opened by a click while the posted view looked up its photo.
const street = { open: false, imageId: null };
const dataManager = {
...fakeLayers(new Set()),
layers: new Map([
[
'street-level',
{
module: {
closeViewer: () =>
Object.assign(street, { open: false, imageId: null }),
},
},
],
]),
};
const { run } = recorder({
show_street_level: () => {
Object.assign(street, { open: true, imageId: 'b' });
return {
ok: false,
stage: 'imagery',
cancelled: true,
superseded: true,
error: 'The camera moved on before the photo opened',
};
},
});
const view = createView({
camera: { lat: 1, lon: 2, altitude_m: 500 },
street_level: { provider: 'mapillary' },
});
const viewer = fakeViewer();
const steps = await applyView(view, { viewer, dataManager, run });
assert.deepEqual(street, { open: true, imageId: 'b' }, 'photo B stays');
assert.equal(viewer.flights.length, 0, 'the camera stays with the user');
assert.equal(steps.at(-1).ok, false, 'reported as not applied');
});
test('a view whose photo was found but could not load keeps its error in view', async () => {
const closed = [];
const dataManager = {
...fakeLayers(new Set()),
layers: new Map([
['street-level', { module: { closeViewer: () => closed.push(true) } }],
]),
};
const { run } = recorder({
show_street_level: () => ({
ok: false,
stage: 'imagery',
imageryError: true,
location: '1.00000, 2.00000',
error: 'Image could not be opened',
}),
});
const view = createView({
camera: { lat: 1, lon: 2, altitude_m: 500 },
street_level: { provider: 'mapillary' },
});
await applyView(view, { viewer: fakeViewer(), dataManager, run });
assert.equal(closed.length, 0, 'the viewer keeps the new photo’s error');
});
test('a view whose photos fail closes the photo an earlier view opened', async () => {
const closed = [];
const dataManager = {
...fakeLayers(new Set()),
layers: new Map([
['street-level', { module: { closeViewer: () => closed.push(true) } }],
]),
};
const { run } = recorder({
show_street_level: () => ({
ok: false,
stage: 'key',
error: 'needs its key',
}),
});
const view = createView({
camera: { lat: 1, lon: 2, altitude_m: 500 },
street_level: { provider: 'google' },
});
const viewer = fakeViewer();
await applyView(view, { viewer, dataManager, run });
assert.equal(closed.length, 1, 'closed before the view flies away');
assert.equal(viewer.flights.length, 1);
const opened = recorder({ show_street_level: () => ({ ok: true }) });
await applyView(view, { viewer, dataManager, run: opened.run });
assert.equal(closed.length, 1, 'an opened photo stays');
});
+118
View File
@@ -0,0 +1,118 @@
import '../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import * as Cesium from 'cesium';
import { installViews } from './embed.js';
import { createGevActionRunner } from '../voice/gevActions.js';
import { createAnnotationEngine } from '../annotations/annotationEngine.js';
import { fakeCesiumViewer } from '../testSupport/streetLevelFakes.mjs';
// A camera taken while a posted view waits on terrain stays with whoever took
// it: the stale view stands down whole, so it neither stops a newer follow nor
// frames its own marks. Offline: real installViews, action runner and
// annotation engine; the viewer, renderer, flights data and terrain are fixtures.
globalThis.fetch = async () => { throw new Error('Unexpected network request in offline reproduction'); };
globalThis.requestAnimationFrame = () => 1;
globalThis.cancelAnimationFrame = () => {};
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
function deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
}
async function scenario(t, effect) {
document.body.className = '';
const entered = deferred();
const terrain = deferred();
const viewer = fakeCesiumViewer();
viewer.clock = { onTick: new Cesium.Event() };
viewer.canvas = viewer.scene.canvas;
// A real perspective frustum looking down at 0,0; the old view's mark
// at 48N,2E is outside it. No override of the engine's visibility logic.
Object.assign(viewer.camera, {
position: Cesium.Cartesian3.fromDegrees(0, 0, 1000),
direction: new Cesium.Cartesian3(-1, 0, 0),
up: new Cesium.Cartesian3(0, 0, 1),
heading: 0,
frustum: new Cesium.PerspectiveFrustum({ fov: Math.PI / 3, aspectRatio: 1, near: 1, far: 20000000 }),
});
const entity = { id: 'fixture-aircraft' };
const aircraft = { icao24: 'fixture', callsign: 'Fixture', latitude: 0, longitude: 0 };
let stopCalls = 0;
const flights = {
findByQuery: () => aircraft,
trackById: () => { viewer.trackedEntity = entity; return true; },
getTrackedInfo: () => viewer.trackedEntity ? aircraft : null,
stopTracking: () => { stopCalls++; viewer.trackedEntity = undefined; return true; },
};
const dataManager = {
layers: new Map([['flights', { module: flights }]]),
getAll: () => [{ id: 'flights', enabled: true }],
isEnabled: id => id === 'flights',
setLayerParams: () => true,
};
const shell = {
_navigationGeneration: 1,
initialRestorePromise: Promise.resolve(),
setCleanView() {},
runImmediateNavigation(_noun, navigate) { this._navigationGeneration++; return navigate(); },
};
const annotations = createAnnotationEngine({ viewer,
renderer: { add() {}, update() {}, remove() {}, sync() {}, destroy() {} },
});
const runner = createGevActionRunner({ viewer, dataManager, styleManager: shell, annotations });
const calls = [];
const run = async (name, args, options) => {
calls.push(name);
return runner(name, args, options);
};
const replies = [];
const peer = { postMessage: message => replies.push(message) };
const windowRef = new EventTarget();
windowRef.parent = peer;
windowRef.document = document;
const remove = installViews({ shell, viewer, dataManager, run, windowRef,
location: { search: '?embed=1', hash: '' },
terrain: { getHeights: () => { entered.resolve(); return terrain.promise; } },
});
t.after(() => { remove(); runner.dispose(); annotations.destroy(); document.body.className = ''; });
await tick();
const event = new Event('message');
Object.assign(event, { source: peer, origin: 'https://host.example', data: {
type: 'gev:view', id: 1, view: {
camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 },
layers: ['flights'],
street_level: { provider: 'mapillary' },
annotations: effect === 'annotations'
? [{ type: 'pin', latitude: 48, longitude: 2, label: 'Old view' }] : [],
},
} });
windowRef.dispatchEvent(event);
await entered.promise;
if (effect === 'follow') {
const follow = await runner('track_entity', { query: 'Fixture', layerId: 'flights' });
assert.equal(follow.ok, true, 'new follow starts through the real runner');
assert.equal(viewer.trackedEntity, entity);
} else {
viewer.canvas.dispatchEvent(new Event('pointerdown'));
}
terrain.resolve([{ ellipsoid: 200 }]);
for (let n = 0; n < 50 && !replies.some(reply => reply.id === 1); n++) await tick();
const reply = replies.find(reply => reply.id === 1);
t.diagnostic(JSON.stringify({ effect, calls, stopCalls, followed: viewer.trackedEntity === entity,
annotationCount: annotations.count(), annotationFlights: viewer.flights.started, reply }));
assert.ok(reply, 'host receives completion');
assert.equal(reply.ok, false, 'superseded view is acknowledged as not applied');
assert.ok(!calls.includes('show_street_level'), 'superseded request never reaches photo handler');
if (effect === 'follow') {
assert.equal(stopCalls, 0, 'stale view must not stop the newer follow');
assert.equal(viewer.trackedEntity, entity, 'the new tracked entity stays selected');
} else {
assert.equal(viewer.flights.started, 0, 'stale annotations must not fly the camera');
}
}
for (const effect of ['follow', 'annotations']) {
test(`a camera taken during the terrain wait keeps the user's ${effect}`, { timeout: 5000 }, t => scenario(t, effect));
}
+23 -7
View File
@@ -462,8 +462,11 @@ export function panelRuntime(config) {
* Load God's Eye View into this page from the app's panel build, in
* inline embed mode, starting at the first view as its link would.
*/
async function startApp(url, view) {
async function startApp(url, view, fresh = false) {
window.GEV_EMBED_INLINE = true;
// Whether this result was just issued, not handed back by a host that
// mounted the panel again: only then may its link open a billed photo.
window.GEV_EMBED_FRESH = fresh;
window.GEV_APP_BASE_URL = config.appBaseUrl;
try {
history.replaceState(
@@ -551,13 +554,21 @@ 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;
let queuedFresh = false;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
// Every view carries whether its result was issued just now: only those may
// open a billed photo, whichever result on this mount they came in.
const postView = (view, fresh) =>
window.postMessage(
{ type: 'gev:view', id: nextId++, view, fresh: fresh === true },
'*',
);
function show(result) {
const data = result?.structuredContent;
const view = data?.view;
@@ -565,13 +576,18 @@ export function panelRuntime(config) {
if (!view || !url) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
const issuedAt = Number(data.issued_at);
const fresh =
Number.isFinite(issuedAt) &&
Math.abs(Date.now() - issuedAt) <= FRESH_RESULT_MS;
if (ready) return postView(view, fresh);
queued = view;
queuedFresh = fresh;
if (started) return;
started = true;
// i18n-ignore: host-panel script, no app catalogs
say("Loading God's Eye View…");
startApp(url, view).catch((error) =>
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}.`),
);
@@ -592,7 +608,7 @@ export function panelRuntime(config) {
if (message?.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
if (queued) postView(queued, queuedFresh);
queued = null;
}
return;
+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { panelRuntime } from './globePanelRuntime.js';
const element = () => ({
isConnected: true,
hidden: true,
textContent: '',
addEventListener() {},
remove() {
this.isConnected = false;
},
});
test('every view the panel posts says whether its result was issued just now', async () => {
const savedWindow = globalThis.window;
const savedDocument = globalThis.document;
const views = [];
const parent = { postMessage() {} };
const windowRef = new EventTarget();
Object.assign(windowRef, {
parent,
postMessage: (message) => {
if (message?.type === 'gev:view') views.push(message);
},
});
const elements = { status: element(), open: element(), expand: element() };
globalThis.window = windowRef;
globalThis.document = {
getElementById: (id) => elements[id] ?? null,
addEventListener() {},
baseURI: 'https://host.example/panel',
body: { clientWidth: 600 },
};
const deliver = (source, data) => {
const event = new Event('message');
Object.assign(event, { source, data });
windowRef.dispatchEvent(event);
};
const result = (issuedAt) => ({
jsonrpc: '2.0',
method: 'ui/notifications/tool-result',
params: {
structuredContent: {
url: 'http://localhost:4173/#sl=google,48.85840,2.29450',
issued_at: issuedAt,
view: { camera: { lat: 48.8584, lon: 2.2945 } },
},
},
});
try {
panelRuntime({
appBaseUrl: 'http://localhost:4173/',
panelBase: '/panel/',
protocolVersion: '2025-06-18',
panelHeight: 480,
});
deliver(windowRef, { type: 'gev:ready' });
deliver(parent, result(Date.now()));
// A host replaying an old result on the same mount.
deliver(parent, result(Date.now() - 60 * 60 * 1000));
deliver(parent, result(undefined));
} finally {
globalThis.window = savedWindow;
globalThis.document = savedDocument;
}
assert.deepEqual(
views.map((message) => message.fresh),
[true, false, false],
);
});
+3 -1
View File
@@ -246,8 +246,10 @@ export function createApplicationTools({
shell: styleManager,
viewer,
dataManager,
run: (name, args) => voiceCommands.runner(name, args, { signal }),
run: (name, args, options = {}) =>
voiceCommands.runner(name, args, { ...options, signal }),
signal,
terrain: operations.requests.terrain,
}),
);
// An application's own tool takes the pointer as Draw does, and its own
+2
View File
@@ -982,6 +982,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
// Canonical serialization pins every tool name, description, property and
// ordering while allowing source formatting. Derived from the unchanged
// release schema before formatting (the previous source-byte pin passed).
// show_street_level (street-level photos) is a later, separate tool.
const legacyTools = structuredClone(GEV_REALTIME_TOOLS).filter(
(tool) =>
![
@@ -989,6 +990,7 @@ test('the voice TOOL SCHEMA matches the pinned contract — the mission mapping
'resolve_area',
'find_imagery',
'osm_query',
'show_street_level',
].includes(tool.name),
);
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
+28 -1
View File
@@ -1889,6 +1889,7 @@
"KartaView did not filter this area in time — move the map to ask again": "لم يُصفِّ KartaView هذه المنطقة في الوقت المحدد — حرّك الخريطة لإعادة الطلب",
"None of KartaView’s photos here pass the imagery filter": "لا تجتاز أي من صور KartaView هنا مرشح الصور",
"KartaView did not answer in time — try again": "لم يستجب KartaView في الوقت المحدد — أعد المحاولة",
"Mapillary did not answer in time — try again": "لم يستجب Mapillary في الوقت المحدد — أعد المحاولة",
"KartaView could not be reached — check the network": "تعذّر الوصول إلى KartaView — تحقّق من الشبكة",
"KartaView sent an answer that could not be read": "أرسل KartaView استجابة تعذّرت قراءتها",
"KartaView is rate-limiting requests": "يقيّد KartaView معدل الطلبات",
@@ -3734,6 +3735,17 @@
"Refresh map still": "تحديث صورة الخريطة الثابتة",
"The map still uses a separate request and may show a different moment.": "تستخدم صورة الخريطة طلبًا منفصلًا وقد تعرض لحظة مختلفة.",
"Video plays from Québec 511, which sees your IP address.": "يُشغَّل الفيديو من Québec 511، الذي يرى عنوان IP الخاص بك.",
"Press OPEN to load the photo": "اضغط «فتح» لتحميل الصورة",
"Captured {month}": "التُقطت في {month}",
"this spot": "هذا الموضع",
"the view centre": "مركز العرض",
"{source} at {place}": "{source} في {place}",
"Street level at {place}": "مستوى الشارع في {place}",
"Switching map": "جارٍ تبديل الخريطة",
"Opening imagery": "جارٍ فتح الصور",
"Street View is ready: press {open} to load it": "Street View جاهز: اضغط «{open}» لتحميله",
"{source} cannot open this link's street-level photo without a working key": "لا يمكن لـ {source} فتح صورة مستوى الشارع في هذا الرابط بدون مفتاح صالح",
"Street-level photos are hidden in the current view. Leave it first.": "صور مستوى الشارع مخفية في العرض الحالي. اخرج منه أولًا.",
"STATIONS": "المحطات",
"Crewed stations and the craft that visit them": "المحطات المأهولة والمركبات التي تزورها",
"IMAGING": "التصوير",
@@ -3803,5 +3815,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}"
"Vessel UUID: {id}": "معرّف UUID للسفينة: {id}",
"Latitude must be within ±90° and longitude within ±180°": "يجب أن يكون خط العرض ضمن ±90° وخط الطول ضمن ±180°",
"Street Level is unavailable": "مستوى الشارع غير متاح",
"No street-level imagery source is configured": "لم يُعَدّ أي مصدر لصور مستوى الشارع",
"Street Level could not be enabled": "تعذّر تفعيل مستوى الشارع",
"Street Level changed while it looked for imagery": "تغيّر مستوى الشارع أثناء البحث عن الصور",
"Aim the view at a street": "وجّه العرض نحو شارع",
"{source} is not a street-level imagery source here": "{source} ليس مصدرًا لصور مستوى الشارع هنا",
"{source} needs its key": "يحتاج {source} إلى مفتاحه",
"{source} needs the {map} map, which is not available": "يحتاج {source} إلى خريطة {map}، وهي غير متاحة",
"{source} could not be switched on": "تعذّر تفعيل {source}",
"{source} is not available on this map": "{source} غير متاح على هذه الخريطة",
"{source} imagery could not be opened": "تعذّر فتح صور {source}",
"{place} has no position to look for imagery at": "لا يوجد لـ{place} موضع للبحث عن الصور فيه",
"No {source} imagery near {place}": "لا توجد صور {source} قرب {place}",
"{reason} (filters on: {filters})": "{reason} (المرشحات المفعّلة: {filters})"
}
+28 -1
View File
@@ -1791,6 +1791,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView hat dieses Gebiet nicht rechtzeitig gefiltert – Karte verschieben, um erneut anzufragen",
"None of KartaView’s photos here pass the imagery filter": "Keines der KartaView-Fotos hier passt zum Bildfilter",
"KartaView did not answer in time — try again": "KartaView hat nicht rechtzeitig geantwortet – erneut versuchen",
"Mapillary did not answer in time — try again": "Mapillary hat nicht rechtzeitig geantwortet – erneut versuchen",
"KartaView could not be reached — check the network": "KartaView ist nicht erreichbar – Netzwerk prüfen",
"KartaView sent an answer that could not be read": "KartaView hat eine unlesbare Antwort gesendet",
"KartaView is rate-limiting requests": "KartaView begrenzt die Anfragen",
@@ -3410,6 +3411,17 @@
"Refresh map still": "Kartenstandbild aktualisieren",
"The map still uses a separate request and may show a different moment.": "Das Kartenstandbild wird separat abgerufen und kann einen anderen Moment zeigen.",
"Video plays from Québec 511, which sees your IP address.": "Das Video wird von Québec 511 abgespielt, das Ihre IP-Adresse sieht.",
"Press OPEN to load the photo": "Auf ÖFFNEN klicken, um das Foto zu laden",
"Captured {month}": "Aufgenommen im {month}",
"this spot": "diese Stelle",
"the view centre": "die Bildmitte",
"{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",
"Street View is ready: press {open} to load it": "Street View ist bereit: Auf {open} klicken, um es zu laden",
"{source} cannot open this link's street-level photo without a working key": "{source} kann das Straßenfoto dieses Links ohne gültigen Schlüssel nicht öffnen",
"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.",
"STATIONS": "STATIONEN",
"Crewed stations and the craft that visit them": "Bemannte Stationen und die Raumfahrzeuge, die sie anfliegen",
"IMAGING": "BILDGEBUNG",
@@ -3479,5 +3491,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "Schiffs-UUID: {id}"
"Vessel UUID: {id}": "Schiffs-UUID: {id}",
"Latitude must be within ±90° and longitude within ±180°": "Der Breitengrad muss innerhalb von ±90° und der Längengrad innerhalb von ±180° liegen",
"Street Level is unavailable": "Straßenebene ist nicht verfügbar",
"No street-level imagery source is configured": "Keine Quelle für Bilder auf Straßenebene konfiguriert",
"Street Level could not be enabled": "Straßenebene konnte nicht eingeschaltet werden",
"Street Level changed while it looked for imagery": "Straßenebene hat sich während der Bildersuche geändert",
"Aim the view at a street": "Die Ansicht auf eine Straße richten",
"{source} is not a street-level imagery source here": "{source} ist hier keine Quelle für Bilder auf Straßenebene",
"{source} needs its key": "{source} braucht seinen Schlüssel",
"{source} needs the {map} map, which is not available": "{source} braucht die {map}-Karte, die nicht verfügbar ist",
"{source} could not be switched on": "{source} konnte nicht eingeschaltet werden",
"{source} is not available on this map": "{source} ist auf dieser Karte nicht verfügbar",
"{source} imagery could not be opened": "Bilder von {source} konnten nicht geöffnet werden",
"{place} has no position to look for imagery at": "{place} hat keine Position, an der nach Bildern gesucht werden kann",
"No {source} imagery near {place}": "Keine Bilder von {source} in der Nähe von {place}",
"{reason} (filters on: {filters})": "{reason} (aktive Filter: {filters})"
}
+28 -1
View File
@@ -2411,6 +2411,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView no filtró esta zona a tiempo — mueve el mapa para pedirlo de nuevo",
"None of KartaView’s photos here pass the imagery filter": "Ninguna foto de KartaView de aquí pasa el filtro de imágenes",
"KartaView did not answer in time — try again": "KartaView no respondió a tiempo — inténtalo de nuevo",
"Mapillary did not answer in time — try again": "Mapillary no respondió a tiempo — inténtalo de nuevo",
"KartaView could not be reached — check the network": "No se pudo conectar con KartaView — comprueba la red",
"KartaView sent an answer that could not be read": "KartaView envió una respuesta que no se pudo leer",
"KartaView is rate-limiting requests": "KartaView está limitando las peticiones",
@@ -3491,6 +3492,17 @@
"Refresh map still": "Actualizar imagen fija del mapa",
"The map still uses a separate request and may show a different moment.": "La imagen del mapa usa una solicitud independiente y puede mostrar otro momento.",
"Video plays from Québec 511, which sees your IP address.": "El vídeo se reproduce desde Québec 511, que ve tu dirección IP.",
"Press OPEN to load the photo": "Pulse ABRIR para cargar la foto",
"Captured {month}": "Capturada en {month}",
"this spot": "este punto",
"the view centre": "el centro de la vista",
"{source} at {place}": "{source} en {place}",
"Street level at {place}": "A pie de calle en {place}",
"Switching map": "Cambiando de mapa",
"Opening imagery": "Abriendo imágenes",
"Street View is ready: press {open} to load it": "Street View está listo: pulsa {open} para cargarlo",
"{source} cannot open this link's street-level photo without a working key": "{source} no puede abrir la foto a pie de calle de este enlace sin una clave válida",
"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.",
"STATIONS": "ESTACIONES",
"Crewed stations and the craft that visit them": "Estaciones tripuladas y las naves que las visitan",
"IMAGING": "IMÁGENES",
@@ -3560,5 +3572,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID del buque: {id}"
"Vessel UUID: {id}": "UUID del buque: {id}",
"Latitude must be within ±90° and longitude within ±180°": "La latitud debe estar dentro de ±90° y la longitud dentro de ±180°",
"Street Level is unavailable": "A pie de calle no está disponible",
"No street-level imagery source is configured": "No hay ninguna fuente de imágenes a pie de calle configurada",
"Street Level could not be enabled": "No se pudo activar A pie de calle",
"Street Level changed while it looked for imagery": "A pie de calle cambió mientras buscaba imágenes",
"Aim the view at a street": "Apunta la vista a una calle",
"{source} is not a street-level imagery source here": "{source} no es una fuente de imágenes a pie de calle aquí",
"{source} needs its key": "{source} necesita su clave",
"{source} needs the {map} map, which is not available": "{source} requiere el mapa {map}, que no está disponible",
"{source} could not be switched on": "No se pudo activar {source}",
"{source} is not available on this map": "{source} no está disponible en este mapa",
"{source} imagery could not be opened": "No se pudieron abrir las imágenes de {source}",
"{place} has no position to look for imagery at": "{place} no tiene una posición donde buscar imágenes",
"No {source} imagery near {place}": "No hay imágenes de {source} cerca de {place}",
"{reason} (filters on: {filters})": "{reason} (filtros activos: {filters})"
}
+28 -1
View File
@@ -1185,6 +1185,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView n'a pas filtré cette zone à temps — déplacez la carte pour relancer la demande",
"None of KartaView’s photos here pass the imagery filter": "Aucune photo KartaView de cet endroit ne passe le filtre d'images",
"KartaView did not answer in time — try again": "KartaView n'a pas répondu à temps — réessayez",
"Mapillary did not answer in time — try again": "Mapillary n'a pas répondu à temps — réessayez",
"KartaView could not be reached — check the network": "KartaView est injoignable — vérifiez le réseau",
"KartaView sent an answer that could not be read": "KartaView a envoyé une réponse illisible",
"KartaView is rate-limiting requests": "KartaView limite les requêtes",
@@ -3491,6 +3492,17 @@
"Refresh map still": "Actualiser l'image fixe sur la carte",
"The map still uses a separate request and may show a different moment.": "L'image fixe de la carte utilise une requête distincte et peut montrer un autre moment.",
"Video plays from Québec 511, which sees your IP address.": "La vidéo est lue depuis Québec 511, qui voit votre adresse IP.",
"Press OPEN to load the photo": "Appuyez sur OUVRIR pour charger la photo",
"Captured {month}": "Prise en {month}",
"this spot": "cet endroit",
"the view centre": "le centre de la vue",
"{source} at {place}": "{source} à {place}",
"Street level at {place}": "Niveau rue à {place}",
"Switching map": "Changement de carte",
"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",
"{source} cannot open this link's street-level photo without a working key": "{source} ne peut pas ouvrir la photo au niveau de la rue de ce lien sans clé valide",
"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.",
"STATIONS": "STATIONS",
"Crewed stations and the craft that visit them": "Stations habitées et les vaisseaux qui s'y rendent",
"IMAGING": "IMAGERIE",
@@ -3560,5 +3572,20 @@
"ICAO: {id}": "ICAO : {id}",
"NORAD: {id}": "NORAD : {id}",
"MMSI: {id}": "MMSI : {id}",
"Vessel UUID: {id}": "UUID du navire : {id}"
"Vessel UUID: {id}": "UUID du navire : {id}",
"Latitude must be within ±90° and longitude within ±180°": "La latitude doit être comprise dans ±90° et la longitude dans ±180°",
"Street Level is unavailable": "Niveau rue n'est pas disponible",
"No street-level imagery source is configured": "Aucune source d'images au niveau de la rue n'est configurée",
"Street Level could not be enabled": "Impossible d'activer Niveau rue",
"Street Level changed while it looked for imagery": "Niveau rue a changé pendant la recherche d'images",
"Aim the view at a street": "Orientez la vue vers une rue",
"{source} is not a street-level imagery source here": "{source} n'est pas une source d'images au niveau de la rue ici",
"{source} needs its key": "{source} a besoin de sa clé",
"{source} needs the {map} map, which is not available": "{source} nécessite la carte {map}, qui n'est pas disponible",
"{source} could not be switched on": "Impossible d'activer {source}",
"{source} is not available on this map": "{source} n'est pas disponible sur cette carte",
"{source} imagery could not be opened": "Impossible d'ouvrir les images de {source}",
"{place} has no position to look for imagery at": "{place} n'a pas de position où chercher des images",
"No {source} imagery near {place}": "Aucune image {source} près de {place}",
"{reason} (filters on: {filters})": "{reason} (filtres actifs : {filters})"
}
+28 -1
View File
@@ -1051,6 +1051,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView לא סינן את האזור בזמן — הזז את המפה כדי לשאול שוב",
"None of KartaView’s photos here pass the imagery filter": "אף תמונת KartaView כאן לא עוברת את מסנן הצילומים",
"KartaView did not answer in time — try again": "KartaView לא ענה בזמן — נסה שוב",
"Mapillary did not answer in time — try again": "Mapillary לא ענה בזמן — נסה שוב",
"KartaView could not be reached — check the network": "KartaView לא נגיש — בדוק את הרשת",
"KartaView sent an answer that could not be read": "KartaView שלח תשובה שלא ניתן לקרוא",
"KartaView is rate-limiting requests": "KartaView מגביל את קצב הבקשות",
@@ -3292,6 +3293,17 @@
"Refresh map still": "רענון תמונת המפה",
"The map still uses a separate request and may show a different moment.": "תמונת המפה נשלפת בבקשה נפרדת ועשויה להראות רגע שונה.",
"Video plays from Québec 511, which sees your IP address.": "הווידאו זורם מ-Québec 511, שרואה את כתובת ה-IP שלך.",
"Press OPEN to load the photo": "לחץ על פתח כדי לטעון את התמונה",
"Captured {month}": "צולם ב-{month}",
"this spot": "המקום הזה",
"the view centre": "מרכז התצוגה",
"{source} at {place}": "{source} ב-{place}",
"Street level at {place}": "רמת רחוב ב-{place}",
"Switching map": "מחליף מפה",
"Opening imagery": "פותח צילומים",
"Street View is ready: press {open} to load it": "Street View מוכן: לחץ על {open} כדי לטעון אותו",
"{source} cannot open this link's street-level photo without a working key": "{source} לא יכול לפתוח את תמונת רמת הרחוב של הקישור הזה בלי מפתח תקף",
"Street-level photos are hidden in the current view. Leave it first.": "תמונות ברמת הרחוב מוסתרות בתצוגה הנוכחית. צא ממנה קודם.",
"Ship bridge view": "תצוגת גשר האונייה",
"Satellite orbital view": "התצוגה המסלולית של הלוויין",
"Exit bridge view": "יציאה מתצוגת הגשר",
@@ -3560,5 +3572,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID של כלי השיט: {id}"
"Vessel UUID: {id}": "UUID של כלי השיט: {id}",
"Latitude must be within ±90° and longitude within ±180°": "קו הרוחב חייב להיות בטווח ±90° וקו האורך בטווח ±180°",
"Street Level is unavailable": "Street Level אינו זמין",
"No street-level imagery source is configured": "לא הוגדר מקור לצילומי רחוב",
"Street Level could not be enabled": "לא ניתן היה להפעיל את Street Level",
"Street Level changed while it looked for imagery": "Street Level השתנה בזמן החיפוש אחר צילומים",
"Aim the view at a street": "כוון את התצוגה אל רחוב",
"{source} is not a street-level imagery source here": "{source} אינו מקור לצילומי רחוב כאן",
"{source} needs its key": "{source} דורש את המפתח שלו",
"{source} needs the {map} map, which is not available": "{source} דורש את מפת {map}, שאינה זמינה",
"{source} could not be switched on": "לא ניתן היה להפעיל את {source}",
"{source} is not available on this map": "{source} אינו זמין במפה הזו",
"{source} imagery could not be opened": "לא ניתן היה לפתוח את הצילומים של {source}",
"{place} has no position to look for imagery at": "ל-{place} אין מיקום לחיפוש צילומים",
"No {source} imagery near {place}": "אין צילומים של {source} ליד {place}",
"{reason} (filters on: {filters})": "{reason} (מסננים פעילים: {filters})"
}
+28 -1
View File
@@ -1185,6 +1185,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView non ha filtrato quest'area in tempo: sposta la mappa per riprovare",
"None of KartaView’s photos here pass the imagery filter": "Nessuna delle foto KartaView qui supera il filtro delle immagini",
"KartaView did not answer in time — try again": "KartaView non ha risposto in tempo: riprova",
"Mapillary did not answer in time — try again": "Mapillary non ha risposto in tempo: riprova",
"KartaView could not be reached — check the network": "KartaView non è raggiungibile: controlla la rete",
"KartaView sent an answer that could not be read": "KartaView ha inviato una risposta illeggibile",
"KartaView is rate-limiting requests": "KartaView sta limitando le richieste",
@@ -3491,6 +3492,17 @@
"Refresh map still": "Aggiorna immagine fissa sulla mappa",
"The map still uses a separate request and may show a different moment.": "L’immagine sulla mappa usa una richiesta separata e può mostrare un momento diverso.",
"Video plays from Québec 511, which sees your IP address.": "Il video viene riprodotto da Québec 511, che vede il tuo indirizzo IP.",
"Press OPEN to load the photo": "Premi APRI per caricare la foto",
"Captured {month}": "Scattata a {month}",
"this spot": "questo punto",
"the view centre": "il centro della vista",
"{source} at {place}": "{source} a {place}",
"Street level at {place}": "Vista stradale a {place}",
"Switching map": "Cambio mappa",
"Opening imagery": "Apertura delle immagini",
"Street View is ready: press {open} to load it": "Street View è pronto: premi {open} per caricarlo",
"{source} cannot open this link's street-level photo without a working key": "{source} non può aprire la foto a livello stradale di questo link senza una chiave valida",
"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.",
"STATIONS": "STAZIONI",
"Crewed stations and the craft that visit them": "Stazioni con equipaggio e i veicoli che le visitano",
"IMAGING": "IMMAGINI",
@@ -3560,5 +3572,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID della nave: {id}"
"Vessel UUID: {id}": "UUID della nave: {id}",
"Latitude must be within ±90° and longitude within ±180°": "La latitudine deve essere entro ±90° e la longitudine entro ±180°",
"Street Level is unavailable": "Vista stradale non è disponibile",
"No street-level imagery source is configured": "Nessuna fonte di immagini a livello stradale configurata",
"Street Level could not be enabled": "Impossibile attivare Vista stradale",
"Street Level changed while it looked for imagery": "Vista stradale è cambiata durante la ricerca delle immagini",
"Aim the view at a street": "Punta la vista su una strada",
"{source} is not a street-level imagery source here": "{source} non è una fonte di immagini a livello stradale qui",
"{source} needs its key": "{source} richiede la sua chiave",
"{source} needs the {map} map, which is not available": "{source} richiede la mappa {map}, che non è disponibile",
"{source} could not be switched on": "Impossibile attivare {source}",
"{source} is not available on this map": "{source} non è disponibile su questa mappa",
"{source} imagery could not be opened": "Impossibile aprire le immagini di {source}",
"{place} has no position to look for imagery at": "{place} non ha una posizione in cui cercare immagini",
"No {source} imagery near {place}": "Nessuna immagine di {source} vicino a {place}",
"{reason} (filters on: {filters})": "{reason} (filtri attivi: {filters})"
}
+28 -1
View File
@@ -1147,6 +1147,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaViewがこの範囲を時間内に絞り込めませんでした。地図を動かして再度リクエストしてください",
"None of KartaView’s photos here pass the imagery filter": "この場所のKartaViewの写真はいずれも画像フィルターを通過しません",
"KartaView did not answer in time — try again": "KartaViewから時間内に応答がありませんでした。もう一度お試しください",
"Mapillary did not answer in time — try again": "Mapillaryから時間内に応答がありませんでした。もう一度お試しください",
"KartaView could not be reached — check the network": "KartaViewに接続できません。ネットワークを確認してください",
"KartaView sent an answer that could not be read": "KartaViewから読み取れない応答が返されました",
"KartaView is rate-limiting requests": "KartaViewがリクエストをレート制限中です",
@@ -3329,6 +3330,17 @@
"Refresh map still": "地図上の静止画を更新",
"The map still uses a separate request and may show a different moment.": "地図上の静止画は別のリクエストで取得されるため、異なる時点を表示する場合があります。",
"Video plays from Québec 511, which sees your IP address.": "動画は Québec 511 から再生され、Québec 511 にはあなたの IP アドレスが見えます。",
"Press OPEN to load the photo": "「開く」を押して写真を読み込む",
"Captured {month}": "{month}撮影",
"this spot": "この地点",
"the view centre": "表示中心",
"{source} at {place}": "{place}の{source}",
"Street level at {place}": "{place}のストリートレベル",
"Switching map": "地図を切り替え中",
"Opening imagery": "画像を開いています",
"Street View is ready: press {open} to load it": "ストリートビューの準備ができました:{open} を押して読み込みます",
"{source} cannot open this link's street-level photo without a working key": "{source} は有効なキーがないため、このリンクのストリートレベル写真を開けません",
"Street-level photos are hidden in the current view. Leave it first.": "現在の表示ではストリートレベルの写真は非表示です。先にこの表示を終了してください。",
"STATIONS": "ステーション",
"Crewed stations and the craft that visit them": "有人宇宙ステーションと、そこを訪れる宇宙機",
"IMAGING": "地球観測",
@@ -3398,5 +3410,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "船舶UUID: {id}"
"Vessel UUID: {id}": "船舶UUID: {id}",
"Latitude must be within ±90° and longitude within ±180°": "緯度は±90°、経度は±180°の範囲内で指定してください",
"Street Level is unavailable": "ストリートレベルは利用できません",
"No street-level imagery source is configured": "ストリートレベル画像のソースが設定されていません",
"Street Level could not be enabled": "ストリートレベルをオンにできませんでした",
"Street Level changed while it looked for imagery": "画像の検索中にストリートレベルが変更されました",
"Aim the view at a street": "表示を通りに向けてください",
"{source} is not a street-level imagery source here": "{source}はここではストリートレベル画像のソースではありません",
"{source} needs its key": "{source}にはキーが必要です",
"{source} needs the {map} map, which is not available": "{source}には{map}マップが必要ですが、利用できません",
"{source} could not be switched on": "{source}をオンにできませんでした",
"{source} is not available on this map": "{source}はこのマップでは利用できません",
"{source} imagery could not be opened": "{source}の画像を開けませんでした",
"{place} has no position to look for imagery at": "{place}には画像を探す位置がありません",
"No {source} imagery near {place}": "{place}の近くに{source}の画像はありません",
"{reason} (filters on: {filters})": "{reason}(有効なフィルター: {filters})"
}
+28 -1
View File
@@ -1183,6 +1183,7 @@
"KartaView did not filter this area in time — move the map to ask again": "O KartaView não filtrou esta área a tempo — mova o mapa para pedir de novo",
"None of KartaView’s photos here pass the imagery filter": "Nenhuma foto do KartaView aqui passa no filtro de imagens",
"KartaView did not answer in time — try again": "O KartaView não respondeu a tempo — tente novamente",
"Mapillary did not answer in time — try again": "O Mapillary não respondeu a tempo — tente novamente",
"KartaView could not be reached — check the network": "Não foi possível acessar o KartaView — verifique a rede",
"KartaView sent an answer that could not be read": "O KartaView enviou uma resposta que não pôde ser lida",
"KartaView is rate-limiting requests": "O KartaView está limitando as requisições",
@@ -3491,6 +3492,17 @@
"Refresh map still": "Atualizar imagem fixa no mapa",
"The map still uses a separate request and may show a different moment.": "A imagem no mapa usa uma solicitação separada e pode mostrar outro momento.",
"Video plays from Québec 511, which sees your IP address.": "O vídeo é reproduzido a partir do Québec 511, que vê o seu endereço IP.",
"Press OPEN to load the photo": "Clique em ABRIR para carregar a foto",
"Captured {month}": "Capturada em {month}",
"this spot": "este ponto",
"the view centre": "o centro da vista",
"{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",
"Street View is ready: press {open} to load it": "O Street View está pronto: clique em {open} para carregar",
"{source} cannot open this link's street-level photo without a working key": "{source} não pode abrir a foto ao nível da rua deste link sem uma chave válida",
"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.",
"STATIONS": "ESTAÇÕES",
"Crewed stations and the craft that visit them": "Estações tripuladas e as naves que as visitam",
"IMAGING": "IMAGENS",
@@ -3560,5 +3572,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID da embarcação: {id}"
"Vessel UUID: {id}": "UUID da embarcação: {id}",
"Latitude must be within ±90° and longitude within ±180°": "A latitude deve estar dentro de ±90° e a longitude dentro de ±180°",
"Street Level is unavailable": "Nível da rua não está disponível",
"No street-level imagery source is configured": "Nenhuma fonte de imagens no nível da rua configurada",
"Street Level could not be enabled": "Não foi possível ativar Nível da rua",
"Street Level changed while it looked for imagery": "Nível da rua mudou enquanto procurava imagens",
"Aim the view at a street": "Aponte a vista para uma rua",
"{source} is not a street-level imagery source here": "{source} não é uma fonte de imagens no nível da rua aqui",
"{source} needs its key": "{source} precisa da sua chave",
"{source} needs the {map} map, which is not available": "{source} requer o mapa {map}, que não está disponível",
"{source} could not be switched on": "Não foi possível ativar {source}",
"{source} is not available on this map": "{source} não está disponível neste mapa",
"{source} imagery could not be opened": "Não foi possível abrir as imagens de {source}",
"{place} has no position to look for imagery at": "{place} não tem uma posição para buscar imagens",
"No {source} imagery near {place}": "Nenhuma imagem de {source} perto de {place}",
"{reason} (filters on: {filters})": "{reason} (filtros ativos: {filters})"
}
+28 -1
View File
@@ -2452,6 +2452,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView не успел отфильтровать эту область — сдвиньте карту, чтобы запросить снова",
"None of KartaView’s photos here pass the imagery filter": "Ни одна фотография KartaView здесь не проходит фильтр снимков",
"KartaView did not answer in time — try again": "KartaView не ответил вовремя — попробуйте снова",
"Mapillary did not answer in time — try again": "Mapillary не ответил вовремя — попробуйте снова",
"KartaView could not be reached — check the network": "Не удалось связаться с KartaView — проверьте сеть",
"KartaView sent an answer that could not be read": "KartaView прислал ответ, который не удалось прочитать",
"KartaView is rate-limiting requests": "KartaView ограничивает частоту запросов",
@@ -3572,6 +3573,17 @@
"Refresh map still": "Обновить кадр на карте",
"The map still uses a separate request and may show a different moment.": "Кадр на карте запрашивается отдельно и может показывать другой момент.",
"Video plays from Québec 511, which sees your IP address.": "Видео воспроизводится с Québec 511, который видит ваш IP-адрес.",
"Press OPEN to load the photo": "Нажмите ОТКРЫТЬ, чтобы загрузить снимок",
"Captured {month}": "Снято: {month}",
"this spot": "эта точка",
"the view centre": "центр вида",
"{source} at {place}": "{source}: {place}",
"Street level at {place}": "Уличные снимки: {place}",
"Switching map": "Смена карты",
"Opening imagery": "Открытие снимков",
"Street View is ready: press {open} to load it": "Просмотр улиц готов: нажмите {open}, чтобы загрузить",
"{source} cannot open this link's street-level photo without a working key": "{source} не может открыть уличное фото по этой ссылке без рабочего ключа",
"Street-level photos are hidden in the current view. Leave it first.": "Уличные снимки скрыты в текущем режиме. Сначала выйдите из него.",
"STATIONS": "СТАНЦИИ",
"Crewed stations and the craft that visit them": "Пилотируемые станции и прилетающие к ним корабли",
"IMAGING": "СЪЁМКА",
@@ -3641,5 +3653,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID судна: {id}"
"Vessel UUID: {id}": "UUID судна: {id}",
"Latitude must be within ±90° and longitude within ±180°": "Широта должна быть в пределах ±90°, а долгота — в пределах ±180°",
"Street Level is unavailable": "Уличные снимки недоступны",
"No street-level imagery source is configured": "Источник уличных снимков не настроен",
"Street Level could not be enabled": "Не удалось включить уличные снимки",
"Street Level changed while it looked for imagery": "Уличные снимки изменились во время поиска",
"Aim the view at a street": "Наведите вид на улицу",
"{source} is not a street-level imagery source here": "{source} здесь не является источником уличных снимков",
"{source} needs its key": "{source}: нужен ключ",
"{source} needs the {map} map, which is not available": "{source}: нужна карта {map}, но она недоступна",
"{source} could not be switched on": "Не удалось включить {source}",
"{source} is not available on this map": "{source}: недоступно на этой карте",
"{source} imagery could not be opened": "Не удалось открыть снимки {source}",
"{place} has no position to look for imagery at": "У места «{place}» нет координат для поиска снимков",
"No {source} imagery near {place}": "Нет снимков {source} рядом с {place}",
"{reason} (filters on: {filters})": "{reason} (включены фильтры: {filters})"
}
+27
View File
@@ -1713,6 +1713,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView hann inte filtrera området – flytta kartan för att fråga igen",
"None of KartaView’s photos here pass the imagery filter": "Inga av KartaViews foton här klarar bildfiltret",
"KartaView did not answer in time — try again": "KartaView svarade inte i tid – försök igen",
"Mapillary did not answer in time — try again": "Mapillary svarade inte i tid – försök igen",
"KartaView could not be reached — check the network": "KartaView kunde inte nås – kontrollera nätverket",
"KartaView sent an answer that could not be read": "KartaView skickade ett svar som inte kunde läsas",
"KartaView is rate-limiting requests": "KartaView begränsar antalet förfrågningar",
@@ -3327,6 +3328,17 @@
"Refresh map still": "Uppdatera stillbilden på kartan",
"The map still uses a separate request and may show a different moment.": "Stillbilden på kartan hämtas separat och kan visa ett annat ögonblick.",
"Video plays from Québec 511, which sees your IP address.": "Videon spelas upp från Québec 511, som ser din IP-adress.",
"Press OPEN to load the photo": "Tryck på ÖPPNA för att läsa in bilden",
"Captured {month}": "Tagen {month}",
"this spot": "den här platsen",
"the view centre": "vyns mitt",
"{source} at {place}": "{source} vid {place}",
"Street level at {place}": "Gatunivå vid {place}",
"Switching map": "Byter karta",
"Opening imagery": "Öppnar bilder",
"Street View is ready: press {open} to load it": "Street View är redo: tryck på {open} för att läsa in den",
"{source} cannot open this link's street-level photo without a working key": "{source} kan inte öppna länkens bild på gatunivå utan en fungerande nyckel",
"Street-level photos are hidden in the current view. Leave it first.": "Bilder på gatunivå är dolda i den aktuella vyn. Lämna den först.",
"STATIONS": "STATIONER",
"Crewed stations and the craft that visit them": "Bemannade rymdstationer och farkosterna som besöker dem",
"IMAGING": "AVBILDNING",
@@ -3472,6 +3484,21 @@
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "Fartygs-UUID: {id}",
"Latitude must be within ±90° and longitude within ±180°": "Latituden måste ligga inom ±90° och longituden inom ±180°",
"Street Level is unavailable": "”Gatunivå” är inte tillgängligt",
"No street-level imagery source is configured": "Ingen källa för bilder på gatunivå är konfigurerad",
"Street Level could not be enabled": "”Gatunivå” kunde inte aktiveras",
"Street Level changed while it looked for imagery": "”Gatunivå” ändrades medan bilder söktes",
"Aim the view at a street": "Rikta vyn mot en gata",
"{source} is not a street-level imagery source here": "{source} är inte en källa för bilder på gatunivå här",
"{source} needs its key": "{source} behöver sin nyckel",
"{source} needs the {map} map, which is not available": "{source} behöver kartan {map}, som inte är tillgänglig",
"{source} could not be switched on": "{source} kunde inte slås på",
"{source} is not available on this map": "{source} är inte tillgänglig på den här kartan",
"{source} imagery could not be opened": "Bilder från {source} kunde inte öppnas",
"{place} has no position to look for imagery at": "{place} har ingen position att söka efter bilder vid",
"No {source} imagery near {place}": "Inga bilder från {source} nära {place}",
"{reason} (filters on: {filters})": "{reason} (aktiva filter: {filters})",
"1 h/s": "1 h/s",
"1 d/s": "1 d/s",
"1 wk/s": "1 v/s",
+28 -1
View File
@@ -1203,6 +1203,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView не встиг відфільтрувати цю область — зсуньте карту, щоб запитати знову",
"None of KartaView’s photos here pass the imagery filter": "Жодне фото KartaView тут не проходить фільтр знімків",
"KartaView did not answer in time — try again": "KartaView не відповів вчасно — спробуйте знову",
"Mapillary did not answer in time — try again": "Mapillary не відповів вчасно — спробуйте знову",
"KartaView could not be reached — check the network": "Не вдалося звʼязатися з KartaView — перевірте мережу",
"KartaView sent an answer that could not be read": "KartaView надіслав відповідь, яку не вдалося прочитати",
"KartaView is rate-limiting requests": "KartaView обмежує частоту запитів",
@@ -3572,6 +3573,17 @@
"Refresh map still": "Оновити кадр на карті",
"The map still uses a separate request and may show a different moment.": "Кадр на карті запитується окремо та може показувати інший момент.",
"Video plays from Québec 511, which sees your IP address.": "Відео відтворюється з Québec 511, який бачить вашу IP-адресу.",
"Press OPEN to load the photo": "Натисніть «ВІДКРИТИ», щоб завантажити фото",
"Captured {month}": "Знято: {month}",
"this spot": "це місце",
"the view centre": "центр карти",
"{source} at {place}": "{source}: {place}",
"Street level at {place}": "Вуличні знімки: {place}",
"Switching map": "Перемикання карти",
"Opening imagery": "Відкриття знімків",
"Street View is ready: press {open} to load it": "Перегляд вулиць готовий: натисніть {open}, щоб завантажити",
"{source} cannot open this link's street-level photo without a working key": "{source} не може відкрити вуличне фото за цим посиланням без робочого ключа",
"Street-level photos are hidden in the current view. Leave it first.": "Вуличні знімки приховані в поточному режимі. Спершу вийдіть із нього.",
"STATIONS": "СТАНЦІЇ",
"Crewed stations and the craft that visit them": "Пілотовані станції та кораблі, що до них прилітають",
"IMAGING": "ЗЙОМКА",
@@ -3641,5 +3653,20 @@
"ICAO: {id}": "ICAO: {id}",
"NORAD: {id}": "NORAD: {id}",
"MMSI: {id}": "MMSI: {id}",
"Vessel UUID: {id}": "UUID судна: {id}"
"Vessel UUID: {id}": "UUID судна: {id}",
"Latitude must be within ±90° and longitude within ±180°": "Широта має бути в межах ±90°, а довгота — у межах ±180°",
"Street Level is unavailable": "Вуличні знімки недоступні",
"No street-level imagery source is configured": "Джерело вуличних знімків не налаштовано",
"Street Level could not be enabled": "Не вдалося увімкнути вуличні знімки",
"Street Level changed while it looked for imagery": "Вуличні знімки змінилися під час пошуку",
"Aim the view at a street": "Наведіть вигляд на вулицю",
"{source} is not a street-level imagery source here": "{source} тут не є джерелом вуличних знімків",
"{source} needs its key": "{source}: потрібен ключ",
"{source} needs the {map} map, which is not available": "{source}: потрібна карта {map}, але вона недоступна",
"{source} could not be switched on": "Не вдалося увімкнути {source}",
"{source} is not available on this map": "{source}: недоступно на цій карті",
"{source} imagery could not be opened": "Не вдалося відкрити знімки {source}",
"{place} has no position to look for imagery at": "Для місця «{place}» немає координат для пошуку знімків",
"No {source} imagery near {place}": "Немає знімків {source} поблизу {place}",
"{reason} (filters on: {filters})": "{reason} (увімкнено фільтри: {filters})"
}
+28 -1
View File
@@ -1124,6 +1124,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及时筛选此区域——移动地图以重新请求",
"None of KartaView’s photos here pass the imagery filter": "此处没有 KartaView 照片通过影像筛选",
"KartaView did not answer in time — try again": "KartaView 未及时响应——请重试",
"Mapillary did not answer in time — try again": "Mapillary 未及时响应——请重试",
"KartaView could not be reached — check the network": "无法连接 KartaView——请检查网络",
"KartaView sent an answer that could not be read": "KartaView 返回的响应无法读取",
"KartaView is rate-limiting requests": "KartaView 已被限流",
@@ -3329,6 +3330,17 @@
"Refresh map still": "刷新地图静止画面",
"The map still uses a separate request and may show a different moment.": "地图静止画面通过单独的请求获取,可能显示不同的时刻。",
"Video plays from Québec 511, which sees your IP address.": "视频从 Québec 511 播放,Québec 511 会看到你的 IP 地址。",
"Press OPEN to load the photo": "点击“打开”加载照片",
"Captured {month}": "拍摄于 {month}",
"this spot": "此处",
"the view centre": "视图中心",
"{source} at {place}": "{place} 的 {source}",
"Street level at {place}": "{place} 的街景",
"Switching map": "正在切换地图",
"Opening imagery": "正在打开影像",
"Street View is ready: press {open} to load it": "街景已就绪:按 {open} 加载",
"{source} cannot open this link's street-level photo without a working key": "没有有效密钥,{source} 无法打开此链接的街景照片",
"Street-level photos are hidden in the current view. Leave it first.": "当前视图隐藏了街景照片。请先退出该视图。",
"STATIONS": "空间站",
"Crewed stations and the craft that visit them": "载人空间站及来访飞行器",
"IMAGING": "成像",
@@ -3398,5 +3410,20 @@
"ICAO: {id}": "ICAO:{id}",
"NORAD: {id}": "NORAD:{id}",
"MMSI: {id}": "MMSI:{id}",
"Vessel UUID: {id}": "船舶 UUID:{id}"
"Vessel UUID: {id}": "船舶 UUID:{id}",
"Latitude must be within ±90° and longitude within ±180°": "纬度须在 ±90° 以内,经度须在 ±180° 以内",
"Street Level is unavailable": "街景不可用",
"No street-level imagery source is configured": "未配置街景影像来源",
"Street Level could not be enabled": "无法开启街景",
"Street Level changed while it looked for imagery": "查找影像时街景已更改",
"Aim the view at a street": "请将视野对准街道",
"{source} is not a street-level imagery source here": "{source} 在此处不是街景影像来源",
"{source} needs its key": "{source} 需要其密钥",
"{source} needs the {map} map, which is not available": "{source} 需要 {map} 地图,但该地图不可用",
"{source} could not be switched on": "无法开启 {source}",
"{source} is not available on this map": "{source} 在此地图上不可用",
"{source} imagery could not be opened": "无法打开 {source} 影像",
"{place} has no position to look for imagery at": "{place} 没有可查找影像的位置",
"No {source} imagery near {place}": "{place} 附近没有 {source} 影像",
"{reason} (filters on: {filters})": "{reason}(已开启筛选:{filters})"
}
+28 -1
View File
@@ -1119,6 +1119,7 @@
"KartaView did not filter this area in time — move the map to ask again": "KartaView 未能及時篩選此區域——移動地圖以重新請求",
"None of KartaView’s photos here pass the imagery filter": "此處沒有 KartaView 照片通過影像篩選",
"KartaView did not answer in time — try again": "KartaView 未及時響應——請重試",
"Mapillary did not answer in time — try again": "Mapillary 未及時響應——請重試",
"KartaView could not be reached — check the network": "無法連接 KartaView——請檢查網絡",
"KartaView sent an answer that could not be read": "KartaView 返回的響應無法讀取",
"KartaView is rate-limiting requests": "KartaView 已被限流",
@@ -3329,6 +3330,17 @@
"Refresh map still": "重新整理地圖靜態畫面",
"The map still uses a separate request and may show a different moment.": "地圖靜態畫面使用獨立請求,顯示的時間可能不同。",
"Video plays from Québec 511, which sees your IP address.": "影片從 Québec 511 播放,Québec 511 會看到你的 IP 位址。",
"Press OPEN to load the photo": "點擊“打開”加載照片",
"Captured {month}": "拍攝於 {month}",
"this spot": "此處",
"the view centre": "視圖中心",
"{source} at {place}": "{place} 的 {source}",
"Street level at {place}": "{place} 的街景",
"Switching map": "正在切換地圖",
"Opening imagery": "正在打開影像",
"Street View is ready: press {open} to load it": "街景已就緒:按 {open} 加載",
"{source} cannot open this link's street-level photo without a working key": "沒有有效密鑰,{source} 無法打開此鏈接的街景照片",
"Street-level photos are hidden in the current view. Leave it first.": "當前視圖隱藏了街景照片。請先退出該視圖。",
"STATIONS": "太空站",
"Crewed stations and the craft that visit them": "載人太空站及來訪飛行器",
"IMAGING": "成像",
@@ -3398,5 +3410,20 @@
"ICAO: {id}": "ICAO:{id}",
"NORAD: {id}": "NORAD:{id}",
"MMSI: {id}": "MMSI:{id}",
"Vessel UUID: {id}": "船舶 UUID:{id}"
"Vessel UUID: {id}": "船舶 UUID:{id}",
"Latitude must be within ±90° and longitude within ±180°": "緯度須在 ±90° 以內,經度須在 ±180° 以內",
"Street Level is unavailable": "街景不可用",
"No street-level imagery source is configured": "未配置街景影像來源",
"Street Level could not be enabled": "無法開啟街景",
"Street Level changed while it looked for imagery": "查找影像時街景已更改",
"Aim the view at a street": "請將視野對準街道",
"{source} is not a street-level imagery source here": "{source} 在此處不是街景影像來源",
"{source} needs its key": "{source} 需要其密鑰",
"{source} needs the {map} map, which is not available": "{source} 需要 {map} 地圖,但該地圖不可用",
"{source} could not be switched on": "無法開啟 {source}",
"{source} is not available on this map": "{source} 在此地圖上不可用",
"{source} imagery could not be opened": "無法打開 {source} 影像",
"{place} has no position to look for imagery at": "{place} 沒有可查找影像的位置",
"No {source} imagery near {place}": "{place} 附近沒有 {source} 影像",
"{reason} (filters on: {filters})": "{reason}(已開啟篩選:{filters})"
}
+23 -11
View File
@@ -535,14 +535,26 @@ export function createModel({
return `${isolate(latPart)} ${isolate(lonPart)}`;
}
/**
* Millisecond delta → `{ unit: '<1h' | 'h' | 'd', count }`, or null for
* invalid input. Both age formatters read it, so they pick the same unit.
*/
function ageParts(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return null;
const hours = deltaMs / 3600000;
if (hours < 1) return { unit: '<1h', count: 0 };
const wholeHours = Math.round(hours);
if (wholeHours < 48) return { unit: 'h', count: wholeHours };
return { unit: 'd', count: Math.round(hours / 24) };
}
/** Millisecond delta → "<1h" / "Xh" / "Xd", or '' for invalid input. */
function formatAge(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
const hours = deltaMs / 3600000;
if (hours < 1) return '<1h';
if (hours < 48) return `${Math.round(hours)}h`;
return `${Math.round(hours / 24)}d`;
const age = ageParts(deltaMs);
if (!age) return '';
return age.unit === '<1h' ? '<1h' : `${age.count}${age.unit}`;
}
/** Millisecond delta → "<1m ago" / "Xm ago" / "Xh ago" (fresh-feed readout). */
@@ -573,12 +585,12 @@ export function createModel({
/** formatAge for a card: "<1h", "24h", "3d" in the UI language ("24 ч"). */
function formatAgeLabel(deltaMs) {
if (!Number.isFinite(deltaMs) || deltaMs < 0) return '';
const hours = deltaMs / 3600000;
if (hours < 1) return t('<1h', { context: 'age' });
if (hours < 48)
return t('{count}h', { context: 'age', count: Math.round(hours) });
return t('{count}d', { context: 'age', count: Math.round(hours / 24) });
const age = ageParts(deltaMs);
if (!age) return '';
if (age.unit === '<1h') return t('<1h', { context: 'age' });
return age.unit === 'h'
? t('{count}h', { context: 'age', count: age.count })
: t('{count}d', { context: 'age', count: age.count });
}
/** Normalized 0..1 confidence → low/nominal/high display bucket. */
+45
View File
@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { getLocale, setLocale } from '../../i18n/index.js';
import { createModel } from './model.js';
const HOUR_MS = 3600000;
test('a fire age that rounds to 48 hours reads in days, as 48 hours does', () => {
const previousLocale = getLocale();
const { formatAge, formatAgeLabel } = createModel({
services: { anchors: {} },
});
try {
setLocale('en');
assert.equal(formatAge(47.4 * HOUR_MS), '47h');
assert.equal(formatAge(47.5 * HOUR_MS), '2d');
assert.equal(formatAge(47.6 * HOUR_MS), '2d');
assert.equal(formatAge(48 * HOUR_MS), '2d');
assert.equal(formatAgeLabel(47.4 * HOUR_MS), '47h');
assert.equal(formatAgeLabel(47.5 * HOUR_MS), '2d');
assert.equal(formatAgeLabel(47.6 * HOUR_MS), formatAgeLabel(48 * HOUR_MS));
assert.equal(formatAgeLabel(48 * HOUR_MS), '2d');
} finally {
setLocale(previousLocale);
}
});
test('both fire age formatters agree on invalid, sub-hour and multi-day ages', () => {
const previousLocale = getLocale();
const { formatAge, formatAgeLabel } = createModel({
services: { anchors: {} },
});
try {
setLocale('en');
for (const format of [formatAge, formatAgeLabel]) {
assert.equal(format(-1), '');
assert.equal(format(Number.NaN), '');
assert.equal(format(0.5 * HOUR_MS), '<1h');
assert.equal(format(1 * HOUR_MS), '1h');
assert.equal(format(72 * HOUR_MS), '3d');
}
} finally {
setLocale(previousLocale);
}
});
+195 -18
View File
@@ -15,13 +15,14 @@ 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 {
AIM_AT_GROUND_MESSAGE,
keyStatusLabel,
NEAREST_RADIUS_M,
NEAREST_RADIUS_MAX_M,
POSITION_PICK_ID,
STREET_LEVEL_LAYER_ID,
} from './policy.js';
@@ -33,6 +34,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).
@@ -170,6 +176,12 @@ export function createStreetLevelLayer({
const owner = state.providers.get(state.street.providerId);
if (state.street.open && owner && !providerAvailable(owner))
layer.closeViewer();
// An armed lookup for a source whose map left dies too.
const armedFor = state.street.armed?.providerIds?.map((id) =>
state.providers.get(id),
);
if (armedFor?.some((entry) => entry && !providerAvailable(entry)))
state.street.armed = null;
for (const entry of state.providers.values()) {
if (!entry.def.requiresMapStack) continue;
if (state.enabled && entry.on && providerAvailable(entry))
@@ -254,8 +266,11 @@ export function createStreetLevelLayer({
if (providerAvailable(entry)) parts.credits.show(state.viewer, entry.def);
}
/** Sources whose open photo a replacing request keeps up while it switches them off. */
const heldViewers = new Map();
function deactivate(entry) {
parts.viewerHost.unmount(entry.def.id);
if (!heldViewers.has(entry.def.id)) parts.viewerHost.unmount(entry.def.id);
entry.instance.deactivate();
parts.credits.hide(state.viewer, entry.def);
}
@@ -268,6 +283,13 @@ export function createStreetLevelLayer({
nearestLookup = null;
}
/** Close the image and deselect its sequence on the map. */
function closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
}
/** Ground under the screen centre; null for sky, undefined when it cannot aim. */
function screenCentreGround() {
const canvas = state.viewer?.scene?.canvas;
@@ -288,8 +310,14 @@ export function createStreetLevelLayer({
}
/** `frame: false` keeps the camera where it is (the user picked the spot). */
function openImage(providerId, imageId, { frame = true } = {}) {
function openImage(
providerId,
imageId,
{ frame = true, signal = null } = {},
) {
if (signal?.aborted) return Promise.resolve(false);
abortNearest();
state.street.armed = null;
const entry = state.providers.get(providerId);
if (entry && !providerAvailable(entry)) {
state.street.loading = false;
@@ -297,7 +325,7 @@ export function createStreetLevelLayer({
notify();
return Promise.resolve(false);
}
return parts.viewerHost.open(providerId, imageId, { frame });
return parts.viewerHost.open(providerId, imageId, { frame, signal });
}
function unavailableReason(entry) {
@@ -343,6 +371,9 @@ export function createStreetLevelLayer({
function switchProvider(entry, on) {
if (entry.on === on) return;
entry.on = on;
// A lookup armed for this source dies with it.
if (!on && state.street.armed?.providerIds?.includes(entry.def.id))
state.street.armed = null;
if (!state.enabled) return;
if (on) activate(entry);
else deactivate(entry);
@@ -479,6 +510,33 @@ export function createStreetLevelLayer({
},
/** Registered providers, in chip order. */
providerIds: defs.map((def) => def.id),
/** The map stack a provider needs (`requiresMapStack`), or null for any. */
requiredMapStack(providerId) {
const def = defs.find((entry) => entry.id === providerId);
return def?.requiresMapStack || null;
},
/**
* A provider's name and key state on any map (the panel lists a provider
* only on the map it needs). `configured` is null until its status call
* answers; `keyRequired` covers a missing or refused key.
*/
providerStatus(providerId) {
const entry = state.providers.get(providerId);
if (!entry) return null;
const stats = entry.instance.coverageStats();
return {
id: entry.def.id,
name: entry.def.name,
on: entry.on,
configured: entry.status ? entry.status.configured === true : null,
keyRequired: stats.keyRequired === true || stats.keyRejected === true,
keyRejected: stats.keyRejected === true,
groundClick: entry.def.groundClick === true,
billed: entry.def.billedPerImage === true,
available: providerAvailable(entry),
error: stats.error || null,
};
},
getSourceAvailability() {
const available = defs.length > 0;
return {
@@ -636,19 +694,66 @@ export function createStreetLevelLayer({
* Open the nearest image of the active providers (or `providerIds`) near
* a point, else the view centre, or with `aim: 'screen-centre'` the ground
* under the screen centre (sky: nothing). Providers without
* `nearestImage` are skipped.
* `nearestImage` are skipped. Options:
* - `radiusM` widens the search where a provider can (a geocoded landmark
* is rarely within 50 m of a street);
* - `signal` retires the lookup: nothing opens after it fires;
* - `armed: false` (the assistant's own request) ignores a link's armed
* point and keeps it until a photo opens;
* - `replace: true` keeps an open photo (and EXPAND) up while it looks and
* closes it if nothing opens;
* - `proceed()` is asked before a found photo loads or an empty lookup
* reports; false retires the request and leaves the panel as it was.
* Why nothing opened is in `street.nearestOutcome`: 'empty', 'sky' or
* null (an error).
*/
async openNearest(
point,
{ providerIds = null, frame = true, aim = 'view' } = {},
{
providerIds = null,
frame = true,
aim = 'view',
radiusM = null,
signal: callerSignal = null,
armed: useArmed = true,
replace = false,
proceed = null,
} = {},
) {
if (callerSignal?.aborted) return false;
state.street.nearestOutcome = null;
// An OPEN press after a link armed a lookup opens what the link asked;
// any other lookup is a new request and drops the armed one.
const armed = useArmed ? state.street.armed : null;
if (useArmed) state.street.armed = null;
const closeReplaced = () => {
if (replace && state.street.open) closeViewer();
};
if (!point && aim === 'screen-centre' && armed) {
// OPEN promises the view centre: the link's spot only while the view
// still shows it, or when the view cannot be picked.
const centre = armed.point ? screenCentreGround() : null;
if (!armed.point) radiusM ??= armed.radiusM;
else if (
centre === undefined ||
(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))
: NEAREST_RADIUS_M;
let view = point;
if (!view && aim === 'screen-centre') {
view = screenCentreGround();
if (view === null) {
abortNearest();
closeReplaced();
state.street.loading = false;
state.street.error = AIM_AT_GROUND_MESSAGE;
state.street.nearestOutcome = 'sky';
notify();
return false;
}
@@ -660,6 +765,19 @@ export function createStreetLevelLayer({
const lookup = new AbortController();
nearestLookup = lookup;
const { signal } = lookup;
const onCallerAbort = () => lookup.abort();
callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
const retired = () => {
callerSignal?.removeEventListener('abort', onCallerAbort);
// A newer lookup owns the panel, its spinner and its error.
if (nearestLookup !== lookup) return false;
nearestLookup = null;
if (state.street.loading) {
state.street.loading = false;
notify();
}
return false;
};
state.street.loading = true;
state.street.error = null;
notify();
@@ -674,49 +792,108 @@ export function createStreetLevelLayer({
try {
imageId = await entry.instance.nearestImage(
{ lat: view.lat, lon: view.lon },
{ signal },
radius === NEAREST_RADIUS_M
? { signal }
: { signal, radiusM: radius },
);
} catch (error) {
if (callerSignal?.aborted) return retired();
// Overtaken: whatever replaced it owns the panel, errors included.
if (signal.aborted) return false;
if (signal.aborted) return retired();
// One provider failing (no key, offline) must not hide the others.
lastError = error;
continue;
}
if (signal.aborted) return false;
if (callerSignal?.aborted) return retired();
if (signal.aborted) return retired();
if (!state.enabled) break;
// Switched off, or its map left, while it looked: try the next.
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
if (proceed && proceed() !== true) return retired();
nearestLookup = null;
return openImage(entry.def.id, imageId, { frame });
callerSignal?.removeEventListener('abort', onCallerAbort);
// A sequence playing in the replaced photo would step past this one.
if (replace && state.street.open) parts.viewerHost.stopPlayback();
// The caller's signal still counts while the viewer mounts and loads.
return openImage(entry.def.id, imageId, {
frame,
signal: callerSignal,
});
}
if (nearestLookup === lookup) nearestLookup = null;
callerSignal?.removeEventListener('abort', onCallerAbort);
// Replaced while it looked: the panel is the newer lookup's.
if (nearestLookup !== lookup) return false;
// Given up by its caller: the panel stays as it was.
if (proceed && proceed() !== true) return retired();
nearestLookup = null;
closeReplaced();
// Switched off while it looked: nothing to open, nothing to report.
const stillOn = entries.some(
(entry) => entry.on && providerAvailable(entry),
);
// A provider's own refusal is English data that the panel translates;
// the core's refusal is built here, for the panel only, in the language.
if (state.enabled && stillOn && !lastError)
state.street.nearestOutcome = 'empty';
if (state.enabled && stillOn)
state.street.error =
lastError?.message ||
(point
? t('No street-level imagery within {radius} m of that point', {
radius: NEAREST_RADIUS_M,
radius,
})
: t(
'No street-level imagery within {radius} m of the view centre',
{ radius: NEAREST_RADIUS_M },
{
radius,
},
));
state.street.loading = false;
notify();
return false;
},
/** Close the image and deselect its sequence on the map. */
closeViewer() {
abortNearest();
parts.viewerHost.close();
parts.clearSequences();
closeViewer,
/**
* Keep the open photo up while a request that replaces it switches its
* source off, so EXPAND stays; only that source's own switch-off is held,
* a source switched by hand meanwhile still stops a load. Release frees
* the old viewer.
*/
holdViewer() {
const id = state.street.open ? state.street.providerId : null;
if (!id) return () => {};
heldViewers.set(id, (heldViewers.get(id) || 0) + 1);
let held = true;
return () => {
if (!held) return;
held = false;
const left = heldViewers.get(id) - 1;
if (left > 0) return void heldViewers.set(id, left);
heldViewers.delete(id);
if (state.providers.get(id)?.on !== false) return;
// Its photo still up (the request gave up) closes with its source;
// otherwise the request replaced or closed it, and the panel keeps
// what that request left (an error, an armed OPEN).
if (state.street.open && state.street.providerId === id)
parts.viewerHost.unmount(id);
else parts.viewerHost.release(id);
};
},
/**
* Arm a nearest lookup for the next OPEN press without running it (a link
* to a source that bills every load). Any other lookup, open or close, the
* source switched off or its map leaving clears it.
*/
armNearest(point, { providerIds = null, radiusM = null } = {}) {
state.street.armed = {
point:
Number.isFinite(point?.lat) && Number.isFinite(point?.lon)
? { lat: point.lat, lon: point.lon }
: null,
providerIds: Array.isArray(providerIds) ? [...providerIds] : null,
radiusM: Number.isFinite(radiusM) ? radiusM : null,
};
notify();
},
setViewerRenderMode: (mode) => parts.viewerHost.setRenderMode(mode),
resizeViewer: () => parts.viewerHost.resize(),
+950 -2
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { getEventListeners } from 'node:events';
import * as Cesium from 'cesium';
import { createStreetLevelLayer } from './index.js';
import { MAPILLARY_CREDIT_HTML } from './providers/mapillary/policy.js';
@@ -12,6 +13,9 @@ import {
fakeStreetLevelProvider as fakeProvider,
} from '../../testSupport/streetLevelFakes.mjs';
import { setLocale } from '../../i18n/index.js';
// Loaded up front: it brings Vue, which reads `document` once when it loads,
// and several tests here stand a bare fake document in for the page.
import { createGevActionRunner } from '../../voice/gevActions.js';
/**
* A layer over `source` and `photoViewer`, initialised on a stand-in viewer
@@ -362,7 +366,11 @@ function fakeViewer() {
}
/** A layer over `providers`, initialised and enabled on a stand-in viewer. */
async function enabledLayer(t, providers = [fakeProvider()]) {
async function enabledLayer(
t,
providers = [fakeProvider()],
{ services, setup } = {},
) {
const saved = globalThis.document;
// A pose moves the position marker, whose glyph is drawn on a canvas.
const drawing = new Proxy({}, { get: () => () => ({ addColorStop() {} }) });
@@ -370,7 +378,11 @@ async function enabledLayer(t, providers = [fakeProvider()]) {
createElement: () => ({ getContext: () => drawing }),
});
const viewer = fakeViewer();
const layer = createStreetLevelLayer({ providers });
setup?.(viewer);
const layer = createStreetLevelLayer({
providers,
...(services ? { services } : {}),
});
layer.init(viewer);
layer.enable(viewer);
t.after(() => {
@@ -944,6 +956,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');
@@ -1118,6 +1178,732 @@ test('switching source by params clears a failed lookup’s error; reapplying th
assert.equal(second.getUIState().street.error, kept);
});
test('openNearest searches wider on request, never past the cap, and says how far it looked', async (t) => {
const { layer, fake } = await withStreetView(t, { nearest: () => null });
layer.setProviderEnabled('google', true);
await settle();
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false, radiusM: 250 },
);
assert.equal(fake.lookups.at(-1).radius, 250);
assert.equal(
layer.getUIState().street.error,
'No street-level imagery within 250 m of that point',
);
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false, radiusM: 5000 },
);
assert.equal(fake.lookups.at(-1).radius, 250, 'capped');
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.equal(fake.lookups.at(-1).radius, 50, 'a click keeps the default');
});
test('a caller’s signal retires a nearest lookup: nothing opens after it fires', async (t) => {
let answer = null;
const { layer, fake } = await withStreetView(t, {
nearest: () => 'pano-a',
});
const lib = fake.library;
const service = lib.StreetViewService.prototype.getPanorama;
lib.StreetViewService.prototype.getPanorama = function (request) {
return new Promise((resolve, reject) => {
answer = () => service.call(this, request).then(resolve, reject);
});
};
layer.setProviderEnabled('google', true);
await settle();
const controller = new AbortController();
const pending = layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false, signal: controller.signal },
);
await settle();
controller.abort();
answer?.();
assert.equal(await pending, false);
const street = layer.getUIState().street;
assert.equal(street.open, false, 'the retired lookup opened nothing');
assert.equal(street.loading, false, 'no stuck spinner');
assert.equal(street.error, null, 'and reports no missing imagery');
const refused = await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false, signal: controller.signal },
);
assert.equal(refused, false, 'an already-aborted signal asks nothing');
});
test('providerStatus reports name, key state and billing on any map', async (t) => {
const { layer } = await withStreetView(t, { mapStack: 'osm' });
const google = layer.providerStatus('google');
assert.equal(google.name, 'Google Street View');
assert.equal(google.billed, true, 'every Street View load is billed');
assert.equal(google.available, false, 'not on this map');
assert.equal(google.keyRequired, false);
assert.equal(layer.providerStatus('mapillary').billed, false);
assert.equal(layer.providerStatus('nope'), null);
assert.equal(layer.requiredMapStack('google'), 'photoreal');
assert.equal(layer.requiredMapStack('mapillary'), null);
});
test('an armed lookup asks nothing until the OPEN press, then opens at its point and radius once', async (t) => {
const { layer, fake, mapillaryLookups } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
const before = fake.lookups.length;
layer.armNearest(
{ lat: 38.57986, lon: -121.49372 },
{ providerIds: ['google'], radiusM: 250 },
);
await settle();
await new Promise((resolve) => setTimeout(resolve, 20));
// The click gate: arming is free. No provider was asked, nothing opened.
assert.equal(
fake.lookups.length,
before,
'no Google lookup before the press',
);
assert.deepEqual(mapillaryLookups, [], 'no Mapillary lookup either');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().street.loading, false);
assert.deepEqual(layer.getUIState().street.armed, {
point: { lat: 38.57986, lon: -121.49372 },
providerIds: ['google'],
radiusM: 250,
});
// The OPEN button: no point, aimed at the screen centre.
const opened = await layer.openNearest(undefined, {
providerIds: ['google'],
frame: false,
aim: 'screen-centre',
});
assert.equal(opened, true);
assert.equal(
fake.lookups.length,
before + 1,
'exactly one lookup for the press',
);
const request = fake.lookups.at(-1);
assert.deepEqual(
request.location,
{ lat: 38.57986, lng: -121.49372 },
'the armed point, not the screen centre',
);
assert.equal(request.radius, 250);
assert.equal(layer.getUIState().street.armed, null, 'used once');
layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] });
layer.closeViewer();
assert.equal(layer.getUIState().street.armed, null, 'closing clears it');
layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] });
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false },
);
assert.deepEqual(
fake.lookups.at(-1).location,
{ lat: 38.58, lng: -121.49 },
'an explicit point wins',
);
assert.equal(layer.getUIState().street.armed, null);
// Only the OPEN press consumes the armed point: "street view here" after a
// pan is a new request at the view centre, and it drops the armed one.
layer.closeViewer();
layer.armNearest(
{ lat: 10, lon: 20 },
{ providerIds: ['google'], radiusM: 250 },
);
await layer.openNearest(null, { providerIds: ['google'], frame: false });
const centre = fake.lookups.at(-1);
assert.notDeepEqual(
centre.location,
{ lat: 10, lng: 20 },
'the view centre, not the armed point',
);
assert.equal(centre.radius, 50, 'and the default radius');
assert.equal(layer.getUIState().street.armed, null, 'dropped');
// Opening any image, or switching the layer off, clears it too.
layer.armNearest({ lat: 10, lon: 20 }, { providerIds: ['google'] });
await layer.openImage('google', 'pano-a');
assert.equal(layer.getUIState().street.armed, null, 'cleared by an open');
layer.armNearest({ lat: 10, lon: 20 }, { providerIds: ['google'] });
layer.disable();
assert.equal(layer.getUIState().street.armed, null, 'cleared by disable');
});
test('a caller’s signal: an aborted one asks nothing, and a provider that ignores the abort still opens nothing', async (t) => {
const { layer, fake } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
const before = fake.lookups.length;
const done = new AbortController();
done.abort();
assert.equal(
await layer.openNearest(
{ lat: 38.58, lon: -121.49 },
{ providerIds: ['google'], frame: false, signal: done.signal },
),
false,
);
assert.equal(
fake.lookups.length,
before,
'an already-aborted signal asks nothing',
);
});
test('a provider that ignores the abort and answers late still opens nothing', async (t) => {
let seen = null;
let release = null;
const slow = fakeProvider({
nearestImage: async (_point, { signal } = {}) => {
seen = signal;
await new Promise((resolve) => {
release = resolve;
});
return 'late-image';
},
});
const { layer: ignoring } = await enabledLayer(t, [slow]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
ignoring.attachViewerHost(fakeHost());
const controller = new AbortController();
const pending = ignoring.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: controller.signal },
);
await settle();
controller.abort();
assert.equal(seen?.aborted, true, 'the provider sees the abort');
release();
assert.equal(await pending, false);
const street = ignoring.getUIState().street;
assert.equal(street.open, false, 'the late answer opened nothing');
assert.equal(street.loading, false, 'no stuck spinner');
assert.equal(street.error, null);
});
test('providerStatus reports a keyless source on any map', async (t) => {
const keyless = await withStreetView(t, {
mapStack: 'osm',
mapillaryKeyless: true,
});
await settle();
const mapillary = keyless.layer.providerStatus('mapillary');
assert.equal(mapillary.configured, false);
assert.equal(mapillary.keyRequired, true);
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();
await settle();
const google = refused.layer.providerStatus('google');
assert.equal(
google.keyRequired,
true,
'a refused key gates like a missing one',
);
assert.equal(google.keyRejected, true);
assert.equal(google.available, false, 'still not on this map');
});
test('an armed lookup dies with its source: switched off, or its map gone', async (t) => {
const { layer, stack } = await withStreetView(t);
layer.setProviderEnabled('google', true);
await settle();
layer.armNearest(
{ lat: 1, lon: 2 },
{ providerIds: ['google'], radiusM: 250 },
);
layer.setProviderEnabled('google', false);
assert.equal(
layer.getUIState().street.armed,
null,
'switching the source off clears it',
);
layer.setProviderEnabled('google', true);
layer.armNearest(
{ lat: 1, lon: 2 },
{ providerIds: ['google'], radiusM: 250 },
);
// Lighting Mapillary instead (one source at a time) turns Google off too.
layer.setProviderEnabled('mapillary', true);
assert.equal(
layer.getUIState().street.armed,
null,
'another source lit clears it',
);
layer.setProviderEnabled('google', true);
layer.armNearest(
{ lat: 1, lon: 2 },
{ providerIds: ['google'], radiusM: 250 },
);
// A map-stack notify that keeps Google 3D (a silent re-settle) keeps it.
stack.switchTo('photoreal');
assert.notEqual(
layer.getUIState().street.armed,
null,
'a notify on the same map keeps it',
);
stack.switchTo('osm');
assert.equal(
layer.getUIState().street.armed,
null,
'leaving Google 3D clears it',
);
// Arming notifies, so a panel can show the state.
let notified = 0;
const unsubscribe = layer.subscribe(() => notified++);
stack.switchTo('photoreal');
await settle();
const before = notified;
layer.armNearest({ lat: 1, lon: 2 }, { providerIds: ['google'] });
await settle();
assert.ok(notified > before, 'subscribers hear about the armed lookup');
unsubscribe();
});
test('a lookup armed with no point opens under the middle of the screen on the press', 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;
// Registered before the layer exists, so a throwing create() still
// restores the document; destroy() runs first, while the stub is in place.
let layer = null;
t.after(() => {
layer?.destroy();
globalThis.document = saved;
});
layer = create({ providers: [provider], services: { scenePick } });
layer.init(viewer);
layer.enable(viewer);
layer.armNearest(null, { providerIds: [provider.id], radiusM: 250 });
assert.equal(
layer.getUIState().street.armed.point,
null,
'no point stored, not 0,0',
);
await layer.openNearest(undefined, { aim: 'screen-centre' });
assert.equal(lookups.length, 1);
assert.ok(
Math.abs(lookups[0].lat - 38.5799) < 1e-9,
'the screen centre, not Null Island',
);
assert.ok(Math.abs(lookups[0].lon - -121.4937) < 1e-9);
assert.equal(lookups[0].radiusM, 250, 'with the armed radius');
});
test('the action over the real layer: a billed link arms without a lookup, and one press makes exactly one at the point', async (t) => {
const { layer, fake, viewer } = await withStreetView(t);
// The runner wires pointer tracking to the clock and camera.
viewer.clock ??= { onTick: { addEventListener: () => () => {} } };
viewer.camera.moveEnd ??= { addEventListener() {} };
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
isEnabled: () => true,
getLayerParams: () => layer.getParams(),
setLayerParams: (_id, params) => {
layer.setParams(params);
return true;
},
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }],
};
const styleManager = {
mapStackController: { getActiveId: () => 'photoreal' },
setMapStack: async () => ({ ok: true, activeStack: 'photoreal' }),
};
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
const before = fake.lookups.length;
const armed = await runner('show_street_level', {
latitude: 38.57986,
longitude: -121.49372,
provider: 'google',
fly: false,
autoOpen: false,
});
assert.equal(armed.ok, true, armed.error);
assert.equal(armed.armed, true);
assert.equal(armed.providerName, 'Google Street View');
await settle();
assert.equal(fake.lookups.length, before, 'arming made no Google request');
assert.deepEqual(
layer
.getUIState()
.providers.filter((p) => p.on)
.map((p) => p.id),
['google'],
);
assert.equal(layer.getUIState().street.open, false);
// The OPEN button.
assert.equal(
await layer.openNearest(undefined, {
providerIds: ['google'],
frame: false,
aim: 'screen-centre',
}),
true,
);
assert.equal(fake.lookups.length, before + 1, 'one request for the press');
assert.deepEqual(fake.lookups.at(-1).location, {
lat: 38.57986,
lng: -121.49372,
});
assert.equal(fake.lookups.at(-1).radius, 250);
assert.equal(layer.getUIState().street.imageId, 'pano-a');
// The same call without the gate opens at once, with one request.
layer.closeViewer();
const opened = await runner('show_street_level', {
latitude: 38.57986,
longitude: -121.49372,
provider: 'google',
fly: false,
});
assert.equal(opened.ok, true, opened.error);
assert.equal(opened.imageId, 'pano-a');
assert.equal(fake.lookups.length, before + 2);
});
test('openNearest keeps honouring the caller after the lookup: an abort while the viewer loads opens nothing', async (t) => {
let releaseMount = null;
const provider = fakeProvider({ nearestImage: async () => 'img-1' });
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
const mount = instance.viewer.mount;
instance.viewer.mount = async (...args) => {
await new Promise((resolve) => {
releaseMount = resolve;
});
return mount(...args);
};
return instance;
};
const { layer } = await enabledLayer(t, [provider]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
const controller = new AbortController();
const pending = layer.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: controller.signal },
);
for (let i = 0; i < 20 && !releaseMount; i++) await settle();
assert.ok(releaseMount, 'the lookup answered and the viewer is mounting');
controller.abort();
releaseMount();
assert.equal(await pending, false);
assert.deepEqual(provider.calls.open, [], 'the photo was never opened');
assert.equal(layer.getUIState().street.open, false);
assert.equal(layer.getUIState().street.loading, false);
});
for (const rejectsOnAbort of [true, false]) {
test(`cancelling a lookup over an open photo clears its spinner (provider rejects: ${rejectsOnAbort})`, async (t) => {
const answer = deferred();
const provider = fakeProvider({
nearestImage: (_point, { signal }) => {
if (rejectsOnAbort)
signal.addEventListener('abort', () => answer.reject(signal.reason), {
once: true,
});
return answer.promise;
},
});
const { layer } = await enabledLayer(t, [provider]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
assert.equal(
await layer.openImage('mapillary', 'existing', { frame: false }),
true,
);
const controller = new AbortController();
const pending = layer.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: controller.signal },
);
assert.equal(layer.getUIState().street.loading, true);
controller.abort();
if (!rejectsOnAbort) answer.resolve('late-image');
assert.equal(await pending, false);
const street = layer.getUIState().street;
assert.equal(
street.loading,
false,
'the cancelled lookup owns and clears the spinner',
);
assert.equal(street.open, true, 'the existing photo remains open');
assert.equal(street.error, null);
assert.deepEqual(
provider.calls.open,
['existing'],
'no replacement photo opens',
);
});
}
for (const rejectsOnAbort of [true, false]) {
test(`retired lookups release the caller listener (provider rejects: ${rejectsOnAbort})`, async (t) => {
let answer;
const provider = fakeProvider({
nearestImage: (_point, { signal }) => {
answer = deferred();
if (rejectsOnAbort)
signal.addEventListener('abort', () => answer.reject(signal.reason), {
once: true,
});
return answer.promise;
},
});
const { layer } = await enabledLayer(t, [provider]);
const lifetime = new AbortController();
for (let i = 0; i < 3; i++) {
const pending = layer.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: lifetime.signal },
);
assert.equal(getEventListeners(lifetime.signal, 'abort').length, 1);
layer.closeViewer();
if (!rejectsOnAbort) answer.resolve('late-image');
assert.equal(await pending, false);
assert.equal(
getEventListeners(lifetime.signal, 'abort').length,
0,
'closing the lookup releases its listener on the application lifetime',
);
}
assert.equal(lifetime.signal.aborted, false);
});
}
test('an aborted older lookup leaves its replacement’s spinner alone', async (t) => {
const answers = [];
const provider = fakeProvider({
nearestImage: (_point, { signal } = {}) =>
new Promise((resolve, reject) => {
answers.push(resolve);
signal?.addEventListener('abort', () =>
reject(Object.assign(new Error('aborted'), { name: 'AbortError' })),
);
}),
});
const { layer } = await enabledLayer(t, [provider]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
const older = new AbortController();
const first = layer.openNearest(
{ lat: 1, lon: 2 },
{ frame: false, signal: older.signal },
);
await settle();
// A newer request replaces it, and its caller withdraws the old one too.
const second = layer.openNearest({ lat: 3, lon: 4 }, { frame: false });
older.abort();
assert.equal(await first, false);
assert.equal(
layer.getUIState().street.loading,
true,
'the newer lookup is still loading',
);
answers.at(-1)(null);
assert.equal(await second, false);
assert.equal(layer.getUIState().street.loading, false);
assert.match(
layer.getUIState().street.error,
/No street-level imagery/,
'the newer lookup reports its own result',
);
});
test('show_street_level opens a KartaView photo, and a plain link opens it at once since it is not billed', async (t) => {
const mapillary = fakeProvider();
let nearest = 'kv-1';
const kartaview = fakeProvider({
id: 'kartaview',
pickPrefix: 'kv:',
nearestImage: async () => nearest,
});
kartaview.name = 'KartaView';
kartaview.label = 'KARTAVIEW';
kartaview.defaultOn = false;
// As the real provider: 360° / flat and SINCE (the setFilter log stays in
// the fake's closure).
kartaview.filters = Object.freeze(['pano', 'since']);
const { layer, viewer } = await enabledLayer(t, [mapillary, kartaview]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
viewer.clock ??= { onTick: { addEventListener: () => () => {} } };
viewer.camera.moveEnd ??= { addEventListener() {} };
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
isEnabled: () => true,
getLayerParams: () => layer.getParams(),
setLayerParams: (_id, params) => {
layer.setParams(params);
return true;
},
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }],
};
const styleManager = {
mapStackController: { getActiveId: () => 'osm' },
setMapStack: async () => ({ ok: false, error: 'no map switch expected' }),
};
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
// The way a plain sl=kartaview link calls it: autoOpen false.
const result = await runner('show_street_level', {
latitude: 52.52,
longitude: 13.405,
provider: 'kartaview',
fly: false,
autoOpen: false,
});
assert.equal(result.ok, true, result.error);
assert.equal(result.armed, undefined, 'not armed: KartaView bills nothing');
assert.equal(result.provider, 'kartaview');
assert.equal(result.providerName, 'KartaView');
assert.deepEqual(
layer
.getUIState()
.providers.filter((p) => p.on)
.map((p) => p.id),
['kartaview'],
);
assert.deepEqual(kartaview.calls.open, ['kv-1']);
assert.equal(layer.providerStatus('kartaview').billed, false);
assert.equal(layer.requiredMapStack('kartaview'), null);
// With 360° only on, an empty search names the filter, not just "no imagery".
layer.closeViewer?.();
layer.setParams({ pano: 'pano' });
nearest = null;
const empty = await runner('show_street_level', {
latitude: 52.52,
longitude: 13.405,
provider: 'kartaview',
fly: false,
});
assert.deepEqual([empty.ok, empty.stage], [false, 'imagery']);
assert.equal(empty.filter?.pano, 'pano');
assert.match(empty.error, /with the 360° filter on$/);
layer.setParams({ pano: 'all' });
const plain = await runner('show_street_level', {
latitude: 52.52,
longitude: 13.405,
provider: 'kartaview',
fly: false,
});
assert.equal(plain.ok, false);
assert.equal(plain.filter, undefined, 'no filter, no hint');
assert.doesNotMatch(plain.error, /filter/);
});
test('show_street_level switches from KartaView to Panoramax and opens its photo at once, unbilled', async (t) => {
const mapillary = fakeProvider();
const kartaview = fakeProvider({ id: 'kartaview', pickPrefix: 'kv:' });
kartaview.name = 'KartaView';
kartaview.defaultOn = false;
const panoramax = fakeProvider({
id: 'panoramax',
pickPrefix: 'px:',
nearestImage: async () => 'px-1',
});
panoramax.name = 'Panoramax';
panoramax.label = 'PANORAMAX';
panoramax.defaultOn = false;
const { layer, viewer } = await enabledLayer(t, [
mapillary,
kartaview,
panoramax,
]);
const { fakeHost } =
await import('../../testSupport/googleStreetViewFakes.mjs');
layer.attachViewerHost(fakeHost());
viewer.clock ??= { onTick: { addEventListener: () => () => {} } };
viewer.camera.moveEnd ??= { addEventListener() {} };
layer.setParams({ mapillary: false, kartaview: true, panoramax: false });
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
isEnabled: () => true,
getLayerParams: () => layer.getParams(),
setLayerParams: (_id, params) => {
layer.setParams(params);
return true;
},
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled: true }],
};
const styleManager = {
mapStackController: { getActiveId: () => 'osm' },
setMapStack: async () => ({ ok: false, error: 'no map switch expected' }),
};
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
// The way a plain sl=panoramax link calls it: autoOpen false.
const result = await runner('show_street_level', {
latitude: 48.8874,
longitude: 2.3495,
provider: 'panoramax',
fly: false,
autoOpen: false,
});
assert.equal(result.ok, true, result.error);
assert.equal(result.armed, undefined, 'not armed: Panoramax bills nothing');
assert.equal(result.providerName, 'Panoramax');
assert.deepEqual(
layer
.getUIState()
.providers.filter((p) => p.on)
.map((p) => p.id),
['panoramax'],
'KartaView switched off',
);
assert.deepEqual(panoramax.calls.open, ['px-1']);
assert.deepEqual(kartaview.calls.open, [], 'nothing asked of KartaView');
assert.equal(layer.providerStatus('panoramax').billed, false);
assert.equal(layer.requiredMapStack('panoramax'), null);
});
test('the panel says why a lit Street View draws nothing off Google 3D', async (t) => {
const { presentStreetLevelPanel } =
await import('../../ui/streetLevelPresentation.js');
@@ -1128,6 +1914,88 @@ test('the panel says why a lit Street View draws nothing off Google 3D', async (
assert.match(view.meta, /Google Street View needs the Google 3D map/);
});
test('OPEN aimed at the sky after a link armed opens nothing: never the far link point', async (t) => {
const scenePick = await import('../../data/scenePick.js');
const lookups = [];
const provider = fakeProvider({
nearestImage: async (point) => {
lookups.push(point);
return null;
},
});
const { layer } = await enabledLayer(t, [provider], {
services: { scenePick },
setup: (viewer) => {
// Every pick misses the ground: the screen centre is sky.
Object.assign(viewer.scene, {
pickPositionSupported: true,
pickPosition: () => undefined,
});
viewer.globe = { pick: () => undefined };
viewer.scene.canvas.clientWidth = 1000;
viewer.scene.canvas.clientHeight = 600;
},
});
layer.armNearest({ lat: 48.8584, lon: 2.2945 }, { radiusM: 250 });
assert.equal(
await layer.openNearest(undefined, { aim: 'screen-centre' }),
false,
);
assert.deepEqual(lookups, [], 'no paid Paris lookup from another city’s sky');
const street = layer.getUIState().street;
assert.equal(street.nearestOutcome, 'sky');
assert.match(street.error, /Aim the middle of the view/);
});
test('a request of its own (armed: false) neither uses nor drops the link’s armed point', async (t) => {
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 centre = Cesium.Cartesian3.fromDegrees(-121.4937, 38.5799, 0);
const { layer } = await enabledLayer(t, [provider], {
services: { scenePick },
setup: (viewer) => {
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;
},
});
// About 120 m away: an OPEN press would take the link's spot.
layer.armNearest({ lat: 38.5808, lon: -121.494 }, { radiusM: 250 });
await layer.openNearest(undefined, { aim: 'screen-centre', armed: false });
const asked = lookups.at(-1);
assert.ok(Math.abs(asked.point.lat - 38.5799) < 1e-9, 'the screen centre');
assert.equal(asked.radiusM, null, 'its own radius');
assert.equal(layer.getUIState().street.nearestOutcome, 'empty');
assert.deepEqual(
layer.getUIState().street.armed?.point,
{ lat: 38.5808, lon: -121.494 },
'still armed for the OPEN press',
);
});
test('a failing source is not reported as missing imagery', async (t) => {
const provider = fakeProvider({
nearestImage: async () => {
throw new Error('Mapillary graph HTTP 500');
},
});
const { layer } = await enabledLayer(t, [provider]);
assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), false);
const street = layer.getUIState().street;
assert.equal(street.nearestOutcome, null);
assert.equal(street.error, 'Mapillary graph HTTP 500');
});
/** Mapillary (keyless unless `mapillary` says otherwise) plus KartaView. */
function keylessLayer(t, mapillary = { hasToken: () => false }) {
const layer = createStreetLevelLayer({
@@ -1183,3 +2051,83 @@ test('filter-only updates keep the lit source, even a chosen keyless Mapillary',
);
}
});
test('a replacing lookup keeps the viewer open while it looks, so EXPAND stays with the new photo', async (t) => {
const answers = ['img-1', 'img-2'];
const provider = posingProvider({
nearestImage: async () => answers.shift(),
});
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
assert.equal(await layer.openNearest({ lat: 1, lon: 2 }), true);
const seen = [];
const unsubscribe = layer.subscribe(() =>
seen.push(layer.getUIState().street.open),
);
t.after(unsubscribe);
assert.equal(
await layer.openNearest({ lat: 3, lon: 4 }, { replace: true }),
true,
);
assert.ok(seen.length > 0);
assert.ok(!seen.includes(false), 'never published closed on the way');
assert.deepEqual(provider.calls.open, ['img-1', 'img-2']);
});
test('a replacing lookup that finds nothing closes the old photo and keeps its own message', async (t) => {
const answers = ['img-1', null];
const provider = posingProvider({
nearestImage: async () => answers.shift(),
});
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
await layer.openNearest({ lat: 1, lon: 2 });
assert.equal(
await layer.openNearest({ lat: 3, lon: 4 }, { replace: true }),
false,
);
const street = layer.getUIState().street;
assert.equal(street.open, false, 'no photo of another place');
assert.equal(street.nearestOutcome, 'empty');
assert.match(street.error, /No street-level imagery within 50 m/);
});
test('a found photo the viewer cannot load keeps its error on the open viewer', async (t) => {
const provider = posingProvider({ nearestImage: async () => 'img-1' });
const create = provider.create;
provider.create = (context) => {
const instance = create(context);
instance.viewer.open = async () => {
throw new Error('Image could not be opened');
};
return instance;
};
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
assert.equal(
await layer.openNearest({ lat: 1, lon: 2 }, { replace: true }),
false,
);
const street = layer.getUIState().street;
assert.equal(street.open, true);
assert.equal(street.error, 'Image could not be opened');
});
test('proceed() is asked after the lookup: false loads nothing and clears the spinner', async (t) => {
const provider = posingProvider({ nearestImage: async () => 'img-1' });
const { layer } = await enabledLayer(t, [provider]);
layer.attachViewerHost({});
let asked = 0;
const opened = await layer.openNearest(
{ lat: 1, lon: 2 },
{ proceed: () => (asked++, false) },
);
assert.equal(opened, false);
assert.equal(asked, 1);
assert.deepEqual(provider.calls.open, [], 'the viewer never loads it');
assert.equal(layer.getUIState().street.loading, false);
assert.equal(
await layer.openNearest({ lat: 1, lon: 2 }, { proceed: () => true }),
true,
);
});
+8
View File
@@ -40,6 +40,14 @@ export const FILTER_DEFAULT = Object.freeze({ pano: 'all', sinceDays: 0 });
/** Nearest-image search radius in metres. */
export const NEAREST_RADIUS_M = 50;
/**
* Widest nearest-image radius a caller may ask for, in metres. A geocoded
* landmark sits at its centroid, often further than 50 m from the nearest
* street; providers that can search wider (Google) honour it, the rest keep
* their own cap.
*/
export const NEAREST_RADIUS_MAX_M = 250;
/** The Google 3D map stack: Street View may be shown only on it. */
export const GOOGLE_3D_MAP_STACK_ID = 'photoreal';
@@ -12,6 +12,7 @@ import {
GROUND_CLICK_HINT,
KEY_REJECTED_MESSAGE,
NEAREST_RADIUS_M,
NEAREST_RADIUS_MAX_M,
NEAREST_TIMEOUT_MS,
NO_ANSWER_MESSAGE,
PICK_PREFIX,
@@ -67,6 +68,7 @@ export function createGoogleProvider({
requiresMapStack: GOOGLE_3D_MAP_STACK_ID,
// Every panorama is billed to the Maps key.
defaultOn: false,
billedPerImage: true,
// All 360°, and no coverage for a date filter to thin.
filters: [],
marker: Object.freeze({ kind: 'view-cone', color: COLORS.coverage }),
@@ -136,8 +138,12 @@ export function createGoogleProvider({
* Nearest Google-collected outdoor panorama, or null. The imagery
* filters do not apply (see `filters`).
*/
async nearestImage({ lat, lon }, { signal } = {}) {
async nearestImage({ lat, lon }, { signal, radiusM } = {}) {
if (loader.authFailed()) throw new Error(KEY_REJECTED_MESSAGE);
// Wider than the shared default only on request, never past the cap.
const radius = Number.isFinite(radiusM)
? Math.min(NEAREST_RADIUS_MAX_M, Math.max(1, radiusM))
: NEAREST_RADIUS_M;
signal?.throwIfAborted();
// Set once the caller has its answer (aborted, timed out, refused):
// a lookup still waiting on Google's script then asks nothing.
@@ -150,7 +156,7 @@ export function createGoogleProvider({
try {
const { data } = await service.getPanorama({
location: { lat, lng: lon },
radius: NEAREST_RADIUS_M,
radius,
// Both: Google's own imagery, outdoors (sources intersect).
sources: [GOOGLE, OUTDOOR],
preference: lib.StreetViewPreference.NEAREST,
@@ -1,7 +1,7 @@
import { COLORS as SHARED_COLORS, PROVIDER_COLORS } from '../../policy.js';
import { msg } from '../../../../i18n/index.js';
export { NEAREST_RADIUS_M } from '../../policy.js';
export { NEAREST_RADIUS_M, NEAREST_RADIUS_MAX_M } from '../../policy.js';
export const GOOGLE_PROVIDER_ID = 'google';
export const GOOGLE_NAME = msg('Google Street View');
@@ -291,3 +291,13 @@ test('the panorama a lookup found is dated without a second lookup', async () =>
'no getPanorama({pano}) for a panorama the lookup already described',
);
});
test('a wider lookup radius is honoured up to the cap', async () => {
const { instance, fake } = setup();
await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: 250 });
assert.equal(fake.lookups.at(-1).radius, 250);
await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: 9999 });
assert.equal(fake.lookups.at(-1).radius, 250);
await instance.nearestImage({ lat: 38.58, lon: -121.49 }, { radiusM: NaN });
assert.equal(fake.lookups.at(-1).radius, 50);
});
@@ -5,6 +5,8 @@ import {
} from '../../lineCoverage.js';
import { createSequenceCones, thinImages } from '../../sequenceCones.js';
import { coverageZoomForHeight } from '../../tileMath.js';
import { passesImageryFilter } from '../../filter.js';
import { metresBetween } from '../../view.js';
import { decodeCoverageTile } from './decode.js';
import { createMapillaryViewer } from './viewer.js';
import {
@@ -195,6 +197,35 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
error: state.sequence.error,
}),
/**
* The nearest image that passes the imagery filter, or null: what
* show_street_level and sl= links open (clicks open a line, then a
* cone). The core aborts `signal` once a newer request overtakes it.
*/
async nearestImage({ lat, lon }, { signal } = {}) {
const images = await source.nearestImages({ lat, lon }, { signal });
// The API returns the images in the radius in no particular order.
const metres = (record) => {
const [lon2, lat2] = (record.computed_geometry || record.geometry)
?.coordinates || [NaN, NaN];
const d = metresBetween({ lon, lat }, { lon: lon2, lat: lat2 });
return Number.isFinite(d) ? d : Infinity;
};
const filter = context.getFilter();
const hit = [...images]
.sort((a, b) => metres(a) - metres(b))
.find((record) =>
passesImageryFilter(
{
isPano: record.is_pano === true,
capturedAt: Number(record.captured_at) || 0,
},
filter,
),
);
return hit ? String(hit.id) : null;
},
viewer:
photoViewer ||
createMapillaryViewer({ source, render: context.services?.render }),
@@ -16,6 +16,7 @@ export const MAPILLARY_SOURCE_METHODS = Object.freeze([
'getStatus',
'getTile',
'getSequenceImages',
'nearestImages',
]);
/** Stable id prefixes for picked primitives; every one starts with `mly:`. */
@@ -72,3 +73,18 @@ export function mapillaryImageUrl(imageId) {
/** Fields requested for a sequence's images. */
export const SEQUENCE_IMAGE_FIELDS =
'id,captured_at,compass_angle,geometry,is_pano';
/**
* Images asked for in a nearest-image search: the API cuts at the limit
* before any order, so this is its most; the distance sort and the imagery
* filter choose among the photos this one request returns.
*/
export const NEAREST_LIMIT = 100;
/**
* A nearest-image search not answered in full (body included) by then is
* given up: a link or posted view, and every view queued behind it, waits.
*/
export const NEAREST_TIMEOUT_MS = 12_000;
/** 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';
@@ -170,3 +170,41 @@ test('a status the server never answers falls back to the bundled token once the
assert.deepEqual(await status, { configured: false });
assert.equal(instance.coverageStats().keyRequired, true);
});
/** A graph image record at a point. */
const imageAt = (id, lon, lat, extra = {}) => ({
id,
is_pano: false,
captured_at: 10,
geometry: { type: 'Point', coordinates: [lon, lat] },
...extra,
});
test('nearestImage opens the closest image that passes the filter, not the first the API returned', async () => {
const requests = [];
const answer = [
imageAt('far', 2.0004, 1), // ~45 m east
imageAt('near-pano', 2.00002, 1, { is_pano: true }), // ~2 m, a panorama
imageAt('near', 2.00005, 1), // ~5 m east
imageAt('mid', 2, 1.0002), // ~22 m north
];
const source = fakeMapillarySource({
nearestImages: async (query, options) => {
requests.push({ query, options });
return answer;
},
});
const { signal } = new AbortController();
const flat = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'flat', sinceMs: null }),
);
assert.equal(await flat.nearestImage({ lat: 1, lon: 2 }, { signal }), 'near');
assert.equal(requests[0].options.signal, signal, 'the core can abort it');
assert.deepEqual(requests[0].query, { lat: 1, lon: 2 });
const any = createMapillaryProvider({ source }).create(fakeContext());
assert.equal(await any.nearestImage({ lat: 1, lon: 2 }), 'near-pano');
const tooOld = createMapillaryProvider({ source }).create(
fakeContext({ pano: 'all', sinceMs: 100 }),
);
assert.equal(await tooOld.nearestImage({ lat: 1, lon: 2 }), null);
});
@@ -0,0 +1,56 @@
import '../../../../testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { createStreetLevelLayer } from '../../index.js';
import { fakeStreetLevelProvider, fakeCesiumViewer } from '../../../../testSupport/streetLevelFakes.mjs';
import { createMapillaryViewer } from './viewer.js';
import { showStreetLevel } from '../../../../voice/gevActions.js';
// Library boundary fixture: moveTo changes the image without stopping PLAY,
// matching installed MapillaryJS Viewer.moveTo / Navigator.moveTo$.
const fakeLibrary = `export class Viewer {
constructor() { globalThis.__replacePlayViewer = this; this.playing = false; this.id = null; this.stops = 0; }
on() {} remove() {} resize() {} setRenderMode() {}
getComponent() { return { play: () => {this.playing = true;}, stop: () => {this.playing = false; this.stops++;} }; }
async moveTo(id) { this.id = id; return { id, sequenceId: 'fixture-sequence', cameraType: 'perspective' }; }
async getPosition() { return { lng: 2, lat: 48 }; }
async getPointOfView() { return { bearing: 0 }; }
}
export class GraphDataProvider {}
export const RenderMode = { Fill: 1, Letterbox: 0 };`;
const hooks = registerHooks({
resolve(specifier, context, next) {
if (specifier === 'mapillary-js') return { url: 'data:text/javascript,' + encodeURIComponent(fakeLibrary), shortCircuit: true };
if (specifier.endsWith('mapillary.css')) return { url: 'data:text/javascript,export default {}', shortCircuit: true };
return next(specifier, context);
},
});
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
test('a photo the action asks for stops sequence PLAY', async t => {
const provider = fakeStreetLevelProvider({ nearestImage: async () => 'B' });
const create = provider.create;
provider.create = context => ({ ...create(context), viewer: createMapillaryViewer({ source: { token: '' } }) });
const viewer = fakeCesiumViewer(); viewer.canvas = viewer.scene.canvas;
const layer = createStreetLevelLayer({ providers: [provider] });
layer.init(viewer); layer.enable(viewer);
layer.attachViewerHost(document.body.appendChild(document.createElement('div')));
t.after(() => { layer.destroy(); hooks.deregister(); delete globalThis.__replacePlayViewer; document.body.replaceChildren(); });
await Promise.resolve();
await layer.openImage('mapillary', 'A', { frame: false });
const libraryViewer = globalThis.__replacePlayViewer;
libraryViewer.getComponent('sequence').play();
assert.equal(libraryViewer.playing, true);
const manager = {
layers: new Map([['street-level', { module: layer }]]), isEnabled: () => true,
getLayerParams: () => layer.getParams(), setLayerParams: (_id, params) => layer.setParams(params),
};
const result = await showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, {
viewer, dataManager: manager, styleManager: { _navigationGeneration: 1 },
runGevAction: async () => ({ ok: true, latitude: 48, longitude: 2, label: 'fixture destination' }),
});
assert.equal(result.ok, true);
assert.equal(libraryViewer.id, 'B');
assert.equal(layer.getUIState().street.open, true);
assert.equal(libraryViewer.playing, false, 'requested image must stay selected rather than continue sequence PLAY');
});
@@ -1,9 +1,17 @@
import { msg } from '../../../../i18n/marker.js';
import { NEAREST_RADIUS_M } from '../../policy.js';
import {
MAPILLARY_GRAPH_HOST,
NEAREST_IMAGE_FIELDS,
NEAREST_LIMIT,
NEAREST_TIMEOUT_MS,
SEQUENCE_IMAGE_FIELDS,
SEQUENCE_IMAGES_LIMIT,
} from './policy.js';
/** English data: the Street Level panel translates it. */
const NO_ANSWER = msg('Mapillary did not answer in time — try again');
/** HTTP error flagged `keyRequired`, `keyRejected` or `retryAfterSec` (429). */
export class MapillarySourceError extends Error {
constructor(message, { status = 0, payload = null, retryAfterSec } = {}) {
@@ -66,7 +74,47 @@ export function createMapillarySource({
...endpoints,
};
async function graph(path, params = {}, { signal } = {}) {
async function graph(path, params = {}, { signal, timeoutMs = 0 } = {}) {
if (!timeoutMs) return graphOnce(path, params, { signal });
// The whole answer, body included, within `timeoutMs`; the caller's
// abort still wins.
if (signal?.aborted) throw signal.reason;
const controller = new AbortController();
const onAbort = () => controller.abort(signal.reason);
signal?.addEventListener('abort', onAbort, { once: true });
const timer = setTimeout(
() =>
controller.abort(
new MapillarySourceError(NO_ANSWER, {
status: 504,
}),
),
timeoutMs,
);
try {
// A body that never ends is given up too, whatever the fetch does.
const gaveUp = new Promise((_, reject) =>
controller.signal.addEventListener(
'abort',
() => reject(controller.signal.reason),
{ once: true },
),
);
return await Promise.race([
graphOnce(path, params, { signal: controller.signal }),
gaveUp,
]);
} catch (error) {
if (signal?.aborted) throw signal.reason;
if (controller.signal.aborted) throw controller.signal.reason;
throw error;
} finally {
clearTimeout(timer);
signal?.removeEventListener('abort', onAbort);
}
}
async function graphOnce(path, params, { signal }) {
if (graphBase) {
const search = new URLSearchParams(params);
const response = await fetchImpl(`${graphBase}${path}?${search}`, {
@@ -147,5 +195,21 @@ export function createMapillarySource({
);
return Array.isArray(payload?.data) ? payload.data : [];
},
/** Images within `NEAREST_RADIUS_M` of a point, in no particular order. */
async nearestImages({ lat, lon }, { signal } = {}) {
const payload = await graph(
'images',
{
lat: String(lat),
lng: String(lon),
radius: String(NEAREST_RADIUS_M),
limit: String(NEAREST_LIMIT),
fields: NEAREST_IMAGE_FIELDS,
},
{ signal, timeoutMs: NEAREST_TIMEOUT_MS },
);
return Array.isArray(payload?.data) ? payload.data : [];
},
};
}
@@ -126,6 +126,25 @@ test('a rejected key and a rate limit are told apart from other tile errors', as
);
});
test('nearestImages asks the graph API around a point, within 50 m, for at most 100 images', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const images = await source.nearestImages({ lat: 38.5, lon: -121.5 });
assert.equal(images.length, 1);
const url = new URL(calls[0].url);
assert.equal(url.pathname, '/images');
assert.equal(url.searchParams.get('lat'), '38.5');
assert.equal(url.searchParams.get('lng'), '-121.5');
assert.equal(url.searchParams.get('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(url.searchParams.get('limit'), '100', "the API's most");
assert.match(url.searchParams.get('fields'), /computed_geometry/);
assert.match(url.searchParams.get('fields'), /is_pano/);
});
test('with a Graph endpoint, sequence lookups go there without a token', async () => {
const { calls, fetchImpl } = fakeFetch(() =>
jsonResponse({ data: [{ id: '1', geometry: { coordinates: [1, 2] } }] }),
@@ -157,3 +176,63 @@ test('Graph endpoint errors keep their status', async () => {
(error) => error.status === 503 && error.keyRequired === true,
);
});
test('a nearest-image search that never answers is given up, body included', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const { NEAREST_TIMEOUT_MS } = await import('./policy.js');
// Headers arrive; the body never ends, and the fetch ignores its signal.
const { fetchImpl } = fakeFetch(() => ({
ok: true,
status: 200,
json: () => new Promise(() => {}),
}));
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const pending = source.nearestImages({ lat: 1, lon: 2 });
const outcome = pending.then(
() => 'answered',
(error) => error,
);
await Promise.resolve();
t.mock.timers.tick(NEAREST_TIMEOUT_MS);
const error = await outcome;
assert.ok(error instanceof MapillarySourceError, String(error));
assert.equal(error.status, 504);
assert.equal(error.keyRequired, false, 'not a key problem');
// English data, shown in the interface language like the other sources'.
const { setLocale } = await import('../../../../i18n/index.js');
const { translateStreetLevelError } =
await import('../../../../ui/streetLevelPresentation.js');
assert.equal(
translateStreetLevelError(error.message),
'Mapillary did not answer in time — try again',
);
for (const [locale, expected] of [
['de', /nicht rechtzeitig/],
['ja', /時間内に応答/],
]) {
setLocale(locale);
try {
assert.match(translateStreetLevelError(error.message), expected, locale);
} finally {
setLocale('en');
}
}
});
test('the caller’s abort still ends a nearest-image search before its deadline', async () => {
const { fetchImpl } = fakeFetch(
(_url, init) =>
new Promise((_, reject) =>
init.signal.addEventListener('abort', () => reject(init.signal.reason)),
),
);
const source = createMapillarySource({ token: 'MLY|1|abc', fetchImpl });
const controller = new AbortController();
const pending = source.nearestImages(
{ lat: 1, lon: 2 },
{ signal: controller.signal },
);
const reason = new Error('the turn moved on');
controller.abort(reason);
await assert.rejects(pending, (error) => error === reason);
});
@@ -174,6 +174,15 @@ export function createMapillaryViewer({ source, render } = {}) {
current = null;
}
/** A playing sequence keeps stepping through (and downloading) images. */
function stopPlayback() {
try {
viewer?.getComponent('sequence')?.stop();
} catch {
/* sequence component not available */
}
}
return {
async mount(host) {
if (!host) throw new Error('Street Level viewer has no host element');
@@ -195,15 +204,12 @@ export function createMapillaryViewer({ source, render } = {}) {
close() {
pendingOpen = null;
current = null;
// A playing sequence would keep stepping through (and downloading)
// images in the hidden viewer.
try {
viewer?.getComponent('sequence')?.stop();
} catch {
/* sequence component not available */
}
// Hidden, the viewer must not keep playing.
stopPlayback();
},
stopPlayback,
unmount() {
pendingOpen = null;
// A build still waiting for the library gives up when it arrives.
+2 -1
View File
@@ -20,6 +20,7 @@ export function filtersOf(provider) {
* @property {boolean} [groundClick] No coverage: a ground click at street zoom opens its nearest image.
* @property {string} [requiresMapStack] Usable only on this map stack.
* @property {boolean} [defaultOn] Default true; the first that defaults on is lit.
* @property {boolean} [billedPerImage] Every image load costs the key's owner: a link never opens one without a click.
* @property {() => boolean} [keyReady] False when its key is missing now; the
* first keyless provider is lit instead of a default that cannot draw.
* @property {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
@@ -50,7 +51,7 @@ export function filtersOf(provider) {
* @property {(sequenceId: string) => Promise<void>} [selectSequence]
* @property {() => void} [clearSequence]
* @property {() => {selectedId: string|null, images: number, loading: boolean, error?: string|null}} [sequenceStats]
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal}) => Promise<string|null>} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError).
* @property {(point: {lat: number, lon: number}, options?: {signal?: AbortSignal, radiusM?: number}) => Promise<string|null>} [nearestImage] For ground clicks and `openNearest`; an aborted lookup gives up (it may reject with an AbortError). `radiusM` asks for a wider search than NEAREST_RADIUS_M; a provider that cannot search wider ignores it.
* @property {ViewerAdapter} viewer
*
* @typedef {object} ViewerAdapter
+4
View File
@@ -19,6 +19,8 @@ export function freshStreet() {
capturedAt: null,
/** 'day', or 'month' for providers that date images by month (Google). */
capturedAtPrecision: 'day',
/** Why the last nearest lookup opened nothing: 'empty', 'sky' or null. */
nearestOutcome: null,
/** A place name for the caption (Google's address), or null. */
title: null,
/** Whether the open viewer has FIT/FILL render modes. */
@@ -29,6 +31,8 @@ export function freshStreet() {
externalUrl: null,
loading: false,
error: null,
// A lookup a link prepared but left to a click: {point, providerIds, radiusM}.
armed: null,
};
}
+55 -5
View File
@@ -73,6 +73,19 @@ export function createViewerHost({ state, parts }) {
active.unsubscribe();
active.adapter.unmount();
active = null;
// The photo being replaced belonged to that source: none of its details
// (id, place, author, link) describe the one loading now.
const { open, loading, error, providerId, providerName, providerLabel } =
state.street;
Object.assign(state.street, freshStreet(), {
open,
loading,
error,
providerId,
providerName,
providerLabel,
});
parts.marker.clear();
}
const adapter = entry.instance.viewer;
const promise = (async () => {
@@ -99,12 +112,17 @@ export function createViewerHost({ state, parts }) {
}
/**
* Open an image: true once its first pose is in, false if it failed or was
* overtaken. `frame: false` leaves the camera where it is.
* Open an image: true once its first pose is in, false if it failed, was
* overtaken, or `signal` fired first. An abort while the viewer mounts or
* loads closes the photo: nothing opens for a request that was withdrawn.
*/
async function open(providerId, imageId, { frame = true } = {}) {
async function open(
providerId,
imageId,
{ frame = true, signal = null } = {},
) {
const entry = state.providers.get(providerId);
if (!entry || !imageId) return false;
if (!entry || !imageId || signal?.aborted) return false;
if (!state.street.host) {
// A nearest-image lookup may have set it; nothing is loading now.
state.street.loading = false;
@@ -115,6 +133,10 @@ export function createViewerHost({ state, parts }) {
}
const seq = ++openSeq;
const current = () => seq === openSeq;
const onAbort = () => {
if (current()) close();
};
signal?.addEventListener('abort', onAbort, { once: true });
// Claimed now, honoured after loading only if nothing newer took the camera.
const ticket = frame ? parts.framing.begin() : null;
Object.assign(state.street, {
@@ -138,6 +160,7 @@ export function createViewerHost({ state, parts }) {
if (current())
state.street.error = error?.message || msg('Image could not be opened');
} finally {
signal?.removeEventListener('abort', onAbort);
if (current()) state.street.loading = false;
notify();
}
@@ -165,6 +188,11 @@ export function createViewerHost({ state, parts }) {
active?.adapter.resize();
}
/** Stop a sequence playing in the open photo (adapters that play). */
function stopPlayback() {
active?.adapter.stopPlayback?.();
}
function setRenderMode(mode) {
state.street.renderMode = mode === 'fill' ? 'fill' : 'letterbox';
active?.adapter.setRenderMode?.(state.street.renderMode);
@@ -187,5 +215,27 @@ export function createViewerHost({ state, parts }) {
active = null;
}
return { attach, open, close, resize, setRenderMode, unmount };
/**
* Destroy `providerId`'s viewer and leave the panel's state alone: what a
* request leaves behind once it replaced or closed that source's photo.
*/
function release(providerId) {
if (active?.id !== providerId && mounting?.id !== providerId) return;
const pending = mounting;
mounting = null;
active?.unsubscribe();
(active?.adapter ?? pending?.adapter)?.unmount();
active = null;
}
return {
attach,
open,
close,
resize,
setRenderMode,
stopPlayback,
unmount,
release,
};
}
@@ -557,6 +557,51 @@ test('an adapter that can frame but says this image has no frame offers no FIT /
assert.equal(state.street.renderModes, true, 'a flat image can be framed');
});
test('a caller that withdraws while the viewer is still mounting gets no photo', async () => {
const gate = deferred();
const adapter = fakeAdapter({ gate });
const { state, host } = harness(adapter);
const controller = new AbortController();
const pending = host.open('mapillary', 'a', { signal: controller.signal });
assert.equal(state.street.open, true, 'opening');
controller.abort(); // the library is still loading
gate.resolve();
assert.equal(await pending, false);
assert.deepEqual(adapter.calls.open, [], 'the image was never opened');
assert.equal(state.street.open, false, 'the photo is closed');
assert.equal(state.street.loading, false, 'no stuck spinner');
assert.equal(state.street.imageId, null);
});
test('a caller that withdraws while the photo is loading gets it closed', async () => {
const openGate = deferred();
const adapter = fakeAdapter({ openGate });
const { state, host } = harness(adapter);
const controller = new AbortController();
const pending = host.open('mapillary', 'a', { signal: controller.signal });
await settle();
assert.deepEqual(adapter.calls.open, ['a'], 'loading');
controller.abort();
openGate.resolve();
assert.equal(await pending, false);
assert.equal(state.street.open, false, 'the withdrawn photo is closed');
assert.equal(state.street.imageId, null);
// An already-withdrawn request asks nothing at all.
const done = new AbortController();
done.abort();
assert.equal(
await host.open('mapillary', 'b', { signal: done.signal }),
false,
);
assert.deepEqual(adapter.calls.open, ['a']);
// A signal that never fires changes nothing.
assert.equal(
await host.open('mapillary', 'c', { signal: new AbortController().signal }),
true,
);
assert.equal(state.street.imageId, 'c');
});
test('a pose’s licence reaches the caption state, and a closed viewer forgets it', async () => {
const { state, host, google } = twoProviders();
await host.open('google', 'g-1');
+6
View File
@@ -380,6 +380,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
});
return {
label,
latitude: lat,
longitude: lng,
navigationMode: 'natural-region-swath',
rangeM: swath.rangeM,
};
@@ -415,6 +417,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
if (flight) {
return {
label,
latitude: lat,
longitude: lng,
navigationMode,
rangeM: null,
};
@@ -468,6 +472,8 @@ export async function searchAndFlyTo(viewer, query, options = {}) {
});
return {
label,
latitude: lat,
longitude: lng,
navigationMode: requestedRange
? 'explicit-range'
: options.forceClose
+32
View File
@@ -778,3 +778,35 @@ test('a precise search without an outline frames against the resolved ground, no
const eye = target.height + flight.offset.range * Math.sin(-flight.offset.pitch);
assert.ok(eye > 171 + 100, `eye ${eye} must clear the 171 m surface`);
});
test('searchAndFlyTo reports the place it flew to, whichever framing it chose', async () => {
// A city frames its viewport.
const city = await runSearch(stubViewer(), {});
assert.equal(city.latitude, 30.2672);
assert.equal(city.longitude, -97.7431);
// A precise place flies to a landmark.
const place = await runSearch(stubViewer(), {}, {
query: 'Texas State Capitol',
result: {
formatted_address: '1100 Congress Ave, Austin, TX',
types: ['establishment', 'point_of_interest'],
geometry: { location: { lat: 30.2747, lng: -97.7403 } },
},
});
assert.equal(place.latitude, 30.2747);
assert.equal(place.longitude, -97.7403);
// A natural region frames a swath.
const region = await runSearch(stubViewer(), {}, {
query: 'Rocky Mountains',
result: {
formatted_address: 'Rocky Mountains',
types: ['natural_feature', 'establishment'],
geometry: {
location: { lat: 43.7, lng: -110.7 },
viewport: { southwest: { lat: 35, lng: -118 }, northeast: { lat: 52, lng: -104 } },
},
},
});
assert.equal(region.latitude, 43.7);
assert.equal(region.longitude, -110.7);
});
+34
View File
@@ -182,3 +182,37 @@ export function reassertNavigationHandoff({
release?.();
return true;
}
/** Gestures on the globe that take the camera from a pending request. */
const CAMERA_GESTURES = ['pointerdown', 'wheel'];
/**
* Whether the camera is still a request's: a navigation the shell stamps (a
* search, the assistant, a link) or a press or wheel on the globe hands it to
* someone else. `claim()` takes the navigation stamped so far as the
* request's (its own flight); a gesture is never forgiven. `stop()` removes
* the gesture listeners.
*/
export function watchCameraOwner(viewer, navigation) {
let generation = navigation?._navigationGeneration;
const canvas = viewer?.canvas ?? viewer?.scene?.canvas ?? null;
let taken = false;
const onGesture = () => {
taken = true;
};
for (const type of CAMERA_GESTURES)
canvas?.addEventListener?.(type, onGesture, { passive: true });
return {
claim: () => {
generation = navigation?._navigationGeneration;
},
taken: () =>
taken ||
(Number.isFinite(generation) &&
navigation._navigationGeneration !== generation),
stop: () => {
for (const type of CAMERA_GESTURES)
canvas?.removeEventListener?.(type, onGesture);
},
};
}
+6 -3
View File
@@ -25,14 +25,15 @@ const css = readStylesheet(new URL('../style.css', import.meta.url));
function realtimeTools() { return GEV_REALTIME_TOOLS; }
test('Realtime schema exposes the authoritative 34-tool inventory', () => {
test('Realtime schema exposes the authoritative 35-tool inventory', () => {
const tools = realtimeTools();
assert.equal(tools.length, 34);
assert.equal(tools.length, 35);
const names = tools.map((tool) => tool.name);
assert.equal(new Set(names).size, 34, 'tool names are unique');
assert.equal(new Set(names).size, 35, 'tool names are unique');
assert.ok(names.includes('resolve_area'));
assert.ok(names.includes('find_imagery'));
assert.ok(names.includes('osm_query'));
assert.ok(names.includes('show_street_level'));
assert.ok(names.includes('set_context_mode'));
assert.ok(names.includes('control_cockpit'));
assert.ok(names.includes('set_voice_inactivity_timeout'));
@@ -206,6 +207,8 @@ test('no unchanged Realtime tool definition drifts silently', () => {
'resolve_area',
'find_imagery',
'osm_query',
// Street-level photos: a new composite tool, not an edit to a shipped one.
'show_street_level',
]);
const unchanged = realtimeTools()
.filter((tool) => !TOUCHED.has(tool.name))
+186
View File
@@ -0,0 +1,186 @@
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStreetLevelLayer } from './layers/streetLevel/index.js';
import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetLevelFakes.mjs';
import { showStreetLevel } from './voice/gevActions.js';
import { installViews } from './app/embed.js';
import { ShareRestoration } from './ui/shareRestoration.js';
import { StreetLevelControls } from './ui/streetLevelControls.js';
import { createApp, nextTick } from 'vue';
import { createPinia } from 'pinia';
import StreetLevelPanel from './ui/views/panels/StreetLevelPanel.vue';
import { useStreetLevelStore } from './ui/stores/streetLevel.js';
// Street-level request lifecycle across the action, the layer, links and the
// panel. Offline: providers and camera flights are fixtures.
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
function deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
}
async function harness(t, { nearestImage = async () => 'A', failImage = null, extraProviders = [] } = {}) {
document.body.className = '';
document.body.replaceChildren();
const provider = fakeStreetLevelProvider({ nearestImage });
const originalCreate = provider.create;
const opened = [];
provider.create = context => {
const instance = originalCreate(context);
let pose;
instance.viewer.onPose = listener => { pose = listener; return () => { pose = null; }; };
instance.viewer.open = async id => {
opened.push(id);
if (id === failImage) throw new Error('Fixture image load failed');
pose?.({ imageId: id });
};
return instance;
};
const viewer = fakeCesiumViewer();
viewer.canvas = viewer.scene.canvas;
viewer.canvas.setPointerCapture = () => {};
const layer = createStreetLevelLayer({ providers: [provider, ...extraProviders] });
layer.init(viewer);
layer.enable(viewer);
const host = document.body.appendChild(document.createElement('div'));
layer.attachViewerHost(host);
t.after(() => { layer.destroy(); document.body.className = ''; document.body.replaceChildren(); });
await settle();
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
getAll: () => [{ id: 'street-level', enabled: true }],
isEnabled: () => true,
getLayerParams: () => ({ mapillary: true }),
setLayerParams: (_id, params) => layer.setParams(params),
};
const styleManager = { _navigationGeneration: 1 };
const flights = [];
const context = {
viewer, dataManager, styleManager,
runOptions: { signal: new AbortController().signal },
runGevAction: async (name, args) => {
assert.equal(name, 'fly_to_location');
flights.push(args);
return { ok: true, latitude: 48, longitude: 2, label: 'fixture point' };
},
};
const run = (name, args) =>
name === 'show_street_level' ? showStreetLevel(args, context) : Promise.resolve({ ok: true });
return { layer, viewer, dataManager, context, run, opened, flights };
}
test('a landed link does not reclaim a camera the user moved while its layers load', async t => {
const h = await harness(t);
const location = { search: '', hash: '' };
const shell = h.context.styleManager;
shell.initialRestorePromise = Promise.resolve();
const remove = installViews({ ...h, shell, location, windowRef: window });
t.after(remove);
await settle();
location.hash = '#sl=mapillary,48,2';
const layers = deferred();
const landed = deferred();
const restoration = Object.assign(Object.create(ShareRestoration.prototype), {
_shareRestoreGeneration: 1, _disposed: false,
shareLinkManager: {
applyState: async () => { landed.resolve(); return { camera: 'applied' }; },
completeInitialRestore() {},
},
_sharedVessel: () => null,
});
const restoring = restoration._runShareRestore({}, {
applyCamera: true, navigationToken: 1, layersPromise: layers.promise,
settle: detail => window.dispatchEvent(new CustomEvent('gev:share-navigation-restore-settled', { detail })),
});
await landed.promise;
shell._navigationGeneration++;
layers.resolve([]);
await restoring;
await settle(); await settle();
assert.equal(h.flights.length, 0, 'an old landed link must not fly after later navigation');
});
test('a replacement from another source keeps EXPAND', async t => {
const h = await harness(t, {
extraProviders: [fakeStreetLevelProvider({ id: 'panoramax', pickPrefix: 'pm:', nearestImage: async () => 'B' })],
});
const root = document.body.appendChild(document.createElement('div'));
root.innerHTML = '<button id="sl-status"></button><div id="sl-viewer-wrap"><div id="sl-viewer"></div></div><button id="sl-viewer-expand"></button>';
const wrap = root.querySelector('#sl-viewer-wrap');
let fullscreen = null;
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen });
const oldExit = document.exitFullscreen;
// As a browser does: the change is announced after the switch.
const announce = () => setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0);
document.exitFullscreen = async () => { fullscreen = null; announce(); };
wrap.requestFullscreen = async () => { fullscreen = wrap; announce(); };
const store = { attach: () => 1, detach() {}, publish() {}, setExpandAvailable() {}, setExpanded() {}, setEmbedOverlay() {} };
const controls = new StreetLevelControls({ root, layer: h.layer, actions: {}, store });
const off = h.layer.subscribe(state => controls.render(state));
t.after(() => { off(); controls.destroy(); delete document.fullscreenElement; document.exitFullscreen = oldExit; });
assert.equal(await h.layer.openImage('mapillary', 'previous', { frame: false }), true);
await controls._toggleExpanded();
assert.equal(controls.isViewerExpanded(), true);
const result = await showStreetLevel({ provider: 'panoramax', latitude: 48, longitude: 2 }, h.context);
assert.equal(result.ok, true);
assert.equal(h.layer.getUIState().street.providerId, 'panoramax');
assert.equal(controls.isViewerExpanded(), true, 'replacement must preserve the expanded viewing surface');
});
for (const outcome of ['empty', 'error', 'image']) {
test(`a replacement retired by a navigation keeps the open photo (${outcome})`, async t => {
const lookup = deferred();
const entered = deferred();
const h = await harness(t, { nearestImage: async () => {
entered.resolve(); await lookup.promise;
if (outcome === 'error') throw new Error('Fixture lookup failure');
return outcome === 'image' ? 'B' : null;
} });
await h.layer.openImage('mapillary', 'A', { frame: false });
const action = showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, h.context);
await entered.promise;
h.context.styleManager._navigationGeneration++;
lookup.resolve();
const result = await action;
assert.ok(result.cancelled || result.superseded);
assert.deepEqual(h.opened, ['A']);
assert.equal(h.layer.getUIState().street.open, true, 'retired lookup must preserve A');
assert.equal(h.layer.getUIState().street.error, null, 'retired lookup must not publish an error');
});
}
test('a failed replacement shows its error inside the expanded viewer', async t => {
const h = await harness(t, { nearestImage: async () => 'B', failImage: 'B' });
const root = document.body.appendChild(document.createElement('div'));
const pinia = createPinia();
const app = createApp(StreetLevelPanel); app.use(pinia); app.mount(root);
const store = useStreetLevelStore(pinia);
const wrap = root.querySelector('#sl-viewer-wrap');
let fullscreen = null;
Object.defineProperty(document, 'fullscreenElement', { configurable: true, get: () => fullscreen });
const oldExit = document.exitFullscreen;
// As a browser does: the change is announced after the switch.
const announce = () => setTimeout(() => document.dispatchEvent(new Event('fullscreenchange')), 0);
document.exitFullscreen = async () => { fullscreen = null; announce(); };
wrap.requestFullscreen = async () => { fullscreen = wrap; announce(); };
const controls = new StreetLevelControls({ root, layer: h.layer, actions: {}, store });
const off = h.layer.subscribe(state => controls.render(state));
t.after(() => { off(); controls.destroy(); app.unmount(); delete document.fullscreenElement; document.exitFullscreen = oldExit; });
await h.layer.openImage('mapillary', 'A', { frame: false });
await nextTick();
await controls._toggleExpanded();
await settle();
const result = await showStreetLevel({ provider: 'mapillary', latitude: 48, longitude: 2 }, h.context);
await nextTick();
assert.equal(result.ok, false);
assert.equal(controls.isViewerExpanded(), true);
assert.equal(document.fullscreenElement, wrap);
assert.equal(h.layer.getUIState().street.imageId, 'A');
assert.match(h.layer.getUIState().street.error, /Fixture image load failed/);
assert.equal(wrap.contains(root.querySelector('#sl-error')), false);
const error = wrap.querySelector('#sl-viewer-error');
assert.match(error.textContent, /Fixture image load failed/);
assert.equal(error.hidden, false, 'error must be visible inside fullscreen');
});
+23
View File
@@ -115,3 +115,26 @@ test('OPEN STREET VIEW keeps its width beside a long coverage line', () => {
);
assert.match(panelCss, /\.sl-meta-row \.sl-meta \{[^}]*min-width: 0;/);
});
test('embed mode shows an open photo over the globe, filling the overlay', () => {
const overlay = css.match(
/body\.ui-embed \.sl-viewer-wrap-embed \{([^}]*)\}/,
);
assert.ok(overlay, 'embed styles the viewer it moves over the globe');
for (const rule of ['position: fixed', 'inset: 12px', 'z-index: 1000'])
assert.ok(overlay[1].includes(rule), rule);
// Cockpit, recording and scene playback own the screen: the photo hides.
for (const mode of ['cockpit-mode', 'recording-mode', 'scene-playback-mode'])
assert.match(
css,
new RegExp(
`body\\.ui-embed\\.${mode} \\.sl-viewer-wrap-embed[^{]*\\{[^}]*display: none`,
),
mode,
);
// The photo takes the overlay's height, as it does full screen.
assert.match(
css,
/body\.ui-embed \.sl-viewer-wrap-embed \.sl-viewer \{[^}]*flex: 1 1 auto/,
);
});
+281
View File
@@ -0,0 +1,281 @@
import './testSupport/registerDom.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { createStreetLevelLayer } from './layers/streetLevel/index.js';
import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetLevelFakes.mjs';
import { showStreetLevel } from './voice/gevActions.js';
import { installViews } from './app/embed.js';
import { groundedLookAt } from './view/index.js';
import { composeCatalog, coreTools } from './tools/index.js';
// Replacing a street-level photo, across sources and against the user's own
// moves; posted views waiting on terrain, and their look-at near a pole.
// Offline: providers, lookups, terrain and flights are fixtures.
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
function deferred() {
let resolve;
const promise = new Promise(done => { resolve = done; });
return { promise, resolve };
}
function provider(id, { nearest = async () => 'B', load = async () => {}, billed = false } = {}) {
const def = fakeStreetLevelProvider({ id, pickPrefix: id + ':', nearestImage: nearest });
def.name = id;
def.label = id.toUpperCase();
def.billedPerImage = billed;
const create = def.create;
def.create = context => {
const instance = create(context);
let pose;
instance.viewer.onPose = listener => { pose = listener; return () => { pose = null; }; };
instance.viewer.open = async imageId => {
def.calls.open.push(imageId);
await load(imageId);
pose?.({ imageId, position: { lat: 48, lon: 2 }, creator: 'fixture photographer',
externalUrl: 'https://www.mapillary.com/app/?pKey=' + imageId });
};
return instance;
};
return def;
}
async function harness(t, defs) {
document.body.className = '';
document.body.replaceChildren();
const viewer = fakeCesiumViewer();
viewer.canvas = viewer.scene.canvas;
viewer.canvas.setPointerCapture = () => {};
const layer = createStreetLevelLayer({ providers: defs });
layer.init(viewer);
layer.enable(viewer);
layer.attachViewerHost(document.body.appendChild(document.createElement('div')));
t.after(() => { layer.destroy(); document.body.className = ''; document.body.replaceChildren(); });
await settle();
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
getAll: () => [{ id: 'street-level', enabled: true }],
isEnabled: () => true,
getLayerParams: () => ({ mapillary: true }),
setLayerParams: (_id, params) => layer.setParams(params),
};
const shell = { _navigationGeneration: 1, initialRestorePromise: Promise.resolve(), setCleanView() {} };
const flights = [];
const context = { viewer, dataManager, styleManager: shell,
runOptions: { signal: new AbortController().signal },
runGevAction: async (name, args) => {
assert.equal(name, 'fly_to_location');
flights.push(args);
return { ok: true, latitude: args.latitude, longitude: args.longitude, label: 'fixture point' };
},
};
const run = (name, args) => name === 'show_street_level' ? showStreetLevel(args, context) : Promise.resolve({ ok: true });
return { layer, viewer, dataManager, shell, context, run, flights };
}
async function embed(t, h, terrain) {
const replies = [];
const peer = { postMessage: message => replies.push(message) };
const windowRef = new EventTarget();
windowRef.parent = peer;
windowRef.document = document;
const remove = installViews({ ...h, terrain, windowRef, location: { search: '?embed=1', hash: '' } });
t.after(remove);
await settle();
const post = (id, view) => {
const event = new Event('message');
Object.assign(event, { source: peer, origin: 'https://host.example', data: { type: 'gev:view', id, view } });
windowRef.dispatchEvent(event);
};
return { post, replies };
}
const args = provider => ({ provider, latitude: 48, longitude: 2, fly: false });
const view = pitch => ({ camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: pitch }, street_level: { provider: 'mapillary' } });
for (const outcome of ['empty', 'error']) {
test('a replacement from another source that opens nothing keeps its explanation (' + outcome + ')', async t => {
const a = provider('mapillary');
const b = provider('panoramax', { nearest: async () => {
if (outcome === 'error') throw new Error('Fixture lookup failure');
return null;
} });
const h = await harness(t, [a, b]);
assert.equal(await h.layer.openImage('mapillary', 'A', { frame: false }), true);
const result = await showStreetLevel(args('panoramax'), h.context);
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ result, error: street.error, nearestOutcome: street.nearestOutcome }));
assert.equal(result.ok, false);
assert.equal(street.open, false);
assert.ok(street.error, 'replacement explanation must survive release');
if (outcome === 'empty') assert.equal(street.nearestOutcome, 'empty');
});
}
test('an arming link from another source keeps its armed point', async t => {
const h = await harness(t, [provider('mapillary'), provider('google', { billed: true })]);
assert.equal(await h.layer.openImage('mapillary', 'A', { frame: false }), true);
const result = await showStreetLevel({ ...args('google'), autoOpen: false }, h.context);
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ result, armed: street.armed }));
assert.equal(result.armed, true);
assert.deepEqual(street.armed?.point, { lat: 48, lon: 2 });
});
test('a same-source lookup that finds nothing keeps its explanation', async t => {
const h = await harness(t, [provider('mapillary', { nearest: async () => null })]);
await h.layer.openImage('mapillary', 'A', { frame: false });
const result = await showStreetLevel(args('mapillary'), h.context);
assert.equal(result.noImagery, true);
assert.ok(h.layer.getUIState().street.error);
assert.equal(h.layer.getUIState().street.nearestOutcome, 'empty');
});
for (const takeover of ['navigation', 'gesture', 'none']) {
test('a posted view waiting on terrain yields to the user (' + takeover + ')', async t => {
const a = provider('mapillary');
const h = await harness(t, [a]);
const entered = deferred();
const terrain = deferred();
const e = await embed(t, h, { getHeights: async () => { entered.resolve(); return terrain.promise; } });
e.post(1, view(-45));
await entered.promise;
if (takeover === 'navigation') h.shell._navigationGeneration++;
if (takeover === 'gesture') h.viewer.canvas.dispatchEvent(new Event('pointerdown'));
terrain.resolve([{ ellipsoid: 200 }]);
for (let n = 0; n < 8 && !e.replies.some(r => r.id === 1); n++) await settle();
const reply = e.replies.find(r => r.id === 1);
t.diagnostic(JSON.stringify({ takeover, flights: h.flights, opened: a.calls.open, reply }));
assert.ok(reply, 'host receives completion');
assert.equal(h.flights.length, takeover === 'none' ? 1 : 0, 'superseded view must not reclaim the camera');
assert.equal(a.calls.open.length, takeover === 'none' ? 1 : 0);
assert.equal(reply.ok, takeover === 'none');
});
}
test('a posted view looking at the horizon does not wait on terrain or hold the queue', async t => {
const h = await harness(t, [provider('mapillary')]);
const terrain = deferred();
let terrainCalls = 0;
const e = await embed(t, h, { getHeights: () => { terrainCalls++; return terrain.promise; } });
e.post(1, view(0));
e.post(2, { ...view(-90), street_level: { provider: 'mapillary', lat: 49, lon: 3 } });
for (let n = 0; n < 8; n++) await settle();
const before = e.replies.filter(r => r.id).map(r => r.id);
terrain.resolve([{ ellipsoid: 200 }]);
for (let n = 0; n < 8 && !e.replies.some(r => r.id === 2); n++) await settle();
const after = e.replies.filter(r => r.id).map(r => r.id);
t.diagnostic(JSON.stringify({ terrainCalls, before, after, flights: h.flights }));
assert.deepEqual(after, [1, 2], 'both complete after irrelevant terrain is released');
assert.deepEqual(before, [1, 2], 'both should complete without any terrain response');
assert.equal(terrainCalls, 0);
});
for (const initial of [true, false]) {
test('a source switched by hand during the load stops the request (photo open before: ' + initial + ')', async t => {
const entered = deferred();
const load = deferred();
const a = provider('mapillary', { load: async id => { if (id === 'B') { entered.resolve(); await load.promise; } } });
const h = await harness(t, [a, provider('panoramax')]);
if (initial) await h.layer.openImage('mapillary', 'A', { frame: false });
const request = showStreetLevel(args('mapillary'), h.context);
await entered.promise;
h.layer.setProviderEnabled('panoramax', true);
load.resolve();
const result = await request;
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ initial, result, open: street.open, sourceOn: h.layer.providerStatus('mapillary').on }));
assert.equal(h.layer.providerStatus('mapillary').on, false);
assert.equal(street.open, false);
assert.equal(result.ok, false, 'request for the disabled source must not report success');
assert.equal(result.cancelled, true);
});
}
test('a failed replacement from another source does not keep the old photo\'s details', async t => {
const h = await harness(t, [provider('mapillary'), provider('panoramax', { load: async () => { throw new Error('Fixture image load failure'); } })]);
await h.layer.openImage('mapillary', 'A', { frame: false });
const before = h.layer.getUIState().street;
assert.equal(before.imageId, 'A');
assert.ok(before.externalUrl);
const result = await showStreetLevel(args('panoramax'), h.context);
const street = h.layer.getUIState().street;
t.diagnostic(JSON.stringify({ result, street }));
assert.equal(result.ok, false);
assert.equal(street.providerId, 'panoramax');
assert.match(street.error, /Fixture image load failure/);
assert.deepEqual({ imageId: street.imageId, position: street.position, creator: street.creator, externalUrl: street.externalUrl },
{ imageId: null, position: null, creator: null, externalUrl: null }, 'destroyed provider photo must not be attributed to replacement');
});
const rejections = [];
process.on('unhandledRejection', reason => { rejections.push(String(reason?.message || reason)); });
// 111 m from the North Pole, looking east and down: 1 km ahead is more than a
// full turn of that parallel.
const north = { camera: { lat: 89.999, lon: 170, altitude_m: 1000, pitch_deg: -45, heading_deg: 90 }, street_level: { provider: 'mapillary' } };
const placed = { camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 }, street_level: { provider: 'mapillary', lat: 48, lon: 2 } };
test('a grounded look-at near a pole stays on the globe', async () => {
const point = await groundedLookAt(north.camera, { getHeights: async () => [{ ellipsoid: 0 }] });
assert.ok(Math.abs(point.lat) <= 90 && Math.abs(point.lon) <= 180, JSON.stringify(point));
});
test('show_in_gods_eye_view frames the South Pole with a tilted camera', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' }, terrain: { getHeights: async () => [{ ellipsoid: 2835 }] } },
});
const result = await catalog.call('show_in_gods_eye_view', {
camera: { lat: -90, lon: 0, altitude_m: 3500, pitch_deg: -30, heading_deg: 45 },
street_level: { provider: 'mapillary' },
});
const street = result.data?.view?.street_level;
assert.ok(street && Math.abs(street.lat) <= 90 && Math.abs(street.lon) <= 180, JSON.stringify(result));
});
test('a posted polar view is answered and the next view still applies', async t => {
rejections.length = 0;
const h = await harness(t, [provider('mapillary')]);
const e = await embed(t, h, { getHeights: async () => [{ ellipsoid: 0 }] });
e.post(1, north);
e.post(2, placed);
for (let n = 0; n < 20 && !e.replies.some(r => r.id === 2); n++) await settle();
t.diagnostic(JSON.stringify({ replies: e.replies.filter(r => r.id), rejections }));
assert.ok(e.replies.some(r => r.id === 1), 'the polar view gets an answer');
assert.ok(e.replies.some(r => r.id === 2 && r.ok === true), 'the next view still applies');
assert.deepEqual(rejections, [], 'no rejection escapes the views queue');
});
test('a stalled terrain answer does not hold posted views', { timeout: 15000 }, async t => {
const h = await harness(t, [provider('mapillary')]);
let asked = 0;
// Hangs like a stalled upstream; gives up only when its signal aborts.
const terrain = {
getHeights: (_points, { signal } = {}) => {
asked++;
return new Promise((_, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true }));
},
};
const e = await embed(t, h, terrain);
e.post(1, view(-45));
e.post(2, placed);
const started = Date.now();
while (Date.now() - started < 5000 && !e.replies.some(r => r.id === 2)) await new Promise(resolve => setTimeout(resolve, 100));
t.diagnostic(JSON.stringify({ asked, waitedMs: Date.now() - started, replies: e.replies.filter(r => r.id).map(r => r.id) }));
assert.equal(asked, 1);
assert.ok(e.replies.some(r => r.id === 1), 'the view falls back to its own estimate within 5 s');
assert.ok(e.replies.some(r => r.id === 2), 'the next view is not held behind it');
});
test('a posted view that throws is answered and does not hold the views behind it', async t => {
rejections.length = 0;
const h = await harness(t, [provider('mapillary')]);
const getAll = h.dataManager.getAll;
let calls = 0;
h.dataManager.getAll = () => {
if (calls++ === 0) throw new Error('Fixture layer list failure');
return getAll();
};
const e = await embed(t, h, null);
e.post(1, placed);
e.post(2, placed);
for (let n = 0; n < 20 && !e.replies.some(r => r.id === 2); n++) await settle();
t.diagnostic(JSON.stringify({ replies: e.replies.filter(r => r.id), rejections }));
assert.deepEqual(e.replies.find(r => r.id === 1), { type: 'gev:view-applied', id: 1, ok: false, error: 'Fixture layer list failure' });
assert.ok(e.replies.some(r => r.id === 2 && r.ok === true), 'the next view still applies');
assert.deepEqual(rejections, []);
});
+1
View File
@@ -100,6 +100,7 @@ export function fakeMapillarySource(overrides = {}) {
return new Uint8Array(0);
},
getSequenceImages: async () => [],
nearestImages: async () => [],
...overrides,
};
}
+47 -3
View File
@@ -1,6 +1,11 @@
/** Links that open God's Eye View at a view. */
import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js';
import {
VIEW_PROPERTIES,
createView,
groundedLookAt,
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';
@@ -47,10 +52,31 @@ export async function resolveViewArguments(args, { services, signal, tools }) {
? { ...args.camera }
: { ...base.camera, ...args.camera };
let label = null;
// Street-level photos open at the area's centre unless a point is given;
// a new area or camera position moves a passed-through view's photos with
// it.
const moved =
Boolean(args.area) ||
args.camera?.lat !== undefined ||
args.camera?.lon !== undefined;
let streetLevel =
args.street_level && typeof args.street_level === 'object'
? { ...args.street_level }
: base.street_level && typeof base.street_level === 'object'
? moved
? { provider: base.street_level.provider }
: { ...base.street_level }
: null;
if (args.area) {
const area = await resolveArea(args.area, { services, signal });
label = area.label;
const framed = cameraForArea(area);
if (
streetLevel &&
streetLevel.lat === undefined &&
streetLevel.lon === undefined
)
streetLevel = { ...streetLevel, lat: framed.lat, lon: framed.lon };
// A tilted view of an area looks at its center from behind, unless the
// caller placed the camera itself.
camera =
@@ -75,6 +101,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?.terrain, signal);
if (point) streetLevel = { ...streetLevel, ...point };
}
let view;
try {
view = createView({
@@ -84,6 +119,7 @@ export async function resolveViewArguments(args, { services, signal, tools }) {
map: args.map ?? base.map ?? null,
follow,
annotations: args.annotations ?? base.annotations ?? [],
street_level: streetLevel,
});
} catch (error) {
// A view passed through can hold anything; report it as bad arguments.
@@ -113,7 +149,9 @@ export const showInGodsEyeView = defineTool({
'it. Pass the view another answer returned, or describe one: an area ' +
'framed from above or a camera position, with chosen data layers, visual ' +
'style and map, optionally following an aircraft (or riding in its ' +
'cockpit view) or a satellite, and with marks drawn on the map.',
'cockpit view) or a satellite, with marks drawn on the map, or opening ' +
'street-level photos (Google Street View, Mapillary, KartaView or ' +
'Panoramax) at a place.',
inputSchema: {
type: 'object',
properties: VIEW_ARGUMENTS,
@@ -131,7 +169,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() } : {}),
},
};
},
});
+186
View File
@@ -113,3 +113,189 @@ test('links carry a camera, style, map and an entity to follow', async () => {
/Give a view, an area, or a camera with lat and lon/,
);
});
test('links can open street-level photos at the area, on the map the source needs', async () => {
const { viewFromParams } = await import('../../view/index.js');
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
const result = await catalog.call('show_in_gods_eye_view', {
area: { lat: 38.5799, lon: -121.4937, radius_km: 1 },
street_level: { provider: 'google' },
});
const { view } = result.data;
assert.equal(view.map, 'photoreal');
assert.deepEqual(view.layers, ['street-level']);
assert.deepEqual(view.street_level, {
provider: 'google',
lat: 38.5799,
lon: -121.4937,
});
const params = new URLSearchParams(new URL(result.data.url).hash.slice(1));
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,
street_level: { provider: 'mapillary', lat: 38.58, lon: -121.49 },
});
assert.deepEqual(point.data.view.street_level, {
provider: 'mapillary',
lat: 38.58,
lon: -121.49,
});
await assert.rejects(
catalog.call('show_in_gods_eye_view', {
area: { lat: 0, lon: 0, radius_km: 1 },
map: 'osm',
street_level: { provider: 'google' },
}),
/photoreal/,
);
});
test('a new area or camera position moves a passed-through view’s photos, and a tilted area still opens them at its centre', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
const first = await catalog.call('show_in_gods_eye_view', {
area: { lat: 38.5799, lon: -121.4937, radius_km: 1 },
street_level: { provider: 'google' },
});
const second = await catalog.call('show_in_gods_eye_view', {
view: first.data.view,
area: { lat: 42.3314, lon: -83.0458, radius_km: 1 },
});
assert.deepEqual(second.data.view.street_level, {
provider: 'google',
lat: 42.3314,
lon: -83.0458,
});
// A new camera position moves them too; turning the camera keeps them.
const moved = await catalog.call('show_in_gods_eye_view', {
view: first.data.view,
camera: { lat: 40.758, lon: -73.9855, altitude_m: 800, pitch_deg: -90 },
});
assert.deepEqual(moved.data.view.street_level, {
provider: 'google',
lat: 40.758,
lon: -73.9855,
});
const turned = await catalog.call('show_in_gods_eye_view', {
view: first.data.view,
camera: { heading_deg: 90 },
});
assert.deepEqual(turned.data.view.street_level, first.data.view.street_level);
const tilted = await catalog.call('show_in_gods_eye_view', {
area: { lat: 48.8584, lon: 2.2945, radius_km: 1 },
camera: { pitch_deg: -30 },
street_level: { provider: 'mapillary' },
});
assert.notEqual(
tilted.data.view.camera.lat,
48.8584,
'the camera sits behind the target',
);
assert.deepEqual(tilted.data.view.street_level, {
provider: 'mapillary',
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 },
follow: { kind: 'aircraft', id: 'AE1234' },
street_level: {},
}),
/cannot both follow/,
);
});
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,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
const first = await catalog.call('show_in_gods_eye_view', {
area: { lat: 38.5799, lon: -121.4937, radius_km: 1 },
street_level: { provider: 'mapillary' },
});
const again = await catalog.call('show_in_gods_eye_view', {
view: first.data.view,
});
assert.deepEqual(again.data.view.street_level, first.data.view.street_level);
assert.deepEqual(again.data.view, first.data.view);
});
+24 -2
View File
@@ -4,7 +4,11 @@ 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,
MOBILE_SHELL_MEDIA_QUERY,
sheetForPanelId,
} from './mobileShell.js';
import { VisualSettings } from './visualSettings.js';
import { NavigationController } from './navigationController.js';
import { ShareRestoration } from './shareRestoration.js';
@@ -1143,6 +1147,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) =>
@@ -1258,6 +1263,20 @@ 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 (!globalThis.matchMedia?.(MOBILE_SHELL_MEDIA_QUERY)?.matches) return;
if (sheet) this._mobileShell?.setSheet(sheet);
// The sheet opens at its top; a panel lower in the rail is scrolled to.
document.getElementById(panelId)?.scrollIntoView?.({ block: 'nearest' });
}
/**
* Toggles "clean view" mode which hides all UI panels via a CSS body class.
* @param {boolean} [forceEnabled] - Explicit on/off. Omit to toggle.
@@ -1364,9 +1383,12 @@ export class StyleManager extends ShellFacade {
};
}
if (!target.available) {
// The controller knows why (Google 3D: no Maps key; others: no ion token).
return {
ok: false,
error: `${target.label} requires a Cesium ion token`,
error:
target.unavailableReason ||
`${target.label} requires a Cesium ion token`,
activeStack: this.mapStackController.getActiveId(),
};
}
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { StyleManager } from './applicationShell.js';
function reveal({ phone }) {
const sheets = [];
const scrolls = [];
const panel = { scrollIntoView: (options) => scrolls.push(options) };
const saved = globalThis.document;
const savedMatchMedia = globalThis.matchMedia;
globalThis.document = {
getElementById: (id) => (id === 'street-level-panel' ? panel : null),
};
globalThis.matchMedia = (query) => ({
matches: phone && query === '(max-width: 720px)',
});
try {
StyleManager.prototype.revealPanel.call(
{ _mobileShell: { setSheet: (sheet) => sheets.push(sheet) } },
'street-level-panel',
);
} finally {
globalThis.document = saved;
globalThis.matchMedia = savedMatchMedia;
}
return { sheets, scrolls };
}
test('revealing a panel opens its phone sheet and scrolls the panel into view', () => {
const { sheets, scrolls } = reveal({ phone: true });
assert.deepEqual(sheets, ['cameras']);
// The panel sits below the sheet's first screen at 375×812.
assert.deepEqual(scrolls, [{ block: 'nearest' }]);
});
test('revealing a panel above 720px neither opens a sheet nor scrolls the rail', () => {
assert.deepEqual(reveal({ phone: false }), { sheets: [], scrolls: [] });
});
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { StyleManager } from './applicationShell.js';
test('an unavailable map stack reports the controller’s own reason, not always an ion token', async () => {
const controller = {
getStacks: () => [
{
id: 'photoreal',
label: 'Google 3D',
available: false,
unavailableReason: 'Google 3D needs GOOGLE_MAPS_API_KEY',
},
{ id: 'bing-aerial', label: 'Bing aerial', available: false },
],
getActiveId: () => 'osm',
};
const shell = { mapStackController: controller };
const photoreal = await StyleManager.prototype.setMapStack.call(
shell,
'photoreal',
);
assert.deepEqual(photoreal, {
ok: false,
error: 'Google 3D needs GOOGLE_MAPS_API_KEY',
activeStack: 'osm',
});
const bing = await StyleManager.prototype.setMapStack.call(
shell,
'bing-aerial',
);
assert.equal(bing.error, 'Bing aerial requires a Cesium ion token');
});
+3 -1
View File
@@ -317,7 +317,9 @@ export class ShareRestoration {
// the shared flight having landed.
const vessel = this._sharedVessel(state);
if (vessel) void this._restoreSharedVessel(vessel, current);
settle({ status: 'settled', share, layers, tracking });
// The token tells listeners whether anyone navigated after the
// restore's flight landed (while the layers were still loading).
settle({ status: 'settled', share, layers, tracking, navigationToken });
} catch (error) {
if (!current()) return;
this.shareLinkManager.completeInitialRestore(state.restoreToken);
+9
View File
@@ -19,6 +19,8 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
const expanded = shallowRef(null);
/** False on a page with neither element fullscreen nor popovers. */
const expandAvailable = shallowRef(true);
/** Whether the photo is shown over the globe on an embedded page. */
const embedOverlay = shallowRef(false);
/** @type {{handlers: StreetLevelHandlers}|null} */
let owner = null;
@@ -47,6 +49,11 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
expandAvailable.value = Boolean(available);
}
/** @param {boolean} on */
function setEmbedOverlay(on) {
embedOverlay.value = Boolean(on);
}
/**
* Stop forwarding actions to the owner holding `token`. The panel keeps
* its last render, as the page did when its controls were wired by hand.
@@ -69,10 +76,12 @@ export const useStreetLevelStore = defineStore('gev-street-level', () => {
view,
expanded,
expandAvailable,
embedOverlay,
attach,
publish,
setExpanded,
setExpandAvailable,
setEmbedOverlay,
detach,
act,
describeSinceStop,
+56 -2
View File
@@ -11,6 +11,11 @@ const RENDER_MODE_KEY = 'gev:street-level:render-mode';
/** <body> classes of the modes that hide every panel; an expanded photo leaves with them. */
const PANEL_HIDING_MODES = ['ui-clean-view', 'recording-mode', 'cockpit-mode'];
/** True on an embedded page (`?embed=1` or the inline panel). */
function isEmbedPage() {
return globalThis.document?.body?.classList?.contains('ui-embed') === true;
}
/**
* Drives the Street Level panel body: provider chips, filters and viewer.
* The panel view renders what this presents (`gev-street-level` store); the
@@ -137,6 +142,15 @@ export class StreetLevelControls {
});
}
this.store.setExpandAvailable(Boolean(this._expandMode));
// Embed mode: Esc closes an open photo, since the rail it would go back to
// is hidden. Full screen keeps its own Esc (above), which leaves it first.
this.listen(document, 'keydown', (event) => {
if (event.key !== 'Escape' || !this._embedHome || this.isViewerExpanded())
return;
event.preventDefault();
event.stopPropagation();
this.layer.closeViewer?.();
});
try {
const stored = localStorage.getItem(RENDER_MODE_KEY);
if (stored === 'fill' || stored === 'letterbox')
@@ -271,8 +285,16 @@ export class StreetLevelControls {
this._modesObserver = null;
return;
}
// Embed mode keeps Clean View on for its globe while the photo is lifted
// over it: there only recording and the cockpit end EXPAND.
const leaveIfHiding = () => {
if (PANEL_HIDING_MODES.some((mode) => body.classList.contains(mode)))
if (
PANEL_HIDING_MODES.some(
(mode) =>
body.classList.contains(mode) &&
!(mode === 'ui-clean-view' && isEmbedPage()),
)
)
this._exitExpanded();
};
if (!this._modesObserver && typeof MutationObserver === 'function') {
@@ -293,6 +315,31 @@ export class StreetLevelControls {
this._requestResize();
}
/**
* Embed mode hides the right rail: an open photo moves to <body> as an
* overlay on the globe (`sl-viewer-wrap-embed`, embed.css) and goes back
* when it closes. Vue keeps patching the wrap there: its siblings are static.
*/
_syncEmbedOverlay(open) {
const wrap = this._elements.viewerWrap;
if (!wrap || !document.body) return;
const lift = open === true && isEmbedPage();
this.store.setEmbedOverlay(lift);
if (lift && !this._embedHome) {
this._embedHome = { parent: wrap.parentNode, next: wrap.nextSibling };
document.body.appendChild(wrap);
wrap.classList.add('sl-viewer-wrap-embed');
this._requestResize();
} else if (!lift && this._embedHome) {
wrap.classList.remove('sl-viewer-wrap-embed');
const { parent, next } = this._embedHome;
this._embedHome = null;
if (parent?.isConnected)
parent.insertBefore(wrap, next?.parentNode === parent ? next : null);
this._requestResize();
}
}
connect() {
this._unsubscribe?.();
this._unsubscribe = null;
@@ -348,8 +395,14 @@ 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;
this._syncEmbedOverlay(open);
}
destroy() {
@@ -362,6 +415,7 @@ export class StreetLevelControls {
if (this._expandMode === 'popover')
this._elements.viewerWrap?.removeAttribute('popover');
this._watchPanelHidingModes(false);
this._syncEmbedOverlay(false);
this.listeners.abort();
this._resizeObserver?.disconnect();
this._resizeObserver = null;
+180
View File
@@ -622,6 +622,36 @@ for (const mode of ['ui-clean-view', 'recording-mode', 'cockpit-mode'])
}
}));
test('in embed mode, which keeps Clean View on for its globe, EXPAND stays; the cockpit still ends it', () =>
withDom(async (dom) => {
const saved = globalThis.MutationObserver;
const watch = fakeMutationObserver();
globalThis.MutationObserver = watch.FakeObserver;
try {
dom.document.body.classList.add('ui-embed');
dom.document.body.classList.add('ui-clean-view');
const { layer, controls } = await stubPanel(
dom,
uiState({ open: false }),
);
await layer.publish(uiState({ open: true }));
dom.document.querySelector('#sl-viewer-expand').click();
await settle();
await settle();
watch.fire();
await settle();
assert.equal(controls.isViewerExpanded(), true, 'EXPAND stays open');
dom.document.body.classList.toggle('cockpit-mode', true);
watch.fire();
await settle();
await settle();
assert.equal(controls.isViewerExpanded(), false);
controls.destroy();
} finally {
globalThis.MutationObserver = saved;
}
}));
/* ── Restores and keyboard rules ───────────────────────────────────────── */
test('only an explicit switch-on opens the panel, unstored; a restore never does (P2-1)', () =>
@@ -921,3 +951,153 @@ test('a window changed elsewhere under a focused SINCE thumb leaves its readout
assert.equal(since.getAttribute('aria-valuetext'), label());
controls.destroy();
}));
/** In embed mode, the viewer moved out of the hidden rail onto the globe. */
const overGlobe = (dom) =>
dom.wrap.parentNode === dom.document.body &&
dom.wrap.classList.contains('sl-viewer-wrap-embed');
const escape = () =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
);
test('in embed mode an open photo leaves the hidden rail for an overlay on the globe, and goes back when it closes', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
const home = dom.wrap.parentNode;
const after = dom.wrap.nextSibling;
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
assert.equal(overGlobe(dom), false, 'nothing to show yet');
await layer.publish(uiState({ open: true }));
assert.equal(overGlobe(dom), true, 'shown over the globe');
assert.equal(dom.wrap.hidden, false, 'the panel view still renders it');
await layer.publish(uiState({ open: true, renderMode: 'fill' }));
assert.equal(overGlobe(dom), true, 'an update keeps it there');
assert.equal(
dom.wrap.querySelector('[aria-checked="true"]')?.textContent.trim(),
'FILL',
'and the panel view keeps patching it there',
);
await layer.publish(uiState({ open: false }));
assert.equal(overGlobe(dom), false);
assertSameNode(dom.wrap.parentNode, home, 'back in the panel');
assertSameNode(dom.wrap.nextSibling, after, 'in its own place');
assert.equal(dom.wrap.hidden, true);
controls.destroy();
}));
test('outside embed mode a photo stays in the panel', () =>
withDom(async (dom) => {
const home = dom.wrap.parentNode;
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
await layer.publish(uiState({ open: true }));
assert.equal(overGlobe(dom), false);
assertSameNode(dom.wrap.parentNode, home);
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');
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
let closed = 0;
layer.closeViewer = () => {
closed++;
layer.publish(uiState({ open: false }));
};
await layer.publish(uiState({ open: true }));
escape();
await nextTick();
assert.equal(closed, 1, 'Esc closed the photo');
assert.equal(overGlobe(dom), false);
await layer.publish(uiState({ open: true }));
assert.equal(overGlobe(dom), true, 'the next photo is shown');
controls.destroy();
}));
test('in embed mode Esc in full screen leaves full screen and keeps the photo', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
let closed = 0;
layer.closeViewer = () => closed++;
await layer.publish(uiState({ open: true }));
await dom.wrap.requestFullscreen();
await settle();
assert.equal(controls.isViewerExpanded(), true);
escape();
assert.equal(closed, 0, 'the photo stays open');
controls.destroy();
}));
test('outside embed mode Esc does not close a photo in the panel', () =>
withDom(async (dom) => {
const { layer, controls } = await stubPanel(dom, uiState({ open: true }));
let closed = 0;
layer.closeViewer = () => closed++;
escape();
assert.equal(closed, 0);
controls.destroy();
}));
test('destroying the panel in embed mode puts an open photo back in the panel', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
const home = dom.wrap.parentNode;
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
await layer.publish(uiState({ open: true }));
controls.destroy();
assert.equal(overGlobe(dom), false);
assertSameNode(dom.wrap.parentNode, home);
}));
test('an embedded photo failure is shown inside the overlay, and only there', () =>
withDom(async (dom) => {
dom.document.body.classList.add('ui-embed');
const { layer, controls } = await stubPanel(dom, uiState({ open: false }));
const failed = uiState({ open: true });
Object.assign(failed.street, {
imageId: null,
error: 'Image could not be opened',
});
await layer.publish(failed);
assert.equal(overGlobe(dom), true);
const error = dom.wrap.querySelector('#sl-viewer-error');
assert.ok(error, 'the overlay contains an error region');
assert.equal(error.hidden, false);
assert.match(error.textContent, /Image could not be opened/);
assert.equal(dom.root.querySelector('#sl-error').hidden, true);
await layer.publish(uiState({ open: true }));
assert.equal(error.hidden, true, 'a successful retry clears the error');
controls.destroy();
}));
test('outside embed mode a photo failure stays in the panel error region', () =>
withDom(async (dom) => {
const failed = uiState({ open: true });
failed.street.error = 'Image could not be opened';
const { controls } = await stubPanel(dom, failed);
const error = dom.wrap.querySelector('#sl-viewer-error');
assert.ok(error);
assert.equal(error.hidden, true, 'no duplicate error inside the photo');
assert.equal(dom.root.querySelector('#sl-error').hidden, false);
controls.destroy();
}));
+24
View File
@@ -13,3 +13,27 @@ body.ui-embed #world-overlay-actions,
body.ui-embed #view-switcher {
display: none !important;
}
/* Street-level photos: the rail that holds the panel is hidden here, so an
open photo shows over the globe (streetLevelControls moves the viewer). */
body.ui-embed .sl-viewer-wrap-embed {
position: fixed;
inset: 12px;
z-index: 1000;
}
/* Cockpit, recording and scene playback own the screen, as they hide the
panel elsewhere: the lifted photo steps out of their way. */
body.ui-embed.cockpit-mode .sl-viewer-wrap-embed,
body.ui-embed.recording-mode .sl-viewer-wrap-embed,
body.ui-embed.scene-playback-mode .sl-viewer-wrap-embed {
display: none;
}
body.ui-embed .sl-viewer-wrap-embed .sl-viewer-stage,
body.ui-embed .sl-viewer-wrap-embed .sl-viewer {
flex: 1 1 auto;
height: auto;
min-height: 0;
aspect-ratio: auto;
}
+3 -2
View File
@@ -13,7 +13,7 @@ import UiSlider from '../../kit/UiSlider.js';
// Until it renders, the panel shows its initial markup. The viewer element
// belongs to the layer and EXPAND to the controls.
const store = useStreetLevelStore();
const { view, expanded, expandAvailable } = storeToRefs(store);
const { view, expanded, expandAvailable, embedOverlay } = storeToRefs(store);
const status = computed(
() =>
@@ -142,6 +142,7 @@ const vThumb = {
<button id="sl-viewer-close" class="sl-viewer-x" type="button" :title="t('Close the image and deselect it on the map')" :aria-label="t('Close the street-level image')">×</button>
</div>
</div>
<div id="sl-viewer-error" class="sl-error" role="alert" :hidden="!(embedOverlay || expanded) || !view?.error"><span aria-hidden="true">⚠</span> {{ view?.error || '' }}</div>
<div class="sl-viewer-stage">
<div id="sl-viewer-placeholder" class="sl-viewer-placeholder" :hidden="!viewer.loading"></div>
<div id="sl-viewer" class="sl-viewer" :aria-label="t('Street-level viewer')"></div>
@@ -158,7 +159,7 @@ const vThumb = {
<span class="cctv-summary-label">{{ t('PROVIDERS') }}</span>
<div id="sl-provider-chips" class="sl-chips"><RailChips :chips="view ? view.providers : []" /></div>
</div>
<div id="sl-error" class="sl-error" role="alert" :hidden="!view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
<div id="sl-error" class="sl-error" role="alert" :hidden="embedOverlay || expanded || !view?.error"><span aria-hidden="true">⚠</span> <span id="sl-error-text">{{ view?.error || '' }}</span></div>
<!-- A missing key gates the filters only: the chips above stay
focusable so their tooltip can say how to add the key. -->
<fieldset id="sl-controls" class="sl-controls" :disabled="Boolean(view?.controlsDisabled)" :hidden="!shows.pano && !shows.since">
+196 -7
View File
@@ -11,6 +11,7 @@ import {
decodeLayerStateParams,
encodeLayerStateParams,
} from '../data/layerState.js';
import { encodeIdSwitches } from '../data/idSwitches.js';
import { RETIRED_STYLES } from '../visualStylePolicy.js';
/** Visual styles by internal name, with the name share links use. */
@@ -65,6 +66,94 @@ const FOLLOWABLE = Object.freeze({
export const VIEW_FOLLOW_KINDS = Object.freeze(Object.keys(FOLLOWABLE));
/**
* Street-level imagery sources a view can open, the layer that shows them and
* the map a source needs (Google's terms allow Street View beside Google maps
* only). Literal here: this module stays portable.
*/
const STREET_LEVEL_LAYER = 'street-level';
export const VIEW_STREET_LEVEL_PROVIDERS = Object.freeze([
'google',
'mapillary',
'kartaview',
'panoramax',
]);
const STREET_LEVEL_MAP = Object.freeze({ google: 'photoreal' });
const STREET_LEVEL_PARAM = 'sl';
const EARTH_RADIUS_M = 6_371_000;
/** Above this a camera frames a region, not a street: photos open under it. */
const LOOK_AT_MAX_ALTITUDE_M = 20_000;
/** Flatter than this the look-at point is at the horizon: use the camera's. */
const LOOK_AT_MIN_PITCH_DEG = -5;
/** Further ahead than this the camera frames a region: use its own point. */
const LOOK_AT_MAX_DISTANCE_M = 10_000;
/**
* The ground point a street-scale camera looks at: straight below it when it
* looks down, else ahead along its heading by altitude / tan(pitch). A camera
* with no altitude, above street scale, aimed at the horizon, or looking
* further than 10 km ahead gives its own point. Longitude wraps at the
* antimeridian.
*/
export function lookAtPoint(camera) {
const pitch = Number(camera.pitch_deg ?? -90);
const altitude = Number(camera.altitude_m);
if (
!(altitude > 0) ||
altitude > LOOK_AT_MAX_ALTITUDE_M ||
!(pitch > -89) ||
pitch > LOOK_AT_MIN_PITCH_DEG
)
return { lat: camera.lat, lon: camera.lon };
const ground = altitude / Math.tan((-pitch * Math.PI) / 180);
if (ground > LOOK_AT_MAX_DISTANCE_M)
return { lat: camera.lat, lon: camera.lon };
const heading = ((Number(camera.heading_deg) || 0) * Math.PI) / 180;
const dLat =
((ground * Math.cos(heading)) / EARTH_RADIUS_M) * (180 / Math.PI);
const cosLat = Math.cos((camera.lat * Math.PI) / 180) || 1e-6;
const dLon =
((ground * Math.sin(heading)) / (EARTH_RADIUS_M * cosLat)) *
(180 / Math.PI);
const lon = camera.lon + dLon;
return {
lat: clamp(camera.lat + dLat, -90, 90),
// Near a pole one step ahead can be several turns of the parallel.
lon: ((((lon + 180) % 360) + 360) % 360) - 180,
};
}
/**
* 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. `terrain` is a heights service
* (`getHeights`); null when it is missing or cannot say.
*/
export async function groundedLookAt(camera, terrain, signal) {
if (typeof terrain?.getHeights !== 'function') return null;
// Looking down or at the horizon the point is the camera's own: the ground
// under it changes nothing, so it is not asked for.
if (
!Number.isFinite(camera?.altitude_m) ||
!(camera.pitch_deg > -89) ||
camera.pitch_deg > LOOK_AT_MIN_PITCH_DEG
)
return null;
let heights;
try {
heights = await 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 });
}
/** Annotation kinds the app draws, as in its annotate_map action. */
export const VIEW_ANNOTATION_TYPES = Object.freeze([
'pin',
@@ -162,6 +251,21 @@ export const VIEW_PROPERTIES = Object.freeze({
additionalProperties: false,
},
}),
street_level: Object.freeze({
type: 'object',
description:
'Open street-level photos at a point: Google Street View (google; the ' +
'map becomes Google 3D), Mapillary, KartaView or Panoramax. lat/lon ' +
'default to the spot the camera looks at. The app flies there and ' +
'opens the nearest photo; a plain link with google only arms it, ' +
'leaving the billed load to a click. Not with follow.',
properties: {
provider: { type: 'string', enum: [...VIEW_STREET_LEVEL_PROVIDERS] },
lat: { type: 'number', minimum: -90, maximum: 90 },
lon: { type: 'number', minimum: -180, maximum: 180 },
},
additionalProperties: false,
}),
follow: Object.freeze({
type: 'object',
description:
@@ -251,6 +355,7 @@ export function createView({
map = null,
follow = null,
annotations = [],
street_level: streetLevel = null,
} = {}) {
if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon))
throw new TypeError('A view needs a camera lat and lon');
@@ -264,10 +369,38 @@ export function createView({
throw new TypeError(`Cannot follow a ${follow.kind}`);
if (follow?.cockpit === true && !followed.cockpit)
throw new TypeError(`A ${follow.kind} has no cockpit view`);
let street = null;
if (streetLevel) {
if (follow)
throw new TypeError(
'A view cannot both follow an entity and open street-level photos',
);
const provider = streetLevel.provider ?? null;
if (provider !== null && !VIEW_STREET_LEVEL_PROVIDERS.includes(provider))
throw new TypeError(`Unknown street-level source: ${provider}`);
const needs = provider ? STREET_LEVEL_MAP[provider] || null : null;
if (needs && map !== null && map !== needs)
throw new TypeError(
`${provider} street-level imagery shows only on the ${needs} map`,
);
if (needs) map = needs;
// The point defaults to where the camera looks: a view "of X" opens
// photos at X, also when the camera sits behind it. It is a pair: half
// of one is not mixed with the camera's other half.
const given = [latitude(streetLevel.lat), longitude(streetLevel.lon)];
const seen = lookAtPoint(camera);
const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given;
street = Object.freeze({
...(provider ? { provider } : {}),
lat: round(clamp(lat, -90, 90), 5),
lon: round(clamp(lon, -180, 180), 5),
});
}
const enabled = new Set(
layers.filter((layer) => REGISTERED_LAYER_IDS.includes(layer)),
);
if (followed) enabled.add(followed.layer);
if (street) enabled.add(STREET_LEVEL_LAYER);
return Object.freeze({
camera: Object.freeze({
lat: round(clamp(camera.lat, -90, 90), 4),
@@ -296,6 +429,7 @@ export function createView({
.map(annotationOf)
.filter(Boolean),
),
street_level: street,
});
}
@@ -321,8 +455,29 @@ export function viewToParams(view) {
...state.options[followed.owner],
[followed.key]: view.follow.id,
};
// The restored layer lights the source the photos come from.
const provider = view.street_level?.provider;
if (provider)
state.options[STREET_LEVEL_LAYER] = {
...state.options[STREET_LEVEL_LAYER],
mapillary: provider === 'mapillary',
providerSwitches: encodeIdSwitches(
VIEW_STREET_LEVEL_PROVIDERS.filter((id) => id !== 'mapillary').map(
(id) => [id, id === provider],
),
),
};
encodeLayerStateParams(params, state);
}
if (view.street_level)
params.set(
STREET_LEVEL_PARAM,
[
view.street_level.provider ?? '',
view.street_level.lat.toFixed(5),
view.street_level.lon.toFixed(5),
].join(','),
);
if (view.annotations.length) {
const encoded = JSON.stringify(view.annotations);
if (encoded.length <= MAX_ANNOTATION_PARAM_CHARS)
@@ -345,6 +500,40 @@ export function annotationsFromParams(params) {
}
}
/**
* The street-level photos a share link opens (`sl=<source>,<lat>,<lon>`, the
* source optional), or null.
*/
export function streetLevelFromParams(params) {
const raw = params.get(STREET_LEVEL_PARAM);
if (!raw) return null;
// Three fields, plain decimal degrees: that alone bounds the value.
const parts = raw.split(',');
if (parts.length !== 3) return null;
const [provider, lat, lon] = parts;
if (provider && !VIEW_STREET_LEVEL_PROVIDERS.includes(provider)) return null;
// Plain decimal degrees only: no exponents, hex or blanks.
const DECIMAL = /^-?\d{1,3}(\.\d{1,8})?$/;
if (!DECIMAL.test(lat) || !DECIMAL.test(lon)) return null;
const point = { lat: latitude(Number(lat)), lon: longitude(Number(lon)) };
if (point.lat === undefined || point.lon === undefined) return null;
return { ...(provider ? { provider } : {}), ...point };
}
/** The entity a decoded layer state follows, or null. */
function followFromState(state) {
for (const [kind, followed] of Object.entries(FOLLOWABLE)) {
const id = state?.options?.[followed.owner]?.[followed.key];
if (id !== null && id !== undefined) return { kind, id: String(id) };
}
return null;
}
/** The entity a share link follows, or null. */
export function followFromParams(params) {
return followFromState(decodeLayerStateParams(params));
}
/** The view a set of share-link hash parameters restores, or null. */
export function viewFromParams(params) {
const lat = Number.parseFloat(params.get('lat'));
@@ -355,13 +544,12 @@ export function viewFromParams(params) {
return Number.isFinite(value) ? value : undefined;
};
const state = decodeLayerStateParams(params);
let follow = null;
for (const [kind, followed] of Object.entries(FOLLOWABLE)) {
const id = state?.options?.[followed.owner]?.[followed.key];
if (id !== null && id !== undefined && !follow)
follow = { kind, id: String(id) };
}
const follow = followFromState(state);
const map = params.get('map');
// A link is untrusted input: its photos win over a map their source cannot
// show on, and a followed entity keeps the camera.
const street = follow ? null : streetLevelFromParams(params);
const needs = street?.provider ? STREET_LEVEL_MAP[street.provider] : null;
return createView({
camera: {
lat,
@@ -376,9 +564,10 @@ export function viewFromParams(params) {
: Object.hasOwn(STYLES_BY_URL_NAME, params.get('style'))
? STYLES_BY_URL_NAME[params.get('style')]
: null,
map: VIEW_MAPS.includes(map) ? map : null,
map: needs ? needs : VIEW_MAPS.includes(map) ? map : null,
follow,
annotations: annotationsFromParams(params),
street_level: street,
});
}
+383
View File
@@ -127,6 +127,389 @@ test('annotations travel in the link, keeping only what the app draws', async ()
assert.equal(viewToParams(many).has('an'), false);
});
test('street-level photos travel in the link, light their source and bring their map', async () => {
const { streetLevelFromParams, VIEW_STREET_LEVEL_PROVIDERS } =
await import('./index.js');
const { decodeLayerStateParams } = await import('../data/layerState.js');
assert.deepEqual(VIEW_STREET_LEVEL_PROVIDERS, [
'google',
'mapillary',
'kartaview',
'panoramax',
]);
const view = createView({
camera: { lat: 38.5799, lon: -121.4937, altitude_m: 400 },
street_level: { provider: 'google' },
});
assert.equal(view.map, 'photoreal', 'Street View brings Google 3D');
assert.deepEqual(view.layers, ['street-level']);
assert.deepEqual(view.street_level, {
provider: 'google',
lat: 38.5799,
lon: -121.4937,
});
const params = viewToParams(view);
assert.equal(params.get('sl'), 'google,38.57990,-121.49370');
assert.deepEqual(viewFromParams(params), view);
const state = decodeLayerStateParams(params);
assert.deepEqual(state.enabledLayerIds, ['street-level']);
assert.equal(state.options['street-level'].mapillary, false);
assert.equal(
state.options['street-level'].providerSwitches,
'google-1*kartaview-0*panoramax-0',
'Street View on, KartaView off',
);
const mapillary = createView({
camera: { lat: 1, lon: 2 },
map: 'osm',
street_level: { provider: 'mapillary', lat: 1.00001, lon: 2.00001 },
});
assert.equal(mapillary.map, 'osm', 'Mapillary shows on any map');
assert.deepEqual(viewFromParams(viewToParams(mapillary)), mapillary);
const lit = decodeLayerStateParams(viewToParams(mapillary)).options[
'street-level'
];
assert.equal(lit.mapillary, true);
assert.equal(
lit.providerSwitches,
'google-0*kartaview-0*panoramax-0',
'a Mapillary link lights Mapillary only',
);
const anySource = createView({
camera: { lat: 1, lon: 2 },
street_level: {},
});
assert.deepEqual(anySource.street_level, { lat: 1, lon: 2 });
assert.equal(viewToParams(anySource).get('sl'), ',1.00000,2.00000');
assert.deepEqual(viewFromParams(viewToParams(anySource)), anySource);
assert.throws(
() =>
createView({
camera: { lat: 1, lon: 2 },
map: 'osm',
street_level: { provider: 'google' },
}),
/photoreal/,
);
assert.throws(
() =>
createView({
camera: { lat: 1, lon: 2 },
street_level: { provider: 'openstreetcam' },
}),
/Unknown street-level source/,
);
assert.equal(
streetLevelFromParams(new URLSearchParams('sl=google,x,y')),
null,
);
assert.equal(streetLevelFromParams(new URLSearchParams('sl=bing,1,2')), null);
assert.equal(streetLevelFromParams(new URLSearchParams('lat=1')), null);
assert.equal(createView({ camera: { lat: 1, lon: 2 } }).street_level, null);
// Half a point is not mixed with the camera's other half.
const half = createView({
camera: { lat: 1, lon: 2 },
street_level: { provider: 'mapillary', lat: 40 },
});
assert.deepEqual(half.street_level, {
provider: 'mapillary',
lat: 1,
lon: 2,
});
});
test('street-level photos: a tilted camera opens them where it looks, a link cannot pair them with the wrong map, and follow is refused', async () => {
const { streetLevelFromParams } = await import('./index.js');
// Looking north from 1,000 m at 45°: the ground point is ~1 km north.
const tilted = createView({
camera: {
lat: 48.8494,
lon: 2.2945,
altitude_m: 1000,
heading_deg: 0,
pitch_deg: -45,
},
street_level: { provider: 'google' },
});
assert.ok(
tilted.street_level.lat > 48.858 && tilted.street_level.lat < 48.859,
String(tilted.street_level.lat),
);
assert.equal(tilted.street_level.lon, 2.2945);
const down = createView({
camera: { lat: 1, lon: 2, altitude_m: 1000 },
street_level: {},
});
assert.deepEqual(down.street_level, { lat: 1, lon: 2 });
// A hand-edited link: the photos win over the map.
const link = viewFromParams(
new URLSearchParams('v=2&lat=1&lon=2&map=osm&sl=google,1,2'),
);
assert.equal(link.map, 'photoreal');
assert.deepEqual(link.street_level, { provider: 'google', lat: 1, lon: 2 });
assert.throws(
() =>
createView({
camera: { lat: 1, lon: 2 },
follow: { kind: 'aircraft', id: 'a1' },
street_level: {},
}),
/cannot both follow/,
);
// A followed link drops its photos rather than fight over the camera.
const followed = viewFromParams(
viewToParams(
createView({
camera: { lat: 1, lon: 2 },
follow: { kind: 'satellite', id: '25544' },
}),
),
);
assert.equal(followed.street_level, null);
// Strict parsing: three fields, real numbers, in range, bounded length.
for (const bad of [
'google,1',
'google,1,2,3',
'google,1abc,2',
'google,91,0',
'google,,2',
'google,1,2'.padEnd(65, '0'),
])
assert.equal(
streetLevelFromParams(new URLSearchParams({ sl: bad })),
null,
bad,
);
});
test('the look-at default is street-scale only, follows the heading, and wraps at the antimeridian', async () => {
const { followFromParams, streetLevelFromParams } =
await import('./index.js');
const at = (camera) =>
createView({ camera, street_level: { provider: 'mapillary' } })
.street_level;
// Looking east from 1,000 m at 45° in Paris: ~1 km east is ~0.0137° of longitude.
const east = at({
lat: 48.85,
lon: 2.2945,
altitude_m: 1000,
heading_deg: 90,
pitch_deg: -45,
});
assert.ok(Math.abs(east.lon - (2.2945 + 0.01365)) < 0.0005, String(east.lon));
assert.ok(Math.abs(east.lat - 48.85) < 0.0002, String(east.lat));
// Looking west from the same place lands west.
assert.ok(
at({
lat: 48.85,
lon: 2.2945,
altitude_m: 1000,
heading_deg: 270,
pitch_deg: -45,
}).lon < 2.2945,
);
// At the horizon, with no altitude, or above street scale: the camera's own point.
assert.deepEqual(at({ lat: 1, lon: 2, altitude_m: 1000, pitch_deg: 0 }), {
provider: 'mapillary',
lat: 1,
lon: 2,
});
assert.deepEqual(at({ lat: 1, lon: 2, pitch_deg: -30 }), {
provider: 'mapillary',
lat: 1,
lon: 2,
});
assert.deepEqual(at({ lat: 1, lon: 2, altitude_m: 50_000, pitch_deg: -30 }), {
provider: 'mapillary',
lat: 1,
lon: 2,
});
// Across the antimeridian the longitude wraps instead of clamping.
const wrapped = at({
lat: 0,
lon: 179.999,
altitude_m: 1000,
heading_deg: 90,
pitch_deg: -45,
});
assert.ok(wrapped.lon < -179.9, String(wrapped.lon));
// A followed link keeps its camera: the photos are dropped even when both are in the hash.
const followed = viewToParams(
createView({
camera: { lat: 1, lon: 2 },
follow: { kind: 'satellite', id: '25544' },
}),
);
followed.set('sl', 'google,1.00000,2.00000');
assert.deepEqual(followFromParams(followed), {
kind: 'satellite',
id: '25544',
});
assert.equal(viewFromParams(followed).street_level, null);
assert.equal(followFromParams(new URLSearchParams('v=2&lat=1&lon=2')), null);
// Strict decimal degrees: no exponents, hex, blanks or over-long values.
for (const bad of [
'google,1e1,2',
'google,0x10,2',
'google, 1,2',
'google,1.,2',
`google,${'1.0000000'.padEnd(60, '0')},2`,
])
assert.equal(
streetLevelFromParams(new URLSearchParams({ sl: bad })),
null,
bad,
);
assert.deepEqual(
streetLevelFromParams(new URLSearchParams({ sl: ',-1.5,2' })),
{ lat: -1.5, lon: 2 },
);
});
test('the look-at thresholds are pinned: 20 km up, -5° of pitch, 10 km ahead', () => {
const at = (camera) => createView({ camera, street_level: {} }).street_level;
const own = { lat: 1, lon: 2 };
const moved = (point) => point.lat !== own.lat || point.lon !== own.lon;
// Altitude ceiling, at a steep pitch that keeps the distance under 10 km.
assert.ok(
moved(at({ ...own, altitude_m: 19_999, pitch_deg: -70 })),
'19,999 m applies',
);
assert.ok(
!moved(at({ ...own, altitude_m: 20_001, pitch_deg: -70 })),
'20,001 m does not',
);
// Pitch cutoff, at 100 m so the distance cap cannot decide it (1.4 km ahead).
assert.ok(
moved(at({ ...own, altitude_m: 100, pitch_deg: -6 })),
'-6° applies',
);
assert.ok(
!moved(at({ ...own, altitude_m: 100, pitch_deg: -4 })),
'-4° does not',
);
// Distance cap, pinned either side of 10 km at 1,000 m: -5.8° looks 9.84 km
// ahead, -5.6° 10.2 km.
assert.ok(
moved(at({ ...own, altitude_m: 1000, pitch_deg: -5.8 })),
'9.84 km ahead applies',
);
assert.ok(
!moved(at({ ...own, altitude_m: 1000, pitch_deg: -5.6 })),
'10.2 km ahead does not',
);
assert.ok(
!moved(at({ ...own, altitude_m: 10_000, pitch_deg: -30 })),
'17 km ahead does not',
);
assert.ok(
moved(at({ ...own, altitude_m: 5_000, pitch_deg: -30 })),
'8.7 km ahead applies',
);
// Near-vertical stays put (no tan blow-up).
assert.ok(!moved(at({ ...own, altitude_m: 1000, pitch_deg: -89.5 })));
// Western wrap and the pole clamp.
const west = at({
lat: 0,
lon: -179.999,
altitude_m: 1000,
heading_deg: 270,
pitch_deg: -45,
});
assert.ok(west.lon > 179.9, String(west.lon));
const pole = at({
lat: 89.9995,
lon: 0,
altitude_m: 1000,
heading_deg: 0,
pitch_deg: -45,
});
assert.ok(pole.lat <= 90, String(pole.lat));
});
test('sl rejects a bad longitude slot and over-precise values, and no length bound hides the regex', async () => {
const { streetLevelFromParams } = await import('./index.js');
for (const bad of [
'google,1,1e1',
'google,1,0x10',
'google,1, 2',
'google,1,2.',
'google,1.123456789,2',
'google,1,2.123456789',
])
assert.equal(
streetLevelFromParams(new URLSearchParams({ sl: bad })),
null,
bad,
);
assert.deepEqual(
streetLevelFromParams(
new URLSearchParams({ sl: 'google,-89.12345678,179.12345678' }),
),
{ provider: 'google', lat: -89.12345678, lon: 179.12345678 },
);
});
test('KartaView photos travel in a link and light KartaView alone, on any map', async () => {
const { streetLevelFromParams } = await import('./index.js');
const { decodeLayerStateParams } = await import('../data/layerState.js');
const view = createView({
camera: { lat: 52.52, lon: 13.405, altitude_m: 400 },
map: 'osm',
street_level: { provider: 'kartaview' },
});
assert.equal(view.map, 'osm', 'KartaView needs no particular map');
assert.deepEqual(view.layers, ['street-level']);
const params = viewToParams(view);
assert.equal(params.get('sl'), 'kartaview,52.52000,13.40500');
assert.deepEqual(viewFromParams(params), view);
const lit = decodeLayerStateParams(params).options['street-level'];
assert.equal(lit.mapillary, false);
assert.equal(
lit.providerSwitches,
'google-0*kartaview-1*panoramax-0',
'KartaView on, the others off',
);
assert.deepEqual(
streetLevelFromParams(new URLSearchParams({ sl: 'kartaview,1,2' })),
{
provider: 'kartaview',
lat: 1,
lon: 2,
},
);
});
test('Panoramax photos travel in a link and light Panoramax alone, on any map', async () => {
const { streetLevelFromParams } = await import('./index.js');
const { decodeLayerStateParams } = await import('../data/layerState.js');
const view = createView({
camera: { lat: 48.8874, lon: 2.3495, altitude_m: 400 },
map: 'osm',
street_level: { provider: 'panoramax' },
});
assert.equal(view.map, 'osm', 'Panoramax needs no particular map');
assert.deepEqual(view.layers, ['street-level']);
const params = viewToParams(view);
assert.equal(params.get('sl'), 'panoramax,48.88740,2.34950');
assert.deepEqual(viewFromParams(params), view);
const lit = decodeLayerStateParams(params).options['street-level'];
assert.equal(lit.mapillary, false);
assert.equal(
lit.providerSwitches,
'google-0*kartaview-0*panoramax-1',
'Panoramax on, the others off',
);
assert.deepEqual(
streetLevelFromParams(new URLSearchParams({ sl: 'panoramax,1,2' })),
{ provider: 'panoramax', lat: 1, lon: 2 },
);
});
test('a view naming the retired Snow style opens on Normal', () => {
const camera = { lat: 1, lon: 2 };
assert.equal(createView({ camera, style: 'snow' }).style, 'normal');
+30
View File
@@ -107,6 +107,36 @@ const schemas = [
required: ['layerId'],
},
},
{
name: 'show_street_level',
parameters: {
type: 'object',
additionalProperties: false,
properties: {
query: {
type: 'string',
maxLength: 160,
},
latitude: {
type: 'number',
minimum: -90,
maximum: 90,
},
longitude: {
type: 'number',
minimum: -180,
maximum: 180,
},
provider: {
type: 'string',
enum: ['google', 'mapillary', 'kartaview', 'panoramax'],
},
referent: {
type: 'integer',
},
},
},
},
{
name: 'adjust_camera_zoom',
parameters: {
+3 -1
View File
@@ -58,7 +58,8 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
// control_cockpit states the own-layer entry rule (Contacts not required).
// move_camera tilt takes an optional pitchDeg angle, described as
// absolute with its -45 default, with steps sent as a direction.
'6a0d154e68beb787c4e20f12d29a5c86577dc343f3e71d8b5651998ea45d8c3c',
// show_street_level (map, source, photo) adds one tool.
'0c27f62062001e6a07241cd301c3d357f55e9b74f66cd2904d16590510337468',
);
});
@@ -155,6 +156,7 @@ test('all legacy action arguments are byte-identical after removing the delibera
'resolve_area',
'find_imagery',
'osm_query',
'show_street_level',
].includes(tool.name),
);
// Layer enums are generated from the voice layer manifest and pinned by
+3
View File
@@ -143,6 +143,8 @@ function resolveFly(args, { pointer, referents }) {
entity: {
layerId: target.layerId,
id: target.id,
// The answer names it, not its id ("near cam-9").
...(target.label ? { label: target.label } : {}),
...(hasPosition(target) ? { lat: target.lat, lon: target.lon } : {}),
},
},
@@ -345,6 +347,7 @@ function resolveCropPixels(args, { imageFrame, cameraKey }) {
const RESOLVERS = Object.freeze({
track_entity: resolveTrack,
fly_to_location: resolveFly,
show_street_level: resolveFly,
get_entity_context: resolveContext,
analyst_query: resolveAnalyst,
annotate_map: resolveAnnotate,
+8 -1
View File
@@ -104,7 +104,7 @@ test('referents: numbered items resolve for track, fly and context', () => {
const last = resolveDeicticArgs('track_entity', { query: 'x', referent: -1 }, { referents });
assert.equal(last.args.query, 'ccc333', '-1 is the last one');
const fly = resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents });
assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111' } }, 'moving contacts are located at run time');
assert.deepEqual(fly.args, { entity: { layerId: 'flights', id: 'aaa111', label: 'SWA12' } }, 'moving contacts are located at run time');
const context = resolveDeicticArgs('get_entity_context', { referent: 3 }, { referents });
assert.equal(context.args.scope, 'target');
assert.equal(context.args.target.label, 'AAL9');
@@ -180,3 +180,10 @@ test('numbered custom-labeled pins retain their resolved coordinates', () => {
assert.deepEqual(resolveDeicticArgs('fly_to_location', { referent: 1 }, { referents }).args,
{ latitude: 30.27, longitude: -97.74 });
});
test('show_street_level: "street view of this" opens photos at the pointed ground point', () => {
const out = resolveDeicticArgs('show_street_level', { query: 'pointer', provider: 'google' }, { pointer: ground });
assert.deepEqual(out.args, { provider: 'google', latitude: 30.27, longitude: -97.74 });
assert.equal(out.used.source, 'pointer');
assert.deepEqual(resolveDeicticArgs('show_street_level', { query: 'Eiffel Tower' }, { pointer: ground }).args, { query: 'Eiffel Tower' });
});
+482 -1
View File
@@ -15,6 +15,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,
@@ -35,6 +36,10 @@ import {
isContextRecordActive,
} from '../data/contextStore.js';
import { CCTV_FOCUS_RESULT } from '../layers/cctv/index.js';
import {
NEAREST_RADIUS_MAX_M as STREET_LEVEL_WIDE_RADIUS_M,
STREET_LEVEL_LAYER_ID,
} from '../layers/streetLevel/policy.js';
import {
createAnalystEngine,
normalizeAnalystSpec,
@@ -55,6 +60,7 @@ import {
import * as defaultFloorServices from '../data/groundFloor.js';
import { isPickedWorldPosition } from '../data/scenePick.js';
import { unavailablePlaceSearch } from '../search/placeSearch.js';
import { watchCameraOwner } from '../navigationPolicy.js';
import * as defaultAnnotationResolver from '../annotations/annotationResolver.js';
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
@@ -503,7 +509,7 @@ export function createGevActionRunner({
const pending = dataManager.getAll?.().find((l) => l.id === layerId);
reportProgress('layer', pending?.name || layerId);
}
const changeOptions = { origin: 'voice' };
const changeOptions = { origin: actionIntentOrigin(runOptions) };
if (runOptions.signal) changeOptions.signal = runOptions.signal;
let changed = false;
let changeError = null;
@@ -796,6 +802,20 @@ export function createGevActionRunner({
};
}
if (name === 'show_street_level') {
return showStreetLevel(args, {
viewer,
dataManager,
styleManager,
placeSearch,
runGevAction,
runOptions,
current,
reportProgress,
otherHandoffs,
});
}
if (name === 'set_visual_style') {
const style = normalizeStyle(args.style);
if (!style)
@@ -2936,6 +2956,29 @@ function stopAllTracking(viewer, dataManager) {
return { ok: true, action: 'stop_tracking', released };
}
/**
* The imagery filter the lit source honours when it is narrower than the
* default (360° or flat only, or SINCE), or null.
* @returns {{ pano?: string, sinceDays?: number, label: string } | null}
*/
export function activeImageryFilter(ui) {
const filter = ui?.filter || {};
const controls = ui?.filterControls || {};
const out = {};
const names = [];
if (controls.pano && (filter.pano === 'pano' || filter.pano === 'flat')) {
out.pano = filter.pano;
names.push(filter.pano === 'pano' ? '360°' : 'flat-photo');
}
if (controls.since && Number(filter.sinceDays) > 0) {
out.sinceDays = Number(filter.sinceDays);
names.push('SINCE');
}
if (!names.length) return null;
out.label = `${names.join(' and ')} filter${names.length > 1 ? 's' : ''}`;
return out;
}
/**
* Frames entities near the current view target with a cinematic pull-back:
* oblique high pitch for aircraft/ships, shallow wide pitch for satellites.
@@ -3709,6 +3752,425 @@ function normalizeStyle(value) {
return null;
}
/** The application panel that holds the street-level photo. */
const STREET_LEVEL_PANEL_ID = 'street-level-panel';
/**
* The layer-state origin of an action's changes. The assistant's are the
* user's own choices and persist; a link replays someone else's view
* (`origin: 'programmatic'`) and never writes the recipient's preferences.
*/
function actionIntentOrigin(runOptions) {
return runOptions?.origin === 'programmatic' ? 'programmatic' : 'voice';
}
/**
* Whether cockpit, clean view, recording or scene playback hides the panel a
* street-level photo opens in. Embed mode uses clean view for its globe but
* lifts the photo out of the hidden rail.
*/
function streetLevelPanelHidden() {
const body = globalThis.document?.body;
const embedded = body?.classList?.contains('ui-embed') === true;
return EXCLUSIVE_SURFACE_CLASSES.some(
(mode) =>
body?.classList?.contains(mode) &&
!(embedded && mode === 'ui-clean-view'),
);
}
/**
* "Show me <place> on Street View / on Mapillary": fly there, light the source
* (and its map) and open the nearest image. Each stage reports its own
* failure, so speech can say which part did not happen. `autoOpen: false`
* (links) stops short of a billed load: the panel is armed for OPEN.
*/
export async function showStreetLevel(args, context) {
// After any await a newer turn, a navigation, a globe gesture or a mode
// that hides the panel may have taken over: each later stage checks again.
const owner = watchCameraOwner(context.viewer, context.styleManager);
// A photo held up while the request switches its source is let go however
// the request ends: replaced by then, or closed if its source stayed off.
let releaseViewer = null;
const holdViewer = (layerModule) => {
releaseViewer ??= layerModule.holdViewer?.() ?? null;
};
try {
return await requestStreetLevel(args, { ...context, owner, holdViewer });
} finally {
releaseViewer?.();
owner.stop();
}
}
async function requestStreetLevel(
args,
{
dataManager,
styleManager,
runGevAction,
owner,
holdViewer = () => {},
runOptions = {},
current = () => true,
reportProgress = () => {},
otherHandoffs = () => 0,
},
) {
const action = 'show_street_level';
// Navigations other than voice fly-tos (this request's own flight) so far.
const handoffs = otherHandoffs();
let provider = null;
let providerName = null;
let label = null;
const fail = (stage, error, extra = {}) => ({
ok: false,
action,
stage,
cancelled: !current(),
error,
...(provider ? { provider } : {}),
...(providerName ? { providerName } : {}),
...(label ? { label } : {}),
...(atViewCentre ? { atViewCentre } : {}),
...extra,
});
let atViewCentre = false;
const query = String(args.query || '').trim();
const latitude = Number(args.latitude);
const longitude = Number(args.longitude);
const coordinatesGiven = args.latitude != null && args.longitude != null;
const hasCoordinates =
coordinatesGiven && Math.abs(latitude) <= 90 && Math.abs(longitude) <= 180;
if (coordinatesGiven && !hasCoordinates)
return fail(
'location',
'Latitude must be within ±90° and longitude within ±180°',
);
// A referent that is a layer entity (deixis): located when the flight starts.
const entity =
args.entity && typeof args.entity === 'object' && args.entity.id
? args.entity
: null;
const layerModule =
dataManager?.layers?.get?.(STREET_LEVEL_LAYER_ID)?.module || null;
if (!layerModule || typeof layerModule.openNearest !== 'function')
return fail('provider', 'Street Level is unavailable');
const providerIds = Array.isArray(layerModule.providerIds)
? layerModule.providerIds
: [];
// The source: asked for, else the one lit now, else the first registered.
const requested = String(args.provider || '')
.trim()
.toLowerCase();
if (requested) {
if (!providerIds.includes(requested))
return fail(
'provider',
`${requested} is not a street-level imagery source here`,
);
provider = requested;
} else {
const params = dataManager.getLayerParams?.(STREET_LEVEL_LAYER_ID) || {};
provider =
providerIds.find((id) => params[id] === true) || providerIds[0] || null;
}
if (!provider)
return fail('provider', 'No street-level imagery source is configured');
const status = layerModule.providerStatus?.(provider) || null;
providerName = status?.name || provider;
// The key first: without one nothing changes, as with a chip.
if (status && (status.keyRequired === true || status.configured === false))
return fail('key', status.error || `${providerName} needs its key`, {
keyRejected: status.keyRejected === true,
keyAlternatives: layerModule.getUIState?.()?.keyAlternatives || [],
});
// A hidden panel refuses before the camera leaves a recording or scene.
const hidden = (extra = {}) =>
fail(
'view',
msg(
'Street-level photos are hidden in the current view. Leave it first.',
),
extra,
);
if (streetLevelPanelHidden()) return hidden();
const interrupted = (stage, extra = {}) => {
if (!current())
return fail(stage, 'Street-level request was cancelled', {
cancelled: true,
location,
...extra,
});
if (owner.taken())
return fail(stage, 'The camera moved on before the photo opened', {
cancelled: true,
superseded: true,
location,
...extra,
});
if (streetLevelPanelHidden()) return hidden({ location, ...extra });
return null;
};
// Then the place: a name that geocodes to nothing changes nothing else.
label =
query ||
(entity ? String(entity.label || entity.id) : '') ||
(hasCoordinates ? `${latitude.toFixed(4)}, ${longitude.toFixed(4)}` : '') ||
'the view centre';
atViewCentre = !query && !hasCoordinates && !entity;
let point = null;
let location = null;
// No place at all: the layer looks at the ground under the screen centre,
// as the OPEN button does. `fly: false` (tests) keeps the camera for a
// point given by its coordinates.
if (atViewCentre || (args.fly === false && hasCoordinates)) {
if (hasCoordinates) point = { lat: latitude, lon: longitude };
} else {
try {
location = await runGevAction(
'fly_to_location',
{
...(entity
? { entity }
: hasCoordinates
? { latitude, longitude }
: { query }),
viewMode: 'close',
waitForArrival: true,
},
runOptions,
);
} catch (error) {
// A geocoder that throws (offline, a provider down) is this stage's
// failure too, not an error past the action.
return fail('location', `Could not arrive at ${label}`, {
cancelled: !current(),
detail: String(error?.message || error),
});
}
if (location?.ok !== true || !current())
return fail(
'location',
location?.error || `Could not arrive at ${location?.label || label}`,
{ cancelled: !current() || Boolean(location?.cancelled), location },
);
// An item, or a named place flown to by its coordinates, keeps its name:
// such a flight reports only coordinates.
label =
entity || (query && hasCoordinates) ? label : location.label || label;
if (
!Number.isFinite(location.latitude) ||
!Number.isFinite(location.longitude)
)
return fail(
'location',
`${label} has no position to look for imagery at`,
{ location },
);
point = { lat: location.latitude, lon: location.longitude };
}
// A mode that hides the panel may have started during the flight. The
// camera is the request's from where its flight landed, unless another
// navigation took it during the flight.
if (location && otherHandoffs() === handoffs) owner.claim();
const arrived = interrupted('view');
if (arrived) return arrived;
// 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;
if (requiredStack && activeStack !== requiredStack) {
reportProgress('map', requiredStack);
const map = await runGevAction(
'set_map_stack',
{ stack: requiredStack },
runOptions,
);
if (map?.ok !== true && current())
return fail(
'map',
map?.error ||
`${providerName} needs the ${requiredStack} map, which is not available`,
{ map, location },
);
const stop = interrupted('map', { map });
if (stop) return stop;
}
// Light the source the way a chip click does, so the choice is kept. An
// open photo of another source stays up until this request replaces it.
const openPhoto = layerModule.getUIState?.()?.street;
if (openPhoto?.open === true && openPhoto.providerId !== provider)
holdViewer(layerModule);
const switches = Object.fromEntries(
providerIds.map((id) => [id, id === provider]),
);
let accepted = true;
if (typeof dataManager.setLayerParams === 'function')
accepted =
dataManager.setLayerParams(STREET_LEVEL_LAYER_ID, switches, {
origin: actionIntentOrigin(runOptions),
}) !== false;
else layerModule.setProviderEnabled?.(provider, true);
if (!accepted)
return fail('provider', `${providerName} could not be switched on`, {
location,
});
let layer = null;
if (!dataManager.isEnabled?.(STREET_LEVEL_LAYER_ID)) {
layer = await runGevAction(
'set_layer_visibility',
{ layerId: STREET_LEVEL_LAYER_ID, enabled: true },
runOptions,
);
if (layer?.ok !== true && current())
return fail(
'layer',
layer?.error || 'Street Level could not be enabled',
{ cancelled: Boolean(layer?.cancelled), layer, location },
);
const stop = interrupted('layer', { layer });
if (stop) return stop;
}
// Listed now that its map is up, and lit.
const ui = layerModule.getUIState?.() || {};
const snapshot =
(ui.providers || []).find((entry) => entry.id === provider) || null;
if (!snapshot)
return fail(
'provider',
ui.coverage?.hint || `${providerName} is not available on this map`,
{ location },
);
if (snapshot.on !== true)
return fail('provider', `${providerName} could not be switched on`, {
location,
});
// A link never loads a billed photo by itself: it arms OPEN instead.
if (args.autoOpen === false && status?.billed === true) {
// A photo of another place must not stay up beside the armed request.
if (layerModule.getUIState?.()?.street?.open === true)
layerModule.closeViewer?.();
layerModule.armNearest?.(point, {
providerIds: [provider],
...(snapshot.groundClick === true
? { radiusM: STREET_LEVEL_WIDE_RADIUS_M }
: {}),
});
return {
ok: true,
action,
provider,
providerName,
label,
armed: true,
flew: location !== null,
...(location ? { location: location.label } : {}),
};
}
// Street View has no coverage to click, so it searches wider: a geocoded
// landmark is rarely within 50 m of a street. An open photo stays up while
// this looks and closes if nothing opens; a found photo loads only while
// the camera and the panel are still the request's.
reportProgress('imagery', label);
const opened = await layerModule.openNearest(point, {
providerIds: [provider],
frame: false,
replace: true,
proceed: () => current() && !owner.taken() && !streetLevelPanelHidden(),
// Under the screen centre, not the middle of a tilted view's footprint;
// this request is the user's own, not a link's armed one.
...(point ? {} : { aim: 'screen-centre', armed: false }),
...(runOptions.signal ? { signal: runOptions.signal } : {}),
...(snapshot.groundClick === true
? { radiusM: STREET_LEVEL_WIDE_RADIUS_M }
: {}),
});
const street = layerModule.getUIState?.()?.street || {};
// A photo that opened after the camera moved on, or into a panel hidden
// meanwhile, is closed again.
const late = interrupted('imagery');
if (late) {
if (opened === true) layerModule.closeViewer?.();
return late;
}
if (opened !== true) {
// Why nothing opened: the key, a change under the lookup, or no imagery.
const after = layerModule.providerStatus?.(provider) || status || {};
if (after.keyRequired === true || after.keyRejected === true)
return fail(
'key',
street.error || after.error || `${providerName} needs its key`,
{ keyRejected: after.keyRejected === true, location },
);
if (
!dataManager.isEnabled?.(STREET_LEVEL_LAYER_ID) ||
after.on === false ||
after.available === false
)
return fail(
'imagery',
street.error || 'Street Level changed while it looked for imagery',
{ cancelled: true, location },
);
// The screen centre is sky: nothing was looked up.
if (street.nearestOutcome === 'sky')
return fail('imagery', street.error || 'Aim the view at a street', {
location,
aim: 'sky',
});
// The source or the viewer failed: an error, not missing coverage.
if (street.nearestOutcome !== 'empty')
return fail(
'imagery',
street.error || `${providerName} imagery could not be opened`,
{ location, imageryError: true },
);
// An imagery filter the source honours can hide every photo here; say
// so, or the answer reads as "no coverage" when one switch would do.
const filter = activeImageryFilter(layerModule.getUIState?.());
const reason = street.error || `No ${providerName} imagery near ${label}`;
return fail(
'imagery',
filter ? `${reason} with the ${filter.label} on` : reason,
{ location, noImagery: true, ...(filter ? { filter } : {}) },
);
}
// Every explicit request shows its photo, also one replacing a photo whose
// panel or phone sheet the user had closed. Embed mode lifts the photo.
if (!globalThis.document?.body?.classList?.contains('ui-embed')) {
styleManager?.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
persist: false,
});
styleManager?.revealPanel?.(STREET_LEVEL_PANEL_ID);
}
return {
ok: true,
action,
provider,
providerName,
label,
imageId: street.imageId ?? null,
title: street.title ?? null,
capturedAt: street.capturedAt ?? null,
capturedAtPrecision: street.capturedAtPrecision ?? null,
position: street.position ?? null,
externalUrl: street.externalUrl ?? null,
flew: location !== null,
...(atViewCentre ? { atViewCentre } : {}),
...(location ? { location: location.label } : {}),
};
}
async function flyToRequestedLocation(
viewer,
args,
@@ -3781,11 +4243,25 @@ async function flyToRequestedLocation(
);
if (result === false)
return cancelled(CITY_POIS[locationId]?.name || locationId);
// Where the camera went: the first POI for a close view, else the middle
// of the city's overview bounds.
const city = CITY_POIS[locationId];
const overview =
!(rangeM || args.viewMode === 'close') && Boolean(city?.viewBounds);
const centre = overview
? {
lat:
(city.viewBounds.southwest.lat + city.viewBounds.northeast.lat) / 2,
lon:
(city.viewBounds.southwest.lng + city.viewBounds.northeast.lng) / 2,
}
: city?.pois?.[0] || null;
const response = {
ok: Boolean(result),
action: 'fly_to_location',
locationId,
label: CITY_POIS[locationId]?.name || locationId,
...(centre ? { latitude: centre.lat, longitude: centre.lon } : {}),
rangeM: result?.range ? Math.round(result.range) : rangeM || null,
navigationMode: rangeM
? 'explicit-range'
@@ -3846,6 +4322,7 @@ async function flyToRequestedLocation(
action: 'fly_to_location',
query,
label: poi?.name || query,
...(poi ? { latitude: poi.lat, longitude: poi.lon } : {}),
navigationMode: rangeM ? 'preset-poi-range' : 'preset-poi',
rangeM: result?.range ? Math.round(result.range) : rangeM || null,
};
@@ -3876,6 +4353,10 @@ async function flyToRequestedLocation(
action: 'fly_to_location',
query,
label: destination?.label || query,
...(Number.isFinite(destination?.latitude) &&
Number.isFinite(destination?.longitude)
? { latitude: destination.latitude, longitude: destination.longitude }
: {}),
navigationMode: destination?.navigationMode || null,
rangeM: destination?.rangeM || rangeM || null,
};
+856
View File
@@ -30,6 +30,7 @@ import {
normalizeStackId,
} from './gevActions.js';
import { MAP_STACKS } from '../mapStackController.js';
import { KEY_REJECTED_MESSAGE } from '../layers/streetLevel/providers/google/policy.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { presentResult } from './resultDisplay.js';
@@ -6548,6 +6549,568 @@ test('analyst-only lookup distinguishes duplicate names and uses canonical aircr
assert.equal(plane.latitude, 36, 'canonical ID resolves the current position, not old coordinates');
});
/** A Street Level layer stub, a map stack and a data manager for show_street_level. */
function createStreetLevelHarness({
activeStack = 'osm',
lit = { mapillary: true, google: false },
enabled = false,
keyRequired = false,
configured = !keyRequired,
nearest = 'pano-a',
paramsAccepted = true,
enableResult = true,
listGoogle = true,
duringLookup = null,
keyRejectedAtStart = false,
lightFails = false,
imageryError = null,
sky = false,
photoOpen = false,
duringMapSwitch = null,
duringEnable = null,
} = {}) {
const order = [];
const lookups = [];
const origins = [];
const revealed = [];
const { viewer, styleManager, currentGeneration, panelSearch } = createVoiceNavigationHarness();
// Who owns the camera: the shell's navigation stamps and globe gestures.
Object.defineProperty(styleManager, '_navigationGeneration', {
get: () => currentGeneration(),
});
viewer.scene.canvas = Object.assign(new EventTarget(), { clientWidth: 1200, clientHeight: 800 });
let completeFlight = null;
viewer.camera.lookAt = () => {};
viewer.camera.flyToBoundingSphere = (_sphere, options) => {
order.push('fly');
completeFlight = options.complete;
};
styleManager.runImmediateLocationNavigation = (navigate) =>
styleManager.runImmediateNavigation('location', navigate);
let stack = activeStack;
styleManager.mapStackController = { getActiveId: () => stack };
styleManager.setMapStack = async (id) => {
order.push(`map:${id}`);
stack = id;
duringMapSwitch?.();
return { ok: true, activeStack: id, error: null };
};
let params = { ...lit };
let keyRejected = keyRejectedAtStart;
const street = photoOpen
? { open: true, imageId: 'eiffel-photo', error: null }
: { open: false, imageId: null, error: null };
const names = { google: 'Google Street View', mapillary: 'Mapillary' };
const layer = {
providerIds: ['mapillary', 'google'],
requiredMapStack: (id) => (id === 'google' ? 'photoreal' : null),
providerStatus: (id) =>
names[id]
? {
id,
name: names[id],
on: params[id] === true,
configured,
keyRequired: keyRequired || keyRejected,
keyRejected,
error: keyRejected ? KEY_REJECTED_MESSAGE : keyRequired ? `${names[id]} needs its key` : null,
groundClick: id === 'google',
billed: id === 'google',
available: id !== 'google' || stack === 'photoreal',
}
: null,
getUIState: () => ({
providers: ['mapillary', 'google']
.filter((id) => id !== 'google' || (stack === 'photoreal' && listGoogle))
.map((id) => ({
id,
name: id === 'google' ? 'Google Street View' : 'Mapillary',
on: params[id] === true,
configured: !keyRequired,
keyRequired,
groundClick: id === 'google',
hint: keyRequired ? `${id} needs its key` : '',
error: null,
})),
keyAlternatives: [],
coverage: { hint: 'not on this map' },
street: { ...street },
}),
closeViewer() {
order.push('close');
Object.assign(street, { open: false, imageId: null, error: null, nearestOutcome: null });
},
armNearest(point, options) {
order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`);
},
async openNearest(point, options) {
lookups.push({ point, ...options });
order.push(`open:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${options.frame}${point ? '' : ':view-centre'}`);
if (options.signal?.aborted) return false;
if (duringLookup) {
const effect = duringLookup();
if (effect === 'key-rejected') {
keyRejected = true;
street.error = KEY_REJECTED_MESSAGE;
return false;
}
if (effect === 'layer-off') {
enabled = false;
return false;
}
}
// Like the layer: a lookup its caller gave up leaves the panel alone,
// and a replacing lookup that opens nothing closes the photo.
if (options.proceed && options.proceed() !== true) return false;
const closeReplaced = () => {
if (options.replace && street.open) layer.closeViewer();
};
if (imageryError) {
closeReplaced();
street.error = imageryError;
street.nearestOutcome = null;
return false;
}
if (!nearest) {
closeReplaced();
street.nearestOutcome = options.aim === 'screen-centre' && sky ? 'sky' : 'empty';
street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`;
return false;
}
street.open = true;
street.imageId = nearest;
street.title = '10th St, Sacramento';
street.position = point;
return true;
},
};
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
isEnabled: () => enabled,
getLayerParams: () => ({ ...params }),
setLayerParams(id, next, { origin }) {
origins.push(origin);
order.push(`params:${Object.entries(next).filter(([, on]) => on).map(([k]) => k).join('+')}:${origin}`);
if (paramsAccepted === false) return false;
if (!lightFails) params = { ...params, ...next };
return true;
},
async setEnabled(id, _on, options) {
origins.push(options?.origin);
order.push(`enable:${id}`);
if (enableResult === false) return false;
enabled = true;
duringEnable?.();
return true;
},
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }],
};
styleManager.setPanelCollapsed = (id, collapsed) => revealed.push(`collapse:${id}:${collapsed}`);
styleManager.revealPanel = (id) => revealed.push(`reveal:${id}`);
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
return {
runner,
order,
finishFlight: () => completeFlight?.(),
layer,
dataManager,
lookups,
origins,
revealed,
street,
// The user searches or flies elsewhere (a stamp, as the shell's), or
// drags the globe.
navigate: panelSearch,
gesture: () => viewer.scene.canvas.dispatchEvent(new Event('pointerdown')),
};
}
/** A loaded CCTV camera the entity branch can locate. */
function withCamera(harness) {
harness.dataManager.layers.set('cctv', {
module: {
findByQuery: (id) => (id === 'cam-9' ? { latitude: 38.58, longitude: -121.5 } : null),
},
});
const wasEnabled = harness.dataManager.isEnabled;
harness.dataManager.isEnabled = (id) => id === 'cctv' || wasEnabled(id);
return harness;
}
test('show_street_level flies first, then switches the map, lights the source, turns the layer on and opens the nearest photo', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { runner, order, finishFlight } = createStreetLevelHarness();
const pending = runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google' });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(order, ['fly'], 'nothing changes until the place is reached');
finishFlight();
const result = await pending;
assert.equal(result.ok, true, result.error);
assert.deepEqual(order, [
'fly',
'map:photoreal',
'params:google:voice',
'enable:street-level',
'open:google:250:false',
]);
assert.equal(result.provider, 'google');
assert.equal(result.providerName, 'Google Street View');
assert.equal(result.imageId, 'pano-a');
assert.equal(result.title, '10th St, Sacramento');
assert.equal(result.flew, true);
// A name given with its coordinates is what the answer says.
const named = createStreetLevelHarness();
const naming = named.runner('show_street_level', {
query: 'Capitol Mall', latitude: 38.5799, longitude: -121.4937, provider: 'google',
});
await new Promise((resolve) => setImmediate(resolve));
named.finishFlight();
assert.equal((await naming).label, 'Capitol Mall');
});
test('show_street_level on Mapillary keeps the map, searches at the default radius and skips what is already on', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { runner, order } = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const result = await runner('show_street_level', {
latitude: 38.58,
longitude: -121.49,
provider: 'mapillary',
fly: false,
});
assert.equal(result.ok, true, result.error);
assert.deepEqual(order, ['params:mapillary:voice', 'open:mapillary:default:false']);
assert.equal(result.flew, false);
});
test('show_street_level without a source uses the one lit in the panel', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { runner, order } = createStreetLevelHarness({
activeStack: 'photoreal',
lit: { mapillary: false, google: true },
enabled: true,
});
const result = await runner('show_street_level', { latitude: 1, longitude: 2, fly: false });
assert.equal(result.ok, true, result.error);
assert.equal(result.provider, 'google');
assert.deepEqual(order, ['params:google:voice', 'open:google:250:false']);
});
test('show_street_level reports each stage it could not pass', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const centre = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const none = await centre.runner('show_street_level', { provider: 'google' });
assert.equal(none.ok, true, 'no place at all means the view centre');
assert.equal(none.label, 'the view centre');
assert.deepEqual(centre.order, ['params:google:voice', 'open:google:250:false:view-centre']);
const offGlobe = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const outOfRange = await offGlobe.runner('show_street_level', { latitude: 95, longitude: 2, provider: 'google' });
assert.deepEqual([outOfRange.ok, outOfRange.stage], [false, 'location']);
assert.deepEqual(offGlobe.order, [], 'nothing flies, switches or looks up');
const unknown = await createStreetLevelHarness().runner('show_street_level', {
latitude: 1, longitude: 2, provider: 'openstreetcam', fly: false,
});
assert.deepEqual([unknown.ok, unknown.stage], [false, 'provider']);
assert.match(unknown.error, /openstreetcam/);
const keylessHarness = createStreetLevelHarness({ enabled: true, keyRequired: true });
const keyless = await keylessHarness.runner('show_street_level', {
latitude: 1, longitude: 2, provider: 'google', fly: false,
});
assert.deepEqual([keyless.ok, keyless.stage], [false, 'key']);
assert.match(keyless.error, /needs its key/);
assert.equal(keyless.providerName, 'Google Street View');
assert.deepEqual(keylessHarness.order, [], 'a keyless source changes nothing: no flight, no map, no switch');
const refusedSwitch = await createStreetLevelHarness({ enabled: true, paramsAccepted: false }).runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'google', fly: false },
);
assert.deepEqual([refusedSwitch.ok, refusedSwitch.stage], [false, 'provider']);
assert.match(refusedSwitch.error, /could not be switched on/);
const empty = await createStreetLevelHarness({ enabled: true, nearest: null }).runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'google', fly: false },
);
assert.deepEqual([empty.ok, empty.stage], [false, 'imagery']);
assert.match(empty.error, /within 250 m/);
});
test('show_street_level stops at the map when the source’s map is refused, before touching the panel', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { layer, order } = createStreetLevelHarness({ enabled: true });
const reason = 'Google 3D needs GOOGLE_MAPS_API_KEY';
const dataManager = {
layers: new Map([['street-level', { module: layer }]]),
isEnabled: () => true,
getLayerParams: () => ({ mapillary: true, google: false }),
setLayerParams: () => order.push('params'),
getAll: () => [],
};
const { viewer, styleManager } = createVoiceNavigationHarness();
styleManager.mapStackController = { getActiveId: () => 'osm' };
styleManager.setMapStack = async () => ({ ok: false, activeStack: 'osm', error: reason });
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
const result = await runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage, result.error], [false, 'map', reason]);
assert.deepEqual(order, [], 'the source is not switched when its map is refused');
});
test('show_street_level opens photos of a numbered item or a pointed spot through its entity', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { runner, order, finishFlight } = withCamera(
createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }),
);
const pending = runner('show_street_level', {
provider: 'google',
entity: { layerId: 'cctv', id: 'cam-9', label: 'Camera 9', lat: 38.58, lon: -121.5 },
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(order, ['fly'], 'the entity position is flown to');
finishFlight();
const result = await pending;
assert.equal(result.ok, true, result.error);
assert.equal(result.label, 'Camera 9');
assert.deepEqual(order, ['fly', 'params:google:voice', 'open:google:250:false']);
});
test('show_street_level hands the point from a named place, a preset and a curated POI to the lookup', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const points = [];
const make = () => {
const harness = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const open = harness.layer.openNearest;
harness.layer.openNearest = async (point, options) => {
points.push(point);
return open(point, options);
};
return harness;
};
// A geocoded name: the flight reports where it landed.
const named = make();
const viewerFor = () => ({ ...createVoiceNavigationHarness().viewer });
const runnerWithSearch = createGevActionRunner({
viewer: viewerFor(),
styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) },
dataManager: {
layers: new Map([['street-level', { module: named.layer }]]),
isEnabled: () => true,
getLayerParams: () => ({ mapillary: true, google: false }),
setLayerParams: () => true,
getAll: () => [],
},
searchNavigation: async (_viewer, query, options) => {
options.onComplete?.();
return { label: `${query}, CA`, latitude: 38.5799, longitude: -121.4937, navigationMode: 'precise-place', rangeM: 250 };
},
});
const geocoded = await runnerWithSearch('show_street_level', { query: '10th Street, Sacramento', provider: 'mapillary' });
assert.equal(geocoded.ok, true, geocoded.error);
assert.equal(geocoded.label, '10th Street, Sacramento, CA');
assert.deepEqual(points.at(-1), { lat: 38.5799, lon: -121.4937 });
// A curated POI and a city preset fly to their hand-tuned poses and report them.
for (const [args, expected] of [
[{ query: 'Golden Gate Bridge', provider: 'mapillary' }, { lat: 37.8199, lon: -122.4783 }],
[{ query: 'austin', provider: 'mapillary' }, null],
]) {
const harness = make();
const pending = harness.runner('show_street_level', args);
await new Promise((resolve) => setImmediate(resolve));
harness.finishFlight();
const result = await pending;
assert.equal(result.ok, true, `${args.query}: ${result.error}`);
const point = points.at(-1);
if (expected) assert.deepEqual(point, expected);
else assert.ok(Number.isFinite(point.lat) && Number.isFinite(point.lon), 'a preset flight reports its point');
}
});
test('show_street_level retires its lookup when the turn is superseded, and says so', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { runner, order, layer } = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const controller = new AbortController();
let currentTurn = true;
layer.openNearest = async (_point, options) => {
order.push(`open:signal:${Boolean(options.signal)}`);
controller.abort();
currentTurn = false;
return false;
};
const result = await runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'google', fly: false },
{ signal: controller.signal, isCurrent: () => currentTurn },
);
assert.deepEqual([result.ok, result.cancelled, result.stage], [false, true, 'imagery']);
assert.doesNotMatch(result.error, /No .* imagery/);
assert.ok(order.includes('open:signal:true'), 'the lookup gets the turn signal');
});
test('show_street_level with autoOpen:false arms a billed source and leaves the load to a click; a free source still opens', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const armedHarness = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const armed = await armedHarness.runner('show_street_level', {
latitude: 38.5799, longitude: -121.4937, provider: 'google', fly: false, autoOpen: false,
});
assert.equal(armed.ok, true, armed.error);
assert.equal(armed.armed, true);
assert.equal(armed.imageId, undefined);
assert.deepEqual(
armedHarness.order,
['map:photoreal', 'params:google:voice', 'arm:google:250:38.5799,-121.4937'],
'map and source are ready, the lookup is armed at the point, nothing is loaded',
);
assert.match(armed.say, /^Google Street View is ready at .*press OPEN/);
assert.deepEqual(
{ ...armed, say: undefined, display: undefined },
{ ok: true, action: 'show_street_level', provider: 'google', providerName: 'Google Street View', label: '38.5799, -121.4937', armed: true, flew: false, say: undefined, display: undefined },
);
const free = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const opened = await free.runner('show_street_level', {
latitude: 38.5799, longitude: -121.4937, provider: 'mapillary', fly: false, autoOpen: false,
});
assert.equal(opened.ok, true, opened.error);
assert.equal(opened.armed, undefined);
assert.equal(opened.imageId, 'pano-a');
});
test('show_street_level tells a failed enable, a delisted source, a key refused mid-lookup and a layer switched off apart', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const at = { latitude: 1, longitude: 2, provider: 'google', fly: false };
const notEnabled = await createStreetLevelHarness({ activeStack: 'photoreal', enableResult: false }).runner('show_street_level', at);
assert.deepEqual([notEnabled.ok, notEnabled.stage, notEnabled.cancelled], [false, 'layer', false]);
assert.match(notEnabled.say, /Couldn't turn on Street Level/);
const delisted = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', listGoogle: false }).runner('show_street_level', at);
assert.deepEqual([delisted.ok, delisted.stage], [false, 'provider']);
assert.equal(delisted.error, 'not on this map');
const refused = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => 'key-rejected' }).runner('show_street_level', at);
assert.deepEqual([refused.ok, refused.stage, refused.keyRejected], [false, 'key', true]);
assert.equal(refused.say, 'Google Street View refused its key.');
const switchedOff = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => 'layer-off' }).runner('show_street_level', at);
assert.deepEqual([switchedOff.ok, switchedOff.stage, switchedOff.cancelled], [false, 'imagery', true]);
assert.equal(switchedOff.say, undefined, 'a request the user withdrew is not spoken');
// A source whose status call says it is not configured is key-gated even
// before its coverage reports a missing key.
const unconfigured = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', configured: false });
const gated = await unconfigured.runner('show_street_level', at);
assert.deepEqual([gated.ok, gated.stage], [false, 'key']);
assert.deepEqual(unconfigured.order, [], 'nothing changed');
});
test('show_street_level reports a geocoder that throws as its location stage', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
const runner = createGevActionRunner({
viewer: createVoiceNavigationHarness().viewer,
styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) },
dataManager: h.dataManager,
placeSearch: {
geocode: async () => {
throw new Error('geocoder unreachable');
},
},
});
const result = await runner('show_street_level', { query: 'Capitol Mall', provider: 'mapillary' });
assert.deepEqual([result.ok, result.stage], [false, 'location']);
assert.equal(result.error, 'Could not arrive at Capitol Mall');
assert.deepEqual(h.order.filter((step) => /^(params|open|arm)/.test(step)), [], 'nothing switched or looked up');
});
test('show_street_level reports a flight that lands without a position', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const hit = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
// A flight that reports no coordinates cannot be followed by a lookup.
const noPoint = createGevActionRunner({
viewer: createVoiceNavigationHarness().viewer,
styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) },
dataManager: hit.dataManager,
searchNavigation: async (_viewer, query, options) => {
options.onComplete?.();
return { label: query, navigationMode: 'city-overview', rangeM: null };
},
});
const unplaced = await noPoint('show_street_level', { query: 'Somewhere', provider: 'mapillary' });
assert.deepEqual([unplaced.ok, unplaced.stage], [false, 'location']);
assert.match(unplaced.error, /no position/);
});
test('fly_to_location reports the overview centre for a city framed as a whole, and the first POI for a close view', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const { viewer, styleManager } = createVoiceNavigationHarness();
viewer.camera.lookAt = () => {};
viewer.camera.flyTo = (options) => options.complete?.();
viewer.camera.flyToBoundingSphere = (_s, options) => options.complete?.();
styleManager.runImmediateLocationNavigation = (navigate) => styleManager.runImmediateNavigation('location', navigate);
const runner = createGevActionRunner({ viewer, styleManager, dataManager: { layers: new Map(), getAll: () => [] } });
const overview = await runner('fly_to_location', { locationId: 'austin' });
assert.equal(overview.ok, true);
assert.ok(Math.abs(overview.latitude - 30.31) < 0.001, String(overview.latitude));
assert.ok(Math.abs(overview.longitude - -97.75) < 0.001, String(overview.longitude));
const close = await runner('fly_to_location', { locationId: 'austin', viewMode: 'close' });
assert.deepEqual([close.latitude, close.longitude], [30.2747, -97.7403]);
});
test('show_street_level arms the view centre when a billed link names no place, and keeps the flight label when it flew', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const centre = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const armed = await centre.runner('show_street_level', { provider: 'google', autoOpen: false });
assert.equal(armed.ok, true, armed.error);
assert.equal(armed.armed, true);
assert.deepEqual(centre.order, ['params:google:voice', 'arm:google:250:view-centre'], 'armed at the view centre, nothing opened');
const flown = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
const pending = flown.runner('show_street_level', { latitude: 38.5799, longitude: -121.4937, provider: 'google', autoOpen: false });
await new Promise((resolve) => setImmediate(resolve));
flown.finishFlight();
const result = await pending;
assert.equal(result.armed, true);
assert.equal(result.flew, true);
assert.equal(result.location, '38.5799, -121.4937');
assert.ok(!flown.order.some((entry) => entry.startsWith('open:')), 'no load after the flight');
});
test('show_street_level says "refused its key" when Google already refused it, and fails when the source will not light', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const refusedHarness = createStreetLevelHarness({ enabled: true, keyRejectedAtStart: true });
const refused = await refusedHarness.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'google', fly: false });
assert.deepEqual([refused.ok, refused.stage, refused.keyRejected], [false, 'key', true]);
assert.equal(refused.error, KEY_REJECTED_MESSAGE);
assert.equal(refused.say, 'Google Street View refused its key.');
assert.deepEqual(refusedHarness.order, [], 'nothing changed');
const dark = await createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', lightFails: true }).runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'google', fly: false },
);
assert.deepEqual([dark.ok, dark.stage], [false, 'provider']);
assert.match(dark.error, /could not be switched on/);
});
test('activeImageryFilter reports only the narrowing filters the lit source honours', async () => {
const { activeImageryFilter } = await import('./gevActions.js');
const both = { pano: true, since: true };
assert.equal(activeImageryFilter({ filter: { pano: 'all', sinceDays: 0 }, filterControls: both }), null);
assert.deepEqual(activeImageryFilter({ filter: { pano: 'pano', sinceDays: 0 }, filterControls: both }), {
pano: 'pano',
label: '360° filter',
});
assert.deepEqual(activeImageryFilter({ filter: { pano: 'flat', sinceDays: 365 }, filterControls: both }), {
pano: 'flat',
sinceDays: 365,
label: 'flat-photo and SINCE filters',
});
assert.equal(
activeImageryFilter({ filter: { pano: 'pano', sinceDays: 365 }, filterControls: { pano: false, since: false } }),
null,
'Street View honours neither',
);
assert.equal(activeImageryFilter(undefined), null);
});
test('analyst item display strings are single-line and bounded for voice', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const rows = [{ id: 'stable-id', name: 'Şehir\n' + 'x'.repeat(3000), label: 'Town\tStation', operator: 'City\nPD', lat: 30, lon: -97 }];
@@ -6688,3 +7251,296 @@ 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 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;
}
}
});
test('show_street_level checks the panel again after every wait: a mode entered meanwhile loads nothing unseen', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
const classes = new Set();
globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } };
try {
for (const [mode, when] of [
['ui-clean-view', 'duringEnable'],
['cockpit-mode', 'duringMapSwitch'],
]) {
classes.clear();
const h = createStreetLevelHarness({ [when]: () => classes.add(mode) });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage], [false, 'view'], mode);
assert.deepEqual(h.order.filter((step) => /^(open|arm)/.test(step)), [], `${mode}: no billed load`);
assert.deepEqual(h.revealed, [], `${mode}: nothing revealed`);
}
// Entered while the photo loaded: it is closed again, not reported as shown.
classes.clear();
const late = createStreetLevelHarness({ enabled: true });
const open = late.layer.openNearest;
late.layer.openNearest = async (...call) => {
const opened = await open(...call);
classes.add('recording-mode');
return opened;
};
const result = await late.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.deepEqual([result.ok, result.stage], [false, 'view']);
assert.equal(late.order.at(-1), 'close');
assert.deepEqual(late.revealed, []);
} finally {
globalThis.document = saved;
}
});
test('show_street_level refuses a hidden panel before the camera leaves a recording', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
globalThis.document = { body: { classList: { contains: (name) => name === 'recording-mode' } } };
try {
const { runner, order } = createStreetLevelHarness();
const result = await runner('show_street_level', { query: 'Eiffel Tower', provider: 'mapillary' });
assert.deepEqual([result.ok, result.stage], [false, 'view']);
assert.deepEqual(order, [], 'no flight');
} finally {
globalThis.document = saved;
}
});
test('show_street_level stops once the camera is someone else’s: a navigation or a gesture during the map switch', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
for (const take of ['navigate', 'gesture']) {
let h;
h = createStreetLevelHarness({ duringMapSwitch: () => h[take]() });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.equal(result.ok, true, `${take}: Mapillary needs no map switch`);
h = createStreetLevelHarness({ duringMapSwitch: () => h[take]() });
const google = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([google.ok, google.stage, google.cancelled, google.superseded], [false, 'map', true, true], take);
assert.deepEqual(h.order.filter((step) => /^(params|enable|open|arm)/.test(step)), [], `${take}: nothing lit for the old place`);
}
// During the layer switch-on: the photo lookup never runs.
let h;
h = createStreetLevelHarness({ duringEnable: () => h.navigate() });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.deepEqual([result.ok, result.stage, result.superseded], [false, 'layer', true]);
assert.ok(!h.order.some((step) => step.startsWith('open:')));
});
test('show_street_level loads no photo it found once the camera or the panel moved on during the lookup', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
for (const take of ['navigate', 'gesture']) {
let h;
h = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => h[take]() });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage, result.superseded], [false, 'imagery', true], take);
assert.equal(h.lookups.length, 1);
assert.equal(h.street.open, false, `${take}: the billed panorama never loads`);
assert.ok(!h.order.includes('close'), `${take}: nothing opened, nothing to close`);
}
const saved = globalThis.document;
const classes = new Set();
globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } };
try {
const h = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal', duringLookup: () => classes.add('ui-clean-view') });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google', fly: false });
assert.deepEqual([result.ok, result.stage], [false, 'view']);
assert.equal(h.street.open, false, 'nothing loads into the hidden panel');
} finally {
globalThis.document = saved;
}
});
test('show_street_level replaces an open photo in place: the viewer (and EXPAND) stays up while it looks', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness({ enabled: true, photoOpen: true, nearest: 'pano-b' });
const result = await h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
assert.equal(result.ok, true);
assert.equal(h.lookups[0].replace, true, 'the lookup takes over the open photo');
assert.ok(!h.order.includes('close'), 'never closed on the way');
assert.deepEqual([h.street.open, h.street.imageId], [true, 'pano-b']);
});
test('show_street_level owns the camera from where its own flight lands', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness();
const pending = h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary' });
await new Promise((resolve) => setTimeout(resolve, 0));
h.finishFlight();
const result = await pending;
assert.equal(result.ok, true, result.error);
assert.ok(h.order.some((step) => step.startsWith('open:mapillary')));
});
// A camera taken during the action's own flight. 'navigate' is a
// location-panel search: a stamp that leaves the flight flying. 'gesture' is a
// press on the globe, which does not cancel a Cesium flight either.
for (const take of ['navigate', 'gesture']) {
test(`show_street_level stands down when the camera is taken during its own flight (${take})`, async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness();
const pending = h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google' });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(h.order, ['fly'], 'the flight is under way');
h[take]();
h.finishFlight();
const result = await pending;
assert.deepEqual([result.ok, result.superseded], [false, true], result.error);
assert.deepEqual(
h.order.filter((step) => /^(map|params|open|arm|enable)/.test(step)),
[],
'no map switch, no source, no billed photo for the place the user left',
);
});
}
test('show_street_level closes the previous photo, so one that finds nothing leaves no other place on screen', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const empty = createStreetLevelHarness({ enabled: true, photoOpen: true, nearest: null });
const result = await empty.runner('show_street_level', { latitude: 23.4, longitude: 12.5, provider: 'mapillary', fly: false });
assert.deepEqual([result.ok, result.noImagery], [false, true]);
assert.equal(empty.street.open, false, 'the Paris photo is gone');
assert.match(empty.street.error, /No street-level imagery/, 'the panel shows why');
// A link that only arms a billed source closes it too.
const armed = createStreetLevelHarness({ enabled: true, photoOpen: true, activeStack: 'photoreal' });
await armed.runner('show_street_level', { latitude: 23.4, longitude: 12.5, provider: 'google', fly: false, autoOpen: false });
assert.deepEqual(armed.order.filter((step) => /^(close|arm)/.test(step)).map((step) => step.split(':')[0]), ['close', 'arm']);
});
test('show_street_level opens over the clean embedded globe but respects its other exclusive modes', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const saved = globalThis.document;
try {
for (const mode of [null, 'cockpit-mode', 'recording-mode', 'scene-playback-mode']) {
const classes = new Set(['ui-embed', 'ui-clean-view', mode]);
globalThis.document = { body: { classList: { contains: (name) => classes.has(name) } } };
const { runner, order } = createStreetLevelHarness({ enabled: true });
const result = await runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'mapillary', fly: false });
if (mode) {
assert.deepEqual([result.ok, result.stage], [false, 'view'], mode);
assert.deepEqual(order.filter((step) => /^(map|params|open|arm|enable)/.test(step)), [], `${mode}: nothing switched`);
} else {
assert.equal(result.ok, true, 'embed clean view lifts the photo over the globe');
assert.ok(order.some((step) => step.startsWith('open:mapillary')));
}
}
} finally {
globalThis.document = saved;
}
});
test('show_street_level from a link keeps the recipient’s preferences: its switches are not the user’s', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const link = createStreetLevelHarness();
const linked = await link.runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'mapillary', fly: false },
{ origin: 'programmatic' },
);
assert.equal(linked.ok, true, linked.error);
assert.deepEqual(link.origins, ['programmatic', 'programmatic'], 'source switch and layer on');
const asked = createStreetLevelHarness();
await asked.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
assert.deepEqual(asked.origins, ['voice', 'voice'], 'the assistant’s request is the user’s choice');
});
test('show_street_level with no place looks under the screen centre, not a link’s armed point', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const here = createStreetLevelHarness({ enabled: true });
const result = await here.runner('show_street_level', { provider: 'mapillary' });
assert.equal(result.ok, true, result.error);
assert.equal(result.atViewCentre, true);
assert.equal(here.lookups[0].point, null);
assert.equal(here.lookups[0].aim, 'screen-centre');
assert.equal(here.lookups[0].armed, false);
const placed = createStreetLevelHarness({ enabled: true });
await placed.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
assert.equal(placed.lookups[0].aim, undefined, 'a place is looked up where it is');
const sky = createStreetLevelHarness({ enabled: true, nearest: null, sky: true });
const refused = await sky.runner('show_street_level', { provider: 'mapillary' });
assert.deepEqual([refused.ok, refused.stage, refused.aim], [false, 'imagery', 'sky']);
});
test('show_street_level tells missing imagery from a failing source', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const empty = await createStreetLevelHarness({ enabled: true, nearest: null }).runner('show_street_level', {
latitude: 1, longitude: 2, provider: 'mapillary', fly: false,
});
assert.deepEqual([empty.stage, empty.noImagery, empty.imageryError], ['imagery', true, undefined]);
const broken = await createStreetLevelHarness({ enabled: true, imageryError: 'Mapillary graph HTTP 500' }).runner(
'show_street_level',
{ latitude: 1, longitude: 2, provider: 'mapillary', fly: false },
);
assert.deepEqual([broken.stage, broken.noImagery, broken.imageryError], ['imagery', undefined, true]);
assert.equal(broken.error, 'Mapillary graph HTTP 500');
});
test('show_street_level shows every photo it opens, also over a panel the user had closed', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const harness = createStreetLevelHarness({ enabled: true });
await harness.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
await harness.runner('show_street_level', { latitude: 3, longitude: 4, provider: 'mapillary', fly: false });
assert.deepEqual(harness.revealed, [
'collapse:street-level-panel:false',
'reveal:street-level-panel',
'collapse:street-level-panel:false',
'reveal:street-level-panel',
]);
const missing = createStreetLevelHarness({ enabled: true, nearest: null });
await missing.runner('show_street_level', { latitude: 1, longitude: 2, provider: 'mapillary', fly: false });
assert.deepEqual(missing.revealed, [], 'nothing opened, nothing revealed');
});
+4 -4
View File
@@ -714,13 +714,13 @@ test('without an operator Overpass the prompt never offers place search', () =>
});
test('the Realtime prompt stays within its size budget', () => {
// Instructions plus tool schemas, in characters, under the same 55,500
// Instructions plus tool schemas, in characters, under the same 56,500
// ceiling as the deixis prompt lint. Voice geometry adds resolve_area,
// find_imagery and the analyst area scopes (osm_query only with an
// operator Overpass).
// operator Overpass); show_street_level adds its tool and routing rule.
const size = (overpass) =>
realtimeInstructions(undefined, { overpass }).length +
JSON.stringify(realtimeTools({ overpass })).length;
assert.ok(size(false) <= 55_500, `default prompt ${size(false)} chars`);
assert.ok(size(true) <= 55_500, `with Overpass ${size(true)} chars`);
assert.ok(size(false) <= 56_500, `default prompt ${size(false)} chars`);
assert.ok(size(true) <= 56_500, `with Overpass ${size(true)} chars`);
});
+6 -1
View File
@@ -364,7 +364,12 @@ const layers = [
},
{
id: 'street-level',
aliases: ['street level', 'street-level imagery', 'mapillary'],
aliases: [
'street level',
'street-level imagery',
'mapillary',
'street view',
],
context: false,
noQuery: 'street-level imagery; no countable records',
},
+1
View File
@@ -119,6 +119,7 @@ test('spoken aliases resolve to real layers, keep every legacy phrase and never
'weather radar': 'weather-radar', lightning: 'weather-lightning', 'military bases': 'military-installations',
'recent imagery': 'recent-imagery', 'bike share': 'bikeshare', 'rocket launches': 'rocket-launches',
'street level': 'street-level', mapillary: 'street-level',
'street view': 'street-level',
wind: 'wind', directions: 'directions', 'fire perimeters': 'fire-perimeters',
'wildfire perimeters': 'fire-perimeters', 'burn areas': 'fire-perimeters',
};
+4 -12
View File
@@ -343,19 +343,11 @@ test('deixis prompt lint: one short rule, and every sentinel it names exists in
tool('annotate_map').annotations.items.properties.target.description,
/"pointer"/,
);
// Budget: the whole Realtime prompt (instructions + tools), measured in
// characters. It was 56,308 after the area/imagery/OSM merge; stating each
// policy once (WHERE precedence, AREAS routing, AFTER TOOLS speech) brought
// it to 51,646. Voice geometry (resolve_area, find_imagery, osm_query and
// the analyst area scopes) raised the ceiling to 55,500; tightening the
// AREAS rule and the area tool descriptions left it at 55,103 as measured
// below (every tool, osm_query included); main's Sea Marks and Ski Pistes
// layers bring it to 55,210, and the tilt pitchDeg argument (with its -45
// default) and main's cockpit entry rule to 55,339. integrationContract's
// Overpass prompt is the tightest, at 55,499 (1 under). A new tool or rule
// has to fit under this ceiling.
// Budget: the whole Realtime prompt (instructions + every tool, osm_query
// included), in characters. Every session pays for it in latency and cost,
// so a new tool or rule has to fit under this ceiling or earn a raise.
const size = instructions.length + JSON.stringify(GEV_REALTIME_TOOLS).length;
assert.ok(size <= 55_500, `prompt size ${size}`);
assert.ok(size <= 56_500, `prompt size ${size}`);
});
test('after-tool speech allows returned items to answer requested lists and rankings', () => {
+1
View File
@@ -27,6 +27,7 @@ const CAMERA_TOOLS = new Set([
'fly_route',
'frame_overhead',
'select_nearest_aircraft',
'show_street_level',
'control_cockpit',
]);
+204
View File
@@ -24,6 +24,7 @@
import { voiceLayer } from './layerManifest.js';
import {
formatDate,
formatNumber,
getLocale,
msg,
@@ -178,9 +179,27 @@ const KNOWN_ERRORS = new Map(
),
msg('Could not arrive at the requested place'),
msg('The nearest airborne aircraft could not be selected'),
// show_street_level
msg('Latitude must be within ±90° and longitude within ±180°'),
msg('Street Level is unavailable'),
msg('No street-level imagery source is configured'),
msg('Street Level could not be enabled'),
msg('Street Level changed while it looked for imagery'),
msg('Aim the view at a street'),
].map((text) => [text, text]),
);
/** The imagery filters an empty street-level result names, as its chips say them. */
function shownImageryFilters(label) {
return [
label.includes('360°') ? '360°' : null,
label.includes('flat-photo') ? t('FLAT') : null,
label.includes('SINCE') ? t('SINCE') : null,
]
.filter(Boolean)
.join(', ');
}
function noneWithinView(target, radius) {
switch (target) {
case 'flights':
@@ -258,6 +277,69 @@ const ERROR_PATTERNS = [
/^No (\S+) within (\d+(?:\.\d+)?) km of the current view$/,
([, target, km]) => noneWithinView(target, shownKm(km)),
],
// show_street_level: the filter clause first, around the reason it extends.
[
/^(.+) with the (.+ filters?) on$/,
([, reason, filters]) =>
t('{reason} (filters on: {filters})', {
reason: shownError(reason),
filters: shownImageryFilters(filters),
}),
],
[
/^(.+) is not a street-level imagery source here$/,
([, source]) =>
t('{source} is not a street-level imagery source here', { source }),
],
[
/^(.+) needs its key$/,
([, source]) =>
t('{source} needs its key', { source: translateKnown(source) }),
],
[
/^(.+) needs the (.+) map, which is not available$/,
([, source, map]) =>
t('{source} needs the {map} map, which is not available', {
source: translateKnown(source),
map: map === 'photoreal' ? 'Google 3D' : map,
}),
],
[
/^(.+) could not be switched on$/,
([, source]) =>
t('{source} could not be switched on', {
source: translateKnown(source),
}),
],
[
/^(.+) is not available on this map$/,
([, source]) =>
t('{source} is not available on this map', {
source: translateKnown(source),
}),
],
[
/^(.+) imagery could not be opened$/,
([, source]) =>
t('{source} imagery could not be opened', {
source: translateKnown(source),
}),
],
[
/^(.+) has no position to look for imagery at$/,
([, place]) =>
t('{place} has no position to look for imagery at', {
place: localizePlaceName(place),
}),
],
[
/^No (.+) imagery near (.+)$/,
([, source, place]) =>
t('No {source} imagery near {place}', {
source: translateKnown(source),
place: localizePlaceName(place),
}),
],
];
/** A tool error as the card shows it. */
@@ -594,6 +676,98 @@ function selectNearestAircraft(result) {
};
}
/** Month and year an image was captured, or ''. */
function capturedMonth(capturedAt) {
const ms = Number(capturedAt);
if (!Number.isFinite(ms) || ms <= 0) return '';
try {
// Dates are UTC months (Google: the first of the month).
return formatDate(new Date(ms), {
month: 'short',
year: 'numeric',
timeZone: 'UTC',
});
} catch {
return '';
}
}
function showStreetLevel(result) {
// `location` is the flight's own result on a failed flight; its label is
// the place, never the object.
const place = spokenLabel(
result?.label ||
(typeof result?.location === 'string'
? result.location
: result?.location?.label) ||
'there',
);
const source = spokenLabel(result?.providerName || 'Street Level');
// The card in the interface language; the spoken line stays English.
const shownSource = result?.providerName
? spokenLabel(result.providerName)
: t('Street Level');
// The view centre is a phrase, not a place name: the card says it in the
// interface language.
const shown = result?.atViewCentre
? t('the view centre')
: spokenLabel(localizePlaceName(result?.label || '')) || place;
if (result?.cancelled) return null;
if (!result?.ok) {
let say;
if (result?.stage === 'location') say = `Couldn't find ${place}.`;
else if (result?.stage === 'map')
say = `${source} needs the Google 3D map, which isn't available.`;
else if (result?.stage === 'key')
say = result?.keyRejected
? `${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' && result?.aim === 'sky')
say = 'Aim the middle of the view at a street first.';
else if (result?.stage === 'imagery' && result?.filter?.label)
say = `No ${source} imagery near ${place} with the ${result.filter.label} on.`;
else if (result?.stage === 'imagery' && result?.noImagery === true)
say = `No ${source} imagery near ${place}.`;
// A service or viewer failure is not missing coverage.
else if (result?.stage === 'imagery')
say = `${source} could not open imagery near ${place}.`;
else say = `Couldn't open ${source}.`;
return {
say,
display: {
title: shownSource,
lines: errorLines(result),
sources: [],
},
};
}
if (result.armed === true)
return {
say: sentence(`${source} is ready at ${place}: press OPEN to load it`),
display: {
title: shownSource,
lines: [shown, t('Press OPEN to load the photo')],
sources: [{ label: shownSource }],
},
};
const title = spokenLabel(result.title || '', 60);
const month = capturedMonth(result.capturedAt);
return {
say: sentence(`${source} at ${title || place}`),
display: {
title: shownSource,
lines: [
title || shown,
month ? t('Captured {month}', { month }) : null,
].filter(Boolean),
sources: [{ label: shownSource }],
},
};
}
const SPOKEN_ROUTE = Object.freeze({
between: (origin, destination) => `${origin} to ${destination}`,
from: (origin) => `from ${origin}`,
@@ -801,6 +975,7 @@ export const SPEECH_BUILDERS = Object.freeze({
fly_to_location: flyToLocation,
frame_overhead: frameOverhead,
select_nearest_aircraft: selectNearestAircraft,
show_street_level: showStreetLevel,
get_entity_context: getEntityContext,
get_current_view_state: getCurrentViewState,
annotate_map: annotateMap,
@@ -856,6 +1031,14 @@ function annotationTargets(args) {
.filter(Boolean);
}
/** Street-level sources as a plan step names them. */
const STREET_LEVEL_SOURCE_NAMES = Object.freeze({
google: 'Street View',
mapillary: 'Mapillary',
kartaview: 'KartaView',
panoramax: 'Panoramax',
});
// frame_overhead targets as the schema names them.
function frameStepLabel(target) {
switch (target) {
@@ -927,6 +1110,17 @@ export function planStepLabel(name, args = {}) {
? t('Nearest aircraft to {place}', { place: shownPlace(place, 32) })
: t('Nearest aircraft to Here');
}
case 'show_street_level': {
const source = STREET_LEVEL_SOURCE_NAMES[args.provider];
const place = args.query
? shownPlace(args.query, 32)
: args.latitude != null
? t('this spot')
: t('the view centre');
return source
? t('{source} at {place}', { source, place })
: t('Street level at {place}', { place });
}
case 'set_layer_visibility': {
const on = args.enabled !== false;
const label =
@@ -988,6 +1182,8 @@ export function narrationLabel(name, args = {}) {
presetPlaceName(args.locationQuery || args.locationId || ''),
32,
);
case 'show_street_level':
return shownPlace(args.query || '', 32);
case 'resolve_area':
return displayName(args.query || '', 32);
case 'find_imagery':
@@ -1008,6 +1204,8 @@ const STEP_LABELS = Object.freeze({
layer: msg('Turning on layer'),
refresh: msg('Loading aircraft'),
nearest: msg('Picking nearest'),
map: msg('Switching map'),
imagery: msg('Opening imagery'),
draw: msg('Drawing outline'),
catalog: msg('Searching imagery catalog'),
drape: msg('Loading imagery'),
@@ -1056,6 +1254,12 @@ export function progressLine(step, label) {
return 'Loading aircraft there.';
case 'nearest':
return 'Picking the nearest aircraft.';
case 'map':
return place === 'photoreal'
? 'Switching to Google 3D.'
: 'Switching the map.';
case 'imagery':
return place ? `Looking for imagery at ${place}.` : null;
case 'still':
return place ? `Still working on ${place}.` : 'Still working on it.';
default:
+232
View File
@@ -536,6 +536,238 @@ test('labels are bounded and cleaned before they are spoken', () => {
);
});
test('the street-level card says "the view centre" in the interface language', () => {
setLocale('de');
try {
const out = envelope('show_street_level', {
ok: true,
providerName: 'Mapillary',
label: 'the view centre',
atViewCentre: true,
});
assert.equal(out.display.lines[0], 'die Bildmitte');
assert.match(out.say, /the view centre/, 'speech stays English');
} finally {
setLocale('en');
}
});
test('street-level failure cards are in the interface language; speech stays English', () => {
setLocale('de');
try {
const card = (error, extra = {}) =>
envelope('show_street_level', {
ok: false,
action: 'show_street_level',
providerName: 'Google Street View',
label: 'Paris',
error,
...extra,
});
const lines = (error, extra) => card(error, extra).display.lines;
assert.deepEqual(lines('Google Street View needs its key', { stage: 'key' }), [
'Google Street View braucht seinen Schlüssel',
]);
assert.deepEqual(
lines('Google Street View needs the photoreal map, which is not available', { stage: 'map' }),
['Google Street View braucht die Google 3D-Karte, die nicht verfügbar ist'],
);
assert.deepEqual(lines('Mapillary could not be switched on', { stage: 'provider' }), [
'Mapillary konnte nicht eingeschaltet werden',
]);
assert.deepEqual(lines('Street Level could not be enabled', { stage: 'layer' }), [
'Straßenebene konnte nicht eingeschaltet werden',
]);
assert.deepEqual(
lines('Latitude must be within ±90° and longitude within ±180°', { stage: 'location' }),
['Der Breitengrad muss innerhalb von ±90° und der Längengrad innerhalb von ±180° liegen'],
);
assert.deepEqual(lines('Mapillary imagery could not be opened', { stage: 'imagery' }), [
'Bilder von Mapillary konnten nicht geöffnet werden',
]);
// The filter clause wraps the reason, already in the interface language,
// and names the filters as their chips do.
const filtered = card('No Mapillary imagery near Paris with the 360° and SINCE filters on', {
stage: 'imagery',
noImagery: true,
filter: { label: '360° and SINCE filters' },
});
assert.deepEqual(filtered.display.lines, [
'Keine Bilder von Mapillary in der Nähe von Paris (aktive Filter: 360°, SEIT)',
]);
assert.match(filtered.say, /with the 360° and SINCE filters on/, 'speech stays English');
} finally {
setLocale('en');
}
});
test('street-level speech names the source and the place, and says which step failed', () => {
const ok = envelope('show_street_level', {
ok: true,
action: 'show_street_level',
provider: 'google',
providerName: 'Google Street View',
label: '10th Street, Sacramento',
title: '1000 10th St, Sacramento, CA',
capturedAt: Date.UTC(2024, 4, 1), // Google dates: the first of the month, UTC
});
assert.equal(ok.say, 'Google Street View at 1000 10th St, Sacramento, CA.');
assertSpeakable(ok.say);
assert.equal(ok.display.title, 'Google Street View');
assert.equal(ok.display.lines[1], 'Captured May 2024');
const stages = {
location: "Couldn't find Nowhere.",
map: "Google Street View needs the Google 3D map, which isn't available.",
key: 'Google Street View needs its key.',
layer: "Couldn't turn on Street Level.",
// A service or viewer failure, not missing coverage.
imagery: 'Google Street View could not open imagery near Nowhere.',
};
for (const [stage, say] of Object.entries(stages)) {
const out = envelope('show_street_level', {
ok: false,
stage,
label: 'Nowhere',
providerName: 'Google Street View',
error: 'detail',
});
assert.equal(out.say, say, stage);
assertSpeakable(out.say);
assert.deepEqual(out.display.lines, ['detail']);
}
const empty = envelope('show_street_level', {
ok: false,
stage: 'imagery',
noImagery: true,
label: 'Nowhere',
providerName: 'Mapillary',
});
assert.equal(empty.say, 'No Mapillary imagery near Nowhere.');
const sky = envelope('show_street_level', {
ok: false,
stage: 'imagery',
aim: 'sky',
atViewCentre: true,
label: 'the view centre',
providerName: 'Mapillary',
});
assert.equal(sky.say, 'Aim the middle of the view at a street first.');
assertSpeakable(sky.say);
const armed = envelope('show_street_level', {
ok: true,
armed: true,
providerName: 'Google Street View',
label: '10th St, Sacramento',
});
assert.equal(armed.say, 'Google Street View is ready at 10th St, Sacramento: press OPEN to load it.');
assertSpeakable(armed.say);
assert.deepEqual(armed.display.lines, ['10th St, Sacramento', 'Press OPEN to load the photo']);
const unfound = envelope('show_street_level', {
ok: false,
stage: 'location',
location: { ok: false, action: 'fly_to_location', label: 'Nowhere Town' },
});
assert.equal(unfound.say, "Couldn't find Nowhere Town.");
assert.equal(unfound.display.title, 'Street Level');
const refused = envelope('show_street_level', {
ok: false,
stage: 'key',
keyRejected: true,
providerName: 'Google Street View',
});
assert.equal(refused.say, 'Google Street View refused its key.');
assert.equal(
envelope('show_street_level', { ok: false, cancelled: true }).say,
undefined,
);
assert.equal(
planStepLabel('show_street_level', {
query: 'Eiffel Tower',
provider: 'google',
}),
'Street View at Eiffel Tower',
);
assert.equal(
planStepLabel('show_street_level', {
query: 'Times Square',
provider: 'mapillary',
}),
'Mapillary at Times Square',
);
assert.equal(
progressStepLabel('imagery', 'Eiffel Tower'),
'Opening imagery: Eiffel Tower',
);
assert.equal(progressLine('map', 'photoreal'), 'Switching to Google 3D.');
assert.equal(
progressLine('imagery', 'Eiffel Tower'),
'Looking for imagery at Eiffel Tower.',
);
});
test('the capture month is a UTC month whatever the timezone, and labels name the place', async () => {
const { execFileSync } = await import('node:child_process');
const speech = new URL('./speech.js', import.meta.url).href;
// Google dates a panorama 2024-05 as the first of the month, UTC. West of
// Greenwich that instant is still April; the card must still say May.
const out = execFileSync(
process.execPath,
[
'--input-type=module',
'-e',
`import { attachVoiceResult } from ${JSON.stringify(speech)};
const r = attachVoiceResult('show_street_level', { ok: true, providerName: 'Google Street View', label: 'x', capturedAt: Date.UTC(2024, 4, 1) });
process.stdout.write(r.display.lines[1]);`,
],
{ env: { ...process.env, TZ: 'America/Los_Angeles' }, encoding: 'utf8' },
);
assert.equal(out, 'Captured May 2024');
const { narrationLabel } = await import('./speech.js');
assert.equal(narrationLabel('show_street_level', { query: 'Eiffel Tower' }), 'Eiffel Tower');
assert.equal(narrationLabel('show_street_level', {}), '');
assert.equal(
planStepLabel('show_street_level', { latitude: 1, longitude: 2 }),
'Street level at this spot',
);
assert.equal(planStepLabel('show_street_level', {}), 'Street level at the view centre');
});
test('the plan step names KartaView as a source', () => {
assert.equal(
planStepLabel('show_street_level', {
query: 'Alexanderplatz',
provider: 'kartaview',
}),
'KartaView at Alexanderplatz',
);
});
test('an empty street-level search names the imagery filter that hid the photos', () => {
const out = envelope('show_street_level', {
ok: false,
stage: 'imagery',
label: 'Alexanderplatz',
providerName: 'KartaView',
filter: { pano: 'pano', label: '360° filter' },
});
assert.equal(
out.say,
'No KartaView imagery near Alexanderplatz with the 360° filter on.',
);
assertSpeakable(out.say);
});
test('the plan step names Panoramax as a source', () => {
assert.equal(
planStepLabel('show_street_level', {
query: 'Montmartre',
provider: 'panoramax',
}),
'Panoramax at Montmartre',
);
});
test('progress step labels join through the catalog colon', () => {
setLocale('fr');
try {