diff --git a/server/src/db/repositories/PaddleFeatures.repository.ts b/server/src/db/repositories/PaddleFeatures.repository.ts index 34b9036a4..068ad4e36 100644 --- a/server/src/db/repositories/PaddleFeatures.repository.ts +++ b/server/src/db/repositories/PaddleFeatures.repository.ts @@ -18,6 +18,22 @@ export interface PaddleFeatureRow { max_lng: number; } +/** The columns a stored object is read with. */ +const FEATURE_COLUMNS = [ + 'region_id', + 'osm_ref', + 'kind', + 'group_key', + 'name', + 'geometry', + 'tags', + 'tag_hash', + 'min_lat', + 'min_lng', + 'max_lat', + 'max_lng', +] as const; + /** A box in degrees, south to north and west to east. */ export interface Box { south: number; @@ -43,20 +59,22 @@ export class PaddleFeaturesRepository extends TrekRepository { async findInBox(box: Box, limit: number): Promise { return await this.db() .selectFrom('paddle_features') - .select([ - 'region_id', - 'osm_ref', - 'kind', - 'group_key', - 'name', - 'geometry', - 'tags', - 'tag_hash', - 'min_lat', - 'min_lng', - 'max_lat', - 'max_lng', - ]) + .select(FEATURE_COLUMNS) + .where('min_lat', '<=', box.north) + .where('max_lat', '>=', box.south) + .where('min_lng', '<=', box.east) + .where('max_lng', '>=', box.west) + .orderBy('id', 'asc') + .limit(limit) + .execute(); + } + + /** The parts of the given locks whose box reaches into `box`, at most `limit` of them, in a stable order. */ + async findGroupParts(keys: readonly string[], box: Box, limit: number): Promise { + return await this.db() + .selectFrom('paddle_features') + .select(FEATURE_COLUMNS) + .where('group_key', 'in', [...keys]) .where('min_lat', '<=', box.north) .where('max_lat', '>=', box.south) .where('min_lng', '<=', box.east) diff --git a/server/src/db/repositories/PaddleRegions.repository.ts b/server/src/db/repositories/PaddleRegions.repository.ts index 5fac10db4..043b2b879 100644 --- a/server/src/db/repositories/PaddleRegions.repository.ts +++ b/server/src/db/repositories/PaddleRegions.repository.ts @@ -49,6 +49,11 @@ export class PaddleRegionsRepository extends TrekRepository { return await this.db().selectFrom('paddle_regions').select(COLUMNS).orderBy('name', 'asc').execute(); } + /** When each region was last read, by id: what the obstacle data's version is made of, without the boundaries. */ + async readTimes(): Promise> { + return await this.db().selectFrom('paddle_regions').select(['id', 'imported_at']).orderBy('id', 'asc').execute(); + } + async findById(id: string): Promise { return await this.db().selectFrom('paddle_regions').select(COLUMNS).where('id', '=', id).executeTakeFirst(); } diff --git a/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts b/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts index 721ae6826..40afe9d86 100644 --- a/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts +++ b/server/src/nest/paddle-obstacles/paddle-obstacles.service.ts @@ -22,6 +22,8 @@ import { /** The widest a run of a line is read at once, in degrees a side; a long tour is read run by run. */ const RUN_SPAN_DEG = 0.1; +/** How far round the parts of a lock found along a line its other parts are looked for, in degrees (about 2 km). */ +const LOCK_PART_MARGIN_DEG = 0.02; /** What the data holds along a paddle tour's line, as the checklist reads it. */ export interface PaddleObstaclesAlong { @@ -140,15 +142,18 @@ export class PaddleObstaclesService { const seen = new Set(); const features: PaddleFeature[] = []; const regions = new Map(); + const take = (row: PaddleFeatureRow) => { + if (seen.has(row.osm_ref)) return; + seen.add(row.osm_ref); + const feature = featureOf(row); + if (feature) features.push(feature); + }; for (const run of runs) { - for (const row of run.rows.slice(0, PADDLE_OBSTACLES_CAP)) { - if (seen.has(row.osm_ref)) continue; - seen.add(row.osm_ref); - const feature = featureOf(row); - if (feature) features.push(feature); - } + run.rows.slice(0, PADDLE_OBSTACLES_CAP).forEach(take); for (const region of run.regions) regions.set(region.id, region); } + // The parts of a lock the corridor cut off: its checklist entry reads the lock whole, however the route runs. + (await this.lockPartsAround(runs.flatMap((run) => run.rows.slice(0, PADDLE_OBSTACLES_CAP)))).forEach(take); const held = [...regions.values()]; return { features, @@ -167,13 +172,27 @@ export class PaddleObstaclesService { }; } + /** Every stored part of the locks among the rows, within a margin round the parts found. */ + private async lockPartsAround(rows: readonly PaddleFeatureRow[]): Promise { + const grouped = rows.filter((row) => row.group_key !== null); + if (grouped.length === 0) return []; + const keys = [...new Set(grouped.flatMap((row) => row.group_key ?? []))]; + const box = { + south: Math.min(...grouped.map((row) => row.min_lat)) - LOCK_PART_MARGIN_DEG, + west: Math.min(...grouped.map((row) => row.min_lng)) - LOCK_PART_MARGIN_DEG, + north: Math.max(...grouped.map((row) => row.max_lat)) + LOCK_PART_MARGIN_DEG, + east: Math.max(...grouped.map((row) => row.max_lng)) + LOCK_PART_MARGIN_DEG, + }; + return await this.features.findGroupParts(keys, box, PADDLE_OBSTACLES_CAP); + } + /** * A text that changes whenever the data a checklist reads could have: * an object added or removed, a region read again or removed. The checklist * caches its reading under it. */ async dataVersion(): Promise { - const [stats, regions] = await Promise.all([this.features.stats(), this.regions.list()]); + const [stats, regions] = await Promise.all([this.features.stats(), this.regions.readTimes()]); return `${stats.count}:${stats.max_id ?? '-'}:${regions.map((region) => `${region.id}@${region.imported_at ?? '-'}`).join(';')}`; } diff --git a/server/src/nest/tours/tour-paddle-check.cache.ts b/server/src/nest/tours/tour-paddle-check.cache.ts index b152ffd8a..a8aad5ad5 100644 --- a/server/src/nest/tours/tour-paddle-check.cache.ts +++ b/server/src/nest/tours/tour-paddle-check.cache.ts @@ -13,29 +13,39 @@ export interface PaddleCheckEvaluation { data: Pick; } -const MAX_ENTRIES = 256; +/** A reading as kept: without its line, which the caller holds and the key already stands for. */ +type CachedEvaluation = Omit; + +const MAX_ENTRIES = 64; /** - * The evaluations of the last lines read, keyed by the line, the boat and - * the version of the obstacle data, so the tours list of a trip reads its - * paddle tours' obstacles once and not on every call. Held in memory: one - * process serves the app, and a new data version simply misses. + * The evaluations of the last lines read, keyed by the line and the boat, + * so the tours list of a trip reads its paddle tours' obstacles once and not + * on every call. Held in memory for one version of the obstacle data: a new + * version empties it, since nothing read before can be served again. */ @Injectable() export class PaddleCheckCache { - private readonly entries = new Map(); + private readonly entries = new Map(); + private version: string | null = null; - get(key: string): PaddleCheckEvaluation | undefined { - const hit = this.entries.get(key); - if (hit) { - // Read again, kept longer: the map's order is the order of use. - this.entries.delete(key); - this.entries.set(key, hit); - } - return hit; + /** The obstacle data's current version; a different one drops every reading kept. */ + atVersion(version: string): void { + if (version === this.version) return; + this.entries.clear(); + this.version = version; } - set(key: string, value: PaddleCheckEvaluation): void { + get(key: string, line: PaddleCheckLine): PaddleCheckEvaluation | undefined { + const hit = this.entries.get(key); + if (!hit) return undefined; + // Read again, kept longer: the map's order is the order of use. + this.entries.delete(key); + this.entries.set(key, hit); + return { ...hit, line }; + } + + set(key: string, { line: _line, ...value }: PaddleCheckEvaluation): void { this.entries.delete(key); this.entries.set(key, value); if (this.entries.size > MAX_ENTRIES) { diff --git a/server/src/nest/tours/tour-paddle-check.service.ts b/server/src/nest/tours/tour-paddle-check.service.ts index f763294a3..6939e957c 100644 --- a/server/src/nest/tours/tour-paddle-check.service.ts +++ b/server/src/nest/tours/tour-paddle-check.service.ts @@ -21,6 +21,7 @@ import { obstacleEntriesOf, ownEntriesOf, PADDLE_CHECK_MAX_ENTRIES, + PADDLE_CHECK_MAX_OWN, PADDLE_CHECK_VERSION, paddleCheckLineOfPoints, paddleCheckLineOfTrack, @@ -107,8 +108,9 @@ export class TourPaddleCheckService { async evaluate(line: PaddleCheckLine, boat: PaddleBoatType | null): Promise { try { const version = await this.obstacles.dataVersion(); - const key = `${PADDLE_CHECK_VERSION}|${shortHash(JSON.stringify([line.points, line.portages, line.locks]))}|${boat ?? ''}|${version}`; - const hit = this.cache.get(key); + this.cache.atVersion(version); + const key = `${PADDLE_CHECK_VERSION}|${shortHash(JSON.stringify([line.points, line.portages, line.locks]))}|${boat ?? ''}`; + const hit = this.cache.get(key, line); if (hit) return hit; const along = await this.obstacles.readAlong(line.points); const candidates = obstacleCandidatesOf({ @@ -160,7 +162,9 @@ export class TourPaddleCheckService { own = []; data = UNAVAILABLE; } + // The checklist carries as many entries and own portages and locks as its contract holds. entries = entries.slice(0, PADDLE_CHECK_MAX_ENTRIES); + own = own.slice(0, PADDLE_CHECK_MAX_OWN); const summary = paddleCheckSummaryOf(entries, own, data); return { entries, own, check: { ...summary, revision: shortHash(JSON.stringify({ entries, own, data })) } }; } diff --git a/server/tests/unit/db/repositories/PaddleRegions.repository.test.ts b/server/tests/unit/db/repositories/PaddleRegions.repository.test.ts index 76ace286a..4c26d86d6 100644 --- a/server/tests/unit/db/repositories/PaddleRegions.repository.test.ts +++ b/server/tests/unit/db/repositories/PaddleRegions.repository.test.ts @@ -182,4 +182,30 @@ describe('PaddleFeaturesRepository', () => { expect(stats.count).toBe(2); expect(stats.max_id).toEqual(expect.any(Number)); }); + + it('PADFEATREPO-004: reads only the parts of the given locks inside a box', async () => { + await add('oberpfalz'); + await features.replaceRegion('oberpfalz', [ + { ...feature('node/1', 49.5, 12.5), group_key: 'lock:node/1' }, + { ...feature('node/2', 49.51, 12.5), group_key: 'lock:node/1' }, + { ...feature('node/3', 49.52, 12.5), group_key: 'lock:node/3' }, + { ...feature('node/4', 49.9, 12.5), group_key: 'lock:node/1' }, + ]); + const parts = await features.findGroupParts( + ['lock:node/1'], + { south: 49.4, west: 12.4, north: 49.6, east: 12.6 }, + 10, + ); + expect(parts.map((row) => row.osm_ref)).toEqual(['node/1', 'node/2']); + }); + + it('PADREGREPO-010: names when each region was last read, without its boundary', async () => { + await add('oberpfalz'); + await add('bayern'); + await regions.updateRegion('oberpfalz', { status: 'ready', imported_at: '2026-10-02 04:20:00' }); + expect(await regions.readTimes()).toEqual([ + { id: 'bayern', imported_at: null }, + { id: 'oberpfalz', imported_at: '2026-10-02 04:20:00' }, + ]); + }); }); diff --git a/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts b/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts index 8584535c8..e454c6ce4 100644 --- a/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts +++ b/server/tests/unit/nest/paddle-obstacles/paddle-obstacles.service.test.ts @@ -278,3 +278,27 @@ describe('PaddleObstaclesService.dataVersion', () => { expect(await service.dataVersion()).toBe(empty); }); }); + +describe('PaddleObstaclesService.readAlong: whole locks', () => { + it('PADOBS-015: a lock the corridor cuts through comes with its parts beyond it, other locks and objects do not', async () => { + await region('oberpfalz'); + const part = (ref: string, lat: number, lng: number, group: string | null) => ({ + ...weir('oberpfalz', ref, lat, lng), + kind: 'lock_gate', + group_key: group, + }); + await insertRows(orm(), PaddleFeatures, [ + part('node/1', 49.2, 12.005, 'lock:node/1'), + // 1.1 km north of the line, out of the corridor but part of the same lock. + part('node/2', 49.21, 12.005, 'lock:node/1'), + // Another lock out there, and a lone weir. + part('node/3', 49.21, 12.006, 'lock:node/3'), + weir('oberpfalz', 'node/4', 49.21, 12.007), + ]); + const along = await service.readAlong([ + [49.2, 12.0], + [49.2, 12.01], + ]); + expect(along.features.map((feature) => feature.ref).sort()).toEqual(['node/1', 'node/2']); + }); +}); diff --git a/server/tests/unit/nest/tours.paddle-check.cache.test.ts b/server/tests/unit/nest/tours.paddle-check.cache.test.ts index 9fffa0148..67e6c6178 100644 --- a/server/tests/unit/nest/tours.paddle-check.cache.test.ts +++ b/server/tests/unit/nest/tours.paddle-check.cache.test.ts @@ -1,39 +1,59 @@ /** - * PaddleCheckCache: the readings of the last lines, the least recently used - * one dropped once it holds more than it keeps. + * PaddleCheckCache: the readings of the last lines for one version of the + * obstacle data, kept without their lines, the least recently used one + * dropped once it holds more than it keeps, and all of them dropped with a + * new version. */ import { PaddleCheckCache, type PaddleCheckEvaluation } from '../../../src/nest/tours/tour-paddle-check.cache'; +import type { PaddleCheckLine } from '@trek/shared'; import { describe, expect, it } from 'vitest'; +const line = (totalM: number): PaddleCheckLine => ({ points: [], alongM: [], totalM, portages: [], locks: [] }); + function evaluation(totalM: number): PaddleCheckEvaluation { return { - line: { points: [], alongM: [], totalM, portages: [], locks: [] }, + line: line(totalM), candidates: [], own: [], - dataKeys: new Set(), + dataKeys: new Set([`node/${totalM}`]), complete: true, data: { data: 'covered', data_as_of: null, read_at: null }, }; } describe('PaddleCheckCache', () => { - it('PCC-001: answers what it was given and nothing it was not', () => { + it('PCC-001: answers what it was given with the line it is asked for, and nothing it was not', () => { const cache = new PaddleCheckCache(); + cache.atVersion('v1'); cache.set('a', evaluation(1)); - expect(cache.get('a')?.line.totalM).toBe(1); - expect(cache.get('b')).toBeUndefined(); + const asked = line(7); + const hit = cache.get('a', asked); + expect(hit?.line).toBe(asked); + expect(hit?.dataKeys.has('node/1')).toBe(true); + expect(cache.get('b', asked)).toBeUndefined(); cache.set('a', evaluation(2)); - expect(cache.get('a')?.line.totalM).toBe(2); + expect(cache.get('a', asked)?.dataKeys.has('node/2')).toBe(true); }); - it('PCC-002: past 256 readings drops the one used longest ago, a reading just read is kept', () => { + it('PCC-002: past 64 readings drops the one used longest ago, a reading just read is kept', () => { const cache = new PaddleCheckCache(); - for (let i = 0; i < 256; i++) cache.set(`line-${i}`, evaluation(i)); - expect(cache.get('line-0')).toBeDefined(); - cache.set('line-256', evaluation(256)); - expect(cache.get('line-0')).toBeDefined(); - expect(cache.get('line-1')).toBeUndefined(); - expect(cache.get('line-256')).toBeDefined(); + cache.atVersion('v1'); + for (let i = 0; i < 64; i++) cache.set(`line-${i}`, evaluation(i)); + expect(cache.get('line-0', line(0))).toBeDefined(); + cache.set('line-64', evaluation(64)); + expect(cache.get('line-0', line(0))).toBeDefined(); + expect(cache.get('line-1', line(1))).toBeUndefined(); + expect(cache.get('line-64', line(64))).toBeDefined(); + }); + + it('PCC-003: a new version of the data drops every reading, the same version keeps them', () => { + const cache = new PaddleCheckCache(); + cache.atVersion('v1'); + cache.set('a', evaluation(1)); + cache.atVersion('v1'); + expect(cache.get('a', line(1))).toBeDefined(); + cache.atVersion('v2'); + expect(cache.get('a', line(1))).toBeUndefined(); }); }); diff --git a/shared/src/paddle/paddle-route-relation.ts b/shared/src/paddle/paddle-route-relation.ts index 50929ec5b..264f8f72e 100644 --- a/shared/src/paddle/paddle-route-relation.ts +++ b/shared/src/paddle/paddle-route-relation.ts @@ -213,7 +213,7 @@ 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). */ +/** Two visits whose closest points lie closer than this along the route are one passage. */ 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; @@ -257,9 +257,12 @@ function segmentApproach( /** * 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 + * order: a run of segments in reach is one visit, anchored where it comes + * closest (the earlier point on a tie), and two visits whose anchors lie + * closer than PASSAGE_GAP_M along the route are one. Two runs whose anchors + * lie further apart stay two passages even when the route left the reach + * only briefly between them; that binds a decision to more of the route, so + * it errs towards asking for a look again. 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. */ diff --git a/shared/src/tours/paddle-check.schema.ts b/shared/src/tours/paddle-check.schema.ts index b55ee5a52..b316b3c1d 100644 --- a/shared/src/tours/paddle-check.schema.ts +++ b/shared/src/tours/paddle-check.schema.ts @@ -112,11 +112,15 @@ export type ObstacleDecisionRecord = z.infer; @@ -197,7 +201,7 @@ export const obstacleSnapshotSchema = z.object({ v: z.literal(1), kind: paddleObstacleKindSchema, name: z.string().nullable(), - refs: z.array(osmRefSchema).min(1).max(30), + refs: z.array(osmRefSchema).min(1).max(OBSTACLE_REFS_MAX), at: latLng, bounds, relation: z.enum(['on_route', 'near']), diff --git a/shared/src/tours/paddle-check.spec.ts b/shared/src/tours/paddle-check.spec.ts index c3ba1a088..543fdfde2 100644 --- a/shared/src/tours/paddle-check.spec.ts +++ b/shared/src/tours/paddle-check.spec.ts @@ -28,6 +28,7 @@ import { type PaddleCheckLine, } from './paddle-check-basis'; import { + OBSTACLE_REFS_MAX, obstacleBasisSchema, obstacleSnapshotSchema, paddleCheckSchema, @@ -223,7 +224,10 @@ function reviewOf(candidate: ObstacleCandidate, overrides: Partial { 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); + // The mean of each part's own place, the middle of its extent: the gates at 975 and 1015 m, the chamber at 995 m. + expect(gapM(positionOf(lock.source), at(0, 995))).toBeLessThan(1); }); it('keeps the source through a new name or check on site, and changes it with a paddling rule on any part', () => { @@ -504,6 +508,30 @@ describe('obstacleCandidatesOf', () => { }); }); +describe('the caps of one entry', () => { + it('names a lock of more parts than an entry holds by its nearest part and the first others, as the schemas allow', () => { + // 40 gates along the chamber, all of one lock; the first one sits on the route. + const parts = Array.from({ length: 40 }, (_, i) => + feature(`way/${500 + i}`, 'lock_gate', wayThrough([-10, 960 + i], [10, 960 + i]), { group_key: 'lock:way/500' }), + ); + const [lock] = candidatesOf(straight, parts); + expect(lock?.refs).toHaveLength(OBSTACLE_REFS_MAX); + expect(new Set(lock?.refs).size).toBe(OBSTACLE_REFS_MAX); + if (!lock) throw new Error('no lock'); + expect(obstacleSnapshotSchema.safeParse(obstacleSnapshotOf(lock)).success).toBe(true); + }); + + it('lists at most as many obstacles under one portage as an own entry holds', () => { + const line: PaddleCheckLine = { ...straight, portages: [{ fromM: 100, toM: 1900, index: 1 }] }; + const candidates = Array.from({ length: 35 }, (_, i) => + candidateOf(`node/${i + 1}`, { passage_m: [200 + i * 40] }), + ); + const [portage] = ownEntriesOf({ line, candidates, features: [], boat: 'kayak' }); + expect(portage?.covers).toHaveLength(OBSTACLE_REFS_MAX); + expect(tourOwnEntrySchema.safeParse(portage).success).toBe(true); + }); +}); + describe('ownEntriesOf', () => { const planned: PaddleCheckLine = { ...straight, @@ -890,3 +918,40 @@ describe('paddleCheckSummaryOf', () => { expect(paddleCheckSummaryOf(entries, own, bench)).toMatchObject({ decided: 1, open: 0, review: 0, bans: 1 }); }); }); + +describe('where an object stands', () => { + it('keeps the place of a line through a node added on it or the way turned round', () => { + const weir = feature('way/70', 'weir', wayThrough([-20, 1000], [20, 1000])); + const noded = feature('way/70', 'weir', wayThrough([-20, 1000], [5, 1000], [20, 1000])); + const turned = feature('way/70', 'weir', wayThrough([20, 1000], [-20, 1000])); + const [source] = candidatesOf(straight, [weir]).map((candidate) => positionOf(candidate.source)); + for (const other of [noded, turned]) { + const [moved] = candidatesOf(straight, [other]).map((candidate) => positionOf(candidate.source)); + if (!source || !moved) throw new Error('no candidate'); + expect(gapM(moved, source)).toBeLessThan(1); + } + }); + + it('keeps an earlier decision about an object beside the route as one still near it, by the object itself', () => { + // The weir stood 140 m north of the route; the route point of its passage was on the line. + const review = reviewOf(candidateOf('way/71', { at: at(0, 1000), bounds: [at(140, 1000), at(140, 1000)] }), { + decided_at: '2026-09-02T08:00:00.000Z', + }); + const entries = obstacleEntriesOf({ + candidates: [], + reviews: [review], + dataKeys: new Set(), + complete: true, + line: straight, + }); + expect(entries.map((entry) => entry.status)).toEqual(['gone']); + const far = reviewOf(candidateOf('way/72', { at: at(0, 1000), bounds: [at(400, 1000), at(420, 1010)] }), { + decided_at: '2026-09-02T08:00:00.000Z', + }); + expect( + obstacleEntriesOf({ candidates: [], reviews: [far], dataKeys: new Set(), complete: true, line: straight }).map( + (entry) => entry.status, + ), + ).toEqual(['off_route']); + }); +}); diff --git a/shared/src/tours/paddle-check.ts b/shared/src/tours/paddle-check.ts index 470029fc1..9f41e3c22 100644 --- a/shared/src/tours/paddle-check.ts +++ b/shared/src/tours/paddle-check.ts @@ -26,13 +26,14 @@ import { sourceTokenOf, type PaddleCheckLine, } from './paddle-check-basis'; -import type { - ObstacleDecision, - ObstacleEntryStatus, - ObstacleSnapshot, - PaddleCheck, - TourObstacleEntry, - TourOwnEntry, +import { + OBSTACLE_REFS_MAX, + type ObstacleDecision, + type ObstacleEntryStatus, + type ObstacleSnapshot, + type PaddleCheck, + type TourObstacleEntry, + type TourOwnEntry, } from './paddle-check.schema'; import type { PaddleBoatType } from './tours.schema'; @@ -106,11 +107,16 @@ export function paddleGeometryPoints(geometry: PaddleGeometry): Array<[number, n 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. */ +/** + * Where an object stands as one point: a point itself, a line or an area the + * middle of its extent, so adding a node or turning a way round leaves it in + * place and only a change of its shape moves it. + */ 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); + if (geometry.type === 'Point' || points.length === 0) return points[0] ?? [0, 0]; + const [[south, west], [north, east]] = boundsOf(points); + return [(south + north) / 2, (west + east) / 2]; } function mean(points: ReadonlyArray): [number, number] { @@ -227,13 +233,14 @@ export function obstacleCandidatesOf(input: { const position = mean(members.map((member) => representative(member.geometry))); candidates.push({ key, + // The part nearest the route first, the map selects it; a huge group names its first parts only. refs: [ anchor.feature.ref, ...members .map((member) => member.ref) .filter((ref) => ref !== anchor.feature.ref) .sort(), - ], + ].slice(0, OBSTACLE_REFS_MAX), kind, name: anchor.feature.name ?? members.find((member) => member.name)?.name ?? null, at, @@ -302,7 +309,8 @@ export function ownEntriesOf(input: { (m) => m >= portage.fromM - PORTAGE_PROOF_SLACK_M && m <= portage.toM + PORTAGE_PROOF_SLACK_M, ), ) - .map((candidate) => candidate.key), + .map((candidate) => candidate.key) + .slice(0, OBSTACLE_REFS_MAX), bypassed: false, advice: null, conflict: restrictions.includes('forbidden') ? 'forbidden' : restrictions.includes('private') ? 'private' : null, @@ -375,6 +383,31 @@ function decisionRecordOf(review: StoredObstacleReview) { }; } +/** + * Whether the route still comes within NEAR_M of where an obstacle was, by + * its whole extent rather than the point of the passage, since an object + * near the route lies up to that far from it. + */ +function reachesRoute(line: PaddleCheckLine, bounds: ObstacleSnapshot['bounds']): boolean { + // Read as numbers: a consumer compiled without strict checks types the tuples loosely. + const [south, west] = [Number(bounds[0][0]), Number(bounds[0][1])]; + const [north, east] = [Number(bounds[1][0]), Number(bounds[1][1])]; + if (south === north && west === east) return distanceToRouteM(line.points, [south, west]) <= NEAR_M; + const extent: PaddleGeometry = { + type: 'Polygon', + coordinates: [ + [ + [west, south], + [east, south], + [east, north], + [west, north], + [west, south], + ], + ], + }; + return routePassages(line.points, extent, NEAR_M).length > 0; +} + /** * The checklist: the current candidates in route order with their status, * then the earlier decisions no candidate carries any more, newest first. @@ -408,11 +441,9 @@ export function obstacleEntriesOf(input: { .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 (!reachesRoute(input.line, snapshot.bounds)) status = 'off_route'; else if (input.complete) status = review.source_fingerprint === 'gone' ? 'kept' : 'gone'; else status = 'unknown'; return {