mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(tours): plan rides on horseback from signposted horse routes
A tour can now be a ride (HOR-01). The planner offers "Horse ride" as a tour type with its own horseshoe, and a Horse routes card beside the waypoints looks, when asked, for the route=horse relations OpenStreetMap knows in the map view. A route opens in a dialog that shows it on a map in the sections it is made of, split at forks, gaps and crossings, so nothing is ever joined across a gap. The rider picks a section and its direction, sees what the horse=* tags along it say (open, limited, closed, or unknown where untagged) and whether it is signposted the other way, and takes it as one undoable step. A section that does not start where the ride ends is only joined by a straight manual stretch once the rider switched that on. Nothing of a ride is routed: every leg follows a section or is drawn by hand, and the waypoint list and the map say which (a section as a solid line with its route, a manual stretch dashed and marked unchecked). A point at a section stays where the route has it, a ride works out no riding time, and distance, heights, day assignment, GPX import and export, the 3D view and the reversed ride keep working with each leg's origin saved and opened again. A failed lookup leaves the ride and the routes already found as they were. The server reads the routes through the shared Overpass client (GET /api/trips/:tripId/tours/horse-routes, Tours addon, trip access), keeps an answer twelve hours per grid-aligned view, and the tour contract refuses a routed leg, a via ferrata or a riding time on a ride and a horse route section on any other type. A migration enables the horse tour type.
This commit is contained in:
@@ -146,8 +146,8 @@ export const FERRATA_TILE_PAYLOAD: JsonColumn<FerrataTilePayload | null> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* A tour leg taken as a fixed line (a via ferrata's own line from OSM) rather
|
||||
* than routed. A row that does not decode reads as a routed leg, which the
|
||||
* A tour leg taken as a fixed line (a via ferrata's own line from OSM, a
|
||||
* section of a horse route, a stretch drawn by hand) rather than routed. A row that does not decode reads as a routed leg, which the
|
||||
* editor then plans again.
|
||||
*/
|
||||
export const TOUR_WAYPOINT_FIXED_LEG: JsonColumn<TourFixedLeg | null> = {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Migration } from '@mikro-orm/migrations';
|
||||
|
||||
/**
|
||||
* HOR-01: a tour can be a ride on horseback. `horse` is a key of its own in
|
||||
* `tour_types`, enabled, with no routing profile: a ride is never routed, it
|
||||
* is made of sections of signposted horse routes and stretches the rider
|
||||
* draws by hand. Those live in `tour_waypoints.fixed_leg` like every other
|
||||
* fixed leg, so no column is added and no existing tour changes its meaning.
|
||||
* An instance that already defined the key keeps its own row.
|
||||
*/
|
||||
export class Migration20200101045600_a_tour_can_be_ridden_on_horseback extends Migration {
|
||||
override name = 'Migration20200101045600_a_tour_can_be_ridden_on_horseback';
|
||||
|
||||
override up(): void {
|
||||
this.addSql(`
|
||||
INSERT INTO tour_types (key, label_key, icon, color, routing_profile, is_sport, enabled, sort_order)
|
||||
VALUES ('horse', 'tourTypes.horse', 'Horseshoe', '#a16207', NULL, 1, 1, 7)
|
||||
ON CONFLICT (key) DO NOTHING
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
HORSE_ROUTE_SECTION_MAX_POINTS,
|
||||
haversineMetres,
|
||||
type HorseAccess,
|
||||
type HorseAccessShare,
|
||||
type HorseSignposted,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* HOR-01: a horse route's ways cut into the sections a rider can take whole.
|
||||
*
|
||||
* The member ways are read as a graph of their nodes: a section runs from a
|
||||
* node where the route ends, forks or meets itself to the next such node, so
|
||||
* a gap in the route ends a section and a fork starts new ones. Nothing is
|
||||
* joined across a gap, nothing is guessed about which branch is the "main"
|
||||
* one, and a section keeps the line of its ways point for point. Pure, so the
|
||||
* service and its tests share it.
|
||||
*/
|
||||
|
||||
/** One step of a member way between two of its nodes. */
|
||||
export interface RouteEdge {
|
||||
from: number;
|
||||
to: number;
|
||||
/** The `horse=*` value of the way the step is on. */
|
||||
access: HorseAccess;
|
||||
/** The way the route is signposted along the step: 1 from `from` to `to`, -1 the other way, 0 both or unsaid. */
|
||||
signed: 1 | -1 | 0;
|
||||
/** The place of its way among the route's members, which orders the sections. */
|
||||
order: number;
|
||||
}
|
||||
|
||||
/** A section as the route answer carries it, before it is numbered. */
|
||||
interface SectionLine {
|
||||
geometry: [number, number][];
|
||||
lengthM: number;
|
||||
access: HorseAccessShare[];
|
||||
signposted: HorseSignposted | null;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
edge: RouteEdge;
|
||||
/** Walked from `from` to `to`. */
|
||||
forward: boolean;
|
||||
}
|
||||
|
||||
interface Chain {
|
||||
nodes: number[];
|
||||
steps: Step[];
|
||||
}
|
||||
|
||||
/** The edges at every node, by index. */
|
||||
function incidence(edges: readonly RouteEdge[]): Map<number, number[]> {
|
||||
const at = new Map<number, number[]>();
|
||||
const add = (node: number, index: number) => {
|
||||
const list = at.get(node);
|
||||
if (list) list.push(index);
|
||||
else at.set(node, [index]);
|
||||
};
|
||||
edges.forEach((edge, index) => {
|
||||
add(edge.from, index);
|
||||
add(edge.to, index);
|
||||
});
|
||||
return at;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every edge in exactly one chain. A chain starts at a node that is no plain
|
||||
* through point (an end, a fork, a crossing) and goes on through nodes with
|
||||
* two edges until it reaches the next such node; what is left after that are
|
||||
* closed rings without any, each walked once round from where it is entered.
|
||||
*/
|
||||
function chainsOf(edges: readonly RouteEdge[]): Chain[] {
|
||||
const at = incidence(edges);
|
||||
const used = new Set<number>();
|
||||
const chains: Chain[] = [];
|
||||
const through = (node: number) => at.get(node)?.length === 2;
|
||||
const walk = (start: number, first: number) => {
|
||||
const chain: Chain = { nodes: [start], steps: [] };
|
||||
let node = start;
|
||||
let index: number | undefined = first;
|
||||
while (index !== undefined) {
|
||||
used.add(index);
|
||||
const edge = edges[index] as RouteEdge;
|
||||
const forward = edge.from === node;
|
||||
node = forward ? edge.to : edge.from;
|
||||
chain.steps.push({ edge, forward });
|
||||
chain.nodes.push(node);
|
||||
index = through(node) && node !== start ? at.get(node)?.find((next) => !used.has(next)) : undefined;
|
||||
}
|
||||
chains.push(chain);
|
||||
};
|
||||
for (const [node, list] of at) {
|
||||
if (through(node)) continue;
|
||||
for (const index of list) if (!used.has(index)) walk(node, index);
|
||||
}
|
||||
edges.forEach((edge, index) => {
|
||||
if (!used.has(index)) walk(edge.from, index);
|
||||
});
|
||||
return chains;
|
||||
}
|
||||
|
||||
/** The direction a run of steps is signposted in: one way along all of it, or null. */
|
||||
function signpostedOf(steps: readonly Step[]): HorseSignposted | null {
|
||||
const signs = new Set(steps.map(({ edge, forward }) => edge.signed * (forward ? 1 : -1)));
|
||||
if (signs.size !== 1 || signs.has(0)) return null;
|
||||
return signs.has(1) ? 'with' : 'against';
|
||||
}
|
||||
|
||||
/** A run of steps as a section: its line, its length, the access along it and its direction. */
|
||||
function toSection(nodes: readonly number[], steps: readonly Step[], coords: ReadonlyMap<number, [number, number]>) {
|
||||
const geometry = nodes.map((node) => coords.get(node) as [number, number]);
|
||||
const byValue = new Map<HorseAccess, number>();
|
||||
let lengthM = 0;
|
||||
steps.forEach(({ edge }, index) => {
|
||||
const [aLat, aLng] = geometry[index] as [number, number];
|
||||
const [bLat, bLng] = geometry[index + 1] as [number, number];
|
||||
const metres = haversineMetres(aLat, aLng, bLat, bLng);
|
||||
lengthM += metres;
|
||||
byValue.set(edge.access, (byValue.get(edge.access) ?? 0) + metres);
|
||||
});
|
||||
const access = [...byValue].map(([value, metres]) => ({ value, length_m: Math.round(metres) }));
|
||||
return {
|
||||
geometry,
|
||||
lengthM: Math.round(lengthM),
|
||||
access: access.sort((a, b) => b.length_m - a.length_m),
|
||||
signposted: signpostedOf(steps),
|
||||
order: Math.min(...steps.map(({ edge }) => edge.order)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections of a route out of its edges, in the order its ways are listed,
|
||||
* each at most HORSE_ROUTE_SECTION_MAX_POINTS points long: a longer chain is
|
||||
* cut into pieces that meet end to end. A section of no length is left out.
|
||||
* Every node an edge names must have its place in `coords`.
|
||||
*/
|
||||
export function routeSections(
|
||||
edges: readonly RouteEdge[],
|
||||
coords: ReadonlyMap<number, [number, number]>,
|
||||
): SectionLine[] {
|
||||
const perPiece = HORSE_ROUTE_SECTION_MAX_POINTS - 1;
|
||||
const sections: SectionLine[] = [];
|
||||
for (const { nodes, steps } of chainsOf(edges)) {
|
||||
for (let start = 0; start < steps.length; start += perPiece) {
|
||||
const piece = steps.slice(start, start + perPiece);
|
||||
sections.push(toSection(nodes.slice(start, start + piece.length + 1), piece, coords));
|
||||
}
|
||||
}
|
||||
return sections.filter((section) => section.lengthM > 0).sort((a, b) => a.order - b.order);
|
||||
}
|
||||
@@ -30,11 +30,14 @@ const SKI_ACTIVITY: Record<SkiDiscipline, string> = {
|
||||
touring: 'backcountry_skiing',
|
||||
};
|
||||
|
||||
/** The activity a ride names in GPX (HOR-01), in the words Garmin reads. */
|
||||
const HORSE_ACTIVITY = 'horseback_riding';
|
||||
|
||||
/**
|
||||
* The activity a tour's track names in GPX: a paddle tour its boat, a bike
|
||||
* tour and a run their profile, a ski track its discipline (SKI-02), a hike on
|
||||
* snowshoes snowshoeing (HIK-01) and any other hike hiking; whatever is
|
||||
* missing falls back to the plainest.
|
||||
* tour and a run their profile, a ski track its discipline (SKI-02), a ride
|
||||
* horseback riding (HOR-01), a hike on snowshoes snowshoeing (HIK-01) and any
|
||||
* other hike hiking; whatever is missing falls back to the plainest.
|
||||
*/
|
||||
export function gpxActivityOf(tour: {
|
||||
tour_type: string;
|
||||
@@ -49,6 +52,7 @@ export function gpxActivityOf(tour: {
|
||||
if (type === 'bike') return BIKE_ACTIVITY[(bike ?? 'touring') as BikeProfile] ?? BIKE_ACTIVITY.touring;
|
||||
if (type === 'run') return RUN_ACTIVITY[(tour.run_profile ?? 'running') as RunProfile] ?? RUN_ACTIVITY.running;
|
||||
if (type === 'ski') return SKI_ACTIVITY[tour.ski_discipline as SkiDiscipline] ?? 'skiing';
|
||||
if (type === 'horse') return HORSE_ACTIVITY;
|
||||
if (type !== 'hike') return type;
|
||||
// A winter hike is walked as any hike is; only snowshoes make it an activity of its own.
|
||||
return tour.winter_activity === 'snowshoe' ? 'snowshoeing' : 'hiking';
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { AddonGuard } from '../addons/addon.guard';
|
||||
import { RequireAddon } from '../addons/require-addon.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { badRequest } from '../common/domain-error';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { TripAccessGuard } from '../permissions/trip-access.guard';
|
||||
import { TourHorseRoutesService } from './tour-horse-routes.service';
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
FERRATA_BBOX_INVALID_MESSAGE,
|
||||
FERRATA_BBOX_TOO_LARGE_MESSAGE,
|
||||
horseRoutesQuerySchema,
|
||||
horseRoutesResponseSchema,
|
||||
type HorseRoutesQuery,
|
||||
type HorseRoutesResponse,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The query through the shared contract, answered with one of two messages:
|
||||
* a box that is too large is a different fix for the client (zoom in) than
|
||||
* one that is malformed.
|
||||
*/
|
||||
export function parseHorseRoutesQuery(raw: Record<string, unknown>): HorseRoutesQuery {
|
||||
const parsed = horseRoutesQuerySchema.safeParse(raw);
|
||||
if (parsed.success) return parsed.data;
|
||||
const tooLarge = parsed.error.issues.some((issue) => issue.message === FERRATA_BBOX_TOO_LARGE_MESSAGE);
|
||||
throw badRequest(tooLarge ? FERRATA_BBOX_TOO_LARGE_MESSAGE : FERRATA_BBOX_INVALID_MESSAGE);
|
||||
}
|
||||
|
||||
/**
|
||||
* HOR-01 GET /api/trips/:tripId/tours/horse-routes?bbox=south,west,north,east:
|
||||
* the signposted horse routes OpenStreetMap knows inside a map view, cut into
|
||||
* the sections a horse tour can take. A read, so anyone who sees the trip may
|
||||
* ask; the box is the caller's view and nothing about the trip goes out.
|
||||
* Declared before the tours' `:placeId` routes, which would read the path as
|
||||
* a tour.
|
||||
*/
|
||||
@Controller('api/trips/:tripId/tours')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
|
||||
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
|
||||
export class TourHorseRoutesController {
|
||||
constructor(private readonly horseRoutes: TourHorseRoutesService) {}
|
||||
|
||||
@Get('horse-routes')
|
||||
@ResponseContract(horseRoutesResponseSchema)
|
||||
async list(@Query() raw: Record<string, unknown>): Promise<HorseRoutesResponse> {
|
||||
return await this.horseRoutes.list(parseHorseRoutesQuery(raw).bbox);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { routeSections, type RouteEdge } from './horse-route-sections';
|
||||
import {
|
||||
HORSE_ROUTE_SECTIONS_CAP,
|
||||
HORSE_ROUTES_RESULT_CAP,
|
||||
type FerrataBbox,
|
||||
type HorseAccess,
|
||||
type HorseRoute,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* HOR-01, the pure half of the horse route lookup: the Overpass query, the
|
||||
* box it asks for, and the routes read out of the elements it answers with.
|
||||
* Kept free of I/O so the service and its tests share it.
|
||||
*/
|
||||
|
||||
/** How long Overpass itself may work on one query, in seconds (`[timeout:N]`). */
|
||||
const HORSE_ROUTES_QUERY_TIMEOUT_S = 25;
|
||||
|
||||
/** The most member ways one query asks for; one more is asked, which is how a cut answer is told. */
|
||||
export const HORSE_ROUTE_WAYS_CAP = 4000;
|
||||
|
||||
/** The grid a box is grown outwards to, so nearby views share one answer. */
|
||||
const GRID_DEG = 0.05;
|
||||
const NAME_MAX = 255;
|
||||
const REF_MAX = 64;
|
||||
|
||||
const snapDown = (v: number) => Math.floor(v / GRID_DEG + 1e-9) * GRID_DEG;
|
||||
const snapUp = (v: number) => Math.ceil(v / GRID_DEG - 1e-9) * GRID_DEG;
|
||||
const fixed = (v: number) => Number(v.toFixed(2));
|
||||
|
||||
/** The box grown outwards to the 0.05 degree grid and clamped to the globe. */
|
||||
export function snapHorseRouteBbox(bbox: FerrataBbox): FerrataBbox {
|
||||
return {
|
||||
south: fixed(Math.max(-90, snapDown(bbox.south))),
|
||||
west: fixed(Math.max(-180, snapDown(bbox.west))),
|
||||
north: fixed(Math.min(90, snapUp(bbox.north))),
|
||||
east: fixed(Math.min(180, snapUp(bbox.east))),
|
||||
};
|
||||
}
|
||||
|
||||
/** The key a box is remembered under: its snapped edges. */
|
||||
export function horseRouteBboxKey(bbox: FerrataBbox): string {
|
||||
return [bbox.south, bbox.west, bbox.north, bbox.east].join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* The `route=horse` relations that reach into the box, with their members,
|
||||
* and those of their ways that lie in it, with their tags, nodes and line. A
|
||||
* way crossing the edge comes whole; a member way outside the box is left out
|
||||
* and the route is said to go on.
|
||||
*/
|
||||
export function buildHorseRoutesQuery(bbox: FerrataBbox): string {
|
||||
const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`;
|
||||
return [
|
||||
`[out:json][timeout:${HORSE_ROUTES_QUERY_TIMEOUT_S}];`,
|
||||
`rel["route"="horse"]${box}->.routes;`,
|
||||
`way(r.routes)${box}->.ways;`,
|
||||
`.routes out body qt ${HORSE_ROUTES_RESULT_CAP + 1};`,
|
||||
`.ways out body geom qt ${HORSE_ROUTE_WAYS_CAP + 1};`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const osmId = z.number().int().positive();
|
||||
const tags = z.record(z.string(), z.string()).optional();
|
||||
const point = z.object({ lat: z.number().min(-90).max(90), lon: z.number().min(-180).max(180) });
|
||||
|
||||
const wayElementSchema = z.object({
|
||||
type: z.literal('way'),
|
||||
id: osmId,
|
||||
tags,
|
||||
nodes: z.array(osmId).max(20000),
|
||||
// A point Overpass could not place comes back as null; its steps are left out.
|
||||
geometry: z.array(point.nullable()).max(20000),
|
||||
});
|
||||
const relationElementSchema = z.object({
|
||||
type: z.literal('relation'),
|
||||
id: osmId,
|
||||
tags,
|
||||
members: z.array(z.object({ type: z.string(), ref: z.number().int(), role: z.string().optional() })).max(20000),
|
||||
});
|
||||
const elementSchema = z.discriminatedUnion('type', [wayElementSchema, relationElementSchema]);
|
||||
type WayElement = z.infer<typeof wayElementSchema>;
|
||||
type RelationElement = z.infer<typeof relationElementSchema>;
|
||||
|
||||
const KNOWN_ACCESS = new Set<HorseAccess>([
|
||||
'designated',
|
||||
'yes',
|
||||
'permissive',
|
||||
'destination',
|
||||
'discouraged',
|
||||
'private',
|
||||
'no',
|
||||
]);
|
||||
|
||||
/** `horse=*` as tagged: a common value as it is, any other `other`, none `unknown`. */
|
||||
export function horseAccessOf(tag: string | undefined): HorseAccess {
|
||||
const value = tag?.trim().toLowerCase() ?? '';
|
||||
if (value === '') return 'unknown';
|
||||
return KNOWN_ACCESS.has(value as HorseAccess) ? (value as HorseAccess) : 'other';
|
||||
}
|
||||
|
||||
/** A member's role as the direction the route is signposted along its way: 1 with it, -1 against, 0 both. */
|
||||
function signOfRole(role: string | undefined): 1 | -1 | 0 {
|
||||
if (role === 'forward') return 1;
|
||||
if (role === 'backward') return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function cleanText(value: string | undefined, max: number): string | null {
|
||||
const text = value?.trim() ?? '';
|
||||
return text === '' ? null : text.slice(0, max);
|
||||
}
|
||||
|
||||
/** The steps of one member way, each between two placed nodes, and the places of its nodes. */
|
||||
function wayEdges(way: WayElement, role: string | undefined, order: number, coords: Map<number, [number, number]>) {
|
||||
const access = horseAccessOf(way.tags?.horse);
|
||||
const signed = signOfRole(role);
|
||||
const edges: RouteEdge[] = [];
|
||||
way.nodes.forEach((node, index) => {
|
||||
const place = way.geometry[index];
|
||||
if (place) coords.set(node, [place.lat, place.lon]);
|
||||
const next = way.nodes[index + 1];
|
||||
const nextPlace = way.geometry[index + 1];
|
||||
if (next === undefined || !place || !nextPlace || next === node) return;
|
||||
edges.push({ from: node, to: next, access, signed, order });
|
||||
});
|
||||
return edges;
|
||||
}
|
||||
|
||||
/**
|
||||
* One relation as a route: its sections out of the member ways the answer
|
||||
* holds, each way once (the first time the route lists it), and whether it
|
||||
* has member ways the answer does not hold. Null when none of its ways is in
|
||||
* the box, so there is nothing of it to take; `cut` when it had more sections
|
||||
* than one answer carries.
|
||||
*/
|
||||
function toRoute(
|
||||
relation: RelationElement,
|
||||
ways: ReadonlyMap<number, WayElement>,
|
||||
): { route: HorseRoute; cut: boolean } | null {
|
||||
const coords = new Map<number, [number, number]>();
|
||||
const edges: RouteEdge[] = [];
|
||||
const seen = new Set<number>();
|
||||
let continues = false;
|
||||
relation.members.forEach((member, order) => {
|
||||
if (member.type !== 'way' || seen.has(member.ref)) return;
|
||||
seen.add(member.ref);
|
||||
const way = ways.get(member.ref);
|
||||
if (!way) {
|
||||
continues = true;
|
||||
return;
|
||||
}
|
||||
edges.push(...wayEdges(way, member.role, order, coords));
|
||||
});
|
||||
const sections = routeSections(edges, coords);
|
||||
if (sections.length === 0) return null;
|
||||
const route: HorseRoute = {
|
||||
id: `relation/${relation.id}`,
|
||||
name: cleanText(relation.tags?.name, NAME_MAX),
|
||||
ref: cleanText(relation.tags?.ref, REF_MAX),
|
||||
osmUrl: `https://www.openstreetmap.org/relation/${relation.id}`,
|
||||
continues,
|
||||
sections: sections.slice(0, HORSE_ROUTE_SECTIONS_CAP).map((section, index) => ({
|
||||
number: index + 1,
|
||||
geometry: section.geometry,
|
||||
length_m: section.lengthM,
|
||||
access: section.access,
|
||||
signposted: section.signposted,
|
||||
})),
|
||||
};
|
||||
return { route, cut: sections.length > HORSE_ROUTE_SECTIONS_CAP };
|
||||
}
|
||||
|
||||
/**
|
||||
* The routes out of the raw elements. An element of another shape is left
|
||||
* out rather than failing the whole answer. `truncated` says the answer was
|
||||
* cut: more relations or ways than were asked for, or a route with more
|
||||
* sections than one answer carries.
|
||||
*/
|
||||
export function toHorseRoutes(elements: unknown[]): { routes: HorseRoute[]; truncated: boolean } {
|
||||
const ways = new Map<number, WayElement>();
|
||||
const relations: RelationElement[] = [];
|
||||
for (const raw of elements) {
|
||||
const parsed = elementSchema.safeParse(raw);
|
||||
if (!parsed.success) continue;
|
||||
if (parsed.data.type === 'way') ways.set(parsed.data.id, parsed.data);
|
||||
else relations.push(parsed.data);
|
||||
}
|
||||
const cut = relations.length > HORSE_ROUTES_RESULT_CAP || ways.size > HORSE_ROUTE_WAYS_CAP;
|
||||
const routes: HorseRoute[] = [];
|
||||
let sectionsCut = false;
|
||||
for (const relation of relations.slice(0, HORSE_ROUTES_RESULT_CAP)) {
|
||||
const read = toRoute(relation, ways);
|
||||
if (!read) continue;
|
||||
sectionsCut ||= read.cut;
|
||||
routes.push(read.route);
|
||||
}
|
||||
return { routes, truncated: cut || sectionsCut };
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import { OverpassElementsClient } from '../overpass/overpass-elements.service';
|
||||
import {
|
||||
buildHorseRoutesQuery,
|
||||
horseRouteBboxKey,
|
||||
snapHorseRouteBbox,
|
||||
toHorseRoutes,
|
||||
} from './tour-horse-routes.helpers';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
HORSE_ROUTES_ATTRIBUTION,
|
||||
HORSE_ROUTES_UNAVAILABLE_CODE,
|
||||
type FerrataBbox,
|
||||
type HorseRoutesResponse,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** How long an answer is kept: signposted routes change on the scale of seasons, the mirrors are shared. */
|
||||
export const HORSE_ROUTES_KEPT_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
/** The most answers kept at once; the one kept longest goes first. */
|
||||
const HORSE_ROUTES_KEPT_MAX = 200;
|
||||
|
||||
export const HORSE_ROUTES_UNAVAILABLE_MESSAGE = 'Horse route data is unavailable right now';
|
||||
|
||||
type Answer = Omit<HorseRoutesResponse, 'source' | 'attribution'>;
|
||||
|
||||
/**
|
||||
* HOR-01: the signposted horse routes around a map view, from OpenStreetMap
|
||||
* through the operator's Overpass mirrors. The view is grown to the 0.05
|
||||
* degree grid, so nearby views share one answer, and an answer is kept for
|
||||
* HORSE_ROUTES_KEPT_MS in memory; two requests for the same box at once share
|
||||
* one query. Read only: nothing is stored, and nothing about the trip is sent.
|
||||
*
|
||||
* When no mirror answers, the client gets a 502 `{ error, code }` it can show,
|
||||
* and whatever it had loaded before stays as it was.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TourHorseRoutesService {
|
||||
private readonly kept = new Map<string, { at: number; answer: Answer }>();
|
||||
private readonly pending = new Map<string, Promise<Answer>>();
|
||||
|
||||
constructor(private readonly overpass: OverpassElementsClient) {}
|
||||
|
||||
async list(bbox: FerrataBbox, now: number = Date.now()): Promise<HorseRoutesResponse> {
|
||||
const box = snapHorseRouteBbox(bbox);
|
||||
const key = horseRouteBboxKey(box);
|
||||
const known = this.kept.get(key);
|
||||
const answer = known && now - known.at < HORSE_ROUTES_KEPT_MS ? known.answer : await this.fetch(key, box, now);
|
||||
return { ...answer, source: 'openstreetmap', attribution: HORSE_ROUTES_ATTRIBUTION };
|
||||
}
|
||||
|
||||
/** One query for the box, shared with every request for it until it settles. */
|
||||
private fetch(key: string, box: FerrataBbox, now: number): Promise<Answer> {
|
||||
const running = this.pending.get(key);
|
||||
if (running) return running;
|
||||
const run = this.query(box).then((answer) => {
|
||||
this.keep(key, answer, now);
|
||||
return answer;
|
||||
});
|
||||
this.pending.set(key, run);
|
||||
const settle = () => this.pending.delete(key);
|
||||
void run.then(settle, settle);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async query(box: FerrataBbox): Promise<Answer> {
|
||||
let elements: unknown[];
|
||||
try {
|
||||
elements = await this.overpass.query(buildHorseRoutesQuery(box), 'HorseRoutes');
|
||||
} catch {
|
||||
// The client already logged why each mirror failed.
|
||||
throw new DomainError(502, HORSE_ROUTES_UNAVAILABLE_MESSAGE, {
|
||||
code: HORSE_ROUTES_UNAVAILABLE_CODE,
|
||||
details: { code: HORSE_ROUTES_UNAVAILABLE_CODE },
|
||||
});
|
||||
}
|
||||
return toHorseRoutes(elements);
|
||||
}
|
||||
|
||||
private keep(key: string, answer: Answer, at: number): void {
|
||||
this.kept.delete(key);
|
||||
this.kept.set(key, { at, answer });
|
||||
// A map keeps its insertion order: the first key is the one kept longest.
|
||||
if (this.kept.size > HORSE_ROUTES_KEPT_MAX) this.kept.delete(this.kept.keys().next().value as string);
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'list_tours',
|
||||
description:
|
||||
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, or ski for a ski track), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
|
||||
'List the saved tours of a trip (hikes, city walks, runs, bike tours, paddle tours, rides on horseback and ski tracks planned or imported in the Tours addon): name, type (hike, walk for a city walk, run, bike, kayak for a paddle tour, horse for a ride, or ski for a ski track; a ride has no riding time worked out, so its duration is null), where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit of a hike (max_hiking_difficulty, SAC T1 to T6) and its route choices (hiking_preferences: avoid_climbs for gentler ways, avoid_steps, avoid_ferries; weightings, never a grade of the path), the profile a bike tour is routed for (bike_profile: touring for paved cycle paths and quiet roads, road for asphalt, gravel for gravel and farm tracks, mtb for rough tracks; a routing preference, never a statement that a way is rideable or safe), the winter label of a hike (winter_activity: winter_hike or snowshoe, null for none; a label the travellers chose, while the route and its time are planned as for any hike, never a statement that a way can be walked in winter or anything about snow or avalanches), the profile and pace of a run (run_profile: running for runnable, mostly paved ways in towns and parks, trail for forest tracks and natural paths up to mountain paths (T2); a routing preference, never a statement that a way can be run; run_pace_seconds_per_km, the pace the runners planned with, null while they entered none; its duration is the distance at that pace and nothing else, no climb, descent or difficulty is in it, and it is a plan, never a recorded time), the classification of a ski track (ski_discipline: alpine, snowboard, cross_country or touring for ski touring; ski_technique for cross-country only: classic, skating or unknown when the travellers did not know; a ski track is imported from a GPX file and kept as recorded, never routed, its distance and heights come from the file, it has no ski time worked out, and nothing in it says anything about snow, avalanches or whether a run or trail is open), the boat, water and chosen speed of a paddle tour (boat_type, water_context, paddle_speed_kmh in km/h, null while the suggested speed of the boat applies), the check bench of a paddle tour (paddle_check: how far the OpenStreetMap obstacle data covers its line and how many obstacles are open, to check again or decided by the travellers; a planning status, never a safety or legal clearance), for a city walk the number of its stations (station_count: its points without the shaping points and the way back of a loop), their stays as far as entered (station_stay_minutes) and how many have none (station_stays_unknown), whether a tour still waits for its way to be planned (route_pending: saved with its points only, so its distance and walking time stay null until someone opens it in the planner and saves it), the revision (raised by every saved change of the tour), and the days of the trip each tour is on.',
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -100,7 +100,7 @@ export class ToursMcp {
|
||||
@Tool({
|
||||
name: 'get_tour_plan',
|
||||
description:
|
||||
'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point whose fixed_leg is set leads to the next one along a line the router did not plan: kind ferrata follows a via ferrata, kind free is drawn by hand and was checked by nothing. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (its own id, the same across saves; the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app) and opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known). How far a walk got on a day is get_city_walk_progress. The full track geometry is not included.',
|
||||
'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. A control point whose fixed_leg is set leads to the next one along a line the router did not plan: kind ferrata follows a via ferrata, kind free is drawn by hand and was checked by nothing, kind horse_route (a ride only) follows a section of a signposted horse route from OpenStreetMap in the direction the rider chose (signposted: with or against the direction the route is signposted in, null where it is not signposted one way; access: the metres of each horse=* value its ways carried when it was taken, unknown where untagged). Being on a horse route is no permission to ride it. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (its own id, the same across saves; the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app) and opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known). How far a walk got on a day is get_city_walk_progress. The full track geometry is not included.',
|
||||
inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it') },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
|
||||
@@ -19,6 +19,8 @@ import { WandererModule } from '../wanderer/wanderer.module';
|
||||
import { CityWalkBriefingService } from './city-walk-briefing.service';
|
||||
import { CityWalkMcp } from './city-walk.mcp';
|
||||
import { TourGpxExportService } from './tour-gpx-export.service';
|
||||
import { TourHorseRoutesController } from './tour-horse-routes.controller';
|
||||
import { TourHorseRoutesService } from './tour-horse-routes.service';
|
||||
import { TourLibraryController, TourTakeOverController } from './tour-library.controller';
|
||||
import { TourLibraryMcp } from './tour-library.mcp';
|
||||
import { TourLibraryService } from './tour-library.service';
|
||||
@@ -68,7 +70,8 @@ import { Module } from '@nestjs/common';
|
||||
McpSharedModule,
|
||||
// PLN-02 searches places along a tour through the map search.
|
||||
MapsModule,
|
||||
// BIK-05 reads the trail grades along a mountain bike tour from OpenStreetMap.
|
||||
// BIK-05 reads the trail grades along a mountain bike tour from OpenStreetMap,
|
||||
// HOR-01 the signposted horse routes around a ride.
|
||||
OverpassModule,
|
||||
AddonsModule,
|
||||
// The wanderer import reads trails through the caller's own connection.
|
||||
@@ -88,6 +91,7 @@ import { Module } from '@nestjs/common';
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
TourHorseRoutesController,
|
||||
TourTakeOverController,
|
||||
TourPoiSearchController,
|
||||
TourSkiController,
|
||||
@@ -106,6 +110,7 @@ import { Module } from '@nestjs/common';
|
||||
TourPaddleCheckService,
|
||||
TourObstacleReviewsService,
|
||||
TourGpxExportService,
|
||||
TourHorseRoutesService,
|
||||
ToursWandererService,
|
||||
TourLibraryService,
|
||||
TourPoiSearchService,
|
||||
|
||||
Reference in New Issue
Block a user