mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-11 22:02:21 +00:00
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:
1 parent
ae66d07575
commit
0ebc09079d
8 files changed
+193
-79
No files matched your search
@@ -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
@@ -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(() => {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user