Street Level: polar look-at, a bounded terrain wait, takeovers during the flight

- The look-at longitude wraps fully, so a tilted camera at a pole gives a
  point on the globe. The strict off-globe check on street_level is undone:
  points are clamped again, as before.
- A posted view that fails is answered, and the views behind it still apply.
- A posted view waits at most 2 s for the terrain under its camera, then
  keeps its own estimate.
- A camera taken during that wait stays where it is: the rest of the view
  applies, and only its photos stand down.
- show_street_level keeps a search or a press on the globe made during its
  own flight: it adopts only its own navigation stamps on landing, and a
  gesture is never forgiven.
This commit is contained in:
German Khokhlov committed 2026-11-11 18:02:15 +04:00
1 parent ae66d07575
commit 0ebc09079d
8 files changed
+193 -79

No files matched your search

+3 -1
View File
@@ -5323,7 +5323,9 @@ another map; `street_level` does not combine with `follow`.
`show_in_gods_eye_view`, and the embedded app for a posted view with no
point, measure the view's altitude from the terrain under the camera
(`groundedLookAt` with the terrain heights service) before taking the look-at
point, and keep the view's own estimate without a terrain answer. Links carry
point, and keep the view's own estimate without a terrain answer (the embedded
app waits 2 s for one). A camera taken during that wait stays where it is:
the rest of the view applies and its photos do not open. Links carry
`sl=<source>,<lat>,<lon>`; the globe panel applies views through the action,
and a plain link runs it once restored (one shot: the rewritten hash drops
`sl`). A link that follows an entity ignores `sl`.
+64 -42
View File
@@ -39,6 +39,8 @@ const FOLLOW_LAYERS = {
const FOLLOW_ATTEMPTS = 20;
const FOLLOW_RETRY_MS = 1000;
const FLIGHT_SECONDS = 2;
/** How long a posted view waits for the terrain under its camera. */
const TERRAIN_WAIT_MS = 2000;
/**
* Whether this page shows the app embedded: `?embed=1` when another page
@@ -495,51 +497,71 @@ export function installViews({
typeof asked === 'object' &&
asked.lat == null &&
asked.lon == null;
queue = queue.then(async () => {
if (signal?.aborted) return;
if (unplaced && view.street_level) {
// Whoever takes the camera while the terrain answers (a drag, a
// search, the assistant) keeps it: the view stands down.
const owner = watchCameraOwner(viewer, shell);
const point = await groundedLookAt(view.camera, terrain, signal)
.catch(() => null)
.finally(() => owner.stop());
queue = queue
.then(async () => {
if (signal?.aborted) return;
if (owner.taken()) {
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
steps: [{ step: 'street_level', ok: false, superseded: true }],
},
replyOrigin,
);
return;
let apply = run;
if (unplaced && view.street_level) {
// A terrain service that does not answer soon leaves the view's
// own estimate.
const wait = AbortSignal.timeout(TERRAIN_WAIT_MS);
const owner = watchCameraOwner(viewer, shell);
const point = await groundedLookAt(
view.camera,
terrain,
signal ? AbortSignal.any([signal, wait]) : wait,
)
.catch(() => null)
.finally(() => owner.stop());
if (signal?.aborted) return;
// Whoever takes the camera meanwhile (a drag, a search, the
// assistant) keeps it: the photos stand down, as when the camera
// is taken during the action, and the rest of the view applies.
if (owner.taken())
apply = (name, ...rest) =>
name === 'show_street_level'
? {
ok: false,
cancelled: true,
superseded: true,
error: 'The camera moved on before the photo opened',
}
: run(name, ...rest);
else if (point)
view = createView({
...event.data.view,
street_level: { ...asked, ...point },
});
}
if (point)
view = createView({
...event.data.view,
street_level: { ...asked, ...point },
});
}
const steps = await applyView(view, {
viewer,
dataManager,
run,
signal,
autoOpen,
});
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
const steps = await applyView(view, {
viewer,
dataManager,
run: apply,
signal,
autoOpen,
});
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
);
})
// A view that fails is answered, and the views behind it still apply.
.catch((error) =>
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
error: error?.message || String(error),
},
replyOrigin,
),
);
});
};
windowRef.addEventListener('message', onMessage);
void ready.then(() => {
+3 -3
View File
@@ -188,8 +188,9 @@ const CAMERA_GESTURES = ['pointerdown', 'wheel'];
/**
* Whether the camera is still a request's: a navigation the shell stamps (a
* search, the assistant, a link) or a press or wheel on the globe hands it to
* someone else. `claim()` makes the camera as it is now the request's (after
* its own flight); `stop()` removes the gesture listeners.
* someone else. `claim()` takes the navigation stamped so far as the
* request's (its own flight); a gesture is never forgiven. `stop()` removes
* the gesture listeners.
*/
export function watchCameraOwner(viewer, navigation) {
let generation = navigation?._navigationGeneration;
@@ -203,7 +204,6 @@ export function watchCameraOwner(viewer, navigation) {
return {
claim: () => {
generation = navigation?._navigationGeneration;
taken = false;
},
taken: () =>
taken ||
+87 -2
View File
@@ -5,10 +5,12 @@ import { createStreetLevelLayer } from './layers/streetLevel/index.js';
import { fakeStreetLevelProvider, fakeCesiumViewer } from './testSupport/streetLevelFakes.mjs';
import { showStreetLevel } from './voice/gevActions.js';
import { installViews } from './app/embed.js';
import { groundedLookAt } from './view/index.js';
import { composeCatalog, coreTools } from './tools/index.js';
// Replacing a street-level photo, across sources and against the user's own
// moves; posted views waiting on terrain. Offline: providers, lookups, terrain
// and flights are fixtures.
// moves; posted views waiting on terrain, and their look-at near a pole.
// Offline: providers, lookups, terrain and flights are fixtures.
globalThis.fetch = async () => { throw new Error('Unexpected network use in offline test'); };
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
function deferred() {
@@ -142,6 +144,11 @@ for (const takeover of ['navigation', 'gesture', 'none']) {
assert.equal(h.flights.length, takeover === 'none' ? 1 : 0, 'superseded view must not reclaim the camera');
assert.equal(a.calls.open.length, takeover === 'none' ? 1 : 0);
assert.equal(reply.ok, takeover === 'none');
// Only the photos stand down: the rest of the view still applies, and
// the camera stays with whoever took it.
const steps = reply.steps.map(step => step.step);
assert.ok(steps.includes('clear_annotations'), JSON.stringify(steps));
assert.ok(!steps.includes('camera'), JSON.stringify(steps));
});
}
@@ -199,3 +206,81 @@ test('a failed replacement from another source does not keep the old photo\'s de
assert.deepEqual({ imageId: street.imageId, position: street.position, creator: street.creator, externalUrl: street.externalUrl },
{ imageId: null, position: null, creator: null, externalUrl: null }, 'destroyed provider photo must not be attributed to replacement');
});
const rejections = [];
process.on('unhandledRejection', reason => { rejections.push(String(reason?.message || reason)); });
// 111 m from the North Pole, looking east and down: 1 km ahead is more than a
// full turn of that parallel.
const north = { camera: { lat: 89.999, lon: 170, altitude_m: 1000, pitch_deg: -45, heading_deg: 90 }, street_level: { provider: 'mapillary' } };
const placed = { camera: { lat: 48, lon: 2, altitude_m: 1000, pitch_deg: -45 }, street_level: { provider: 'mapillary', lat: 48, lon: 2 } };
test('a grounded look-at near a pole stays on the globe', async () => {
const point = await groundedLookAt(north.camera, { getHeights: async () => [{ ellipsoid: 0 }] });
assert.ok(Math.abs(point.lat) <= 90 && Math.abs(point.lon) <= 180, JSON.stringify(point));
});
test('show_in_gods_eye_view frames the South Pole with a tilted camera', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' }, terrain: { getHeights: async () => [{ ellipsoid: 2835 }] } },
});
const result = await catalog.call('show_in_gods_eye_view', {
camera: { lat: -90, lon: 0, altitude_m: 3500, pitch_deg: -30, heading_deg: 45 },
street_level: { provider: 'mapillary' },
});
const street = result.data?.view?.street_level;
assert.ok(street && Math.abs(street.lat) <= 90 && Math.abs(street.lon) <= 180, JSON.stringify(result));
});
test('a posted polar view is answered and the next view still applies', async t => {
rejections.length = 0;
const h = await harness(t, [provider('mapillary')]);
const e = await embed(t, h, { getHeights: async () => [{ ellipsoid: 0 }] });
e.post(1, north);
e.post(2, placed);
for (let n = 0; n < 20 && !e.replies.some(r => r.id === 2); n++) await settle();
t.diagnostic(JSON.stringify({ replies: e.replies.filter(r => r.id), rejections }));
assert.ok(e.replies.some(r => r.id === 1), 'the polar view gets an answer');
assert.ok(e.replies.some(r => r.id === 2 && r.ok === true), 'the next view still applies');
assert.deepEqual(rejections, [], 'no rejection escapes the views queue');
});
test('a stalled terrain answer does not hold posted views', { timeout: 15000 }, async t => {
const h = await harness(t, [provider('mapillary')]);
let asked = 0;
// Hangs like a stalled upstream; gives up only when its signal aborts.
const terrain = {
getHeights: (_points, { signal } = {}) => {
asked++;
return new Promise((_, reject) => signal?.addEventListener('abort', () => reject(signal.reason), { once: true }));
},
};
const e = await embed(t, h, terrain);
e.post(1, view(-45));
e.post(2, placed);
const started = Date.now();
while (Date.now() - started < 5000 && !e.replies.some(r => r.id === 2)) await new Promise(resolve => setTimeout(resolve, 100));
t.diagnostic(JSON.stringify({ asked, waitedMs: Date.now() - started, replies: e.replies.filter(r => r.id).map(r => r.id) }));
assert.equal(asked, 1);
assert.ok(e.replies.some(r => r.id === 1), 'the view falls back to its own estimate within 5 s');
assert.ok(e.replies.some(r => r.id === 2), 'the next view is not held behind it');
});
test('a posted view that throws is answered and does not hold the views behind it', async t => {
rejections.length = 0;
const h = await harness(t, [provider('mapillary')]);
const getAll = h.dataManager.getAll;
let calls = 0;
h.dataManager.getAll = () => {
if (calls++ === 0) throw new Error('Fixture layer list failure');
return getAll();
};
const e = await embed(t, h, null);
e.post(1, placed);
e.post(2, placed);
for (let n = 0; n < 20 && !e.replies.some(r => r.id === 2); n++) await settle();
t.diagnostic(JSON.stringify({ replies: e.replies.filter(r => r.id), rejections }));
assert.deepEqual(e.replies.find(r => r.id === 1), { type: 'gev:view-applied', id: 1, ok: false, error: 'Fixture layer list failure' });
assert.ok(e.replies.some(r => r.id === 2 && r.ok === true), 'the next view still applies');
assert.deepEqual(rejections, []);
});
+2 -7
View File
@@ -119,7 +119,8 @@ export function lookAtPoint(camera) {
const lon = camera.lon + dLon;
return {
lat: clamp(camera.lat + dLat, -90, 90),
lon: lon > 180 ? lon - 360 : lon < -180 ? lon + 360 : lon,
// Near a pole one step ahead can be several turns of the parallel.
lon: ((((lon + 180) % 360) + 360) % 360) - 180,
};
}
@@ -387,12 +388,6 @@ export function createView({
// photos at X, also when the camera sits behind it. It is a pair: half
// of one is not mixed with the camera's other half.
const given = [latitude(streetLevel.lat), longitude(streetLevel.lon)];
// A coordinate given off the globe is an error, not a reason to guess.
for (const [index, key] of ['lat', 'lon'].entries())
if (streetLevel[key] != null && given[index] === undefined)
throw new TypeError(
`Street-level ${key} is not a valid coordinate: ${streetLevel[key]}`,
);
const seen = lookAtPoint(camera);
const [lat, lon] = given.includes(undefined) ? [seen.lat, seen.lon] : given;
street = Object.freeze({
-15
View File
@@ -208,21 +208,6 @@ test('street-level photos travel in the link, light their source and bring their
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);
// A point off the globe is refused, like an unknown source.
for (const bad of [
{ lat: 95, lon: 2 },
{ lat: 48, lon: 200 },
{ lat: Number.NaN, lon: 2 },
])
assert.throws(
() =>
createView({
camera: { lat: 1, lon: 2 },
street_level: { provider: 'mapillary', ...bad },
}),
TypeError,
JSON.stringify(bad),
);
// Half a point is not mixed with the camera's other half.
const half = createView({
camera: { lat: 1, lon: 2 },
+7 -2
View File
@@ -812,6 +812,7 @@ export function createGevActionRunner({
runOptions,
current,
reportProgress,
otherHandoffs,
});
}
@@ -3813,9 +3814,12 @@ async function requestStreetLevel(
runOptions = {},
current = () => true,
reportProgress = () => {},
otherHandoffs = () => 0,
},
) {
const action = 'show_street_level';
// Navigations other than voice fly-tos (this request's own flight) so far.
const handoffs = otherHandoffs();
let provider = null;
let providerName = null;
let label = null;
@@ -3972,8 +3976,9 @@ async function requestStreetLevel(
}
// A mode that hides the panel may have started during the flight. The
// camera is the request's from where its flight landed.
if (location) owner.claim();
// camera is the request's from where its flight landed, unless another
// navigation took it during the flight.
if (location && otherHandoffs() === handoffs) owner.claim();
const arrived = interrupted('view');
if (arrived) return arrived;
+27 -7
View File
@@ -6573,11 +6573,10 @@ function createStreetLevelHarness({
const lookups = [];
const origins = [];
const revealed = [];
const { viewer, styleManager, currentGeneration } = createVoiceNavigationHarness();
const { viewer, styleManager, currentGeneration, panelSearch } = createVoiceNavigationHarness();
// Who owns the camera: the shell's navigation stamps and globe gestures.
let userNavigations = 0;
Object.defineProperty(styleManager, '_navigationGeneration', {
get: () => currentGeneration() + userNavigations,
get: () => currentGeneration(),
});
viewer.scene.canvas = Object.assign(new EventTarget(), { clientWidth: 1200, clientHeight: 800 });
let completeFlight = null;
@@ -6719,10 +6718,9 @@ function createStreetLevelHarness({
origins,
revealed,
street,
// The user searches or flies elsewhere, or drags the globe.
navigate: () => {
userNavigations += 1;
},
// The user searches or flies elsewhere (a stamp, as the shell's), or
// drags the globe.
navigate: panelSearch,
gesture: () => viewer.scene.canvas.dispatchEvent(new Event('pointerdown')),
};
}
@@ -7428,6 +7426,28 @@ test('show_street_level owns the camera from where its own flight lands', async
assert.ok(h.order.some((step) => step.startsWith('open:mapillary')));
});
// A camera taken during the action's own flight. 'navigate' is a
// location-panel search: a stamp that leaves the flight flying. 'gesture' is a
// press on the globe, which does not cancel a Cesium flight either.
for (const take of ['navigate', 'gesture']) {
test(`show_street_level stands down when the camera is taken during its own flight (${take})`, async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const h = createStreetLevelHarness();
const pending = h.runner('show_street_level', { latitude: 38.58, longitude: -121.49, provider: 'google' });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(h.order, ['fly'], 'the flight is under way');
h[take]();
h.finishFlight();
const result = await pending;
assert.deepEqual([result.ok, result.superseded], [false, true], result.error);
assert.deepEqual(
h.order.filter((step) => /^(map|params|open|arm|enable)/.test(step)),
[],
'no map switch, no source, no billed photo for the place the user left',
);
});
}
test('show_street_level closes the previous photo, so one that finds nothing leaves no other place on screen', async () => {
globalThis.window = globalThis.window || { clearTimeout, setTimeout, requestIdleCallback: null };
const empty = createStreetLevelHarness({ enabled: true, photoOpen: true, nearest: null });