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

+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(