From 86976aef1021b77b5f4c5ac35a8b96cdfb0de4d5 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 17 Oct 2026 00:29:10 +0000 Subject: [PATCH] feat(tours): the contracts and the pure core of the paddle obstacle checklist PAD-06 and PAD-09 in shared. A paddle tour's checklist lists the obstacles of the OpenStreetMap data along its route, each bound to a basis: the route near the obstacle as samples around every passage, and the material data of the obstacle as a hash and its position. A decision keeps that basis, so it reads as one to check again once the route or the data changed there, and a portage decision holds while the paddler's own portage carries round every passage. The check bench sums the list up for the tours list and the day plan. Nothing in it is a safety or legal clearance. The route relation learns the passages of a route by an object and the distance of a point to a route. The client takes the obstacle key from shared instead of its own copy. --- .../Tours/paddle/paddleObstacleModel.test.ts | 8 +- .../Tours/paddle/paddleObstacleModel.ts | 5 - shared/src/index.ts | 3 + .../src/paddle/paddle-route-relation.spec.ts | 66 +- shared/src/paddle/paddle-route-relation.ts | 110 +++ shared/src/tours/paddle-check-basis.spec.ts | 419 ++++++++ shared/src/tours/paddle-check-basis.ts | 250 +++++ shared/src/tours/paddle-check.schema.spec.ts | 624 ++++++++++++ shared/src/tours/paddle-check.schema.ts | 208 ++++ shared/src/tours/paddle-check.spec.ts | 892 ++++++++++++++++++ shared/src/tours/paddle-check.ts | 467 +++++++++ shared/src/tours/tours.schema.ts | 9 + 12 files changed, 3048 insertions(+), 13 deletions(-) create mode 100644 shared/src/tours/paddle-check-basis.spec.ts create mode 100644 shared/src/tours/paddle-check-basis.ts create mode 100644 shared/src/tours/paddle-check.schema.spec.ts create mode 100644 shared/src/tours/paddle-check.schema.ts create mode 100644 shared/src/tours/paddle-check.spec.ts create mode 100644 shared/src/tours/paddle-check.ts diff --git a/client/src/components/Tours/paddle/paddleObstacleModel.test.ts b/client/src/components/Tours/paddle/paddleObstacleModel.test.ts index 2fc8fb62f..22fdae8ef 100644 --- a/client/src/components/Tours/paddle/paddleObstacleModel.test.ts +++ b/client/src/components/Tours/paddle/paddleObstacleModel.test.ts @@ -12,7 +12,6 @@ import { isBlockingKind, metresOf, OBSTACLE_FACT_KEYS, - obstacleKeyOf, osmUrlOf, paddleObstacleDetail, routeStripObstacles, @@ -55,16 +54,11 @@ function detailOf( }); } -describe('osmUrlOf and obstacleKeyOf', () => { +describe('osmUrlOf', () => { it('FE-PADDLE-OBSMODEL-001: links the object in OpenStreetMap by its own reference', () => { expect(osmUrlOf('way/29256405')).toBe('https://www.openstreetmap.org/way/29256405'); expect(osmUrlOf('node/17')).toBe('https://www.openstreetmap.org/node/17'); }); - - it('FE-PADDLE-OBSMODEL-002: decides about the parts of a lock together and about everything else on its own', () => { - expect(obstacleKeyOf({ ref: 'way/2', group_key: 'lock:way/1' })).toBe('lock:way/1'); - expect(obstacleKeyOf({ ref: 'node/3', group_key: null })).toBe('node/3'); - }); }); describe('isBlockingKind', () => { diff --git a/client/src/components/Tours/paddle/paddleObstacleModel.ts b/client/src/components/Tours/paddle/paddleObstacleModel.ts index 9fd9c457e..15d02bb76 100644 --- a/client/src/components/Tours/paddle/paddleObstacleModel.ts +++ b/client/src/components/Tours/paddle/paddleObstacleModel.ts @@ -53,11 +53,6 @@ export function osmUrlOf(ref: string): string { return `https://www.openstreetmap.org/${ref}`; } -/** The one an obstacle checklist decides about: a lock's parts together, everything else on its own. */ -export function obstacleKeyOf(feature: Pick): string { - return feature.group_key ?? feature.ref; -} - export function isBlockingKind(kind: PaddleObstacleKind): boolean { return PADDLE_BLOCKING_KINDS.includes(kind); } diff --git a/shared/src/index.ts b/shared/src/index.ts index 1440536ea..99bb99e23 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -58,6 +58,9 @@ export * from './tours/tours.metrics'; export * from './tours/tour-gpx-import.schema'; export * from './tours/paddle-plan.schema'; export * from './tours/paddle-plan'; +export * from './tours/paddle-check.schema'; +export * from './tours/paddle-check-basis'; +export * from './tours/paddle-check'; export * from './wanderer/wanderer.schema'; export * from './geo/haversine'; export * from './collection/collection.schema'; diff --git a/shared/src/paddle/paddle-route-relation.spec.ts b/shared/src/paddle/paddle-route-relation.spec.ts index d7cc7bcaa..e05f23bef 100644 --- a/shared/src/paddle/paddle-route-relation.spec.ts +++ b/shared/src/paddle/paddle-route-relation.spec.ts @@ -1,5 +1,13 @@ import type { PaddleFeature, PaddleGeometry } from './paddle-obstacles.schema'; -import { measuredRouteLengthM, NEAR_M, ON_ROUTE_M, relateObstaclesToRoute } from './paddle-route-relation'; +import { + distanceToRouteM, + MAX_PASSAGES, + measuredRouteLengthM, + NEAR_M, + ON_ROUTE_M, + relateObstaclesToRoute, + routePassages, +} from './paddle-route-relation'; import { describe, expect, it } from 'vitest'; @@ -100,3 +108,59 @@ describe('measuredRouteLengthM', () => { expect(measuredRouteLengthM([])).toBe(0); }); }); + +describe('routePassages', () => { + const weir: PaddleGeometry = { type: 'LineString', coordinates: [lngLat(-40, 1200), lngLat(40, 1200)] }; + + it('finds the one place a route crosses an object, anchored where it crosses', () => { + expect(routePassages(route, weir, ON_ROUTE_M)).toEqual([{ segment: 1, t: expect.closeTo(0.2, 6), distanceM: 0 }]); + expect(routePassages([at(0, 0)], weir, ON_ROUTE_M)).toEqual([]); + }); + + it('an out-and-back tour passes twice, each passage on its own leg', () => { + const outAndBack = [at(0, 0), at(0, 2000), at(10, 2000), at(10, 0)]; + const passages = routePassages(outAndBack, weir, ON_ROUTE_M); + expect(passages.map((p) => p.segment)).toEqual([0, 2]); + expect(passages.every((p) => p.distanceM === 0)).toBe(true); + }); + + it('two runs in reach closer than the gap along the route are one passage', () => { + // A bend that steps out of reach for 30 m and comes back. + const point: PaddleGeometry = { type: 'Point', coordinates: lngLat(0, 1000) }; + const wiggle = [at(0, 980), at(0, 1000), at(30, 1010), at(0, 1020), at(0, 1040)]; + expect(routePassages(wiggle, point, 20)).toHaveLength(1); + }); + + it('keeps the closest passages when the route passes more often than it keeps, in route order', () => { + const point: PaddleGeometry = { type: 'Point', coordinates: lngLat(0, 0) }; + // Six passes at 10, 20, ... 60 m north, each 1 km apart along the route. + const zigzag = Array.from({ length: 6 }, (_, i) => [ + at((i + 1) * 10, -100), + at((i + 1) * 10, 100), + at(500, 100), + at(500, -100), + ]).flat(); + const passages = routePassages(zigzag, point, NEAR_M); + expect(passages).toHaveLength(MAX_PASSAGES); + expect(passages.map((p) => Math.round(p.distanceM))).toEqual([10, 20, 30, 40]); + expect([...passages].sort((x, y) => x.segment - y.segment)).toEqual(passages); + }); + + it('a route inside an area passes it at distance 0, and nothing out of reach counts', () => { + const basin: PaddleGeometry = { + type: 'Polygon', + coordinates: [[lngLat(-500, -500), lngLat(-500, 2500), lngLat(500, 2500), lngLat(500, -500), lngLat(-500, -500)]], + }; + expect(routePassages(route, basin, ON_ROUTE_M)).toEqual([{ segment: 0, t: 0, distanceM: 0 }]); + const far: PaddleGeometry = { type: 'Point', coordinates: lngLat(400, 1000) }; + expect(routePassages(route, far, NEAR_M)).toEqual([]); + }); +}); + +describe('distanceToRouteM', () => { + it('is the shortest distance from a point to the route, Infinity without a line', () => { + expect(distanceToRouteM(route, at(100, 500))).toBeCloseTo(100, 0); + expect(distanceToRouteM(route, at(0, 2300))).toBeCloseTo(300, 0); + expect(distanceToRouteM([at(0, 0)], at(0, 10))).toBe(Number.POSITIVE_INFINITY); + }); +}); diff --git a/shared/src/paddle/paddle-route-relation.ts b/shared/src/paddle/paddle-route-relation.ts index 0f57f3ba6..50929ec5b 100644 --- a/shared/src/paddle/paddle-route-relation.ts +++ b/shared/src/paddle/paddle-route-relation.ts @@ -212,3 +212,113 @@ export function relateObstaclesToRoute( } return related.sort((a, b) => a.alongM - b.alongM); } + +/** Two visits of the route closer than this along it are one passage (a bend that leaves the reach for a moment). */ +export const PASSAGE_GAP_M = 50; +/** The most passages one object keeps: an out-and-back tour passes twice, a winding river a few times more. */ +export const MAX_PASSAGES = 4; + +/** One time the route comes within reach of an object: the segment, the share along it, and how close it comes. */ +export interface RoutePassage { + segment: number; + t: number; + distanceM: number; +} + +/** The closest one route segment comes to an object's outline, and where along the segment that is. */ +function segmentApproach( + a: Local, + b: Local, + outline: ReadonlyArray<[Local, Local]>, + rings: readonly (readonly Local[])[], + isArea: boolean, +): { distance: number; t: number } { + let best = { distance: Number.POSITIVE_INFINITY, t: 0 }; + const consider = (distance: number, t: number) => { + if (distance < best.distance || (distance === best.distance && t < best.t)) best = { distance, t }; + }; + if (isArea && inArea(a, rings)) consider(0, 0); + if (isArea && inArea(b, rings)) consider(0, 1); + for (const [c, d] of outline) { + const hit = crosses(a, b, c, d); + if (hit !== null) { + consider(0, hit); + continue; + } + for (const p of [c, d]) { + const near = closestOnSegment(p, a, b); + consider(near.distance, near.t); + } + consider(closestOnSegment(a, c, d).distance, 0); + consider(closestOnSegment(b, c, d).distance, 1); + } + return best; +} + +/** + * Every separate time the route comes within `reachM` of an object, in route + * order: a run of segments in reach is one visit, two runs closer than + * PASSAGE_GAP_M along the route are one, and each visit is anchored where it + * comes closest (the earlier point on a tie). An out-and-back tour passes an + * obstacle twice, so a change to its way back is not missed. At most + * MAX_PASSAGES are kept, the closest ones. + */ +export function routePassages(route: readonly LatLng[], geometry: PaddleGeometry, reachM: number): RoutePassage[] { + if (route.length < 2) return []; + const project = projector(route[0]?.[0] ?? 0); + const { path, along } = measured(route); + const rings = ringsOf(geometry).map((ring) => ring.map(([lng, lat]) => project(lat, lng))); + const outline = segmentsOf(rings); + const reach = boundsOf(rings.flat(), reachM); + const isArea = geometry.type === 'Polygon'; + const visits: Array = []; + let current: (RoutePassage & { atM: number }) | null = null; + for (let i = 1; i < path.length; i++) { + const a = path[i - 1] as Local; + const b = path[i] as Local; + const approach = overlaps(boundsOf([a, b], 0), reach) + ? segmentApproach(a, b, outline, rings, isArea) + : { distance: Number.POSITIVE_INFINITY, t: 0 }; + if (approach.distance > reachM) { + current = null; + continue; + } + const startM = along[i - 1] ?? 0; + const atM = startM + approach.t * ((along[i] ?? startM) - startM); + const visit = { segment: i - 1, t: approach.t, distanceM: approach.distance, atM }; + if (current === null) { + current = visit; + visits.push(current); + } else if (approach.distance < current.distanceM) { + Object.assign(current, visit); + } + } + // Two visits whose closest points lie within PASSAGE_GAP_M along the route are one passage. + const merged: typeof visits = []; + for (const visit of visits) { + const last = merged[merged.length - 1]; + if (last && visit.atM - last.atM < PASSAGE_GAP_M) { + if (visit.distanceM < last.distanceM) Object.assign(last, visit); + } else { + merged.push({ ...visit }); + } + } + return merged + .sort((x, y) => x.distanceM - y.distanceM || x.segment - y.segment) + .slice(0, MAX_PASSAGES) + .sort((x, y) => x.segment - y.segment || x.t - y.t) + .map(({ segment, t, distanceM }) => ({ segment, t, distanceM })); +} + +/** The shortest distance from a point (`[lat, lng]`) to the route, in metres; Infinity for a route of fewer than two points. */ +export function distanceToRouteM(route: readonly LatLng[], point: readonly [number, number]): number { + if (route.length < 2) return Number.POSITIVE_INFINITY; + const project = projector(route[0]?.[0] ?? 0); + const { path } = measured(route); + const p = project(Number(point[0]), Number(point[1])); + let best = Number.POSITIVE_INFINITY; + for (let i = 1; i < path.length; i++) { + best = Math.min(best, closestOnSegment(p, path[i - 1] as Local, path[i] as Local).distance); + } + return best; +} diff --git a/shared/src/tours/paddle-check-basis.spec.ts b/shared/src/tours/paddle-check-basis.spec.ts new file mode 100644 index 000000000..5f4d62d9d --- /dev/null +++ b/shared/src/tours/paddle-check-basis.spec.ts @@ -0,0 +1,419 @@ +import { haversineMetres } from '../geo/haversine'; +import type { PaddleFeature } from '../paddle/paddle-obstacles.schema'; +import { + BASIS_TOLERANCE_M, + isCarriedAt, + materialOf, + paddleCheckLineOfPoints, + paddleCheckLineOfTrack, + pointAlong, + PORTAGE_PROOF_SLACK_M, + routeTokenOf, + sameRouteToken, + sameSourceToken, + sourceTokenOf, + type PaddleCheckLine, +} from './paddle-check-basis'; +import { routeTokenSchema, sourceTokenSchema } from './paddle-check.schema'; +import { paddleLayoutOfPoints, type PaddlePlanPoint } from './paddle-plan'; +import type { TrackMarks } from './paddle-plan.schema'; + +import { describe, expect, it } from 'vitest'; + +/** + * PAD-06: what a decision about an obstacle is bound to. The line of a planned + * tour and of a recorded track with the portages and locks of their layout, a + * point along it and whether the boat is carried there, the route token and its + * tolerance, the material data of an obstacle and the source token. + */ + +/** Metres north and east of 49.1°N 12.1°E, near enough for these distances. */ +const M_PER_DEG_LAT = 111_195; +const M_PER_DEG_LNG = 111_195 * Math.cos((49.1 * Math.PI) / 180); +function at(north: number, east = 0): [number, number] { + return [49.1 + north / M_PER_DEG_LAT, 12.1 + east / M_PER_DEG_LNG]; +} + +/** Metres between two [lat, lng] positions, measured as the module measures them. */ +function gapM(a: readonly [number, number], b: readonly [number, number]): number { + return haversineMetres(a[0], a[1], b[0], b[1]); +} + +/** A river running due north for 2 km, `east` metres east of the origin. */ +function river(east = 0): Array<[number, number]> { + return [at(0, east), at(1000, east), at(2000, east)]; +} + +/** Marks that carry the boat from `fromM` to `toM` along a track and hold no lock. */ +function carry(fromM: number, toM: number): TrackMarks { + return { portages: [{ from_m: fromM, to_m: toM, extra_minutes: null }], locks: [] }; +} + +/** The line of a recorded track; every route handed in here has two usable points or more. */ +function trackLine(route: ReadonlyArray>, marks: TrackMarks | null = null): PaddleCheckLine { + const line = paddleCheckLineOfTrack(route, marks); + if (!line) throw new Error('the route makes no line'); + return line; +} + +/** A control point `north` metres up the river. */ +function stop(north: number, extra: Omit = {}): PaddlePlanPoint { + const [lat, lng] = at(north); + return { lat, lng, ...extra }; +} + +/** The line of a planned tour; every tour handed in here makes one. */ +function plannedLine(points: readonly PaddlePlanPoint[]): PaddleCheckLine { + const line = paddleCheckLineOfPoints(points); + if (!line) throw new Error('the tour makes no line'); + return line; +} + +/** The metres along a line at its `index`-th point. */ +function metreOf(line: PaddleCheckLine, index: number): number { + const metre = line.alongM[index]; + if (metre === undefined) throw new Error(`the line has no point ${index}`); + return metre; +} + +/** + * A planned tour up the river: a drawn leg with a bend point, a carried leg + * with a lock at its take-out, then two straight legs past a second lock. + */ +const planned: PaddlePlanPoint[] = [ + stop(0, { fixed_leg: { kind: 'free', geometry: [at(0), at(500), at(1000)] } }), + stop(1000, { + fixed_leg: { kind: 'portage', geometry: [at(1000), at(1200)] }, + lock: { wait_minutes: 20, transit_minutes: 10 }, + }), + stop(1200), + stop(2000, { lock: { wait_minutes: null, transit_minutes: 5 } }), + stop(3000), +]; + +/** + * A hand-made line 200 m long on the equator whose longitude grows by 1e-5 + * degrees a metre from -0.001, so a sample's rounded longitude reads as its + * metre less 100. The boat is carried from 50 m to 100 m. + */ +const ruler: PaddleCheckLine = { + points: [ + [0, -0.001], + [0, 0.001], + ], + alongM: [0, 200], + totalM: 200, + portages: [{ fromM: 50, toM: 100, index: 1 }], + locks: [], +}; + +function feature(ref: string, extra: Partial> = {}): PaddleFeature { + const base: PaddleFeature = { + ref, + kind: 'weir', + group_key: null, + name: null, + geometry: { type: 'Point', coordinates: [12.1, 49.1] }, + tags: { waterway: 'weir', canoe: 'portage' }, + tag_hash: ref, + region_id: 'oberpfalz', + }; + return { ...base, ...extra }; +} + +/** The weir of these examples with tags added or changed. */ +function weirWith(tags: Record): PaddleFeature { + return feature('way/7', { tags: { waterway: 'weir', canoe: 'portage', ...tags } }); +} + +describe('paddleCheckLineOfPoints', () => { + it('joins the legs, drawn or straight, keeping each joint once and measuring by haversine', () => { + const line = plannedLine(planned); + expect(line.points).toEqual([at(0), at(500), at(1000), at(1200), at(2000), at(3000)]); + expect(line.alongM.map(Math.round)).toEqual([0, 500, 1000, 1200, 2000, 3000]); + expect(line.alongM[1]).toBe(gapM(at(0), at(500))); + expect(line.totalM).toBe(line.alongM[5]); + }); + + it('takes its portages and locks from the layout of the tour, in the metres of the line', () => { + const line = plannedLine(planned); + const layout = paddleLayoutOfPoints(planned); + expect(line.totalM).toBeCloseTo(layout.totalM, 6); + const carried = layout.stretches.filter((stretch) => stretch.kind === 'portage'); + expect(line.portages).toEqual(carried.map((s) => ({ fromM: s.fromM, toM: s.toM, index: s.startIndex }))); + expect(line.locks).toEqual(layout.locks.map(({ atM, index, bypassed }) => ({ atM, index, bypassed }))); + // The portage runs from the take-out to the re-entry of the line, the locks sit on their places. + expect(line.portages).toEqual([ + { fromM: expect.closeTo(metreOf(line, 2), 6), toM: expect.closeTo(metreOf(line, 3), 6), index: 1 }, + ]); + expect(line.locks).toEqual([ + { atM: expect.closeTo(metreOf(line, 2), 6), index: 1, bypassed: true }, + { atM: expect.closeTo(metreOf(line, 4), 6), index: 3, bypassed: false }, + ]); + }); + + it('makes no line of fewer than two places, or of legs that never leave the spot', () => { + const empty = stop(0, { fixed_leg: { kind: 'free', geometry: [] } }); + expect(paddleCheckLineOfPoints([])).toBeNull(); + expect(paddleCheckLineOfPoints([stop(0)])).toBeNull(); + expect(paddleCheckLineOfPoints([stop(0), stop(0)])).toBeNull(); + expect(paddleCheckLineOfPoints([empty, stop(1000)])).toBeNull(); + }); +}); + +describe('paddleCheckLineOfTrack', () => { + it('reads the points as numbers, leaves out those that are not, and measures by haversine', () => { + const [a, b, c] = [at(0), at(1000), at(2000)]; + const route = [ + [a[0], a[1], 350], + ['north', 'east', 351], + [String(b[0]), String(b[1]), '352'], + [49.11], + [Number.NaN, b[1]], + [c[0], c[1], 353], + ]; + const line = trackLine(route); + expect(line.points).toEqual([a, b, c]); + expect(line.alongM).toEqual([0, gapM(a, b), gapM(a, b) + gapM(b, c)]); + expect(line.totalM).toBe(gapM(a, b) + gapM(b, c)); + expect(line.portages).toEqual([]); + expect(line.locks).toEqual([]); + expect(paddleCheckLineOfTrack(route, undefined)).toEqual(line); + }); + + it('lays the marks onto the track, clamped to its end, sorted, and dropped where nothing is left', () => { + const line = trackLine(river(), { + portages: [ + { from_m: 1500, to_m: 2500, extra_minutes: null }, + { from_m: 2600, to_m: 2800, extra_minutes: null }, + { from_m: 200, to_m: 400, extra_minutes: 5 }, + ], + locks: [ + { at_m: 1800, wait_minutes: 10, transit_minutes: 5 }, + { at_m: 2500, wait_minutes: null, transit_minutes: null }, + { at_m: 1000, wait_minutes: null, transit_minutes: null }, + ], + }); + expect(line.portages).toEqual([ + { fromM: 200, toM: 400, index: null }, + { fromM: 1500, toM: line.totalM, index: null }, + ]); + expect(line.locks).toEqual([ + { atM: 1000, index: null, bypassed: false }, + { atM: 1800, index: null, bypassed: true }, + ]); + }); + + it('makes no line of a track with fewer than two usable points', () => { + expect(paddleCheckLineOfTrack([], null)).toBeNull(); + expect(paddleCheckLineOfTrack([at(0), ['north', 'east'], [Number.NaN, 12.1]], null)).toBeNull(); + }); +}); + +describe('pointAlong and isCarriedAt', () => { + it('finds the point a distance along the line, between its points and clamped to its ends', () => { + const line = trackLine(river()); + expect(pointAlong(line, -50)).toEqual(at(0)); + expect(pointAlong(line, 0)).toEqual(at(0)); + expect(gapM(pointAlong(line, 250), at(250))).toBeLessThan(0.01); + expect(gapM(pointAlong(line, 1500), at(1500))).toBeLessThan(0.01); + expect(gapM(pointAlong(line, line.totalM), at(2000))).toBeLessThan(0.01); + expect(pointAlong(line, line.totalM + 50)).toEqual(at(2000)); + }); + + it('answers 0, 0 for a line without points instead of failing', () => { + expect(pointAlong({ points: [], alongM: [], totalM: 0, portages: [], locks: [] }, 10)).toEqual([0, 0]); + }); + + it('carries the boat along a portage, its ends included, and a slack past the ends only when asked', () => { + const line = trackLine(river(), carry(1000, 1200)); + expect([999, 1000, 1200, 1201].map((m) => isCarriedAt(line, m))).toEqual([false, true, true, false]); + const within = PORTAGE_PROOF_SLACK_M - 1; + const beyond = PORTAGE_PROOF_SLACK_M + 1; + expect(isCarriedAt(line, 1000 - within)).toBe(false); + expect(isCarriedAt(line, 1000 - within, PORTAGE_PROOF_SLACK_M)).toBe(true); + expect(isCarriedAt(line, 1200 + within, PORTAGE_PROOF_SLACK_M)).toBe(true); + expect(isCarriedAt(line, 1000 - beyond, PORTAGE_PROOF_SLACK_M)).toBe(false); + expect(isCarriedAt(line, 1200 + beyond, PORTAGE_PROOF_SLACK_M)).toBe(false); + expect(isCarriedAt(trackLine(river()), 1100, PORTAGE_PROOF_SLACK_M)).toBe(false); + }); +}); + +describe('routeTokenOf and sameRouteToken', () => { + it('writes nine samples 25 m apart around each passage, carried ones as p and those beyond an end as _', () => { + expect(routeTokenOf(ruler, [25, 190], 'near')).toBe( + 'p1:n:_|_|_|0,-100,w|0,-75,w|0,-50,p|0,-25,p|0,0,p|0,25,w;0,-10,p|0,15,w|0,40,w|0,65,w|0,90,w|_|_|_|_', + ); + // A sample on an end of the line is kept on it. + expect(routeTokenOf(ruler, [100], 'on_route')).toBe( + 'p1:o:0,-100,w|0,-75,w|0,-50,p|0,-25,p|0,0,p|0,25,w|0,50,w|0,75,w|0,100,w', + ); + }); + + it('reads its own token back as the same route, and four passages at the longest coordinates fit the contract', () => { + const line = trackLine([ + [-45.5, -179.5], + [-45.48, -179.5], + ]); + const token = routeTokenOf(line, [300, 900, 1500, 2100], 'on_route'); + expect(routeTokenOf(line, [300, 900, 1500, 2100], 'on_route')).toBe(token); + expect(sameRouteToken(token, token)).toBe(true); + const visits = token.slice('p1:o:'.length).split(';'); + expect(visits.map((visit) => visit.split('|').length)).toEqual([9, 9, 9, 9]); + expect(token).not.toContain('_'); + expect(routeTokenSchema.safeParse(token).success).toBe(true); + }); + + it('reads a route moved sideways within the tolerance as the same, and one moved farther as another', () => { + const before = routeTokenOf(trackLine(river()), [1000], 'on_route'); + const nudged = routeTokenOf(trackLine(river(BASIS_TOLERANCE_M - 1)), [1000], 'on_route'); + const moved = routeTokenOf(trackLine(river(BASIS_TOLERANCE_M + 1)), [1000], 'on_route'); + expect(nudged).not.toBe(before); + expect(sameRouteToken(before, nudged)).toBe(true); + expect(sameRouteToken(nudged, before)).toBe(true); + expect(sameRouteToken(before, moved)).toBe(false); + }); + + it('reads a sample paddled in one and carried in the other as another route, a portage away from it as none', () => { + const paddled = routeTokenOf(trackLine(river()), [1000], 'on_route'); + const carried = routeTokenOf(trackLine(river(), carry(1050, 1300)), [1000], 'on_route'); + const away = routeTokenOf(trackLine(river(), carry(1500, 1700)), [1000], 'on_route'); + expect(sameRouteToken(paddled, carried)).toBe(false); + expect(away).toBe(paddled); + }); + + it('reads a sample beyond the end in one and on the line in the other as another route', () => { + const ending = trackLine(river()); + const longer = trackLine([...river(), at(2100)]); + const before = routeTokenOf(ending, [1950], 'on_route'); + expect(before).toContain('_'); + expect(sameRouteToken(before, routeTokenOf(ending, [1950], 'on_route'))).toBe(true); + expect(sameRouteToken(before, routeTokenOf(longer, [1950], 'on_route'))).toBe(false); + }); + + it('reads another relation, another number of passages or of samples as another route', () => { + const line = trackLine(river()); + const token = routeTokenOf(line, [1000], 'on_route'); + expect(sameRouteToken(token, routeTokenOf(line, [1000], 'near'))).toBe(false); + expect(sameRouteToken(token, routeTokenOf(line, [400, 1000], 'on_route'))).toBe(false); + expect(sameRouteToken('p1:o:_|_', 'p1:o:_|_')).toBe(true); + expect(sameRouteToken('p1:o:_|_', 'p1:o:_|_|_')).toBe(false); + }); + + it('matches nothing with a token that does not read, not even itself', () => { + const token = routeTokenOf(trackLine(river()), [1000], 'on_route'); + const unreadable = [ + '', + 'garbage', + 'p2:o:_', + 'p1:x:_', + 'p1:o:', + 'p1:o:4910000,1210000', + 'p1:o:4910000,1210000,x', + 'p1:o:north,east,w', + ]; + for (const bad of unreadable) { + expect(sameRouteToken(token, bad)).toBe(false); + expect(sameRouteToken(bad, token)).toBe(false); + expect(sameRouteToken(bad, bad)).toBe(false); + } + }); +}); + +describe('materialOf', () => { + it('writes what each member is and the tags that bear on passing, as JSON', () => { + expect(materialOf([weirWith({ name: 'Wehr Pielenhofen', height: '1.2' })])).toBe( + '[["way/7","weir","Point",[["canoe","portage"],["height","1.2"],["waterway","weir"]]]]', + ); + }); + + it('leaves out names, references, operators, websites, survey dates and where the geometry lies', () => { + const plain = [feature('way/7'), feature('node/3', { kind: 'lock_gate', tags: { lock: 'yes' } })]; + const dressed = [ + feature('node/3', { + kind: 'lock_gate', + name: 'Schleuse Regensburg', + tags: { lock: 'yes', lock_name: 'Schleuse Regensburg', ref: '3', operator: 'WSA Donau MDK' }, + }), + feature('way/7', { + name: 'Wehr Pielenhofen', + tag_hash: 'another hash', + geometry: { type: 'Point', coordinates: [12.1002, 49.1001] }, + tags: { + waterway: 'weir', + canoe: 'portage', + name: 'Wehr Pielenhofen', + website: 'https://example.org/wehr', + check_date: '2026-09-01', + 'check_date:canoe': '2026-07-01', + 'survey:date': '2026-08-01', + }, + }), + ]; + expect(materialOf(dressed)).toBe(materialOf(plain)); + // The members are read in ref order without reordering the list handed in. + expect(plain.map((member) => member.ref)).toEqual(['way/7', 'node/3']); + }); + + it('changes with what bears on passing: a paddling rule, a height, a note, the kind, the shape or a member', () => { + const crest: PaddleFeature['geometry'] = { + type: 'LineString', + coordinates: [ + [12.1, 49.1], + [12.1001, 49.1], + ], + }; + const before = materialOf([feature('way/7')]); + const changed: PaddleFeature[][] = [ + [weirWith({ canoe: 'no' })], + [weirWith({ height: '1.5' })], + [weirWith({ description: 'Bootsgasse links' })], + [weirWith({ fee: 'yes' })], + [feature('way/7', { kind: 'dam' })], + [feature('way/7', { geometry: crest })], + [feature('way/7'), feature('node/3')], + [feature('way/8')], + ]; + for (const members of changed) expect(materialOf(members)).not.toBe(before); + }); +}); + +describe('sourceTokenOf and sameSourceToken', () => { + const hash = '0123456789abcdef'; + const here = sourceTokenOf(hash, at(0)); + + it('writes the hash and the position in 1e-5 degrees, as the contract reads it', () => { + expect(here).toBe('s1:0123456789abcdef:4910000,1210000'); + expect(sourceTokenOf(hash, [49.123456, 12.000004])).toBe('s1:0123456789abcdef:4912346,1200000'); + expect(sourceTokenOf(hash, [-33.9, -70.65])).toBe('s1:0123456789abcdef:-3390000,-7065000'); + expect(sourceTokenSchema.safeParse(sourceTokenOf(hash, [-33.9, -70.65])).success).toBe(true); + }); + + it('reads the same hash within the tolerance as the same data, and farther off or under another hash as other data', () => { + expect(sameSourceToken(here, here)).toBe(true); + expect(sameSourceToken(here, sourceTokenOf(hash, at(0, BASIS_TOLERANCE_M - 1)))).toBe(true); + expect(sameSourceToken(here, sourceTokenOf(hash, at(BASIS_TOLERANCE_M - 1, 0)))).toBe(true); + expect(sameSourceToken(here, sourceTokenOf(hash, at(0, BASIS_TOLERANCE_M + 1)))).toBe(false); + expect(sameSourceToken(here, sourceTokenOf(hash, at(BASIS_TOLERANCE_M + 1, 0)))).toBe(false); + expect(sameSourceToken(here, sourceTokenOf('fedcba9876543210', at(0)))).toBe(false); + }); + + it('matches gone only with gone, and a token that does not read with nothing', () => { + expect(sameSourceToken('gone', 'gone')).toBe(true); + expect(sameSourceToken('gone', here)).toBe(false); + expect(sameSourceToken(here, 'gone')).toBe(false); + const unreadable = [ + '', + 'garbage', + 's1:0123456789ABCDEF:4910000,1210000', + 's1:0123456789abcde:4910000,1210000', + 's1:0123456789abcdef:49.1,12.1', + 's2:0123456789abcdef:4910000,1210000', + ]; + for (const bad of unreadable) { + expect(sameSourceToken(bad, here)).toBe(false); + expect(sameSourceToken(here, bad)).toBe(false); + expect(sameSourceToken(bad, bad)).toBe(false); + } + }); +}); diff --git a/shared/src/tours/paddle-check-basis.ts b/shared/src/tours/paddle-check-basis.ts new file mode 100644 index 000000000..63d05bb88 --- /dev/null +++ b/shared/src/tours/paddle-check-basis.ts @@ -0,0 +1,250 @@ +import { haversineMetres } from '../geo/haversine'; +import type { PaddleFeature } from '../paddle/paddle-obstacles.schema'; +import { paddleLayoutOfPoints, paddleLayoutOfTrack, type PaddlePlanPoint } from './paddle-plan'; +import type { TrackMarks } from './paddle-plan.schema'; + +/** + * PAD-06: what a decision about an obstacle is bound to, as plain values + * both sides can compute. A decision holds while the route near the obstacle + * and the obstacle's material data stay as they were; a change far away, a + * new name or a monthly nudge of a few metres leaves it standing, a moved + * line near the weir, a water stretch turned into a carry, or a new paddling + * rule makes it one to check again. + */ + +/** Raised when the way a basis is built changes, so stored ones read as different. */ +export const PADDLE_CHECK_VERSION = 1; +/** The route is sampled every SAMPLE_STEP_M on either side of a passage, SAMPLE_STEPS times. */ +export const SAMPLE_STEP_M = 25; +export const SAMPLE_STEPS = 4; +/** Two samples or two positions this close count as the same place. */ +export const BASIS_TOLERANCE_M = 15; +/** A portage proves a passage this far past its ends, for rounding along the line. */ +export const PORTAGE_PROOF_SLACK_M = 5; + +/** A paddle tour's line as the checklist reads it: its points, the metres along it, and its own portages and locks. */ +export interface PaddleCheckLine { + /** [lat, lng] pairs. */ + points: Array<[number, number]>; + /** Metres from the start at each point, by haversine, so they agree with the time estimate's layout. */ + alongM: number[]; + totalM: number; + portages: Array<{ fromM: number; toM: number; index: number | null }>; + locks: Array<{ atM: number; index: number | null; bypassed: boolean }>; +} + +function cumulative(points: ReadonlyArray, start = 0): number[] { + const along: number[] = []; + let total = start; + points.forEach((point, i) => { + const prev = points[i - 1]; + if (prev) total += haversineMetres(prev[0], prev[1], point[0], point[1]); + along.push(total); + }); + return along; +} + +/** + * The line of a planned tour: each leg its own drawn geometry or the straight + * line to the next point, measured leg by leg as the layout measures them, so + * a portage's metres here are the time card's metres. + */ +export function paddleCheckLineOfPoints(points: readonly PaddlePlanPoint[]): PaddleCheckLine | null { + if (points.length < 2) return null; + const line: Array<[number, number]> = []; + const alongM: number[] = []; + let distance = 0; + points.forEach((point, index) => { + const next = points[index + 1]; + if (!next) return; + const leg: Array<[number, number]> = point.fixed_leg + ? point.fixed_leg.geometry.map((p) => [Number(p[0]), Number(p[1])] as [number, number]) + : [ + [point.lat, point.lng], + [next.lat, next.lng], + ]; + const legAlong = cumulative(leg, distance); + leg.forEach((p, i) => { + const last = line[line.length - 1]; + // A leg starts where the last one ended; the joint is kept once. + if (last && last[0] === p[0] && last[1] === p[1]) return; + line.push(p); + alongM.push(legAlong[i] ?? distance); + }); + distance = legAlong[legAlong.length - 1] ?? distance; + }); + if (line.length < 2) return null; + const layout = paddleLayoutOfPoints(points); + return { + points: line, + alongM, + totalM: distance, + portages: layout.stretches + .filter((stretch) => stretch.kind === 'portage') + .map((stretch) => ({ fromM: stretch.fromM, toM: stretch.toM, index: stretch.startIndex })), + locks: layout.locks.map((lock) => ({ atM: lock.atM, index: lock.index, bypassed: lock.bypassed })), + }; +} + +/** The line of a recorded track with the marks laid onto it; points that are not numbers are left out. */ +export function paddleCheckLineOfTrack( + route: ReadonlyArray>, + marks: TrackMarks | null | undefined, +): PaddleCheckLine | null { + const points = route + .map((p) => [Number(p[0]), Number(p[1])] as [number, number]) + .filter(([lat, lng]) => Number.isFinite(lat) && Number.isFinite(lng)); + if (points.length < 2) return null; + const alongM = cumulative(points); + const totalM = alongM[alongM.length - 1] ?? 0; + const layout = paddleLayoutOfTrack(totalM, marks); + return { + points, + alongM, + totalM, + portages: layout.stretches + .filter((stretch) => stretch.kind === 'portage') + .map((stretch) => ({ fromM: stretch.fromM, toM: stretch.toM, index: null })), + locks: layout.locks.map((lock) => ({ atM: lock.atM, index: null, bypassed: lock.bypassed })), + }; +} + +/** The point `atM` metres along the line, clamped to its ends. */ +export function pointAlong(line: PaddleCheckLine, atM: number): [number, number] { + const { points, alongM } = line; + const first = points[0] ?? [0, 0]; + if (atM <= (alongM[0] ?? 0)) return [first[0], first[1]]; + for (let i = 1; i < points.length; i++) { + const end = alongM[i] ?? 0; + if (atM > end) continue; + const start = alongM[i - 1] ?? 0; + const a = points[i - 1] ?? first; + const b = points[i] ?? first; + const t = end > start ? (atM - start) / (end - start) : 0; + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + } + const last = points[points.length - 1] ?? first; + return [last[0], last[1]]; +} + +/** Whether the paddler's own plan carries the boat at `atM`. */ +export function isCarriedAt(line: PaddleCheckLine, atM: number, slackM = 0): boolean { + return line.portages.some((portage) => atM >= portage.fromM - slackM && atM <= portage.toM + slackM); +} + +const e5 = (value: number) => Math.round(value * 1e5); + +/** + * The route near an obstacle as a token: its relation, then for each passage + * nine samples, SAMPLE_STEP_M apart around it, each a rounded position and + * whether the boat is paddled (`w`) or carried (`p`) there; a sample beyond + * an end of the line is `_`. + */ +export function routeTokenOf( + line: PaddleCheckLine, + passageM: readonly number[], + relation: 'on_route' | 'near', +): string { + const visits = passageM.map((m) => { + const samples: string[] = []; + for (let k = -SAMPLE_STEPS; k <= SAMPLE_STEPS; k++) { + const at = m + k * SAMPLE_STEP_M; + if (at < 0 || at > line.totalM) { + samples.push('_'); + continue; + } + const [lat, lng] = pointAlong(line, at); + samples.push(`${e5(lat)},${e5(lng)},${isCarriedAt(line, at) ? 'p' : 'w'}`); + } + return samples.join('|'); + }); + return `p1:${relation === 'on_route' ? 'o' : 'n'}:${visits.join(';')}`; +} + +type Sample = { lat: number; lng: number; kind: string } | null; + +function parseRouteToken(token: string): { relation: string; visits: Sample[][] } | null { + const match = /^p1:([on]):(.+)$/.exec(token); + if (!match) return null; + const visits: Sample[][] = []; + for (const visit of (match[2] ?? '').split(';')) { + const samples: Sample[] = []; + for (const sample of visit.split('|')) { + if (sample === '_') { + samples.push(null); + continue; + } + const [lat, lng, kind] = sample.split(','); + const parsed = { lat: Number(lat) / 1e5, lng: Number(lng) / 1e5, kind: kind ?? '' }; + if (!Number.isFinite(parsed.lat) || !Number.isFinite(parsed.lng) || (kind !== 'w' && kind !== 'p')) return null; + samples.push(parsed); + } + visits.push(samples); + } + return { relation: match[1] ?? '', visits }; +} + +/** + * Whether two route tokens describe the same route near the obstacle: the + * same relation and number of passages, and every sample within + * BASIS_TOLERANCE_M of its twin, paddled or carried alike. A token that + * does not read matches nothing. + */ +export function sameRouteToken(a: string, b: string): boolean { + const x = parseRouteToken(a); + const y = parseRouteToken(b); + if (!x || !y || x.relation !== y.relation || x.visits.length !== y.visits.length) return false; + return x.visits.every((visit, i) => { + const twin = y.visits[i] ?? []; + if (visit.length !== twin.length) return false; + return visit.every((sample, j) => { + const other = twin[j] ?? null; + if (sample === null || other === null) return sample === other; + return ( + sample.kind === other.kind && haversineMetres(sample.lat, sample.lng, other.lat, other.lng) <= BASIS_TOLERANCE_M + ); + }); + }); +} + +/** Tags that say nothing about passing: names, references, who runs it and when someone last looked. */ +const COSMETIC = new Set(['name', 'lock_name', 'ref', 'operator', 'website', 'check_date', 'survey:date']); +const isCosmetic = (key: string) => COSMETIC.has(key) || key.startsWith('check_date:'); + +/** The material data of an obstacle's members, as text to hash: what they are and the tags that bear on passing. */ +export function materialOf(members: readonly PaddleFeature[]): string { + const rows = [...members] + .sort((a, b) => a.ref.localeCompare(b.ref)) + .map((member) => [ + member.ref, + member.kind, + member.geometry.type, + Object.keys(member.tags) + .filter((key) => !isCosmetic(key)) + .sort() + .map((key) => [key, member.tags[key]]), + ]); + return JSON.stringify(rows); +} + +/** The source basis: the hash of the material data and the obstacle's position, rounded. */ +export function sourceTokenOf(hash16: string, at: readonly [number, number]): string { + return `s1:${hash16}:${e5(at[0])},${e5(at[1])}`; +} + +function parseSourceToken(token: string): { hash: string; lat: number; lng: number } | null { + const match = /^s1:([0-9a-f]{16}):(-?\d+),(-?\d+)$/.exec(token); + if (!match) return null; + return { hash: match[1] ?? '', lat: Number(match[2]) / 1e5, lng: Number(match[3]) / 1e5 }; +} + +/** + * Whether two source tokens describe the same obstacle data: the same hash, + * positions within BASIS_TOLERANCE_M. `gone` matches only `gone`. + */ +export function sameSourceToken(a: string, b: string): boolean { + if (a === 'gone' || b === 'gone') return a === b; + const x = parseSourceToken(a); + const y = parseSourceToken(b); + return !!x && !!y && x.hash === y.hash && haversineMetres(x.lat, x.lng, y.lat, y.lng) <= BASIS_TOLERANCE_M; +} diff --git a/shared/src/tours/paddle-check.schema.spec.ts b/shared/src/tours/paddle-check.schema.spec.ts new file mode 100644 index 000000000..f2f1cc026 --- /dev/null +++ b/shared/src/tours/paddle-check.schema.spec.ts @@ -0,0 +1,624 @@ +/** + * PAD-06 and PAD-09 contracts: the checklist write (what each op type may + * carry, the note trimmed and held to 500, each obstacle once, 1 to 100 ops), + * keys, references and the basis tokens (the longest paddle-check-basis can + * write among them), the checklist and bench shapes, and the paddle_check and + * obstacle_check fields of the tours list and the tour detail, which drop a + * value this build cannot read rather than refuse it. Their cases parse each + * value with its own schema too, since the catch would hide a contract bug. + */ +import { routeTokenOf, sourceTokenOf, type PaddleCheckLine } from './paddle-check-basis'; +import { + OBSTACLE_DECISIONS, + PADDLE_CHECK_MAX_ENTRIES, + ROUTE_TOKEN_MAX, + obstacleBasisSchema, + obstacleDecisionRecordSchema, + obstacleDecisionSchema, + obstacleEntryStatusSchema, + obstacleKeySchema, + obstacleReviewOpSchema, + obstacleSnapshotSchema, + osmRefSchema, + paddleAccessSchema, + paddleCheckDataSchema, + paddleCheckSchema, + routeTokenSchema, + sourceTokenSchema, + tourObstacleCheckSchema, + tourObstacleEntrySchema, + tourObstacleReviewsRequestSchema, + tourOwnEntrySchema, + type ObstacleDecisionRecord, + type ObstacleReviewOp, + type ObstacleSnapshot, + type PaddleCheck, + type TourObstacleCheck, + type TourObstacleEntry, + type TourOwnEntry, +} from './paddle-check.schema'; +import { + tourCreateResponseSchema, + tourDetailResponseSchema, + tourListItemSchema, + tourListResponseSchema, +} from './tours.schema'; + +import { describe, expect, it } from 'vitest'; + +type Issue = { path: ReadonlyArray; message: string }; +type Parsed = { success: true } | { success: false; error: { issues: readonly Issue[] } }; +/** Any schema here, as far as these cases use one. */ +type Schema = { safeParse(value: unknown): Parsed }; + +/** Whether a schema reads a value. */ +const reads = (schema: Schema, value: unknown): boolean => schema.safeParse(value).success; + +/** The issues a schema raises for a value, each as its path and its message; none when it reads. */ +function issuesOf(schema: Schema, value: unknown): Array<[string, string]> { + const result = schema.safeParse(value); + if (result.success) return []; + return result.error.issues.map((issue): [string, string] => [issue.path.join('.'), issue.message]); +} + +/** References to `count` different nodes. */ +const refsOf = (count: number) => Array.from({ length: count }, (_, i) => `node/${i + 1}`); + +/** A sample of a route basis 25·k m east of 12.1°E on 49.1°N: carried within 25 m of the lock, paddled beyond. */ +const sample = (k: number) => `4910000,${1210000 + 34 * k},${Math.abs(k) <= 1 ? 'p' : 'w'}`; +/** One passage of a route basis: nine samples around it. */ +const VISIT = [-4, -3, -2, -1, 0, 1, 2, 3, 4].map(sample).join('|'); +/** A route basis as the server writes one: on the route, then its one passage. */ +const ROUTE = `p1:o:${VISIT}`; +/** A source basis: the hash of the material data, then the position in hundred-thousandths of a degree. */ +const SOURCE = 's1:3f9a0c1d2b4e5f60:4910000,1210000'; +const BASIS = { route: ROUTE, source: SOURCE }; + +/** What an object says about paddling when it says nothing. */ +const NO_RULE = { value: null, source: null, condition: null, forbidden: false, private: false }; + +const DECISION: ObstacleDecisionRecord = { + decision: 'portage', + note: 'Carry on the left bank', + decided_at: '2026-10-01T09:30:00.000Z', + decided_by: 3, +}; + +/** A lock group on the route, keyed by its smallest member, the paddler's portage round it planned and decided. */ +const ENTRY: TourObstacleEntry = { + key: 'lock:node/4410221', + refs: ['way/29256405', 'node/4410221'], + kind: 'lock', + name: 'Schleuse Regensburg', + at: [49.1, 12.1], + bounds: [ + [49.0997, 12.0993], + [49.1004, 12.1008], + ], + relation: 'on_route', + along_m: 1250, + distance_m: 0, + passages: 1, + access: NO_RULE, + advice: null, + portage: 'planned', + local_check: '2024-05-12', + data_as_of: '2026-09-30', + status: 'decided', + changed: [], + basis: BASIS, + decision: DECISION, +}; + +/** A weir decided earlier that the data no longer holds: no relation, no basis, its decision kept. */ +const EARLIER: TourObstacleEntry = { + ...ENTRY, + key: 'way/31337', + refs: ['way/31337'], + kind: 'weir', + name: null, + relation: null, + along_m: null, + distance_m: null, + passages: 0, + portage: 'none', + local_check: null, + status: 'gone', + basis: null, + decision: { ...DECISION, decision: 'pass', note: null, decided_by: null }, +}; + +/** The paddler's own portage round that lock. */ +const OWN: TourOwnEntry = { + kind: 'portage', + index: 2, + from_m: 1180, + to_m: 1320, + at: [49.1001, 12.0999], + bounds: [ + [49.0998, 12.0991], + [49.1003, 12.1009], + ], + covers: ['lock:node/4410221'], + bypassed: false, + advice: null, + conflict: null, +}; + +/** The bench of that checklist: the lock decided, the weir gone from the data, the one portage. */ +const BENCH: PaddleCheck = { + data: 'covered', + data_as_of: '2026-09-30', + read_at: '2026-10-02T04:00:00.000Z', + entries: 1, + open: 0, + review: 1, + decided: 1, + bans: 0, + own: 1, + revision: '9b1c6e0d4a7f2358', +}; + +const CHECK: TourObstacleCheck = { + revision: '9b1c6e0d4a7f2358', + evaluated_at: '2026-10-16T08:00:00.000Z', + entries: [ENTRY, EARLIER], + own: [OWN], +}; + +/** What a decision about the lock keeps of it. */ +const SNAPSHOT: ObstacleSnapshot = { + v: 1, + kind: 'lock', + name: 'Schleuse Regensburg', + refs: ENTRY.refs, + at: ENTRY.at, + bounds: ENTRY.bounds, + relation: 'on_route', + along_m: 1250, + region_id: 'oberpfalz', + data_as_of: '2026-09-30', +}; + +/** A paddle tour as the tours list carries it, without a bench. */ +const PADDLE_TOUR = { + place_id: 7, + tour_type: 'kayak', + distance: 12.4, + elevation_gain: null, + elevation_loss: null, + duration: null, + difficulty: null, + wanderer_ref: null, + match_confidence: null, + max_hiking_difficulty: null, + boat_type: 'kayak', + water_context: 'river', + name: 'Regen from Roding', + planned: true, + caution: false, + has_waypoints: true, + origin: 'trek', +}; +const HIKE = { ...PADDLE_TOUR, tour_type: 'hike', max_hiking_difficulty: 2, boat_type: null, water_context: null }; + +/** The detail of that paddle tour, its bench included, without a checklist. */ +const DETAIL = { + tour: { ...PADDLE_TOUR, paddle_check: BENCH }, + waypoints: [ + { lat: 49.1, lng: 12.08, role: 'start', sequence: 0 }, + { lat: 49.1, lng: 12.25, role: 'end', sequence: 1 }, + ], +}; + +/** An op on a weir with nothing set but its type; each case adds what it carries. */ +function op(type: ObstacleReviewOp['op'], extra: Record = {}): Record { + return { key: 'way/29256405', op: type, decision: null, note: null, basis: null, ...extra }; +} + +const NEEDS_BASIS = 'a decision needs its choice and its basis'; +const ONLY_DECIDE = 'only a decision carries a choice and a basis'; +const NO_NOTE = 'removing or keeping a decision carries no note'; + +describe('the closed lists', () => { + it('names the four decisions, the eight entry states and the five data states', () => { + expect(OBSTACLE_DECISIONS).toEqual(['pass', 'portage', 'reroute', 'not_affected']); + expect(obstacleDecisionSchema.options).toEqual([...OBSTACLE_DECISIONS]); + expect(reads(obstacleDecisionSchema, 'skip')).toBe(false); + expect(obstacleEntryStatusSchema.options).toEqual([ + 'open', + 'decided', + 'todo', + 'review', + 'gone', + 'kept', + 'off_route', + 'unknown', + ]); + expect(paddleCheckDataSchema.options).toEqual(['covered', 'partial', 'none', 'limited', 'unavailable']); + }); +}); + +describe('obstacle keys and references', () => { + it('a key names one object, or a lock by its smallest member; a reference names an object only', () => { + for (const key of ['node/1', 'way/29256405', 'lock:way/29256405', `node/${'9'.repeat(15)}`]) { + expect(reads(obstacleKeySchema, key)).toBe(true); + } + for (const key of [ + '', + 'relation/5', + 'lock:relation/5', + 'lock:lock:node/1', + 'lock:', + 'node/', + `node/${'9'.repeat(16)}`, + 'Node/1', + 'node/-1', + 'node/1 ', + ]) { + expect(reads(obstacleKeySchema, key)).toBe(false); + } + expect(reads(osmRefSchema, 'way/29256405')).toBe(true); + for (const ref of ['lock:way/29256405', 'relation/5', 'way/', 'way/1/2']) { + expect(reads(osmRefSchema, ref)).toBe(false); + } + }); +}); + +describe('the basis tokens', () => { + it('a route basis is its version, its relation, then nine samples per passage, passages apart by semicolons', () => { + const nearStart = `p1:n:_|_|_|_|${[0, 1, 2, 3, 4].map(sample).join('|')}`; + for (const token of [ROUTE, nearStart, `${ROUTE};${VISIT}`, 'p1:o:-3386000,-15120000,w']) { + expect(reads(routeTokenSchema, token)).toBe(true); + } + }); + + it('refuses a route basis of another version, relation or alphabet, an empty one and one past the limit', () => { + const atMax = `p1:o:${'_'.repeat(ROUTE_TOKEN_MAX - 5)}`; + expect(atMax).toHaveLength(ROUTE_TOKEN_MAX); + expect(reads(routeTokenSchema, atMax)).toBe(true); + for (const token of [ + `${atMax}_`, + '', + 'p1:o:', + 'p2:o:_', + 'p1:x:_', + 'P1:o:_', + ' p1:o:_', + 'p1:o:_\n', + 'p1:o:4910000,1210000,c', + 'p1:o:49.1,12.1,w', + 'p1:o:4910000 1210000,w', + ]) { + expect(reads(routeTokenSchema, token)).toBe(false); + } + }); + + it('a source basis is a hash of 16 hex digits and the position in hundred-thousandths of a degree, or gone', () => { + for (const token of [ + SOURCE, + 'gone', + 's1:0123456789abcdef:-3386000,-15120000', + 's1:0123456789abcdef:-9000000,-18000000', + ]) { + expect(reads(sourceTokenSchema, token)).toBe(true); + } + for (const token of [ + '', + 'Gone', + 'gone ', + 's1:0123456789ABCDEF:4910000,1210000', + 's1:0123456789abcde:4910000,1210000', + 's1:0123456789abcdef0:4910000,1210000', + 's1:0123456789abcdef', + 's1:0123456789abcdef:4910000', + 's2:0123456789abcdef:4910000,1210000', + 's1:0123456789abcdef:49.1,12.1', + 's1:0123456789abcdef:+4910000,1210000', + 's1:0123456789abcdef:12345678,1210000', + 's1:0123456789abcdef:4910000,123456789', + ]) { + expect(reads(sourceTokenSchema, token)).toBe(false); + } + }); + + it('a basis carries both tokens, each in its place', () => { + expect(obstacleBasisSchema.parse(BASIS)).toEqual(BASIS); + expect(reads(obstacleBasisSchema, { route: ROUTE, source: 'gone' })).toBe(true); + expect(reads(obstacleBasisSchema, { route: SOURCE, source: ROUTE })).toBe(false); + expect(reads(obstacleBasisSchema, { route: ROUTE })).toBe(false); + }); + + it('reads the tokens paddle-check-basis writes, up to the longest route basis there can be', () => { + // 850 m along 89.12345°S from 179.5°W: every sample has a sign and all the digits a position can have. + const line: PaddleCheckLine = { + points: [ + [-89.12345, -179.5], + [-89.12345, -179], + ], + alongM: [0, 850], + totalM: 850, + portages: [{ fromM: 280, toM: 320, index: 1 }], + locks: [], + }; + const longest = routeTokenOf(line, [150, 300, 450, 600], 'on_route'); + // `p1:o:`, then four passages of nine samples such as `-8912345,-17947059,w`, 20 characters each. + expect(longest).toHaveLength(5 + 4 * (9 * 20 + 8) + 3); + expect(longest.length).toBeLessThanOrEqual(ROUTE_TOKEN_MAX); + expect(reads(routeTokenSchema, longest)).toBe(true); + // A passage at the start: the four samples before it lie beyond the line. + const atStart = routeTokenOf(line, [0], 'near'); + expect(atStart).toMatch(/^p1:n:(_\|){4}-8912345,-17950000,w\|/); + expect(reads(routeTokenSchema, atStart)).toBe(true); + const positions: Array<[number, number]> = [ + [-89.12345, -179.12345], + [90, 180], + [-90, -180], + ]; + for (const at of positions) { + expect(reads(sourceTokenSchema, sourceTokenOf('0123456789abcdef', at))).toBe(true); + } + }); +}); + +describe('obstacleReviewOpSchema', () => { + const opIssues = (value: unknown) => issuesOf(obstacleReviewOpSchema, value); + + it('a decision carries its choice and the basis the user saw, and a note if it likes', () => { + const decide = op('decide', { decision: 'portage', basis: BASIS, note: ' Carry on the left bank ' }); + expect(obstacleReviewOpSchema.parse(decide)).toEqual({ ...decide, note: 'Carry on the left bank' }); + expect(opIssues(op('decide', { decision: 'not_affected', basis: BASIS }))).toEqual([]); + }); + + it('refuses a decision without its choice or without its basis', () => { + expect(opIssues(op('decide', { basis: BASIS }))).toEqual([['decision', NEEDS_BASIS]]); + expect(opIssues(op('decide', { decision: 'pass' }))).toEqual([['decision', NEEDS_BASIS]]); + expect(opIssues(op('decide'))).toEqual([['decision', NEEDS_BASIS]]); + }); + + it('a note op changes only the note, to a text or to none, and carries no choice or basis', () => { + expect(opIssues(op('note', { note: 'Ask the lock keeper first' }))).toEqual([]); + expect(opIssues(op('note'))).toEqual([]); + expect(opIssues(op('note', { note: 'Low water', decision: 'pass' }))).toEqual([['decision', ONLY_DECIDE]]); + expect(opIssues(op('note', { note: 'Low water', basis: BASIS }))).toEqual([['decision', ONLY_DECIDE]]); + }); + + it('clearing or keeping a decision carries nothing but the key', () => { + for (const type of ['clear', 'keep'] as const) { + expect(opIssues(op(type))).toEqual([]); + expect(opIssues(op(type, { note: 'Not needed' }))).toEqual([['note', NO_NOTE]]); + expect(opIssues(op(type, { decision: 'pass', basis: BASIS }))).toEqual([['decision', ONLY_DECIDE]]); + expect(opIssues(op(type, { decision: 'pass', basis: BASIS, note: 'x' }))).toEqual([ + ['decision', ONLY_DECIDE], + ['note', NO_NOTE], + ]); + } + }); + + it('trims the note and holds what is left to 500 characters', () => { + const padded = obstacleReviewOpSchema.parse(op('note', { note: ` ${'a'.repeat(500)}\n` })); + expect(padded.note).toBe('a'.repeat(500)); + expect(opIssues(op('note', { note: 'a'.repeat(501) }))).toEqual([['note', expect.any(String)]]); + }); + + it('refuses an unknown op, a key that names no object, an unknown choice, a broken basis and a missing field', () => { + for (const value of [ + op('clear', { op: 'delete' }), + op('clear', { key: 'relation/5' }), + op('decide', { decision: 'skip', basis: BASIS }), + op('decide', { decision: 'pass', basis: { route: ROUTE, source: 's1:nothex:1,1' } }), + op('decide', { decision: 'pass', basis: { route: ROUTE } }), + { key: 'way/29256405', op: 'clear' }, + ]) { + expect(reads(obstacleReviewOpSchema, value)).toBe(false); + } + }); +}); + +describe('tourObstacleReviewsRequestSchema', () => { + const requestIssues = (value: unknown) => issuesOf(tourObstacleReviewsRequestSchema, value); + /** One clear op on each of `count` different obstacles. */ + const ops = (count: number) => refsOf(count).map((key) => op('clear', { key })); + + it('takes 1 to 100 ops', () => { + expect(requestIssues({ ops: ops(1) })).toEqual([]); + expect(requestIssues({ ops: ops(100) })).toEqual([]); + for (const value of [{ ops: [] }, { ops: ops(101) }, {}]) { + expect(reads(tourObstacleReviewsRequestSchema, value)).toBe(false); + } + }); + + it('takes each obstacle at most once, a lock group and one of its members being two', () => { + const twice = [op('decide', { decision: 'pass', basis: BASIS }), op('note', { note: 'Low water' })]; + expect(requestIssues({ ops: twice })).toEqual([['ops', 'each obstacle at most once']]); + expect(requestIssues({ ops: [op('clear', { key: 'lock:way/29256405' }), op('clear')] })).toEqual([]); + }); + + it('names an op that breaks its own rule by its place in the list', () => { + const list = [op('clear', { key: 'way/1' }), op('decide', { key: 'way/2' })]; + expect(requestIssues({ ops: list })).toEqual([['ops.1.decision', NEEDS_BASIS]]); + }); +}); + +describe('the checklist shapes', () => { + it('access reads what the tags say about paddling there, canoe read first', () => { + const ban = { value: 'no', source: 'canoe', condition: null, forbidden: true, private: false }; + expect(paddleAccessSchema.parse(ban)).toEqual(ban); + expect(paddleAccessSchema.parse(NO_RULE)).toEqual(NO_RULE); + for (const broken of [{ source: 'motorboat' }, { forbidden: 'yes' }, { condition: 3 }]) { + expect(reads(paddleAccessSchema, { ...ban, ...broken })).toBe(false); + } + expect(reads(paddleAccessSchema, { value: 'no', source: 'canoe', condition: null, forbidden: true })).toBe(false); + }); + + it('a decision record keeps the choice, the note, when and by whom', () => { + expect(obstacleDecisionRecordSchema.parse(DECISION)).toEqual(DECISION); + expect(reads(obstacleDecisionRecordSchema, { ...DECISION, note: null, decided_by: null })).toBe(true); + for (const broken of [{ decision: 'skip' }, { decided_by: 1.5 }, { decided_at: null }]) { + expect(reads(obstacleDecisionRecordSchema, { ...DECISION, ...broken })).toBe(false); + } + }); + + it('an entry on the route, a near one and an earlier decision the route no longer meets all read', () => { + expect(tourObstacleEntrySchema.parse(ENTRY)).toEqual(ENTRY); + expect(tourObstacleEntrySchema.parse(EARLIER)).toEqual(EARLIER); + const near = { ...ENTRY, relation: 'near', distance_m: 140, status: 'open', basis: BASIS, decision: null }; + expect(reads(tourObstacleEntrySchema, near)).toBe(true); + }); + + it('holds an entry to 1 to 30 references, 0 to 4 passages, two changes at most and its closed lists', () => { + const widest = { ...ENTRY, refs: refsOf(30), passages: 4, changed: ['route', 'source'] }; + expect(reads(tourObstacleEntrySchema, widest)).toBe(true); + expect(reads(tourObstacleEntrySchema, { ...ENTRY, passages: 0 })).toBe(true); + for (const broken of [ + { key: 'relation/5' }, + { refs: [] }, + { refs: refsOf(31) }, + { refs: ['lock:node/4410221'] }, + { passages: 5 }, + { passages: -1 }, + { passages: 1.5 }, + { changed: ['route', 'source', 'route'] }, + { changed: ['name'] }, + { relation: 'far' }, + { status: 'cleared' }, + { kind: 'bridge' }, + { portage: 'maybe' }, + { advice: 'portage' }, + { at: [49.1] }, + { at: [49.1, Number.POSITIVE_INFINITY] }, + { bounds: [[49.1, 12.1]] }, + { access: { ...NO_RULE, source: 'motorboat' } }, + ]) { + expect(reads(tourObstacleEntrySchema, { ...ENTRY, ...broken })).toBe(false); + } + }); + + it('an own portage and an own lock read, each covering 30 obstacles at most', () => { + const lock: TourOwnEntry = { + ...OWN, + kind: 'lock', + index: null, + from_m: 2400, + to_m: 2400, + covers: [], + advice: 'sup_lock_portage', + }; + expect(tourOwnEntrySchema.parse(OWN)).toEqual(OWN); + expect(tourOwnEntrySchema.parse(lock)).toEqual(lock); + expect(reads(tourOwnEntrySchema, { ...OWN, covers: refsOf(30), conflict: 'private' })).toBe(true); + for (const broken of [ + { covers: refsOf(31) }, + { covers: ['relation/5'] }, + { conflict: 'closed' }, + { kind: 'weir' }, + { index: 1.5 }, + { bypassed: null }, + { advice: 'portage' }, + ]) { + expect(reads(tourOwnEntrySchema, { ...OWN, ...broken })).toBe(false); + } + }); + + it('the bench counts in whole numbers from 0 and carries a revision of 1 to 64 characters', () => { + expect(paddleCheckSchema.parse(BENCH)).toEqual(BENCH); + const unread = { ...BENCH, data: 'unavailable', data_as_of: null, read_at: null, revision: 'r'.repeat(64) }; + expect(reads(paddleCheckSchema, unread)).toBe(true); + for (const field of ['entries', 'open', 'review', 'decided', 'bans', 'own'] as const) { + expect(reads(paddleCheckSchema, { ...BENCH, [field]: 0 })).toBe(true); + expect(reads(paddleCheckSchema, { ...BENCH, [field]: -1 })).toBe(false); + expect(reads(paddleCheckSchema, { ...BENCH, [field]: 1.5 })).toBe(false); + } + for (const broken of [{ revision: '' }, { revision: 'r'.repeat(65) }, { data: 'stale' }, { read_at: 0 }]) { + expect(reads(paddleCheckSchema, { ...BENCH, ...broken })).toBe(false); + } + }); + + it('a checklist carries at most PADDLE_CHECK_MAX_ENTRIES entries and 100 own ones', () => { + expect(tourObstacleCheckSchema.parse(CHECK)).toEqual(CHECK); + expect(reads(tourObstacleCheckSchema, { ...CHECK, entries: [], own: [] })).toBe(true); + const entries = refsOf(PADDLE_CHECK_MAX_ENTRIES).map((ref) => ({ ...ENTRY, key: ref, refs: [ref] })); + const own = Array.from({ length: 100 }, () => OWN); + expect(reads(tourObstacleCheckSchema, { ...CHECK, entries, own })).toBe(true); + expect(reads(tourObstacleCheckSchema, { ...CHECK, entries: [...entries, ENTRY] })).toBe(false); + expect(reads(tourObstacleCheckSchema, { ...CHECK, own: [...own, OWN] })).toBe(false); + expect(reads(tourObstacleCheckSchema, { ...CHECK, revision: '' })).toBe(false); + expect(reads(tourObstacleCheckSchema, { ...CHECK, entries: [{ ...ENTRY, status: 'cleared' }] })).toBe(false); + }); + + it('a snapshot keeps what a decision was about, taken while the obstacle met the route', () => { + expect(obstacleSnapshotSchema.parse(SNAPSHOT)).toEqual(SNAPSHOT); + expect(reads(obstacleSnapshotSchema, { ...SNAPSHOT, name: null, region_id: null, data_as_of: null })).toBe(true); + for (const broken of [{ v: 2 }, { relation: null }, { along_m: null }, { refs: [] }, { kind: 'bridge' }]) { + expect(reads(obstacleSnapshotSchema, { ...SNAPSHOT, ...broken })).toBe(false); + } + }); +}); + +describe('tourListItemSchema: paddle_check (PAD-09)', () => { + it('a hike carries no bench and reads exactly as before', () => { + expect(tourListItemSchema.parse(HIKE)).toStrictEqual(HIKE); + }); + + it('a paddle tour carries its bench, null while it has no line, or none in a list cached before it', () => { + const withBench = { ...PADDLE_TOUR, paddle_check: BENCH }; + expect(paddleCheckSchema.parse(BENCH)).toEqual(BENCH); + expect(tourListItemSchema.parse(withBench)).toStrictEqual(withBench); + expect(tourListItemSchema.parse({ ...PADDLE_TOUR, paddle_check: null }).paddle_check).toBeNull(); + expect(tourListItemSchema.parse(PADDLE_TOUR)).not.toHaveProperty('paddle_check'); + }); + + it('drops a bench this build cannot read and keeps the tour', () => { + for (const unreadable of [ + { ...BENCH, data: 'stale' }, + { ...BENCH, open: -1 }, + { ...BENCH, revision: '' }, + { ...BENCH, bans: undefined }, + 'covered', + 42, + ]) { + expect(reads(paddleCheckSchema, unreadable)).toBe(false); + const parsed = tourListItemSchema.parse({ ...PADDLE_TOUR, paddle_check: unreadable }); + expect(parsed.paddle_check).toBeUndefined(); + expect(parsed).toEqual(PADDLE_TOUR); + } + const stale = { ...PADDLE_TOUR, paddle_check: { ...BENCH, data: 'stale' } }; + expect(tourListResponseSchema.parse({ tours: [HIKE, stale] }).tours).toEqual([HIKE, PADDLE_TOUR]); + }); + + it('still refuses a tour whose own fields do not read', () => { + expect(reads(tourListItemSchema, { ...PADDLE_TOUR, planned: 'yes', paddle_check: BENCH })).toBe(false); + }); +}); + +describe('tourCreateResponseSchema: obstacle_check (PAD-06)', () => { + it('is the detail response too', () => { + expect(tourDetailResponseSchema).toBe(tourCreateResponseSchema); + }); + + it('keeps a checklist that reads, and a detail without one reads as before', () => { + const withCheck = { ...DETAIL, obstacle_check: CHECK }; + expect(tourObstacleCheckSchema.parse(CHECK)).toEqual(CHECK); + expect(tourCreateResponseSchema.parse(withCheck)).toStrictEqual(withCheck); + expect(tourCreateResponseSchema.parse(DETAIL)).toStrictEqual(DETAIL); + }); + + it('drops a checklist this build cannot read and keeps the detail', () => { + for (const unreadable of [ + { ...CHECK, entries: [{ ...ENTRY, status: 'cleared' }] }, + { ...CHECK, own: [{ ...OWN, kind: 'ferry' }] }, + { ...CHECK, revision: '' }, + null, + 'v2', + ]) { + expect(reads(tourObstacleCheckSchema, unreadable)).toBe(false); + const parsed = tourCreateResponseSchema.parse({ ...DETAIL, obstacle_check: unreadable }); + expect(parsed.obstacle_check).toBeUndefined(); + expect(parsed).toEqual(DETAIL); + } + }); + + it('still refuses a detail whose own fields do not read', () => { + expect(reads(tourCreateResponseSchema, { ...DETAIL, waypoints: null, obstacle_check: CHECK })).toBe(false); + }); +}); diff --git a/shared/src/tours/paddle-check.schema.ts b/shared/src/tours/paddle-check.schema.ts new file mode 100644 index 000000000..b55ee5a52 --- /dev/null +++ b/shared/src/tours/paddle-check.schema.ts @@ -0,0 +1,208 @@ +import { paddleObstacleKindSchema } from '../paddle/paddle-obstacles.schema'; + +import { z } from 'zod'; + +/** + * PAD-06 and PAD-09: the obstacle checklist of a paddle tour and the check + * bench that sums it up. The server works out every entry, its status and + * the basis a decision is bound to; a client sends back the basis of the + * entry the user saw. Nothing here is a safety or legal clearance: an entry + * is "decided" when the paddler made a choice, and a ban in the data counts + * whatever was decided. + */ + +export const OBSTACLE_DECISIONS = ['pass', 'portage', 'reroute', 'not_affected'] as const; +export const obstacleDecisionSchema = z.enum(OBSTACLE_DECISIONS); +export type ObstacleDecision = z.infer; + +/** An OpenStreetMap object as the paddle data names it. */ +export const osmRefSchema = z.string().regex(/^(node|way)\/\d{1,15}$/); +/** What a decision is about: one object, or the parts of one lock together (`lock:`). */ +export const obstacleKeySchema = z.string().regex(/^(lock:)?(node|way)\/\d{1,15}$/); + +/** The longest route basis: four passages of nine samples. */ +export const ROUTE_TOKEN_MAX = 1200; +/** The route near an obstacle as it was when decided: its relation, then the samples around each passage. */ +export const routeTokenSchema = z + .string() + .max(ROUTE_TOKEN_MAX) + .regex(/^p1:[on]:[-0-9,|;wp_]+$/); +/** The obstacle's material data as it was when decided: a hash and its position, or `gone` once kept without it. */ +export const sourceTokenSchema = z.string().regex(/^(s1:[0-9a-f]{16}:-?\d{1,7},-?\d{1,8}|gone)$/); +export const obstacleBasisSchema = z.object({ route: routeTokenSchema, source: sourceTokenSchema }); +export type ObstacleBasis = z.infer; + +/** + * One change to the checklist: `decide` stores a decision against the basis + * the user saw, `note` changes only the note, `clear` removes the decision, + * `keep` keeps a decision about an object the data no longer holds. + */ +export const obstacleReviewOpSchema = z + .object({ + key: obstacleKeySchema, + op: z.enum(['decide', 'note', 'clear', 'keep']), + decision: obstacleDecisionSchema.nullable(), + note: z.string().trim().max(500).nullable(), + basis: obstacleBasisSchema.nullable(), + }) + .superRefine((op, ctx) => { + const decides = op.op === 'decide'; + if (decides && (op.decision === null || op.basis === null)) { + ctx.addIssue({ code: 'custom', path: ['decision'], message: 'a decision needs its choice and its basis' }); + } + if (!decides && (op.decision !== null || op.basis !== null)) { + ctx.addIssue({ code: 'custom', path: ['decision'], message: 'only a decision carries a choice and a basis' }); + } + if ((op.op === 'clear' || op.op === 'keep') && op.note !== null) { + ctx.addIssue({ code: 'custom', path: ['note'], message: 'removing or keeping a decision carries no note' }); + } + }); +export type ObstacleReviewOp = z.infer; + +/** PUT /api/trips/:tripId/tours/:placeId/obstacle-reviews: changes to a paddle tour's checklist, at most one per obstacle. */ +export const tourObstacleReviewsRequestSchema = z + .object({ ops: z.array(obstacleReviewOpSchema).min(1).max(100) }) + .superRefine((body, ctx) => { + const keys = body.ops.map((op) => op.key); + if (new Set(keys).size !== keys.length) { + ctx.addIssue({ code: 'custom', path: ['ops'], message: 'each obstacle at most once' }); + } + }); +export type TourObstacleReviewsRequest = z.infer; + +const latLng = z.tuple([z.number().finite(), z.number().finite()]); +/** [[south, west], [north, east]]. */ +const bounds = z.tuple([latLng, latLng]); +const count = z.number().int().nonnegative(); + +/** What an object's tags say about paddling there, canoe read first (mirrors `PaddleAccess`). */ +export const paddleAccessSchema = z.object({ + value: z.string().nullable(), + source: z.enum(['canoe', 'boat', 'access']).nullable(), + condition: z.string().nullable(), + forbidden: z.boolean(), + private: z.boolean(), +}); + +/** + * Where an entry stands. Counted on the bench: open, decided, todo (a choice + * whose plan is missing: no portage drawn yet, the route still passes), + * review (the route or the data changed since) and gone (the data no longer + * holds it). Shown apart as earlier decisions: kept, off_route, unknown. + */ +export const obstacleEntryStatusSchema = z.enum([ + 'open', + 'decided', + 'todo', + 'review', + 'gone', + 'kept', + 'off_route', + 'unknown', +]); +export type ObstacleEntryStatus = z.infer; + +export const obstacleDecisionRecordSchema = z.object({ + decision: obstacleDecisionSchema, + note: z.string().nullable(), + decided_at: z.string(), + decided_by: z.number().int().nullable(), +}); +export type ObstacleDecisionRecord = z.infer; + +/** The most entries one checklist carries; an area holding more answers as cut short. */ +export const PADDLE_CHECK_MAX_ENTRIES = 300; + +export const tourObstacleEntrySchema = z.object({ + key: obstacleKeySchema, + /** The objects it stands for, the one nearest the route first: the map selects that one. */ + refs: z.array(osmRefSchema).min(1).max(30), + kind: paddleObstacleKindSchema, + name: z.string().nullable(), + at: latLng, + bounds, + /** How it stands to the route; null for an earlier decision the route no longer meets. */ + relation: z.enum(['on_route', 'near']).nullable(), + along_m: z.number().nullable(), + distance_m: z.number().nullable(), + passages: z.number().int().min(0).max(4), + access: paddleAccessSchema, + advice: z.literal('sup_lock_portage').nullable(), + /** Whether the paddler's own portages carry round every passage of the route by it. */ + portage: z.enum(['planned', 'partial', 'none']), + /** The latest check on site the data records (OSM `check_date`), as written; null when it records none. */ + local_check: z.string().nullable(), + /** The date the data is current to. */ + data_as_of: z.string().nullable(), + status: obstacleEntryStatusSchema, + changed: z.array(z.enum(['route', 'source'])).max(2), + /** What a decision now is bound to; null where nothing can be decided. */ + basis: obstacleBasisSchema.nullable(), + decision: obstacleDecisionRecordSchema.nullable(), +}); +export type TourObstacleEntry = z.infer; + +/** The paddler's own portages and locks, listed beside the data's objects: their own plan, never a fact from a source. */ +export const tourOwnEntrySchema = z.object({ + kind: z.enum(['portage', 'lock']), + /** The control point it belongs to on a planned tour; null on a recorded track. */ + index: z.number().int().nullable(), + from_m: z.number(), + to_m: z.number(), + at: latLng, + bounds, + /** The obstacles a portage carries round. */ + covers: z.array(obstacleKeySchema).max(30), + bypassed: z.boolean(), + advice: z.literal('sup_lock_portage').nullable(), + /** A carrying way the data records as forbidden or private. */ + conflict: z.enum(['forbidden', 'private']).nullable(), +}); +export type TourOwnEntry = z.infer; + +/** How far the obstacle data reaches over the route: covered, in part, not at all, cut short, or not readable now. */ +export const paddleCheckDataSchema = z.enum(['covered', 'partial', 'none', 'limited', 'unavailable']); +export type PaddleCheckData = z.infer; + +/** PAD-09: the check bench of a paddle tour in the tours list and the day plan. */ +export const paddleCheckSchema = z.object({ + data: paddleCheckDataSchema, + /** The date the obstacle data is current to (the oldest region read). */ + data_as_of: z.string().nullable(), + /** When TREK read that data from its source, never a check on site. */ + read_at: z.string().nullable(), + entries: count, + open: count, + review: count, + decided: count, + /** Bans and private ground on the route the data records, whatever was decided. */ + bans: count, + own: count, + /** Changes whenever the checklist would read differently. */ + revision: z.string().min(1).max(64), +}); +export type PaddleCheck = z.infer; + +/** PAD-06: the checklist of a paddle tour, in its detail. */ +export const tourObstacleCheckSchema = z.object({ + revision: z.string().min(1).max(64), + evaluated_at: z.string(), + entries: z.array(tourObstacleEntrySchema).max(PADDLE_CHECK_MAX_ENTRIES), + own: z.array(tourOwnEntrySchema).max(100), +}); +export type TourObstacleCheck = z.infer; + +/** What a decision keeps of its obstacle, so it still says what it was about once the data holds it no more. */ +export const obstacleSnapshotSchema = z.object({ + v: z.literal(1), + kind: paddleObstacleKindSchema, + name: z.string().nullable(), + refs: z.array(osmRefSchema).min(1).max(30), + at: latLng, + bounds, + relation: z.enum(['on_route', 'near']), + along_m: z.number(), + region_id: z.string().nullable(), + data_as_of: z.string().nullable(), +}); +export type ObstacleSnapshot = z.infer; diff --git a/shared/src/tours/paddle-check.spec.ts b/shared/src/tours/paddle-check.spec.ts new file mode 100644 index 000000000..c3ba1a088 --- /dev/null +++ b/shared/src/tours/paddle-check.spec.ts @@ -0,0 +1,892 @@ +import { haversineMetres } from '../geo/haversine'; +import type { + PaddleAccess, + PaddleCoverageRegion, + PaddleFeature, + PaddleGeometry, +} from '../paddle/paddle-obstacles.schema'; +import { MAX_PASSAGES, NEAR_M } from '../paddle/paddle-route-relation'; +import { + isChecklistRelevant, + obstacleCandidatesOf, + obstacleEntriesOf, + obstacleSnapshotOf, + obstacleStatusOf, + ownEntriesOf, + paddleCheckSummaryOf, + paddleObstacleKeyOf, + portageWayRestriction, + type ObstacleCandidate, + type StoredObstacleReview, +} from './paddle-check'; +import { + materialOf, + routeTokenOf, + sameRouteToken, + sameSourceToken, + sourceTokenOf, + type PaddleCheckLine, +} from './paddle-check-basis'; +import { + obstacleBasisSchema, + obstacleSnapshotSchema, + paddleCheckSchema, + tourObstacleEntrySchema, + tourOwnEntrySchema, + type ObstacleDecision, + type PaddleCheck, + type TourObstacleEntry, + type TourOwnEntry, +} from './paddle-check.schema'; +import type { PaddleBoatType } from './tours.schema'; + +import { describe, expect, it } from 'vitest'; + +/* + * PAD-06 and PAD-09: the obstacle checklist of a paddle tour and its check + * bench. Which objects of the data a tour has to decide about and the basis a + * decision is bound to, the paddler's own portages and locks, every row of the + * status table, the earlier decisions no current object carries, and the + * counts on the bench. + * + * Places are metres north and east of 49.1°N 12.1°E in the flat view the route + * relations measure in (111 195 m a degree). Metres along a line are haversine + * metres (R = 6371 km), under a millimetre per kilometre shorter, so places + * along a line are compared to half a metre rather than to the digit. + */ +const M_PER_DEG_LAT = 111_195; +const M_PER_DEG_LNG = 111_195 * Math.cos((49.1 * Math.PI) / 180); +const at = (north: number, east: number): [number, number] => [ + 49.1 + north / M_PER_DEG_LAT, + 12.1 + east / M_PER_DEG_LNG, +]; +const lngLat = (north: number, east: number): [number, number] => { + const [lat, lng] = at(north, east); + return [lng, lat]; +}; + +type Tags = Record; +type Portages = PaddleCheckLine['portages']; +type Boat = PaddleBoatType | null; + +function pointAt(north: number, east: number): PaddleGeometry { + return { type: 'Point', coordinates: lngLat(north, east) }; +} + +/** A line through [north, east] vertices. */ +function wayThrough(...vertices: Array<[number, number]>): PaddleGeometry { + return { type: 'LineString', coordinates: vertices.map(([north, east]) => lngLat(north, east)) }; +} + +/** A rectangle by its south, west, north and east sides, its outline closed where it starts. */ +function area(s: number, w: number, n: number, e: number): PaddleGeometry { + return { type: 'Polygon', coordinates: [[lngLat(s, w), lngLat(s, e), lngLat(n, e), lngLat(n, w), lngLat(s, w)]] }; +} + +function feature( + ref: string, + kind: PaddleFeature['kind'], + geometry: PaddleGeometry, + extra: Partial = {}, +): PaddleFeature { + return { + ref, + kind, + group_key: null, + name: null, + geometry, + tags: {}, + tag_hash: ref, + region_id: 'oberpfalz', + ...extra, + }; +} + +/** A line through the points, measured by haversine as a check line is, with no portages or locks yet. */ +function lineOf(points: Array<[number, number]>): PaddleCheckLine { + const alongM: number[] = []; + let totalM = 0; + points.forEach(([lat, lng], i) => { + const prev = points[i - 1]; + if (prev) totalM += haversineMetres(prev[0], prev[1], lat, lng); + alongM.push(totalM); + }); + return { points, alongM, totalM, portages: [], locks: [] }; +} + +/** Metres between two [lat, lng] points; Infinity when the first is missing. */ +function gapM(a: readonly [number, number] | undefined, b: readonly [number, number]): number { + return a ? haversineMetres(a[0], a[1], b[0], b[1]) : Number.POSITIVE_INFINITY; +} + +/** The [lat, lng] a source token holds. */ +function positionOf(token: string): [number, number] { + const match = /:(-?\d+),(-?\d+)$/.exec(token); + return [Number(match?.[1]) / 1e5, Number(match?.[2]) / 1e5]; +} + +/** The one item of a list; the test fails when there is not exactly one. */ +function single(items: readonly T[]): T { + const [item, ...rest] = items; + if (item === undefined || rest.length > 0) throw new Error(`expected exactly one item, got ${items.length}`); + return item; +} + +/** FNV-1a over 64 bits as 16 hex digits: a deterministic stand-in for the server's hash. */ +function fnv1a(text: string): string { + let hash = 0xcbf29ce484222325n; + for (let i = 0; i < text.length; i++) { + hash ^= BigInt(text.charCodeAt(i)); + hash = (hash * 0x100000001b3n) & 0xffffffffffffffffn; + } + return hash.toString(16).padStart(16, '0'); +} + +const HASH_A = '0123456789abcdef'; +const HASH_B = 'fedcba9876543210'; +const DATA_AS_OF = '2026-09-01T00:00:00Z'; +const REGIONS: PaddleCoverageRegion[] = [ + { id: 'oberpfalz', name: 'Oberpfalz', source_timestamp: DATA_AS_OF, imported_at: '2026-09-03T08:00:00Z' }, +]; +const NO_RULE: PaddleAccess = { value: null, source: null, condition: null, forbidden: false, private: false }; +const BAN: PaddleAccess = { ...NO_RULE, value: 'no', source: 'canoe', forbidden: true }; +const PRIVATE_WATER: PaddleAccess = { ...NO_RULE, value: 'private', source: 'boat', private: true }; + +/** A river reach due east, 2 km long. */ +const straight = lineOf([at(0, 0), at(0, 2000)]); +/** Out 2 km east, 20 m across and back west: whatever lies across the water is passed twice. */ +const outAndBack = lineOf([at(0, 0), at(0, 2000), at(20, 2000), at(20, 0)]); +/** The paddler's carry from 900 to 1100 m. */ +const carry = { fromM: 900, toM: 1100, index: 1 }; +/** The basis of a weir at 1000 m on the straight reach. */ +const ROUTE = routeTokenOf(straight, [1000], 'on_route'); +const SOURCE = sourceTokenOf(HASH_A, at(0, 1000)); + +/** + * A lock the straight reach runs through: the upper gate across the water at + * 975 m, the chamber from 980 to 1010 m and the lower gate at 1015 m. + */ +function lockAt(upper: Tags = {}, chamber: Tags = {}, lower: Tags = {}): PaddleFeature[] { + const part = (tags: Tags) => ({ group_key: 'lock:way/100', tags }); + return [ + feature('way/300', 'lock_gate', wayThrough([-10, 975], [10, 975]), part(upper)), + feature('way/100', 'lock', area(-10, 980, 10, 1010), { ...part(chamber), name: 'Schleuse Kelheim' }), + feature('way/301', 'lock_gate', wayThrough([-10, 1015], [10, 1015]), part(lower)), + ]; +} + +function candidatesOf(line: PaddleCheckLine, features: readonly PaddleFeature[], boat: Boat = null) { + return obstacleCandidatesOf({ line, features, regions: REGIONS, boat, hash: fnv1a }); +} + +/** A candidate as obstacleCandidatesOf makes one: by default a weir on the straight reach at 1000 m. */ +function candidateOf(key: string, overrides: Partial = {}): ObstacleCandidate { + return { + key, + refs: [key.replace(/^lock:/, '')], + kind: 'weir', + name: null, + at: at(0, 1000), + bounds: [at(0, 1000), at(0, 1000)], + relation: 'on_route', + along_m: 1000, + distance_m: 0, + passages: 1, + access: NO_RULE, + advice: null, + portage: 'none', + local_check: null, + data_as_of: DATA_AS_OF, + route: ROUTE, + source: SOURCE, + region_id: 'oberpfalz', + passage_m: [1000], + ...overrides, + }; +} + +/** A stored decision about a candidate, bound to its basis as it stands. */ +function reviewOf(candidate: ObstacleCandidate, overrides: Partial = {}): StoredObstacleReview { + return { + obstacle_key: candidate.key, + decision: 'pass', + note: null, + route_fingerprint: candidate.route, + source_fingerprint: candidate.source, + snapshot: obstacleSnapshotOf(candidate), + decided_at: '2026-07-01T10:00:00.000Z', + decided_by: 7, + ...overrides, + }; +} + +/** A decision taken on a day about an object at a place, which no current candidate carries. */ +function earlierReview(key: string, place: [number, number], day: string, source = SOURCE): StoredObstacleReview { + const decidedAt = `${day}T08:00:00.000Z`; + return reviewOf(candidateOf(key, { at: place }), { decided_at: decidedAt, source_fingerprint: source }); +} + +/** A checklist entry as obstacleEntriesOf makes one, open unless told otherwise. */ +function entryOf(key: string, overrides: Partial = {}): TourObstacleEntry { + const { route, source, region_id: _region, passage_m: _passages, ...entry } = candidateOf(key); + return { ...entry, status: 'open', changed: [], basis: { route, source }, decision: null, ...overrides }; +} + +/** One of the paddler's own entries: by default a portage from 900 to 1100 m. */ +function ownOf(overrides: Partial = {}): TourOwnEntry { + return { + kind: 'portage', + index: 1, + from_m: 900, + to_m: 1100, + at: at(0, 1000), + bounds: [at(0, 900), at(0, 1100)], + covers: [], + bypassed: false, + advice: null, + conflict: null, + ...overrides, + }; +} + +describe('paddleObstacleKeyOf', () => { + it('names the parts of a lock by their group and anything else by its own reference', () => { + expect(paddleObstacleKeyOf({ ref: 'way/300', group_key: 'lock:way/100' })).toBe('lock:way/100'); + expect(paddleObstacleKeyOf({ ref: 'node/7', group_key: null })).toBe('node/7'); + }); +}); + +describe('isChecklistRelevant', () => { + const listed = (kind: PaddleFeature['kind'], tags: Tags) => + isChecklistRelevant(feature('node/1', kind, pointAt(0, 0), { tags })); + + it('lists every obstacle whatever its tags, and never a place or way that helps', () => { + const obstacles = ['weir', 'dam', 'lock', 'lock_gate', 'waterfall', 'rapids', 'hazard'] as const; + expect(obstacles.filter((kind) => !listed(kind, { canoe: 'yes' }))).toEqual([]); + const helpers = ['put_in', 'egress', 'slipway', 'portage'] as const; + expect(helpers.filter((kind) => listed(kind, { canoe: 'no' }))).toEqual([]); + }); + + // An access object asks for a decision unless it lets anyone paddle; an unknown value is no permission. + const rules: Array<[string, boolean, Tags]> = [ + ['canoe=yes', false, { canoe: 'yes' }], + ['boat=designated', false, { boat: 'designated' }], + ['canoe=permissive', false, { canoe: 'permissive' }], + ['canoe=official', false, { canoe: 'official' }], + ['canoe=yes over boat=no', false, { canoe: 'yes', boat: 'no' }], + ['no paddling rule at all', false, {}], + ['canoe=no', true, { canoe: 'no' }], + ['access=no', true, { access: 'no' }], + ['boat=private', true, { boat: 'private' }], + ['canoe=yes and a seasonal ban', true, { canoe: 'yes', 'canoe:conditional': 'no @ (Nov 01-Mar 31)' }], + ['only a seasonal ban', true, { 'boat:conditional': 'no @ (Nov 01-Mar 31)' }], + ['canoe=portage', true, { canoe: 'portage' }], + ['canoe=permit', true, { canoe: 'permit' }], + ]; + + it.each(rules)('an access object with %s is listed: %s', (_label, expected, tags) => { + expect(listed('access', tags)).toBe(expected); + }); +}); + +describe('portageWayRestriction', () => { + it('reads portage:access before access and finds nothing against an open or untagged way', () => { + expect(portageWayRestriction({ 'portage:access': 'no' })).toBe('forbidden'); + expect(portageWayRestriction({ access: 'no' })).toBe('forbidden'); + expect(portageWayRestriction({ access: 'private' })).toBe('private'); + expect(portageWayRestriction({ 'portage:access': 'private', access: 'yes' })).toBe('private'); + expect(portageWayRestriction({ 'portage:access': 'yes', access: 'no' })).toBeNull(); + expect(portageWayRestriction({ access: 'permissive' })).toBeNull(); + expect(portageWayRestriction({})).toBeNull(); + }); +}); + +describe('obstacleCandidatesOf', () => { + it('lists only what the paddler has to decide about, in route order', () => { + const features = [ + feature('node/5', 'weir', pointAt(100, 1500)), + feature('node/6', 'weir', pointAt(400, 1200)), // beyond NEAR_M + feature('node/7', 'put_in', pointAt(0, 300)), // a place that helps + feature('way/60', 'access', wayThrough([0, 0], [0, 2000]), { tags: { canoe: 'yes' } }), // open water + ...lockAt(), + ]; + expect(candidatesOf(straight, features).map((candidate) => candidate.key)).toEqual(['lock:way/100', 'node/5']); + expect(candidatesOf(straight, [])).toEqual([]); + }); + + it('takes the parts of a lock together: kind lock, the part nearest the route first, bounds round them all', () => { + const lock = single(candidatesOf(straight, lockAt())); + expect(lock).toMatchObject({ + key: 'lock:way/100', + refs: ['way/300', 'way/100', 'way/301'], + kind: 'lock', + name: 'Schleuse Kelheim', + bounds: [at(-10, 975), at(10, 1015)], + relation: 'on_route', + along_m: 975, + distance_m: 0, + passages: 1, + access: NO_RULE, + advice: null, + portage: 'none', + local_check: null, + data_as_of: DATA_AS_OF, + region_id: 'oberpfalz', + }); + // The upper gate at 975 m, the chamber from 980 m and the lower gate at 1015 m are one passage. + expect(lock.passage_m).toEqual([expect.closeTo(975, 0)]); + expect(gapM(lock.at, at(0, 975))).toBeLessThan(0.5); + }); + + it('binds the entry to the route around its passage and to the material data of all its parts', () => { + const features = lockAt(); + const lock = single(candidatesOf(straight, features)); + expect(lock.route.startsWith('p1:o:')).toBe(true); + expect(sameRouteToken(lock.route, routeTokenOf(straight, [975], 'on_route'))).toBe(true); + expect(lock.source.split(':')[1]).toBe(fnv1a(materialOf(features))); + // The mean of each part's own place: a gate's middle (here second) vertex, the chamber's mean outline point. + expect(gapM(positionOf(lock.source), at(6, 994))).toBeLessThan(1); + }); + + it('keeps the source through a new name or check on site, and changes it with a paddling rule on any part', () => { + const { source } = single(candidatesOf(straight, lockAt())); + const renamed = lockAt({}, { name: 'Schleuse Kelheim', operator: 'WSV', check_date: '2026-09-30' }); + expect(single(candidatesOf(straight, renamed)).source).toBe(source); + const banned = lockAt({}, {}, { canoe: 'no' }); + expect(sameSourceToken(single(candidatesOf(straight, banned)).source, source)).toBe(false); + }); + + it('takes the first 16 hex digits of a longer hash', () => { + const long = obstacleCandidatesOf({ + line: straight, + features: lockAt(), + regions: REGIONS, + boat: null, + hash: (text) => fnv1a(text).repeat(4), + }); + expect(single(long).source).toBe(single(candidatesOf(straight, lockAt())).source); + }); + + it('is on the route when any part is, and finds passages only within the reach of that relation', () => { + const part = { group_key: 'lock:node/1' }; + const features = [ + feature('node/1', 'lock_gate', pointAt(10, 500), part), + feature('node/2', 'lock', pointAt(80, 800), part), + ]; + // Within NEAR_M the chamber 80 m off would make a passage of its own at 800 m; within ON_ROUTE_M it does not. + expect(single(candidatesOf(straight, features))).toMatchObject({ + key: 'lock:node/1', + refs: ['node/1', 'node/2'], + kind: 'lock', + relation: 'on_route', + distance_m: 10, + along_m: 500, + passages: 1, + }); + }); + + it('is near when the route only comes within NEAR_M, its data date unknown outside the regions read', () => { + const weir = feature('node/5', 'weir', pointAt(100, 1500), { name: 'Wehr Pielenhofen', region_id: 'elsewhere' }); + const entry = single(candidatesOf(straight, [weir])); + expect(entry).toMatchObject({ + key: 'node/5', + refs: ['node/5'], + kind: 'weir', + name: 'Wehr Pielenhofen', + relation: 'near', + distance_m: 100, + along_m: 1500, + passages: 1, + data_as_of: null, + region_id: 'elsewhere', + }); + expect(entry.route.startsWith('p1:n:')).toBe(true); + }); + + it('keeps the closest passages of all the parts together, in route order', () => { + const part = { group_key: 'lock:node/11' }; + const gates = [ + feature('node/11', 'lock_gate', pointAt(5, 200), part), + feature('node/12', 'lock_gate', pointAt(4, 500), part), + feature('node/13', 'lock_gate', pointAt(1, 800), part), + feature('node/14', 'lock_gate', pointAt(2, 1100), part), + feature('node/15', 'lock_gate', pointAt(3, 1400), part), + ]; + const lock = single(candidatesOf(straight, gates)); + // Five passages 300 m apart; the one 5 m off at 200 m is the farthest and goes. + expect(lock.passages).toBe(MAX_PASSAGES); + expect(lock.passage_m).toEqual([500, 800, 1100, 1400].map((m) => expect.closeTo(m, 0))); + expect(lock).toMatchObject({ + refs: ['node/13', 'node/11', 'node/12', 'node/14', 'node/15'], + kind: 'lock_gate', + name: null, + along_m: 500, + distance_m: 1, + }); + expect(gapM(lock.at, at(0, 500))).toBeLessThan(0.5); + }); + + it('takes the strictest paddling rule among the parts: a ban, private water, a condition, then any value', () => { + const accessOf = (upper: Tags, chamber: Tags, lower: Tags) => + single(candidatesOf(straight, lockAt(upper, chamber, lower))).access; + const season = { 'canoe:conditional': 'no @ (Nov 01-Mar 31)' }; + expect(accessOf({}, { canoe: 'yes' }, {})).toEqual({ ...NO_RULE, value: 'yes', source: 'canoe' }); + expect(accessOf({ canoe: 'yes' }, season, {})).toEqual({ ...NO_RULE, condition: 'no @ (Nov 01-Mar 31)' }); + expect(accessOf(season, { boat: 'private' }, { canoe: 'yes' })).toEqual(PRIVATE_WATER); + expect(accessOf({ boat: 'private' }, {}, { access: 'no' })).toEqual({ ...BAN, source: 'access' }); + }); + + it('records the latest check on site any part notes, as written, and nothing that is no check date', () => { + const features = lockAt( + { 'check_date:canoe': '2025-07-01' }, + { check_date: '2024-05-12', 'survey:date': '2025-09' }, + { check_date: '2026-08-01T10:00:00Z', start_date: '2027-01-01' }, + ); + expect(single(candidatesOf(straight, features)).local_check).toBe('2025-09'); + }); + + it('advises a SUP to carry round a lock or lock gate the plan does not carry round, and no other boat', () => { + const adviceOf = (features: PaddleFeature[], boat: Boat, portages: Portages = []) => + single(candidatesOf({ ...straight, portages }, features, boat)).advice; + const gate = feature('node/8', 'lock_gate', pointAt(5, 1000)); + const weir = feature('node/9', 'weir', pointAt(5, 1000)); + expect(adviceOf(lockAt(), 'sup')).toBe('sup_lock_portage'); + expect(adviceOf([gate], 'sup')).toBe('sup_lock_portage'); + expect(adviceOf(lockAt(), 'sup', [carry])).toBeNull(); + expect(adviceOf([weir], 'sup')).toBeNull(); + expect(adviceOf(lockAt(), 'kayak')).toBeNull(); + expect(adviceOf(lockAt(), 'canoe')).toBeNull(); + expect(adviceOf(lockAt(), null)).toBeNull(); + }); + + describe('on an out-and-back tour', () => { + const weir = feature('way/40', 'weir', wayThrough([-30, 1000], [50, 1000])); + /** The carry round the weir on the way back, which crosses it at 3020 m. */ + const back = { fromM: 2920, toM: 3120, index: 3 }; + const on = (portages: Portages, features = [weir], boat: Boat = null) => + single(candidatesOf({ ...outAndBack, portages }, features, boat)); + + it('passes an obstacle twice, once each way, and binds the route around both passages', () => { + const entry = on([]); + expect(entry).toMatchObject({ relation: 'on_route', distance_m: 0, along_m: 1000, passages: 2 }); + expect(entry.passage_m).toEqual([expect.closeTo(1000, 0), expect.closeTo(3020, 0)]); + expect(entry.route.split(';')).toHaveLength(2); + expect(() => obstacleBasisSchema.parse({ route: entry.route, source: entry.source })).not.toThrow(); + }); + + it('counts the portage planned only when the plan carries round every passage', () => { + expect(on([]).portage).toBe('none'); + expect(on([carry]).portage).toBe('partial'); + expect(on([back]).portage).toBe('partial'); + expect(on([carry, back]).portage).toBe('planned'); + }); + + it('lets a carry prove a passage within its 5 m slack and not beyond', () => { + // The way out crosses the weir at 1000 m: a carry ending 4 m short of it proves it, one 6 m short does not. + expect(on([{ fromM: 900, toM: 996, index: 1 }, back]).portage).toBe('planned'); + expect(on([{ fromM: 900, toM: 994, index: 1 }, back]).portage).toBe('partial'); + expect(on([{ fromM: 1004, toM: 1100, index: 1 }, back]).portage).toBe('planned'); + expect(on([{ fromM: 1006, toM: 1100, index: 1 }, back]).portage).toBe('partial'); + }); + + it('changes the route token where a carry is drawn and leaves the source as it was', () => { + const open = on([]); + const planned = on([carry, back]); + expect(sameRouteToken(open.route, planned.route)).toBe(false); + expect(planned.source).toBe(open.source); + }); + + it('keeps the SUP advice at a lock gate the plan carries round only one way', () => { + const gate = feature('way/41', 'lock_gate', wayThrough([-30, 1000], [50, 1000])); + expect(on([carry], [gate], 'sup')).toMatchObject({ portage: 'partial', advice: 'sup_lock_portage' }); + expect(on([carry, back], [gate], 'sup')).toMatchObject({ portage: 'planned', advice: null }); + }); + }); +}); + +describe('ownEntriesOf', () => { + const planned: PaddleCheckLine = { + ...straight, + portages: [carry], + locks: [ + { atM: 1500, index: 3, bypassed: false }, + { atM: 400, index: 0, bypassed: true }, + ], + }; + + it('lists each portage with the obstacles it carries round, and each lock, in route order', () => { + const candidates = [ + candidateOf('way/1', { passage_m: [1000] }), + candidateOf('way/2', { passage_m: [1105] }), // 5 m past the end of the carry, within its slack + candidateOf('way/3', { passage_m: [1106] }), + candidateOf('lock:way/4', { passage_m: [300, 895] }), // one of its passages 5 m before the carry starts + ]; + const own = ownEntriesOf({ line: planned, candidates, features: [], boat: 'kayak' }); + expect(own.map((entry) => [entry.kind, entry.from_m, entry.to_m])).toEqual([ + ['lock', 400, 400], + ['portage', 900, 1100], + ['lock', 1500, 1500], + ]); + const [bypassed, portage, lock] = own; + expect(portage).toMatchObject({ + kind: 'portage', + index: 1, + covers: ['way/1', 'way/2', 'lock:way/4'], + bypassed: false, + advice: null, + conflict: null, + }); + expect(gapM(portage?.at, at(0, 1000))).toBeLessThan(0.5); + expect(gapM(portage?.bounds[0], at(0, 900))).toBeLessThan(0.5); + expect(gapM(portage?.bounds[1], at(0, 1100))).toBeLessThan(0.5); + expect(lock).toMatchObject({ kind: 'lock', index: 3, covers: [], bypassed: false, advice: null, conflict: null }); + expect(gapM(lock?.at, at(0, 1500))).toBeLessThan(0.5); + expect(lock?.bounds).toEqual([lock?.at, lock?.at]); + expect(bypassed).toMatchObject({ kind: 'lock', index: 0, bypassed: true }); + for (const entry of own) expect(() => tourOwnEntrySchema.parse(entry)).not.toThrow(); + }); + + it('advises a SUP at its own lock unless the plan goes round it', () => { + const sup = ownEntriesOf({ line: planned, candidates: [], features: [], boat: 'sup' }); + expect(sup.map((entry) => [entry.kind, entry.advice])).toEqual([ + ['lock', null], + ['portage', null], + ['lock', 'sup_lock_portage'], + ]); + const kayak = ownEntriesOf({ line: planned, candidates: [], features: [], boat: 'kayak' }); + expect(kayak.map((entry) => entry.advice)).toEqual([null, null, null]); + }); + + it('marks a portage whose carrying way the data records as forbidden or private, the stricter winning', () => { + const line: PaddleCheckLine = { + ...straight, + portages: [ + { fromM: 200, toM: 300, index: 0 }, + { fromM: 900, toM: 1100, index: 1 }, + { fromM: 1400, toM: 1600, index: 4 }, + ], + }; + const features = [ + // Along the first carry: an open way, a closed one 60 m off, and the river itself closed to boats. + feature('way/703', 'portage', wayThrough([8, 210], [8, 290])), + feature('way/704', 'portage', wayThrough([60, 210], [60, 290]), { tags: { access: 'no' } }), + feature('way/705', 'access', wayThrough([0, 150], [0, 350]), { tags: { canoe: 'private' } }), + // Along the second: a private path. + feature('way/700', 'portage', wayThrough([8, 910], [8, 1090]), { tags: { access: 'private' } }), + // Along the third: a private path, and on the other bank a way where carrying is forbidden. + feature('way/701', 'portage', wayThrough([8, 1450], [8, 1550]), { tags: { access: 'private' } }), + feature('way/702', 'portage', wayThrough([-8, 1450], [-8, 1550]), { tags: { 'portage:access': 'no' } }), + ]; + const own = ownEntriesOf({ line, candidates: [], features, boat: null }); + expect(own.map((entry) => entry.conflict)).toEqual([null, 'private', 'forbidden']); + }); + + it('follows the line round a turn for the bounds and the middle of a portage', () => { + const line: PaddleCheckLine = { ...outAndBack, portages: [{ fromM: 1900, toM: 2120, index: 2 }] }; + const portage = single(ownEntriesOf({ line, candidates: [], features: [], boat: null })); + expect(portage).toMatchObject({ kind: 'portage', index: 2, from_m: 1900, to_m: 2120 }); + expect(portage.bounds[1]).toEqual(at(20, 2000)); + expect(gapM(portage.bounds[0], at(0, 1900))).toBeLessThan(0.5); + expect(gapM(portage.at, at(10, 2000))).toBeLessThan(0.5); + }); +}); + +describe('obstacleStatusOf', () => { + type Current = Parameters[0]['current']; + const carried = routeTokenOf({ ...straight, portages: [carry] }, [1000], 'on_route'); + const nearOnly = routeTokenOf(straight, [1000], 'near'); + const nudged = sourceTokenOf(HASH_A, at(10, 1000)); + const moved = sourceTokenOf(HASH_A, at(40, 1000)); + const other = sourceTokenOf(HASH_B, at(0, 1000)); + const decided = { status: 'decided', changed: [] }; + const todo = { status: 'todo', changed: [] }; + const review = (...changed: Array<'route' | 'source'>) => ({ status: 'review', changed }); + /** A decision about the weir at 1000 m, `now` and `stored` replacing parts of the current and the stored basis. */ + const statusOf = (decision: ObstacleDecision, now: Partial = {}, stored: Partial = {}) => + obstacleStatusOf({ + current: { route: now.route ?? ROUTE, source: now.source ?? SOURCE, portage: now.portage ?? 'none' }, + review: { decision, route: stored.route ?? ROUTE, source: stored.source ?? SOURCE }, + }); + + it('is open while nothing is decided', () => { + const current = { route: ROUTE, source: SOURCE, portage: 'planned' } as const; + expect(obstacleStatusOf({ current, review: null })).toEqual({ status: 'open', changed: [] }); + }); + + it('decides a pass or not affected on an unchanged basis; a reroute stays to do while the route passes', () => { + expect(statusOf('pass')).toEqual(decided); + expect(statusOf('not_affected')).toEqual(decided); + expect(statusOf('reroute')).toEqual(todo); + }); + + it('holds a decision while the obstacle moves within the tolerance', () => { + expect(statusOf('pass', { source: nudged })).toEqual(decided); + }); + + it('decides a portage only while the plan carries round every passage', () => { + expect(statusOf('portage', { portage: 'planned' })).toEqual(decided); + expect(statusOf('portage', { portage: 'partial' })).toEqual(todo); + expect(statusOf('portage', { portage: 'none' })).toEqual(todo); + }); + + it('holds a portage over the route its own carry changed, and keeps it to do without the carry', () => { + expect(statusOf('portage', { route: carried, portage: 'planned' })).toEqual(decided); + expect(statusOf('portage', { route: nearOnly, portage: 'none' })).toEqual(todo); + }); + + it('asks for any other decision to be checked again once the route changed', () => { + expect(statusOf('pass', { route: carried })).toEqual(review('route')); + expect(statusOf('reroute', { route: carried })).toEqual(review('route')); + expect(statusOf('not_affected', { route: nearOnly })).toEqual(review('route')); + }); + + it('asks for every decision to be checked again once the data changed or the obstacle moved', () => { + expect(statusOf('pass', { source: other })).toEqual(review('source')); + expect(statusOf('not_affected', { source: moved })).toEqual(review('source')); + expect(statusOf('portage', { route: carried, source: other, portage: 'planned' })).toEqual(review('source')); + expect(statusOf('pass', { route: carried, source: other })).toEqual(review('source', 'route')); + }); + + it('never matches a stored basis kept as gone or one that does not read', () => { + expect(statusOf('pass', {}, { source: 'gone' })).toEqual(review('source')); + expect(statusOf('pass', {}, { route: 'p1:o:garbage' })).toEqual(review('route')); + }); +}); + +describe('obstacleSnapshotOf', () => { + it('keeps what a decision needs to say what it was about', () => { + const lock = single(candidatesOf(straight, lockAt())); + const snapshot = obstacleSnapshotOf(lock); + expect(snapshot).toEqual({ + v: 1, + kind: 'lock', + name: 'Schleuse Kelheim', + refs: ['way/300', 'way/100', 'way/301'], + at: lock.at, + bounds: [at(-10, 975), at(10, 1015)], + relation: 'on_route', + along_m: 975, + region_id: 'oberpfalz', + data_as_of: DATA_AS_OF, + }); + expect(obstacleSnapshotSchema.parse(snapshot)).toEqual(snapshot); + }); + + it('reads a candidate without a relation or a place along the route as near, at 0 m', () => { + const snapshot = obstacleSnapshotOf(candidateOf('way/1', { relation: null, along_m: null })); + expect(snapshot).toMatchObject({ relation: 'near', along_m: 0 }); + }); +}); + +describe('obstacleEntriesOf', () => { + const upstream = candidateOf('way/1', { + at: at(0, 500), + along_m: 500, + passage_m: [500], + route: routeTokenOf(straight, [500], 'on_route'), + source: sourceTokenOf(HASH_A, at(0, 500)), + }); + const weir = candidateOf('way/3'); + const sideArm = candidateOf('node/2', { + at: at(0, 1500), + relation: 'near', + along_m: 1500, + distance_m: 80, + passage_m: [1500], + route: routeTokenOf(straight, [1500], 'near'), + source: sourceTokenOf(HASH_B, at(80, 1500)), + }); + /** Decisions no current candidate carries, the oldest first. */ + const earlier = [ + earlierReview('way/10', at(0, 800), '2026-05-01'), // still in the data, the route no longer meets it + earlierReview('way/11', at(NEAR_M + 10, 1000), '2026-06-01'), // gone from the data, far from the route + earlierReview('way/12', at(NEAR_M - 10, 1200), '2026-08-01'), // gone from the data, near the route + earlierReview('way/13', at(0, 1800), '2026-09-01', 'gone'), // kept on purpose once it was gone + ]; + + it('lists the current candidates first with status and basis, then the earlier decisions newest first', () => { + const reviews = [ + ...earlier, + reviewOf(upstream, { note: 'links am Ufer vorbei' }), + reviewOf(weir, { route_fingerprint: routeTokenOf(straight, [1300], 'on_route') }), + ]; + const entries = obstacleEntriesOf({ + candidates: [upstream, weir, sideArm], + reviews, + dataKeys: new Set(['way/1', 'way/3', 'node/2', 'way/10']), + complete: true, + line: straight, + }); + expect(entries.map((entry) => [entry.key, entry.status])).toEqual([ + ['way/1', 'decided'], + ['way/3', 'review'], + ['node/2', 'open'], + ['way/13', 'kept'], + ['way/12', 'gone'], + ['way/11', 'off_route'], + ['way/10', 'off_route'], + ]); + const [decided, moved, open] = entries; + expect(decided).toMatchObject({ + changed: [], + basis: { route: upstream.route, source: upstream.source }, + decision: { + decision: 'pass', + note: 'links am Ufer vorbei', + decided_at: '2026-07-01T10:00:00.000Z', + decided_by: 7, + }, + }); + // An entry carries the basis it stands on now, not the one it was decided against. + expect(moved).toMatchObject({ changed: ['route'], basis: { route: weir.route, source: weir.source } }); + expect(open).toMatchObject({ + changed: [], + basis: { route: sideArm.route, source: sideArm.source }, + decision: null, + }); + for (const key of ['route', 'source', 'region_id', 'passage_m']) expect(decided).not.toHaveProperty(key); + for (const entry of entries) expect(() => tourObstacleEntrySchema.parse(entry)).not.toThrow(); + }); + + it('builds an earlier entry from its snapshot alone: no relation, basis, rule or advice of its own', () => { + const lock = candidateOf('lock:way/12', { + refs: ['way/12', 'way/14'], + kind: 'lock', + name: 'Alte Schleuse', + at: at(20, 1200), + bounds: [at(10, 1190), at(30, 1210)], + access: BAN, + advice: 'sup_lock_portage', + local_check: '2025-06', + }); + const review = reviewOf(lock, { decision: 'portage', note: 'links umtragen', decided_by: null }); + const input = { candidates: [], reviews: [review], dataKeys: new Set(), complete: true, line: straight }; + expect(single(obstacleEntriesOf(input))).toEqual({ + key: 'lock:way/12', + refs: ['way/12', 'way/14'], + kind: 'lock', + name: 'Alte Schleuse', + at: at(20, 1200), + bounds: [at(10, 1190), at(30, 1210)], + relation: null, + along_m: null, + distance_m: null, + passages: 0, + access: NO_RULE, + advice: null, + portage: 'none', + local_check: null, + data_as_of: DATA_AS_OF, + status: 'gone', + changed: [], + basis: null, + decision: { + decision: 'portage', + note: 'links umtragen', + decided_at: '2026-07-01T10:00:00.000Z', + decided_by: null, + }, + }); + }); + + it('tells neither gone nor kept while the data is incomplete, but still knows what the route left', () => { + const entries = obstacleEntriesOf({ + candidates: [], + reviews: earlier, + dataKeys: new Set(['way/10']), + complete: false, + line: straight, + }); + expect(entries.map((entry) => [entry.key, entry.status])).toEqual([ + ['way/13', 'unknown'], + ['way/12', 'unknown'], + ['way/11', 'off_route'], + ['way/10', 'off_route'], + ]); + }); + + it('keeps a portage decision to do until the plan carries round the weir; a pass decided before asks again', () => { + const crossing = feature('way/40', 'weir', wayThrough([-30, 1000], [30, 1000])); + const before = single(candidatesOf(straight, [crossing])); + const after = single(candidatesOf({ ...straight, portages: [carry] }, [crossing])); + const statusOn = (candidate: ObstacleCandidate, decision: ObstacleDecision) => { + const [entry] = obstacleEntriesOf({ + candidates: [candidate], + reviews: [reviewOf(before, { decision })], + dataKeys: new Set([candidate.key]), + complete: true, + line: straight, + }); + return [entry?.status, entry?.changed]; + }; + expect(statusOn(before, 'portage')).toEqual(['todo', []]); + expect(statusOn(after, 'portage')).toEqual(['decided', []]); + expect(statusOn(after, 'pass')).toEqual(['review', ['route']]); + }); +}); + +describe('paddleCheckSummaryOf', () => { + const bench: Pick = { + data: 'covered', + data_as_of: DATA_AS_OF, + read_at: '2026-09-03T08:00:00Z', + }; + const earlier: Partial = { + relation: null, + along_m: null, + distance_m: null, + passages: 0, + basis: null, + }; + + it('counts open with to do, review with gone, the decided, and every ban on the route whatever was decided', () => { + const entries = [ + entryOf('way/1', { status: 'decided', access: BAN }), + entryOf('way/2', { status: 'open', access: PRIVATE_WATER }), + entryOf('way/3', { status: 'todo', relation: 'near', access: BAN }), + entryOf('way/4', { status: 'review' }), + entryOf('way/5', { ...earlier, status: 'gone' }), + entryOf('way/6', { ...earlier, status: 'kept' }), + entryOf('way/7', { ...earlier, status: 'off_route', access: BAN }), + entryOf('way/8', { ...earlier, status: 'unknown' }), + ]; + const own = [ + ownOf({ conflict: 'private' }), + ownOf({ kind: 'lock', index: 2 }), + ownOf({ index: 4, conflict: 'forbidden' }), + ]; + const summary = paddleCheckSummaryOf(entries, own, bench); + // Bans: the two on the route, decided or not, and the two conflicting carrying ways; a near ban and an + // earlier decision off the route do not count, and kept, off_route and unknown count nowhere. + expect(summary).toEqual({ ...bench, entries: 4, open: 2, review: 2, decided: 1, bans: 4, own: 3 }); + expect(paddleCheckSchema.parse({ ...summary, revision: 'r1' })).toEqual({ ...summary, revision: 'r1' }); + }); + + it('counts nothing on an empty checklist and passes the state of the data through', () => { + const none = { data: 'none', data_as_of: null, read_at: null } as const; + expect(paddleCheckSummaryOf([], [], none)).toEqual({ + ...none, + entries: 0, + open: 0, + review: 0, + decided: 0, + bans: 0, + own: 0, + }); + }); + + it('keeps a ban in the data on the bench after the paddler decided to pass it', () => { + const closed = feature('way/50', 'access', wayThrough([5, 1200], [8, 1800]), { tags: { canoe: 'no' } }); + const candidate = single(candidatesOf(straight, [closed], 'kayak')); + expect(candidate).toMatchObject({ relation: 'on_route', distance_m: 5, along_m: 1200, access: BAN }); + const entries = obstacleEntriesOf({ + candidates: [candidate], + reviews: [reviewOf(candidate, { decision: 'pass' })], + dataKeys: new Set(['way/50']), + complete: true, + line: straight, + }); + const own = ownEntriesOf({ line: straight, candidates: [candidate], features: [closed], boat: 'kayak' }); + expect(paddleCheckSummaryOf(entries, own, bench)).toMatchObject({ decided: 1, open: 0, review: 0, bans: 1 }); + }); +}); diff --git a/shared/src/tours/paddle-check.ts b/shared/src/tours/paddle-check.ts new file mode 100644 index 000000000..470029fc1 --- /dev/null +++ b/shared/src/tours/paddle-check.ts @@ -0,0 +1,467 @@ +import { + PADDLE_BLOCKING_KINDS, + paddleAccessOf, + type PaddleAccess, + type PaddleCoverageRegion, + type PaddleFeature, + type PaddleGeometry, +} from '../paddle/paddle-obstacles.schema'; +import { + distanceToRouteM, + MAX_PASSAGES, + NEAR_M, + ON_ROUTE_M, + PASSAGE_GAP_M, + relateObstaclesToRoute, + routePassages, +} from '../paddle/paddle-route-relation'; +import { + isCarriedAt, + materialOf, + PORTAGE_PROOF_SLACK_M, + pointAlong, + routeTokenOf, + sameRouteToken, + sameSourceToken, + sourceTokenOf, + type PaddleCheckLine, +} from './paddle-check-basis'; +import type { + ObstacleDecision, + ObstacleEntryStatus, + ObstacleSnapshot, + PaddleCheck, + TourObstacleEntry, + TourOwnEntry, +} from './paddle-check.schema'; +import type { PaddleBoatType } from './tours.schema'; + +/** + * PAD-06 and PAD-09 as plain functions the server runs and the client reuses + * for its optimistic writes: which obstacles of the data a paddle tour has to + * decide about, how each stands with the decision stored for it, and what the + * check bench sums up. A decision is the paddler's own plan; a ban in the data + * stays counted whatever was decided, and nothing here clears a tour. + */ + +/** The one an obstacle checklist decides about: a lock's parts together, everything else on its own. */ +export function paddleObstacleKeyOf(feature: Pick): string { + return feature.group_key ?? feature.ref; +} + +/** Access values that let anyone paddle; any other value, a condition, a ban or private water asks for a decision. */ +const OPEN_ACCESS = new Set(['yes', 'designated', 'permissive', 'official']); + +/** + * Whether an object belongs on the checklist: an obstacle, or an access rule + * that restricts paddling. A river tagged `canoe=yes` is no entry; an unknown + * value is one, since missing or unclear values are no permission. + */ +export function isChecklistRelevant(feature: PaddleFeature): boolean { + if (!PADDLE_BLOCKING_KINDS.includes(feature.kind)) return false; + if (feature.kind !== 'access') return true; + const access = paddleAccessOf(feature.tags); + return ( + access.forbidden || + access.private || + access.condition !== null || + (access.value !== null && !OPEN_ACCESS.has(access.value)) + ); +} + +/** What the data says about walking a carrying way: forbidden, private, or nothing against it. */ +export function portageWayRestriction(tags: Readonly>): 'forbidden' | 'private' | null { + const value = tags['portage:access'] ?? tags.access; + if (value === 'no') return 'forbidden'; + if (value === 'private') return 'private'; + return null; +} + +/** A checklist entry as the data and the line make it, before a stored decision is laid over it. */ +export interface ObstacleCandidate extends Omit { + route: string; + source: string; + region_id: string | null; + /** Metres along the line of each passage, in route order. */ + passage_m: number[]; +} + +/** One stored decision as the checklist reads it. */ +export interface StoredObstacleReview { + obstacle_key: string; + decision: ObstacleDecision; + note: string | null; + route_fingerprint: string; + source_fingerprint: string; + snapshot: ObstacleSnapshot; + decided_at: string; + decided_by: number | null; +} + +/** An object's points as [lat, lng], read as plain numbers (the client compiles this without strict mode). */ +export function paddleGeometryPoints(geometry: PaddleGeometry): Array<[number, number]> { + const pair = (position: readonly unknown[]): [number, number] => [Number(position[1]), Number(position[0])]; + if (geometry.type === 'Point') return [pair(geometry.coordinates)]; + if (geometry.type === 'LineString') return geometry.coordinates.map(pair); + return (geometry.coordinates[0] ?? []).map(pair); +} + +/** Where an object stands as one point: a point itself, a line's middle vertex, an area's mean outline point. */ +function representative(geometry: PaddleGeometry): [number, number] { + const points = paddleGeometryPoints(geometry); + if (geometry.type === 'LineString') return points[Math.floor(points.length / 2)] ?? [0, 0]; + return mean(points); +} + +function mean(points: ReadonlyArray): [number, number] { + if (points.length === 0) return [0, 0]; + const sum = points.reduce((acc, [lat, lng]) => [acc[0] + lat, acc[1] + lng], [0, 0]); + return [sum[0] / points.length, sum[1] / points.length]; +} + +function boundsOf(points: ReadonlyArray): [[number, number], [number, number]] { + let south = 90; + let west = 180; + let north = -90; + let east = -180; + for (const [lat, lng] of points) { + south = Math.min(south, lat); + north = Math.max(north, lat); + west = Math.min(west, lng); + east = Math.max(east, lng); + } + return [ + [south, west], + [north, east], + ]; +} + +/** The strictest paddling rule among an obstacle's members: a ban, private water, a condition, then any value. */ +function strictestAccess(members: readonly PaddleFeature[]): PaddleAccess { + const rules = members.map((member) => paddleAccessOf(member.tags)); + const rank = (access: PaddleAccess) => + access.forbidden ? 4 : access.private ? 3 : access.condition !== null ? 2 : access.value !== null ? 1 : 0; + return rules.reduce( + (best, access) => (rank(access) > rank(best) ? access : best), + rules[0] ?? paddleAccessOf({}), + ); +} + +const LOCAL_CHECK_DATE = /^\d{4}-\d{2}(-\d{2})?$/; + +/** The latest check on site the members record, as written; null when they record none. */ +function localCheckOf(members: readonly PaddleFeature[]): string | null { + const dates = members.flatMap((member) => + Object.entries(member.tags) + .filter( + ([key, value]) => + (key === 'check_date' || key === 'survey:date' || key.startsWith('check_date:')) && + LOCAL_CHECK_DATE.test(value), + ) + .map(([, value]) => value), + ); + const sorted = dates.sort(); + return sorted[sorted.length - 1] ?? null; +} + +/** Metres along the line of the passages of one member, measured on the line's own (haversine) metres. */ +function passagesAlong(line: PaddleCheckLine, member: PaddleFeature, reachM: number) { + return routePassages(line.points, member.geometry, reachM).map((passage) => { + const start = line.alongM[passage.segment] ?? 0; + const end = line.alongM[passage.segment + 1] ?? start; + return { atM: start + passage.t * (end - start), distanceM: passage.distanceM }; + }); +} + +/** The passages of a whole obstacle: merged within PASSAGE_GAP_M, the closest MAX_PASSAGES kept, in route order. */ +function mergedPassages(found: ReadonlyArray<{ atM: number; distanceM: number }>): number[] { + const merged: Array<{ atM: number; distanceM: number }> = []; + for (const passage of [...found].sort((a, b) => a.atM - b.atM)) { + const last = merged[merged.length - 1]; + if (last && passage.atM - last.atM < PASSAGE_GAP_M) { + if (passage.distanceM < last.distanceM) Object.assign(last, passage); + } else merged.push({ ...passage }); + } + return merged + .sort((a, b) => a.distanceM - b.distanceM || a.atM - b.atM) + .slice(0, MAX_PASSAGES) + .map((passage) => passage.atM) + .sort((a, b) => a - b); +} + +/** + * The obstacles of the data a paddle tour has to decide about, in route + * order, each with the basis a decision about it would be bound to. The parts + * of one lock are one entry; an entry is on the route when any part is, near + * it otherwise, and keeps every passage of the route by it. + */ +export function obstacleCandidatesOf(input: { + line: PaddleCheckLine; + features: readonly PaddleFeature[]; + regions: readonly PaddleCoverageRegion[]; + boat: PaddleBoatType | null; + hash: (text: string) => string; +}): ObstacleCandidate[] { + const { line } = input; + const relevant = input.features.filter(isChecklistRelevant); + const related = relateObstaclesToRoute(line.points, relevant); + const groups = new Map(); + for (const entry of related) { + const key = paddleObstacleKeyOf(entry.feature); + groups.set(key, [...(groups.get(key) ?? []), entry]); + } + const candidates: ObstacleCandidate[] = []; + for (const [key, hits] of groups) { + const members = relevant.filter((feature) => paddleObstacleKeyOf(feature) === key); + const anchor = [...hits].sort((a, b) => a.distanceM - b.distanceM || a.alongM - b.alongM)[0]; + if (!anchor) continue; + const relation = hits.some((hit) => hit.relation === 'on_route') ? 'on_route' : 'near'; + const reach = relation === 'on_route' ? ON_ROUTE_M : NEAR_M; + let passageM = mergedPassages(hits.flatMap((hit) => passagesAlong(line, hit.feature, reach))); + // Related means within reach, so a passage is there; should rounding lose it, the closest point stands in. + if (passageM.length === 0) passageM = [Math.min(line.totalM, anchor.alongM)]; + const kind = members.some((member) => member.kind === 'lock') ? 'lock' : anchor.feature.kind; + const carried = passageM.filter((m) => isCarriedAt(line, m, PORTAGE_PROOF_SLACK_M)).length; + const portage = carried === passageM.length ? 'planned' : carried > 0 ? 'partial' : 'none'; + const at = pointAlong(line, passageM[0] ?? 0); + const position = mean(members.map((member) => representative(member.geometry))); + candidates.push({ + key, + refs: [ + anchor.feature.ref, + ...members + .map((member) => member.ref) + .filter((ref) => ref !== anchor.feature.ref) + .sort(), + ], + kind, + name: anchor.feature.name ?? members.find((member) => member.name)?.name ?? null, + at, + bounds: boundsOf(members.flatMap((member) => paddleGeometryPoints(member.geometry))), + relation, + along_m: Math.round(passageM[0] ?? 0), + distance_m: Math.round(Math.min(...hits.map((hit) => hit.distanceM))), + passages: passageM.length, + access: strictestAccess(members), + advice: + input.boat === 'sup' && (kind === 'lock' || kind === 'lock_gate') && portage !== 'planned' + ? 'sup_lock_portage' + : null, + portage, + local_check: localCheckOf(members), + data_as_of: input.regions.find((region) => region.id === anchor.feature.region_id)?.source_timestamp ?? null, + route: routeTokenOf(line, passageM, relation), + source: sourceTokenOf(input.hash(materialOf(members)).slice(0, 16), position), + region_id: anchor.feature.region_id, + passage_m: passageM, + }); + } + return candidates.sort((a, b) => (a.along_m ?? 0) - (b.along_m ?? 0)); +} + +/** The part of the line between two distances, its ends interpolated. */ +function subLine(line: PaddleCheckLine, fromM: number, toM: number): Array<[number, number]> { + const inner = line.points.filter((_, i) => { + const m = line.alongM[i] ?? 0; + return m > fromM && m < toM; + }); + return [pointAlong(line, fromM), ...inner, pointAlong(line, toM)]; +} + +/** + * The paddler's own portages and locks as entries of the checklist, their + * own plan beside the data: which obstacles a portage carries round, whether + * the data records its carrying way as forbidden or private, and the SUP + * advice at a lock the plan passes through. + */ +export function ownEntriesOf(input: { + line: PaddleCheckLine; + candidates: readonly ObstacleCandidate[]; + features: readonly PaddleFeature[]; + boat: PaddleBoatType | null; +}): TourOwnEntry[] { + const { line } = input; + const carryingWays = input.features.filter( + (feature) => feature.kind === 'portage' && portageWayRestriction(feature.tags) !== null, + ); + const portages = line.portages.map((portage): TourOwnEntry => { + const path = subLine(line, portage.fromM, portage.toM); + const restrictions = relateObstaclesToRoute(path, carryingWays) + .filter((entry) => entry.relation === 'on_route') + .map((entry) => portageWayRestriction(entry.feature.tags)); + return { + kind: 'portage', + index: portage.index, + from_m: Math.round(portage.fromM), + to_m: Math.round(portage.toM), + at: pointAlong(line, (portage.fromM + portage.toM) / 2), + bounds: boundsOf(path), + covers: input.candidates + .filter((candidate) => + candidate.passage_m.some( + (m) => m >= portage.fromM - PORTAGE_PROOF_SLACK_M && m <= portage.toM + PORTAGE_PROOF_SLACK_M, + ), + ) + .map((candidate) => candidate.key), + bypassed: false, + advice: null, + conflict: restrictions.includes('forbidden') ? 'forbidden' : restrictions.includes('private') ? 'private' : null, + }; + }); + const locks = line.locks.map((lock): TourOwnEntry => { + const at = pointAlong(line, lock.atM); + return { + kind: 'lock', + index: lock.index, + from_m: Math.round(lock.atM), + to_m: Math.round(lock.atM), + at, + bounds: [at, at], + covers: [], + bypassed: lock.bypassed, + advice: input.boat === 'sup' && !lock.bypassed ? 'sup_lock_portage' : null, + conflict: null, + }; + }); + return [...portages, ...locks].sort((a, b) => a.from_m - b.from_m); +} + +/** + * How an entry stands with the decision stored for it. A changed source + * always asks for a new look; so does a changed route, except for a portage + * decision, which planning the carry changes the route for on purpose: it + * holds while the paddler's own portage carries round every passage. A + * reroute stays to do while the route still passes. + */ +export function obstacleStatusOf(input: { + current: { route: string; source: string; portage: 'planned' | 'partial' | 'none' }; + review: { decision: ObstacleDecision; route: string; source: string } | null; +}): { status: 'open' | 'decided' | 'todo' | 'review'; changed: Array<'route' | 'source'> } { + const { current, review } = input; + if (!review) return { status: 'open', changed: [] }; + const changed: Array<'route' | 'source'> = []; + if (!sameSourceToken(review.source, current.source)) changed.push('source'); + if (review.decision !== 'portage' && !sameRouteToken(review.route, current.route)) changed.push('route'); + if (changed.length > 0) return { status: 'review', changed }; + if (review.decision === 'portage') return { status: current.portage === 'planned' ? 'decided' : 'todo', changed }; + if (review.decision === 'reroute') return { status: 'todo', changed }; + return { status: 'decided', changed }; +} + +/** What a decision keeps of its obstacle. */ +export function obstacleSnapshotOf(candidate: ObstacleCandidate): ObstacleSnapshot { + return { + v: 1, + kind: candidate.kind, + name: candidate.name, + refs: candidate.refs, + at: candidate.at, + bounds: candidate.bounds, + relation: candidate.relation ?? 'near', + along_m: candidate.along_m ?? 0, + region_id: candidate.region_id, + data_as_of: candidate.data_as_of, + }; +} + +const NO_ACCESS: PaddleAccess = { value: null, source: null, condition: null, forbidden: false, private: false }; + +function decisionRecordOf(review: StoredObstacleReview) { + return { + decision: review.decision, + note: review.note, + decided_at: review.decided_at, + decided_by: review.decided_by, + }; +} + +/** + * The checklist: the current candidates in route order with their status, + * then the earlier decisions no candidate carries any more, newest first. + * Such a decision is about an object still in the data the route no longer + * meets (off_route), or one the complete data no longer holds (gone, or kept + * when the paddler kept the decision), or one TREK cannot tell (unknown, when + * the data is incomplete). None of them is deleted or applied silently. + */ +export function obstacleEntriesOf(input: { + candidates: readonly ObstacleCandidate[]; + reviews: readonly StoredObstacleReview[]; + dataKeys: ReadonlySet; + complete: boolean; + line: PaddleCheckLine; +}): TourObstacleEntry[] { + const byKey = new Map(input.reviews.map((review) => [review.obstacle_key, review])); + const current = input.candidates.map((candidate): TourObstacleEntry => { + const review = byKey.get(candidate.key) ?? null; + const { status, changed } = obstacleStatusOf({ + current: candidate, + review: review + ? { decision: review.decision, route: review.route_fingerprint, source: review.source_fingerprint } + : null, + }); + const { route, source, region_id: _region, passage_m: _passages, ...entry } = candidate; + return { ...entry, status, changed, basis: { route, source }, decision: review ? decisionRecordOf(review) : null }; + }); + const listed = new Set(input.candidates.map((candidate) => candidate.key)); + const earlier = input.reviews + .filter((review) => !listed.has(review.obstacle_key)) + .sort((a, b) => b.decided_at.localeCompare(a.decided_at)) + .map((review): TourObstacleEntry => { + const { snapshot } = review; + // Read as numbers: a consumer compiled without strict checks types the tuple loosely. + const at: [number, number] = [Number(snapshot.at[0]), Number(snapshot.at[1])]; + let status: ObstacleEntryStatus; + if (input.dataKeys.has(review.obstacle_key)) status = 'off_route'; + else if (distanceToRouteM(input.line.points, at) > NEAR_M) status = 'off_route'; + else if (input.complete) status = review.source_fingerprint === 'gone' ? 'kept' : 'gone'; + else status = 'unknown'; + return { + key: review.obstacle_key, + refs: snapshot.refs, + kind: snapshot.kind, + name: snapshot.name, + at: snapshot.at, + bounds: snapshot.bounds, + relation: null, + along_m: null, + distance_m: null, + passages: 0, + access: NO_ACCESS, + advice: null, + portage: 'none', + local_check: null, + data_as_of: snapshot.data_as_of, + status, + changed: [], + basis: null, + decision: decisionRecordOf(review), + }; + }); + return [...current, ...earlier]; +} + +/** + * PAD-09's check bench out of the checklist: how many entries are open (a + * choice whose plan is missing included), to check again (a vanished object + * included), decided, and how many bans or private stretches the route meets + * whatever was decided. Earlier decisions off the route are not counted. + */ +export function paddleCheckSummaryOf( + entries: readonly TourObstacleEntry[], + own: readonly TourOwnEntry[], + data: Pick, +): Omit { + const count = (statuses: readonly ObstacleEntryStatus[]) => + entries.filter((entry) => statuses.includes(entry.status)).length; + return { + ...data, + entries: entries.filter((entry) => entry.relation !== null).length, + open: count(['open', 'todo']), + review: count(['review', 'gone']), + decided: count(['decided']), + bans: + entries.filter((entry) => entry.relation === 'on_route' && (entry.access.forbidden || entry.access.private)) + .length + own.filter((entry) => entry.conflict !== null).length, + own: own.length, + }; +} diff --git a/shared/src/tours/tours.schema.ts b/shared/src/tours/tours.schema.ts index f8b48f748..b326b3eac 100644 --- a/shared/src/tours/tours.schema.ts +++ b/shared/src/tours/tours.schema.ts @@ -1,3 +1,4 @@ +import { paddleCheckSchema, tourObstacleCheckSchema } from './paddle-check.schema'; import { paddleLockSchema, paddleStopMinutesSchema, @@ -153,6 +154,12 @@ export const tourListItemSchema = tourSchema.extend({ has_recorded_track: z.boolean().optional(), /** Which way the tour came in; absent only in a list cached before TREK recorded it. */ origin: tourOriginSchema.optional(), + /** + * PAD-09: the check bench of a paddle tour, null when it has no line yet. + * Only paddle tours carry it. A value this build cannot read (a newer + * server) is dropped rather than refused, so a queued write still lands. + */ + paddle_check: paddleCheckSchema.nullable().optional().catch(undefined), }); export type TourListItem = z.infer; @@ -356,6 +363,8 @@ export const tourCreateResponseSchema = z.object({ waypoints: z.array(tourWaypointSchema), /** Where the tour came from (P2C-02); absent only in a detail cached before TREK sent it. */ provenance: tourProvenanceSchema.optional(), + /** PAD-06: a paddle tour's obstacle checklist; dropped, never refused, when this build cannot read it. */ + obstacle_check: tourObstacleCheckSchema.optional().catch(undefined), }); export type TourCreateResponse = z.infer;