diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index b53207b8d..14eb8ac27 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -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). diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 0457944bd..259963519 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -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 | diff --git a/scripts/format-scope.json b/scripts/format-scope.json index c868f363a..e48d6bd17 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -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" ] diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 21a5a9266..d9d5c93e0 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -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" diff --git a/server/mcp/services.js b/server/mcp/services.js index b9df46bd4..fb3785223 100644 --- a/server/mcp/services.js +++ b/server/mcp/services.js @@ -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 }), diff --git a/src/tools/index.js b/src/tools/index.js index a8d6f9eca..0efcd84f5 100644 --- a/src/tools/index.js +++ b/src/tools/index.js @@ -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, ]); diff --git a/src/tools/queries/share.js b/src/tools/queries/share.js new file mode 100644 index 000000000..1c1aaf703 --- /dev/null +++ b/src/tools/queries/share.js @@ -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 }, + }; + }, +}); diff --git a/src/tools/queries/share.test.mjs b/src/tools/queries/share.test.mjs new file mode 100644 index 000000000..570bc1d0f --- /dev/null +++ b/src/tools/queries/share.test.mjs @@ -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', + ); +});