mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
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:
1 parent
9d12da0a79
commit
b6af8b34e0
8 files changed
+120
-3
No files matched your search
@@ -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
@@ -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 |
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user