mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
fix(tours): search along a tour through the places index along the line, and name what could not be searched (PLN-02, CW-05)
The search along a tour found nothing. It asked the places index for all kinds of a stretch at once, above the 100 rows and 200 characters of terms the index takes, so the index refused and every stretch went to the public Overpass mirrors, which do not answer from this server. Now the index is asked in small circles strung along the line, one request per kind and circle within its limits, and an empty circle counts as an answer, so a city walk lists its sights, museums, parks, cafes, restaurants and markets in well under a second. The kinds the index does not know (viewpoints, huts, drinking water, toilets and the like) are asked of Overpass along the whole line at the same time, one query per stretch of up to 50 km, three at a time, each with a short deadline of its own; a line past 400 km is searched along its start and says so. When the index answers no circle at all, its kinds go to Overpass beside the others instead of after them. A kind no source could be searched for is named in a calm notice above the list instead of reporting nothing found, and the reason is written to the log. Unnamed taps, toilets, shelters, picnic spots, viewpoints, summits, slipways and repair stands stay in the list and on the map under the name of their kind; an unnamed garden, cafe or memorial is left out. The places nearest the line are kept when there are more than sixty, listed from the start, with the opening hours the source has. A walk's parks are parks and gardens only, and its museums are museums and galleries only, no longer the beaches, summits, theatres and cinemas of the map's explore buttons; a water park the index files under parks is not one of them. The index also answers markets and summits. The explore buttons keep their single request, and a search of several kinds that would exceed the index limits is asked one kind at a time instead of being refused. The MCP tool search_tour_pois and its wiki row describe the walk kinds and the new unavailable list, and the OVERPASS_TIMEOUT_MS row says the search along a tour keeps its own shorter deadline.
This commit is contained in:
@@ -152,7 +152,11 @@ function ThemeGrid({
|
||||
);
|
||||
}
|
||||
|
||||
/** What the search answered beyond its places: out of date, offline, failed, partly searched, cut, or empty. */
|
||||
/**
|
||||
* What the search answered beyond its places: out of date, offline, failed,
|
||||
* kinds that could not be searched, partly searched, cut, or empty. Nothing
|
||||
* found is said only when every kind was searched.
|
||||
*/
|
||||
function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit }) {
|
||||
const { t } = useTranslation();
|
||||
const { status, answer, outdated } = search;
|
||||
@@ -166,7 +170,9 @@ function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit })
|
||||
);
|
||||
if (!answer) return null;
|
||||
// Out of date is no fault, the route was just changed (often by adding a place from here): said plainly.
|
||||
const unavailable = answer.unavailable.map((category) => t(TOUR_POI_LOOKS[category].labelKey)).join(', ');
|
||||
const notes = [
|
||||
unavailable && t('tours.poi.unavailable', { kinds: unavailable }),
|
||||
answer.partial && t('tours.poi.partial'),
|
||||
answer.capped && t('tours.poi.capped'),
|
||||
answer.truncated && t('tours.poi.truncated'),
|
||||
@@ -179,7 +185,7 @@ function SearchNotices({ search, unit }: { search: TourPoiSearch; unit: Unit })
|
||||
{note}
|
||||
</TourNotice>
|
||||
))}
|
||||
{answer.pois.length === 0 && (
|
||||
{answer.pois.length === 0 && answer.unavailable.length === 0 && (
|
||||
<p className="m-0 text-content-muted" style={fs(12, 'body')}>
|
||||
{t('tours.poi.empty', { distance: formatDistance(km(search.corridorM), unit) })}
|
||||
</p>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import type { TourPoi, TourPoiSearchResponse } from '@trek/shared';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, within } from '../../../../tests/helpers/render';
|
||||
import { TranslationProvider } from '../../../i18n/TranslationContext';
|
||||
import { OnlineOnlyError } from '../../../repo/onlineOnly';
|
||||
import { tourPoiSearchRepo } from '../../../repo/tourPoiSearchRepo';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
@@ -9,9 +10,10 @@ import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController } from '../planner/useTourPlanner';
|
||||
import { TourPoiSearchCard } from './TourPoiSearchCard';
|
||||
import { usePoiInsertion, type PoiInsertionPlanner } from './usePoiInsertion';
|
||||
import { useTourPoiMarkers } from './useTourPoiMarkers';
|
||||
import { useTourPoiSearch } from './useTourPoiSearch';
|
||||
|
||||
// FE-TOUR-POI-001 to FE-TOUR-POI-008
|
||||
// FE-TOUR-POI-001 to FE-TOUR-POI-010
|
||||
|
||||
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
|
||||
|
||||
@@ -39,7 +41,13 @@ const SPRING: TourPoi = {
|
||||
distance_m: 80,
|
||||
along_m: 1650,
|
||||
};
|
||||
const ANSWER: TourPoiSearchResponse = { pois: [SPRING], truncated: false, partial: false, capped: false };
|
||||
const ANSWER: TourPoiSearchResponse = {
|
||||
pois: [SPRING],
|
||||
truncated: false,
|
||||
partial: false,
|
||||
capped: false,
|
||||
unavailable: [],
|
||||
};
|
||||
|
||||
let search: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
@@ -203,4 +211,38 @@ describe('TourPoiSearchCard', () => {
|
||||
fireEvent.click(within(row).getByTestId('tour-poi-add'));
|
||||
await waitFor(() => expect(planner.editWaypoints).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-009: names the kinds that could not be searched, and does not claim nothing was found', async () => {
|
||||
search.mockResolvedValue({ ...ANSWER, pois: [], unavailable: ['viewpoint', 'shelter'] });
|
||||
render(<TourPoiSearchCard planner={searchPlanner()} />);
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
expect(
|
||||
await screen.findByText('Could not be searched right now: Viewpoints, Huts and shelters. Try again in a moment.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Nothing found/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-TOUR-POI-010: an unnamed place is named by its kind in the list, on the map and once taken in', async () => {
|
||||
const tap: TourPoi = { ...SPRING, id: 'node:2', name: '' };
|
||||
search.mockResolvedValue({ ...ANSWER, pois: [tap] });
|
||||
const planner = { ...searchPlanner(), ...insertionPlanner() } as unknown as TourPlannerController;
|
||||
render(<TourPoiSearchCard planner={planner} />);
|
||||
fireEvent.click(screen.getByTestId('tour-poi-search'));
|
||||
const row = await screen.findByTestId('tour-poi-node:2');
|
||||
expect(within(row).getByRole('button', { name: /Drinking water/ })).toBeInTheDocument();
|
||||
|
||||
const markers = renderHook(() => useTourPoiMarkers(), { wrapper: TranslationProvider });
|
||||
expect(markers.result.current.pois.map((poi) => poi.name)).toEqual(['Drinking water']);
|
||||
|
||||
let taking = insertionPlanner();
|
||||
const { result, rerender } = renderHook(() => usePoiInsertion(taking), { wrapper: TranslationProvider });
|
||||
act(() => {
|
||||
result.current.insert(tap);
|
||||
});
|
||||
const added = vi.mocked(taking.setSelectedWaypointId).mock.calls[0]?.[0] as string;
|
||||
const withPoint = [PA, PB, { id: added, lat: tap.lat, lng: tap.lng, role: 'via' as const }, PC];
|
||||
taking = insertionPlanner({ waypoints: withPoint, routeCurrent: false, status: 'routing' });
|
||||
rerender();
|
||||
expect(result.current.outcome).toEqual({ kind: 'measuring', name: 'Drinking water' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { TourPoi } from '@trek/shared';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import { randomId } from '../../../utils/randomId';
|
||||
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
|
||||
import type { TourPlannerWaypoint } from '../planner/tourPlannerWaypoints';
|
||||
import type { TourPlannerController, TourPlannerStatus } from '../planner/useTourPlanner';
|
||||
import { defaultAfterId, insertionBlock, withPointAfter } from './poiInsertion';
|
||||
@@ -57,6 +59,7 @@ function measuresOf(planner: PoiInsertionPlanner): Measures | null {
|
||||
* hand or a fixed line is never cut: the point after which it would go says why.
|
||||
*/
|
||||
export function usePoiInsertion(planner: PoiInsertionPlanner) {
|
||||
const { t } = useTranslation();
|
||||
const select = useTourPoiStore((state) => state.select);
|
||||
const [afterChoice, setAfterChoice] = useState<{ poiId: string; afterId: string } | null>(null);
|
||||
const [inserted, setInserted] = useState<Inserted | null>(null);
|
||||
@@ -88,11 +91,13 @@ export function usePoiInsertion(planner: PoiInsertionPlanner) {
|
||||
const before = measuresOf(planner);
|
||||
planner.editWaypoints((current) => withPointAfter(current, afterId, point) ?? current);
|
||||
planner.setSelectedWaypointId(point.id);
|
||||
setInserted({ pointId: point.id, name: poi.name, before, pointCount: next.length });
|
||||
// An unnamed place (a tap, a bench) is told by its kind, as the list names it.
|
||||
const name = poi.name || t(TOUR_POI_LOOKS[poi.category].labelKey);
|
||||
setInserted({ pointId: point.id, name, before, pointCount: next.length });
|
||||
select(null);
|
||||
return true;
|
||||
},
|
||||
[afterIdFor, planner, select]
|
||||
[afterIdFor, planner, select, t]
|
||||
);
|
||||
|
||||
const dismiss = useCallback(() => setInserted(null), []);
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useTourPoiStore } from '../../../store/tourPoiStore';
|
||||
import type { Poi } from '../../Map/poiCategories';
|
||||
import { TOUR_POI_LOOKS, type Poi } from '../../Map/poiCategories';
|
||||
|
||||
/**
|
||||
* PLN-02: the places found along the tour as the planner map draws POIs, each
|
||||
* in its category's look, and a click on one that picks it in the card.
|
||||
* in its category's look, and a click on one that picks it in the card. An
|
||||
* unnamed place (a tap, a bench) is named by its kind, as the list names it.
|
||||
*/
|
||||
export function useTourPoiMarkers(): { pois: Poi[]; onPoiClick: (poi: Poi) => void } {
|
||||
const { t } = useTranslation();
|
||||
const found = useTourPoiStore((state) => state.pois);
|
||||
const select = useTourPoiStore((state) => state.select);
|
||||
const pois = useMemo(
|
||||
() =>
|
||||
found.map((poi): Poi => ({
|
||||
osm_id: poi.id,
|
||||
name: poi.name,
|
||||
name: poi.name || t(TOUR_POI_LOOKS[poi.category].labelKey),
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
category: poi.category,
|
||||
@@ -25,7 +28,7 @@ export function useTourPoiMarkers(): { pois: Poi[]; onPoiClick: (poi: Poi) => vo
|
||||
cuisine: null,
|
||||
source: poi.source,
|
||||
})),
|
||||
[found]
|
||||
[found, t]
|
||||
);
|
||||
return { pois, onPoiClick: (poi: Poi) => select(poi.osm_id) };
|
||||
}
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ DEMO_MODE=false # Demo mode: resets data hourly
|
||||
# NOMINATIM_URL=https://nominatim.openstreetmap.org # Custom HTTP(S) Nominatim base URL for every geocoding call the server makes: place search, reverse lookup, place details and the Atlas region fill. When set it REPLACES the public service with no fallback, so whatever answers has to speak the Nominatim API (/search, /reverse, /lookup) and hold the coverage you expect. TREK keeps its ~1 request per second spacing whichever instance answers. Unset/blank uses the public default.
|
||||
|
||||
# OVERPASS_URL= # Custom Overpass endpoint(s) for the map POI "explore" search, comma-separated. When set, REPLACES the bundled public mirrors — point it at an internal/self-hosted Overpass instance when the public ones are unreachable from your network. Non-http(s) entries are ignored. If you don't self-host Overpass but the public mirrors throttle you, setting APP_URL also gives outbound requests a unique User-Agent the mirrors rate-limit less.
|
||||
# OVERPASS_TIMEOUT_MS=25000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. The query itself gives Overpass 20 seconds of work, so a lower value hangs up on answers that were still coming. Raise it for a slow self-hosted instance. (default: 25000)
|
||||
# OVERPASS_TIMEOUT_MS=25000 # Per-endpoint timeout (ms) for Overpass POI requests; slower endpoints are abandoned so a faster mirror wins. The query itself gives Overpass 20 seconds of work, so a lower value hangs up on answers that were still coming. Raise it for a slow self-hosted instance. The Tours search along a tour does not read it: it gives Overpass 10 seconds of work and waits 12. (default: 25000)
|
||||
|
||||
# TREK_PLACES_ENABLED=false # Whether place search asks the TREK Places API at https://places.liketrek.com: suggestions, the full search, the category buttons on the map, the Road trip search along the route, import geocoding and offline downloads. It is on by default and has no switch in the admin panel, because whether searches leave the instance is a deployment decision. Set false to search the way it did before 4.3.0 (OpenStreetMap, or your Google/Amap key) and send nothing to that host. (default: true)
|
||||
# TREK_PLACES_URL=https://places.liketrek.com # Base URL of a copy of the Places API you run yourself; it has to answer the same /v1 API. Read from configuration rather than typed by a user, so an address on your LAN or Docker network works without ALLOW_INTERNAL_NETWORK. (default: https://places.liketrek.com)
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* The geometry of a search along a line (PLN-02): where the index is asked,
|
||||
* the stretches Overpass is asked in, and the query that asks for every kind
|
||||
* near one stretch at once. Pure: PoisAlongResolver makes the calls.
|
||||
*/
|
||||
import { lineMetres, pointAtMeters, simplifyLine, sliceAtMeters, type LatLng } from '@trek/shared/roadtrip';
|
||||
|
||||
/**
|
||||
* The most circles one line is asked in. The index answers each in a fraction
|
||||
* of a second, so this bounds the requests one search sends rather than its
|
||||
* time; a longer line is searched along its first part and the answer says so.
|
||||
*/
|
||||
const MAX_BEADS = 24;
|
||||
|
||||
/** The most points the Overpass query carries; the line is simplified until it fits. */
|
||||
const MAX_AROUND_POINTS = 120;
|
||||
|
||||
const METRES_PER_DEGREE = 111_320;
|
||||
|
||||
/**
|
||||
* The longest stretch of a line one Overpass query covers, so the box it asks
|
||||
* for candidates stays close to its part of the line however the line runs.
|
||||
*/
|
||||
const OVERPASS_STRETCH_M = 50_000;
|
||||
|
||||
/** The most stretches one line is asked in; a longer line is searched along its first 400 km and the answer says so. */
|
||||
const MAX_OVERPASS_STRETCHES = 8;
|
||||
|
||||
/**
|
||||
* How long Overpass may spend on the query near a line, in seconds, sent as
|
||||
* its `[timeout:N]`. Shorter than the explore pill's, because a person waits
|
||||
* for the search beside the index. The client waits a little longer than this
|
||||
* (osm.client), never less, or it would hang up on an answer still coming.
|
||||
*/
|
||||
export const OVERPASS_ALONG_QUERY_TIMEOUT_S = 10;
|
||||
|
||||
/** One circle the index is asked in. */
|
||||
interface AlongBead {
|
||||
lat: number;
|
||||
lng: number;
|
||||
radiusM: number;
|
||||
}
|
||||
|
||||
export interface AlongPlan {
|
||||
beads: AlongBead[];
|
||||
/** The part of the line the beads cover: all of it, unless the line was too long. */
|
||||
covered: LatLng[];
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Circles strung along the line like beads, so together they hold every point
|
||||
* within the corridor of it. They lie a step apart, at least two and at most
|
||||
* six corridor widths. Each reaches half a step and a corridor width: a place
|
||||
* near the line is at most a corridor from the line, and that point of the
|
||||
* line at most half a step from a bead, however the line turns at a street
|
||||
* corner. The index answers nearest first, so the wider reach costs nothing
|
||||
* close to the line. A line longer than the beads reach is covered along its
|
||||
* start.
|
||||
*/
|
||||
export function alongPlan(line: LatLng[], corridorM: number): AlongPlan {
|
||||
const total = lineMetres(line);
|
||||
const step = Math.min(6 * corridorM, Math.max(2 * corridorM, total / (MAX_BEADS - 1)));
|
||||
const coveredM = Math.min(total, step * (MAX_BEADS - 1));
|
||||
// A hair of slack, so a line exactly a whole number of steps long takes no bead twice.
|
||||
const steps = Math.max(0, Math.ceil(coveredM / step - 1e-9));
|
||||
const radiusM = Math.ceil(step / 2 + corridorM);
|
||||
const beads = Array.from({ length: steps + 1 }, (_, index) => pointAtMeters(line, Math.min(coveredM, index * step)))
|
||||
.filter((at): at is LatLng => at !== null)
|
||||
.map((at) => ({ lat: at.lat, lng: at.lng, radiusM }));
|
||||
const capped = coveredM < total;
|
||||
return { beads, covered: capped ? sliceAtMeters(line, 0, coveredM) : line, capped };
|
||||
}
|
||||
|
||||
interface AlongStretches {
|
||||
stretches: LatLng[][];
|
||||
/** The line was longer than the stretches reach; only its start is asked. */
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line cut into stretches of equal length, none longer than one Overpass
|
||||
* query covers, together the whole line unless it runs past what one search
|
||||
* asks. A line that fits one stretch is asked as it is.
|
||||
*/
|
||||
export function overpassStretches(line: LatLng[]): AlongStretches {
|
||||
const total = lineMetres(line);
|
||||
// A hair of slack, so a line exactly a whole number of stretches long takes no extra one.
|
||||
const needed = Math.max(1, Math.ceil(total / OVERPASS_STRETCH_M - 1e-9));
|
||||
if (needed === 1) return { stretches: [line], capped: false };
|
||||
const length = total / needed;
|
||||
// The last stretch ends at the line's own end, not where adding up the lengths lands.
|
||||
const end = (index: number) => (index + 1 === needed ? total : (index + 1) * length);
|
||||
const stretches = Array.from({ length: Math.min(needed, MAX_OVERPASS_STRETCHES) }, (_, index) =>
|
||||
sliceAtMeters(line, index * length, end(index)),
|
||||
);
|
||||
return { stretches, capped: needed > MAX_OVERPASS_STRETCHES };
|
||||
}
|
||||
|
||||
interface AroundPath {
|
||||
points: LatLng[];
|
||||
/** How far from the simplified line a place may lie and still be near the real one. */
|
||||
radiusM: number;
|
||||
bbox: { south: number; west: number; north: number; east: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* The line as Overpass' `around` filter takes it: simplified until it has at
|
||||
* most a hundred and twenty points, the reach widened by what the
|
||||
* simplification moved it, and the box that holds it all, so the query looks
|
||||
* at the candidates of that box only.
|
||||
*/
|
||||
export function aroundPath(line: LatLng[], corridorM: number): AroundPath {
|
||||
let toleranceM = Math.max(10, corridorM / 10);
|
||||
let points = simplifyLine(line, toleranceM / 1000);
|
||||
while (points.length > MAX_AROUND_POINTS) {
|
||||
toleranceM *= 2;
|
||||
points = simplifyLine(line, toleranceM / 1000);
|
||||
}
|
||||
const radiusM = Math.ceil(corridorM + toleranceM);
|
||||
const lats = points.map((point) => point.lat);
|
||||
const lngs = points.map((point) => point.lng);
|
||||
const padLat = radiusM / METRES_PER_DEGREE;
|
||||
const widest = Math.max(...lats.map((lat) => Math.abs(lat)));
|
||||
const padLng = radiusM / (METRES_PER_DEGREE * Math.max(0.01, Math.cos((widest * Math.PI) / 180)));
|
||||
return {
|
||||
points,
|
||||
radiusM,
|
||||
bbox: {
|
||||
south: Math.min(...lats) - padLat,
|
||||
west: Math.min(...lngs) - padLng,
|
||||
north: Math.max(...lats) + padLat,
|
||||
east: Math.max(...lngs) + padLng,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const coordinate = (value: number) => value.toFixed(5);
|
||||
|
||||
/**
|
||||
* One Overpass query for every kind near the line: the candidates of the box,
|
||||
* then those of them within reach of the line itself, with the short deadline
|
||||
* of its own above.
|
||||
*/
|
||||
export function overpassAlongQuery(filters: readonly string[], path: AroundPath, cap: number): string {
|
||||
const { south, west, north, east } = path.bbox;
|
||||
const box = `(${[south, west, north, east].map(coordinate).join(',')})`;
|
||||
const selectors = filters
|
||||
.map((filter) => {
|
||||
const [key, value] = filter.split('=');
|
||||
return ` nwr["${key}"="${value}"]${box};`;
|
||||
})
|
||||
.join('\n');
|
||||
const line = path.points.map((point) => `${coordinate(point.lat)},${coordinate(point.lng)}`).join(',');
|
||||
return `[out:json][timeout:${OVERPASS_ALONG_QUERY_TIMEOUT_S}];\n(\n${selectors}\n)->.cand;\nnwr.cand(around:${path.radiusM},${line});\nout center tags ${cap + 25};`;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { getCountryFromCoords } from '../../utils/country-borders';
|
||||
import { buildOsmDetails, parsePoiCategories, samePlace, toApiLang } from './maps.helpers';
|
||||
import { POI_RESULT_CAP, type PoiSearchResult } from './providers/osm.client';
|
||||
import {
|
||||
INDEX_MAX_CATEGORY_CHARS,
|
||||
INDEX_MAX_LIMIT,
|
||||
POI_CATEGORY_TO_TREK,
|
||||
indexRowTerm,
|
||||
isOsmHit,
|
||||
@@ -25,6 +27,8 @@ type PoiBbox = { south: number; west: number; north: number; east: number };
|
||||
export interface IndexPoiPlan {
|
||||
wanted: string[];
|
||||
terms: string[];
|
||||
/** The category each term answers for, so a request can be cut per category. */
|
||||
kindOfTerm: ReadonlyMap<string, string>;
|
||||
lat: number;
|
||||
lng: number;
|
||||
radius: number;
|
||||
@@ -34,6 +38,11 @@ export interface IndexPoiPlan {
|
||||
labelFor: (leaf: string | null, path: string | null) => string | undefined;
|
||||
}
|
||||
|
||||
/** Whether the index has terms for a category, so it can answer it at all. */
|
||||
export function indexKnowsCategory(key: string): boolean {
|
||||
return (POI_CATEGORY_TO_TREK[key]?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The index query for a POI request, or null when the index cannot answer it.
|
||||
*
|
||||
@@ -44,28 +53,6 @@ export interface IndexPoiPlan {
|
||||
* sixteen boxes, and each of those races four public mirrors.
|
||||
*/
|
||||
export function indexPoiPlan(category: string, bbox: PoiBbox, limit: number): IndexPoiPlan | null {
|
||||
const wanted = parsePoiCategories(category);
|
||||
// Which category each Overture term belongs to, so a hit can be labelled
|
||||
// with the category that actually produced it rather than with the whole
|
||||
// list. The client colours and groups its markers by that field.
|
||||
const categoryOfTerm = new Map<string, string>();
|
||||
for (const key of wanted) {
|
||||
for (const term of POI_CATEGORY_TO_TREK[key] ?? []) categoryOfTerm.set(term, key);
|
||||
}
|
||||
// All or nothing: a category the index has no terms for has to be answered
|
||||
// by Overpass, and a mixed answer would silently drop it.
|
||||
const indexKnowsAll = wanted.length > 0 && wanted.every((key) => POI_CATEGORY_TO_TREK[key]?.length);
|
||||
if (!indexKnowsAll) return null;
|
||||
// A row belongs to the term whose path it lies under, not to one it is merely
|
||||
// spelled like: the leaf alone is rarely a term (`italian_restaurant` sits
|
||||
// under `food_and_drink>restaurant`), and falling through to wanted[0] then
|
||||
// labelled it with whichever pill the user tapped first, so a trattoria came
|
||||
// back as a petrol station. The most specific term wins, so a water park
|
||||
// answers the activity pill rather than the park it sits under.
|
||||
const labelFor = (leaf: string | null, path: string | null): string | undefined => {
|
||||
const term = indexRowTerm(categoryOfTerm.keys(), leaf, path);
|
||||
return term === undefined ? undefined : categoryOfTerm.get(term);
|
||||
};
|
||||
const lat = (bbox.south + bbox.north) / 2;
|
||||
const lng = (bbox.west + bbox.east) / 2;
|
||||
// Half the diagonal, so the circle covers the viewport corners rather
|
||||
@@ -76,12 +63,117 @@ export function indexPoiPlan(category: string, bbox: PoiBbox, limit: number): In
|
||||
(bbox.east - bbox.west) * 111_320 * Math.cos((lat * Math.PI) / 180),
|
||||
) / 2,
|
||||
);
|
||||
return indexPoiPlanAround(category, { lat, lng }, reach, limit);
|
||||
}
|
||||
|
||||
/** The same plan for a circle the caller chose: a viewport's, or one bead of a line searched along. */
|
||||
export function indexPoiPlanAround(
|
||||
category: string,
|
||||
centre: { lat: number; lng: number },
|
||||
reach: number,
|
||||
limit: number,
|
||||
): IndexPoiPlan | null {
|
||||
const wanted = parsePoiCategories(category);
|
||||
// Which category each Overture term belongs to, so a hit can be labelled
|
||||
// with the category that actually produced it rather than with the whole
|
||||
// list. The client colours and groups its markers by that field.
|
||||
const categoryOfTerm = new Map<string, string>();
|
||||
for (const key of wanted) {
|
||||
for (const term of POI_CATEGORY_TO_TREK[key] ?? []) categoryOfTerm.set(term, key);
|
||||
}
|
||||
// All or nothing: a category the index has no terms for has to be answered
|
||||
// by Overpass, and a mixed answer would silently drop it.
|
||||
if (wanted.length === 0 || !wanted.every(indexKnowsCategory)) return null;
|
||||
// A row belongs to the term whose path it lies under, not to one it is merely
|
||||
// spelled like: the leaf alone is rarely a term (`italian_restaurant` sits
|
||||
// under `food_and_drink>restaurant`), and falling through to wanted[0] then
|
||||
// labelled it with whichever pill the user tapped first, so a trattoria came
|
||||
// back as a petrol station. The most specific term wins, so a water park
|
||||
// answers the activity pill rather than the park it sits under.
|
||||
const labelFor = (leaf: string | null, path: string | null): string | undefined => {
|
||||
const term = indexRowTerm(categoryOfTerm.keys(), leaf, path);
|
||||
return term === undefined ? undefined : categoryOfTerm.get(term);
|
||||
};
|
||||
const radius = Math.min(20000, Math.max(300, reach));
|
||||
// The same budget the Overpass path spends: per category, capped, so a
|
||||
// mixed search does not spend the whole allowance on whichever kind
|
||||
// happens to be densest.
|
||||
const cap = Math.min(limit * wanted.length, POI_RESULT_CAP);
|
||||
return { wanted, terms: [...categoryOfTerm.keys()], lat, lng, radius, reach, cap, labelFor };
|
||||
const terms = [...categoryOfTerm.keys()];
|
||||
return { wanted, terms, kindOfTerm: categoryOfTerm, lat: centre.lat, lng: centre.lng, radius, reach, cap, labelFor };
|
||||
}
|
||||
|
||||
/** One request to the index: the comma-joined terms it asks for and how many rows. */
|
||||
interface IndexRequest {
|
||||
category: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/** Terms joined with commas into as few lists as the service takes, none longer than its limit. */
|
||||
function packTerms(terms: readonly string[]): string[] {
|
||||
return terms.reduce<string[]>((lists, term) => {
|
||||
const last = lists.at(-1);
|
||||
if (last !== undefined && last.length + 1 + term.length <= INDEX_MAX_CATEGORY_CHARS) {
|
||||
lists[lists.length - 1] = `${last},${term}`;
|
||||
} else lists.push(term);
|
||||
return lists;
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The requests that answer a plan within the service's limits. One request,
|
||||
* exactly as before, while the whole plan fits; otherwise one per category
|
||||
* (its terms packed into lists the service takes), each with that category's
|
||||
* share of the budget. A plan of several categories used to ask for their
|
||||
* budgets together, and above 100 rows the service refused the request.
|
||||
*/
|
||||
export function indexRequests(plan: IndexPoiPlan): IndexRequest[] {
|
||||
const all = plan.terms.join(',');
|
||||
if (plan.cap <= INDEX_MAX_LIMIT && all.length <= INDEX_MAX_CATEGORY_CHARS) {
|
||||
return [{ category: all, limit: plan.cap }];
|
||||
}
|
||||
return indexRequestsPerKind(plan);
|
||||
}
|
||||
|
||||
/**
|
||||
* One request per category, each with its share of the budget and its terms
|
||||
* packed into lists the service takes. The search along a tour always asks
|
||||
* this way, so the restaurants of a city centre cannot take the place of its
|
||||
* one market among the nearest rows.
|
||||
*/
|
||||
export function indexRequestsPerKind(plan: IndexPoiPlan): IndexRequest[] {
|
||||
const share = Math.max(1, Math.min(INDEX_MAX_LIMIT, Math.floor(plan.cap / plan.wanted.length)));
|
||||
return plan.wanted.flatMap((key) =>
|
||||
packTerms(plan.terms.filter((term) => plan.kindOfTerm.get(term) === key)).map((category) => ({
|
||||
category,
|
||||
limit: share,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/** Every term the index is asked by, whichever kind asks for it. */
|
||||
const EVERY_TERM = Object.values(POI_CATEGORY_TO_TREK).flat();
|
||||
|
||||
/**
|
||||
* Whether a row's most specific known term is one an asked kind asks for. A
|
||||
* water park lies under the parks' path, but `activity` names its own path for
|
||||
* it, so a search for parks alone does not list it; a row no term knows is
|
||||
* left to {@link belonging}. The search along a tour asks this; the explore
|
||||
* pill keeps every row its pills' paths hold.
|
||||
*/
|
||||
export function ownedByAsked(
|
||||
row: { category: string | null; categoryPath: string | null },
|
||||
asked: readonly string[],
|
||||
): boolean {
|
||||
const term = indexRowTerm(EVERY_TERM, row.category, row.categoryPath);
|
||||
return term === undefined || asked.some((kind) => POI_CATEGORY_TO_TREK[kind]?.includes(term));
|
||||
}
|
||||
|
||||
/** The rows of several pages as one list, each place once, in the order they came. */
|
||||
export function mergeIndexPages<T extends { gers: string }>(pages: readonly T[][]): T[] {
|
||||
const byId = new Map<string, T>();
|
||||
for (const row of pages.flat()) if (!byId.has(row.gers)) byId.set(row.gers, row);
|
||||
return [...byId.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,10 +192,13 @@ export function indexPoiPlan(category: string, bbox: PoiBbox, limit: number): In
|
||||
export function indexPoiAnswer(
|
||||
found: (TrekPlace & { categoryPath?: string | null })[],
|
||||
plan: IndexPoiPlan,
|
||||
pageFull: boolean,
|
||||
): PoiSearchResult {
|
||||
const { wanted, labelFor, cap, radius, reach } = plan;
|
||||
// Pages asked per category can together run past the cap.
|
||||
const kept = belonging(found, plan).slice(0, cap);
|
||||
return {
|
||||
pois: belonging(found, plan).map((p) => ({
|
||||
pois: kept.map((p) => ({
|
||||
osm_id: `gers:${p.gers}`,
|
||||
name: p.name,
|
||||
lat: p.lat,
|
||||
@@ -133,8 +228,9 @@ export function indexPoiAnswer(
|
||||
})),
|
||||
source: 'trek-places' as const,
|
||||
// The page the index sent, not what was left of it: a full page means more
|
||||
// places, however many of them this one had to leave out.
|
||||
truncated: found.length >= cap,
|
||||
// places, however many of them this one had to leave out. Pages asked per
|
||||
// category say so for themselves, and their sum may run past the cap.
|
||||
truncated: pageFull || found.length > cap,
|
||||
// A wide viewport is narrowed here too, and the caller is told so
|
||||
// for the same reason the Overpass path tells it.
|
||||
clamped: radius < reach,
|
||||
|
||||
@@ -380,6 +380,10 @@ export const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
bike_repair: ['amenity=bicycle_repair_station', 'shop=bicycle'],
|
||||
// A city walk's market (CW-05): the square itself, not the shops of a mall as `shopping` has them.
|
||||
market: ['amenity=marketplace'],
|
||||
// A city walk's museums and galleries (CW-05), without the theatres and public art of `museum`.
|
||||
museum_gallery: ['tourism=museum', 'tourism=gallery'],
|
||||
// A city walk's parks and gardens (CW-05), without the beaches and summits of `nature`.
|
||||
park: ['leisure=park', 'leisure=garden'],
|
||||
};
|
||||
|
||||
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MapsService } from './maps.service';
|
||||
import { PlaceDetailsResolver } from './place-details.resolver';
|
||||
import { PlacePhotoResolver } from './place-photo.resolver';
|
||||
import { PlacesProviderSelector } from './places-provider.selector';
|
||||
import { PoisAlongResolver } from './pois-along.resolver';
|
||||
import { GooglePlacesClient } from './providers/google-places.provider';
|
||||
import { OsmClient } from './providers/osm.client';
|
||||
import { WikimediaClient } from './providers/wikimedia.client';
|
||||
@@ -54,6 +55,7 @@ import { Module } from '@nestjs/common';
|
||||
PlacePhotoResolver,
|
||||
MapsUrlResolver,
|
||||
PlaceDetailsResolver,
|
||||
PoisAlongResolver,
|
||||
],
|
||||
exports: [MapsService, GooglePlacesClient, OsmClient, WikimediaClient],
|
||||
})
|
||||
|
||||
@@ -4,13 +4,21 @@ import type { AppSettingsRepository } from '../../db/repositories/AppSettings.re
|
||||
import type { GeoLane } from '../geo/nominatim.client';
|
||||
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
|
||||
import type { ApiKeySource } from '../settings/instance-api-keys';
|
||||
import { belonging, indexPoiAnswer, indexPoiPlan, indexSuggestions } from './maps-index.helpers';
|
||||
import {
|
||||
belonging,
|
||||
indexPoiAnswer,
|
||||
indexPoiPlan,
|
||||
indexRequests,
|
||||
indexSuggestions,
|
||||
mergeIndexPages,
|
||||
} from './maps-index.helpers';
|
||||
import { NEARBY_DEFAULT_LIMIT, NEARBY_DEFAULT_RADIUS_M, nearbyCacheKey, nearestFirst } from './maps-nearby.helpers';
|
||||
import { MapsUrlResolver, type ResolvedMapsUrl } from './maps-url.resolver';
|
||||
import { toApiLang, mergeSearchResults } from './maps.helpers';
|
||||
import { PlaceDetailsResolver } from './place-details.resolver';
|
||||
import { PlacePhotoResolver } from './place-photo.resolver';
|
||||
import { PlacesProviderSelector, type KeyedProvider } from './places-provider.selector';
|
||||
import { PoisAlongResolver, type PoisAlongResult } from './pois-along.resolver';
|
||||
import type { AmapPlacesProvider } from './providers/amap.provider';
|
||||
import { GooglePlacesClient } from './providers/google-places.provider';
|
||||
import { OsmClient, type PoiSearchResult } from './providers/osm.client';
|
||||
@@ -75,6 +83,7 @@ export class MapsService {
|
||||
private readonly photos: PlacePhotoResolver,
|
||||
private readonly links: MapsUrlResolver,
|
||||
private readonly placeDetails: PlaceDetailsResolver,
|
||||
private readonly along: PoisAlongResolver,
|
||||
) {}
|
||||
|
||||
private async isSettingDisabled(key: string): Promise<boolean> {
|
||||
@@ -209,13 +218,17 @@ export class MapsService {
|
||||
const plan = indexPoiPlan(category, bbox, limit);
|
||||
if (this.trekPlacesEnabled() && plan) {
|
||||
try {
|
||||
const found = await trekPlacesNearby(plan.lat, plan.lng, {
|
||||
radius: plan.radius,
|
||||
limit: plan.cap,
|
||||
category: plan.terms.join(','),
|
||||
});
|
||||
// One request while the plan fits the service's limits, one per category beyond them.
|
||||
const pages = await Promise.all(
|
||||
indexRequests(plan).map(async ({ category: terms, limit: rows }) => ({
|
||||
rows,
|
||||
found: await trekPlacesNearby(plan.lat, plan.lng, { radius: plan.radius, limit: rows, category: terms }),
|
||||
})),
|
||||
);
|
||||
const found = mergeIndexPages(pages.map((page) => page.found));
|
||||
const pageFull = pages.some((page) => page.found.length >= page.rows);
|
||||
// A page of near misses only is a miss too, not an empty answer.
|
||||
if (belonging(found, plan).length > 0) return indexPoiAnswer(found, plan);
|
||||
if (belonging(found, plan).length > 0) return indexPoiAnswer(found, plan, pageFull);
|
||||
} catch (err: unknown) {
|
||||
console.warn('TREK Places nearby failed, falling back:', (err as Error).message);
|
||||
}
|
||||
@@ -223,6 +236,22 @@ export class MapsService {
|
||||
return this.osm.searchOverpassPois(category, bbox, lang, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Places of some kinds near a line (PLN-02): the index in circles along it,
|
||||
* Overpass for the kinds the index does not know, side by side. Never calls
|
||||
* Google. `limit` is each kind's budget per source; `failed` names the kinds
|
||||
* no source could be asked for.
|
||||
*/
|
||||
poisAlong(
|
||||
categories: string[],
|
||||
line: { lat: number; lng: number }[],
|
||||
corridorM: number,
|
||||
lang?: string,
|
||||
limit = 30,
|
||||
): Promise<PoisAlongResult> {
|
||||
return this.along.search({ categories, line, corridorM, lang, limit }, this.trekPlacesEnabled());
|
||||
}
|
||||
|
||||
// ── Keys and the keyed provider (resolved by PlacesProviderSelector) ─────
|
||||
|
||||
/** The Places credential for this request and where it came from (#1939). */
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Places of some kinds near a line (PLN-02, CW-05), from the TREK index and
|
||||
* OpenStreetMap side by side.
|
||||
*
|
||||
* The kinds the index knows are asked in circles strung along the line, the way
|
||||
* the explore pill asks it for a viewport: one small request per kind and
|
||||
* circle, each inside the service's limits, a few at a time. An empty
|
||||
* circle is an answer, so those kinds never wait on Overpass while the index
|
||||
* answers. The kinds it does not know (a viewpoint, a tap, a hut) go to
|
||||
* Overpass at the same time, one query near each stretch of the whole line,
|
||||
* with a short deadline. A source that fails is logged and its kinds named in
|
||||
* `failed`, so the caller can say what could not be searched instead of
|
||||
* reporting nothing found.
|
||||
*
|
||||
* MapsService.poisAlong delegates here and hands in whether the index is
|
||||
* switched on, which is a deployment property it owns.
|
||||
*/
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import { alongPlan, overpassStretches, type AlongPlan } from './maps-along.helpers';
|
||||
import {
|
||||
indexKnowsCategory,
|
||||
indexPoiAnswer,
|
||||
indexPoiPlanAround,
|
||||
indexRequestsPerKind,
|
||||
ownedByAsked,
|
||||
} from './maps-index.helpers';
|
||||
import type { OverpassPoi } from './maps.helpers';
|
||||
import { OsmClient } from './providers/osm.client';
|
||||
import { trekPlacesNearby } from './trek-places.client';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { LatLng } from '@trek/shared/roadtrip';
|
||||
|
||||
/** How many index requests are in flight at once, so one search never floods the service. */
|
||||
const PARALLEL_INDEX_REQUESTS = 8;
|
||||
|
||||
/** How many stretches one source asks Overpass for at once, so the public mirrors are not flooded. */
|
||||
const PARALLEL_OVERPASS_STRETCHES = 3;
|
||||
|
||||
/** What is searched for along which line: the kinds, how far from it, and each kind's budget per source. */
|
||||
interface PoisAlongQuery {
|
||||
categories: string[];
|
||||
line: LatLng[];
|
||||
corridorM: number;
|
||||
lang?: string;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface PoisAlongResult {
|
||||
pois: OverpassPoi[];
|
||||
/** The kinds no source could be asked for right now: their places are missing, not absent. */
|
||||
failed: string[];
|
||||
/** Some circles or stretches of the line could not be searched; the places of the others are there. */
|
||||
partial: boolean;
|
||||
/** A source had more places than it answered with. */
|
||||
truncated: boolean;
|
||||
/** The line was longer than one search covers; only its first part was searched. */
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
/** What one request of a source brings back. */
|
||||
interface Page {
|
||||
pois: OverpassPoi[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
interface Batched extends Page {
|
||||
/** How many of the source's requests answered, and how many failed. */
|
||||
answered: number;
|
||||
missed: number;
|
||||
/** Why requests failed, or null when every one answered. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface SourceAnswer extends Batched {
|
||||
/** The source was asked along the start of the line only. */
|
||||
capped: boolean;
|
||||
}
|
||||
|
||||
const NOTHING: SourceAnswer = { pois: [], truncated: false, answered: 0, missed: 0, error: null, capped: false };
|
||||
|
||||
const reason = (err: unknown) => (err instanceof Error ? err.message : String(err));
|
||||
|
||||
/** Each place once, as the first circle or source that found it has it: neighbouring circles overlap. */
|
||||
function oncePerPlace(pois: OverpassPoi[]): OverpassPoi[] {
|
||||
const seen = new Map<string, OverpassPoi>();
|
||||
for (const poi of pois) if (!seen.has(poi.osm_id)) seen.set(poi.osm_id, poi);
|
||||
return [...seen.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests a few at a time, each batch after the last, so no more than a
|
||||
* batch is ever in flight; a failed one leaves the others.
|
||||
*/
|
||||
async function inBatches(requests: Array<() => Promise<Page>>, parallel: number): Promise<Batched> {
|
||||
const batches = Array.from({ length: Math.ceil(requests.length / parallel) }, (_, index) =>
|
||||
requests.slice(index * parallel, (index + 1) * parallel),
|
||||
);
|
||||
const settled = await batches.reduce<Promise<PromiseSettledResult<Page>[]>>(
|
||||
async (done, batch) => [...(await done), ...(await Promise.allSettled(batch.map((ask) => ask())))],
|
||||
Promise.resolve([]),
|
||||
);
|
||||
const pages = settled.flatMap((entry) => (entry.status === 'fulfilled' ? [entry.value] : []));
|
||||
const misses = settled.flatMap((entry) => (entry.status === 'rejected' ? [reason(entry.reason)] : []));
|
||||
return {
|
||||
pois: pages.flatMap((page) => page.pois),
|
||||
truncated: pages.some((page) => page.truncated),
|
||||
answered: pages.length,
|
||||
missed: misses.length,
|
||||
error: misses.length ? `${misses.length} of ${settled.length} requests failed: ${misses[0]}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A source asked that answered none of its requests: its kinds could not be searched. */
|
||||
const unanswered = (answer: SourceAnswer) => answer.missed > 0 && answer.answered === 0;
|
||||
|
||||
@Injectable()
|
||||
export class PoisAlongResolver {
|
||||
constructor(private readonly osm: OsmClient) {}
|
||||
|
||||
async search(query: PoisAlongQuery, indexEnabled: boolean): Promise<PoisAlongResult> {
|
||||
const plan = alongPlan(query.line, query.corridorM);
|
||||
const indexed = indexEnabled ? query.categories.filter(indexKnowsCategory) : [];
|
||||
const rest = query.categories.filter((key) => !indexed.includes(key));
|
||||
// Only an index that answered no circle at all hands its kinds to Overpass,
|
||||
// once, while the other kinds are still being asked there rather than after.
|
||||
const indexPart = this.askIndex(indexed, plan, query.limit).then(async (index) => ({
|
||||
index,
|
||||
fallback: indexed.length > 0 && index.answered === 0 ? await this.askOverpass(indexed, query) : NOTHING,
|
||||
}));
|
||||
const [{ index, fallback }, overpass] = await Promise.all([indexPart, this.askOverpass(rest, query)]);
|
||||
if (index.error) logWarn(`[Maps] TREK places index along a line: ${index.error}`);
|
||||
for (const answer of [overpass, fallback]) {
|
||||
if (answer.error) logWarn(`[Maps] OpenStreetMap along a line: ${answer.error}`);
|
||||
}
|
||||
const sources = [index, overpass, fallback];
|
||||
return {
|
||||
pois: oncePerPlace([...index.pois, ...overpass.pois, ...fallback.pois]),
|
||||
failed: [...(unanswered(overpass) ? rest : []), ...(unanswered(fallback) ? indexed : [])],
|
||||
partial: sources.some((answer) => answer.answered > 0 && answer.missed > 0),
|
||||
truncated: sources.some((answer) => answer.truncated),
|
||||
// A source that answered nothing searched nothing: its kinds are failed or asked of Overpass instead.
|
||||
capped: sources.some((answer) => answer.answered > 0 && answer.capped),
|
||||
};
|
||||
}
|
||||
|
||||
/** The index kinds in every circle, a few requests at a time; a failed request leaves the others. */
|
||||
private async askIndex(kinds: string[], plan: AlongPlan, limit: number): Promise<SourceAnswer> {
|
||||
// No kinds make no plan, and so no request.
|
||||
const requests = plan.beads.flatMap((bead) => {
|
||||
const circle = indexPoiPlanAround(kinds.join(','), bead, bead.radiusM, limit);
|
||||
return circle
|
||||
? indexRequestsPerKind(circle).map((request) => async (): Promise<Page> => {
|
||||
const found = await trekPlacesNearby(circle.lat, circle.lng, {
|
||||
radius: circle.radius,
|
||||
limit: request.limit,
|
||||
category: request.category,
|
||||
});
|
||||
// A row goes to the kind that names its path most closely: a water park is no park.
|
||||
const own = found.filter((row) => ownedByAsked(row, kinds));
|
||||
const answer = indexPoiAnswer(own, circle, found.length >= request.limit);
|
||||
return { pois: answer.pois, truncated: answer.truncated };
|
||||
})
|
||||
: [];
|
||||
});
|
||||
return { ...(await inBatches(requests, PARALLEL_INDEX_REQUESTS)), capped: plan.capped };
|
||||
}
|
||||
|
||||
/**
|
||||
* The other kinds near each stretch of the whole line, one Overpass query
|
||||
* per stretch; nothing to ask is nothing found.
|
||||
*/
|
||||
private async askOverpass(kinds: string[], query: PoisAlongQuery): Promise<SourceAnswer> {
|
||||
if (!kinds.length) return NOTHING;
|
||||
const along = overpassStretches(query.line);
|
||||
const asked = kinds.join(',');
|
||||
const requests = along.stretches.map(
|
||||
(stretch) => () => this.osm.searchOverpassPoisAlong(asked, stretch, query.corridorM, query.lang, query.limit),
|
||||
);
|
||||
const answer = await inBatches(requests, PARALLEL_OVERPASS_STRETCHES);
|
||||
return { ...answer, error: answer.error && `${kinds.join(', ')}: ${answer.error}`, capped: along.capped };
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
* (lint:boundaries holds that).
|
||||
*/
|
||||
import { nominatimFetch, type GeoLane } from '../../geo/nominatim.client';
|
||||
import { OVERPASS_ALONG_QUERY_TIMEOUT_S, aroundPath, overpassAlongQuery } from '../maps-along.helpers';
|
||||
import { nearbyOverpassQuery, overpassNearbyRecords } from '../maps-nearby.helpers';
|
||||
import {
|
||||
UA,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
import { readWikiIdentity } from './wiki-identity';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { normalizePlaceWebsite } from '@trek/shared';
|
||||
import type { LatLng } from '@trek/shared/roadtrip';
|
||||
|
||||
function nominatimCategory(item: { class?: string; type?: string }): string | null {
|
||||
if (!item.class || !item.type || item.type === 'yes') return null;
|
||||
@@ -130,14 +132,14 @@ export const POI_RESULT_CAP = 240;
|
||||
// valid JSON. Throws {status:502} only if every mirror fails. Racing (rather than
|
||||
// trying one-by-one) keeps latency at the fastest reachable mirror instead of the
|
||||
// sum of every dead mirror's timeout.
|
||||
async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
async function overpassFetch(query: string, timeoutMs = OVERPASS_TIMEOUT_MS): Promise<OverpassPoiElement[]> {
|
||||
const body = `data=${encodeURIComponent(query)}`;
|
||||
const controllers: AbortController[] = [];
|
||||
|
||||
const attempt = async (url: string): Promise<OverpassPoiElement[]> => {
|
||||
const ctrl = new AbortController();
|
||||
controllers.push(ctrl);
|
||||
const timer = setTimeout(() => ctrl.abort(), OVERPASS_TIMEOUT_MS);
|
||||
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -188,6 +190,133 @@ async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/** The kinds of a POI request and the OSM selectors that answer each, every selector labelled with its kind. */
|
||||
interface PoiFilters {
|
||||
categories: string[];
|
||||
filters: string[];
|
||||
categoryOfFilter: Map<string, string>;
|
||||
}
|
||||
|
||||
/** One or several categories as Overpass selectors; an unknown one refuses the whole request. */
|
||||
function poiFilters(category: string): PoiFilters {
|
||||
// Each OSM selector belongs to exactly one category, so a hit can still be
|
||||
// labelled with the category it answered.
|
||||
const categories = parsePoiCategories(category);
|
||||
if (!categories.length) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
const categoryOfFilter = new Map<string, string>();
|
||||
for (const key of categories) {
|
||||
const own = CATEGORY_OSM_FILTERS[key];
|
||||
if (!own) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
for (const f of own) categoryOfFilter.set(f, key);
|
||||
}
|
||||
return { categories, filters: [...categoryOfFilter.keys()], categoryOfFilter };
|
||||
}
|
||||
|
||||
/** Keeps an answer in the POI cache, dropping the oldest entry once the cache is full. */
|
||||
function cachePoiAnswer(cacheKey: string, value: PoiSearchResult): void {
|
||||
// FIFO eviction: a Map preserves insertion order, so the first key is the oldest.
|
||||
if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string);
|
||||
POI_CACHE.set(cacheKey, { at: Date.now(), value });
|
||||
}
|
||||
|
||||
/** A fresh cached answer, or undefined; an expired one is dropped on the way. */
|
||||
function cachedPoiAnswer(cacheKey: string): PoiSearchResult | undefined {
|
||||
const cached = POI_CACHE.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value;
|
||||
if (cached) POI_CACHE.delete(cacheKey); // expired: drop it before refetching
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The kinds whose places OSM rarely names, kept unnamed by the search along a
|
||||
* tour: a tap, a hut, a bench with a view. An unnamed garden or memorial
|
||||
* plaque is no place to suggest, so every other kind still needs a name.
|
||||
*/
|
||||
const KEPT_UNNAMED_ALONG: ReadonlySet<string> = new Set([
|
||||
'drinking_water',
|
||||
'toilets',
|
||||
'shelter',
|
||||
'picnic',
|
||||
'viewpoint',
|
||||
'peak',
|
||||
'slipway',
|
||||
'bike_repair',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Overpass elements as POI rows, each labelled with the category its selector
|
||||
* belongs to. An unnamed element is dropped unless its category is one of
|
||||
* `keptUnnamed`; a shut-down place never comes back.
|
||||
*/
|
||||
function readPois(
|
||||
elements: OverpassPoiElement[],
|
||||
match: PoiFilters,
|
||||
osmLang: string,
|
||||
keptUnnamed: ReadonlySet<string>,
|
||||
): OverpassPoi[] {
|
||||
const { filters, categoryOfFilter, categories } = match;
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
const tags = el.tags || {};
|
||||
const matched =
|
||||
filters.find((f) => {
|
||||
const [k, v] = f.split('=');
|
||||
return tags[k] === v;
|
||||
}) || filters[0];
|
||||
const category = categoryOfFilter.get(matched) ?? categories[0];
|
||||
// `operator` comes last but matters for the road categories: petrol stations,
|
||||
// charging points and service areas are routinely mapped with an operator and no
|
||||
// name, and dropping those would empty the road trip corridor over long stretches.
|
||||
const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null;
|
||||
if (!name && !keptUnnamed.has(category)) continue; // unnamed POIs aren't useful to add to a plan
|
||||
// A shut-down place is not somewhere to plan a visit (#1341). OSM usually
|
||||
// re-tags one with a `disused:`/`abandoned:` prefix, and those never match
|
||||
// the selectors above, but plenty keep their original tag and gain a marker
|
||||
// instead, and those do come back. `opening_hours=closed`/`off` is the same
|
||||
// statement in the hours field.
|
||||
if (tags.disused === 'yes' || tags.abandoned === 'yes') continue;
|
||||
if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue;
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
const addr =
|
||||
[tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']]
|
||||
.filter(Boolean)
|
||||
.join(' ') || null;
|
||||
pois.push({
|
||||
osm_id: `${el.type}:${el.id}`,
|
||||
name: name ?? '',
|
||||
lat,
|
||||
lng,
|
||||
category,
|
||||
poi_type: matched,
|
||||
address: addr,
|
||||
website: normalizePlaceWebsite(tags.website) ?? normalizePlaceWebsite(tags['contact:website']),
|
||||
phone: tags.phone || tags['contact:phone'] || null,
|
||||
opening_hours: tags.opening_hours || null,
|
||||
cuisine: tags.cuisine || null,
|
||||
brand: tags.brand || tags.operator || null,
|
||||
// Only the plain Q-id form is passed on; anything else would be a lookup we
|
||||
// would have to guess at.
|
||||
brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null,
|
||||
// Only where it means something. Every POI carries `capacity` and `fee` for its
|
||||
// own reasons (a restaurant's capacity is seats), so reading them as charging
|
||||
// data anywhere else would be wrong on most of the map.
|
||||
charging: category === 'charging' ? readChargingInfo(tags) : null,
|
||||
source: 'openstreetmap',
|
||||
});
|
||||
}
|
||||
return pois;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the search along a line waits for Overpass: the query's own
|
||||
* deadline and two seconds to hand the answer back. It sits inside one request
|
||||
* a person is waiting on, beside the index, so it gives up well before the
|
||||
* explore pill's budget, and OVERPASS_TIMEOUT_MS does not apply to it.
|
||||
*/
|
||||
const OVERPASS_ALONG_TIMEOUT_MS = (OVERPASS_ALONG_QUERY_TIMEOUT_S + 2) * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class OsmClient {
|
||||
// ── Nominatim search ───────────────────────────────────────────────────────
|
||||
@@ -542,17 +671,9 @@ export class OsmClient {
|
||||
lang?: string,
|
||||
limit = 60,
|
||||
): Promise<PoiSearchResult> {
|
||||
// One or several categories in one query. Each OSM selector belongs to exactly one
|
||||
// category, so a hit can still be labelled with the category it answered.
|
||||
const categories = parsePoiCategories(category);
|
||||
if (!categories.length) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
const categoryOfFilter = new Map<string, string>();
|
||||
for (const key of categories) {
|
||||
const own = CATEGORY_OSM_FILTERS[key];
|
||||
if (!own) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
for (const f of own) categoryOfFilter.set(f, key);
|
||||
}
|
||||
const filters = [...categoryOfFilter.keys()];
|
||||
// One or several categories in one query.
|
||||
const match = poiFilters(category);
|
||||
const { categories, filters } = match;
|
||||
// Each category gets its own share of the cap, or a mixed search would spend the
|
||||
// whole budget on whichever kind happens to be densest.
|
||||
const cap = Math.min(limit * categories.length, POI_RESULT_CAP);
|
||||
@@ -572,9 +693,8 @@ export class OsmClient {
|
||||
|
||||
// Serve repeat pans/toggles of the same area straight from the cache.
|
||||
const cacheKey = `${[...categories].sort((a, b) => a.localeCompare(b)).join('+')}|${osmLang}|${south.toFixed(2)},${west.toFixed(2)},${north.toFixed(2)},${east.toFixed(2)}|${cap}`;
|
||||
const cached = POI_CACHE.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < POI_CACHE_TTL_MS) return cached.value;
|
||||
if (cached) POI_CACHE.delete(cacheKey); // expired — drop it before refetching
|
||||
const cached = cachedPoiAnswer(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${south},${west},${north},${east})`;
|
||||
@@ -588,63 +708,47 @@ export class OsmClient {
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:${OVERPASS_QUERY_TIMEOUT_S}];\n(\n${selectors}\n);\nout center tags ${cap + 25};`;
|
||||
|
||||
const elements = await overpassFetch(query);
|
||||
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
const tags = el.tags || {};
|
||||
// `operator` comes last but matters for the road categories: petrol stations,
|
||||
// charging points and service areas are routinely mapped with an operator and no
|
||||
// name, and dropping those would empty the road trip corridor over long stretches.
|
||||
const name = tags[`name:${osmLang}`] || tags['int_name'] || tags.name || tags.brand || tags.operator || null;
|
||||
if (!name) continue; // unnamed POIs aren't useful to add to a plan
|
||||
// A shut-down place is not somewhere to plan a visit (#1341). OSM usually
|
||||
// re-tags one with a `disused:`/`abandoned:` prefix, and those never match
|
||||
// the selectors above — but plenty keep their original tag and gain a marker
|
||||
// instead, and those do come back. `opening_hours=closed`/`off` is the same
|
||||
// statement in the hours field.
|
||||
if (tags.disused === 'yes' || tags.abandoned === 'yes') continue;
|
||||
if (tags.opening_hours === 'closed' || tags.opening_hours === 'off') continue;
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
const matched =
|
||||
filters.find((f) => {
|
||||
const [k, v] = f.split('=');
|
||||
return tags[k] === v;
|
||||
}) || filters[0];
|
||||
const addr =
|
||||
[tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']]
|
||||
.filter(Boolean)
|
||||
.join(' ') || null;
|
||||
pois.push({
|
||||
osm_id: `${el.type}:${el.id}`,
|
||||
name,
|
||||
lat,
|
||||
lng,
|
||||
category: categoryOfFilter.get(matched) ?? categories[0],
|
||||
poi_type: matched,
|
||||
address: addr,
|
||||
website: normalizePlaceWebsite(tags.website) ?? normalizePlaceWebsite(tags['contact:website']),
|
||||
phone: tags.phone || tags['contact:phone'] || null,
|
||||
opening_hours: tags.opening_hours || null,
|
||||
cuisine: tags.cuisine || null,
|
||||
brand: tags.brand || tags.operator || null,
|
||||
// Only the plain Q-id form is passed on; anything else would be a lookup we
|
||||
// would have to guess at.
|
||||
brand_wikidata: /^Q[0-9]+$/.test(tags['brand:wikidata'] || '') ? tags['brand:wikidata'] : null,
|
||||
// Only where it means something. Every POI carries `capacity` and `fee` for its
|
||||
// own reasons — a restaurant's capacity is seats — so reading them as charging
|
||||
// data anywhere else would be wrong on most of the map.
|
||||
charging: categoryOfFilter.get(matched) === 'charging' ? readChargingInfo(tags) : null,
|
||||
source: 'openstreetmap',
|
||||
});
|
||||
}
|
||||
const pois = readPois(await overpassFetch(query), match, osmLang, new Set());
|
||||
const truncated = pois.length > cap;
|
||||
const value: PoiSearchResult = { pois: pois.slice(0, cap), source: 'openstreetmap', truncated, clamped };
|
||||
// FIFO eviction: a Map preserves insertion order, so the first key is the oldest.
|
||||
if (POI_CACHE.size >= POI_CACHE_MAX) POI_CACHE.delete(POI_CACHE.keys().next().value as string);
|
||||
POI_CACHE.set(cacheKey, { at: Date.now(), value });
|
||||
cachePoiAnswer(cacheKey, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* POIs of one or several categories near a line (PLN-02): one Overpass query
|
||||
* for the candidates of the line's box within reach of the line itself, so
|
||||
* the cap is spent on the corridor rather than on the box. Unnamed taps,
|
||||
* huts and the like stay, because they are what a hike looks for, while an
|
||||
* unnamed garden or cafe is dropped as on the explore pill. A short deadline
|
||||
* of its own; failing throws as the explore pill's search does.
|
||||
*/
|
||||
async searchOverpassPoisAlong(
|
||||
category: string,
|
||||
line: LatLng[],
|
||||
corridorM: number,
|
||||
lang?: string,
|
||||
limit = 30,
|
||||
): Promise<PoiSearchResult> {
|
||||
const match = poiFilters(category);
|
||||
const cap = Math.min(limit * match.categories.length, POI_RESULT_CAP);
|
||||
const path = aroundPath(line, corridorM);
|
||||
const osmLang = toApiLang(lang).split('-')[0].toLowerCase();
|
||||
const kinds = [...match.categories].sort((a, b) => a.localeCompare(b)).join('+');
|
||||
const points = path.points.map((point) => `${point.lat.toFixed(5)},${point.lng.toFixed(5)}`).join(';');
|
||||
const cacheKey = `along|${kinds}|${osmLang}|${path.radiusM}|${points}|${cap}`;
|
||||
const cached = cachedPoiAnswer(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
const elements = await overpassFetch(overpassAlongQuery(match.filters, path, cap), OVERPASS_ALONG_TIMEOUT_MS);
|
||||
const pois = readPois(elements, match, osmLang, KEPT_UNNAMED_ALONG);
|
||||
const value: PoiSearchResult = {
|
||||
pois: pois.slice(0, cap),
|
||||
source: 'openstreetmap',
|
||||
truncated: pois.length > cap,
|
||||
clamped: false,
|
||||
};
|
||||
cachePoiAnswer(cacheKey, value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ const TIMEOUT_MS = 3500;
|
||||
/** A search answer is a few kB. A megabyte means something is wrong upstream. */
|
||||
const MAX_BYTES = 1_000_000;
|
||||
|
||||
/**
|
||||
* The service's own limits on one /v1/nearby request: it refuses a `limit`
|
||||
* above the first and a `category` list longer than the second with a 422.
|
||||
* A refused request reads like a failure to the caller, so a request is cut to
|
||||
* fit them before it leaves (maps-index.helpers' indexRequests).
|
||||
*/
|
||||
export const INDEX_MAX_LIMIT = 100;
|
||||
export const INDEX_MAX_CATEGORY_CHARS = 200;
|
||||
|
||||
/**
|
||||
* The area download is the one call that is neither short nor interactive.
|
||||
*
|
||||
@@ -391,6 +400,15 @@ export const POI_CATEGORY_TO_TREK: Record<string, string[]> = {
|
||||
charging: ['travel_and_transportation>fueling_station>ev_charging_station'],
|
||||
rest_area: ['travel_and_transportation>ground_transport_facility_or_service>road_structures_and_service>rest_stop'],
|
||||
campsite: ['lodging>campground', 'lodging>rv_park'],
|
||||
// What the search along a tour asks for (PLN-02, CW-05), each path checked
|
||||
// against a live answer of the index: museums and galleries alone, without
|
||||
// the theatres and cinemas of `museum`; parks and gardens alone, without the
|
||||
// beaches and mountains `nature` brings; the market square and its farmers'
|
||||
// and night markets; a summit.
|
||||
museum_gallery: ['arts_and_entertainment>museum', 'arts_and_entertainment>arts_and_crafts_space>art_gallery'],
|
||||
park: ['sports_and_recreation>park', 'geographic_entities>built_feature>garden'],
|
||||
market: ['shopping>market'],
|
||||
peak: ['geographic_entities>land_feature>mountain'],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type TourPoiSearchRequest,
|
||||
type TourPoiSearchResponse,
|
||||
} from '@trek/shared';
|
||||
import { corridorTiles, simplifyLine } from '@trek/shared/roadtrip';
|
||||
|
||||
/** The map search's own categories each Tours category asks for. */
|
||||
const MAPS_CATEGORIES: Record<TourPoiCategory, readonly string[]> = {
|
||||
@@ -32,28 +31,24 @@ const MAPS_CATEGORIES: Record<TourPoiCategory, readonly string[]> = {
|
||||
slipway: ['slipway'],
|
||||
boat_rental: ['boat_rental'],
|
||||
bike_repair: ['bike_repair'],
|
||||
museum: ['museum'],
|
||||
park: ['nature'],
|
||||
// Museums and galleries alone, parks and gardens alone: the explore pill's
|
||||
// `museum` also brings theatres and cinemas, its `nature` beaches and summits.
|
||||
museum: ['museum_gallery'],
|
||||
park: ['park'],
|
||||
cafe: ['cafe'],
|
||||
restaurant: ['restaurant'],
|
||||
market: ['market'],
|
||||
};
|
||||
|
||||
/**
|
||||
* How many stretches of the line one search covers, each at most about half a
|
||||
* degree, so a long tour is searched along its first stretches and the answer
|
||||
* says so. Three are asked at a time, so the public mirrors are not flooded.
|
||||
*/
|
||||
const MAX_TILES = 12;
|
||||
const PARALLEL_TILES = 3;
|
||||
/** The most places one stretch is asked for. */
|
||||
const PER_TILE_LIMIT = 60;
|
||||
/** The most places of one kind the map search answers from each source. */
|
||||
const PER_KIND_LIMIT = 30;
|
||||
|
||||
/** Within this many metres, two places of one name are one place mapped twice. */
|
||||
const SAME_PLACE_M = 60;
|
||||
|
||||
function sameSpot(a: Pick<TourPoi, 'name' | 'lat' | 'lng'>, b: { name: string; lat: number; lng: number }): boolean {
|
||||
if (a.name.trim().toLocaleLowerCase() !== b.name.trim().toLocaleLowerCase()) return false;
|
||||
type MapsPoi = Awaited<ReturnType<MapsService['poisAlong']>>['pois'][number];
|
||||
|
||||
function withinSamePlace(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: number }): boolean {
|
||||
const metresPerDegree = 111_320;
|
||||
const dLat = (a.lat - b.lat) * metresPerDegree;
|
||||
const dLng = (a.lng - b.lng) * metresPerDegree * Math.cos((a.lat * Math.PI) / 180);
|
||||
@@ -65,13 +60,51 @@ function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[])
|
||||
}
|
||||
|
||||
/**
|
||||
* PLN-02: places along a tour, searched on purpose. The line is cut into
|
||||
* stretches as the road trip's corridor search does, each searched through
|
||||
* the map search (the TREK places index first, OpenStreetMap after), the
|
||||
* places kept that lie within the corridor of the line itself, nearest the
|
||||
* start first. Nothing is written. A stretch that fails leaves the others'
|
||||
* places and marks the answer partial; the distance is measured straight
|
||||
* across to the line, never a way someone walked or paddled.
|
||||
* The places found, each once and within the corridor, with where they lie.
|
||||
* OpenStreetMap often maps one place twice, as a point and as its outline, and
|
||||
* the index may hold it beside OpenStreetMap: one name in one spot is one
|
||||
* place. An unnamed place (a tap, a bench) is never merged with another.
|
||||
*/
|
||||
function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<[number, number]>): TourPoi[] {
|
||||
const kept = new Map<string, TourPoi>();
|
||||
const byName = new Map<string, TourPoi[]>();
|
||||
for (const poi of found) {
|
||||
const category = tourCategoryOf(poi.category, body.categories);
|
||||
const near = nearnessToLine({ lat: poi.lat, lng: poi.lng }, line);
|
||||
if (!category || !near || near.distanceM > body.corridor_m || kept.has(poi.osm_id)) continue;
|
||||
const name = poi.name.trim().toLocaleLowerCase();
|
||||
const namesakes = name ? (byName.get(name) ?? []) : [];
|
||||
if (namesakes.some((other) => withinSamePlace(other, poi))) continue;
|
||||
const place: TourPoi = {
|
||||
id: poi.osm_id,
|
||||
name: poi.name,
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
category,
|
||||
kind: poi.poi_type,
|
||||
source: poi.source,
|
||||
address: poi.address,
|
||||
website: poi.website,
|
||||
phone: poi.phone,
|
||||
opening_hours: poi.opening_hours,
|
||||
distance_m: Math.round(near.distanceM),
|
||||
along_m: Math.round(near.alongM),
|
||||
};
|
||||
kept.set(poi.osm_id, place);
|
||||
if (name) byName.set(name, [...namesakes, place]);
|
||||
}
|
||||
return [...kept.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* PLN-02: places along a tour, searched on purpose. The map search asks the
|
||||
* TREK places index in circles along the line and OpenStreetMap near it for
|
||||
* the kinds the index does not know; the places kept are those within the
|
||||
* corridor of the line itself, the nearest to it when there are more than one
|
||||
* answer carries, listed from the start. Nothing is written. A kind no source
|
||||
* could be asked for is named as unavailable, so a failed source never reads
|
||||
* as nothing found; the distance is measured straight across to the line,
|
||||
* never a way someone walked or paddled.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourPoiSearchService {
|
||||
@@ -82,65 +115,27 @@ export class TourPoiSearchService {
|
||||
) {}
|
||||
|
||||
async search(body: TourPoiSearchRequest): Promise<TourPoiSearchResponse> {
|
||||
const corridorKm = body.corridor_m / 1000;
|
||||
// The contract's tuples, spelled out as pairs once for the measures below.
|
||||
const line = body.line.map((point): [number, number] => [Number(point[0]), Number(point[1])]);
|
||||
const simplified = simplifyLine(
|
||||
const asked = [...new Set(body.categories.flatMap((category) => MAPS_CATEGORIES[category]))];
|
||||
const along = await this.maps.poisAlong(
|
||||
asked,
|
||||
line.map(([lat, lng]) => ({ lat, lng })),
|
||||
Math.max(0.05, corridorKm / 4),
|
||||
body.corridor_m,
|
||||
body.lang,
|
||||
PER_KIND_LIMIT,
|
||||
);
|
||||
const tiles = corridorTiles(simplified, corridorKm);
|
||||
const searched = tiles.slice(0, MAX_TILES);
|
||||
const asked = [...new Set(body.categories.flatMap((category) => MAPS_CATEGORIES[category]))].join(',');
|
||||
|
||||
// Three stretches at a time, each batch after the last, so no more than three are ever in flight.
|
||||
const batches = Array.from({ length: Math.ceil(searched.length / PARALLEL_TILES) }, (_, index) =>
|
||||
searched.slice(index * PARALLEL_TILES, (index + 1) * PARALLEL_TILES),
|
||||
);
|
||||
const answers = await batches.reduce<
|
||||
Promise<Array<PromiseSettledResult<Awaited<ReturnType<MapsService['pois']>>>>>
|
||||
>(
|
||||
async (done, batch) => [
|
||||
...(await done),
|
||||
...(await Promise.allSettled(batch.map((tile) => this.maps.pois(asked, tile, body.lang, PER_TILE_LIMIT)))),
|
||||
],
|
||||
Promise.resolve([]),
|
||||
);
|
||||
|
||||
const found = new Map<string, TourPoi>();
|
||||
let truncated = false;
|
||||
for (const answer of answers) {
|
||||
if (answer.status !== 'fulfilled') continue;
|
||||
truncated ||= answer.value.truncated || answer.value.clamped;
|
||||
for (const poi of answer.value.pois) {
|
||||
const category = tourCategoryOf(poi.category, body.categories);
|
||||
const near = nearnessToLine({ lat: poi.lat, lng: poi.lng }, line);
|
||||
if (!category || !near || near.distanceM > body.corridor_m || found.has(poi.osm_id)) continue;
|
||||
// OpenStreetMap often maps one place twice, as a point and as its outline: one name in one spot is one place.
|
||||
if (poi.name && [...found.values()].some((other) => sameSpot(other, poi))) continue;
|
||||
found.set(poi.osm_id, {
|
||||
id: poi.osm_id,
|
||||
name: poi.name,
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
category,
|
||||
kind: poi.poi_type,
|
||||
source: poi.source ?? 'openstreetmap',
|
||||
address: poi.address,
|
||||
website: poi.website,
|
||||
phone: poi.phone,
|
||||
opening_hours: poi.opening_hours,
|
||||
distance_m: Math.round(near.distanceM),
|
||||
along_m: Math.round(near.alongM),
|
||||
});
|
||||
}
|
||||
}
|
||||
const pois = [...found.values()].sort((a, b) => a.along_m - b.along_m || a.distance_m - b.distance_m);
|
||||
const places = placesWithin(along.pois, body, line);
|
||||
// The nearest to the line when there are too many, then in the order the tour meets them.
|
||||
const nearest = [...places].sort((a, b) => a.distance_m - b.distance_m).slice(0, TOUR_POI_LIMIT);
|
||||
return {
|
||||
pois: pois.slice(0, TOUR_POI_LIMIT),
|
||||
truncated: truncated || pois.length > TOUR_POI_LIMIT,
|
||||
partial: answers.some((answer) => answer.status === 'rejected'),
|
||||
capped: tiles.length > MAX_TILES,
|
||||
pois: nearest.sort((a, b) => a.along_m - b.along_m || a.distance_m - b.distance_m),
|
||||
truncated: along.truncated || places.length > TOUR_POI_LIMIT,
|
||||
partial: along.partial,
|
||||
capped: along.capped,
|
||||
unavailable: body.categories.filter((category) =>
|
||||
MAPS_CATEGORIES[category].every((mapsCategory) => along.failed.includes(mapsCategory)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'search_tour_pois',
|
||||
description:
|
||||
'Search places along the saved line of a tour: what a hike, a bike tour or a paddle tour stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops), within corridor_m metres of the line. Answers each place with its source, kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line), nearest the start first, up to 60. partial says some stretches could not be searched, capped that only the first part of a long line was. Nothing is added to the tour or the trip.',
|
||||
'Search places along the saved line of a tour: what a hike, a bike tour, a paddle tour or a city walk stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental, bike_repair for repair stations and bike shops, and for a city walk museum for museums and galleries, park for parks and gardens, cafe, restaurant and market), within corridor_m metres of the line. Answers each place with its source (TREK places index or OpenStreetMap), kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line); up to 60, the nearest to the line when more match, listed from the start. A place OpenStreetMap has no name for (a tap, a bench) comes with an empty name. unavailable lists the categories no source could be searched for right now: their places are missing, not absent, so ask again later rather than reporting none. partial says some stretches could not be searched, capped that only the first part of a long line was, truncated that more places matched. Nothing is added to the tour or the trip.',
|
||||
inputSchema: {
|
||||
tripId: idSchema,
|
||||
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
|
||||
|
||||
@@ -57,7 +57,7 @@ const { journeyHooks } = vi.hoisted(() => ({
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
// The map search behind the POI search (PLN-02): no network in a test.
|
||||
const mapsPois = vi.fn();
|
||||
const mapsPoisAlong = vi.fn();
|
||||
const realtime = new FakeRealtimeService();
|
||||
|
||||
let orm: TestOrm;
|
||||
@@ -102,7 +102,7 @@ describe('Tours planned e2e (real guards + temp SQLite)', () => {
|
||||
.overrideProvider(JourneyDomainService)
|
||||
.useValue(journeyHooks)
|
||||
.overrideProvider(MapsService)
|
||||
.useValue({ pois: mapsPois })
|
||||
.useValue({ poisAlong: mapsPoisAlong })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
@@ -708,7 +708,7 @@ describe('Tours planned e2e (real guards + temp SQLite)', () => {
|
||||
};
|
||||
|
||||
it('answers the places along a draft line to anyone who sees the trip, and writes nothing', async () => {
|
||||
mapsPois.mockResolvedValue({ pois: [spring], source: 'openstreetmap', truncated: false, clamped: false });
|
||||
mapsPoisAlong.mockResolvedValue({ pois: [spring], failed: [], partial: false, truncated: false, capped: false });
|
||||
const places = await countRows(orm, Places);
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/tours/poi-search')
|
||||
@@ -718,11 +718,33 @@ describe('Tours planned e2e (real guards + temp SQLite)', () => {
|
||||
expect(res.body.pois).toEqual([
|
||||
expect.objectContaining({ id: 'node:7', name: 'Spring', category: 'drinking_water' }),
|
||||
]);
|
||||
expect(res.body).toMatchObject({ truncated: false, partial: false, capped: false });
|
||||
expect(res.body).toMatchObject({ truncated: false, partial: false, capped: false, unavailable: [] });
|
||||
expect(await countRows(orm, Places)).toBe(places);
|
||||
expect(realtime.broadcastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the kinds that could not be searched right now instead of reporting nothing found', async () => {
|
||||
mapsPoisAlong.mockResolvedValue({
|
||||
pois: [],
|
||||
failed: ['viewpoint', 'shelter'],
|
||||
partial: false,
|
||||
truncated: false,
|
||||
capped: false,
|
||||
});
|
||||
const res = await request(server)
|
||||
.post('/api/trips/5/tours/poi-search')
|
||||
.set('Cookie', sessionCookie(VIEWER))
|
||||
.send({ ...body, categories: ['viewpoint', 'shelter', 'drinking_water'] });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
pois: [],
|
||||
truncated: false,
|
||||
partial: false,
|
||||
capped: false,
|
||||
unavailable: ['viewpoint', 'shelter'],
|
||||
});
|
||||
});
|
||||
|
||||
it('404 for someone outside the trip, 400 for a body it does not know, 401 without a session', async () => {
|
||||
const outsider = await request(server)
|
||||
.post('/api/trips/5/tours/poi-search')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MapsService } from '../../src/nest/maps/maps.service';
|
||||
import { PlaceDetailsResolver } from '../../src/nest/maps/place-details.resolver';
|
||||
import { PlacePhotoResolver } from '../../src/nest/maps/place-photo.resolver';
|
||||
import { PlacesProviderSelector } from '../../src/nest/maps/places-provider.selector';
|
||||
import { PoisAlongResolver } from '../../src/nest/maps/pois-along.resolver';
|
||||
import { GooglePlacesClient } from '../../src/nest/maps/providers/google-places.provider';
|
||||
import { OsmClient } from '../../src/nest/maps/providers/osm.client';
|
||||
import { WikimediaClient } from '../../src/nest/maps/providers/wikimedia.client';
|
||||
@@ -44,7 +45,8 @@ export function buildMapsParts(
|
||||
const photos = new PlacePhotoResolver(photoCache, places, google, wiki, selector);
|
||||
const links = new MapsUrlResolver(osm);
|
||||
const details = new PlaceDetailsResolver(placeDetailsCache, google, osm, selector);
|
||||
const svc = new MapsService(photoCache, appSettings, google, osm, wiki, selector, photos, links, details);
|
||||
const along = new PoisAlongResolver(osm);
|
||||
const svc = new MapsService(photoCache, appSettings, google, osm, wiki, selector, photos, links, details, along);
|
||||
return { svc, google, osm, wiki, selector };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* MAPS-ALONG-001..007: the geometry of a search along a line (PLN-02). Where
|
||||
* the index is asked (circles strung along the line), the stretches Overpass
|
||||
* is asked in and the query near one of them, all pure.
|
||||
*/
|
||||
import {
|
||||
OVERPASS_ALONG_QUERY_TIMEOUT_S,
|
||||
alongPlan,
|
||||
aroundPath,
|
||||
overpassAlongQuery,
|
||||
overpassStretches,
|
||||
} from '../../../src/nest/maps/maps-along.helpers';
|
||||
import { haversineKm, lineMetres, type LatLng } from '@trek/shared/roadtrip';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ORIGIN = { lat: 47, lng: 11 };
|
||||
const M_PER_DEG = 111_320;
|
||||
|
||||
/** A point `x` metres east and `y` metres north of the origin. */
|
||||
function at(x: number, y: number): LatLng {
|
||||
return {
|
||||
lat: ORIGIN.lat + y / M_PER_DEG,
|
||||
lng: ORIGIN.lng + x / (M_PER_DEG * Math.cos((ORIGIN.lat * Math.PI) / 180)),
|
||||
};
|
||||
}
|
||||
|
||||
const metres = (a: LatLng, b: LatLng) => haversineKm(a, b) * 1000;
|
||||
|
||||
describe('alongPlan', () => {
|
||||
it('MAPS-ALONG-001: a straight 3 km line at 500 m takes a circle every kilometre, each reaching its stretch', () => {
|
||||
const line = [at(0, 0), at(0, 3000)];
|
||||
const plan = alongPlan(line, 500);
|
||||
expect(plan.capped).toBe(false);
|
||||
expect(plan.covered).toBe(line);
|
||||
expect(plan.beads.map((bead) => Math.round(metres(line[0] as LatLng, bead)))).toEqual([
|
||||
0,
|
||||
1000,
|
||||
2000,
|
||||
Math.round(lineMetres(line)),
|
||||
]);
|
||||
// Half a step along and a corridor across, whichever way the line turns.
|
||||
expect(plan.beads.every((bead) => bead.radiusM === 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-002: on a zig-zag line every point within the corridor lies inside some circle', () => {
|
||||
// Eight legs of 700 m, turning a right angle at every corner, as a walk through a street grid does.
|
||||
const line = Array.from({ length: 9 }, (_, index) => at(index * 495, index % 2 === 0 ? 0 : 495));
|
||||
const corridor = 500;
|
||||
const plan = alongPlan(line, corridor);
|
||||
const outside: LatLng[] = [];
|
||||
for (let leg = 1; leg < line.length; leg++) {
|
||||
const a = line[leg - 1] as LatLng;
|
||||
const b = line[leg] as LatLng;
|
||||
const length = metres(a, b);
|
||||
// The leg's direction in metres, and the normal to it.
|
||||
const dx = ((b.lng - a.lng) * M_PER_DEG * Math.cos((ORIGIN.lat * Math.PI) / 180)) / length;
|
||||
const dy = ((b.lat - a.lat) * M_PER_DEG) / length;
|
||||
const ax = (a.lng - ORIGIN.lng) * M_PER_DEG * Math.cos((ORIGIN.lat * Math.PI) / 180);
|
||||
const ay = (a.lat - ORIGIN.lat) * M_PER_DEG;
|
||||
for (let along = 0; along <= length; along += 25) {
|
||||
for (const side of [-1, 1]) {
|
||||
const point = at(ax + dx * along - dy * corridor * side, ay + dy * along + dx * corridor * side);
|
||||
if (!plan.beads.some((bead) => metres(bead, point) <= bead.radiusM)) outside.push(point);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(outside).toEqual([]);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-003: a 100 km line at 250 m takes 24 circles and says it covered only its start', () => {
|
||||
const line = [at(0, 0), at(0, 100_000)];
|
||||
const plan = alongPlan(line, 250);
|
||||
expect(plan.beads).toHaveLength(24);
|
||||
expect(Math.round(metres(plan.beads[0] as LatLng, plan.beads[1] as LatLng))).toBe(1500);
|
||||
expect(plan.capped).toBe(true);
|
||||
expect(lineMetres(plan.covered)).toBeCloseTo(34_500, -1);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-004: two points 30 m apart take a circle at each end', () => {
|
||||
const plan = alongPlan([at(0, 0), at(30, 0)], 250);
|
||||
expect(plan.beads).toHaveLength(2);
|
||||
expect(plan.capped).toBe(false);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-004b: a line with no points takes no circle', () => {
|
||||
expect(alongPlan([], 500).beads).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aroundPath and overpassAlongQuery', () => {
|
||||
// A wiggly 20 km line of 2000 points, more than the query may carry.
|
||||
const wiggly = Array.from({ length: 2000 }, (_, index) => at(index * 10, Math.sin(index / 3) * 40));
|
||||
|
||||
it('MAPS-ALONG-005: the line is simplified to at most 120 points, its reach widened by what that moved it', () => {
|
||||
const path = aroundPath(wiggly, 500);
|
||||
expect(path.points.length).toBeLessThanOrEqual(120);
|
||||
expect(path.radiusM).toBeGreaterThanOrEqual(550);
|
||||
for (const point of wiggly) {
|
||||
expect(point.lat).toBeGreaterThanOrEqual(path.bbox.south);
|
||||
expect(point.lat).toBeLessThanOrEqual(path.bbox.north);
|
||||
expect(point.lng).toBeGreaterThanOrEqual(path.bbox.west);
|
||||
expect(point.lng).toBeLessThanOrEqual(path.bbox.east);
|
||||
}
|
||||
// A short line keeps its points and only the corridor's tenth as slack.
|
||||
const short = aroundPath([at(0, 0), at(0, 1000)], 500);
|
||||
expect(short.points).toHaveLength(2);
|
||||
expect(short.radiusM).toBe(550);
|
||||
// The box reaches a radius past the line on every side.
|
||||
expect(short.bbox.south).toBeCloseTo(ORIGIN.lat - 550 / M_PER_DEG, 8);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-006: one query asks the box for candidates and keeps those near the line', () => {
|
||||
const path = aroundPath([at(0, 0), at(0, 1000)], 500);
|
||||
const query = overpassAlongQuery(['tourism=viewpoint', 'amenity=drinking_water'], path, 60);
|
||||
// The deadline the client's own wait is derived from, so the two never drift apart.
|
||||
expect(OVERPASS_ALONG_QUERY_TIMEOUT_S).toBe(10);
|
||||
expect(query.startsWith(`[out:json][timeout:${OVERPASS_ALONG_QUERY_TIMEOUT_S}];`)).toBe(true);
|
||||
expect(query).toContain('nwr["tourism"="viewpoint"](');
|
||||
expect(query).toContain('nwr["amenity"="drinking_water"](');
|
||||
expect(query.match(/->\.cand;/g)).toHaveLength(1);
|
||||
expect(query.match(/around:/g)).toHaveLength(1);
|
||||
expect(query).toContain(`nwr.cand(around:550,47.00000,11.00000,${at(0, 1000).lat.toFixed(5)},11.00000);`);
|
||||
expect(query.endsWith('out center tags 85;')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('overpassStretches', () => {
|
||||
it('MAPS-ALONG-007: a line is cut into equal stretches of at most 50 km, the whole line up to 400 km', () => {
|
||||
const walk = [at(0, 0), at(0, 3000)];
|
||||
expect(overpassStretches(walk)).toEqual({ stretches: [walk], capped: false });
|
||||
|
||||
// 120 km with a bend: three stretches of 40 km, each starting where the last one ended.
|
||||
const ride = [at(0, 0), at(0, 70_000), at(50_000, 70_000)];
|
||||
const cut = overpassStretches(ride);
|
||||
expect(cut.capped).toBe(false);
|
||||
expect(cut.stretches.map((stretch) => Math.round(lineMetres(stretch) / 1000))).toEqual([40, 40, 40]);
|
||||
expect(cut.stretches.map((stretch) => stretch[0])).toEqual([
|
||||
ride[0],
|
||||
cut.stretches[0]?.at(-1),
|
||||
cut.stretches[1]?.at(-1),
|
||||
]);
|
||||
expect(cut.stretches[2]?.at(-1)).toBe(ride[2]);
|
||||
|
||||
// 450 km takes nine stretches of 50 km; the first eight are asked and the answer says so.
|
||||
const long = [at(0, 0), at(0, 450_000)];
|
||||
const crossing = overpassStretches(long);
|
||||
expect(crossing.capped).toBe(true);
|
||||
expect(crossing.stretches).toHaveLength(8);
|
||||
expect(Math.round(lineMetres(crossing.stretches[0] ?? []) / 1000)).toBe(50);
|
||||
expect(metres(ORIGIN, crossing.stretches[7]?.at(-1) ?? ORIGIN)).toBeCloseTo((lineMetres(long) * 8) / 9, -1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* MAPS-ALONG-010..019: places near a line (PLN-02), from the index in circles
|
||||
* along it and from Overpass, in stretches of the whole line, for the kinds
|
||||
* the index does not know.
|
||||
*
|
||||
* The index is asked through a stub here, Overpass through a spy on the OSM
|
||||
* client: these cases are about who is asked for what, and how a source that
|
||||
* fails is reported instead of reading as nothing found.
|
||||
*/
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
import { ownedByAsked } from '../../../src/nest/maps/maps-index.helpers';
|
||||
import { INDEX_MAX_CATEGORY_CHARS, INDEX_MAX_LIMIT } from '../../../src/nest/maps/trek-places.client';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||||
import { buildMapsParts } from '../../helpers/maps-service';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockNearby, logWarn } = vi.hoisted(() => ({
|
||||
logWarn: vi.fn(),
|
||||
mockNearby: vi.fn(
|
||||
async (
|
||||
_lat: number,
|
||||
_lng: number,
|
||||
_opts?: { radius?: number; limit?: number; category?: string },
|
||||
): Promise<unknown[]> => [],
|
||||
),
|
||||
}));
|
||||
// The log line is the operator's only trace of a source that failed; the logger itself writes to trek.log.
|
||||
vi.mock('../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/nest/audit/audit-log.logger')>()),
|
||||
logWarn,
|
||||
}));
|
||||
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
|
||||
trekPlacesNearby: mockNearby,
|
||||
}));
|
||||
|
||||
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
|
||||
const noUsers = { getApiKeyColumn: async () => null } as unknown as UsersRepository;
|
||||
|
||||
// Just under 3 km due north at 500 m: four circles about a kilometre apart, each reaching 1000 m.
|
||||
const LINE = [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.0269, lng: 11 },
|
||||
];
|
||||
|
||||
/** An index row of one category path. */
|
||||
function row(gers: string, name: string, categoryPath: string, over: Record<string, unknown> = {}) {
|
||||
return {
|
||||
gers,
|
||||
name,
|
||||
lat: 47.01,
|
||||
lng: 11.001,
|
||||
category: categoryPath.slice(categoryPath.lastIndexOf('>') + 1),
|
||||
categoryPath,
|
||||
confidence: 0.9,
|
||||
address: null,
|
||||
contact: null,
|
||||
brand: null,
|
||||
source: 'overture',
|
||||
hours: null,
|
||||
distanceMetres: 100,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const CAFE = 'food_and_drink>casual_eatery>cafe';
|
||||
const MUSEUM = 'arts_and_entertainment>museum';
|
||||
|
||||
const SPRING = {
|
||||
osm_id: 'node:7',
|
||||
name: '',
|
||||
lat: 47.012,
|
||||
lng: 11.002,
|
||||
category: 'drinking_water',
|
||||
poi_type: 'amenity=drinking_water',
|
||||
address: null,
|
||||
website: null,
|
||||
phone: null,
|
||||
opening_hours: null,
|
||||
cuisine: null,
|
||||
brand: null,
|
||||
brand_wikidata: null,
|
||||
charging: null,
|
||||
source: 'openstreetmap' as const,
|
||||
};
|
||||
|
||||
const ORIGINAL_TREK_PLACES = process.env.TREK_PLACES_ENABLED;
|
||||
|
||||
function make(enabled = true) {
|
||||
if (enabled) delete process.env.TREK_PLACES_ENABLED;
|
||||
else process.env.TREK_PLACES_ENABLED = 'false';
|
||||
const { svc, osm } = buildMapsParts(
|
||||
{} as PlacePhotoCacheService,
|
||||
noAppSettings,
|
||||
noUsers,
|
||||
{} as never,
|
||||
{} as never,
|
||||
noGoogleQuota,
|
||||
);
|
||||
const overpass = vi
|
||||
.spyOn(osm, 'searchOverpassPoisAlong')
|
||||
.mockResolvedValue({ pois: [SPRING], source: 'openstreetmap', truncated: false, clamped: false });
|
||||
return { svc, overpass };
|
||||
}
|
||||
|
||||
/** The lines logged as warnings. */
|
||||
const mapsWarnings = () => logWarn.mock.calls.map((call) => String(call[0]));
|
||||
|
||||
/** A line due north from 47°N 11°E, `degrees` of latitude long: about 111 km each. */
|
||||
const north = (degrees: number) => [
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47 + degrees, lng: 11 },
|
||||
];
|
||||
|
||||
/** Counts the Overpass queries in flight, each answering after a turn of the event loop. */
|
||||
function inFlight(overpass: ReturnType<typeof make>['overpass']) {
|
||||
const seen = { now: 0, most: 0 };
|
||||
overpass.mockImplementation(async () => {
|
||||
seen.now += 1;
|
||||
seen.most = Math.max(seen.most, seen.now);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
seen.now -= 1;
|
||||
return { pois: [SPRING], source: 'openstreetmap', truncated: false, clamped: false };
|
||||
});
|
||||
return seen;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockNearby.mockReset();
|
||||
mockNearby.mockResolvedValue([]);
|
||||
logWarn.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_TREK_PLACES === undefined) delete process.env.TREK_PLACES_ENABLED;
|
||||
else process.env.TREK_PLACES_ENABLED = ORIGINAL_TREK_PLACES;
|
||||
});
|
||||
|
||||
describe('MapsService.poisAlong', () => {
|
||||
it('MAPS-ALONG-010: asks the index in every circle for its kinds only, and Overpass once for the rest', async () => {
|
||||
const { svc, overpass } = make();
|
||||
|
||||
const out = await svc.poisAlong(['cafe', 'drinking_water'], LINE, 500, 'de', 30);
|
||||
|
||||
expect(mockNearby).toHaveBeenCalledTimes(4);
|
||||
const centres = mockNearby.mock.calls.map(([lat, lng]) => [Number(lat.toFixed(3)), Number(lng.toFixed(3))]);
|
||||
expect(centres).toEqual([
|
||||
[47, 11],
|
||||
[47.009, 11],
|
||||
[47.018, 11],
|
||||
[47.027, 11],
|
||||
]);
|
||||
for (const [, , opts] of mockNearby.mock.calls) {
|
||||
expect(opts).toEqual({
|
||||
radius: 1000,
|
||||
limit: 30,
|
||||
category: 'food_and_drink>casual_eatery>cafe,food_and_drink>non_alcoholic_beverage_venue>coffee_shop',
|
||||
});
|
||||
}
|
||||
expect(overpass).toHaveBeenCalledTimes(1);
|
||||
expect(overpass).toHaveBeenCalledWith('drinking_water', LINE, 500, 'de', 30);
|
||||
// The unnamed spring stays: a tap is what a walk looks for.
|
||||
expect(out).toEqual({ pois: [SPRING], failed: [], partial: false, truncated: false, capped: false });
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-011: a walk asks one request per kind when they would not fit one, each inside the limits', async () => {
|
||||
const { svc, overpass } = make();
|
||||
|
||||
await svc.poisAlong(['sights', 'museum', 'park', 'cafe', 'restaurant', 'market'], LINE, 500, 'de', 30);
|
||||
|
||||
// Six kinds in four circles; none of them is asked of Overpass.
|
||||
expect(mockNearby).toHaveBeenCalledTimes(24);
|
||||
for (const [, , opts] of mockNearby.mock.calls) {
|
||||
expect(opts?.limit).toBe(30);
|
||||
expect(opts?.limit).toBeLessThanOrEqual(INDEX_MAX_LIMIT);
|
||||
expect(opts?.category?.length).toBeLessThanOrEqual(INDEX_MAX_CATEGORY_CHARS);
|
||||
}
|
||||
expect(mockNearby.mock.calls.slice(0, 6).map(([, , opts]) => opts?.category)).toEqual([
|
||||
'cultural_and_historic>historic_site,cultural_and_historic>memorial_site>monument',
|
||||
'arts_and_entertainment>museum,arts_and_entertainment>arts_and_crafts_space>art_gallery,arts_and_entertainment>performing_arts_venue,arts_and_entertainment>movie_theater',
|
||||
'sports_and_recreation>park,geographic_entities>built_feature>garden',
|
||||
'food_and_drink>casual_eatery>cafe,food_and_drink>non_alcoholic_beverage_venue>coffee_shop',
|
||||
'food_and_drink>restaurant,food_and_drink>casual_eatery',
|
||||
'shopping>market',
|
||||
]);
|
||||
// An empty circle is an answer: Overpass is never asked for what the index knows.
|
||||
expect(overpass).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-011b: kinds that would fit one request are still asked one by one, so a market is not crowded out', async () => {
|
||||
const { svc } = make();
|
||||
|
||||
await svc.poisAlong(['restaurant', 'cafe', 'market'], LINE, 500, 'de', 30);
|
||||
|
||||
expect(mockNearby).toHaveBeenCalledTimes(12);
|
||||
expect(mockNearby.mock.calls.slice(0, 3).map(([, , opts]) => [opts?.category, opts?.limit])).toEqual([
|
||||
['food_and_drink>restaurant,food_and_drink>casual_eatery', 30],
|
||||
['food_and_drink>casual_eatery>cafe,food_and_drink>non_alcoholic_beverage_venue>coffee_shop', 30],
|
||||
['shopping>market', 30],
|
||||
]);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-012: an index that answers no circle hands its kinds to Overpass once, and says why', async () => {
|
||||
mockNearby.mockRejectedValue(new Error('places api down'));
|
||||
const { svc, overpass } = make();
|
||||
const seen = inFlight(overpass);
|
||||
|
||||
const out = await svc.poisAlong(['cafe', 'drinking_water'], LINE, 500, 'de', 30);
|
||||
|
||||
expect(overpass).toHaveBeenCalledTimes(2);
|
||||
expect(overpass).toHaveBeenLastCalledWith('cafe', LINE, 500, 'de', 30);
|
||||
// The index's kinds are asked while the others still are, not one deadline after the other.
|
||||
expect(seen.most).toBe(2);
|
||||
expect(out.failed).toEqual([]);
|
||||
// Nothing of the line was left out: Overpass searched all of it.
|
||||
expect(out.partial).toBe(false);
|
||||
expect(mapsWarnings()).toEqual([
|
||||
expect.stringContaining('[Maps] TREK places index along a line: 4 of 4 requests failed: places api down'),
|
||||
]);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-013: one circle failing keeps the places of the others and marks the answer partial', async () => {
|
||||
mockNearby.mockRejectedValueOnce('timeout').mockResolvedValue([row('c-1', 'Kaffeehaus', CAFE)]);
|
||||
const { svc, overpass } = make();
|
||||
|
||||
const out = await svc.poisAlong(['cafe'], LINE, 500, 'de', 30);
|
||||
|
||||
expect(overpass).not.toHaveBeenCalled();
|
||||
expect(out.partial).toBe(true);
|
||||
// The same cafe in three overlapping circles is one place.
|
||||
expect(out.pois.map((poi) => [poi.osm_id, poi.category])).toEqual([['gers:c-1', 'cafe']]);
|
||||
expect(mapsWarnings()).toEqual([expect.stringContaining('1 of 4 requests failed: timeout')]);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-014: Overpass failing names its kinds as failed, keeps the index places and is logged once', async () => {
|
||||
mockNearby.mockResolvedValue([row('c-1', 'Kaffeehaus', CAFE)]);
|
||||
const { svc, overpass } = make();
|
||||
overpass.mockRejectedValue(new Error('Could not reach any Overpass endpoint'));
|
||||
|
||||
const out = await svc.poisAlong(['cafe', 'viewpoint', 'drinking_water'], LINE, 500, 'de', 30);
|
||||
|
||||
expect(out.failed).toEqual(['viewpoint', 'drinking_water']);
|
||||
expect(out.pois.map((poi) => poi.name)).toEqual(['Kaffeehaus']);
|
||||
expect(out.partial).toBe(false);
|
||||
expect(mapsWarnings()).toEqual([
|
||||
expect.stringContaining(
|
||||
'[Maps] OpenStreetMap along a line: viewpoint, drinking_water: 1 of 1 requests failed: Could not reach any',
|
||||
),
|
||||
]);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-015: with the index switched off every kind goes to Overpass in one query', async () => {
|
||||
const { svc, overpass } = make(false);
|
||||
|
||||
await svc.poisAlong(['cafe', 'drinking_water'], LINE, 500);
|
||||
|
||||
expect(mockNearby).not.toHaveBeenCalled();
|
||||
expect(overpass).toHaveBeenCalledTimes(1);
|
||||
expect(overpass).toHaveBeenCalledWith('cafe,drinking_water', LINE, 500, undefined, 30);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-016: each hit is labelled with the kind that produced it, its hours kept; a full page is cut', async () => {
|
||||
const hours = { osm: 'Tu-Su 10:00-18:00', source: 'openstreetmap', sourceUrl: null };
|
||||
mockNearby.mockImplementation(async (_lat, _lng, opts) =>
|
||||
opts?.category?.startsWith('arts')
|
||||
? [
|
||||
row('m-1', 'Lenbachhaus', MUSEUM, { hours }),
|
||||
...Array.from({ length: 29 }, (_, i) => row(`m-${i + 2}`, `Museum ${i}`, MUSEUM)),
|
||||
]
|
||||
: opts?.category?.startsWith('sports')
|
||||
? [
|
||||
row('p-1', 'Englischer Garten', 'sports_and_recreation>park'),
|
||||
row('p-2', 'Therme Erding', 'sports_and_recreation>park>water_park'),
|
||||
]
|
||||
: [row('s-1', 'Siegestor', 'cultural_and_historic>memorial_site>monument')],
|
||||
);
|
||||
const { svc } = make();
|
||||
|
||||
const out = await svc.poisAlong(['sights', 'museum', 'park'], LINE, 500, 'de', 30);
|
||||
|
||||
expect(out.truncated).toBe(true);
|
||||
const byName = new Map(out.pois.map((poi) => [poi.name, poi]));
|
||||
expect(byName.get('Lenbachhaus')).toMatchObject({ category: 'museum', opening_hours: 'Tu-Su 10:00-18:00' });
|
||||
expect(byName.get('Siegestor')).toMatchObject({ category: 'sights', opening_hours: null });
|
||||
expect(byName.get('Englischer Garten')).toMatchObject({ category: 'park' });
|
||||
// A water park lies under the parks' path, but it is the activity pill's: no park of a walk.
|
||||
expect(byName.has('Therme Erding')).toBe(false);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-016b: a row goes to the kind that names its path most closely, a row no kind knows is left as it is', () => {
|
||||
const waterPark = { category: 'water_park', categoryPath: 'sports_and_recreation>park>water_park' };
|
||||
expect(ownedByAsked(waterPark, ['park'])).toBe(false);
|
||||
expect(ownedByAsked(waterPark, ['park', 'activity'])).toBe(true);
|
||||
expect(ownedByAsked({ category: 'park', categoryPath: 'sports_and_recreation>park' }, ['park'])).toBe(true);
|
||||
// Judged by its leaf where the index left the path empty.
|
||||
expect(ownedByAsked({ category: 'water_park', categoryPath: null }, ['park'])).toBe(false);
|
||||
expect(ownedByAsked({ category: null, categoryPath: null }, ['park'])).toBe(true);
|
||||
expect(ownedByAsked({ category: 'pier', categoryPath: 'travel>pier' }, ['park'])).toBe(true);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-017: an index and an Overpass that both fail name every kind as failed', async () => {
|
||||
mockNearby.mockRejectedValue(new Error('places api down'));
|
||||
const { svc, overpass } = make();
|
||||
overpass.mockRejectedValue(new Error('Could not reach any Overpass endpoint'));
|
||||
|
||||
const out = await svc.poisAlong(['cafe', 'viewpoint'], LINE, 500, 'de', 30);
|
||||
|
||||
expect(out).toEqual({ pois: [], failed: ['viewpoint', 'cafe'], partial: false, truncated: false, capped: false });
|
||||
expect(mapsWarnings()).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-018: a line longer than the circles reach has its index kinds searched along its start, and says so', async () => {
|
||||
const { svc, overpass } = make();
|
||||
const long = north(1);
|
||||
|
||||
const out = await svc.poisAlong(['cafe', 'viewpoint'], long, 250, 'de', 30);
|
||||
|
||||
expect(out.capped).toBe(true);
|
||||
expect(mockNearby).toHaveBeenCalledTimes(24);
|
||||
expect(Math.max(...mockNearby.mock.calls.map(([lat]) => lat))).toBeLessThan(47.32);
|
||||
// Overpass searches the whole line in three stretches of about 37 km, the last one ending where the line does.
|
||||
expect(overpass).toHaveBeenCalledTimes(3);
|
||||
const stretches = overpass.mock.calls.map((call) => call[1]);
|
||||
expect(stretches[0]?.[0]).toEqual(long[0]);
|
||||
expect(stretches[1]?.[0]).toEqual(stretches[0]?.at(-1));
|
||||
expect(stretches[2]?.[0]).toEqual(stretches[1]?.at(-1));
|
||||
expect(stretches[2]?.at(-1)).toEqual(long[1]);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-019: the kinds Overpass answers are searched along the whole line, a few stretches at a time', async () => {
|
||||
const { svc, overpass } = make();
|
||||
const seen = inFlight(overpass);
|
||||
|
||||
const ride = await svc.poisAlong(['drinking_water', 'toilets'], north(1), 500, 'de', 30);
|
||||
|
||||
expect(ride).toMatchObject({ capped: false, partial: false, failed: [] });
|
||||
expect(overpass.mock.calls.map((call) => call[0])).toEqual([
|
||||
'drinking_water,toilets',
|
||||
'drinking_water,toilets',
|
||||
'drinking_water,toilets',
|
||||
]);
|
||||
|
||||
// An index that answers nothing leaves its kinds to Overpass, which reaches the whole line.
|
||||
overpass.mockClear();
|
||||
mockNearby.mockRejectedValue(new Error('places api down'));
|
||||
const fallback = await svc.poisAlong(['cafe'], north(1), 500, 'de', 30);
|
||||
expect(fallback).toMatchObject({ capped: false, failed: [] });
|
||||
expect(overpass).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Past 400 km the line is searched along its start; never more than three stretches are in flight.
|
||||
overpass.mockClear();
|
||||
seen.most = 0;
|
||||
const crossing = await svc.poisAlong(['viewpoint'], north(4.5), 500, 'de', 30);
|
||||
expect(crossing.capped).toBe(true);
|
||||
expect(overpass).toHaveBeenCalledTimes(8);
|
||||
expect(seen.most).toBe(3);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-019b: one stretch failing keeps the places of the others and marks the answer partial', async () => {
|
||||
const { svc, overpass } = make();
|
||||
overpass.mockRejectedValueOnce(new Error('rate limited'));
|
||||
|
||||
const out = await svc.poisAlong(['drinking_water'], north(1), 500, 'de', 30);
|
||||
|
||||
expect(out).toMatchObject({ pois: [SPRING], failed: [], partial: true, capped: false });
|
||||
expect(mapsWarnings()).toEqual([
|
||||
expect.stringContaining(
|
||||
'[Maps] OpenStreetMap along a line: drinking_water: 1 of 3 requests failed: rate limited',
|
||||
),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* MAPS-POIS-001..012 — the explore pill, answered from the index.
|
||||
* MAPS-POIS-001..018: the explore pill, answered from the index.
|
||||
*
|
||||
* The pill used to be Overpass and nothing else, and the public mirrors it asks
|
||||
* are regularly overloaded. The index answers first now, so the two things that
|
||||
@@ -9,8 +9,16 @@
|
||||
*/
|
||||
import type { AppSettingsRepository } from '../../../src/db/repositories/AppSettings.repository';
|
||||
import type { UsersRepository } from '../../../src/db/repositories/Users.repository';
|
||||
import {
|
||||
indexKnowsCategory,
|
||||
indexPoiAnswer,
|
||||
indexPoiPlanAround,
|
||||
indexRequests,
|
||||
indexRequestsPerKind,
|
||||
} from '../../../src/nest/maps/maps-index.helpers';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import type { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
import { INDEX_MAX_CATEGORY_CHARS } from '../../../src/nest/maps/trek-places.client';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
import { noGoogleQuota } from '../../helpers/google-quota';
|
||||
import { buildMapsParts } from '../../helpers/maps-service';
|
||||
@@ -290,7 +298,7 @@ describe('MapsService.pois answered from the index', () => {
|
||||
expect(out.clamped).toBe(false);
|
||||
});
|
||||
|
||||
it('MAPS-POIS-013: a corridor search asks the index once for all its categories', async () => {
|
||||
it('MAPS-POIS-013: a corridor search asks the index for all its categories, in one request while they fit', async () => {
|
||||
// The corridor sends a comma-separated list. Looking the whole string up as
|
||||
// one key missed every time, so every corridor query fell through to
|
||||
// Overpass — on the one path where that hurts most, because a single search
|
||||
@@ -314,10 +322,11 @@ describe('MapsService.pois answered from the index', () => {
|
||||
const svc = make();
|
||||
const overpass = stubOverpass(svc);
|
||||
|
||||
const out = await svc.pois('fuel,charging', BOX);
|
||||
const out = await svc.pois('fuel,charging', BOX, undefined, 40);
|
||||
|
||||
expect(overpass).not.toHaveBeenCalled();
|
||||
// One request carrying every term the two categories map to.
|
||||
expect(mockNearby).toHaveBeenCalledTimes(1);
|
||||
expect(mockNearby).toHaveBeenCalledWith(
|
||||
0,
|
||||
0,
|
||||
@@ -326,13 +335,29 @@ describe('MapsService.pois answered from the index', () => {
|
||||
'travel_and_transportation>fueling_station>gas_station,travel_and_transportation>fueling_station>ev_charging_station',
|
||||
// Per category, so a mixed search does not spend the whole allowance on
|
||||
// whichever kind happens to be densest.
|
||||
limit: 120,
|
||||
limit: 80,
|
||||
}),
|
||||
);
|
||||
// Each hit carries the category that produced it, not the list that was
|
||||
// asked for: the client colours and groups its markers by that field, and
|
||||
// "fuel,charging" is not a category.
|
||||
expect(out.pois.map((p) => p.category)).toEqual(['fuel', 'charging']);
|
||||
|
||||
// At the pill's own budget the two together ask for 120 rows, and the
|
||||
// service refuses more than 100 with a 422: one request each, then.
|
||||
mockNearby.mockClear();
|
||||
const split = await svc.pois('fuel,charging', BOX);
|
||||
|
||||
expect(overpass).not.toHaveBeenCalled();
|
||||
expect(mockNearby.mock.calls.map(([, , opts]) => opts)).toEqual([
|
||||
{ radius: 7872, limit: 60, category: 'travel_and_transportation>fueling_station>gas_station' },
|
||||
{ radius: 7872, limit: 60, category: 'travel_and_transportation>fueling_station>ev_charging_station' },
|
||||
]);
|
||||
// Both requests answered both rows here: each place is listed once.
|
||||
expect(split.pois.map((p) => [p.name, p.category])).toEqual([
|
||||
['Aral', 'fuel'],
|
||||
['Ionity', 'charging'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('MAPS-POIS-013b: a leaf category that is only a substring of a term is still labelled by it', async () => {
|
||||
@@ -499,6 +524,71 @@ describe('MapsService.pois answered from the index', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapsService.pois within the index service limits', () => {
|
||||
it('MAPS-POIS-016: knows which categories the index answers, and narrows or widens a circle as for a viewport', () => {
|
||||
expect(['nature', 'park', 'market', 'peak', 'museum_gallery'].map(indexKnowsCategory)).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
expect(['viewpoint', 'drinking_water', 'toilets', 'shelter'].map(indexKnowsCategory)).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
const centre = { lat: 48.1, lng: 11.5 };
|
||||
expect(indexPoiPlanAround('park', centre, 120, 30)).toMatchObject({ radius: 300, reach: 120, cap: 30, ...centre });
|
||||
expect(indexPoiPlanAround('park', centre, 50_000, 30)?.radius).toBe(20000);
|
||||
expect(indexPoiPlanAround('park,viewpoint', centre, 1000, 30)).toBeNull();
|
||||
expect(indexPoiPlanAround('', centre, 1000, 30)).toBeNull();
|
||||
});
|
||||
|
||||
it('MAPS-POIS-017: many categories are asked one by one, each its share, a full page cut and every place once', async () => {
|
||||
mockNearby.mockImplementation(async (_lat, _lng, opts) =>
|
||||
opts?.category?.startsWith('food_and_drink>restaurant')
|
||||
? rows(48).map((r) => ({ ...r, categoryPath: 'food_and_drink>restaurant' }))
|
||||
: [{ ...FULL, gers: 'shared-1' }],
|
||||
);
|
||||
const svc = make();
|
||||
const overpass = stubOverpass(svc);
|
||||
|
||||
const out = await svc.pois('restaurant,cafe,bar,hotel,sights', BOX);
|
||||
|
||||
expect(overpass).not.toHaveBeenCalled();
|
||||
expect(mockNearby).toHaveBeenCalledTimes(5);
|
||||
for (const [, , opts] of mockNearby.mock.calls) {
|
||||
// 240 over five categories, and never above what the service takes.
|
||||
expect(opts?.limit).toBe(48);
|
||||
expect(opts?.category?.length).toBeLessThanOrEqual(INDEX_MAX_CATEGORY_CHARS);
|
||||
}
|
||||
// The restaurants' page was full, so there are more of them.
|
||||
expect(out.truncated).toBe(true);
|
||||
expect(out.pois.filter((p) => p.osm_id === 'gers:shared-1')).toHaveLength(1);
|
||||
expect(out.pois).toHaveLength(49);
|
||||
});
|
||||
|
||||
it('MAPS-POIS-018: pages that together run past the cap are cut to it', () => {
|
||||
const plan = indexPoiPlanAround('cafe', { lat: 0, lng: 0 }, 1000, 2);
|
||||
if (!plan) throw new Error('cafe has index terms');
|
||||
const answer = indexPoiAnswer(rows(3), plan, false);
|
||||
expect(answer.pois).toHaveLength(2);
|
||||
expect(answer.truncated).toBe(true);
|
||||
expect(indexRequests(plan)).toEqual([
|
||||
{
|
||||
category: 'food_and_drink>casual_eatery>cafe,food_and_drink>non_alcoholic_beverage_venue>coffee_shop',
|
||||
limit: 2,
|
||||
},
|
||||
]);
|
||||
const food = indexPoiPlanAround('restaurant,cafe', { lat: 0, lng: 0 }, 1000, 30);
|
||||
// Together they fit one request; one per kind gives each its own thirty.
|
||||
expect(food && indexRequests(food)).toHaveLength(1);
|
||||
expect(food && indexRequestsPerKind(food).map((request) => request.limit)).toEqual([30, 30]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapsService.pois falls back to Overpass', () => {
|
||||
it('MAPS-POIS-009: a pill the index has no categories for never reaches the index', async () => {
|
||||
const svc = make();
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* MAPS-ALONG-020..026: the Overpass query near a line (PLN-02). One query for
|
||||
* every kind the index does not know, the candidates of the line's box kept
|
||||
* within reach of the line itself, and unnamed taps and huts kept, because
|
||||
* they are what a hike looks for, while an unnamed garden or cafe is not. The
|
||||
* network is a stubbed global fetch.
|
||||
*/
|
||||
import { OsmClient } from '../../../src/nest/maps/providers/osm.client';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const osm = new OsmClient();
|
||||
|
||||
/** A line of two points, a little apart per case, so no case is answered from another's cache. */
|
||||
const line = (n: number) => [
|
||||
{ lat: 47.4 + n / 100, lng: 11.1 },
|
||||
{ lat: 47.41 + n / 100, lng: 11.1 },
|
||||
];
|
||||
|
||||
const answer = (elements: unknown[]) => ({ ok: true, json: async () => ({ elements }) });
|
||||
|
||||
/** The query of a fetch call, read back the way Overpass reads the form. */
|
||||
const queryOf = (call: unknown[] | undefined) =>
|
||||
decodeURIComponent(String((call?.[1] as RequestInit | undefined)?.body ?? '')).replace(/^data=/, '');
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('OsmClient.searchOverpassPoisAlong', () => {
|
||||
it('MAPS-ALONG-020: asks near the line once, keeps unnamed places and drops the closed ones', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(
|
||||
answer([
|
||||
{ type: 'node', id: 1, lat: 47.405, lon: 11.1005, tags: { amenity: 'drinking_water' } },
|
||||
{ type: 'way', id: 2, center: { lat: 47.406, lon: 11.1 }, tags: { name: 'Gamshütte', amenity: 'shelter' } },
|
||||
{ type: 'node', id: 3, lat: 47.407, lon: 11.1, tags: { amenity: 'drinking_water', disused: 'yes' } },
|
||||
{
|
||||
type: 'node',
|
||||
id: 4,
|
||||
lat: 47.408,
|
||||
lon: 11.1,
|
||||
tags: { name: 'Alm', amenity: 'shelter', opening_hours: 'off' },
|
||||
},
|
||||
{ type: 'node', id: 5, tags: { name: 'Nowhere', amenity: 'shelter' } },
|
||||
]),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const out = await osm.searchOverpassPoisAlong('drinking_water,shelter', line(0), 500, 'de-DE');
|
||||
|
||||
const query = queryOf(fetchMock.mock.calls[0]);
|
||||
expect(query).toContain('->.cand;');
|
||||
expect(query).toContain('nwr.cand(around:550,47.40000,11.10000,47.41000,11.10000);');
|
||||
expect(query).toContain('nwr["amenity"="drinking_water"](');
|
||||
expect(query).toContain('nwr["tourism"="alpine_hut"](');
|
||||
// Thirty of each kind, and the query's own slack.
|
||||
expect(query).toContain('out center tags 85;');
|
||||
expect(out.pois.map((poi) => [poi.osm_id, poi.name, poi.category])).toEqual([
|
||||
['node:1', '', 'drinking_water'],
|
||||
['way:2', 'Gamshütte', 'shelter'],
|
||||
]);
|
||||
expect(out).toMatchObject({ source: 'openstreetmap', truncated: false, clamped: false });
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-021: an unknown kind is refused before anything is asked', async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(osm.searchOverpassPoisAlong('drinking_water,unicorns', line(1), 500)).rejects.toMatchObject({
|
||||
status: 400,
|
||||
});
|
||||
await expect(osm.searchOverpassPoisAlong(' , ', line(1), 500)).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-022: the same kinds along the same line are answered from the cache, in any order', async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(answer([{ type: 'node', id: 9, lat: 47.425, lon: 11.1, tags: { amenity: 'toilets' } }]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await osm.searchOverpassPoisAlong('toilets,picnic', line(2), 500, 'de');
|
||||
const asked = fetchMock.mock.calls.length;
|
||||
const again = await osm.searchOverpassPoisAlong('picnic,toilets', line(2), 500, 'de');
|
||||
|
||||
expect(fetchMock.mock.calls.length).toBe(asked);
|
||||
expect(again.pois.map((poi) => poi.osm_id)).toEqual(['node:9']);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-023: every mirror down throws, so the caller can name the kinds as unavailable', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 504, json: async () => ({}) }));
|
||||
|
||||
await expect(osm.searchOverpassPoisAlong('viewpoint', line(3), 500)).rejects.toMatchObject({ status: 502 });
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-024b: an answer older than five minutes is asked again, and the oldest of a full cache goes', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(answer([]));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.useFakeTimers({ now: new Date('2026-10-19T10:00:00Z'), toFake: ['Date'] });
|
||||
try {
|
||||
await osm.searchOverpassPoisAlong('bike_repair', line(5), 500);
|
||||
const asked = fetchMock.mock.calls.length;
|
||||
vi.setSystemTime(new Date('2026-10-19T10:06:00Z'));
|
||||
await osm.searchOverpassPoisAlong('bike_repair', line(5), 500);
|
||||
expect(fetchMock.mock.calls.length).toBe(2 * asked);
|
||||
|
||||
// Six hundred other lines overfill the cache of five hundred: the first of them is asked again, the last is not.
|
||||
const lines = Array.from({ length: 600 }, (_, i) => [
|
||||
{ lat: 10 + i / 1000, lng: 20 },
|
||||
{ lat: 10.0005 + i / 1000, lng: 20 },
|
||||
]);
|
||||
await lines.reduce<Promise<unknown>>(async (done, each) => {
|
||||
await done;
|
||||
return osm.searchOverpassPoisAlong('boat_rental', each, 250);
|
||||
}, Promise.resolve(null));
|
||||
const filled = fetchMock.mock.calls.length;
|
||||
await osm.searchOverpassPoisAlong('boat_rental', lines[599] ?? [], 250);
|
||||
expect(fetchMock.mock.calls.length).toBe(filled);
|
||||
await osm.searchOverpassPoisAlong('boat_rental', lines[0] ?? [], 250);
|
||||
expect(fetchMock.mock.calls.length).toBe(filled + asked);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-026: only the kinds OSM rarely names keep their unnamed places, a city walk gets named ones', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
answer([
|
||||
{ type: 'node', id: 1, lat: 47.465, lon: 11.1, tags: { amenity: 'drinking_water' } },
|
||||
{ type: 'way', id: 2, center: { lat: 47.466, lon: 11.1 }, tags: { leisure: 'garden' } },
|
||||
{ type: 'way', id: 3, center: { lat: 47.467, lon: 11.1 }, tags: { leisure: 'garden', name: 'Hofgarten' } },
|
||||
{ type: 'node', id: 4, lat: 47.468, lon: 11.1, tags: { amenity: 'cafe' } },
|
||||
{ type: 'node', id: 5, lat: 47.469, lon: 11.1, tags: { historic: 'memorial' } },
|
||||
{ type: 'node', id: 6, lat: 47.469, lon: 11.1, tags: { tourism: 'viewpoint' } },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const out = await osm.searchOverpassPoisAlong('drinking_water,park,cafe,sights,viewpoint', line(6), 500, 'de');
|
||||
|
||||
expect(out.pois.map((poi) => [poi.osm_id, poi.name, poi.category])).toEqual([
|
||||
['node:1', '', 'drinking_water'],
|
||||
['way:3', 'Hofgarten', 'park'],
|
||||
['node:6', '', 'viewpoint'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('MAPS-ALONG-024: more places than the budget are cut to it and said to be', async () => {
|
||||
const many = Array.from({ length: 40 }, (_, i) => ({
|
||||
type: 'node',
|
||||
id: 100 + i,
|
||||
lat: 47.44,
|
||||
lon: 11.1,
|
||||
tags: { tourism: 'viewpoint' },
|
||||
}));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(answer(many)));
|
||||
|
||||
const out = await osm.searchOverpassPoisAlong('viewpoint', line(4), 500, undefined, 30);
|
||||
|
||||
expect(out.pois).toHaveLength(30);
|
||||
expect(out.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OsmClient.searchOverpassPois (the explore pill)', () => {
|
||||
it('MAPS-ALONG-025: still drops unnamed places, which a plan has no use for', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
answer([
|
||||
{ type: 'node', id: 1, lat: 50.1, lon: 8.1, tags: { amenity: 'cafe' } },
|
||||
{ type: 'node', id: 3, lat: 50.1, lon: 8.1 },
|
||||
{
|
||||
type: 'node',
|
||||
id: 2,
|
||||
lat: 50.11,
|
||||
lon: 8.11,
|
||||
tags: { name: 'Café Mozart', amenity: 'cafe', 'brand:wikidata': 'Q123' },
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const out = await osm.searchOverpassPois('cafe', { south: 50.05, west: 8.05, north: 50.15, east: 8.15 }, 'de');
|
||||
|
||||
expect(out.pois.map((poi) => [poi.name, poi.brand_wikidata])).toEqual([['Café Mozart', 'Q123']]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* TourPoiSearchService (PLN-02): places along a tour's line through the map
|
||||
* search. The map search is a stub here: these tests are about which
|
||||
* stretches are asked, what is kept and how the answer says what it missed.
|
||||
* TourPoiSearchService (PLN-02, CW-05): places along a tour's line through
|
||||
* the map search. The map search is a stub here: these tests are about what is
|
||||
* asked for, what is kept and how the answer says what it missed.
|
||||
*/
|
||||
import { Places } from '../../../src/db/entities/Places.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
@@ -20,17 +20,21 @@ import { createTour } from '../../helpers/tours-repos';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const pois = vi.fn();
|
||||
const poisAlong = vi.fn();
|
||||
let t: TestModule;
|
||||
let service: TourPoiSearchService;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [ToursModule], overrides: [{ provide: MapsService, useValue: { pois } }] });
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [{ provide: MapsService, useValue: { poisAlong } }],
|
||||
});
|
||||
service = t.get(TourPoiSearchService);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
pois.mockReset();
|
||||
poisAlong.mockReset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
@@ -66,15 +70,16 @@ function poi(id: string, lat: number, lng: number, category = 'viewpoint', over:
|
||||
|
||||
const answer = (list: unknown[], over: Record<string, unknown> = {}) => ({
|
||||
pois: list,
|
||||
source: 'openstreetmap',
|
||||
failed: [],
|
||||
partial: false,
|
||||
truncated: false,
|
||||
clamped: false,
|
||||
capped: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('TourPoiSearchService', () => {
|
||||
it('TOUR-POI-001: keeps the places within the corridor, once each, nearest the start first, with where they lie', async () => {
|
||||
pois.mockResolvedValue(
|
||||
poisAlong.mockResolvedValue(
|
||||
answer([
|
||||
poi('node:far', 47.41, 11.2),
|
||||
poi('node:late', 47.418, 11.1005),
|
||||
@@ -96,7 +101,7 @@ describe('TourPoiSearchService', () => {
|
||||
});
|
||||
|
||||
it('TOUR-POI-006: one name in one spot is one place, mapped twice as a point and an outline', async () => {
|
||||
pois.mockResolvedValue(
|
||||
poisAlong.mockResolvedValue(
|
||||
answer([
|
||||
poi('node:9', 47.41, 11.1003, 'viewpoint', { name: 'AlpspiX' }),
|
||||
poi('way:9', 47.4101, 11.1004, 'viewpoint', { name: 'AlpspiX' }),
|
||||
@@ -107,8 +112,8 @@ describe('TourPoiSearchService', () => {
|
||||
expect(result.pois.map((found) => found.id)).toEqual(['node:9', 'node:10']);
|
||||
});
|
||||
|
||||
it('TOUR-POI-002: asks the map search for what each category means, and names a hit by the category asked', async () => {
|
||||
pois.mockResolvedValue(answer([poi('node:cafe', 47.41, 11.1, 'cafe')]));
|
||||
it('TOUR-POI-002: asks the map search along the line for what each category means, and names a hit by the category asked', async () => {
|
||||
poisAlong.mockResolvedValue(answer([poi('node:cafe', 47.41, 11.1, 'cafe')]));
|
||||
const result = await service.search({
|
||||
tour_type: 'hike',
|
||||
categories: ['food', 'drinking_water'],
|
||||
@@ -116,28 +121,28 @@ describe('TourPoiSearchService', () => {
|
||||
line: LINE,
|
||||
lang: 'de',
|
||||
});
|
||||
expect(pois).toHaveBeenCalledWith('restaurant,cafe,drinking_water', expect.any(Object), 'de', 60);
|
||||
expect(poisAlong).toHaveBeenCalledWith(
|
||||
['restaurant', 'cafe', 'drinking_water'],
|
||||
[
|
||||
{ lat: 47.4, lng: 11.1 },
|
||||
{ lat: 47.42, lng: 11.1 },
|
||||
],
|
||||
250,
|
||||
'de',
|
||||
30,
|
||||
);
|
||||
expect(result.pois[0]?.category).toBe('food');
|
||||
});
|
||||
|
||||
it('TOUR-POI-003: a stretch that fails leaves the others and marks the answer partial; a cut answer is truncated', async () => {
|
||||
// A line long enough to need several stretches.
|
||||
const long: Array<[number, number]> = [
|
||||
[47.0, 11.0],
|
||||
[48.6, 11.0],
|
||||
];
|
||||
pois
|
||||
.mockRejectedValueOnce(new Error('overpass down'))
|
||||
.mockResolvedValue(answer([poi('node:a', 47.6, 11.0005)], { truncated: true }));
|
||||
const result = await service.search({ tour_type: 'hike', categories: ['viewpoint'], corridor_m: 500, line: long });
|
||||
expect(pois.mock.calls.length).toBeGreaterThan(1);
|
||||
expect(result.partial).toBe(true);
|
||||
expect(result.truncated).toBe(true);
|
||||
it('TOUR-POI-003: says when parts of the line could not be searched or more places matched than came back', async () => {
|
||||
poisAlong.mockResolvedValue(answer([poi('node:a', 47.41, 11.1005)], { partial: true, truncated: true }));
|
||||
const result = await service.search({ tour_type: 'hike', categories: ['viewpoint'], corridor_m: 500, line: LINE });
|
||||
expect(result).toMatchObject({ partial: true, truncated: true, capped: false, unavailable: [] });
|
||||
expect(result.pois.map((found) => found.id)).toEqual(['node:a']);
|
||||
});
|
||||
|
||||
it('TOUR-POI-004: a line longer than one search covers is searched along its first stretches and says so', async () => {
|
||||
pois.mockResolvedValue(answer([]));
|
||||
it('TOUR-POI-004: a line longer than one search covers says it was searched along its first part', async () => {
|
||||
poisAlong.mockResolvedValue(answer([], { capped: true }));
|
||||
const result = await service.search({
|
||||
tour_type: 'kayak',
|
||||
categories: ['slipway'],
|
||||
@@ -147,8 +152,84 @@ describe('TourPoiSearchService', () => {
|
||||
[52.0, 14.0],
|
||||
],
|
||||
});
|
||||
expect(pois).toHaveBeenCalledTimes(12);
|
||||
expect(result.capped).toBe(true);
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it('TOUR-POI-007: names the kinds no source could search, a kind of two only when both failed', async () => {
|
||||
poisAlong.mockResolvedValue(answer([], { failed: ['viewpoint', 'cafe', 'market'] }));
|
||||
const walk = await service.search({
|
||||
tour_type: 'walk',
|
||||
categories: ['market', 'sights', 'viewpoint'],
|
||||
corridor_m: 500,
|
||||
line: LINE,
|
||||
});
|
||||
expect(walk.unavailable).toEqual(['market', 'viewpoint']);
|
||||
const hike = await service.search({ tour_type: 'hike', categories: ['food'], corridor_m: 500, line: LINE });
|
||||
expect(hike.unavailable).toEqual([]);
|
||||
poisAlong.mockResolvedValue(answer([], { failed: ['restaurant', 'cafe'] }));
|
||||
expect(
|
||||
(await service.search({ tour_type: 'hike', categories: ['food'], corridor_m: 500, line: LINE })).unavailable,
|
||||
).toEqual(['food']);
|
||||
});
|
||||
|
||||
it('TOUR-POI-008: an unnamed place is kept and never merged with another unnamed one nearby', async () => {
|
||||
poisAlong.mockResolvedValue(
|
||||
answer([
|
||||
poi('node:1', 47.41, 11.1003, 'drinking_water', { name: '' }),
|
||||
poi('node:2', 47.4101, 11.1003, 'drinking_water', { name: '' }),
|
||||
// Across the line from the first: as far along, a little further off it.
|
||||
poi('node:3', 47.41, 11.0995, 'drinking_water', { name: '' }),
|
||||
]),
|
||||
);
|
||||
const result = await service.search({
|
||||
tour_type: 'hike',
|
||||
categories: ['drinking_water'],
|
||||
corridor_m: 500,
|
||||
line: LINE,
|
||||
});
|
||||
expect(result.pois.map((found) => [found.id, found.name])).toEqual([
|
||||
['node:1', ''],
|
||||
['node:3', ''],
|
||||
['node:2', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
it('TOUR-POI-009: of more places than an answer carries, the nearest to the line are listed from the start', async () => {
|
||||
// Seventy cafes along the line, every other one a little further off it.
|
||||
const many = Array.from({ length: 70 }, (_, i) =>
|
||||
poi(`node:${i}`, 47.4 + (i + 1) * 0.0002, 11.1 + (i % 2 === 0 ? 0.0001 : 0.003), 'cafe'),
|
||||
);
|
||||
poisAlong.mockResolvedValue(answer(many));
|
||||
const result = await service.search({ tour_type: 'walk', categories: ['cafe'], corridor_m: 500, line: LINE });
|
||||
expect(result.pois).toHaveLength(60);
|
||||
expect(result.truncated).toBe(true);
|
||||
// All 35 near ones are in, from the start of the line to its end.
|
||||
const ids = new Set(result.pois.map((found) => found.id));
|
||||
expect(many.filter((_, i) => i % 2 === 0).every((near) => ids.has(near.osm_id))).toBe(true);
|
||||
const along = result.pois.map((found) => found.along_m);
|
||||
expect(along).toEqual([...along].sort((a, b) => a - b));
|
||||
});
|
||||
|
||||
it('TOUR-POI-010: a place of a circle outside the corridor and one of a kind not asked for are left out', async () => {
|
||||
poisAlong.mockResolvedValue(
|
||||
answer([
|
||||
poi('gers:in', 47.41, 11.101, 'park', { source: 'trek-places' }),
|
||||
poi('gers:off', 47.41, 11.115, 'park', { source: 'trek-places' }),
|
||||
poi('gers:other', 47.41, 11.1001, 'nature', { source: 'trek-places' }),
|
||||
]),
|
||||
);
|
||||
const result = await service.search({
|
||||
tour_type: 'walk',
|
||||
categories: ['park', 'museum'],
|
||||
corridor_m: 500,
|
||||
line: LINE,
|
||||
});
|
||||
// Parks and gardens alone, museums and galleries alone: not the beaches, theatres and cinemas of the explore pill.
|
||||
expect(poisAlong.mock.calls[0]?.[0]).toEqual(['park', 'museum_gallery']);
|
||||
expect(result.pois.map((found) => [found.id, found.category, found.source])).toEqual([
|
||||
['gers:in', 'park', 'trek-places'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('TOUR-POI-005: searches along a saved tour for MCP, and a tour without a line is not found', async () => {
|
||||
@@ -156,7 +237,7 @@ describe('TourPoiSearchService', () => {
|
||||
const trip = createTrip(db, user.id);
|
||||
const ridge = createPlace(db, trip.id, { name: 'Ridge' });
|
||||
createTour(db, ridge.id);
|
||||
pois.mockResolvedValue(answer([poi('node:x', 47.41, 11.1)]));
|
||||
poisAlong.mockResolvedValue(answer([poi('node:x', 47.41, 11.1)]));
|
||||
await expect(
|
||||
service.searchSavedTour(trip.id, ridge.id, { categories: ['viewpoint'], corridor_m: 500 }),
|
||||
).rejects.toThrow('Tour not found');
|
||||
|
||||
@@ -718,11 +718,13 @@ describe('toPlaceRecord', () => {
|
||||
});
|
||||
|
||||
describe('POI_CATEGORY_TO_TREK', () => {
|
||||
it('covers every pill the planner offers', () => {
|
||||
it('covers every pill the planner offers, and the kinds of the search along a tour it holds', () => {
|
||||
// The keys are the contract with the client's POI_CATEGORIES. A missing one
|
||||
// silently drops that pill back to Overpass, which is the slow path — and
|
||||
// for the four a drive needs that is the worst path of all, because the
|
||||
// corridor search asks for them sixteen boxes at a time.
|
||||
// corridor search asks for them sixteen boxes at a time. Museums and
|
||||
// galleries, park, market and peak are asked along a tour (PLN-02,
|
||||
// CW-05), each path checked against a live answer of the index.
|
||||
expect(Object.keys(POI_CATEGORY_TO_TREK).sort()).toEqual([
|
||||
'activity',
|
||||
'bar',
|
||||
@@ -731,8 +733,12 @@ describe('POI_CATEGORY_TO_TREK', () => {
|
||||
'charging',
|
||||
'fuel',
|
||||
'hotel',
|
||||
'market',
|
||||
'museum',
|
||||
'museum_gallery',
|
||||
'nature',
|
||||
'park',
|
||||
'peak',
|
||||
'rest_area',
|
||||
'restaurant',
|
||||
'shopping',
|
||||
|
||||
@@ -330,6 +330,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'لم ينجح البحث. حاول مرة أخرى بعد قليل.',
|
||||
'tours.poi.outdated': 'وُجدت للمسار كما كان سابقًا. ابحث مرة أخرى لتطابق المسار الحالي.',
|
||||
'tours.poi.partial': 'تعذّر البحث في بعض المقاطع، وقد تكون هناك أماكن مفقودة.',
|
||||
'tours.poi.unavailable': 'تعذّر البحث عن هذه الآن: {kinds}. حاول مرة أخرى بعد قليل.',
|
||||
'tours.poi.capped': 'الجولة طويلة: تم البحث في جزئها الأول فقط.',
|
||||
'tours.poi.truncated': 'هناك أماكن أكثر مما يظهر. قلّل المسافة أو الأنواع.',
|
||||
'tours.poi.empty': 'لم يُعثر على شيء ضمن {distance} من المسار.',
|
||||
|
||||
@@ -319,6 +319,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Axtarış alınmadı. Bir azdan yenidən cəhd et.',
|
||||
'tours.poi.outdated': 'Marşrutun əvvəlki halı üçün tapılıb. İndikinə uyğun olması üçün yenidən axtar.',
|
||||
'tours.poi.partial': 'Bəzi hissələrdə axtarış aparılmadı; orada yerlər çatışmaya bilər.',
|
||||
'tours.poi.unavailable': 'Bunlar hazırda axtarıla bilmədi: {kinds}. Bir azdan yenidən cəhd et.',
|
||||
'tours.poi.capped': 'Tur uzundur: yalnız ilk hissəsində axtarış aparıldı.',
|
||||
'tours.poi.truncated': 'Göstəriləndən çox yer var. Məsafəni və ya növləri azalt.',
|
||||
'tours.poi.empty': 'Marşrutdan {distance} məsafədə heç nə tapılmadı.',
|
||||
|
||||
@@ -324,6 +324,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'A busca não funcionou. Tente novamente em instantes.',
|
||||
'tours.poi.outdated': 'Encontrado para a rota como era antes. Busque novamente para combinar com a atual.',
|
||||
'tours.poi.partial': 'Alguns trechos não puderam ser buscados; podem faltar lugares neles.',
|
||||
'tours.poi.unavailable': 'Não foi possível buscar agora: {kinds}. Tente novamente em instantes.',
|
||||
'tours.poi.capped': 'O passeio é longo: só a primeira parte foi buscada.',
|
||||
'tours.poi.truncated': 'Há mais lugares do que os mostrados. Reduza a distância ou os tipos.',
|
||||
'tours.poi.empty': 'Nada encontrado a até {distance} da rota.',
|
||||
|
||||
@@ -325,6 +325,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': "La cerca no ha funcionat. Torna-ho a provar d'aquí a una estona.",
|
||||
'tours.poi.outdated': "Trobat per a la ruta tal com era abans. Torna a cercar perquè encaixi amb l'actual.",
|
||||
'tours.poi.partial': "No s'han pogut cercar alguns trams; hi poden faltar llocs.",
|
||||
'tours.poi.unavailable': "Ara no s'ha pogut cercar: {kinds}. Torna-ho a provar d'aquí a una estona.",
|
||||
'tours.poi.capped': "La ruta és llarga: només se n'ha cercat la primera part.",
|
||||
'tours.poi.truncated': 'Hi ha més llocs dels que es mostren. Redueix la distància o els tipus.',
|
||||
'tours.poi.empty': "No s'ha trobat res a menys de {distance} de la ruta.",
|
||||
|
||||
@@ -319,6 +319,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Hledání se nepovedlo. Zkus to za chvíli znovu.',
|
||||
'tours.poi.outdated': 'Nalezeno pro trasu v dřívější podobě. Hledej znovu, aby to odpovídalo té současné.',
|
||||
'tours.poi.partial': 'Některé úseky se nepodařilo prohledat; místa tam mohou chybět.',
|
||||
'tours.poi.unavailable': 'Teď se nepodařilo prohledat: {kinds}. Zkus to za chvíli znovu.',
|
||||
'tours.poi.capped': 'Túra je dlouhá: prohledána byla jen její první část.',
|
||||
'tours.poi.truncated': 'Míst je víc, než se zobrazuje. Zmenši vzdálenost nebo druhy.',
|
||||
'tours.poi.empty': 'Do {distance} od trasy nic nenalezeno.',
|
||||
|
||||
@@ -328,6 +328,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Die Suche hat nicht geklappt. Versuch es gleich noch einmal.',
|
||||
'tours.poi.outdated': 'Gefunden für die Route, wie sie vorher war. Suche erneut, damit es zur jetzigen passt.',
|
||||
'tours.poi.partial': 'Einige Abschnitte ließen sich nicht durchsuchen; dort können Orte fehlen.',
|
||||
'tours.poi.unavailable': 'Gerade nicht durchsuchbar: {kinds}. Versuch es gleich noch einmal.',
|
||||
'tours.poi.capped': 'Die Tour ist lang: Nur ihr erster Teil wurde durchsucht.',
|
||||
'tours.poi.truncated': 'Es gibt mehr Orte als angezeigt. Verkleinere den Abstand oder wähle weniger Arten.',
|
||||
'tours.poi.empty': 'Nichts gefunden im Abstand von {distance} zur Route.',
|
||||
|
||||
@@ -318,6 +318,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'The search did not work. Try again in a moment.',
|
||||
'tours.poi.outdated': 'Found for the route as it was before. Search again to match it now.',
|
||||
'tours.poi.partial': 'Some stretches could not be searched; places there may be missing.',
|
||||
'tours.poi.unavailable': 'Could not be searched right now: {kinds}. Try again in a moment.',
|
||||
'tours.poi.capped': 'The tour is long: only its first part was searched.',
|
||||
'tours.poi.truncated': 'There are more places than shown. Narrow the distance or the kinds.',
|
||||
'tours.poi.empty': 'Nothing found within {distance} of the route.',
|
||||
|
||||
@@ -324,6 +324,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'La búsqueda no funcionó. Inténtalo de nuevo en un momento.',
|
||||
'tours.poi.outdated': 'Encontrado para la ruta tal como era antes. Busca de nuevo para que coincida con la actual.',
|
||||
'tours.poi.partial': 'Algunos tramos no se pudieron buscar; pueden faltar lugares allí.',
|
||||
'tours.poi.unavailable': 'No se ha podido buscar ahora: {kinds}. Inténtalo de nuevo en un momento.',
|
||||
'tours.poi.capped': 'La ruta es larga: solo se buscó su primera parte.',
|
||||
'tours.poi.truncated': 'Hay más lugares de los que se muestran. Reduce la distancia o los tipos.',
|
||||
'tours.poi.empty': 'No se encontró nada a menos de {distance} de la ruta.',
|
||||
|
||||
@@ -321,6 +321,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Otsing ei õnnestunud. Proovi kohe uuesti.',
|
||||
'tours.poi.outdated': 'Leitud marsruudi varasema kuju jaoks. Otsi uuesti, et see sobiks praegusega.',
|
||||
'tours.poi.partial': 'Mõnda lõiku ei saanud läbi otsida; sealt võivad kohad puududa.',
|
||||
'tours.poi.unavailable': 'Praegu ei saanud otsida: {kinds}. Proovi kohe uuesti.',
|
||||
'tours.poi.capped': 'Matk on pikk: läbi otsiti ainult selle esimene osa.',
|
||||
'tours.poi.truncated': 'Kohti on rohkem, kui näidatakse. Vähenda kaugust või liike.',
|
||||
'tours.poi.empty': 'Marsruudist {distance} kaugusel ei leitud midagi.',
|
||||
|
||||
@@ -331,6 +331,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': "La recherche n'a pas abouti. Réessaie dans un instant.",
|
||||
'tours.poi.outdated': "Trouvé pour l'itinéraire tel qu'il était avant. Cherche à nouveau pour l'actuel.",
|
||||
'tours.poi.partial': "Certains tronçons n'ont pas pu être cherchés ; des lieux peuvent y manquer.",
|
||||
'tours.poi.unavailable': 'Recherche impossible pour le moment : {kinds}. Réessaie dans un instant.',
|
||||
'tours.poi.capped': "L'itinéraire est long : seule sa première partie a été cherchée.",
|
||||
'tours.poi.truncated': "Il y a plus de lieux qu'affichés. Réduis la distance ou les types.",
|
||||
'tours.poi.empty': "Rien trouvé à moins de {distance} de l'itinéraire.",
|
||||
|
||||
@@ -331,6 +331,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Η αναζήτηση δεν πέτυχε. Δοκίμασε ξανά σε λίγο.',
|
||||
'tours.poi.outdated': 'Βρέθηκαν για τη διαδρομή όπως ήταν πριν. Αναζήτησε ξανά για την τωρινή.',
|
||||
'tours.poi.partial': 'Ορισμένα τμήματα δεν αναζητήθηκαν· μπορεί να λείπουν μέρη εκεί.',
|
||||
'tours.poi.unavailable': 'Δεν ήταν δυνατή η αναζήτηση τώρα: {kinds}. Δοκίμασε ξανά σε λίγο.',
|
||||
'tours.poi.capped': 'Η διαδρομή είναι μεγάλη: αναζητήθηκε μόνο το πρώτο της μέρος.',
|
||||
'tours.poi.truncated': 'Υπάρχουν περισσότερα μέρη από όσα εμφανίζονται. Μείωσε την απόσταση ή τα είδη.',
|
||||
'tours.poi.empty': 'Δεν βρέθηκε τίποτα σε απόσταση {distance} από τη διαδρομή.',
|
||||
|
||||
@@ -326,6 +326,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'A keresés nem sikerült. Próbáld újra egy kicsit később.',
|
||||
'tours.poi.outdated': 'Az útvonal korábbi állapotához találva. Keress újra, hogy a mostanihoz illjen.',
|
||||
'tours.poi.partial': 'Néhány szakaszt nem sikerült átkeresni; ott helyek hiányozhatnak.',
|
||||
'tours.poi.unavailable': 'Ezeket most nem lehetett keresni: {kinds}. Próbáld újra egy kicsit később.',
|
||||
'tours.poi.capped': 'A túra hosszú: csak az első részét kerestük át.',
|
||||
'tours.poi.truncated': 'Több hely van, mint amennyi látszik. Szűkítsd a távolságot vagy a fajtákat.',
|
||||
'tours.poi.empty': 'Az útvonaltól {distance} távolságon belül nincs találat.',
|
||||
|
||||
@@ -318,6 +318,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Pencarian gagal. Coba lagi sebentar lagi.',
|
||||
'tours.poi.outdated': 'Ditemukan untuk rute seperti sebelumnya. Cari lagi agar cocok dengan yang sekarang.',
|
||||
'tours.poi.partial': 'Beberapa ruas tidak dapat dicari; tempat di sana mungkin terlewat.',
|
||||
'tours.poi.unavailable': 'Saat ini tidak dapat dicari: {kinds}. Coba lagi sebentar lagi.',
|
||||
'tours.poi.capped': 'Turnya panjang: hanya bagian pertamanya yang dicari.',
|
||||
'tours.poi.truncated': 'Ada lebih banyak tempat daripada yang ditampilkan. Persempit jarak atau jenisnya.',
|
||||
'tours.poi.empty': 'Tidak ada yang ditemukan dalam {distance} dari rute.',
|
||||
|
||||
@@ -326,6 +326,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'La ricerca non è riuscita. Riprova tra un momento.',
|
||||
'tours.poi.outdated': "Trovato per il percorso com'era prima. Cerca di nuovo per quello attuale.",
|
||||
'tours.poi.partial': 'Alcuni tratti non sono stati cercati; lì potrebbero mancare dei luoghi.',
|
||||
'tours.poi.unavailable': 'Al momento non è stato possibile cercare: {kinds}. Riprova tra un momento.',
|
||||
'tours.poi.capped': 'Il percorso è lungo: è stata cercata solo la prima parte.',
|
||||
'tours.poi.truncated': 'Ci sono più luoghi di quelli mostrati. Riduci la distanza o i tipi.',
|
||||
'tours.poi.empty': 'Nulla trovato entro {distance} dal percorso.',
|
||||
|
||||
@@ -312,6 +312,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': '検索できませんでした。少ししてからもう一度お試しください。',
|
||||
'tours.poi.outdated': '以前のルートで見つかった結果です。現在のルートに合わせるにはもう一度検索してください。',
|
||||
'tours.poi.partial': '一部の区間は検索できませんでした。その区間の場所が欠けている可能性があります。',
|
||||
'tours.poi.unavailable': '現在は検索できませんでした:{kinds}。少ししてからもう一度お試しください。',
|
||||
'tours.poi.capped': 'ツアーが長いため、最初の部分だけを検索しました。',
|
||||
'tours.poi.truncated': '表示されている以上の場所があります。距離か種類を絞り込んでください。',
|
||||
'tours.poi.empty': 'ルートから {distance} 以内には見つかりませんでした。',
|
||||
|
||||
@@ -310,6 +310,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': '검색하지 못했습니다. 잠시 후 다시 시도하세요.',
|
||||
'tours.poi.outdated': '이전 경로 기준의 결과입니다. 지금 경로에 맞추려면 다시 검색하세요.',
|
||||
'tours.poi.partial': '일부 구간은 검색하지 못했습니다. 그 구간의 장소가 빠졌을 수 있습니다.',
|
||||
'tours.poi.unavailable': '지금은 검색할 수 없었습니다: {kinds}. 잠시 후 다시 시도하세요.',
|
||||
'tours.poi.capped': '투어가 길어서 앞부분만 검색했습니다.',
|
||||
'tours.poi.truncated': '표시된 것보다 장소가 더 많습니다. 거리나 종류를 줄이세요.',
|
||||
'tours.poi.empty': '경로에서 {distance} 이내에 찾은 것이 없습니다.',
|
||||
|
||||
@@ -327,6 +327,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Het zoeken lukte niet. Probeer het zo opnieuw.',
|
||||
'tours.poi.outdated': 'Gevonden voor de route zoals die eerst was. Zoek opnieuw voor de huidige.',
|
||||
'tours.poi.partial': 'Sommige stukken konden niet worden doorzocht; daar kunnen plekken ontbreken.',
|
||||
'tours.poi.unavailable': 'Kon nu niet worden doorzocht: {kinds}. Probeer het zo opnieuw.',
|
||||
'tours.poi.capped': 'De tocht is lang: alleen het eerste deel is doorzocht.',
|
||||
'tours.poi.truncated': 'Er zijn meer plekken dan getoond. Verklein de afstand of de soorten.',
|
||||
'tours.poi.empty': 'Niets gevonden binnen {distance} van de route.',
|
||||
|
||||
@@ -333,6 +333,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Wyszukiwanie się nie powiodło. Spróbuj ponownie za chwilę.',
|
||||
'tours.poi.outdated': 'Znalezione dla trasy w poprzedniej postaci. Wyszukaj ponownie dla obecnej.',
|
||||
'tours.poi.partial': 'Niektórych odcinków nie udało się przeszukać; może tam brakować miejsc.',
|
||||
'tours.poi.unavailable': 'Nie udało się teraz wyszukać: {kinds}. Spróbuj ponownie za chwilę.',
|
||||
'tours.poi.capped': 'Trasa jest długa: przeszukano tylko jej pierwszą część.',
|
||||
'tours.poi.truncated': 'Miejsc jest więcej, niż pokazano. Zmniejsz odległość lub liczbę rodzajów.',
|
||||
'tours.poi.empty': 'Nic nie znaleziono w odległości {distance} od trasy.',
|
||||
|
||||
@@ -337,6 +337,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Поиск не удался. Попробуйте ещё раз чуть позже.',
|
||||
'tours.poi.outdated': 'Найдено для прежнего варианта маршрута. Чтобы учесть текущий, найдите снова.',
|
||||
'tours.poi.partial': 'Некоторые участки не удалось проверить; там могут отсутствовать места.',
|
||||
'tours.poi.unavailable': 'Сейчас не удалось выполнить поиск: {kinds}. Попробуйте ещё раз чуть позже.',
|
||||
'tours.poi.capped': 'Маршрут длинный: проверена только его первая часть.',
|
||||
'tours.poi.truncated': 'Мест больше, чем показано. Уменьшите расстояние или число видов.',
|
||||
'tours.poi.empty': 'В пределах {distance} от маршрута ничего не найдено.',
|
||||
|
||||
@@ -320,6 +320,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Hľadanie sa nepodarilo. Skús to o chvíľu znova.',
|
||||
'tours.poi.outdated': 'Nájdené pre trasu v predchádzajúcej podobe. Hľadaj znova pre súčasnú.',
|
||||
'tours.poi.partial': 'Niektoré úseky sa nepodarilo prehľadať; miesta tam môžu chýbať.',
|
||||
'tours.poi.unavailable': 'Teraz sa nepodarilo prehľadať: {kinds}. Skús to o chvíľu znova.',
|
||||
'tours.poi.capped': 'Túra je dlhá: prehľadaná bola len jej prvá časť.',
|
||||
'tours.poi.truncated': 'Miest je viac, než sa zobrazuje. Zmenši vzdialenosť alebo druhy.',
|
||||
'tours.poi.empty': 'Do {distance} od trasy sa nič nenašlo.',
|
||||
|
||||
@@ -317,6 +317,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Sökningen fungerade inte. Försök igen om en stund.',
|
||||
'tours.poi.outdated': 'Hittat för rutten som den var tidigare. Sök igen för den nuvarande.',
|
||||
'tours.poi.partial': 'Vissa sträckor kunde inte genomsökas; platser där kan saknas.',
|
||||
'tours.poi.unavailable': 'Gick inte att söka efter just nu: {kinds}. Försök igen om en stund.',
|
||||
'tours.poi.capped': 'Turen är lång: bara den första delen genomsöktes.',
|
||||
'tours.poi.truncated': 'Det finns fler platser än som visas. Minska avståndet eller slagen.',
|
||||
'tours.poi.empty': 'Inget hittades inom {distance} från rutten.',
|
||||
|
||||
@@ -305,6 +305,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'ค้นหาไม่สำเร็จ ลองอีกครั้งในอีกสักครู่',
|
||||
'tours.poi.outdated': 'ผลนี้ได้จากเส้นทางแบบเดิม ค้นหาอีกครั้งให้ตรงกับเส้นทางปัจจุบัน',
|
||||
'tours.poi.partial': 'ค้นหาบางช่วงไม่ได้ อาจมีสถานที่ในช่วงนั้นหายไป',
|
||||
'tours.poi.unavailable': 'ค้นหาตอนนี้ไม่ได้: {kinds} ลองอีกครั้งในอีกสักครู่',
|
||||
'tours.poi.capped': 'ทัวร์ยาว ค้นหาได้เฉพาะช่วงแรก',
|
||||
'tours.poi.truncated': 'มีสถานที่มากกว่าที่แสดง ลดระยะหรือประเภทลง',
|
||||
'tours.poi.empty': 'ไม่พบสิ่งใดภายใน {distance} จากเส้นทาง',
|
||||
|
||||
@@ -20,7 +20,7 @@ const tokens = (value: string) => [...value.matchAll(/\{[^{}]+\}/g)].map((match)
|
||||
// placeholder spec; here only the general keys are.
|
||||
const groups = pluralGroups(Object.keys(english));
|
||||
const generalKeys = (keys: string[]) => keys.filter((key) => !pluralFormOf(key, groups));
|
||||
const GENERAL_KEY_COUNT = 766;
|
||||
const GENERAL_KEY_COUNT = 767;
|
||||
|
||||
function literalKeys(source: string): string[] {
|
||||
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
|
||||
|
||||
@@ -319,6 +319,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Arama olmadı. Birazdan tekrar dene.',
|
||||
'tours.poi.outdated': 'Rotanın önceki hâli için bulundu. Şimdikine uyması için yeniden ara.',
|
||||
'tours.poi.partial': 'Bazı kısımlarda arama yapılamadı; orada yerler eksik olabilir.',
|
||||
'tours.poi.unavailable': 'Şu anda aranamadı: {kinds}. Birazdan tekrar dene.',
|
||||
'tours.poi.capped': 'Tur uzun: yalnızca ilk kısmında arama yapıldı.',
|
||||
'tours.poi.truncated': 'Gösterilenden fazla yer var. Mesafeyi veya türleri daralt.',
|
||||
'tours.poi.empty': 'Rotaya {distance} mesafe içinde bir şey bulunamadı.',
|
||||
|
||||
@@ -332,6 +332,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Пошук не вдався. Спробуйте ще раз трохи згодом.',
|
||||
'tours.poi.outdated': 'Знайдено для попереднього варіанта маршруту. Щоб урахувати поточний, шукайте знову.',
|
||||
'tours.poi.partial': 'Деякі ділянки не вдалося перевірити; там можуть бракувати місць.',
|
||||
'tours.poi.unavailable': 'Зараз не вдалося виконати пошук: {kinds}. Спробуйте ще раз трохи згодом.',
|
||||
'tours.poi.capped': 'Маршрут довгий: перевірено лише його першу частину.',
|
||||
'tours.poi.truncated': 'Місць більше, ніж показано. Зменште відстань або кількість видів.',
|
||||
'tours.poi.empty': 'У межах {distance} від маршруту нічого не знайдено.',
|
||||
|
||||
@@ -319,6 +319,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': 'Tìm kiếm không thành công. Hãy thử lại sau giây lát.',
|
||||
'tours.poi.outdated': 'Kết quả cho lộ trình trước đó. Hãy tìm lại để khớp với lộ trình hiện tại.',
|
||||
'tours.poi.partial': 'Một số đoạn không tìm được; có thể thiếu địa điểm ở đó.',
|
||||
'tours.poi.unavailable': 'Hiện không thể tìm: {kinds}. Hãy thử lại sau giây lát.',
|
||||
'tours.poi.capped': 'Chuyến đi dài: chỉ phần đầu được tìm.',
|
||||
'tours.poi.truncated': 'Có nhiều địa điểm hơn số hiển thị. Hãy thu hẹp khoảng cách hoặc loại.',
|
||||
'tours.poi.empty': 'Không tìm thấy gì trong khoảng {distance} tới lộ trình.',
|
||||
|
||||
@@ -295,6 +295,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': '搜尋未成功。請稍後再試。',
|
||||
'tours.poi.outdated': '這是依先前的路線找到的。請重新搜尋以符合目前路線。',
|
||||
'tours.poi.partial': '有些路段無法搜尋,那裡可能缺少地點。',
|
||||
'tours.poi.unavailable': '暫時無法搜尋:{kinds}。請稍後再試。',
|
||||
'tours.poi.capped': '路線較長:只搜尋了前一段。',
|
||||
'tours.poi.truncated': '地點比顯示的更多。請縮小範圍或類型。',
|
||||
'tours.poi.empty': '距路線 {distance} 內沒有找到任何地點。',
|
||||
|
||||
@@ -296,6 +296,7 @@ const tours: TranslationStrings = {
|
||||
'tours.poi.failed': '搜索未成功。请稍后再试。',
|
||||
'tours.poi.outdated': '这是按之前的路线找到的。请重新搜索以匹配当前路线。',
|
||||
'tours.poi.partial': '有些路段无法搜索,那里可能缺少地点。',
|
||||
'tours.poi.unavailable': '暂时无法搜索:{kinds}。请稍后再试。',
|
||||
'tours.poi.capped': '路线较长:只搜索了前一段。',
|
||||
'tours.poi.truncated': '地点比显示的更多。请缩小范围或类型。',
|
||||
'tours.poi.empty': '距路线 {distance} 内没有找到任何地点。',
|
||||
|
||||
@@ -63,8 +63,18 @@ describe('tour POI search contracts (PLN-02)', () => {
|
||||
truncated: false,
|
||||
partial: true,
|
||||
capped: false,
|
||||
unavailable: [],
|
||||
});
|
||||
expect(answer.partial).toBe(true);
|
||||
expect(answer.pois[0]?.distance_m).toBe(40);
|
||||
});
|
||||
|
||||
it('names the kinds that could not be searched, each a known category, and always says so', () => {
|
||||
const answer = { pois: [], truncated: false, partial: false, capped: false };
|
||||
expect(tourPoiSearchResponseSchema.parse({ ...answer, unavailable: ['viewpoint'] }).unavailable).toEqual([
|
||||
'viewpoint',
|
||||
]);
|
||||
expect(tourPoiSearchResponseSchema.safeParse({ ...answer, unavailable: ['fuel'] }).success).toBe(false);
|
||||
expect(tourPoiSearchResponseSchema.safeParse(answer).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,7 +55,7 @@ export const WALK_THEME_KEYS = Object.keys(WALK_THEMES) as WalkTheme[];
|
||||
|
||||
/** How far from the line a place may lie, the choices offered, in metres. */
|
||||
export const TOUR_POI_CORRIDORS_M = [250, 500, 1000, 2000] as const;
|
||||
/** The most places one answer carries, nearest the start first. */
|
||||
/** The most places one answer carries: the nearest to the line, listed from the start. */
|
||||
export const TOUR_POI_LIMIT = 60;
|
||||
/** The most points the line sent may have; a longer line is thinned first. */
|
||||
export const TOUR_POI_MAX_LINE_POINTS = 1000;
|
||||
@@ -108,5 +108,7 @@ export const tourPoiSearchResponseSchema = z.object({
|
||||
partial: z.boolean(),
|
||||
/** The line was longer than one search covers; only its first part was searched. */
|
||||
capped: z.boolean(),
|
||||
/** Kinds no source could be asked for right now; their places are missing, not absent. */
|
||||
unavailable: z.array(tourPoiCategorySchema),
|
||||
});
|
||||
export type TourPoiSearchResponse = z.infer<typeof tourPoiSearchResponseSchema>;
|
||||
|
||||
@@ -497,7 +497,7 @@ next key rotation or admin reset would quietly switch the file back to WAL.
|
||||
| `IDEMPOTENCY_TTL_SECONDS` | How long (in seconds) stored idempotency keys are kept before garbage collection. The offline client replays queued mutations with their `X-Idempotency-Key` on reconnect, so this must exceed the longest expected offline window or a replay could create a duplicate. Invalid values abort startup. | `2592000` (30 days) |
|
||||
| `NOMINATIM_URL` | Custom HTTP(S) Nominatim base URL for every geocoding call the server makes: place search, reverse lookup, place details and the Atlas region fill. When set it **replaces** the public OpenStreetMap service for all of them, with no fallback, so point it at an instance whose import covers the places your users plan and that speaks the Nominatim API (`/search`, `/reverse`, `/lookup`). Photon, Geoapify and LocationIQ are not drop-in replacements. TREK keeps its client-side spacing of roughly one request a second whichever instance answers, so this changes where the requests go, not how fast they are sent. Unset or blank uses the default. With the TREK Places API on, suggestions while typing come from the TREK API's own OpenStreetMap layer and reach Nominatim only when the TREK API has nothing. | `https://nominatim.openstreetmap.org` |
|
||||
| `OVERPASS_URL` | Custom [Overpass API](https://wiki.openstreetmap.org/wiki/Overpass_API) endpoint(s), comma-separated, for the map's category buttons, the Road trip search along the drive and the `search_pois` MCP tool whenever the TREK Places API has no answer or is switched off. When set it **replaces** the bundled public mirrors: point it at an internal or self-hosted Overpass instance when the public mirrors are unreachable from your network (e.g. firewalled/locked-down egress in a Kubernetes cluster). Entries that aren't valid `http(s)` URLs are ignored. If you don't run your own Overpass but the public mirrors throttle TREK, first make sure `APP_URL` (or `ALLOWED_ORIGINS`) is set: that alone gives outbound Overpass/Nominatim requests a unique User-Agent, which the public mirrors rate-limit far less. One request is not covered: the tag lookup behind an OpenStreetMap place's details (website, phone, Wikidata) still goes to `overpass-api.de` whatever this is set to, and where that host is unreachable those details fall back to the tags Nominatim returns. | bundled public mirrors |
|
||||
| `OVERPASS_TIMEOUT_MS` | Per-endpoint timeout (in milliseconds) for Overpass POI requests. Endpoints race in parallel and one that hasn't answered within this window is abandoned so a faster mirror can win. The query itself gives Overpass 20 seconds of work, so a lower value hangs up on answers that were still coming; that is why the default rose from `12000` in 4.3.0. Drop any lower value you set by hand, and raise it only for a slow self-hosted Overpass instance. Invalid values abort startup. | `25000` |
|
||||
| `OVERPASS_TIMEOUT_MS` | Per-endpoint timeout (in milliseconds) for Overpass POI requests. Endpoints race in parallel and one that hasn't answered within this window is abandoned so a faster mirror can win. The query itself gives Overpass 20 seconds of work, so a lower value hangs up on answers that were still coming; that is why the default rose from `12000` in 4.3.0. Drop any lower value you set by hand, and raise it only for a slow self-hosted Overpass instance. The Tours search along a tour does not read it: it gives Overpass 10 seconds of work and waits 12, because someone is waiting for the list. Invalid values abort startup. | `25000` |
|
||||
| `LLM_TIMEOUT_MS` | How long (in milliseconds) one AI-parsing call may take before it is abandoned. One ceiling for every provider, applied to the abort signal and to the underlying HTTP client alike. The default is generous so heavier parsing work fits without a code change; lower it if you use a cloud provider and would rather fail fast. Invalid values abort startup. | `900000` (15 min) |
|
||||
| `LLM_MAX_OUTPUT_TOKENS` | The longest answer (in tokens) one AI-parsing call may ask the model for, sent to every provider. A document with many bookings needs a long answer; when the model hits the cap the import says so and names this variable. Raise it for such documents, but keep prompt plus cap inside the context your model has, or the server refuses the request. Unset keeps each provider's own cap. A whole number from 256 to 1000000, anything else aborts startup. | `4096` (OpenAI-compatible), `8192` (Anthropic) |
|
||||
|
||||
|
||||
@@ -241,7 +241,7 @@ Read only: the saved tours of a trip, as the Tours list and the tour detail show
|
||||
| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The full track geometry is not included | `trips:read` |
|
||||
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
|
||||
| `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` |
|
||||
| `search_tour_pois` | Places along the saved line of a tour within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental): source, kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line, nearest the start first, up to 60. Says when stretches could not be searched or only the first part of a long line was. Nothing is added | `trips:read` |
|
||||
| `search_tour_pois` | Places along the saved line of a tour within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental, bike repair, and for a city walk museums and galleries, parks and gardens, coffee shops, places to eat and markets): source (TREK places index or OpenStreetMap), kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line; up to 60, the nearest to the line when more match, listed from the start. A place OpenStreetMap has no name for comes with an empty name. `unavailable` names the categories no source could be searched for right now, so their places are missing rather than absent. Says when stretches could not be searched, only the first part of a long line was, or more places matched. Nothing is added | `trips:read` |
|
||||
| `list_tour_library` | The saved tours of every trip the user can open, to choose one to take over into another trip: name, type, distance, ascent, descent, planned times and the trip each is in. Narrowed by a part of the name, a type or a trip; the trip being planned can be left out. Up to 100 tours, `truncated` says there were more | `trips:read` |
|
||||
| `take_over_tour` | Copies a saved tour of another trip into a trip as an independent tour: its line, measures, settings and control points, without its days, its links to places of the other trip or the obstacle decisions taken there. The copy remembers which trip and tour it came from; the source trip is not changed. Needs `place_edit` on the trip it writes into | `trips:write` |
|
||||
| `plan_paddle_route` | Plans the legs of a paddle tour on the water network TREK builds from the paddle regions an admin added: each leg (`from`, `to`) along rivers and canals and across lakes, carried round weirs, dams and waterfalls, through or round locks (`lock_policy`), with `boat` and `lake_style` (`direct` or `shore`). Answers per leg its length, how far its ends were moved onto the water and its stretches (water, lake, whitewater, boat pass, portage with its source, lock, gap); a leg that cannot be planned says why (`not_on_water`, `no_region`, `cross_region`, `no_water_path`, `busy`, `timeout`). Nothing is saved; a planning aid, never a safety or legal clearance | `trips:read` |
|
||||
|
||||
Reference in New Issue
Block a user