mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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': 'أي جولة تناسب أي يوم؟',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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 ?',
|
||||
|
||||
@@ -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': 'Ποια εκδρομή ταιριάζει σε ποια μέρα;',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': 'どのツアーがどの日に合う?',
|
||||
|
||||
@@ -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': '어떤 투어가 어떤 날에 맞을까?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': 'Какой тур подходит к какому дню?',
|
||||
|
||||
@@ -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ň?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': 'ทัวร์ไหนเหมาะกับวันไหน?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': 'Який тур пасує до якого дня?',
|
||||
|
||||
@@ -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?',
|
||||
|
||||
@@ -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': '哪條路線適合哪一天?',
|
||||
|
||||
@@ -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': '哪条路线适合哪一天?',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in New Issue
Block a user