feat: add a tool that links to an area in the app

Add open_in_gods_eye_view. It builds a version 2 share link looking
straight down on an area, with altitude chosen from the area's size, on
the app address the application supplies.
This commit is contained in:
Sameh Khamis committed 2026-10-01 12:42:08 -07:00
1 parent 9d12da0a79
commit b6af8b34e0
8 files changed
+120 -3

No files matched your search

+2 -1
View File
@@ -13,7 +13,8 @@ pass over a point, and those overhead now), public cameras (including a
camera's current image), radio stations, place search, routing, bike-share
stations, transit vehicles, road traffic flow, weather, regional briefs, tropical cyclones, fire
perimeters, terrain height, military installations and map features, plus a
combined situation brief and the app's heads-up display caption. Tools reuse the layers' portable source factories, take a
combined situation brief, the app's heads-up display caption, and a link that
opens the app over an area. Tools reuse the layers' portable source factories, take a
shared `area` argument (place name, bounding box, or point and radius) and cap
lists at 25 rows by default. The app does not import the tools, so its runtime
behavior is unchanged. See [tools and the MCP server](TOOLS.md).
+2 -1
View File
@@ -52,7 +52,7 @@ code runs wherever an application routes those paths.
The `weather`, `regional`, `terrain`, `summary` and `features` services are the
application request services from `gods-eye-view/application/requests`.
`situation_brief` runs each section whose services are supplied and marks the
others unavailable. The `bikeshare` service is `{ systems, getStations }`: the system registry and
others unavailable. `app` is `{ baseUrl }`, the address links open. The `bikeshare` service is `{ systems, getStations }`: the system registry and
the GBFS source. `createGeocodePlaceService` resolves place names through `/api/geocode`;
`createPlaceSearchService` searches `/api/google/*` and reports when no search key
is configured; `createRouteService` plans routes through `/api/route`.
@@ -122,3 +122,4 @@ public feeds the sources already use.
| `get_map_features` | `features` | Administrative areas, named places or monuments at a location (needs Overpass) |
| `situation_brief` | `weather` | Weather, earthquakes, fires, aircraft, ships and cyclones for an area, by section |
| `get_hud_caption` | `weather`, `summary` | The app's heads-up display caption for an area |
| `open_in_gods_eye_view` | `app` | A share link looking straight down on an area |
+2 -1
View File
@@ -877,5 +877,6 @@
"src/layers/transit/source.test.mjs",
"src/tools/queries/mobility.test.mjs",
"src/tools/queries/environment.test.mjs",
"src/tools/queries/maritime.test.mjs"
"src/tools/queries/maritime.test.mjs",
"src/tools/queries/share.test.mjs"
]
+1
View File
@@ -3097,6 +3097,7 @@
"src/tools/queries/environment.js",
"src/tools/queries/brief.js",
"src/tools/queries/maritime.js",
"src/tools/queries/share.js",
"src/sources/gbfsStations.js",
"src/sources/tle.js",
"src/data/satellitePass.js"
+1
View File
@@ -52,6 +52,7 @@ export function createLocalToolServices(options = {}) {
const fetchImpl = createApiFetch(options);
const requests = createApplicationRequestServices({ fetchImpl });
return {
app: { baseUrl: options.apiBase ?? DEFAULT_API_BASE },
earthquakes: createUsgsEarthquakeSource({ fetchImpl }),
fires: createFirmsSource({ fetchImpl }),
launches: createLaunchSource({ fetchImpl }),
+2
View File
@@ -31,6 +31,7 @@ import {
getTransitVehicles,
} from './queries/mobility.js';
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
import { openInGodsEyeView } from './queries/share.js';
import {
findCctvCameras,
findRadioStations,
@@ -95,4 +96,5 @@ export const coreTools = Object.freeze([
getMapFeatures,
situationBrief,
getHudCaption,
openInGodsEyeView,
]);
+55
View File
@@ -0,0 +1,55 @@
/** A link that opens God's Eye View on an area. */
import { defineTool, ToolError } from '../catalog.js';
import { AREA_SCHEMA, areaCenter, areaRadiusKm, resolveArea } from '../area.js';
const MIN_ALTITUDE_M = 500;
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.",
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA },
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");
}
const area = await resolveArea(args.area, { services, signal });
const center = areaCenter(area);
const altitude = Math.round(
Math.min(
MAX_ALTITUDE_M,
Math.max(
MIN_ALTITUDE_M,
(areaRadiusKm(area) * 1000) / GROUND_PER_ALTITUDE,
),
),
);
// The share-link camera fields; the app restores its defaults for the rest.
base.hash = new URLSearchParams({
v: '2',
lat: center.lat.toFixed(4),
lon: center.lon.toFixed(4),
alt: String(altitude),
heading: '0',
pitch: '-90',
}).toString();
return {
summary: `Open ${area.label} in God's Eye View: ${base.href}`,
data: { url: base.href, center, altitude_m: altitude },
};
},
});
+55
View File
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
test('links open the app looking straight down on the area', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'https://maps.example/view?x=1' } },
});
const result = await catalog.call('open_in_gods_eye_view', {
area: { lat: 30.2672, lon: -97.7431, radius_km: 5 },
});
const url = new URL(result.data.url);
assert.equal(
url.origin + url.pathname + url.search,
'https://maps.example/view?x=1',
);
assert.deepEqual(Object.fromEntries(new URLSearchParams(url.hash.slice(1))), {
v: '2',
lat: '30.2672',
lon: '-97.7431',
alt: '9091',
heading: '0',
pitch: '-90',
});
assert.equal(
result.summary,
`Open 5 km around 30.267, -97.743 in God's Eye View: ${url.href}`,
);
});
test('altitude is bounded for tiny and planet-sized areas', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:4173/' } },
});
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);
const world = await catalog.call('open_in_gods_eye_view', {
area: { bbox: [-180, -85, 180, 85] },
});
assert.equal(world.data.altitude_m, 15000000);
const broken = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'not a url' } },
});
await assert.rejects(
broken.call('open_in_gods_eye_view', {
area: { lat: 0, lon: 0, radius_km: 1 },
}),
(error) => error.code === 'unavailable',
);
});