mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-13 14:52:19 +00:00
Merge upstream main into denis/rail-layer
This commit is contained in:
commit
13818d0b9f
87 files changed
+7438
-144
No files matched your search
+272
-24
@@ -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
@@ -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');
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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})"
|
||||
}
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -100,6 +100,7 @@ export function fakeMapillarySource(overrides = {}) {
|
||||
return new Uint8Array(0);
|
||||
},
|
||||
getSequenceImages: async () => [],
|
||||
nearestImages: async () => [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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() } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: [] });
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -27,6 +27,7 @@ const CAMERA_TOOLS = new Set([
|
||||
'fly_route',
|
||||
'frame_overhead',
|
||||
'select_nearest_aircraft',
|
||||
'show_street_level',
|
||||
'control_cockpit',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user