mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(tours): checklist entries within their contract, whole locks and a leaner reading
A lock flight chained into one group of more than 30 parts broke the checklist's contract and made every decision about it fail; an entry now names its nearest part and the first others, a portage lists at most 30 obstacles and a checklist at most 100 own entries. Reading along a line takes in the parts of a lock the corridor cut off, so its data basis no longer depends on how the route runs. A line or an area stands at the middle of its extent, so adding a node leaves it in place, and an earlier decision is judged by where the obstacle was, not by the route point of its passage. The cache keeps its readings without their lines, for one data version at a time, and the data version is read without the region boundaries.
This commit is contained in:
@@ -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<PaddleFeatures> {
|
||||
async findInBox(box: Box, limit: number): Promise<PaddleFeatureRow[]> {
|
||||
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<PaddleFeatureRow[]> {
|
||||
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)
|
||||
|
||||
@@ -49,6 +49,11 @@ export class PaddleRegionsRepository extends TrekRepository<PaddleRegions> {
|
||||
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<Array<{ id: string; imported_at: string | null }>> {
|
||||
return await this.db().selectFrom('paddle_regions').select(['id', 'imported_at']).orderBy('id', 'asc').execute();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PaddleRegionRow | undefined> {
|
||||
return await this.db().selectFrom('paddle_regions').select(COLUMNS).where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
const features: PaddleFeature[] = [];
|
||||
const regions = new Map<string, PaddleRegionRow>();
|
||||
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<PaddleFeatureRow[]> {
|
||||
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<string> {
|
||||
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(';')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,29 +13,39 @@ export interface PaddleCheckEvaluation {
|
||||
data: Pick<PaddleCheck, 'data' | 'data_as_of' | 'read_at'>;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 256;
|
||||
/** A reading as kept: without its line, which the caller holds and the key already stands for. */
|
||||
type CachedEvaluation = Omit<PaddleCheckEvaluation, 'line'>;
|
||||
|
||||
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<string, PaddleCheckEvaluation>();
|
||||
private readonly entries = new Map<string, CachedEvaluation>();
|
||||
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) {
|
||||
|
||||
@@ -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<PaddleCheckEvaluation> {
|
||||
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 })) } };
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -112,11 +112,15 @@ export type ObstacleDecisionRecord = z.infer<typeof obstacleDecisionRecordSchema
|
||||
|
||||
/** The most entries one checklist carries; an area holding more answers as cut short. */
|
||||
export const PADDLE_CHECK_MAX_ENTRIES = 300;
|
||||
/** The most own portages and locks one checklist lists. */
|
||||
export const PADDLE_CHECK_MAX_OWN = 100;
|
||||
/** The most objects one entry names, and the most obstacles one portage lists as carried round. */
|
||||
export const OBSTACLE_REFS_MAX = 30;
|
||||
|
||||
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),
|
||||
refs: z.array(osmRefSchema).min(1).max(OBSTACLE_REFS_MAX),
|
||||
kind: paddleObstacleKindSchema,
|
||||
name: z.string().nullable(),
|
||||
at: latLng,
|
||||
@@ -152,7 +156,7 @@ export const tourOwnEntrySchema = z.object({
|
||||
at: latLng,
|
||||
bounds,
|
||||
/** The obstacles a portage carries round. */
|
||||
covers: z.array(obstacleKeySchema).max(30),
|
||||
covers: z.array(obstacleKeySchema).max(OBSTACLE_REFS_MAX),
|
||||
bypassed: z.boolean(),
|
||||
advice: z.literal('sup_lock_portage').nullable(),
|
||||
/** A carrying way the data records as forbidden or private. */
|
||||
@@ -188,7 +192,7 @@ 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),
|
||||
own: z.array(tourOwnEntrySchema).max(PADDLE_CHECK_MAX_OWN),
|
||||
});
|
||||
export type TourObstacleCheck = z.infer<typeof tourObstacleCheckSchema>;
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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<StoredObstacl
|
||||
/** 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 });
|
||||
return reviewOf(candidateOf(key, { at: place, bounds: [place, place] }), {
|
||||
decided_at: decidedAt,
|
||||
source_fingerprint: source,
|
||||
});
|
||||
}
|
||||
|
||||
/** A checklist entry as obstacleEntriesOf makes one, open unless told otherwise. */
|
||||
@@ -344,8 +348,8 @@ describe('obstacleCandidatesOf', () => {
|
||||
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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<readonly [number, number]>): [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 {
|
||||
|
||||
Reference in New Issue
Block a user