feat(tours): round hikes, other ways of a leg and wanderer trails through the assistant

The assistant can now search a round hike of about a wished length from one
point and the router's other ways for one leg of a hike, with the planner's
own search, and propose each one as a hike card to compare and take. It can
also search the user's wanderer trails, preview one as a card, import it and
check a wanderer tour against its source.

The round search, its loop geometry, the test whether two lines are one way,
the other ways among a router's offers and the pins that hold a chosen way
move to @trek/shared; the planner and the road trip use them from there.
This commit is contained in:
Maurice
2026-10-23 21:15:37 +02:00
parent 338548bf7f
commit 14196358fb
74 changed files with 3031 additions and 438 deletions
+136
View File
@@ -0,0 +1,136 @@
// SAME-WAY-001 to SAME-WAY-008: whether two routed lines are one way, the other ways among a
// router's offers, and the fewest pins that make the router keep to one.
import { furthestFrom, otherWaysOf, pinWay, sameRoad, type RoadLine, type WayPin } from './same-way';
import { describe, expect, it, vi } from 'vitest';
type Line = [number, number][];
const RAIL: Line = [
[53.55, 9.99],
[53.0, 11.5],
[52.52, 13.4],
];
const DETOUR: Line = [
[53.55, 9.99],
[53.4, 11.5],
[53.3, 12.8],
[52.52, 13.4],
];
const road = (coordinates: Line, over: Partial<RoadLine> = {}): RoadLine => ({
coordinates,
distance: 300_000,
duration: 11_000,
...over,
});
describe('sameRoad', () => {
it('SAME-WAY-001: a road that runs the whole of another and turns off to a point and back is not that road', () => {
const plainRoad = road(
[
[61, 10],
[61, 11],
[61, 12],
],
{ distance: 100_000, duration: 3600 },
);
const spur = road(
[
[61, 10],
[61, 11],
[61.3, 11],
[61, 11],
[61, 12],
],
{ distance: 166_000, duration: 5400 },
);
expect(sameRoad(plainRoad, spur)).toBe(false);
expect(sameRoad(spur, plainRoad)).toBe(false);
expect(sameRoad(plainRoad, { ...plainRoad, distance: 104_000, duration: 3900 })).toBe(true);
});
it('SAME-WAY-002: one road drawn with a different spacing of points is still one road', () => {
const dense = Array.from({ length: 2001 }, (_, i): [number, number] => [50, i * 0.005]);
const sparse: Line = [
[50, 0],
...Array.from({ length: 200 }, (_, i): [number, number] => [50, 0.025 + i * 0.05]),
[50, 10],
];
expect(sameRoad(road(dense), road(sparse))).toBe(true);
const off = furthestFrom([[50.02, 5]], dense);
expect(off?.index).toBe(0);
expect(off?.km).toBeCloseTo(2.22, 1);
});
it('SAME-WAY-003: lines without a shape are compared by their figures', () => {
expect(furthestFrom([], RAIL)).toBeNull();
expect(sameRoad(road([]), road([], { distance: 300_100, duration: 11_030 }))).toBe(true);
expect(sameRoad(road([]), road([], { distance: 301_000 }))).toBe(false);
});
});
describe('otherWaysOf', () => {
it('SAME-WAY-004: the router’s first answer and repeats of a way are no other ways, and there are at most so many', () => {
const current = { coordinates: RAIL };
const far: Line = [
[53.55, 9.99],
[52.6, 11.5],
[52.52, 13.4],
];
const offers = [{ coordinates: DETOUR }, { coordinates: RAIL }, { coordinates: DETOUR }, { coordinates: far }];
expect(otherWaysOf(current, offers, 3).map((offer) => offer.coordinates)).toEqual([DETOUR, far]);
expect(otherWaysOf(current, offers, 1).map((offer) => offer.coordinates)).toEqual([DETOUR]);
expect(otherWaysOf(current, [{ coordinates: DETOUR }], 3)).toEqual([]);
});
});
describe('pinWay', () => {
it('SAME-WAY-005: the router’s own preference is asked with no pin, any other offer from where it strays furthest', async () => {
const route = vi.fn(async (_pins: WayPin[]) => road(DETOUR));
expect(await pinWay({ offer: { ...road(DETOUR), direct: true }, current: road(RAIL), route })).toMatchObject({
held: true,
pins: [],
});
expect(route.mock.calls[0]?.[0]).toEqual([]);
expect(await pinWay({ offer: road(DETOUR), current: road(RAIL), route })).toMatchObject({
held: true,
pins: [{ lat: 53.3, lng: 12.8 }],
});
});
it('SAME-WAY-006: an answer that is not the offer adds the point it missed, in the order the offer runs', async () => {
const route = vi
.fn(async (_pins: WayPin[]) => road(DETOUR))
.mockResolvedValueOnce(
road([
[53.55, 9.99],
[53.0, 11.5],
[53.3, 12.8],
[52.52, 13.4],
]),
);
const proof = await pinWay({ offer: road(DETOUR), current: road(RAIL), route });
expect(route).toHaveBeenCalledTimes(2);
expect(proof?.pins).toEqual([
{ lat: 53.4, lng: 11.5 },
{ lat: 53.3, lng: 12.8 },
]);
});
it('SAME-WAY-007: a way the router does not keep to within the pins, or an answer of a stand-in, holds nothing', async () => {
const never = vi.fn(async (_pins: WayPin[]) => road(RAIL));
const refused = await pinWay({ offer: road(DETOUR), current: road(RAIL), route: never, maxPins: 1 });
expect(refused).toMatchObject({ held: false, pins: [], fellBack: false });
const standIn = vi.fn(async (_pins: WayPin[]) => ({ ...road(DETOUR), fellBack: true }));
expect(await pinWay({ offer: road(DETOUR), current: road(RAIL), route: standIn })).toMatchObject({
held: false,
fellBack: true,
});
});
it('SAME-WAY-008: nothing is asked once the check is called off', async () => {
const route = vi.fn(async (_pins: WayPin[]) => road(DETOUR));
expect(await pinWay({ offer: road(DETOUR), current: road(RAIL), route, signal: { aborted: true } })).toBeNull();
expect(route).not.toHaveBeenCalled();
});
});
+231
View File
@@ -0,0 +1,231 @@
import { haversineKm } from '../roadtrip/corridor';
/**
* Whether two routed lines are one way, and how a way the router offered is
* held with a few points pinned on it. The road trip's other roads, the
* planner's other ways of a hike's section and the assistant's ways of a leg
* all decide with these, so the browser and the server agree on what counts
* as another way and on what holds it.
*/
type LinePoint = [number, number];
/** What two routes are compared by: their line, and their figures where there is none. */
export interface RoadLine {
coordinates: LinePoint[];
distance: number;
duration: number;
}
/**
* How far two lines may stray from each other and still be the same road, in kilometres.
*
* Parallel carriageways, a different slip road onto the same motorway and the noise
* between two engines' idea of where a road is all sit well under this. A way somebody
* would call an alternative leaves it by kilometres: the ferry-free run from Hamburg to
* Copenhagen, which is a genuinely different road, peaks 2.3 km off the default.
*/
const SAME_ROAD_KM = 0.5;
/** The point of segment a-b closest to `p`, with longitude scaled by `cos` so the plane is fair. */
function closestOnSegment(p: LinePoint, a: LinePoint, b: LinePoint, cos: number): LinePoint {
const dx = (b[1] - a[1]) * cos;
const dy = b[0] - a[0];
const length = dx * dx + dy * dy;
if (length === 0) return a;
const t = Math.max(0, Math.min(1, ((p[1] - a[1]) * cos * dx + (p[0] - a[0]) * dy) / length));
return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
}
/**
* Where `reference` passes closest to `point`, as a squared distance in the same scaled
* degrees and the spot on the line it was measured to.
*
* Coarse, then fine. The sampled vertices find the stretch, and the segments either side
* of the nearest one find the road itself. Measured to the sampled vertex alone, a point
* on the very same road sat up to half a sample apart from it, which on a thousand
* kilometre leg is kilometres: two engines' lines of one motorway then read as two roads.
*/
function nearestOn(point: LinePoint, reference: readonly LinePoint[], step: number): { d: number; at: LinePoint } {
const cos = Math.cos((point[0] * Math.PI) / 180);
const squared = (p: LinePoint): number => {
const dLat = point[0] - p[0];
const dLng = (point[1] - p[1]) * cos;
return dLat * dLat + dLng * dLng;
};
let nearestIndex = 0;
let nearest = Infinity;
for (let j = 0; j < reference.length; j += step) {
const vertex = reference[j];
if (!vertex) continue;
const d = squared(vertex);
if (d < nearest) {
nearest = d;
nearestIndex = j;
}
}
let at = reference[nearestIndex] ?? point;
const last = Math.min(reference.length - 1, nearestIndex + step);
for (let k = Math.max(0, nearestIndex - step); k < last; k++) {
const a = reference[k];
const b = reference[k + 1];
if (!a || !b) continue;
const on = closestOnSegment(point, a, b, cos);
const d = squared(on);
if (d < nearest) {
nearest = d;
at = on;
}
}
return { d: nearest, at };
}
/**
* The point of `line` that lies furthest from `reference`, plus how far off it actually is.
*
* The search itself stays in squared degrees with a cosine on the longitude: it only has
* to ORDER the candidates, and a haversine per pair of a 200-by-200 comparison buys
* nothing for it. The winner is converted once at the end, because `sameRoad` needs a
* real distance rather than a rank, and half a degree is a different number of metres in
* Andalusia than in Lapland.
*
* `index` is where on `line` the point sits, so points taken off one line can be put back
* in the order that line drives through them.
*/
export function furthestFrom(
line: readonly LinePoint[],
reference: readonly LinePoint[],
): { index: number; lat: number; lng: number; km: number } | null {
if (!line.length || !reference.length) return null;
// Every tenth vertex is plenty: alternatives differ over kilometres, not metres, and a
// full cross product of two thousand-point lines is not worth the milliseconds.
const step = Math.max(1, Math.floor(reference.length / 200));
let bestIndex = -1;
let bestNeighbour: LinePoint | null = null;
let bestDist = -1;
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
const vertex = line[i];
if (!vertex) continue;
const near = nearestOn(vertex, reference, step);
if (near.d > bestDist) {
bestDist = near.d;
bestIndex = i;
bestNeighbour = near.at;
}
}
const best = line[bestIndex];
if (!best) return null;
const [lat, lng] = best;
const km = bestNeighbour ? haversineKm({ lat, lng }, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0;
return { index: bestIndex, lat, lng, km };
}
/**
* Two lines are the same road.
*
* The geometry does not care which engine drew it, so it decides whenever there is one:
* lengths and durations of the same road differ between engines by more than a real
* detour does. The numbers stay as the fallback for a line with no shape to compare.
*
* Measured both ways round. Asked only whether `b` stays near `a`, a road that runs the
* whole of `b` and then turns off to a point and back passed as `b`, because every point
* of `b` is on it; that is exactly the shape of a leg bent by a via, and the picker and
* the check behind a choice both have to tell it from the plain road.
*/
export function sameRoad(a: RoadLine, b: RoadLine): boolean {
const there = furthestFrom(b.coordinates, a.coordinates);
const back = furthestFrom(a.coordinates, b.coordinates);
if (there && back) return Math.max(there.km, back.km) < SAME_ROAD_KM;
return Math.abs(a.distance - b.distance) < 200 && Math.abs(a.duration - b.duration) < 60;
}
/**
* The other ways among a router's offers between two points: every offer
* after its first that is not a way already kept, at most `most`. The first
* is the router's own way under the same rules, the one the route already
* takes, so it is never one of them; `current` is that way as drawn now.
*/
export function otherWaysOf<T extends { coordinates: LinePoint[] }>(
current: { coordinates: LinePoint[] },
offers: readonly T[],
most: number,
): T[] {
const line = (way: { coordinates: LinePoint[] }): RoadLine => ({
coordinates: way.coordinates,
distance: 0,
duration: 0,
});
const kept: T[] = [];
for (const offer of offers.slice(1)) {
if (kept.length >= most) break;
const known = [current, ...kept].some((way) => sameRoad(line(way), line(offer)));
if (!known) kept.push(offer);
}
return kept;
}
/** The most points one choice may pin to the way: every pin is one more question to a router that allows one a second. */
export const MAX_WAY_PINS = 3;
/** A point a choice is held by, in the order the chosen way runs through them. */
export interface WayPin {
lat: number;
lng: number;
}
/** Whether the router keeps to an offer, and the pins that make it do so. */
export interface PinnedWay<R> {
/** True when the router took the offer with `pins` in place. */
held: boolean;
/** The pins that held it, in the order the offer runs through them. Empty otherwise. */
pins: WayPin[];
/** True when the last answer came from a stand-in engine, which proves nothing either way. */
fellBack: boolean;
/** The router's answer to the last attempt. */
last: R;
}
/**
* The fewest points that make the router take `offer`, found by asking it.
*
* The router's own preference (`offer.direct`) starts with no pin; any other offer with
* the point furthest from the way taken now. After each answer that is not the offer,
* the point of the offer furthest from that answer joins in, placed in the order the
* offer runs through them, until the answer is the offer or `maxPins` is reached. An
* answer from a stand-in engine proves nothing about the one that will draw the way, so
* it ends the check without a verdict. Null once `signal` aborted: nothing more is asked.
*/
export async function pinWay<R extends RoadLine & { fellBack?: boolean }>({
offer,
current,
route,
signal,
maxPins = MAX_WAY_PINS,
}: {
offer: RoadLine & { direct?: boolean };
/** The way taken now; the first pin is where the offer leaves it furthest. */
current: RoadLine | undefined;
route: (pins: WayPin[]) => Promise<R>;
signal?: { readonly aborted: boolean };
maxPins?: number;
}): Promise<PinnedWay<R> | null> {
const placed: { index: number; lat: number; lng: number }[] = [];
if (!offer.direct) {
const far = furthestFrom(offer.coordinates, current?.coordinates ?? []);
if (far) placed.push(far);
}
for (;;) {
if (signal?.aborted) return null;
const last = await route(placed.map(({ lat, lng }) => ({ lat, lng })));
if (last.fellBack) return { held: false, pins: [], fellBack: true, last };
if (sameRoad(last, offer)) {
return { held: true, pins: placed.map(({ lat, lng }) => ({ lat, lng })), fellBack: false, last };
}
if (placed.length >= maxPins) return { held: false, pins: [], fellBack: false, last };
const far = furthestFrom(offer.coordinates, last.coordinates);
// The same point again would ask the same question and earn the same answer.
if (!far || placed.some((p) => p.index === far.index)) return { held: false, pins: [], fellBack: false, last };
placed.push(far);
placed.sort((a, b) => a.index - b.index);
}
}
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'قراءة الجولة',
'assistant.step.readingTours': 'قراءة الجولات',
'assistant.step.tourPois': 'البحث على طول الجولة',
'assistant.step.findingRounds': 'البحث عن جولات دائرية',
'assistant.step.findingWays': 'البحث عن طرق أخرى',
'assistant.step.searchingWanderer': 'البحث في wanderer',
'assistant.step.readingTrail': 'قراءة المسار',
'assistant.step.checkingSource': 'التحقق من المصدر',
'assistant.followUp.tourEasier': 'أرني طريقًا أسهل',
'assistant.followUp.tourPacking': 'ماذا يجب أن آخذ معي؟',
'assistant.followUp.tourFit': 'أي جولة تناسب أي يوم؟',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Tur oxunur',
'assistant.step.readingTours': 'Turlar oxunur',
'assistant.step.tourPois': 'Tur boyunca axtarılır',
'assistant.step.findingRounds': 'Dairəvi yürüşlər axtarılır',
'assistant.step.findingWays': 'Başqa yollar axtarılır',
'assistant.step.searchingWanderer': 'wanderer-də axtarılır',
'assistant.step.readingTrail': 'Cığır oxunur',
'assistant.step.checkingSource': 'Mənbə yoxlanılır',
'assistant.followUp.tourEasier': 'Daha asan yol göstər',
'assistant.followUp.tourPacking': 'Nə götürməliyəm?',
'assistant.followUp.tourFit': 'Hansı tur hansı günə uyğundur?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Lendo o passeio',
'assistant.step.readingTours': 'Lendo os passeios',
'assistant.step.tourPois': 'Procurando ao longo do passeio',
'assistant.step.findingRounds': 'Procurando trilhas circulares',
'assistant.step.findingWays': 'Procurando outros caminhos',
'assistant.step.searchingWanderer': 'Buscando no wanderer',
'assistant.step.readingTrail': 'Lendo a trilha',
'assistant.step.checkingSource': 'Verificando a origem',
'assistant.followUp.tourEasier': 'Mostre um caminho mais fácil',
'assistant.followUp.tourPacking': 'O que devo levar?',
'assistant.followUp.tourFit': 'Qual passeio combina com qual dia?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Llegint l’excursió',
'assistant.step.readingTours': 'Llegint les excursions',
'assistant.step.tourPois': 'Cercant al llarg de l’excursió',
'assistant.step.findingRounds': 'Cercant excursions circulars',
'assistant.step.findingWays': 'Cercant altres camins',
'assistant.step.searchingWanderer': 'Cercant a wanderer',
'assistant.step.readingTrail': 'Llegint el sender',
'assistant.step.checkingSource': 'Comprovant l’origen',
'assistant.followUp.tourEasier': 'Mostra’m un camí més fàcil',
'assistant.followUp.tourPacking': 'Què m’hauria d’emportar?',
'assistant.followUp.tourFit': 'Quina excursió encaixa amb quin dia?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Čtu výlet',
'assistant.step.readingTours': 'Čtu výlety',
'assistant.step.tourPois': 'Hledám podél výletu',
'assistant.step.findingRounds': 'Hledám okružní túry',
'assistant.step.findingWays': 'Hledám jiné cesty',
'assistant.step.searchingWanderer': 'Hledám ve wandereru',
'assistant.step.readingTrail': 'Čtu stezku',
'assistant.step.checkingSource': 'Kontroluji zdroj',
'assistant.followUp.tourEasier': 'Ukaž snazší cestu',
'assistant.followUp.tourPacking': 'Co si mám sbalit?',
'assistant.followUp.tourFit': 'Který výlet se hodí na který den?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Liest die Tour',
'assistant.step.readingTours': 'Liest die Touren',
'assistant.step.tourPois': 'Sucht entlang der Tour',
'assistant.step.findingRounds': 'Sucht Rundwanderungen',
'assistant.step.findingWays': 'Sucht andere Wege',
'assistant.step.searchingWanderer': 'Durchsucht wanderer',
'assistant.step.readingTrail': 'Liest den Weg',
'assistant.step.checkingSource': 'Prüft die Quelle',
'assistant.followUp.tourEasier': 'Zeig einen leichteren Weg',
'assistant.followUp.tourPacking': 'Was soll ich dafür packen?',
'assistant.followUp.tourFit': 'Welche Tour passt zu welchem Tag?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Reading the tour',
'assistant.step.readingTours': 'Reading the tours',
'assistant.step.tourPois': 'Looking along the tour',
'assistant.step.findingRounds': 'Looking for round hikes',
'assistant.step.findingWays': 'Looking for other ways',
'assistant.step.searchingWanderer': 'Searching wanderer',
'assistant.step.readingTrail': 'Reading the trail',
'assistant.step.checkingSource': 'Checking the source',
'assistant.followUp.tourEasier': 'Show an easier way',
'assistant.followUp.tourPacking': 'What should I pack for it?',
'assistant.followUp.tourFit': 'Which tour suits which day?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Leyendo la excursión',
'assistant.step.readingTours': 'Leyendo las excursiones',
'assistant.step.tourPois': 'Buscando a lo largo de la excursión',
'assistant.step.findingRounds': 'Buscando rutas circulares',
'assistant.step.findingWays': 'Buscando otros caminos',
'assistant.step.searchingWanderer': 'Buscando en wanderer',
'assistant.step.readingTrail': 'Leyendo el sendero',
'assistant.step.checkingSource': 'Comprobando el origen',
'assistant.followUp.tourEasier': 'Muéstrame un camino más fácil',
'assistant.followUp.tourPacking': '¿Qué debería llevar?',
'assistant.followUp.tourFit': '¿Qué excursión encaja con qué día?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Loen retke',
'assistant.step.readingTours': 'Loen retki',
'assistant.step.tourPois': 'Otsin retke äärest',
'assistant.step.findingRounds': 'Otsin ringmatku',
'assistant.step.findingWays': 'Otsin teisi teid',
'assistant.step.searchingWanderer': 'Otsin wandererist',
'assistant.step.readingTrail': 'Loen rada',
'assistant.step.checkingSource': 'Kontrollin allikat',
'assistant.followUp.tourEasier': 'Näita lihtsamat teed',
'assistant.followUp.tourPacking': 'Mida ma peaksin kaasa pakkima?',
'assistant.followUp.tourFit': 'Milline retk sobib millisele päevale?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Lecture de la sortie',
'assistant.step.readingTours': 'Lecture des sorties',
'assistant.step.tourPois': 'Recherche le long de la sortie',
'assistant.step.findingRounds': 'Recherche de boucles',
'assistant.step.findingWays': 'Recherche d’autres chemins',
'assistant.step.searchingWanderer': 'Recherche dans wanderer',
'assistant.step.readingTrail': 'Lecture du sentier',
'assistant.step.checkingSource': 'Vérification de la source',
'assistant.followUp.tourEasier': 'Montrer un chemin plus facile',
'assistant.followUp.tourPacking': 'Que dois-je emporter ?',
'assistant.followUp.tourFit': 'Quelle sortie pour quel jour ?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Διαβάζω την εκδρομή',
'assistant.step.readingTours': 'Διαβάζω τις εκδρομές',
'assistant.step.tourPois': 'Ψάχνω κατά μήκος της εκδρομής',
'assistant.step.findingRounds': 'Ψάχνω κυκλικές πεζοπορίες',
'assistant.step.findingWays': 'Ψάχνω άλλους δρόμους',
'assistant.step.searchingWanderer': 'Ψάχνω στο wanderer',
'assistant.step.readingTrail': 'Διαβάζω το μονοπάτι',
'assistant.step.checkingSource': 'Ελέγχω την πηγή',
'assistant.followUp.tourEasier': 'Δείξε μου μια πιο εύκολη διαδρομή',
'assistant.followUp.tourPacking': 'Τι να πάρω μαζί μου;',
'assistant.followUp.tourFit': 'Ποια εκδρομή ταιριάζει σε ποια μέρα;',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'A túra beolvasása',
'assistant.step.readingTours': 'A túrák beolvasása',
'assistant.step.tourPois': 'Keresés a túra mentén',
'assistant.step.findingRounds': 'Körtúrák keresése',
'assistant.step.findingWays': 'Más utak keresése',
'assistant.step.searchingWanderer': 'Keresés a wandererben',
'assistant.step.readingTrail': 'Az ösvény beolvasása',
'assistant.step.checkingSource': 'A forrás ellenőrzése',
'assistant.followUp.tourEasier': 'Mutass egy könnyebb utat',
'assistant.followUp.tourPacking': 'Mit vigyek magammal?',
'assistant.followUp.tourFit': 'Melyik túra melyik napra illik?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Membaca tur',
'assistant.step.readingTours': 'Membaca tur-tur',
'assistant.step.tourPois': 'Mencari di sepanjang tur',
'assistant.step.findingRounds': 'Mencari rute melingkar',
'assistant.step.findingWays': 'Mencari jalan lain',
'assistant.step.searchingWanderer': 'Mencari di wanderer',
'assistant.step.readingTrail': 'Membaca jalur',
'assistant.step.checkingSource': 'Memeriksa sumber',
'assistant.followUp.tourEasier': 'Tunjukkan jalan yang lebih mudah',
'assistant.followUp.tourPacking': 'Apa yang harus saya bawa?',
'assistant.followUp.tourFit': 'Tur mana yang cocok untuk hari apa?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Leggo l’escursione',
'assistant.step.readingTours': 'Leggo le escursioni',
'assistant.step.tourPois': 'Cerco lungo l’escursione',
'assistant.step.findingRounds': 'Cerco anelli',
'assistant.step.findingWays': 'Cerco altre vie',
'assistant.step.searchingWanderer': 'Cerco su wanderer',
'assistant.step.readingTrail': 'Leggo il sentiero',
'assistant.step.checkingSource': 'Controllo la fonte',
'assistant.followUp.tourEasier': 'Mostrami un percorso più facile',
'assistant.followUp.tourPacking': 'Cosa dovrei mettere nello zaino?',
'assistant.followUp.tourFit': 'Quale escursione per quale giorno?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'ツアーを読み込み中',
'assistant.step.readingTours': 'ツアー一覧を読み込み中',
'assistant.step.tourPois': 'ツアー沿いを検索中',
'assistant.step.findingRounds': '周回ルートを検索中',
'assistant.step.findingWays': '別のルートを検索中',
'assistant.step.searchingWanderer': 'wandererを検索中',
'assistant.step.readingTrail': 'トレイルを読み込み中',
'assistant.step.checkingSource': '元データを確認中',
'assistant.followUp.tourEasier': 'もっと楽な道を見せて',
'assistant.followUp.tourPacking': '何を持っていけばいい?',
'assistant.followUp.tourFit': 'どのツアーがどの日に合う?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': '투어 읽는 중',
'assistant.step.readingTours': '투어 목록 읽는 중',
'assistant.step.tourPois': '투어를 따라 찾는 중',
'assistant.step.findingRounds': '원점 회귀 코스 찾는 중',
'assistant.step.findingWays': '다른 길 찾는 중',
'assistant.step.searchingWanderer': 'wanderer 검색 중',
'assistant.step.readingTrail': '트레일 읽는 중',
'assistant.step.checkingSource': '출처 확인 중',
'assistant.followUp.tourEasier': '더 쉬운 길 보여줘',
'assistant.followUp.tourPacking': '무엇을 챙겨야 할까?',
'assistant.followUp.tourFit': '어떤 투어가 어떤 날에 맞을까?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Tocht lezen',
'assistant.step.readingTours': 'Tochten lezen',
'assistant.step.tourPois': 'Zoeken langs de tocht',
'assistant.step.findingRounds': 'Rondwandelingen zoeken',
'assistant.step.findingWays': 'Andere wegen zoeken',
'assistant.step.searchingWanderer': 'Zoeken in wanderer',
'assistant.step.readingTrail': 'Pad lezen',
'assistant.step.checkingSource': 'Bron controleren',
'assistant.followUp.tourEasier': 'Laat een makkelijkere weg zien',
'assistant.followUp.tourPacking': 'Wat moet ik meenemen?',
'assistant.followUp.tourFit': 'Welke tocht past bij welke dag?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Czytam wycieczkę',
'assistant.step.readingTours': 'Czytam wycieczki',
'assistant.step.tourPois': 'Szukam wzdłuż wycieczki',
'assistant.step.findingRounds': 'Szukam pętli',
'assistant.step.findingWays': 'Szukam innych dróg',
'assistant.step.searchingWanderer': 'Szukam w wanderer',
'assistant.step.readingTrail': 'Czytam szlak',
'assistant.step.checkingSource': 'Sprawdzam źródło',
'assistant.followUp.tourEasier': 'Pokaż łatwiejszą drogę',
'assistant.followUp.tourPacking': 'Co powinienem spakować?',
'assistant.followUp.tourFit': 'Która wycieczka pasuje do którego dnia?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Читаю тур',
'assistant.step.readingTours': 'Читаю туры',
'assistant.step.tourPois': 'Ищу вдоль тура',
'assistant.step.findingRounds': 'Ищу кольцевые маршруты',
'assistant.step.findingWays': 'Ищу другие пути',
'assistant.step.searchingWanderer': 'Ищу в wanderer',
'assistant.step.readingTrail': 'Читаю тропу',
'assistant.step.checkingSource': 'Проверяю источник',
'assistant.followUp.tourEasier': 'Покажи путь полегче',
'assistant.followUp.tourPacking': 'Что мне взять с собой?',
'assistant.followUp.tourFit': 'Какой тур подходит к какому дню?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Čítam túru',
'assistant.step.readingTours': 'Čítam túry',
'assistant.step.tourPois': 'Hľadám pozdĺž túry',
'assistant.step.findingRounds': 'Hľadám okružné túry',
'assistant.step.findingWays': 'Hľadám iné cesty',
'assistant.step.searchingWanderer': 'Hľadám vo wanderer',
'assistant.step.readingTrail': 'Čítam chodník',
'assistant.step.checkingSource': 'Kontrolujem zdroj',
'assistant.followUp.tourEasier': 'Ukáž ľahšiu cestu',
'assistant.followUp.tourPacking': 'Čo si mám zbaliť?',
'assistant.followUp.tourFit': 'Ktorá túra sa hodí na ktorý deň?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Läser turen',
'assistant.step.readingTours': 'Läser turerna',
'assistant.step.tourPois': 'Letar längs turen',
'assistant.step.findingRounds': 'Letar efter rundturer',
'assistant.step.findingWays': 'Letar efter andra vägar',
'assistant.step.searchingWanderer': 'Söker i wanderer',
'assistant.step.readingTrail': 'Läser leden',
'assistant.step.checkingSource': 'Kontrollerar källan',
'assistant.followUp.tourEasier': 'Visa en lättare väg',
'assistant.followUp.tourPacking': 'Vad ska jag packa?',
'assistant.followUp.tourFit': 'Vilken tur passar vilken dag?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'กำลังอ่านทัวร์',
'assistant.step.readingTours': 'กำลังอ่านรายการทัวร์',
'assistant.step.tourPois': 'กำลังค้นหาตามเส้นทางทัวร์',
'assistant.step.findingRounds': 'กำลังหาเส้นทางวงรอบ',
'assistant.step.findingWays': 'กำลังหาทางอื่น',
'assistant.step.searchingWanderer': 'กำลังค้นหาใน wanderer',
'assistant.step.readingTrail': 'กำลังอ่านเส้นทางเดิน',
'assistant.step.checkingSource': 'กำลังตรวจสอบแหล่งที่มา',
'assistant.followUp.tourEasier': 'แสดงทางที่ง่ายกว่านี้',
'assistant.followUp.tourPacking': 'ควรเตรียมอะไรไปบ้าง?',
'assistant.followUp.tourFit': 'ทัวร์ไหนเหมาะกับวันไหน?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Tur okunuyor',
'assistant.step.readingTours': 'Turlar okunuyor',
'assistant.step.tourPois': 'Tur boyunca aranıyor',
'assistant.step.findingRounds': 'Dairesel yürüyüşler aranıyor',
'assistant.step.findingWays': 'Başka yollar aranıyor',
'assistant.step.searchingWanderer': 'wanderer’da aranıyor',
'assistant.step.readingTrail': 'Patika okunuyor',
'assistant.step.checkingSource': 'Kaynak denetleniyor',
'assistant.followUp.tourEasier': 'Daha kolay bir yol göster',
'assistant.followUp.tourPacking': 'Ne götürmeliyim?',
'assistant.followUp.tourFit': 'Hangi tur hangi güne uyar?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Читаю тур',
'assistant.step.readingTours': 'Читаю тури',
'assistant.step.tourPois': 'Шукаю вздовж туру',
'assistant.step.findingRounds': 'Шукаю кільцеві маршрути',
'assistant.step.findingWays': 'Шукаю інші шляхи',
'assistant.step.searchingWanderer': 'Шукаю у wanderer',
'assistant.step.readingTrail': 'Читаю стежку',
'assistant.step.checkingSource': 'Перевіряю джерело',
'assistant.followUp.tourEasier': 'Покажи легший шлях',
'assistant.followUp.tourPacking': 'Що мені взяти з собою?',
'assistant.followUp.tourFit': 'Який тур пасує до якого дня?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': 'Đang đọc chuyến đi',
'assistant.step.readingTours': 'Đang đọc các chuyến đi',
'assistant.step.tourPois': 'Đang tìm dọc chuyến đi',
'assistant.step.findingRounds': 'Đang tìm cung đường vòng',
'assistant.step.findingWays': 'Đang tìm lối khác',
'assistant.step.searchingWanderer': 'Đang tìm trên wanderer',
'assistant.step.readingTrail': 'Đang đọc đường mòn',
'assistant.step.checkingSource': 'Đang kiểm tra nguồn',
'assistant.followUp.tourEasier': 'Cho xem lối đi dễ hơn',
'assistant.followUp.tourPacking': 'Tôi nên mang theo gì?',
'assistant.followUp.tourFit': 'Chuyến nào hợp với ngày nào?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': '正在讀取路線',
'assistant.step.readingTours': '正在讀取路線清單',
'assistant.step.tourPois': '正在沿路線搜尋',
'assistant.step.findingRounds': '正在尋找環形路線',
'assistant.step.findingWays': '正在尋找其他路徑',
'assistant.step.searchingWanderer': '正在搜尋 wanderer',
'assistant.step.readingTrail': '正在讀取步道',
'assistant.step.checkingSource': '正在檢查來源',
'assistant.followUp.tourEasier': '給我一條更輕鬆的路',
'assistant.followUp.tourPacking': '我該帶些什麼?',
'assistant.followUp.tourFit': '哪條路線適合哪一天?',
+5
View File
@@ -6,6 +6,11 @@ const assistantTour: TranslationStrings = {
'assistant.step.readingTour': '正在读取路线',
'assistant.step.readingTours': '正在读取路线列表',
'assistant.step.tourPois': '正在沿路线搜索',
'assistant.step.findingRounds': '正在寻找环形路线',
'assistant.step.findingWays': '正在寻找其他路径',
'assistant.step.searchingWanderer': '正在搜索 wanderer',
'assistant.step.readingTrail': '正在读取步道',
'assistant.step.checkingSource': '正在检查来源',
'assistant.followUp.tourEasier': '给我一条更轻松的路',
'assistant.followUp.tourPacking': '我该带些什么?',
'assistant.followUp.tourFit': '哪条路线适合哪一天?',
+5
View File
@@ -86,6 +86,10 @@ export * from './tours/tours.metrics';
export * from './tours/hiking-costing';
export * from './tours/hike-plan.schema';
export * from './tours/tour-hike.schema';
export * from './tours/round-hike-geometry';
export * from './tours/round-hike-search';
export * from './tours/hike-ways.schema';
export * from './geo/same-way';
export * from './tours/tour-display.schema';
export * from './tours/line-along';
export * from './tours/hike-schedule';
@@ -99,6 +103,7 @@ export * from './tours/route-corridor';
export * from './tours/horse-route.schema';
export * from './wanderer/wanderer.schema';
export * from './wanderer/wanderer-watch.schema';
export * from './wanderer/wanderer-mcp.schema';
export * from './geo/haversine';
export * from './collection/collection.schema';
export * from './collection/collection-file.schema';
+47
View File
@@ -0,0 +1,47 @@
// HIKE-WAYS-SCHEMA-001 to HIKE-WAYS-SCHEMA-004: what a round hike and the ways of a leg are asked with.
import { hikeLegCount, hikeLegWayRequestSchema, hikeRoundProposeRequestSchema } from './hike-ways.schema';
import { describe, expect, it } from 'vitest';
const A = { lat: 47.5, lng: 11.1, name: 'Car park' };
const B = { lat: 47.52, lng: 11.14 };
const C = { lat: 47.55, lng: 11.2, name: 'Hut' };
describe('hikeRoundProposeRequestSchema', () => {
it('HIKE-WAYS-SCHEMA-001: a round is asked from one point for a length in range', () => {
expect(hikeRoundProposeRequestSchema.parse({ name: 'Round', start: A, distance_km: 12 })).toMatchObject({
distance_km: 12,
});
expect(hikeRoundProposeRequestSchema.safeParse({ name: 'Round', start: A, distance_km: 0.5 }).success).toBe(false);
expect(hikeRoundProposeRequestSchema.safeParse({ name: 'Round', start: A, distance_km: 61 }).success).toBe(false);
expect(
hikeRoundProposeRequestSchema.safeParse({ name: 'Round', start: A, distance_km: 8, round: 'd' }).success,
).toBe(false);
expect(hikeRoundProposeRequestSchema.safeParse({ name: 'Round', start: A, distance_km: 8, extra: 1 }).success).toBe(
false,
);
});
});
describe('hikeLegWayRequestSchema', () => {
it('HIKE-WAYS-SCHEMA-002: a hike has one leg fewer than points, a round trip one more for its way back', () => {
expect(hikeLegCount(3, false)).toBe(2);
expect(hikeLegCount(3, true)).toBe(3);
expect(hikeLegCount(0, false)).toBe(0);
});
it('HIKE-WAYS-SCHEMA-003: only a leg the hike has can be asked for', () => {
const base = { name: 'Hike', waypoints: [A, B, C] };
expect(hikeLegWayRequestSchema.safeParse({ ...base, leg: 2, way: 'b' }).success).toBe(true);
expect(hikeLegWayRequestSchema.safeParse({ ...base, leg: 3 }).success).toBe(false);
expect(hikeLegWayRequestSchema.safeParse({ ...base, leg: 3, round_trip: true }).success).toBe(true);
expect(hikeLegWayRequestSchema.safeParse({ ...base, leg: 0 }).success).toBe(false);
});
it('HIKE-WAYS-SCHEMA-004: the ends of stages hold as a hike’s do', () => {
const staged = { name: 'Trek', waypoints: [{ ...A, stage_end: true }, B, C], leg: 1 };
expect(hikeLegWayRequestSchema.safeParse(staged).success).toBe(false);
const fine = { name: 'Trek', waypoints: [A, { ...B, stage_end: true }, C], leg: 1, tour_id: 4 };
expect(hikeLegWayRequestSchema.safeParse(fine).success).toBe(true);
});
});
+149
View File
@@ -0,0 +1,149 @@
import { idSchema } from '../common/primitives.schema';
import { HIKE_UNROUTED_REASONS, hikePlanPointSchema } from './hike-plan.schema';
import { ROUND_MAX_M, ROUND_MIN_M, ROUND_MOST } from './round-hike-search';
import {
HIKE_MAX_WAYPOINTS,
hikeProposeRequestSchema,
hikeStageIssues,
type HikeProposal,
type HikeStop,
} from './tour-hike.schema';
import { z } from 'zod';
/**
* RTE-02 and RTE-03 through the assistant: a round hike of about a wished
* length out of one point, and the other ways the router knows for one leg of
* a hike. Both are proposals the way propose_hike's are (they read only and
* answer a hike proposal, one way per call), so the chat compares them as the
* ways of one hike and creates the chosen one with create_hike.
*/
/** The letters the ways of one answer go by: the first, second and third found. */
export const HIKE_WAY_KEYS = ['a', 'b', 'c'] as const satisfies readonly string[];
export type HikeWayKey = (typeof HIKE_WAY_KEYS)[number];
const wayKeySchema = z.enum(HIKE_WAY_KEYS);
const {
name,
waypoints,
max_hiking_difficulty,
hiking_preferences,
round_trip,
variant_label,
day_id,
date,
start_time,
include_geometry,
} = hikeProposeRequestSchema.shape;
export const hikeRoundProposeRequestSchema = z
.object({
name: name.describe('What the round is called, such as "Round over the Kesselberg"'),
start: hikePlanPointSchema.describe('Where the round starts and ends: a car park, a station, a hut'),
distance_km: z
.number()
.min(ROUND_MIN_M / 1000)
.max(ROUND_MAX_M / 1000)
.describe(
`How long the round should be, in kilometres (${ROUND_MIN_M / 1000} to ${ROUND_MAX_M / 1000}); a round within a fifth of it either way is offered`,
),
round: wayKeySchema
.optional()
.describe(
`Which of the rounds found to propose: a, b or c (up to ${ROUND_MOST} different ones); a when left out. One search answers all three, so ask for a, b and c in one step to compare them`,
),
max_hiking_difficulty,
hiking_preferences,
variant_label,
day_id,
date,
start_time,
include_geometry,
})
.strict();
export type HikeRoundProposeRequest = z.infer<typeof hikeRoundProposeRequestSchema>;
/** How many legs a hike through `count` points has, its way back included for a round trip. */
export function hikeLegCount(count: number, roundTrip: boolean | undefined): number {
return Math.max(0, count - 1 + (roundTrip === true ? 1 : 0));
}
export const hikeLegWayRequestSchema = z
.object({
name,
waypoints,
round_trip,
leg: z
.number()
.int()
.min(1)
.max(HIKE_MAX_WAYPOINTS)
.describe(
'The leg to find other ways for, counted from 1: leg 1 runs from the first point to the second; on a round trip the last leg is the way back to the start',
),
way: wayKeySchema
.optional()
.describe(
'Which of the other ways to propose: a, b or c, in the order the router offers them; a when left out. Ask for a, b and c in one step to compare them',
),
tour_id: idSchema
.optional()
.describe(
'The saved hike these points are from (its place id, as list_tours gives it): the ways are then changes to that hike, made with update_tour, not new hikes',
),
max_hiking_difficulty,
hiking_preferences,
day_id,
date,
start_time,
include_geometry,
})
.strict()
.superRefine((value, ctx) => {
for (const issue of hikeStageIssues(value.waypoints, value.round_trip)) {
ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message });
}
if (value.leg > hikeLegCount(value.waypoints.length, value.round_trip)) {
ctx.addIssue({ code: 'custom', path: ['leg'], message: 'The hike has no such leg' });
}
});
export type HikeLegWayRequest = z.infer<typeof hikeLegWayRequestSchema>;
/** Why no round or no other way could be proposed: the router's reasons, or a search that found none. */
export const HIKE_WAY_MISSES = [...HIKE_UNROUTED_REASONS, 'none_found'] as const;
export type HikeWayMiss = (typeof HIKE_WAY_MISSES)[number];
/** The points a proposed way is created or changed through. */
export interface HikeWayPoints {
/** In order, with the stops a day ends at on a hike over several days. */
waypoints: HikeStop[];
round_trip: boolean;
}
/** A way asked for that is not there: which, how many there are, and why none or fewer. */
export interface HikeWayMissing {
found: false;
way: HikeWayKey;
ways_found: number;
why: HikeWayMiss;
}
/** A round found: the hike proposal of it, with how it stands to the wish and the points to create it through. */
export type HikeRoundProposal = HikeProposal & {
found: true;
round: { key: HikeWayKey; target_m: number; deviation_pct: number; rounds_found: number };
create_with: HikeWayPoints;
};
/** Another way for one leg: the whole hike proposed with the leg taken that way, and the points it runs through. */
export type HikeLegWayProposal = HikeProposal & {
found: true;
leg_way: { key: HikeWayKey; leg: number; ways_found: number; tour_id: number | null };
/** The hike as it runs now, for comparison: its length, climb and walking time. */
current: { distance_m: number | null; ascent_m: number | null; duration_minutes: number | null };
/** The hike as it runs now, thinned for drawing; only with include_geometry. */
current_geometry?: HikeProposal['line_geometry'];
create_with: HikeWayPoints;
};
@@ -0,0 +1,78 @@
// TOUR-ROUND-GEOMETRY-001 to TOUR-ROUND-GEOMETRY-005: where a round hike's loop is steered
// through, and how much of a line covers ground twice or the ground of another (RTE-03).
import { haversineMetres } from '../geo/haversine';
import { loopSpots, retracedShare, sharedShare, spotToward, STRAIGHT_PER_RADIUS_M } from './round-hike-geometry';
import { describe, expect, it } from 'vitest';
const START = { lat: 47.5, lng: 11.1 };
type Spot = { lat: number; lng: number };
const meters = (a: Spot | undefined, b: Spot | undefined) =>
a && b ? haversineMetres(a.lat, a.lng, b.lat, b.lng) : NaN;
const line = (...spots: { lat: number; lng: number }[]): [number, number][] => spots.map(({ lat, lng }) => [lat, lng]);
describe('spotToward', () => {
it('TOUR-ROUND-GEOMETRY-001: lies the asked distance away in the asked direction', () => {
const north = spotToward(START, 0, 1000);
expect(meters(START, north)).toBeCloseTo(1000, 0);
expect(north.lat).toBeGreaterThan(START.lat);
expect(north.lng).toBeCloseTo(START.lng, 9);
const east = spotToward(START, 90, 2500);
expect(meters(START, east)).toBeCloseTo(2500, 0);
expect(east.lng).toBeGreaterThan(START.lng);
// Across the date line the longitude comes back into range.
expect(spotToward({ lat: 0, lng: 179.999 }, 90, 1000).lng).toBeLessThan(-179.99);
});
});
describe('loopSpots', () => {
it('TOUR-ROUND-GEOMETRY-002: three corners of a square in a circle touching the start, its centre toward the bearing', () => {
const radius = 1000;
const spots = loopSpots(START, 90, radius);
expect(spots).toHaveLength(3);
const centre = spotToward(START, 90, radius);
for (const spot of spots) expect(meters(centre, spot)).toBeCloseTo(radius, 0);
// The far corner lies opposite the start, a diameter away.
expect(meters(START, spots[1])).toBeCloseTo(2 * radius, 0);
const corners = [START, ...spots, START];
let straight = 0;
for (let i = 1; i < corners.length; i += 1) straight += meters(corners[i - 1], corners[i]);
expect(straight).toBeCloseTo(STRAIGHT_PER_RADIUS_M * radius, -1);
});
});
describe('retracedShare', () => {
it('TOUR-ROUND-GEOMETRY-003: a loop covers little ground twice, a way there and back all of it', () => {
const loop = line(START, ...loopSpots(START, 0, 800), START);
expect(retracedShare(loop)).toBeLessThan(0.1);
const far = spotToward(START, 45, 3000);
expect(retracedShare(line(START, far, START))).toBeGreaterThan(0.9);
// A loop with a long way in and out shares that way.
const gate = spotToward(START, 0, 1500);
const lollipop = line(START, gate, ...loopSpots(gate, 0, 300), gate, START);
expect(retracedShare(lollipop)).toBeGreaterThan(0.4);
});
it('TOUR-ROUND-GEOMETRY-004: a line too short to sample shares nothing', () => {
expect(retracedShare([])).toBe(0);
expect(retracedShare(line(START))).toBe(0);
expect(retracedShare(line(START, START))).toBe(0);
});
});
describe('sharedShare', () => {
it('TOUR-ROUND-GEOMETRY-005: the share of one line on the ground of another', () => {
const loop = line(START, ...loopSpots(START, 0, 800), START);
expect(sharedShare(loop, loop)).toBe(1);
const other = line(START, ...loopSpots(START, 180, 800), START);
expect(sharedShare(loop, other)).toBeLessThan(0.1);
// Half of a line lies on the other: the share says so, one way round.
const half = line(START, spotToward(START, 0, 1000));
const whole = line(START, spotToward(START, 0, 2000));
expect(sharedShare(half, whole)).toBe(1);
expect(sharedShare(whole, half)).toBeCloseTo(0.5, 1);
expect(sharedShare([], loop)).toBe(0);
expect(sharedShare(loop, line(START))).toBe(0);
expect(sharedShare(line(START, START), loop)).toBe(1);
});
});
+147
View File
@@ -0,0 +1,147 @@
/**
* RTE-03: the plain geometry a round hike is searched with. Where the loop is
* steered through, and how much of a routed line covers the same ground twice
* or the ground of another line. Nothing here asks a router: the planner in
* the browser and the assistant on the server search with the same rules.
*/
type Spot = { lat: number; lng: number };
type Line = readonly (readonly [number, number])[];
const EARTH_M = 6_371_000;
const toRad = (deg: number) => (deg * Math.PI) / 180;
const toDeg = (rad: number) => (rad * 180) / Math.PI;
/** The spot `meters` away from `from` toward `bearingDeg` (clockwise from north), on the sphere. */
export function spotToward(from: Spot, bearingDeg: number, meters: number): Spot {
const angle = meters / EARTH_M;
const bearing = toRad(bearingDeg);
const lat1 = toRad(from.lat);
const lng1 = toRad(from.lng);
const lat2 = Math.asin(Math.sin(lat1) * Math.cos(angle) + Math.cos(lat1) * Math.sin(angle) * Math.cos(bearing));
const lng2 =
lng1 +
Math.atan2(Math.sin(bearing) * Math.sin(angle) * Math.cos(lat1), Math.cos(angle) - Math.sin(lat1) * Math.sin(lat2));
return { lat: toDeg(lat2), lng: ((toDeg(lng2) + 540) % 360) - 180 };
}
/** The loop's length in straight lines from corner to corner, per metre of its circle's radius: a square's. */
export const STRAIGHT_PER_RADIUS_M = 4 * Math.SQRT2;
/**
* The three spots a loop out of `start` is steered through: the other corners
* of a square in a circle of `radiusM` that touches the start, its centre
* toward `bearingDeg`. Walked in order, the loop goes round the circle once
* and comes back to the start from the other side.
*/
export function loopSpots(start: Spot, bearingDeg: number, radiusM: number): Spot[] {
const centre = spotToward(start, bearingDeg, radiusM);
// Seen from the centre the start lies the other way; the corners follow it a quarter turn apart.
const towardStart = bearingDeg + 180;
return [90, 180, 270].map((turn) => spotToward(centre, towardStart + turn, radiusM));
}
/** Samples are taken along a line this far apart. */
const SAMPLE_M = 20;
/** Two samples this close lie on the same ground: about the width of a path and the router's drawing of it. */
const SAME_GROUND_M = 25;
/**
* Ground passed twice only counts when the two passes lie this far apart
* along the line: the samples next to one another on a bend are one pass.
*/
const SECOND_PASS_M = 300;
interface Sample {
x: number;
y: number;
along: number;
}
type Projector = (point: readonly [number, number]) => { x: number; y: number };
/** A flat projection in metres around one latitude, good for the few kilometres a hike spans. */
function projector(refLat: number): Projector {
const perDegLat = (Math.PI * EARTH_M) / 180;
const perDegLng = perDegLat * Math.cos(toRad(refLat));
return ([lat, lng]) => ({ x: lng * perDegLng, y: lat * perDegLat });
}
/** The line as samples every few metres, each with how far along the line it lies. */
function samplesOf(line: Line, project: Projector): Sample[] {
const samples: Sample[] = [];
let along = 0;
let next = 0;
for (let i = 1; i < line.length; i += 1) {
const before = line[i - 1];
const here = line[i];
if (!before || !here) continue;
const a = project(before);
const b = project(here);
const length = Math.hypot(b.x - a.x, b.y - a.y);
while (next <= along + length) {
const share = length > 0 ? (next - along) / length : 0;
samples.push({ x: a.x + (b.x - a.x) * share, y: a.y + (b.y - a.y) * share, along: next });
next += SAMPLE_M;
}
along += length;
}
return samples;
}
const cellOf = (value: number) => Math.floor(value / SAME_GROUND_M);
/** The samples by the grid cell they lie in, so a neighbour is looked for among a few, not all. */
function gridOf(samples: readonly Sample[]): Map<string, Sample[]> {
const grid = new Map<string, Sample[]>();
for (const sample of samples) {
const key = `${cellOf(sample.x)}:${cellOf(sample.y)}`;
const cell = grid.get(key);
if (cell) cell.push(sample);
else grid.set(key, [sample]);
}
return grid;
}
/** Whether a sample of `grid` that `counts` lies on the same ground as `sample`. */
function hasNeighbour(grid: Map<string, Sample[]>, sample: Sample, counts: (other: Sample) => boolean): boolean {
const cx = cellOf(sample.x);
const cy = cellOf(sample.y);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
const near = grid
.get(`${cx + dx}:${cy + dy}`)
?.some((other) => Math.hypot(other.x - sample.x, other.y - sample.y) <= SAME_GROUND_M && counts(other));
if (near) return true;
}
}
return false;
}
/**
* The share of a line that covers ground it passes a second time, from 0 to
* 1. A loop with a short way in and out shares little; the same way there and
* back shares all of it.
*/
export function retracedShare(line: Line): number {
const first = line[0];
if (!first || line.length < 2) return 0;
const samples = samplesOf(line, projector(first[0]));
if (samples.length < 2) return 0;
const grid = gridOf(samples);
const twice = samples.filter((sample) =>
hasNeighbour(grid, sample, (other) => Math.abs(other.along - sample.along) > SECOND_PASS_M),
).length;
return twice / samples.length;
}
/** The share of line `a` that lies on the ground of line `b`, from 0 to 1. */
export function sharedShare(a: Line, b: Line): number {
const first = a[0];
if (!first || a.length < 2 || b.length < 2) return 0;
const project = projector(first[0]);
const samples = samplesOf(a, project);
if (samples.length === 0) return 0;
const grid = gridOf(samplesOf(b, project));
const shared = samples.filter((sample) => hasNeighbour(grid, sample, () => true)).length;
return shared / samples.length;
}
+113
View File
@@ -0,0 +1,113 @@
// TOUR-ROUND-SEARCH-001 to TOUR-ROUND-SEARCH-008: the search for round hikes of about a wished
// length, with the router stood in for: which loops are asked and which are offered (RTE-03).
import { haversineMetres } from '../geo/haversine';
import { spotToward } from './round-hike-geometry';
import {
deviationOf,
distanceWindow,
fitsWish,
isRoundTarget,
MAX_ROUND_ASKS,
ROUND_MAX_M,
ROUND_MIN_M,
ROUND_MOST,
searchRoundLoops,
type RoundLoop,
} from './round-hike-search';
import { describe, expect, it, vi } from 'vitest';
type Spot = { lat: number; lng: number };
const START = { lat: 47.5, lng: 11.1 };
/** A line through the spots, and its length along them. */
function loopThrough(spots: Spot[]): RoundLoop {
const points = [START, ...spots, START];
let distanceM = 0;
for (let i = 1; i < points.length; i += 1) {
const a = points[i - 1];
const b = points[i];
if (a && b) distanceM += haversineMetres(a.lat, a.lng, b.lat, b.lng);
}
return { coordinates: points.map(({ lat, lng }): [number, number] => [lat, lng]), via: spots, distanceM };
}
describe('the wished length', () => {
it('TOUR-ROUND-SEARCH-001: a round fits within a fifth either way, and its deviation is signed', () => {
const { minM, maxM } = distanceWindow(10_000);
expect(minM).toBeCloseTo(8_000);
expect(maxM).toBeCloseTo(12_000);
expect(fitsWish(8_001, 10_000)).toBe(true);
expect(fitsWish(11_999, 10_000)).toBe(true);
expect(fitsWish(7_999, 10_000)).toBe(false);
expect(fitsWish(12_001, 10_000)).toBe(false);
expect(deviationOf(10_800, 10_000)).toBeCloseTo(0.08);
expect(deviationOf(9_500, 10_000)).toBeCloseTo(-0.05);
});
it('TOUR-ROUND-SEARCH-002: only a number in range can be asked for', () => {
expect(isRoundTarget(null)).toBe(false);
expect(isRoundTarget(Number.NaN)).toBe(false);
expect(isRoundTarget(ROUND_MIN_M - 1)).toBe(false);
expect(isRoundTarget(ROUND_MIN_M)).toBe(true);
expect(isRoundTarget(ROUND_MAX_M)).toBe(true);
expect(isRoundTarget(ROUND_MAX_M + 1)).toBe(false);
});
});
describe('searchRoundLoops', () => {
it('TOUR-ROUND-SEARCH-003: three different rounds within a fifth of the wish, a short first loop asked again larger', async () => {
const ask = vi.fn(async (spots: Spot[]) => loopThrough(spots));
const steps: number[] = [];
const state = await searchRoundLoops(START, 10_000, ask, { onStep: ({ found }) => steps.push(found.length) });
expect(state.found).toHaveLength(ROUND_MOST);
expect(state.asked).toBe(4);
expect(state.answered).toBe(4);
for (const round of state.found) expect(fitsWish(round.distanceM, 10_000)).toBe(true);
expect(steps).toEqual([1, 2, 3]);
for (const [spots] of ask.mock.calls) expect(spots).toHaveLength(3);
});
it('TOUR-ROUND-SEARCH-004: a router that never answers leaves nothing answered', async () => {
const state = await searchRoundLoops(START, 10_000, async () => undefined);
expect(state).toEqual({ found: [], asked: 6, answered: 0 });
});
it('TOUR-ROUND-SEARCH-005: a way there and back is no round, however well it fits', async () => {
const far = spotToward(START, 0, 5_000);
const state = await searchRoundLoops(START, 10_000, async () => ({ ...loopThrough([far]), via: [far] }));
expect(state.found).toEqual([]);
expect(state.answered).toBeGreaterThan(0);
});
it('TOUR-ROUND-SEARCH-006: a loop with a spot off the paths is not asked again at another size', async () => {
const ask = vi.fn(async () => null);
const state = await searchRoundLoops(START, 10_000, ask);
expect(ask).toHaveBeenCalledTimes(6);
expect(state.found).toEqual([]);
});
it('TOUR-ROUND-SEARCH-007: the same round found again is offered once, and the questions stay bounded', async () => {
const fixed = loopThrough([0, 90, 180].map((bearing) => spotToward(START, bearing, 2_400)));
expect((await searchRoundLoops(START, 10_000, async () => fixed)).found).toHaveLength(1);
const huge = loopThrough([spotToward(START, 0, 30_000), spotToward(START, 90, 30_000)]);
const ask = vi.fn(async () => huge);
const state = await searchRoundLoops(START, 10_000, ask);
expect(ask).toHaveBeenCalledTimes(MAX_ROUND_ASKS);
expect(state).toMatchObject({ found: [], asked: MAX_ROUND_ASKS });
});
it('TOUR-ROUND-SEARCH-008: nothing more is asked or heard once the search is called off', async () => {
const signal = { aborted: false };
const onStep = vi.fn();
const ask = vi.fn(async (spots: Spot[]) => {
signal.aborted = true;
return loopThrough(spots);
});
const state = await searchRoundLoops(START, 10_000, ask, { signal, onStep });
expect(ask).toHaveBeenCalledTimes(1);
expect(onStep).not.toHaveBeenCalled();
expect(state.answered).toBe(0);
});
});
+188
View File
@@ -0,0 +1,188 @@
import { haversineMetres } from '../geo/haversine';
import { loopSpots, retracedShare, sharedShare, STRAIGHT_PER_RADIUS_M } from './round-hike-geometry';
/**
* RTE-03: the search for round hikes of about a wished length out of a start
* point, without the router: the planner in the browser asks its Valhalla,
* the assistant on the server asks the server's, and both look through the
* same directions, sizes and rules. `ask` plans one loop through the spots it
* is handed; everything else, which loop is asked next and which is a round
* worth offering, is decided here.
*/
type Spot = { lat: number; lng: number };
/** How far a round may lie off the wished length and still be offered: a fifth either way. */
export const ROUND_TOLERANCE = 0.2;
/** The shortest round asked for, in metres: below it there is hardly a loop to walk. */
export const ROUND_MIN_M = 1_000;
/** The longest round asked for, in metres: a long day on foot, and as far as the router is asked to plan in one go. */
export const ROUND_MAX_M = 60_000;
/** The length a search starts with, in metres, until one was asked for. */
export const ROUND_DEFAULT_M = 10_000;
/** The most rounds one search offers, lettered like the other ways of a section. */
export const ROUND_MOST = 3;
/**
* The most questions one search puts to the router. The public instance
* answers one a second, so a search ends within about a quarter of a minute.
*/
export const MAX_ROUND_ASKS = 12;
/**
* The directions a search sends its loops in, in turn: three a third of a
* turn apart first, so the first rounds found lie on different sides of the
* start, then the three between them.
*/
const BEARINGS = [0, 120, 240, 60, 180, 300] as const;
/** How much longer the paths are than the straight lines between the corners, until the router said. */
const FIRST_DETOUR = 1.3;
/** A loop walking more than this share of its ground twice is an out and back, not a round. */
const MAX_RETRACED_SHARE = 0.4;
/** Two rounds sharing more than this share of their ground are one round, walked a little apart. */
const MAX_SHARED_SHARE = 0.6;
/** A loop that missed the length is asked again once, its circle scaled within these bounds. */
const RESCALE = { min: 0.5, max: 2 } as const;
/** The shortest and longest round that fits a wish of `targetM`. */
export function distanceWindow(targetM: number): { minM: number; maxM: number } {
return { minM: targetM * (1 - ROUND_TOLERANCE), maxM: targetM * (1 + ROUND_TOLERANCE) };
}
/** Whether a round of `distanceM` fits a wish of `targetM`. */
export function fitsWish(distanceM: number, targetM: number): boolean {
const { minM, maxM } = distanceWindow(targetM);
return distanceM >= minM && distanceM <= maxM;
}
/** How far a round lies off the wish, as a signed share: 0.08 is 8 % longer, -0.05 is 5 % shorter. */
export function deviationOf(distanceM: number, targetM: number): number {
return (distanceM - targetM) / targetM;
}
/** Whether a wished length can be asked for at all. */
export function isRoundTarget(targetM: number | null): targetM is number {
return targetM !== null && Number.isFinite(targetM) && targetM >= ROUND_MIN_M && targetM <= ROUND_MAX_M;
}
/** A loop the router planned out of the start and back: its line, where it met the steering spots, how far it is. */
export interface RoundLoop {
coordinates: [number, number][];
/** Where the line passes the spots it was steered through: the shaping points a round is planned by. */
via: Spot[];
/** Measured along the line. */
distanceM: number;
}
/**
* One loop asked of the router through `[start, ...spots, start]`: the loop,
* null when a spot lies too far from any path to steer through, undefined
* when the router gave no answer.
*/
export type RoundLoopAsk = (spots: Spot[]) => Promise<RoundLoop | null | undefined>;
/** What a search has found and asked so far. */
export interface RoundSearchState {
/** The rounds to offer, in the order they were found, at most ROUND_MOST. */
found: RoundLoop[];
/** The questions put, at most MAX_ROUND_ASKS. */
asked: number;
/** The questions the router answered; none at all is a failed search. */
answered: number;
}
export interface RoundSearchOptions {
/** Read after every question: once it says aborted, nothing more is asked. */
signal?: { readonly aborted: boolean };
/** Hears the state after each direction, so the rounds can show up as they are found. */
onStep?: (state: Readonly<RoundSearchState>) => void;
}
const clamp = (value: number, { min, max }: { readonly min: number; readonly max: number }) =>
Math.min(max, Math.max(min, value));
/** The circle a loop of `targetM` is steered round, for paths `detour` times longer than straight lines. */
const radiusFor = (targetM: number, detour: number) => targetM / (detour * STRAIGHT_PER_RADIUS_M);
/** The loop in straight lines from corner to corner, in metres. */
function straightLength(spots: readonly Spot[]): number {
let metres = 0;
for (let i = 1; i < spots.length; i += 1) {
const a = spots[i - 1];
const b = spots[i];
if (a && b) metres += haversineMetres(a.lat, a.lng, b.lat, b.lng);
}
return metres;
}
/**
* What a planned loop is worth: a round to offer, a loop to ask again larger
* or smaller, or nothing (an out and back, or a round already found).
*/
function judge(loop: RoundLoop, targetM: number, found: readonly RoundLoop[]): 'round' | 'rescale' | 'drop' {
if (!fitsWish(loop.distanceM, targetM)) return 'rescale';
if (retracedShare(loop.coordinates) > MAX_RETRACED_SHARE) return 'drop';
const known = found.some(
(round) =>
Math.max(sharedShare(loop.coordinates, round.coordinates), sharedShare(round.coordinates, loop.coordinates)) >
MAX_SHARED_SHARE,
);
return known ? 'drop' : 'round';
}
interface Search {
start: Spot;
targetM: number;
ask: RoundLoopAsk;
signal: { readonly aborted: boolean } | undefined;
state: RoundSearchState;
detour: number;
}
/**
* The loops in one direction: asked once at the size the detour so far
* suggests, and once more scaled by how far the first missed the length.
*/
async function searchBearing(search: Search, bearing: number): Promise<void> {
const { start, targetM, state } = search;
let radius = radiusFor(targetM, search.detour);
for (let attempt = 0; attempt < 2 && state.asked < MAX_ROUND_ASKS; attempt += 1) {
const spots = loopSpots(start, bearing, radius);
const loop = await search.ask(spots);
state.asked += 1;
if (search.signal?.aborted || loop === undefined) return;
state.answered += 1;
if (!loop) return;
// The paths here are this much longer than the straight lines: the next circle is drawn for them.
const straightM = straightLength([start, ...spots, start]);
if (straightM > 0) search.detour = clamp(loop.distanceM / straightM, { min: 1, max: 3 });
const verdict = judge(loop, targetM, state.found);
if (verdict === 'round') state.found.push(loop);
if (verdict !== 'rescale') return;
radius *= clamp(targetM / loop.distanceM, RESCALE);
}
}
/**
* Up to ROUND_MOST different round hikes out of `start`, each within a fifth
* of `targetM`. Loops are steered round circles in several directions; a
* loop that misses the length is asked once more at a corrected size, one
* that walks its ground twice or repeats a round found is left out. At most
* MAX_ROUND_ASKS questions; once `signal` aborts, nothing more is asked and
* `onStep` hears nothing more.
*/
export async function searchRoundLoops(
start: Spot,
targetM: number,
ask: RoundLoopAsk,
options: RoundSearchOptions = {},
): Promise<RoundSearchState> {
const state: RoundSearchState = { found: [], asked: 0, answered: 0 };
const search: Search = { start, targetM, ask, signal: options.signal, state, detour: FIRST_DETOUR };
for (const bearing of BEARINGS) {
if (state.found.length >= ROUND_MOST || state.asked >= MAX_ROUND_ASKS) break;
await searchBearing(search, bearing);
if (options.signal?.aborted) return state;
options.onStep?.({ found: [...state.found], asked: state.asked, answered: state.answered });
}
return state;
}
@@ -0,0 +1,34 @@
// WANDERER-MCP-SCHEMA-001 to WANDERER-MCP-SCHEMA-002: the wanderer routes as the assistant asks them.
import {
wandererMcpImportRequestSchema,
wandererMcpPreviewRequestSchema,
wandererMcpSearchRequestSchema,
wandererMcpSourceRequestSchema,
} from './wanderer-mcp.schema';
import { describe, expect, it } from 'vitest';
describe('wanderer over MCP', () => {
it('WANDERER-MCP-SCHEMA-001: a search takes words, a page and a part of the map, nothing else', () => {
expect(wandererMcpSearchRequestSchema.parse({ q: ' Zugspitze ', page: 2 })).toEqual({ q: 'Zugspitze', page: 2 });
expect(wandererMcpSearchRequestSchema.parse({})).toEqual({});
expect(wandererMcpSearchRequestSchema.safeParse({ page: 26 }).success).toBe(false);
expect(
wandererMcpSearchRequestSchema.safeParse({ bbox: { south: 48, west: 11, north: 47, east: 12 } }).success,
).toBe(false);
expect(wandererMcpSearchRequestSchema.safeParse({ q: 'x', token: 'y' }).success).toBe(false);
});
it('WANDERER-MCP-SCHEMA-002: a preview, an import and a check name their trail or tour', () => {
expect(wandererMcpPreviewRequestSchema.safeParse({ trailId: 'abc' }).success).toBe(true);
expect(wandererMcpPreviewRequestSchema.safeParse({ trailId: '' }).success).toBe(false);
const hash = 'a'.repeat(32);
expect(wandererMcpImportRequestSchema.parse({ trailId: 'abc', contentHash: hash })).toEqual({
trailId: 'abc',
contentHash: hash,
});
expect(wandererMcpImportRequestSchema.safeParse({ trailId: 'abc', contentHash: 'short' }).success).toBe(false);
expect(wandererMcpSourceRequestSchema.safeParse({ placeId: 3 }).success).toBe(true);
expect(wandererMcpSourceRequestSchema.safeParse({ placeId: 0 }).success).toBe(false);
});
});
@@ -0,0 +1,58 @@
import { idSchema } from '../common/primitives.schema';
import { includeGeometryFlagSchema } from '../tours/tour-display.schema';
import { wandererBboxSchema, wandererImportRequestSchema, WANDERER_SEARCH_MAX_PAGE } from './wanderer.schema';
import { z } from 'zod';
/**
* The trip-side wanderer routes as the assistant asks them over MCP: the same
* search, preview, import and source check, with each field described for a
* model. The REST bodies stay as they are (`wandererSearchRequestSchema`,
* `wandererImportRequestSchema`); these take the same values.
*/
const trailIdSchema = wandererImportRequestSchema.shape.trailId.describe(
'The trail on the wanderer instance, as search_wanderer_trails gives it (id)',
);
export const wandererMcpSearchRequestSchema = z
.object({
q: z
.string()
.trim()
.max(200)
.optional()
.describe('Words to look for in the trails, such as a place, a summit or a region; every trail when left out'),
page: z
.number()
.int()
.min(1)
.max(WANDERER_SEARCH_MAX_PAGE)
.optional()
.describe('The page of results, from 1; 1 when left out'),
bbox: wandererBboxSchema
.optional()
.describe('Only trails that run through this part of the map: south, west, north and east in degrees'),
})
.strict();
export type WandererMcpSearchRequest = z.infer<typeof wandererMcpSearchRequestSchema>;
export const wandererMcpPreviewRequestSchema = z
.object({ trailId: trailIdSchema, include_geometry: includeGeometryFlagSchema })
.strict();
export type WandererMcpPreviewRequest = z.infer<typeof wandererMcpPreviewRequestSchema>;
export const wandererMcpImportRequestSchema = z
.object({
trailId: trailIdSchema,
contentHash: wandererImportRequestSchema.shape.contentHash.describe(
'The contentHash preview_wanderer_trail gave for the trail: what the travellers saw is what is imported, or nothing',
),
})
.strict();
export type WandererMcpImportRequest = z.infer<typeof wandererMcpImportRequestSchema>;
export const wandererMcpSourceRequestSchema = z
.object({ placeId: idSchema.describe('The tour imported from wanderer (origin wanderer), as list_tours gives it') })
.strict();
export type WandererMcpSourceRequest = z.infer<typeof wandererMcpSourceRequestSchema>;