mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-14 23:32:20 +00:00
feat(street-level): "show me X on Street View" action, street_level view field and sl= links
show_street_level (voice and typed) flies to a place and opens its nearest street-level photo from Google Street View, Mapillary, KartaView or Panoramax; Street View switches to Google 3D first. The view model gains a street_level field, share links carry sl=<source>,<lat>,<lon>, and show_in_gods_eye_view can open a photo. A plain sl=google link only arms the point and waits for OPEN, so it never bills a key without a click. Embed pages show an open photo in an overlay that Esc closes. On-screen strings are translated into es, ru, zh-CN and zh-Hant. Covered by unit tests and three new steps in the Street Level browser suite.
This commit is contained in:
1 parent
f3adccd01a
commit
98de41c94a
64 files changed
+4147
-66
No files matched your search
+89
-6
@@ -13,12 +13,18 @@
|
||||
|
||||
import * as Cesium from 'cesium';
|
||||
import { announceNavigationAuthority } from '../navigationPolicy.js';
|
||||
import { annotationsFromParams, createView } from '../view/index.js';
|
||||
import {
|
||||
annotationsFromParams,
|
||||
createView,
|
||||
followFromParams,
|
||||
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',
|
||||
@@ -165,6 +171,26 @@ function flyTo(viewer, camera) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `show_street_level` arguments for a view's street-level photos. A plain
|
||||
* link (`autoOpen: false`) never loads a photo from a source that bills every
|
||||
* load: the app arms the panel and the OPEN button is one click away. 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';
|
||||
const ARMED_TOAST =
|
||||
'Street View is ready: press OPEN GOOGLE STREET VIEW to load it';
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -198,6 +224,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 +235,18 @@ 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: the action flies to the point at street
|
||||
// level, lights the source and opens the nearest photo. When it fails
|
||||
// before flying, the view's own camera still applies.
|
||||
const result = await act(
|
||||
'show_street_level',
|
||||
streetLevelArgs(view.street_level),
|
||||
);
|
||||
if (result?.ok !== true && !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;
|
||||
@@ -254,12 +294,56 @@ export function installViews({
|
||||
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(/^#/, ''),
|
||||
);
|
||||
// What the link itself asks for runs first, one thing at a time; posted
|
||||
// views queue behind it. One failing step does not take the next with it.
|
||||
// The camera's owner at restore: anyone who navigates while the link's
|
||||
// steps wait (a gesture, a search, the assistant) takes it, and a passive
|
||||
// link must not fly over them.
|
||||
let generationAtRestore = null;
|
||||
let queue = ready.then(() => {
|
||||
generationAtRestore = shell._navigationGeneration;
|
||||
});
|
||||
const navigatedSinceRestore = () =>
|
||||
Number.isFinite(generationAtRestore) &&
|
||||
shell._navigationGeneration !== generationAtRestore;
|
||||
const linkStep = (work) =>
|
||||
queue.then(async () => {
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
await work();
|
||||
} catch {
|
||||
/* reported by the action itself */
|
||||
}
|
||||
});
|
||||
const linked = annotationsFromParams(hashParams);
|
||||
if (linked.length)
|
||||
void ready.then(() => {
|
||||
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
|
||||
queue = linkStep(() => run('annotate_map', { annotations: linked }));
|
||||
// A link to street-level photos opens them once the link has restored. A
|
||||
// link anyone can publish stops short of a billed load and leaves it to a
|
||||
// click; only the panel page's own link (inline embed) is the client's
|
||||
// request. A link that follows an entity keeps the camera on it.
|
||||
const streetLevel = followFromParams(hashParams)
|
||||
? null
|
||||
: streetLevelFromParams(hashParams);
|
||||
if (streetLevel)
|
||||
queue = linkStep(async () => {
|
||||
if (navigatedSinceRestore()) return;
|
||||
const result = await run(
|
||||
'show_street_level',
|
||||
streetLevelArgs(streetLevel, { autoOpen: isEmbeddedInline() }),
|
||||
);
|
||||
// The restore window keeps the panel collapsed; OPEN must be in view.
|
||||
// Embed mode hides the rail that holds it, so there the link only arms
|
||||
// (a posted view is the way to open a photo in a frame).
|
||||
if (result?.armed === true && !isEmbedded(location)) {
|
||||
shell.setPanelCollapsed?.(STREET_LEVEL_PANEL_ID, false, {
|
||||
explicit: true,
|
||||
});
|
||||
shell._showToast?.(ARMED_TOAST);
|
||||
}
|
||||
});
|
||||
if (!isEmbedded(location)) return () => {};
|
||||
|
||||
@@ -278,7 +362,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;
|
||||
|
||||
+473
-1
@@ -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 = [];
|
||||
@@ -393,3 +393,475 @@ 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.
|
||||
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], [[], []]);
|
||||
});
|
||||
|
||||
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 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 { 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]),
|
||||
},
|
||||
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 }]],
|
||||
'the panel opens so OPEN is in view',
|
||||
);
|
||||
// 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 (inline embed) is the client's request.
|
||||
globalThis.GEV_EMBED_INLINE = true;
|
||||
try {
|
||||
const inline = recorder({ show_street_level: () => ({ ok: true }) });
|
||||
const inlineWindow = new EventTarget();
|
||||
inlineWindow.document = { body: { classList: { add() {} } } };
|
||||
inlineWindow.postMessage = () => {};
|
||||
// The inline panel keeps rendering from a timer; give it inert clocks.
|
||||
Object.assign(inlineWindow, {
|
||||
requestAnimationFrame: () => 1,
|
||||
cancelAnimationFrame() {},
|
||||
setTimeout: () => 1,
|
||||
clearTimeout() {},
|
||||
setInterval: () => 1,
|
||||
clearInterval() {},
|
||||
});
|
||||
const stop = installViews({
|
||||
shell: { initialRestorePromise: Promise.resolve(), setCleanView() {} },
|
||||
viewer: fakeViewer(),
|
||||
dataManager: fakeLayers(new Set()),
|
||||
run: inline.run,
|
||||
location: {
|
||||
search: '',
|
||||
hash: '#v=2&lat=1&lon=2&map=photoreal&sl=google,38.57986,-121.49372',
|
||||
},
|
||||
windowRef: inlineWindow,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal('autoOpen' in inline.calls[0][1], false);
|
||||
stop();
|
||||
} finally {
|
||||
delete globalThis.GEV_EMBED_INLINE;
|
||||
}
|
||||
// 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']);
|
||||
});
|
||||
@@ -982,8 +982,9 @@ 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) => tool.name !== 'set_cyber_sonar',
|
||||
(tool) => !['set_cyber_sonar', 'show_street_level'].includes(tool.name),
|
||||
);
|
||||
const hudLayout = legacyTools.find((tool) => tool.name === 'set_hud')
|
||||
.parameters.properties.layout;
|
||||
|
||||
@@ -3222,5 +3222,13 @@
|
||||
"Audio": "الصوت",
|
||||
"UTILITIES": "الأدوات",
|
||||
"Exit cockpit to fly to a drawing": "اخرج من القمرة للانتقال إلى رسم",
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة"
|
||||
"Directions, drawing and your own files": "الاتجاهات والرسم وملفاتك الخاصة",
|
||||
"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": "جارٍ فتح الصور"
|
||||
}
|
||||
@@ -2942,5 +2942,13 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "WERKZEUGE",
|
||||
"Exit cockpit to fly to a drawing": "Cockpit verlassen, um zu einer Zeichnung zu fliegen",
|
||||
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien"
|
||||
"Directions, drawing and your own files": "Route, Zeichnen und eigene Dateien",
|
||||
"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"
|
||||
}
|
||||
@@ -906,6 +906,8 @@
|
||||
"Bike Share": "Bicis compartidas",
|
||||
"Cameras": "Cámaras",
|
||||
"Street Level": "A pie de calle",
|
||||
"Press OPEN to load the photo": "Pulse ABRIR para cargar la foto",
|
||||
"Captured {month}": "Capturada en {month}",
|
||||
"Mapped ALPR Cameras": "Cámaras ALPR mapeadas",
|
||||
"Data Centers": "Centros de datos",
|
||||
"Active Fires": "Incendios activos",
|
||||
@@ -1745,6 +1747,10 @@
|
||||
"Mark {targets}": "Marcar: {targets}",
|
||||
"Fly to {place}": "Volar a {place}",
|
||||
"Nearest aircraft to {place}": "Aeronave más cercana a {place}",
|
||||
"{source} at {place}": "{source} en {place}",
|
||||
"Street level at {place}": "A pie de calle en {place}",
|
||||
"this spot": "este punto",
|
||||
"the view centre": "el centro de la vista",
|
||||
"Read the view": "Consultar la vista",
|
||||
"Check view state": "Comprobar el estado de la vista",
|
||||
"Frame {target}": "Encuadrar {target}",
|
||||
@@ -1752,6 +1758,8 @@
|
||||
"Tracing outline": "Trazando contorno",
|
||||
"Looking up place": "Buscando lugar",
|
||||
"Flying": "Volando",
|
||||
"Switching map": "Cambiando de mapa",
|
||||
"Opening imagery": "Abriendo imágenes",
|
||||
"Turning on layer": "Activando capa",
|
||||
"Loading aircraft": "Cargando aeronaves",
|
||||
"Picking nearest": "Eligiendo la más cercana",
|
||||
|
||||
@@ -3012,5 +3012,13 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "OUTILS",
|
||||
"Exit cockpit to fly to a drawing": "Quittez le cockpit pour rejoindre un dessin",
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers"
|
||||
"Directions, drawing and your own files": "Itinéraire, dessin et vos propres fichiers",
|
||||
"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"
|
||||
}
|
||||
@@ -3012,5 +3012,13 @@
|
||||
"Audio": "Audio",
|
||||
"UTILITIES": "STRUMENTI",
|
||||
"Exit cockpit to fly to a drawing": "Esci dal cockpit per volare a un disegno",
|
||||
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file"
|
||||
"Directions, drawing and your own files": "Indicazioni, disegno e i tuoi file",
|
||||
"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"
|
||||
}
|
||||
@@ -2872,5 +2872,13 @@
|
||||
"Audio": "オーディオ",
|
||||
"UTILITIES": "ユーティリティ",
|
||||
"Exit cockpit to fly to a drawing": "描画の位置へ移動するにはコックピットを終了してください",
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル"
|
||||
"Directions, drawing and your own files": "ルート案内、描画、自分のファイル",
|
||||
"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": "画像を開いています"
|
||||
}
|
||||
@@ -3012,5 +3012,13 @@
|
||||
"Audio": "Áudio",
|
||||
"UTILITIES": "UTILITÁRIOS",
|
||||
"Exit cockpit to fly to a drawing": "Saia da cabine para voar até um desenho",
|
||||
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos"
|
||||
"Directions, drawing and your own files": "Rotas, desenho e seus próprios arquivos",
|
||||
"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"
|
||||
}
|
||||
@@ -915,6 +915,8 @@
|
||||
"Bike Share": "Прокат велосипедов",
|
||||
"Cameras": "Камеры",
|
||||
"Street Level": "Уличные снимки",
|
||||
"Press OPEN to load the photo": "Нажмите ОТКРЫТЬ, чтобы загрузить снимок",
|
||||
"Captured {month}": "Снято: {month}",
|
||||
"Mapped ALPR Cameras": "Камеры ALPR на карте",
|
||||
"Data Centers": "Дата-центры",
|
||||
"Active Fires": "Активные пожары",
|
||||
@@ -1778,6 +1780,10 @@
|
||||
"Mark {targets}": "Отметить {targets}",
|
||||
"Fly to {place}": "Перелёт: {place}",
|
||||
"Nearest aircraft to {place}": "Ближайший самолёт: {place}",
|
||||
"{source} at {place}": "{source}: {place}",
|
||||
"Street level at {place}": "Уличные снимки: {place}",
|
||||
"this spot": "эта точка",
|
||||
"the view centre": "центр вида",
|
||||
"Read the view": "Осмотреть видимую область",
|
||||
"Check view state": "Проверить состояние вида",
|
||||
"Frame {target}": "Показать в кадре: {target}",
|
||||
@@ -1785,6 +1791,8 @@
|
||||
"Tracing outline": "Построение контура",
|
||||
"Looking up place": "Поиск места",
|
||||
"Flying": "Перелёт",
|
||||
"Switching map": "Смена карты",
|
||||
"Opening imagery": "Открытие снимков",
|
||||
"Turning on layer": "Включение слоя",
|
||||
"Loading aircraft": "Загрузка самолётов",
|
||||
"Picking nearest": "Выбор ближайших",
|
||||
|
||||
@@ -3082,5 +3082,13 @@
|
||||
"Audio": "Аудіо",
|
||||
"UTILITIES": "ІНСТРУМЕНТИ",
|
||||
"Exit cockpit to fly to a drawing": "Вийдіть із кабіни, щоб перелетіти до малюнка",
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли"
|
||||
"Directions, drawing and your own files": "Маршрути, малювання та ваші файли",
|
||||
"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": "Відкриття знімків"
|
||||
}
|
||||
@@ -1737,6 +1737,8 @@
|
||||
"Bike Share": "共享单车",
|
||||
"Cameras": "摄像头",
|
||||
"Street Level": "街景",
|
||||
"Press OPEN to load the photo": "点击“打开”加载照片",
|
||||
"Captured {month}": "拍摄于 {month}",
|
||||
"Mapped ALPR Cameras": "地图标注 ALPR 摄像头",
|
||||
"Data Centers": "数据中心",
|
||||
"Active Fires": "活跃火点",
|
||||
@@ -2416,6 +2418,10 @@
|
||||
"Fly to {place}": "飞往 {place}",
|
||||
"Fly to Location": "飞往地点",
|
||||
"Nearest aircraft to {place}": "距离 {place} 最近的飞机",
|
||||
"{source} at {place}": "{place} 的 {source}",
|
||||
"Street level at {place}": "{place} 的街景",
|
||||
"this spot": "此处",
|
||||
"the view centre": "视图中心",
|
||||
"Nearest aircraft to Here": "距离此处最近的飞机",
|
||||
"Layer on": "图层已开启",
|
||||
"Layer off": "图层已关闭",
|
||||
@@ -2428,6 +2434,8 @@
|
||||
"Tracing outline": "正在绘制轮廓",
|
||||
"Looking up place": "正在查询地点",
|
||||
"Flying": "飞行中",
|
||||
"Switching map": "正在切换地图",
|
||||
"Opening imagery": "正在打开影像",
|
||||
"Turning on layer": "正在开启图层",
|
||||
"Loading aircraft": "正在加载飞机",
|
||||
"Picking nearest": "正在选取最近目标",
|
||||
|
||||
@@ -2872,5 +2872,13 @@
|
||||
"Right panels": "右側面板",
|
||||
"UTILITIES": "工具",
|
||||
"Exit cockpit to fly to a drawing": "請先退出座艙,再飛往繪製位置",
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件"
|
||||
"Directions, drawing and your own files": "路線導航、繪製和你自己的文件",
|
||||
"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": "正在打開影像"
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AIM_AT_GROUND_MESSAGE,
|
||||
keyStatusLabel,
|
||||
NEAREST_RADIUS_M,
|
||||
NEAREST_RADIUS_MAX_M,
|
||||
POSITION_PICK_ID,
|
||||
STREET_LEVEL_LAYER_ID,
|
||||
} from './policy.js';
|
||||
@@ -157,6 +158,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))
|
||||
@@ -275,8 +282,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;
|
||||
@@ -284,7 +297,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) {
|
||||
@@ -330,6 +343,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);
|
||||
@@ -466,6 +482,37 @@ 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;
|
||||
},
|
||||
/** The coverage lines a provider draws now (see `coverageLines`). */
|
||||
coverageLines(providerId) {
|
||||
return state.providers.get(providerId)?.instance.coverageLines?.() ?? [];
|
||||
},
|
||||
/**
|
||||
* 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 {
|
||||
@@ -612,13 +659,34 @@ 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
|
||||
* under the screen centre (sky: nothing). `radiusM` widens the search for
|
||||
* providers that can (a geocoded landmark is rarely within 50 m of a
|
||||
* street); clicks keep the default. A caller's `signal` retires the
|
||||
* lookup: nothing opens after it fires. Providers without
|
||||
* `nearestImage` are skipped.
|
||||
*/
|
||||
async openNearest(
|
||||
point,
|
||||
{ providerIds = null, frame = true, aim = 'view' } = {},
|
||||
{
|
||||
providerIds = null,
|
||||
frame = true,
|
||||
aim = 'view',
|
||||
radiusM = null,
|
||||
signal: callerSignal = null,
|
||||
} = {},
|
||||
) {
|
||||
if (callerSignal?.aborted) return false;
|
||||
// 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 = state.street.armed;
|
||||
state.street.armed = null;
|
||||
if (!point && aim === 'screen-centre' && armed) {
|
||||
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();
|
||||
@@ -637,6 +705,21 @@ 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);
|
||||
// Only the lookup that still owns the panel may touch it: a newer one
|
||||
// has its own spinner and error.
|
||||
if (nearestLookup !== lookup) return false;
|
||||
nearestLookup = null;
|
||||
// The caller moved on: the panel shows nothing of this lookup.
|
||||
if (state.street.loading && !state.street.open) {
|
||||
state.street.loading = false;
|
||||
notify();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
state.street.loading = true;
|
||||
state.street.error = null;
|
||||
notify();
|
||||
@@ -651,23 +734,35 @@ 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;
|
||||
// One provider failing (no key, offline) must not hide the others.
|
||||
lastError = error;
|
||||
continue;
|
||||
}
|
||||
if (callerSignal?.aborted) return retired();
|
||||
if (signal.aborted) return false;
|
||||
if (!state.enabled) break;
|
||||
// Switched off, or its map left, while it looked: try the next.
|
||||
if (!imageId || !entry.on || !providerAvailable(entry)) continue;
|
||||
nearestLookup = null;
|
||||
return openImage(entry.def.id, imageId, { frame });
|
||||
callerSignal?.removeEventListener('abort', onCallerAbort);
|
||||
// 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;
|
||||
nearestLookup = null;
|
||||
// Switched off while it looked: nothing to open, nothing to report.
|
||||
const stillOn = entries.some(
|
||||
(entry) => entry.on && providerAvailable(entry),
|
||||
@@ -679,11 +774,13 @@ export function createStreetLevelLayer({
|
||||
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();
|
||||
@@ -695,6 +792,23 @@ export function createStreetLevelLayer({
|
||||
parts.viewerHost.close();
|
||||
parts.clearSequences();
|
||||
},
|
||||
/**
|
||||
* Prepare a nearest lookup for the next OPEN press without running it: a
|
||||
* link to a source that bills every load stops here. The press opens at
|
||||
* this point with this radius; 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(),
|
||||
};
|
||||
|
||||
@@ -12,6 +12,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
|
||||
@@ -1118,6 +1121,674 @@ 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 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('coverageLines hands back what a provider draws, and nothing for providers without lines', async (t) => {
|
||||
const drawing = fakeProvider();
|
||||
const create = drawing.create;
|
||||
drawing.create = (context) => ({
|
||||
...create(context),
|
||||
coverageLines: () => [
|
||||
{
|
||||
id: 's1',
|
||||
parts: [
|
||||
[
|
||||
[1, 2],
|
||||
[1, 2.001],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const { layer } = await enabledLayer(t, [drawing]);
|
||||
assert.deepEqual(layer.coverageLines(drawing.id), [
|
||||
{
|
||||
id: 's1',
|
||||
parts: [
|
||||
[
|
||||
[1, 2],
|
||||
[1, 2.001],
|
||||
],
|
||||
],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(layer.coverageLines('nope'), []);
|
||||
});
|
||||
|
||||
test('coverageLines is empty for a provider that draws no coverage', async (t) => {
|
||||
const { layer } = await enabledLayer(t, [fakeProvider()]);
|
||||
assert.deepEqual(layer.coverageLines(fakeProvider().id), []);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -531,6 +531,25 @@ export function createLineCoverage({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The sequence lines drawn now, as `[lon, lat]` parts per sequence: for
|
||||
* diagnostics and the browser gate, which aims its clicks at them instead
|
||||
* of scanning pixels: what each tile drew (`attachPrimitive` keeps it in
|
||||
* `entry.sequences`). Read-only copies.
|
||||
*/
|
||||
function drawnLines() {
|
||||
const lines = [];
|
||||
for (const entry of state.coverage.tiles.values())
|
||||
for (const sequence of entry.sequences.values())
|
||||
lines.push({
|
||||
id: sequence.id,
|
||||
parts: (sequence.parts || []).map((part) =>
|
||||
part.map(([lon, lat]) => [lon, lat]),
|
||||
),
|
||||
});
|
||||
return lines;
|
||||
}
|
||||
|
||||
return {
|
||||
attach,
|
||||
detach,
|
||||
@@ -543,5 +562,6 @@ export function createLineCoverage({
|
||||
highlight,
|
||||
sequenceCount,
|
||||
stats,
|
||||
drawnLines,
|
||||
};
|
||||
}
|
||||
@@ -654,3 +654,67 @@ test('the selected sequence is drawn and highlighted even past the per-tile cap'
|
||||
assert.ok(state.coverage.highlight, 'the selected line is highlighted');
|
||||
coverage.clear();
|
||||
});
|
||||
|
||||
test('drawnLines reports the lines on the globe as [lon, lat] copies, filtered like the drawing', async () => {
|
||||
const { source, state, coverage, centre } = setup();
|
||||
assert.deepEqual(coverage.drawnLines(), [], 'nothing before a tile');
|
||||
const sequences = [
|
||||
{
|
||||
id: 'flat-1',
|
||||
parts: [
|
||||
[
|
||||
[centre.lon - 0.001, centre.lat],
|
||||
[centre.lon + 0.001, centre.lat],
|
||||
],
|
||||
],
|
||||
capturedAt: 2_000_000_000_000,
|
||||
},
|
||||
{
|
||||
id: 'pano-1',
|
||||
parts: [
|
||||
[
|
||||
[centre.lon, centre.lat - 0.001],
|
||||
[centre.lon, centre.lat],
|
||||
[centre.lon, centre.lat + 0.001],
|
||||
],
|
||||
],
|
||||
capturedAt: 1_000_000_000_000,
|
||||
isPano: true,
|
||||
},
|
||||
];
|
||||
state.filter = { pano: 'pano', sinceDays: 0 };
|
||||
coverage.refresh();
|
||||
source.calls[0].resolve(encodeCoverageTile(centre.tile, { sequences }));
|
||||
await settle();
|
||||
const lines = coverage.drawnLines();
|
||||
assert.deepEqual(
|
||||
lines.map((line) => line.id),
|
||||
['pano-1'],
|
||||
'only the drawn (filtered) lines',
|
||||
);
|
||||
assert.equal(lines[0].parts.length, 1);
|
||||
assert.equal(lines[0].parts[0].length, 3);
|
||||
// Tile coordinates are quantised to the tile grid (a few metres at z14).
|
||||
const [midLon, midLat] = lines[0].parts[0][1];
|
||||
assert.ok(
|
||||
Math.abs(midLon - centre.lon) < 1e-4 &&
|
||||
Math.abs(midLat - centre.lat) < 1e-4,
|
||||
`coordinates as [lon, lat]: ${midLon}, ${midLat} vs ${centre.lon}, ${centre.lat}`,
|
||||
);
|
||||
assert.ok(
|
||||
lines[0].parts[0][0][1] < lines[0].parts[0][2][1],
|
||||
'vertex order kept: south to north',
|
||||
);
|
||||
// Copies: a caller cannot bend the drawn line.
|
||||
lines[0].parts[0][1][0] = 0;
|
||||
assert.notEqual(coverage.drawnLines()[0].parts[0][1][0], 0);
|
||||
state.filter = { pano: 'all', sinceDays: 0 };
|
||||
coverage.rebuild();
|
||||
assert.deepEqual(
|
||||
coverage
|
||||
.drawnLines()
|
||||
.map((line) => line.id)
|
||||
.sort(),
|
||||
['flat-1', 'pano-1'],
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -535,6 +535,7 @@ export function createKartaViewProvider({
|
||||
loading: state.sequence.loading,
|
||||
error: state.sequence.error,
|
||||
}),
|
||||
coverageLines: () => coverage.drawnLines(),
|
||||
|
||||
/**
|
||||
* The nearest photo within the search radius that can be shown, or
|
||||
|
||||
@@ -191,6 +191,7 @@ export function createMapillaryProvider({ source, photoViewer = null }) {
|
||||
loading: state.sequence.loading,
|
||||
error: state.sequence.error,
|
||||
}),
|
||||
coverageLines: () => parts.coverage.drawnLines(),
|
||||
|
||||
viewer:
|
||||
photoViewer ||
|
||||
|
||||
@@ -526,6 +526,9 @@ export function createPanoramaxProvider({
|
||||
cones.clearSelection();
|
||||
drawn = null;
|
||||
},
|
||||
// The lines drawn now (current and stale tiles), as [lon, lat]
|
||||
// parts, for diagnostics and the browser gate's line clicks.
|
||||
coverageLines: () => coverage.drawnLines(),
|
||||
sequenceStats: () => ({
|
||||
selectedId: state.sequence.selectedId,
|
||||
images: state.sequence.images.length,
|
||||
|
||||
@@ -1182,3 +1182,9 @@ test('a busy or timed-out tile keeps its own message; only a body that is not a
|
||||
assert.equal(env.instance.sequenceStats().error, failure.message);
|
||||
}
|
||||
});
|
||||
|
||||
test('coverageLines exposes the drawn lines, none before any tile', () => {
|
||||
const { instance } = setup();
|
||||
assert.equal(typeof instance.coverageLines, 'function');
|
||||
assert.deepEqual(instance.coverageLines(), []);
|
||||
});
|
||||
@@ -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 {{kind: 'view-cone', color: string}} [marker] Default: GEV's pin.
|
||||
* @property {Array<'pano'|'since'>} [filters] Filters it honours (default both).
|
||||
* @property {{noun: string, action: string}} [coverageLabel] How the panel counts its lines and says what a click does (default: sequences, "click a line for its photos").
|
||||
@@ -48,7 +49,8 @@ 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 {() => Array<{id: string, parts: Array<Array<[number, number]>>}>} [coverageLines] The coverage lines drawn now, as [lon, lat] parts (diagnostics and the browser gate).
|
||||
* @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
|
||||
|
||||
@@ -29,6 +29,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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -99,12 +99,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 +120,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 +147,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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -25,11 +25,12 @@ const css = readStylesheet(new URL('../style.css', import.meta.url));
|
||||
|
||||
function realtimeTools() { return GEV_REALTIME_TOOLS; }
|
||||
|
||||
test('Realtime schema exposes the authoritative 31-tool inventory', () => {
|
||||
test('Realtime schema exposes the authoritative 32-tool inventory', () => {
|
||||
const tools = realtimeTools();
|
||||
assert.equal(tools.length, 31);
|
||||
assert.equal(tools.length, 32);
|
||||
const names = tools.map((tool) => tool.name);
|
||||
assert.equal(new Set(names).size, 31, 'tool names are unique');
|
||||
assert.equal(new Set(names).size, 32, 'tool names are unique');
|
||||
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'));
|
||||
@@ -199,6 +200,8 @@ test('no unchanged Realtime tool definition drifts silently', () => {
|
||||
'track_entity',
|
||||
// Point-and-ask: 'pointer' sentinels and referent arguments.
|
||||
'annotate_map',
|
||||
// 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))
|
||||
|
||||
@@ -115,3 +115,17 @@ 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);
|
||||
// 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/,
|
||||
);
|
||||
});
|
||||
@@ -47,10 +47,26 @@ 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 moves a passed-through view's photos with it.
|
||||
let streetLevel =
|
||||
args.street_level && typeof args.street_level === 'object'
|
||||
? { ...args.street_level }
|
||||
: base.street_level && typeof base.street_level === 'object'
|
||||
? args.area
|
||||
? { 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 =
|
||||
@@ -84,6 +100,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 +130,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,
|
||||
|
||||
@@ -113,3 +113,104 @@ 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);
|
||||
|
||||
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 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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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 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);
|
||||
});
|
||||
@@ -1318,9 +1318,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,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');
|
||||
});
|
||||
@@ -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')
|
||||
@@ -293,6 +307,32 @@ export class StreetLevelControls {
|
||||
this._requestResize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Embed mode hides the right rail, panel and viewer with it: an open photo
|
||||
* moves to <body> as an overlay on the globe (`sl-viewer-wrap-embed`, see
|
||||
* embed.css) and returns to its place in the panel when it closes. The
|
||||
* panel view keeps patching the wrap where it is: its siblings are static,
|
||||
* so Vue never inserts next to it.
|
||||
*/
|
||||
_syncEmbedOverlay(open) {
|
||||
const wrap = this._elements.viewerWrap;
|
||||
if (!wrap || !document.body) return;
|
||||
const lift = open === true && isEmbedPage();
|
||||
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;
|
||||
@@ -350,6 +390,9 @@ export class StreetLevelControls {
|
||||
const open = view.viewer.open;
|
||||
if (open && !this._wasOpen) this.setCollapsed(false, { persist: false });
|
||||
this._wasOpen = open;
|
||||
// Embed mode hides the rail the panel sits in: an open photo is shown over
|
||||
// the globe instead, and goes back into the panel when it closes.
|
||||
this._syncEmbedOverlay(open);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@@ -362,6 +405,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;
|
||||
|
||||
@@ -921,3 +921,103 @@ 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('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);
|
||||
}));
|
||||
@@ -13,3 +13,19 @@ 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
+164
-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,63 @@ 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.
|
||||
*/
|
||||
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),
|
||||
lon: lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon,
|
||||
};
|
||||
}
|
||||
|
||||
/** Annotation kinds the app draws, as in its annotate_map action. */
|
||||
export const VIEW_ANNOTATION_TYPES = Object.freeze([
|
||||
'pin',
|
||||
@@ -162,6 +220,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 +324,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 +338,37 @@ 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.
|
||||
const seen = lookAtPoint(camera);
|
||||
const lat = latitude(streetLevel.lat) ?? seen.lat;
|
||||
const lon = longitude(streetLevel.lon) ?? seen.lon;
|
||||
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 +397,7 @@ export function createView({
|
||||
.map(annotationOf)
|
||||
.filter(Boolean),
|
||||
),
|
||||
street_level: street,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -321,8 +423,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 +468,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 +512,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 +532,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,379 @@ 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);
|
||||
});
|
||||
|
||||
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');
|
||||
|
||||
@@ -103,6 +103,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: {
|
||||
|
||||
@@ -38,21 +38,38 @@ test('the complete Realtime tool payload pins the manifest-generated layer relea
|
||||
// consolidated tool wording (each policy stated once); model-facing tools
|
||||
// omit top-level anyOf (OpenAI rejects it). Contacts list routing preserves
|
||||
// requested radius and center authority in descriptions only.
|
||||
// Street Level and GNSS extend the manifest; tracked and analyst aircraft describe broadcasts.
|
||||
// Air quality (ECCC AQHI) extends the manifest.
|
||||
// Globe rotation adds one motion; visual keyhole fit and explicit
|
||||
// reset guidance/defaults update only move_camera and mode descriptions.
|
||||
// S2 GhostMaps extends the generated layer enums.
|
||||
// The Utilities panel (Directions, Draw, Import) adds one panel target.
|
||||
// Panel adds named voice targets and timeout; shared Panel/Cockpit guidance
|
||||
// is consolidated without changing either feature's arguments.
|
||||
// Snow leaves the set_visual_style enum.
|
||||
// The Operator HUD layout leaves set_hud's layout enum.
|
||||
// The layer alias hint leaves out aliases the layer id already spells.
|
||||
// Street Level, GNSS and air quality (ECCC AQHI) extend the manifest;
|
||||
// tracked and analyst aircraft describe broadcasts; globe rotation adds
|
||||
// one motion; S2 GhostMaps, the Utilities panel target and the voice panel
|
||||
// (named targets, timeout) follow; Snow leaves the set_visual_style enum, the Operator HUD layout
|
||||
// leaves set_hud's layout enum and the layer alias hint leaves out aliases
|
||||
// the layer id already spells; show_street_level (map, source, photo) is
|
||||
// the only change on top of main.
|
||||
SHOW_STREET_LEVEL_PAYLOAD_DIGEST,
|
||||
);
|
||||
// Every shipped tool is byte-identical to the previous release: the new
|
||||
// tool is the whole difference, so a later edit cannot hide in a re-pin.
|
||||
const shipped = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify(
|
||||
stable(
|
||||
GEV_REALTIME_TOOLS.filter(
|
||||
(tool) =>
|
||||
!['set_cyber_sonar', 'show_street_level'].includes(tool.name),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.digest('hex');
|
||||
assert.equal(
|
||||
shipped,
|
||||
'dc2bac1be93a503dc88d637beea2f1bc457e1f9167422bd91548b44756a8aca5',
|
||||
);
|
||||
});
|
||||
|
||||
const SHOW_STREET_LEVEL_PAYLOAD_DIGEST =
|
||||
'2198c1f95cfbc19e89ba8814ad0fd8303a74a7b254703f2e7a4893f5798d9aed';
|
||||
|
||||
test('descriptions customize wording without changing immutable shared arguments', () => {
|
||||
const descriptions = {
|
||||
fly_to_location: {
|
||||
@@ -112,6 +129,7 @@ test('all legacy action arguments are byte-identical after removing the delibera
|
||||
'next_satellite_pass',
|
||||
'set_cyber_sonar',
|
||||
'set_voice_inactivity_timeout',
|
||||
'show_street_level',
|
||||
].includes(tool.name),
|
||||
);
|
||||
// Layer enums are generated from the voice layer manifest and pinned by
|
||||
|
||||
@@ -330,6 +330,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,
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -34,6 +34,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,
|
||||
@@ -731,6 +735,18 @@ export function createGevActionRunner({
|
||||
};
|
||||
}
|
||||
|
||||
if (name === 'show_street_level') {
|
||||
return showStreetLevel(args, {
|
||||
dataManager,
|
||||
styleManager,
|
||||
placeSearch,
|
||||
runGevAction,
|
||||
runOptions,
|
||||
current,
|
||||
reportProgress,
|
||||
});
|
||||
}
|
||||
|
||||
if (name === 'set_visual_style') {
|
||||
const style = normalizeStyle(args.style);
|
||||
if (!style)
|
||||
@@ -2582,6 +2598,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.
|
||||
@@ -2589,6 +2628,7 @@ function stopAllTracking(viewer, dataManager) {
|
||||
* detection so the framed entities are labeled, and reports
|
||||
* detectionEnabled so the voice agent can mention labels are on.
|
||||
*/
|
||||
|
||||
async function frameOverhead(viewer, dataManager, styleManager, args = {}) {
|
||||
const targetRaw = String(args.target || 'flights').toLowerCase();
|
||||
const layerId =
|
||||
@@ -3245,6 +3285,311 @@ function normalizeStyle(value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Show me <place> on Street View / on Mapillary": fly to the place, make sure
|
||||
* the imagery source's map is up, light that source in Street Level, turn the
|
||||
* layer on and open the nearest image. One source at a time, as the panel
|
||||
* does; the choice persists like a chip click. The key is checked before
|
||||
* anything changes, and each stage reports its own failure so speech can say
|
||||
* which part did not happen. With no place at all, the view centre is used.
|
||||
* `autoOpen: false` (links, not the model) stops short of loading a photo
|
||||
* from a source that bills every load: the panel is armed and the OPEN
|
||||
* button is one click away.
|
||||
*/
|
||||
export async function showStreetLevel(
|
||||
args,
|
||||
{
|
||||
dataManager,
|
||||
styleManager,
|
||||
placeSearch,
|
||||
runGevAction,
|
||||
runOptions = {},
|
||||
current = () => true,
|
||||
reportProgress = () => {},
|
||||
},
|
||||
) {
|
||||
const action = 'show_street_level';
|
||||
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 } : {}),
|
||||
...extra,
|
||||
});
|
||||
const query = String(args.query || '').trim();
|
||||
const latitude = Number(args.latitude);
|
||||
const longitude = Number(args.longitude);
|
||||
const hasCoordinates =
|
||||
args.latitude != null &&
|
||||
args.longitude != null &&
|
||||
Number.isFinite(latitude) &&
|
||||
Number.isFinite(longitude);
|
||||
// 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, on any map: a chip without a key explains it and switches
|
||||
// nothing, and so does this.
|
||||
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 || [],
|
||||
});
|
||||
|
||||
// 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';
|
||||
const atViewCentre = !query && !hasCoordinates && !entity;
|
||||
let point = null;
|
||||
let location = null;
|
||||
if (args.fly === false || atViewCentre) {
|
||||
if (hasCoordinates) point = { lat: latitude, lon: longitude };
|
||||
else if (entity) {
|
||||
// Where it is now, as the flying path does: the coordinates a list
|
||||
// saved go stale as soon as the item moves or unloads.
|
||||
const located = locateLayerEntity(dataManager, entity);
|
||||
if (!located) return fail('location', `${label} is no longer loaded`);
|
||||
point = { lat: located.lat, lon: located.lon };
|
||||
} else if (query) {
|
||||
reportProgress('search', query);
|
||||
const outcome = await placeSearch.geocode(query, {
|
||||
signal: runOptions.signal,
|
||||
});
|
||||
const place = outcome?.place;
|
||||
if (!current())
|
||||
return fail('location', 'Street-level request was cancelled');
|
||||
if (!Number.isFinite(place?.lat) || !Number.isFinite(place?.lng))
|
||||
return fail('location', `Could not find ${query}`);
|
||||
point = { lat: place.lat, lon: place.lng };
|
||||
label = place.label || query;
|
||||
}
|
||||
// No place at all: the layer looks under the view centre.
|
||||
} else {
|
||||
location = await runGevAction(
|
||||
'fly_to_location',
|
||||
{
|
||||
...(entity
|
||||
? { entity }
|
||||
: hasCoordinates
|
||||
? { latitude, longitude }
|
||||
: { query }),
|
||||
viewMode: 'close',
|
||||
waitForArrival: true,
|
||||
},
|
||||
runOptions,
|
||||
);
|
||||
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 keeps its name: the flight to it reports only coordinates.
|
||||
label = entity ? 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 };
|
||||
}
|
||||
|
||||
// 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 },
|
||||
);
|
||||
}
|
||||
|
||||
// Light the source the way a chip click does, so the choice is kept.
|
||||
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: 'voice',
|
||||
}) !== 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: !current() || Boolean(layer?.cancelled), layer, location },
|
||||
);
|
||||
}
|
||||
|
||||
// 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 billed source never loads from a link without a click: everything is
|
||||
// ready and OPEN is one press away at the view centre.
|
||||
if (args.autoOpen === false && status?.billed === true) {
|
||||
// The next OPEN press loads exactly what the link asked for.
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// A source with no coverage to click (Street View) is searched wider: a
|
||||
// geocoded landmark is rarely within 50 m of a street. A newer turn
|
||||
// retires the lookup: nothing opens after the user moved on.
|
||||
reportProgress('imagery', label);
|
||||
const opened = await layerModule.openNearest(point, {
|
||||
providerIds: [provider],
|
||||
frame: false,
|
||||
...(runOptions.signal ? { signal: runOptions.signal } : {}),
|
||||
...(snapshot.groundClick === true
|
||||
? { radiusM: STREET_LEVEL_WIDE_RADIUS_M }
|
||||
: {}),
|
||||
});
|
||||
const street = layerModule.getUIState?.()?.street || {};
|
||||
// A cancelled request opens nothing (the lookup and the viewer both honour
|
||||
// the turn's signal); a photo that did open is reported as open.
|
||||
if (opened !== true && !current())
|
||||
return fail('imagery', 'Street-level request was cancelled', {
|
||||
cancelled: true,
|
||||
location,
|
||||
});
|
||||
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 },
|
||||
);
|
||||
// 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, ...(filter ? { filter } : {}) },
|
||||
);
|
||||
}
|
||||
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,
|
||||
...(location ? { location: location.label } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function flyToRequestedLocation(
|
||||
viewer,
|
||||
args,
|
||||
@@ -3309,11 +3654,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'
|
||||
@@ -3374,6 +3733,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,
|
||||
};
|
||||
@@ -3404,6 +3764,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,
|
||||
};
|
||||
|
||||
@@ -20,6 +20,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';
|
||||
import { attachVoiceResult } from './speech.js';
|
||||
@@ -3987,6 +3988,538 @@ 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,
|
||||
} = {}) {
|
||||
const order = [];
|
||||
const { viewer, styleManager } = createVoiceNavigationHarness();
|
||||
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;
|
||||
return { ok: true, activeStack: id, error: null };
|
||||
};
|
||||
let params = { ...lit };
|
||||
let keyRejected = keyRejectedAtStart;
|
||||
const street = { 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 },
|
||||
}),
|
||||
armNearest(point, options) {
|
||||
order.push(`arm:${options.providerIds.join('+')}:${options.radiusM ?? 'default'}:${point ? `${point.lat},${point.lon}` : 'view-centre'}`);
|
||||
},
|
||||
async openNearest(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;
|
||||
}
|
||||
}
|
||||
if (!nearest) {
|
||||
street.error = `No street-level imagery within ${options.radiusM ?? 50} m of ${point ? 'that point' : 'the view centre'}`;
|
||||
return false;
|
||||
}
|
||||
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 }) {
|
||||
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) {
|
||||
order.push(`enable:${id}`);
|
||||
if (enableResult === false) return false;
|
||||
enabled = true;
|
||||
return true;
|
||||
},
|
||||
getAll: () => [{ id: 'street-level', name: 'Street Level', enabled }],
|
||||
};
|
||||
const runner = createGevActionRunner({ viewer, styleManager, dataManager });
|
||||
return { runner, order, finishFlight: () => completeFlight?.(), layer, dataManager };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
});
|
||||
|
||||
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 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']);
|
||||
// Without a flight the entity's own position is used.
|
||||
const still = withCamera(createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' }));
|
||||
const kept = await still.runner('show_street_level', {
|
||||
provider: 'google',
|
||||
fly: false,
|
||||
entity: { layerId: 'cctv', id: 'cam-9', lat: 38.58, lon: -121.5 },
|
||||
});
|
||||
assert.equal(kept.ok, true, kept.error);
|
||||
assert.deepEqual(still.order, ['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 without a flight geocodes a name, locates an item, and reports what it cannot place', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const hit = createStreetLevelHarness({ enabled: true, activeStack: 'osm' });
|
||||
const points = [];
|
||||
const open = hit.layer.openNearest;
|
||||
hit.layer.openNearest = (point, options) => {
|
||||
points.push(point);
|
||||
return open(point, options);
|
||||
};
|
||||
const runner = createGevActionRunner({
|
||||
viewer: createVoiceNavigationHarness().viewer,
|
||||
styleManager: { mapStackController: { getActiveId: () => 'osm' }, setMapStack: async () => ({ ok: true }) },
|
||||
dataManager: hit.dataManager,
|
||||
placeSearch: {
|
||||
geocode: async (query) =>
|
||||
query === 'Capitol Mall'
|
||||
? { place: { lat: 38.5792, lng: -121.4958, label: 'Capitol Mall, Sacramento' } }
|
||||
: { place: null, answered: true },
|
||||
},
|
||||
});
|
||||
const found = await runner('show_street_level', { query: 'Capitol Mall', provider: 'mapillary', fly: false });
|
||||
assert.equal(found.ok, true, found.error);
|
||||
assert.equal(found.label, 'Capitol Mall, Sacramento');
|
||||
assert.deepEqual(points.at(-1), { lat: 38.5792, lon: -121.4958 });
|
||||
assert.equal(found.flew, false);
|
||||
|
||||
const miss = await runner('show_street_level', { query: 'Atlantis Boulevard', provider: 'mapillary', fly: false });
|
||||
assert.deepEqual([miss.ok, miss.stage], [false, 'location']);
|
||||
assert.equal(miss.say, "Couldn't find Atlantis Boulevard.");
|
||||
assert.equal(points.length, 1, 'a miss asks for no imagery');
|
||||
|
||||
const gone = await runner('show_street_level', {
|
||||
provider: 'mapillary', fly: false, entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7' },
|
||||
});
|
||||
assert.deepEqual([gone.ok, gone.stage], [false, 'location']);
|
||||
assert.equal(gone.say, "Couldn't find Camera 7.");
|
||||
|
||||
// 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('show_street_level without a flight needs the item where it is now, not where a list saw it', async () => {
|
||||
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
|
||||
const harness = createStreetLevelHarness({ enabled: true, activeStack: 'photoreal' });
|
||||
// The referent carries the coordinates the list saved, but it is no longer loaded.
|
||||
const stale = await harness.runner('show_street_level', {
|
||||
provider: 'google',
|
||||
fly: false,
|
||||
entity: { layerId: 'cctv', id: 'cam-gone', label: 'Camera 7', lat: 38.58, lon: -121.5 },
|
||||
});
|
||||
assert.deepEqual([stale.ok, stale.stage], [false, 'location']);
|
||||
assert.match(stale.error, /no longer loaded/);
|
||||
assert.ok(!harness.order.some((entry) => entry.startsWith('open:')), 'nothing looked up at the stale point');
|
||||
});
|
||||
|
||||
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('set_panel_open accepts the Authoring name for the scene panel', async () => {
|
||||
const calls = [];
|
||||
const runner = createGevActionRunner({
|
||||
|
||||
@@ -352,7 +352,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',
|
||||
};
|
||||
|
||||
@@ -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 { analystHeadline } from './resultDisplay.js';
|
||||
import {
|
||||
formatDate,
|
||||
formatNumber,
|
||||
getLocale,
|
||||
msg,
|
||||
@@ -564,6 +565,87 @@ 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');
|
||||
const shown = 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 === 'imagery')
|
||||
say = result?.filter?.label
|
||||
? `No ${source} imagery near ${place} with the ${result.filter.label} on.`
|
||||
: `No ${source} 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}`,
|
||||
@@ -771,6 +853,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,
|
||||
@@ -826,6 +909,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) {
|
||||
@@ -897,6 +988,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 =
|
||||
@@ -940,6 +1042,8 @@ export function narrationLabel(name, args = {}) {
|
||||
presetPlaceName(args.locationQuery || args.locationId || ''),
|
||||
32,
|
||||
);
|
||||
case 'show_street_level':
|
||||
return shownPlace(args.query || '', 32);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -954,6 +1058,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'),
|
||||
});
|
||||
|
||||
/** On-screen label for a progress step. */
|
||||
@@ -988,6 +1094,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:
|
||||
|
||||
@@ -489,6 +489,154 @@ test('labels are bounded and cleaned before they are spoken', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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.",
|
||||
imagery: 'No Google Street View 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 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