maps: OSM details come from TREK's own copy of OpenStreetMap first

Opening an OSM place asked the public Overpass servers for its tags, which
took five seconds and more and answered nothing while they were down. Where
the TREK services are in use, the details now come from the Places API's
details layer (/v1/osm), with the community values in the same answer, and
Overpass is asked only for an element the layer does not hold.
This commit is contained in:
Maurice
2026-10-30 20:49:58 +01:00
parent 5cfe925b78
commit f01ffc2c67
6 changed files with 102 additions and 5 deletions
+9 -1
View File
@@ -32,13 +32,21 @@ export function withCommunityValues(
return out;
}
/** Records built from TREK's own details layer, which carry the community answer already. */
const asked = new WeakSet<object>();
export function markCommunityAsked(details: Record<string, unknown>): Record<string, unknown> {
asked.add(details);
return details;
}
/** OSM details as Overpass and Photon gave them, with the community's values for the same element. */
export async function osmDetailsWithCommunity(
placeId: string,
details: Record<string, unknown> | null,
): Promise<Record<string, unknown> | null> {
const key = communityKeyOf(placeId);
if (!details || !key) return details;
if (!details || !key || asked.has(details)) return details;
const published = await trekCommunityByKeys([key]);
return withCommunityValues(details, published[key]);
}
+11 -2
View File
@@ -26,6 +26,7 @@
*/
import { logWarn } from '../../audit/audit-log.logger';
import { nominatimFetch, type GeoLane } from '../../geo/nominatim.client';
import { markCommunityAsked, withCommunityValues } from '../community-overlay';
import { OVERPASS_ALONG_QUERY_TIMEOUT_S, aroundPath, overpassAlongQuery } from '../maps-along.helpers';
import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers';
import {
@@ -47,6 +48,7 @@ import {
} from '../maps.helpers';
import { overpassSelector, tagsMatchFilter } from '../osm-filter.helpers';
import { OverpassBusy, hedged, lastAnsweredFirst, retryAfterMs, soonestRetryMs } from '../overpass-hedge.helpers';
import { asOverpassElement, trekOsmElement } from '../trek-osm-element';
import {
photonElementDetails,
photonIdentity,
@@ -732,10 +734,17 @@ export class OsmClient {
const [osmType, osmId] = placeId.split(':');
// Where Photon is the geocoder, Overpass places the element and Photon
// names it; Nominatim is asked only when Overpass could not place it.
const located = this.photon.inUse() ? await this.fetchOverpassDetails(osmType, osmId, true) : undefined;
// TREK's own copy of OSM first: one read on the Places API instead of the
// public Overpass servers, which take seconds and are often down.
const own = this.photon.inUse() ? await trekOsmElement(osmType, osmId) : null;
const located = own
? asOverpassElement(own)
: this.photon.inUse()
? await this.fetchOverpassDetails(osmType, osmId, true)
: undefined;
if (located) {
const answer = await photonElementDetails(this.photon, located, { osmType, osmId, placeId, lang });
if (answer) return answer;
if (answer) return own ? markCommunityAsked(withCommunityValues(answer, own)) : answer;
}
// buildOsmDetails never yields name/address/coordinates — Nominatim is
// the source for those (Overpass contributes the tag-derived rest).
+37
View File
@@ -0,0 +1,37 @@
import { getJson, type TrekCommunityValues } from './trek-places.client';
/** An OSM element as the TREK Places API's details layer answers it: the tags, the point, and the community values. */
interface TrekOsmElement extends TrekCommunityValues {
name: string;
lat: number;
lng: number;
tags: Record<string, string>;
}
/** The details layer answers in well under a second or not at all; Overpass is the fallback, not this. */
const TIMEOUT_MS = 3_000;
/**
* An element's detail tags from TREK's own copy of OpenStreetMap
* (`/v1/osm/{type}/{id}`), or null when the layer does not hold it, the index
* is switched off or the service does not answer. Then Overpass is asked, as
* before.
*/
export async function trekOsmElement(osmType: string, osmId: string): Promise<TrekOsmElement | null> {
try {
const body = await getJson<{ place?: TrekOsmElement }>(
`/v1/osm/${encodeURIComponent(osmType)}/${encodeURIComponent(osmId)}`,
{},
{ timeoutMs: TIMEOUT_MS },
);
const place = body.place;
return place && typeof place.lat === 'number' && typeof place.lng === 'number' && place.tags ? place : null;
} catch {
return null;
}
}
/** The element as Overpass would have placed it (the shape of OverpassElement), for the code that reads Overpass answers. */
export function asOverpassElement(element: TrekOsmElement): { tags: Record<string, string>; lat: number; lon: number } {
return { tags: element.tags, lat: element.lat, lon: element.lng };
}
+1 -1
View File
@@ -241,7 +241,7 @@ function statusError(status: number): Error & { status: number } {
return err;
}
async function getJson<T>(
export async function getJson<T>(
path: string,
params: Record<string, string | number | undefined>,
opts: { maxBytes?: number; timeoutMs?: number; breaker?: Breaker } = {},
@@ -73,3 +73,12 @@ describe('community values on OSM details', () => {
expect(await osmDetailsWithCommunity('node:7', DETAILS)).toEqual(DETAILS);
});
});
describe('records that carry the community answer already', () => {
it('COMOV-006: are not asked about a second time', async () => {
const { markCommunityAsked } = await import('../../../src/nest/maps/community-overlay');
const record = markCommunityAsked({ ...DETAILS });
expect(await osmDetailsWithCommunity('relation:3389505', record)).toBe(record);
expect(ask).not.toHaveBeenCalled();
});
});
+35 -1
View File
@@ -34,6 +34,7 @@ const NOMINATIM_REVERSE = {
let identity: FakeServiceIdentity;
let photonReply: () => Response;
let overpassReply: () => Response;
let placesReply: (url: URL) => Response;
let osm: OsmClient;
const hosts: string[] = [];
@@ -55,6 +56,8 @@ beforeEach(() => {
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
// TREK's own details layer holds nothing unless a test says so.
placesReply = () => new Response(JSON.stringify({ detail: 'no such element' }), { status: 404 });
hosts.length = 0;
vi.stubEnv('NOMINATIM_URL', '');
vi.stubGlobal(
@@ -64,6 +67,7 @@ beforeEach(() => {
hosts.push(url.hostname);
if (url.hostname === 'photon.liketrek.com') return photonReply();
if (url.hostname === 'overpass-api.de') return overpassReply();
if (url.hostname === 'places.liketrek.com') return placesReply(url);
const body = url.pathname.endsWith('/reverse') ? NOMINATIM_REVERSE : [NOMINATIM_GATE];
return new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
}),
@@ -292,7 +296,37 @@ it('OSM-PHOTON-009: a lookup by id is Overpass for the tags and the place, Photo
osm_id: 'way:518071791',
website: 'https://example.org',
});
expect(hosts).toEqual(['overpass-api.de', 'photon.liketrek.com']);
// TREK's details layer is asked first; it does not hold this element here.
expect(hosts).toEqual(['places.liketrek.com', 'overpass-api.de', 'photon.liketrek.com']);
});
it("OSM-PHOTON-021: an element in TREK's own details layer asks no Overpass, and carries the community values", async () => {
placesReply = (url) => {
expect(url.pathname).toBe('/v1/osm/way/518071791');
return new Response(
JSON.stringify({
place: {
name: 'Brandenburger Tor',
lat: 52.5163,
lng: 13.3777,
tags: { website: 'https://example.org', opening_hours: '24/7' },
contact: { phone: '+49 30 1234567' },
community: { version: 2, fields: ['phone'] },
},
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
);
};
const details = await osm.elementDetails('way:518071791', 'en');
expect(details).toMatchObject({
website: 'https://example.org',
opening_hours: '24/7',
phone: '+49 30 1234567',
community_version: 2,
lat: 52.5163,
lng: 13.3777,
});
expect(hosts).toEqual(['places.liketrek.com', 'photon.liketrek.com']);
});
it('OSM-PHOTON-018: Nominatim names the element only when Overpass cannot place it, or without the TREK services', async () => {