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
@@ -124,15 +124,36 @@ describe('paddle region contracts (PAD-05A)', () => {
id: 'oberpfalz',
name: 'Oberpfalz',
status: 'importing',
origin: 'admin',
source_timestamp: null,
imported_at: null,
feature_count: 0,
error: null,
progress: { phase: 'download', fraction: 0.4 },
network: null,
};
expect(paddleRegionSchema.safeParse(region).success).toBe(true);
// PAD-11: the water network built from the read, counted, and the phase that builds it.
const network = {
water_km: 412.5,
lakes: 12,
portages: 3,
inferred_portages: 7,
gaps: 2,
locks: 1,
built_at: '2026-10-17T15:30:20Z',
bytes: 1024,
};
expect(
paddleRegionSchema.safeParse({ ...region, network, progress: { phase: 'build', fraction: 0.5 } }).success,
).toBe(true);
expect(paddleRegionSchema.safeParse({ ...region, network: { ...network, lakes: -1 } }).success).toBe(false);
expect(paddleRegionSchema.safeParse({ ...region, network: undefined }).success).toBe(false);
expect(paddleRegionSchema.safeParse({ ...region, progress: { phase: 'read', fraction: null } }).success).toBe(true);
expect(paddleRegionSchema.safeParse({ ...region, progress: { phase: 'read', fraction: 2 } }).success).toBe(false);
// PAD-11C: who added it, an admin or TREK for a paddler.
expect(paddleRegionSchema.safeParse({ ...region, origin: 'auto' }).success).toBe(true);
expect(paddleRegionSchema.safeParse({ ...region, origin: 'paddler' }).success).toBe(false);
expect(paddleRegionSchema.safeParse({ ...region, status: 'done' }).success).toBe(false);
});
@@ -0,0 +1,74 @@
import {
MAX_PADDLE_LOAD_POINTS,
PADDLE_AUTO_MAX_MB,
paddleRegionListResponseSchema,
paddleRegionLoadRequestSchema,
paddleRegionLoadResponseSchema,
paddleRegionLoadStateSchema,
paddleRegionPendingSchema,
paddleRegionSettingsSchema,
} from './paddle-regions.schema';
import { describe, expect, it } from 'vitest';
const REF = { id: 'oberpfalz', name: 'Oberpfalz' };
const PLACE = { lat: 49.1, lng: 12.1 };
describe('paddle region loading (PAD-11C)', () => {
it('asks about one place up to a handful, retries only when told to', () => {
expect(paddleRegionLoadRequestSchema.parse({ points: [PLACE] })).toEqual({ points: [PLACE], retry: false });
expect(paddleRegionLoadRequestSchema.safeParse({ points: [] }).success).toBe(false);
const many = Array.from({ length: MAX_PADDLE_LOAD_POINTS + 1 }, () => PLACE);
expect(paddleRegionLoadRequestSchema.safeParse({ points: many }).success).toBe(false);
expect(paddleRegionLoadRequestSchema.safeParse({ points: [{ lat: 91, lng: 0 }] }).success).toBe(false);
});
it('answers every state a place can be in, each with what it needs', () => {
const states = [
{ state: 'ready', region: REF },
{ state: 'no_water', region: REF },
{ state: 'loading', region: REF, progress: { phase: 'download', fraction: 0.3 }, ahead: 0 },
{ state: 'loading', region: REF, progress: null, ahead: 2 },
{ state: 'requested', region: REF, reason: 'too_large' },
{ state: 'declined', region: REF },
{ state: 'failed', region: REF },
{ state: 'unavailable', region: null, reason: 'no_extract' },
{ state: 'unavailable', region: REF, reason: 'off' },
];
for (const state of states) expect(paddleRegionLoadStateSchema.safeParse(state).success).toBe(true);
expect(paddleRegionLoadResponseSchema.safeParse({ points: states }).success).toBe(true);
expect(paddleRegionLoadStateSchema.safeParse({ state: 'loading', region: REF, progress: null }).success).toBe(
false,
);
expect(paddleRegionLoadStateSchema.safeParse({ state: 'requested', region: REF, reason: 'bored' }).success).toBe(
false,
);
});
it('holds the admin setting to its modes and a size between its bounds', () => {
expect(paddleRegionSettingsSchema.safeParse({ mode: 'auto', max_mb: PADDLE_AUTO_MAX_MB.default }).success).toBe(
true,
);
expect(paddleRegionSettingsSchema.safeParse({ mode: 'always', max_mb: 500 }).success).toBe(false);
expect(paddleRegionSettingsSchema.safeParse({ mode: 'ask', max_mb: PADDLE_AUTO_MAX_MB.max + 1 }).success).toBe(
false,
);
expect(paddleRegionSettingsSchema.safeParse({ mode: 'off', max_mb: 2.5 }).success).toBe(false);
});
it('lists the regions with the requests that wait for an admin', () => {
const request = {
id: 'norway',
name: 'Norway',
reason: 'too_large',
bytes: 1_300_000_000,
requested_by: 'elmo',
requested_at: '2026-10-18T09:00:00Z',
declined: false,
};
expect(paddleRegionPendingSchema.safeParse(request).success).toBe(true);
expect(paddleRegionPendingSchema.safeParse({ ...request, requested_by: null, bytes: null }).success).toBe(true);
expect(paddleRegionListResponseSchema.safeParse({ regions: [], requests: [request] }).success).toBe(true);
expect(paddleRegionListResponseSchema.safeParse({ regions: [] }).success).toBe(false);
});
});
+132 -6
View File
@@ -1,3 +1,5 @@
import { paddleRoutePointSchema } from './paddle-route.schema';
import { z } from 'zod';
/**
@@ -10,10 +12,36 @@ import { z } from 'zod';
export const paddleRegionStatusSchema = z.enum(['queued', 'importing', 'ready', 'failed']);
export type PaddleRegionStatus = z.infer<typeof paddleRegionStatusSchema>;
/** PAD-11: what a region's water network holds, as the build counted it. */
export const paddleRegionGraphStatsSchema = z.object({
/** Kilometres of river and canal the router can follow. */
water_km: z.number().nonnegative(),
lakes: z.number().int().nonnegative(),
portages: z.number().int().nonnegative(),
/** Barriers the router found no mapped portage for, only a short straight carry. */
inferred_portages: z.number().int().nonnegative(),
/** Short gaps between waters the router bridges, marked as such. */
gaps: z.number().int().nonnegative(),
locks: z.number().int().nonnegative(),
});
export type PaddleRegionGraphStats = z.infer<typeof paddleRegionGraphStatsSchema>;
/** How far a read has come: the download, the reading of the file, the build of its network, each with the share done (null while unknown). */
export const paddleRegionProgressSchema = z.object({
phase: z.enum(['download', 'read', 'build']),
fraction: z.number().min(0).max(1).nullable(),
});
export type PaddleRegionProgress = z.infer<typeof paddleRegionProgressSchema>;
/** Who added a region: an admin by hand, or TREK itself for a paddler planning there (PAD-11C). */
export const paddleRegionOriginSchema = z.enum(['admin', 'auto']);
export type PaddleRegionOrigin = z.infer<typeof paddleRegionOriginSchema>;
export const paddleRegionSchema = z.object({
id: z.string(),
name: z.string(),
status: paddleRegionStatusSchema,
origin: paddleRegionOriginSchema,
/** The date the region's data is current to, ISO 8601; null before the first read. */
source_timestamp: z.string().nullable(),
/** When TREK last read it, ISO 8601. */
@@ -23,19 +51,67 @@ export const paddleRegionSchema = z.object({
error: z.string().nullable(),
/**
* How far the read under way has come: the download, then the reading of
* the file, each with the share done (null while the size is unknown).
* the file and the build of its network, each with the share done.
* Null unless the region is being read.
*/
progress: z
.object({
phase: z.enum(['download', 'read']),
fraction: z.number().min(0).max(1).nullable(),
progress: paddleRegionProgressSchema.nullable(),
/**
* PAD-11: the water network built from the same read, for routing: how big
* it came out and what the data left open. Null before the first build.
*/
network: paddleRegionGraphStatsSchema
.extend({
/** When the network was built, ISO 8601. */
built_at: z.string(),
/** The size of its file. */
bytes: z.number().int().nonnegative(),
})
.nullable(),
});
export type PaddleRegion = z.infer<typeof paddleRegionSchema>;
export const paddleRegionListResponseSchema = z.object({ regions: z.array(paddleRegionSchema) });
/**
* PAD-11C: how TREK gets the water data of a place a paddler plans in, when
* no region covers it yet. `auto` adds the smallest of Geofabrik's regions
* around it by itself, up to `max_mb` and a few a day; what is larger, or past
* the day's few, waits for an admin as a request. `ask` sends every such
* place to an admin as a request, `off` neither loads nor asks.
*/
export const paddleRegionAutoModeSchema = z.enum(['auto', 'ask', 'off']);
export type PaddleRegionAutoMode = z.infer<typeof paddleRegionAutoModeSchema>;
export const PADDLE_AUTO_MAX_MB = { min: 10, max: 4096, default: 500 } as const;
/** How many regions TREK adds by itself in one day (UTC); more wait for an admin. */
export const PADDLE_AUTO_REGIONS_PER_DAY = 5;
export const paddleRegionSettingsSchema = z.object({
mode: paddleRegionAutoModeSchema,
max_mb: z.number().int().min(PADDLE_AUTO_MAX_MB.min).max(PADDLE_AUTO_MAX_MB.max),
});
export type PaddleRegionSettings = z.infer<typeof paddleRegionSettingsSchema>;
/** Why a region waits for an admin: the mode asks for it, it is larger than allowed, or the day's few are used up. */
export const paddleRegionWaitReasonSchema = z.enum(['ask', 'too_large', 'limit']);
export type PaddleRegionWaitReason = z.infer<typeof paddleRegionWaitReasonSchema>;
/** A region a paddler's planning asked for and an admin has yet to allow (or declined). */
export const paddleRegionPendingSchema = z.object({
id: z.string(),
name: z.string(),
reason: paddleRegionWaitReasonSchema,
/** The extract's size as Geofabrik announced it, bytes; null when it did not say. */
bytes: z.number().int().nonnegative().nullable(),
/** The user whose planning asked for it, by name; null once that account is gone. */
requested_by: z.string().nullable(),
requested_at: z.string(),
declined: z.boolean(),
});
export type PaddleRegionPending = z.infer<typeof paddleRegionPendingSchema>;
export const paddleRegionListResponseSchema = z.object({
regions: z.array(paddleRegionSchema),
requests: z.array(paddleRegionPendingSchema),
});
export type PaddleRegionListResponse = z.infer<typeof paddleRegionListResponseSchema>;
export const paddleRegionCatalogEntrySchema = z.object({
@@ -68,3 +144,53 @@ export type PaddleRegionBoundary = z.infer<typeof paddleRegionBoundarySchema>;
export const paddleRegionRequestSchema = z.object({ id: paddleRegionIdSchema });
export type PaddleRegionRequest = z.infer<typeof paddleRegionRequestSchema>;
/** The most places one look for water data asks about; the planner asks for the ends of its unplanned legs. */
export const MAX_PADDLE_LOAD_POINTS = 10;
/**
* PAD-11C: the planner asks for the water data around places no region
* covers. Asking again is how it follows a load: an answer never starts a
* second one, and a request already with the admins is not sent twice.
*/
export const paddleRegionLoadRequestSchema = z.object({
points: z.array(paddleRoutePointSchema).min(1).max(MAX_PADDLE_LOAD_POINTS),
/** Read a region again whose last read failed. */
retry: z.boolean().default(false),
});
export type PaddleRegionLoadRequest = z.input<typeof paddleRegionLoadRequestSchema>;
const regionRefSchema = z.object({ id: z.string(), name: z.string() });
/**
* Where the water data around one place stands. `ready`: a network covers it,
* plan again. `no_water`: its region is read and holds no water near it.
* `loading`: its region is downloaded or read now, `ahead` others before it.
* `requested` and `declined`: it waits for an admin, or an admin said no.
* `failed`: the last read of its region failed. `unavailable`: nothing to be
* done here (loading is off, Geofabrik has no extract for the place, or its
* list cannot be fetched right now).
*/
export const paddleRegionLoadStateSchema = z.discriminatedUnion('state', [
z.object({ state: z.literal('ready'), region: regionRefSchema }),
z.object({ state: z.literal('no_water'), region: regionRefSchema }),
z.object({
state: z.literal('loading'),
region: regionRefSchema,
progress: paddleRegionProgressSchema.nullable(),
ahead: z.number().int().nonnegative(),
}),
z.object({ state: z.literal('requested'), region: regionRefSchema, reason: paddleRegionWaitReasonSchema }),
z.object({ state: z.literal('declined'), region: regionRefSchema }),
z.object({ state: z.literal('failed'), region: regionRefSchema }),
z.object({
state: z.literal('unavailable'),
region: regionRefSchema.nullable(),
reason: z.enum(['off', 'no_extract', 'catalog_unavailable']),
}),
]);
export type PaddleRegionLoadState = z.infer<typeof paddleRegionLoadStateSchema>;
/** One answer per place asked about, in the order asked. */
export const paddleRegionLoadResponseSchema = z.object({ points: z.array(paddleRegionLoadStateSchema) });
export type PaddleRegionLoadResponse = z.infer<typeof paddleRegionLoadResponseSchema>;
@@ -0,0 +1,51 @@
import { MAX_PADDLE_ROUTE_LEGS, paddleRouteRequestSchema, paddleRouteResponseSchema } from './paddle-route.schema';
import { describe, expect, it } from 'vitest';
const A = { lat: 49.1, lng: 12.1 };
const B = { lat: 49.0, lng: 12.1 };
describe('paddle route contract (PAD-11)', () => {
it('fills a request with a kayak, straight over the lakes and locks as the data says', () => {
expect(paddleRouteRequestSchema.parse({ legs: [{ from: A, to: B }] })).toEqual({
legs: [{ from: A, to: B }],
boat: 'kayak',
lake_style: 'direct',
lock_policy: 'auto',
});
});
it('refuses no legs, too many legs, a point off the globe and an unknown boat', () => {
const many = Array.from({ length: MAX_PADDLE_ROUTE_LEGS + 1 }, () => ({ from: A, to: B }));
expect(paddleRouteRequestSchema.safeParse({ legs: [] }).success).toBe(false);
expect(paddleRouteRequestSchema.safeParse({ legs: many }).success).toBe(false);
expect(paddleRouteRequestSchema.safeParse({ legs: [{ from: { lat: 91, lng: 0 }, to: B }] }).success).toBe(false);
expect(paddleRouteRequestSchema.safeParse({ legs: [{ from: A, to: B }], boat: 'yacht' }).success).toBe(false);
});
it('answers each leg planned with its line and stretches, or failed with a code', () => {
const answer = {
graph_rev: 'r1',
legs: [
{
status: 'ok',
geometry: [
[A.lat, A.lng],
[B.lat, B.lng],
],
segments: [{ kind: 'rapids', from: 0, to: 1, name: null, source: null, osm_ref: null, grade: 2 }],
distance_m: 11100,
snapped_m: { from: 4, to: 0 },
},
{ status: 'failed', code: 'no_region', at: A },
],
};
expect(paddleRouteResponseSchema.parse(answer)).toEqual(answer);
const badGrade = structuredClone(answer);
badGrade.legs[0]!.segments![0]!.grade = 7;
expect(paddleRouteResponseSchema.safeParse(badGrade).success).toBe(false);
const oneVertex = structuredClone(answer);
oneVertex.legs[0]!.geometry = [[A.lat, A.lng]];
expect(paddleRouteResponseSchema.safeParse(oneVertex).success).toBe(false);
});
});
+109
View File
@@ -0,0 +1,109 @@
import { paddleBoatTypeSchema, paddleLakeStyleSchema } from '../tours/tours.schema';
import { z } from 'zod';
/**
* PAD-11: routing on the water. TREK builds a water network of its own from
* the same OpenStreetMap extracts the paddle obstacles come from, and plans a
* paddle tour's legs on it: rivers and canals along their centre lines, lakes
* across the water with a margin to the shore, and short portages where a
* weir, a dam or a closed stretch bars the way. Nothing is joined by a
* straight line in secret: a stretch the data cannot connect fails with the
* place it fails at, and a short gap the router bridges says so.
*/
export const paddleRoutePointSchema = z.object({
lat: z.number().finite().min(-90).max(90),
lng: z.number().finite().min(-180).max(180),
});
export type PaddleRoutePoint = z.infer<typeof paddleRoutePointSchema>;
/** What a lock on the way means: going through it, carrying round it, or whichever is quicker. */
export const paddleLockPolicySchema = z.enum(['auto', 'use', 'portage']);
export type PaddleLockPolicy = z.infer<typeof paddleLockPolicySchema>;
/** The most legs one request plans; a tour with more asks again. */
export const MAX_PADDLE_ROUTE_LEGS = 40;
export const paddleRouteRequestSchema = z.object({
legs: z
.array(z.object({ from: paddleRoutePointSchema, to: paddleRoutePointSchema }))
.min(1)
.max(MAX_PADDLE_ROUTE_LEGS),
boat: paddleBoatTypeSchema.default('kayak'),
lake_style: paddleLakeStyleSchema.default('direct'),
lock_policy: paddleLockPolicySchema.default('auto'),
});
export type PaddleRouteRequest = z.infer<typeof paddleRouteRequestSchema>;
export type PaddleRouteInput = z.input<typeof paddleRouteRequestSchema>;
/**
* The stretches of a routed leg, by index into its geometry: paddled on a
* river or canal, across a lake, down whitewater (a stretch OSM grades), through
* a boat pass beside a weir, carried as a portage, through a lock, or a short
* gap in the data the router bridged.
*/
export const paddleRouteSegmentKindSchema = z.enum(['water', 'lake', 'rapids', 'pass', 'portage', 'lock', 'gap']);
export type PaddleRouteSegmentKind = z.infer<typeof paddleRouteSegmentKindSchema>;
/** Where a portage's line came from: a portage way mapped in OSM, a footpath, or drawn straight by the router. */
export const paddlePortageSourceSchema = z.enum(['tagged', 'footway', 'inferred']);
export type PaddlePortageSource = z.infer<typeof paddlePortageSourceSchema>;
export const paddleRouteSegmentSchema = z.object({
kind: paddleRouteSegmentKindSchema,
/** First and last index into the leg's geometry, both included. */
from: z.number().int().nonnegative(),
to: z.number().int().nonnegative(),
/** The water's, the lock's or the barrier's name, where OSM gives one. */
name: z.string().nullable(),
/** A portage's origin; null for every other kind. */
source: paddlePortageSourceSchema.nullable(),
/** The OSM object behind a portage's barrier or a lock, `way/123` style. */
osm_ref: z.string().nullable(),
/** A whitewater stretch's grade (1 to 6); null for every other kind. */
grade: z.number().int().min(1).max(6).nullable(),
});
export type PaddleRouteSegment = z.infer<typeof paddleRouteSegmentSchema>;
export const paddleRouteFailureCodeSchema = z.enum([
/** A point lies farther from any water the network knows than the router reaches. */
'not_on_water',
/** No region with a water network covers the point. */
'no_region',
/** The region's network is being built; asked again shortly it may answer. */
'building',
/** Both ends are on water, but no way over water (and short portages) joins them. */
'no_water_path',
/** The ends lie in two regions that do not share the stretch between them. */
'cross_region',
'busy',
'timeout',
]);
export type PaddleRouteFailureCode = z.infer<typeof paddleRouteFailureCodeSchema>;
export const paddleRouteLegSchema = z.discriminatedUnion('status', [
z.object({
status: z.literal('ok'),
/** [lat, lng] from the leg's start to its end, on the water. */
geometry: z.array(z.tuple([z.number(), z.number()])).min(2),
segments: z.array(paddleRouteSegmentSchema).min(1),
distance_m: z.number().nonnegative(),
/** How far the ends were moved onto the water, in metres. */
snapped_m: z.object({ from: z.number().nonnegative(), to: z.number().nonnegative() }),
}),
z.object({
status: z.literal('failed'),
code: paddleRouteFailureCodeSchema,
/** Where it failed: the point off the water, or the nearest water to it. */
at: paddleRoutePointSchema.nullable(),
}),
]);
export type PaddleRouteLeg = z.infer<typeof paddleRouteLegSchema>;
export const paddleRouteResponseSchema = z.object({
legs: z.array(paddleRouteLegSchema),
/** The data the legs were planned on, per region used; a newer one may plan them differently. */
graph_rev: z.string().nullable(),
});
export type PaddleRouteResponse = z.infer<typeof paddleRouteResponseSchema>;