feat: carry annotations in views and aim tilted views at the area

A view can carry the marks the app's annotate_map action draws, and links
carry them in the an parameter, bounded in count and size. A tilted view of
an area now places the camera behind its center so the area stays in the
middle of the frame.
This commit is contained in:
Sameh Khamis committed 2026-10-02 15:21:36 -07:00
1 parent af8856cedf
commit f68aa988ec
5 files changed
+265 -4

No files matched your search

+15 -3
View File
@@ -3,7 +3,7 @@
import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js';
import { defineTool, ToolError } from '../catalog.js';
import { AREA_SCHEMA, resolveArea } from '../area.js';
import { cameraForArea } from '../views.js';
import { cameraForArea, cameraLookingAt } from '../views.js';
/** Tool arguments that describe a view: an area to frame, or a camera. */
export const VIEW_ARGUMENTS = Object.freeze({
@@ -21,7 +21,17 @@ export async function resolveViewArguments(args, { services, signal }) {
if (args.area) {
const area = await resolveArea(args.area, { services, signal });
label = area.label;
camera = { ...cameraForArea(area), ...camera };
const framed = cameraForArea(area);
// A tilted view of an area looks at its center from behind, unless the
// caller placed the camera itself.
camera =
camera.lat === undefined && camera.lon === undefined
? cameraLookingAt(framed, {
altitudeM: camera.altitude_m ?? framed.altitude_m,
headingDeg: camera.heading_deg ?? 0,
pitchDeg: camera.pitch_deg ?? -90,
})
: { ...framed, ...camera };
}
if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon))
throw new ToolError(
@@ -34,6 +44,7 @@ export async function resolveViewArguments(args, { services, signal }) {
style: args.style ?? null,
map: args.map ?? null,
follow: args.follow ?? null,
annotations: args.annotations ?? [],
});
return {
view,
@@ -56,7 +67,8 @@ export const openInGodsEyeView = defineTool({
description:
"A link that opens God's Eye View at a view: an area framed from above " +
'or a camera position, with chosen data layers, visual style and map, ' +
'optionally following an aircraft or satellite.',
'optionally following an aircraft or satellite and with marks drawn on ' +
'the globe.',
inputSchema: {
type: 'object',
properties: VIEW_ARGUMENTS,
+37
View File
@@ -28,6 +28,43 @@ export function cameraForArea(area) {
};
}
const EARTH_RADIUS_M = 6_371_008.8;
/**
* The camera position for looking at a point from `altitudeM` with a heading
* and a tilt: straight above it when looking down, otherwise pulled back
* along the heading so the point stays in the middle of the view.
*/
export function cameraLookingAt(
target,
{ altitudeM, headingDeg = 0, pitchDeg = -90 },
) {
const camera = {
lat: target.lat,
lon: target.lon,
altitude_m: altitudeM,
heading_deg: headingDeg,
pitch_deg: pitchDeg,
};
if (pitchDeg <= -89.5) return camera;
// Ground distance from the camera to the point it looks at, on flat ground.
const back = altitudeM / Math.tan((-Math.min(pitchDeg, -5) * Math.PI) / 180);
const heading = (headingDeg * Math.PI) / 180;
const lat =
target.lat -
((back * Math.cos(heading)) / EARTH_RADIUS_M) * (180 / Math.PI);
const lon =
target.lon -
((back * Math.sin(heading)) /
(EARTH_RADIUS_M * Math.cos((target.lat * Math.PI) / 180))) *
(180 / Math.PI);
return {
...camera,
lat: Math.max(-90, Math.min(90, lat)),
lon: ((((lon + 180) % 360) + 360) % 360) - 180,
};
}
/** The app's address, or null when it is not configured. */
function appUrl(services) {
try {
+27 -1
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from './index.js';
import { viewFromParams } from '../view/index.js';
import { cameraForArea, suggestView } from './views.js';
import { cameraForArea, cameraLookingAt, suggestView } from './views.js';
import { resolveArea } from './area.js';
const app = { baseUrl: 'https://maps.example/' };
@@ -100,3 +100,29 @@ test('the flood and the brief frame what they describe', async () => {
});
assert.deepEqual(brief.data.view.layers, ['earthquakes', 'flights']);
});
test('a tilted camera sits behind the point it looks at', () => {
const target = { lat: 25, lon: 121 };
assert.deepEqual(cameraLookingAt(target, { altitudeM: 1000 }), {
lat: 25,
lon: 121,
altitude_m: 1000,
heading_deg: 0,
pitch_deg: -90,
});
// Looking north at 45 degrees from 10 km up: about 10 km south.
const north = cameraLookingAt(target, {
altitudeM: 10000,
headingDeg: 0,
pitchDeg: -45,
});
assert.ok(Math.abs(north.lat - (25 - 10000 / 111195)) < 1e-3);
assert.ok(Math.abs(north.lon - 121) < 1e-9);
// Looking east: the camera is to the west.
const east = cameraLookingAt(target, {
altitudeM: 10000,
headingDeg: 90,
pitchDeg: -45,
});
assert.ok(east.lon < 121 && Math.abs(east.lat - 25) < 1e-9);
});