From d4311a9d5455a218bfb1b2269f7a42bc99db1a6b Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 17:31:09 -0700 Subject: [PATCH] Let a view show an aircraft's cockpit view A view's follow may ask for cockpit view, which the app enters after it follows the aircraft; links cannot carry it and open the app following. Applying a view no longer flies the camera when the followed entity is already there, since the flight ended the follow; it flies only while waiting for the entity. The panel applies its first view again only when it asks for cockpit view. --- docs/TOOLS.md | 9 ++++--- src/app/embed.js | 47 ++++++++++++++++++++++-------------- src/app/embed.test.mjs | 27 +++++++++++++++++++++ src/app/globePanelRuntime.js | 4 +-- src/tools/queries/share.js | 4 +-- src/view/index.js | 11 +++++++++ 6 files changed, 77 insertions(+), 25 deletions(-) diff --git a/docs/TOOLS.md b/docs/TOOLS.md index ca47b0d7a..8930ca373 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -91,7 +91,8 @@ aircraft or satellite to follow. `createView` builds and bounds one, `viewToParams` and `viewFromParams` write and read it in the share-link format the app restores, and `viewUrl` gives the address that opens it. The style names are the ones share links use. Ships cannot be followed from a -link yet. +link yet. An aircraft's `follow` may ask for its cockpit view +(`cockpit: true`); links cannot carry that and open the app following it. A view can also carry `annotations`, the marks the app's `annotate_map` action draws (pins, highlights, areas, arrows, routes and labels at a place @@ -156,8 +157,10 @@ an app-only tool with `ui: { visibility: ['app'] }`; and setup prompts hidden; provider attribution stays. A page that frames it changes the view by posting `{ type: 'gev:view', id, view }` to the frame. The app applies it through its own actions (style, map, exactly the view's -layers, annotations, the camera, then the followed entity, retried until its -layer has it) and answers `{ type: 'gev:view-applied', id, ok, steps }`. It +layers, annotations, then the followed entity, retried until its layer has +it, or the camera when nothing is followed or the entity is not there yet, +since a camera flight would end the follow; then cockpit view when asked) and +answers `{ type: 'gev:view-applied', id, ok, steps }`. It posts `{ type: 'gev:ready' }` once it can take views, and only its parent page can send them. See `src/app/embed.js`. diff --git a/src/app/embed.js b/src/app/embed.js index 5bfb2a1be..f88568094 100644 --- a/src/app/embed.js +++ b/src/app/embed.js @@ -185,25 +185,36 @@ export async function applyView( await act('clear_annotations', {}); if (view.annotations.length) await act('annotate_map', { annotations: [...view.annotations] }); - steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); - if (view.follow) { - // The followed entity appears once its layer has data; retry until then. - const args = { - query: view.follow.id, - layerId: FOLLOW_LAYERS[view.follow.kind], - }; - let followed = false; - for (let attempt = 0; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) { - if (signal?.aborted) break; - try { - followed = (await run('track_entity', args))?.ok === true; - } catch { - followed = false; - } - if (!followed) await delay(retryMs, signal); - } - steps.push({ step: 'follow', ok: followed }); + if (!view.follow) { + steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + return steps; } + // A followed entity owns the camera, and a camera flight would end the + // follow, so fly to the view only while the entity is not there yet. It + // appears once its layer has data; retry until then. + const layerId = FOLLOW_LAYERS[view.follow.kind]; + const follow = async () => { + try { + return ( + (await run('track_entity', { query: view.follow.id, layerId }))?.ok === + true + ); + } catch { + return false; + } + }; + let followed = await follow(); + if (!followed) { + steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) }); + for (let attempt = 1; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) { + if (signal?.aborted) break; + await delay(retryMs, signal); + followed = await follow(); + } + } + steps.push({ step: 'follow', ok: followed }); + if (followed && view.follow.cockpit) + await act('control_cockpit', { action: 'enter', targetLayer: layerId }); return steps; } diff --git a/src/app/embed.test.mjs b/src/app/embed.test.mjs index 19e0b552f..acbf87baa 100644 --- a/src/app/embed.test.mjs +++ b/src/app/embed.test.mjs @@ -283,3 +283,30 @@ test('a panel reports a render error in full and stops drawing', () => { console.error = original; } }); + +test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => { + const viewer = fakeViewer(); + const { calls, run } = recorder(); + const steps = await applyView( + createView({ + camera: { lat: 32.7, lon: -117.2 }, + follow: { kind: 'military_aircraft', id: 'AE1234', cockpit: true }, + }), + { viewer, dataManager: fakeLayers(new Set()), run, retryMs: 0 }, + ); + // Following worked at once, so no camera flight ended it. + assert.equal(viewer.flights.length, 0); + assert.deepEqual(calls.slice(-2), [ + ['track_entity', { query: 'ae1234', layerId: 'military' }], + ['control_cockpit', { action: 'enter', targetLayer: 'military' }], + ]); + assert.ok(steps.every((step) => step.ok)); + assert.throws( + () => + createView({ + camera: { lat: 0, lon: 0 }, + follow: { kind: 'satellite', id: '25544', cockpit: true }, + }), + /no cockpit view/, + ); +}); diff --git a/src/app/globePanelRuntime.js b/src/app/globePanelRuntime.js index 73e1c1a48..8b33dbcce 100644 --- a/src/app/globePanelRuntime.js +++ b/src/app/globePanelRuntime.js @@ -471,8 +471,8 @@ export function panelRuntime(config) { location.href.split('#')[0] + new URL(url).hash, ); // The app restores this view from its link, following included, so - // it must not be applied again: flying there would end the follow. - if (queued === view) queued = null; + // it needs applying again only for what links cannot carry: cockpit. + if (queued === view && !view.follow?.cockpit) queued = null; } catch { // The view still arrives once the app is ready. } diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index f00fc43bb..fb51f6a01 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -85,8 +85,8 @@ export const showInGodsEyeView = defineTool({ "God's Eye View globe in the conversation; everywhere, a link that opens " + '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 satellite and with ' + - 'marks drawn on the map.', + 'style and map, optionally following an aircraft (or riding in its ' + + 'cockpit view) or a satellite, and with marks drawn on the map.', inputSchema: { type: 'object', properties: VIEW_ARGUMENTS, diff --git a/src/view/index.js b/src/view/index.js index 7bd0cd18c..8ab56d5a4 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -48,11 +48,13 @@ const FOLLOWABLE = Object.freeze({ layer: 'flights', owner: 'flights', key: 'selectedFlightsTrackingId', + cockpit: true, }, military_aircraft: { layer: 'military', owner: 'flights', key: 'selectedMilitaryTrackingId', + cockpit: true, }, satellite: { layer: 'satellites', @@ -169,6 +171,12 @@ export const VIEW_PROPERTIES = Object.freeze({ properties: { kind: { type: 'string', enum: [...VIEW_FOLLOW_KINDS] }, id: { type: 'string', pattern: '^[0-9A-Za-z~_-]{1,16}$' }, + cockpit: { + type: 'boolean', + description: + "Show the aircraft's cockpit view instead of following it from " + + 'outside. Aircraft only; links open the app following it.', + }, }, required: ['kind', 'id'], additionalProperties: false, @@ -253,6 +261,8 @@ export function createView({ const followed = follow ? FOLLOWABLE[follow.kind] : null; if (follow && !followed) throw new TypeError(`Cannot follow a ${follow.kind}`); + if (follow?.cockpit === true && !followed.cockpit) + throw new TypeError(`A ${follow.kind} has no cockpit view`); const enabled = new Set( layers.filter((layer) => REGISTERED_LAYER_IDS.includes(layer)), ); @@ -276,6 +286,7 @@ export function createView({ ? Object.freeze({ kind: follow.kind, id: String(follow.id).toLowerCase(), + ...(follow.cockpit === true ? { cockpit: true } : {}), }) : null, annotations: Object.freeze(