mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14196358fb |
@@ -134,6 +134,9 @@ const TOUR_PLANNING_TOOLS: ReadonlySet<string> = new Set([
|
||||
'plan_ski_route',
|
||||
'prepare_tour_change',
|
||||
'save_tour_change',
|
||||
'propose_round_hike',
|
||||
'propose_hike_leg_way',
|
||||
'import_wanderer_trail',
|
||||
]);
|
||||
|
||||
/** The tool that keeps a note in the trip notes; kept at once, without a card. */
|
||||
|
||||
@@ -106,6 +106,11 @@ export const TOUR_LOOK = {
|
||||
find_tours_for_day: { stepKey: 'assistant.step.readingTours' },
|
||||
search_tour_pois: { stepKey: 'assistant.step.tourPois' },
|
||||
get_city_walk_progress: { stepKey: 'assistant.step.readingTour' },
|
||||
propose_round_hike: { stepKey: 'assistant.step.findingRounds' },
|
||||
propose_hike_leg_way: { stepKey: 'assistant.step.findingWays' },
|
||||
search_wanderer_trails: { stepKey: 'assistant.step.searchingWanderer' },
|
||||
preview_wanderer_trail: { stepKey: 'assistant.step.readingTrail' },
|
||||
check_tour_source: { stepKey: 'assistant.step.checkingSource' },
|
||||
},
|
||||
entities: {
|
||||
tour: {
|
||||
|
||||
@@ -143,6 +143,8 @@ describe('tourRouteModel', () => {
|
||||
expect(canTake(SAVED)).toBe(false);
|
||||
expect(canTake({ ...HIKE, activity: 'kayak', source: 'plan' })).toBe(false);
|
||||
expect(canTake({ ...HIKE, source: 'pending' })).toBe(false);
|
||||
// Another way for a leg of a saved hike changes that hike; it is not created anew.
|
||||
expect(canTake({ ...HIKE, placeId: 12 })).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-AI-TOUR-010: proposals of one answer form ways of three at most; anything else stands alone', () => {
|
||||
|
||||
@@ -227,10 +227,12 @@ export function routePoints(card: TourRouteCard): [number, number][] {
|
||||
|
||||
/**
|
||||
* Whether "Plan this" can take the proposal: a hike whose way was planned,
|
||||
* or a city walk. A paddle or ski plan, a saved tour and a held change cannot.
|
||||
* or a city walk. A paddle or ski plan, a saved tour, a held change and the
|
||||
* other way of a saved hike's leg (named by its place, changed with
|
||||
* update_tour rather than created anew) cannot.
|
||||
*/
|
||||
export function canTake(card: TourRouteCard): boolean {
|
||||
if (card.source !== 'proposal') return false;
|
||||
if (card.source !== 'proposal' || card.placeId !== null) return false;
|
||||
return card.activity === 'walk' || (card.activity === 'hike' && card.routed);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
calculateAlternatives,
|
||||
avoidedClasses,
|
||||
routeEngineFor,
|
||||
sameRoad,
|
||||
furthestFrom,
|
||||
osrmLegSegment,
|
||||
} from './RouteCalculator'
|
||||
|
||||
@@ -1204,37 +1202,6 @@ describe('a trip that avoids something', () => {
|
||||
expect(plain.avoidance).toBeUndefined()
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-064: a road that runs the whole of another and turns off to a point and back is not that road', () => {
|
||||
// The shape of a leg bent by a via: every point of the plain road lies on it, so
|
||||
// asked only one way round the two read as one.
|
||||
const plainRoad = { coordinates: [[61, 10], [61, 11], [61, 12]] as [number, number][], distance: 100_000, duration: 3600 }
|
||||
const spur = { coordinates: [[61, 10], [61, 11], [61.3, 11], [61, 11], [61, 12]] as [number, number][], 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('FE-COMP-ROUTECALCULATOR-065: one road drawn with a different spacing of points is still one road', () => {
|
||||
// Two engines' lines of one long road share almost no vertex. Measured to the nearest
|
||||
// sampled vertex, a point on the same road sat half a sample off it: kilometres here.
|
||||
// The dense line is sampled every tenth vertex, 0.05 degrees apart; the sparse one sits
|
||||
// exactly between those samples, 1.8 km from the nearest, on the very same road.
|
||||
const dense = Array.from({ length: 2001 }, (_, i) => [50, i * 0.005] as [number, number])
|
||||
const sparse: [number, number][] = [
|
||||
[50, 0],
|
||||
...Array.from({ length: 200 }, (_, i) => [50, 0.025 + i * 0.05] as [number, number]),
|
||||
[50, 10],
|
||||
]
|
||||
const a = { coordinates: dense, distance: 700_000, duration: 25_000 }
|
||||
const b = { coordinates: sparse, distance: 712_000, duration: 27_000 }
|
||||
|
||||
expect(sameRoad(a, b)).toBe(true)
|
||||
const off = furthestFrom([[50.02, 5]], dense)
|
||||
expect(off?.index).toBe(0)
|
||||
expect(off?.km).toBeCloseTo(2.22, 1)
|
||||
})
|
||||
|
||||
it('FE-COMP-ROUTECALCULATOR-066: the avoidance offer beside an OSRM leg says when it crosses by ferry', async () => {
|
||||
server.use(
|
||||
http.get(`${FOSSGIS.driving}/:coords`, () => HttpResponse.json({ code: 'Ok', routes: [{ geometry: { coordinates: [[10, 63], [11, 63], [12, 63]] }, distance: 100000, duration: 3600 }] })),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { haversineKm } from '../../utils/geo'
|
||||
import { formatDistance } from '../../utils/units'
|
||||
import { countRoute } from './routeUsageCounter'
|
||||
import { valhallaRouteAvoiding, valhallaRun, valhallaAvailable, valhallaAlternates, valhallaSpacing, legAvoids, type AvoidClass, type ValhallaLeg } from './valhallaRoute'
|
||||
import type { RouteUsageSurface } from '@trek/shared'
|
||||
import { furthestFrom, sameRoad, type RouteUsageSurface } from '@trek/shared'
|
||||
|
||||
// FOSSGIS hosts OSRM with real per-profile routing (car/foot/bike) — the
|
||||
// project-osrm.org demo is car-only (it ignores the profile in the URL). Use
|
||||
@@ -803,41 +803,6 @@ export type RoadLine = Pick<RouteAlternative, 'coordinates' | 'distance' | 'dura
|
||||
*/
|
||||
const EXCLUDABLE_CLASSES = ['motorway', 'toll', 'ferry'] as const
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
/**
|
||||
* Two lines are the same road.
|
||||
*
|
||||
* Length and duration used to decide this, and stopped being enough when a second engine
|
||||
* started answering. Valhalla prices roads differently: measured over twelve European
|
||||
* legs its distances ran 1.8 % above OSRM's, so on a 470 km leg the same road comes back
|
||||
* thirteen kilometres apart and a 200 m tolerance calls it a discovery. Two identical
|
||||
* blue lines on the map is exactly what that looks like. Duration is the worse of the
|
||||
* two to lean on: over the same twelve legs it ranged from 14.7 % under OSRM's to 13.2 %
|
||||
* over, with the sign set by the region rather than by the road.
|
||||
*
|
||||
* The geometry does not care which engine drew it, so it decides whenever there is one.
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts that have already answered "I do not do exclude", by base URL.
|
||||
@@ -1103,87 +1068,3 @@ function furthestPointFrom(line: [number, number][], reference: [number, number]
|
||||
const furthest = furthestFrom(line, reference)
|
||||
return furthest ? { lat: furthest.lat, lng: furthest.lng } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The same point, 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: [number, number][],
|
||||
reference: [number, number][],
|
||||
): { 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: [number, number] | null = null
|
||||
let bestDist = -1
|
||||
for (let i = 0; i < line.length; i += Math.max(1, Math.floor(line.length / 200))) {
|
||||
const near = nearestOn(line[i], reference, step)
|
||||
if (near.d > bestDist) { bestDist = near.d; bestIndex = i; bestNeighbour = near.at }
|
||||
}
|
||||
if (bestIndex < 0) return null
|
||||
const [lat, lng] = line[bestIndex]
|
||||
const km = bestNeighbour ? haversineKm({ lat, lng }, { lat: bestNeighbour[0], lng: bestNeighbour[1] }) : 0
|
||||
return { index: bestIndex, lat, lng, km }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: [number, number],
|
||||
reference: [number, number][],
|
||||
step: number,
|
||||
): { d: number; at: [number, number] } {
|
||||
const cos = Math.cos((point[0] * Math.PI) / 180)
|
||||
const squared = (p: [number, number]): 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 d = squared(reference[j])
|
||||
if (d < nearest) { nearest = d; nearestIndex = j }
|
||||
}
|
||||
let at = reference[nearestIndex]
|
||||
const last = Math.min(reference.length - 1, nearestIndex + step)
|
||||
for (let k = Math.max(0, nearestIndex - step); k < last; k++) {
|
||||
const on = closestOnSegment(point, reference[k], reference[k + 1], cos)
|
||||
const d = squared(on)
|
||||
if (d < nearest) { nearest = d; at = on }
|
||||
}
|
||||
return { d: nearest, at }
|
||||
}
|
||||
|
||||
/** The point of segment a-b closest to `p`, with longitude scaled by `cos` so the plane is fair. */
|
||||
function closestOnSegment(
|
||||
p: [number, number],
|
||||
a: [number, number],
|
||||
b: [number, number],
|
||||
cos: number,
|
||||
): [number, number] {
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -1,35 +1,17 @@
|
||||
import { furthestFrom, sameRoad, type RoadLine } from '../Map/RouteCalculator'
|
||||
import { MAX_WAY_PINS, pinWay, type PinnedWay, type WayPin } from '@trek/shared'
|
||||
import type { RoadLine } from '../Map/RouteCalculator'
|
||||
import { isStoredStop, type RouteSegment } from '@trek/shared/roadtrip'
|
||||
import type { RailLegRoute, RailLegRouter, RoadtripDay, RoadtripStop } from './useRoadtripRoutes'
|
||||
import type { OfferedRoute, RailDrive, ViaAnchor } from './useRouteAlternatives'
|
||||
|
||||
/**
|
||||
* The most points one choice may pin to the road.
|
||||
*
|
||||
* Every pin is one more request to a host that allows one a second, and one more handle on
|
||||
* the map somebody has to understand later. A way that three points cannot hold is not
|
||||
* held by the router at all: a ferry OSRM will not board, or a class the day is not
|
||||
* weighed against.
|
||||
*/
|
||||
export const MAX_PINS = 3
|
||||
/** The most points one choice may pin to the road (see `MAX_WAY_PINS`). */
|
||||
export const MAX_PINS = MAX_WAY_PINS
|
||||
|
||||
/** A point a choice is held by, in the order the chosen road drives through them. */
|
||||
export interface Pin {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
export type Pin = WayPin
|
||||
|
||||
/** Whether the rail's router drives an offer, and the pins that make it do so. */
|
||||
export interface PinProof {
|
||||
/** True when the router drove the offer with `pins` in place. */
|
||||
held: boolean
|
||||
/** The pins that held it, in the order the offer drives through them. Empty otherwise. */
|
||||
pins: Pin[]
|
||||
/** 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: RailLegRoute
|
||||
}
|
||||
export type PinProof = PinnedWay<RailLegRoute>
|
||||
|
||||
/**
|
||||
* The fewest points that make the rail's own router drive `offer`, found by asking it.
|
||||
@@ -37,19 +19,10 @@ export interface PinProof {
|
||||
* A choice used to be stored as one via at the point where the offer strayed furthest,
|
||||
* and nobody asked whether the router then drove the offer. Often it did not. A point on
|
||||
* a ferry is pulled to the nearest pier by OSRM, which never boards one, so Amsterdam to
|
||||
* Newcastle "without the motorway" came back as a thousand kilometres through Calais. An
|
||||
* offer weighed away from motorways by the second engine is held by one point for a few
|
||||
* kilometres, and the motorway takes the rest. Either way the choice looked saved and the
|
||||
* drive did something else.
|
||||
*
|
||||
* So the check is the router itself. The router's own preference starts with no pin; any
|
||||
* other offer with the point furthest from the road being driven. 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 drives through them, until the answer is the offer or `maxPins` is
|
||||
* reached. The spacing between the requests is the router's own (`RailLegRouter.route`).
|
||||
*
|
||||
* An answer from a stand-in engine proves nothing about the one that will draw the leg,
|
||||
* so it ends the check without a verdict on the road.
|
||||
* Newcastle "without the motorway" came back as a thousand kilometres through Calais. So
|
||||
* the check is the router itself, the way `pinWay` asks it; the spacing between the
|
||||
* requests is the router's own (`RailLegRouter.route`). The server asks the same way for
|
||||
* the ways of a hike's leg.
|
||||
*/
|
||||
export async function pinAlternative({
|
||||
offer,
|
||||
@@ -65,23 +38,9 @@ export async function pinAlternative({
|
||||
signal: AbortSignal
|
||||
maxPins?: number
|
||||
}): Promise<PinProof> {
|
||||
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) throw new DOMException('The check was abandoned', 'AbortError')
|
||||
const last = await route(placed.map(({ lat, lng }) => ({ lat, lng })), signal)
|
||||
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)
|
||||
}
|
||||
const proof = await pinWay({ offer, current, route: (pins) => route(pins, signal), signal, maxPins })
|
||||
if (!proof) throw new DOMException('The check was abandoned', 'AbortError')
|
||||
return proof
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { calculateAlternatives, sameRoad, type RouteAlternative, type RouteEngine } from '../Map/RouteCalculator'
|
||||
import { sameRoad } from '@trek/shared'
|
||||
import { calculateAlternatives, type RouteAlternative, type RouteEngine } from '../Map/RouteCalculator'
|
||||
import type { RailLegRoute, RailLegRouter } from './useRoadtripRoutes'
|
||||
|
||||
/** One offered way of driving a leg, with what it means for this trip. */
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
deviationOf,
|
||||
distanceWindow,
|
||||
isRoundTarget,
|
||||
MAX_ROUND_ASKS,
|
||||
ROUND_MAX_M,
|
||||
ROUND_MIN_M,
|
||||
ROUND_TOLERANCE,
|
||||
} from '@trek/shared';
|
||||
import { AlertTriangle, Check, Loader2, Mountain, RefreshCcwDot, Search, Target } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
@@ -11,19 +20,7 @@ import { fs } from '../../shared/DialogShell';
|
||||
import { Tooltip } from '../../shared/Tooltip';
|
||||
import { TourNotice } from '../tourParts';
|
||||
import { MapCard, MapCardActions, MapCornerOverlay, StopDisc, VariantFigures, WayChoice } from './mapCardParts';
|
||||
import { MAX_ROUND_ASKS } from './roundHikeRouting';
|
||||
import {
|
||||
deviationOf,
|
||||
distanceWindow,
|
||||
isRoundTarget,
|
||||
metersTyped,
|
||||
ROUND_MAX_M,
|
||||
ROUND_MIN_M,
|
||||
ROUND_TOLERANCE,
|
||||
roundStarts,
|
||||
typedLength,
|
||||
type RoundCandidate,
|
||||
} from './roundHikes';
|
||||
import { metersTyped, roundStarts, typedLength, type RoundCandidate } from './roundHikes';
|
||||
import { stopNumbersOf } from './tourPlannerWaypoints';
|
||||
import type { RoundHikeView } from './useRoundHikes';
|
||||
import type { TourPlannerController } from './useTourPlanner';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { hikingTimeMinutes, type PlannableTourType } from '@trek/shared';
|
||||
import { hikingTimeMinutes, otherWaysOf, type PlannableTourType } from '@trek/shared';
|
||||
import { analyzeRouteGeometry } from '../../../utils/routeGeometry';
|
||||
import { sameRoad } from '../../Map/RouteCalculator';
|
||||
import type { CostingOptions, ValhallaLeg } from '../../Map/valhallaRoute';
|
||||
import { legSpanOf } from './routeLegAt';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
@@ -137,32 +136,27 @@ export function currentVariantOf(
|
||||
};
|
||||
}
|
||||
|
||||
const roadOf = (coordinates: [number, number][]) => ({ coordinates, distance: 0, duration: 0 });
|
||||
|
||||
/**
|
||||
* The way the section takes now first, then each other way the router
|
||||
* offered that is not the same way again, lettered in the order the router
|
||||
* gave them and at most three. The router's first answer is never one of
|
||||
* them: it is its own way between the two points under the same rules, the
|
||||
* one the tour already routes the section by, so choosing it could change
|
||||
* nothing even where it is drawn a little apart from the route. Their heights
|
||||
* are still to be asked for.
|
||||
* offered that is not the same way again (`otherWaysOf`), lettered in the
|
||||
* order the router gave them and at most three. The router's first answer is
|
||||
* never one of them: it is its own way between the two points under the same
|
||||
* rules, the one the tour already routes the section by, so choosing it could
|
||||
* change nothing even where it is drawn a little apart from the route. Their
|
||||
* heights are still to be asked for.
|
||||
*/
|
||||
export function offeredVariants(current: LegVariant, offers: readonly ValhallaLeg[]): LegVariant[] {
|
||||
const variants: LegVariant[] = [current];
|
||||
for (const offer of offers.slice(1)) {
|
||||
const key = OTHER_WAY_KEYS[variants.length - 1];
|
||||
if (!key) break;
|
||||
if (variants.some((known) => sameRoad(roadOf(known.coordinates), roadOf(offer.coordinates)))) continue;
|
||||
variants.push({
|
||||
key,
|
||||
const others = otherWaysOf(current, offers, OTHER_WAY_KEYS.length);
|
||||
return [
|
||||
current,
|
||||
...others.map((offer, index): LegVariant => ({
|
||||
key: OTHER_WAY_KEYS[index] ?? 'c',
|
||||
current: false,
|
||||
coordinates: offer.coordinates,
|
||||
...figuresOf(offer.coordinates),
|
||||
heights: 'pending',
|
||||
});
|
||||
}
|
||||
return variants;
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
/** A way with the heights the router answered, or with its heights unknown when there were none. */
|
||||
|
||||
@@ -10,9 +10,9 @@ const { valhallaRun, enrichTourElevations } = vi.hoisted(() => ({
|
||||
vi.mock('../../Map/valhallaRoute', () => ({ valhallaRun }));
|
||||
vi.mock('./tourRouting', () => ({ enrichTourElevations }));
|
||||
|
||||
import { spotToward } from './roundHikeGeometry';
|
||||
import { findRoundHikes, MAX_ROUND_ASKS, type RoundSearchReport } from './roundHikeRouting';
|
||||
import { fitsWish, type RoundRequest } from './roundHikes';
|
||||
import { fitsWish, MAX_ROUND_ASKS, spotToward } from '@trek/shared';
|
||||
import { findRoundHikes, type RoundSearchReport } from './roundHikeRouting';
|
||||
import type { RoundRequest } from './roundHikes';
|
||||
|
||||
type Spot = { lat: number; lng: number };
|
||||
|
||||
|
||||
@@ -1,34 +1,12 @@
|
||||
import { haversineKm } from '../../../utils/geo';
|
||||
import { searchRoundLoops, type RoundLoop } from '@trek/shared';
|
||||
import { valhallaRun } from '../../Map/valhallaRoute';
|
||||
import { figuresOf } from './legVariants';
|
||||
import { loopSpots, retracedShare, sharedShare, STRAIGHT_PER_RADIUS_M } from './roundHikeGeometry';
|
||||
import { fitsWish, ROUND_KEYS, type RoundCandidate, type RoundRequest } from './roundHikes';
|
||||
import { ROUND_KEYS, type RoundCandidate, type RoundRequest } from './roundHikes';
|
||||
import { MAX_TOUR_SNAP_M } from './tourRouteOutcome';
|
||||
import { enrichTourElevations } from './tourRouting';
|
||||
|
||||
type Spot = { lat: number; lng: number };
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/**
|
||||
* 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,
|
||||
* with the heights of what it found.
|
||||
*/
|
||||
export const MAX_ROUND_ASKS = 12;
|
||||
/** 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;
|
||||
|
||||
/** Where a search stands: still asking, done with what it found, or without one answer from the router. */
|
||||
export interface RoundSearchReport {
|
||||
phase: 'searching' | 'done' | 'failed';
|
||||
@@ -37,33 +15,17 @@ export interface RoundSearchReport {
|
||||
asked: number;
|
||||
}
|
||||
|
||||
/** A loop the router planned: its line, where it met the steering spots, and how far it is. */
|
||||
interface Loop {
|
||||
coordinates: [number, number][];
|
||||
via: Spot[];
|
||||
distanceM: number;
|
||||
/** The same loop in straight lines from corner to corner. */
|
||||
straightM: number;
|
||||
}
|
||||
|
||||
const clamp = (value: number, { min, max }: { min: number; 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);
|
||||
|
||||
function straightLength(spots: readonly Spot[]): number {
|
||||
let km = 0;
|
||||
for (let i = 1; i < spots.length; i += 1) km += haversineKm(spots[i - 1], spots[i]);
|
||||
return km * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* One loop out of the start and back, asked on foot under the hike's rules:
|
||||
* the same question the planner asks for a hike through these points, so the
|
||||
* round taken is the round shown. `undefined` when the router gave no answer,
|
||||
* null when a spot lies too far from any path to steer through.
|
||||
*/
|
||||
async function askLoop(request: RoundRequest, spots: Spot[], signal: AbortSignal): Promise<Loop | null | undefined> {
|
||||
async function askLoop(
|
||||
request: RoundRequest,
|
||||
spots: Spot[],
|
||||
signal: AbortSignal
|
||||
): Promise<RoundLoop | null | undefined> {
|
||||
const points = [request.start, ...spots, request.start];
|
||||
const run = await valhallaRun(points, 'walking', [], signal, request.costing);
|
||||
if (!run) return undefined;
|
||||
@@ -73,28 +35,12 @@ async function askLoop(request: RoundRequest, spots: Spot[], signal: AbortSignal
|
||||
coordinates,
|
||||
via: run.snapped.slice(1, -1).map(({ at }) => ({ lat: at[0], lng: at[1] })),
|
||||
distanceM: figuresOf(coordinates).distanceM,
|
||||
straightM: straightLength(points),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: Loop, request: RoundRequest, found: readonly RoundCandidate[]): 'round' | 'rescale' | 'drop' {
|
||||
if (!fitsWish(loop.distanceM, request.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';
|
||||
}
|
||||
|
||||
function candidateOf(loop: Loop, index: number): RoundCandidate {
|
||||
function candidateOf(loop: RoundLoop, index: number): RoundCandidate {
|
||||
return {
|
||||
key: ROUND_KEYS[index],
|
||||
key: ROUND_KEYS[index] ?? 'c',
|
||||
current: false,
|
||||
coordinates: loop.coordinates,
|
||||
...figuresOf(loop.coordinates),
|
||||
@@ -103,63 +49,32 @@ function candidateOf(loop: Loop, index: number): RoundCandidate {
|
||||
};
|
||||
}
|
||||
|
||||
/** What one search has learned so far, carried from one question to the next. */
|
||||
interface SearchState {
|
||||
found: RoundCandidate[];
|
||||
asked: number;
|
||||
answered: number;
|
||||
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(request: RoundRequest, bearing: number, state: SearchState, signal: AbortSignal) {
|
||||
let radius = radiusFor(request.targetM, state.detour);
|
||||
for (let attempt = 0; attempt < 2 && state.asked < MAX_ROUND_ASKS; attempt += 1) {
|
||||
const loop = await askLoop(request, loopSpots(request.start, bearing, radius), signal);
|
||||
state.asked += 1;
|
||||
if (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.
|
||||
if (loop.straightM > 0) state.detour = clamp(loop.distanceM / loop.straightM, { min: 1, max: 3 });
|
||||
const verdict = judge(loop, request, state.found);
|
||||
if (verdict === 'round') state.found.push(candidateOf(loop, state.found.length));
|
||||
if (verdict !== 'rescale') return;
|
||||
radius *= clamp(request.targetM / loop.distanceM, RESCALE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RTE-03: up to three different round hikes out of the start, each within a
|
||||
* fifth of the wished length. The router plans loops 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. Then the heights of each round, one after the other. `report`
|
||||
* hears every step, so the rounds and their figures fill in as they come;
|
||||
* nothing is reported once `signal` aborts.
|
||||
* fifth of the wished length, searched by the rules both the planner and the
|
||||
* assistant search by (`searchRoundLoops`), the loops asked of the browser's
|
||||
* router. Then the heights of each round, one after the other. `report` hears
|
||||
* every step, so the rounds and their figures fill in as they come; nothing
|
||||
* is reported once `signal` aborts.
|
||||
*/
|
||||
export async function findRoundHikes(
|
||||
request: RoundRequest,
|
||||
signal: AbortSignal,
|
||||
report: (answer: RoundSearchReport) => void
|
||||
): Promise<void> {
|
||||
const state: SearchState = { found: [], asked: 0, answered: 0, detour: FIRST_DETOUR };
|
||||
for (const bearing of BEARINGS) {
|
||||
if (state.found.length >= ROUND_KEYS.length || state.asked >= MAX_ROUND_ASKS) break;
|
||||
await searchBearing(request, bearing, state, signal);
|
||||
if (signal.aborted) return;
|
||||
report({ phase: 'searching', candidates: [...state.found], asked: state.asked });
|
||||
}
|
||||
const state = await searchRoundLoops(request.start, request.targetM, (spots) => askLoop(request, spots, signal), {
|
||||
signal,
|
||||
onStep: ({ found, asked }) => report({ phase: 'searching', candidates: found.map(candidateOf), asked }),
|
||||
});
|
||||
if (signal.aborted) return;
|
||||
if (state.answered === 0) {
|
||||
report({ phase: 'failed', candidates: [], asked: state.asked });
|
||||
return;
|
||||
}
|
||||
let candidates = [...state.found];
|
||||
const found = state.found.map(candidateOf);
|
||||
let candidates = [...found];
|
||||
report({ phase: 'done', candidates, asked: state.asked });
|
||||
for (const round of state.found) {
|
||||
for (const round of found) {
|
||||
const heights = await enrichTourElevations(round.coordinates, signal);
|
||||
if (signal.aborted) return;
|
||||
// A round without heights keeps its length; its climb, descent and time stay unknown.
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
// FE-TOUR-ROUND-HIKES-001 to FE-TOUR-ROUND-HIKES-007: the model of round hikes out of a stop,
|
||||
// FE-TOUR-ROUND-HIKES-002 to FE-TOUR-ROUND-HIKES-007: the model of round hikes out of a stop,
|
||||
// when a round fits the wish, what a search asks and the plan a chosen round becomes (RTE-03).
|
||||
import { isClosedLoop } from '@trek/shared';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { haversineKm } from '../../../utils/geo';
|
||||
import {
|
||||
canStartRound,
|
||||
deviationOf,
|
||||
distanceWindow,
|
||||
fitsWish,
|
||||
isRoundTarget,
|
||||
metersTyped,
|
||||
ROUND_MAX_M,
|
||||
ROUND_MIN_M,
|
||||
roundPlanOf,
|
||||
roundRequestOf,
|
||||
roundStarts,
|
||||
@@ -27,25 +21,7 @@ const BACK: TourPlannerWaypoint = { id: 'back', lat: 47.5, lng: 11.1, role: 'end
|
||||
const COSTING = { max_hiking_difficulty: 2 };
|
||||
|
||||
describe('the wished length', () => {
|
||||
it('FE-TOUR-ROUND-HIKES-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('FE-TOUR-ROUND-HIKES-002: only a number in range can be asked for, typed in either unit', () => {
|
||||
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);
|
||||
it('FE-TOUR-ROUND-HIKES-002: a length is typed in either unit', () => {
|
||||
expect(metersTyped('12,5', 'metric')).toBe(12_500);
|
||||
expect(metersTyped(' 10 ', 'metric')).toBe(10_000);
|
||||
expect(metersTyped('', 'metric')).toBeNull();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { isClosedLoop, type PlannableTourType } from '@trek/shared';
|
||||
import { distanceWindow, isClosedLoop, isRoundTarget, spotToward, type PlannableTourType } from '@trek/shared';
|
||||
import type { DistanceUnit } from '../../../types';
|
||||
import { convertDistance } from '../../../utils/units';
|
||||
import type { CostingOptions } from '../../Map/valhallaRoute';
|
||||
import { OTHER_WAY_KEYS, type LegVariant } from './legVariants';
|
||||
import { spotToward } from './roundHikeGeometry';
|
||||
import type { TourPlannerWaypoint } from './tourPlannerWaypoints';
|
||||
import { closeLoop } from './tourRouteActions';
|
||||
|
||||
@@ -16,15 +15,6 @@ import { closeLoop } from './tourRouteActions';
|
||||
|
||||
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 rounds one search offers at most, lettered and coloured like the other ways of a section. */
|
||||
export const ROUND_KEYS = OTHER_WAY_KEYS;
|
||||
export type RoundKey = (typeof ROUND_KEYS)[number];
|
||||
@@ -46,22 +36,6 @@ export interface RoundRequest {
|
||||
key: string;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A length as typed in the traveller's unit, in metres; a comma reads as a
|
||||
* decimal point. Null while the text is no number.
|
||||
@@ -78,11 +52,6 @@ export function typedLength(meters: number, unit: DistanceUnit): string {
|
||||
return String(Math.round(convertDistance(meters / 1000, unit) * 10) / 10);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The points a round can start from: the stops of the tour. A shaping point
|
||||
* is no place to start from, and the way back of a loop is its start again.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
hikingCosting,
|
||||
ROUND_DEFAULT_M,
|
||||
type HikingPreferences,
|
||||
type PlannableTourType,
|
||||
type TourMaxHikingDifficulty,
|
||||
@@ -10,7 +11,6 @@ import type { CostingOptions } from '../../Map/valhallaRoute';
|
||||
import { findRoundHikes, type RoundSearchReport } from './roundHikeRouting';
|
||||
import {
|
||||
canStartRound,
|
||||
ROUND_DEFAULT_M,
|
||||
roundPlanOf,
|
||||
roundRequestOf,
|
||||
searchAreaOf,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { recordOf, type Row } from './cards/card-values';
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* "Plan this" on the planner's other searches the assistant ran: a round
|
||||
* hike and another way for one leg of a hike, created as a hike through the
|
||||
* points the proposal answered (create_with), and a wanderer trail, imported
|
||||
* with the fingerprint its preview answered. What is created comes from the
|
||||
* stored proposal, never from the request.
|
||||
*/
|
||||
|
||||
/** The proposing reads of this kind, and the tool each one is created through. */
|
||||
export const WAY_CREATES: Readonly<Record<string, string>> = {
|
||||
propose_round_hike: 'create_hike',
|
||||
propose_hike_leg_way: 'create_hike',
|
||||
preview_wanderer_trail: 'import_wanderer_trail',
|
||||
};
|
||||
|
||||
/** The fields of a round or a leg's way a hike is created with as the call asked them. */
|
||||
const ASKED_FIELDS = ['name', 'max_hiking_difficulty', 'hiking_preferences', 'day_id', 'start_time'] as const;
|
||||
|
||||
/** A point of create_with as create_hike takes it: where it is, its name, its kind and a stage ending there. */
|
||||
function pointOf(row: Row): Row | null {
|
||||
if (typeof row.lat !== 'number' || typeof row.lng !== 'number') return null;
|
||||
return {
|
||||
lat: row.lat,
|
||||
lng: row.lng,
|
||||
...(typeof row.name === 'string' ? { name: row.name } : {}),
|
||||
...(typeof row.poi_category === 'string' ? { poi_category: row.poi_category } : {}),
|
||||
...(row.stage_end === true ? { stage_end: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether `toolName` is one of these searches. */
|
||||
export function isWayProposal(toolName: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(WAY_CREATES, toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* The change a taken way holds, or null when nothing can be created from it:
|
||||
* a way that was not planned, the way of a saved hike's leg (that hike is
|
||||
* changed with update_tour), a trail already in the trip as it is.
|
||||
*/
|
||||
export function wayTakeArgsOf(toolName: string, args: Row, result: Row, card: TourRouteCard): Row | null {
|
||||
const via = WAY_CREATES[toolName];
|
||||
if (via === 'import_wanderer_trail') {
|
||||
if (result.state === 'unchanged' || typeof args.trailId !== 'string' || typeof result.contentHash !== 'string') {
|
||||
return null;
|
||||
}
|
||||
return { via, trailId: args.trailId, contentHash: result.contentHash };
|
||||
}
|
||||
if (via !== 'create_hike' || !card.routed || card.placeId !== null) return null;
|
||||
const create = recordOf(result.create_with);
|
||||
const rows = Array.isArray(create.waypoints) ? create.waypoints.map(recordOf) : [];
|
||||
const waypoints = rows.map(pointOf);
|
||||
if (waypoints.length < 2 || waypoints.some((point) => point === null)) return null;
|
||||
const taken: Row = { via, waypoints, round_trip: create.round_trip === true };
|
||||
for (const key of ASKED_FIELDS) if (args[key] !== undefined) taken[key] = args[key];
|
||||
return taken;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { buildTripCard, categoriesFrom, todayIn, type TripCard } from './assistant-context';
|
||||
import { partsOf, toThreadMessages } from './assistant-history';
|
||||
import { isWayProposal, WAY_CREATES, wayTakeArgsOf } from './assistant-proposal-ways';
|
||||
import { AssistantStreamRegistry } from './assistant-stream.registry';
|
||||
import { TAKE_PROPOSAL_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
|
||||
import { AssistantToolsService, type ToolRun } from './assistant-tools.service';
|
||||
@@ -69,9 +70,10 @@ interface StoredProposal {
|
||||
}
|
||||
|
||||
/** The proposing reads a card can be taken from, and the tool each one is created through. */
|
||||
const CREATES: Readonly<Record<string, 'create_hike' | 'create_city_walk'>> = {
|
||||
const CREATES: Readonly<Record<string, string>> = {
|
||||
propose_hike: 'create_hike',
|
||||
propose_city_walk: 'create_city_walk',
|
||||
...WAY_CREATES,
|
||||
};
|
||||
const PROPOSING: ReadonlySet<string> = new Set(Object.keys(CREATES));
|
||||
|
||||
@@ -151,6 +153,9 @@ const HIKE_FIELDS = [
|
||||
* nothing can be created from.
|
||||
*/
|
||||
export function takeArgsOf(proposal: StoredProposal): Row | null {
|
||||
if (isWayProposal(proposal.toolName)) {
|
||||
return wayTakeArgsOf(proposal.toolName, proposal.args, proposal.result, proposal.card);
|
||||
}
|
||||
const via = CREATES[proposal.toolName];
|
||||
if (via === 'create_hike') {
|
||||
if (!proposal.card.routed) return null;
|
||||
|
||||
@@ -244,6 +244,8 @@ const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
'CollectionToursMcp',
|
||||
'PaddleRoutingMcp',
|
||||
'SkiRoutingMcp',
|
||||
'HikeWaysMcp',
|
||||
'ToursWandererMcp',
|
||||
],
|
||||
},
|
||||
roadtrip: {
|
||||
|
||||
@@ -145,8 +145,8 @@ export const PREVIEW_BOOK_PAGE_TOOL = 'preview_book_page';
|
||||
/** The model's tools a found place is added through: a new place for the day, or one the trip has already. */
|
||||
const FOUND_PLACE_VIA: ReadonlySet<string> = new Set(['create_and_assign_place', 'assign_place_to_day']);
|
||||
|
||||
/** The model's tools a taken proposal is created through: a hike, a city walk. */
|
||||
const PROPOSAL_VIA: ReadonlySet<string> = new Set(['create_hike', 'create_city_walk']);
|
||||
/** The model's tools a taken proposal is created through: a hike, a city walk, a wanderer trail imported. */
|
||||
const PROPOSAL_VIA: ReadonlySet<string> = new Set(['create_hike', 'create_city_walk', 'import_wanderer_trail']);
|
||||
|
||||
/**
|
||||
* Changes the server applies itself: a booking from an attached file, a search
|
||||
|
||||
@@ -16,6 +16,7 @@ import { proposalCard, savedTourCard } from './tour-route.cards';
|
||||
import { cityWalkCard, paddleCard, skiCard } from './tour-sport.cards';
|
||||
import { DELETE_CREATED_TOUR, DELETE_IMPORTED_TOUR } from './tour-undo';
|
||||
import { describeVisit, loadVisit, SET_VISIT_UNDO } from './tour-visit.cards';
|
||||
import { IMPORT_WANDERER_TRAIL, legWayCard, roundHikeCard, wandererPreviewCard } from './tour-ways.cards';
|
||||
import { TOUR_WRITE_CARDS } from './tour-writes.cards';
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,10 @@ export const TOUR_CARDS: DomainCards = {
|
||||
tools: {
|
||||
propose_hike: { argPolicy: WITH_GEOMETRY, answer: proposalCard },
|
||||
propose_city_walk: { argPolicy: WITH_GEOMETRY, answer: cityWalkCard },
|
||||
propose_round_hike: { argPolicy: WITH_GEOMETRY, answer: roundHikeCard },
|
||||
propose_hike_leg_way: { argPolicy: WITH_GEOMETRY, answer: legWayCard },
|
||||
preview_wanderer_trail: { argPolicy: WITH_GEOMETRY, answer: wandererPreviewCard },
|
||||
import_wanderer_trail: IMPORT_WANDERER_TRAIL,
|
||||
plan_paddle_route: { argPolicy: WITH_GEOMETRY, answer: paddleCard },
|
||||
plan_ski_route: { argPolicy: WITH_GEOMETRY, answer: skiCard },
|
||||
get_tour_plan: { project: withoutPreview, answer: savedTourCard },
|
||||
@@ -95,6 +100,6 @@ export const TOUR_CARDS: DomainCards = {
|
||||
},
|
||||
prompt: [
|
||||
'Hikes: call propose_hike with a name, and day_id and start_time when known; to compare ways, call it up to three times in one step, each with a variant_label and along false, the way you recommend first (the card shows the first one chosen). Call create_hike only once the user chose a way. A hike over several days is one hike: stage_end on each stop a day ends at (no day_id), then each stage on its day with assign_place_to_day and tourStageKey.',
|
||||
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters. Once a tour is planned, offer what goes with it: places along it (search_tour_pois), what to pack (get_tour_packing, link_tour_packing), the GPX file (export_tour_gpx).',
|
||||
'The app draws a proposed, planned or saved tour (route, profile, stops, times, weather, warnings) and lists of tours as cards: do not repeat their figures or list them again, say in one sentence what matters. Once a tour is planned, offer what goes with it: places along it (search_tour_pois), what to pack (get_tour_packing, link_tour_packing), the GPX file (export_tour_gpx). A round of about so many km from one point: propose_round_hike with round a, b and c in one step; other ways for one leg of a hike: propose_hike_leg_way with way a, b and c; then create_hike with the chosen create_with (update_tour for a saved hike). Wanderer trails of the user: search_wanderer_trails, preview_wanderer_trail, and import_wanderer_trail only after a yes.',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { AnswerInput, CardReader, Described, PreviewContext, ToolArgs, ToolCards } from '../card-types';
|
||||
import { clipped, finite, recordOf, type Row } from '../card-values';
|
||||
import { Rows, text } from '../preview-format';
|
||||
import { climbOf, fitted, lineOf, metresOf, minutesOf, near, routeCard, stopOf, whole } from './tour-card-values';
|
||||
import { appliedTourCard } from './tour-change.cards';
|
||||
import { hikeCard } from './tour-route.cards';
|
||||
import { DELETE_CREATED_TOUR } from './tour-undo';
|
||||
import { TOUR_ROUTE_LIMITS, wandererDifficultyToSac, type TourRouteCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The cards of the planner's other searches through the assistant: a round
|
||||
* hike of a wished length and another way for one leg of a hike, drawn as
|
||||
* propose_hike's proposals are (they answer one), the leg's way beside the
|
||||
* hike as it runs now; and a wanderer trail, previewed as a way to take in
|
||||
* and imported with its own change card.
|
||||
*/
|
||||
|
||||
/** How close a trail's end must lie to its start to be a loop with one stop. */
|
||||
const LOOP_END_M = 60;
|
||||
|
||||
/** Whether the tool found the way it was asked for; one not found answers `found: false` and draws nothing. */
|
||||
function found(value: unknown): boolean {
|
||||
return recordOf(value).found === true;
|
||||
}
|
||||
|
||||
/** propose_round_hike: the round as a hike proposal's card. */
|
||||
export function roundHikeCard({ value, args, card }: AnswerInput): TourRouteCard | null {
|
||||
if (!found(value)) return null;
|
||||
const proposal = hikeCard(value, args, card, 'proposal');
|
||||
return proposal ? fitted(proposal) : null;
|
||||
}
|
||||
|
||||
/** The hike as it runs now, under a way for one of its legs: its line flat, its length, climb and time. */
|
||||
function currentOf(value: Row): TourRouteCard['previous'] {
|
||||
const current = recordOf(value.current);
|
||||
const line = lineOf(value.current_geometry, TOUR_ROUTE_LIMITS.previous);
|
||||
const distanceM = metresOf(current.distance_m);
|
||||
if (line.length < 2 || distanceM === null) return null;
|
||||
return {
|
||||
line: line.map(([lat, lng]): [number, number] => [lat, lng]),
|
||||
distanceM,
|
||||
ascentM: climbOf(current.ascent_m),
|
||||
minutes: minutesOf(current.duration_minutes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* propose_hike_leg_way: the whole hike with the leg taken that way, the way
|
||||
* it runs now drawn faint beneath it; a saved hike's way names its tour, so
|
||||
* it is changed with update_tour rather than planned anew.
|
||||
*/
|
||||
export function legWayCard({ value, args, card }: AnswerInput): TourRouteCard | null {
|
||||
if (!found(value)) return null;
|
||||
const proposal = hikeCard(value, args, card, 'proposal');
|
||||
if (!proposal) return null;
|
||||
const answer = recordOf(value);
|
||||
const placeId = whole(recordOf(answer.leg_way).tour_id, 1, Number.MAX_SAFE_INTEGER);
|
||||
return fitted({ ...proposal, placeId, previous: currentOf(answer) });
|
||||
}
|
||||
|
||||
/**
|
||||
* A wanderer trail as a way to take in: its full line, the length and climb
|
||||
* measured from its GPX, and wanderer's difficulty on the SAC scale. One the
|
||||
* trip already holds as it is reads as that saved tour.
|
||||
*/
|
||||
export function wandererTrailCard(value: unknown, source: 'proposal' | 'pending'): TourRouteCard | null {
|
||||
const preview = recordOf(value);
|
||||
const trail = recordOf(preview.trail);
|
||||
const line = lineOf(preview.line_geometry);
|
||||
const [first, last] = [line[0], line.at(-1)];
|
||||
if (!first || !last || line.length < 2) return null;
|
||||
const snapshot = recordOf(preview.snapshot);
|
||||
const kept = preview.state === 'unchanged' ? whole(snapshot.placeId, 1, Number.MAX_SAFE_INTEGER) : null;
|
||||
const distanceKm = finite(preview.distanceKm);
|
||||
const start = { lat: first[0], lng: first[1] };
|
||||
const end = { lat: last[0], lng: last[1] };
|
||||
const loop = near(start, end, LOOP_END_M);
|
||||
const difficulty = trail.difficulty;
|
||||
const sac =
|
||||
difficulty === 'easy' || difficulty === 'moderate' || difficulty === 'difficult'
|
||||
? wandererDifficultyToSac(difficulty)
|
||||
: null;
|
||||
return fitted(
|
||||
routeCard({
|
||||
source: kept !== null && source === 'proposal' ? 'saved' : source,
|
||||
activity: 'hike',
|
||||
placeId: source === 'proposal' ? kept : null,
|
||||
name: clipped(trail.name, 120),
|
||||
distanceM: distanceKm === null ? null : metresOf(distanceKm * 1000),
|
||||
ascentM: climbOf(preview.elevationGainM),
|
||||
descentM: climbOf(preview.elevationLossM),
|
||||
limit: sac,
|
||||
routed: true,
|
||||
line,
|
||||
stops: [
|
||||
stopOf({ role: 'start', ...start, atM: 0, name: clipped(trail.location, 80) }),
|
||||
...(loop ? [] : [stopOf({ role: 'end', ...end, atM: metresOf((distanceKm ?? 0) * 1000) ?? 0 })]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** preview_wanderer_trail: the trail as a card. */
|
||||
export function wandererPreviewCard({ value }: AnswerInput): TourRouteCard | null {
|
||||
return wandererTrailCard(value, 'proposal');
|
||||
}
|
||||
|
||||
/** What a held wanderer import looked up: the trail's preview. */
|
||||
interface TrailLoaded {
|
||||
preview: unknown;
|
||||
}
|
||||
|
||||
/** import_wanderer_trail: the trail previewed again, for its name and its line. */
|
||||
async function loadImport(args: ToolArgs, read: CardReader): Promise<TrailLoaded | null> {
|
||||
if (typeof args.trailId !== 'string') return null;
|
||||
const preview = await read('preview_wanderer_trail', { trailId: args.trailId, include_geometry: true });
|
||||
return preview ? { preview } : null;
|
||||
}
|
||||
|
||||
/** The SAC grade a wanderer difficulty stands for, T1 to T3. */
|
||||
function gradeOf(difficulty: unknown): string {
|
||||
const sac =
|
||||
difficulty === 'easy' || difficulty === 'moderate' || difficulty === 'difficult'
|
||||
? wandererDifficultyToSac(difficulty)
|
||||
: null;
|
||||
return sac === null ? '' : `T${sac}`;
|
||||
}
|
||||
|
||||
/** import_wanderer_trail: the trail by its name, where it lies, its grade and its page on wanderer. */
|
||||
function describeImport(_ctx: PreviewContext, args: ToolArgs, loaded: TrailLoaded | null): Described {
|
||||
const trail = recordOf(recordOf(loaded?.preview).trail);
|
||||
const rows = new Rows()
|
||||
.add('location', text(trail.location))
|
||||
.add('difficulty', gradeOf(trail.difficulty))
|
||||
.add('link', text(trail.externalUrl));
|
||||
return { subject: text(trail.name) || text(args.trailId), rows, dayId: null, placeIds: [] };
|
||||
}
|
||||
|
||||
export const IMPORT_WANDERER_TRAIL: ToolCards<TrailLoaded> = {
|
||||
write: { verbKey: 'importTour', entity: 'tour' },
|
||||
load: loadImport,
|
||||
describe: describeImport,
|
||||
change: (_ctx, _args, loaded) => (loaded ? wandererTrailCard(loaded.preview, 'pending') : null),
|
||||
applied: appliedTourCard,
|
||||
undo: DELETE_CREATED_TOUR,
|
||||
};
|
||||
@@ -83,6 +83,8 @@ const valhallaSchema = z.object({
|
||||
}),
|
||||
});
|
||||
type Point = { lat: number; lng: number };
|
||||
/** A Valhalla answer that asked for alternates: its own way, then each other way it offers. */
|
||||
const alternatesSchema = valhallaSchema.extend({ alternates: z.array(valhallaSchema).max(5).optional() });
|
||||
export interface RoadtripRoute {
|
||||
leg: RoutedLeg;
|
||||
parts: { distance: number; duration: number }[];
|
||||
@@ -588,4 +590,33 @@ export class RoadtripRouterService {
|
||||
if (!valhalla) throw new RouteRefusal('no_valhalla', 'No Valhalla is set to describe a line with');
|
||||
return tracedWaysOf(await this.request(`${valhalla}/trace_attributes`, traceBody(line), TRACE_TIMEOUT_MS));
|
||||
}
|
||||
|
||||
/**
|
||||
* RTE-02: the ways on foot between two points under a hike's own costing,
|
||||
* the router's own first and then each other way it offers, at most
|
||||
* `count` besides, as the planner asks them (`valhallaAlternates`). Asked of
|
||||
* Valhalla alone through the paced queue: only Valhalla knows a hiking limit
|
||||
* and offers alternates on foot. A `RouteRefusal` where there is no Valhalla
|
||||
* or no way within the limit.
|
||||
*/
|
||||
async alternatesOnFoot(
|
||||
userId: number,
|
||||
from: Point,
|
||||
to: Point,
|
||||
costingOptions: NonNullable<RouteOptions['costingOptions']>,
|
||||
count: number,
|
||||
): Promise<Array<[number, number][]>> {
|
||||
const valhalla = await this.valhallaFor(userId);
|
||||
if (!valhalla) throw new RouteRefusal('no_valhalla', 'No Valhalla is set to plan a hike with');
|
||||
const payload = await this.request(`${valhalla}/route`, {
|
||||
locations: [from, to].map((p) => ({ lat: p.lat, lon: p.lng, radius: 50 })),
|
||||
costing: 'pedestrian',
|
||||
costing_options: { pedestrian: { ...costingOptions } },
|
||||
directions_options: { units: 'kilometers' },
|
||||
...(count > 0 ? { alternates: count } : {}),
|
||||
});
|
||||
const parsed = alternatesSchema.parse(payload);
|
||||
const trips = [parsed.trip, ...(parsed.alternates ?? []).map((alternate) => alternate.trip)];
|
||||
return trips.map((trip) => trip.legs.flatMap((leg) => decode(leg.shape)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { hikePointsOf } from './hike-plan.helpers';
|
||||
import {
|
||||
HIKE_MAX_WAYPOINTS,
|
||||
HIKE_WAY_KEYS,
|
||||
hikingPreferencesOf,
|
||||
TOURS_MAX_HIKING_DIFFICULTY,
|
||||
type HikeLegWayRequest,
|
||||
type HikePlanPoint,
|
||||
type HikeRoundProposeRequest,
|
||||
type HikeStop,
|
||||
type HikeWayKey,
|
||||
type HikeWayMiss,
|
||||
type HikeWayMissing,
|
||||
type HikeWayPoints,
|
||||
type HikingPreferences,
|
||||
type RoadLine,
|
||||
type WayPin,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The pure halves of the round hikes and the ways of a leg the assistant
|
||||
* proposes (HikeWaysService): which way a call asks for, the points a found
|
||||
* way is proposed and created through, and the keys a search is kept under.
|
||||
*/
|
||||
|
||||
type Spot = { lat: number; lng: number };
|
||||
|
||||
/** Points nearer than this many decimal places of a degree (about a metre) are one point for a search kept. */
|
||||
const KEY_DECIMALS = 5;
|
||||
|
||||
const keySpot = ({ lat, lng }: Spot) => [Number(lat.toFixed(KEY_DECIMALS)), Number(lng.toFixed(KEY_DECIMALS))];
|
||||
|
||||
/** The position of a way's letter among the ways found: a is the first. */
|
||||
export function wayIndexOf(key: HikeWayKey | undefined): number {
|
||||
return Math.max(0, HIKE_WAY_KEYS.indexOf(key ?? 'a'));
|
||||
}
|
||||
|
||||
/** The letter of the way at `index`. */
|
||||
export function wayKeyAt(index: number): HikeWayKey {
|
||||
return HIKE_WAY_KEYS[index] ?? 'a';
|
||||
}
|
||||
|
||||
/** The answer for a way asked for that was not found: why none, or that fewer were. */
|
||||
export function wayMissing(key: HikeWayKey | undefined, found: number, why: HikeWayMiss | null): HikeWayMissing {
|
||||
return { found: false, way: key ?? 'a', ways_found: found, why: why ?? 'none_found' };
|
||||
}
|
||||
|
||||
/** The hike's limit and preferences as a search is asked under, the defaults filled in. */
|
||||
export function rulesOf(request: { max_hiking_difficulty?: number; hiking_preferences?: HikingPreferences | null }): {
|
||||
limit: number;
|
||||
preferences: HikingPreferences | null;
|
||||
} {
|
||||
return {
|
||||
limit: request.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY,
|
||||
preferences: hikingPreferencesOf(request.hiking_preferences),
|
||||
};
|
||||
}
|
||||
|
||||
/** What one round search depends on: who asked in which trip, the start to about a metre, the length and the rules. */
|
||||
export function roundSearchKey(userId: number, tripId: number, request: HikeRoundProposeRequest): string {
|
||||
const { limit, preferences } = rulesOf(request);
|
||||
return JSON.stringify([
|
||||
'round',
|
||||
userId,
|
||||
tripId,
|
||||
keySpot(request.start),
|
||||
Math.round(request.distance_km * 1000),
|
||||
limit,
|
||||
preferences,
|
||||
]);
|
||||
}
|
||||
|
||||
/** What the ways of one leg depend on: who asked in which trip, the hike's points, the leg and the rules. */
|
||||
export function legWaysKey(userId: number, tripId: number, request: HikeLegWayRequest): string {
|
||||
const { limit, preferences } = rulesOf(request);
|
||||
const points = hikePointsOf(request.waypoints, request.round_trip).map(keySpot);
|
||||
return JSON.stringify(['leg', userId, tripId, points, request.leg, limit, preferences]);
|
||||
}
|
||||
|
||||
/** The points a round is proposed and created through: its start, then the spots its loop is steered through. */
|
||||
export function roundPointsOf(start: HikePlanPoint, via: readonly Spot[]): HikeWayPoints {
|
||||
return { waypoints: [start, ...via.map(({ lat, lng }) => ({ lat, lng }))], round_trip: true };
|
||||
}
|
||||
|
||||
/** The two ends of leg `leg` (counted from 1) of a hike through these points, its way back included. */
|
||||
export function legEndsOf(
|
||||
waypoints: readonly HikeStop[],
|
||||
roundTrip: boolean | undefined,
|
||||
leg: number,
|
||||
): { from: Spot; to: Spot } | null {
|
||||
const points = hikePointsOf(waypoints, roundTrip);
|
||||
const from = points[leg - 1];
|
||||
const to = points[leg];
|
||||
return from && to ? { from: { lat: from.lat, lng: from.lng }, to: { lat: to.lat, lng: to.lng } } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The hike's points with `pins` set on leg `leg`, right after the point it
|
||||
* starts at; on a round trip the pins of the way back go last, before the
|
||||
* start the way back returns to. Null where the pins would take the hike past
|
||||
* the points one hike is asked through.
|
||||
*/
|
||||
export function pinnedPointsOf(
|
||||
waypoints: readonly HikeStop[],
|
||||
leg: number,
|
||||
pins: readonly WayPin[],
|
||||
): HikeStop[] | null {
|
||||
if (waypoints.length + pins.length > HIKE_MAX_WAYPOINTS) return null;
|
||||
const at = Math.min(leg, waypoints.length);
|
||||
return [...waypoints.slice(0, at), ...pins.map(({ lat, lng }) => ({ lat, lng })), ...waypoints.slice(at)];
|
||||
}
|
||||
|
||||
/** A router's line as a way to compare. */
|
||||
export function roadOf(coordinates: Array<[number, number]>, distance = 0, duration = 0): RoadLine {
|
||||
return { coordinates, distance, duration };
|
||||
}
|
||||
|
||||
/** A signed share as whole percent: 0.084 is 8. */
|
||||
export function percentOf(share: number): number {
|
||||
return Math.round(share * 100);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { HikeWaysService } from './hike-ways.service';
|
||||
import {
|
||||
hikeLegWayRequestSchema,
|
||||
hikeRoundProposeRequestSchema,
|
||||
idSchema,
|
||||
type HikeLegWayRequest,
|
||||
type HikeRoundProposeRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* RTE-02 and RTE-03 over MCP: the planner's round hikes of a wished length
|
||||
* and its other ways for one leg of a hike. Both read only, as propose_hike
|
||||
* does (the planner offers them to anyone who can see the trip), and answer a
|
||||
* hike proposal of one way per call; the hike is created with create_hike, or
|
||||
* a saved one changed with update_tour, once the traveller chose.
|
||||
*/
|
||||
@McpController()
|
||||
export class HikeWaysMcp {
|
||||
constructor(
|
||||
private readonly ways: HikeWaysService,
|
||||
private readonly access: TripAccessService,
|
||||
// Read by the addon gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'propose_round_hike',
|
||||
description:
|
||||
"Find round hikes of about distance_km out of one start point and back (start: a car park, a station, a hut) and propose one of them, without saving anything, as the Tours planner's round search does: the router plans loops on foot in several directions under max_hiking_difficulty and hiking_preferences, keeps those within a fifth of the length that do not walk the same ground twice, and offers up to three different rounds, a, b and c. One search answers all three, so call it up to three times in one step with round a, b and c, each with along left out, to show them as ways to choose from (a search takes up to about a quarter of a minute). Each answer is the hike proposal propose_hike gives for the round (distance, climb, walking time, SAC grades, schedule and warnings with day_id or date and start_time), with round (key, target_m, deviation_pct, how far it lies off the length, and rounds_found) and create_with, the points to create it through: create_hike with name, create_with.waypoints, round_trip true and the same max_hiking_difficulty and hiking_preferences, only after the traveller chose one. A round that is not there answers found false with ways_found and why (none_found where no round fits, router_unavailable, no_valhalla). The chat shows each round as a card: never list coordinates or figures, say in a sentence what sets the rounds apart.",
|
||||
inputSchema: { tripId: idSchema, ...hikeRoundProposeRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async proposeRoundHike({ tripId, ...request }: { tripId: number } & HikeRoundProposeRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
return ok(await this.ways.proposeRound(ctx.userId, tripId, request));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'propose_hike_leg_way',
|
||||
description:
|
||||
"Propose another way for one leg of a hike, as the Tours planner's other ways of a section: the router's other ways on foot between the two points of leg (counted from 1; on a round trip the last leg is the way back) under the hike's max_hiking_difficulty and hiking_preferences, each held by the fewest points on it the router keeps to, at most three, a, b and c. Give the hike as it stands: name, waypoints and round_trip of a proposal, or of a saved hike from get_tour_plan with tour_id (its place id). One search answers all three, so call it up to three times in one step with way a, b and c to show them as ways to choose from. Each answer is the hike proposal propose_hike gives for the whole hike with the leg taken that way, with leg_way (key, leg, ways_found), current (the hike as it runs now: distance_m, ascent_m, duration_minutes) to compare against, and create_with, the hike's points with the way's points set on the leg. Only after the traveller chose one: for a hike not yet saved create_hike with name, create_with.waypoints and create_with.round_trip; for a saved hike update_tour with those waypoints (a round trip ending on its start) and its expected_revision. A way that is not there answers found false with ways_found and why (none_found where the router knows no other way it keeps to). The chat shows each way as a card beside the way it runs now: never list coordinates or figures, say in a sentence how the ways differ.",
|
||||
inputSchema: { tripId: idSchema, ...hikeLegWayRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async proposeHikeLegWay({ tripId, ...request }: { tripId: number } & HikeLegWayRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
// The tool's fields are checked one by one; the leg against the hike's points only here.
|
||||
const checked = hikeLegWayRequestSchema.safeParse(request);
|
||||
if (!checked.success) return errorResult(checked.error.issues.map((issue) => issue.message).join('; '));
|
||||
return ok(await this.ways.proposeLegWay(ctx.userId, tripId, checked.data));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import { badRequest } from '../common/domain-error';
|
||||
import { RoadtripRouterService, RouteRefusal } from '../roadtrip/roadtrip-router.service';
|
||||
import { HikePlanService } from './hike-plan.service';
|
||||
import { joinedLine } from './hike-routing.helpers';
|
||||
import {
|
||||
legEndsOf,
|
||||
legWaysKey,
|
||||
percentOf,
|
||||
pinnedPointsOf,
|
||||
roadOf,
|
||||
roundPointsOf,
|
||||
roundSearchKey,
|
||||
rulesOf,
|
||||
wayIndexOf,
|
||||
wayKeyAt,
|
||||
wayMissing,
|
||||
} from './hike-ways.helpers';
|
||||
import { ToursService } from './tours.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
computeTourMetrics,
|
||||
deviationOf,
|
||||
hikingCosting,
|
||||
HIKE_WAY_KEYS,
|
||||
otherWaysOf,
|
||||
pinWay,
|
||||
searchRoundLoops,
|
||||
TOUR_MAX_SNAP_M,
|
||||
type HikeLegWayProposal,
|
||||
type HikeLegWayRequest,
|
||||
type HikeRoundProposal,
|
||||
type HikeRoundProposeRequest,
|
||||
type HikeStop,
|
||||
type HikeWayMiss,
|
||||
type HikeWayMissing,
|
||||
type HikeWayPoints,
|
||||
type RoadLine,
|
||||
type RoundLoop,
|
||||
type WayPin,
|
||||
} from '@trek/shared';
|
||||
|
||||
type Spot = { lat: number; lng: number };
|
||||
|
||||
/** The day the router is asked for: a hike's way does not depend on it (see HikeRoutingService). */
|
||||
const ANY_DAY = 0;
|
||||
/** How long a search is kept: the rounds or ways of one answer are asked for one after another, and taken later. */
|
||||
const SEARCH_TTL_MS = 15 * 60_000;
|
||||
/** The most searches kept; the oldest goes first. */
|
||||
const SEARCH_MAX = 32;
|
||||
|
||||
/** What a round search found: the rounds, or why there are none. */
|
||||
interface RoundSearch {
|
||||
rounds: RoundLoop[];
|
||||
why: HikeWayMiss | null;
|
||||
}
|
||||
|
||||
/** What the ways of one leg came to: the hike's points with each way pinned on, or why there are none. */
|
||||
interface LegWays {
|
||||
ways: HikeStop[][];
|
||||
why: HikeWayMiss | null;
|
||||
}
|
||||
|
||||
interface Kept<T> {
|
||||
at: number;
|
||||
search: Promise<T>;
|
||||
}
|
||||
|
||||
/** What went wrong, for the log. */
|
||||
function reasonOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/** Why a router that answered nothing answered nothing, in a proposal's words. */
|
||||
function missOf(err: unknown): HikeWayMiss {
|
||||
return err instanceof RouteRefusal ? err.why : 'router_unavailable';
|
||||
}
|
||||
|
||||
/**
|
||||
* RTE-02 and RTE-03 for 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, each answered as a hike proposal (HikePlanService.propose), so the
|
||||
* chat draws and compares it as it does propose_hike's and creates the one
|
||||
* chosen with create_hike. The search is the planner's (`searchRoundLoops`,
|
||||
* `otherWaysOf`, `pinWay` in @trek/shared), asked of the server's router
|
||||
* under the hike's own costing. One search answers the three ways: the calls
|
||||
* for b and c share the search the call for a started, kept for a while.
|
||||
* Writes nothing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HikeWaysService {
|
||||
private readonly kept = new Map<string, Kept<RoundSearch | LegWays>>();
|
||||
|
||||
constructor(
|
||||
private readonly router: RoadtripRouterService,
|
||||
private readonly hikes: HikePlanService,
|
||||
private readonly tours: ToursService,
|
||||
) {}
|
||||
|
||||
/** The round `request.round` of the rounds found out of the start, proposed as a hike; missing where there is none. */
|
||||
async proposeRound(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
request: HikeRoundProposeRequest,
|
||||
): Promise<HikeRoundProposal | HikeWayMissing> {
|
||||
const key = roundSearchKey(userId, tripId, request);
|
||||
const search = await this.recall(key, () => this.searchRounds(userId, tripId, request));
|
||||
const { rounds, why } = search as RoundSearch;
|
||||
const index = wayIndexOf(request.round);
|
||||
const round = rounds[index];
|
||||
if (!round) return wayMissing(request.round, rounds.length, why);
|
||||
const points = roundPointsOf(request.start, round.via);
|
||||
const proposal = await this.hikes.propose(userId, tripId, {
|
||||
name: request.name,
|
||||
...points,
|
||||
max_hiking_difficulty: request.max_hiking_difficulty,
|
||||
hiking_preferences: request.hiking_preferences,
|
||||
variant_label: request.variant_label,
|
||||
day_id: request.day_id,
|
||||
date: request.date,
|
||||
start_time: request.start_time,
|
||||
along: false,
|
||||
include_geometry: request.include_geometry,
|
||||
});
|
||||
const targetM = Math.round(request.distance_km * 1000);
|
||||
return {
|
||||
...proposal,
|
||||
found: true,
|
||||
round: {
|
||||
key: wayKeyAt(index),
|
||||
target_m: targetM,
|
||||
deviation_pct: percentOf(deviationOf(proposal.distance_m ?? round.distanceM, targetM)),
|
||||
rounds_found: rounds.length,
|
||||
},
|
||||
create_with: points,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The way `request.way` of the other ways for one leg, proposed as the
|
||||
* whole hike with that leg taken so, beside the hike as it runs now;
|
||||
* missing where there is none. A saved hike named by `tour_id` must be a
|
||||
* hike of the trip.
|
||||
*/
|
||||
async proposeLegWay(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
request: HikeLegWayRequest,
|
||||
): Promise<HikeLegWayProposal | HikeWayMissing> {
|
||||
if (request.tour_id !== undefined) {
|
||||
const { tour } = await this.tours.getTour(String(tripId), String(request.tour_id));
|
||||
if (tour.tour_type !== 'hike') throw badRequest('Only the legs of a hike have other ways here.');
|
||||
}
|
||||
const key = legWaysKey(userId, tripId, request);
|
||||
const search = await this.recall(key, () => this.searchLegWays(userId, tripId, request));
|
||||
const { ways, why } = search as LegWays;
|
||||
const index = wayIndexOf(request.way);
|
||||
const waypoints = ways[index];
|
||||
if (!waypoints) return wayMissing(request.way, ways.length, why);
|
||||
const roundTrip = request.round_trip === true;
|
||||
const base = {
|
||||
name: request.name,
|
||||
max_hiking_difficulty: request.max_hiking_difficulty,
|
||||
hiking_preferences: request.hiking_preferences,
|
||||
day_id: request.day_id,
|
||||
date: request.date,
|
||||
start_time: request.start_time,
|
||||
round_trip: roundTrip,
|
||||
along: false,
|
||||
include_geometry: request.include_geometry,
|
||||
};
|
||||
const current = await this.hikes.propose(userId, tripId, { ...base, waypoints: request.waypoints });
|
||||
const proposal = await this.hikes.propose(userId, tripId, { ...base, waypoints });
|
||||
const points: HikeWayPoints = {
|
||||
waypoints: waypoints.map(({ lat, lng, name, poi_category, stage_end }) => ({
|
||||
lat,
|
||||
lng,
|
||||
...(name === undefined ? {} : { name }),
|
||||
...(poi_category === undefined ? {} : { poi_category }),
|
||||
...(stage_end === true ? { stage_end } : {}),
|
||||
})),
|
||||
round_trip: roundTrip,
|
||||
};
|
||||
return {
|
||||
...proposal,
|
||||
found: true,
|
||||
leg_way: { key: wayKeyAt(index), leg: request.leg, ways_found: ways.length, tour_id: request.tour_id ?? null },
|
||||
current: {
|
||||
distance_m: current.distance_m,
|
||||
ascent_m: current.ascent_m,
|
||||
duration_minutes: current.duration_minutes,
|
||||
},
|
||||
...(request.include_geometry === true && current.line_geometry
|
||||
? { current_geometry: current.line_geometry }
|
||||
: {}),
|
||||
create_with: points,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The search kept under `key`, or a new one started; one asked while
|
||||
* another for the same key runs shares it. A search that failed is not kept.
|
||||
*/
|
||||
private async recall<T extends RoundSearch | LegWays>(key: string, start: () => Promise<T>): Promise<T> {
|
||||
const now = Date.now();
|
||||
for (const [other, entry] of this.kept) if (now - entry.at >= SEARCH_TTL_MS) this.kept.delete(other);
|
||||
const kept = this.kept.get(key);
|
||||
if (kept) return (await kept.search) as T;
|
||||
const search = start();
|
||||
this.kept.set(key, { at: now, search });
|
||||
const [oldest] = this.kept.keys();
|
||||
if (this.kept.size > SEARCH_MAX && oldest !== undefined) this.kept.delete(oldest);
|
||||
try {
|
||||
return await search;
|
||||
} catch (err: unknown) {
|
||||
this.kept.delete(key);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** The rounds out of the start, asked of the router as the planner asks them. */
|
||||
private async searchRounds(userId: number, tripId: number, request: HikeRoundProposeRequest): Promise<RoundSearch> {
|
||||
const { limit, preferences } = rulesOf(request);
|
||||
const costingOptions = hikingCosting(limit, preferences);
|
||||
const start = { lat: request.start.lat, lng: request.start.lng };
|
||||
let refused: HikeWayMiss | null = null;
|
||||
const ask = async (spots: Spot[]): Promise<RoundLoop | null | undefined> => {
|
||||
try {
|
||||
const route = await this.router.route(userId, tripId, ANY_DAY, [start, ...spots, start], 'walking', [], {
|
||||
costingOptions,
|
||||
});
|
||||
if ((route.snapped ?? []).some((point) => point.meters > TOUR_MAX_SNAP_M)) return null;
|
||||
const coordinates = joinedLine(route.leg.line);
|
||||
const via = route.snapped?.slice(1, -1).map(({ at }) => ({ lat: at[0], lng: at[1] })) ?? spots;
|
||||
return { coordinates, via, distanceM: Math.round(computeTourMetrics(coordinates).distanceKm * 1000) };
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof RouteRefusal && err.why !== 'no_valhalla') return null;
|
||||
refused = missOf(err);
|
||||
if (!(err instanceof RouteRefusal)) logWarn(`[Hike] no round for trip ${tripId}: ${reasonOf(err)}`);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
// Without a Valhalla to ask no round can be found: the search stops at the first answer saying so.
|
||||
const signal = {
|
||||
get aborted() {
|
||||
return refused === 'no_valhalla';
|
||||
},
|
||||
};
|
||||
const state = await searchRoundLoops(start, request.distance_km * 1000, ask, { signal });
|
||||
if (state.answered === 0) return { rounds: [], why: refused ?? 'router_unavailable' };
|
||||
return { rounds: state.found, why: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* The other ways of one leg: the router's ways between its two ends under
|
||||
* the hike's costing, those that are no way already known, each held by
|
||||
* the fewest pins the router keeps to (as the planner holds a way chosen),
|
||||
* and the hike's points with those pins set on the leg. A way the router
|
||||
* does not keep to, or that would take the hike past its points, is left out.
|
||||
*/
|
||||
private async searchLegWays(userId: number, tripId: number, request: HikeLegWayRequest): Promise<LegWays> {
|
||||
const ends = legEndsOf(request.waypoints, request.round_trip, request.leg);
|
||||
if (!ends) throw badRequest('The hike has no such leg');
|
||||
const { limit, preferences } = rulesOf(request);
|
||||
const costingOptions = hikingCosting(limit, preferences);
|
||||
let offers: Array<[number, number][]>;
|
||||
try {
|
||||
offers = await this.router.alternatesOnFoot(userId, ends.from, ends.to, costingOptions, HIKE_WAY_KEYS.length);
|
||||
} catch (err: unknown) {
|
||||
if (!(err instanceof RouteRefusal)) logWarn(`[Hike] no other ways for trip ${tripId}: ${reasonOf(err)}`);
|
||||
return { ways: [], why: missOf(err) };
|
||||
}
|
||||
const [own] = offers;
|
||||
if (!own) return { ways: [], why: 'no_way' };
|
||||
const current = roadOf(own);
|
||||
const route = async (pins: WayPin[]): Promise<RoadLine & { fellBack?: boolean }> => {
|
||||
try {
|
||||
const way = await this.router.route(userId, tripId, ANY_DAY, [ends.from, ...pins, ends.to], 'walking', [], {
|
||||
costingOptions,
|
||||
});
|
||||
return roadOf(joinedLine(way.leg.line), way.leg.seg.distance, way.leg.seg.duration);
|
||||
} catch {
|
||||
// No answer proves nothing: the way is not offered.
|
||||
return { ...roadOf([]), fellBack: true };
|
||||
}
|
||||
};
|
||||
const others = otherWaysOf(
|
||||
current,
|
||||
offers.map((line) => ({ coordinates: line })),
|
||||
HIKE_WAY_KEYS.length,
|
||||
);
|
||||
// One way after another: each check asks the router's one paced queue.
|
||||
const ways = await others.reduce<Promise<HikeStop[][]>>(async (before, offer) => {
|
||||
const held = await before;
|
||||
const proof = await pinWay({ offer: roadOf(offer.coordinates), current, route });
|
||||
const pinned = proof?.held ? pinnedPointsOf(request.waypoints, request.leg, proof.pins) : null;
|
||||
return pinned ? [...held, pinned] : held;
|
||||
}, Promise.resolve([]));
|
||||
return { ways, why: null };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { noAccess, permissionDenied } from '../../mcp/tools/_shared';
|
||||
import {
|
||||
McpController,
|
||||
Tool,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
ok,
|
||||
type McpContext,
|
||||
} from '../../nest-mcp';
|
||||
import type { HasAddons } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { ToursWandererService } from './tours-wanderer.service';
|
||||
import {
|
||||
idSchema,
|
||||
wandererMcpImportRequestSchema,
|
||||
wandererMcpPreviewRequestSchema,
|
||||
wandererMcpSearchRequestSchema,
|
||||
wandererMcpSourceRequestSchema,
|
||||
type WandererMcpImportRequest,
|
||||
type WandererMcpPreviewRequest,
|
||||
type WandererMcpSearchRequest,
|
||||
type WandererMcpSourceRequest,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** Offered only while the admin allows wanderer under Tours, as WandererEnabledGuard lets its routes through. */
|
||||
const wandererOn = (_ctx: McpContext, self: HasAddons): Promise<boolean> => self.addons.isWandererAvailable();
|
||||
|
||||
/**
|
||||
* The trip-side wanderer routes over MCP (ToursWandererController): the
|
||||
* caller's own wanderer trails searched, one previewed, imported as a tour
|
||||
* of the trip, and a tour imported from it checked against its source. Each
|
||||
* runs on the caller's own connection with 'place_edit' on the trip, as REST
|
||||
* asks it of every route there (looking is the first step of importing). The
|
||||
* import writes the snapshot through the same service, which tells the trip
|
||||
* about it (tours:changed and place:created) without leaving out any socket.
|
||||
* A refusal of the service is a DomainError, which the registry answers as an
|
||||
* error result with the text REST sends.
|
||||
*/
|
||||
@McpController()
|
||||
export class ToursWandererMcp {
|
||||
constructor(
|
||||
private readonly wanderer: ToursWandererService,
|
||||
private readonly access: TripAccessService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
// Read by the wanderer gate of every tool here.
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
/** Trip access and 'place_edit', as the REST routes' guards ask them; the refusal to answer, or null. */
|
||||
private async refusal(tripId: number, userId: number) {
|
||||
if (!(await this.access.findAccessible(tripId, userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, userId))) return permissionDenied();
|
||||
return null;
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'search_wanderer_trails',
|
||||
description:
|
||||
"Search the traveller's own wanderer trails (their connected wanderer instance, a trail database) by words (q: a place, summit or region), page and part of the map (bbox), for a trip. The answer lists trails with id, name, description, location, distanceM, durationS, elevationGainM, elevationLossM, difficulty (easy, moderate, difficult), public, author, externalUrl and importedAs (the tours of this trip already imported from the trail), with total, page and pageSize. A traveller without a wanderer connection gets an error saying so. Preview a trail with preview_wanderer_trail before importing it. Needs place editing permission on the trip.",
|
||||
inputSchema: { tripId: idSchema, ...wandererMcpSearchRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: wandererOn,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async searchTrails({ tripId, q, page, bbox }: { tripId: number } & WandererMcpSearchRequest, ctx: McpContext) {
|
||||
const refused = await this.refusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
const found = await this.wanderer.search(String(tripId), ctx.userId, { q: q ?? '', page: page ?? 1, bbox });
|
||||
// The instance's simplified lines are for a map; the model plans with the facts.
|
||||
return ok({ ...found, trails: found.trails.map(({ line: _line, ...trail }) => trail) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'preview_wanderer_trail',
|
||||
description:
|
||||
'Preview one wanderer trail before it is imported into the trip, without writing anything: its facts, distanceKm, elevationGainM and elevationLossM measured from its full GPX, points, contentHash (what import_wanderer_trail takes), state (new: not in the trip yet; unchanged: already imported as it is; changed: imported, and the trail changed since), snapshot (the tour imported last: placeId, name, importedAt, editedLocally) and changes (name, description, route). The chat shows the trail as a card: never list its figures, say in a sentence what matters. Import it only after the traveller said yes. Needs place editing permission on the trip.',
|
||||
inputSchema: { tripId: idSchema, ...wandererMcpPreviewRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: wandererOn,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async previewTrail(
|
||||
{ tripId, trailId, include_geometry }: { tripId: number } & WandererMcpPreviewRequest,
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const refused = await this.refusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
const { line, trail, ...preview } = await this.wanderer.preview(String(tripId), ctx.userId, trailId);
|
||||
const { line: _simplified, ...facts } = trail;
|
||||
return ok({ ...preview, trail: facts, ...(include_geometry === true ? { line_geometry: line } : {}) });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'import_wanderer_trail',
|
||||
description:
|
||||
"Import a wanderer trail into the trip as a tour (a hike with the trail's full line, its source noted), as the Tours tab's wanderer import does: trailId and the contentHash preview_wanderer_trail gave. Refused when the trail changed since the preview (preview it again) or when the trip already holds this very content (the error names its placeId). An earlier import of the trail stays as it is: a changed trail comes in as a new tour beside it. The answer is the tour as list_tours reads it (tour.place_id is its id). Needs place editing permission on the trip. Import only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema, ...wandererMcpImportRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: wandererOn,
|
||||
access: { group: 'trips', mode: 'write' },
|
||||
})
|
||||
async importTrail({ tripId, ...request }: { tripId: number } & WandererMcpImportRequest, ctx: McpContext) {
|
||||
const refused = await this.refusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
return ok(await this.wanderer.import(String(tripId), ctx.userId, request));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'check_tour_source',
|
||||
description:
|
||||
"Ask wanderer whether the trail a tour of the trip was imported from (origin wanderer) changed since, without changing the tour. state: unchanged, changed (changes names name, description or route; reimport has the trailId and contentHash to import the trail again as a new tour, which import_wanderer_trail does once the traveller said yes), unknown (imported before TREK kept a fingerprint; reimport is given), unavailable (the trail is gone or no longer the traveller's to read), not_connected (no wanderer connection) or other_instance (the connection is to another instance). editedLocally says whether the tour was changed in TREK since the import. A tour that did not come from wanderer is refused. Needs place editing permission on the trip.",
|
||||
inputSchema: { tripId: idSchema, ...wandererMcpSourceRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: wandererOn,
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async checkSource({ tripId, placeId }: { tripId: number } & WandererMcpSourceRequest, ctx: McpContext) {
|
||||
const refused = await this.refusal(tripId, ctx.userId);
|
||||
if (refused) return refused;
|
||||
return ok(await this.wanderer.checkSource(String(tripId), ctx.userId, String(placeId)));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import { CollectionToursService } from './collection-tours.service';
|
||||
import { HikePlanMcp } from './hike-plan.mcp';
|
||||
import { HikePlanService } from './hike-plan.service';
|
||||
import { HikeRoutingService } from './hike-routing.service';
|
||||
import { HikeWaysMcp } from './hike-ways.mcp';
|
||||
import { HikeWaysService } from './hike-ways.service';
|
||||
import { TourActivitiesController } from './tour-activities.controller';
|
||||
import { TourActivitiesMcp } from './tour-activities.mcp';
|
||||
import { TourActivitiesService } from './tour-activities.service';
|
||||
@@ -77,6 +79,7 @@ import { TourWalkImportMcp } from './tour-walk-import.mcp';
|
||||
import { TourWalkImportService } from './tour-walk-import.service';
|
||||
import { ToursImportController } from './tours-import.controller';
|
||||
import { ToursWandererController } from './tours-wanderer.controller';
|
||||
import { ToursWandererMcp } from './tours-wanderer.mcp';
|
||||
import { ToursWandererService } from './tours-wanderer.service';
|
||||
import { ToursController } from './tours.controller';
|
||||
import { ToursMcp } from './tours.mcp';
|
||||
@@ -99,7 +102,9 @@ import { Module } from '@nestjs/common';
|
||||
* tours' own repositories, is planned on foot by RoadtripModule's router and
|
||||
* is created through ToursService (CW-11). A hike an assistant plans with the
|
||||
* traveller is proposed, created, changed or taken in from a GPX file through
|
||||
* the same writes, its way planned on the server by HikeRoutingService, its
|
||||
* the same writes, its way planned on the server by HikeRoutingService (a
|
||||
* round of a wished length and the other ways of a leg found by HikeWaysService
|
||||
* with the planner's own search), its
|
||||
* day read from WeatherModule's detailed weather and the places along it from
|
||||
* the tours' own search; one created on a day is put there through
|
||||
* AssignmentsService in the same write. A
|
||||
@@ -212,6 +217,7 @@ import { Module } from '@nestjs/common';
|
||||
CityWalkBriefingService,
|
||||
HikeRoutingService,
|
||||
HikePlanService,
|
||||
HikeWaysService,
|
||||
TourChangePreviewService,
|
||||
TourChangeSaveService,
|
||||
TourChangeUndoService,
|
||||
@@ -233,6 +239,8 @@ import { Module } from '@nestjs/common';
|
||||
TourActivityStatsMcp,
|
||||
CollectionToursMcp,
|
||||
TourPackingMcp,
|
||||
HikeWaysMcp,
|
||||
ToursWandererMcp,
|
||||
],
|
||||
})
|
||||
export class ToursModule {}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { makePackingBag, makePackingItem } from '../../helpers/factories/packing
|
||||
import { makeCategory, makePlace, makeTag } from '../../helpers/factories/places';
|
||||
import { makeReservation } from '../../helpers/factories/reservations';
|
||||
import { createRow, findRow, insertRow, updateRows } from '../../helpers/factories/rows';
|
||||
import { setAddonEnabled, setCollabFeature } from '../../helpers/factories/settings';
|
||||
import { setAddonEnabled, setAppSetting, setCollabFeature } from '../../helpers/factories/settings';
|
||||
import { makeTodoItem } from '../../helpers/factories/todos';
|
||||
import { makeTour } from '../../helpers/factories/tours';
|
||||
import { addTripMember, makeTrip, readTripDays } from '../../helpers/factories/trips';
|
||||
@@ -221,6 +221,8 @@ export async function openHarness(): Promise<ParityHarness> {
|
||||
const orm = app.get(MikroORM);
|
||||
await oneByOne(Object.values(ADDON_IDS), (addon) => setAddonEnabled(orm, addon, true));
|
||||
await oneByOne(['chat', 'notes', 'polls', 'whatsnext'] as const, (feature) => setCollabFeature(orm, feature, true));
|
||||
// The wanderer tools show only while wanderer is allowed under Tours.
|
||||
await setAppSetting(orm, 'tours_wanderer_enabled', 'true');
|
||||
// The photo search tools show only while a provider is on.
|
||||
await updateRows(orm, PhotoProviders, { id: { $in: ['immich', 'synologyphotos'] } }, { enabled: 1 });
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity';
|
||||
import { Tours } from '../../../src/db/entities/Tours.entity';
|
||||
import { RoadtripRouterService } from '../../../src/nest/roadtrip/roadtrip-router.service';
|
||||
import { proposalIdOf } from '../../../src/nest/tours/tour-change-proposal';
|
||||
import { ToursWandererService } from '../../../src/nest/tours/tours-wanderer.service';
|
||||
import { WandererService } from '../../../src/nest/wanderer/wanderer.service';
|
||||
import { makeDayAssignment } from '../../helpers/factories/itinerary';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { createRow, deleteRows, updateRows } from '../../helpers/factories/rows';
|
||||
@@ -192,6 +194,53 @@ const gpxOf = (s: Scene) =>
|
||||
`<gpx><trk><name>Ridge ${s.n}</name><trkseg><trkpt lat="47.6" lon="11.3"><ele>800</ele></trkpt>` +
|
||||
'<trkpt lat="47.61" lon="11.3"><ele>1000</ele></trkpt></trkseg></trk></gpx>';
|
||||
|
||||
/** The fingerprint of the wanderer trail a case's setup previewed, by the scene's number. */
|
||||
const trailHashFor = new Map<number, string>();
|
||||
|
||||
function trailHash(s: Scene): string {
|
||||
const hash = trailHashFor.get(s.n);
|
||||
if (hash === undefined) throw new Error(`scene ${s.n}: the case's setup previewed no trail`);
|
||||
return hash;
|
||||
}
|
||||
|
||||
/** No wanderer instance in a test: the owner's own trail, found and downloaded as the connection would. */
|
||||
function wandererTrail(app: INestApplication): () => void {
|
||||
const wanderer = app.get(WandererService);
|
||||
const summary = {
|
||||
id: 'trail-1',
|
||||
name: 'Kesselberg',
|
||||
description: null,
|
||||
location: 'Kochel',
|
||||
distanceM: 2200,
|
||||
durationS: 3600,
|
||||
elevationGainM: 200,
|
||||
elevationLossM: 0,
|
||||
difficulty: 'moderate' as const,
|
||||
public: false,
|
||||
author: 'owner',
|
||||
line: [],
|
||||
externalUrl: 'https://wanderer.example/trail/view/trail-1',
|
||||
};
|
||||
const found = vi.spyOn(wanderer, 'findTrail').mockResolvedValue(summary);
|
||||
const download = vi.spyOn(wanderer, 'download').mockResolvedValue({
|
||||
instance: 'https://wanderer.example',
|
||||
trail: { id: 'trail-1', name: 'Kesselberg', description: null, author: 'owner', updated: null } as never,
|
||||
gpx:
|
||||
'<gpx><trk><name>Kesselberg</name><trkseg><trkpt lat="47.6" lon="11.3"><ele>800</ele></trkpt>' +
|
||||
'<trkpt lat="47.62" lon="11.3"><ele>1000</ele></trkpt></trkseg></trk></gpx>',
|
||||
});
|
||||
return () => {
|
||||
found.mockRestore();
|
||||
download.mockRestore();
|
||||
};
|
||||
}
|
||||
|
||||
/** The trail previewed first, as the traveller sees it before the import: its fingerprint is what the import takes. */
|
||||
async function previewTrail(s: Scene, app: INestApplication): Promise<void> {
|
||||
const preview = await app.get(ToursWandererService).preview(String(s.trip.id), s.owner.id, 'trail-1');
|
||||
trailHashFor.set(s.n, preview.contentHash);
|
||||
}
|
||||
|
||||
const roadtripDay = (s: Scene) => `/api/trips/${s.trip.id}/roadtrip/days/${s.days[0].id}`;
|
||||
const tourPath = (s: Scene) => `/api/trips/${s.trip.id}/tours/${s.tourPlace.id}`;
|
||||
|
||||
@@ -365,6 +414,18 @@ export const toursRoadtrip: CaseGroup = {
|
||||
twin: 'POST /api/trips/:tripId/tours/import/gpx with the file',
|
||||
expect: ['tours:changed', 'place:created'],
|
||||
},
|
||||
{
|
||||
tool: 'import_wanderer_trail',
|
||||
setup: previewTrail,
|
||||
args: (s) => ({ tripId: s.trip.id, trailId: 'trail-1', contentHash: trailHash(s) }),
|
||||
rest: {
|
||||
method: 'post',
|
||||
path: (s) => `/api/trips/${s.trip.id}/tours/wanderer/import`,
|
||||
body: (s) => ({ trailId: 'trail-1', contentHash: trailHash(s) }),
|
||||
},
|
||||
stub: wandererTrail,
|
||||
expect: ['tours:changed', 'place:created'],
|
||||
},
|
||||
{
|
||||
tool: 'save_tour_change',
|
||||
needs: ['tour'],
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* ASSISTANT-WAYS-TAKE-001 to ASSISTANT-WAYS-TAKE-004: "Plan this" on a round
|
||||
* hike, another way of a leg and a wanderer trail: the change each holds,
|
||||
* taken from the stored proposal, never from the request.
|
||||
*/
|
||||
import { isWayProposal, wayTakeArgsOf } from '../../../../src/nest/assistant/assistant-proposal-ways';
|
||||
import { takeArgsOf } from '../../../../src/nest/assistant/assistant-proposals.service';
|
||||
import { writeToolOf } from '../../../../src/nest/assistant/assistant-tools.constants';
|
||||
import type { TourRouteCard } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const CARD = { kind: 'tourRoute', source: 'proposal', routed: true, placeId: null } as TourRouteCard;
|
||||
const START = { lat: 47.6, lng: 11.3, name: 'Car park', at_m: 0 };
|
||||
const ROUND = {
|
||||
create_with: {
|
||||
waypoints: [START, { lat: 47.61, lng: 11.31 }, { lat: 47.62, lng: 11.3, poi_category: 'peak' }],
|
||||
round_trip: true,
|
||||
},
|
||||
};
|
||||
|
||||
describe('taking a round or a leg’s way', () => {
|
||||
it('ASSISTANT-WAYS-TAKE-001: a round is created through the points it answered, under the rules it was asked with', () => {
|
||||
const args = { name: 'Round', start: START, distance_km: 10, max_hiking_difficulty: 3, day_id: 31, round: 'b' };
|
||||
expect(wayTakeArgsOf('propose_round_hike', args, ROUND, CARD)).toEqual({
|
||||
via: 'create_hike',
|
||||
name: 'Round',
|
||||
max_hiking_difficulty: 3,
|
||||
day_id: 31,
|
||||
round_trip: true,
|
||||
waypoints: [
|
||||
{ lat: 47.6, lng: 11.3, name: 'Car park' },
|
||||
{ lat: 47.61, lng: 11.31 },
|
||||
{ lat: 47.62, lng: 11.3, poi_category: 'peak' },
|
||||
],
|
||||
});
|
||||
expect(takeArgsOf({ toolName: 'propose_round_hike', args, result: ROUND, card: CARD })).toMatchObject({
|
||||
via: 'create_hike',
|
||||
});
|
||||
// A stop a day ends at stays one on a hike over several days.
|
||||
const trek = {
|
||||
create_with: { waypoints: [START, { lat: 47.7, lng: 11.4, stage_end: true }, START], round_trip: false },
|
||||
};
|
||||
expect(wayTakeArgsOf('propose_hike_leg_way', { name: 'Trek' }, trek, CARD)?.waypoints).toEqual([
|
||||
{ lat: 47.6, lng: 11.3, name: 'Car park' },
|
||||
{ lat: 47.7, lng: 11.4, stage_end: true },
|
||||
{ lat: 47.6, lng: 11.3, name: 'Car park' },
|
||||
]);
|
||||
expect(isWayProposal('propose_hike_leg_way')).toBe(true);
|
||||
expect(isWayProposal('toString')).toBe(false);
|
||||
});
|
||||
|
||||
it('ASSISTANT-WAYS-TAKE-002: nothing is taken from a way not planned, a saved hike’s leg or broken points', () => {
|
||||
expect(wayTakeArgsOf('propose_round_hike', {}, ROUND, { ...CARD, routed: false })).toBeNull();
|
||||
expect(wayTakeArgsOf('propose_hike_leg_way', {}, ROUND, { ...CARD, placeId: 61 })).toBeNull();
|
||||
const broken = { create_with: { waypoints: [START, { lat: 'x' }], round_trip: false } };
|
||||
expect(wayTakeArgsOf('propose_hike_leg_way', {}, broken, CARD)).toBeNull();
|
||||
expect(wayTakeArgsOf('propose_hike_leg_way', {}, {}, CARD)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('taking a wanderer trail', () => {
|
||||
it('ASSISTANT-WAYS-TAKE-003: a trail is imported with the fingerprint its preview answered', () => {
|
||||
const result = { contentHash: 'h'.repeat(32), state: 'new' };
|
||||
expect(wayTakeArgsOf('preview_wanderer_trail', { trailId: 'trail-1' }, result, CARD)).toEqual({
|
||||
via: 'import_wanderer_trail',
|
||||
trailId: 'trail-1',
|
||||
contentHash: 'h'.repeat(32),
|
||||
});
|
||||
// The take runs the import itself.
|
||||
expect(writeToolOf('take_proposal', { via: 'import_wanderer_trail' })).toBe('import_wanderer_trail');
|
||||
});
|
||||
|
||||
it('ASSISTANT-WAYS-TAKE-004: a trail already in the trip as it is, or a preview without its fingerprint, is not taken', () => {
|
||||
const args = { trailId: 'trail-1' };
|
||||
expect(wayTakeArgsOf('preview_wanderer_trail', args, { contentHash: 'h', state: 'unchanged' }, CARD)).toBeNull();
|
||||
expect(wayTakeArgsOf('preview_wanderer_trail', args, { state: 'new' }, CARD)).toBeNull();
|
||||
expect(wayTakeArgsOf('preview_wanderer_trail', {}, { contentHash: 'h', state: 'new' }, CARD)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* ASTCARD-TOUR-WAYS-001 to ASTCARD-TOUR-WAYS-008
|
||||
*
|
||||
* The cards of the planner's other searches through the assistant: a round
|
||||
* hike and another way for one leg, drawn as propose_hike's proposals are,
|
||||
* the leg's way beside the hike as it runs now; a wanderer trail previewed
|
||||
* as a way to take in, and its import with a change card and a way back.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import type { CardReader, PreviewContext } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import {
|
||||
answerCardOf,
|
||||
ASSISTANT_ARG_POLICY,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
CARD_PROMPT_LINES,
|
||||
} from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import {
|
||||
IMPORT_WANDERER_TRAIL,
|
||||
legWayCard,
|
||||
roundHikeCard,
|
||||
wandererPreviewCard,
|
||||
wandererTrailCard,
|
||||
} from '../../../../../../src/nest/assistant/cards/tour/tour-ways.cards';
|
||||
import { trekCardSchema } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const card = buildTripCard(1, 7, { trip: { title: 'Bavaria' }, days: [] }, { places: [] }, null);
|
||||
const ctx: PreviewContext = { card, categories: new Map(), locale: 'en-GB', today: '2026-06-01', airport: () => null };
|
||||
|
||||
const LINE: Array<[number, number, number]> = [
|
||||
[47.6, 11.3, 800],
|
||||
[47.61, 11.31, 950],
|
||||
[47.62, 11.3, 1100],
|
||||
[47.6, 11.3, 800],
|
||||
];
|
||||
|
||||
/** A proposal as propose_hike answers it, its way planned, with what the search adds. */
|
||||
function proposal(extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
found: true,
|
||||
name: 'Round over the Kesselberg',
|
||||
variant_label: null,
|
||||
route: { planned: true },
|
||||
distance_m: 10_400,
|
||||
ascent_m: 300,
|
||||
descent_m: 300,
|
||||
duration_minutes: 190,
|
||||
duration_basis: 'din33466',
|
||||
max_hiking_difficulty: 2,
|
||||
line_geometry: LINE,
|
||||
waypoints: [
|
||||
{ lat: 47.6, lng: 11.3, name: 'Car park', at_m: 0, minutes: 0 },
|
||||
{ lat: 47.61, lng: 11.31, name: null, at_m: 3000, minutes: 60 },
|
||||
{ lat: 47.6, lng: 11.3, name: 'Car park', at_m: 10_400, minutes: 190 },
|
||||
],
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
const answer = (value: unknown, args: Record<string, unknown> = {}) => ({
|
||||
toolCallId: 't1',
|
||||
args,
|
||||
value,
|
||||
card,
|
||||
today: '2026-06-01',
|
||||
});
|
||||
|
||||
describe('a round and another way of a leg', () => {
|
||||
it('ASTCARD-TOUR-WAYS-001: a round found is a hike proposal’s card; one not found draws nothing', () => {
|
||||
const drawn = roundHikeCard(answer(proposal({ round: { key: 'a' } })));
|
||||
expect(drawn).toMatchObject({ kind: 'tourRoute', source: 'proposal', activity: 'hike', distanceM: 10_400 });
|
||||
expect(trekCardSchema.safeParse(drawn).success).toBe(true);
|
||||
expect(roundHikeCard(answer({ found: false, way: 'b', ways_found: 1, why: 'none_found' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-WAYS-002: a leg’s way draws the hike as it runs now beneath it, and names a saved hike', () => {
|
||||
const value = proposal({
|
||||
leg_way: { key: 'b', leg: 1, ways_found: 2, tour_id: 61 },
|
||||
current: { distance_m: 9_800, ascent_m: 280, duration_minutes: 175 },
|
||||
current_geometry: LINE.slice(0, 3),
|
||||
});
|
||||
const drawn = legWayCard(answer(value));
|
||||
expect(drawn?.previous).toMatchObject({ distanceM: 9_800, ascentM: 280, minutes: 175 });
|
||||
expect(drawn?.previous?.line).toHaveLength(3);
|
||||
expect(drawn?.placeId).toBe(61);
|
||||
expect(trekCardSchema.safeParse(drawn).success).toBe(true);
|
||||
const unsaved = legWayCard(answer(proposal({ leg_way: { tour_id: null }, current: {} })));
|
||||
expect(unsaved).toMatchObject({ placeId: null, previous: null });
|
||||
expect(legWayCard(answer({ found: false }))).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-WAYS-003: the planning tools are always asked for their line, and the prompt names them', () => {
|
||||
for (const tool of ['propose_round_hike', 'propose_hike_leg_way', 'preview_wanderer_trail']) {
|
||||
expect(ASSISTANT_ARG_POLICY[tool]?.forced, tool).toEqual({ include_geometry: true });
|
||||
}
|
||||
expect(answerCardOf('propose_round_hike', answer(proposal()))?.kind).toBe('tourRoute');
|
||||
const prompt = CARD_PROMPT_LINES.join(' ');
|
||||
expect(prompt).toContain('propose_round_hike');
|
||||
expect(prompt).toContain('import_wanderer_trail');
|
||||
});
|
||||
});
|
||||
|
||||
const PREVIEW = {
|
||||
trail: {
|
||||
id: 'trail-1',
|
||||
name: 'Herzogstand',
|
||||
location: 'Kochel',
|
||||
difficulty: 'moderate',
|
||||
externalUrl: 'https://w.example/t/1',
|
||||
},
|
||||
distanceKm: 9.1,
|
||||
elevationGainM: 812,
|
||||
elevationLossM: 790,
|
||||
hasElevation: true,
|
||||
points: 900,
|
||||
line_geometry: [
|
||||
[47.6, 11.3],
|
||||
[47.62, 11.33],
|
||||
[47.65, 11.36],
|
||||
],
|
||||
contentHash: 'h'.repeat(32),
|
||||
state: 'new',
|
||||
snapshot: null,
|
||||
changes: [],
|
||||
};
|
||||
|
||||
describe('a wanderer trail', () => {
|
||||
it('ASTCARD-TOUR-WAYS-004: a trail previewed is a hike to take in: its line, length, climb and grade', () => {
|
||||
const drawn = wandererPreviewCard(answer(PREVIEW));
|
||||
expect(drawn).toMatchObject({
|
||||
source: 'proposal',
|
||||
activity: 'hike',
|
||||
placeId: null,
|
||||
name: 'Herzogstand',
|
||||
distanceM: 9100,
|
||||
ascentM: 812,
|
||||
descentM: 790,
|
||||
limit: 2,
|
||||
routed: true,
|
||||
});
|
||||
expect(drawn?.stops.map((stop) => stop.role)).toEqual(['start', 'end']);
|
||||
expect(drawn?.stops[0]?.name).toBe('Kochel');
|
||||
expect(trekCardSchema.safeParse(drawn).success).toBe(true);
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-WAYS-005: one already in the trip as it is reads as that saved tour; a loop has one stop', () => {
|
||||
const kept = wandererPreviewCard(answer({ ...PREVIEW, state: 'unchanged', snapshot: { placeId: 61 } }));
|
||||
expect(kept).toMatchObject({ source: 'saved', placeId: 61 });
|
||||
const loop = wandererTrailCard(
|
||||
{
|
||||
...PREVIEW,
|
||||
trail: { name: 'Loop', difficulty: 'unknown' },
|
||||
line_geometry: [
|
||||
[47.6, 11.3],
|
||||
[47.62, 11.33],
|
||||
[47.6, 11.3],
|
||||
],
|
||||
},
|
||||
'pending',
|
||||
);
|
||||
expect(loop).toMatchObject({ source: 'pending', placeId: null, limit: null });
|
||||
expect(loop?.stops).toHaveLength(1);
|
||||
expect(wandererPreviewCard(answer({ ...PREVIEW, line_geometry: [] }))).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-WAYS-006: a held import reads the trail again and shows it waiting, named and graded', async () => {
|
||||
const read = vi.fn(async () => PREVIEW) as unknown as CardReader;
|
||||
const loaded = await IMPORT_WANDERER_TRAIL.load?.({ trailId: 'trail-1', contentHash: 'x' }, read);
|
||||
expect(read).toHaveBeenCalledWith('preview_wanderer_trail', { trailId: 'trail-1', include_geometry: true });
|
||||
const described = IMPORT_WANDERER_TRAIL.describe?.(ctx, { trailId: 'trail-1' }, loaded ?? null);
|
||||
expect(described?.subject).toBe('Herzogstand');
|
||||
expect(described?.rows.rows.map((row) => [row.labelKey, row.after])).toEqual([
|
||||
['location', 'Kochel'],
|
||||
['difficulty', 'T2'],
|
||||
['link', 'https://w.example/t/1'],
|
||||
]);
|
||||
expect(IMPORT_WANDERER_TRAIL.change?.(ctx, {}, loaded ?? null)).toMatchObject({ source: 'pending' });
|
||||
expect(IMPORT_WANDERER_TRAIL.change?.(ctx, {}, null)).toBeNull();
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-WAYS-007: an import without a trail looks nothing up and is named by the trail asked for', async () => {
|
||||
const read = vi.fn() as unknown as CardReader;
|
||||
expect(await IMPORT_WANDERER_TRAIL.load?.({}, read)).toBeNull();
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
expect(IMPORT_WANDERER_TRAIL.describe?.(ctx, { trailId: 'trail-9' }, null).subject).toBe('trail-9');
|
||||
});
|
||||
|
||||
it('ASTCARD-TOUR-WAYS-008: an import is a tour added, taken back by deleting the tour it made', () => {
|
||||
expect(ASSISTANT_WRITE_TOOLS.import_wanderer_trail).toEqual({ verbKey: 'importTour', entity: 'tour' });
|
||||
expect(ASSISTANT_UNDO_MAP.import_wanderer_trail?.args({}, { tour: { place_id: 72 } }, null)).toEqual({
|
||||
placeId: 72,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The pure halves of the round hikes and the ways of a leg the assistant
|
||||
* proposes: which way a call asks for, the points a found way runs through,
|
||||
* and the keys a search is kept under.
|
||||
*/
|
||||
import {
|
||||
legEndsOf,
|
||||
legWaysKey,
|
||||
percentOf,
|
||||
pinnedPointsOf,
|
||||
roadOf,
|
||||
roundPointsOf,
|
||||
roundSearchKey,
|
||||
rulesOf,
|
||||
wayIndexOf,
|
||||
wayKeyAt,
|
||||
wayMissing,
|
||||
} from '../../../src/nest/tours/hike-ways.helpers';
|
||||
import { HIKE_MAX_WAYPOINTS, TOURS_MAX_HIKING_DIFFICULTY } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// HIKE-WAYS-HELP-001 to HIKE-WAYS-HELP-006
|
||||
|
||||
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('the ways a call asks for', () => {
|
||||
it('HIKE-WAYS-HELP-001: a, b and c are the first, second and third way; a when left out', () => {
|
||||
expect([wayIndexOf(undefined), wayIndexOf('a'), wayIndexOf('b'), wayIndexOf('c')]).toEqual([0, 0, 1, 2]);
|
||||
expect([wayKeyAt(0), wayKeyAt(2), wayKeyAt(7)]).toEqual(['a', 'c', 'a']);
|
||||
expect(wayMissing('b', 1, null)).toEqual({ found: false, way: 'b', ways_found: 1, why: 'none_found' });
|
||||
expect(wayMissing(undefined, 0, 'no_valhalla')).toEqual({
|
||||
found: false,
|
||||
way: 'a',
|
||||
ways_found: 0,
|
||||
why: 'no_valhalla',
|
||||
});
|
||||
});
|
||||
|
||||
it('HIKE-WAYS-HELP-002: the rules default to the hike’s limit and no preferences; shares read as whole percent', () => {
|
||||
expect(rulesOf({})).toEqual({ limit: TOURS_MAX_HIKING_DIFFICULTY, preferences: null });
|
||||
expect(rulesOf({ max_hiking_difficulty: 4 }).limit).toBe(4);
|
||||
expect([percentOf(0.084), percentOf(-0.05)]).toEqual([8, -5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the keys a search is kept under', () => {
|
||||
it('HIKE-WAYS-HELP-003: one search per user, trip, start to about a metre, length and rules', () => {
|
||||
const round = { name: 'Round', start: A, distance_km: 10 };
|
||||
expect(roundSearchKey(1, 2, round)).toBe(roundSearchKey(1, 2, { ...round, name: 'Other', round: 'c' }));
|
||||
expect(roundSearchKey(1, 2, round)).toBe(roundSearchKey(1, 2, { ...round, start: { ...A, lat: 47.500001 } }));
|
||||
expect(roundSearchKey(1, 2, round)).not.toBe(roundSearchKey(3, 2, round));
|
||||
expect(roundSearchKey(1, 2, round)).not.toBe(roundSearchKey(1, 2, { ...round, distance_km: 12 }));
|
||||
expect(roundSearchKey(1, 2, round)).not.toBe(roundSearchKey(1, 2, { ...round, max_hiking_difficulty: 4 }));
|
||||
const leg = { name: 'Hike', waypoints: [A, B, C], leg: 1 };
|
||||
expect(legWaysKey(1, 2, leg)).toBe(legWaysKey(1, 2, { ...leg, way: 'b' }));
|
||||
expect(legWaysKey(1, 2, leg)).not.toBe(legWaysKey(1, 2, { ...leg, leg: 2 }));
|
||||
expect(legWaysKey(1, 2, leg)).not.toBe(legWaysKey(1, 2, { ...leg, round_trip: true }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('the points of a way', () => {
|
||||
it('HIKE-WAYS-HELP-004: a round runs from its start through the spots its loop was steered through, and back', () => {
|
||||
expect(roundPointsOf(A, [B, { lat: 47.6, lng: 11.3 }])).toEqual({
|
||||
waypoints: [A, { lat: B.lat, lng: B.lng }, { lat: 47.6, lng: 11.3 }],
|
||||
round_trip: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('HIKE-WAYS-HELP-005: a leg runs between its two points, the way back of a round trip to its start', () => {
|
||||
expect(legEndsOf([A, B, C], false, 1)).toEqual({
|
||||
from: { lat: A.lat, lng: A.lng },
|
||||
to: { lat: B.lat, lng: B.lng },
|
||||
});
|
||||
expect(legEndsOf([A, B, C], true, 3)).toEqual({ from: { lat: C.lat, lng: C.lng }, to: { lat: A.lat, lng: A.lng } });
|
||||
expect(legEndsOf([A, B, C], false, 3)).toBeNull();
|
||||
});
|
||||
|
||||
it('HIKE-WAYS-HELP-006: the pins of a leg go right after its first point, those of the way back last', () => {
|
||||
const pin = { lat: 47.51, lng: 11.12 };
|
||||
expect(pinnedPointsOf([A, B, C], 1, [pin])).toEqual([A, pin, B, C]);
|
||||
expect(pinnedPointsOf([A, B, C], 2, [pin])).toEqual([A, B, pin, C]);
|
||||
expect(pinnedPointsOf([A, B, C], 3, [pin])).toEqual([A, B, C, pin]);
|
||||
const full = Array.from({ length: HIKE_MAX_WAYPOINTS }, (_, index) => ({ lat: 47 + index / 100, lng: 11 }));
|
||||
expect(pinnedPointsOf(full, 1, [pin])).toBeNull();
|
||||
expect(roadOf([[47, 11]])).toEqual({ coordinates: [[47, 11]], distance: 0, duration: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* HikeWaysMcp and HikeWaysService: the planner's round hikes of a wished
|
||||
* length and its other ways for one leg, over MCP. The search is the shared
|
||||
* one, asked of a stubbed RoadtripRouterService that walks straight between
|
||||
* the points it is given; each way found is proposed through a stub of
|
||||
* HikePlanService.propose (its own suite plans the hike). The trip and a
|
||||
* saved hike are seeded through the ORM factories over a snapshot test db.
|
||||
*/
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { RoadtripRouterService, RouteRefusal } from '../../../src/nest/roadtrip/roadtrip-router.service';
|
||||
import { HikePlanService } from '../../../src/nest/tours/hike-plan.service';
|
||||
import { HikeWaysMcp } from '../../../src/nest/tours/hike-ways.mcp';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { makePlace } from '../../helpers/factories/places';
|
||||
import { makeTour } from '../../helpers/factories/tours';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import type { HikeProposeRequest } from '@trek/shared';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// HIKE-WAYS-MCP-001 to HIKE-WAYS-MCP-008
|
||||
|
||||
type Spot = { lat: number; lng: number };
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const route = vi.fn<RoadtripRouterService['route']>();
|
||||
const alternatesOnFoot = vi.fn<RoadtripRouterService['alternatesOnFoot']>();
|
||||
const propose = vi.fn<HikePlanService['propose']>();
|
||||
let t: TestModule;
|
||||
let mcp: HikeWaysMcp;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
/** The router's answer for points walked straight between, each met where it was asked. */
|
||||
function straight(points: Spot[]) {
|
||||
return {
|
||||
leg: {
|
||||
line: points.map(({ lat, lng }): [number, number] => [lat, lng]),
|
||||
seg: { distance: 0, duration: 0 },
|
||||
},
|
||||
parts: points.slice(1).map(() => ({ distance: 0, duration: 0 })),
|
||||
snapped: points.map(({ lat, lng }) => ({ asked: [lat, lng], at: [lat, lng], meters: 0 })),
|
||||
avoidMissed: [],
|
||||
} as unknown as Awaited<ReturnType<RoadtripRouterService['route']>>;
|
||||
}
|
||||
|
||||
/** The stub's proposal: the request's points given back, a length and a line. */
|
||||
async function proposal(_userId: number, _tripId: number, request: HikeProposeRequest) {
|
||||
return {
|
||||
name: request.name,
|
||||
variant_label: request.variant_label ?? null,
|
||||
route: { planned: true },
|
||||
distance_m: 10_400,
|
||||
ascent_m: 420,
|
||||
duration_minutes: 190,
|
||||
waypoints: request.waypoints,
|
||||
round_trip: request.round_trip ?? false,
|
||||
line_geometry: request.waypoints.map(({ lat, lng }): [number, number] => [lat, lng]),
|
||||
} as unknown as Awaited<ReturnType<HikePlanService['propose']>>;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [
|
||||
{ provide: RoadtripRouterService, useValue: { route, alternatesOnFoot } },
|
||||
{ provide: HikePlanService, useValue: { propose } },
|
||||
],
|
||||
});
|
||||
mcp = t.get(HikeWaysMcp);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
route.mockReset().mockImplementation(async (_u, _t, _d, points) => straight(points));
|
||||
alternatesOnFoot.mockReset();
|
||||
propose.mockReset().mockImplementation(proposal);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
/** The JSON a tool answered, or the error text it refused with. */
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
async function tripOf() {
|
||||
const { user } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
return { userId: user.id, tripId: trip.id };
|
||||
}
|
||||
|
||||
const START = { lat: 47.5, lng: 11.1, name: 'Car park' };
|
||||
|
||||
describe('propose_round_hike', () => {
|
||||
it('HIKE-WAYS-MCP-001: proposes the round asked for as a hike back to its start, with the points to create it', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
const answer = read(
|
||||
await mcp.proposeRoundHike({ tripId, name: 'Round', start: START, distance_km: 10, round: 'b' }, ctx(userId)),
|
||||
);
|
||||
expect(answer.found).toBe(true);
|
||||
expect(answer.round).toMatchObject({ key: 'b', target_m: 10_000, deviation_pct: 4, rounds_found: 3 });
|
||||
expect(answer.create_with.round_trip).toBe(true);
|
||||
expect(answer.create_with.waypoints).toHaveLength(4);
|
||||
expect(answer.create_with.waypoints[0]).toEqual(START);
|
||||
const [, , asked] = propose.mock.calls[0] ?? [];
|
||||
expect(asked).toMatchObject({ name: 'Round', round_trip: true, along: false });
|
||||
// Every loop was asked on foot under the hike's own costing, out of the start and back.
|
||||
for (const [, , , points, profile, , options] of route.mock.calls) {
|
||||
expect(points[0]).toEqual({ lat: START.lat, lng: START.lng });
|
||||
expect(points.at(-1)).toEqual({ lat: START.lat, lng: START.lng });
|
||||
expect(profile).toBe('walking');
|
||||
expect(options?.costingOptions).toMatchObject({ max_hiking_difficulty: 2 });
|
||||
}
|
||||
});
|
||||
|
||||
it('HIKE-WAYS-MCP-002: the rounds of one search are asked for one after another without asking the router again', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
const request = { tripId, name: 'Round', start: START, distance_km: 10 };
|
||||
await mcp.proposeRoundHike({ ...request, round: 'a' }, ctx(userId));
|
||||
const asked = route.mock.calls.length;
|
||||
const c = read(await mcp.proposeRoundHike({ ...request, round: 'c' }, ctx(userId)));
|
||||
expect(c.round.key).toBe('c');
|
||||
expect(route.mock.calls.length).toBe(asked);
|
||||
});
|
||||
|
||||
it('HIKE-WAYS-MCP-003: a router that never answers, or no Valhalla, finds no round and says why', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
route.mockRejectedValue(new Error('down'));
|
||||
const down = read(await mcp.proposeRoundHike({ tripId, name: 'R', start: START, distance_km: 8 }, ctx(userId)));
|
||||
expect(down).toEqual({ found: false, way: 'a', ways_found: 0, why: 'router_unavailable' });
|
||||
route.mockReset().mockRejectedValue(new RouteRefusal('no_valhalla', 'none'));
|
||||
const none = read(await mcp.proposeRoundHike({ tripId, name: 'R', start: START, distance_km: 9 }, ctx(userId)));
|
||||
expect(none.why).toBe('no_valhalla');
|
||||
// Without a Valhalla the search stops at its first question.
|
||||
expect(route).toHaveBeenCalledTimes(1);
|
||||
expect(propose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('HIKE-WAYS-MCP-004: a trip the caller cannot see is not found', async () => {
|
||||
const { tripId } = await tripOf();
|
||||
const { user: stranger } = await makeUser(orm());
|
||||
const answer = read(
|
||||
await mcp.proposeRoundHike({ tripId, name: 'R', start: START, distance_km: 8 }, ctx(stranger.id)),
|
||||
);
|
||||
expect(answer.error).toBeDefined();
|
||||
expect(route).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
const A = { lat: 47.5, lng: 11.1, name: 'Car park' };
|
||||
const B = { lat: 47.52, lng: 11.1, name: 'Hut' };
|
||||
const OWN: Array<[number, number]> = [
|
||||
[47.5, 11.1],
|
||||
[47.52, 11.1],
|
||||
];
|
||||
const OTHER: Array<[number, number]> = [
|
||||
[47.5, 11.1],
|
||||
[47.51, 11.12],
|
||||
[47.52, 11.1],
|
||||
];
|
||||
|
||||
describe('propose_hike_leg_way', () => {
|
||||
it('HIKE-WAYS-MCP-005: the other way of a leg is the whole hike with the way held by a pin, beside the hike as it runs', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
alternatesOnFoot.mockResolvedValue([OWN, OTHER]);
|
||||
const answer = read(
|
||||
await mcp.proposeHikeLegWay(
|
||||
{ tripId, name: 'Hike', waypoints: [A, B], leg: 1, include_geometry: true },
|
||||
ctx(userId),
|
||||
),
|
||||
);
|
||||
expect(answer.found).toBe(true);
|
||||
expect(answer.leg_way).toEqual({ key: 'a', leg: 1, ways_found: 1, tour_id: null });
|
||||
expect(answer.create_with).toEqual({ waypoints: [A, { lat: 47.51, lng: 11.12 }, B], round_trip: false });
|
||||
expect(answer.current).toEqual({ distance_m: 10_400, ascent_m: 420, duration_minutes: 190 });
|
||||
expect(answer.current_geometry).toHaveLength(2);
|
||||
const [, from, to, costing, count] = alternatesOnFoot.mock.calls[0] ?? [];
|
||||
expect([from, to, count]).toEqual([{ lat: A.lat, lng: A.lng }, { lat: B.lat, lng: B.lng }, 3]);
|
||||
expect(costing).toMatchObject({ max_hiking_difficulty: 2 });
|
||||
});
|
||||
|
||||
it('HIKE-WAYS-MCP-006: a router that knows only its own way, or a way it does not keep to, offers none', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
alternatesOnFoot.mockResolvedValue([OWN, OWN]);
|
||||
const same = read(await mcp.proposeHikeLegWay({ tripId, name: 'H', waypoints: [A, B], leg: 1 }, ctx(userId)));
|
||||
expect(same).toEqual({ found: false, way: 'a', ways_found: 0, why: 'none_found' });
|
||||
alternatesOnFoot.mockResolvedValue([OWN, OTHER]);
|
||||
route.mockRejectedValue(new Error('down'));
|
||||
const held = read(
|
||||
await mcp.proposeHikeLegWay(
|
||||
{ tripId, name: 'H', waypoints: [A, B], leg: 1, max_hiking_difficulty: 3 },
|
||||
ctx(userId),
|
||||
),
|
||||
);
|
||||
expect(held.found).toBe(false);
|
||||
alternatesOnFoot.mockRejectedValue(new RouteRefusal('no_way', 'none'));
|
||||
const none = read(
|
||||
await mcp.proposeHikeLegWay(
|
||||
{ tripId, name: 'H', waypoints: [A, B], leg: 1, max_hiking_difficulty: 4 },
|
||||
ctx(userId),
|
||||
),
|
||||
);
|
||||
expect(none.why).toBe('no_way');
|
||||
});
|
||||
|
||||
it('HIKE-WAYS-MCP-007: a leg the hike does not have is refused', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
const answer = read(await mcp.proposeHikeLegWay({ tripId, name: 'H', waypoints: [A, B], leg: 2 }, ctx(userId)));
|
||||
expect(answer.error).toContain('no such leg');
|
||||
expect(alternatesOnFoot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('HIKE-WAYS-MCP-008: the way of a saved hike names that hike', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
const place = await makePlace(orm(), tripId, { name: 'Saved hike', lat: A.lat, lng: A.lng });
|
||||
await makeTour(orm(), place.id);
|
||||
alternatesOnFoot.mockResolvedValue([OWN, OTHER]);
|
||||
const answer = read(
|
||||
await mcp.proposeHikeLegWay({ tripId, name: 'H', waypoints: [A, B], leg: 1, tour_id: place.id }, ctx(userId)),
|
||||
);
|
||||
expect(answer.leg_way.tour_id).toBe(place.id);
|
||||
});
|
||||
});
|
||||
@@ -588,3 +588,89 @@ describe('server Roadtrip router, a hike: its costing, heights and ways', () =>
|
||||
await expect(setup().router.wayIds(1, line(3))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('server Roadtrip router: other ways on foot (RTE-02)', () => {
|
||||
const trip = (points: Array<[number, number]>) => ({
|
||||
legs: [{ shape: valhallaPolyline(points), summary: { length: 1.2, time: 900 } }],
|
||||
});
|
||||
|
||||
it('ROUTER-WAYS-001: asks Valhalla once on foot under the hike’s costing, its own way first', async () => {
|
||||
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
trip: trip([
|
||||
[47, 11],
|
||||
[47.01, 11.01],
|
||||
]),
|
||||
alternates: [
|
||||
{
|
||||
trip: trip([
|
||||
[47, 11],
|
||||
[47.02, 11],
|
||||
[47.01, 11.01],
|
||||
]),
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const { router } = setup({ valhalla_base_url: 'https://valhalla.internal/' });
|
||||
const ways = await router.alternatesOnFoot(
|
||||
1,
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.01, lng: 11.01 },
|
||||
{ max_hiking_difficulty: 3 },
|
||||
3,
|
||||
);
|
||||
expect(ways).toEqual([
|
||||
[
|
||||
[47, 11],
|
||||
[47.01, 11.01],
|
||||
],
|
||||
[
|
||||
[47, 11],
|
||||
[47.02, 11],
|
||||
[47.01, 11.01],
|
||||
],
|
||||
]);
|
||||
const [url, init] = vi.mocked(safeFetchAdminConfigured).mock.calls[0] ?? [];
|
||||
expect(url).toBe('https://valhalla.internal/route');
|
||||
expect(JSON.parse(String((init as RequestInit).body))).toMatchObject({
|
||||
costing: 'pedestrian',
|
||||
costing_options: { pedestrian: { max_hiking_difficulty: 3 } },
|
||||
alternates: 3,
|
||||
locations: [
|
||||
{ lat: 47, lon: 11 },
|
||||
{ lat: 47.01, lon: 11.01 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('ROUTER-WAYS-002: an answer without alternates is the one way; no Valhalla refuses before asking', async () => {
|
||||
vi.mocked(safeFetchAdminConfigured).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
trip: trip([
|
||||
[47, 11],
|
||||
[47.01, 11.01],
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
const ways = await setup().router.alternatesOnFoot(1, { lat: 47, lng: 11 }, { lat: 47.01, lng: 11.01 }, {}, 0);
|
||||
expect(ways).toHaveLength(1);
|
||||
const body = JSON.parse(String((vi.mocked(safeFetchAdminConfigured).mock.calls[0]?.[1] as RequestInit).body));
|
||||
expect(body.alternates).toBeUndefined();
|
||||
vi.mocked(safeFetchAdminConfigured).mockClear();
|
||||
await expect(
|
||||
setup({ routing_base_url: 'http://router.internal' }).router.alternatesOnFoot(
|
||||
1,
|
||||
{ lat: 47, lng: 11 },
|
||||
{ lat: 47.01, lng: 11.01 },
|
||||
{},
|
||||
3,
|
||||
),
|
||||
).rejects.toMatchObject({ why: 'no_valhalla' });
|
||||
expect(safeFetchAdminConfigured).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* ToursWandererMcp: the trip-side wanderer routes over MCP. Each tool checks
|
||||
* trip access and 'place_edit' as the REST guards do, then hands the same
|
||||
* values to ToursWandererService, a stub in this suite (its own suite reads
|
||||
* a stubbed instance); the trip and its members are seeded through the ORM
|
||||
* factories over a snapshot test db.
|
||||
*/
|
||||
import { TripMembers } from '../../../src/db/entities/TripMembers.entity';
|
||||
import type { McpContext } from '../../../src/nest-mcp';
|
||||
import { getEntry, type ClassRef } from '../../../src/nest-mcp/metadata';
|
||||
import { AddonsService } from '../../../src/nest/addons/addons.service';
|
||||
import { McpToolGuardsService } from '../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { ToursWandererMcp } from '../../../src/nest/tours/tours-wanderer.mcp';
|
||||
import { ToursWandererService } from '../../../src/nest/tours/tours-wanderer.service';
|
||||
import { ToursModule } from '../../../src/nest/tours/tours.module';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { insertRow } from '../../helpers/factories/rows';
|
||||
import { makeTrip } from '../../helpers/factories/trips';
|
||||
import { makeUser } from '../../helpers/factories/users';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// WANDERER-MCP-001 to WANDERER-MCP-006
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const search = vi.fn<ToursWandererService['search']>();
|
||||
const preview = vi.fn<ToursWandererService['preview']>();
|
||||
const importTrail = vi.fn<ToursWandererService['import']>();
|
||||
const checkSource = vi.fn<ToursWandererService['checkSource']>();
|
||||
const hasTripPermission = vi.fn<McpToolGuardsService['hasTripPermission']>();
|
||||
const isWandererAvailable = vi.fn<AddonsService['isWandererAvailable']>();
|
||||
let t: TestModule;
|
||||
let mcp: ToursWandererMcp;
|
||||
const orm = () => t.get(MikroORM);
|
||||
|
||||
const TRAIL = {
|
||||
id: 'trail-1',
|
||||
name: 'Herzogstand',
|
||||
description: null,
|
||||
location: 'Kochel',
|
||||
distanceM: 9000,
|
||||
durationS: 14_400,
|
||||
elevationGainM: 800,
|
||||
elevationLossM: 800,
|
||||
difficulty: 'moderate' as const,
|
||||
public: true,
|
||||
author: null,
|
||||
line: [
|
||||
[47.6, 11.3],
|
||||
[47.62, 11.33],
|
||||
] as Array<[number, number]>,
|
||||
externalUrl: 'https://wanderer.example/trails/trail-1',
|
||||
importedAs: [],
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({
|
||||
db,
|
||||
imports: [ToursModule],
|
||||
overrides: [
|
||||
{ provide: ToursWandererService, useValue: { search, preview, import: importTrail, checkSource } },
|
||||
{ provide: McpToolGuardsService, useValue: { hasTripPermission } },
|
||||
],
|
||||
});
|
||||
mcp = t.get(ToursWandererMcp);
|
||||
vi.spyOn(t.get(AddonsService), 'isWandererAvailable').mockImplementation(isWandererAvailable);
|
||||
});
|
||||
beforeEach(() => {
|
||||
resetTestDb(db);
|
||||
hasTripPermission.mockReset().mockResolvedValue(true);
|
||||
isWandererAvailable.mockReset().mockResolvedValue(true);
|
||||
search.mockReset().mockResolvedValue({ trails: [TRAIL], total: 1, page: 1, pageSize: 20 });
|
||||
preview.mockReset().mockResolvedValue({
|
||||
trail: TRAIL,
|
||||
distanceKm: 9.1,
|
||||
elevationGainM: 812,
|
||||
elevationLossM: 812,
|
||||
hasElevation: true,
|
||||
points: 900,
|
||||
line: TRAIL.line,
|
||||
contentHash: 'h'.repeat(32),
|
||||
state: 'new',
|
||||
snapshot: null,
|
||||
changes: [],
|
||||
});
|
||||
importTrail.mockReset();
|
||||
checkSource.mockReset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const ctx = (userId: number) => ({ userId }) as McpContext;
|
||||
|
||||
function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) {
|
||||
const text = result.content[0]?.text ?? '';
|
||||
return result.isError ? { error: text } : JSON.parse(text);
|
||||
}
|
||||
|
||||
async function tripOf() {
|
||||
const { user } = await makeUser(orm());
|
||||
const trip = await makeTrip(orm(), user.id);
|
||||
return { userId: user.id, tripId: trip.id };
|
||||
}
|
||||
|
||||
describe('wanderer over MCP', () => {
|
||||
it('WANDERER-MCP-001: a search hands its words on and answers the trails without their lines', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
const answer = read(await mcp.searchTrails({ tripId, q: 'Herzogstand' }, ctx(userId)));
|
||||
expect(search).toHaveBeenCalledWith(String(tripId), userId, { q: 'Herzogstand', page: 1, bbox: undefined });
|
||||
expect(answer.trails[0]).toMatchObject({ id: 'trail-1', name: 'Herzogstand' });
|
||||
expect(answer.trails[0].line).toBeUndefined();
|
||||
expect(hasTripPermission).toHaveBeenCalledWith('place_edit', tripId, userId);
|
||||
});
|
||||
|
||||
it('WANDERER-MCP-002: a preview answers its line for drawing only when asked, as line_geometry', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
const plain = read(await mcp.previewTrail({ tripId, trailId: 'trail-1' }, ctx(userId)));
|
||||
expect(plain.line).toBeUndefined();
|
||||
expect(plain.line_geometry).toBeUndefined();
|
||||
expect(plain.trail.line).toBeUndefined();
|
||||
expect(plain.contentHash).toBe('h'.repeat(32));
|
||||
const drawn = read(await mcp.previewTrail({ tripId, trailId: 'trail-1', include_geometry: true }, ctx(userId)));
|
||||
expect(drawn.line_geometry).toEqual(TRAIL.line);
|
||||
});
|
||||
|
||||
it('WANDERER-MCP-003: the import and the source check run through the service with the caller’s rights', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
importTrail.mockResolvedValue({ tour: { place_id: 9 } } as never);
|
||||
const imported = read(
|
||||
await mcp.importTrail({ tripId, trailId: 'trail-1', contentHash: 'h'.repeat(32) }, ctx(userId)),
|
||||
);
|
||||
expect(imported.tour.place_id).toBe(9);
|
||||
expect(importTrail).toHaveBeenCalledWith(String(tripId), userId, {
|
||||
trailId: 'trail-1',
|
||||
contentHash: 'h'.repeat(32),
|
||||
});
|
||||
checkSource.mockResolvedValue({
|
||||
state: 'unchanged',
|
||||
changes: [],
|
||||
externalUpdated: null,
|
||||
importedUpdated: null,
|
||||
editedLocally: false,
|
||||
reimport: null,
|
||||
});
|
||||
const checked = read(await mcp.checkSource({ tripId, placeId: 9 }, ctx(userId)));
|
||||
expect(checked.state).toBe('unchanged');
|
||||
expect(checkSource).toHaveBeenCalledWith(String(tripId), userId, '9');
|
||||
});
|
||||
|
||||
it('WANDERER-MCP-004: without place editing permission nothing is read or written', async () => {
|
||||
const { userId, tripId } = await tripOf();
|
||||
hasTripPermission.mockResolvedValue(false);
|
||||
expect(read(await mcp.searchTrails({ tripId }, ctx(userId))).error).toBeDefined();
|
||||
expect(
|
||||
read(await mcp.importTrail({ tripId, trailId: 'x', contentHash: 'h'.repeat(32) }, ctx(userId))).error,
|
||||
).toBeDefined();
|
||||
expect(search).not.toHaveBeenCalled();
|
||||
expect(importTrail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WANDERER-MCP-005: a trip the caller cannot see is not found', async () => {
|
||||
const { tripId } = await tripOf();
|
||||
const { user: stranger } = await makeUser(orm());
|
||||
expect(read(await mcp.previewTrail({ tripId, trailId: 'trail-1' }, ctx(stranger.id))).error).toBeDefined();
|
||||
expect(preview).not.toHaveBeenCalled();
|
||||
// A member who may not edit places is refused as well.
|
||||
await insertRow(orm(), TripMembers, { trip: tripId, user: stranger.id });
|
||||
hasTripPermission.mockResolvedValue(false);
|
||||
expect(read(await mcp.previewTrail({ tripId, trailId: 'trail-1' }, ctx(stranger.id))).error).toBeDefined();
|
||||
expect(preview).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WANDERER-MCP-006: the tools are offered only while wanderer is allowed under Tours', async () => {
|
||||
const methods = ['searchTrails', 'previewTrail', 'importTrail', 'checkSource'];
|
||||
const options = methods.map((method) => {
|
||||
const entry = getEntry(ToursWandererMcp as unknown as ClassRef, method);
|
||||
if (!entry || entry.kind !== 'tool') throw new Error(`${method} is no MCP tool`);
|
||||
return entry.options;
|
||||
});
|
||||
expect(options.map((tool) => [tool.name, tool.annotations?.readOnlyHint, tool.access])).toEqual([
|
||||
['search_wanderer_trails', true, { group: 'trips', mode: 'read' }],
|
||||
['preview_wanderer_trail', true, { group: 'trips', mode: 'read' }],
|
||||
['import_wanderer_trail', false, { group: 'trips', mode: 'write' }],
|
||||
['check_tour_source', true, { group: 'trips', mode: 'read' }],
|
||||
]);
|
||||
const gate = options[0]?.when;
|
||||
expect(await gate?.(ctx(1), mcp)).toBe(true);
|
||||
isWandererAvailable.mockResolvedValue(false);
|
||||
expect(await gate?.(ctx(1), mcp)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
+12
-9
@@ -1,15 +1,18 @@
|
||||
// FE-TOUR-ROUND-GEOMETRY-001 to FE-TOUR-ROUND-GEOMETRY-005: where a round hike's loop is steered
|
||||
// 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';
|
||||
import { haversineKm } from '../../../utils/geo';
|
||||
import { loopSpots, retracedShare, sharedShare, spotToward, STRAIGHT_PER_RADIUS_M } from './roundHikeGeometry';
|
||||
|
||||
const START = { lat: 47.5, lng: 11.1 };
|
||||
const meters = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) => haversineKm(a, b) * 1000;
|
||||
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('FE-TOUR-ROUND-GEOMETRY-001: lies the asked distance away in the asked direction', () => {
|
||||
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);
|
||||
@@ -23,7 +26,7 @@ describe('spotToward', () => {
|
||||
});
|
||||
|
||||
describe('loopSpots', () => {
|
||||
it('FE-TOUR-ROUND-GEOMETRY-002: three corners of a square in a circle touching the start, its centre toward the bearing', () => {
|
||||
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);
|
||||
@@ -39,7 +42,7 @@ describe('loopSpots', () => {
|
||||
});
|
||||
|
||||
describe('retracedShare', () => {
|
||||
it('FE-TOUR-ROUND-GEOMETRY-003: a loop covers little ground twice, a way there and back all of it', () => {
|
||||
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);
|
||||
@@ -50,7 +53,7 @@ describe('retracedShare', () => {
|
||||
expect(retracedShare(lollipop)).toBeGreaterThan(0.4);
|
||||
});
|
||||
|
||||
it('FE-TOUR-ROUND-GEOMETRY-004: a line too short to sample shares nothing', () => {
|
||||
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);
|
||||
@@ -58,7 +61,7 @@ describe('retracedShare', () => {
|
||||
});
|
||||
|
||||
describe('sharedShare', () => {
|
||||
it('FE-TOUR-ROUND-GEOMETRY-005: the share of one line on the ground of another', () => {
|
||||
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);
|
||||
+19
-11
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 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.
|
||||
* 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 };
|
||||
@@ -56,21 +57,26 @@ interface Sample {
|
||||
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) {
|
||||
function projector(refLat: number): Projector {
|
||||
const perDegLat = (Math.PI * EARTH_M) / 180;
|
||||
const perDegLng = perDegLat * Math.cos(toRad(refLat));
|
||||
return ([lat, lng]: readonly [number, number]) => ({ x: lng * perDegLng, y: lat * perDegLat });
|
||||
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: ReturnType<typeof projector>): Sample[] {
|
||||
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 a = project(line[i - 1]);
|
||||
const b = project(line[i]);
|
||||
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;
|
||||
@@ -117,20 +123,22 @@ function hasNeighbour(grid: Map<string, Sample[]>, sample: Sample, counts: (othe
|
||||
* back shares all of it.
|
||||
*/
|
||||
export function retracedShare(line: Line): number {
|
||||
if (line.length < 2) return 0;
|
||||
const samples = samplesOf(line, projector(line[0][0]));
|
||||
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)
|
||||
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 {
|
||||
if (a.length < 2 || b.length < 2) return 0;
|
||||
const project = projector(a[0][0]);
|
||||
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));
|
||||
@@ -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>;
|
||||
@@ -314,6 +314,12 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
|
||||
| `undo_tour_change` | Takes back a saved step once the traveller said yes to the preview of exactly that step: `step_id` and `expected_revision` as `prepare_tour_undo` gave them, and `confirmed` set to true. Everything is checked again before anything is written, and a tour changed since the preview, a step no longer the latest change or taken back already, or a station visited or skipped meanwhile that would go, refuses it and leaves the tour as it is. Restores the tour whole or not at all, exactly as it was before the step: its line, its stations with their keys and the distance and walking time stored then. A step is taken back once, and taking it back raises the tour's revision like any other save. Needs `place_edit` on the trip | `trips:write` |
|
||||
| `plan_paddle_route` | Plans the legs of a paddle tour on the water network TREK builds from the paddle regions an admin added: each leg (`from`, `to`) along rivers and canals and across lakes, carried round weirs, dams and waterfalls, through or round locks (`lock_policy`), with `boat` and `lake_style` (`direct` or `shore`). Answers per leg its length, how far its ends were moved onto the water and its stretches (water, lake, whitewater, boat pass, portage with its source, lock, gap) with where each starts and ends in metres along the leg; a leg that cannot be planned says why (`not_on_water`, `no_region`, `cross_region`, `no_water_path`, `busy`, `timeout`). Nothing is saved; a planning aid, never a safety or legal clearance. With `include_geometry` each planned leg adds its line for drawing (`line_geometry`), all legs together up to 300 points shared by length | `trips:read` |
|
||||
| `plan_ski_route` | Plans the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths of OpenStreetMap, which the Places host serves as one graph per ski area: for a `discipline` (`alpine`, `snowboard`, `cross_country` with a `technique`, `touring`, `snowshoe`, `winter_hike`) and its `options` (difficulty limit, freeride, lift classes to avoid, riding down, priority, pace, groomed or lit trails only, ascent along pistes, lifts at all). Answers per leg its length, climb and descent, its time split into skiing, lifts, waiting and walking, how far its ends were moved onto the network, its stretches with where each starts and ends in metres along the leg, and notes on what they rest on; a leg that cannot be planned says why (`not_on_piste`, `no_area`, `cross_area`, `disconnected`, `blocked_by_difficulty`, `blocked_by_lift_avoid`, `blocked_by_technique`, `needs_ride_down`, `uphill_only`, `loading`, `places_unavailable`, `disabled`, `busy`, `timeout`) and, where one helps, the one change that would plan it. No clicked point leaves the server. Times are estimates, nothing says what is open, groomed or safe today; nothing is saved. With `include_geometry` each planned leg adds its line for drawing (`line_geometry`, with its heights), all legs together up to 300 points shared by length | `trips:read` |
|
||||
| `propose_round_hike` | Finds round hikes of about `distance_km` (1 to 60) out of one start and back, as the Tours planner's round search does, and proposes one of them without saving anything: loops on foot steered round circles in several directions under the hike's limit and preferences, those within a fifth of the length that do not walk the same ground twice, up to three different ones (`round` a, b or c). One search answers all three and is kept for 15 minutes, so the rounds are asked for one after another without asking the router again. Each answer is the proposal `propose_hike` gives for the round, with how far it lies off the length and `create_with`, the points `create_hike` creates it through; a round that is not there says why | `trips:read` |
|
||||
| `propose_hike_leg_way` | Proposes another way for one leg of a hike, as the planner's other ways of a section: the router's other ways on foot between the leg's two points under the hike's rules, each held by the fewest points on it the router keeps to, up to three (`way` a, b or c). The answer is the proposal of the whole hike with the leg taken that way, beside the hike as it runs now (`current`), with `create_with`; a hike not yet saved is created with `create_hike`, a saved one (`tour_id`) changed with `update_tour` | `trips:read` |
|
||||
| `search_wanderer_trails` | Searches the user's own wanderer trails by words, page and part of the map, as the Tours tab's wanderer search does, on their own connection; each trail with its facts and the tours of the trip already imported from it. Only while the admin allows wanderer under Tours. Needs `place_edit` on the trip | `trips:read` |
|
||||
| `preview_wanderer_trail` | Previews one wanderer trail before it is imported, without writing anything: its length and climb measured from its full GPX, the fingerprint the import takes (`contentHash`) and how it stands to a tour of the trip already imported from it. Needs `place_edit` on the trip | `trips:read` |
|
||||
| `import_wanderer_trail` | Imports a wanderer trail into the trip as a tour, as the Tours tab's import does, with the fingerprint the preview gave: refused when the trail changed since, or when the trip holds this very content already. An earlier import stays as it is. Needs `place_edit` on the trip | `trips:write` |
|
||||
| `check_tour_source` | Asks wanderer whether the trail a tour was imported from changed since, without changing the tour: unchanged, changed (with what changed and what a new import takes), unknown, unavailable, no connection or another instance. Needs `place_edit` on the trip | `trips:read` |
|
||||
|
||||
### Road trip _(Road trip addon required)_
|
||||
|
||||
|
||||
Reference in New Issue
Block a user