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.
This commit is contained in:
Sameh Khamis committed 2026-10-02 18:29:42 -07:00
1 parent 89a448ee83
commit d4311a9d54
6 files changed
+77 -25

No files matched your search

+6 -3
View File
@@ -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`.
+29 -18
View File
@@ -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;
}
+27
View File
@@ -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/,
);
});
+2 -2
View File
@@ -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.
}
+2 -2
View File
@@ -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,
+11
View File
@@ -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(