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);
});
+137
View File
@@ -63,6 +63,26 @@ const FOLLOWABLE = Object.freeze({
export const VIEW_FOLLOW_KINDS = Object.freeze(Object.keys(FOLLOWABLE));
/** Annotation kinds the app draws, as in its annotate_map action. */
export const VIEW_ANNOTATION_TYPES = Object.freeze([
'pin',
'highlight',
'area',
'arrow',
'route',
'label',
]);
const ANNOTATION_COLORS = Object.freeze([
'primary',
'amber',
'cyan',
'green',
'red',
]);
const MAX_ANNOTATIONS = 24;
const MAX_ANNOTATION_PARAM_CHARS = 6000;
const ANNOTATION_PARAM = 'an';
const MIN_ALTITUDE_M = 50;
const MAX_ALTITUDE_M = 20_000_000;
@@ -103,6 +123,43 @@ export const VIEW_PROPERTIES = Object.freeze({
enum: [...VIEW_MAPS],
description: 'Map imagery; photoreal is Google 3D tiles.',
}),
annotations: Object.freeze({
type: 'array',
maxItems: MAX_ANNOTATIONS,
description:
'Marks drawn on the globe: a pin, highlight, area or label at a place ' +
'name (target) or lat/lon, an arrow between two points, or a route ' +
'through points.',
items: {
type: 'object',
properties: {
type: { type: 'string', enum: [...VIEW_ANNOTATION_TYPES] },
target: { type: 'string', minLength: 1, maxLength: 200 },
latitude: { type: 'number', minimum: -90, maximum: 90 },
longitude: { type: 'number', minimum: -180, maximum: 180 },
toLatitude: { type: 'number', minimum: -90, maximum: 90 },
toLongitude: { type: 'number', minimum: -180, maximum: 180 },
points: {
type: 'array',
minItems: 2,
maxItems: 12,
items: {
type: 'object',
properties: {
latitude: { type: 'number', minimum: -90, maximum: 90 },
longitude: { type: 'number', minimum: -180, maximum: 180 },
},
required: ['latitude', 'longitude'],
additionalProperties: false,
},
},
label: { type: 'string', maxLength: 120 },
color: { type: 'string', enum: [...ANNOTATION_COLORS] },
},
required: ['type'],
additionalProperties: false,
},
}),
follow: Object.freeze({
type: 'object',
description:
@@ -119,6 +176,59 @@ export const VIEW_PROPERTIES = Object.freeze({
});
const round = (value, digits) => Number(value.toFixed(digits));
const latitude = (value) =>
Number.isFinite(value) && Math.abs(value) <= 90 ? value : undefined;
const longitude = (value) =>
Number.isFinite(value) && Math.abs(value) <= 180 ? value : undefined;
const text = (value, max) =>
typeof value === 'string' && value.trim()
? value.trim().slice(0, max)
: undefined;
/**
* One annotation with only the fields the app draws, or null when it has
* nothing to place. Unknown fields and out-of-range values are dropped.
*/
function annotationOf(input) {
if (!input || !VIEW_ANNOTATION_TYPES.includes(input.type)) return null;
const points = Array.isArray(input.points)
? input.points
.slice(0, 12)
.map((point) => ({
latitude: latitude(point?.latitude),
longitude: longitude(point?.longitude),
}))
.filter(
(point) =>
point.latitude !== undefined && point.longitude !== undefined,
)
: [];
// Coordinates are kept or dropped in pairs; half a position places nothing.
const pair = (lat, lon) =>
latitude(lat) !== undefined && longitude(lon) !== undefined
? [lat, lon]
: [undefined, undefined];
const [lat, lon] = pair(input.latitude, input.longitude);
const [toLat, toLon] = pair(input.toLatitude, input.toLongitude);
const entry = Object.fromEntries(
Object.entries({
type: input.type,
target: text(input.target, 200),
latitude: lat,
longitude: lon,
toLatitude: toLat,
toLongitude: toLon,
points: points.length >= 2 ? points : undefined,
label: text(input.label, 120),
color: ANNOTATION_COLORS.includes(input.color) ? input.color : undefined,
}).filter(([, value]) => value !== undefined),
);
const placed =
entry.target !== undefined ||
entry.points !== undefined ||
(entry.latitude !== undefined && entry.longitude !== undefined);
return placed ? Object.freeze(entry) : null;
}
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
/**
@@ -132,6 +242,7 @@ export function createView({
style = null,
map = null,
follow = null,
annotations = [],
} = {}) {
if (!Number.isFinite(camera?.lat) || !Number.isFinite(camera?.lon))
throw new TypeError('A view needs a camera lat and lon');
@@ -167,6 +278,12 @@ export function createView({
id: String(follow.id).toLowerCase(),
})
: null,
annotations: Object.freeze(
(Array.isArray(annotations) ? annotations : [])
.slice(0, MAX_ANNOTATIONS)
.map(annotationOf)
.filter(Boolean),
),
});
}
@@ -194,9 +311,28 @@ export function viewToParams(view) {
};
encodeLayerStateParams(params, state);
}
if (view.annotations.length) {
const encoded = JSON.stringify(view.annotations);
if (encoded.length <= MAX_ANNOTATION_PARAM_CHARS)
params.set(ANNOTATION_PARAM, encoded);
}
return params;
}
/** The annotations a share link carries, or an empty list. */
export function annotationsFromParams(params) {
const raw = params.get(ANNOTATION_PARAM);
if (!raw || raw.length > MAX_ANNOTATION_PARAM_CHARS) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.slice(0, MAX_ANNOTATIONS).map(annotationOf).filter(Boolean)
: [];
} catch {
return [];
}
}
/** The view a set of share-link hash parameters restores, or null. */
export function viewFromParams(params) {
const lat = Number.parseFloat(params.get('lat'));
@@ -226,6 +362,7 @@ export function viewFromParams(params) {
style: STYLES_BY_URL_NAME[params.get('style')] ?? null,
map: VIEW_MAPS.includes(map) ? map : null,
follow,
annotations: annotationsFromParams(params),
});
}
+49
View File
@@ -77,3 +77,52 @@ test('views use the same style names as the app share links', async () => {
assert.match(source, /const STYLE_TO_URL = STYLE_URL_NAMES;/);
assert.equal(STYLE_URL_NAMES.retro, 'crt');
});
test('annotations travel in the link, keeping only what the app draws', async () => {
const { annotationsFromParams } = await import('./index.js');
const view = createView({
camera: { lat: 25, lon: 121 },
annotations: [
{ type: 'pin', target: 'Taipei 101', label: 'Taipei 101', color: 'red' },
{
type: 'arrow',
latitude: 24,
longitude: 119,
toLatitude: 25,
toLongitude: 121,
},
{
type: 'route',
points: [
{ latitude: 1, longitude: 2 },
{ latitude: 3, longitude: 4 },
],
screenX: 0.5,
},
{ type: 'pin' },
{ type: 'sparkle', target: 'x' },
{ type: 'label', latitude: 200, longitude: 0, target: 'Here' },
],
});
assert.equal(view.annotations.length, 4);
assert.equal(view.annotations[2].screenX, undefined);
assert.deepEqual(view.annotations[3], { type: 'label', target: 'Here' });
const params = viewToParams(view);
assert.ok(params.has('an'));
assert.deepEqual(viewFromParams(params), view);
assert.deepEqual(
annotationsFromParams(new URLSearchParams('an=not json')),
[],
);
const many = createView({
camera: { lat: 0, lon: 0 },
annotations: Array.from({ length: 30 }, () => ({
type: 'label',
target: 'x'.repeat(200),
label: 'y'.repeat(120),
})),
});
assert.equal(many.annotations.length, 24);
// Too long for a link: the view keeps them, the link leaves them out.
assert.equal(viewToParams(many).has('an'), false);
});