feat(tours): route paddle tours on the water, shaping points and auto-loaded regions

Paddle tours are planned on the water by TREK's own router: a water network
per paddle region, built from the same Geofabrik extract as the obstacles and
searched in a worker thread of the server (no sidecar). Rivers, canals and
lakes (direct or along the shore), portages round weirs, locks by policy,
whitewater by boat, gaps marked. Found portages and locks become points of
their own; an unplannable leg says why and offers a way out.

- Shaping points: a click on the line sets a point that steers the route
  without being a stop (hikes and paddle tours); recompute a single leg.
- Lake style direct/shore, suggested from the wind forecast.
- Regions load by themselves where a paddler plans (admin setting: automatic,
  on request, off; size cap and a daily count), with requests for the admins,
  a notification, and the load followed on the leg.
- Portage from the last point, portages follow footways via the walking
  router (or stay straight), "put in here" on slipways and put-ins.
- MCP plan_paddle_route with the REST route's rules.
This commit is contained in:
Maurice
2026-10-18 01:13:24 +02:00
parent b76a298bb3
commit 1bc82f97fd
321 changed files with 14675 additions and 471 deletions
@@ -0,0 +1,37 @@
import { EntityRepositoryType, type Opt, PrimaryKeyProp, defineEntity, p } from '@mikro-orm/core';
import { PaddleRegionRequestsRepository } from '../repositories/PaddleRegionRequests.repository';
import { DbTimestampType } from '../types';
export class PaddleRegionRequests {
[EntityRepositoryType]?: PaddleRegionRequestsRepository;
[PrimaryKeyProp]?: 'region_id';
region_id!: string;
name!: string;
reason!: TPaddleRegionRequestsReason;
bytes?: number | null;
requested_by?: string | null;
requested_at!: string & Opt;
declined_at?: string | null;
}
export const PaddleRegionRequestsReason = {
ASK: 'ask',
TOO_LARGE: 'too_large',
LIMIT: 'limit',
} as const;
export type TPaddleRegionRequestsReason = (typeof PaddleRegionRequestsReason)[keyof typeof PaddleRegionRequestsReason];
export const PaddleRegionRequestsSchema = defineEntity({
class: PaddleRegionRequests,
repository: () => PaddleRegionRequestsRepository,
properties: {
region_id: p.text().primary(),
name: p.text(),
reason: p.enum(() => PaddleRegionRequestsReason),
bytes: p.integer().nullable(),
requested_by: p.text().nullable(),
requested_at: p.type(DbTimestampType).defaultRaw(`CURRENT_TIMESTAMP`),
declined_at: p.type(DbTimestampType).nullable(),
},
});
+13 -1
View File
@@ -1,7 +1,8 @@
import { Collection, EntityRepositoryType, type Opt, defineEntity, p } from '@mikro-orm/core';
import { Collection, EntityRepositoryType, type Opt, type Ref, defineEntity, p } from '@mikro-orm/core';
import { PaddleRegionsRepository } from '../repositories/PaddleRegions.repository';
import { DbTimestampType } from '../types';
import { PaddleFeatures } from './PaddleFeatures.entity';
import { PaddleWaterGraphs } from './PaddleWaterGraphs.entity';
export class PaddleRegions {
[EntityRepositoryType]?: PaddleRegionsRepository;
@@ -18,7 +19,9 @@ export class PaddleRegions {
feature_count: number & Opt = 0;
error?: string | null;
created_at?: string | null;
origin: TPaddleRegionsOrigin & Opt = PaddleRegionsOrigin.ADMIN;
paddle_features_collection = new Collection<PaddleFeatures>(this);
paddle_water_graphs: Ref<PaddleWaterGraphs> | null = null;
}
export const PaddleRegionsStatus = {
@@ -30,6 +33,13 @@ export const PaddleRegionsStatus = {
export type TPaddleRegionsStatus = (typeof PaddleRegionsStatus)[keyof typeof PaddleRegionsStatus];
export const PaddleRegionsOrigin = {
ADMIN: 'admin',
AUTO: 'auto',
} as const;
export type TPaddleRegionsOrigin = (typeof PaddleRegionsOrigin)[keyof typeof PaddleRegionsOrigin];
export const PaddleRegionsSchema = defineEntity({
class: PaddleRegions,
repository: () => PaddleRegionsRepository,
@@ -47,6 +57,8 @@ export const PaddleRegionsSchema = defineEntity({
feature_count: p.integer().default(0),
error: p.text().nullable(),
created_at: p.type(DbTimestampType).nullable().defaultRaw(`CURRENT_TIMESTAMP`),
origin: p.enum(() => PaddleRegionsOrigin).default('admin'),
paddle_features_collection: () => p.oneToMany(PaddleFeatures).mappedBy('region').hidden(),
paddle_water_graphs: () => p.oneToOne(PaddleWaterGraphs).ref().mappedBy('region').hidden(),
},
});
@@ -0,0 +1,45 @@
import { EntityRepositoryType, PrimaryKeyProp, type Ref, defineEntity, p } from '@mikro-orm/core';
import { PaddleWaterGraphsRepository } from '../repositories/PaddleWaterGraphs.repository';
import { DbTimestampType } from '../types';
import { PaddleRegions } from './PaddleRegions.entity';
export class PaddleWaterGraphs {
[EntityRepositoryType]?: PaddleWaterGraphsRepository;
[PrimaryKeyProp]?: 'region';
region?: Ref<PaddleRegions> | null;
region_id?: string | null;
file!: string;
rev!: string;
bytes!: number;
built_at!: string;
stats!: string;
min_lat!: number;
min_lng!: number;
max_lat!: number;
max_lng!: number;
}
export const PaddleWaterGraphsSchema = defineEntity({
class: PaddleWaterGraphs,
repository: () => PaddleWaterGraphsRepository,
indexes: [
{
name: 'idx_paddle_water_graphs_box',
properties: ['min_lat', 'max_lat', 'min_lng', 'max_lng'],
},
],
uniques: [{ properties: ['region'] }],
properties: {
region: () => p.oneToOne(PaddleRegions).primary().ref().deleteRule('cascade').nullable().hidden(),
region_id: p.text().nullable().persist(false),
file: p.text(),
rev: p.text(),
bytes: p.integer(),
built_at: p.type(DbTimestampType),
stats: p.text(),
min_lat: p.double(),
min_lng: p.double(),
max_lat: p.double(),
max_lng: p.double(),
},
});
@@ -13,12 +13,16 @@ export class TourWaypoints {
sequence!: number;
fixed_leg?: string | null;
lock?: string | null;
shaping: number & Opt = 0;
}
export const TourWaypointsSchema = defineEntity({
class: TourWaypoints,
repository: () => TourWaypointsRepository,
uniques: [{ properties: ['place', 'sequence'] }],
checks: [
{ name: 'tour_waypoints_shaping_check', expression: 'shaping IN (0, 1)' },
],
properties: {
id: p.integer().primary(),
place: () => p.manyToOne(Tours).ref().name('place_id').deleteRule('cascade').hidden(),
@@ -29,5 +33,6 @@ export const TourWaypointsSchema = defineEntity({
sequence: p.integer(),
fixed_leg: p.text().nullable(),
lock: p.text().nullable(),
shaping: p.integer().default(0),
},
});
+9
View File
@@ -33,6 +33,7 @@ export class Tours {
paddle_speed_kmh?: number | null;
portage_speed_kmh?: number | null;
track_marks?: string | null;
paddle_lake_style?: TToursPaddleLakeStyle | null;
tourTypeRef!: Ref<TourTypes>;
tour_obstacle_reviews_collection = new Collection<TourObstacleReviews>(this);
tour_sources: Ref<TourSources> | null = null;
@@ -63,6 +64,13 @@ export const ToursWaterContext = {
export type TToursWaterContext = (typeof ToursWaterContext)[keyof typeof ToursWaterContext];
export const ToursPaddleLakeStyle = {
DIRECT: 'direct',
SHORE: 'shore',
} as const;
export type TToursPaddleLakeStyle = (typeof ToursPaddleLakeStyle)[keyof typeof ToursPaddleLakeStyle];
export const ToursSchema = defineEntity({
class: Tours,
repository: () => ToursRepository,
@@ -98,6 +106,7 @@ export const ToursSchema = defineEntity({
paddle_speed_kmh: p.double().nullable(),
portage_speed_kmh: p.double().nullable(),
track_marks: p.text().nullable(),
paddle_lake_style: p.enum(() => ToursPaddleLakeStyle).nullable(),
tourTypeRef: () => p.manyToOne(TourTypes).ref().joinColumn('tour_type').hidden().index('idx_tours_tour_type'),
tour_obstacle_reviews_collection: () => p.oneToMany(TourObstacleReviews).mappedBy('place').hidden(),
tour_sources: () => p.oneToOne(TourSources).ref().mappedBy('place').hidden(),
+4
View File
@@ -62,7 +62,9 @@ import { PackingTemplateCategoriesSchema } from './PackingTemplateCategories.ent
import { PackingTemplateItemsSchema } from './PackingTemplateItems.entity';
import { PackingTemplatesSchema } from './PackingTemplates.entity';
import { PaddleFeaturesSchema } from './PaddleFeatures.entity';
import { PaddleRegionRequestsSchema } from './PaddleRegionRequests.entity';
import { PaddleRegionsSchema } from './PaddleRegions.entity';
import { PaddleWaterGraphsSchema } from './PaddleWaterGraphs.entity';
import { PasswordResetTokensSchema } from './PasswordResetTokens.entity';
import { PhotoProviderFieldsSchema } from './PhotoProviderFields.entity';
import { PhotoProvidersSchema } from './PhotoProviders.entity';
@@ -204,7 +206,9 @@ export const ALL_ENTITIES: readonly EntitySchema[] = [
PackingTemplateItemsSchema,
PackingTemplatesSchema,
PaddleFeaturesSchema,
PaddleRegionRequestsSchema,
PaddleRegionsSchema,
PaddleWaterGraphsSchema,
PasswordResetTokensSchema,
PhotoProviderFieldsSchema,
PhotoProvidersSchema,
+9
View File
@@ -6,6 +6,7 @@ import {
paddleGeometrySchema,
paddleLockSchema,
paddleRegionBoundarySchema,
paddleRegionGraphStatsSchema,
tourFixedLegSchema,
trackMarksSchema,
type Ferrata,
@@ -13,6 +14,7 @@ import {
type PaddleGeometry,
type PaddleLock,
type PaddleRegionBoundary,
type PaddleRegionGraphStats,
type TourFixedLeg,
type TrackMarks,
} from '@trek/shared';
@@ -194,6 +196,13 @@ export const PADDLE_REGION_BOUNDARY: JsonColumn<PaddleRegionBoundary | null> = {
fallback: () => null,
};
/** What a paddle region's water network holds (PAD-11). A broken row has none, and the region shows no network figures. */
export const PADDLE_REGION_GRAPH_STATS: JsonColumn<PaddleRegionGraphStats | null> = {
column: 'paddle_water_graphs.stats',
schema: paddleRegionGraphStatsSchema.nullable(),
fallback: () => null,
};
/** What a paddle tour's obstacle decision keeps of its obstacle (PAD-06). A broken row has none, and the decision is left out. */
export const TOUR_OBSTACLE_SNAPSHOT: JsonColumn<ObstacleSnapshot | null> = {
column: 'tour_obstacle_reviews.obstacle_snapshot',
@@ -0,0 +1,13 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
import type { InsertOptional } from './columns';
export interface PaddleRegionRequestsTable {
region_id: string;
name: string;
reason: string;
bytes: InsertOptional<number | null>;
requested_by: InsertOptional<string | null>;
requested_at: InsertOptional<string>;
declined_at: InsertOptional<string | null>;
}
@@ -16,4 +16,5 @@ export interface PaddleRegionsTable {
feature_count: InsertOptional<number>;
error: InsertOptional<string | null>;
created_at: InsertOptional<string | null>;
origin: InsertOptional<string>;
}
@@ -0,0 +1,15 @@
// Generated by scripts/generate-db-types.ts from the migrated schema. Do not edit by hand:
// change the migration, then run `npm run gen:db-types`.
export interface PaddleWaterGraphsTable {
region_id: string;
file: string;
rev: string;
bytes: number;
built_at: string;
stats: string;
min_lat: number;
min_lng: number;
max_lat: number;
max_lng: number;
}
@@ -11,4 +11,5 @@ export interface TourWaypointsTable {
sequence: number;
fixed_leg: InsertOptional<string | null>;
lock: InsertOptional<string | null>;
shaping: InsertOptional<number>;
}
+1
View File
@@ -25,4 +25,5 @@ export interface ToursTable {
paddle_speed_kmh: InsertOptional<number | null>;
portage_speed_kmh: InsertOptional<number | null>;
track_marks: InsertOptional<string | null>;
paddle_lake_style: InsertOptional<string | null>;
}
+4
View File
@@ -67,7 +67,9 @@ import type { PackingTemplateCategoriesTable } from './PackingTemplateCategories
import type { PackingTemplateItemsTable } from './PackingTemplateItems.table';
import type { PackingTemplatesTable } from './PackingTemplates.table';
import type { PaddleFeaturesTable } from './PaddleFeatures.table';
import type { PaddleRegionRequestsTable } from './PaddleRegionRequests.table';
import type { PaddleRegionsTable } from './PaddleRegions.table';
import type { PaddleWaterGraphsTable } from './PaddleWaterGraphs.table';
import type { PasswordResetTokensTable } from './PasswordResetTokens.table';
import type { PhotoProviderFieldsTable } from './PhotoProviderFields.table';
import type { PhotoProvidersTable } from './PhotoProviders.table';
@@ -215,7 +217,9 @@ export interface DB {
packing_template_items: PackingTemplateItemsTable;
packing_templates: PackingTemplatesTable;
paddle_features: PaddleFeaturesTable;
paddle_region_requests: PaddleRegionRequestsTable;
paddle_regions: PaddleRegionsTable;
paddle_water_graphs: PaddleWaterGraphsTable;
password_reset_tokens: PasswordResetTokensTable;
photo_provider_fields: PhotoProviderFieldsTable;
photo_providers: PhotoProvidersTable;
@@ -0,0 +1,34 @@
import { Migration } from '@mikro-orm/migrations';
/**
* PAD-11: the water network a paddle region builds for routing, from the same
* read as its obstacles. The network itself is a file in the data folder
* (`file`, named after its revision `rev`, the first hex digits of its
* hash); the row says where it is, how big it came out, when it was built,
* what it holds (`stats`, JSON: kilometres of water, lakes, portages, gaps,
* locks) and the box it covers, so a point finds its network without reading
* the region. One row per region, gone with the region.
*/
export class Migration20200101044400_a_paddle_region_builds_its_water_network extends Migration {
override name = 'Migration20200101044400_a_paddle_region_builds_its_water_network';
override up(): void {
this.addSql(`
CREATE TABLE IF NOT EXISTS paddle_water_graphs (
region_id TEXT PRIMARY KEY REFERENCES paddle_regions(id) ON DELETE CASCADE,
file TEXT NOT NULL,
rev TEXT NOT NULL,
bytes INTEGER NOT NULL,
built_at DATETIME NOT NULL,
stats TEXT NOT NULL,
min_lat REAL NOT NULL,
min_lng REAL NOT NULL,
max_lat REAL NOT NULL,
max_lng REAL NOT NULL
)
`);
this.addSql(
`CREATE INDEX IF NOT EXISTS idx_paddle_water_graphs_box ON paddle_water_graphs (min_lat, max_lat, min_lng, max_lng)`,
);
}
}
@@ -0,0 +1,14 @@
import { Migration } from '@mikro-orm/migrations';
/**
* P2A-03: a waypoint can be a shaping point, there only to steer the route:
* no stop, no place to get in or out, nothing a day plan shows. 0 or 1;
* every waypoint saved before is a stop, as it was.
*/
export class Migration20200101044500_a_tour_waypoint_can_shape_the_route extends Migration {
override name = 'Migration20200101044500_a_tour_waypoint_can_shape_the_route';
override up(): void {
this.addSql(`ALTER TABLE tour_waypoints ADD COLUMN shaping INTEGER NOT NULL DEFAULT 0 CHECK (shaping IN (0, 1))`);
}
}
@@ -0,0 +1,16 @@
import { Migration } from '@mikro-orm/migrations';
/**
* PAD-11: how a paddle tour crosses its lakes, as the router planned it:
* `direct` (straight over, also what NULL means) or `shore` (along the
* shore, for wind). Kept with the tour so it routes the same when opened again.
*/
export class Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes extends Migration {
override name = 'Migration20200101044600_a_paddle_tour_remembers_how_it_crosses_lakes';
override up(): void {
this.addSql(
`ALTER TABLE tours ADD COLUMN paddle_lake_style TEXT CHECK (paddle_lake_style IS NULL OR paddle_lake_style IN ('direct', 'shore'))`,
);
}
}
@@ -0,0 +1,35 @@
import { Migration } from '@mikro-orm/migrations';
/**
* PAD-11C: TREK fetches the water data of a place a paddler plans in by
* itself. `paddle_regions.origin` says who added a region: an admin by hand
* (`admin`, every region before this) or TREK for a paddler (`auto`), which
* also counts against the few a day it may add.
*
* `paddle_region_requests` holds a region that waits for an admin instead:
* the mode asks for it, it is larger than allowed or the day's few are used
* up (`reason`). It keeps the size Geofabrik announced, the name of whoever
* asked (as it read then; no link to the account, a request outlives it) and
* when, and whether an admin declined it (then it is not asked for again). A
* request goes once its region is added.
*/
export class Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans extends Migration {
override name = 'Migration20200101044700_paddle_water_data_loads_where_a_paddler_plans';
override up(): void {
this.addSql(
`ALTER TABLE paddle_regions ADD COLUMN origin TEXT NOT NULL DEFAULT 'admin' CHECK (origin IN ('admin', 'auto'))`,
);
this.addSql(`
CREATE TABLE IF NOT EXISTS paddle_region_requests (
region_id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
reason TEXT NOT NULL CHECK (reason IN ('ask', 'too_large', 'limit')),
bytes INTEGER,
requested_by TEXT,
requested_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
declined_at DATETIME
)
`);
}
}
@@ -0,0 +1,80 @@
import type { PaddleRegionRequests, TPaddleRegionRequestsReason } from '../entities/PaddleRegionRequests.entity';
import type { DB } from '../kysely/db';
import { dbNow } from '../types';
import { TrekRepository } from './_shared/trek-repository';
/** A region a paddler's planning asked for, as stored. */
export interface PaddleRegionRequestRow {
region_id: string;
name: string;
reason: TPaddleRegionRequestsReason;
bytes: number | null;
requested_by: string | null;
requested_at: string;
declined_at: string | null;
}
/** `reason` narrowed to the three values the column's CHECK constraint allows. */
type RequestsDB = {
paddle_region_requests: Omit<DB['paddle_region_requests'], 'reason'> & { reason: TPaddleRegionRequestsReason };
};
const COLUMNS = ['region_id', 'name', 'reason', 'bytes', 'requested_by', 'requested_at', 'declined_at'] as const;
/** `paddle_region_requests`: regions that wait for an admin before TREK loads them (PAD-11C). */
export class PaddleRegionRequestsRepository extends TrekRepository<PaddleRegionRequests> {
private db() {
return this.kysely<RequestsDB>();
}
/** Every request, the newest first. */
async list(): Promise<PaddleRegionRequestRow[]> {
return await this.db()
.selectFrom('paddle_region_requests')
.select(COLUMNS)
.orderBy('requested_at', 'desc')
.orderBy('region_id', 'asc')
.execute();
}
async findByRegion(regionId: string): Promise<PaddleRegionRequestRow | undefined> {
return await this.db()
.selectFrom('paddle_region_requests')
.select(COLUMNS)
.where('region_id', '=', regionId)
.executeTakeFirst();
}
/** Files a request; one already there, declined or not, stays as it is. */
async file(row: {
region_id: string;
name: string;
reason: TPaddleRegionRequestsReason;
bytes: number | null;
requested_by: string;
}): Promise<boolean> {
const result = await this.db()
.insertInto('paddle_region_requests')
.values({ ...row, requested_at: dbNow() })
.onConflict((oc) => oc.column('region_id').doNothing())
.executeTakeFirst();
return Number(result.numInsertedOrUpdatedRows ?? 0) > 0;
}
async decline(regionId: string): Promise<boolean> {
const result = await this.db()
.updateTable('paddle_region_requests')
.set({ declined_at: dbNow() })
.where('region_id', '=', regionId)
.executeTakeFirst();
return Number(result.numUpdatedRows ?? 0) > 0;
}
async remove(regionId: string): Promise<boolean> {
const result = await this.db()
.deleteFrom('paddle_region_requests')
.where('region_id', '=', regionId)
.executeTakeFirst();
return Number(result.numDeletedRows ?? 0) > 0;
}
}
@@ -1,4 +1,10 @@
import { type PaddleRegions, PaddleRegionsStatus, type TPaddleRegionsStatus } from '../entities/PaddleRegions.entity';
import {
type PaddleRegions,
PaddleRegionsOrigin,
PaddleRegionsStatus,
type TPaddleRegionsOrigin,
type TPaddleRegionsStatus,
} from '../entities/PaddleRegions.entity';
import type { DB } from '../kysely/db';
import type { Box } from './PaddleFeatures.repository';
import { TrekRepository } from './_shared/trek-repository';
@@ -17,11 +23,15 @@ export interface PaddleRegionRow {
imported_at: string | null;
feature_count: number;
error: string | null;
origin: TPaddleRegionsOrigin;
}
/** `status` narrowed to the four values the column's CHECK constraint allows. */
/** `status` and `origin` narrowed to the values the columns' CHECK constraints allow. */
type RegionsDB = {
paddle_regions: Omit<DB['paddle_regions'], 'status'> & { status: TPaddleRegionsStatus };
paddle_regions: Omit<DB['paddle_regions'], 'status' | 'origin'> & {
status: TPaddleRegionsStatus;
origin: TPaddleRegionsOrigin;
};
};
const COLUMNS = [
@@ -37,6 +47,7 @@ const COLUMNS = [
'imported_at',
'feature_count',
'error',
'origin',
] as const;
/** `paddle_regions`: the regional extracts an admin chose for paddle data (PAD-05A). */
@@ -76,6 +87,40 @@ export class PaddleRegionsRepository extends TrekRepository<PaddleRegions> {
.execute();
}
/** Every region whose box holds the point, whatever its state; the caller checks the boundary. */
async aroundPoint(lat: number, lng: number): Promise<PaddleRegionRow[]> {
return await this.db()
.selectFrom('paddle_regions')
.select(COLUMNS)
.where('min_lat', '<=', lat)
.where('max_lat', '>=', lat)
.where('min_lng', '<=', lng)
.where('max_lng', '>=', lng)
.orderBy('id', 'asc')
.execute();
}
/** The regions waiting for their read, oldest first: the order they are read in. */
async queued(): Promise<Array<Pick<PaddleRegionRow, 'id'>>> {
return await this.db()
.selectFrom('paddle_regions')
.select('id')
.where('status', '=', PaddleRegionsStatus.QUEUED)
.orderBy('created_at', 'asc')
.execute();
}
/** How many regions TREK added by itself since `since` (a canonical UTC timestamp). */
async countAddedAutomaticallySince(since: string): Promise<number> {
const row = await this.db()
.selectFrom('paddle_regions')
.select((eb) => eb.fn.countAll<number>().as('count'))
.where('origin', '=', PaddleRegionsOrigin.AUTO)
.where('created_at', '>=', since)
.executeTakeFirst();
return Number(row?.count ?? 0);
}
/** The first region waiting for its read, oldest first. */
async nextQueued(): Promise<PaddleRegionRow | undefined> {
return await this.db()
@@ -88,10 +133,11 @@ export class PaddleRegionsRepository extends TrekRepository<PaddleRegions> {
async insertRegion(
row: Pick<PaddleRegionRow, 'id' | 'name' | 'boundary' | 'min_lat' | 'min_lng' | 'max_lat' | 'max_lng'>,
origin: TPaddleRegionsOrigin = PaddleRegionsOrigin.ADMIN,
): Promise<void> {
await this.db()
.insertInto('paddle_regions')
.values({ ...row, status: PaddleRegionsStatus.QUEUED, feature_count: 0 })
.values({ ...row, status: PaddleRegionsStatus.QUEUED, feature_count: 0, origin })
.onConflict((oc) => oc.column('id').doNothing())
.execute();
}
@@ -0,0 +1,86 @@
import type { PaddleWaterGraphs } from '../entities/PaddleWaterGraphs.entity';
import type { DB } from '../kysely/db';
import type { DbTimestamp } from '../types';
import { TrekRepository } from './_shared/trek-repository';
/** A region's water network as stored: where its file is, what it holds (`stats` still JSON text), the box it covers. */
export interface PaddleWaterGraphRow {
region_id: string;
file: string;
rev: string;
bytes: number;
built_at: string;
stats: string;
min_lat: number;
min_lng: number;
max_lat: number;
max_lng: number;
}
const COLUMNS = [
'region_id',
'file',
'rev',
'bytes',
'built_at',
'stats',
'min_lat',
'min_lng',
'max_lat',
'max_lng',
] as const;
/** `paddle_water_graphs`: the water network each paddle region built for routing (PAD-11). */
export class PaddleWaterGraphsRepository extends TrekRepository<PaddleWaterGraphs> {
private db() {
return this.kysely<Pick<DB, 'paddle_water_graphs'>>();
}
async list(): Promise<PaddleWaterGraphRow[]> {
return await this.db().selectFrom('paddle_water_graphs').select(COLUMNS).orderBy('region_id', 'asc').execute();
}
async findByRegion(regionId: string): Promise<PaddleWaterGraphRow | undefined> {
return await this.db()
.selectFrom('paddle_water_graphs')
.select(COLUMNS)
.where('region_id', '=', regionId)
.executeTakeFirst();
}
/** The networks whose box holds the point, smallest box first: the most local data plans the leg. */
async coveringPoint(lat: number, lng: number): Promise<PaddleWaterGraphRow[]> {
const rows = await this.db()
.selectFrom('paddle_water_graphs')
.select(COLUMNS)
.where('min_lat', '<=', lat)
.where('max_lat', '>=', lat)
.where('min_lng', '<=', lng)
.where('max_lng', '>=', lng)
.orderBy('region_id', 'asc')
.execute();
const area = (row: PaddleWaterGraphRow) => (row.max_lat - row.min_lat) * (row.max_lng - row.min_lng);
return rows.sort((a, b) => area(a) - area(b));
}
/** Records a region's new network, replacing the one it had. */
async saveGraph(row: Omit<PaddleWaterGraphRow, 'built_at'> & { built_at: DbTimestamp }): Promise<void> {
await this.db()
.insertInto('paddle_water_graphs')
.values(row)
.onConflict((oc) =>
oc.column('region_id').doUpdateSet({
file: row.file,
rev: row.rev,
bytes: row.bytes,
built_at: row.built_at,
stats: row.stats,
min_lat: row.min_lat,
min_lng: row.min_lng,
max_lat: row.max_lat,
max_lng: row.max_lng,
}),
)
.execute();
}
}
@@ -15,6 +15,8 @@ export interface TourWaypointRow {
fixed_leg?: TourFixedLeg | null;
/** A lock at this point of a paddle tour; absent where there is none. */
lock?: PaddleLock | null;
/** P2A-03: a shaping point, steering the route only; absent for a stop. */
shaping?: boolean;
}
/** The row as stored, the fixed leg still JSON text. */
@@ -25,13 +27,19 @@ interface StoredWaypoint {
sequence: number;
fixed_leg: string | null;
lock: string | null;
shaping: number;
}
/** A stored row as callers read it: the fixed leg and the lock decoded, each left out entirely where there is none. */
function readWaypoint({ fixed_leg, lock, ...point }: StoredWaypoint): TourWaypointRow {
/** A stored row as callers read it: the fixed leg, the lock and the shaping flag, each left out entirely where there is none. */
function readWaypoint({ fixed_leg, lock, shaping, ...point }: StoredWaypoint): TourWaypointRow {
const leg = decodeJson(TOUR_WAYPOINT_FIXED_LEG, fixed_leg, 'tour waypoint');
const stop = decodeJson(TOUR_WAYPOINT_LOCK, lock, 'tour waypoint');
return { ...point, ...(leg ? { fixed_leg: leg } : {}), ...(stop ? { lock: stop } : {}) };
return {
...point,
...(leg ? { fixed_leg: leg } : {}),
...(stop ? { lock: stop } : {}),
...(shaping ? { shaping: true } : {}),
};
}
/** {@link TourWaypointRow} plus the tour it belongs to, as the trip copy reads it. */
@@ -54,11 +62,11 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
return this.kysely<TourWaypointsKyselyDB>();
}
/** TW1 — `SELECT lat, lng, role, sequence, fixed_leg, lock FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */
/** TW1 — `SELECT lat, lng, role, sequence, fixed_leg, lock, shaping FROM tour_waypoints WHERE place_id = ? ORDER BY sequence`. */
async listForPlace(place_id: number): Promise<TourWaypointRow[]> {
const rows = await this.db()
.selectFrom('tour_waypoints')
.select(['lat', 'lng', 'role', 'sequence', 'fixed_leg', 'lock'])
.select(['lat', 'lng', 'role', 'sequence', 'fixed_leg', 'lock', 'shaping'])
.where('place_id', '=', place_id)
.orderBy('sequence', 'asc')
.execute();
@@ -70,7 +78,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
const rows = await this.db()
.selectFrom('tour_waypoints as tw')
.innerJoin('places as p', 'p.id', 'tw.place_id')
.select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence', 'tw.fixed_leg', 'tw.lock'])
.select(['tw.place_id', 'tw.lat', 'tw.lng', 'tw.role', 'tw.sequence', 'tw.fixed_leg', 'tw.lock', 'tw.shaping'])
.where('p.trip_id', '=', trip_id)
.orderBy('tw.place_id', 'asc')
.orderBy('tw.sequence', 'asc')
@@ -79,7 +87,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
}
/**
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg, lock)
* TW3 — `INSERT INTO tour_waypoints (place_id, lat, lng, role, sequence, fixed_leg, lock, shaping)
* VALUES (...)` for each point. The caller owns the transaction: a create
* writes the tour first, an edit clears the old points first.
*/
@@ -94,6 +102,7 @@ export class TourWaypointsRepository extends TrekRepository<TourWaypoints> {
sequence: w.sequence,
fixed_leg: w.fixed_leg ? encodeJson(TOUR_WAYPOINT_FIXED_LEG, w.fixed_leg) : null,
lock: w.lock ? encodeJson(TOUR_WAYPOINT_LOCK, w.lock) : null,
shaping: w.shaping ? 1 : 0,
})),
);
}
@@ -2,7 +2,7 @@ import { type Tours, type TToursOrigin, ToursOrigin } from '../entities/Tours.en
import type { DB } from '../kysely/db';
import type { AssertRowKeys } from './_shared/rows';
import { TrekRepository } from './_shared/trek-repository';
import type { PaddleBoatType, PaddleWaterContext } from '@trek/shared';
import type { PaddleBoatType, PaddleLakeStyle, PaddleWaterContext } from '@trek/shared';
/**
* A tour as the Tours list and detail read it: the `tours` facet joined with
@@ -32,6 +32,7 @@ export interface TourListRow {
water_context: string | null;
paddle_speed_kmh: number | null;
portage_speed_kmh: number | null;
paddle_lake_style: string | null;
/** The marks laid onto a recorded track, still JSON text. */
track_marks: string | null;
has_recorded_track: number;
@@ -62,6 +63,7 @@ export interface TourRow {
water_context: string | null;
paddle_speed_kmh: number | null;
portage_speed_kmh: number | null;
paddle_lake_style: string | null;
track_marks: string | null;
}
const _tourRowKeys: AssertRowKeys<TourRow, Tours> = true;
@@ -83,6 +85,7 @@ export interface TourInsert {
water_context?: PaddleWaterContext | null;
paddle_speed_kmh?: number | null;
portage_speed_kmh?: number | null;
paddle_lake_style?: PaddleLakeStyle | null;
}
/** What a route edit replaces on an existing tour: the type and everything that belongs to it. */
@@ -95,6 +98,7 @@ export type TourUpdate = Omit<
| 'water_context'
| 'paddle_speed_kmh'
| 'portage_speed_kmh'
| 'paddle_lake_style'
> & {
planned_duration_minutes?: number | null;
break_additional_minutes?: number | null;
@@ -102,6 +106,7 @@ export type TourUpdate = Omit<
water_context: PaddleWaterContext | null;
paddle_speed_kmh: number | null;
portage_speed_kmh: number | null;
paddle_lake_style: PaddleLakeStyle | null;
};
/** A recorded track's paddle plan (PAD-03, PAD-07): its speeds, its marks and the time they make. */
@@ -193,6 +198,7 @@ export class ToursRepository extends TrekRepository<Tours> {
't.water_context as water_context',
't.paddle_speed_kmh as paddle_speed_kmh',
't.portage_speed_kmh as portage_speed_kmh',
't.paddle_lake_style as paddle_lake_style',
't.track_marks as track_marks',
eb('t.recorded_route_geometry', 'is not', null).as('has_recorded_track'),
eb
@@ -449,6 +455,7 @@ export class ToursRepository extends TrekRepository<Tours> {
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
portage_speed_kmh: input.portage_speed_kmh ?? null,
paddle_lake_style: input.paddle_lake_style ?? null,
track_marks: input.track_marks ?? null,
});
}
@@ -501,6 +508,7 @@ export class ToursRepository extends TrekRepository<Tours> {
water_context: input.water_context,
paddle_speed_kmh: input.paddle_speed_kmh,
portage_speed_kmh: input.portage_speed_kmh,
paddle_lake_style: input.paddle_lake_style,
})
.where('place_id', '=', place_id)
.where('place_id', 'in', (eb) => eb.selectFrom('places').select('id').where('trip_id', '=', trip_id))
+2
View File
@@ -63,6 +63,7 @@ import { OauthModule } from './oauth/oauth.module';
import { OidcModule } from './oidc/oidc.module';
import { PackingModule } from './packing/packing.module';
import { PaddleObstaclesModule } from './paddle-obstacles/paddle-obstacles.module';
import { PaddleRoutingModule } from './paddle-routing/paddle-routing.module';
import { PermissionsModule } from './permissions/permissions.module';
import { PhotosModule } from './photos/photos.module';
import { PlaceEnrichmentModule } from './place-enrichment/place-enrichment.module';
@@ -158,6 +159,7 @@ import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
WandererModule,
FerratasModule,
PaddleObstaclesModule,
PaddleRoutingModule,
TripsModule,
CollabModule,
FilesModule,
@@ -109,6 +109,9 @@ export const APP_SETTINGS = {
// Off until an admin allows it: every user's wanderer calls go out to an address of their choosing.
tours_wanderer_enabled: { schema: flag, default: 'false' },
route_usage_enabled: { schema: flag, default: 'false' },
// PAD-11C: how TREK gets the water data of a place a paddler plans in (auto, ask, off) and the largest extract it loads by itself.
paddle_auto_load: { schema: z.enum(['auto', 'ask', 'off']), default: 'auto' },
paddle_auto_max_mb: { schema: intText, default: '500' },
airtrail_sync_enabled: { schema: flag, default: 'true' },
airtrail_poll_interval_minutes: { schema: intText, default: null },
dawarich_poll_interval_minutes: { schema: intText, default: null },
@@ -40,6 +40,7 @@ export type NotifEventType =
| 'packing_tagged'
| 'version_available'
| 'replica_failure'
| 'paddle_region_requested'
| 'synology_session_cleared'
| 'plugin_notification';
@@ -57,12 +58,17 @@ export const ALL_EVENT_TYPES: NotifEventType[] = [
'packing_tagged',
'version_available',
'replica_failure',
'paddle_region_requested',
'synology_session_cleared',
'plugin_notification',
];
/** Events that target admins only (shown in admin panel, not in user settings). */
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>(['version_available', 'replica_failure']);
export const ADMIN_SCOPED_EVENTS = new Set<NotifEventType>([
'version_available',
'replica_failure',
'paddle_region_requested',
]);
// Compile-time guard: shared NotificationEventKey and server NotifEventType must
// stay in sync. It sits next to the union it guards — it used to live in the
@@ -221,6 +221,13 @@ const EVENT_NOTIFICATION_CONFIG: Record<string, EventNotifConfig> = {
navigateTextKey: 'notif.action.view',
navigateTarget: () => '/admin?tab=storage',
},
paddle_region_requested: {
inAppType: 'navigate',
titleKey: 'notif.paddle_region_requested.title',
textKey: 'notif.paddle_region_requested.text',
navigateTextKey: 'notif.action.view_admin',
navigateTarget: () => '/admin?tab=addons',
},
synology_session_cleared: {
inAppType: 'simple',
titleKey: 'notifications.synologySessionCleared.title',
@@ -33,6 +33,7 @@ const NTFY_EVENT_META: Partial<Record<NotifEventType, { priority: 1 | 2 | 3 | 4
packing_tagged: { priority: 3, tags: ['luggage'] },
version_available: { priority: 4, tags: ['package'] },
replica_failure: { priority: 5, tags: ['warning', 'floppy_disk'] },
paddle_region_requested: { priority: 3, tags: ['canoe'] },
synology_session_cleared: { priority: 3, tags: ['warning'] },
};
const NTFY_DEFAULT_META = { priority: 3 as const, tags: [] as string[] };
@@ -1,4 +1,5 @@
import { readCappedJson } from '../../utils/cappedFetch';
import { logWarn } from '../audit/audit-log.logger';
import type { RegionBoundary } from './paddle-geo';
import { Injectable } from '@nestjs/common';
import { paddleRegionBoundarySchema } from '@trek/shared';
@@ -23,6 +24,7 @@ const INDEX_TIMEOUT_MS = 20_000;
/** The list is a few megabytes; far more means it is not the list. */
const MAX_INDEX_BYTES = 32 * 1024 * 1024;
const INDEX_FRESH_MS = 24 * 60 * 60 * 1000;
const SIZE_TIMEOUT_MS = 10_000;
/** A whole country is a few gigabytes; a regional extract far less. More than this is refused. */
export const MAX_EXTRACT_BYTES = 4 * 1024 * 1024 * 1024;
const DOWNLOAD_TIMEOUT_MS = 60 * 60 * 1000;
@@ -65,15 +67,20 @@ export function isExtractUrl(url: string): boolean {
* A redirect anywhere else is refused, so the list's addresses are the only
* ones TREK ever reads an extract from.
*/
async function fetchFromGeofabrik(url: string, signal: AbortSignal, hops = 0): Promise<Response> {
const response = await fetch(url, { signal, redirect: 'manual' });
async function fetchFromGeofabrik(
url: string,
signal: AbortSignal,
hops = 0,
method: 'GET' | 'HEAD' = 'GET',
): Promise<Response> {
const response = await fetch(url, { signal, redirect: 'manual', method });
if (response.status < 300 || response.status >= 400) return response;
await response.body?.cancel().catch(() => undefined);
const location = response.headers.get('location');
const next = location ? new URL(location, url) : null;
if (!next || !isGeofabrikUrl(next)) throw new Error('Geofabrik redirected away from its own servers');
if (hops >= MAX_REDIRECTS) throw new Error('Geofabrik redirected too often');
return await fetchFromGeofabrik(next.href, signal, hops + 1);
return await fetchFromGeofabrik(next.href, signal, hops + 1, method);
}
@Injectable()
@@ -107,6 +114,24 @@ export class GeofabrikClient {
return regions;
}
/**
* The size of an extract the list names, in bytes, as Geofabrik announces
* it; null when it does not say or cannot be asked right now. Nothing is
* downloaded: it is asked with HEAD.
*/
async sizeOf(url: string): Promise<number | null> {
if (!isExtractUrl(url)) throw new Error('Not a Geofabrik extract');
try {
const response = await fetchFromGeofabrik(url, AbortSignal.timeout(SIZE_TIMEOUT_MS), 0, 'HEAD');
const size = Number(response.headers.get('content-length'));
return response.ok && Number.isSafeInteger(size) && size > 0 ? size : null;
} catch (err) {
// Not knowing the size is no failure: the caller treats it as unknown and the download still caps it.
logWarn(`[Paddle] size of ${url}: ${err instanceof Error ? err.message : String(err)}`);
return null;
}
}
/**
* One region's extract written to `target`. The address must be one the
* list named; the body is counted as it streams and cut at `maxBytes`
@@ -0,0 +1,97 @@
import { type ImportOutcome, type ImportPhase, importExtract } from './paddle-import';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { Worker } from 'node:worker_threads';
/** Reads a region's extract; in a thread of its own where the build ships the script, else on the calling thread. */
export interface ImportRunner {
run(
path: string,
regionId: string,
onProgress: (phase: ImportPhase, fraction: number | null) => void,
signal: AbortSignal,
): Promise<ImportOutcome>;
}
export const PADDLE_IMPORT_RUNNER = Symbol('PADDLE_IMPORT_RUNNER');
/** The thread's heap: a large region's read builds its network in this much, and fails cleanly when it does not fit. */
const IMPORT_HEAP_MB = 1536;
export class InlineImportRunner implements ImportRunner {
async run(
path: string,
regionId: string,
onProgress: (phase: ImportPhase, fraction: number | null) => void,
signal: AbortSignal,
): Promise<ImportOutcome> {
const outcome = await importExtract(path, regionId, onProgress);
signal.throwIfAborted();
return outcome;
}
}
/**
* The read in a worker thread: the server's thread stays free while a large
* region takes minutes, and a read that runs out of memory ends its thread,
* not the server. Calling it off ends the thread at once.
*/
export class WorkerImportRunner implements ImportRunner {
constructor(private readonly entry: string) {}
async run(
path: string,
regionId: string,
onProgress: (phase: ImportPhase, fraction: number | null) => void,
signal: AbortSignal,
): Promise<ImportOutcome> {
signal.throwIfAborted();
const execArgv = process.execArgv.filter(
(arg, index, all) =>
arg === '--require' || arg === '-r' || all[index - 1] === '--require' || all[index - 1] === '-r',
);
const worker = new Worker(this.entry, {
workerData: { path, regionId },
execArgv,
resourceLimits: { maxOldGenerationSizeMb: IMPORT_HEAP_MB },
});
return await new Promise<ImportOutcome>((resolve, reject) => {
let settled = false;
const finish = (action: () => void) => {
if (settled) return;
settled = true;
signal.removeEventListener('abort', abort);
action();
void worker.terminate();
};
const abort = () =>
finish(() => reject(signal.reason instanceof Error ? signal.reason : new Error(String(signal.reason))));
signal.addEventListener('abort', abort);
worker.on(
'message',
(message: {
progress?: { phase: ImportPhase; fraction: number | null };
outcome?: ImportOutcome;
error?: string;
}) => {
if (message.progress) onProgress(message.progress.phase, message.progress.fraction);
else if (message.outcome) {
const outcome = message.outcome;
if (outcome.graph) outcome.graph = { ...outcome.graph, bytes: new Uint8Array(outcome.graph.bytes) };
finish(() => resolve(outcome));
} else finish(() => reject(new Error(message.error ?? 'the read failed')));
},
);
worker.on('error', (err) => finish(() => reject(err)));
worker.on('exit', (code) =>
finish(() => reject(new Error(`the read ended early (${code}); a smaller region may fit`))),
);
});
}
}
export function importRunner(): ImportRunner {
const entry = join(__dirname, 'paddle-import.worker.js');
return existsSync(entry) ? new WorkerImportRunner(entry) : new InlineImportRunner();
}
@@ -0,0 +1,79 @@
/**
* The positions of the nodes a read needs, held as sorted typed arrays rather
* than a Set and a Map: a lake-rich region asks for millions of nodes, which
* as objects would cost several times the memory and run into V8's limit on
* the size of a Map. Ids are collected first, then sorted once; the pass over
* the file's nodes fills in their positions at 1e-7 degrees, OSM's own
* resolution.
*/
export class NodeStore {
private ids = new Float64Array(1024);
private count = 0;
private sealed = false;
private lats = new Int32Array(0);
private lons = new Int32Array(0);
private found = new Uint8Array(0);
get size(): number {
return this.count;
}
want(id: number): void {
if (this.sealed) throw new Error('NodeStore is sealed');
if (this.count === this.ids.length) {
const grown = new Float64Array(this.ids.length * 2);
grown.set(this.ids);
this.ids = grown;
}
this.ids[this.count++] = id;
}
wantAll(ids: readonly number[]): void {
for (const id of ids) this.want(id);
}
/** Sorts and dedupes the ids; from here on positions can be filled in. */
seal(): void {
if (this.sealed) return;
const sorted = this.ids.subarray(0, this.count).sort();
let unique = 0;
for (let i = 0; i < sorted.length; i++) {
if (i === 0 || sorted[i] !== sorted[unique - 1]) sorted[unique++] = sorted[i] as number;
}
this.ids = sorted.slice(0, unique);
this.count = unique;
this.lats = new Int32Array(unique);
this.lons = new Int32Array(unique);
this.found = new Uint8Array(unique);
this.sealed = true;
}
private indexOf(id: number): number {
let low = 0;
let high = this.count - 1;
while (low <= high) {
const mid = (low + high) >>> 1;
const at = this.ids[mid] as number;
if (at === id) return mid;
if (at < id) low = mid + 1;
else high = mid - 1;
}
return -1;
}
/** Records a node's position if the read wants it. */
offer(id: number, lat: number, lon: number): void {
const index = this.indexOf(id);
if (index < 0) return;
this.lats[index] = Math.round(lat * 1e7);
this.lons[index] = Math.round(lon * 1e7);
this.found[index] = 1;
}
/** [lat, lon] of a node, or null when the file did not hold it. */
get(id: number): [number, number] | null {
const index = this.indexOf(id);
if (index < 0 || this.found[index] === 0) return null;
return [(this.lats[index] as number) / 1e7, (this.lons[index] as number) / 1e7];
}
}
@@ -5,8 +5,9 @@ import { inflateSync } from 'node:zlib';
* A reader for OpenStreetMap's PBF format, as Geofabrik's regional extracts
* come: the file is a run of blobs, each a length-prefixed header and a
* (usually zlib-compressed) block. A block holds its own string table and
* groups of nodes (packed as "dense" nodes) and ways. Relations are not read:
* every object PAD-05A evaluates is a node or a way.
* groups of nodes (packed as "dense" nodes), ways and relations. A visitor
* names what it wants, and only that is decoded: a pass that needs nothing
* but positions takes them without the nodes' tags.
*
* Only what the paddle data needs is decoded, and nothing is kept between
* blocks, so a region of any size is read in the memory of one block (a few
@@ -36,12 +37,25 @@ export interface OsmWay {
tags: Record<string, string>;
}
type OsmMemberType = 'node' | 'way' | 'relation';
export interface OsmRelation {
id: number;
members: Array<{ type: OsmMemberType; ref: number; role: string }>;
tags: Record<string, string>;
}
export interface OsmBlockVisitor {
/** Every node of the block, tagged or not; `tags` is empty for most. */
node?: (node: OsmNode) => void;
/** Every node's position alone, its tags left undecoded; ignored when `node` is given. */
coord?: (id: number, lat: number, lon: number) => void;
way?: (way: OsmWay) => void;
relation?: (relation: OsmRelation) => void;
}
const MEMBER_TYPES: readonly OsmMemberType[] = ['node', 'way', 'relation'];
/** A protobuf field walker over one buffer, enough for the OSM schema. */
class Proto {
pos: number;
@@ -189,6 +203,28 @@ function tagsOf(
return tags;
}
function readDenseCoords(buf: Buffer, ctx: BlockContext, visit: NonNullable<OsmBlockVisitor['coord']>): void {
const p = new Proto(buf);
let ids: number[] = [];
let lats: number[] = [];
let lons: number[] = [];
for (let key = p.key(); key; key = p.key()) {
if (key.field === 1) ids = p.packed(true);
else if (key.field === 8) lats = p.packed(true);
else if (key.field === 9) lons = p.packed(true);
else p.skip(key.wire);
}
let id = 0;
let lat = 0;
let lon = 0;
for (let index = 0; index < ids.length; index++) {
id += ids[index] as number;
lat += lats[index] ?? 0;
lon += lons[index] ?? 0;
visit(id, (ctx.latOffset + ctx.granularity * lat) * 1e-9, (ctx.lonOffset + ctx.granularity * lon) * 1e-9);
}
}
function readDense(buf: Buffer, ctx: BlockContext, visit: (node: OsmNode) => void): void {
const p = new Proto(buf);
let ids: number[] = [];
@@ -267,7 +303,36 @@ function readWay(buf: Buffer, strings: readonly string[]): OsmWay {
return { id, refs, tags: tagsOf(keys, values, strings) };
}
/** One data block: its string table, then each group's nodes and ways handed to the visitor. */
function readRelation(buf: Buffer, strings: readonly string[]): OsmRelation {
const p = new Proto(buf);
let id = 0;
let keys: number[] = [];
let values: number[] = [];
let roles: number[] = [];
let memberDeltas: number[] = [];
let types: number[] = [];
for (let key = p.key(); key; key = p.key()) {
if (key.field === 1) id = p.varint();
else if (key.field === 2) keys = p.packed(false);
else if (key.field === 3) values = p.packed(false);
else if (key.field === 8) roles = p.packed(false);
else if (key.field === 9) memberDeltas = p.packed(true);
else if (key.field === 10) types = p.packed(false);
else p.skip(key.wire);
}
let ref = 0;
const members = memberDeltas.map((delta, index) => {
ref += delta;
return {
type: MEMBER_TYPES[types[index] ?? 0] ?? 'node',
ref,
role: strings[roles[index] ?? 0] ?? '',
};
});
return { id, members, tags: tagsOf(keys, values, strings) };
}
/** One data block: its string table, then each group's nodes, ways and relations handed to the visitor. */
export function readDataBlock(block: Buffer, visitor: OsmBlockVisitor): void {
const p = new Proto(block);
const strings: string[] = [];
@@ -286,17 +351,26 @@ export function readDataBlock(block: Buffer, visitor: OsmBlockVisitor): void {
else if (key.field === 20 && key.wire === 0) ctx.lonOffset = p.int64();
else p.skip(key.wire);
}
const { node, coord, way, relation } = visitor;
for (const group of groups) {
const g = new Proto(group);
for (let key = g.key(); key; key = g.key()) {
if (key.field === 1 && key.wire === 2 && visitor.node) visitor.node(readNode(g.bytes(), ctx));
else if (key.field === 2 && key.wire === 2 && visitor.node) readDense(g.bytes(), ctx, visitor.node);
else if (key.field === 3 && key.wire === 2 && visitor.way) visitor.way(readWay(g.bytes(), strings));
if (key.wire !== 2) g.skip(key.wire);
else if (key.field === 1 && (node || coord)) visitNode(readNode(g.bytes(), ctx), visitor);
else if (key.field === 2 && node) readDense(g.bytes(), ctx, node);
else if (key.field === 2 && coord) readDenseCoords(g.bytes(), ctx, coord);
else if (key.field === 3 && way) way(readWay(g.bytes(), strings));
else if (key.field === 4 && relation) relation(readRelation(g.bytes(), strings));
else g.skip(key.wire);
}
}
}
function visitNode(read: OsmNode, visitor: OsmBlockVisitor): void {
if (visitor.node) visitor.node(read);
else visitor.coord?.(read.id, read.lat, read.lon);
}
/**
* The bytes read from a file and not yet taken as a blob, kept as the chunks
* they arrived in and joined only when a blob reaches across chunks.
@@ -1,3 +1,4 @@
import { NodeStore } from './node-store';
import { readPbfFile, type OsmWay } from './osm-pbf.reader';
import { keptTags, paddleKindOf, paddleNameOf } from './paddle-tags';
import { compareCodeUnits, type PaddleGeometry, type PaddleObstacleKind } from '@trek/shared';
@@ -6,10 +7,11 @@ import { createHash } from 'node:crypto';
import { stat } from 'node:fs/promises';
/**
* PAD-05A: the paddle objects of one regional extract, read in two passes.
* The first keeps every tagged node that is one, and the ways that are, with
* the ids of their nodes; the second looks those nodes' positions up. Only
* the nodes a kept way needs are held, so a region is read in bounded memory.
* PAD-05A: the paddle objects of one regional extract. The first pass keeps
* every tagged node that is one, and the ways that are, with the ids of their
* nodes; the last looks those nodes' positions up. Only the nodes a kept way
* needs are held, as sorted typed arrays, so a region is read in bounded
* memory.
*/
/** A way longer than this keeps every n-th node so its line stays readable; its ends are always kept. */
@@ -91,13 +93,10 @@ function build(
};
}
function wayFeature(
pending: PendingWay,
coords: ReadonlyMap<number, readonly [number, number]>,
): ExtractedFeature | null {
const points = pending.way.refs.flatMap((ref) => {
const at = coords.get(ref);
return at ? [at] : [];
function wayFeature(pending: PendingWay, coordOf: ExtractResult['coordOf']): ExtractedFeature | null {
const points = pending.way.refs.flatMap((ref): Array<readonly [number, number]> => {
const at = coordOf(ref);
return at ? [[at[1], at[0]]] : [];
});
if (points.length < 2) return null;
const first = points[0] as readonly [number, number];
@@ -153,57 +152,88 @@ export function groupLockParts(features: ExtractedFeature[]): void {
}
/**
* Every paddle object of the extract at `path`, and the date its data is
* current to. `onProgress` hears the share of the work done: the first pass
* is the first half, the lookup of the ways' nodes the second.
* What else a read collects from the same passes: the water network's lines,
* areas and barriers (PAD-11). It names the node ids it needs through `want`
* and the member ways of its multipolygons through `neededWays`; the read
* fetches both and hands back a lookup of the positions.
*/
export interface ExtractSink {
node(id: number, tags: Readonly<Record<string, string>>): void;
way(id: number, refs: readonly number[], tags: Readonly<Record<string, string>>): void;
relation(
id: number,
members: ReadonlyArray<{ type: string; ref: number; role: string }>,
tags: Readonly<Record<string, string>>,
): void;
memberWay(id: number, refs: readonly number[]): void;
readonly neededWays: ReadonlySet<number>;
}
export interface ExtractResult {
timestamp: number | null;
features: ExtractedFeature[];
/** [lat, lon] of every node a kept way or the sink asked for. */
coordOf: (nodeId: number) => [number, number] | null;
}
/**
* Every paddle object of the extract at `path`, the date its data is current
* to, and whatever `sinkFor` collects alongside. The first pass reads every
* tagged element; a second, only when the sink kept multipolygons, reads the
* ways that make them up; the last fetches the positions of every node
* asked for. `onProgress` hears the share of the passes done.
*/
export async function extractPaddleFeatures(
path: string,
onProgress?: (fraction: number) => void,
): Promise<{ timestamp: number | null; features: ExtractedFeature[] }> {
sinkFor?: (want: (nodeId: number) => void) => ExtractSink,
): Promise<ExtractResult> {
const { size } = await stat(path);
const progressOf = (pass: number) => (bytesRead: number) =>
onProgress?.(size > 0 ? (pass + Math.min(1, bytesRead / size)) / 2 : 1);
const store = new NodeStore();
const sink = sinkFor?.((id) => store.want(id));
// With a sink there may be a pass over the member ways between the two.
const passes = sink ? 3 : 2;
let pass = 0;
const progressOf = () => (bytesRead: number) =>
onProgress?.(size > 0 ? (pass + Math.min(1, bytesRead / size)) / passes : 1);
const features: ExtractedFeature[] = [];
const pending: PendingWay[] = [];
const needed = new Set<number>();
const { timestamp } = await readPbfFile(
path,
{
node: (node) => {
if (Object.keys(node.tags).length === 0) return;
sink?.node(node.id, node.tags);
const kind = paddleKindOf(node.tags);
if (!kind) return;
const point: [number, number] = [node.lon, node.lat];
features.push(build(`node/${node.id}`, kind, node.tags, { type: 'Point', coordinates: point }, [point]));
},
way: (way) => {
sink?.way(way.id, way.refs, way.tags);
const kind = paddleKindOf(way.tags);
if (!kind) return;
const refs = thinned(way.refs);
pending.push({ way: { ...way, refs }, kind });
for (const ref of refs) needed.add(ref);
store.wantAll(refs);
},
relation: sink ? (relation) => sink.relation(relation.id, relation.members, relation.tags) : undefined,
},
progressOf(0),
progressOf(),
);
if (pending.length > 0) {
const coords = new Map<number, readonly [number, number]>();
await readPbfFile(
path,
{
node: (node) => {
if (needed.has(node.id)) coords.set(node.id, [node.lon, node.lat]);
},
},
progressOf(1),
);
for (const way of pending) {
const feature = wayFeature(way, coords);
if (feature) features.push(feature);
}
if (sink && sink.neededWays.size > 0) {
pass = 1;
await readPbfFile(path, { way: (way) => sink.memberWay(way.id, way.refs) }, progressOf());
}
pass = passes - 1;
store.seal();
if (store.size > 0) await readPbfFile(path, { coord: (id, lat, lon) => store.offer(id, lat, lon) }, progressOf());
const coordOf = (id: number): [number, number] | null => store.get(id);
for (const way of pending) {
const feature = wayFeature(way, coordOf);
if (feature) features.push(feature);
}
onProgress?.(1);
groupLockParts(features);
return { timestamp, features };
return { timestamp, features, coordOf };
}
@@ -0,0 +1,33 @@
import { buildRegionGraph, type BuiltWaterGraph, WaterCollector } from '../paddle-routing/water-graph.contract';
import { extractPaddleFeatures, type ExtractedFeature } from './paddle-extract';
export type ImportPhase = 'read' | 'build';
export interface ImportOutcome {
timestamp: number | null;
features: ExtractedFeature[];
/** The region's water network for routing (PAD-11); null when the extract holds no water it routes on. */
graph: BuiltWaterGraph | null;
}
/**
* One region's read: its paddle obstacles and, from the same passes, the
* water network the router plans on. Pure work on a file, so it runs in a
* thread of its own and the server keeps answering meanwhile.
*/
export async function importExtract(
path: string,
regionId: string,
onProgress: (phase: ImportPhase, fraction: number | null) => void,
): Promise<ImportOutcome> {
let collector: WaterCollector | null = null;
const { timestamp, features, coordOf } = await extractPaddleFeatures(
path,
(fraction) => onProgress('read', fraction),
(want) => (collector = new WaterCollector(want)),
);
onProgress('build', null);
const sourceTimestamp = timestamp === null ? null : new Date(timestamp * 1000).toISOString();
const graph = collector ? buildRegionGraph(collector, coordOf, { regionId, sourceTimestamp }) : null;
return { timestamp, features, graph };
}
@@ -0,0 +1,12 @@
import { importExtract } from './paddle-import';
import { parentPort, workerData } from 'node:worker_threads';
/** One region's read in a thread of its own; it reports its progress and hands the outcome back, then ends. */
const { path, regionId } = workerData as { path: string; regionId: string };
importExtract(path, regionId, (phase, fraction) => parentPort?.postMessage({ progress: { phase, fraction } })).then(
// Copied, not transferred: a small Buffer shares Node's pool, which a transfer would take from this thread.
(outcome) => parentPort?.postMessage({ outcome }),
(err: unknown) => parentPort?.postMessage({ error: err instanceof Error ? err.message : String(err) }),
);
@@ -8,15 +8,24 @@ import { AdminGuard } from '../auth-core/admin.guard';
import { CurrentUser } from '../auth-core/current-user.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { PaddleObstaclesQueryDto, PaddleRegionCatalogQueryDto, PaddleRegionRequestDto } from './paddle-obstacles.dto';
import {
PaddleObstaclesQueryDto,
PaddleRegionCatalogQueryDto,
PaddleRegionLoadRequestDto,
PaddleRegionRequestDto,
PaddleRegionSettingsDto,
} from './paddle-obstacles.dto';
import { PaddleObstaclesService } from './paddle-obstacles.service';
import { PaddleRegionLoadService } from './paddle-region-load.service';
import { PaddleRegionsService } from './paddle-regions.service';
import { Body, Controller, Get, HttpCode, Post, Query, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, Post, Put, Query, Req, UseGuards } from '@nestjs/common';
import {
paddleObstaclesResponseSchema,
paddleRegionCatalogResponseSchema,
paddleRegionListResponseSchema,
paddleRegionLoadResponseSchema,
paddleRegionSchema,
paddleRegionSettingsSchema,
successResponseSchema,
} from '@trek/shared';
@@ -26,39 +35,84 @@ import type { Request } from 'express';
* GET /api/paddle/obstacles?bbox=: the paddle objects of a map area and its
* coverage (PAD-05A), for anyone signed in while Tours is on. The data is
* OpenStreetMap's, read from regional extracts, not trip data.
*
* POST /api/paddle/regions/load: the water data around places no region
* covers yet (PAD-11C), loaded or asked for as the admin's setting says.
* Asking again follows the load; it never starts a second one.
*/
@Controller('api/paddle')
@UseGuards(AddonGuard, JwtAuthGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class PaddleObstaclesController {
constructor(private readonly obstacles: PaddleObstaclesService) {}
constructor(
private readonly obstacles: PaddleObstaclesService,
private readonly load: PaddleRegionLoadService,
) {}
@Get('obstacles')
@ResponseContract(paddleObstaclesResponseSchema)
async inBox(@Query() query: PaddleObstaclesQueryDto) {
return await this.obstacles.inBox(query.bbox);
}
@Post('regions/load')
@HttpCode(200)
@ResponseContract(paddleRegionLoadResponseSchema)
async loadRegions(@CurrentUser() user: User, @Body() body: PaddleRegionLoadRequestDto) {
return { points: await this.load.load(user, body) };
}
}
/**
* /api/admin/paddle-regions: which regions TREK reads paddle data for. The
* list, Geofabrik's catalogue to pick from, adding (which starts the first
* read), reading again and removing, each change with its audit row. Ids
* travel in the body or the query: some of Geofabrik's hold a slash, and a
* route segment is no place for a choice that names an outside source.
* list (with the regions paddlers asked for), Geofabrik's catalogue to pick
* from, adding (which starts the first read and settles a request), reading
* again, removing, declining a request, and how TREK loads regions by itself,
* each change with its audit row. Ids travel in the body or the query: some
* of Geofabrik's hold a slash, and a route segment is no place for a choice
* that names an outside source.
*/
@Controller('api/admin/paddle-regions')
@UseGuards(JwtAuthGuard, AdminGuard)
export class PaddleRegionsController {
constructor(
private readonly regions: PaddleRegionsService,
private readonly load: PaddleRegionLoadService,
private readonly audit: AuditService,
) {}
@Get()
@ResponseContract(paddleRegionListResponseSchema)
async list() {
return { regions: await this.regions.list() };
return { regions: await this.regions.list(), requests: await this.load.pending() };
}
@Get('settings')
@ResponseContract(paddleRegionSettingsSchema)
async settings() {
return await this.load.settings();
}
@Put('settings')
@ResponseContract(paddleRegionSettingsSchema)
async updateSettings(@CurrentUser() user: User, @Body() body: PaddleRegionSettingsDto, @Req() req: Request) {
const settings = await this.load.updateSettings(body);
await this.audit.writeAudit({
userId: user.id,
action: 'admin.paddle_region_settings',
ip: getClientIp(req),
details: { ...settings },
});
return settings;
}
@Post('decline')
@HttpCode(200)
@ResponseContract(successResponseSchema)
async decline(@CurrentUser() user: User, @Body() body: PaddleRegionRequestDto, @Req() req: Request) {
await this.load.decline(body.id);
await this.record(user, 'admin.paddle_region_decline', body.id, req);
return { success: true as const };
}
@Get('catalog')
@@ -1,7 +1,15 @@
import { paddleObstaclesQuerySchema, paddleRegionCatalogQuerySchema, paddleRegionRequestSchema } from '@trek/shared';
import {
paddleObstaclesQuerySchema,
paddleRegionCatalogQuerySchema,
paddleRegionLoadRequestSchema,
paddleRegionRequestSchema,
paddleRegionSettingsSchema,
} from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class PaddleObstaclesQueryDto extends createZodDto(paddleObstaclesQuerySchema) {}
export class PaddleRegionCatalogQueryDto extends createZodDto(paddleRegionCatalogQuerySchema) {}
export class PaddleRegionRequestDto extends createZodDto(paddleRegionRequestSchema) {}
export class PaddleRegionLoadRequestDto extends createZodDto(paddleRegionLoadRequestSchema) {}
export class PaddleRegionSettingsDto extends createZodDto(paddleRegionSettingsSchema) {}
@@ -1,11 +1,17 @@
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { PaddleFeatures } from '../../db/entities/PaddleFeatures.entity';
import { PaddleRegionRequests } from '../../db/entities/PaddleRegionRequests.entity';
import { PaddleRegions } from '../../db/entities/PaddleRegions.entity';
import { AddonsModule } from '../addons/addons.module';
import { AuditModule } from '../audit/audit.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { PaddleRoutingModule } from '../paddle-routing/paddle-routing.module';
import { SchedulingModule } from '../scheduling/scheduling.module';
import { GeofabrikClient } from './geofabrik.client';
import { PADDLE_IMPORT_RUNNER, importRunner } from './import-runner';
import { PaddleObstaclesController, PaddleRegionsController } from './paddle-obstacles.controller';
import { PaddleObstaclesService } from './paddle-obstacles.service';
import { PaddleRegionLoadService } from './paddle-region-load.service';
import { PaddleRegionsJob } from './paddle-regions.job';
import { PaddleRegionsService } from './paddle-regions.service';
import { MikroOrmModule } from '@mikro-orm/nestjs';
@@ -14,13 +20,30 @@ import { Module } from '@nestjs/common';
/**
* PAD-05A: paddle obstacles from OpenStreetMap. The regions an admin chose,
* their background reads from Geofabrik's extracts, and the objects of a map
* area for the planner. PaddleObstaclesService is exported for the tours
* domain's route checks (PAD-06, PAD-09).
* area for the planner. The same read builds the region's water network,
* which it hands to the paddle routing domain (PAD-11). Where a paddler plans
* outside every region, the region around the place is loaded or asked for by
* itself (PAD-11C). PaddleObstaclesService is exported for the tours domain's
* route checks (PAD-06, PAD-09).
*/
@Module({
imports: [AddonsModule, AuditModule, SchedulingModule, MikroOrmModule.forFeature([PaddleRegions, PaddleFeatures])],
imports: [
AddonsModule,
AuditModule,
NotificationsModule,
SchedulingModule,
PaddleRoutingModule,
MikroOrmModule.forFeature([PaddleRegions, PaddleFeatures, PaddleRegionRequests, AppSettings]),
],
controllers: [PaddleObstaclesController, PaddleRegionsController],
providers: [GeofabrikClient, PaddleObstaclesService, PaddleRegionsService, PaddleRegionsJob],
providers: [
GeofabrikClient,
PaddleObstaclesService,
PaddleRegionsService,
PaddleRegionLoadService,
PaddleRegionsJob,
{ provide: PADDLE_IMPORT_RUNNER, useFactory: importRunner },
],
exports: [PaddleObstaclesService],
})
export class PaddleObstaclesModule {}
@@ -0,0 +1,266 @@
import { AppSettings } from '../../db/entities/AppSettings.entity';
import { PaddleRegionRequests } from '../../db/entities/PaddleRegionRequests.entity';
import { PaddleRegions } from '../../db/entities/PaddleRegions.entity';
import { PADDLE_REGION_BOUNDARY } from '../../db/json-columns';
import type { AppSettingsRepository } from '../../db/repositories/AppSettings.repository';
import type { PaddleRegionRequestsRepository } from '../../db/repositories/PaddleRegionRequests.repository';
import type { PaddleRegionRow, PaddleRegionsRepository } from '../../db/repositories/PaddleRegions.repository';
import { dbNow, utcSuffix } from '../../db/types';
import type { User } from '../../types';
import { decodeJson } from '../../utils/json-column';
import { logError, logInfo } from '../audit/audit-log.logger';
import { readAppSetting } from '../common/app-settings.registry';
import { DemoService } from '../common/demo.service';
import { DomainError } from '../common/domain-error';
import { NotificationsService } from '../notifications/notifications.service';
import { PaddleRoutingService } from '../paddle-routing/paddle-routing.service';
import { GeofabrikClient, type GeofabrikRegion } from './geofabrik.client';
import { boundaryContains, boxOfBoundary } from './paddle-geo';
import { PaddleRegionsService, REGION_NOT_FOUND_MESSAGE } from './paddle-regions.service';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable } from '@nestjs/common';
import {
PADDLE_AUTO_MAX_MB,
PADDLE_AUTO_REGIONS_PER_DAY,
paddleRegionAutoModeSchema,
type PaddleRegionLoadRequest,
type PaddleRegionLoadState,
type PaddleRegionPending,
type PaddleRegionSettings,
type PaddleRegionWaitReason,
type PaddleRoutePoint,
} from '@trek/shared';
const DAY_MS = 24 * 60 * 60 * 1000;
const MB = 1024 * 1024;
type RegionRef = { id: string; name: string };
/** Where a place stands before anything is decided: covered, in a region TREK holds, in one of Geofabrik's, or nowhere. */
type Place =
| { kind: 'covered'; region: RegionRef }
| { kind: 'held'; row: PaddleRegionRow }
| { kind: 'catalogue'; region: GeofabrikRegion }
| { kind: 'none'; reason: 'no_extract' | 'catalog_unavailable' };
function areaOf(box: { south: number; west: number; north: number; east: number }): number {
return (box.north - box.south) * (box.east - box.west);
}
/** The smallest of the regions around a place, by the area of its box: the most local extract is the cheapest to read. */
function smallest<T>(candidates: readonly T[], area: (candidate: T) => number): T | null {
return candidates.reduce<T | null>(
(best, candidate) => (best && area(best) <= area(candidate) ? best : candidate),
null,
);
}
function rowArea(row: PaddleRegionRow): number {
if (row.min_lat === null || row.max_lat === null || row.min_lng === null || row.max_lng === null) return Infinity;
return areaOf({ south: row.min_lat, west: row.min_lng, north: row.max_lat, east: row.max_lng });
}
function toPending(row: Awaited<ReturnType<PaddleRegionRequestsRepository['list']>>[number]): PaddleRegionPending {
return {
id: row.region_id,
name: row.name,
reason: row.reason,
bytes: row.bytes,
requested_by: row.requested_by,
requested_at: utcSuffix(row.requested_at) ?? row.requested_at,
declined: row.declined_at !== null,
};
}
/**
* PAD-11C: the water data of a place a paddler plans in, fetched without an
* admin's round trip. The planner asks for the places its unplanned legs end
* at; for each this finds the region that covers it or would, the smallest of
* Geofabrik's regions around it, and per the admin's setting loads it right
* away (within a size and a few a day), files it as a request for the admins,
* or does neither. Asking again is how the planner follows a load, so an
* answer never starts a second one and a request is filed and announced once.
* Only the region's id ever leaves the server, never the place.
*/
@Injectable()
export class PaddleRegionLoadService {
constructor(
private readonly regionsService: PaddleRegionsService,
private readonly geofabrik: GeofabrikClient,
private readonly routing: PaddleRoutingService,
private readonly notifications: NotificationsService,
private readonly demo: DemoService,
@InjectRepository(PaddleRegions) private readonly regions: PaddleRegionsRepository,
@InjectRepository(PaddleRegionRequests) private readonly requests: PaddleRegionRequestsRepository,
@InjectRepository(AppSettings) private readonly appSettings: AppSettingsRepository,
) {}
async settings(): Promise<PaddleRegionSettings> {
const mode = paddleRegionAutoModeSchema.safeParse(await readAppSetting(this.appSettings, 'paddle_auto_load'));
const maxMb = Number.parseInt((await readAppSetting(this.appSettings, 'paddle_auto_max_mb')) ?? '', 10);
return {
mode: mode.success ? mode.data : 'auto',
max_mb:
Number.isSafeInteger(maxMb) && maxMb >= PADDLE_AUTO_MAX_MB.min && maxMb <= PADDLE_AUTO_MAX_MB.max
? maxMb
: PADDLE_AUTO_MAX_MB.default,
};
}
async updateSettings(settings: PaddleRegionSettings): Promise<PaddleRegionSettings> {
await this.appSettings.setValue('paddle_auto_load', settings.mode);
await this.appSettings.setValue('paddle_auto_max_mb', String(settings.max_mb));
return await this.settings();
}
async pending(): Promise<PaddleRegionPending[]> {
return (await this.requests.list()).map(toPending);
}
/** An admin declines a request: it stays, marked, so the planner says so and does not ask for it again. */
async decline(id: string): Promise<void> {
if (!(await this.requests.decline(id))) throw new DomainError(404, REGION_NOT_FOUND_MESSAGE);
}
/**
* Where the water data around each place stands, in the order asked, after
* starting what the setting allows. Places in one region share one decision.
*/
async load(user: User, request: PaddleRegionLoadRequest): Promise<PaddleRegionLoadState[]> {
const places = await Promise.all(request.points.map((point) => this.placeOf(point)));
const decided = new Map<string, PaddleRegionLoadState>();
// One after the other: two places in one region must not both add it or file it.
const answers: PaddleRegionLoadState[] = [];
await places.reduce(async (previous, place) => {
await previous;
const key = place.kind === 'none' ? null : place.kind === 'held' ? place.row.id : place.region.id;
const known = key === null ? undefined : decided.get(key);
const answer = known ?? (await this.decide(place, user, request.retry ?? false));
if (key !== null) decided.set(key, answer);
answers.push(answer);
}, Promise.resolve());
return answers;
}
private async placeOf(point: PaddleRoutePoint): Promise<Place> {
const covering = await this.routing.regionsCovering(point.lat, point.lng);
const held = (await this.regions.aroundPoint(point.lat, point.lng)).filter((row) => {
const boundary = decodeJson(PADDLE_REGION_BOUNDARY, row.boundary, row.id);
return boundary === null || boundaryContains(boundary, point.lat, point.lng);
});
// A network reaching the place plans it, whichever region's boundary it lies in.
const coveringRow =
held.find((row) => covering.includes(row.id)) ??
(covering[0] === undefined ? undefined : await this.regions.findById(covering[0]));
if (coveringRow) return { kind: 'covered', region: { id: coveringRow.id, name: coveringRow.name } };
const row = smallest(held, rowArea);
if (row) return { kind: 'held', row };
let catalogue: Map<string, GeofabrikRegion>;
try {
catalogue = await this.geofabrik.regions();
} catch (err) {
logError(`[Paddle] Geofabrik index: ${err instanceof Error ? err.message : String(err)}`);
return { kind: 'none', reason: 'catalog_unavailable' };
}
const around = [...catalogue.values()].filter(
(region) => region.boundary !== null && boundaryContains(region.boundary, point.lat, point.lng),
);
// Every region in the list has a boundary of its own; one without is never picked for a place.
const region = smallest(around, (candidate) =>
candidate.boundary ? areaOf(boxOfBoundary(candidate.boundary)) : Infinity,
);
return region ? { kind: 'catalogue', region } : { kind: 'none', reason: 'no_extract' };
}
private async decide(place: Place, user: User, retry: boolean): Promise<PaddleRegionLoadState> {
switch (place.kind) {
case 'covered':
return { state: 'ready', region: place.region };
case 'none':
return { state: 'unavailable', region: null, reason: place.reason };
case 'held':
return await this.decideHeld(place.row, retry);
case 'catalogue':
return await this.decideNew(place.region, user);
}
}
/** A region TREK holds already: being read, failed, or read and still without water at the place. */
private async decideHeld(row: PaddleRegionRow, retry: boolean): Promise<PaddleRegionLoadState> {
const region = { id: row.id, name: row.name };
if (row.status === 'queued' || row.status === 'importing') return await this.loading(region);
if (row.status === 'failed' && !retry) return { state: 'failed', region };
// Read before networks were built (or failed and asked again): the next read builds one.
const networks = await this.routing.networks();
if (row.status === 'failed' || !networks.has(row.id)) {
await this.regionsService.refresh(row.id);
return await this.loading(region);
}
return { state: 'no_water', region };
}
/** A region TREK does not hold: loaded, filed as a request, or neither, as the setting says. */
private async decideNew(region: GeofabrikRegion, user: User): Promise<PaddleRegionLoadState> {
const ref = { id: region.id, name: region.name };
const { mode, max_mb } = await this.settings();
// A demo account plans on what is there; it never sets a download going.
if (mode === 'off' || (await this.demo.isDemoUserId(user.id)))
return { state: 'unavailable', region: ref, reason: 'off' };
const filed = await this.requests.findByRegion(region.id);
if (filed?.declined_at) return { state: 'declined', region: ref };
// An admin planning is an admin deciding: their region loads at once, whatever its size.
if (user.role === 'admin') return await this.add(ref, 'admin');
if (filed) return { state: 'requested', region: ref, reason: filed.reason };
if (mode === 'ask') return await this.request(ref, 'ask', null, user);
const bytes = await this.geofabrik.sizeOf(region.pbfUrl);
if (bytes !== null && bytes > max_mb * MB) return await this.request(ref, 'too_large', bytes, user);
const since = dbNow(new Date(Date.now() - DAY_MS));
if ((await this.regions.countAddedAutomaticallySince(since)) >= PADDLE_AUTO_REGIONS_PER_DAY) {
return await this.request(ref, 'limit', bytes, user);
}
return await this.add(ref, 'auto');
}
private async add(region: RegionRef, origin: 'admin' | 'auto'): Promise<PaddleRegionLoadState> {
await this.regionsService.add(region.id, origin);
logInfo(`[Paddle] ${region.id}: added for a paddler's planning (${origin})`);
return await this.loading(region);
}
private async loading(region: RegionRef): Promise<PaddleRegionLoadState> {
return { state: 'loading', region, ...(await this.regionsService.readState(region.id)) };
}
/** Files a request and tells the admins, once per region. */
private async request(
region: RegionRef,
reason: PaddleRegionWaitReason,
bytes: number | null,
user: User,
): Promise<PaddleRegionLoadState> {
const filed = await this.requests.file({
region_id: region.id,
name: region.name,
reason,
bytes,
requested_by: user.username,
});
if (filed) await this.announce(region, user);
return { state: 'requested', region, reason };
}
/** @txStandalone the notice to the admins stands on its own; a failed one never undoes the request */
private async announce(region: RegionRef, user: User): Promise<void> {
try {
await this.notifications.send({
event: 'paddle_region_requested',
actorId: user.id,
scope: 'admin',
targetId: 0,
params: { region: region.name, actor: user.username },
});
} catch (err) {
logError(`[Paddle] telling the admins about ${region.id}: ${err instanceof Error ? err.message : String(err)}`);
}
}
}
@@ -1,7 +1,9 @@
import { PaddleFeatures } from '../../db/entities/PaddleFeatures.entity';
import { PaddleRegions } from '../../db/entities/PaddleRegions.entity';
import { PaddleRegionRequests } from '../../db/entities/PaddleRegionRequests.entity';
import { PaddleRegions, type TPaddleRegionsOrigin } from '../../db/entities/PaddleRegions.entity';
import { PADDLE_FEATURE_GEOMETRY, PADDLE_FEATURE_TAGS, PADDLE_REGION_BOUNDARY } from '../../db/json-columns';
import type { PaddleFeatureRow, PaddleFeaturesRepository } from '../../db/repositories/PaddleFeatures.repository';
import type { PaddleRegionRequestsRepository } from '../../db/repositories/PaddleRegionRequests.repository';
import type { PaddleRegionRow, PaddleRegionsRepository } from '../../db/repositories/PaddleRegions.repository';
import { dbNow, utcSuffix } from '../../db/types';
import { encodeJson } from '../../utils/json-column';
@@ -10,12 +12,14 @@ import { logError, logInfo } from '../audit/audit-log.logger';
import { DomainError } from '../common/domain-error';
import { withRequestContext } from '../database/request-context';
import { UnitOfWork } from '../database/unit-of-work';
import { PaddleRoutingService } from '../paddle-routing/paddle-routing.service';
import { GeofabrikClient, type GeofabrikRegion } from './geofabrik.client';
import { extractPaddleFeatures, type ExtractedFeature } from './paddle-extract';
import { PADDLE_IMPORT_RUNNER, type ImportRunner } from './import-runner';
import type { ExtractedFeature } from './paddle-extract';
import { boxOfBoundary } from './paddle-geo';
import { MikroORM } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, type OnApplicationShutdown } from '@nestjs/common';
import { Inject, Injectable, type OnApplicationShutdown } from '@nestjs/common';
import type { PaddleRegion, PaddleRegionCatalogEntry } from '@trek/shared';
import { mkdir, rm, unlink } from 'node:fs/promises';
@@ -32,16 +36,22 @@ const STOPPED_AT_SHUTDOWN = 'stopped by the shutdown; read again on the next sta
/** What the region being read is doing, for the admin list; held in memory, as one process reads. */
type ReadProgress = NonNullable<PaddleRegion['progress']>;
function toRegion(row: PaddleRegionRow, progress: ReadProgress | null = null): PaddleRegion {
function toRegion(
row: PaddleRegionRow,
progress: ReadProgress | null = null,
network: PaddleRegion['network'] = null,
): PaddleRegion {
return {
id: row.id,
name: row.name,
status: row.status,
origin: row.origin,
source_timestamp: row.source_timestamp,
imported_at: utcSuffix(row.imported_at),
feature_count: row.feature_count,
error: row.error,
progress: row.status === 'importing' ? progress : null,
network,
};
}
@@ -105,10 +115,16 @@ export class PaddleRegionsService implements OnApplicationShutdown {
private readonly orm: MikroORM,
@InjectRepository(PaddleRegions) private readonly regions: PaddleRegionsRepository,
@InjectRepository(PaddleFeatures) private readonly features: PaddleFeaturesRepository,
@InjectRepository(PaddleRegionRequests) private readonly requests: PaddleRegionRequestsRepository,
private readonly routing: PaddleRoutingService,
@Inject(PADDLE_IMPORT_RUNNER) private readonly importer: ImportRunner,
) {}
async list(): Promise<PaddleRegion[]> {
return (await this.regions.list()).map((row) => toRegion(row, this.progressOf(row.id)));
const networks = await this.routing.networks();
return (await this.regions.list()).map((row) =>
toRegion(row, this.progressOf(row.id), networks.get(row.id) ?? null),
);
}
/** Regions of Geofabrik's list whose name or id matches, with the regions they lie in. */
@@ -129,8 +145,11 @@ export class PaddleRegionsService implements OnApplicationShutdown {
.map((region) => ({ id: region.id, name: region.name, path: path(region.parent) }));
}
/** Adds a region and queues its first read; a region already there is read again, as `refresh` does. */
async add(id: string): Promise<PaddleRegion> {
/**
* Adds a region and queues its first read; a region already there is read
* again, as `refresh` does. A paddler's request for it is settled by adding it.
*/
async add(id: string, origin: TPaddleRegionsOrigin = 'admin'): Promise<PaddleRegion> {
if (await this.regions.findById(id)) return await this.refresh(id);
const region = (await this.catalogue()).get(id);
if (!region)
@@ -140,15 +159,19 @@ export class PaddleRegionsService implements OnApplicationShutdown {
});
const box = region.boundary ? boxOfBoundary(region.boundary) : null;
await this.uow.transactional(async () => {
await this.regions.insertRegion({
id,
name: region.name,
boundary: region.boundary ? encodeJson(PADDLE_REGION_BOUNDARY, region.boundary) : null,
min_lat: box?.south ?? null,
min_lng: box?.west ?? null,
max_lat: box?.north ?? null,
max_lng: box?.east ?? null,
});
await this.requests.remove(id);
await this.regions.insertRegion(
{
id,
name: region.name,
boundary: region.boundary ? encodeJson(PADDLE_REGION_BOUNDARY, region.boundary) : null,
min_lat: box?.south ?? null,
min_lng: box?.west ?? null,
max_lat: box?.north ?? null,
max_lng: box?.east ?? null,
},
origin,
);
await this.regions.updateRegion(id, { status: 'queued', error: null });
});
this.kick();
@@ -224,6 +247,13 @@ export class PaddleRegionsService implements OnApplicationShutdown {
return join(this.paths.tmpDir, 'paddle');
}
/** How far a queued or importing region is: its read's progress, and how many regions are read before it. */
async readState(id: string): Promise<{ progress: ReadProgress | null; ahead: number }> {
if (this.current?.id === id) return { progress: this.progressOf(id), ahead: 0 };
const waiting = (await this.regions.queued()).findIndex((row) => row.id === id);
return { progress: null, ahead: Math.max(0, waiting) + (this.current ? 1 : 0) };
}
private progressOf(id: string): ReadProgress | null {
return this.current?.id === id ? { ...this.current.progress } : null;
}
@@ -267,6 +297,8 @@ export class PaddleRegionsService implements OnApplicationShutdown {
const dir = this.downloadDir();
// Some of Geofabrik's ids are nested (`us/texas`); the file stays in the one folder.
const file = join(dir, `${region.id.replaceAll('/', '_')}-${Date.now()}.osm.pbf`);
// A network written but never recorded (the read failed or was called off after it) goes again.
let graphFile: string | null = null;
try {
const known = (await this.geofabrik.regions()).get(region.id);
if (!known) throw new Error(REGION_UNKNOWN_MESSAGE);
@@ -277,10 +309,15 @@ export class PaddleRegionsService implements OnApplicationShutdown {
onProgress: (fraction) => (current.progress = { phase: 'download', fraction }),
});
current.progress = { phase: 'read', fraction: 0 };
const { timestamp, features } = await extractPaddleFeatures(file, (fraction) => {
current.progress = { phase: 'read', fraction };
});
const { timestamp, features, graph } = await this.importer.run(
file,
region.id,
(phase, fraction) => (current.progress = { phase, fraction }),
abort.signal,
);
abort.signal.throwIfAborted();
graphFile = graph ? await this.routing.writeGraphFile(region.id, graph) : null;
let previousGraph: string | null = null;
const kept = await this.uow.transactional(async () => {
if (!(await this.regions.findById(region.id))) return false;
await this.features.replaceRegion(region.id, features.map(toRow));
@@ -293,9 +330,12 @@ export class PaddleRegionsService implements OnApplicationShutdown {
// Without a boundary the region covers what its objects span, so the map can still tell it is covered.
...(known.boundary ? {} : extentOf(features)),
});
if (graph && graphFile) previousGraph = await this.routing.recordGraph(region.id, graphFile, graph);
return true;
});
if (!kept) throw new Error(REMOVED_WHILE_READ);
if (graphFile) await this.routing.replaced(previousGraph, graphFile);
graphFile = null;
logInfo(
`[Paddle] ${region.id}: ${features.length} objects read in ${Math.round((Date.now() - started) / 1000)} s`,
);
@@ -311,6 +351,7 @@ export class PaddleRegionsService implements OnApplicationShutdown {
} finally {
if (this.current === current) this.current = null;
await unlink(file).catch(() => undefined);
if (graphFile) await this.routing.discard(graphFile);
}
}
}
@@ -0,0 +1,146 @@
import { EdgeKind, PortageSource, type WaterGraphData } from '../water-graph';
import { BuildState } from './build-state';
import type { CollectedWater } from './collect';
import { attachLakes, rasterizeLakes } from './lakes';
import { buildLines, joinDanglingEnds, type CoordOf } from './lines';
import { buildPortages } from './portages';
/**
* PAD-11: the water network of a region from what the collector kept. The
* order matters: lines first (with their barriers), then the lakes cut them
* where they enter, then line ends left short are joined, and last the
* barriers get their carries, on the network as it finally runs.
*/
export function buildWaterGraph(
water: CollectedWater,
coordOf: CoordOf,
meta: { regionId: string; sourceTimestamp: string | null },
): WaterGraphData {
const state = new BuildState();
buildLines(water, coordOf, state);
const lakes = attachLakes(state, rasterizeLakes(water, coordOf));
joinDanglingEnds(state);
buildPortages(state, water, coordOf);
return freeze(state, lakes, meta);
}
function freeze(
state: BuildState,
lakes: WaterGraphData['lakes'],
meta: { regionId: string; sourceTimestamp: string | null },
): WaterGraphData {
const live = state.live().map(({ edge }) => edge);
// Only nodes an edge or a lake still uses are kept, renumbered in the order they were made.
const used = new Uint8Array(state.nodes.length);
for (const edge of live) {
used[edge.a] = 1;
used[edge.b] = 1;
}
state.nodes.forEach((node, at) => {
if (node.lake >= 0) used[at] = 1;
});
const renumber = new Int32Array(state.nodes.length).fill(-1);
let count = 0;
used.forEach((flag, at) => {
if (flag) renumber[at] = count++;
});
const nodeLat = new Float64Array(count);
const nodeLng = new Float64Array(count);
const nodeLake = new Int32Array(count).fill(-1);
state.nodes.forEach((node, at) => {
const index = renumber[at] as number;
if (index < 0) return;
nodeLat[index] = node.lat;
nodeLng[index] = node.lng;
nodeLake[index] = node.lake;
});
const strings: string[] = [];
const stringIndex = new Map<string, number>();
const intern = (text: string | null): number => {
if (text === null || text === '') return -1;
const known = stringIndex.get(text);
if (known !== undefined) return known;
strings.push(text);
stringIndex.set(text, strings.length - 1);
return strings.length - 1;
};
const n = live.length;
const edgeA = new Uint32Array(n);
const edgeB = new Uint32Array(n);
const edgeLength = new Float32Array(n);
const edgeKind = new Uint8Array(n);
const edgeFlow = new Uint8Array(n);
const edgeCurrent = new Float32Array(n);
const edgeAccess = new Uint8Array(n);
const edgeOneway = new Uint8Array(n);
const edgeGrade = new Uint8Array(n);
const edgeGeomStart = new Uint32Array(n);
const edgeGeomCount = new Uint32Array(n);
const edgeName = new Int32Array(n);
const edgeRef = new Int32Array(n);
const edgeSource = new Uint8Array(n);
const pool = new Float64Array(live.reduce((sum, edge) => sum + edge.line.length * 2, 0));
const stats = { water_km: 0, lakes: lakes.length, portages: 0, inferred_portages: 0, gaps: 0, locks: 0 };
// The straight carry beside a lock is an option, not a barrier without a portage; it stays out of the count.
const lockRefs = new Set(live.filter((edge) => edge.kind === EdgeKind.lock).map((edge) => edge.ref));
let cursor = 0;
live.forEach((edge, index) => {
edgeA[index] = renumber[edge.a] as number;
edgeB[index] = renumber[edge.b] as number;
const length = state.lengthOf(edge);
edgeLength[index] = length;
edgeKind[index] = edge.kind;
edgeFlow[index] = edge.flow;
edgeCurrent[index] = edge.current;
edgeAccess[index] = edge.access;
edgeOneway[index] = edge.oneway;
edgeGrade[index] = edge.grade;
edgeGeomStart[index] = cursor / 2;
edgeGeomCount[index] = edge.line.length;
for (const [lat, lng] of edge.line) {
pool[cursor++] = lat;
pool[cursor++] = lng;
}
edgeName[index] = intern(edge.name);
edgeRef[index] = intern(edge.ref);
edgeSource[index] = edge.source;
if (edge.kind === EdgeKind.water || edge.kind === EdgeKind.connector || edge.kind === EdgeKind.pass)
stats.water_km += length / 1000;
else if (edge.kind === EdgeKind.gap) stats.gaps++;
else if (edge.kind === EdgeKind.portage && !lockRefs.has(edge.ref)) {
if (edge.source === PortageSource.inferred) stats.inferred_portages++;
else stats.portages++;
}
});
stats.water_km = Math.round(stats.water_km * 10) / 10;
stats.locks = lockRefs.size;
return {
regionId: meta.regionId,
sourceTimestamp: meta.sourceTimestamp,
nodeLat,
nodeLng,
nodeLake,
edgeA,
edgeB,
edgeLength,
edgeKind,
edgeFlow,
edgeCurrent,
edgeAccess,
edgeOneway,
edgeGrade,
edgeGeomStart,
edgeGeomCount,
edgeName,
edgeRef,
edgeSource,
pool,
strings,
lakes: lakes.map((lake) => ({ ...lake, portals: lake.portals.map((node) => renumber[node] as number) })),
stats,
};
}
@@ -0,0 +1,241 @@
import { lineLength, metresBetween, projectOnSegment } from '../engine/geo';
import { Access, EdgeKind, type EdgeKindCode, PortageSource } from '../water-graph';
/** A node while the network is built: where it is, and the lake it opens into (-1 for none). */
export interface BuildNode {
lat: number;
lng: number;
lake: number;
/** Set on the sides of a weir, dam or waterfall: such a node never opens into a lake. */
barrier: boolean;
}
export interface BuildEdge {
a: number;
b: number;
/** [lat, lng] from a to b. */
line: [number, number][];
kind: EdgeKindCode;
flow: 0 | 1;
current: number;
access: number;
oneway: 0 | 1;
/** The whitewater grade of the stretch (0 when none is given). */
grade: number;
name: string | null;
ref: string | null;
source: number;
/** Dropped edges stay in the list as holes, so indices handed out earlier keep meaning the same edge. */
dead?: boolean;
}
export interface BarrierSite {
lat: number;
lng: number;
name: string | null;
ref: string;
/** The nodes on its sides, one per water line that meets it. */
sides: number[];
}
/** The network under construction: nodes, edges, and the barriers to carry round. */
export class BuildState {
readonly nodes: BuildNode[] = [];
readonly edges: BuildEdge[] = [];
readonly barriers: BarrierSite[] = [];
/** The edges at each node, kept up to date as edges are added and cut; dead ones are filtered on reading. */
private readonly at: number[][] = [];
addNode(lat: number, lng: number, barrier = false): number {
this.nodes.push({ lat, lng, lake: -1, barrier });
this.at.push([]);
return this.nodes.length - 1;
}
addEdge(edge: Omit<BuildEdge, 'dead'> & { dead?: boolean }): number {
// A copy of a dropped edge comes in alive.
const { dead: _dead, ...alive } = edge;
this.edges.push(alive);
const index = this.edges.length - 1;
this.at[edge.a]?.push(index);
if (edge.b !== edge.a) this.at[edge.b]?.push(index);
return index;
}
/** The live edges at a node. */
edgesAt(node: number): number[] {
return (this.at[node] ?? []).filter((edge) => !this.edges[edge]?.dead);
}
/** A land edge carrying the boat between two nodes along `line`. */
addPortage(
a: number,
b: number,
line: [number, number][],
source: number,
name: string | null,
ref: string | null,
): number {
return this.addEdge({
a,
b,
line,
kind: EdgeKind.portage,
flow: 0,
current: 0,
access: Access.open,
oneway: 0,
grade: 0,
name,
ref,
source,
});
}
/** A straight piece of water joining two nodes the data leaves apart: a connector when short, a gap beyond. */
addLink(a: number, b: number, kind: EdgeKindCode): number {
const from = this.nodes[a] as BuildNode;
const to = this.nodes[b] as BuildNode;
return this.addEdge({
a,
b,
line: [
[from.lat, from.lng],
[to.lat, to.lng],
],
kind,
flow: 0,
current: 0,
access: Access.open,
oneway: 0,
grade: 0,
name: null,
ref: null,
source: PortageSource.tagged,
});
}
/**
* Cuts an edge at one of its inner points, which becomes a node; the first
* part keeps the edge's index. Returns the node at the cut (an end node when
* the point is an end).
*/
splitAt(edgeIndex: number, pointIndex: number): number {
const edge = this.edges[edgeIndex] as BuildEdge;
if (pointIndex <= 0) return edge.a;
if (pointIndex >= edge.line.length - 1) return edge.b;
const [lat, lng] = edge.line[pointIndex] as [number, number];
const node = this.addNode(lat, lng);
const tail: Omit<BuildEdge, 'dead'> = { ...edge, a: node, line: edge.line.slice(pointIndex) };
const oldB = edge.b;
edge.line = edge.line.slice(0, pointIndex + 1);
edge.b = node;
this.at[node]?.push(edgeIndex);
if (oldB !== edge.a) this.at[oldB] = (this.at[oldB] ?? []).filter((other) => other !== edgeIndex);
this.addEdge(tail);
return node;
}
/**
* The node at the point of an edge nearest to a spot, cutting the point into
* the line where it falls between two of its points.
*/
nodeNearest(edgeIndex: number, lat: number, lng: number): number {
const edge = this.edges[edgeIndex] as BuildEdge;
let best = { seg: 0, t: 0, distance: Infinity };
for (let seg = 0; seg + 1 < edge.line.length; seg++) {
const a = edge.line[seg] as [number, number];
const b = edge.line[seg + 1] as [number, number];
const { t, distance } = projectOnSegment(lat, lng, a[0], a[1], b[0], b[1]);
if (distance < best.distance) best = { seg, t, distance };
}
if (best.t <= 0.02) return this.splitAt(edgeIndex, best.seg);
if (best.t >= 0.98) return this.splitAt(edgeIndex, best.seg + 1);
const a = edge.line[best.seg] as [number, number];
const b = edge.line[best.seg + 1] as [number, number];
edge.line.splice(best.seg + 1, 0, [a[0] + (b[0] - a[0]) * best.t, a[1] + (b[1] - a[1]) * best.t]);
return this.splitAt(edgeIndex, best.seg + 1);
}
/**
* The node `offset` metres along an edge from its end at `node`, cutting a
* point into the line where none stands; the far end when the edge is shorter.
*/
nodeAlong(edgeIndex: number, node: number, offset: number): number {
const edge = this.edges[edgeIndex] as BuildEdge;
const forward = edge.a === node;
const last = edge.line.length - 1;
let travelled = 0;
for (let step = 1; step <= last; step++) {
const fromAt = forward ? step - 1 : last - step + 1;
const toAt = forward ? step : last - step;
const from = edge.line[fromAt] as [number, number];
const to = edge.line[toAt] as [number, number];
const length = metresBetween(from[0], from[1], to[0], to[1]);
if (travelled + length >= offset && length > 0) {
const share = (offset - travelled) / length;
if (share >= 0.999) return this.splitAt(edgeIndex, toAt);
const point: [number, number] = [from[0] + (to[0] - from[0]) * share, from[1] + (to[1] - from[1]) * share];
const insertAt = forward ? step : last - step + 1;
edge.line.splice(insertAt, 0, point);
return this.splitAt(edgeIndex, insertAt);
}
travelled += length;
}
return forward ? edge.b : edge.a;
}
live(): Array<{ edge: BuildEdge; index: number }> {
return this.edges.flatMap((edge, index) => (edge.dead ? [] : [{ edge, index }]));
}
/** The live edges at each node, by index. */
incidence(): number[][] {
return this.nodes.map((_, node) => this.edgesAt(node));
}
lengthOf(edge: BuildEdge): number {
return lineLength(edge.line);
}
distance(a: number, b: number): number {
const p = this.nodes[a] as BuildNode;
const q = this.nodes[b] as BuildNode;
return metresBetween(p.lat, p.lng, q.lat, q.lng);
}
}
/** Points of a set of lines bucketed on a grid of about 100 m, for the nearest point within a few hundred metres. */
export class PointGrid<T> {
private readonly cells = new Map<string, Array<{ lat: number; lng: number; item: T }>>();
private static readonly DEG = 0.001;
private keyOf(lat: number, lng: number): string {
return `${Math.floor(lat / PointGrid.DEG)}:${Math.floor(lng / PointGrid.DEG)}`;
}
add(lat: number, lng: number, item: T): void {
const key = this.keyOf(lat, lng);
const cell = this.cells.get(key);
if (cell) cell.push({ lat, lng, item });
else this.cells.set(key, [{ lat, lng, item }]);
}
/** The points within `radiusM`, nearest first. */
near(lat: number, lng: number, radiusM: number): Array<{ item: T; distance: number; lat: number; lng: number }> {
const reachLat = Math.ceil(radiusM / 111_195 / PointGrid.DEG);
const reachLng = Math.ceil(radiusM / (111_195 * Math.cos((lat * Math.PI) / 180)) / PointGrid.DEG);
const row = Math.floor(lat / PointGrid.DEG);
const col = Math.floor(lng / PointGrid.DEG);
const found: Array<{ item: T; distance: number; lat: number; lng: number }> = [];
for (let r = row - reachLat; r <= row + reachLat; r++) {
for (let c = col - reachLng; c <= col + reachLng; c++) {
for (const point of this.cells.get(`${r}:${c}`) ?? []) {
const distance = metresBetween(lat, lng, point.lat, point.lng);
if (distance <= radiusM) found.push({ item: point.item, distance, lat: point.lat, lng: point.lng });
}
}
}
return found.sort((x, y) => x.distance - y.distance);
}
}
@@ -0,0 +1,201 @@
/**
* PAD-11: what of an OpenStreetMap extract goes into the water network, read
* in the same pass as the paddle obstacles. The elements arrive as the reader
* gives them; the collector keeps a lean copy of the ones it wants and the
* ids of the nodes and member ways it will need positions and lines for.
*
* - Centre lines: river, canal, fairway, tidal channel, flowline, link and
* canoe pass; a stream only where a boat tag allows it or it is 4 m wide or
* more; a ditch or drain only with canoe=yes. A fish pass never.
* - Lakes: closed ways and multipolygons of natural=water (not the river and
* canal areas, which the centre lines already carry) and of
* landuse=reservoir.
* - Barriers: weirs, dams and waterfalls, as nodes or as lines across a river.
* - Portage ways, for carrying round a barrier.
*/
export interface CollectedWay {
id: number;
refs: number[];
tags: Record<string, string>;
}
export interface CollectedBarrier {
osmRef: string;
kind: 'weir' | 'dam' | 'waterfall';
name: string | null;
}
export interface CollectedArea {
osmRef: string;
name: string | null;
/** A closed way's nodes, or a multipolygon's member ways with their roles. */
ring?: number[];
members?: Array<{ way: number; role: string }>;
}
export interface CollectedWater {
centrelines: CollectedWay[];
barrierNodes: Map<number, CollectedBarrier>;
barrierWays: Array<CollectedWay & { barrier: CollectedBarrier }>;
portageWays: CollectedWay[];
areas: CollectedArea[];
/** Member ways of the multipolygons, filled in by the second pass. */
memberWays: Map<number, number[]>;
}
const CENTRELINES = new Set(['river', 'canal', 'fairway', 'tidal_channel', 'flowline', 'link', 'canoe_pass']);
const SMALL = new Set(['stream']);
const DITCHES = new Set(['ditch', 'drain']);
const BOAT_YES = new Set(['yes', 'designated', 'permissive']);
const BARRIERS = new Set(['weir', 'dam', 'waterfall']);
/** Water areas a centre line already carries, or that no one paddles. */
const NOT_LAKES = new Set(['river', 'canal', 'stream', 'ditch', 'drain', 'wastewater', 'stream_pool', 'moat']);
/** A centre line keeps these tags; the rest of OSM's stays behind. */
const LINE_TAGS = [
'waterway',
'whitewater:section_grade',
'name',
'canoe',
'kayak',
'boat',
'access',
'oneway:canoe',
'oneway:boat',
'intermittent',
'tunnel',
'lock',
'lock_name',
];
function boatAllows(tags: Readonly<Record<string, string>>): boolean {
return ['canoe', 'kayak', 'boat'].some((key) => BOAT_YES.has(tags[key] ?? ''));
}
function widthOf(tags: Readonly<Record<string, string>>): number {
const parsed = Number.parseFloat((tags.width ?? '').replace(',', '.'));
return Number.isFinite(parsed) ? parsed : 0;
}
/** The race of a mill or a power plant: water led to and from its wheel or turbine, nothing to paddle. */
const RACES = new Set(['headrace', 'tailrace', 'spillway']);
/** Whether a waterway line belongs in the network. */
function isNetworkLine(tags: Readonly<Record<string, string>>): boolean {
const waterway = tags.waterway;
if (waterway === undefined) return false;
if ((RACES.has(tags.usage ?? '') || RACES.has(tags.canal ?? '')) && !boatAllows(tags)) return false;
if (CENTRELINES.has(waterway)) return true;
if (SMALL.has(waterway)) return boatAllows(tags) || widthOf(tags) >= 4;
if (DITCHES.has(waterway)) return tags.canoe === 'yes';
return false;
}
/** Whether an area is a lake (or pond, reservoir, oxbow) the network may cross. */
function isLakeArea(tags: Readonly<Record<string, string>>): boolean {
if (tags.landuse === 'reservoir' || tags.landuse === 'basin')
return tags.water === undefined || !NOT_LAKES.has(tags.water);
return tags.natural === 'water' && !NOT_LAKES.has(tags.water ?? '');
}
function barrierOf(tags: Readonly<Record<string, string>>, osmRef: string): CollectedBarrier | null {
const waterway = tags.waterway ?? '';
if (!BARRIERS.has(waterway)) return null;
return { osmRef, kind: waterway as CollectedBarrier['kind'], name: tags.name ?? null };
}
function isPortageWay(tags: Readonly<Record<string, string>>): boolean {
return tags.canoe === 'portage' || tags.portage === 'yes' || tags.whitewater === 'portage_way';
}
function lineTags(tags: Readonly<Record<string, string>>): Record<string, string> {
const kept: Record<string, string> = {};
for (const key of LINE_TAGS) {
const value = tags[key];
if (value !== undefined) kept[key] = value;
}
return kept;
}
export class WaterCollector {
readonly water: CollectedWater = {
centrelines: [],
barrierNodes: new Map(),
barrierWays: [],
portageWays: [],
areas: [],
memberWays: new Map(),
};
/** Member ways of kept multipolygons, read in a second pass over the ways. */
readonly neededWays = new Set<number>();
/** `want` hears every node id a kept element runs through: their positions are needed. */
constructor(private readonly want: (nodeId: number) => void) {}
node(id: number, tags: Readonly<Record<string, string>>): void {
const barrier = barrierOf(tags, `node/${id}`);
if (barrier) {
this.water.barrierNodes.set(id, barrier);
this.want(id);
}
}
way(id: number, refs: readonly number[], tags: Readonly<Record<string, string>>): void {
if (isNetworkLine(tags)) {
this.keep(this.water.centrelines, { id, refs: [...refs], tags: lineTags(tags) });
return;
}
const barrier = barrierOf(tags, `way/${id}`);
if (barrier) {
this.water.barrierWays.push({ id, refs: [...refs], tags: {}, barrier });
this.wantAll(refs);
return;
}
if (isPortageWay(tags)) {
this.keep(this.water.portageWays, {
id,
refs: [...refs],
tags: { access: tags.access ?? '', name: tags.name ?? '' },
});
return;
}
const closed = refs.length >= 4 && refs[0] === refs[refs.length - 1];
if (closed && isLakeArea(tags)) {
this.water.areas.push({ osmRef: `way/${id}`, name: tags.name ?? null, ring: [...refs] });
this.wantAll(refs);
}
}
relation(
id: number,
members: ReadonlyArray<{ type: string; ref: number; role: string }>,
tags: Readonly<Record<string, string>>,
): void {
if (tags.type !== 'multipolygon' || !isLakeArea(tags)) return;
const ways = members.filter((member) => member.type === 'way');
if (ways.length === 0) return;
this.water.areas.push({
osmRef: `relation/${id}`,
name: tags.name ?? null,
members: ways.map((member) => ({ way: member.ref, role: member.role })),
});
for (const member of ways) this.neededWays.add(member.ref);
}
/** The second pass: a member way's nodes, kept whatever the way's own tags say. */
memberWay(id: number, refs: readonly number[]): void {
if (!this.neededWays.has(id)) return;
this.water.memberWays.set(id, [...refs]);
this.wantAll(refs);
}
private keep(list: CollectedWay[], way: CollectedWay): void {
list.push(way);
this.wantAll(way.refs);
}
private wantAll(refs: readonly number[]): void {
for (const ref of refs) this.want(ref);
}
}
@@ -0,0 +1,332 @@
import { LocalFrame } from '../engine/geo';
import type { LakeGrid } from '../water-graph';
import { type BuildEdge, type BuildState, PointGrid } from './build-state';
import type { CollectedArea, CollectedWater } from './collect';
import type { CoordOf } from './lines';
/** Ponds below this stay out; above FREE_LAKE_HA a lake stays even without water flowing in. */
const MIN_LAKE_HA = 0.5;
const FREE_LAKE_HA = 5;
/** The finest grid and the most cells a lake may take; a large lake gets coarser cells. */
const MIN_CELL_M = 5;
const MAX_CELLS = 800_000;
/** Line points this close to a barrier never count as in a lake, so a dam is never crossed through its reservoir. */
const BARRIER_CLEARANCE_M = 40;
/** A line that ends this close to a lake's water opens onto the lake. */
const SHORE_REACH_M = 30;
type Ring = Array<[number, number]>;
/**
* The rings of a multipolygon from its member ways, joined end to end by
* their node ids as OSM splits long shores into many ways. A ring that does
* not close is broken data and left out; the others are kept.
*/
function assembleRings(
members: ReadonlyArray<{ refs: number[]; role: string }>,
): Array<{ refs: number[]; inner: boolean }> {
const rings: Array<{ refs: number[]; inner: boolean }> = [];
for (const inner of [false, true]) {
const pieces = members
.filter((member) => (member.role === 'inner') === inner && member.refs.length >= 2)
.map((member) => [...member.refs]);
const byEnd = new Map<number, number[][]>();
const index = (piece: number[]) => {
for (const end of [piece[0] as number, piece[piece.length - 1] as number])
byEnd.set(end, [...(byEnd.get(end) ?? []), piece]);
};
const unindex = (piece: number[]) => {
for (const end of [piece[0] as number, piece[piece.length - 1] as number]) {
byEnd.set(
end,
(byEnd.get(end) ?? []).filter((other) => other !== piece),
);
}
};
pieces.forEach(index);
const used = new Set<number[]>();
for (const start of pieces) {
if (used.has(start)) continue;
used.add(start);
unindex(start);
const ring = [...start];
while (ring[0] !== ring[ring.length - 1]) {
const end = ring[ring.length - 1] as number;
const next = (byEnd.get(end) ?? []).find((piece) => !used.has(piece));
if (!next) break;
used.add(next);
unindex(next);
ring.push(...(next[0] === end ? next.slice(1) : [...next].reverse().slice(1)));
}
if (ring.length >= 4 && ring[0] === ring[ring.length - 1]) rings.push({ refs: ring, inner });
}
}
return rings;
}
function ringsOf(
area: CollectedArea,
water: CollectedWater,
coordOf: CoordOf,
): Array<{ points: Ring; inner: boolean }> {
const refRings = area.ring
? [{ refs: area.ring, inner: false }]
: assembleRings(
(area.members ?? []).map((member) => ({ refs: water.memberWays.get(member.way) ?? [], role: member.role })),
);
return refRings.flatMap(({ refs, inner }) => {
const points = refs.flatMap((ref) => {
const at = coordOf(ref);
return at ? [at] : [];
});
return points.length >= 4 ? [{ points, inner }] : [];
});
}
function setBit(mask: Uint8Array, index: number): void {
mask[index >> 3] = (mask[index >> 3] as number) | (1 << (index & 7));
}
export function maskHas(lake: Pick<LakeGrid, 'mask' | 'width' | 'height'>, col: number, row: number): boolean {
if (col < 0 || row < 0 || col >= lake.width || row >= lake.height) return false;
const index = row * lake.width + col;
return ((lake.mask[index >> 3] as number) & (1 << (index & 7))) !== 0;
}
/**
* A lake as a grid of water cells. A cell is water when its centre lies in
* the lake (outer rings minus islands), and also when a shore line passes
* through it, so a narrow channel a cell wide stays open; the router keeps
* its distance from the shore, not the grid.
*/
function rasterizeLake(rings: Array<{ points: Ring; inner: boolean }>, name: string | null): LakeGrid | null {
const outer = rings.filter((ring) => !ring.inner);
if (outer.length === 0) return null;
let south = 90;
let west = 180;
let north = -90;
let east = -180;
for (const ring of outer) {
for (const [lat, lng] of ring.points) {
south = Math.min(south, lat);
north = Math.max(north, lat);
west = Math.min(west, lng);
east = Math.max(east, lng);
}
}
const frame = new LocalFrame(south, west);
const spanX = frame.x(east);
const spanY = frame.y(north);
const cell = Math.max(MIN_CELL_M, Math.sqrt((spanX * spanY) / MAX_CELLS));
const width = Math.ceil(spanX / cell) + 1;
const height = Math.ceil(spanY / cell) + 1;
const mask = new Uint8Array(Math.ceil((width * height) / 8));
const local = rings.map((ring) => ring.points.map(([lat, lng]) => [frame.x(lng), frame.y(lat)] as [number, number]));
// Scanlines: every ring crossing of a row's centre line, filled pairwise (even-odd, so islands stay land).
const crossings: number[][] = Array.from({ length: height }, () => []);
let area = 0;
local.forEach((points, index) => {
let ringArea = 0;
for (let i = 1; i < points.length; i++) {
const [x1, y1] = points[i - 1] as [number, number];
const [x2, y2] = points[i] as [number, number];
ringArea += x1 * y2 - x2 * y1;
if (y1 === y2) continue;
const low = Math.min(y1, y2);
const high = Math.max(y1, y2);
for (let row = Math.max(0, Math.ceil(low / cell - 0.5)); row < height; row++) {
const y = (row + 0.5) * cell;
if (y >= high) break;
if (y < low) continue;
(crossings[row] as number[]).push(x1 + ((y - y1) * (x2 - x1)) / (y2 - y1));
}
}
area += (rings[index]?.inner ? -1 : 1) * Math.abs(ringArea / 2);
});
const hectares = area / 10_000;
if (hectares < MIN_LAKE_HA) return null;
crossings.forEach((xs, row) => {
xs.sort((a, b) => a - b);
for (let i = 0; i + 1 < xs.length; i += 2) {
const from = Math.max(0, Math.ceil((xs[i] as number) / cell - 0.5));
const to = Math.min(width - 1, Math.floor((xs[i + 1] as number) / cell - 0.5));
for (let col = from; col <= to; col++) setBit(mask, row * width + col);
}
});
// Shore lines mark the cells they pass through.
for (const points of local) {
for (let i = 1; i < points.length; i++) {
const [x1, y1] = points[i - 1] as [number, number];
const [x2, y2] = points[i] as [number, number];
const steps = Math.max(1, Math.ceil(Math.hypot(x2 - x1, y2 - y1) / (cell / 2)));
for (let step = 0; step <= steps; step++) {
const col = Math.floor((x1 + ((x2 - x1) * step) / steps) / cell);
const row = Math.floor((y1 + ((y2 - y1) * step) / steps) / cell);
if (col >= 0 && row >= 0 && col < width && row < height) setBit(mask, row * width + col);
}
}
}
return { name, lat0: south, lng0: west, cell, width, height, mask, portals: new Int32Array(0), hectares };
}
/** The lakes on a coarse grid of their boxes, for asking which lake a point lies in. */
export class LakeIndex {
private readonly cells = new Map<string, number[]>();
private static readonly DEG = 0.02;
constructor(private readonly lakes: readonly LakeGrid[]) {
lakes.forEach((lake, index) => {
const frame = new LocalFrame(lake.lat0, lake.lng0);
const north = frame.lat(lake.height * lake.cell);
const east = frame.lng(lake.width * lake.cell);
for (let r = Math.floor(lake.lat0 / LakeIndex.DEG); r <= Math.floor(north / LakeIndex.DEG); r++) {
for (let c = Math.floor(lake.lng0 / LakeIndex.DEG); c <= Math.floor(east / LakeIndex.DEG); c++) {
const key = `${r}:${c}`;
const cell = this.cells.get(key);
if (cell) cell.push(index);
else this.cells.set(key, [index]);
}
}
});
}
/** The first lake whose water cell holds the point, or -1. */
at(lat: number, lng: number): number {
for (const index of this.cells.get(`${Math.floor(lat / LakeIndex.DEG)}:${Math.floor(lng / LakeIndex.DEG)}`) ?? []) {
const lake = this.lakes[index] as LakeGrid;
const frame = new LocalFrame(lake.lat0, lake.lng0);
if (maskHas(lake, Math.floor(frame.x(lng) / lake.cell), Math.floor(frame.y(lat) / lake.cell))) return index;
}
return -1;
}
}
export function rasterizeLakes(water: CollectedWater, coordOf: CoordOf): LakeGrid[] {
return water.areas.flatMap((area) => {
const lake = rasterizeLake(ringsOf(area, water, coordOf), area.name);
return lake ? [lake] : [];
});
}
/**
* Cuts the lines where they enter and leave a lake: what runs on land stays,
* reaching one point into the lake, where a portal node opens onto it; what
* runs inside is dropped, since the lake's own grid routes across it. Then
* lakes nothing opens into and smaller than FREE_LAKE_HA go.
*/
export function attachLakes(state: BuildState, lakes: LakeGrid[]): LakeGrid[] {
const index = new LakeIndex(lakes);
const barrierGrid = new PointGrid<number>();
state.barriers.forEach((site, at) => barrierGrid.add(site.lat, site.lng, at));
const nearBarrier = (lat: number, lng: number) => barrierGrid.near(lat, lng, BARRIER_CLEARANCE_M).length > 0;
const lakeOfPoint = (lat: number, lng: number) => (nearBarrier(lat, lng) ? -1 : index.at(lat, lng));
const count = state.edges.length;
for (let e = 0; e < count; e++) {
const edge = state.edges[e] as BuildEdge;
if (edge.dead) continue;
const inside = edge.line.map(([lat, lng], at) => {
const endNode = at === 0 ? edge.a : at === edge.line.length - 1 ? edge.b : -1;
if (endNode >= 0 && state.nodes[endNode]?.barrier) return -1;
return lakeOfPoint(lat, lng);
});
if (inside.every((lake) => lake < 0)) continue;
edge.dead = true;
// One node per point, so a point that both ends a run and starts a crossing into the next lake is one portal.
const portals = new Map<number, number>();
const portalAt = (point: number): number => {
const known = portals.get(point);
if (known !== undefined) return known;
let node: number;
if (point === 0) node = markPortal(state, edge.a, inside[0] as number);
else if (point === edge.line.length - 1) node = markPortal(state, edge.b, inside[point] as number);
else {
const [lat, lng] = edge.line[point] as [number, number];
node = markPortal(state, state.addNode(lat, lng), inside[point] as number);
}
portals.set(point, node);
return node;
};
let runStart = -1;
for (let point = 0; point <= edge.line.length; point++) {
const outside = point < edge.line.length && (inside[point] as number) < 0;
if (outside && runStart < 0) runStart = point;
if (!outside && runStart >= 0) {
// The run reaches one point into the water on each side it touches a lake.
const first = runStart > 0 ? runStart - 1 : 0;
const last = point < edge.line.length ? point : point - 1;
if (last > first) {
const a = first === 0 && runStart === 0 ? edge.a : portalAt(first);
const b = last === edge.line.length - 1 && point === edge.line.length ? edge.b : portalAt(last);
state.addEdge({ ...edge, a, b, line: edge.line.slice(first, last + 1) });
}
runStart = -1;
}
}
// Where the line passes straight from one lake into the next (two waters mapped edge to edge),
// the step between them joins the two lakes' portals.
for (let point = 1; point < edge.line.length; point++) {
const from = inside[point - 1] as number;
const to = inside[point] as number;
if (from < 0 || to < 0 || from === to) continue;
state.addEdge({
...edge,
a: portalAt(point - 1),
b: portalAt(point),
line: edge.line.slice(point - 1, point + 1),
});
}
// Ends inside a lake open onto it even when nothing of the edge stays.
if ((inside[0] as number) >= 0) markPortal(state, edge.a, inside[0] as number);
const lastPoint = edge.line.length - 1;
if ((inside[lastPoint] as number) >= 0) markPortal(state, edge.b, inside[lastPoint] as number);
}
// Line ends that stop just short of a lake's water open onto it too.
const incidence = state.incidence();
state.nodes.forEach((node, at) => {
if (node.lake >= 0 || node.barrier || (incidence[at] as number[]).length !== 1) return;
const lake = lakeNear(index, node.lat, node.lng);
if (lake >= 0) markPortal(state, at, lake);
});
const portals = lakes.map(() => [] as number[]);
state.nodes.forEach((node, at) => {
if (node.lake >= 0) portals[node.lake]?.push(at);
});
const kept: LakeGrid[] = [];
const remap = new Map<number, number>();
lakes.forEach((lake, at) => {
const lakePortals = portals[at] as number[];
if (lakePortals.length === 0 && lake.hectares < FREE_LAKE_HA) return;
remap.set(at, kept.length);
kept.push({ ...lake, portals: Int32Array.from(lakePortals) });
});
for (const node of state.nodes) if (node.lake >= 0) node.lake = remap.get(node.lake) ?? -1;
return kept;
}
/** The lake at a point, or the nearest one within SHORE_REACH_M of it (searched on two rings of eight bearings). */
function lakeNear(index: LakeIndex, lat: number, lng: number): number {
const here = index.at(lat, lng);
if (here >= 0) return here;
const cos = Math.cos((lat * Math.PI) / 180);
for (const reach of [SHORE_REACH_M / 2, SHORE_REACH_M]) {
for (let bearing = 0; bearing < 8; bearing++) {
const angle = (bearing * Math.PI) / 4;
const lake = index.at(
lat + (reach * Math.cos(angle)) / 111_195,
lng + (reach * Math.sin(angle)) / (111_195 * cos),
);
if (lake >= 0) return lake;
}
}
return -1;
}
function markPortal(state: BuildState, node: number, lake: number): number {
const at = state.nodes[node];
if (at && at.lake < 0 && !at.barrier && lake >= 0) at.lake = lake;
return node;
}
@@ -0,0 +1,284 @@
import { Access, EdgeKind } from '../water-graph';
import { BuildState, PointGrid } from './build-state';
import type { CollectedBarrier, CollectedWater, CollectedWay } from './collect';
export type CoordOf = (nodeId: number) => [number, number] | null;
/** How fast the water runs, km/h, by the kind of line; still water has none. */
const CURRENT_KMH: Record<string, number> = {
river: 1.5,
stream: 2,
canoe_pass: 1.5,
flowline: 0.5,
};
/** A dangling end this close to other water is joined to it as water, farther up to GAP_M as a marked gap. */
const CONNECT_M = 30;
const GAP_M = 150;
interface LinePoint {
ref: number;
lat: number;
lng: number;
barrier: CollectedBarrier | null;
}
function closedTo(value: string | undefined): boolean {
return value === 'no' || value === 'private';
}
/** A stretch of this whitewater grade or harder is no touring water; the router leaves it out. */
const CLOSED_GRADE = 3;
/** The whitewater grade a line gives (`whitewater:section_grade`, 0 to 6), 0 when none. */
function gradeOf(tags: Readonly<Record<string, string>>): number {
const grade = Number.parseInt(tags['whitewater:section_grade'] ?? '', 10);
return Number.isFinite(grade) && grade > 0 ? Math.min(6, grade) : 0;
}
/** Who may paddle a line: the most specific boat tag decides (canoe, kayak, then boat, then access). */
function accessOf(tags: Readonly<Record<string, string>>): number {
const rule = tags.canoe ?? tags.kayak ?? tags.boat ?? tags.access;
if (closedTo(rule)) return Access.closed;
if (gradeOf(tags) >= CLOSED_GRADE) return Access.closed;
const tunnel = tags.tunnel === 'yes' || tags.tunnel === 'culvert';
if (tunnel && !['yes', 'designated', 'permissive'].includes(tags.canoe ?? tags.boat ?? '')) return Access.closed;
if (rule === 'discouraged' || tags.intermittent === 'yes') return Access.discouraged;
return Access.open;
}
interface Segment {
aLat: number;
aLng: number;
bLat: number;
bLng: number;
barrier: CollectedBarrier;
refs: Set<number>;
}
/** Where two segments cross, as the share along the first, or null. */
function crossing(
aLat: number,
aLng: number,
bLat: number,
bLng: number,
s: Pick<Segment, 'aLat' | 'aLng' | 'bLat' | 'bLng'>,
): number | null {
const rLat = bLat - aLat;
const rLng = bLng - aLng;
const qLat = s.bLat - s.aLat;
const qLng = s.bLng - s.aLng;
const denom = rLat * qLng - rLng * qLat;
if (denom === 0) return null;
const t = ((s.aLat - aLat) * qLng - (s.aLng - aLng) * qLat) / denom;
const u = ((s.aLat - aLat) * rLng - (s.aLng - aLng) * rLat) / denom;
return t >= 0 && t <= 1 && u >= 0 && u <= 1 ? t : null;
}
/** A crossing this close to an end of a river segment is on that end's node, in shares of the segment. */
const AT_VERTEX = 1e-9;
/** Barrier lines on a coarse grid, for the river segments that might cross them. */
function barrierSegments(water: CollectedWater, coordOf: CoordOf): Map<string, Segment[]> {
const grid = new Map<string, Segment[]>();
for (const way of water.barrierWays) {
const refs = new Set(way.refs);
for (let i = 1; i < way.refs.length; i++) {
const a = coordOf(way.refs[i - 1] as number);
const b = coordOf(way.refs[i] as number);
if (!a || !b) continue;
const segment: Segment = { aLat: a[0], aLng: a[1], bLat: b[0], bLng: b[1], barrier: way.barrier, refs };
for (const key of cellsOf(a[0], a[1], b[0], b[1])) {
const cell = grid.get(key);
if (cell) cell.push(segment);
else grid.set(key, [segment]);
}
}
}
return grid;
}
const SEG_CELL = 0.005;
function cellsOf(aLat: number, aLng: number, bLat: number, bLng: number): string[] {
const keys: string[] = [];
for (let r = Math.floor(Math.min(aLat, bLat) / SEG_CELL); r <= Math.floor(Math.max(aLat, bLat) / SEG_CELL); r++) {
for (let c = Math.floor(Math.min(aLng, bLng) / SEG_CELL); c <= Math.floor(Math.max(aLng, bLng) / SEG_CELL); c++) {
keys.push(`${r}:${c}`);
}
}
return keys;
}
/**
* A centre line's points, with a barrier marked where a weir or dam stands on
* it: on a shared node, or where a barrier line crosses it without one, which
* is cut in as a point of its own. Missing nodes (outside the extract) drop out.
*/
function linePoints(
way: CollectedWay,
barrierAt: ReadonlyMap<number, CollectedBarrier>,
coordOf: CoordOf,
barriers: Map<string, Segment[]>,
nextSynthetic: () => number,
): LinePoint[] {
const points: LinePoint[] = [];
for (const ref of way.refs) {
const at = coordOf(ref);
if (!at) continue;
const previous = points[points.length - 1];
if (previous && previous.lat === at[0] && previous.lng === at[1]) continue;
// A barrier line crossing exactly through a node of the river bars that node rather than a point beside it.
let onVertex: CollectedBarrier | null = null;
if (previous) {
const crossings: Array<{ t: number; barrier: CollectedBarrier }> = [];
for (const key of new Set(cellsOf(previous.lat, previous.lng, at[0], at[1]))) {
for (const segment of barriers.get(key) ?? []) {
if (segment.refs.has(previous.ref) || segment.refs.has(ref)) continue;
const t = crossing(previous.lat, previous.lng, at[0], at[1], segment);
if (t !== null && !crossings.some((seen) => seen.barrier === segment.barrier))
crossings.push({ t, barrier: segment.barrier });
}
}
crossings.sort((x, y) => x.t - y.t);
for (const { t, barrier } of crossings) {
if (t <= AT_VERTEX) previous.barrier ??= barrier;
else if (t >= 1 - AT_VERTEX) onVertex ??= barrier;
else
points.push({
ref: nextSynthetic(),
lat: previous.lat + (at[0] - previous.lat) * t,
lng: previous.lng + (at[1] - previous.lng) * t,
barrier,
});
}
}
points.push({ ref, lat: at[0], lng: at[1], barrier: barrierAt.get(ref) ?? onVertex });
}
return points;
}
/**
* The rivers and canals as edges between the points where they meet, end or
* are barred. A barrier is no node at all but one node per side, so nothing
* paddles through it; the portage builder joins the sides later.
*/
export function buildLines(water: CollectedWater, coordOf: CoordOf, state: BuildState): void {
const barrierGrid = barrierSegments(water, coordOf);
const barrierAt = new Map<number, CollectedBarrier>();
for (const barrierWay of water.barrierWays) for (const ref of barrierWay.refs) barrierAt.set(ref, barrierWay.barrier);
for (const [ref, barrier] of water.barrierNodes) barrierAt.set(ref, barrier);
let synthetic = 0;
const nextSynthetic = () => --synthetic;
const lines = water.centrelines.map((way) => ({
way,
points: linePoints(way, barrierAt, coordOf, barrierGrid, nextSynthetic),
}));
const uses = new Map<number, number>();
for (const { points } of lines) for (const point of points) uses.set(point.ref, (uses.get(point.ref) ?? 0) + 1);
const nodeOfRef = new Map<number, number>();
const sites = new Map<number, number>();
const nodeFor = (point: LinePoint): number => {
if (point.barrier) {
// Each line meeting a barrier gets its own side node.
const side = state.addNode(point.lat, point.lng, true);
let site = sites.get(point.ref);
if (site === undefined) {
site = state.barriers.length;
state.barriers.push({
lat: point.lat,
lng: point.lng,
name: point.barrier.name,
ref: point.barrier.osmRef,
sides: [],
});
sites.set(point.ref, site);
}
state.barriers[site]?.sides.push(side);
return side;
}
const known = nodeOfRef.get(point.ref);
if (known !== undefined) return known;
const node = state.addNode(point.lat, point.lng);
nodeOfRef.set(point.ref, node);
return node;
};
for (const { way, points } of lines) {
if (points.length < 2) continue;
const tags = way.tags;
const current = CURRENT_KMH[tags.waterway ?? ''] ?? 0;
const attrs = {
kind: tags.lock === 'yes' ? EdgeKind.lock : tags.waterway === 'canoe_pass' ? EdgeKind.pass : EdgeKind.water,
flow: current > 0 ? (1 as const) : (0 as const),
current,
access: accessOf(tags),
oneway: tags['oneway:canoe'] === 'yes' || tags['oneway:boat'] === 'yes' ? (1 as const) : (0 as const),
grade: gradeOf(tags),
// A lock goes by its own name (Schleuse Diemitz), not the waterway it lies on.
name: (tags.lock === 'yes' ? tags.lock_name : undefined) ?? tags.name ?? tags.lock_name ?? null,
ref: `way/${way.id}`,
source: 0,
};
let start = 0;
let startNode = nodeFor(points[0] as LinePoint);
for (let i = 1; i < points.length; i++) {
const point = points[i] as LinePoint;
const isLast = i === points.length - 1;
if (!isLast && !point.barrier && (uses.get(point.ref) ?? 0) < 2) continue;
const endNode = nodeFor(point);
state.addEdge({
a: startNode,
b: endNode,
line: points.slice(start, i + 1).map((p) => [p.lat, p.lng]),
...attrs,
});
// Past a barrier the line goes on from a node of its own, the other side.
startNode = point.barrier && !isLast ? nodeFor(point) : endNode;
start = i;
}
}
}
/**
* Joins line ends that stop short of other water, as OSM often leaves a side
* arm a few metres off the river: to the nearest point of another line within
* CONNECT_M as water, within GAP_M as a gap the route marks. Barrier sides are
* left alone (a barrier is meant to stop the line), and so are ends that open
* into a lake, which the lake joins.
*/
export function joinDanglingEnds(state: BuildState): void {
const incidence = state.incidence();
const grid = new PointGrid<{ edge: number; point: number }>();
state.edges.forEach((edge, index) => {
if (edge.dead) return;
edge.line.forEach(([lat, lng], point) => grid.add(lat, lng, { edge: index, point }));
});
const requests: Array<{ node: number; edge: number; point: number; distance: number }> = [];
state.nodes.forEach((node, index) => {
const at = incidence[index] as number[];
if (node.barrier || node.lake >= 0 || at.length !== 1) return;
const own = new Set(at);
const target = grid.near(node.lat, node.lng, GAP_M).find(({ item }) => !own.has(item.edge));
if (target)
requests.push({ node: index, edge: target.item.edge, point: target.item.point, distance: target.distance });
});
// Cut every target edge once per point, from its far end back, so earlier point indices still hold.
const byEdge = new Map<number, number[]>();
for (const request of requests) byEdge.set(request.edge, [...(byEdge.get(request.edge) ?? []), request.point]);
const nodeAt = new Map<string, number>();
for (const [edge, points] of byEdge) {
for (const point of [...new Set(points)].sort((x, y) => y - x))
nodeAt.set(`${edge}:${point}`, state.splitAt(edge, point));
}
const joined = new Set<string>();
for (const request of requests) {
const target = nodeAt.get(`${request.edge}:${request.point}`) as number;
if (target === request.node) continue;
const key = request.node < target ? `${request.node}-${target}` : `${target}-${request.node}`;
if (joined.has(key)) continue;
joined.add(key);
state.addLink(request.node, target, request.distance <= CONNECT_M ? EdgeKind.connector : EdgeKind.gap);
}
}
@@ -0,0 +1,177 @@
import { projectOnSegment } from '../engine/geo';
import { Access, EdgeKind, PortageSource } from '../water-graph';
import { type BarrierSite, type BuildEdge, type BuildState, PointGrid } from './build-state';
import type { CollectedWater } from './collect';
import type { CoordOf } from './lines';
/** A mapped portage way counts for a barrier when it starts and ends this close to the water on two of its sides. */
const PORTAGE_REACH_M = 80;
/** How far around a barrier its mapped portage ways are looked for. */
const PORTAGE_SEARCH_M = 250;
/** Without a mapped way the carry starts and ends this far up and down the water from the barrier... */
const INFERRED_OFFSET_M = 25;
/** ...and is not drawn when it would come out longer than this. */
const INFERRED_MAX_M = 150;
/** A lock longer than this gets no straight carry beside it. */
const LOCK_CARRY_MAX_M = 400;
interface PortageWay {
points: Array<[number, number]>;
name: string | null;
}
/** The single live edge at a barrier side, or null when the lake builder took it. */
function sideEdge(state: BuildState, side: number): number | null {
const at = state.edgesAt(side);
return at.length === 1 ? (at[0] as number) : null;
}
/** How far an edge passes from a spot, in metres. */
function distanceTo(edge: BuildEdge, lat: number, lng: number): number {
let best = Infinity;
for (let seg = 0; seg + 1 < edge.line.length; seg++) {
const a = edge.line[seg] as [number, number];
const b = edge.line[seg + 1] as [number, number];
best = Math.min(best, projectOnSegment(lat, lng, a[0], a[1], b[0], b[1]).distance);
}
return best;
}
/**
* Joins the sides of every barrier by a carry: along a portage way mapped in
* OSM where one reaches the water on two sides, else straight between points
* a little way up and down the water, marked as inferred. A lock open to boats
* also gets a straight carry beside it, for a paddler who would rather carry
* than wait; a lock closed to them (a ship lock with canoe=no) gets only a
* mapped portage way, never a guessed carry across its grounds.
*/
export function buildPortages(state: BuildState, water: CollectedWater, coordOf: CoordOf): void {
const ways: PortageWay[] = water.portageWays.flatMap((way) => {
if (way.tags.access === 'private' || way.tags.access === 'no') return [];
const points = way.refs.flatMap((ref) => {
const at = coordOf(ref);
return at ? [at] : [];
});
return points.length >= 2 ? [{ points, name: way.tags.name || null }] : [];
});
const wayGrid = new PointGrid<PortageWay>();
for (const way of ways) for (const [lat, lng] of way.points) wayGrid.add(lat, lng, way);
for (const site of state.barriers) {
const sides = site.sides.flatMap((side) => {
const edge = sideEdge(state, side);
return edge === null ? [] : [{ side, edge }];
});
if (sides.length < 2) continue;
const candidates = [...new Set(wayGrid.near(site.lat, site.lng, PORTAGE_SEARCH_M).map(({ item }) => item))];
let carried = false;
for (const way of candidates) carried = carryAlong(state, site, sides, way) || carried;
if (!carried) inferCarries(state, site, sides);
}
for (const { edge, index } of state.live()) {
if (edge.kind !== EdgeKind.lock) continue;
if (edge.access === Access.closed) {
const sides = [edge.a, edge.b].flatMap((node) => {
const other = state.edgesAt(node).find((at) => at !== index && state.edges[at]?.kind !== EdgeKind.portage);
return other === undefined ? [] : [{ side: node, edge: other }];
});
if (sides.length < 2) continue;
const middle = edge.line[Math.floor(edge.line.length / 2)] as [number, number];
const site: BarrierSite = {
lat: middle[0],
lng: middle[1],
name: edge.name,
ref: edge.ref ?? '',
sides: [edge.a, edge.b],
};
for (const way of new Set(wayGrid.near(site.lat, site.lng, PORTAGE_SEARCH_M).map(({ item }) => item)))
carryAlong(state, site, sides, way);
continue;
}
if (state.distance(edge.a, edge.b) > LOCK_CARRY_MAX_M) continue;
const from = state.nodes[edge.a];
const to = state.nodes[edge.b];
if (!from || !to) continue;
state.addPortage(
edge.a,
edge.b,
[
[from.lat, from.lng],
[to.lat, to.lng],
],
PortageSource.inferred,
edge.name,
edge.ref,
);
}
}
/**
* A mapped portage way as the carry round a barrier: its two ends paired with
* two different sides, the pairing whose farther end lies nearest the water,
* both within PORTAGE_REACH_M.
*/
function carryAlong(
state: BuildState,
site: BarrierSite,
sides: Array<{ side: number; edge: number }>,
way: PortageWay,
): boolean {
const first = way.points[0] as [number, number];
const last = way.points[way.points.length - 1] as [number, number];
const reach = sides.map(({ edge }) => ({
edge,
first: distanceTo(state.edges[edge] as BuildEdge, first[0], first[1]),
last: distanceTo(state.edges[edge] as BuildEdge, last[0], last[1]),
}));
let best: { start: (typeof reach)[number]; end: (typeof reach)[number]; worst: number } | null = null;
for (const start of reach) {
for (const end of reach) {
if (start.edge === end.edge) continue;
const worst = Math.max(start.first, end.last);
if (worst <= PORTAGE_REACH_M && (!best || worst < best.worst)) best = { start, end, worst };
}
}
if (!best) return false;
// Out and back in where the portage way reaches the water, not at the barrier itself.
const a = state.nodeNearest(best.start.edge, first[0], first[1]);
const b = state.nodeNearest(best.end.edge, last[0], last[1]);
const nodeA = state.nodes[a];
const nodeB = state.nodes[b];
if (!nodeA || !nodeB) return false;
state.addPortage(
a,
b,
[[nodeA.lat, nodeA.lng], ...way.points, [nodeB.lat, nodeB.lng]],
PortageSource.tagged,
way.name ?? site.name,
site.ref,
);
return true;
}
function inferCarries(state: BuildState, site: BarrierSite, sides: Array<{ side: number; edge: number }>): void {
const ends = sides.map(({ side, edge }) => state.nodeAlong(edge, side, INFERRED_OFFSET_M));
for (let i = 0; i < ends.length; i++) {
for (let j = i + 1; j < ends.length; j++) {
const a = ends[i] as number;
const b = ends[j] as number;
if (state.distance(a, b) > INFERRED_MAX_M) continue;
const from = state.nodes[a];
const to = state.nodes[b];
if (!from || !to) continue;
state.addPortage(
a,
b,
[
[from.lat, from.lng],
[to.lat, to.lng],
],
PortageSource.inferred,
site.name,
site.ref,
);
}
}
}
@@ -0,0 +1,71 @@
/** Metres per degree of latitude, the figure the paddle code reckons with throughout. */
const M_PER_DEG = 111_195;
export function metresBetween(aLat: number, aLng: number, bLat: number, bLng: number): number {
const cos = Math.cos((((aLat + bLat) / 2) * Math.PI) / 180);
return Math.hypot((bLat - aLat) * M_PER_DEG, (bLng - aLng) * M_PER_DEG * cos);
}
/**
* A flat frame in metres around one spot, good for the few kilometres a lake
* or a stretch of river spans: x east, y north.
*/
export class LocalFrame {
readonly cos: number;
constructor(
readonly lat0: number,
readonly lng0: number,
) {
this.cos = Math.cos((lat0 * Math.PI) / 180);
}
x(lng: number): number {
return (lng - this.lng0) * M_PER_DEG * this.cos;
}
y(lat: number): number {
return (lat - this.lat0) * M_PER_DEG;
}
lat(y: number): number {
return this.lat0 + y / M_PER_DEG;
}
lng(x: number): number {
return this.lng0 + x / (M_PER_DEG * this.cos);
}
}
/**
* Where a point falls on a segment: the share along it (clamped to the
* segment) and the distance to it, in metres.
*/
export function projectOnSegment(
lat: number,
lng: number,
aLat: number,
aLng: number,
bLat: number,
bLng: number,
): { t: number; distance: number } {
const cos = Math.cos((lat * Math.PI) / 180);
const ax = (aLng - lng) * cos;
const ay = aLat - lat;
const dx = (bLng - aLng) * cos;
const dy = bLat - aLat;
const length2 = dx * dx + dy * dy;
const t = length2 === 0 ? 0 : Math.max(0, Math.min(1, -(ax * dx + ay * dy) / length2));
return { t, distance: Math.hypot(ax + t * dx, ay + t * dy) * M_PER_DEG };
}
/** The length of a [lat, lng] line in metres. */
export function lineLength(points: ReadonlyArray<readonly [number, number]>): number {
let total = 0;
for (let i = 1; i < points.length; i++) {
const a = points[i - 1] as readonly [number, number];
const b = points[i] as readonly [number, number];
total += metresBetween(a[0], a[1], b[0], b[1]);
}
return total;
}
@@ -0,0 +1,355 @@
import { maskHas } from '../build/lakes';
import type { LakeGrid } from '../water-graph';
import { LocalFrame } from './geo';
import { MinHeap } from './min-heap';
import type { PaddleLakeStyle } from '@trek/shared';
/**
* Routing across one lake on its grid of water cells. Every cell knows how
* far it lies from the shore; a crossing costs its length weighed by that:
* straight over is cheapest with the default style, staying near the shore
* is with `shore`, and water farther out than half a boat's open-water limit
* costs so much that the route takes it only when there is no other way.
*
* Two levels keep a large lake quick: the costs between the lake's portals
* come from a coarse copy of the grid (a few ten thousand cells, however big
* the lake), and only the line actually taken is searched on the fine grid,
* by A* towards its end.
*/
/** Keep this far off the shore where the lake is wide enough, in metres. */
const SHORE_MARGIN_M = 12;
/** With `shore`, open water costs more from here out, up to SHORE_FAR_M. */
const SHORE_NEAR_M = 40;
const SHORE_FAR_M = 200;
const SHORE_WEIGHT = 1.5;
/** Water beyond a boat's open-water reach costs this many times its length. */
const OVER_LIMIT_WEIGHT = 20;
/** How far a point off the grid may be from water cells to count as on the lake, in cells. */
const SNAP_CELLS = 6;
/** The coarse grid holds at most about this many cells. */
const COARSE_CELLS = 60_000;
const NEIGHBOURS: ReadonlyArray<readonly [number, number, number]> = [
[1, 0, 1],
[-1, 0, 1],
[0, 1, 1],
[0, -1, 1],
[1, 1, Math.SQRT2],
[1, -1, Math.SQRT2],
[-1, 1, Math.SQRT2],
[-1, -1, Math.SQRT2],
];
export interface LakeProfile {
style: PaddleLakeStyle;
/** The widest open water the boat should cross, in metres. */
maxCrossingM: number;
}
/** What a search needs of a grid: its size, which cells are water and what a cell costs per metre. */
interface Grid {
width: number;
height: number;
cell: number;
water: (cell: number) => boolean;
weight: (cell: number) => number;
}
function weightOf(shore: number, profile: LakeProfile): number {
let weight = 1;
if (shore < SHORE_MARGIN_M) weight += 0.5;
if (profile.style === 'shore') {
weight += SHORE_WEIGHT * Math.min(1, Math.max(0, (shore - SHORE_NEAR_M) / (SHORE_FAR_M - SHORE_NEAR_M)));
}
if (shore > profile.maxCrossingM / 2) weight *= OVER_LIMIT_WEIGHT;
return weight;
}
function step(grid: Grid, at: number, each: (next: number, length: number) => void): void {
const { width, height, cell } = grid;
const col = at % width;
const row = (at - col) / width;
for (const [dc, dr, factor] of NEIGHBOURS) {
const c = col + dc;
const r = row + dr;
if (c < 0 || r < 0 || c >= width || r >= height) continue;
const next = r * width + c;
if (!grid.water(next)) continue;
// A diagonal step may not cut a corner of land.
if (dc !== 0 && dr !== 0 && (!grid.water(row * width + c) || !grid.water(r * width + col))) continue;
each(next, factor * cell);
}
}
/** Weighted metres from one cell to every cell, stopping once all `targets` are settled. */
function dijkstra(grid: Grid, source: number, targets: ReadonlySet<number>): Float64Array {
const cost = new Float64Array(grid.width * grid.height).fill(Infinity);
const heap = new MinHeap(4096);
cost[source] = 0;
heap.push(source, 0);
let remaining = targets.size;
while (heap.size > 0 && remaining > 0) {
const key = heap.peekKey();
const at = heap.pop();
if (key > (cost[at] as number)) continue;
if (targets.has(at)) remaining--;
const here = grid.weight(at);
step(grid, at, (next, length) => {
const via = (cost[at] as number) + ((here + grid.weight(next)) / 2) * length;
if (via < (cost[next] as number)) {
cost[next] = via;
heap.push(next, via);
}
});
}
return cost;
}
/** The cheapest cell path between two cells, by A* with the straight distance as its guide; null when none. */
function astar(grid: Grid, from: number, to: number): number[] | null {
const { width } = grid;
const toCol = to % width;
const toRow = (to - toCol) / width;
const h = (cell: number) => {
const dc = Math.abs((cell % width) - toCol);
const dr = Math.abs(Math.floor(cell / width) - toRow);
return (Math.max(dc, dr) + (Math.SQRT2 - 1) * Math.min(dc, dr)) * grid.cell;
};
const cost = new Float64Array(grid.width * grid.height).fill(Infinity);
const parent = new Int32Array(grid.width * grid.height).fill(-1);
const closed = new Uint8Array(grid.width * grid.height);
const heap = new MinHeap(4096);
cost[from] = 0;
heap.push(from, h(from));
while (heap.size > 0) {
const at = heap.pop();
if (closed[at]) continue;
closed[at] = 1;
if (at === to) {
const cells = [to];
for (let cell = to; cell !== from;) {
cell = parent[cell] as number;
cells.push(cell);
}
return cells.reverse();
}
const base = cost[at] as number;
const here = grid.weight(at);
step(grid, at, (next, length) => {
const via = base + ((here + grid.weight(next)) / 2) * length;
if (via < (cost[next] as number)) {
cost[next] = via;
parent[next] = at;
heap.push(next, via + h(next));
}
});
}
return null;
}
export class LakeField {
readonly frame: LocalFrame;
private shore: Float32Array | null = null;
private coarse: { factor: number; width: number; height: number; water: Uint8Array; shore: Float32Array } | null =
null;
constructor(readonly lake: LakeGrid) {
this.frame = new LocalFrame(lake.lat0, lake.lng0);
}
get cells(): number {
return this.lake.width * this.lake.height;
}
isWater(cell: number): boolean {
return maskHas(this.lake, cell % this.lake.width, Math.floor(cell / this.lake.width));
}
/** The water cell at a point, or the nearest within SNAP_CELLS of it; -1 when none. */
waterCellNear(lat: number, lng: number): number {
const col = Math.floor(this.frame.x(lng) / this.lake.cell);
const row = Math.floor(this.frame.y(lat) / this.lake.cell);
let best = -1;
let bestDistance = Infinity;
for (let dr = -SNAP_CELLS; dr <= SNAP_CELLS; dr++) {
for (let dc = -SNAP_CELLS; dc <= SNAP_CELLS; dc++) {
const c = col + dc;
const r = row + dr;
if (!maskHas(this.lake, c, r)) continue;
const distance = dr * dr + dc * dc;
if (distance < bestDistance) {
bestDistance = distance;
best = r * this.lake.width + c;
}
}
}
return best;
}
centre(cell: number): [number, number] {
const col = cell % this.lake.width;
const row = Math.floor(cell / this.lake.width);
return [this.frame.lat((row + 0.5) * this.lake.cell), this.frame.lng((col + 0.5) * this.lake.cell)];
}
/** Every cell's distance to the nearest land cell, in metres (a chamfer transform, 3-4 weights). */
shoreDistance(): Float32Array {
if (this.shore) return this.shore;
const { width, height, cell } = this.lake;
const far = 1e9;
const d = new Float32Array(width * height);
for (let i = 0; i < d.length; i++) d[i] = this.isWater(i) ? far : 0;
const relax = (i: number, j: number, w: number) => {
const via = (d[j] as number) + w;
if (via < (d[i] as number)) d[i] = via;
};
for (let r = 0; r < height; r++) {
for (let c = 0; c < width; c++) {
const i = r * width + c;
if (d[i] === 0) continue;
// Outside the grid is land too.
if (r === 0 || c === 0 || r === height - 1 || c === width - 1) d[i] = Math.min(d[i] as number, 3);
if (c > 0) relax(i, i - 1, 3);
if (r > 0) {
relax(i, i - width, 3);
if (c > 0) relax(i, i - width - 1, 4);
if (c < width - 1) relax(i, i - width + 1, 4);
}
}
}
for (let r = height - 1; r >= 0; r--) {
for (let c = width - 1; c >= 0; c--) {
const i = r * width + c;
if (d[i] === 0) continue;
if (c < width - 1) relax(i, i + 1, 3);
if (r < height - 1) {
relax(i, i + width, 3);
if (c < width - 1) relax(i, i + width + 1, 4);
if (c > 0) relax(i, i + width - 1, 4);
}
}
}
const scale = cell / 3;
for (let i = 0; i < d.length; i++) d[i] = (d[i] as number) * scale;
this.shore = d;
return d;
}
/** What crossing a cell costs per metre, for this profile. */
weight(cell: number, profile: LakeProfile): number {
return weightOf(this.shoreDistance()[cell] as number, profile);
}
private fine(profile: LakeProfile): Grid {
const shore = this.shoreDistance();
return {
width: this.lake.width,
height: this.lake.height,
cell: this.lake.cell,
water: (cell) => this.isWater(cell),
weight: (cell) => weightOf(shore[cell] as number, profile),
};
}
/**
* The lake on a grid of blocks of `factor` cells a side: a block is water
* when any of its cells is, and as far from the shore as its farthest cell.
*/
private coarseGrid() {
if (this.coarse) return this.coarse;
const { width, height } = this.lake;
const factor = Math.max(1, Math.ceil(Math.sqrt((width * height) / COARSE_CELLS)));
const cw = Math.ceil(width / factor);
const ch = Math.ceil(height / factor);
const water = new Uint8Array(cw * ch);
const shore = new Float32Array(cw * ch);
const fine = this.shoreDistance();
for (let r = 0; r < height; r++) {
for (let c = 0; c < width; c++) {
const i = r * width + c;
if (!this.isWater(i)) continue;
const block = Math.floor(r / factor) * cw + Math.floor(c / factor);
water[block] = 1;
shore[block] = Math.max(shore[block] as number, fine[i] as number);
}
}
this.coarse = { factor, width: cw, height: ch, water, shore };
return this.coarse;
}
private coarseOf(cell: number): number {
const coarse = this.coarseGrid();
const col = cell % this.lake.width;
const row = (cell - col) / this.lake.width;
return Math.floor(row / coarse.factor) * coarse.width + Math.floor(col / coarse.factor);
}
/**
* Weighted metres from one cell to each of `targets`, read off the coarse
* grid: close enough to choose between ways, and never less than the
* straight distance. Infinity where a target cannot be reached.
*/
costsTo(source: number, targets: readonly number[], profile: LakeProfile): number[] {
const coarse = this.coarseGrid();
const grid: Grid = {
width: coarse.width,
height: coarse.height,
cell: this.lake.cell * coarse.factor,
water: (cell) => coarse.water[cell] === 1,
weight: (cell) => weightOf(coarse.shore[cell] as number, profile),
};
const blocks = targets.map((cell) => this.coarseOf(cell));
const field = dijkstra(grid, this.coarseOf(source), new Set(blocks));
const [sLat, sLng] = this.centre(source);
return targets.map((cell, index) => {
const reached = field[blocks[index] as number] as number;
if (!Number.isFinite(reached)) return Infinity;
const [lat, lng] = this.centre(cell);
const straight = Math.hypot(this.frame.x(lng) - this.frame.x(sLng), this.frame.y(lat) - this.frame.y(sLat));
return Math.max(reached, straight);
});
}
/**
* The line from one cell to another as a paddler would take it: the
* cheapest cell path, then pulled straight wherever a straight line stays on
* water no more costly than the cells it joins.
*/
path(from: number, to: number, profile: LakeProfile): Array<[number, number]> | null {
const cells = astar(this.fine(profile), from, to);
if (!cells) return null;
return this.pull(cells, profile).map((cell) => this.centre(cell));
}
private pull(cells: number[], profile: LakeProfile): number[] {
const kept = [cells[0] as number];
let anchor = 0;
for (let i = 2; i < cells.length; i++) {
if (!this.clear(cells[anchor] as number, cells[i] as number, profile)) {
kept.push(cells[i - 1] as number);
anchor = i - 1;
}
}
if (cells.length > 1) kept.push(cells[cells.length - 1] as number);
return kept;
}
/** Whether a straight line between two cells runs on water no more costly than its ends. */
private clear(a: number, b: number, profile: LakeProfile): boolean {
const { width } = this.lake;
const ac = a % width;
const ar = (a - ac) / width;
const bc = b % width;
const br = (b - bc) / width;
const ceiling = Math.max(this.weight(a, profile), this.weight(b, profile)) * 1.05;
const steps = Math.max(Math.abs(bc - ac), Math.abs(br - ar)) * 2;
for (let s = 1; s < steps; s++) {
const c = Math.round(ac + ((bc - ac) * s) / steps);
const r = Math.round(ar + ((br - ar) * s) / steps);
const cell = r * width + c;
if (!this.isWater(cell) || this.weight(cell, profile) > ceiling) return false;
}
return true;
}
}
@@ -0,0 +1,75 @@
/**
* A binary min-heap of integer ids by a float priority, on typed arrays so a
* search over a few hundred thousand cells or nodes makes no garbage. An id
* may be pushed again with a better priority; the stale entry is skipped by
* the caller when it comes up.
*/
export class MinHeap {
private ids: Int32Array;
private keys: Float64Array;
private length = 0;
constructor(capacity = 1024) {
this.ids = new Int32Array(capacity);
this.keys = new Float64Array(capacity);
}
get size(): number {
return this.length;
}
clear(): void {
this.length = 0;
}
push(id: number, key: number): void {
if (this.length === this.ids.length) this.grow();
let at = this.length++;
while (at > 0) {
const parent = (at - 1) >> 1;
const parentKey = this.keys[parent] as number;
if (parentKey <= key) break;
this.ids[at] = this.ids[parent] as number;
this.keys[at] = parentKey;
at = parent;
}
this.ids[at] = id;
this.keys[at] = key;
}
peekKey(): number {
return this.keys[0] as number;
}
/** The id with the lowest priority, taken off; call only while `size` > 0. */
pop(): number {
const top = this.ids[0] as number;
const last = --this.length;
if (last > 0) {
const id = this.ids[last] as number;
const key = this.keys[last] as number;
let at = 0;
for (;;) {
let child = at * 2 + 1;
if (child >= last) break;
if (child + 1 < last && (this.keys[child + 1] as number) < (this.keys[child] as number)) child++;
if ((this.keys[child] as number) >= key) break;
this.ids[at] = this.ids[child] as number;
this.keys[at] = this.keys[child] as number;
at = child;
}
this.ids[at] = id;
this.keys[at] = key;
}
return top;
}
private grow(): void {
const ids = new Int32Array(this.ids.length * 2);
const keys = new Float64Array(this.keys.length * 2);
ids.set(this.ids);
keys.set(this.keys);
this.ids = ids;
this.keys = keys;
}
}
@@ -0,0 +1,644 @@
import { LakeIndex } from '../build/lakes';
import { Access, EdgeKind, PortageSource, type WaterGraphData } from '../water-graph';
import { metresBetween, projectOnSegment } from './geo';
import { LakeField, type LakeProfile } from './lake-field';
import { MinHeap } from './min-heap';
import type {
PaddleBoatType,
PaddleLakeStyle,
PaddleLockPolicy,
PaddleRouteLeg,
PaddleRoutePoint,
PaddleRouteSegment,
} from '@trek/shared';
/**
* PAD-11: plans one leg at a time on a region's water network. The ends are
* moved onto the water (a lake cell, or the nearest river within SNAP_M);
* an A* search then weighs every way by the time it takes: paddling with or
* against the current, carrying a portage, waiting at a lock, crossing a lake
* on its grid. Edges a boat may not use are not taken at all.
*/
export interface RouteProfile {
boat: PaddleBoatType;
lakeStyle: PaddleLakeStyle;
lockPolicy: PaddleLockPolicy;
}
/**
* A point farther than this from any water the network knows is not on the
* water. Generous on purpose: a click on the bank or a slipway of a wide river
* lies 100 m and more from its centre line.
*/
export const SNAP_M = 200;
/** Planning speeds by boat, km/h: they choose between ways; the paddler's own speed times the tour. */
const BOAT_KMH: Record<PaddleBoatType, number> = { kayak: 7, canoe: 6, sup: 5, other: 6 };
/** The widest open water each boat should cross, in metres. */
const OPEN_WATER_M: Record<PaddleBoatType, number> = { kayak: 1500, canoe: 750, sup: 400, other: 1500 };
const CARRY_KMH = 2.5;
/** Getting out, unloading and back in at a portage, and waiting plus going through at a lock, seconds. */
const CARRY_EXTRA_S = 8 * 60;
const LOCK_S = 15 * 60;
const DISCOURAGED = 3;
/** Whitewater from this grade is taken only when it saves a lot; a SUP never takes it. */
const RAPIDS_GRADE = 2;
const RAPIDS_WEIGHT = 2;
const GAP_WEIGHT = 20;
/** Never slower than this against a current, km/h; the river is still paddled, just slowly. */
const MIN_UPSTREAM_KMH = 1;
const MAX_CURRENT_KMH = 2;
const MAX_SETTLED = 400_000;
/** How many portals' cost lists stay cached between searches. */
const PORTAL_COST_CACHE = 2000;
const BUDGET_MS = 3000;
type SnapEdge = { type: 'edge'; edge: number; seg: number; t: number; lat: number; lng: number; distance: number };
type SnapLake = { type: 'lake'; lake: number; cell: number; lat: number; lng: number; distance: number };
type Snap = SnapEdge | SnapLake;
/** How the search came to a node: along an edge (forward or back), across a lake, or from the start. */
interface Step {
from: number;
kind: 'edge' | 'lake' | 'partial';
edge: number;
forward: boolean;
lake: number;
}
interface Piece {
points: Array<[number, number]>;
kind: PaddleRouteSegment['kind'];
name: string | null;
source: PaddleRouteSegment['source'];
osmRef: string | null;
grade: number | null;
}
export class WaterRouter {
private readonly offsets: Uint32Array;
private readonly arcs: Uint32Array;
private readonly lakeIndex: LakeIndex;
private readonly fields = new Map<number, LakeField>();
private readonly portalCells = new Map<number, Array<{ node: number; cell: number }>>();
private readonly portalCostCache = new Map<string, number[]>();
private readonly lockRefs: Set<number>;
private readonly segmentCells: Float64Array;
private readonly segmentEdge: Int32Array;
private readonly segmentIndex: Int32Array;
private static readonly CELL = 0.004;
constructor(readonly graph: WaterGraphData) {
const nodes = graph.nodeLat.length;
const degree = new Uint32Array(nodes + 1);
for (let e = 0; e < graph.edgeA.length; e++) {
degree[graph.edgeA[e] as number]++;
degree[graph.edgeB[e] as number]++;
}
this.offsets = new Uint32Array(nodes + 1);
for (let n = 0; n < nodes; n++) this.offsets[n + 1] = (this.offsets[n] as number) + (degree[n] as number);
this.arcs = new Uint32Array(this.offsets[nodes] as number);
const fill = this.offsets.slice(0, nodes);
for (let e = 0; e < graph.edgeA.length; e++) {
this.arcs[fill[graph.edgeA[e] as number]++ as number] = e * 2;
this.arcs[fill[graph.edgeB[e] as number]++ as number] = e * 2 + 1;
}
this.lakeIndex = new LakeIndex(graph.lakes);
this.lockRefs = new Set<number>();
for (let e = 0; e < graph.edgeKind.length; e++) {
if (graph.edgeKind[e] === EdgeKind.lock && (graph.edgeRef[e] as number) >= 0)
this.lockRefs.add(graph.edgeRef[e] as number);
}
({ cells: this.segmentCells, edges: this.segmentEdge, segs: this.segmentIndex } = this.indexSegments());
}
/** Every segment of every edge filed under the grid cells its box touches, sorted by cell for lookup. */
private indexSegments(): { cells: Float64Array; edges: Int32Array; segs: Int32Array } {
const g = this.graph;
const cells: number[] = [];
const edges: number[] = [];
const segs: number[] = [];
for (let e = 0; e < g.edgeA.length; e++) {
const start = g.edgeGeomStart[e] as number;
const count = g.edgeGeomCount[e] as number;
for (let s = 0; s + 1 < count; s++) {
const aLat = g.pool[(start + s) * 2] as number;
const aLng = g.pool[(start + s) * 2 + 1] as number;
const bLat = g.pool[(start + s + 1) * 2] as number;
const bLng = g.pool[(start + s + 1) * 2 + 1] as number;
for (
let r = Math.floor(Math.min(aLat, bLat) / WaterRouter.CELL);
r <= Math.floor(Math.max(aLat, bLat) / WaterRouter.CELL);
r++
) {
for (
let c = Math.floor(Math.min(aLng, bLng) / WaterRouter.CELL);
c <= Math.floor(Math.max(aLng, bLng) / WaterRouter.CELL);
c++
) {
cells.push(WaterRouter.key(r, c));
edges.push(e);
segs.push(s);
}
}
}
}
const order = Array.from(cells.keys()).sort((x, y) => (cells[x] as number) - (cells[y] as number) || x - y);
return {
cells: Float64Array.from(order, (i) => cells[i] as number),
edges: Int32Array.from(order, (i) => edges[i] as number),
segs: Int32Array.from(order, (i) => segs[i] as number),
};
}
private static key(row: number, col: number): number {
return (row + 50_000) * 100_000 + (col + 50_000);
}
private field(lake: number): LakeField {
let field = this.fields.get(lake);
if (!field) {
field = new LakeField(this.graph.lakes[lake] as NonNullable<WaterGraphData['lakes'][number]>);
this.fields.set(lake, field);
}
return field;
}
private point(edge: number, index: number): [number, number] {
const at = ((this.graph.edgeGeomStart[edge] as number) + index) * 2;
return [this.graph.pool[at] as number, this.graph.pool[at + 1] as number];
}
private edgeLine(edge: number): Array<[number, number]> {
const count = this.graph.edgeGeomCount[edge] as number;
return Array.from({ length: count }, (_, i) => this.point(edge, i));
}
/** The nearest open segment within `radius` metres. */
private nearestSegment(lat: number, lng: number, radius: number): SnapEdge | null {
const reach = Math.ceil(radius / 111_195 / WaterRouter.CELL / Math.cos((lat * Math.PI) / 180)) + 1;
const row = Math.floor(lat / WaterRouter.CELL);
const col = Math.floor(lng / WaterRouter.CELL);
let best: SnapEdge | null = null;
let bestClosed: SnapEdge | null = null;
for (let r = row - reach; r <= row + reach; r++) {
for (let c = col - reach; c <= col + reach; c++) {
const key = WaterRouter.key(r, c);
for (let i = this.lowerBound(key); i < this.segmentCells.length && this.segmentCells[i] === key; i++) {
const edge = this.segmentEdge[i] as number;
const seg = this.segmentIndex[i] as number;
const kind = this.graph.edgeKind[edge];
if (kind === EdgeKind.portage || kind === EdgeKind.gap) continue;
const a = this.point(edge, seg);
const b = this.point(edge, seg + 1);
const { t, distance } = projectOnSegment(lat, lng, a[0], a[1], b[0], b[1]);
if (distance > radius) continue;
const snap: SnapEdge = {
type: 'edge',
edge,
seg,
t,
lat: a[0] + (b[0] - a[0]) * t,
lng: a[1] + (b[1] - a[1]) * t,
distance,
};
const closed = this.graph.edgeAccess[edge] === Access.closed;
if (closed) {
if (!bestClosed || distance < bestClosed.distance) bestClosed = snap;
} else if (!best || distance < best.distance) best = snap;
}
}
}
return best ?? bestClosed;
}
private lowerBound(key: number): number {
let low = 0;
let high = this.segmentCells.length;
while (low < high) {
const mid = (low + high) >>> 1;
if ((this.segmentCells[mid] as number) < key) low = mid + 1;
else high = mid;
}
return low;
}
/** The point put on the water: inside a lake as it is, else on the nearest river or canal, else at a lake's edge. */
snap(point: PaddleRoutePoint): Snap | null {
const lake = this.lakeIndex.at(point.lat, point.lng);
if (lake >= 0) {
const cell = this.field(lake).waterCellNear(point.lat, point.lng);
if (cell >= 0) return { type: 'lake', lake, cell, lat: point.lat, lng: point.lng, distance: 0 };
}
const edge = this.nearestSegment(point.lat, point.lng, SNAP_M);
if (edge) return edge;
for (let i = 0; i < this.graph.lakes.length; i++) {
const field = this.field(i);
const cell = field.waterCellNear(point.lat, point.lng);
if (cell < 0) continue;
const [lat, lng] = field.centre(cell);
const distance = metresBetween(point.lat, point.lng, lat, lng);
if (distance <= SNAP_M) return { type: 'lake', lake: i, cell, lat, lng, distance };
}
return null;
}
/** Seconds to travel an edge in a direction, or Infinity where the boat may not. */
private edgeSeconds(edge: number, forward: boolean, length: number, profile: RouteProfile): number {
const g = this.graph;
const kind = g.edgeKind[edge];
const speed = BOAT_KMH[profile.boat];
if (kind === EdgeKind.portage) {
const lockCarry = this.lockRefs.has(g.edgeRef[edge] as number);
if (lockCarry && profile.lockPolicy === 'use') return Infinity;
// A straight carry round an open lock is a guess over its walls: taken only when the paddler wants to carry.
if (lockCarry && g.edgeSource[edge] === PortageSource.inferred && profile.lockPolicy !== 'portage')
return Infinity;
return (length / 1000 / CARRY_KMH) * 3600 + CARRY_EXTRA_S;
}
if (g.edgeAccess[edge] === Access.closed) return Infinity;
if (g.edgeOneway[edge] === 1 && !forward) return Infinity;
if (kind === EdgeKind.lock && profile.lockPolicy === 'portage') return Infinity;
const grade = g.edgeGrade[edge] as number;
if (grade >= RAPIDS_GRADE && profile.boat === 'sup') return Infinity;
let kmh = speed;
const current = g.edgeCurrent[edge] as number;
if (g.edgeFlow[edge] === 1 && current > 0)
kmh = forward ? speed + 0.8 * current : Math.max(MIN_UPSTREAM_KMH, speed - current);
let seconds = (length / 1000 / kmh) * 3600;
if (g.edgeAccess[edge] === Access.discouraged) seconds *= DISCOURAGED;
if (grade >= RAPIDS_GRADE) seconds *= RAPIDS_WEIGHT;
if (kind === EdgeKind.gap) seconds *= GAP_WEIGHT;
if (kind === EdgeKind.lock) seconds += LOCK_S;
return seconds;
}
private lakeProfile(profile: RouteProfile): LakeProfile {
return { style: profile.lakeStyle, maxCrossingM: OPEN_WATER_M[profile.boat] };
}
/** Seconds across a lake for weighted metres. */
private lakeSeconds(weighted: number, profile: RouteProfile): number {
return (weighted / 1000 / BOAT_KMH[profile.boat]) * 3600;
}
private portalCell(field: LakeField, node: number): number {
return field.waterCellNear(this.graph.nodeLat[node] as number, this.graph.nodeLng[node] as number);
}
/** A lake's portal nodes with the water cell each opens onto (-1 when none is near). */
private lakePortals(lake: number): Array<{ node: number; cell: number }> {
let portals = this.portalCells.get(lake);
if (!portals) {
const field = this.field(lake);
portals = Array.from(this.graph.lakes[lake]?.portals ?? [], (node) => ({
node,
cell: this.portalCell(field, node),
}));
this.portalCells.set(lake, portals);
}
return portals;
}
/** Weighted metres from a cell to each of a lake's portals, kept for the next search through the same lake. */
private portalCosts(lake: number, sourceCell: number, profile: LakeProfile): number[] {
const key = `${lake}:${sourceCell}:${profile.style}:${profile.maxCrossingM}`;
let costs = this.portalCostCache.get(key);
if (costs) {
this.portalCostCache.delete(key);
} else {
const portals = this.lakePortals(lake);
const reachable = portals.filter(({ cell }) => cell >= 0);
const reached = this.field(lake).costsTo(
sourceCell,
reachable.map(({ cell }) => cell),
profile,
);
const byCell = new Map(reachable.map(({ cell }, index) => [cell, reached[index] as number]));
costs = portals.map(({ cell }) => byCell.get(cell) ?? Infinity);
}
this.portalCostCache.set(key, costs);
if (this.portalCostCache.size > PORTAL_COST_CACHE)
this.portalCostCache.delete(this.portalCostCache.keys().next().value as string);
return costs;
}
route(
from: PaddleRoutePoint,
to: PaddleRoutePoint,
profile: RouteProfile,
deadline = Date.now() + BUDGET_MS,
): PaddleRouteLeg {
const start = this.snap(from);
if (!start) return { status: 'failed', code: 'not_on_water', at: from };
const end = this.snap(to);
if (!end) return { status: 'failed', code: 'not_on_water', at: to };
const found = this.search(start, end, profile, deadline);
if (found === 'timeout') return { status: 'failed', code: 'timeout', at: null };
if (!found) return { status: 'failed', code: 'no_water_path', at: null };
return this.assemble(found, start, end, profile);
}
private search(
start: Snap,
end: Snap,
profile: RouteProfile,
deadline: number,
): Map<number, Step> | 'timeout' | null {
const g = this.graph;
const nodes = g.nodeLat.length;
const S = nodes;
const T = nodes + 1;
const cost = new Map<number, number>();
const came = new Map<number, Step>();
const heap = new MinHeap();
const vmax = BOAT_KMH[profile.boat] + 0.8 * MAX_CURRENT_KMH;
const h = (node: number) => {
const lat = node === S ? start.lat : node === T ? end.lat : (g.nodeLat[node] as number);
const lng = node === S ? start.lng : node === T ? end.lng : (g.nodeLng[node] as number);
return (metresBetween(lat, lng, end.lat, end.lng) / 1000 / vmax) * 3600;
};
const relax = (node: number, via: number, step: Step) => {
if (via < (cost.get(node) ?? Infinity)) {
cost.set(node, via);
came.set(node, step);
heap.push(node, via + h(node));
}
};
const lakeProfile = this.lakeProfile(profile);
const lakeHops = (lake: number, sourceCell: number, base: number, fromNode: number) => {
const field = this.field(lake);
const portals = this.lakePortals(lake);
const costs = this.portalCosts(lake, sourceCell, lakeProfile);
portals.forEach(({ node, cell }, index) => {
const weighted = costs[index] as number;
if (node === fromNode || cell < 0 || !Number.isFinite(weighted)) return;
relax(node, base + this.lakeSeconds(weighted, profile), {
from: fromNode,
kind: 'lake',
edge: -1,
forward: true,
lake,
});
});
if (end.type === 'lake' && end.lake === lake) {
const [toEnd] = field.costsTo(sourceCell, [end.cell], lakeProfile);
if (Number.isFinite(toEnd as number)) {
relax(T, base + this.lakeSeconds(toEnd as number, profile), {
from: fromNode,
kind: 'lake',
edge: -1,
forward: true,
lake,
});
}
}
};
cost.set(S, 0);
// From the start: along its edge both ways, or across its lake.
if (start.type === 'edge') {
const length = this.partialLength(start.edge, start, 'toB');
relax(g.edgeB[start.edge] as number, this.edgeSeconds(start.edge, true, length, profile), {
from: S,
kind: 'partial',
edge: start.edge,
forward: true,
lake: -1,
});
const back = this.partialLength(start.edge, start, 'toA');
relax(g.edgeA[start.edge] as number, this.edgeSeconds(start.edge, false, back, profile), {
from: S,
kind: 'partial',
edge: start.edge,
forward: false,
lake: -1,
});
if (end.type === 'edge' && end.edge === start.edge) {
const forward = start.seg + start.t <= end.seg + end.t;
const between = this.betweenLength(start.edge, start, end);
relax(T, this.edgeSeconds(start.edge, forward, between, profile), {
from: S,
kind: 'partial',
edge: start.edge,
forward,
lake: -1,
});
}
} else {
lakeHops(start.lake, start.cell, 0, S);
}
let settled = 0;
while (heap.size > 0) {
const node = heap.pop();
if (node === T) return came;
if (++settled > MAX_SETTLED) return null;
// Checked on the first node already: a job that waited past its time in the queue gives up at once.
if ((settled & 1023) === 1 && Date.now() > deadline) return 'timeout';
const base = cost.get(node) as number;
if (node === S) continue;
for (let i = this.offsets[node] as number; i < (this.offsets[node + 1] as number); i++) {
const arc = this.arcs[i] as number;
const edge = arc >> 1;
const forward = (arc & 1) === 0;
const other = forward ? (g.edgeB[edge] as number) : (g.edgeA[edge] as number);
const seconds = this.edgeSeconds(edge, forward, g.edgeLength[edge] as number, profile);
if (Number.isFinite(seconds))
relax(other, base + seconds, { from: node, kind: 'edge', edge, forward, lake: -1 });
}
if (end.type === 'edge') {
if (node === g.edgeA[end.edge]) {
const length = this.partialLength(end.edge, end, 'toA');
relax(T, base + this.edgeSeconds(end.edge, true, length, profile), {
from: node,
kind: 'partial',
edge: end.edge,
forward: true,
lake: -1,
});
}
if (node === g.edgeB[end.edge]) {
const length = this.partialLength(end.edge, end, 'toB');
relax(T, base + this.edgeSeconds(end.edge, false, length, profile), {
from: node,
kind: 'partial',
edge: end.edge,
forward: false,
lake: -1,
});
}
}
const lake = g.nodeLake[node] as number;
if (lake >= 0) {
const cell = this.portalCell(this.field(lake), node);
if (cell >= 0) lakeHops(lake, cell, base, node);
}
}
return null;
}
/** Metres from a snapped point along its edge to the edge's a or b end. */
private partialLength(edge: number, snap: SnapEdge, toward: 'toA' | 'toB'): number {
const count = this.graph.edgeGeomCount[edge] as number;
const a = this.point(edge, snap.seg);
const b = this.point(edge, snap.seg + 1);
let length = 0;
if (toward === 'toA') {
length += metresBetween(snap.lat, snap.lng, a[0], a[1]);
for (let i = snap.seg; i > 0; i--) length += this.segmentLength(edge, i - 1);
} else {
length += metresBetween(snap.lat, snap.lng, b[0], b[1]);
for (let i = snap.seg + 1; i < count - 1; i++) length += this.segmentLength(edge, i);
}
return length;
}
private segmentLength(edge: number, seg: number): number {
const a = this.point(edge, seg);
const b = this.point(edge, seg + 1);
return metresBetween(a[0], a[1], b[0], b[1]);
}
private betweenLength(edge: number, x: SnapEdge, y: SnapEdge): number {
return Math.abs(this.partialLength(edge, x, 'toA') - this.partialLength(edge, y, 'toA'));
}
/** The points of an edge between two snapped spots on it (or a snap and an end), in travel order. */
private partialLine(edge: number, from: SnapEdge | 'a' | 'b', to: SnapEdge | 'a' | 'b'): Array<[number, number]> {
const count = this.graph.edgeGeomCount[edge] as number;
const position = (spot: SnapEdge | 'a' | 'b') => (spot === 'a' ? 0 : spot === 'b' ? count - 1 : spot.seg + spot.t);
const p = position(from);
const q = position(to);
const points: Array<[number, number]> = [];
const at = (spot: SnapEdge | 'a' | 'b'): [number, number] =>
spot === 'a' ? this.point(edge, 0) : spot === 'b' ? this.point(edge, count - 1) : [spot.lat, spot.lng];
points.push(at(from));
if (p <= q) for (let i = Math.floor(p) + 1; i < q; i++) points.push(this.point(edge, i));
else for (let i = Math.ceil(p) - 1; i > q; i--) points.push(this.point(edge, i));
points.push(at(to));
return points;
}
private assemble(came: Map<number, Step>, start: Snap, end: Snap, profile: RouteProfile): PaddleRouteLeg {
const g = this.graph;
const nodes = g.nodeLat.length;
const S = nodes;
const T = nodes + 1;
const chain: Array<{ node: number; step: Step }> = [];
for (let node = T; node !== S;) {
const step = came.get(node) as Step;
chain.unshift({ node, step });
node = step.from;
}
const lakeProfile = this.lakeProfile(profile);
const pieces: Piece[] = [];
const nodePoint = (node: number): [number, number] =>
node === S
? [start.lat, start.lng]
: node === T
? [end.lat, end.lng]
: [g.nodeLat[node] as number, g.nodeLng[node] as number];
for (const { node, step } of chain) {
if (step.kind === 'lake') {
const field = this.field(step.lake);
const fromPoint = nodePoint(step.from);
const toPoint = nodePoint(node);
const fromCell =
step.from === S && start.type === 'lake' ? start.cell : field.waterCellNear(fromPoint[0], fromPoint[1]);
const toCell = node === T && end.type === 'lake' ? end.cell : field.waterCellNear(toPoint[0], toPoint[1]);
const middle = field.path(fromCell, toCell, lakeProfile) ?? [];
pieces.push({
points: [fromPoint, ...middle.slice(1, -1), toPoint],
kind: 'lake',
name: g.lakes[step.lake]?.name ?? null,
source: null,
osmRef: null,
grade: null,
});
continue;
}
const edge = step.edge;
let points: Array<[number, number]>;
if (step.kind === 'partial') {
const fromSpot = step.from === S && start.type === 'edge' ? start : step.forward ? 'a' : 'b';
const toSpot = node === T && end.type === 'edge' ? end : step.forward ? 'b' : 'a';
points = this.partialLine(edge, fromSpot, toSpot);
} else {
const line = this.edgeLine(edge);
points = step.forward ? line : line.reverse();
}
pieces.push({ points, ...this.describe(edge) });
}
return this.finish(pieces, start, end);
}
private describe(edge: number): Omit<Piece, 'points'> {
const g = this.graph;
const kind = g.edgeKind[edge];
const name = (g.edgeName[edge] as number) >= 0 ? (g.strings[g.edgeName[edge] as number] ?? null) : null;
const osmRef = (g.edgeRef[edge] as number) >= 0 ? (g.strings[g.edgeRef[edge] as number] ?? null) : null;
if (kind === EdgeKind.portage) {
const source =
g.edgeSource[edge] === PortageSource.tagged
? 'tagged'
: g.edgeSource[edge] === PortageSource.footway
? 'footway'
: 'inferred';
return { kind: 'portage', name, source, osmRef, grade: null };
}
if (kind === EdgeKind.lock) return { kind: 'lock', name, source: null, osmRef, grade: null };
if (kind === EdgeKind.gap) return { kind: 'gap', name: null, source: null, osmRef: null, grade: null };
if (kind === EdgeKind.pass) return { kind: 'pass', name, source: null, osmRef, grade: null };
const grade = g.edgeGrade[edge] as number;
if (grade >= RAPIDS_GRADE) return { kind: 'rapids', name, source: null, osmRef, grade };
return { kind: 'water', name, source: null, osmRef: null, grade: null };
}
/** One line from the pieces, with neighbouring pieces of a kind merged into one segment (portages and locks stay apart). */
private finish(pieces: Piece[], start: Snap, end: Snap): PaddleRouteLeg {
const geometry: Array<[number, number]> = [];
const segments: PaddleRouteSegment[] = [];
for (const piece of pieces) {
const points = piece.points.filter(
(point, i) => i === 0 || point[0] !== piece.points[i - 1]?.[0] || point[1] !== piece.points[i - 1]?.[1],
);
if (points.length < 2) continue;
const offset = geometry.length === 0 ? 0 : geometry.length - 1;
geometry.push(...(geometry.length === 0 ? points : points.slice(1)));
const to = geometry.length - 1;
const previous = segments[segments.length - 1];
const mergeable = piece.kind !== 'portage' && piece.kind !== 'lock';
// One water keeps one segment; where the name changes (Naab into Donau) a new one starts.
const sameWater = !previous?.name || !piece.name || previous.name === piece.name;
if (previous && mergeable && previous.kind === piece.kind && previous.grade === piece.grade && sameWater) {
previous.to = to;
previous.name ??= piece.name;
} else {
segments.push({
kind: piece.kind,
from: offset,
to,
name: piece.name,
source: piece.source,
osm_ref: piece.osmRef,
grade: piece.grade,
});
}
}
if (geometry.length < 2 || segments.length === 0) return { status: 'failed', code: 'no_water_path', at: null };
let distance = 0;
for (let i = 1; i < geometry.length; i++) {
const a = geometry[i - 1] as [number, number];
const b = geometry[i] as [number, number];
distance += metresBetween(a[0], a[1], b[0], b[1]);
}
return {
status: 'ok',
geometry,
segments,
distance_m: Math.round(distance),
snapped_m: { from: Math.round(start.distance), to: Math.round(end.distance) },
};
}
}
@@ -0,0 +1,19 @@
import { GraphCache, runJob, type RouteJob } from './router-runner';
import { parentPort } from 'node:worker_threads';
/** The router thread: plans the legs it is sent on graphs it keeps loaded, so the server's thread never waits on a search. */
const cache = new GraphCache();
parentPort?.on('message', (message: { id: number; job?: RouteJob; forget?: string }) => {
if (message.forget) {
cache.forget(message.forget);
return;
}
if (!message.job) return;
runJob(cache, message.job).then(
(legs) => parentPort?.postMessage({ id: message.id, legs }),
(err: unknown) =>
parentPort?.postMessage({ id: message.id, error: err instanceof Error ? err.message : String(err) }),
);
});
@@ -0,0 +1,29 @@
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
import { ResponseContract } from '../common/response-contract';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { PaddleRouteRequestDto } from './paddle-routing.dto';
import { PaddleRoutingService } from './paddle-routing.service';
import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
import { paddleRouteResponseSchema } from '@trek/shared';
/**
* POST /api/trips/:tripId/paddle/route: legs of a paddle tour planned on the
* water (PAD-11). It reads and writes nothing of the trip; the trip in the
* path only says whose planner asks, so anyone who can see the trip may.
*/
@Controller('api/trips/:tripId/paddle')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.TOURS, 'Tours')
export class PaddleRoutingController {
constructor(private readonly routing: PaddleRoutingService) {}
@Post('route')
@HttpCode(200)
@ResponseContract(paddleRouteResponseSchema)
async route(@Body() body: PaddleRouteRequestDto) {
return await this.routing.route(body);
}
}
@@ -0,0 +1,5 @@
import { paddleRouteRequestSchema } from '@trek/shared';
import { createZodDto } from 'nestjs-zod';
export class PaddleRouteRequestDto extends createZodDto(paddleRouteRequestSchema) {}
@@ -0,0 +1,50 @@
import { ADDON_IDS } from '../../addons';
import { noAccess } from '../../mcp/tools/_shared';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
import { addonGate } from '../addons/addon-gate';
import { AddonsService } from '../addons/addons.service';
import { TripAccessService } from '../trip-membership/trip-access.service';
import { PaddleRoutingService } from './paddle-routing.service';
import { idSchema, paddleRouteRequestSchema, type PaddleRouteInput, type PaddleRouteLeg } from '@trek/shared';
/** A planned leg as a client reads it: its length and its stretches, not its thousands of points. */
function summary(leg: PaddleRouteLeg) {
if (leg.status === 'failed') return leg;
return {
status: leg.status,
distance_m: leg.distance_m,
snapped_m: leg.snapped_m,
points: leg.geometry.length,
segments: leg.segments.map(({ kind, name, source, osm_ref }) => ({ kind, name, source, osm_ref })),
};
}
/**
* PAD-11: the paddle router for a client, read only and with the REST
* route's rules: the caller must see the trip, Tours must be on, and the
* request is the same contract. It plans and answers; nothing is saved.
*/
@McpController()
export class PaddleRoutingMcp {
constructor(
private readonly routing: PaddleRoutingService,
private readonly access: TripAccessService,
// Read by the addon gate.
readonly addons: AddonsService,
) {}
@Tool({
name: 'plan_paddle_route',
description:
'Plan the legs of a paddle tour on the water, on the water network TREK builds from the OpenStreetMap data of the paddle regions an admin added. Each leg (from, to) is planned along rivers and canals and across lakes, with short portages where a weir, dam or waterfall bars the way and through or around locks (lock_policy). lake_style direct crosses lakes straight, shore keeps near the shore (for wind). Answers per leg its length, how far its ends were moved onto the water, and its stretches (water, lake, portage with its source, lock, gap); a leg that cannot be planned says why (not_on_water, no_region, no_water_path, cross_region). A planning aid, never a safety or legal clearance; nothing is saved.',
inputSchema: { tripId: idSchema, ...paddleRouteRequestSchema.shape },
annotations: TOOL_ANNOTATIONS_READONLY,
when: addonGate(ADDON_IDS.TOURS),
access: { group: 'trips', mode: 'read' },
})
async planPaddleRoute({ tripId, ...request }: PaddleRouteInput & { tripId: number }, ctx: McpContext) {
if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess();
const planned = await this.routing.route(paddleRouteRequestSchema.parse(request));
return ok({ legs: planned.legs.map(summary), graph_rev: planned.graph_rev });
}
}
@@ -0,0 +1,34 @@
import { PaddleWaterGraphs } from '../../db/entities/PaddleWaterGraphs.entity';
import { AddonsModule } from '../addons/addons.module';
import { PermissionsModule } from '../permissions/permissions.module';
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
import { PaddleRoutingController } from './paddle-routing.controller';
import { PaddleRoutingMcp } from './paddle-routing.mcp';
import { PaddleRoutingService } from './paddle-routing.service';
import { InlineRouterRunner, PADDLE_ROUTER_RUNNER } from './router-runner';
import { WorkerRouterRunner } from './worker-router-runner';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { Module } from '@nestjs/common';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
/** The router's own thread where the build ships its script; on the calling thread under the TypeScript sources (the tests). */
function routerRunner() {
const entry = join(__dirname, 'paddle-router.worker.js');
return existsSync(entry) ? new WorkerRouterRunner(entry) : new InlineRouterRunner();
}
/**
* PAD-11: routing for paddle tours on a water network TREK builds itself,
* from the regions the paddle obstacles domain reads. The search runs in a
* worker thread of the server; PaddleRoutingService is exported for that
* domain to hand over each region's network as it is built.
*/
@Module({
imports: [AddonsModule, PermissionsModule, TripMembershipModule, MikroOrmModule.forFeature([PaddleWaterGraphs])],
controllers: [PaddleRoutingController],
providers: [PaddleRoutingService, PaddleRoutingMcp, { provide: PADDLE_ROUTER_RUNNER, useFactory: routerRunner }],
exports: [PaddleRoutingService],
})
export class PaddleRoutingModule {}
@@ -0,0 +1,212 @@
import { PaddleWaterGraphs } from '../../db/entities/PaddleWaterGraphs.entity';
import { PADDLE_REGION_GRAPH_STATS } from '../../db/json-columns';
import type {
PaddleWaterGraphRow,
PaddleWaterGraphsRepository,
} from '../../db/repositories/PaddleWaterGraphs.repository';
import { dbNow, utcSuffix } from '../../db/types';
import { decodeJson, encodeJson } from '../../utils/json-column';
import { DataPathsService } from '../app-config/data-paths.service';
import { logError, logInfo } from '../audit/audit-log.logger';
import { withRequestContext } from '../database/request-context';
import type { RouteProfile } from './engine/router';
import { PADDLE_ROUTER_RUNNER, type RouterRunner } from './router-runner';
import type { BuiltWaterGraph } from './water-graph.contract';
import { MikroORM } from '@mikro-orm/core';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Inject, Injectable, type OnApplicationBootstrap, type OnApplicationShutdown } from '@nestjs/common';
import type { PaddleRegion, PaddleRouteLeg, PaddleRouteRequest, PaddleRouteResponse } from '@trek/shared';
import { mkdir, readdir, rename, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
const GRAPH_SUFFIX = '.wgraph';
type Network = NonNullable<PaddleRegion['network']>;
/**
* PAD-11: routing on the water. Each paddle region's import hands over the
* water network it built; this service keeps the network's file in the data
* folder (written beside, then renamed into place, so a reader never sees half
* a file), records it, and plans legs on it in the router's thread. A leg is
* planned on the smallest region whose network covers both its ends.
*/
@Injectable()
export class PaddleRoutingService implements OnApplicationBootstrap, OnApplicationShutdown {
constructor(
private readonly paths: DataPathsService,
private readonly orm: MikroORM,
@InjectRepository(PaddleWaterGraphs) private readonly graphs: PaddleWaterGraphsRepository,
@Inject(PADDLE_ROUTER_RUNNER) private readonly runner: RouterRunner,
) {}
private dir(): string {
return join(this.paths.dataDir, 'paddle', 'graphs');
}
private pathOf(file: string): string {
return join(this.dir(), file);
}
/** Clears network files no region names any more, and halves of a write a crash left. */
async onApplicationBootstrap(): Promise<void> {
try {
await withRequestContext(this.orm, () => this.sweep());
} catch (err) {
logError(`[Paddle] sweeping water networks: ${err instanceof Error ? err.message : String(err)}`);
}
}
async onApplicationShutdown(): Promise<void> {
await this.runner.close();
}
async sweep(): Promise<void> {
const kept = new Set((await this.graphs.list()).map((row) => row.file));
let entries: string[];
try {
entries = await readdir(this.dir());
} catch {
return;
}
const stale = entries.filter(
(entry) => !kept.has(entry) && (entry.endsWith(GRAPH_SUFFIX) || entry.endsWith('.tmp')),
);
await Promise.all(stale.map((entry) => rm(this.pathOf(entry), { force: true })));
}
/** Writes a built network beside its final name and renames it into place; returns the file's name. */
async writeGraphFile(regionId: string, built: BuiltWaterGraph): Promise<string> {
await mkdir(this.dir(), { recursive: true });
const file = `${regionId.replaceAll('/', '_')}.${built.rev}${GRAPH_SUFFIX}`;
const temp = this.pathOf(`${file}.tmp`);
await writeFile(temp, built.bytes);
await rename(temp, this.pathOf(file));
return file;
}
/** Records a region's new network; called inside the import's transaction, beside the swap of its obstacles. */
async recordGraph(regionId: string, file: string, built: BuiltWaterGraph): Promise<string | null> {
const previous = (await this.graphs.findByRegion(regionId))?.file ?? null;
await this.graphs.saveGraph({
region_id: regionId,
file,
rev: built.rev,
bytes: built.bytes.byteLength,
built_at: dbNow(),
stats: encodeJson(PADDLE_REGION_GRAPH_STATS, built.stats),
min_lat: built.box.minLat,
min_lng: built.box.minLng,
max_lat: built.box.maxLat,
max_lng: built.box.maxLng,
});
return previous;
}
/** After the import committed: the network it replaced goes, from disk and from the router's memory. */
async replaced(previous: string | null, current: string): Promise<void> {
if (!previous || previous === current) return;
this.runner.forget(this.pathOf(previous));
await rm(this.pathOf(previous), { force: true });
}
/** A written network whose import did not commit. */
async discard(file: string): Promise<void> {
await rm(this.pathOf(file), { force: true });
}
/** What each region's network holds, by region id, for the admin list. */
async networks(): Promise<Map<string, Network>> {
const rows = await this.graphs.list();
return new Map(
rows.flatMap((row) => {
const network = this.networkOf(row);
return network ? [[row.region_id, network] as const] : [];
}),
);
}
/** The regions whose network reaches a point, smallest first: where a leg ending there would be planned. */
async regionsCovering(lat: number, lng: number): Promise<string[]> {
return (await this.graphs.coveringPoint(lat, lng)).map((row) => row.region_id);
}
private networkOf(row: PaddleWaterGraphRow): Network | null {
const stats = decodeJson(PADDLE_REGION_GRAPH_STATS, row.stats, row.region_id);
if (!stats) return null;
return { ...stats, built_at: utcSuffix(row.built_at) ?? row.built_at, bytes: row.bytes };
}
/**
* Plans every leg of a request: each on the smallest region covering both
* its ends, the legs of one region in one go. A leg no region covers, or
* whose ends lie in regions that do not overlap, fails on its own; the
* others are planned all the same.
*/
async route(request: PaddleRouteRequest): Promise<PaddleRouteResponse> {
const profile: RouteProfile = {
boat: request.boat,
lakeStyle: request.lake_style,
lockPolicy: request.lock_policy,
};
const legs: PaddleRouteLeg[] = new Array(request.legs.length);
const jobs = new Map<string, { row: PaddleWaterGraphRow; indices: number[] }>();
const placed = await Promise.all(request.legs.map((leg) => this.regionOf(leg)));
placed.forEach((place, index) => {
if ('status' in place) {
legs[index] = place;
return;
}
const job = jobs.get(place.region_id) ?? { row: place, indices: [] };
job.indices.push(index);
jobs.set(place.region_id, job);
});
// Each region's legs in one job; the router thread takes them one after the other.
await Promise.all(
[...jobs.values()].map(async ({ row, indices }) => {
const planned = await this.planOn(
row,
indices.map((index) => request.legs[index] as PaddleRouteRequest['legs'][number]),
profile,
);
indices.forEach((index, at) => (legs[index] = planned[at] as PaddleRouteLeg));
}),
);
const revs = [...jobs.values()].map(({ row }) => row.rev);
return { legs, graph_rev: revs.length > 0 ? revs.join('+') : null };
}
/** The smallest region whose network covers both ends of a leg, or why there is none. */
private async regionOf(leg: PaddleRouteRequest['legs'][number]): Promise<PaddleWaterGraphRow | PaddleRouteLeg> {
const [fromRows, toRows] = await Promise.all([
this.graphs.coveringPoint(leg.from.lat, leg.from.lng),
this.graphs.coveringPoint(leg.to.lat, leg.to.lng),
]);
if (fromRows.length === 0) return { status: 'failed', code: 'no_region', at: leg.from };
if (toRows.length === 0) return { status: 'failed', code: 'no_region', at: leg.to };
const toIds = new Set(toRows.map((row) => row.region_id));
return (
fromRows.find((candidate) => toIds.has(candidate.region_id)) ?? {
status: 'failed',
code: 'cross_region',
at: leg.to,
}
);
}
/** One region's legs planned in the router's thread; a thread that fails or hangs fails them all, as busy or timeout. */
private async planOn(
row: PaddleWaterGraphRow,
legs: PaddleRouteRequest['legs'],
profile: RouteProfile,
): Promise<PaddleRouteLeg[]> {
try {
return await this.runner.run({ file: this.pathOf(row.file), legs, profile });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
logInfo(`[Paddle] routing on ${row.region_id}: ${message}`);
const code = message === 'timeout' ? 'timeout' : 'busy';
return legs.map((): PaddleRouteLeg => ({ status: 'failed', code, at: null }));
}
}
}
@@ -0,0 +1,74 @@
import { WaterRouter, type RouteProfile } from './engine/router';
import { decodeWaterGraph } from './water-graph';
import type { PaddleRouteLeg, PaddleRoutePoint } from '@trek/shared';
import { readFile } from 'node:fs/promises';
/** One region's legs to plan, read from the graph file at `file`. */
export interface RouteJob {
file: string;
legs: Array<{ from: PaddleRoutePoint; to: PaddleRoutePoint }>;
profile: RouteProfile;
}
/** Plans legs on a region's graph: in a worker thread in production, on the calling thread in tests. */
export interface RouterRunner {
run(job: RouteJob): Promise<PaddleRouteLeg[]>;
/** Drops a graph from memory, after a region was built again or removed. */
forget(file: string): void;
close(): Promise<void>;
}
export const PADDLE_ROUTER_RUNNER = Symbol('PADDLE_ROUTER_RUNNER');
/** How many regions' graphs stay loaded; the least recently used one goes first. */
const LOADED_REGIONS = 4;
/** The loaded graphs of a thread, newest use last. */
export class GraphCache {
private readonly routers = new Map<string, Promise<WaterRouter>>();
get(file: string): Promise<WaterRouter> {
let router = this.routers.get(file);
if (router !== undefined) {
this.routers.delete(file);
} else {
router = readFile(file).then((bytes) => new WaterRouter(decodeWaterGraph(bytes)));
router.catch(() => this.routers.delete(file));
}
this.routers.set(file, router);
while (this.routers.size > LOADED_REGIONS) this.routers.delete(this.routers.keys().next().value as string);
return router;
}
forget(file: string): void {
this.routers.delete(file);
}
clear(): void {
this.routers.clear();
}
}
export async function runJob(cache: GraphCache, job: RouteJob): Promise<PaddleRouteLeg[]> {
const router = await cache.get(job.file);
return job.legs.map(({ from, to }) => router.route(from, to, job.profile));
}
/** The test runner: the same work on the calling thread. */
export class InlineRouterRunner implements RouterRunner {
private readonly cache = new GraphCache();
run(job: RouteJob): Promise<PaddleRouteLeg[]> {
return runJob(this.cache, job);
}
forget(file: string): void {
this.cache.forget(file);
}
close(): Promise<void> {
this.cache.clear();
return Promise.resolve();
}
}
@@ -0,0 +1,53 @@
import { buildWaterGraph } from './build/build-graph';
import { WaterCollector } from './build/collect';
import { SNAP_M } from './engine/router';
import { encodeWaterGraph } from './water-graph';
import type { PaddleRegionGraphStats } from '@trek/shared';
/**
* PAD-11, what the paddle region import uses of routing: a collector that
* rides along the extract's passes, and the build of the region's network
* from what it kept, as the bytes of the network's file.
*/
export { WaterCollector };
export interface BuiltWaterGraph {
bytes: Uint8Array;
rev: string;
stats: PaddleRegionGraphStats;
box: { minLat: number; minLng: number; maxLat: number; maxLng: number };
}
export function buildRegionGraph(
collector: WaterCollector,
coordOf: (nodeId: number) => [number, number] | null,
meta: { regionId: string; sourceTimestamp: string | null },
): BuiltWaterGraph | null {
const graph = buildWaterGraph(collector.water, coordOf, meta);
if (graph.nodeLat.length === 0 && graph.lakes.length === 0) return null;
const box = { minLat: 90, minLng: 180, maxLat: -90, maxLng: -180 };
for (let i = 0; i < graph.nodeLat.length; i++) {
box.minLat = Math.min(box.minLat, graph.nodeLat[i] as number);
box.maxLat = Math.max(box.maxLat, graph.nodeLat[i] as number);
box.minLng = Math.min(box.minLng, graph.nodeLng[i] as number);
box.maxLng = Math.max(box.maxLng, graph.nodeLng[i] as number);
}
for (const lake of graph.lakes) {
box.minLat = Math.min(box.minLat, lake.lat0);
box.minLng = Math.min(box.minLng, lake.lng0);
box.maxLat = Math.max(box.maxLat, lake.lat0 + (lake.height * lake.cell) / 111_195);
box.maxLng = Math.max(
box.maxLng,
lake.lng0 + (lake.width * lake.cell) / (111_195 * Math.cos((lake.lat0 * Math.PI) / 180)),
);
}
// Widened by the snap distance: a point on the bank beside the outermost water still finds this network.
const padLat = SNAP_M / 111_195;
const padLng = SNAP_M / (111_195 * Math.cos((((box.minLat + box.maxLat) / 2) * Math.PI) / 180));
box.minLat -= padLat;
box.maxLat += padLat;
box.minLng -= padLng;
box.maxLng += padLng;
const { bytes, rev } = encodeWaterGraph(graph);
return { bytes, rev, stats: graph.stats, box };
}
@@ -0,0 +1,206 @@
import type { PaddleRegionGraphStats } from '@trek/shared';
import { createHash } from 'node:crypto';
/**
* The water network of one region, as the router holds it: nodes where waters
* meet, end or are barred, edges along the rivers and canals between them
* (each with its line in a shared pool of points), the portages and gaps the
* builder added, and every lake as a grid of water cells. Plain typed arrays,
* so a region loads from its file in one read and costs a few bytes a point.
*/
const GRAPH_FORMAT = 2;
/** `pass` is a boat pass beside a weir (waterway=canoe_pass): paddled, and worth telling the paddler about. */
export const EdgeKind = { water: 0, portage: 1, gap: 2, lock: 3, connector: 4, pass: 5 } as const;
export type EdgeKindCode = (typeof EdgeKind)[keyof typeof EdgeKind];
/** Who may paddle an edge: anyone, nobody (canoe=no, private), or anyone at a cost (discouraged). */
export const Access = { open: 0, closed: 1, discouraged: 2 } as const;
export const PortageSource = { tagged: 0, footway: 1, inferred: 2 } as const;
export interface LakeGrid {
name: string | null;
/** The grid's south-west corner and its cell size in metres. */
lat0: number;
lng0: number;
cell: number;
width: number;
height: number;
/** One bit per cell, row by row from the south: set where the cell is water. */
mask: Uint8Array;
/** The nodes through which the network enters and leaves the lake. */
portals: Int32Array;
/** Hectares of water, for the stats and for keeping small ponds out. */
hectares: number;
}
export interface WaterGraphData {
regionId: string;
sourceTimestamp: string | null;
nodeLat: Float64Array;
nodeLng: Float64Array;
/** The lake a node is a portal of, or -1. */
nodeLake: Int32Array;
edgeA: Uint32Array;
edgeB: Uint32Array;
edgeLength: Float32Array;
edgeKind: Uint8Array;
/** 1 when the water flows from a to b (a river drawn downstream), 0 on still water. */
edgeFlow: Uint8Array;
/** The current, km/h, where the water flows. */
edgeCurrent: Float32Array;
edgeAccess: Uint8Array;
/** 1 when a boat may only go from a to b (oneway:canoe). */
edgeOneway: Uint8Array;
/** The whitewater grade, 0 when the data gives none. */
edgeGrade: Uint8Array;
edgeGeomStart: Uint32Array;
edgeGeomCount: Uint32Array;
edgeName: Int32Array;
edgeRef: Int32Array;
edgeSource: Uint8Array;
/** Every edge's points, [lat, lng] pairs one after another, a to b. */
pool: Float64Array;
strings: string[];
lakes: LakeGrid[];
stats: PaddleRegionGraphStats;
}
const MAGIC = 'TREKWG';
type ArrayName = Exclude<
{
[K in keyof WaterGraphData]: WaterGraphData[K] extends ArrayBufferView ? K : never;
}[keyof WaterGraphData],
undefined
>;
const ARRAYS: ReadonlyArray<[ArrayName, string]> = [
['nodeLat', 'f64'],
['nodeLng', 'f64'],
['nodeLake', 'i32'],
['edgeA', 'u32'],
['edgeB', 'u32'],
['edgeLength', 'f32'],
['edgeKind', 'u8'],
['edgeFlow', 'u8'],
['edgeCurrent', 'f32'],
['edgeAccess', 'u8'],
['edgeOneway', 'u8'],
['edgeGrade', 'u8'],
['edgeGeomStart', 'u32'],
['edgeGeomCount', 'u32'],
['edgeName', 'i32'],
['edgeRef', 'i32'],
['edgeSource', 'u8'],
['pool', 'f64'],
];
const VIEWS = {
f64: Float64Array,
f32: Float32Array,
i32: Int32Array,
u32: Uint32Array,
u8: Uint8Array,
} as const;
function padTo8(length: number): number {
return (length + 7) & ~7;
}
/**
* The graph as one file: a magic, the length of a JSON head, the head (sizes,
* offsets, strings, lake frames) and the arrays, each aligned to 8 bytes.
* The same graph always gives the same bytes, so its hash names its revision.
*/
export function encodeWaterGraph(graph: WaterGraphData): { bytes: Buffer; rev: string } {
const chunks: Buffer[] = [];
let offset = 0;
const sections: Array<{ name: string; type: string; offset: number; length: number }> = [];
const add = (name: string, type: string, view: ArrayBufferView) => {
const raw = Buffer.from(view.buffer, view.byteOffset, view.byteLength);
const padded = Buffer.alloc(padTo8(raw.length));
raw.copy(padded);
sections.push({ name, type, offset, length: view.byteLength });
chunks.push(padded);
offset += padded.length;
};
for (const [name, type] of ARRAYS) add(name, type, graph[name]);
graph.lakes.forEach((lake, index) => {
add(`lake${index}.mask`, 'u8', lake.mask);
add(`lake${index}.portals`, 'i32', lake.portals);
});
const head = Buffer.from(
JSON.stringify({
format: GRAPH_FORMAT,
regionId: graph.regionId,
sourceTimestamp: graph.sourceTimestamp,
strings: graph.strings,
stats: graph.stats,
lakes: graph.lakes.map(({ name, lat0, lng0, cell, width, height, hectares }) => ({
name,
lat0,
lng0,
cell,
width,
height,
hectares,
})),
sections,
}),
'utf8',
);
const prefix = Buffer.alloc(padTo8(MAGIC.length + 4 + head.length));
prefix.write(MAGIC, 0, 'latin1');
prefix.writeUInt32LE(head.length, MAGIC.length);
head.copy(prefix, MAGIC.length + 4);
const bytes = Buffer.concat([prefix, ...chunks]);
return { bytes, rev: createHash('sha256').update(bytes).digest('hex').slice(0, 16) };
}
interface Head {
format: number;
regionId: string;
sourceTimestamp: string | null;
strings: string[];
stats: PaddleRegionGraphStats;
lakes: Array<Omit<LakeGrid, 'mask' | 'portals'>>;
sections: Array<{ name: string; type: keyof typeof VIEWS; offset: number; length: number }>;
}
export function decodeWaterGraph(bytes: Buffer): WaterGraphData {
if (bytes.toString('latin1', 0, MAGIC.length) !== MAGIC) throw new Error('Not a water graph');
const headLength = bytes.readUInt32LE(MAGIC.length);
const head = JSON.parse(bytes.toString('utf8', MAGIC.length + 4, MAGIC.length + 4 + headLength)) as Head;
if (head.format !== GRAPH_FORMAT) throw new Error(`Water graph format ${head.format} is not ${GRAPH_FORMAT}`);
const base = padTo8(MAGIC.length + 4 + headLength);
// Copied out of the file buffer, so every view starts aligned whatever Node's pool did with it.
const arrays = new Map<string, ArrayBufferView>();
for (const section of head.sections) {
const View = VIEWS[section.type];
const copy = new Uint8Array(section.length);
copy.set(bytes.subarray(base + section.offset, base + section.offset + section.length));
arrays.set(section.name, new View(copy.buffer, 0, section.length / View.BYTES_PER_ELEMENT));
}
const take = <T extends ArrayBufferView>(name: string): T => {
const view = arrays.get(name);
if (!view) throw new Error(`Water graph lacks ${name}`);
return view as T;
};
const graph = {
regionId: head.regionId,
sourceTimestamp: head.sourceTimestamp,
strings: head.strings,
stats: head.stats,
lakes: head.lakes.map((lake, index) => ({
...lake,
mask: take<Uint8Array>(`lake${index}.mask`),
portals: take<Int32Array>(`lake${index}.portals`),
})),
} as Partial<WaterGraphData>;
for (const [name] of ARRAYS) (graph as Record<string, unknown>)[name] = take(name);
return graph as WaterGraphData;
}
@@ -0,0 +1,102 @@
import type { RouteJob, RouterRunner } from './router-runner';
import type { PaddleRouteLeg } from '@trek/shared';
import { join } from 'node:path';
import { Worker } from 'node:worker_threads';
/** A request the thread has not answered in this long is given up, and the thread is started over. */
const ANSWER_MS = 8000;
/** A thread with nothing to do is ended after this long; the next request starts it again. */
const IDLE_MS = 30 * 60 * 1000;
interface Pending {
resolve: (legs: PaddleRouteLeg[]) => void;
reject: (err: Error) => void;
timer: NodeJS.Timeout;
}
/**
* The router's thread, started on the first request and ended when idle. A
* search that hangs or a thread that dies fails its requests and is replaced
* by a fresh thread, so one bad graph never stops routing for good.
*/
export class WorkerRouterRunner implements RouterRunner {
private worker: Worker | null = null;
private nextId = 1;
private readonly pending = new Map<number, Pending>();
private idle: NodeJS.Timeout | null = null;
constructor(private readonly entry = join(__dirname, 'paddle-router.worker.js')) {}
run(job: RouteJob): Promise<PaddleRouteLeg[]> {
const worker = this.thread();
const id = this.nextId++;
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error('timeout'));
this.restart();
}, ANSWER_MS);
this.pending.set(id, { resolve, reject, timer });
worker.postMessage({ id, job });
});
}
forget(file: string): void {
this.worker?.postMessage({ id: 0, forget: file });
}
async close(): Promise<void> {
if (this.idle) clearTimeout(this.idle);
this.failAll(new Error('closed'));
const worker = this.worker;
this.worker = null;
await worker?.terminate();
}
private thread(): Worker {
if (this.idle) clearTimeout(this.idle);
this.idle = setTimeout(() => void this.close(), IDLE_MS);
this.idle.unref();
if (this.worker) return this.worker;
// The thread loads compiled code the way the server does (with the path aliases the server's own start registers).
const execArgv = process.execArgv.filter(
(arg, index, all) =>
arg === '--require' || arg === '-r' || all[index - 1] === '--require' || all[index - 1] === '-r',
);
const worker = new Worker(this.entry, { execArgv });
worker.on('message', (message: { id: number; legs?: PaddleRouteLeg[]; error?: string }) => {
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
clearTimeout(pending.timer);
if (message.legs) pending.resolve(message.legs);
else pending.reject(new Error(message.error ?? 'router failed'));
});
worker.on('error', (err: Error) => {
if (this.worker === worker) this.worker = null;
this.failAll(err);
});
worker.on('exit', () => {
if (this.worker === worker) this.worker = null;
this.failAll(new Error('router thread ended'));
});
worker.unref();
this.worker = worker;
return worker;
}
private restart(): void {
const worker = this.worker;
this.worker = null;
void worker?.terminate();
}
private failAll(err: Error): void {
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(err);
this.pending.delete(id);
}
}
}
+6
View File
@@ -34,6 +34,7 @@ import {
plannedTourDurationMinutesSchema,
tourWebsiteSchema,
type PaddleBoatType,
type PaddleLakeStyle,
type PaddleWaterContext,
type TourCreateRequest,
type TourCreateResponse,
@@ -103,6 +104,7 @@ export class ToursService {
water_context: r.water_context as PaddleWaterContext | null,
paddle_speed_kmh: r.paddle_speed_kmh,
portage_speed_kmh: r.portage_speed_kmh,
paddle_lake_style: r.paddle_lake_style as PaddleLakeStyle | null,
track_marks: decodeJson(TOUR_TRACK_MARKS, r.track_marks, 'tour'),
has_recorded_track: Boolean(r.has_recorded_track),
planned: Boolean(r.planned),
@@ -135,6 +137,7 @@ export class ToursService {
water_context: input.water_context ?? null,
paddle_speed_kmh: input.paddle_speed_kmh ?? null,
portage_speed_kmh: input.portage_speed_kmh ?? null,
paddle_lake_style: input.paddle_lake_style ?? null,
};
}
@@ -365,6 +368,7 @@ export class ToursService {
water_context: row.water_context as PaddleWaterContext | null,
paddle_speed_kmh: row.paddle_speed_kmh,
portage_speed_kmh: row.portage_speed_kmh,
paddle_lake_style: row.paddle_lake_style as PaddleLakeStyle | null,
});
await this.waypointsRepo.deleteForPlace(pid);
await this.toursRepo.clearRecordedTrack(pid);
@@ -427,6 +431,7 @@ export class ToursService {
water_context: source.water_context as PaddleWaterContext | null,
paddle_speed_kmh: source.paddle_speed_kmh,
portage_speed_kmh: source.portage_speed_kmh,
paddle_lake_style: source.paddle_lake_style as PaddleLakeStyle | null,
track_marks: source.track_marks,
wanderer_ref: source.wanderer_ref,
difficulty: source.difficulty,
@@ -579,6 +584,7 @@ export class ToursService {
derived_from_place_id: null,
origin: 'gpx',
portage_speed_kmh: null,
paddle_lake_style: null,
track_marks: null,
has_recorded_track: 0,
planned: 0,