mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
feat(tours): a hike's day with sun, weather and warnings, and the proposed line kept for create
This commit is contained in:
@@ -1,28 +1,62 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import { thinLine } from '../../utils/polyline';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { PaddleRoutingService } from './paddle-routing.service';
|
||||
import { idSchema, paddleRouteRequestSchema, type PaddleRouteInput, type PaddleRouteLeg } from '@trek/shared';
|
||||
import {
|
||||
cumulativeMetres,
|
||||
idSchema,
|
||||
includeGeometryFlagSchema,
|
||||
paddleRouteRequestSchema,
|
||||
pointsPerLeg,
|
||||
TOUR_DISPLAY_LINE_POINTS,
|
||||
type PaddleRouteInput,
|
||||
type PaddleRouteLeg,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A planned leg as a client reads it: its length and its stretches, not its thousands of points. */
|
||||
function summary(leg: PaddleRouteLeg) {
|
||||
/**
|
||||
* A planned leg as a client reads it: its length and its stretches, each
|
||||
* placed in metres along the leg, not its thousands of points. With a share
|
||||
* of the points for drawing (`points`), its line thinned to that many.
|
||||
*/
|
||||
function summary(leg: PaddleRouteLeg, points: number | null) {
|
||||
if (leg.status === 'failed') return leg;
|
||||
const along = cumulativeMetres(leg.geometry.map(([lat, lng]) => [lat, lng] as const));
|
||||
return {
|
||||
status: leg.status,
|
||||
distance_m: leg.distance_m,
|
||||
snapped_m: leg.snapped_m,
|
||||
points: leg.geometry.length,
|
||||
segments: leg.segments.map(({ kind, name, source, osm_ref }) => ({ kind, name, source, osm_ref })),
|
||||
segments: leg.segments.map(({ kind, name, source, osm_ref, from, to }) => ({
|
||||
kind,
|
||||
name,
|
||||
source,
|
||||
osm_ref,
|
||||
// Both indexes point into the leg's own geometry, so each has its place along it.
|
||||
from_m: Math.round(along[from]),
|
||||
to_m: Math.round(along[to]),
|
||||
})),
|
||||
...(points === null ? {} : { line_geometry: thinLine(leg.geometry, points) }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The points each planned leg draws with, all of them together at most TOUR_DISPLAY_LINE_POINTS, by length. */
|
||||
function linePoints(legs: readonly PaddleRouteLeg[]): Array<number | null> {
|
||||
return pointsPerLeg(
|
||||
legs.map((leg) => (leg.status === 'ok' ? { lengthM: leg.distance_m, points: leg.geometry.length } : null)),
|
||||
TOUR_DISPLAY_LINE_POINTS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* PAD-11: the paddle router for a client, read only and with the REST
|
||||
* route's rules: the caller must see the trip, Tours must be on, and the
|
||||
* request is the same contract. It plans and answers; nothing is saved.
|
||||
* request is the same contract. It plans and answers; nothing is saved. With
|
||||
* include_geometry each planned leg adds its line for drawing, thinned so all
|
||||
* legs together hold at most TOUR_DISPLAY_LINE_POINTS points.
|
||||
*/
|
||||
@McpController()
|
||||
export class PaddleRoutingMcp {
|
||||
@@ -36,15 +70,22 @@ export class PaddleRoutingMcp {
|
||||
@Tool({
|
||||
name: 'plan_paddle_route',
|
||||
description:
|
||||
'Plan the legs of a paddle tour on the water, on the water network TREK builds from the OpenStreetMap data of the paddle regions an admin added. Each leg (from, to) is planned along rivers and canals and across lakes, with short portages where a weir, dam or waterfall bars the way and through or around locks (lock_policy). lake_style direct crosses lakes straight, shore keeps near the shore (for wind). Answers per leg its length, how far its ends were moved onto the water, and its stretches (water, lake, portage with its source, lock, gap); a leg that cannot be planned says why (not_on_water, no_region, no_water_path, cross_region). A planning aid, never a safety or legal clearance; nothing is saved.',
|
||||
inputSchema: { tripId: idSchema, ...paddleRouteRequestSchema.shape },
|
||||
'Plan the legs of a paddle tour on the water, on the water network TREK builds from the OpenStreetMap data of the paddle regions an admin added. Each leg (from, to) is planned along rivers and canals and across lakes, with short portages where a weir, dam or waterfall bars the way and through or around locks (lock_policy). lake_style direct crosses lakes straight, shore keeps near the shore (for wind). Answers per leg its length, how far its ends were moved onto the water, and its stretches (water, lake, portage with its source, lock, gap) with where each starts and ends in metres along the leg (from_m, to_m); a leg that cannot be planned says why (not_on_water, no_region, no_water_path, cross_region). A planning aid, never a safety or legal clearance; nothing is saved.',
|
||||
inputSchema: { tripId: idSchema, ...paddleRouteRequestSchema.shape, include_geometry: includeGeometryFlagSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async planPaddleRoute({ tripId, ...request }: PaddleRouteInput & { tripId: number }, ctx: McpContext) {
|
||||
async planPaddleRoute(
|
||||
{ tripId, include_geometry, ...request }: PaddleRouteInput & { tripId: number; include_geometry?: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
const planned = await this.routing.route(paddleRouteRequestSchema.parse(request));
|
||||
return ok({ legs: planned.legs.map(summary), graph_rev: planned.graph_rev });
|
||||
const points = include_geometry === true ? linePoints(planned.legs) : planned.legs.map(() => null);
|
||||
return ok({
|
||||
legs: planned.legs.map((leg, index) => summary(leg, points[index] ?? null)),
|
||||
graph_rev: planned.graph_rev,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import { thinLine } from '../../utils/polyline';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { SkiRoutingService } from './ski-routing.service';
|
||||
import { idSchema, skiRouteRequestSchema, type SkiRouteInput, type SkiRouteLeg } from '@trek/shared';
|
||||
import {
|
||||
cumulativeMetres,
|
||||
idSchema,
|
||||
includeGeometryFlagSchema,
|
||||
pointsPerLeg,
|
||||
skiRouteRequestSchema,
|
||||
TOUR_DISPLAY_LINE_POINTS,
|
||||
type SkiRouteInput,
|
||||
type SkiRouteLeg,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** A planned leg as a client reads it: its measures and its stretches, not its thousands of points. */
|
||||
function summary(leg: SkiRouteLeg) {
|
||||
/**
|
||||
* A planned leg as a client reads it: its measures and its stretches, each
|
||||
* placed in metres along the leg, not its thousands of points. With a share
|
||||
* of the points for drawing (`points`), its line thinned to that many, every
|
||||
* point keeping its height.
|
||||
*/
|
||||
function summary(leg: SkiRouteLeg, points: number | null) {
|
||||
if (leg.status === 'failed') return leg;
|
||||
const along = cumulativeMetres(leg.geometry.map(([lat, lng]) => [lat, lng] as const));
|
||||
return {
|
||||
status: leg.status,
|
||||
distance_m: leg.distance_m,
|
||||
@@ -18,17 +34,33 @@ function summary(leg: SkiRouteLeg) {
|
||||
seconds: leg.seconds,
|
||||
snapped_m: leg.snapped_m,
|
||||
points: leg.geometry.length,
|
||||
segments: leg.segments.map(({ from: _from, to: _to, grooming: _grooming, osm_way: _way, ...segment }) => segment),
|
||||
segments: leg.segments.map(({ from, to, grooming: _grooming, osm_way: _way, ...segment }) => ({
|
||||
...segment,
|
||||
// Both indexes point into the leg's own geometry, so each has its place along it.
|
||||
from_m: Math.round(along[from]),
|
||||
to_m: Math.round(along[to]),
|
||||
})),
|
||||
why: leg.why,
|
||||
alternatives: leg.alternatives.length,
|
||||
...(points === null ? {} : { line_geometry: thinLine(leg.geometry, points) }),
|
||||
};
|
||||
}
|
||||
|
||||
/** The points each planned leg draws with, all of them together at most TOUR_DISPLAY_LINE_POINTS, by length. */
|
||||
function linePoints(legs: readonly SkiRouteLeg[]): Array<number | null> {
|
||||
return pointsPerLeg(
|
||||
legs.map((leg) => (leg.status === 'ok' ? { lengthM: leg.distance_m, points: leg.geometry.length } : null)),
|
||||
TOUR_DISPLAY_LINE_POINTS,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The piste router for a client, read only and with the REST route's rules:
|
||||
* the caller must see the trip, Tours must be on, and the request is the same
|
||||
* contract, planned by the same service. It plans and answers; nothing is
|
||||
* saved, and no clicked point leaves the server.
|
||||
* saved, and no clicked point leaves the server. With include_geometry each
|
||||
* planned leg adds its line for drawing with its heights, thinned so all legs
|
||||
* together hold at most TOUR_DISPLAY_LINE_POINTS points.
|
||||
*/
|
||||
@McpController()
|
||||
export class SkiRoutingMcp {
|
||||
@@ -42,17 +74,21 @@ export class SkiRoutingMcp {
|
||||
@Tool({
|
||||
name: 'plan_ski_route',
|
||||
description:
|
||||
'Plan the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths of OpenStreetMap, for a discipline (alpine, snowboard, cross_country with a technique of classic, skating or unknown, touring, snowshoe, winter_hike) and its options (max_difficulty from novice to expert, allow_freeride, avoid_lifts by class, allow_lift_down, priority fastest, most_skiing or easiest, pace, groomed_only and lit_only for cross-country, touring_uphill_on_pistes, use_lifts, avoid_inferred_links). Each leg (from, to) answers its length, climb and descent, its time split into skiing, lifts, waiting at lifts and walking, how far its ends were moved onto the network, its stretches (run with difficulty, lift with type, link the map data does not hold, and so on) and notes on what it rests on; a leg that cannot be planned says why (not_on_piste, no_area, cross_area, disconnected, blocked_by_difficulty with the level it needs, blocked_by_lift_avoid, blocked_by_technique, needs_ride_down, uphill_only, loading while an area downloads, places_unavailable, disabled). Times are estimates from the run difficulties and lift types, waits are guesses, and nothing says what is open, groomed or safe today; nothing is saved.',
|
||||
inputSchema: { tripId: idSchema, ...skiRouteRequestSchema.shape },
|
||||
'Plan the legs of a ski and winter tour on the runs, lifts, cross-country trails, ski touring and winter paths of OpenStreetMap, for a discipline (alpine, snowboard, cross_country with a technique of classic, skating or unknown, touring, snowshoe, winter_hike) and its options (max_difficulty from novice to expert, allow_freeride, avoid_lifts by class, allow_lift_down, priority fastest, most_skiing or easiest, pace, groomed_only and lit_only for cross-country, touring_uphill_on_pistes, use_lifts, avoid_inferred_links). Each leg (from, to) answers its length, climb and descent, its time split into skiing, lifts, waiting at lifts and walking, how far its ends were moved onto the network, its stretches (run with difficulty, lift with type, link the map data does not hold, and so on, each with where it starts and ends in metres along the leg, from_m and to_m) and notes on what it rests on; a leg that cannot be planned says why (not_on_piste, no_area, cross_area, disconnected, blocked_by_difficulty with the level it needs, blocked_by_lift_avoid, blocked_by_technique, needs_ride_down, uphill_only, loading while an area downloads, places_unavailable, disabled). Times are estimates from the run difficulties and lift types, waits are guesses, and nothing says what is open, groomed or safe today; nothing is saved.',
|
||||
inputSchema: { tripId: idSchema, ...skiRouteRequestSchema.shape, include_geometry: includeGeometryFlagSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
access: { group: 'trips', mode: 'read' },
|
||||
})
|
||||
async planSkiRoute({ tripId, ...request }: SkiRouteInput & { tripId: number }, ctx: McpContext) {
|
||||
async planSkiRoute(
|
||||
{ tripId, include_geometry, ...request }: SkiRouteInput & { tripId: number; include_geometry?: boolean },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
const planned = await this.routing.route(skiRouteRequestSchema.parse(request));
|
||||
const points = include_geometry === true ? linePoints(planned.legs) : planned.legs.map(() => null);
|
||||
return ok({
|
||||
legs: planned.legs.map(summary),
|
||||
legs: planned.legs.map((leg, index) => summary(leg, points[index] ?? null)),
|
||||
build: planned.build,
|
||||
stale: planned.stale,
|
||||
area_names: planned.area_names,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { badRequest } from '../common/domain-error';
|
||||
import { thinnedLine } from './tours.helpers';
|
||||
import {
|
||||
clockOf,
|
||||
haversineMetres,
|
||||
minutesOf,
|
||||
stationFit,
|
||||
stationHoursOn,
|
||||
TOUR_DISPLAY_LINE_POINTS,
|
||||
TOUR_MAX_SNAP_M,
|
||||
TOUR_POI_MAX_CATEGORIES,
|
||||
TOUR_STATION_HOURS_MAX,
|
||||
@@ -576,6 +578,19 @@ function estimateOf(way: FootWay) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The way on foot for drawing, thinned to TOUR_DISPLAY_LINE_POINTS, where it
|
||||
* was planned and the caller asked for it (include_geometry); nothing else.
|
||||
*/
|
||||
export function walkLineGeometry(
|
||||
way: FootWay,
|
||||
asked: boolean | undefined,
|
||||
): { line_geometry?: Array<[number, number]> } {
|
||||
return asked === true && way.kind === 'routed'
|
||||
? { line_geometry: thinnedLine(way.line, TOUR_DISPLAY_LINE_POINTS) }
|
||||
: {};
|
||||
}
|
||||
|
||||
/** The walk as the proposal says it: its stations with their times, the gaps, the totals and what create_city_walk takes. */
|
||||
export function describeWalk(route: readonly WalkCandidate[], frame: WalkFrame, way: FootWay) {
|
||||
const timeline = timelineOf(route, frame, way.kind === 'routed' ? way.legs : undefined);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
frameOf,
|
||||
planWalk,
|
||||
themeCandidates,
|
||||
walkLineGeometry,
|
||||
type LeftOut,
|
||||
type LeftOutReason,
|
||||
type WalkCandidate,
|
||||
@@ -135,8 +136,10 @@ export class CityWalkBriefingService {
|
||||
themes: theme.candidates,
|
||||
themeLimit: theme.limit,
|
||||
});
|
||||
const way = await this.footWays.plan(userId, tripId, plan.route);
|
||||
return {
|
||||
...describeWalk(plan.route, frame, await this.footWays.plan(userId, tripId, plan.route)),
|
||||
...describeWalk(plan.route, frame, way),
|
||||
...walkLineGeometry(way, briefing.include_geometry),
|
||||
left_out: [...leftOut, ...plan.leftOut],
|
||||
searched: theme.searched,
|
||||
};
|
||||
|
||||
@@ -2,12 +2,20 @@ import { addStretch, metresAlong } from './hike-routing.helpers';
|
||||
import type { PlannedHike } from './hike-routing.service';
|
||||
import type { TourCreateCommand } from './tours.service';
|
||||
import {
|
||||
arrivalsOf,
|
||||
cumulativeMetres,
|
||||
haversineMetres,
|
||||
HIKE_ALONG_MAX,
|
||||
HIKE_PROPOSAL_SAC_SEGMENTS,
|
||||
hikeScheduleOf,
|
||||
hikingPreferencesOf,
|
||||
legMinutes,
|
||||
nearestAlongM,
|
||||
TOUR_MAX_SNAP_M,
|
||||
type GeometryPoint,
|
||||
type HikeAlongPlace,
|
||||
type HikeCreateRequest,
|
||||
type HikeHighPoint,
|
||||
type HikePlan,
|
||||
type HikePlanPoint,
|
||||
type HikeProposal,
|
||||
@@ -16,14 +24,18 @@ import {
|
||||
type HikeRouteOutcome,
|
||||
type HikeSacSegment,
|
||||
type HikeTrailGrades,
|
||||
type TourPoi,
|
||||
type TourWaypoint,
|
||||
type UnroutedHikePlan,
|
||||
type WeatherResult,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The pure halves of a hike an assistant plans over MCP (HikePlanService):
|
||||
* the points it is planned through, the proposal shown from a plan, the way
|
||||
* a saved hike keeps from a plan, and the write a created hike makes.
|
||||
* the points it is planned through, the proposal shown from a plan (where
|
||||
* and when each point is reached, the highest point, the places along the
|
||||
* way, the day), the way a saved hike keeps from a plan, and the write a
|
||||
* created hike makes.
|
||||
*/
|
||||
|
||||
/** The points a hike is planned through: as asked, and for a round trip back to its start. */
|
||||
@@ -40,10 +52,11 @@ export function routeOutcomeOf(plan: HikePlan): HikeRouteOutcome {
|
||||
/** The points as a tour keeps them: in order, the first the start and the last the end, with their names. */
|
||||
export function hikeWaypointsOf(points: readonly HikePlanPoint[]): TourWaypoint[] {
|
||||
const last = points.length - 1;
|
||||
return points.map(({ lat, lng, name }, sequence) => ({
|
||||
return points.map(({ lat, lng, name, poi_category }, sequence) => ({
|
||||
lat,
|
||||
lng,
|
||||
...(name ? { name } : {}),
|
||||
...(poi_category ? { poi_category } : {}),
|
||||
role: sequence === 0 ? 'start' : sequence === last ? 'end' : 'via',
|
||||
sequence,
|
||||
}));
|
||||
@@ -166,8 +179,8 @@ export function heightRangeOf(line: readonly GeometryPoint[]): { min: number; ma
|
||||
const heights = line.flatMap((point) => (point.length === 3 ? [point[2]] : []));
|
||||
if (heights.length === 0 || heights.length !== line.length) return null;
|
||||
return {
|
||||
min: Math.round(heights.reduce((low, height) => Math.min(low, height))),
|
||||
max: Math.round(heights.reduce((high, height) => Math.max(high, height))),
|
||||
min: Math.round(heights.reduce((low, height) => Math.min(low, height), Number.POSITIVE_INFINITY)),
|
||||
max: Math.round(heights.reduce((high, height) => Math.max(high, height), Number.NEGATIVE_INFINITY)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -217,22 +230,121 @@ function unreachableOf(plan: HikePlan): HikeProposal['unreachable'] {
|
||||
return { position: plan.position, snapped_m: plan.snapped_m };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each point lies along the line, in metres: at the end of its leg as
|
||||
* the router measured the legs, laid onto the line's own length, so a point
|
||||
* the way passes twice (a loop's start and end, a summit walked up and down
|
||||
* one path) lies where the way reaches it in order. Where the router gave no
|
||||
* length for each leg, the line's nearest pass in order stands in.
|
||||
*/
|
||||
export function waypointPositionsOf({ line, legs }: PlannedHike, points: readonly HikePlanPoint[]): number[] | null {
|
||||
if (!line) return null;
|
||||
if (!legs || legs.length !== points.length - 1) return positionsAlong(line, points);
|
||||
const length = cumulativeMetres(line).at(-1) ?? 0;
|
||||
const measured = legs.reduce((sum, leg) => sum + leg, 0);
|
||||
const scale = measured > 0 ? length / measured : 0;
|
||||
return runningTotals(legs).map((along) => Math.round(along * scale));
|
||||
}
|
||||
|
||||
/** Each value summed with all before it, after a leading 0: where each point lies when the legs are walked in order. */
|
||||
function runningTotals(legs: readonly number[]): number[] {
|
||||
const totals = [0];
|
||||
let sum = 0;
|
||||
for (const leg of legs) {
|
||||
sum += leg;
|
||||
totals.push(sum);
|
||||
}
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** The walking minutes from the start to each point, the whole time split over the legs by the signposts' rule. */
|
||||
export function waypointMinutesOf(
|
||||
line: readonly GeometryPoint[],
|
||||
positions: readonly number[],
|
||||
total: number,
|
||||
): number[] {
|
||||
return runningTotals(legMinutes(line, positions, total));
|
||||
}
|
||||
|
||||
/** The highest point of a line with every height, the first where two are as high, and where along the line it lies. */
|
||||
export function highPointOf(line: readonly GeometryPoint[] | null): HikeHighPoint | null {
|
||||
if (!line || !heightRangeOf(line)) return null;
|
||||
let top = 0;
|
||||
line.forEach((point, index) => {
|
||||
if ((point[2] ?? 0) > (line[top][2] ?? 0)) top = index;
|
||||
});
|
||||
const [lat, lng, height] = line[top];
|
||||
return { lat, lng, ele_m: Math.round(height ?? 0), at_m: Math.round(cumulativeMetres(line)[top]) };
|
||||
}
|
||||
|
||||
/** Where the day's weather of a hike is read: at its highest point where the heights are known, else at its start. */
|
||||
export function weatherPointOf(points: readonly HikePlanPoint[], { line }: PlannedHike): { lat: number; lng: number } {
|
||||
const high = highPointOf(line);
|
||||
return high ?? { lat: points[0].lat, lng: points[0].lng };
|
||||
}
|
||||
|
||||
/**
|
||||
* The places along a way a proposal names: the nearest to it, at most
|
||||
* HIKE_ALONG_MAX, in the order the way passes them, each placed along the
|
||||
* whole line as the points of the hike are.
|
||||
*/
|
||||
export function alongPlacesOf(pois: readonly TourPoi[], line: readonly GeometryPoint[]): HikeAlongPlace[] {
|
||||
return [...pois]
|
||||
.sort((a, b) => a.distance_m - b.distance_m)
|
||||
.slice(0, HIKE_ALONG_MAX)
|
||||
.map((poi) => ({
|
||||
name: poi.name.trim() || null,
|
||||
category: poi.category,
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
at_m: Math.round(nearestAlongM(line, poi.lat, poi.lng)),
|
||||
off_m: Math.round(poi.distance_m),
|
||||
}))
|
||||
.sort((a, b) => a.at_m - b.at_m);
|
||||
}
|
||||
|
||||
/** What a proposal reads besides its plan: the day it is for, the places along the way and that day's weather. */
|
||||
export interface HikeProposalContext {
|
||||
dayId: number | null;
|
||||
date: string | null;
|
||||
/** Null where none were looked for or the search failed. */
|
||||
along: HikeAlongPlace[] | null;
|
||||
/** Null where none was asked or it could not be had. */
|
||||
weather: WeatherResult | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The proposal of a hike as a plan gives it: the way and its measures, its
|
||||
* profile and grades, each point with where it lies along the way, and the
|
||||
* request's own words given back. Without a way the points are still shown,
|
||||
* grades and highest point, each point with where, after how long and when
|
||||
* it is reached, the places along the way, the day's schedule and warnings,
|
||||
* and the request's own words given back. The line and the profile only when
|
||||
* asked for (include_geometry). Without a way the points are still shown,
|
||||
* with why there is none.
|
||||
*/
|
||||
export function hikeProposalOf(
|
||||
request: HikeProposeRequest,
|
||||
points: readonly HikePlanPoint[],
|
||||
{ plan, line }: PlannedHike,
|
||||
planned: PlannedHike,
|
||||
context: HikeProposalContext,
|
||||
): HikeProposal {
|
||||
const { plan, line } = planned;
|
||||
const routed = plan.kind === 'routed' ? plan : null;
|
||||
const positions = routed && line ? positionsAlong(line, points) : null;
|
||||
const positions = routed ? waypointPositionsOf(planned, points) : null;
|
||||
const heights = routed?.has_heights && line ? heightRangeOf(line) : null;
|
||||
const minutes = routed && line && positions ? waypointMinutesOf(line, positions, routed.duration_minutes) : null;
|
||||
const arrivals = arrivalsOf(request.start_time ?? null, minutes ?? points.map(() => null));
|
||||
const day =
|
||||
routed && context.date
|
||||
? hikeScheduleOf({
|
||||
date: context.date,
|
||||
dayId: context.dayId,
|
||||
start: request.start_time ?? null,
|
||||
minutes: routed.duration_minutes,
|
||||
weather: context.weather,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
name: request.name ?? null,
|
||||
name: request.name,
|
||||
variant_label: request.variant_label ?? null,
|
||||
route: routeOutcomeOf(plan),
|
||||
unreachable: unreachableOf(plan),
|
||||
@@ -244,20 +356,30 @@ export function hikeProposalOf(
|
||||
heights_known: heights !== null,
|
||||
min_ele_m: heights?.min ?? null,
|
||||
max_ele_m: heights?.max ?? null,
|
||||
line: routed?.line ?? [],
|
||||
profile: routed?.profile ?? [],
|
||||
...(routed && request.include_geometry === true
|
||||
? { line_geometry: routed.line, profile_geometry: routed.profile }
|
||||
: {}),
|
||||
sac: proposalSacOf(routed?.grades ?? null),
|
||||
waypoints: points.map(({ lat, lng, name }, index) => ({
|
||||
high_point: routed ? highPointOf(line) : null,
|
||||
waypoints: points.map(({ lat, lng, name, poi_category }, index) => ({
|
||||
lat,
|
||||
lng,
|
||||
name: name || null,
|
||||
poi_category: poi_category ?? null,
|
||||
at_m: positions?.[index] ?? null,
|
||||
snapped_m: routed ? (planned.snapped?.[index] ?? null) : null,
|
||||
minutes: minutes?.[index] ?? null,
|
||||
arrival: arrivals[index] ?? null,
|
||||
})),
|
||||
along: routed ? context.along : null,
|
||||
max_hiking_difficulty: plan.max_hiking_difficulty,
|
||||
hiking_preferences: routed ? routed.hiking_preferences : hikingPreferencesOf(request.hiking_preferences),
|
||||
round_trip: request.round_trip === true,
|
||||
date: request.date ?? null,
|
||||
day_id: context.dayId,
|
||||
date: context.date,
|
||||
start_time: request.start_time ?? null,
|
||||
schedule: day?.schedule ?? null,
|
||||
warnings: day?.warnings ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,11 @@ import {
|
||||
* created, changed or taken in from a GPX file once the traveller said yes,
|
||||
* through the writes the planner and the file import use, with 'place_edit'
|
||||
* on the trip as REST asks (ToursController for a create and an edit,
|
||||
* ToursImportController for the file). Those writes tell the trip about the
|
||||
* tour (tours:changed with place:created or place:updated) without leaving
|
||||
* out any socket, so the tab that asked shows it too. A refusal of the
|
||||
* ToursImportController for the file), and 'day_edit' as well for a hike put
|
||||
* on a day (AssignmentsController). Those writes tell the trip about the
|
||||
* tour (tours:changed with place:created or place:updated, and
|
||||
* assignment:created for its day) without leaving out any socket, so the tab
|
||||
* that asked shows it too. A refusal of the
|
||||
* service is a DomainError, which the registry answers as an error result
|
||||
* with the text REST sends.
|
||||
*/
|
||||
@@ -53,7 +55,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'propose_hike',
|
||||
description:
|
||||
"Plan a hike for a trip and show it to the traveller, without saving anything. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m and max_ele_m, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m, where it lies along the way, and the line and height profile for display. Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake'; date and start_time are given back for the schedule. The chat shows the route, its profile and grades as a card: never list coordinates or the profile, sum the hike up in a sentence and say which grade it reaches. Propose first and create the hike with create_hike only after the traveller said yes.",
|
||||
"Plan a hike for a trip and show it to the traveller, without saving anything; give it a name. The way on foot runs through waypoints in order (the start, the points on the way such as a hut or a summit, each with its poi_category where it is one, the end; with round_trip it comes back to the start) and takes no path harder than max_hiking_difficulty on the SAC scale (1 for T1 hiking to 6 for T6 difficult alpine hiking, 2 when left out), avoiding steep climbs, steps or ferries where there is a way round when hiking_preferences asks so. TREK's router plans it as the tour planner does. The answer gives distance_m, ascent_m and descent_m, the walking time without breaks (duration_minutes: by DIN 33466, the rule hiking signposts use, where the heights are known, duration_basis din33466; else the router's, duration_basis router), min_ele_m, max_ele_m and high_point, the SAC grades OpenStreetMap records along the way (sac: highest, the graded stretches in metres along the way, and unknown_share, the part with no grade, which is unknown and never easy), every point with at_m (where it lies along the way), snapped_m (how far it was moved onto a way), minutes from the start and, with start_time, its arrival, and along: summits, huts, water and viewpoints near the way (null with along false or where they could not be searched). With day_id (a day of the trip, whose date it takes) or date, schedule gives the day: the arrival (next_day past midnight), sunrise and sunset, the weather by the hour at the highest point (weather forecast, or climate further out: last year's day) and, without start_time, latest_start to be back half an hour before sunset; warnings name a start before sunrise (dark_start), an arrival after sunset (dark_end) or within the hour before it (dusk), and the first walking hour with thunderstorms (thunder) or likely rain (rain). Where no way was planned, route says why: no_valhalla (this TREK has no router that knows hiking grades), router_unavailable (try again later), no_way (nothing within the limit joins the points: a higher limit or other points may), point_off_the_way (unreachable names the point and how far it is from any way) or too_long (longer than one hike is planned for: split it into shorter hikes). To compare ways, propose each one with its own variant_label, such as 'short and steep' or 'by the lake', and along false. The chat shows the route, its profile, times, weather and warnings as a card: never list coordinates, the hours or the figures, sum the hike up in a sentence and say what matters. Propose first and create the hike with create_hike only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema, ...hikeProposeRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -67,7 +69,7 @@ export class HikePlanMcp {
|
||||
@Tool({
|
||||
name: 'create_hike',
|
||||
description:
|
||||
"Create a hike in a trip from points the traveller confirmed, usually the waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end), the way on foot planned by the same router as the proposal and kept whole, with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1) and route. Put it on a day with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
|
||||
"Create a hike in a trip from points the traveller confirmed, usually the name and waypoints of a proposal of propose_hike with its max_hiking_difficulty, hiking_preferences and round_trip. It is saved the way the planner saves a new hike: a tour of type hike with its points in order (start, points on the way, end, with their names and poi_category), the way on foot planned by the same router as the proposal and kept whole (the very line a proposal of the last minutes showed), with its heights where every point has one, its distance, ascent, descent and walking time, its hiking limit and preferences, and planned_duration_minutes (the time out, breaks included) where given. Where the router did not answer, for the way or its heights (route planned false, why router_unavailable), the hike is saved with its points only (route_pending true) and TREK plans the way when someone opens it in the planner; no straight line is stored. Any other reason the proposal names (no_valhalla, no_way, point_off_the_way, too_long) refuses the hike with why, and nothing is saved. With day_id the hike is put on that day of the trip in the same write, start_time kept as the start of that visit (which needs day editing permission too); assignment names the visit, null without a day. The answer is the tour as list_tours reads it (tour.place_id is the tour's id, its revision 1), route and assignment. Put it on a day later with assign_place_to_day, link it to the packing list with link_tour_packing, export it with export_tour_gpx, change it with update_tour. Needs place editing permission on the trip. Create it only after the traveller said yes.",
|
||||
inputSchema: { tripId: idSchema.describe('The trip to create the hike in'), ...hikeCreateRequestSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
when: addonGate(ADDON_IDS.TOURS),
|
||||
@@ -76,6 +78,10 @@ export class HikePlanMcp {
|
||||
async createHike({ tripId, ...request }: { tripId: number } & HikeCreateRequest, ctx: McpContext) {
|
||||
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
|
||||
if (!(await this.guards.hasTripPermission('place_edit', tripId, ctx.userId))) return permissionDenied();
|
||||
// Putting it on a day is what REST's assignment route asks 'day_edit' for.
|
||||
if (request.day_id !== undefined && !(await this.guards.hasTripPermission('day_edit', tripId, ctx.userId))) {
|
||||
return permissionDenied();
|
||||
}
|
||||
return ok(await this.hikes.create(ctx.userId, tripId, request));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { badRequest, DomainError } from '../common/domain-error';
|
||||
import { Tours } from '../../db/entities/Tours.entity';
|
||||
import type { ToursRepository } from '../../db/repositories/Tours.repository';
|
||||
import { AssignmentsService } from '../assignments/assignments.service';
|
||||
import { logWarn } from '../audit/audit-log.logger';
|
||||
import { badRequest, DomainError, notFound } from '../common/domain-error';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { WeatherService } from '../weather/weather.service';
|
||||
import {
|
||||
alongPlacesOf,
|
||||
hikeCreateCommandOf,
|
||||
hikePointsOf,
|
||||
hikeProposalOf,
|
||||
@@ -8,18 +14,27 @@ import {
|
||||
repointedWaypoints,
|
||||
unplannedChangeMessage,
|
||||
unplannedCreateMessage,
|
||||
weatherPointOf,
|
||||
type KeptWay,
|
||||
} from './hike-plan.helpers';
|
||||
import { HikeRoutingService, type PlannedHike } from './hike-routing.service';
|
||||
import { noTrackFound } from './tour-file-import.helpers';
|
||||
import { TourPoiSearchService } from './tour-poi-search.service';
|
||||
import { tourRevisionConflict } from './tour-revision';
|
||||
import { thinnedLine } from './tours.helpers';
|
||||
import { ToursService, type ImportGpxAsTourResult, type TourCreateCommand } from './tours.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
HIKE_ALONG_CATEGORIES,
|
||||
HIKE_ALONG_CORRIDOR_M,
|
||||
HIKE_PLAN_MAX_WAYPOINTS,
|
||||
hikingPreferencesOf,
|
||||
TOUR_POI_MAX_LINE_POINTS,
|
||||
tourGpxImportFieldsSchema,
|
||||
TOURS_MAX_HIKING_DIFFICULTY,
|
||||
type GeometryPoint,
|
||||
type HikeAlongPlace,
|
||||
type HikeCreateRequest,
|
||||
type HikePlanPoint,
|
||||
type HikeProposal,
|
||||
@@ -31,10 +46,38 @@ import {
|
||||
type HikeRouteOutcome,
|
||||
type TourUpdateRequest,
|
||||
type TourWaypoint,
|
||||
type WeatherResult,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** What a hike created over MCP answers: the tour as the planner's save answers it, and whether its way was planned. */
|
||||
export type HikeCreated = TourCreateResponse & { route: HikeRouteOutcome };
|
||||
/** The day a hike created over MCP was put on: the visit's id and the day's. */
|
||||
export interface HikeAssignment {
|
||||
id: number;
|
||||
day_id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a hike created over MCP answers: the tour as the planner's save
|
||||
* answers it, whether its way was planned, and the visit it was put on its
|
||||
* day with (null without a day).
|
||||
*/
|
||||
export type HikeCreated = TourCreateResponse & { route: HikeRouteOutcome; assignment: HikeAssignment | null };
|
||||
|
||||
/** The language the day's weather is asked in; only its one-word kinds are read, which are the same in each. */
|
||||
const WEATHER_LANG = 'en';
|
||||
/**
|
||||
* The decimals the weather's point is asked to, about a kilometre: the
|
||||
* variants of one hike then share the weather's cache and its one request.
|
||||
*/
|
||||
const WEATHER_DECIMALS = 2;
|
||||
|
||||
/** A coordinate as the weather is asked for it. */
|
||||
function weatherCoordinate(value: number): string {
|
||||
return String(Number(value.toFixed(WEATHER_DECIMALS)));
|
||||
}
|
||||
|
||||
function reasonOf(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/** What a change answers: the tour as now saved, whether its way was planned anew, and how that went. */
|
||||
export type HikeChanged = TourDetailResponse & { route: HikeRouteOutcome; replanned: boolean };
|
||||
@@ -91,6 +134,10 @@ export class HikePlanService {
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly routing: HikeRoutingService,
|
||||
private readonly tours: ToursService,
|
||||
private readonly assignments: AssignmentsService,
|
||||
private readonly poiSearch: TourPoiSearchService,
|
||||
private readonly weather: WeatherService,
|
||||
@InjectRepository(Tours) private readonly toursRepo: ToursRepository,
|
||||
) {}
|
||||
|
||||
/** The way through the points; `grades` for a proposal only, which shows them. */
|
||||
@@ -110,11 +157,65 @@ export class HikePlanService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A hike proposed for the traveller to look at, with its day: the date of
|
||||
* the trip's day it is on (a day of another trip is not found) or the date
|
||||
* given, the places along the way unless asked to leave them out, and the
|
||||
* day's weather at the way's highest point. The places and the weather are
|
||||
* best effort: without them the proposal still answers.
|
||||
*/
|
||||
async propose(userId: number, tripId: number, request: HikeProposeRequest): Promise<HikeProposal> {
|
||||
const day = await this.dayOf(tripId, request.day_id, request.date);
|
||||
const points = hikePointsOf(request.waypoints, request.round_trip);
|
||||
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
|
||||
const planned = await this.plan(userId, tripId, points, limit, preferences, true);
|
||||
return hikeProposalOf(request, points, planned);
|
||||
const line = planned.plan.kind === 'routed' ? planned.line : null;
|
||||
const [along, weather] = await Promise.all([
|
||||
line && request.along !== false ? this.alongOf(line) : null,
|
||||
line && day.date ? this.weatherOf(weatherPointOf(points, planned), day.date) : null,
|
||||
]);
|
||||
return hikeProposalOf(request, points, planned, { ...day, along, weather });
|
||||
}
|
||||
|
||||
/** The trip's day a hike is planned on and its date, or the date given without a day. */
|
||||
private async dayOf(
|
||||
tripId: number,
|
||||
dayId: number | undefined,
|
||||
date: string | undefined,
|
||||
): Promise<{ dayId: number | null; date: string | null }> {
|
||||
if (dayId === undefined) return { dayId: null, date: date ?? null };
|
||||
const day = await this.toursRepo.dayOfTrip(tripId, dayId);
|
||||
if (!day) throw notFound('Day not found');
|
||||
return { dayId: day.id, date: day.date };
|
||||
}
|
||||
|
||||
/** Summits, huts, water and viewpoints near the way, or null where the search could not be made. */
|
||||
private async alongOf(line: readonly GeometryPoint[]): Promise<HikeAlongPlace[] | null> {
|
||||
try {
|
||||
const found = await this.poiSearch.search({
|
||||
tour_type: 'hike',
|
||||
categories: [...HIKE_ALONG_CATEGORIES],
|
||||
corridor_m: HIKE_ALONG_CORRIDOR_M,
|
||||
line: thinnedLine(line, TOUR_POI_MAX_LINE_POINTS).map(([lat, lng]): [number, number] => [lat, lng]),
|
||||
});
|
||||
// Not one kind could be searched: nothing found would read as nothing there.
|
||||
if (found.unavailable.length === HIKE_ALONG_CATEGORIES.length) return null;
|
||||
return alongPlacesOf(found.pois, line);
|
||||
} catch (err: unknown) {
|
||||
logWarn(`[Hike] no places along the way: ${reasonOf(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The day's detailed weather near a point, or null where it could not be had. */
|
||||
private async weatherOf(point: { lat: number; lng: number }, date: string): Promise<WeatherResult | null> {
|
||||
try {
|
||||
const [lat, lng] = [weatherCoordinate(point.lat), weatherCoordinate(point.lng)];
|
||||
return await this.weather.getDetailed(lat, lng, date, WEATHER_LANG);
|
||||
} catch (err: unknown) {
|
||||
logWarn(`[Hike] no weather for ${date}: ${reasonOf(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -124,8 +225,13 @@ export class HikePlanService {
|
||||
* the planner plans the way when it is opened. Any other reason stands in
|
||||
* the planner as well (no Valhalla, no way within the limit, a point off
|
||||
* every way, a way too long), so the hike is refused and nothing is saved.
|
||||
* With a day it is put on that day in the same write (a day of another trip
|
||||
* is not found), its start kept as the visit's, and the trip is told of the
|
||||
* tour and the visit once both are saved, as the planner's save and its
|
||||
* drop onto the day tell it.
|
||||
*/
|
||||
async create(userId: number, tripId: number, request: HikeCreateRequest): Promise<HikeCreated> {
|
||||
const day = request.day_id === undefined ? null : await this.dayOf(tripId, request.day_id, undefined);
|
||||
const points = hikePointsOf(request.waypoints, request.round_trip);
|
||||
const { max_hiking_difficulty: limit, hiking_preferences: preferences } = request;
|
||||
const planned = await this.plan(userId, tripId, points, limit, preferences, false);
|
||||
@@ -134,8 +240,27 @@ export class HikePlanService {
|
||||
throw notRouted(unplannedCreateMessage(way.route));
|
||||
}
|
||||
const command = hikeCreateCommandOf(request, points, way, planned.plan.max_hiking_difficulty);
|
||||
const created = await this.tours.createTour(String(tripId), command);
|
||||
return { ...created, route: way.route };
|
||||
if (day?.dayId == null) {
|
||||
const created = await this.tours.createTour(String(tripId), command);
|
||||
return { ...created, route: way.route, assignment: null };
|
||||
}
|
||||
const dayId = day.dayId;
|
||||
const written = await this.uow.transactional(async () => {
|
||||
const placeId = await this.tours.insertTour(tripId, command);
|
||||
const added = await this.assignments.createAssignment(dayId, placeId, null);
|
||||
if (!added) throw new Error('Created visit could not be loaded');
|
||||
if (!request.start_time) return { placeId, assignment: added, reordered: null, vias: null };
|
||||
const timed = await this.assignments.updateTime(added.id, request.start_time, null);
|
||||
return { placeId, ...timed, assignment: timed.assignment ?? added };
|
||||
});
|
||||
const trip = String(tripId);
|
||||
const created = await this.tours.announceCreatedTour(trip, written.placeId);
|
||||
// As REST tells the trip of a visit added (and of a start that put its day in another order), to every tab.
|
||||
this.assignments.broadcast(trip, 'assignment:created', { assignment: written.assignment }, undefined);
|
||||
if (written.reordered) this.assignments.broadcast(trip, 'assignment:reordered', written.reordered, undefined);
|
||||
if (written.vias) this.assignments.broadcast(trip, 'roadtripVia:changed', written.vias, undefined);
|
||||
await this.assignments.reconcile(tripId);
|
||||
return { ...created, route: way.route, assignment: { id: written.assignment.id, day_id: dayId } };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,52 @@ export interface PlannedHike {
|
||||
* null where no way was planned.
|
||||
*/
|
||||
line: GeometryPoint[] | null;
|
||||
/**
|
||||
* For each point asked through, how far the router moved it onto a way, in
|
||||
* whole metres; null where no way was planned or the router did not say.
|
||||
*/
|
||||
snapped: number[] | null;
|
||||
/**
|
||||
* The length of each leg between two points asked through, as the router
|
||||
* measured it, in metres; null where no way was planned.
|
||||
*/
|
||||
legs: number[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a planned hike is kept: a hike created or changed from the way a
|
||||
* proposal showed saves that very line, and a proposal asked again (another
|
||||
* label, another start) asks the router nothing.
|
||||
*/
|
||||
const PLAN_MEMO_TTL_MS = 15 * 60_000;
|
||||
/** The most planned hikes kept; the one used longest ago goes first. */
|
||||
const PLAN_MEMO_MAX = 64;
|
||||
/**
|
||||
* The most line points all kept hikes hold together, besides PLAN_MEMO_MAX: a
|
||||
* kept line can run to HIKE_MAX_LINE_POINTS, and the memo shares the box with
|
||||
* everything else.
|
||||
*/
|
||||
const PLAN_MEMO_MAX_POINTS = 200_000;
|
||||
/** Points nearer than this many decimal places of a degree (about a metre) are one point for the memo. */
|
||||
const PLAN_MEMO_DECIMALS = 5;
|
||||
|
||||
interface KeptPlan {
|
||||
at: number;
|
||||
/** Whether the grades were read with it, so a proposal is never answered from a save that read none. */
|
||||
grades: boolean;
|
||||
/** How many points its whole line holds. */
|
||||
points: number;
|
||||
planned: PlannedHike;
|
||||
}
|
||||
|
||||
/** The request as the memo keys it: who asked in which trip, the points to about a metre, the limit, the preferences. */
|
||||
function memoKeyOf(userId: number, tripId: number, request: HikePlanRequest): string {
|
||||
const points = request.waypoints.map(({ lat, lng }) => [
|
||||
Number(lat.toFixed(PLAN_MEMO_DECIMALS)),
|
||||
Number(lng.toFixed(PLAN_MEMO_DECIMALS)),
|
||||
]);
|
||||
const limit = request.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY;
|
||||
return JSON.stringify([userId, tripId, points, limit, hikingPreferencesOf(request.hiking_preferences)]);
|
||||
}
|
||||
|
||||
/** What a plan asks for besides the way and its heights. */
|
||||
@@ -74,10 +120,14 @@ function whyNot(err: unknown): UnroutedHikePlan['why'] {
|
||||
* heights the line stays flat and its climb unknown, without grades the plan
|
||||
* says they could not be read. A way of more points than HIKE_MAX_LINE_POINTS
|
||||
* is not measured: its heights alone would hold the router's queue up for
|
||||
* every user. Writes nothing.
|
||||
* every user. A way planned with every height is kept for a while (PLAN_MEMO_TTL_MS),
|
||||
* so the hike created from a proposal is the line the proposal showed. Writes nothing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class HikeRoutingService {
|
||||
/** The planned hikes kept, the one used last at the end. */
|
||||
private readonly memo = new Map<string, KeptPlan>();
|
||||
|
||||
constructor(
|
||||
private readonly router: RoadtripRouterService,
|
||||
private readonly trailGrades: TourTrailGradeService,
|
||||
@@ -92,11 +142,64 @@ export class HikeRoutingService {
|
||||
// The tools check their own requests; a change plans through the points a hike has saved, held here to the same.
|
||||
const checked = hikePlanRequestSchema.safeParse(request);
|
||||
if (!checked.success) throw badRequest(checked.error.issues.map((issue) => issue.message).join('; '));
|
||||
const key = memoKeyOf(userId, tripId, request);
|
||||
const kept = this.recall(key, withGrades);
|
||||
if (kept) return kept;
|
||||
const planned = await this.planned(userId, tripId, request, withGrades);
|
||||
// Only a way with every height is kept: one without is asked again, and its heights with it.
|
||||
if (planned.plan.kind === 'routed' && planned.plan.has_heights && planned.line) {
|
||||
const grades = withGrades && planned.plan.grades !== null;
|
||||
this.remember(key, { at: Date.now(), grades, points: planned.line.length, planned });
|
||||
}
|
||||
return planned;
|
||||
}
|
||||
|
||||
/** A hike kept from before for the same request, where it is fresh and read the grades it is asked for. */
|
||||
private recall(key: string, withGrades: boolean): PlannedHike | null {
|
||||
const kept = this.memo.get(key);
|
||||
if (!kept) return null;
|
||||
this.memo.delete(key);
|
||||
if (Date.now() - kept.at >= PLAN_MEMO_TTL_MS) return null;
|
||||
// Used now: it goes to the end, the last to be dropped.
|
||||
this.memo.set(key, kept);
|
||||
return withGrades && !kept.grades ? null : kept.planned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a planned hike. Stale ones go first, then the ones used longest ago
|
||||
* until the memo holds at most PLAN_MEMO_MAX hikes of PLAN_MEMO_MAX_POINTS
|
||||
* line points together.
|
||||
*/
|
||||
private remember(key: string, kept: KeptPlan): void {
|
||||
this.memo.delete(key);
|
||||
this.memo.set(key, kept);
|
||||
let points = 0;
|
||||
for (const [other, entry] of this.memo) {
|
||||
if (kept.at - entry.at >= PLAN_MEMO_TTL_MS) this.memo.delete(other);
|
||||
else points += entry.points;
|
||||
}
|
||||
for (const [oldest, entry] of this.memo) {
|
||||
// The hike just kept is last and stays: one line holds far fewer points than the cap.
|
||||
if (this.memo.size <= PLAN_MEMO_MAX && points <= PLAN_MEMO_MAX_POINTS) break;
|
||||
this.memo.delete(oldest);
|
||||
points -= entry.points;
|
||||
}
|
||||
}
|
||||
|
||||
/** The hike planned anew: the way, its heights and, where asked, its grades. */
|
||||
private async planned(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
request: HikePlanRequest,
|
||||
withGrades: boolean,
|
||||
): Promise<PlannedHike> {
|
||||
const limit = request.max_hiking_difficulty ?? TOURS_MAX_HIKING_DIFFICULTY;
|
||||
const preferences = hikingPreferencesOf(request.hiking_preferences);
|
||||
const unrouted = (why: UnroutedHikePlan['why'], position: number | null = null, snapped: number | null = null) => ({
|
||||
plan: { kind: 'unrouted' as const, max_hiking_difficulty: limit, why, position, snapped_m: snapped },
|
||||
line: null,
|
||||
snapped: null,
|
||||
legs: null,
|
||||
});
|
||||
let route: RoadtripRoute;
|
||||
try {
|
||||
@@ -140,6 +243,8 @@ export class HikeRoutingService {
|
||||
grades,
|
||||
},
|
||||
line,
|
||||
snapped: route.snapped ? snapped.map((point) => Math.round(point.meters)) : null,
|
||||
legs: route.parts.map((part) => part.distance),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { RoadtripModule } from '../roadtrip/roadtrip.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { WandererModule } from '../wanderer/wanderer.module';
|
||||
import { WeatherModule } from '../weather/weather.module';
|
||||
import { CityWalkBriefingService } from './city-walk-briefing.service';
|
||||
import { CityWalkFootWayService } from './city-walk-foot-way.service';
|
||||
import { CityWalkMcp } from './city-walk.mcp';
|
||||
@@ -95,7 +96,10 @@ 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. A
|
||||
* the same writes, its way planned on the server by HikeRoutingService, 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
|
||||
* change to a saved city walk an assistant prepares for review is laid onto
|
||||
* its saved points and planned on foot by the same router, and saves nothing
|
||||
* (MCP-T02); once the traveller
|
||||
@@ -144,6 +148,8 @@ import { Module } from '@nestjs/common';
|
||||
// The wanderer watch tells the user of a new hint (WAN-02) and runs as a cron.
|
||||
NotificationsModule,
|
||||
SchedulingModule,
|
||||
// A proposed hike's day reads the detailed weather at its highest point: sun, hours and warnings.
|
||||
WeatherModule,
|
||||
MikroOrmModule.forFeature([
|
||||
CollectionTours,
|
||||
Tours,
|
||||
|
||||
@@ -262,6 +262,17 @@ export class ToursService {
|
||||
async createTour(tripId: string, input: TourCreateCommand, socketId?: string): Promise<TourCreateResponse> {
|
||||
// The controller's TripAccessGuard already resolved this trip id.
|
||||
const tid = toRowId(tripId)!;
|
||||
const placeId = await this.uow.transactional(() => this.insertTour(tid, input));
|
||||
return await this.announceCreatedTour(tripId, placeId, socketId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of a new tour (its place, its facet, its control points), written
|
||||
* inside the caller's transaction after the same checks, so a caller can put
|
||||
* more into that write (a hike put on its day). Answers the new place's id;
|
||||
* the trip is told by announceCreatedTour once the write committed.
|
||||
*/
|
||||
async insertTour(tid: number, input: TourCreateCommand): Promise<number> {
|
||||
await this.assertTourTypeEnabled(input.tour_type);
|
||||
checkTourCommand(input);
|
||||
const start = input.route_geometry ? input.route_geometry[0] : firstPointOf(input.waypoints);
|
||||
@@ -270,36 +281,37 @@ export class ToursService {
|
||||
}
|
||||
const fields = tourRouteFields(input);
|
||||
const derivedFrom = input.derived_from_place_id ?? null;
|
||||
|
||||
const placeId = await this.uow.transactional(async () => {
|
||||
// What it was planned from has to be an import (no waypoints of its own) of this same trip,
|
||||
// read in the write so it cannot go meanwhile.
|
||||
if (derivedFrom !== null) {
|
||||
const source = await this.toursRepo.findInTrip(tid, derivedFrom);
|
||||
if (!source || source.origin === 'trek' || source.has_waypoints) {
|
||||
throw new DomainError(400, 'derived_from_place_id is not an imported tour of this trip');
|
||||
}
|
||||
// What it was planned from has to be an import (no waypoints of its own) of this same trip,
|
||||
// read in the write so it cannot go meanwhile.
|
||||
if (derivedFrom !== null) {
|
||||
const source = await this.toursRepo.findInTrip(tid, derivedFrom);
|
||||
if (!source || source.origin === 'trek' || source.has_waypoints) {
|
||||
throw new DomainError(400, 'derived_from_place_id is not an imported tour of this trip');
|
||||
}
|
||||
const id = await this.placesRepo.insertTourPlace({
|
||||
trip_id: tid,
|
||||
name: input.name,
|
||||
lat: start[0],
|
||||
lng: start[1],
|
||||
route_geometry: input.route_geometry ? JSON.stringify(input.route_geometry) : null,
|
||||
description: input.description ?? null,
|
||||
website: input.website ?? null,
|
||||
});
|
||||
await this.toursRepo.insertTour({
|
||||
place_id: id,
|
||||
...fields,
|
||||
planned_duration_minutes: fields.planned_duration_minutes ?? null,
|
||||
break_additional_minutes: fields.break_additional_minutes ?? null,
|
||||
derived_from_place_id: derivedFrom,
|
||||
});
|
||||
await this.waypointsRepo.insertForPlace(id, keyedWaypoints(await this.linkedWaypoints(tid, input.waypoints)));
|
||||
return id;
|
||||
}
|
||||
const id = await this.placesRepo.insertTourPlace({
|
||||
trip_id: tid,
|
||||
name: input.name,
|
||||
lat: start[0],
|
||||
lng: start[1],
|
||||
route_geometry: input.route_geometry ? JSON.stringify(input.route_geometry) : null,
|
||||
description: input.description ?? null,
|
||||
website: input.website ?? null,
|
||||
});
|
||||
await this.toursRepo.insertTour({
|
||||
place_id: id,
|
||||
...fields,
|
||||
planned_duration_minutes: fields.planned_duration_minutes ?? null,
|
||||
break_additional_minutes: fields.break_additional_minutes ?? null,
|
||||
derived_from_place_id: derivedFrom,
|
||||
});
|
||||
await this.waypointsRepo.insertForPlace(id, keyedWaypoints(await this.linkedWaypoints(tid, input.waypoints)));
|
||||
return id;
|
||||
}
|
||||
|
||||
/** A tour just written, read back as a create answers it, and the trip told once every row committed. */
|
||||
async announceCreatedTour(tripId: string, placeId: number, socketId?: string): Promise<TourCreateResponse> {
|
||||
const tid = toRowId(tripId)!;
|
||||
const row = await this.toursRepo.findInTrip(tid, placeId);
|
||||
const place = await this.placesRepo.findWithTagsAndRatings(placeId);
|
||||
if (!row || !place) throw new Error('Created tour could not be loaded');
|
||||
|
||||
@@ -5,9 +5,14 @@ import { WeatherService } from './weather.service';
|
||||
import { WindService } from './wind.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/** Weather domain (pilot leaf module). Registered in AppModule. */
|
||||
/**
|
||||
* Weather domain (pilot leaf module). Registered in AppModule. WeatherService
|
||||
* is exported for the hikes a traveller plans, whose day reads the detailed
|
||||
* weather at the way's highest point.
|
||||
*/
|
||||
@Module({
|
||||
controllers: [WeatherController],
|
||||
providers: [WeatherService, WindService, WeatherMcp, WeatherRpc],
|
||||
exports: [WeatherService],
|
||||
})
|
||||
export class WeatherModule {}
|
||||
|
||||
Reference in New Issue
Block a user