Files
TREK/server/tests/unit/nest/maps-along.helpers.test.ts
T
Maurice 3c823e07f4 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.
2026-10-20 07:09:56 +00:00

154 lines
6.8 KiB
TypeScript

/**
* 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);
});
});