feat(tours): mark the best known places along a city walk

A search for sights along a tour now also asks the places index for the best
known places near the line, ranked by how often their Wikipedia articles are
read (QRank). A row the index already found is marked as a highlight; a
famous sight no kind names, like a square, is added as a sight of its own.
Stations, hotels and restaurants rank a row they match but are never added.

Highlights are listed first with a star in the list and a badge in the map
card. A place counts as a highlight from 40000 views a year, or the best
three from 5000 where fewer reach that. An index without ranks, or one older
than the endpoint, leaves the search as it was.
This commit is contained in:
Maurice
2026-10-20 12:16:11 +02:00
parent 54baf85a2a
commit 71fe69c1f1
45 changed files with 840 additions and 26 deletions
@@ -1,6 +1,6 @@
import type { TourPoi, WalkTheme } from '@trek/shared';
import { WALK_THEMES } from '@trek/shared';
import { Clock, ExternalLink, MoveHorizontal, Plus, Route, X } from 'lucide-react';
import { Clock, ExternalLink, MoveHorizontal, Plus, Route, Star, X } from 'lucide-react';
import { useCallback, useRef } from 'react';
import { useTranslation } from '../../../i18n';
import { useSettingsStore } from '../../../store/settingsStore';
@@ -9,7 +9,7 @@ import { formatDistance } from '../../../utils/units';
import { MAP_CARD_GLASS } from '../../Map/mapControlShadow';
import { TOUR_POI_LOOKS } from '../../Map/poiCategories';
import { useMapCardDismiss } from '../../Map/useMapCardDismiss';
import { WhiteBadge } from '../../Planner/planParts';
import { SoftPill, WhiteBadge } from '../../Planner/planParts';
import { PillSelect } from '../../shared/dialogParts';
import { fs } from '../../shared/DialogShell';
import type { TourPlannerController } from '../planner/useTourPlanner';
@@ -157,7 +157,22 @@ function TourPoiCard({
<h3 className="line-clamp-2 break-words font-semibold" style={fs(14, 'body')} title={poi.name || kind}>
{poi.name || kind}
</h3>
{poi.name && <p className="truncate text-caption text-content-muted">{kind}</p>}
{(poi.name || poi.highlight) && (
<div className="flex min-w-0 items-center gap-1.5">
{poi.name && <p className="min-w-0 truncate text-caption text-content-muted">{kind}</p>}
{/* One of the best known places along the route, as the star in the list marks it. */}
{poi.highlight && (
<span className="flex-none" title={t('tours.poi.highlightHint')} data-testid="tour-poi-highlight-badge">
<SoftPill
tone="warning"
icon={<Star size={10} strokeWidth={2.2} fill="currentColor" aria-hidden="true" />}
>
{t('tours.poi.highlight')}
</SoftPill>
</span>
)}
</div>
)}
</div>
<button
type="button"
@@ -10,6 +10,7 @@ import {
Route,
Search,
Shuffle,
Star,
Trees,
WifiOff,
X,
@@ -219,6 +220,18 @@ function PoiRow({ poi, selected, unit }: { poi: TourPoi; selected: boolean; unit
>
<look.Icon size={12} strokeWidth={2.2} />
</span>
{/* One of the best known places along the route: a star before its name. */}
{poi.highlight && (
<span
role="img"
aria-label={t('tours.poi.highlight')}
title={t('tours.poi.highlightHint')}
className="flex-none text-warning"
data-testid="tour-poi-highlight"
>
<Star size={12} strokeWidth={2.2} fill="currentColor" aria-hidden="true" />
</span>
)}
<span className="min-w-0 flex-1 truncate font-medium text-content" style={fs(12, 'body')}>
{poi.name || t(look.labelKey)}
</span>
@@ -14,7 +14,7 @@ import { usePoiInsertion, type PoiInsertionPlanner } from './usePoiInsertion';
import { useTourPoiMarkers } from './useTourPoiMarkers';
import { useTourPoiSearch } from './useTourPoiSearch';
// FE-TOUR-POI-001 to FE-TOUR-POI-016
// FE-TOUR-POI-001 to FE-TOUR-POI-018
vi.mock('../planner/useWaypointPlaceNames', () => ({ useWaypointPlaceNames: () => ({}) }));
@@ -288,6 +288,46 @@ describe('TourPoiSearchCard', () => {
});
});
describe('highlights along a tour', () => {
const SQUARE: TourPoi = {
...SPRING,
id: 'way:1',
name: 'Piazza San Marco',
category: 'sights',
kind: 'place=square',
opening_hours: null,
website: null,
highlight: true,
};
it('FE-TOUR-POI-017: a highlight carries a star before its name in the list, a place that is none carries none', async () => {
search.mockResolvedValue({ ...ANSWER, pois: [SQUARE, SPRING] });
render(<TourPoiSearchCard planner={searchPlanner()} />);
fireEvent.click(screen.getByTestId('tour-poi-search'));
const square = await screen.findByTestId('tour-poi-way:1');
const star = within(square).getByTestId('tour-poi-highlight');
expect(star).toHaveAttribute('aria-label', 'Highlight');
expect(star).toHaveAttribute('title', 'One of the best-known places here');
expect(within(square).getByRole('button', { name: /Highlight.*Piazza San Marco/ })).toBeInTheDocument();
expect(within(screen.getByTestId('tour-poi-node:1')).queryByTestId('tour-poi-highlight')).not.toBeInTheDocument();
});
it('FE-TOUR-POI-018: the card over the map says Highlight beside the kind, and only for a highlight', async () => {
search.mockResolvedValue({ ...ANSWER, pois: [SQUARE, SPRING] });
const planner = { ...searchPlanner(), ...insertionPlanner() } as unknown as TourPlannerController;
renderAlong(planner);
fireEvent.click(screen.getByTestId('tour-poi-search'));
fireEvent.click(within(await screen.findByTestId('tour-poi-way:1')).getByRole('button', { name: /Piazza/ }));
const badge = within(screen.getByTestId('tour-poi-overlay')).getByTestId('tour-poi-highlight-badge');
expect(badge).toHaveTextContent('Highlight');
expect(badge).toHaveAttribute('title', 'One of the best-known places here');
expect(screen.getByTestId('tour-poi-overlay')).toHaveTextContent('Sights');
fireEvent.click(within(screen.getByTestId('tour-poi-node:1')).getByRole('button', { name: /Spring/ }));
expect(screen.queryByTestId('tour-poi-highlight-badge')).not.toBeInTheDocument();
});
});
describe('a city walk along its places (CW-05)', () => {
const CAFE: TourPoi = {
...SPRING,
+10
View File
@@ -30,6 +30,7 @@ import {
trekPlacesArea,
trekPlacesNearby,
toPlaceRecord,
type TrekHighlight,
type TrekPlace,
} from './trek-places.client';
import { InjectRepository } from '@mikro-orm/nestjs';
@@ -252,6 +253,15 @@ export class MapsService {
return this.along.search({ categories, line, corridorM, lang, limit }, this.trekPlacesEnabled());
}
/**
* The best known places near a line, from the index's OpenStreetMap layer in
* the same circles as {@link poisAlong}. None when the index is switched off
* or cannot be asked: highlights add to a search, they never fail it.
*/
highlightsAlong(line: { lat: number; lng: number }[], corridorM: number, lang?: string): Promise<TrekHighlight[]> {
return this.along.highlights(line, corridorM, lang, this.trekPlacesEnabled());
}
// ── Keys and the keyed provider (resolved by PlacesProviderSelector) ─────
/** The Places credential for this request and where it came from (#1939). */
+46 -1
View File
@@ -28,13 +28,16 @@ import {
} from './maps-index.helpers';
import type { OverpassPoi } from './maps.helpers';
import { OsmClient, OverpassCoolingDown } from './providers/osm.client';
import { trekPlacesNearby } from './trek-places.client';
import { trekPlacesHighlights, trekPlacesNearby, type TrekHighlight, type TrekHighlights } 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;
/** The most well known places one circle is asked for: the best known come first, so a few are plenty. */
const HIGHLIGHTS_PER_CIRCLE = 20;
/** How many stretches one source asks Overpass for at once, so the public mirrors are not flooded. */
const PARALLEL_OVERPASS_STRETCHES = 3;
@@ -148,6 +151,48 @@ export class PoisAlongResolver {
};
}
/**
* The best known places near a line, from the index's OpenStreetMap layer in
* the same circles the kinds are asked in, each place once. Highlights only
* add to a search: an index switched off, down or without ranks answers
* none, logged once, and never fails the search they were asked beside.
*/
async highlights(
line: LatLng[],
corridorM: number,
lang: string | undefined,
indexEnabled: boolean,
): Promise<TrekHighlight[]> {
if (!indexEnabled) return [];
const plan = alongPlan(line, corridorM);
const perCircle = { limit: HIGHLIGHTS_PER_CIRCLE, lang };
const batches = Array.from({ length: Math.ceil(plan.beads.length / PARALLEL_INDEX_REQUESTS) }, (_, index) =>
plan.beads.slice(index * PARALLEL_INDEX_REQUESTS, (index + 1) * PARALLEL_INDEX_REQUESTS),
);
// A few circles at a time, each batch after the last, as the kinds are asked.
const settled = await batches.reduce<Promise<PromiseSettledResult<TrekHighlights>[]>>(
async (done, batch) => [
...(await done),
...(await Promise.allSettled(
batch.map((bead) => trekPlacesHighlights(bead.lat, bead.lng, { ...perCircle, radius: bead.radiusM })),
)),
],
Promise.resolve([]),
);
const found = new Map<string, TrekHighlight>();
const failure = settled.find((entry): entry is PromiseRejectedResult => entry.status === 'rejected');
for (const entry of settled) {
if (entry.status === 'fulfilled') {
for (const row of entry.value.highlights) {
const key = `${row.osmType}:${row.osmId}`;
if (!found.has(key)) found.set(key, row);
}
}
}
if (failure) logWarn(`[Maps] TREK places highlights along a line: ${reason(failure.reason)}`);
return [...found.values()];
}
/** 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.
@@ -15,6 +15,8 @@ import { readEnv, getAppUrl } from '../../app-config';
import { toApiLang } from './maps.helpers';
import { normalizePlaceWebsite } from '@trek/shared';
import { z } from 'zod';
/** The public instance. An operator may point at their own copy instead. */
export const DEFAULT_TREK_PLACES_URL = 'https://places.liketrek.com';
@@ -452,6 +454,93 @@ export async function trekPlacesNearby(
return Array.isArray(body.results) ? body.results : [];
}
/**
* The bounds of one /v1/highlights request, as the service takes them: a
* request outside them is refused with a 422, so one is clamped to fit before
* it leaves, and a coordinate that is no coordinate is not asked at all.
*/
const HIGHLIGHTS_MIN_RADIUS_M = 10;
const HIGHLIGHTS_MAX_RADIUS_M = 20_000;
const HIGHLIGHTS_DEFAULT_RADIUS_M = 1000;
const HIGHLIGHTS_MAX_LIMIT = 100;
const HIGHLIGHTS_DEFAULT_LIMIT = 30;
/**
* One well known place of the service's OpenStreetMap layer: a row with a
* Wikidata item that QRank ranks, `qrank` its page views. `name` is in the
* language asked where the layer has it, else the local one.
*/
const trekHighlightSchema = z.object({
osmType: z.enum(['node', 'way', 'relation']),
osmId: z.number().int().positive(),
name: z.string().trim().min(1),
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
category: z.string().nullable(),
osmTag: z.string(),
wikidata: z.string().regex(/^Q\d+$/),
qrank: z.number().int().nonnegative(),
distanceMetres: z.number().nonnegative(),
});
export type TrekHighlight = z.infer<typeof trekHighlightSchema>;
export interface TrekHighlights {
highlights: TrekHighlight[];
/** The QRank release the ranks are from, or null when the service does not say. */
qrankRelease: string | null;
}
/** A whole number within the bounds; anything that is no number takes the lower one. */
const clampedInt = (value: number, min: number, max: number) =>
Number.isFinite(value) ? Math.min(max, Math.max(min, Math.round(value))) : min;
/**
* The service's answer as rows TREK can trust: a row of another shape is
* dropped on its own rather than failing the others, since the layer is read
* beside the rest of a search and a broken row should cost that row only.
*/
function readHighlights(body: unknown): TrekHighlights {
const answer = z
.object({ highlights: z.array(z.unknown()).catch([]), qrankRelease: z.string().nullable().catch(null) })
.catch({ highlights: [], qrankRelease: null })
.parse(body);
const highlights = answer.highlights.flatMap((row) => {
const parsed = trekHighlightSchema.safeParse(row);
return parsed.success ? [parsed.data] : [];
});
return { highlights, qrankRelease: answer.qrankRelease };
}
/**
* The best known places around a coordinate, by QRank and then nearest. An
* older layer without ranks answers an empty list, never an error; a service
* that is down throws like every other call here, and the caller goes on
* without highlights.
*/
export async function trekPlacesHighlights(
lat: number,
lng: number,
opts: { radius?: number; limit?: number; lang?: string } = {},
): Promise<TrekHighlights> {
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) {
return { highlights: [], qrankRelease: null };
}
try {
const body = await getJson<unknown>('/v1/highlights', {
lat,
lng,
radius: clampedInt(opts.radius ?? HIGHLIGHTS_DEFAULT_RADIUS_M, HIGHLIGHTS_MIN_RADIUS_M, HIGHLIGHTS_MAX_RADIUS_M),
limit: clampedInt(opts.limit ?? HIGHLIGHTS_DEFAULT_LIMIT, 1, HIGHLIGHTS_MAX_LIMIT),
lang: serviceLang(opts.lang),
});
return readHighlights(body);
} catch (err) {
// A service older than the endpoint knows no highlights, as an old layer knows no ranks.
if ((err as { status?: number }).status === 404) return { highlights: [], qrankRelease: null };
throw err;
}
}
export interface TrekPlacesArea {
count: number;
truncated: boolean;
+158 -16
View File
@@ -11,6 +11,7 @@ import {
plannableTourTypeSchema,
TOUR_POI_LIMIT,
TOUR_POI_MAX_LINE_POINTS,
tourHighlightIds,
type TourPoi,
type TourPoiCategory,
type TourPoiSearchRequest,
@@ -46,13 +47,144 @@ const PER_KIND_LIMIT = 30;
/** Within this many metres, two places of one name are one place mapped twice. */
const SAME_PLACE_M = 60;
type MapsPoi = Awaited<ReturnType<MapsService['poisAlong']>>['pois'][number];
/**
* Within this many metres, a highlight of the very same name is the place a
* row already has: a square's outline and the point on it lie further apart
* than two points of one place do.
*/
const SAME_NAME_M = 500;
function withinSamePlace(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: number }): boolean {
type MapsPoi = Awaited<ReturnType<MapsService['poisAlong']>>['pois'][number];
type MapsHighlight = Awaited<ReturnType<MapsService['highlightsAlong']>>[number];
function metresApart(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: number }): number {
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);
return Math.hypot(dLat, dLng) <= SAME_PLACE_M;
return Math.hypot(dLat, dLng);
}
function withinSamePlace(a: Pick<TourPoi, 'lat' | 'lng'>, b: { lat: number; lng: number }): boolean {
return metresApart(a, b) <= SAME_PLACE_M;
}
/** A name as compared: without case, accents or punctuation. */
function nameKey(name: string): string {
return name
.normalize('NFD')
.replace(/\p{M}/gu, '')
.toLocaleLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.trim();
}
/**
* Every word of one name is in the other, as "Basilica San Marco" is in
* "Basilica di San Marco", or they share at least two words and half of each.
* Whole words only: "Dom" is not "Domplatz", and one shared "Palazzo" does not
* make "Palazzo Ducale" the "Palazzo Reale".
*/
function similarNames(a: string, b: string): boolean {
if (!a || !b) return false;
const words = new Set(a.split(' '));
const others = new Set(b.split(' '));
const shared = [...others].filter((word) => words.has(word)).length;
if (shared === Math.min(words.size, others.size)) return true;
return shared >= 2 && shared * 2 >= Math.max(words.size, others.size);
}
/**
* The row a highlight is already in: the same OpenStreetMap object, the same
* name nearby, or a like name in one spot.
*/
function rowOfHighlight(places: readonly TourPoi[], highlight: MapsHighlight, id: string): TourPoi | undefined {
const name = nameKey(highlight.name);
return places.find((place) => {
if (place.id === id) return true;
const other = nameKey(place.name);
const apart = metresApart(place, highlight);
return (!!name && other === name && apart <= SAME_NAME_M) || (apart <= SAME_PLACE_M && similarNames(name, other));
});
}
/**
* The kinds of the index a highlight may come in as a sight of its own. A
* station, a hotel or a restaurant can be famous too, but it is no sight: it
* still ranks a row it matches, and is never added.
*/
const SIGHT_INDEX_CATEGORIES: ReadonlySet<string> = new Set(['sehenswuerdigkeit', 'kultur']);
/**
* The places found with the best known places near the line merged in: a
* highlight a row already has ranks that row, and a sight no row has is added
* when the rule marks it (Piazza San Marco is a square, which no kind asks for).
* A ranked place the rule does not mark is not worth a row of its own. The
* ranks are kept beside the rows, by id, for the highlight rule.
*/
function withHighlights(
places: readonly TourPoi[],
highlights: readonly MapsHighlight[],
corridorM: number,
line: Array<[number, number]>,
): { places: TourPoi[]; ranks: Map<string, number> } {
const merged = [...places];
const candidates: TourPoi[] = [];
const ranks = new Map<string, number>();
for (const highlight of [...highlights].sort((a, b) => b.qrank - a.qrank)) {
const near = nearnessToLine({ lat: highlight.lat, lng: highlight.lng }, line);
if (!near || near.distanceM > corridorM) continue;
const id = `${highlight.osmType}:${highlight.osmId}`;
const row = rowOfHighlight([...merged, ...candidates], highlight, id);
if (row) {
ranks.set(row.id, Math.max(ranks.get(row.id) ?? 0, highlight.qrank));
continue;
}
if (!highlight.category || !SIGHT_INDEX_CATEGORIES.has(highlight.category)) continue;
candidates.push({
id,
name: highlight.name,
lat: highlight.lat,
lng: highlight.lng,
category: 'sights',
kind: highlight.osmTag,
source: 'openstreetmap',
address: null,
website: null,
phone: null,
opening_hours: null,
distance_m: Math.round(near.distanceM),
along_m: Math.round(near.alongM),
});
ranks.set(id, highlight.qrank);
}
const marked = new Set(tourHighlightIds([...ranks].map(([id, qrank]) => ({ id, qrank }))));
for (const candidate of candidates) {
if (marked.has(candidate.id)) merged.push(candidate);
else ranks.delete(candidate.id);
}
return { places: merged, ranks };
}
/**
* The answer's rows: the highlights first, best known first, then the nearest
* of the rest to the line, in the order the tour meets them. A highlight is
* never cut for a nearer place.
*/
function answerRows(places: readonly TourPoi[], ranks: ReadonlyMap<string, number>): TourPoi[] {
const marked = new Set(tourHighlightIds([...ranks].map(([id, qrank]) => ({ id, qrank }))));
const rank = (place: TourPoi) => ranks.get(place.id) ?? 0;
const highlights = places
.filter((place) => marked.has(place.id))
.sort((a, b) => rank(b) - rank(a))
.slice(0, TOUR_POI_LIMIT)
.map((place) => ({ ...place, highlight: true }));
// The nearest to the line when there are too many, then in the order the tour meets them.
const rest = places
.filter((place) => !marked.has(place.id))
.sort((a, b) => a.distance_m - b.distance_m)
.slice(0, TOUR_POI_LIMIT - highlights.length)
.sort((a, b) => a.along_m - b.along_m || a.distance_m - b.distance_m);
return [...highlights, ...rest];
}
function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[]): TourPoiCategory | undefined {
@@ -104,7 +236,8 @@ function placesWithin(found: MapsPoi[], body: TourPoiSearchRequest, line: Array<
* 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.
* never a way someone walked or paddled. A search for sights also asks for the
* best known places near the line and lists its highlights first.
*/
@Injectable()
export class TourPoiSearchService {
@@ -117,20 +250,17 @@ export class TourPoiSearchService {
async search(body: TourPoiSearchRequest): Promise<TourPoiSearchResponse> {
// 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 points = line.map(([lat, lng]) => ({ lat, lng }));
const asked = [...new Set(body.categories.flatMap((category) => MAPS_CATEGORIES[category]))];
const along = await this.maps.poisAlong(
asked,
line.map(([lat, lng]) => ({ lat, lng })),
body.corridor_m,
body.lang,
PER_KIND_LIMIT,
);
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);
// The best known places are what a search for sights is for, on any tour.
const [along, highlights] = await Promise.all([
this.maps.poisAlong(asked, points, body.corridor_m, body.lang, PER_KIND_LIMIT),
body.categories.includes('sights') ? this.highlightsAlong(points, body) : [],
]);
const found = withHighlights(placesWithin(along.pois, body, line), highlights, body.corridor_m, line);
return {
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,
pois: answerRows(found.places, found.ranks),
truncated: along.truncated || found.places.length > TOUR_POI_LIMIT,
partial: along.partial,
capped: along.capped,
unavailable: body.categories.filter((category) =>
@@ -139,6 +269,18 @@ export class TourPoiSearchService {
};
}
/** The best known places near the line, or none when they cannot be had: they add to a search, never fail it. */
private async highlightsAlong(
points: Array<{ lat: number; lng: number }>,
body: TourPoiSearchRequest,
): Promise<MapsHighlight[]> {
try {
return await this.maps.highlightsAlong(points, body.corridor_m, body.lang);
} catch {
return [];
}
}
/** The same search along a saved tour's line, for a client that has no draft (MCP). */
async searchSavedTour(
tripId: number,
+1 -1
View File
@@ -190,7 +190,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, 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. A tour saved with its points only (route_pending) has no line to search along yet. 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. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too: a square or landmark no category names is added as a sight, and the highlights among them carry highlight: true and come first, the best known first. 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. A tour saved with its points only (route_pending) has no line to search along yet. 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'),
+59 -1
View File
@@ -20,8 +20,15 @@ import { buildMapsParts } from '../../helpers/maps-service';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const { mockNearby, logWarn } = vi.hoisted(() => ({
const { mockNearby, mockHighlights, logWarn } = vi.hoisted(() => ({
logWarn: vi.fn(),
mockHighlights: vi.fn(
async (
_lat: number,
_lng: number,
_opts?: { radius?: number; limit?: number; lang?: string },
): Promise<{ highlights: unknown[]; qrankRelease: string | null }> => ({ highlights: [], qrankRelease: null }),
),
mockNearby: vi.fn(
async (
_lat: number,
@@ -38,6 +45,7 @@ vi.mock('../../../src/nest/audit/audit-log.logger', async (importOriginal) => ({
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
trekPlacesNearby: mockNearby,
trekPlacesHighlights: mockHighlights,
}));
const noAppSettings = { getValue: async () => null } as unknown as AppSettingsRepository;
@@ -141,6 +149,8 @@ function inFlight(overpass: ReturnType<typeof make>['overpass']) {
beforeEach(() => {
mockNearby.mockReset();
mockNearby.mockResolvedValue([]);
mockHighlights.mockReset();
mockHighlights.mockResolvedValue({ highlights: [], qrankRelease: null });
logWarn.mockClear();
});
@@ -523,3 +533,51 @@ describe('MapsService.poisAlong', () => {
}
});
});
describe('MapsService.highlightsAlong', () => {
/** A well known place of the index's OpenStreetMap layer. */
const famous = (osmType: string, osmId: number, qrank: number) => ({
osmType,
osmId,
name: `${osmType} ${osmId}`,
lat: 47.01,
lng: 11,
category: 'sehenswuerdigkeit',
osmTag: 'place=square',
wikidata: 'Q1',
qrank,
distanceMetres: 10,
});
it('MAPS-ALONG-030: asks the best known places in the circles the kinds are asked in, each place once', async () => {
const { svc } = make();
mockHighlights
.mockResolvedValueOnce({ highlights: [famous('way', 1, 900), famous('node', 1, 50)], qrankRelease: null })
.mockResolvedValueOnce({ highlights: [famous('way', 1, 900), famous('relation', 2, 70)], qrankRelease: null });
const out = await svc.highlightsAlong(LINE, 500, 'de');
expect(mockHighlights).toHaveBeenCalledTimes(4);
for (const [, , opts] of mockHighlights.mock.calls) expect(opts).toEqual({ radius: 1000, limit: 20, lang: 'de' });
expect(out.map((row) => `${row.osmType}:${row.osmId}`)).toEqual(['way:1', 'node:1', 'relation:2']);
expect(mockNearby).not.toHaveBeenCalled();
});
it('MAPS-ALONG-031: a circle failing keeps the others, an index down or switched off answers none, never an error', async () => {
const { svc } = make();
mockHighlights
.mockRejectedValueOnce(new Error('TREK Places API 503'))
.mockResolvedValue({ highlights: [famous('way', 1, 900)], qrankRelease: null });
expect((await svc.highlightsAlong(LINE, 500)).map((row) => row.osmId)).toEqual([1]);
expect(mapsWarnings()).toEqual(['[Maps] TREK places highlights along a line: TREK Places API 503']);
mockHighlights.mockReset();
mockHighlights.mockRejectedValue(new Error('timeout'));
await expect(svc.highlightsAlong(LINE, 500)).resolves.toEqual([]);
mockHighlights.mockClear();
const off = make(false);
await expect(off.svc.highlightsAlong(LINE, 500)).resolves.toEqual([]);
expect(mockHighlights).not.toHaveBeenCalled();
});
});
@@ -21,6 +21,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
const db = createSnapshotTestDb();
const poisAlong = vi.fn();
const highlightsAlong = vi.fn();
let t: TestModule;
let service: TourPoiSearchService;
@@ -28,13 +29,15 @@ beforeAll(async () => {
t = await createTestModule({
db,
imports: [ToursModule],
overrides: [{ provide: MapsService, useValue: { poisAlong } }],
overrides: [{ provide: MapsService, useValue: { poisAlong, highlightsAlong } }],
});
service = t.get(TourPoiSearchService);
});
beforeEach(() => {
resetTestDb(db);
poisAlong.mockReset();
highlightsAlong.mockReset();
highlightsAlong.mockResolvedValue([]);
});
afterAll(async () => {
await t.close();
@@ -68,6 +71,30 @@ function poi(id: string, lat: number, lng: number, category = 'viewpoint', over:
};
}
/** A well known place of the index's OpenStreetMap layer, as MapsService.highlightsAlong answers it. */
function famous(
osmType: string,
osmId: number,
name: string,
lat: number,
lng: number,
qrank: number,
category: string | null = 'sehenswuerdigkeit',
) {
return {
osmType,
osmId,
name,
lat,
lng,
category,
osmTag: 'place=square',
wikidata: `Q${osmId}`,
qrank,
distanceMetres: 0,
};
}
const answer = (list: unknown[], over: Record<string, unknown> = {}) => ({
pois: list,
failed: [],
@@ -232,6 +259,144 @@ describe('TourPoiSearchService', () => {
]);
});
it('TOUR-POI-011: a search for sights asks the best known places along the line and adds a square no kind names', async () => {
poisAlong.mockResolvedValue(answer([poi('node:gate', 47.405, 11.1003, 'sights', { name: 'Town gate' })]));
highlightsAlong.mockResolvedValue([famous('way', 1, 'Piazza San Marco', 47.41, 11.1004, 900_000)]);
const result = await service.search({
tour_type: 'walk',
categories: ['sights', 'cafe'],
corridor_m: 500,
line: LINE,
lang: 'it',
});
expect(highlightsAlong).toHaveBeenCalledWith(
[
{ lat: 47.4, lng: 11.1 },
{ lat: 47.42, lng: 11.1 },
],
500,
'it',
);
expect(result.pois.map((found) => [found.id, found.highlight])).toEqual([
['way:1', true],
['node:gate', undefined],
]);
expect(result.pois[0]).toMatchObject({
name: 'Piazza San Marco',
category: 'sights',
kind: 'place=square',
source: 'openstreetmap',
address: null,
opening_hours: null,
});
expect(result.pois[0]?.distance_m).toBeGreaterThan(20);
expect(result.pois[0]?.distance_m).toBeLessThan(40);
expect(result.pois[0]?.along_m).toBeGreaterThan(1050);
expect(result.pois[0]?.along_m).toBeLessThan(1180);
// Without sights nothing of the kind is asked, on a walk or a hike.
highlightsAlong.mockClear();
await service.search({ tour_type: 'walk', categories: ['cafe'], corridor_m: 500, line: LINE });
await service.search({ tour_type: 'hike', categories: ['viewpoint'], corridor_m: 500, line: LINE });
expect(highlightsAlong).not.toHaveBeenCalled();
});
it('TOUR-POI-012: a highlight a row already has marks that row, by object, by name or by a like name in one spot, once', async () => {
poisAlong.mockResolvedValue(
answer([
poi('node:basilica', 47.405, 11.1003, 'sights', { name: 'Basilica di San Marco' }),
poi('node:museo', 47.41, 11.1003, 'museum_gallery', { name: 'Museo Correr' }),
poi('way:7', 47.415, 11.1002, 'sights', { name: 'Campanile' }),
poi('node:cafe', 47.402, 11.1003, 'cafe', { name: 'Caffe Florian' }),
]),
);
highlightsAlong.mockResolvedValue([
famous('way', 7, 'Campanile di San Marco', 47.415, 11.1002, 45_000),
famous('node', 2, 'Piazza San Marco', 47.4102, 11.1004, 80_000),
famous('way', 1, 'Piazza San Marco', 47.4101, 11.1004, 900_000),
famous('node', 3, 'Basilica San Marco', 47.4052, 11.1003, 500_000),
famous('node', 4, 'MUSEO CORRER', 47.4127, 11.1003, 60_000),
// Far outside the corridor: not along this walk, however well known.
famous('way', 99, 'Rialto', 47.41, 11.2, 1_000_000),
]);
const result = await service.search({
tour_type: 'walk',
categories: ['sights', 'museum', 'cafe'],
corridor_m: 500,
line: LINE,
});
expect(result.pois.map((found) => [found.id, found.highlight ?? false])).toEqual([
['way:1', true],
['node:basilica', true],
['node:museo', true],
['way:7', true],
['node:cafe', false],
]);
// The rows a highlight marks keep what their own source said of them.
expect(result.pois[2]).toMatchObject({ name: 'Museo Correr', category: 'museum', kind: 'tourism=museum_gallery' });
});
it('TOUR-POI-013: with fewer than three famous places the best known three are highlights, a lesser one gets no row', async () => {
poisAlong.mockResolvedValue(answer([]));
highlightsAlong.mockResolvedValue([
famous('node', 1, 'Old bridge', 47.402, 11.1002, 4_000),
famous('node', 2, 'Town hall', 47.404, 11.1002, 6_000),
famous('node', 3, 'Cathedral', 47.406, 11.1002, 30_000),
famous('node', 4, 'Castle', 47.408, 11.1002, 8_000),
]);
const result = await service.search({ tour_type: 'hike', categories: ['sights'], corridor_m: 500, line: LINE });
expect(result.pois.map((found) => [found.name, found.highlight ?? false])).toEqual([
['Cathedral', true],
['Castle', true],
['Town hall', true],
]);
});
it('TOUR-POI-015: a famous station or hotel ranks a row it matches but is never added as a sight', async () => {
poisAlong.mockResolvedValue(answer([poi('node:cafe', 47.402, 11.1003, 'cafe', { name: 'Caffe Florian' })]));
highlightsAlong.mockResolvedValue([
famous('node', 1, 'Venezia Santa Lucia', 47.404, 11.1002, 300_000, 'verkehr'),
famous('node', 2, 'Hotel Danieli', 47.406, 11.1002, 90_000, 'uebernachtung'),
famous('node', 3, 'Mystery', 47.408, 11.1002, 90_000, null),
famous('node', 4, 'Caffe Florian', 47.402, 11.1003, 70_000, 'gastronomie'),
]);
const result = await service.search({
tour_type: 'walk',
categories: ['sights', 'cafe'],
corridor_m: 500,
line: LINE,
});
expect(result.pois.map((found) => [found.id, found.highlight ?? false])).toEqual([['node:cafe', true]]);
});
it('TOUR-POI-016: names are matched by whole words, so a cathedral does not swallow its square or a like-named palace', async () => {
poisAlong.mockResolvedValue(
answer([
poi('node:dom', 47.405, 11.1003, 'sights', { name: 'Dom' }),
poi('node:reale', 47.41, 11.1003, 'sights', { name: 'Palazzo Reale' }),
]),
);
highlightsAlong.mockResolvedValue([
famous('way', 1, 'Domplatz', 47.4052, 11.1003, 120_000),
famous('way', 2, 'Palazzo Ducale', 47.4101, 11.1003, 380_000),
]);
const result = await service.search({ tour_type: 'walk', categories: ['sights'], corridor_m: 500, line: LINE });
expect(result.pois.map((found) => [found.id, found.highlight ?? false])).toEqual([
['way:2', true],
['way:1', true],
['node:dom', false],
['node:reale', false],
]);
});
it('TOUR-POI-014: an index that cannot give highlights leaves the search as it was', async () => {
poisAlong.mockResolvedValue(answer([poi('node:gate', 47.405, 11.1003, 'sights', { name: 'Town gate' })]));
highlightsAlong.mockRejectedValue(new Error('TREK Places API 503'));
const result = await service.search({ tour_type: 'walk', categories: ['sights'], corridor_m: 500, line: LINE });
expect(result.pois.map((found) => [found.id, found.highlight])).toEqual([['node:gate', undefined]]);
expect(result).toMatchObject({ truncated: false, partial: false, unavailable: [] });
});
it('TOUR-POI-005: searches along a saved tour for MCP, and a tour without a line is not found', async () => {
const { user } = createUser(db);
const trip = createTrip(db, user.id);
+1
View File
@@ -347,6 +347,7 @@ describe('ToursMcp', () => {
}
expect(description(ToursMcp, 'getCityWalkProgress')).toMatch(/key .*opening hours/);
expect(description(ToursMcp, 'searchTourPois')).toContain('route_pending');
expect(description(ToursMcp, 'searchTourPois')).toMatch(/best known places.*highlight: true/);
expect(description(TourLibraryMcp, 'takeOverTour')).toContain('protection');
for (const method of ['deletePlace', 'bulkDeletePlaces']) {
expect(description(PlacesMcp, method)).toContain(
@@ -13,6 +13,7 @@ import {
trekPlacesArea,
trekPlacesBaseUrl,
trekPlacesById,
trekPlacesHighlights,
trekPlacesNearby,
trekPlacesSearch,
resetTrekPlacesBreaker,
@@ -601,6 +602,78 @@ describe('trekPlacesNearby', () => {
});
});
describe('trekPlacesHighlights', () => {
const SQUARE = {
osmType: 'way',
osmId: 4_567,
name: 'Piazza San Marco',
lat: 45.4341,
lng: 12.3388,
category: 'sehenswuerdigkeit',
osmTag: 'place=square',
wikidata: 'Q190092',
qrank: 912_345,
distanceMetres: 42,
};
it('TREK-PLACES-HL-01: asks /v1/highlights around a point in the reader language and reads its rows', async () => {
stubFetch({ highlights: [SQUARE], qrankRelease: '2026-10-01' });
const out = await trekPlacesHighlights(45.434, 12.338, { radius: 812.6, limit: 12, lang: 'de' });
const url = new URL(calls[0].url);
expect(url.pathname).toBe('/v1/highlights');
expect(url.searchParams.get('lat')).toBe('45.434');
expect(url.searchParams.get('lng')).toBe('12.338');
expect(url.searchParams.get('radius')).toBe('813');
expect(url.searchParams.get('limit')).toBe('12');
expect(url.searchParams.get('lang')).toBe('de');
expect(out).toEqual({ highlights: [SQUARE], qrankRelease: '2026-10-01' });
});
it('TREK-PLACES-HL-02: keeps every request inside the bounds the service takes, and asks nothing for no coordinate', async () => {
stubFetch({ highlights: [] });
await trekPlacesHighlights(45.4, 12.3);
await trekPlacesHighlights(45.4, 12.3, { radius: 1, limit: 0 });
await trekPlacesHighlights(45.4, 12.3, { radius: 90_000, limit: 500 });
await trekPlacesHighlights(45.4, 12.3, { radius: Number.NaN, limit: Number.POSITIVE_INFINITY });
const asked = calls.map((call) => new URL(call.url).searchParams);
expect(asked.map((params) => [params.get('radius'), params.get('limit')])).toEqual([
['1000', '30'],
['10', '1'],
['20000', '100'],
['10', '1'],
]);
expect(asked[0]?.has('lang')).toBe(false);
await expect(trekPlacesHighlights(Number.NaN, 12.3)).resolves.toEqual({ highlights: [], qrankRelease: null });
await expect(trekPlacesHighlights(91, 12.3)).resolves.toEqual({ highlights: [], qrankRelease: null });
expect(calls).toHaveLength(4);
});
it('TREK-PLACES-HL-03: drops a row of another shape on its own, and reads an old layer without ranks as none', async () => {
stubFetch({
highlights: [SQUARE, { ...SQUARE, osmId: 5, wikidata: 'L12' }, { ...SQUARE, osmId: 6, qrank: -1 }, 'row'],
qrankRelease: 20261001,
});
expect(await trekPlacesHighlights(45.4, 12.3)).toEqual({ highlights: [SQUARE], qrankRelease: null });
stubFetch({ highlights: { 0: SQUARE } });
expect(await trekPlacesHighlights(45.4, 12.3)).toEqual({ highlights: [], qrankRelease: null });
stubFetch(null);
expect(await trekPlacesHighlights(45.4, 12.3)).toEqual({ highlights: [], qrankRelease: null });
});
it('TREK-PLACES-HL-04: throws on an error status and when the index is switched off, so the caller goes on without', async () => {
stubFetch({}, 503);
await expect(trekPlacesHighlights(45.4, 12.3)).rejects.toThrow('TREK Places API 503');
env.maps.trekPlacesEnabled = false;
await expect(trekPlacesHighlights(45.4, 12.3)).rejects.toThrow('switched off');
expect(calls).toHaveLength(1);
});
it('TREK-PLACES-HL-05: a service older than the endpoint answers 404, read as no highlights', async () => {
stubFetch({}, 404);
await expect(trekPlacesHighlights(45.4, 12.3)).resolves.toEqual({ highlights: [], qrankRelease: null });
});
});
describe('trekPlacesArea', () => {
it('sends the whole box and caps the rows at 2000', async () => {
stubFetch({ count: 1, truncated: false, results: [PLACE] });
+2
View File
@@ -601,6 +601,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'الوصول بعد بدء جولة {walk} في {time}',
'tours.poi.categoriesNone': 'لم تُختر أي فئة',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'أبرز المعالم',
'tours.poi.highlightHint': 'من أشهر الأماكن هنا',
'tours.planner.tourType': 'نوع الجولة',
'tours.paddle.boat': 'القارب',
'tours.paddle.boat.kayak': 'كاياك',
+2
View File
@@ -602,6 +602,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': '{walk} {time} başlayır, çatma isə daha gec olur',
'tours.poi.categoriesNone': 'Kateqoriya seçilməyib',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Diqqətəlayiq',
'tours.poi.highlightHint': 'Buradakı ən tanınmış yerlərdən biri',
'tours.planner.tourType': 'Tur növü',
'tours.paddle.boat': 'Qayıq',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
+2
View File
@@ -607,6 +607,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Chega depois do início de {walk}, às {time}',
'tours.poi.categoriesNone': 'Nenhuma categoria escolhida',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Destaque',
'tours.poi.highlightHint': 'Um dos lugares mais conhecidos daqui',
'tours.planner.tourType': 'Tipo de passeio',
'tours.paddle.boat': 'Embarcação',
'tours.paddle.boat.kayak': 'Caiaque',
+2
View File
@@ -608,6 +608,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Arriba després que comenci {walk}, a les {time}',
'tours.poi.categoriesNone': 'Cap categoria triada',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Imprescindible',
'tours.poi.highlightHint': "Un dels llocs més coneguts d'aquí",
'tours.planner.tourType': 'Tipus de ruta',
'tours.paddle.boat': 'Embarcació',
'tours.paddle.boat.kayak': 'Caiac',
+2
View File
@@ -595,6 +595,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Příjezd až po začátku procházky {walk} v {time}',
'tours.poi.categoriesNone': 'Žádná kategorie',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Top místo',
'tours.poi.highlightHint': 'Jedno z nejznámějších míst v okolí',
'tours.planner.tourType': 'Typ túry',
'tours.paddle.boat': 'Loď',
'tours.paddle.boat.kayak': 'Kajak',
+2
View File
@@ -612,6 +612,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Ankunft nach dem Start von {walk} um {time}',
'tours.poi.categoriesNone': 'Keine Kategorie gewählt',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Highlight', // same-as-en
'tours.poi.highlightHint': 'Einer der bekanntesten Orte hier',
'tours.planner.tourType': 'Tourtyp',
'tours.paddle.boat': 'Boot',
'tours.paddle.boat.kayak': 'Kajak',
+2
View File
@@ -596,6 +596,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Arrives after {walk} starts at {time}',
'tours.poi.categoriesNone': 'No category chosen',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Highlight',
'tours.poi.highlightHint': 'One of the best-known places here',
'tours.planner.tourType': 'Tour type',
'tours.paddle.boat': 'Boat',
'tours.paddle.boat.kayak': 'Kayak',
+2
View File
@@ -607,6 +607,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Llega después de que empiece {walk}, a las {time}',
'tours.poi.categoriesNone': 'Ninguna categoría elegida',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Imprescindible',
'tours.poi.highlightHint': 'Uno de los lugares más conocidos de aquí',
'tours.planner.tourType': 'Tipo de ruta',
'tours.paddle.boat': 'Embarcación',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
+2
View File
@@ -598,6 +598,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Saabub pärast seda, kui {walk} algab kell {time}',
'tours.poi.categoriesNone': 'Kategooriat pole valitud',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Tippkoht',
'tours.poi.highlightHint': 'Üks siinseid tuntuimaid paiku',
'tours.planner.tourType': 'Matka tüüp',
'tours.paddle.boat': 'Paat',
'tours.paddle.boat.kayak': 'Süst',
+2
View File
@@ -615,6 +615,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Arrivée après le début de {walk} à {time}',
'tours.poi.categoriesNone': 'Aucune catégorie choisie',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Incontournable',
'tours.poi.highlightHint': 'L’un des lieux les plus connus ici',
'tours.planner.tourType': 'Type de sortie',
'tours.paddle.boat': 'Embarcation',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
+2
View File
@@ -618,6 +618,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Άφιξη μετά την έναρξη της βόλτας {walk} στις {time}',
'tours.poi.categoriesNone': 'Δεν επιλέχθηκε κατηγορία',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Κορυφαίο',
'tours.poi.highlightHint': 'Ένα από τα πιο γνωστά μέρη εδώ',
'tours.planner.tourType': 'Τύπος διαδρομής',
'tours.paddle.boat': 'Σκάφος',
'tours.paddle.boat.kayak': 'Καγιάκ',
+2
View File
@@ -608,6 +608,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Érkezés a séta kezdete után: {walk}, {time}',
'tours.poi.categoriesNone': 'Nincs kategória kiválasztva',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Kiemelt',
'tours.poi.highlightHint': 'Az itteni legismertebb helyek egyike',
'tours.planner.tourType': 'Túratípus',
'tours.paddle.boat': 'Hajó',
'tours.paddle.boat.kayak': 'Kajak',
+2
View File
@@ -600,6 +600,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Tiba setelah {walk} dimulai pukul {time}',
'tours.poi.categoriesNone': 'Belum ada kategori',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Sorotan',
'tours.poi.highlightHint': 'Salah satu tempat paling terkenal di sini',
'tours.planner.tourType': 'Jenis tur',
'tours.paddle.boat': 'Perahu',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
+2
View File
@@ -609,6 +609,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': "Arrivo dopo l'inizio di {walk} alle {time}",
'tours.poi.categoriesNone': 'Nessuna categoria scelta',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Da non perdere',
'tours.poi.highlightHint': 'Uno dei luoghi più famosi di qui',
'tours.planner.tourType': 'Tipo di tour',
'tours.paddle.boat': 'Imbarcazione',
'tours.paddle.boat.kayak': 'Kayak', // same-as-en
+2
View File
@@ -587,6 +587,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': '{walk} の開始({time})より後に到着',
'tours.poi.categoriesNone': 'カテゴリー未選択',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': '注目スポット',
'tours.poi.highlightHint': 'このあたりで特に有名な場所のひとつ',
'tours.planner.tourType': 'ツアーの種類',
'tours.paddle.boat': 'ボート',
'tours.paddle.boat.kayak': 'カヤック',
+2
View File
@@ -584,6 +584,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': '{walk} 시작({time}) 이후 도착',
'tours.poi.categoriesNone': '선택한 카테고리 없음',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': '하이라이트',
'tours.poi.highlightHint': '이곳에서 가장 잘 알려진 장소 중 하나',
'tours.planner.tourType': '투어 유형',
'tours.paddle.boat': '보트',
'tours.paddle.boat.kayak': '카약',
+2
View File
@@ -609,6 +609,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Aankomst na de start van {walk} om {time}',
'tours.poi.categoriesNone': 'Geen categorie gekozen',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Topper',
'tours.poi.highlightHint': 'Een van de bekendste plekken hier',
'tours.planner.tourType': 'Soort tocht',
'tours.paddle.boat': 'Boot',
'tours.paddle.boat.kayak': 'Kajak',
+2
View File
@@ -617,6 +617,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Przyjazd po rozpoczęciu trasy {walk} o {time}',
'tours.poi.categoriesNone': 'Nie wybrano kategorii',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Perełka',
'tours.poi.highlightHint': 'Jedno z najbardziej znanych miejsc w okolicy',
'tours.planner.tourType': 'Rodzaj wycieczki',
'tours.paddle.boat': 'Łódź',
'tours.paddle.boat.kayak': 'Kajak',
+2
View File
@@ -620,6 +620,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Прибытие после начала прогулки {walk} в {time}',
'tours.poi.categoriesNone': 'Категория не выбрана',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Главное',
'tours.poi.highlightHint': 'Одно из самых известных мест здесь',
'tours.planner.tourType': 'Тип тура',
'tours.paddle.boat': 'Лодка',
'tours.paddle.boat.kayak': 'Каяк',
+2
View File
@@ -597,6 +597,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Príchod po začiatku prechádzky {walk} o {time}',
'tours.poi.categoriesNone': 'Žiadna kategória',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Top miesto',
'tours.poi.highlightHint': 'Jedno z najznámejších miest v okolí',
'tours.planner.tourType': 'Typ túry',
'tours.paddle.boat': 'Loď',
'tours.paddle.boat.kayak': 'Kajak',
+2
View File
@@ -596,6 +596,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Ankomst efter att {walk} börjar kl. {time}',
'tours.poi.categoriesNone': 'Ingen kategori vald',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Höjdpunkt',
'tours.poi.highlightHint': 'En av de mest kända platserna här',
'tours.planner.tourType': 'Turtyp',
'tours.paddle.boat': 'Båt',
'tours.paddle.boat.kayak': 'Kajak',
+2
View File
@@ -571,6 +571,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'ถึงหลังจาก {walk} เริ่มเวลา {time}',
'tours.poi.categoriesNone': 'ยังไม่ได้เลือกหมวดหมู่',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'ไฮไลต์',
'tours.poi.highlightHint': 'หนึ่งในสถานที่ที่มีชื่อเสียงที่สุดในบริเวณนี้',
'tours.planner.tourType': 'ประเภททริป',
'tours.paddle.boat': 'เรือ',
'tours.paddle.boat.kayak': 'เรือคายัก',
+1 -1
View File
@@ -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 = 823;
const GENERAL_KEY_COUNT = 825;
function literalKeys(source: string): string[] {
const tree = ts.createSourceFile('locale.ts', source, ts.ScriptTarget.Latest, true);
+2
View File
@@ -603,6 +603,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Varış, {walk} {time} saatinde başladıktan sonra',
'tours.poi.categoriesNone': 'Kategori seçilmedi',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Öne çıkan',
'tours.poi.highlightHint': 'Buradaki en bilinen yerlerden biri',
'tours.planner.tourType': 'Tur türü',
'tours.paddle.boat': 'Tekne',
'tours.paddle.boat.kayak': 'Kano (kayak)',
+2
View File
@@ -613,6 +613,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Прибуття після початку прогулянки {walk} о {time}',
'tours.poi.categoriesNone': 'Категорію не вибрано',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Головне',
'tours.poi.highlightHint': 'Одне з найвідоміших місць тут',
'tours.planner.tourType': 'Тип туру',
'tours.paddle.boat': 'Човен',
'tours.paddle.boat.kayak': 'Каяк',
+2
View File
@@ -595,6 +595,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': 'Đến sau khi {walk} bắt đầu lúc {time}',
'tours.poi.categoriesNone': 'Chưa chọn danh mục',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': 'Nổi bật',
'tours.poi.highlightHint': 'Một trong những nơi nổi tiếng nhất ở đây',
'tours.planner.tourType': 'Loại chuyến',
'tours.paddle.boat': 'Thuyền',
'tours.paddle.boat.kayak': 'Thuyền kayak',
+2
View File
@@ -551,6 +551,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': '抵達晚於 {walk} 的開始時間 {time}',
'tours.poi.categoriesNone': '未選擇類別',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': '亮點',
'tours.poi.highlightHint': '這裡最知名的地方之一',
'tours.planner.tourType': '路線類型',
'tours.paddle.boat': '船隻',
'tours.paddle.boat.kayak': '獨木舟(Kayak)',
+2
View File
@@ -552,6 +552,8 @@ const tours: TranslationStrings = {
'tours.walk.transit.late': '到达晚于 {walk} 的开始时间 {time}',
'tours.poi.categoriesNone': '未选择类别',
'tours.poi.categoriesMore': '{first} +{more}',
'tours.poi.highlight': '亮点',
'tours.poi.highlightHint': '这里最知名的地方之一',
'tours.planner.tourType': '路线类型',
'tours.paddle.boat': '船只',
'tours.paddle.boat.kayak': '皮划艇',
+42
View File
@@ -0,0 +1,42 @@
import {
TOUR_HIGHLIGHT_FALLBACK_MIN_QRANK,
TOUR_HIGHLIGHT_MIN_COUNT,
TOUR_HIGHLIGHT_MIN_QRANK,
tourHighlightIds,
} from './tour-highlights';
import { describe, expect, it } from 'vitest';
const rank = (id: string, qrank: number) => ({ id, qrank });
describe('tour highlights', () => {
it('TOUR-HIGHLIGHT-001: every place past the bar is a highlight, best known first', () => {
expect(
tourHighlightIds([
rank('square', 900_000),
rank('cafe', 100),
rank('church', 41_000),
rank('bridge', 40_000),
rank('tower', 250_000),
]),
).toEqual(['square', 'tower', 'church', 'bridge']);
});
it('TOUR-HIGHLIGHT-002: with fewer than three past the bar, the best known down to the fallback fill up to three', () => {
expect(
tourHighlightIds([rank('a', 6_000), rank('b', 60_000), rank('c', 4_999), rank('d', 5_000), rank('e', 7_000)]),
).toEqual(['b', 'e', 'a']);
expect(tourHighlightIds([rank('a', 6_000), rank('b', 4_000)])).toEqual(['a']);
});
it('TOUR-HIGHLIGHT-003: nothing ranked is nothing marked, and a broken rank is never a highlight', () => {
expect(tourHighlightIds([])).toEqual([]);
expect(tourHighlightIds([rank('a', 4_999), rank('b', Number.NaN)])).toEqual([]);
});
it('TOUR-HIGHLIGHT-004: the bars are what the rule says', () => {
expect(TOUR_HIGHLIGHT_MIN_QRANK).toBe(40_000);
expect(TOUR_HIGHLIGHT_FALLBACK_MIN_QRANK).toBe(5_000);
expect(TOUR_HIGHLIGHT_MIN_COUNT).toBe(3);
});
});
+35
View File
@@ -0,0 +1,35 @@
/**
* Which places along a tour are its highlights: the ones people know.
*
* Fame is read from QRank, the page views of a place's Wikidata item across
* the Wikimedia projects, as the TREK places service carries it on its
* OpenStreetMap layer. A place without a Wikidata item has no rank and is
* never a highlight; that says nothing about whether it is worth a visit.
*/
/** A place at least this well known is a highlight wherever it lies. */
export const TOUR_HIGHLIGHT_MIN_QRANK = 40_000;
/**
* A route through quieter streets still shows its best known places: when
* fewer than {@link TOUR_HIGHLIGHT_MIN_COUNT} reach the bar above, the best
* known ones down to this rank fill up to that many.
*/
export const TOUR_HIGHLIGHT_FALLBACK_MIN_QRANK = 5_000;
/** How many highlights the fallback fills up to. */
export const TOUR_HIGHLIGHT_MIN_COUNT = 3;
/**
* The ids of the highlights among places with a rank, best known first. Ties
* keep the order they came in, so the same answer marks the same places.
*/
export function tourHighlightIds(ranked: ReadonlyArray<{ id: string; qrank: number }>): string[] {
const best = [...ranked].filter((row) => Number.isFinite(row.qrank)).sort((a, b) => b.qrank - a.qrank);
const famous = best.filter((row) => row.qrank >= TOUR_HIGHLIGHT_MIN_QRANK);
if (famous.length >= TOUR_HIGHLIGHT_MIN_COUNT) return famous.map((row) => row.id);
return best
.filter((row) => row.qrank >= TOUR_HIGHLIGHT_FALLBACK_MIN_QRANK)
.slice(0, TOUR_HIGHLIGHT_MIN_COUNT)
.map((row) => row.id);
}
@@ -72,6 +72,29 @@ describe('tour POI search contracts (PLN-02)', () => {
expect(answer.pois[0]?.distance_m).toBe(40);
});
it('TOUR-HIGHLIGHT-005: marks a highlight with a flag that may be left off, and refuses one that is no flag', () => {
const poi = {
id: 'way:9',
name: 'Piazza San Marco',
lat: 45.434,
lng: 12.338,
category: 'sights',
kind: 'place=square',
source: 'openstreetmap',
address: null,
website: null,
phone: null,
opening_hours: null,
distance_m: 12,
along_m: 300,
};
const answer = { truncated: false, partial: false, capped: false, unavailable: [] };
const parsed = tourPoiSearchResponseSchema.parse({ ...answer, pois: [{ ...poi, highlight: true }, poi] });
expect(parsed.pois.map((found) => found.highlight)).toEqual([true, undefined]);
const notAFlag = { ...answer, pois: [{ ...poi, highlight: 'yes' }] };
expect(tourPoiSearchResponseSchema.safeParse(notAFlag).success).toBe(false);
});
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([
@@ -12,6 +12,13 @@ import { z } from 'zod';
// The categories live in a module of their own, so the tour contract can name
// one on a waypoint without importing this one, which imports it.
export { TOUR_POI_CATEGORIES, tourPoiCategorySchema, type TourPoiCategory } from './tour-poi-category';
// Which places found are highlights, read by the server that marks them.
export {
TOUR_HIGHLIGHT_FALLBACK_MIN_QRANK,
TOUR_HIGHLIGHT_MIN_COUNT,
TOUR_HIGHLIGHT_MIN_QRANK,
tourHighlightIds,
} from './tour-highlights';
/** The categories each planned tour type offers, the most asked first. */
export const TOUR_POI_CATEGORIES_BY_TYPE: Record<PlannableTourType, readonly TourPoiCategory[]> = {
@@ -80,6 +87,8 @@ export const tourPoiSchema = z.object({
distance_m: z.number().nonnegative(),
/** Metres along the line from its start to the point nearest the place. */
along_m: z.number().nonnegative(),
/** One of the best known places along the line (tour-highlights); absent means not one. */
highlight: z.boolean().optional(),
});
export type TourPoi = z.infer<typeof tourPoiSchema>;
+1 -1
View File
@@ -242,7 +242,7 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` |
| `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `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, 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. A tour saved with its points only (`route_pending`) has no line to search along yet. 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. When sights are asked for, the best known places near the line (by the page views of their Wikidata item) are searched too, so a famous square no category names is found as a sight, and the highlights among them carry `highlight: true` and come first. 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. A tour saved with its points only (`route_pending`) has no line to search along yet. 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. A city walk keeps its stations with their keys, names, stays, importance, protection and opening hours, and none of the visits of the other trip's days. Needs `place_edit` on the trip it writes into | `trips:write` |
| `preview_google_maps_walk` | Reads the stops of a Google Maps walking route shared as a link (a `maps.app.goo.gl` short link or a `google.com/maps/dir` link), in order, for a city walk: each stop's name and position, or no position when the link gives none (nothing is looked up or guessed), and the long link that was read. Only a route Google Maps marks as on foot is read; a car, bike or transit route, or a link that does not say, is refused. A short link is followed only to Google Maps itself. Nothing is saved, and the line drawn in Google Maps is not taken over: TREK plans the way on foot between the stops again. Needs `place_edit` on the trip | `trips:read` |