mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
af8856cedf
commit
f68aa988ec
5 files changed
+265
-4
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user