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;