diff --git a/src/app/embed.js b/src/app/embed.js index 04da9cbc4..f4954e720 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -19,6 +19,7 @@ import { annotationsFromParams, createView, followFromParams, + groundedLookAt, streetLevelFromParams, } from '../view/index.js'; @@ -329,6 +330,7 @@ export function installViews({ dataManager, run, signal, + terrain = null, location = globalThis.location, windowRef = globalThis.window, }) { @@ -477,8 +479,27 @@ export function installViews({ // A panel's host can hand back old results on a remount: only one issued // just now may open a billed photo. A framing page's view is its own. const autoOpen = !isEmbeddedInline() || event.data.fresh === true; + // Photos asked for without a point open where the camera looks, measured + // from the ground under it, as show_in_gods_eye_view places them. + const asked = event.data.view?.street_level; + const unplaced = + asked && + typeof asked === 'object' && + asked.lat == null && + asked.lon == null; queue = queue.then(async () => { if (signal?.aborted) return; + if (unplaced && view.street_level) { + const point = await groundedLookAt(view.camera, terrain, signal).catch( + () => null, + ); + if (signal?.aborted) return; + if (point) + view = createView({ + ...event.data.view, + street_level: { ...asked, ...point }, + }); + } const steps = await applyView(view, { viewer, dataManager, diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 4fe754a92..cb34287a5 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -104,6 +104,65 @@ test('a followed entity is retried until its layer has it', async () => { assert.deepEqual(steps.at(-1), { step: 'follow', ok: true }); }); +test('a posted view without a photo point opens its photos where the camera looks over the ground', async () => { + const parent = { postMessage() {} }; + const windowRef = new EventTarget(); + windowRef.parent = parent; + windowRef.document = { body: { classList: new Set() } }; + windowRef.document.body.classList.add = Set.prototype.add; + const shell = { initialRestorePromise: Promise.resolve(), setCleanView() {} }; + const { calls, run } = recorder(); + const asked = []; + // Denver: the ground is 1,590 m above the ellipsoid under a camera at 1,909 m. + const terrain = { + getHeights: async (points) => { + asked.push(points); + return points.map(() => ({ ellipsoid: 1590 })); + }, + }; + const remove = installViews({ + shell, + viewer: fakeViewer(), + dataManager: fakeLayers(new Set()), + run, + terrain, + location: { search: '?embed=1', hash: '' }, + windowRef, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const event = new Event('message'); + Object.assign(event, { + source: parent, + origin: 'https://host.example', + data: { + type: 'gev:view', + id: 1, + view: { + camera: { + lat: 39.74, + lon: -104.99, + altitude_m: 1909, + pitch_deg: -30, + heading_deg: 0, + }, + street_level: { provider: 'mapillary' }, + }, + }, + }); + windowRef.dispatchEvent(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + remove(); + const [, args] = calls.find(([name]) => name === 'show_street_level'); + // 319 m above the ground at -30° looks ~550 m ahead, not the ~3.3 km the + // ellipsoid altitude would give. + assert.deepEqual(asked, [[{ lat: 39.74, lon: -104.99 }]]); + assert.ok( + args.latitude > 39.744 && args.latitude < 39.746, + String(args.latitude), + ); + assert.equal(args.longitude, -104.99); +}); + test('an embedded page takes views only from its parent and answers it', async () => { const posted = []; const targets = []; diff --git a/src/app/tools.js b/src/app/tools.js index b9a35976d..cffae32b2 100644 --- a/src/app/tools.js +++ b/src/app/tools.js @@ -249,6 +249,7 @@ export function createApplicationTools({ run: (name, args, options = {}) => voiceCommands.runner(name, args, { ...options, signal }), signal, + terrain: operations.requests.terrain, }), ); // An application's own tool takes the pointer as Draw does, and its own diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index bdb9d9267..2e13a98d7 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -3,7 +3,7 @@ import { VIEW_PROPERTIES, createView, - lookAtPoint, + groundedLookAt, viewUrl, } from '../../view/index.js'; import { defineTool, ToolError } from '../catalog.js'; @@ -41,30 +41,6 @@ async function followedAircraftPosition(follow, tools) { } } -/** - * Where a tilted camera looks, measured from the ground under it: a view's - * altitude is above the ellipsoid, and a city a mile up would otherwise put - * the point kilometres too far ahead. Null when the terrain cannot say. - */ -async function groundedLookAt(camera, services, signal) { - if (typeof services?.terrain?.getHeights !== 'function') return null; - if (!Number.isFinite(camera.altitude_m) || !(camera.pitch_deg > -89)) - return null; - let heights; - try { - heights = await services.terrain.getHeights( - [{ lat: camera.lat, lon: camera.lon }], - { signal }, - ); - } catch (error) { - if (signal?.aborted) throw error; - return null; - } - const ground = Number(heights?.[0]?.ellipsoid); - if (!Number.isFinite(ground)) return null; - return lookAtPoint({ ...camera, altitude_m: camera.altitude_m - ground }); -} - /** * The view described by tool arguments. An area frames the camera straight * down over it; camera fields given alongside it override the framing. A @@ -131,7 +107,7 @@ export async function resolveViewArguments(args, { services, signal, tools }) { streetLevel.lat === undefined && streetLevel.lon === undefined ) { - const point = await groundedLookAt(camera, services, signal); + const point = await groundedLookAt(camera, services?.terrain, signal); if (point) streetLevel = { ...streetLevel, ...point }; } let view; diff --git a/src/view/index.js b/src/view/index.js index 11c3b8c7d..7d70e1c8c 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -123,6 +123,30 @@ export function lookAtPoint(camera) { }; } +/** + * Where a tilted camera looks, measured from the ground under it: a view's + * altitude is above the ellipsoid, and a city a mile up would otherwise put + * the point kilometres too far ahead. `terrain` is a heights service + * (`getHeights`); null when it is missing or cannot say. + */ +export async function groundedLookAt(camera, terrain, signal) { + if (typeof terrain?.getHeights !== 'function') return null; + if (!Number.isFinite(camera?.altitude_m) || !(camera.pitch_deg > -89)) + return null; + let heights; + try { + heights = await terrain.getHeights([{ lat: camera.lat, lon: camera.lon }], { + signal, + }); + } catch (error) { + if (signal?.aborted) throw error; + return null; + } + const ground = Number(heights?.[0]?.ellipsoid); + if (!Number.isFinite(ground)) return null; + return lookAtPoint({ ...camera, altitude_m: camera.altitude_m - ground }); +} + /** Annotation kinds the app draws, as in its annotate_map action. */ export const VIEW_ANNOTATION_TYPES = Object.freeze([ 'pin',