diff --git a/src/tools/globePanel.test.mjs b/src/tools/globePanel.test.mjs index ea67bb6e3..51270d884 100644 --- a/src/tools/globePanel.test.mjs +++ b/src/tools/globePanel.test.mjs @@ -75,3 +75,36 @@ test('show_in_gods_eye_view names the panel and shows a view another answer retu (error) => error.code === 'invalid_arguments', ); }); + +test('a view that only follows an aircraft is framed where the aircraft is', async () => { + const { resolveViewArguments } = await import('./queries/share.js'); + const tools = { + has: (name) => name === 'find_aircraft', + call: async (name, args) => ({ + data: { + rows: + args.icao24 === 'ae1234' + ? [{ id: 'ae1234', lat: 32.7, lon: -117.2 }] + : [], + }, + }), + }; + const { view, label } = await resolveViewArguments( + { follow: { kind: 'military_aircraft', id: 'ae1234', cockpit: true } }, + { services: {}, tools }, + ); + assert.deepEqual( + [view.camera.lat, view.camera.lon, view.follow.cockpit], + [32.7, -117.2, true], + ); + assert.equal(label, 'military aircraft ae1234'); + await assert.rejects( + resolveViewArguments( + { follow: { kind: 'aircraft', id: 'abc999' } }, + { services: {}, tools }, + ), + (error) => + error.code === 'invalid_arguments' && + /not reported now/.test(error.message), + ); +}); diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index fb51f6a01..0c59ca7f0 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -18,11 +18,30 @@ export const VIEW_ARGUMENTS = Object.freeze({ ...VIEW_PROPERTIES, }); +// Altitude a view framed on a followed aircraft starts from. +const FOLLOW_ALTITUDE_M = 20_000; + +/** Where a followed aircraft is now, or null when no feed reports it. */ +async function followedAircraftPosition(follow, tools) { + if (!['aircraft', 'military_aircraft'].includes(follow?.kind)) return null; + if (!tools?.has('find_aircraft')) return null; + try { + const { data } = await tools.call('find_aircraft', { icao24: follow.id }); + const row = data?.rows?.find( + (item) => Number.isFinite(item.lat) && Number.isFinite(item.lon), + ); + return row ? { lat: row.lat, lon: row.lon } : null; + } catch { + return null; + } +} + /** * The view described by tool arguments. An area frames the camera straight - * down over it; camera fields given alongside it override the framing. + * down over it; camera fields given alongside it override the framing. A + * view that only follows an aircraft is framed where the aircraft is now. */ -export async function resolveViewArguments(args, { services, signal }) { +export async function resolveViewArguments(args, { services, signal, tools }) { const base = args.view && typeof args.view === 'object' ? args.view : {}; let camera = args.area ? { ...args.camera } @@ -43,11 +62,19 @@ export async function resolveViewArguments(args, { services, signal }) { }) : { ...framed, ...camera }; } - if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon)) - throw new ToolError( - 'invalid_arguments', - 'Give a view, an area, or a camera with lat and lon', - ); + const follow = args.follow ?? base.follow ?? null; + if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon)) { + const position = await followedAircraftPosition(follow, tools); + if (!position) + throw new ToolError( + 'invalid_arguments', + follow + ? `${follow.id} is not reported now; give an area or a camera with lat and lon too` + : 'Give a view, an area, or a camera with lat and lon', + ); + camera = { altitude_m: FOLLOW_ALTITUDE_M, ...camera, ...position }; + label = `${follow.kind === 'military_aircraft' ? 'military aircraft' : 'aircraft'} ${follow.id}`; + } let view; try { view = createView({ @@ -55,7 +82,7 @@ export async function resolveViewArguments(args, { services, signal }) { layers: args.layers ?? base.layers, style: args.style ?? base.style ?? null, map: args.map ?? base.map ?? null, - follow: args.follow ?? base.follow ?? null, + follow, annotations: args.annotations ?? base.annotations ?? [], }); } catch (error) { @@ -94,11 +121,12 @@ export const showInGodsEyeView = defineTool({ }, requires: ['app'], ui: { resourceUri: GLOBE_PANEL_URI }, - async run(args, { services, signal }) { + async run(args, { services, signal, tools }) { const base = appBase(services); const { view, label } = await resolveViewArguments(args, { services, signal, + tools, }); const url = viewUrl(base, view); return {