mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat: describe what the app shows as a view
A view is a camera, data layers, visual style, map imagery and an aircraft, military aircraft or satellite to follow, written in the share-link format the app already restores. gods-eye-view/view builds, encodes and decodes one, and share links take their style names from it. open_in_gods_eye_view now builds its link from a full view: an area or camera, layers, style, map and something to follow.
This commit is contained in:
1 parent
de1e0836b3
commit
96a8a9addb
10 files changed
+463
-72
No files matched your search
+71
-54
@@ -1,10 +1,6 @@
|
||||
/** A link that opens God's Eye View on an area. */
|
||||
/** Links that open God's Eye View at a view. */
|
||||
|
||||
import {
|
||||
REGISTERED_LAYER_IDS,
|
||||
createDefaultLayerState,
|
||||
encodeLayerStateParams,
|
||||
} from '../../data/layerState.js';
|
||||
import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { AREA_SCHEMA, areaCenter, areaRadiusKm, resolveArea } from '../area.js';
|
||||
|
||||
@@ -13,65 +9,86 @@ const MAX_ALTITUDE_M = 15_000_000;
|
||||
// A top-down view sees roughly this many meters of ground per meter of altitude.
|
||||
const GROUND_PER_ALTITUDE = 0.55;
|
||||
|
||||
export const openInGodsEyeView = defineTool({
|
||||
name: 'open_in_gods_eye_view',
|
||||
title: "Open in God's Eye View",
|
||||
description:
|
||||
"A link that opens God's Eye View looking straight down on an area, " +
|
||||
'optionally with chosen data layers turned on.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
layers: {
|
||||
type: 'array',
|
||||
maxItems: REGISTERED_LAYER_IDS.length,
|
||||
items: { type: 'string', enum: [...REGISTERED_LAYER_IDS] },
|
||||
description: 'Layers to turn on, such as "flights" or "earthquakes".',
|
||||
},
|
||||
},
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['app'],
|
||||
async run(args, { services, signal }) {
|
||||
let base;
|
||||
try {
|
||||
base = new URL(services.app.baseUrl);
|
||||
} catch {
|
||||
throw new ToolError('unavailable', "The app's address is not configured");
|
||||
}
|
||||
/** Tool arguments that describe a view: an area to frame, or a camera. */
|
||||
export const VIEW_ARGUMENTS = Object.freeze({
|
||||
area: AREA_SCHEMA,
|
||||
...VIEW_PROPERTIES,
|
||||
});
|
||||
|
||||
/**
|
||||
* The view described by tool arguments. An area frames the camera straight
|
||||
* down over it; camera fields given alongside it override the framing.
|
||||
*/
|
||||
export async function resolveViewArguments(args, { services, signal }) {
|
||||
let camera = { ...args.camera };
|
||||
let label = null;
|
||||
if (args.area) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const center = areaCenter(area);
|
||||
const altitude = Math.round(
|
||||
Math.min(
|
||||
label = area.label;
|
||||
camera = {
|
||||
lat: center.lat,
|
||||
lon: center.lon,
|
||||
altitude_m: Math.min(
|
||||
MAX_ALTITUDE_M,
|
||||
Math.max(
|
||||
MIN_ALTITUDE_M,
|
||||
(areaRadiusKm(area) * 1000) / GROUND_PER_ALTITUDE,
|
||||
),
|
||||
),
|
||||
...camera,
|
||||
};
|
||||
}
|
||||
if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon))
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'Give an area, or a camera with lat and lon',
|
||||
);
|
||||
// The share-link camera and layer fields; the app restores its defaults
|
||||
// for everything else.
|
||||
const params = new URLSearchParams({
|
||||
v: '2',
|
||||
lat: center.lat.toFixed(4),
|
||||
lon: center.lon.toFixed(4),
|
||||
alt: String(altitude),
|
||||
heading: '0',
|
||||
pitch: '-90',
|
||||
const view = createView({
|
||||
camera,
|
||||
layers: args.layers,
|
||||
style: args.style ?? null,
|
||||
map: args.map ?? null,
|
||||
follow: args.follow ?? null,
|
||||
});
|
||||
return {
|
||||
view,
|
||||
label:
|
||||
label ?? `${view.camera.lat.toFixed(3)}, ${view.camera.lon.toFixed(3)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function appBase(services) {
|
||||
try {
|
||||
return new URL(services.app.baseUrl).href;
|
||||
} catch {
|
||||
throw new ToolError('unavailable', "The app's address is not configured");
|
||||
}
|
||||
}
|
||||
|
||||
export const openInGodsEyeView = defineTool({
|
||||
name: 'open_in_gods_eye_view',
|
||||
title: "Open in God's Eye View",
|
||||
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.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: VIEW_ARGUMENTS,
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['app'],
|
||||
async run(args, { services, signal }) {
|
||||
const base = appBase(services);
|
||||
const { view, label } = await resolveViewArguments(args, {
|
||||
services,
|
||||
signal,
|
||||
});
|
||||
const layers = [...new Set(args.layers || [])];
|
||||
if (layers.length)
|
||||
encodeLayerStateParams(params, {
|
||||
...createDefaultLayerState(),
|
||||
enabledLayerIds: layers,
|
||||
});
|
||||
base.hash = params.toString();
|
||||
const url = viewUrl(base, view);
|
||||
return {
|
||||
summary: `Open ${area.label} in God's Eye View: ${base.href}`,
|
||||
data: { url: base.href, center, altitude_m: altitude, layers },
|
||||
summary: `Open ${label} in God's Eye View: ${url}`,
|
||||
data: { url, view },
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -22,6 +22,7 @@ test('links open the app looking straight down on the area', async () => {
|
||||
alt: '9091',
|
||||
heading: '0',
|
||||
pitch: '-90',
|
||||
roll: '0',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
@@ -37,11 +38,11 @@ test('altitude is bounded for tiny and planet-sized areas', async () => {
|
||||
const tiny = await catalog.call('open_in_gods_eye_view', {
|
||||
area: { lat: 0, lon: 0, radius_km: 0.1 },
|
||||
});
|
||||
assert.equal(tiny.data.altitude_m, 500);
|
||||
assert.equal(tiny.data.view.camera.altitude_m, 500);
|
||||
const world = await catalog.call('open_in_gods_eye_view', {
|
||||
area: { bbox: [-180, -85, 180, 85] },
|
||||
});
|
||||
assert.equal(world.data.altitude_m, 15000000);
|
||||
assert.equal(world.data.view.camera.altitude_m, 15000000);
|
||||
const broken = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { app: { baseUrl: 'not a url' } },
|
||||
@@ -64,7 +65,7 @@ test('links turn on the requested layers with the app share-link codec', async (
|
||||
area: { lat: 30.27, lon: -97.74, radius_km: 10 },
|
||||
layers: ['earthquakes', 'flights', 'flights'],
|
||||
});
|
||||
assert.deepEqual(result.data.layers, ['earthquakes', 'flights']);
|
||||
assert.deepEqual(result.data.view.layers, ['earthquakes', 'flights']);
|
||||
const params = new URLSearchParams(new URL(result.data.url).hash.slice(1));
|
||||
assert.deepEqual(decodeLayerStateParams(params).enabledLayerIds.sort(), [
|
||||
'earthquakes',
|
||||
@@ -78,3 +79,37 @@ test('links turn on the requested layers with the app share-link codec', async (
|
||||
/must be one of/,
|
||||
);
|
||||
});
|
||||
|
||||
test('links carry a camera, style, map and an entity to follow', async () => {
|
||||
const { viewFromParams } = await import('../../view/index.js');
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { app: { baseUrl: 'http://localhost:4173/' } },
|
||||
});
|
||||
const result = await catalog.call('open_in_gods_eye_view', {
|
||||
area: { lat: 25, lon: 121, radius_km: 200 },
|
||||
camera: { pitch_deg: -40, heading_deg: 350 },
|
||||
layers: ['ais-live-vessels'],
|
||||
style: 'thermal',
|
||||
map: 'esri-imagery',
|
||||
follow: { kind: 'military_aircraft', id: 'AE1234' },
|
||||
});
|
||||
const params = new URLSearchParams(new URL(result.data.url).hash.slice(1));
|
||||
assert.equal(params.get('style'), 'flir');
|
||||
assert.deepEqual(viewFromParams(params), result.data.view);
|
||||
assert.deepEqual(result.data.view.layers, ['ais-live-vessels', 'military']);
|
||||
assert.deepEqual(result.data.view.follow, {
|
||||
kind: 'military_aircraft',
|
||||
id: 'ae1234',
|
||||
});
|
||||
assert.equal(result.data.view.camera.pitch_deg, -40);
|
||||
const camera = await catalog.call('open_in_gods_eye_view', {
|
||||
camera: { lat: 48.8584, lon: 2.2945, altitude_m: 1200, pitch_deg: -30 },
|
||||
});
|
||||
assert.equal(camera.data.view.camera.altitude_m, 1200);
|
||||
assert.match(camera.summary, /^Open 48\.858, 2\.295 in God's Eye View: /);
|
||||
await assert.rejects(
|
||||
catalog.call('open_in_gods_eye_view', { layers: ['flights'] }),
|
||||
/Give an area, or a camera with lat and lon/,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user