feat(ski-routing): plan ski tours on the piste graphs of TREK Places

A new Nest domain plans the legs of a ski and winter tour on the runs,
lifts, cross-country trails, ski touring and winter paths the Places host
builds from OpenStreetMap, one SGR1 graph per ski area. TREK fetches the
index once, downloads an area by its id when a planner first plans in it,
checks it against the index's SHA-256 and keeps it in data/ski; no
clicked point ever leaves the server. A leg whose area is slower than two
seconds answers loading, and an older copy stands in while the host is
down.

The reader decodes the writer's golden file byte for byte, including the
two edge flags it added (area_has_run, type_unknown). The search is A* in
a worker thread over a profile per discipline (alpine, snowboard,
cross-country by technique, touring, snowshoe, winter hike) with the
times and penalties of the cost model, up to five snap candidates per
point, lift stations as entry points, legs that go on from each other,
pieces of a split network stitched by their boundary nodes, other ways by
the penalty method, and a failed leg explained by relaxing one rule at a
time (difficulty, lift class, technique, riding down, uphill) without ever
handing out the relaxed line.

POST /api/trips/:tripId/ski/route, GET .../ski/area-near and
GET .../ski/network/:areaId sit behind Tours and the trip, with
plan_ski_route as their MCP twin. A daily job looks for a new build and
sweeps areas unused for 90 days. The admin switch ski_routing_enabled is
on wherever TREK Places is.
This commit is contained in:
Maurice
2026-10-20 20:02:46 +02:00
parent beea4c762b
commit c8eb510bd4
52 changed files with 7780 additions and 39 deletions
+2
View File
@@ -59,6 +59,8 @@ export * from './tours/tours.schema';
export * from './tours/run-pace';
export * from './tours/tour-time-estimate';
export * from './tours/ski.schema';
export * from './ski/ski-route.schema';
export * from './ski/winter-index.schema';
export * from './tours/tour-stages';
export * from './tours/tour-provenance.schema';
export * from './tours/tour-activity.schema';
+282
View File
@@ -0,0 +1,282 @@
import {
SKI_DIFFICULTIES,
skiDisciplineSchema,
skiRouteBuildSchema,
skiRouteOptionsSchema,
skiTechniqueSchema,
} from '../tours/ski.schema';
import { z } from 'zod';
/**
* The piste router: legs of a ski and winter tour planned on the runs, lifts,
* cross-country trails, ski touring and winter paths of OpenStreetMap. The
* Places host builds a graph per ski area once and serves it by area id; TREK
* downloads the areas a tour needs and searches them itself, so the points a
* traveller clicks never leave the server. Every leg is planned on its own
* and answered as planned or failed with the reason and, where one helps, the
* one change that would plan it. A failed leg is never drawn by the server:
* the planner draws it straight and says so.
*/
export const skiRoutePointSchema = z.object({
lat: z.number().min(-90).max(90),
lng: z.number().min(-180).max(180),
});
export type SkiRoutePoint = z.infer<typeof skiRoutePointSchema>;
/** The most legs one request plans; a tour with more asks again. */
export const MAX_SKI_ROUTE_LEGS = 40;
/** At most this many other ways per leg, besides the best one. */
export const MAX_SKI_ALTERNATIVES = 2;
export const skiRouteRequestSchema = z.object({
legs: z
.array(
z.object({
from: skiRoutePointSchema,
to: skiRoutePointSchema,
/** The leg ends at a shaping point: it bends the way, the summary does not split there. */
shaping_to: z.boolean().default(false),
}),
)
.min(1)
.max(MAX_SKI_ROUTE_LEGS),
discipline: skiDisciplineSchema,
/** Cross-country only; any other discipline ignores it. Left out on cross-country, either technique will do. */
technique: skiTechniqueSchema.nullable().default(null),
options: skiRouteOptionsSchema.prefault({}),
alternatives: z.number().int().min(0).max(MAX_SKI_ALTERNATIVES).default(0),
});
export type SkiRouteRequest = z.infer<typeof skiRouteRequestSchema>;
export type SkiRouteInput = z.input<typeof skiRouteRequestSchema>;
/**
* The stretches of a planned leg: a run, a lift ridden up, a funicular or rack
* railway, a gondola ridden down, a cross-country trail, a ski touring or
* winter path, a mapped connection between runs, an open slope, a short link
* the map data does not hold, a crossing, or a stretch on foot.
*/
export const skiSegmentKindSchema = z.enum([
'run',
'lift',
'rail',
'lift_down',
'nordic',
'skitour',
'hike',
'connection',
'area',
'link',
'crossing',
'walk',
]);
export type SkiSegmentKind = z.infer<typeof skiSegmentKindSchema>;
/** What a stretch rests on that the traveller should know: guessed links, directions, techniques and the like. */
export const skiFlagSchema = z.enum([
'inferred_link',
'inferred_crossing',
'dir_from_dem',
'dir_conflict',
'technique_assumed',
'relation_inherited',
'area_derived',
'ride_down',
'uphill_on_piste',
'dismount',
'difficulty_unknown',
'tunnel',
]);
export type SkiFlag = z.infer<typeof skiFlagSchema>;
/** A run's difficulty as mapped, freeride and extreme included. */
export const skiRunDifficultySchema = z.enum([...SKI_DIFFICULTIES, 'freeride', 'extreme']);
export type SkiRunDifficulty = z.infer<typeof skiRunDifficultySchema>;
/** A point of a planned leg: [lat, lng, height in metres], the height null where the terrain data has none. */
export const skiGeometryPointSchema = z.tuple([z.number(), z.number(), z.number().nullable()]);
export type SkiGeometryPoint = z.infer<typeof skiGeometryPointSchema>;
export const skiRouteSegmentSchema = z.object({
kind: skiSegmentKindSchema,
/** First and last index into the leg's geometry, both included. */
from: z.number().int().nonnegative(),
to: z.number().int().nonnegative(),
name: z.string().nullable(),
ref: z.string().nullable(),
difficulty: skiRunDifficultySchema.nullable(),
/** The lift's type as OpenStreetMap names it (gondola, chair_detachable, t-bar...); null off a lift. */
lift_type: z.string().nullable(),
/** How the trail is groomed, as mapped (classic, skating, scooter, backcountry, mogul). */
grooming: z.array(z.string()),
/** Time on the move, without waiting. */
seconds: z.number().nonnegative(),
/** Waiting at the lift's bottom station, a guess from the lift's type and capacity. */
wait_seconds: z.number().nonnegative(),
length_m: z.number().nonnegative(),
flags: z.array(skiFlagSchema),
osm_way: z.number().nullable(),
});
export type SkiRouteSegment = z.infer<typeof skiRouteSegmentSchema>;
/** Why the way is as it is: a harder run it kept clear of, or how often it rests on a flagged stretch. */
export const skiWhySchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('avoided_difficulty'),
level: skiRunDifficultySchema,
name: z.string().nullable(),
saved_seconds: z.number().nonnegative(),
}),
z.object({ kind: z.literal('flag'), flag: skiFlagSchema, count: z.number().int().positive() }),
]);
export type SkiWhy = z.infer<typeof skiWhySchema>;
export const skiLegSecondsSchema = z.object({
ski: z.number().nonnegative(),
lift: z.number().nonnegative(),
wait: z.number().nonnegative(),
walk: z.number().nonnegative(),
});
export type SkiLegSeconds = z.infer<typeof skiLegSecondsSchema>;
export const skiRouteAlternativeSchema = z.object({
geometry: z.array(skiGeometryPointSchema).min(2),
segments: z.array(skiRouteSegmentSchema).min(1),
seconds_total: z.number().nonnegative(),
});
export type SkiRouteAlternative = z.infer<typeof skiRouteAlternativeSchema>;
export const skiRouteOkLegSchema = z.object({
status: z.literal('ok'),
geometry: z.array(skiGeometryPointSchema).min(2),
segments: z.array(skiRouteSegmentSchema).min(1),
distance_m: z.number().nonnegative(),
climb_m: z.number().nonnegative(),
descent_m: z.number().nonnegative(),
seconds: skiLegSecondsSchema,
/** How far the ends were moved onto the network, in metres. */
snapped_m: z.object({ from: z.number().nonnegative(), to: z.number().nonnegative() }),
area_ids: z.array(z.string()),
why: z.array(skiWhySchema),
alternatives: z.array(skiRouteAlternativeSchema).max(MAX_SKI_ALTERNATIVES),
});
export type SkiRouteOkLeg = z.infer<typeof skiRouteOkLegSchema>;
export const skiFailCodeSchema = z.enum([
/** A point lies farther from any run, trail or lift the discipline may use than the router reaches. */
'not_on_piste',
/** No ski area of the piste data covers the point. */
'no_area',
/** The ski area is being downloaded; asked again shortly it answers. */
'loading',
/** The piste data cannot be reached and no copy of the area is kept. */
'places_unavailable',
/** The admin switched piste routing off. */
'disabled',
'blocked_by_difficulty',
'blocked_by_lift_avoid',
'blocked_by_technique',
'needs_ride_down',
/** Only a way up joins the points, and the discipline has none without a lift. */
'uphill_only',
/** Nothing of the network joins the points. */
'disconnected',
/** The points lie in two ski areas that do not touch. */
'cross_area',
'busy',
'timeout',
]);
export type SkiFailCode = z.infer<typeof skiFailCodeSchema>;
/** The one change that would plan a failed leg; each field is a separate suggestion. */
export const skiFailHintSchema = z.object({
raise_difficulty: skiRunDifficultySchema.optional(),
allow_lift: z.string().optional(),
technique: skiTechniqueSchema.optional(),
allow_lift_down: z.boolean().optional(),
discipline: skiDisciplineSchema.optional(),
free_draw: z.boolean().optional(),
});
export type SkiFailHint = z.infer<typeof skiFailHintSchema>;
export const skiRouteFailedLegSchema = z.object({
status: z.literal('failed'),
code: skiFailCodeSchema,
/** Where it failed: the point off the network, or the nearest point reached towards the target. */
at: skiRoutePointSchema.nullable(),
hint: skiFailHintSchema.nullable(),
/** The run or lift the reason is about, and how far a point lies off the network. */
detail: z.object({ name: z.string().nullable(), distance_m: z.number().optional() }).nullable(),
});
export type SkiRouteFailedLeg = z.infer<typeof skiRouteFailedLegSchema>;
export const skiRouteLegSchema = z.discriminatedUnion('status', [skiRouteOkLegSchema, skiRouteFailedLegSchema]);
export type SkiRouteLeg = z.infer<typeof skiRouteLegSchema>;
/** The colours a run's difficulty is drawn in: the European scheme, or North America's. */
export const skiColourSchemeSchema = z.enum(['europe', 'north_america']);
export type SkiColourScheme = z.infer<typeof skiColourSchemeSchema>;
export const skiRouteResponseSchema = z.object({
legs: z.array(skiRouteLegSchema),
/** The piste data build the legs were planned on; null when no area was used. */
build: z.string().nullable(),
/** The Places host could not be reached and a kept, older copy of an area was used. */
stale: z.boolean(),
area_names: z.array(z.string()),
/** How the areas planned on colour their runs; Europe's where no area was used. */
colour_scheme: skiColourSchemeSchema,
attribution: z.string(),
});
export type SkiRouteResponse = z.infer<typeof skiRouteResponseSchema>;
/** GET /ski/area-near: whether the ski area at a point is ready, on its way, or cannot be had. */
export const skiAreaStateSchema = z.enum(['ready', 'loading', 'unavailable']);
export type SkiAreaState = z.infer<typeof skiAreaStateSchema>;
export const skiAreaNearQuerySchema = z.object({
lat: z.coerce.number().min(-90).max(90),
lng: z.coerce.number().min(-180).max(180),
});
export type SkiAreaNearQuery = z.infer<typeof skiAreaNearQuerySchema>;
export const skiAreaNearResponseSchema = z.object({
area: z
.object({
id: z.string(),
name: z.string().nullable(),
kind: z.string(),
colour_scheme: skiColourSchemeSchema,
build: z.string().nullable(),
state: skiAreaStateSchema,
})
.nullable(),
});
export type SkiAreaNearResponse = z.infer<typeof skiAreaNearResponseSchema>;
/** An area id as the Places host spells it: `w:<way>` or `r:<relation>`, a piece of a split network with a suffix. */
export const skiAreaIdSchema = z.string().max(64).regex(/^[a-z]:[0-9]+(-[0-9a-z]+)?$/);
/** One drawn line of an area's network: a run, a lift or a trail, as a Google-encoded polyline (precision 5). */
export const skiNetworkFeatureSchema = z.object({
kind: skiSegmentKindSchema,
difficulty: skiRunDifficultySchema.nullable(),
name: z.string().nullable(),
lift_type: z.string().nullable(),
/** Skied or ridden one way only, in the direction it is drawn. */
oneway: z.boolean(),
line: z.string().min(1),
});
export type SkiNetworkFeature = z.infer<typeof skiNetworkFeatureSchema>;
export const skiNetworkResponseSchema = z.object({
area_id: z.string(),
build: z.string().nullable(),
colour_scheme: skiColourSchemeSchema,
/** How far the lines were simplified, in metres. */
simplified_m: z.number().nonnegative(),
features: z.array(skiNetworkFeatureSchema),
});
export type SkiNetworkResponse = z.infer<typeof skiNetworkResponseSchema>;
+65
View File
@@ -0,0 +1,65 @@
import { z } from 'zod';
/**
* What the Places host's winter layer answers before any graph is fetched:
* which build it serves (`/v1/winter/meta`) and every ski area with its box,
* size and checksum (`/v1/winter/index`). Neither takes a coordinate. A new
* build is told by `indexSha`, the SHA-256 of the unpacked index JSON, never
* by the build's name, which repeats when a build is redone the same day.
*/
const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/);
/** The attribution block the winter layer carries; only the two lines TREK prints are required. */
export const winterAttributionSchema = z.object({
data: z.string(),
terrain: z.string(),
});
export type WinterAttribution = z.infer<typeof winterAttributionSchema>;
export const winterMetaSchema = z.object({
build: z.string().min(1).max(40),
format: z.number().int(),
areas: z.number().int().nonnegative(),
indexSha: sha256Schema,
sourceTimestamp: z.string().nullable().optional(),
attribution: winterAttributionSchema,
});
export type WinterMeta = z.infer<typeof winterMetaSchema>;
/** The km of each kind of way an area holds, and its lift count. */
export const winterAreaCountsSchema = z.object({
runsKm: z.number().nonnegative(),
nordicKm: z.number().nonnegative(),
lifts: z.number().int().nonnegative(),
skitourKm: z.number().nonnegative(),
hikeKm: z.number().nonnegative(),
});
export type WinterAreaCounts = z.infer<typeof winterAreaCountsSchema>;
export const winterIndexRowSchema = z.object({
id: z.string().max(64).regex(/^[a-z]:[0-9]+(-[0-9a-z]+)?$/),
name: z.string().nullable(),
kind: z.string(),
country: z.string().nullable(),
colourScheme: z.enum(['europe', 'north_america']),
/** [minLng, minLat, maxLng, maxLat] */
bbox: z.tuple([
z.number().min(-180).max(180),
z.number().min(-90).max(90),
z.number().min(-180).max(180),
z.number().min(-90).max(90),
]),
counts: winterAreaCountsSchema,
/** The size of the area's gzip-packed graph, and its SHA-256, exactly as sent. */
bytes: z.number().int().nonnegative(),
sha256: sha256Schema,
});
export type WinterIndexRow = z.infer<typeof winterIndexRowSchema>;
export const winterIndexSchema = z.object({
build: z.string().min(1).max(40),
format: z.number().int(),
areas: z.array(winterIndexRowSchema),
});
export type WinterIndex = z.infer<typeof winterIndexSchema>;
+109 -6
View File
@@ -2,12 +2,14 @@ import { z } from 'zod';
/**
* SKI-02: a ski and winter tour, planned in the planner or imported from a
* GPX file. A planned one has every leg drawn between its points (the piste
* router comes later), an imported one is kept as it was recorded. TREK works
* out no ski time, and nothing is said about snow, avalanches or whether a
* run or a trail is open. What the traveller names on purpose is the
* discipline: alpine, snowboard, cross-country, ski touring, snowshoeing or
* winter hiking (both of which live here and not on a hike), and for
* GPX file. A planned one has its legs planned by the piste router where the
* piste data reaches (runs, lifts, cross-country trails, ski touring and
* winter paths from OpenStreetMap), and drawn straight where it does not; an
* imported one is kept as it was recorded. A ski time is worked out only for
* legs the router planned, and nothing is said about snow, avalanches or
* whether a run or a trail is open. What the traveller names on purpose is
* the discipline: alpine, snowboard, cross-country, ski touring, snowshoeing
* or winter hiking (both of which live here and not on a hike), and for
* cross-country the technique.
*/
export const SKI_DISCIPLINES = ['alpine', 'snowboard', 'cross_country', 'touring', 'snowshoe', 'winter_hike'] as const;
@@ -65,3 +67,104 @@ export const tourSkiClassificationRequestSchema = z
skiIssues(true, value).forEach((issue) => ctx.addIssue({ code: z.ZodIssueCode.custom, ...issue })),
);
export type TourSkiClassificationRequest = z.infer<typeof tourSkiClassificationRequestSchema>;
/** The run difficulties a skier may set as a limit, easiest first (freeride and extreme are a switch of their own). */
export const SKI_DIFFICULTIES = ['novice', 'easy', 'intermediate', 'advanced', 'expert'] as const;
export const skiDifficultySchema = z.enum(SKI_DIFFICULTIES);
export type SkiDifficulty = z.infer<typeof skiDifficultySchema>;
/** The kinds of lift a skier may leave out: surface lifts, open chairs, gondolas, cable cars and rail. */
export const SKI_LIFT_CLASSES = ['drag', 'open_chair', 'gondola', 'cable_car', 'rail'] as const;
export const skiLiftClassSchema = z.enum(SKI_LIFT_CLASSES);
export type SkiLiftClass = z.infer<typeof skiLiftClassSchema>;
/** What the router weighs most: the quickest way, the most skiing, or the easiest runs. */
export const SKI_ROUTE_PRIORITIES = ['fastest', 'most_skiing', 'easiest'] as const;
export type SkiRoutePriority = (typeof SKI_ROUTE_PRIORITIES)[number];
/** How fast the skier goes down a run, against an average skier. */
export const SKI_PACES = ['relaxed', 'normal', 'sporty'] as const;
export type SkiPace = (typeof SKI_PACES)[number];
/**
* What the piste router is asked for besides the discipline. Every field has
* its default, so an empty object is a complete answer. `use_lifts` left out
* means the discipline's own default: lifts for alpine and snowboard, none for
* the others.
*/
export const skiRouteOptionsSchema = z
.object({
max_difficulty: skiDifficultySchema.default('intermediate'),
allow_freeride: z.boolean().default(false),
avoid_lifts: z.array(skiLiftClassSchema).max(SKI_LIFT_CLASSES.length).default([]),
allow_lift_down: z.boolean().default(true),
priority: z.enum(SKI_ROUTE_PRIORITIES).default('fastest'),
pace: z.enum(SKI_PACES).default('normal'),
groomed_only: z.boolean().default(false),
lit_only: z.boolean().default(false),
touring_uphill_on_pistes: z.boolean().default(false),
use_lifts: z.boolean().optional(),
avoid_inferred_links: z.boolean().default(false),
})
.strict();
export type SkiRouteOptions = z.infer<typeof skiRouteOptionsSchema>;
export type SkiRouteOptionsInput = z.input<typeof skiRouteOptionsSchema>;
export const DEFAULT_SKI_ROUTE_OPTIONS: SkiRouteOptions = skiRouteOptionsSchema.parse({});
/** Disciplines that ride lifts unless the skier says otherwise. */
const LIFTS_BY_DEFAULT: ReadonlySet<SkiDiscipline> = new Set(['alpine', 'snowboard']);
/** Whether a discipline rides lifts with these options: the skier's word, else the discipline's default. */
export function skiUsesLifts(discipline: SkiDiscipline, options: Pick<SkiRouteOptions, 'use_lifts'>): boolean {
return options.use_lifts ?? LIFTS_BY_DEFAULT.has(discipline);
}
/** The options a downhill skier reads, on skis or a board. */
const DOWNHILL_OPTIONS: ReadonlyArray<keyof SkiRouteOptions> = [
'max_difficulty',
'allow_freeride',
'avoid_lifts',
'allow_lift_down',
'priority',
'pace',
'use_lifts',
'avoid_inferred_links',
];
/** The options each discipline reads; the rest are put back to their defaults. */
const OPTIONS_BY_DISCIPLINE: Record<SkiDiscipline, ReadonlyArray<keyof SkiRouteOptions>> = {
alpine: DOWNHILL_OPTIONS,
snowboard: DOWNHILL_OPTIONS,
cross_country: ['groomed_only', 'lit_only', 'use_lifts', 'avoid_inferred_links'],
touring: [
'max_difficulty',
'allow_freeride',
'avoid_lifts',
'allow_lift_down',
'touring_uphill_on_pistes',
'use_lifts',
'avoid_inferred_links',
],
snowshoe: ['allow_lift_down', 'use_lifts', 'avoid_inferred_links'],
winter_hike: ['allow_lift_down', 'use_lifts', 'avoid_inferred_links'],
};
/**
* The options as a discipline reads them: whatever belongs to another
* discipline goes back to its default rather than being refused, so switching
* the discipline keeps a request that is valid as it is.
*/
export function skiOptionsForDiscipline(discipline: SkiDiscipline, options: SkiRouteOptionsInput): SkiRouteOptions {
const parsed = skiRouteOptionsSchema.parse(options);
const kept = new Set(OPTIONS_BY_DISCIPLINE[discipline]);
const result: Record<string, unknown> = { ...DEFAULT_SKI_ROUTE_OPTIONS };
for (const key of Object.keys(parsed) as Array<keyof SkiRouteOptions>) {
if (kept.has(key)) result[key] = parsed[key];
}
if (!kept.has('use_lifts') || parsed.use_lifts === undefined) delete result.use_lifts;
return result as SkiRouteOptions;
}
/** The name of a piste data build a ski tour's legs were planned on, as the Places host names it. */
export const skiRouteBuildSchema = z.string().trim().min(1).max(40).regex(/^[0-9A-Za-z._-]+$/);