diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js index 6820aefbd..941fd4616 100644 --- a/src/tools/queries/share.js +++ b/src/tools/queries/share.js @@ -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, diff --git a/src/tools/views.js b/src/tools/views.js index 9ffd6d588..59e8fe257 100644 --- a/src/tools/views.js +++ b/src/tools/views.js @@ -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 { diff --git a/src/tools/views.test.mjs b/src/tools/views.test.mjs index 6e8d9f321..474b37032 100644 --- a/src/tools/views.test.mjs +++ b/src/tools/views.test.mjs @@ -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); +}); diff --git a/src/view/index.js b/src/view/index.js index d988918ed..7bd0cd18c 100644 --- a/src/view/index.js +++ b/src/view/index.js @@ -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), }); } diff --git a/src/view/index.test.mjs b/src/view/index.test.mjs index 49497c6d7..818feb1ba 100644 --- a/src/view/index.test.mjs +++ b/src/view/index.test.mjs @@ -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); +});