mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
A place's bounds can reach far from the place itself; Tokyo's include remote Pacific islands, so the middle of its box is open ocean. Keep the geocoder's point for a named place and use it as the area's center, while bounds still decide what lies inside the area.
144 lines
4.7 KiB
JavaScript
144 lines
4.7 KiB
JavaScript
/**
|
|
* 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 }, point }`
|
|
* or null. `point` is the place's own location, which can lie far from the
|
|
* middle of its bounds (a region with remote islands, for example).
|
|
*/
|
|
export function createGeocodePlaceService({
|
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
|
} = {}) {
|
|
return {
|
|
async resolve(name, { signal } = {}) {
|
|
const query = new URLSearchParams({ q: name });
|
|
const response = await fetchImpl(`/api/geocode?${query}`, { signal });
|
|
if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`);
|
|
const payload = await response.json();
|
|
return placeFromGeocodeResult(payload?.results?.[0]);
|
|
},
|
|
};
|
|
}
|
|
|
|
/** Convert one geocode result to a place with bounds, or null. */
|
|
export function placeFromGeocodeResult(result) {
|
|
const location = result?.geometry?.location;
|
|
if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng))
|
|
return null;
|
|
const viewport = result.geometry.viewport;
|
|
const bounds =
|
|
viewport &&
|
|
[
|
|
viewport.southwest?.lat,
|
|
viewport.southwest?.lng,
|
|
viewport.northeast?.lat,
|
|
viewport.northeast?.lng,
|
|
].every(Number.isFinite)
|
|
? {
|
|
west: viewport.southwest.lng,
|
|
south: viewport.southwest.lat,
|
|
east: viewport.northeast.lng,
|
|
north: viewport.northeast.lat,
|
|
}
|
|
: {
|
|
west: Math.max(-180, location.lng - POINT_RADIUS_DEGREES),
|
|
south: Math.max(-90, location.lat - POINT_RADIUS_DEGREES),
|
|
east: Math.min(180, location.lng + POINT_RADIUS_DEGREES),
|
|
north: Math.min(90, location.lat + POINT_RADIUS_DEGREES),
|
|
};
|
|
return {
|
|
name: result.formatted_address || null,
|
|
bounds,
|
|
point: { lat: location.lat, lon: location.lng },
|
|
};
|
|
}
|
|
|
|
/** 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}`);
|
|
}
|