mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor: compose configurable geocoding and route services
This commit is contained in:
1 parent
3b91feffdc
commit
8b36c28b06
25 files changed
+764
-211
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { createGeospatialServices } from './geospatial.js';
|
||||
import { createHttpGeospatialProvider } from './http.js';
|
||||
import { createPlaceSearch } from './placeSearch.js';
|
||||
import { createGoogleGeocoder } from './google.js';
|
||||
import { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
|
||||
/** Google first when configured, then keyless Photon; transport stays local to setup. */
|
||||
export function createDefaultPlaceSearch({
|
||||
resolveApiKey,
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
signal,
|
||||
endpoints = {},
|
||||
providers = {},
|
||||
} = {}) {
|
||||
const forward = createPlaceSearch({
|
||||
signal,
|
||||
providers: providers.geocode || [
|
||||
createGoogleGeocoder({
|
||||
request(query, { bias, signal }) {
|
||||
const key = resolveApiKey?.();
|
||||
if (!key) return null;
|
||||
const url = new URL(
|
||||
endpoints.geocode ||
|
||||
'https://maps.googleapis.com/maps/api/geocode/json',
|
||||
);
|
||||
url.searchParams.set('address', query);
|
||||
url.searchParams.set('key', key);
|
||||
if (bias) url.searchParams.set('bounds', bias);
|
||||
return fetchImpl(url.toString(), { signal });
|
||||
},
|
||||
}),
|
||||
createPhotonGeocoder({ fetchImpl, endpoint: endpoints.photon }),
|
||||
],
|
||||
});
|
||||
return {
|
||||
...forward,
|
||||
...createGeospatialServices({
|
||||
signal,
|
||||
providers: {
|
||||
...createHttpGeospatialProvider({
|
||||
fetchImpl,
|
||||
resolveApiKey,
|
||||
endpoints,
|
||||
}),
|
||||
...providers,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// Compatibility for direct module callers. Application composition supplies its own instance.
|
||||
export const defaultGeospatial = createDefaultPlaceSearch({
|
||||
resolveApiKey: () => globalThis.window?.__GOOGLE_MAPS_API_KEY__,
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/** WGS84 coordinate pair in longitude/latitude order. */
|
||||
export function validCoordinate(pair) {
|
||||
return (
|
||||
Array.isArray(pair) &&
|
||||
pair.length >= 2 &&
|
||||
Number.isFinite(pair[0]) &&
|
||||
Math.abs(pair[0]) <= 180 &&
|
||||
Number.isFinite(pair[1]) &&
|
||||
Math.abs(pair[1]) <= 90
|
||||
);
|
||||
}
|
||||
|
||||
/** Compose independent geospatial operations; unsupported operations stay explicit. */
|
||||
export function createGeospatialServices({
|
||||
providers = {},
|
||||
signal: lifetime,
|
||||
} = {}) {
|
||||
const capabilities = Object.freeze(
|
||||
Object.fromEntries(
|
||||
['reverseGeocode', 'textSearch', 'nearby', 'route'].map((name) => [
|
||||
name,
|
||||
typeof providers[name] === 'function',
|
||||
]),
|
||||
),
|
||||
);
|
||||
async function invoke(name, args, options = {}) {
|
||||
const signal = AbortSignal.any(
|
||||
[
|
||||
lifetime,
|
||||
options.signal,
|
||||
AbortSignal.timeout(name === 'route' ? 13_000 : 5_000),
|
||||
].filter(Boolean),
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (!capabilities[name]) return null;
|
||||
const result = await providers[name](...args, { ...options, signal });
|
||||
signal.throwIfAborted();
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
capabilities,
|
||||
signal: lifetime,
|
||||
attribution: Object.freeze({ ...providers.attribution }),
|
||||
reverseGeocode(latitude, longitude, options) {
|
||||
if (!validCoordinate([longitude, latitude])) return Promise.resolve(null);
|
||||
return invoke('reverseGeocode', [latitude, longitude], options);
|
||||
},
|
||||
async textSearch(query, point, options) {
|
||||
if (
|
||||
!String(query || '').trim() ||
|
||||
!validCoordinate([point?.longitude, point?.latitude])
|
||||
)
|
||||
return [];
|
||||
return (
|
||||
(await invoke('textSearch', [String(query).trim(), point], options)) ||
|
||||
[]
|
||||
);
|
||||
},
|
||||
async nearby(point, options) {
|
||||
if (!validCoordinate([point?.longitude, point?.latitude])) return [];
|
||||
return (await invoke('nearby', [point], options)) || [];
|
||||
},
|
||||
async route(coordinates, profile = 'foot', options) {
|
||||
if (
|
||||
!Array.isArray(coordinates) ||
|
||||
coordinates.length < 2 ||
|
||||
coordinates.length > 12 ||
|
||||
!coordinates.every(validCoordinate)
|
||||
)
|
||||
return null;
|
||||
const profiles = providers.routeProfiles || ['foot', 'car', 'bike'];
|
||||
if (!profiles.includes(profile)) return null;
|
||||
const result = await invoke('route', [coordinates, profile], options);
|
||||
if (
|
||||
!result ||
|
||||
!Array.isArray(result.geometry) ||
|
||||
result.geometry.length < 2 ||
|
||||
!result.geometry.every(validCoordinate) ||
|
||||
!Number.isFinite(result.distanceM) ||
|
||||
result.distanceM < 0 ||
|
||||
!Number.isFinite(result.durationS) ||
|
||||
result.durationS < 0
|
||||
)
|
||||
return null;
|
||||
return { ...result, ok: true, profile };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
createGeospatialServices,
|
||||
createHttpGeospatialProvider,
|
||||
createDefaultPlaceSearch,
|
||||
} from './index.js';
|
||||
import { installRouteMiddleware } from '../../server/providers/places/routes.js';
|
||||
import { createRegionalPlaceProvider } from '../../server/providers/regional/place.js';
|
||||
|
||||
const point = { latitude: 30, longitude: -97, radiusM: 250 };
|
||||
const coordinates = [
|
||||
[-97, 30],
|
||||
[-97.001, 30.001],
|
||||
];
|
||||
const route = { geometry: coordinates, distanceM: 120, durationS: 90 };
|
||||
const json = (data) =>
|
||||
new Response(JSON.stringify(data), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
test('each operation can use an independent provider without coupling forward search', async () => {
|
||||
const calls = [];
|
||||
const service = createDefaultPlaceSearch({
|
||||
providers: {
|
||||
geocode: [
|
||||
{
|
||||
geocode: async () => ({
|
||||
place: { lat: 30, lng: -97 },
|
||||
answered: true,
|
||||
}),
|
||||
},
|
||||
],
|
||||
reverseGeocode: async (lat, lon) => {
|
||||
calls.push([lat, lon]);
|
||||
return { locality: 'Town' };
|
||||
},
|
||||
textSearch: async (q) => [{ name: q, ...point }],
|
||||
nearby: async (p) => [p],
|
||||
route: async () => route,
|
||||
routeProfiles: ['foot'],
|
||||
},
|
||||
});
|
||||
assert.equal((await service.geocode('Town')).place.lat, 30);
|
||||
assert.equal((await service.reverseGeocode(30, -97)).locality, 'Town');
|
||||
assert.equal((await service.textSearch('Library', point))[0].name, 'Library');
|
||||
assert.deepEqual(await service.nearby(point), [point]);
|
||||
assert.deepEqual(await service.route(coordinates), {
|
||||
...route,
|
||||
ok: true,
|
||||
profile: 'foot',
|
||||
});
|
||||
assert.equal(await service.route(coordinates, 'car'), null);
|
||||
assert.deepEqual(calls, [[30, -97]]);
|
||||
});
|
||||
|
||||
test('unsupported operations and invalid coordinates never initiate a request', async () => {
|
||||
let calls = 0;
|
||||
const service = createGeospatialServices({
|
||||
providers: {
|
||||
route: () => {
|
||||
calls++;
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(service.capabilities.reverseGeocode, false);
|
||||
assert.equal(await service.reverseGeocode(30, -97), null);
|
||||
assert.equal(
|
||||
await service.route([
|
||||
[999, 30],
|
||||
[-97, 30],
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(await service.route(coordinates, 'spaceship'), null);
|
||||
assert.equal(calls, 0);
|
||||
const invalid = createGeospatialServices({
|
||||
providers: { route: async () => ({ ...route, durationS: NaN }) },
|
||||
});
|
||||
assert.equal(await invalid.route(coordinates), null);
|
||||
});
|
||||
|
||||
test('application or caller cancellation rejects late provider and body results', async () => {
|
||||
for (const lifetimeAbort of [true, false]) {
|
||||
const lifetime = new AbortController();
|
||||
const caller = new AbortController();
|
||||
const service = createGeospatialServices({
|
||||
signal: lifetime.signal,
|
||||
providers: createHttpGeospatialProvider({
|
||||
endpoints: { route: '/custom/route' },
|
||||
fetchImpl: async (_url, { signal }) => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
(lifetimeAbort ? lifetime : caller).abort();
|
||||
assert.equal(signal.aborted, true);
|
||||
return { ...route, ok: true };
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
service.route(coordinates, 'foot', { signal: caller.signal }),
|
||||
{ name: 'AbortError' },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('compatible HTTP endpoints retain units and never place a server credential in browser requests', async () => {
|
||||
const urls = [];
|
||||
const service = createGeospatialServices({
|
||||
providers: createHttpGeospatialProvider({
|
||||
endpoints: {
|
||||
reverse: '/geo/reverse',
|
||||
textSearch: '/geo/search',
|
||||
nearby: '/geo/nearby',
|
||||
route: '/geo/route',
|
||||
},
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(new URL(url, 'https://example.test'));
|
||||
if (url.startsWith('/geo/reverse'))
|
||||
return json({
|
||||
status: 'OK',
|
||||
results: [{ formatted_address: 'Town', address_components: [] }],
|
||||
});
|
||||
if (url.startsWith('/geo/route')) return json({ ok: true, ...route });
|
||||
return json({ places: [{ name: 'Library', ...point }] });
|
||||
},
|
||||
}),
|
||||
});
|
||||
assert.equal(
|
||||
(await service.reverseGeocode(30, -97)).formattedAddress,
|
||||
'Town',
|
||||
);
|
||||
await service.textSearch('Library', point);
|
||||
await service.nearby(point);
|
||||
assert.equal((await service.route(coordinates)).distanceM, 120);
|
||||
assert.deepEqual(
|
||||
urls.map((url) => url.pathname),
|
||||
['/geo/reverse', '/geo/search', '/geo/nearby', '/geo/route'],
|
||||
);
|
||||
assert.ok(urls.every((url) => !url.searchParams.has('key')));
|
||||
});
|
||||
|
||||
test('HTTP refusal cancels its response and does not retry another destination', async () => {
|
||||
let cancelled = 0,
|
||||
calls = 0;
|
||||
const provider = createHttpGeospatialProvider({
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
return {
|
||||
ok: false,
|
||||
body: {
|
||||
async cancel() {
|
||||
cancelled++;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
await assert.rejects(provider.nearby(point), /unavailable/);
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(cancelled, 1);
|
||||
});
|
||||
|
||||
test('Photon configuration changes its endpoint without changing its normalized result', async () => {
|
||||
const service = createDefaultPlaceSearch({
|
||||
endpoints: { photon: 'https://search.example/api/' },
|
||||
fetchImpl: async (url) => {
|
||||
assert.equal(new URL(url).origin, 'https://search.example');
|
||||
return json({
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [-97, 30] },
|
||||
properties: { name: 'Town' },
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
assert.equal((await service.geocode('Town')).place.lat, 30);
|
||||
});
|
||||
|
||||
test('routing middleware uses configured OSRM servers while retaining request bounds and profile', async () => {
|
||||
let handler,
|
||||
calls = 0;
|
||||
installRouteMiddleware(
|
||||
{
|
||||
use(_path, fn) {
|
||||
handler = fn;
|
||||
},
|
||||
},
|
||||
{
|
||||
endpoints: { foot: 'https://routes.example/walking' },
|
||||
fetchImpl: async (url, options) => {
|
||||
calls++;
|
||||
assert.match(
|
||||
url,
|
||||
/^https:\/\/routes\.example\/walking\/route\/v1\/foot\//,
|
||||
);
|
||||
assert.equal(options.redirect, 'error');
|
||||
return json({
|
||||
code: 'Ok',
|
||||
routes: [{ geometry: { coordinates }, distance: 120, duration: 90 }],
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
const request = async (query) => {
|
||||
let payload;
|
||||
await handler(
|
||||
{ url: `/?${query}`, socket: { remoteAddress: '127.0.0.1' } },
|
||||
{
|
||||
writeHead() {},
|
||||
end(body) {
|
||||
payload = JSON.parse(body);
|
||||
},
|
||||
},
|
||||
);
|
||||
return payload;
|
||||
};
|
||||
assert.equal(
|
||||
(await request('profile=foot&coords=-97,30;-97.001,30.001')).distanceM,
|
||||
120,
|
||||
);
|
||||
assert.equal((await request('profile=foot&coords=-97,30;97,-30')).ok, false);
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('regional reverse geocoding uses the configured Nominatim endpoint and projection', async () => {
|
||||
const lookup = createRegionalPlaceProvider({
|
||||
endpoint: 'https://places.example/reverse',
|
||||
requestJson: async (url) => {
|
||||
assert.equal(new URL(url).origin, 'https://places.example');
|
||||
return { address: { city: 'Town', country: 'Country' } };
|
||||
},
|
||||
});
|
||||
assert.equal((await lookup(point)).locality, 'Town');
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { validCoordinate } from './geospatial.js';
|
||||
|
||||
function labels(values) {
|
||||
return [
|
||||
...new Set(
|
||||
values
|
||||
.map((value) =>
|
||||
String(value || '')
|
||||
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 120),
|
||||
)
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** Normalize reverse-geocoder labels without exposing provider response objects. */
|
||||
export function normalizeGoogleReverse(data) {
|
||||
if (data?.status !== 'OK' || !data.results?.length) return null;
|
||||
const result = data.results[0];
|
||||
const relevant = data.results.slice(0, 12);
|
||||
const components = Array.isArray(result.address_components)
|
||||
? result.address_components
|
||||
: [];
|
||||
const component = (type) =>
|
||||
components.find((item) => item.types?.includes(type))?.long_name || null;
|
||||
return {
|
||||
formattedAddress: result.formatted_address || null,
|
||||
locality:
|
||||
component('locality') ||
|
||||
component('postal_town') ||
|
||||
component('administrative_area_level_2'),
|
||||
region: component('administrative_area_level_1'),
|
||||
country: component('country'),
|
||||
types: result.types || [],
|
||||
labels: labels(relevant.map((item) => item.formatted_address)).slice(0, 12),
|
||||
streetLabels: labels(
|
||||
relevant.flatMap((item) =>
|
||||
(item.address_components || [])
|
||||
.filter((entry) => entry.types?.includes('route'))
|
||||
.map((entry) => entry.long_name),
|
||||
),
|
||||
).slice(0, 12),
|
||||
};
|
||||
}
|
||||
|
||||
/** Existing JSON protocols with application-selected endpoints and transport. */
|
||||
export function createHttpGeospatialProvider({
|
||||
fetchImpl = (...args) => fetch(...args),
|
||||
resolveApiKey,
|
||||
endpoints = {},
|
||||
} = {}) {
|
||||
const urls = {
|
||||
reverse: 'https://maps.googleapis.com/maps/api/geocode/json',
|
||||
textSearch: '/api/google/text-search',
|
||||
nearby: '/api/google/nearby-places',
|
||||
route: '/api/route',
|
||||
...endpoints,
|
||||
};
|
||||
async function json(endpoint, params, { signal } = {}) {
|
||||
if (!endpoint) return null;
|
||||
const separator = endpoint.includes('?') ? '&' : '?';
|
||||
const response = await fetchImpl(
|
||||
`${endpoint}${separator}${new URLSearchParams(params)}`,
|
||||
{ signal },
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel?.().catch(() => {});
|
||||
throw new Error('Geospatial service unavailable');
|
||||
}
|
||||
const data = await response.json();
|
||||
signal?.throwIfAborted();
|
||||
return data;
|
||||
}
|
||||
const places = (data) =>
|
||||
(Array.isArray(data?.places) ? data.places : [])
|
||||
.filter((place) => validCoordinate([place.longitude, place.latitude]))
|
||||
.slice(0, 20);
|
||||
return {
|
||||
attribution: {
|
||||
reverseGeocode: 'Google',
|
||||
textSearch: 'Google',
|
||||
nearby: 'Google',
|
||||
route: 'OpenStreetMap / OSRM',
|
||||
},
|
||||
routeProfiles: ['foot', 'car', 'bike'],
|
||||
async reverseGeocode(latitude, longitude, options) {
|
||||
const key = resolveApiKey?.();
|
||||
if (resolveApiKey && !key) return null;
|
||||
return normalizeGoogleReverse(
|
||||
await json(
|
||||
urls.reverse,
|
||||
{ latlng: `${latitude},${longitude}`, ...(key ? { key } : {}) },
|
||||
options,
|
||||
),
|
||||
);
|
||||
},
|
||||
async textSearch(query, { latitude, longitude, radiusM = 6000 }, options) {
|
||||
return places(
|
||||
await json(
|
||||
urls.textSearch,
|
||||
{ q: query, lat: latitude, lon: longitude, radiusM },
|
||||
options,
|
||||
),
|
||||
);
|
||||
},
|
||||
async nearby({ latitude, longitude, radiusM = 250 }, options) {
|
||||
return places(
|
||||
await json(
|
||||
urls.nearby,
|
||||
{ lat: latitude, lon: longitude, radiusM },
|
||||
options,
|
||||
),
|
||||
);
|
||||
},
|
||||
async route(coordinates, profile, options) {
|
||||
const coords = coordinates
|
||||
.map((pair) =>
|
||||
pair
|
||||
.slice(0, 2)
|
||||
.map((n) => n.toFixed(6))
|
||||
.join(','),
|
||||
)
|
||||
.join(';');
|
||||
const data = await json(urls.route, { profile, coords }, options);
|
||||
return data?.ok ? data : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
export { createPlaceSearch, unavailablePlaceSearch } from './placeSearch.js';
|
||||
export { createGoogleGeocoder, normalizeGooglePlace } from './google.js';
|
||||
export { createGeospatialServices, validCoordinate } from './geospatial.js';
|
||||
export {
|
||||
createHttpGeospatialProvider,
|
||||
normalizeGoogleReverse,
|
||||
} from './http.js';
|
||||
export { createPhotonGeocoder } from '../keylessGeocoder.js';
|
||||
export { createDefaultPlaceSearch } from './defaults.js';
|
||||
Reference in new issue
Block a user