feat(tours): route paddle tours on the water, shaping points and auto-loaded regions

Paddle tours are planned on the water by TREK's own router: a water network
per paddle region, built from the same Geofabrik extract as the obstacles and
searched in a worker thread of the server (no sidecar). Rivers, canals and
lakes (direct or along the shore), portages round weirs, locks by policy,
whitewater by boat, gaps marked. Found portages and locks become points of
their own; an unplannable leg says why and offers a way out.

- Shaping points: a click on the line sets a point that steers the route
  without being a stop (hikes and paddle tours); recompute a single leg.
- Lake style direct/shore, suggested from the wind forecast.
- Regions load by themselves where a paddler plans (admin setting: automatic,
  on request, off; size cap and a daily count), with requests for the admins,
  a notification, and the load followed on the leg.
- Portage from the last point, portages follow footways via the walking
  router (or stay straight), "put in here" on slipways and put-ins.
- MCP plan_paddle_route with the REST route's rules.
This commit is contained in:
Maurice
2026-10-18 01:13:24 +02:00
parent b76a298bb3
commit 1bc82f97fd
321 changed files with 14675 additions and 471 deletions
+37
View File
@@ -185,3 +185,40 @@ describe('paddle plan (PAD-03, PAD-07)', () => {
expect(paddle.success).toBe(true);
});
});
describe('a routed paddle tour (PAD-11, P2A-03)', () => {
it('times each leg by the length the router found where it is known, straight where not', () => {
const [a, b, c] = [east(0), east(2), east(4)];
const straight = paddleLayoutOfPoints([a, b, c]);
expect(straight.stretches[0]?.toM).toBeCloseTo(4000, -1);
const routed = paddleLayoutOfPoints([a, b, c], [3500, null]);
expect(routed.stretches[0]?.toM).toBeCloseTo(5500, -1);
});
it('keeps the lake style and shaping points to paddle tours and to plans', () => {
const base = {
name: 'Across the lake',
route_geometry: [
[53, 13, 0],
[53, 13.01, 0],
] as [number, number, number][],
waypoints: [
{ lat: 53, lng: 13, role: 'start', sequence: 0 },
{ lat: 53, lng: 13.005, role: 'via', sequence: 1, shaping: true },
{ lat: 53, lng: 13.01, role: 'end', sequence: 2 },
],
};
const hike = tourCreateRequestSchema.safeParse({ ...base, tour_type: 'hike', paddle_lake_style: 'shore' });
expect(hike.success).toBe(false);
expect(hike.error?.issues.map((issue) => issue.path.join('.'))).toContain('paddle_lake_style');
const paddle = tourCreateRequestSchema.safeParse({
...base,
tour_type: 'kayak',
boat_type: 'kayak',
water_context: 'lake',
paddle_lake_style: 'shore',
});
expect(paddle.success).toBe(true);
expect(tourCreateRequestSchema.safeParse({ ...base, tour_type: 'hike' }).success).toBe(true);
});
});
+13 -3
View File
@@ -90,8 +90,16 @@ function bypassedBy(stretches: readonly PaddleStretch[], atM: number): boolean {
return stretches.some((s) => s.kind === 'portage' && atM >= s.fromM - 0.5 && atM <= s.toM + 0.5);
}
/** The stretches and locks of a planned tour, measured along the legs between its control points. */
export function paddleLayoutOfPoints(points: readonly PaddlePlanPoint[]): PaddleLayout {
/**
* The stretches and locks of a planned tour, measured along the legs between
* its control points. `legLengths` gives a leg's length where the planner
* knows it better than the points do (a leg routed on the water, PAD-11);
* without it a leg is as long as its line, or the straight distance.
*/
export function paddleLayoutOfPoints(
points: readonly PaddlePlanPoint[],
legLengths?: ReadonlyArray<number | null | undefined>,
): PaddleLayout {
const stretches: PaddleStretch[] = [];
const at: number[] = [];
let distance = 0;
@@ -100,7 +108,9 @@ export function paddleLayoutOfPoints(points: readonly PaddlePlanPoint[]): Paddle
const next = points[index + 1];
if (!next) return;
const leg = point.fixed_leg;
const length = leg ? lineLengthM(leg.geometry) : haversineMetres(point.lat, point.lng, next.lat, next.lng);
const length =
legLengths?.[index] ??
(leg ? lineLengthM(leg.geometry) : haversineMetres(point.lat, point.lng, next.lat, next.lng));
const kind = isPortage(leg) ? 'portage' : 'water';
const last = stretches[stretches.length - 1];
if (last && last.kind === kind) last.toM = distance + length;
+20 -1
View File
@@ -40,6 +40,10 @@ export type PaddleBoatType = z.infer<typeof paddleBoatTypeSchema>;
export const paddleWaterContextSchema = z.enum(['river', 'lake']);
export type PaddleWaterContext = z.infer<typeof paddleWaterContextSchema>;
/** PAD-11: how the router crosses lakes: straight over (the default), or along the shore, which a paddler picks for wind. */
export const paddleLakeStyleSchema = z.enum(['direct', 'shore']);
export type PaddleLakeStyle = z.infer<typeof paddleLakeStyleSchema>;
/** The highest planning speed a paddler can enter, in km/h. */
export const MAX_PADDLE_SPEED_KMH = 20;
export const paddleSpeedKmhSchema = z.number().finite().min(0.5).max(MAX_PADDLE_SPEED_KMH);
@@ -123,6 +127,8 @@ export const tourSchema = z.object({
paddle_speed_kmh: z.number().nullable().optional(),
/** PAD-07: the carrying speed of a paddle tour's portages; null while the starting value applies. */
portage_speed_kmh: z.number().nullable().optional(),
/** PAD-11: how its lakes were routed; null (or absent) means straight over. */
paddle_lake_style: paddleLakeStyleSchema.nullable().optional(),
/** PAD-03: portages and locks laid onto a recorded track; null for a planned tour and a track without any. */
track_marks: trackMarksSchema.nullable().optional(),
});
@@ -222,6 +228,11 @@ export const tourWaypointSchema = z.object({
fixed_leg: tourFixedLegSchema.nullable().optional(),
/** PAD-07: a lock at this point of a paddle tour, with the times the paddler expects there. */
lock: paddleLockSchema.nullable().optional(),
/**
* P2A-03: a shaping point, there only to steer the route: no stop, no place
* to get in or out. Absent means a stop, as every point saved before was.
*/
shaping: z.boolean().optional(),
});
export type TourWaypoint = z.infer<typeof tourWaypointSchema>;
@@ -256,6 +267,7 @@ export const tourCreateRequestSchema = z
water_context: paddleWaterContextSchema.nullable().optional(),
paddle_speed_kmh: paddleSpeedKmhSchema.nullable().optional(),
portage_speed_kmh: portageSpeedKmhSchema.nullable().optional(),
paddle_lake_style: paddleLakeStyleSchema.nullable().optional(),
duration_seconds: z.number().finite().nonnegative().nullable().optional(),
planned_duration_minutes: plannedTourDurationMinutesSchema.optional(),
break_additional_minutes: plannedTourDurationMinutesSchema.optional(),
@@ -332,6 +344,7 @@ function tourTypeIssues(value: {
water_context?: PaddleWaterContext | null;
paddle_speed_kmh?: number | null;
portage_speed_kmh?: number | null;
paddle_lake_style?: PaddleLakeStyle | null;
waypoints?: ReadonlyArray<{ fixed_leg?: { kind: string } | null; lock?: unknown }>;
}): Array<{ path: Array<string | number>; message: string }> {
const issues: Array<{ path: Array<string | number>; message: string }> = [];
@@ -345,7 +358,13 @@ function tourTypeIssues(value: {
}
if (value.max_hiking_difficulty === null)
issues.push({ path: ['max_hiking_difficulty'], message: 'a hike needs a hiking difficulty' });
for (const field of ['boat_type', 'water_context', 'paddle_speed_kmh', 'portage_speed_kmh'] as const) {
for (const field of [
'boat_type',
'water_context',
'paddle_speed_kmh',
'portage_speed_kmh',
'paddle_lake_style',
] as const) {
if (value[field] != null) issues.push({ path: [field], message: `${field} belongs to a paddle tour` });
}
// A portage and a lock are a paddler's: a hike carries no boat.