mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
feat: add place search and routing tools
Add search_places and places_nearby over the app's Google Places routes, and plan_route over its OpenStreetMap routing. The place search service reports when no search key is configured, so an unconfigured server is not mistaken for an empty result, and rate limits become retry_later. Route endpoints accept a place name or coordinates.
This commit is contained in:
1 parent
85fee59da1
commit
e680a59bf5
14 files changed
+596
-23
No files matched your search
+3
-2
@@ -1,8 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
- Add tools for language-model clients and a local MCP server. `npm run mcp`
|
||||
serves earthquake, active-fire, launch, aircraft and satellite queries over
|
||||
stdio to clients such as Claude Code, reading from a running app. Tools are defined
|
||||
serves earthquake, active-fire, launch, aircraft, satellite, camera, radio,
|
||||
place and routing queries over stdio to clients such as Claude Code, reading
|
||||
from a running app. Tools are defined
|
||||
once in `gods-eye-view/tools`, reuse the layers' source factories, and are
|
||||
exposed through the protocol adapter in `gods-eye-view/tools/mcp`.
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ and `gods-eye-view/tools/mcp` exposes a composed catalog over the Model Context
|
||||
Protocol. `npm run mcp` serves Core's tools over stdio to a local MCP client,
|
||||
reading from a running app's `/api` routes (default `http://localhost:4173`).
|
||||
Queries cover earthquakes, active fires, recent launches, aircraft (in an
|
||||
area, by identifier, tracks, and type and route lookups) and satellites (next
|
||||
pass over a point, and those overhead now). Tools reuse the layers' portable source factories, take a
|
||||
area, by identifier, tracks, and type and route lookups), satellites (next
|
||||
pass over a point, and those overhead now), public cameras (including a
|
||||
camera's current image), radio stations, place search and routing. 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).
|
||||
|
||||
+6
-1
@@ -49,7 +49,9 @@ Services are the portable source factories the layers already use, such as
|
||||
plus a `places` service with `resolve(name, { signal })`. Sources request
|
||||
relative `/api/...` paths through an injected `fetchImpl`, so the same tool
|
||||
code runs wherever an application routes those paths.
|
||||
`createGeocodePlaceService` resolves place names through `/api/geocode`.
|
||||
`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`.
|
||||
|
||||
## The `area` argument
|
||||
|
||||
@@ -98,3 +100,6 @@ public feeds the sources already use.
|
||||
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
|
||||
| `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content |
|
||||
| `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs |
|
||||
| `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) |
|
||||
| `places_nearby` | `placeSearch` | Notable places around a point (Google Places) |
|
||||
| `plan_route` | `routing` | Walking, driving or cycling route over OpenStreetMap, with a simplified path |
|
||||
@@ -872,5 +872,6 @@
|
||||
"src/tools/localServer.test.mjs",
|
||||
"src/tools/queries/aviation.test.mjs",
|
||||
"src/tools/queries/space.test.mjs",
|
||||
"src/tools/queries/media.test.mjs"
|
||||
"src/tools/queries/media.test.mjs",
|
||||
"src/tools/queries/places.test.mjs"
|
||||
]
|
||||
@@ -3089,6 +3089,7 @@
|
||||
"src/tools/queries/space.js",
|
||||
"src/tools/queries/aviation.js",
|
||||
"src/tools/queries/media.js",
|
||||
"src/tools/queries/places.js",
|
||||
"src/sources/tle.js",
|
||||
"src/data/satellitePass.js"
|
||||
],
|
||||
|
||||
@@ -13,7 +13,11 @@ import {
|
||||
createAdsbLolSource,
|
||||
createOpenSkySource,
|
||||
} from '../../src/sources/live/standalone.js';
|
||||
import { createGeocodePlaceService } from '../../src/tools/places.js';
|
||||
import {
|
||||
createGeocodePlaceService,
|
||||
createPlaceSearchService,
|
||||
createRouteService,
|
||||
} from '../../src/tools/places.js';
|
||||
|
||||
export const DEFAULT_API_BASE = 'http://localhost:4173';
|
||||
|
||||
@@ -46,6 +50,8 @@ export function createLocalToolServices(options = {}) {
|
||||
satellites: createSatelliteSource({ fetchImpl }),
|
||||
cctv: createCctvSource({ fetchImpl }),
|
||||
radio: createRadioSource({ fetchImpl }),
|
||||
placeSearch: createPlaceSearchService({ fetchImpl }),
|
||||
routing: createRouteService({ fetchImpl }),
|
||||
places: createGeocodePlaceService({ fetchImpl }),
|
||||
};
|
||||
}
|
||||
@@ -93,6 +93,53 @@ function circleArea(lat, lon, radiusKm) {
|
||||
};
|
||||
}
|
||||
|
||||
/** JSON Schema for a single location: a place name, or lat and lon. */
|
||||
export const POINT_SCHEMA = Object.freeze({
|
||||
type: 'object',
|
||||
description: 'A place name, or lat and lon.',
|
||||
properties: {
|
||||
place: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
lat: { type: 'number', minimum: -90, maximum: 90 },
|
||||
lon: { type: 'number', minimum: -180, maximum: 180 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
});
|
||||
|
||||
/** Resolve a point argument to `{ label, lat, lon }`. */
|
||||
export async function resolvePoint(point, { services, signal } = {}) {
|
||||
if (point?.place != null && point.lat == null && point.lon == null) {
|
||||
const area = await resolveArea(
|
||||
{ place: point.place },
|
||||
{ services, signal },
|
||||
);
|
||||
return { label: area.label, ...areaCenter(area) };
|
||||
}
|
||||
if (point?.place == null && point?.lat != null && point?.lon != null)
|
||||
return {
|
||||
label: `${point.lat.toFixed(4)}, ${point.lon.toFixed(4)}`,
|
||||
lat: point.lat,
|
||||
lon: point.lon,
|
||||
};
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'A location needs either place, or lat and lon',
|
||||
);
|
||||
}
|
||||
|
||||
/** Distance in kilometers from an area's center to its farthest edge point. */
|
||||
export function areaRadiusKm(area) {
|
||||
if (area.center) return area.center.radiusKm;
|
||||
const center = areaCenter(area);
|
||||
return Math.max(
|
||||
...[
|
||||
{ lat: area.north, lon: area.west },
|
||||
{ lat: area.north, lon: area.east },
|
||||
{ lat: area.south, lon: area.west },
|
||||
{ lat: area.south, lon: area.east },
|
||||
].map((corner) => distanceKm(center, corner)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Great-circle distance in kilometers. */
|
||||
export function distanceKm(a, b) {
|
||||
const toRad = Math.PI / 180;
|
||||
|
||||
+10
-1
@@ -10,6 +10,7 @@ import {
|
||||
getAircraftTrack,
|
||||
} from './queries/aviation.js';
|
||||
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
|
||||
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
|
||||
import {
|
||||
findCctvCameras,
|
||||
findRadioStations,
|
||||
@@ -35,7 +36,12 @@ export {
|
||||
distanceKm,
|
||||
} from './area.js';
|
||||
export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js';
|
||||
export { createGeocodePlaceService, placeFromGeocodeResult } from './places.js';
|
||||
export {
|
||||
createGeocodePlaceService,
|
||||
createPlaceSearchService,
|
||||
createRouteService,
|
||||
placeFromGeocodeResult,
|
||||
} from './places.js';
|
||||
|
||||
/** Every query Core defines, in a stable order. */
|
||||
export const coreTools = Object.freeze([
|
||||
@@ -51,4 +57,7 @@ export const coreTools = Object.freeze([
|
||||
findCctvCameras,
|
||||
getCctvSnapshot,
|
||||
findRadioStations,
|
||||
searchPlaces,
|
||||
placesNearby,
|
||||
planRoute,
|
||||
]);
|
||||
@@ -81,6 +81,9 @@ test('the stdio server answers newline-delimited requests using only its data so
|
||||
'find_cctv_cameras',
|
||||
'get_cctv_snapshot',
|
||||
'find_radio_stations',
|
||||
'search_places',
|
||||
'places_nearby',
|
||||
'plan_route',
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
|
||||
+87
-3
@@ -1,9 +1,16 @@
|
||||
/**
|
||||
* A `places` service backed by the app's `/api/geocode` route. Applications
|
||||
* with a different place search can supply any object with the same
|
||||
* `resolve(name, { signal })` contract.
|
||||
* Place services backed by the app's own routes. Applications with different
|
||||
* providers can supply any objects with the same contracts:
|
||||
*
|
||||
* - `places.resolve(name, { signal })` turns a name into bounds (`/api/geocode`).
|
||||
* - `placeSearch.search(query, near, { signal })` and
|
||||
* `placeSearch.nearby(near, { signal })` find points of interest
|
||||
* (`/api/google/*`), reporting whether search is configured.
|
||||
* - `routing.route(points, profile, { signal })` plans a route (`/api/route`).
|
||||
*/
|
||||
|
||||
import { ToolError } from './catalog.js';
|
||||
|
||||
const POINT_RADIUS_DEGREES = 0.25;
|
||||
|
||||
/** Resolve place names to `{ name, bounds: { west, south, east, north } }` or null. */
|
||||
@@ -49,3 +56,80 @@ export function placeFromGeocodeResult(result) {
|
||||
};
|
||||
return { name: result.formatted_address || null, bounds };
|
||||
}
|
||||
|
||||
/** Search points of interest; `configured` is false when the server has no search key. */
|
||||
export function createPlaceSearchService({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
async function read(path, params, signal) {
|
||||
const response = await fetchImpl(`${path}?${new URLSearchParams(params)}`, {
|
||||
signal,
|
||||
});
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* the status below is authoritative */
|
||||
}
|
||||
if (!response.ok) throw httpFailure(response, 'Place search');
|
||||
return {
|
||||
configured: payload?.configured !== false,
|
||||
places: Array.isArray(payload?.places) ? payload.places : [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
search(query, { latitude, longitude, radiusM }, { signal } = {}) {
|
||||
return read(
|
||||
'/api/google/text-search',
|
||||
{ q: query, lat: latitude, lon: longitude, radiusM },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
nearby({ latitude, longitude, radiusM }, { signal } = {}) {
|
||||
return read(
|
||||
'/api/google/nearby-places',
|
||||
{ lat: latitude, lon: longitude, radiusM },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Plan routes between `[{ lat, lon }]` points; resolves `{ ok, ... }`. */
|
||||
export function createRouteService({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async route(points, profile, { signal } = {}) {
|
||||
const coords = points
|
||||
.map((point) => `${point.lon.toFixed(6)},${point.lat.toFixed(6)}`)
|
||||
.join(';');
|
||||
const response = await fetchImpl(
|
||||
`/api/route?${new URLSearchParams({ profile, coords })}`,
|
||||
{ signal },
|
||||
);
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* the status below is authoritative */
|
||||
}
|
||||
if (response.status === 429) throw httpFailure(response, 'Routing');
|
||||
if (payload?.ok === false || response.status === 400)
|
||||
return { ok: false, error: payload?.error || 'no route found' };
|
||||
if (!response.ok) throw httpFailure(response, 'Routing');
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function httpFailure(response, label) {
|
||||
if (response.status === 429) {
|
||||
const seconds = Number(response.headers.get('retry-after'));
|
||||
return new ToolError('retry_later', `${label} is rate limited`, {
|
||||
retryAfterSeconds:
|
||||
Number.isFinite(seconds) && seconds > 0 ? seconds : null,
|
||||
});
|
||||
}
|
||||
return new Error(`${label} HTTP ${response.status}`);
|
||||
}
|
||||
@@ -8,7 +8,13 @@ import {
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
import {
|
||||
LIMIT_SCHEMA,
|
||||
capRows,
|
||||
countNoun,
|
||||
isoTime,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
|
||||
const ICAO24_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
@@ -200,7 +206,7 @@ export const getAircraftTrack = defineTool({
|
||||
const points = [...track.records].sort(
|
||||
(a, b) => a.observedAtMs - b.observedAtMs,
|
||||
);
|
||||
const kept = thin(points, MAX_TRACK_POINTS);
|
||||
const kept = thinEvenly(points, MAX_TRACK_POINTS);
|
||||
const first = points[0];
|
||||
const last = points.at(-1);
|
||||
return {
|
||||
@@ -224,16 +230,6 @@ export const getAircraftTrack = defineTool({
|
||||
},
|
||||
});
|
||||
|
||||
/** Keep the first and last items and evenly spaced items between them. */
|
||||
function thin(items, max) {
|
||||
if (items.length <= max) return items;
|
||||
const step = (items.length - 1) / (max - 1);
|
||||
return Array.from(
|
||||
{ length: max },
|
||||
(_, index) => items[Math.round(index * step)],
|
||||
);
|
||||
}
|
||||
|
||||
export const getAircraftInfo = defineTool({
|
||||
name: 'get_aircraft_info',
|
||||
title: 'Aircraft type and route',
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/** Place search and routing queries. */
|
||||
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
POINT_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
areaRadiusKm,
|
||||
resolveArea,
|
||||
resolvePoint,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, thinEvenly } from '../results.js';
|
||||
|
||||
const MODES = { walk: 'foot', drive: 'car', bike: 'bike' };
|
||||
const MAX_ROUTE_POINTS = 100;
|
||||
const NOT_CONFIGURED =
|
||||
'Place search needs a Google Places key configured for this server';
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
function placeRow(place) {
|
||||
return {
|
||||
id: place.id ?? null,
|
||||
name: place.name ?? null,
|
||||
address: place.address ?? null,
|
||||
type: place.primaryType ?? null,
|
||||
lat: round(place.latitude, 6),
|
||||
lon: round(place.longitude, 6),
|
||||
distance_m: Number.isFinite(place.distanceM) ? place.distanceM : null,
|
||||
};
|
||||
}
|
||||
|
||||
export const searchPlaces = defineTool({
|
||||
name: 'search_places',
|
||||
title: 'Search places',
|
||||
description:
|
||||
'Find businesses, landmarks and other points of interest matching a ' +
|
||||
'query within an area, using Google Places.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
area: AREA_SCHEMA,
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['query', 'area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['placeSearch'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const center = areaCenter(area);
|
||||
const radiusM = Math.round(
|
||||
Math.min(50000, Math.max(500, areaRadiusKm(area) * 1000)),
|
||||
);
|
||||
const result = await services.placeSearch.search(
|
||||
args.query,
|
||||
{ latitude: center.lat, longitude: center.lon, radiusM },
|
||||
{ signal },
|
||||
);
|
||||
if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED);
|
||||
const rows = result.places
|
||||
.filter((place) =>
|
||||
areaContains(area, { lat: place.latitude, lon: place.longitude }),
|
||||
)
|
||||
.map(placeRow);
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'place')} matching "${args.query}" in ${area.label}.`,
|
||||
data: capRows(rows, args.limit),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const placesNearby = defineTool({
|
||||
name: 'places_nearby',
|
||||
title: 'Places nearby',
|
||||
description:
|
||||
'Notable places around a point within a radius, most notable first, ' +
|
||||
'using Google Places.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lat: { type: 'number', minimum: -90, maximum: 90 },
|
||||
lon: { type: 'number', minimum: -180, maximum: 180 },
|
||||
radius_m: { type: 'number', minimum: 10, maximum: 5000 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['lat', 'lon'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['placeSearch'],
|
||||
async run(args, { services, signal }) {
|
||||
const radiusM = Math.round(args.radius_m ?? 250);
|
||||
const result = await services.placeSearch.nearby(
|
||||
{ latitude: args.lat, longitude: args.lon, radiusM },
|
||||
{ signal },
|
||||
);
|
||||
if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED);
|
||||
const rows = result.places.map(placeRow);
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'place')} within ${radiusM} m.`,
|
||||
data: capRows(rows, args.limit),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const planRoute = defineTool({
|
||||
name: 'plan_route',
|
||||
title: 'Plan a route',
|
||||
description:
|
||||
'Walking, driving or cycling route between two locations over ' +
|
||||
'OpenStreetMap, with distance, duration and a simplified path.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: POINT_SCHEMA,
|
||||
to: POINT_SCHEMA,
|
||||
mode: { type: 'string', enum: Object.keys(MODES) },
|
||||
},
|
||||
required: ['from', 'to'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['routing'],
|
||||
async run(args, { services, signal }) {
|
||||
const [from, to] = await Promise.all([
|
||||
resolvePoint(args.from, { services, signal }),
|
||||
resolvePoint(args.to, { services, signal }),
|
||||
]);
|
||||
const mode = args.mode ?? 'walk';
|
||||
const route = await services.routing.route([from, to], MODES[mode], {
|
||||
signal,
|
||||
});
|
||||
if (!route?.ok)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`No ${mode} route from ${from.label} to ${to.label}: ${route?.error || 'no route found'}`,
|
||||
);
|
||||
const path = thinEvenly(route.geometry, MAX_ROUTE_POINTS).map(
|
||||
([lon, lat]) => [round(lon, 5), round(lat, 5)],
|
||||
);
|
||||
const km = route.distanceM / 1000;
|
||||
const minutes = Math.round(route.durationS / 60);
|
||||
return {
|
||||
summary: `${mode[0].toUpperCase()}${mode.slice(1)} from ${from.label} to ${to.label}: ${km.toFixed(1)} km, about ${minutes} minutes.`,
|
||||
data: {
|
||||
mode,
|
||||
from,
|
||||
to,
|
||||
distance_m: route.distanceM,
|
||||
duration_s: route.durationS,
|
||||
path_lon_lat: path,
|
||||
path_points: route.geometry.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,251 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { createPlaceSearchService, createRouteService } from '../places.js';
|
||||
|
||||
const place = (id, latitude, longitude, extra = {}) => ({
|
||||
id,
|
||||
name: `Place ${id}`,
|
||||
address: `${id} Main St`,
|
||||
latitude,
|
||||
longitude,
|
||||
distanceM: 120,
|
||||
primaryType: 'cafe',
|
||||
types: ['cafe'],
|
||||
...extra,
|
||||
});
|
||||
const placeSearch = (result) => ({
|
||||
calls: [],
|
||||
async search(query, near) {
|
||||
this.calls.push({ query, ...near });
|
||||
return result;
|
||||
},
|
||||
async nearby(near) {
|
||||
this.calls.push(near);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
const places = {
|
||||
resolve: async (name) =>
|
||||
({
|
||||
Austin: {
|
||||
name: 'Austin, Texas',
|
||||
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
|
||||
},
|
||||
Dallas: {
|
||||
name: 'Dallas, Texas',
|
||||
bounds: { west: -96.99, south: 32.62, east: -96.46, north: 33.02 },
|
||||
},
|
||||
})[name] ?? null,
|
||||
};
|
||||
|
||||
test('place search is centered on the area, kept inside it, and reports a missing key', async () => {
|
||||
const search = placeSearch({
|
||||
configured: true,
|
||||
places: [place('a', 30.27, -97.74), place('far', 40.7, -74)],
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { placeSearch: search, places },
|
||||
});
|
||||
const result = await catalog.call('search_places', {
|
||||
query: 'coffee',
|
||||
area: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(result.summary, '1 place matching "coffee" in Austin, Texas.');
|
||||
assert.deepEqual(result.data.rows[0], {
|
||||
id: 'a',
|
||||
name: 'Place a',
|
||||
address: 'a Main St',
|
||||
type: 'cafe',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
distance_m: 120,
|
||||
});
|
||||
assert.equal(search.calls[0].query, 'coffee');
|
||||
assert.ok(Math.abs(search.calls[0].latitude - 30.31) < 1e-9);
|
||||
assert.equal(search.calls[0].radiusM, 29642);
|
||||
|
||||
const unconfigured = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
placeSearch: placeSearch({ configured: false, places: [] }),
|
||||
places,
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
unconfigured.call('search_places', {
|
||||
query: 'coffee',
|
||||
area: { place: 'Austin' },
|
||||
}),
|
||||
(error) =>
|
||||
error.code === 'unavailable' && /Google Places key/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
test('nearby places use the given radius', async () => {
|
||||
const search = placeSearch({
|
||||
configured: true,
|
||||
places: [place('a', 30.27, -97.74)],
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { placeSearch: search },
|
||||
});
|
||||
const result = await catalog.call('places_nearby', {
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
radius_m: 400,
|
||||
});
|
||||
assert.equal(result.summary, '1 place within 400 m.');
|
||||
assert.deepEqual(search.calls[0], {
|
||||
latitude: 30.27,
|
||||
longitude: -97.74,
|
||||
radiusM: 400,
|
||||
});
|
||||
});
|
||||
|
||||
test('routes resolve endpoints, map modes and thin long paths', async () => {
|
||||
const requests = [];
|
||||
const geometry = Array.from({ length: 250 }, (_, index) => [
|
||||
-97.74 + index / 1000,
|
||||
30.27,
|
||||
]);
|
||||
const routing = {
|
||||
async route(points, profile) {
|
||||
requests.push({ points, profile });
|
||||
return {
|
||||
ok: true,
|
||||
profile,
|
||||
distanceM: 312400,
|
||||
durationS: 11220,
|
||||
geometry,
|
||||
};
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { routing, places },
|
||||
});
|
||||
const result = await catalog.call('plan_route', {
|
||||
from: { place: 'Austin' },
|
||||
to: { lat: 32.78, lon: -96.8 },
|
||||
mode: 'drive',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Drive from Austin, Texas to 32.7800, -96.8000: 312.4 km, about 187 minutes.',
|
||||
);
|
||||
assert.equal(requests[0].profile, 'car');
|
||||
assert.deepEqual(requests[0].points[1], {
|
||||
label: '32.7800, -96.8000',
|
||||
lat: 32.78,
|
||||
lon: -96.8,
|
||||
});
|
||||
assert.equal(result.data.path_points, 250);
|
||||
assert.equal(result.data.path_lon_lat.length, 100);
|
||||
assert.deepEqual(result.data.path_lon_lat.at(-1), [-97.491, 30.27]);
|
||||
|
||||
const failing = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
routing: {
|
||||
route: async () => ({ ok: false, error: 'route leg too long' }),
|
||||
},
|
||||
places,
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
failing.call('plan_route', {
|
||||
from: { place: 'Austin' },
|
||||
to: { place: 'Dallas' },
|
||||
}),
|
||||
/No walk route from Austin, Texas to Dallas, Texas: route leg too long/,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('plan_route', {
|
||||
from: { place: 'Austin', lat: 1 },
|
||||
to: { lat: 1, lon: 2 },
|
||||
}),
|
||||
/either place, or lat and lon/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the place and route services read the app routes and translate failures', async () => {
|
||||
const requested = [];
|
||||
const respond = (body, status = 200, headers = {}) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
});
|
||||
const search = createPlaceSearchService({
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(url);
|
||||
return url.includes('nearby')
|
||||
? respond({ configured: false, error: null, places: [] })
|
||||
: respond({ places: [place('a', 1, 2)] });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await search.search('tea', { latitude: 1, longitude: 2, radiusM: 300 }),
|
||||
{
|
||||
configured: true,
|
||||
places: [place('a', 1, 2)],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
await search.nearby({ latitude: 1, longitude: 2, radiusM: 50 }),
|
||||
{ configured: false, places: [] },
|
||||
);
|
||||
assert.deepEqual(requested, [
|
||||
'/api/google/text-search?q=tea&lat=1&lon=2&radiusM=300',
|
||||
'/api/google/nearby-places?lat=1&lon=2&radiusM=50',
|
||||
]);
|
||||
|
||||
const limited = createPlaceSearchService({
|
||||
fetchImpl: async () =>
|
||||
respond({ error: 'Rate limit exceeded', places: [] }, 429, {
|
||||
'Retry-After': '30',
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
limited.nearby({ latitude: 1, longitude: 2, radiusM: 50 }),
|
||||
(error) => error.code === 'retry_later' && error.retryAfterSeconds === 30,
|
||||
);
|
||||
|
||||
const routes = [];
|
||||
const routing = createRouteService({
|
||||
fetchImpl: async (url) => {
|
||||
routes.push(url);
|
||||
if (routes.length === 1)
|
||||
return respond({
|
||||
ok: true,
|
||||
distanceM: 10,
|
||||
durationS: 5,
|
||||
geometry: [[2, 1]],
|
||||
});
|
||||
if (routes.length === 2)
|
||||
return respond({ ok: false, error: 'need 2-12 coordinates' }, 400);
|
||||
return respond({ ok: false, error: 'rate limited' }, 429, {
|
||||
'Retry-After': '5',
|
||||
});
|
||||
},
|
||||
});
|
||||
const points = [
|
||||
{ lat: 1, lon: 2 },
|
||||
{ lat: 1.5, lon: 2.5 },
|
||||
];
|
||||
assert.equal((await routing.route(points, 'foot')).distanceM, 10);
|
||||
assert.equal(
|
||||
routes[0],
|
||||
'/api/route?profile=foot&coords=2.000000%2C1.000000%3B2.500000%2C1.500000',
|
||||
);
|
||||
assert.deepEqual(await routing.route(points, 'foot'), {
|
||||
ok: false,
|
||||
error: 'need 2-12 coordinates',
|
||||
});
|
||||
await assert.rejects(
|
||||
routing.route(points, 'foot'),
|
||||
(error) => error.code === 'retry_later' && error.retryAfterSeconds === 5,
|
||||
);
|
||||
});
|
||||
@@ -31,3 +31,13 @@ export function isoTime(ms) {
|
||||
export function countNoun(count, singular, plural = `${singular}s`) {
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
/** Keep the first and last items and evenly spaced items between them. */
|
||||
export function thinEvenly(items, max) {
|
||||
if (items.length <= max) return items;
|
||||
const step = (items.length - 1) / (max - 1);
|
||||
return Array.from(
|
||||
{ length: max },
|
||||
(_, index) => items[Math.round(index * step)],
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user