Initial Watermaps import

This commit is contained in:
BuTzZ
2026-07-24 11:29:24 +02:00
commit 57f7b4dedb
129 changed files with 43136 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@watermaps/shared",
"version": "0.1.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"vitest": "^3.2.4"
}
}
+445
View File
@@ -0,0 +1,445 @@
import { haversineDistanceM } from "./geo.js";
import type { Coordinate, TideCurvePoint, TideSummary } from "./types.js";
const DEFAULT_MAX_RELIABLE_ACCURACY_M = 100;
const DEFAULT_SAFETY_ALLOWANCE_M = 0.5;
const MILLISECONDS_PER_HOUR = 60 * 60 * 1_000;
export type AnchorWatchStatus = "safe" | "alarm" | "gps-unreliable";
export type AnchorWatchInput = {
anchorPoint: Coordinate;
position: Coordinate;
alarmRadiusM: number;
accuracyM?: number | null;
maxReliableAccuracyM?: number;
};
export type AnchorWatchResult = {
status: AnchorWatchStatus;
anchorPoint: Coordinate;
position: Coordinate;
alarmRadiusM: number;
accuracyM: number | null;
maxReliableAccuracyM: number;
distanceFromAnchorM: number;
conservativeDistanceFromAnchorM: number | null;
positionReliable: boolean;
isOutsideAlarmRadius: boolean;
isConservativelyOutsideAlarmRadius: boolean;
alarmTriggered: boolean;
};
export type AnchorTideSource =
| Pick<TideSummary, "waterLevelCurve">
| readonly TideCurvePoint[]
| null
| undefined;
export type AnchorTideWindowCoverage = "complete" | "partial" | "unavailable";
export type AnchorTideWindowReason =
| "invalid-window"
| "missing-tide-data"
| "start-outside-coverage"
| "incomplete-horizon"
| null;
export type AnchorTideWindowResult = {
coverage: AnchorTideWindowCoverage;
reason: AnchorTideWindowReason;
fromTime: string | null;
untilTime: string | null;
coveredUntilTime: string | null;
horizonHours: number;
startHeightM: number | null;
minimumHeightM: number | null;
maximumHeightM: number | null;
/** Largest non-negative water-level rise relative to fromTime. */
maximumRiseM: number | null;
/** Maximum minus minimum water level inside the covered part of the window. */
tidalRangeM: number | null;
sampleCount: number;
};
export type AnchorRodePlanInput = {
depthAtSetM: number;
bowRollerHeightM: number;
deployedRodeLengthM: number;
scopeRatio: number;
safetyAllowanceM?: number;
tideWindow?: AnchorTideWindowResult | null;
};
export type AnchorRodePlan = {
calculationComplete: boolean;
depthAtSetM: number;
bowRollerHeightM: number;
deployedRodeLengthM: number;
scopeRatio: number;
safetyAllowanceM: number;
verticalDistanceAtSetM: number;
minimumRequiredRodeLengthM: number;
maximumFutureTideRiseM: number | null;
maximumVerticalDistanceM: number | null;
planningVerticalDistanceM: number | null;
requiredRodeLengthM: number | null;
rodeReserveM: number | null;
hasSufficientRode: boolean | null;
horizontalReachAtSetM: number;
/** Maximum horizontal reach at the highest known water level in the horizon. */
horizontalReachM: number | null;
rodeReachesBottomAtSet: boolean;
rodeReachesBottomAtMaximumTide: boolean | null;
};
type TideSample = {
timestamp: number;
heightM: number;
priority: number;
};
/**
* Evaluates an anchor alarm conservatively. A fix is outside only when the
* complete reported accuracy circle lies beyond the alarm radius. Missing or
* excessive GPS accuracy can therefore never trigger an alarm by itself.
*/
export function evaluateAnchorWatch(input: AnchorWatchInput): AnchorWatchResult | null {
const maxReliableAccuracyM = input.maxReliableAccuracyM ?? DEFAULT_MAX_RELIABLE_ACCURACY_M;
if (
!isCoordinate(input.anchorPoint)
|| !isCoordinate(input.position)
|| !isPositiveFinite(input.alarmRadiusM)
|| !isPositiveFinite(maxReliableAccuracyM)
) {
return null;
}
const accuracyM = isNonNegativeFinite(input.accuracyM) ? input.accuracyM : null;
const distanceFromAnchorM = haversineDistanceM(input.anchorPoint, input.position);
const conservativeDistanceFromAnchorM = accuracyM === null
? null
: Math.max(0, distanceFromAnchorM - accuracyM);
const positionReliable = accuracyM !== null && accuracyM <= maxReliableAccuracyM;
const isOutsideAlarmRadius = distanceFromAnchorM > input.alarmRadiusM;
const isConservativelyOutsideAlarmRadius = conservativeDistanceFromAnchorM !== null
&& conservativeDistanceFromAnchorM > input.alarmRadiusM;
const alarmTriggered = positionReliable && isConservativelyOutsideAlarmRadius;
return {
status: !positionReliable ? "gps-unreliable" : alarmTriggered ? "alarm" : "safe",
anchorPoint: { ...input.anchorPoint },
position: { ...input.position },
alarmRadiusM: input.alarmRadiusM,
accuracyM,
maxReliableAccuracyM,
distanceFromAnchorM,
conservativeDistanceFromAnchorM,
positionReliable,
isOutsideAlarmRadius,
isConservativelyOutsideAlarmRadius,
alarmTriggered
};
}
/**
* Interpolates the tide level at both window boundaries, then includes every
* valid curve sample between them. Measurements take precedence over forecast
* values, which in turn take precedence over astronomical predictions.
*/
export function analyzeTideWindow(
source: AnchorTideSource,
fromMs: number,
horizonHours: number
): AnchorTideWindowResult {
if (!isValidTimestamp(fromMs) || !isPositiveFinite(horizonHours)) {
return unavailableTideWindow("invalid-window", fromMs, horizonHours);
}
const untilMs = fromMs + horizonHours * MILLISECONDS_PER_HOUR;
if (!isValidTimestamp(untilMs) || untilMs <= fromMs) {
return unavailableTideWindow("invalid-window", fromMs, horizonHours);
}
const samples = normalizeTideSamples(tideCurveFromSource(source));
if (samples.length === 0) {
return unavailableTideWindow("missing-tide-data", fromMs, horizonHours, untilMs);
}
const startHeightM = interpolateTideHeight(samples, fromMs);
if (startHeightM === null) {
return unavailableTideWindow("start-outside-coverage", fromMs, horizonHours, untilMs);
}
const lastTimestamp = samples.at(-1)!.timestamp;
const coveredUntilMs = Math.min(untilMs, lastTimestamp);
const endHeightM = interpolateTideHeight(samples, coveredUntilMs);
if (endHeightM === null) {
return unavailableTideWindow("start-outside-coverage", fromMs, horizonHours, untilMs);
}
const windowSamples = [
{ timestamp: fromMs, heightM: startHeightM },
...samples
.filter(({ timestamp }) => timestamp > fromMs && timestamp < coveredUntilMs)
.map(({ timestamp, heightM }) => ({ timestamp, heightM })),
{ timestamp: coveredUntilMs, heightM: endHeightM }
];
const uniqueWindowSamples = deduplicateWindowSamples(windowSamples);
const heights = uniqueWindowSamples.map(({ heightM }) => heightM);
const minimumHeightM = Math.min(...heights);
const maximumHeightM = Math.max(...heights);
const complete = lastTimestamp >= untilMs;
return {
coverage: complete ? "complete" : "partial",
reason: complete ? null : "incomplete-horizon",
fromTime: new Date(fromMs).toISOString(),
untilTime: new Date(untilMs).toISOString(),
coveredUntilTime: new Date(coveredUntilMs).toISOString(),
horizonHours,
startHeightM,
minimumHeightM,
maximumHeightM,
maximumRiseM: Math.max(0, maximumHeightM - startHeightM),
tidalRangeM: maximumHeightM - minimumHeightM,
sampleCount: uniqueWindowSamples.length
};
}
/**
* Plans rode length with the conventional scope ratio:
* (depth + bow roller + future tide rise + safety allowance) * scope.
* A partial or missing tide window intentionally produces null future values
* so that an incomplete forecast cannot be presented as a safe rode length.
*/
export function calculateAnchorRodePlan(input: AnchorRodePlanInput): AnchorRodePlan | null {
const safetyAllowanceM = input.safetyAllowanceM ?? DEFAULT_SAFETY_ALLOWANCE_M;
if (
!isNonNegativeFinite(input.depthAtSetM)
|| !isNonNegativeFinite(input.bowRollerHeightM)
|| !isNonNegativeFinite(input.deployedRodeLengthM)
|| !isPositiveFinite(input.scopeRatio)
|| !isNonNegativeFinite(safetyAllowanceM)
) {
return null;
}
const verticalDistanceAtSetM = input.depthAtSetM + input.bowRollerHeightM;
const minimumPlanningVerticalDistanceM = verticalDistanceAtSetM + safetyAllowanceM;
const minimumRequiredRodeLengthM = minimumPlanningVerticalDistanceM * input.scopeRatio;
if (
!Number.isFinite(verticalDistanceAtSetM)
|| !Number.isFinite(minimumPlanningVerticalDistanceM)
|| !Number.isFinite(minimumRequiredRodeLengthM)
) {
return null;
}
const rodeReachesBottomAtSet = input.deployedRodeLengthM >= verticalDistanceAtSetM;
const horizontalReachAtSetM = horizontalReach(
input.deployedRodeLengthM,
verticalDistanceAtSetM
);
const maximumFutureTideRiseM = completeTideRise(input.tideWindow);
if (maximumFutureTideRiseM === null) {
return {
calculationComplete: false,
depthAtSetM: input.depthAtSetM,
bowRollerHeightM: input.bowRollerHeightM,
deployedRodeLengthM: input.deployedRodeLengthM,
scopeRatio: input.scopeRatio,
safetyAllowanceM,
verticalDistanceAtSetM,
minimumRequiredRodeLengthM,
maximumFutureTideRiseM: null,
maximumVerticalDistanceM: null,
planningVerticalDistanceM: null,
requiredRodeLengthM: null,
rodeReserveM: null,
hasSufficientRode: null,
horizontalReachAtSetM,
horizontalReachM: null,
rodeReachesBottomAtSet,
rodeReachesBottomAtMaximumTide: null
};
}
const maximumVerticalDistanceM = verticalDistanceAtSetM + maximumFutureTideRiseM;
const planningVerticalDistanceM = maximumVerticalDistanceM + safetyAllowanceM;
const requiredRodeLengthM = planningVerticalDistanceM * input.scopeRatio;
const rodeReserveM = input.deployedRodeLengthM - requiredRodeLengthM;
if (
!Number.isFinite(maximumVerticalDistanceM)
|| !Number.isFinite(planningVerticalDistanceM)
|| !Number.isFinite(requiredRodeLengthM)
|| !Number.isFinite(rodeReserveM)
) {
return null;
}
const rodeReachesBottomAtMaximumTide =
input.deployedRodeLengthM >= maximumVerticalDistanceM;
return {
calculationComplete: true,
depthAtSetM: input.depthAtSetM,
bowRollerHeightM: input.bowRollerHeightM,
deployedRodeLengthM: input.deployedRodeLengthM,
scopeRatio: input.scopeRatio,
safetyAllowanceM,
verticalDistanceAtSetM,
minimumRequiredRodeLengthM,
maximumFutureTideRiseM,
maximumVerticalDistanceM,
planningVerticalDistanceM,
requiredRodeLengthM,
rodeReserveM,
hasSufficientRode: rodeReserveM >= 0,
horizontalReachAtSetM,
horizontalReachM: horizontalReach(input.deployedRodeLengthM, maximumVerticalDistanceM),
rodeReachesBottomAtSet,
rodeReachesBottomAtMaximumTide
};
}
function completeTideRise(tideWindow: AnchorTideWindowResult | null | undefined): number | null {
return tideWindow?.coverage === "complete" && isNonNegativeFinite(tideWindow.maximumRiseM)
? tideWindow.maximumRiseM
: null;
}
function tideCurveFromSource(source: AnchorTideSource): readonly TideCurvePoint[] {
if (Array.isArray(source)) {
return source;
}
if (source && typeof source === "object" && "waterLevelCurve" in source) {
return Array.isArray(source.waterLevelCurve) ? source.waterLevelCurve : [];
}
return [];
}
function normalizeTideSamples(curve: readonly TideCurvePoint[]): TideSample[] {
const byTimestamp = new Map<number, TideSample>();
for (const point of curve) {
const timestamp = Date.parse(point.time);
const value = tidePointValue(point);
if (!Number.isFinite(timestamp) || !value) {
continue;
}
const existing = byTimestamp.get(timestamp);
if (!existing || value.priority > existing.priority) {
byTimestamp.set(timestamp, { timestamp, ...value });
}
}
return [...byTimestamp.values()].sort((a, b) => a.timestamp - b.timestamp);
}
function tidePointValue(point: TideCurvePoint): Pick<TideSample, "heightM" | "priority"> | null {
if (isFiniteNumber(point.measuredM)) {
return { heightM: point.measuredM, priority: 3 };
}
if (isFiniteNumber(point.forecastM)) {
return { heightM: point.forecastM, priority: 2 };
}
if (isFiniteNumber(point.predictedM)) {
return { heightM: point.predictedM, priority: 1 };
}
return null;
}
function interpolateTideHeight(samples: TideSample[], timestamp: number): number | null {
if (timestamp < samples[0]!.timestamp || timestamp > samples.at(-1)!.timestamp) {
return null;
}
let low = 0;
let high = samples.length - 1;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const sample = samples[middle]!;
if (sample.timestamp === timestamp) {
return sample.heightM;
}
if (sample.timestamp < timestamp) {
low = middle + 1;
} else {
high = middle - 1;
}
}
const before = samples[high];
const after = samples[low];
if (!before || !after || after.timestamp === before.timestamp) {
return null;
}
const fraction = (timestamp - before.timestamp) / (after.timestamp - before.timestamp);
return before.heightM + (after.heightM - before.heightM) * fraction;
}
function unavailableTideWindow(
reason: Exclude<AnchorTideWindowReason, "incomplete-horizon" | null>,
fromMs: number,
horizonHours: number,
untilMs?: number
): AnchorTideWindowResult {
return {
coverage: "unavailable",
reason,
fromTime: isoTimestamp(fromMs),
untilTime: typeof untilMs === "number" ? isoTimestamp(untilMs) : null,
coveredUntilTime: null,
horizonHours,
startHeightM: null,
minimumHeightM: null,
maximumHeightM: null,
maximumRiseM: null,
tidalRangeM: null,
sampleCount: 0
};
}
function deduplicateWindowSamples<T extends { timestamp: number }>(samples: T[]): T[] {
const byTimestamp = new Map<number, T>();
for (const sample of samples) {
byTimestamp.set(sample.timestamp, sample);
}
return [...byTimestamp.values()].sort((a, b) => a.timestamp - b.timestamp);
}
function horizontalReach(rodeLengthM: number, verticalDistanceM: number): number {
if (rodeLengthM <= verticalDistanceM) {
return 0;
}
const verticalRatio = verticalDistanceM / rodeLengthM;
return rodeLengthM * Math.sqrt(Math.max(0, 1 - verticalRatio ** 2));
}
function isValidTimestamp(value: number): boolean {
return Number.isFinite(value) && Number.isFinite(new Date(value).getTime());
}
function isoTimestamp(value: number): string | null {
return isValidTimestamp(value) ? new Date(value).toISOString() : null;
}
function isCoordinate(value: Coordinate): boolean {
return Boolean(value)
&& isFiniteNumber(value.lat)
&& value.lat >= -90
&& value.lat <= 90
&& isFiniteNumber(value.lon)
&& value.lon >= -180
&& value.lon <= 180;
}
function isFiniteNumber(value: unknown): value is number {
return typeof value === "number" && Number.isFinite(value);
}
function isNonNegativeFinite(value: unknown): value is number {
return isFiniteNumber(value) && value >= 0;
}
function isPositiveFinite(value: unknown): value is number {
return isFiniteNumber(value) && value > 0;
}
+894
View File
@@ -0,0 +1,894 @@
import { haversineDistanceNm, sumRouteDistanceNm } from "./geo.js";
import type {
Coordinate,
DepthSample,
RouteOption,
RouteRequest,
RouteResult,
RouteWarning,
VesselProfile
} from "./types.js";
export type FairwayNode = {
id: string;
coordinate: Coordinate;
};
export type FairwayEdge = {
id: string;
name: string;
from: string;
to: string;
coordinates: Coordinate[];
minDepthM: number | null;
maxAirDraftM?: number | null;
maxBeamM?: number | null;
maxDraughtM?: number | null;
oneway?: boolean | "forward" | "backward";
source?: string;
};
type EdgeSnap = {
edge: FairwayEdge;
coordinate: Coordinate;
segmentIndex: number;
t: number;
distanceNm: number;
};
export type FairwayGraph = {
id: string;
name: string;
maxSnapDistanceNm: number;
nodes: FairwayNode[];
edges: FairwayEdge[];
};
type PathStep = {
edge: FairwayEdge;
from: string;
to: string;
weightNm: number;
};
type Adjacency = Map<string, PathStep[]>;
type FairwayLeg = {
coordinates: Coordinate[];
usedEdges: FairwayEdge[];
distanceNm: number;
costNm: number;
};
type FairwayRouteCandidate = {
route: RouteResult;
usedEdges: FairwayEdge[];
};
const DEFAULT_CRUISE_SPEED_KN = 6;
const MAX_ALTERNATIVE_ROUTES = 3;
const ALTERNATIVE_EDGE_PENALTY = 8;
const MAX_ALTERNATIVE_DISTANCE_FACTOR = 1.75;
const MIN_DIFFERENT_DISTANCE_NM = 0.25;
const MIN_DIFFERENT_DISTANCE_RATIO = 0.05;
const EMS_BORKUM_GRAPH: FairwayGraph = {
id: "ems-borkum-seed",
name: "Emsfahrwasser Emden-Borkum",
maxSnapDistanceNm: 3,
nodes: [
{ id: "emden-aussenhafen", coordinate: { lat: 53.344167, lon: 7.186111 } },
{ id: "emden-harbour-leading", coordinate: { lat: 53.333897, lon: 7.201469 } },
{ id: "ems-fairway-inner", coordinate: { lat: 53.33532, lon: 7.132674 } },
{ id: "knock-reach", coordinate: { lat: 53.327597, lon: 7.015002 } },
{ id: "paapsand-reach", coordinate: { lat: 53.339, lon: 6.93 } },
{ id: "westerems-south", coordinate: { lat: 53.376, lon: 6.865 } },
{ id: "eemshaven-approach", coordinate: { lat: 53.442996, lon: 6.833146 } },
{ id: "borkum-south-approach", coordinate: { lat: 53.505, lon: 6.765 } },
{ id: "borkum-reede", coordinate: { lat: 53.563776, lon: 6.750562 } }
],
edges: [
{
id: "emden-harbour-exit",
name: "Emden Außenhafen Ausfahrt",
from: "emden-aussenhafen",
to: "emden-harbour-leading",
minDepthM: 8.5,
coordinates: [
{ lat: 53.344167, lon: 7.186111 },
{ lat: 53.339, lon: 7.191 },
{ lat: 53.333897, lon: 7.201469 }
]
},
{
id: "emden-leading-to-ems",
name: "Emder Leitlinie zum Emsfahrwasser",
from: "emden-harbour-leading",
to: "ems-fairway-inner",
minDepthM: 8.5,
coordinates: [
{ lat: 53.333897, lon: 7.201469 },
{ lat: 53.332236, lon: 7.132959 },
{ lat: 53.33532, lon: 7.132674 }
]
},
{
id: "inner-ems-to-knock",
name: "Inneres Emsfahrwasser",
from: "ems-fairway-inner",
to: "knock-reach",
minDepthM: 8.5,
coordinates: [
{ lat: 53.33532, lon: 7.132674 },
{ lat: 53.333, lon: 7.075 },
{ lat: 53.327597, lon: 7.015002 }
]
},
{
id: "knock-to-paapsand",
name: "Außenems über Paapsand",
from: "knock-reach",
to: "paapsand-reach",
minDepthM: 7.5,
coordinates: [
{ lat: 53.327597, lon: 7.015002 },
{ lat: 53.323764, lon: 6.985 },
{ lat: 53.329, lon: 6.955 },
{ lat: 53.339, lon: 6.93 }
]
},
{
id: "paapsand-to-westerems",
name: "Westerems Südansteuerung",
from: "paapsand-reach",
to: "westerems-south",
minDepthM: 7,
coordinates: [
{ lat: 53.339, lon: 6.93 },
{ lat: 53.348, lon: 6.9 },
{ lat: 53.36, lon: 6.88 },
{ lat: 53.376, lon: 6.865 }
]
},
{
id: "westerems-to-eemshaven",
name: "Westerems Fahrwasser",
from: "westerems-south",
to: "eemshaven-approach",
minDepthM: 7,
coordinates: [
{ lat: 53.376, lon: 6.865 },
{ lat: 53.398, lon: 6.85 },
{ lat: 53.421, lon: 6.838 },
{ lat: 53.442996, lon: 6.833146 }
]
},
{
id: "eemshaven-to-borkum-south",
name: "Borkum Südansteuerung",
from: "eemshaven-approach",
to: "borkum-south-approach",
minDepthM: 6,
coordinates: [
{ lat: 53.442996, lon: 6.833146 },
{ lat: 53.463, lon: 6.807 },
{ lat: 53.483, lon: 6.785 },
{ lat: 53.505, lon: 6.765 }
]
},
{
id: "borkum-south-to-reede",
name: "Borkum Reede Ansteuerung",
from: "borkum-south-approach",
to: "borkum-reede",
minDepthM: 5,
coordinates: [
{ lat: 53.505, lon: 6.765 },
{ lat: 53.525, lon: 6.758 },
{ lat: 53.545, lon: 6.753 },
{ lat: 53.563776, lon: 6.750562 }
]
}
]
};
export function buildFairwayRoute(request: RouteRequest, graph = EMS_BORKUM_GRAPH): RouteResult | null {
return buildFairwayRoutes(request, graph, 1)[0] ?? null;
}
export function buildFairwayRoutes(
request: RouteRequest,
graph = EMS_BORKUM_GRAPH,
maxRoutes = MAX_ALTERNATIVE_ROUTES
): RouteOption[] {
const routeLimit = Math.max(0, Math.min(MAX_ALTERNATIVE_ROUTES, Math.floor(maxRoutes)));
if (routeLimit === 0) {
return [];
}
const routableGraph = filterRestrictedEdges(graph, request);
if (routableGraph.edges.length === 0) {
return [];
}
const accepted: FairwayRouteCandidate[] = [];
const penaltyCounts = new Map<string, number>();
const maxAttempts = Math.max(8, routeLimit * 6);
for (let attempt = 0; attempt < maxAttempts && accepted.length < routeLimit; attempt += 1) {
const candidate = buildFairwayRouteCandidate(request, routableGraph, penaltyCounts);
if (!candidate) {
break;
}
const primaryDistanceNm = accepted[0]?.route.distanceNm;
const isReasonableLength =
primaryDistanceNm === undefined || candidate.route.distanceNm <= primaryDistanceNm * MAX_ALTERNATIVE_DISTANCE_FACTOR;
if (isReasonableLength && isMeaningfullyDifferent(candidate, accepted)) {
accepted.push(candidate);
}
for (const edge of candidate.usedEdges) {
penaltyCounts.set(edge.id, (penaltyCounts.get(edge.id) ?? 0) + 1);
}
}
const ordered = accepted.length > 1
? [accepted[0]!, ...accepted.slice(1).sort((a, b) => a.route.distanceNm - b.route.distanceNm)]
: accepted;
return ordered.map((candidate, index) => ({
...candidate.route,
id: `${graph.id}-route-${index + 1}`,
name: index === 0 ? "Hauptroute" : `Alternative ${index}`
}));
}
function buildFairwayRouteCandidate(
request: RouteRequest,
routableGraph: FairwayGraph,
penaltyCounts: ReadonlyMap<string, number>
): FairwayRouteCandidate | null {
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
const routeCoordinates: Coordinate[] = [];
const usedEdges = new Map<string, FairwayEdge>();
const adjacency = buildAdjacency(routableGraph, penaltyCounts);
for (let index = 0; index < requestedPoints.length - 1; index += 1) {
const legStart = requestedPoints[index]!;
const legDestination = requestedPoints[index + 1]!;
const leg = buildFairwayLeg(routableGraph, legStart, legDestination, penaltyCounts, adjacency);
if (!leg) {
return null;
}
for (const coordinate of leg.coordinates) {
appendCoordinate(routeCoordinates, coordinate);
}
for (const edge of leg.usedEdges) {
usedEdges.set(edge.id, edge);
}
}
const distanceNm = round(sumRouteDistanceNm(routeCoordinates), 2);
const speedKn =
request.vesselProfile.cruiseSpeedKn && request.vesselProfile.cruiseSpeedKn > 0
? request.vesselProfile.cruiseSpeedKn
: DEFAULT_CRUISE_SPEED_KN;
const departureTimestamp = requestedDepartureTimestamp(request.departureTime);
const durationMinutes = Math.round((distanceNm / speedKn) * 60);
const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString();
const depthSamples = request.depthSamples ?? edgeDepthSamples([...usedEdges.values()]);
const depthAssessment = assessFairwayDepthSamples(depthSamples, request.vesselProfile);
const warnings: RouteWarning[] = [
{
code: "FAIRWAY_ROUTE",
severity: "info",
message: `Route über bekannten Fahrwasser-Graphen: ${routableGraph.name}.`
},
{
code: "FAIRWAY_DATA_NOT_OFFICIAL",
severity: "caution",
message: "Fahrwasser-Graph ist eine MVP-Planungshilfe und keine amtliche Navigationsgrundlage."
},
...depthAssessment.warnings
];
const route: RouteResult = {
geometry: {
type: "LineString",
coordinates: routeCoordinates.map((coordinate) => [coordinate.lon, coordinate.lat])
},
distanceNm,
eta,
departureTime: new Date(departureTimestamp).toISOString(),
durationMinutes,
warnings,
minKnownDepthM: depthAssessment.minKnownDepthM,
unknownDepthRatio: depthAssessment.unknownDepthRatio,
dataSources: [
`fairway-graph:${routableGraph.id}`,
...uniqueSources([...usedEdges.values()]),
request.depthSamples?.length
? "submitted-depth-samples"
: [...usedEdges.values()].some((edge) => edge.minDepthM === null)
? "fairway-depth-unknown"
: "fairway-depth-estimates"
],
routingMode: "fairway"
};
return { route, usedEdges: [...usedEdges.values()] };
}
function requestedDepartureTimestamp(value?: string): number {
const timestamp = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(timestamp) ? timestamp : Date.now();
}
export function mergeFairwayGraphs(id: string, name: string, graphs: FairwayGraph[]): FairwayGraph {
const nodes = new Map<string, FairwayNode>();
const edges = new Map<string, FairwayEdge>();
const maxSnapDistanceNm = Math.max(...graphs.map((graph) => graph.maxSnapDistanceNm));
for (const graph of graphs) {
for (const node of graph.nodes) {
nodes.set(`${graph.id}:${node.id}`, {
...node,
id: `${graph.id}:${node.id}`
});
}
for (const edge of graph.edges) {
edges.set(`${graph.id}:${edge.id}`, {
...edge,
id: `${graph.id}:${edge.id}`,
from: `${graph.id}:${edge.from}`,
to: `${graph.id}:${edge.to}`
});
}
}
return {
id,
name,
maxSnapDistanceNm,
nodes: [...nodes.values()],
edges: [...edges.values()]
};
}
function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): FairwayGraph {
const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM;
return {
...graph,
edges: graph.edges.filter((edge) => {
if (edge.minDepthM !== null && edge.minDepthM < requiredDepthM) {
return false;
}
if (edge.maxDraughtM != null && request.vesselProfile.draughtM > edge.maxDraughtM) {
return false;
}
if (
edge.maxAirDraftM != null &&
request.vesselProfile.airDraftM != null &&
request.vesselProfile.airDraftM > edge.maxAirDraftM
) {
return false;
}
if (edge.maxBeamM != null && request.vesselProfile.beamM != null && request.vesselProfile.beamM > edge.maxBeamM) {
return false;
}
return true;
})
};
}
function buildFairwayLeg(
graph: FairwayGraph,
legStart: Coordinate,
legDestination: Coordinate,
penaltyCounts: ReadonlyMap<string, number>,
adjacency: Adjacency
): FairwayLeg | null {
const startSnap = findNearestEdgeSnap(graph, legStart);
const destinationSnap = findNearestEdgeSnap(graph, legDestination);
if (
!startSnap ||
!destinationSnap ||
startSnap.distanceNm > graph.maxSnapDistanceNm ||
destinationSnap.distanceNm > graph.maxSnapDistanceNm
) {
return null;
}
const candidates: FairwayLeg[] = [];
if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) {
const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap);
const coordinates: Coordinate[] = [];
appendCoordinate(coordinates, legStart);
for (const coordinate of directOnEdge) {
appendCoordinate(coordinates, coordinate);
}
appendCoordinate(coordinates, legDestination);
candidates.push({
coordinates,
usedEdges: [startSnap.edge],
distanceNm: sumRouteDistanceNm(coordinates),
costNm: sumRouteDistanceNm(coordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts)
});
}
for (const startNodeId of [startSnap.edge.from, startSnap.edge.to]) {
if (!canTraverseFromSnapToNode(startSnap, startNodeId)) {
continue;
}
for (const destinationNodeId of [destinationSnap.edge.from, destinationSnap.edge.to]) {
if (!canTraverseFromNodeToSnap(destinationSnap, destinationNodeId)) {
continue;
}
const path = shortestPath(graph, adjacency, startNodeId, destinationNodeId);
if (!path) {
continue;
}
const coordinates: Coordinate[] = [];
const usedEdges = new Map<string, FairwayEdge>();
appendCoordinate(coordinates, legStart);
const startEdgeCoordinates = edgePathFromSnapToNode(startSnap, startNodeId);
for (const coordinate of startEdgeCoordinates) {
appendCoordinate(coordinates, coordinate);
}
usedEdges.set(startSnap.edge.id, startSnap.edge);
for (const step of path) {
usedEdges.set(step.edge.id, step.edge);
for (const coordinate of edgeCoordinates(step)) {
appendCoordinate(coordinates, coordinate);
}
}
const destinationEdgeCoordinates = edgePathFromNodeToSnap(destinationSnap, destinationNodeId);
for (const coordinate of destinationEdgeCoordinates) {
appendCoordinate(coordinates, coordinate);
}
usedEdges.set(destinationSnap.edge.id, destinationSnap.edge);
appendCoordinate(coordinates, legDestination);
const distanceNm = sumRouteDistanceNm(coordinates);
const costNm =
sumRouteDistanceNm(startEdgeCoordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) +
path.reduce((total, step) => total + step.weightNm, 0) +
sumRouteDistanceNm(destinationEdgeCoordinates) * edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts);
candidates.push({
coordinates,
usedEdges: [...usedEdges.values()],
distanceNm,
costNm
});
}
}
return candidates.sort((a, b) => a.costNm - b.costNm || a.distanceNm - b.distanceNm)[0] ?? null;
}
function findNearestEdgeSnap(graph: FairwayGraph, coordinate: Coordinate): EdgeSnap | null {
let nearest: EdgeSnap | null = null;
for (const edge of graph.edges) {
for (let index = 0; index < edge.coordinates.length - 1; index += 1) {
const start = edge.coordinates[index]!;
const end = edge.coordinates[index + 1]!;
const snap = closestPointOnSegment(coordinate, start, end);
const distanceNm = haversineDistanceNm(coordinate, snap.coordinate);
if (!nearest || distanceNm < nearest.distanceNm) {
nearest = {
edge,
coordinate: snap.coordinate,
segmentIndex: index,
t: snap.t,
distanceNm
};
}
}
}
return nearest;
}
function shortestPath(
graph: FairwayGraph,
adjacency: Adjacency,
startId: string,
destinationId: string
): PathStep[] | null {
if (startId === destinationId) {
return [];
}
const distances = new Map<string, number>();
const previous = new Map<string, PathStep>();
const visited = new Set<string>();
const queue = new MinHeap();
for (const node of graph.nodes) {
distances.set(node.id, node.id === startId ? 0 : Number.POSITIVE_INFINITY);
}
queue.push({ nodeId: startId, distanceNm: 0 });
while (queue.size > 0) {
const current = queue.pop();
if (!current || visited.has(current.nodeId)) {
continue;
}
visited.add(current.nodeId);
if (current.nodeId === destinationId) {
break;
}
for (const next of adjacency.get(current.nodeId) ?? []) {
if (visited.has(next.to)) {
continue;
}
const alternative = current.distanceNm + next.weightNm;
if (alternative < (distances.get(next.to) ?? Number.POSITIVE_INFINITY)) {
distances.set(next.to, alternative);
previous.set(next.to, next);
queue.push({ nodeId: next.to, distanceNm: alternative });
}
}
}
if (!previous.has(destinationId)) {
return null;
}
const path: PathStep[] = [];
let cursor = destinationId;
while (cursor !== startId) {
const step = previous.get(cursor);
if (!step) {
return null;
}
path.unshift(step);
cursor = step.from;
}
return path;
}
function buildAdjacency(graph: FairwayGraph, penaltyCounts: ReadonlyMap<string, number>): Adjacency {
const adjacency: Adjacency = new Map();
for (const edge of graph.edges) {
const weightNm = edgeLengthNm(edge) * edgePenaltyMultiplier(edge, penaltyCounts);
const forward = { edge, from: edge.from, to: edge.to, weightNm };
const backward = { edge, from: edge.to, to: edge.from, weightNm };
if (canTraverseForward(edge)) {
adjacency.set(edge.from, [...(adjacency.get(edge.from) ?? []), forward]);
}
if (canTraverseBackward(edge)) {
adjacency.set(edge.to, [...(adjacency.get(edge.to) ?? []), backward]);
}
}
return adjacency;
}
function canTraverseForward(edge: FairwayEdge) {
return edge.oneway !== "backward";
}
function canTraverseBackward(edge: FairwayEdge) {
return edge.oneway !== true && edge.oneway !== "forward";
}
function canTraverseBetweenSnaps(start: EdgeSnap, destination: EdgeSnap) {
const startPosition = start.segmentIndex + start.t;
const destinationPosition = destination.segmentIndex + destination.t;
return startPosition <= destinationPosition
? canTraverseForward(start.edge)
: canTraverseBackward(start.edge);
}
function canTraverseFromSnapToNode(snap: EdgeSnap, nodeId: string) {
return nodeId === snap.edge.from ? canTraverseBackward(snap.edge) : canTraverseForward(snap.edge);
}
function canTraverseFromNodeToSnap(snap: EdgeSnap, nodeId: string) {
return nodeId === snap.edge.from ? canTraverseForward(snap.edge) : canTraverseBackward(snap.edge);
}
function edgePenaltyMultiplier(edge: FairwayEdge, penaltyCounts: ReadonlyMap<string, number>) {
return 1 + (penaltyCounts.get(edge.id) ?? 0) * ALTERNATIVE_EDGE_PENALTY;
}
function isMeaningfullyDifferent(candidate: FairwayRouteCandidate, accepted: FairwayRouteCandidate[]) {
if (accepted.length === 0) {
return true;
}
const candidateGeometry = geometrySignature(candidate.route);
const candidateEdges = edgeDistanceMap(candidate.usedEdges);
const candidateEdgeSignature = [...candidateEdges.keys()].sort().join("|");
return accepted.every((existing) => {
if (geometrySignature(existing.route) === candidateGeometry) {
return false;
}
const existingEdges = edgeDistanceMap(existing.usedEdges);
if ([...existingEdges.keys()].sort().join("|") === candidateEdgeSignature) {
return false;
}
const candidateDistanceNm = sumMapValues(candidateEdges);
const existingDistanceNm = sumMapValues(existingEdges);
let differentDistanceNm = 0;
for (const [edgeId, distanceNm] of candidateEdges) {
if (!existingEdges.has(edgeId)) {
differentDistanceNm += distanceNm;
}
}
for (const [edgeId, distanceNm] of existingEdges) {
if (!candidateEdges.has(edgeId)) {
differentDistanceNm += distanceNm;
}
}
const referenceDistanceNm = Math.min(candidateDistanceNm, existingDistanceNm);
const minimumDifferenceNm = Math.max(
MIN_DIFFERENT_DISTANCE_NM,
referenceDistanceNm * MIN_DIFFERENT_DISTANCE_RATIO
);
return differentDistanceNm >= minimumDifferenceNm;
});
}
function edgeDistanceMap(edges: FairwayEdge[]) {
return new Map(edges.map((edge) => [edge.id, edgeLengthNm(edge)]));
}
function sumMapValues(values: Map<string, number>) {
let total = 0;
for (const value of values.values()) {
total += value;
}
return total;
}
function geometrySignature(route: RouteResult) {
return route.geometry.coordinates
.map(([lon, lat]) => `${lon.toFixed(5)},${lat.toFixed(5)}`)
.join(";");
}
class MinHeap {
private readonly items: Array<{ nodeId: string; distanceNm: number }> = [];
get size() {
return this.items.length;
}
push(item: { nodeId: string; distanceNm: number }) {
this.items.push(item);
this.bubbleUp(this.items.length - 1);
}
pop() {
const first = this.items[0];
const last = this.items.pop();
if (!first || !last) {
return first;
}
if (this.items.length > 0) {
this.items[0] = last;
this.bubbleDown(0);
}
return first;
}
private bubbleUp(index: number) {
let cursor = index;
while (cursor > 0) {
const parent = Math.floor((cursor - 1) / 2);
if (this.items[parent]!.distanceNm <= this.items[cursor]!.distanceNm) {
break;
}
this.swap(parent, cursor);
cursor = parent;
}
}
private bubbleDown(index: number) {
let cursor = index;
while (true) {
const left = cursor * 2 + 1;
const right = left + 1;
let smallest = cursor;
if (left < this.items.length && this.items[left]!.distanceNm < this.items[smallest]!.distanceNm) {
smallest = left;
}
if (right < this.items.length && this.items[right]!.distanceNm < this.items[smallest]!.distanceNm) {
smallest = right;
}
if (smallest === cursor) {
break;
}
this.swap(cursor, smallest);
cursor = smallest;
}
}
private swap(a: number, b: number) {
const temp = this.items[a]!;
this.items[a] = this.items[b]!;
this.items[b] = temp;
}
}
function edgeCoordinates(step: PathStep) {
return step.from === step.edge.from ? step.edge.coordinates : [...step.edge.coordinates].reverse();
}
function edgePathFromSnapToNode(snap: EdgeSnap, nodeId: string): Coordinate[] {
const coordinates = snap.edge.coordinates;
if (nodeId === snap.edge.from) {
return [
snap.coordinate,
...coordinates.slice(0, snap.segmentIndex + 1).reverse()
];
}
return [
snap.coordinate,
...coordinates.slice(snap.segmentIndex + 1)
];
}
function edgePathFromNodeToSnap(snap: EdgeSnap, nodeId: string): Coordinate[] {
return [...edgePathFromSnapToNode(snap, nodeId)].reverse();
}
function edgePathBetweenSnaps(a: EdgeSnap, b: EdgeSnap): Coordinate[] {
if (a.edge.id !== b.edge.id) {
return [];
}
const coordinates = a.edge.coordinates;
const aPosition = a.segmentIndex + a.t;
const bPosition = b.segmentIndex + b.t;
if (aPosition <= bPosition) {
return [
a.coordinate,
...coordinates.slice(a.segmentIndex + 1, b.segmentIndex + 1),
b.coordinate
];
}
return [
a.coordinate,
...coordinates.slice(b.segmentIndex + 1, a.segmentIndex + 1).reverse(),
b.coordinate
];
}
function edgeLengthNm(edge: FairwayEdge) {
return sumRouteDistanceNm(edge.coordinates);
}
function edgeDepthSamples(edges: FairwayEdge[]): DepthSample[] {
return edges.map((edge) => ({
coordinate: edge.coordinates[Math.floor(edge.coordinates.length / 2)]!,
depthM: edge.minDepthM
}));
}
function uniqueSources(edges: FairwayEdge[]) {
const sources = new Set<string>();
for (const edge of edges) {
sources.add(edge.source ?? "openstreetmap-openseamap-derived-seamarks");
}
return [...sources];
}
function assessFairwayDepthSamples(
samples: DepthSample[],
profile: VesselProfile
): {
minKnownDepthM: number | null;
unknownDepthRatio: number;
warnings: RouteWarning[];
} {
const known = samples.filter((sample) => typeof sample.depthM === "number");
const unknownDepthRatio = samples.length > 0 ? round((samples.length - known.length) / samples.length, 2) : 1;
const minKnownDepthM = known.length > 0 ? Math.min(...known.map((sample) => sample.depthM!)) : null;
const requiredDepthM = round(profile.draughtM + profile.safetyReserveM, 2);
const warnings: RouteWarning[] = [];
if (unknownDepthRatio > 0) {
warnings.push({
code: "DEPTH_PARTIAL",
severity: unknownDepthRatio > 0.5 ? "caution" : "info",
message: `${Math.round(unknownDepthRatio * 100)}% der Route haben keine Tiefenprobe.`
});
}
if (minKnownDepthM !== null && minKnownDepthM < requiredDepthM) {
warnings.push({
code: "DEPTH_TOO_SHALLOW",
severity: "critical",
message: `Minimale bekannte Tiefe ${round(minKnownDepthM, 1)} m unterschreitet erforderliche Tiefe ${requiredDepthM} m.`
});
}
if (known.length === 0) {
warnings.push({
code: "NO_KNOWN_DEPTH",
severity: "caution",
message: "Alle geprüften Tiefenpunkte sind unbekannt."
});
}
return { minKnownDepthM, unknownDepthRatio, warnings };
}
function appendCoordinate(points: Coordinate[], coordinate: Coordinate) {
const previous = points.at(-1);
if (previous && haversineDistanceNm(previous, coordinate) < 0.005) {
return;
}
points.push(coordinate);
}
function closestPointOnSegment(point: Coordinate, start: Coordinate, end: Coordinate): { coordinate: Coordinate; t: number } {
const origin = start;
const pointXY = toLocalNm(point, origin);
const startXY = toLocalNm(start, origin);
const endXY = toLocalNm(end, origin);
const edgeX = endXY.x - startXY.x;
const edgeY = endXY.y - startXY.y;
const edgeLengthSquared = edgeX * edgeX + edgeY * edgeY;
const rawT =
edgeLengthSquared === 0
? 0
: ((pointXY.x - startXY.x) * edgeX + (pointXY.y - startXY.y) * edgeY) / edgeLengthSquared;
const t = Math.max(0, Math.min(1, rawT));
return {
t,
coordinate: {
lat: start.lat + (end.lat - start.lat) * t,
lon: start.lon + (end.lon - start.lon) * t
}
};
}
function toLocalNm(coordinate: Coordinate, origin: Coordinate) {
const meanLatRad = ((coordinate.lat + origin.lat) / 2) * (Math.PI / 180);
return {
x: (coordinate.lon - origin.lon) * 60 * Math.cos(meanLatRad),
y: (coordinate.lat - origin.lat) * 60
};
}
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
+50
View File
@@ -0,0 +1,50 @@
import type { Coordinate } from "./types.js";
const EARTH_RADIUS_M = 6371008.8;
const METERS_PER_NAUTICAL_MILE = 1852;
const toRad = (degrees: number) => (degrees * Math.PI) / 180;
const toDeg = (radians: number) => (radians * 180) / Math.PI;
export function haversineDistanceM(a: Coordinate, b: Coordinate): number {
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const deltaLat = toRad(b.lat - a.lat);
const deltaLon = toRad(b.lon - a.lon);
const h =
Math.sin(deltaLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(h));
}
export function haversineDistanceNm(a: Coordinate, b: Coordinate): number {
return haversineDistanceM(a, b) / METERS_PER_NAUTICAL_MILE;
}
export function initialBearingDeg(a: Coordinate, b: Coordinate): number {
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const deltaLon = toRad(b.lon - a.lon);
const y = Math.sin(deltaLon) * Math.cos(lat2);
const x =
Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon);
return normalizeHeadingDeg(toDeg(Math.atan2(y, x)));
}
export function normalizeHeadingDeg(value: number): number {
return ((value % 360) + 360) % 360;
}
export function sumRouteDistanceNm(points: Coordinate[]): number {
return points.slice(1).reduce((total, point, index) => {
return total + haversineDistanceNm(points[index]!, point);
}, 0);
}
export function coordinateToGeoJson(coord: Coordinate): [number, number] {
return [coord.lon, coord.lat];
}
+9
View File
@@ -0,0 +1,9 @@
export * from "./anchor-watch.js";
export * from "./fairway-routing.js";
export * from "./geo.js";
export * from "./inland-seed.js";
export * from "./marine-poi-clustering.js";
export * from "./route.js";
export * from "./route-guidance.js";
export * from "./types.js";
export * from "./voyage-planning.js";
+519
View File
@@ -0,0 +1,519 @@
import type { FairwayGraph } from "./fairway-routing.js";
/**
* Offline fallback for the federal-waterway corridor EmdenHamm.
*
* Geometry is simplified from OSM/Geofabrik data (ODbL) and is deliberately
* marked as non-official by the route builder. Runtime PostGIS/OSM graphs take
* precedence whenever available.
*/
export const EMDEN_HAMM_GRAPH: FairwayGraph = {
id: "emden-hamm-inland-seed",
name: "Ems Dortmund-Ems-Kanal Datteln-Hamm-Kanal",
maxSnapDistanceNm: 0.6,
nodes: [
{ id: "emden-aussenhafen", coordinate: { lat: 53.344167, lon: 7.186111 } },
{ id: "dek-north-join", coordinate: { lat: 52.6170897, lon: 7.3077526 } },
{ id: "datteln-junction", coordinate: { lat: 51.6480728, lon: 7.3527065 } },
{ id: "dhk-east-end", coordinate: { lat: 51.677629, lon: 7.9640468 } }
],
edges: [
{
id: "emden-to-dek-north",
name: "Ems / Dortmund-Ems-Kanal Nord",
from: "emden-aussenhafen",
to: "dek-north-join",
coordinates: [
{ lat: 53.344167, lon: 7.186111 },
{ lat: 53.34225, lon: 7.1862148 },
{ lat: 53.3341511, lon: 7.1782959 },
{ lat: 53.3332031, lon: 7.1809176 },
{ lat: 53.3324508, lon: 7.1874995 },
{ lat: 53.3306213, lon: 7.2159901 },
{ lat: 53.3303026, lon: 7.2260241 },
{ lat: 53.3294496, lon: 7.2352787 },
{ lat: 53.3273697, lon: 7.2471977 },
{ lat: 53.3243834, lon: 7.2602837 },
{ lat: 53.3236016, lon: 7.26644 },
{ lat: 53.3230913, lon: 7.2857283 },
{ lat: 53.3225859, lon: 7.2908371 },
{ lat: 53.32151, lon: 7.2972328 },
{ lat: 53.3211925, lon: 7.3026625 },
{ lat: 53.3220702, lon: 7.3279766 },
{ lat: 53.3216156, lon: 7.3328678 },
{ lat: 53.3201507, lon: 7.3367287 },
{ lat: 53.3173316, lon: 7.3405162 },
{ lat: 53.3150449, lon: 7.3421989 },
{ lat: 53.3089384, lon: 7.3440134 },
{ lat: 53.3054877, lon: 7.3457982 },
{ lat: 53.3020775, lon: 7.3489031 },
{ lat: 53.2987108, lon: 7.3542257 },
{ lat: 53.2971689, lon: 7.3590226 },
{ lat: 53.2966973, lon: 7.3631456 },
{ lat: 53.2975248, lon: 7.376437 },
{ lat: 53.2973569, lon: 7.3819934 },
{ lat: 53.2961272, lon: 7.3880646 },
{ lat: 53.2947213, lon: 7.3915472 },
{ lat: 53.2920455, lon: 7.3951429 },
{ lat: 53.2904292, lon: 7.3963532 },
{ lat: 53.2877751, lon: 7.3972301 },
{ lat: 53.2791601, lon: 7.3960676 },
{ lat: 53.2668372, lon: 7.3966812 },
{ lat: 53.2574686, lon: 7.3947391 },
{ lat: 53.2413789, lon: 7.3951356 },
{ lat: 53.2392112, lon: 7.3957146 },
{ lat: 53.2374613, lon: 7.3968799 },
{ lat: 53.2358978, lon: 7.3985932 },
{ lat: 53.2338495, lon: 7.4025153 },
{ lat: 53.2328857, lon: 7.4061076 },
{ lat: 53.2310752, lon: 7.4070965 },
{ lat: 53.2291372, lon: 7.4099058 },
{ lat: 53.2279556, lon: 7.4126611 },
{ lat: 53.2264127, lon: 7.4178089 },
{ lat: 53.2250109, lon: 7.4261561 },
{ lat: 53.2244216, lon: 7.4277024 },
{ lat: 53.223205, lon: 7.4284269 },
{ lat: 53.2122392, lon: 7.4235801 },
{ lat: 53.2111046, lon: 7.4227087 },
{ lat: 53.2099665, lon: 7.4211379 },
{ lat: 53.2070632, lon: 7.4151981 },
{ lat: 53.2049536, lon: 7.4129963 },
{ lat: 53.1959055, lon: 7.4079416 },
{ lat: 53.1947025, lon: 7.4077008 },
{ lat: 53.1912399, lon: 7.4082297 },
{ lat: 53.1886415, lon: 7.406992 },
{ lat: 53.1876291, lon: 7.4058565 },
{ lat: 53.1860987, lon: 7.4027658 },
{ lat: 53.1846572, lon: 7.395211 },
{ lat: 53.1834605, lon: 7.3924313 },
{ lat: 53.1775992, lon: 7.3854977 },
{ lat: 53.1758618, lon: 7.3818268 },
{ lat: 53.1741794, lon: 7.3755437 },
{ lat: 53.1734931, lon: 7.3739448 },
{ lat: 53.1719081, lon: 7.3717593 },
{ lat: 53.1705394, lon: 7.3707727 },
{ lat: 53.1684706, lon: 7.3703369 },
{ lat: 53.1660789, lon: 7.3706305 },
{ lat: 53.1552752, lon: 7.3748426 },
{ lat: 53.152474, lon: 7.3749416 },
{ lat: 53.14331, lon: 7.3731018 },
{ lat: 53.1416425, lon: 7.3723723 },
{ lat: 53.1363304, lon: 7.3666555 },
{ lat: 53.1351363, lon: 7.3657569 },
{ lat: 53.133606, lon: 7.3652889 },
{ lat: 53.1318555, lon: 7.3655406 },
{ lat: 53.1307212, lon: 7.3662005 },
{ lat: 53.1296869, lon: 7.3672274 },
{ lat: 53.1269355, lon: 7.3720176 },
{ lat: 53.1259808, lon: 7.3730968 },
{ lat: 53.1232224, lon: 7.3745745 },
{ lat: 53.1195708, lon: 7.374962 },
{ lat: 53.1148728, lon: 7.3732273 },
{ lat: 53.1134827, lon: 7.3722981 },
{ lat: 53.1091607, lon: 7.366126 },
{ lat: 53.1064519, lon: 7.3633044 },
{ lat: 53.1055528, lon: 7.3618615 },
{ lat: 53.1042265, lon: 7.3576364 },
{ lat: 53.1012596, lon: 7.3436571 },
{ lat: 53.1008737, lon: 7.3400178 },
{ lat: 53.1011548, lon: 7.3364844 },
{ lat: 53.101578, lon: 7.3346367 },
{ lat: 53.1034246, lon: 7.3295923 },
{ lat: 53.1043793, lon: 7.3257868 },
{ lat: 53.1046627, lon: 7.3226679 },
{ lat: 53.1042787, lon: 7.3187284 },
{ lat: 53.1008327, lon: 7.3037106 },
{ lat: 53.0975749, lon: 7.2969348 },
{ lat: 53.0957705, lon: 7.2906035 },
{ lat: 53.0938775, lon: 7.2882137 },
{ lat: 53.0924338, lon: 7.2876847 },
{ lat: 53.0876718, lon: 7.288399 },
{ lat: 53.0837036, lon: 7.2907443 },
{ lat: 53.0825116, lon: 7.2907992 },
{ lat: 53.077845, lon: 7.2877014 },
{ lat: 53.0717543, lon: 7.2871864 },
{ lat: 53.0622735, lon: 7.282547 },
{ lat: 53.0609798, lon: 7.2822322 },
{ lat: 53.0597046, lon: 7.2825412 },
{ lat: 53.0581857, lon: 7.2838079 },
{ lat: 53.0571812, lon: 7.2852374 },
{ lat: 53.0558591, lon: 7.2885582 },
{ lat: 53.0548627, lon: 7.2952707 },
{ lat: 53.0542292, lon: 7.3026114 },
{ lat: 53.0538951, lon: 7.3044599 },
{ lat: 53.0532504, lon: 7.306103 },
{ lat: 53.0512902, lon: 7.3086556 },
{ lat: 53.0472693, lon: 7.3123268 },
{ lat: 53.044445, lon: 7.3168801 },
{ lat: 53.0408708, lon: 7.3184122 },
{ lat: 53.0330587, lon: 7.3174757 },
{ lat: 53.028331, lon: 7.3184288 },
{ lat: 53.0196151, lon: 7.3222115 },
{ lat: 53.0171329, lon: 7.3226863 },
{ lat: 53.0112402, lon: 7.3266074 },
{ lat: 53.0097598, lon: 7.3269998 },
{ lat: 53.009054, lon: 7.326994 },
{ lat: 53.0072925, lon: 7.3255714 },
{ lat: 53.0008004, lon: 7.315204 },
{ lat: 52.9990484, lon: 7.3137708 },
{ lat: 52.9976715, lon: 7.3133473 },
{ lat: 52.9962178, lon: 7.3136367 },
{ lat: 52.9900883, lon: 7.3200214 },
{ lat: 52.988767, lon: 7.3202966 },
{ lat: 52.9876619, lon: 7.3196947 },
{ lat: 52.9866753, lon: 7.3185628 },
{ lat: 52.9856097, lon: 7.3161836 },
{ lat: 52.9803889, lon: 7.3028046 },
{ lat: 52.9784958, lon: 7.3002944 },
{ lat: 52.9765317, lon: 7.2987197 },
{ lat: 52.9732669, lon: 7.2980953 },
{ lat: 52.9684295, lon: 7.3014892 },
{ lat: 52.9668536, lon: 7.3018108 },
{ lat: 52.962217, lon: 7.3008545 },
{ lat: 52.9593081, lon: 7.2996114 },
{ lat: 52.9556311, lon: 7.294138 },
{ lat: 52.9504094, lon: 7.29237 },
{ lat: 52.9490605, lon: 7.2910053 },
{ lat: 52.9473437, lon: 7.2903394 },
{ lat: 52.9446063, lon: 7.2906705 },
{ lat: 52.9420452, lon: 7.2929035 },
{ lat: 52.9405683, lon: 7.2932092 },
{ lat: 52.9385473, lon: 7.2928105 },
{ lat: 52.9348471, lon: 7.2902304 },
{ lat: 52.9337028, lon: 7.2898596 },
{ lat: 52.9326567, lon: 7.2900878 },
{ lat: 52.930346, lon: 7.2921544 },
{ lat: 52.9289926, lon: 7.2942282 },
{ lat: 52.92843, lon: 7.2957248 },
{ lat: 52.9252737, lon: 7.300371 },
{ lat: 52.9246084, lon: 7.3012742 },
{ lat: 52.9233764, lon: 7.3020324 },
{ lat: 52.9173078, lon: 7.3000802 },
{ lat: 52.9149829, lon: 7.3006244 },
{ lat: 52.9139106, lon: 7.3025326 },
{ lat: 52.9136065, lon: 7.3037181 },
{ lat: 52.9127049, lon: 7.3104638 },
{ lat: 52.9122736, lon: 7.3114695 },
{ lat: 52.9109781, lon: 7.31291 },
{ lat: 52.9096876, lon: 7.3130752 },
{ lat: 52.9047544, lon: 7.3112819 },
{ lat: 52.8749296, lon: 7.3153814 },
{ lat: 52.8716001, lon: 7.3136171 },
{ lat: 52.8654966, lon: 7.3093634 },
{ lat: 52.8616456, lon: 7.3015022 },
{ lat: 52.8600068, lon: 7.2997109 },
{ lat: 52.8577335, lon: 7.2999847 },
{ lat: 52.8536487, lon: 7.3055986 },
{ lat: 52.8526372, lon: 7.3062486 },
{ lat: 52.8518399, lon: 7.3064947 },
{ lat: 52.8501275, lon: 7.3054409 },
{ lat: 52.8491213, lon: 7.3033941 },
{ lat: 52.847493, lon: 7.2974716 },
{ lat: 52.8463075, lon: 7.2945737 },
{ lat: 52.8387587, lon: 7.2840013 },
{ lat: 52.8329233, lon: 7.2771552 },
{ lat: 52.8210548, lon: 7.2666132 },
{ lat: 52.8204112, lon: 7.2651054 },
{ lat: 52.8197839, lon: 7.2578788 },
{ lat: 52.8189444, lon: 7.2524551 },
{ lat: 52.8146971, lon: 7.2405902 },
{ lat: 52.8139693, lon: 7.2394003 },
{ lat: 52.8124062, lon: 7.2388741 },
{ lat: 52.8100834, lon: 7.2398424 },
{ lat: 52.8092931, lon: 7.2420158 },
{ lat: 52.8079952, lon: 7.2517842 },
{ lat: 52.806823, lon: 7.2571245 },
{ lat: 52.8052042, lon: 7.2593015 },
{ lat: 52.7993243, lon: 7.2617948 },
{ lat: 52.7982886, lon: 7.2618102 },
{ lat: 52.7961797, lon: 7.2600272 },
{ lat: 52.7949183, lon: 7.2571117 },
{ lat: 52.7943911, lon: 7.2530022 },
{ lat: 52.7938055, lon: 7.2508142 },
{ lat: 52.7921792, lon: 7.2484536 },
{ lat: 52.7900794, lon: 7.247821 },
{ lat: 52.7872742, lon: 7.2487388 },
{ lat: 52.7837534, lon: 7.2519385 },
{ lat: 52.7829039, lon: 7.2532302 },
{ lat: 52.7813809, lon: 7.2575239 },
{ lat: 52.7803266, lon: 7.2590776 },
{ lat: 52.7788761, lon: 7.2600899 },
{ lat: 52.7769806, lon: 7.2601199 },
{ lat: 52.7736866, lon: 7.2583233 },
{ lat: 52.7687312, lon: 7.2546715 },
{ lat: 52.766415, lon: 7.2540051 },
{ lat: 52.7646811, lon: 7.2545577 },
{ lat: 52.7588356, lon: 7.2577194 },
{ lat: 52.757463, lon: 7.2576782 },
{ lat: 52.7523783, lon: 7.2597321 },
{ lat: 52.7331875, lon: 7.2605553 },
{ lat: 52.7311473, lon: 7.2611357 },
{ lat: 52.7261584, lon: 7.2652089 },
{ lat: 52.7201626, lon: 7.2669888 },
{ lat: 52.7189056, lon: 7.2678535 },
{ lat: 52.7169618, lon: 7.2697348 },
{ lat: 52.7151119, lon: 7.2729505 },
{ lat: 52.7131509, lon: 7.2772873 },
{ lat: 52.7106743, lon: 7.2804694 },
{ lat: 52.7080642, lon: 7.2819065 },
{ lat: 52.7030123, lon: 7.2826237 },
{ lat: 52.7018705, lon: 7.2832918 },
{ lat: 52.7009669, lon: 7.2848017 },
{ lat: 52.699707, lon: 7.2890056 },
{ lat: 52.6989115, lon: 7.290014 },
{ lat: 52.6950775, lon: 7.2914452 },
{ lat: 52.6933822, lon: 7.2940772 },
{ lat: 52.6916135, lon: 7.295875 },
{ lat: 52.687663, lon: 7.2983659 },
{ lat: 52.6792435, lon: 7.3050178 },
{ lat: 52.6773994, lon: 7.3054899 },
{ lat: 52.6742508, lon: 7.3054336 },
{ lat: 52.6701505, lon: 7.3041417 },
{ lat: 52.6686201, lon: 7.3041567 },
{ lat: 52.6260159, lon: 7.3075966 },
{ lat: 52.622597, lon: 7.3071821 },
{ lat: 52.6170897, lon: 7.3077526 }
],
minDepthM: null,
maxDraughtM: 2.5,
source: "openstreetmap-geofabrik-curated-seed"
},
{
id: "dek-north-to-datteln",
name: "Dortmund-Ems-Kanal",
from: "dek-north-join",
to: "datteln-junction",
coordinates: [
{ lat: 52.6170897, lon: 7.3077526 },
{ lat: 52.622597, lon: 7.3071821 },
{ lat: 52.6260159, lon: 7.3075966 },
{ lat: 52.6204075, lon: 7.3080555 },
{ lat: 52.613473, lon: 7.3077375 },
{ lat: 52.596938, lon: 7.30907 },
{ lat: 52.5917039, lon: 7.3101252 },
{ lat: 52.5761936, lon: 7.3110592 },
{ lat: 52.571197, lon: 7.3099594 },
{ lat: 52.5690857, lon: 7.3090182 },
{ lat: 52.5645927, lon: 7.3018972 },
{ lat: 52.5601132, lon: 7.3000056 },
{ lat: 52.5548089, lon: 7.2946979 },
{ lat: 52.5498479, lon: 7.2936081 },
{ lat: 52.5429771, lon: 7.2941741 },
{ lat: 52.5412101, lon: 7.2946669 },
{ lat: 52.5386393, lon: 7.2964251 },
{ lat: 52.5278267, lon: 7.3072389 },
{ lat: 52.5182824, lon: 7.3092221 },
{ lat: 52.5110957, lon: 7.3131295 },
{ lat: 52.5098635, lon: 7.3131774 },
{ lat: 52.5088558, lon: 7.3126211 },
{ lat: 52.4958801, lon: 7.2967592 },
{ lat: 52.4933351, lon: 7.2952178 },
{ lat: 52.4907007, lon: 7.2953235 },
{ lat: 52.4820878, lon: 7.2975464 },
{ lat: 52.4733254, lon: 7.3035408 },
{ lat: 52.4690615, lon: 7.3091052 },
{ lat: 52.4685234, lon: 7.3115758 },
{ lat: 52.4677799, lon: 7.3133856 },
{ lat: 52.4645089, lon: 7.3185353 },
{ lat: 52.4631525, lon: 7.3222297 },
{ lat: 52.4621418, lon: 7.3235071 },
{ lat: 52.4587403, lon: 7.3252417 },
{ lat: 52.4577011, lon: 7.3265698 },
{ lat: 52.456919, lon: 7.3280797 },
{ lat: 52.454539, lon: 7.3378833 },
{ lat: 52.4535298, lon: 7.3406716 },
{ lat: 52.4525752, lon: 7.3426166 },
{ lat: 52.4487817, lon: 7.3483184 },
{ lat: 52.4462443, lon: 7.3501691 },
{ lat: 52.439657, lon: 7.3519318 },
{ lat: 52.433434, lon: 7.3546383 },
{ lat: 52.4299175, lon: 7.3553317 },
{ lat: 52.428536, lon: 7.355015 },
{ lat: 52.4264713, lon: 7.3553539 },
{ lat: 52.4210493, lon: 7.356785 },
{ lat: 52.420241, lon: 7.3573823 },
{ lat: 52.419098, lon: 7.358743 },
{ lat: 52.4106445, lon: 7.3736114 },
{ lat: 52.409193, lon: 7.3751639 },
{ lat: 52.4023789, lon: 7.3786667 },
{ lat: 52.4010078, lon: 7.3797733 },
{ lat: 52.3769057, lon: 7.4034264 },
{ lat: 52.3731594, lon: 7.4081666 },
{ lat: 52.3695691, lon: 7.4114168 },
{ lat: 52.3680415, lon: 7.4118584 },
{ lat: 52.3130642, lon: 7.4655886 },
{ lat: 52.3095855, lon: 7.4691484 },
{ lat: 52.3074303, lon: 7.4722069 },
{ lat: 52.3026411, lon: 7.4803427 },
{ lat: 52.301734, lon: 7.4825878 },
{ lat: 52.2850169, lon: 7.533115 },
{ lat: 52.2833922, lon: 7.5387049 },
{ lat: 52.2812836, lon: 7.5510927 },
{ lat: 52.2807456, lon: 7.5692502 },
{ lat: 52.279505, lon: 7.5792087 },
{ lat: 52.2780793, lon: 7.5842601 },
{ lat: 52.2768453, lon: 7.6047386 },
{ lat: 52.2727862, lon: 7.6155463 },
{ lat: 52.2718506, lon: 7.6171632 },
{ lat: 52.2662425, lon: 7.6237637 },
{ lat: 52.262574, lon: 7.6294752 },
{ lat: 52.2611552, lon: 7.6308468 },
{ lat: 52.2572612, lon: 7.6331914 },
{ lat: 52.2561199, lon: 7.634399 },
{ lat: 52.2541929, lon: 7.6376785 },
{ lat: 52.2534004, lon: 7.6394391 },
{ lat: 52.2514056, lon: 7.6461233 },
{ lat: 52.2490882, lon: 7.6582657 },
{ lat: 52.2481518, lon: 7.6609379 },
{ lat: 52.2438497, lon: 7.6695484 },
{ lat: 52.2419657, lon: 7.6713734 },
{ lat: 52.2395845, lon: 7.6726573 },
{ lat: 52.2192806, lon: 7.6781016 },
{ lat: 52.2164458, lon: 7.6792076 },
{ lat: 52.1539038, lon: 7.71243 },
{ lat: 52.1501555, lon: 7.7133827 },
{ lat: 52.1448429, lon: 7.7126776 },
{ lat: 52.1311007, lon: 7.7083238 },
{ lat: 52.1109804, lon: 7.7102029 },
{ lat: 52.1088028, lon: 7.7098465 },
{ lat: 52.1075503, lon: 7.7093683 },
{ lat: 52.0957426, lon: 7.701032 },
{ lat: 52.0880333, lon: 7.6962936 },
{ lat: 52.0499382, lon: 7.6875267 },
{ lat: 52.0464452, lon: 7.6845359 },
{ lat: 52.0439699, lon: 7.6815343 },
{ lat: 52.0387012, lon: 7.6772157 },
{ lat: 52.0376261, lon: 7.6756681 },
{ lat: 52.0363289, lon: 7.6720249 },
{ lat: 52.0340064, lon: 7.6698384 },
{ lat: 52.0272245, lon: 7.6669331 },
{ lat: 52.0048548, lon: 7.659266 },
{ lat: 51.9917005, lon: 7.6596659 },
{ lat: 51.9843599, lon: 7.6608622 },
{ lat: 51.9835995, lon: 7.6606562 },
{ lat: 51.9738328, lon: 7.6631033 },
{ lat: 51.9637058, lon: 7.664755 },
{ lat: 51.9612148, lon: 7.6644501 },
{ lat: 51.9595842, lon: 7.6631927 },
{ lat: 51.9515516, lon: 7.6508656 },
{ lat: 51.9464546, lon: 7.6439993 },
{ lat: 51.942015, lon: 7.6394811 },
{ lat: 51.9394085, lon: 7.638235 },
{ lat: 51.9359995, lon: 7.6380863 },
{ lat: 51.9335976, lon: 7.6390531 },
{ lat: 51.925279, lon: 7.6443876 },
{ lat: 51.9111423, lon: 7.6519349 },
{ lat: 51.9030282, lon: 7.6587678 },
{ lat: 51.8994762, lon: 7.6611153 },
{ lat: 51.896987, lon: 7.6618277 },
{ lat: 51.8933577, lon: 7.6604697 },
{ lat: 51.891044, lon: 7.657519 },
{ lat: 51.889833, lon: 7.654639 },
{ lat: 51.8891476, lon: 7.6516139 },
{ lat: 51.8890417, lon: 7.6472537 },
{ lat: 51.8907633, lon: 7.6316496 },
{ lat: 51.8906442, lon: 7.6211705 },
{ lat: 51.8881303, lon: 7.6065218 },
{ lat: 51.8880268, lon: 7.598351 },
{ lat: 51.8872554, lon: 7.5943696 },
{ lat: 51.8765433, lon: 7.5699591 },
{ lat: 51.8731254, lon: 7.5646547 },
{ lat: 51.8719225, lon: 7.5620455 },
{ lat: 51.8680616, lon: 7.5493253 },
{ lat: 51.860834, lon: 7.5198031 },
{ lat: 51.8598852, lon: 7.5174578 },
{ lat: 51.8558222, lon: 7.5099023 },
{ lat: 51.8543981, lon: 7.5057437 },
{ lat: 51.8535249, lon: 7.5013355 },
{ lat: 51.85167, lon: 7.4878178 },
{ lat: 51.8487703, lon: 7.4713066 },
{ lat: 51.8463552, lon: 7.4620356 },
{ lat: 51.8407769, lon: 7.4453372 },
{ lat: 51.8376589, lon: 7.4388247 },
{ lat: 51.8336571, lon: 7.4327261 },
{ lat: 51.8293776, lon: 7.4278585 },
{ lat: 51.8029486, lon: 7.404659 },
{ lat: 51.7990015, lon: 7.4026374 },
{ lat: 51.7957234, lon: 7.4020127 },
{ lat: 51.7921941, lon: 7.4023695 },
{ lat: 51.7882627, lon: 7.404189 },
{ lat: 51.7785241, lon: 7.4116043 },
{ lat: 51.7765078, lon: 7.4127349 },
{ lat: 51.7670692, lon: 7.4199327 },
{ lat: 51.7585766, lon: 7.4258059 },
{ lat: 51.7349039, lon: 7.4314497 },
{ lat: 51.7322353, lon: 7.4313007 },
{ lat: 51.7289467, lon: 7.4294891 },
{ lat: 51.7220865, lon: 7.4228984 },
{ lat: 51.7173422, lon: 7.4191574 },
{ lat: 51.6884043, lon: 7.4006307 },
{ lat: 51.6857197, lon: 7.3986478 },
{ lat: 51.6800066, lon: 7.3933699 },
{ lat: 51.6744888, lon: 7.3862888 },
{ lat: 51.6625512, lon: 7.3670828 },
{ lat: 51.6572451, lon: 7.362679 },
{ lat: 51.6480728, lon: 7.3527065 }
],
minDepthM: null,
maxDraughtM: 2.5,
source: "openstreetmap-nominatim-curated-seed"
},
{
id: "datteln-to-hamm",
name: "Datteln-Hamm-Kanal",
from: "datteln-junction",
to: "dhk-east-end",
coordinates: [
{ lat: 51.6480728, lon: 7.3527065 },
{ lat: 51.6450616, lon: 7.3599496 },
{ lat: 51.6424301, lon: 7.3690321 },
{ lat: 51.6384637, lon: 7.3870174 },
{ lat: 51.6344477, lon: 7.4025135 },
{ lat: 51.6308286, lon: 7.4198778 },
{ lat: 51.6261392, lon: 7.4329217 },
{ lat: 51.6222866, lon: 7.4462855 },
{ lat: 51.6199153, lon: 7.4503882 },
{ lat: 51.61187, lon: 7.458521 },
{ lat: 51.6105424, lon: 7.4607643 },
{ lat: 51.6090674, lon: 7.4647273 },
{ lat: 51.5966986, lon: 7.5124187 },
{ lat: 51.5961991, lon: 7.5160706 },
{ lat: 51.5961843, lon: 7.5205954 },
{ lat: 51.5973833, lon: 7.5367971 },
{ lat: 51.5985162, lon: 7.540956 },
{ lat: 51.5999678, lon: 7.5435283 },
{ lat: 51.6119472, lon: 7.5564068 },
{ lat: 51.6144902, lon: 7.5609062 },
{ lat: 51.633076, lon: 7.6145695 },
{ lat: 51.6453009, lon: 7.6482751 },
{ lat: 51.6515779, lon: 7.6571256 },
{ lat: 51.6538317, lon: 7.6613153 },
{ lat: 51.6554491, lon: 7.6655052 },
{ lat: 51.6598792, lon: 7.684081 },
{ lat: 51.6650755, lon: 7.7105466 },
{ lat: 51.670276, lon: 7.7254819 },
{ lat: 51.6749153, lon: 7.7497776 },
{ lat: 51.678958, lon: 7.772197 },
{ lat: 51.6803638, lon: 7.7928029 },
{ lat: 51.6810794, lon: 7.7969359 },
{ lat: 51.6821554, lon: 7.8096086 },
{ lat: 51.6845084, lon: 7.8155156 },
{ lat: 51.6882864, lon: 7.8265171 },
{ lat: 51.6911599, lon: 7.8321621 },
{ lat: 51.6932041, lon: 7.8405691 },
{ lat: 51.6953033, lon: 7.8615516 },
{ lat: 51.6941647, lon: 7.8863978 },
{ lat: 51.6902757, lon: 7.9123249 },
{ lat: 51.6882728, lon: 7.9319547 },
{ lat: 51.6878752, lon: 7.9338727 },
{ lat: 51.6818587, lon: 7.9521688 },
{ lat: 51.6794074, lon: 7.9580757 },
{ lat: 51.6792115, lon: 7.9600807 },
{ lat: 51.677629, lon: 7.9640468 }
],
minDepthM: null,
maxDraughtM: 2.5,
source: "openstreetmap-nominatim-curated-seed"
}
]
};
@@ -0,0 +1,678 @@
export type MarinePoiLayer = "locks" | "harbours";
export type MarinePoiRole = "anchor" | "component";
export type MarinePoiCandidate = {
id: string;
layer: MarinePoiLayer;
source: string;
sourceId?: string | null;
name?: string | null;
coordinate: { lon: number; lat: number };
properties: Record<string, unknown>;
role?: MarinePoiRole;
attachToId?: string | null;
topologyPeerIds?: string[];
};
export type CanonicalMarinePoi = {
entityId: string;
layer: MarinePoiLayer;
canonicalSource: string;
canonicalSourceId: string | null;
name: string | null;
coordinate: { lon: number; lat: number };
properties: Record<string, unknown>;
memberIds: string[];
memberCount: number;
};
type PreparedCandidate = MarinePoiCandidate & {
role: MarinePoiRole;
facilityName: string | null;
nameKey: string | null;
rawNameKey: string | null;
officialIds: Map<string, string>;
stableKey: string;
};
const FACILITY_TYPE_WORDS = new Set([
"hafen",
"harbour",
"haven",
"jachclub",
"jachthaven",
"jachtclub",
"club",
"lock",
"lockcomplex",
"marina",
"motorbootclub",
"port",
"schleuse",
"schleusenanlage",
"schleusengebiet",
"sluis",
"sluice",
"sportboothafen",
"vereniging",
"watersportvereniging",
"wsv",
"wsvds",
"yachtclub",
"yachthafen"
]);
const COMPONENT_NAME_PATTERN = /^(?:(?:binnen|aussen|außen|ober|unter|north|south|nord|sued|süd|ost|west)[ -]?)?(?:tor|gate|deur|schleusentor|lock ?gate|kammer|kolk|box|liegeplatz|berth)(?:[ -]?(?:\d+|[ivx]+))?$/iu;
const OFFICIAL_ID_KEYS = ["ref:EU:RIS", "isrs", "wikidata"] as const;
const CONTACT_KEYS = [
"phone",
"website",
"email",
"vhf",
"openingHours",
"opening_hours",
"operator",
"address"
] as const;
const NAMED_RADIUS_M: Record<MarinePoiLayer, number> = { locks: 450, harbours: 120 };
const GENERIC_RADIUS_M: Record<MarinePoiLayer, number> = { locks: 35, harbours: 25 };
const COMPONENT_ATTACH_RADIUS_M: Record<MarinePoiLayer, number> = { locks: 140, harbours: 90 };
const COMPONENT_GROUP_RADIUS_M: Record<MarinePoiLayer, number> = { locks: 80, harbours: 50 };
const MAX_CLUSTER_DIAMETER_M: Record<MarinePoiLayer, number> = { locks: 800, harbours: 350 };
/**
* Resolves raw OSM/EuRIS objects into one display POI per physical facility.
* Components are assigned to one nearest anchor and can therefore never join
* two neighbouring locks or harbours through a single-linkage chain.
*/
export function canonicalizeMarinePois(candidates: MarinePoiCandidate[]): CanonicalMarinePoi[] {
const prepared = candidates
.filter(isValidCandidate)
.map(prepareCandidate)
.sort((left, right) => left.stableKey.localeCompare(right.stableKey));
const byLayer = new Map<MarinePoiLayer, PreparedCandidate[]>([
["locks", []],
["harbours", []]
]);
for (const candidate of prepared) {
byLayer.get(candidate.layer)!.push(candidate);
}
return (["locks", "harbours"] as const)
.flatMap((layer) => canonicalizeLayer(byLayer.get(layer)!))
.sort(
(left, right) =>
left.layer.localeCompare(right.layer) ||
(left.name ?? "").localeCompare(right.name ?? "") ||
left.entityId.localeCompare(right.entityId)
);
}
/** Normalizes only identity-bearing parts of a facility name. */
export function normalizedMarineFacilityName(value: unknown): string | null {
const name = stringValue(value);
if (!name) {
return null;
}
const normalized = name
.normalize("NFKD")
.replace(/[\u0300-\u036f]/gu, "")
.replace(/ß/gu, "ss")
.replace(/&/gu, " und ")
.toLocaleLowerCase("de-DE")
.replace(/\bw\W*s\W*v(?:\W*d\W*s)?\b/gu, (value) => value.replace(/\W/gu, ""))
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
if (!normalized || COMPONENT_NAME_PATTERN.test(normalized)) {
return null;
}
const tokens = normalized.split(/\s+/u);
const hasChamberQualifier = tokens.some(
(token) => token === "kammer" || token === "kolk" || /(?:kammer|kolk)$/u.test(token)
);
const withoutTypes = tokens.filter((token, index) => {
if (FACILITY_TYPE_WORDS.has(token)) return false;
if (/^(?:nord|sud|sued|ost|west|gross|grosse|große|klein|kleine)?(?:kammer|kolk)$/u.test(token)) return false;
if (hasChamberQualifier && ["gross", "grosse", "große", "klein", "kleine"].includes(token)) return false;
const previous = tokens[index - 1];
if ((previous === "kammer" || previous === "kolk") && /^(?:\d+|[ivx]+)$/u.test(token)) return false;
return true;
});
const key = (withoutTypes.length > 0 ? withoutTypes : tokens).join(" ");
return FACILITY_TYPE_WORDS.has(key) ? null : key || null;
}
function canonicalizeLayer(candidates: PreparedCandidate[]): CanonicalMarinePoi[] {
const anchors = candidates.filter((candidate) => candidate.role === "anchor");
const components = candidates.filter((candidate) => candidate.role === "component");
const anchorGroups = mergeAnchors(anchors);
const groups = new Map<string, PreparedCandidate[]>();
const anchorRootById = new Map<string, string>();
for (const group of anchorGroups) {
const root = group[0]!.stableKey;
groups.set(root, [...group]);
for (const anchor of group) {
anchorRootById.set(anchor.id, root);
}
}
const unattached: PreparedCandidate[] = [];
for (const component of components) {
const explicitRoot = component.attachToId ? anchorRootById.get(component.attachToId) : undefined;
const root = explicitRoot ?? nearestCompatibleAnchorRoot(component, anchorGroups);
if (root) {
groups.get(root)!.push(component);
} else {
unattached.push(component);
}
}
for (const group of mergeStandaloneComponents(unattached)) {
groups.set(group[0]!.stableKey, group);
}
return [...groups.values()].map(buildCanonicalPoi);
}
function mergeAnchors(anchors: PreparedCandidate[]): PreparedCandidate[][] {
if (anchors.length === 0) {
return [];
}
const dsu = new DisjointSet(anchors.length);
// Strong identifiers may legitimately match across a larger lock complex.
const officialGroups = new Map<string, number[]>();
anchors.forEach((candidate, index) => {
for (const [kind, value] of candidate.officialIds) {
const key = `${kind}:${value}`;
officialGroups.set(key, [...(officialGroups.get(key) ?? []), index]);
}
});
for (const indices of officialGroups.values()) {
const first = indices[0];
if (first === undefined) continue;
for (const index of indices.slice(1)) {
tryMerge(dsu, anchors, first, index, true);
}
}
// Explicit topology relations are produced by an optional PostGIS rebuild.
const indexById = new Map(anchors.map((candidate, index) => [candidate.id, index]));
anchors.forEach((candidate, index) => {
for (const peerId of candidate.topologyPeerIds ?? []) {
const peerIndex = indexById.get(peerId);
if (peerIndex !== undefined) {
tryMerge(dsu, anchors, index, peerIndex, true);
}
}
});
for (const [left, right] of nearbyPairs(anchors, NAMED_RADIUS_M[anchors[0]!.layer])) {
if (anchorsCompatible(anchors[left]!, anchors[right]!)) {
tryMerge(dsu, anchors, left, right, false);
}
}
return groupsFromDsu(anchors, dsu);
}
function mergeStandaloneComponents(components: PreparedCandidate[]): PreparedCandidate[][] {
if (components.length === 0) {
return [];
}
const dsu = new DisjointSet(components.length);
const radius = COMPONENT_GROUP_RADIUS_M[components[0]!.layer];
for (const [left, right] of nearbyPairs(components, radius)) {
const a = components[left]!;
const b = components[right]!;
const sameSpecificName = Boolean(a.nameKey && a.nameKey === b.nameKey);
if ((sameSpecificName || (!a.nameKey && !b.nameKey)) && !officialIdConflict(a, b)) {
tryMerge(dsu, components, left, right, false);
}
}
return groupsFromDsu(components, dsu);
}
function tryMerge(
dsu: DisjointSet,
candidates: PreparedCandidate[],
left: number,
right: number,
strongIdentity: boolean
) {
const leftRoot = dsu.find(left);
const rightRoot = dsu.find(right);
if (leftRoot === rightRoot) {
return;
}
const leftMembers = candidates.map((_candidate, index) => index).filter((index) => dsu.find(index) === leftRoot);
const rightMembers = candidates.map((_candidate, index) => index).filter((index) => dsu.find(index) === rightRoot);
if (
leftMembers.every((leftIndex) =>
rightMembers.every((rightIndex) =>
clusterPairCompatible(candidates[leftIndex]!, candidates[rightIndex]!, strongIdentity)
)
)
) {
dsu.union(leftRoot, rightRoot);
}
}
function anchorsCompatible(left: PreparedCandidate, right: PreparedCandidate) {
if (officialIdConflict(left, right) || contactIdentityConflict(left, right)) {
return false;
}
const distance = distanceMeters(left.coordinate, right.coordinate);
if (left.nameKey && right.nameKey) {
return left.nameKey === right.nameKey && distance <= NAMED_RADIUS_M[left.layer];
}
return distance <= GENERIC_RADIUS_M[left.layer];
}
function clusterPairCompatible(left: PreparedCandidate, right: PreparedCandidate, strongIdentity: boolean) {
if (officialIdConflict(left, right) || contactIdentityConflict(left, right)) {
return false;
}
const distance = distanceMeters(left.coordinate, right.coordinate);
if (distance > MAX_CLUSTER_DIAMETER_M[left.layer]) {
return false;
}
if (left.nameKey && right.nameKey && left.nameKey !== right.nameKey) {
return strongIdentity && hasMatchingOfficialId(left, right);
}
return true;
}
function nearestCompatibleAnchorRoot(component: PreparedCandidate, anchorGroups: PreparedCandidate[][]) {
let match: { root: string; distance: number } | null = null;
for (const group of anchorGroups) {
for (const anchor of group) {
if (officialIdConflict(component, anchor)) continue;
if (component.nameKey && anchor.nameKey && component.nameKey !== anchor.nameKey) continue;
const distance = distanceMeters(component.coordinate, anchor.coordinate);
const radius =
component.nameKey && anchor.nameKey
? NAMED_RADIUS_M[component.layer]
: COMPONENT_ATTACH_RADIUS_M[component.layer];
if (distance <= radius && (!match || distance < match.distance)) {
match = { root: group[0]!.stableKey, distance };
}
}
}
return match?.root ?? null;
}
function buildCanonicalPoi(members: PreparedCandidate[]): CanonicalMarinePoi {
const sorted = [...members].sort(
(left, right) => canonicalScore(right) - canonicalScore(left) || left.stableKey.localeCompare(right.stableKey)
);
const canonical = sorted[0]!;
const properties = mergeProperties(sorted, canonical);
const memberIds = members.map((member) => member.id).sort();
const canonicalSourceId = canonical.sourceId ?? null;
return {
entityId: `marine-poi:${canonical.layer}:${canonical.source}:${canonicalSourceId ?? canonical.id}`,
layer: canonical.layer,
canonicalSource: canonical.source,
canonicalSourceId,
name: canonical.facilityName ?? sorted.map((member) => member.facilityName).find(Boolean) ?? null,
coordinate: { ...canonical.coordinate },
properties: {
...properties,
layer: canonical.layer,
name: canonical.facilityName ?? properties.name ?? null,
canonicalSource: canonical.source,
canonicalSourceId,
dedupeMemberCount: memberIds.length,
dedupeMemberIds: memberIds
},
memberIds,
memberCount: memberIds.length
};
}
function mergeProperties(sorted: PreparedCandidate[], canonical: PreparedCandidate) {
const merged: Record<string, unknown> = { ...canonical.properties };
for (const candidate of sorted) {
for (const [key, value] of Object.entries(candidate.properties)) {
if (isEmptyValue(merged[key]) && !isEmptyValue(value)) {
merged[key] = value;
}
}
}
const contactsByPriority = [...sorted].sort(
(left, right) => informationPriority(right) - informationPriority(left) || left.stableKey.localeCompare(right.stableKey)
);
const phoneValues = uniquePhoneStrings(
contactsByPriority.flatMap((candidate) => [candidate.properties.phone, candidate.properties["contact:phone"], candidate.properties.phones])
);
const emailValues = uniqueStrings(
contactsByPriority.flatMap((candidate) => [candidate.properties.email, candidate.properties["contact:email"]])
);
const websiteValues = uniqueStrings(
contactsByPriority.flatMap((candidate) => [candidate.properties.website, candidate.properties["contact:website"], candidate.properties.url])
);
const vhfValues = uniqueStrings(
contactsByPriority.flatMap((candidate) => [candidate.properties.vhf, candidate.properties["contact:vhf"]])
);
const sources = [...new Set(sorted.map((candidate) => sourceLabel(candidate.source)))];
const sourceUrl = firstNonEmptyProperty(contactsByPriority, [
"sourceUrl",
"source_url",
"enrichmentSourceUrl"
]);
const updatedAt = latestTimestamp(
sorted.flatMap((candidate) => [
candidate.properties.updatedAt,
candidate.properties.updated_at,
candidate.properties.fetchedAt,
candidate.properties.fetched_at
])
);
setContact(merged, "phone", phoneValues.join("; ") || null, "contact:phone");
setContact(merged, "email", emailValues[0] ?? null, "contact:email");
setContact(merged, "website", websiteValues[0] ?? null, "contact:website");
setContact(merged, "vhf", vhfValues.join("; ") || null, "contact:vhf");
for (const key of CONTACT_KEYS) {
const value = firstNonEmptyProperty(contactsByPriority, [key]);
if (value !== null && isEmptyValue(merged[key])) merged[key] = value;
}
merged.phones = phoneValues;
merged.emails = emailValues;
merged.websites = websiteValues;
merged.vhfChannels = vhfValues;
merged.source = sources.join(" + ");
merged.sources = sources;
merged.sourceUrl = sourceUrl;
merged.updatedAt = updatedAt;
return merged;
}
function prepareCandidate(candidate: MarinePoiCandidate): PreparedCandidate {
const facilityName = firstString(candidate.properties, ["lock_name", "official_name"])
?? stringValue(candidate.name)
?? firstString(candidate.properties, ["name", "seamark:name"]);
return {
...candidate,
sourceId: candidate.sourceId ?? null,
role: candidate.role ?? inferredRole(candidate),
facilityName,
nameKey: normalizedMarineFacilityName(facilityName),
rawNameKey: simpleNameIdentity(facilityName),
officialIds: officialIds(candidate.properties),
stableKey: `${candidate.source}:${candidate.sourceId ?? candidate.id}:${candidate.id}`
};
}
function inferredRole(candidate: MarinePoiCandidate): MarinePoiRole {
if (candidate.source.toLowerCase() === "euris") return "anchor";
const properties = candidate.properties;
const seamarkType = normalizedTag(properties["seamark:type"]);
const waterway = normalizedTag(properties.waterway);
if (candidate.layer === "locks") {
const gateCategory = normalizedTag(properties["seamark:gate:category"]);
return waterway === "lock_gate" || seamarkType === "lock_gate" || (seamarkType === "gate" && gateCategory.includes("lock"))
? "component"
: "anchor";
}
const explicitHarbour =
["harbour", "harbour_basin", "marina"].includes(seamarkType) ||
normalizedTag(properties.leisure) === "marina" ||
Boolean(normalizedTag(properties.harbour)) ||
normalizedTag(properties.industrial) === "port" ||
["harbour", "port"].includes(normalizedTag(properties.landuse));
return waterway === "dock" && !explicitHarbour ? "component" : "anchor";
}
function officialIds(properties: Record<string, unknown>) {
const ids = new Map<string, string>();
for (const key of OFFICIAL_ID_KEYS) {
const value = stringValue(properties[key]);
if (value) ids.set(key, value.toLocaleUpperCase("en-US"));
}
return ids;
}
function officialIdConflict(left: PreparedCandidate, right: PreparedCandidate) {
for (const [kind, value] of left.officialIds) {
const other = right.officialIds.get(kind);
if (other && other !== value) return true;
}
return false;
}
function hasMatchingOfficialId(left: PreparedCandidate, right: PreparedCandidate) {
for (const [kind, value] of left.officialIds) {
if (right.officialIds.get(kind) === value) return true;
}
return false;
}
function contactIdentityConflict(left: PreparedCandidate, right: PreparedCandidate) {
const leftWebsite = normalizedWebsiteIdentity(left.properties);
const rightWebsite = normalizedWebsiteIdentity(right.properties);
return Boolean(
leftWebsite &&
rightWebsite &&
leftWebsite !== rightWebsite &&
left.rawNameKey !== right.rawNameKey
);
}
function simpleNameIdentity(value: unknown) {
return stringValue(value)
?.normalize("NFKD")
.replace(/[\u0300-\u036f]/gu, "")
.replace(/ß/gu, "ss")
.toLocaleLowerCase("de-DE")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim() || null;
}
function normalizedWebsiteIdentity(properties: Record<string, unknown>) {
const value = firstString(properties, ["website", "contact:website", "url"]);
if (!value) return null;
try {
const url = new URL(/^https?:\/\//iu.test(value) ? value : `https://${value}`);
return `${url.hostname.replace(/^www\./iu, "").toLowerCase()}${url.pathname.replace(/\/$/u, "")}`;
} catch {
return value.toLocaleLowerCase("en-US").replace(/\/$/u, "");
}
}
function nearbyPairs(candidates: PreparedCandidate[], radiusM: number): Array<[number, number]> {
if (candidates.length < 2) return [];
const referenceLatitude = candidates.reduce((sum, candidate) => sum + candidate.coordinate.lat, 0) / candidates.length;
const longitudeScale = 111_320 * Math.max(0.1, Math.cos((referenceLatitude * Math.PI) / 180));
const latitudeScale = 110_540;
const cells = new Map<string, number[]>();
const cellCoordinates = candidates.map((candidate) => ({
x: Math.floor((candidate.coordinate.lon * longitudeScale) / radiusM),
y: Math.floor((candidate.coordinate.lat * latitudeScale) / radiusM)
}));
cellCoordinates.forEach(({ x, y }, index) => {
const key = `${x}:${y}`;
cells.set(key, [...(cells.get(key) ?? []), index]);
});
const pairs: Array<[number, number]> = [];
cellCoordinates.forEach(({ x, y }, left) => {
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
for (const right of cells.get(`${x + dx}:${y + dy}`) ?? []) {
if (right > left && distanceMeters(candidates[left]!.coordinate, candidates[right]!.coordinate) <= radiusM) {
pairs.push([left, right]);
}
}
}
}
});
return pairs.sort(
([leftA, rightA], [leftB, rightB]) =>
distanceMeters(candidates[leftA]!.coordinate, candidates[rightA]!.coordinate) -
distanceMeters(candidates[leftB]!.coordinate, candidates[rightB]!.coordinate) ||
leftA - leftB ||
rightA - rightB
);
}
function groupsFromDsu(candidates: PreparedCandidate[], dsu: DisjointSet) {
const groups = new Map<number, PreparedCandidate[]>();
candidates.forEach((candidate, index) => {
const root = dsu.find(index);
groups.set(root, [...(groups.get(root) ?? []), candidate]);
});
return [...groups.values()].map((group) => group.sort((left, right) => left.stableKey.localeCompare(right.stableKey)));
}
function canonicalScore(candidate: PreparedCandidate) {
const source = candidate.source.toLowerCase();
const sourceScore = source === "euris" ? 220 : source === "osm" ? 170 : source === "facility-website" ? 40 : 120;
return sourceScore + (candidate.role === "anchor" ? 50 : 0) + (candidate.nameKey ? 20 : 0) + informationCount(candidate) * 3;
}
function informationPriority(candidate: PreparedCandidate) {
const source = candidate.source.toLowerCase();
const sourceScore = source === "facility-website" ? 300 : source === "euris" ? 250 : source === "osm" ? 100 : 150;
return sourceScore + informationCount(candidate);
}
function informationCount(candidate: PreparedCandidate) {
return CONTACT_KEYS.filter((key) => !isEmptyValue(candidate.properties[key])).length;
}
function sourceLabel(source: string) {
switch (source.toLowerCase()) {
case "euris":
return "EuRIS";
case "osm":
return "OpenStreetMap";
case "facility-website":
return "Betreiber-Website";
default:
return source;
}
}
function firstNonEmptyProperty(candidates: PreparedCandidate[], keys: readonly string[]) {
for (const candidate of candidates) {
const value = firstString(candidate.properties, keys);
if (value) return value;
}
return null;
}
function latestTimestamp(values: unknown[]) {
let latest: { raw: string; time: number } | null = null;
for (const value of values) {
const raw = stringValue(value);
if (!raw) continue;
const time = Date.parse(raw);
if (Number.isFinite(time) && (!latest || time > latest.time)) latest = { raw, time };
}
return latest ? new Date(latest.time).toISOString() : null;
}
function setContact(properties: Record<string, unknown>, key: string, value: string | null, alias: string) {
properties[key] = value;
properties[alias] = value;
}
function uniqueStrings(values: unknown[]) {
const result: string[] = [];
const seen = new Set<string>();
for (const value of values.flatMap((entry) => (Array.isArray(entry) ? entry : [entry]))) {
const string = stringValue(value);
if (!string) continue;
const key = string.toLocaleLowerCase("de-DE").replace(/\s+/gu, " ");
if (!seen.has(key)) {
seen.add(key);
result.push(string);
}
}
return result;
}
function uniquePhoneStrings(values: unknown[]) {
const result: string[] = [];
const seen = new Set<string>();
for (const value of values.flatMap((entry) => (Array.isArray(entry) ? entry : [entry]))) {
const phone = stringValue(value);
if (!phone) continue;
const key = phone.replace(/^00/u, "").replace(/\D/gu, "");
if (key && !seen.has(key)) {
seen.add(key);
result.push(phone);
}
}
return result;
}
function firstString(properties: Record<string, unknown>, keys: readonly string[]) {
for (const key of keys) {
const value = stringValue(properties[key]);
if (value) return value;
}
return null;
}
function stringValue(value: unknown) {
if (typeof value === "string") return value.trim() || null;
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return null;
}
function normalizedTag(value: unknown) {
return stringValue(value)?.toLocaleLowerCase("en-US") ?? "";
}
function isEmptyValue(value: unknown) {
return value === null || value === undefined || (typeof value === "string" && !value.trim());
}
function isValidCandidate(candidate: MarinePoiCandidate) {
return (
(candidate.layer === "locks" || candidate.layer === "harbours") &&
typeof candidate.id === "string" &&
Boolean(candidate.id) &&
Number.isFinite(candidate.coordinate.lon) &&
Number.isFinite(candidate.coordinate.lat) &&
candidate.coordinate.lon >= -180 &&
candidate.coordinate.lon <= 180 &&
candidate.coordinate.lat >= -90 &&
candidate.coordinate.lat <= 90
);
}
function distanceMeters(left: { lon: number; lat: number }, right: { lon: number; lat: number }) {
const meanLatitude = ((left.lat + right.lat) / 2) * (Math.PI / 180);
const x = (right.lon - left.lon) * (Math.PI / 180) * Math.cos(meanLatitude);
const y = (right.lat - left.lat) * (Math.PI / 180);
return Math.hypot(x, y) * 6_371_008.8;
}
class DisjointSet {
private readonly parent: number[];
constructor(size: number) {
this.parent = Array.from({ length: size }, (_value, index) => index);
}
find(index: number): number {
const parent = this.parent[index]!;
if (parent !== index) this.parent[index] = this.find(parent);
return this.parent[index]!;
}
union(left: number, right: number) {
const leftRoot = this.find(left);
const rightRoot = this.find(right);
if (leftRoot === rightRoot) return;
this.parent[Math.max(leftRoot, rightRoot)] = Math.min(leftRoot, rightRoot);
}
}
+622
View File
@@ -0,0 +1,622 @@
import { haversineDistanceM, initialBearingDeg, normalizeHeadingDeg } from "./geo.js";
import type { Coordinate, GeoJsonLineString, RouteResult } from "./types.js";
const EARTH_RADIUS_M = 6_371_008.8;
const METERS_PER_SECOND_PER_KNOT = 1_852 / 3_600;
const MIN_SEGMENT_LENGTH_M = 0.01;
const COORDINATE_EPSILON_M = 0.05;
export type RouteGuidanceRoute =
| RouteResult
| GeoJsonLineString
| readonly (readonly [number, number])[];
export type RouteGuidanceStatus =
| "on-route"
| "approaching-turn"
| "off-route"
| "arrived"
| "gps-unreliable";
export type CrossTrackSide = "port" | "starboard" | "on-route";
export type RouteTurnDirection = "port" | "starboard" | "u-turn";
export type RouteGuidanceTurn = {
direction: RouteTurnDirection;
/** Clockwise/starboard changes are positive; anticlockwise/port changes are negative. */
courseChangeDeg: number;
distanceM: number;
coordinate: Coordinate;
incomingCourseDeg: number;
outgoingCourseDeg: number;
};
export type RouteGuidanceInput = {
route: RouteGuidanceRoute;
position: Coordinate;
headingDeg?: number | null;
speedKn?: number | null;
accuracyM?: number | null;
/** Pass the preceding result's progressM to stabilise guidance at crossings and GPS jitter. */
previousProgressM?: number | null;
};
export type RouteGuidanceOptions = {
offRouteThresholdM?: number;
maxReliableAccuracyM?: number;
arrivalRadiusM?: number;
minLookaheadM?: number;
maxLookaheadM?: number;
lookaheadTimeS?: number;
accuracyLookaheadFactor?: number;
turnMinimumChangeDeg?: number;
turnSampleDistanceM?: number;
turnNoticeDistanceM?: number;
turnSearchDistanceM?: number;
/** Largest ordinary progress change before route-crossing continuity is considered. */
maxProgressJumpM?: number;
/** Spatial tolerance used to disambiguate geometrically overlapping route sections. */
progressAmbiguityM?: number;
};
export type RouteGuidanceResult = {
status: RouteGuidanceStatus;
/** Bearing from the current position to a speed/accuracy-dependent point ahead on the route. */
desiredCourseDeg: number;
/** Clockwise/starboard correction is positive. Null when no heading was supplied. */
courseCorrectionDeg: number | null;
lookaheadDistanceM: number;
lookaheadPoint: Coordinate;
nearestRoutePoint: Coordinate;
nearestSegmentIndex: number;
distanceToRouteM: number;
/** Distance that is certainly outside the reported GPS accuracy circle. */
conservativeDistanceToRouteM: number;
/** Signed route deviation: negative is port, positive is starboard. */
crossTrackErrorM: number;
crossTrackSide: CrossTrackSide;
progressM: number;
progressRatio: number;
routeLengthM: number;
remainingRouteDistanceM: number;
distanceToDestinationM: number;
isOffRoute: boolean;
positionReliable: boolean;
nextTurn: RouteGuidanceTurn | null;
};
type MeasuredRoute = {
coordinates: Coordinate[];
segmentLengthsM: number[];
cumulativeDistanceM: number[];
totalDistanceM: number;
};
type RouteProjection = {
coordinate: Coordinate;
segmentIndex: number;
segmentFraction: number;
routeDistanceM: number;
distanceM: number;
crossTrackErrorM: number;
};
type ResolvedOptions = {
offRouteThresholdM: number;
maxReliableAccuracyM: number;
arrivalRadiusM: number;
minLookaheadM: number;
maxLookaheadM: number;
lookaheadTimeS: number;
accuracyLookaheadFactor: number;
turnMinimumChangeDeg: number;
turnSampleDistanceM: number;
turnNoticeDistanceM: number;
turnSearchDistanceM: number;
maxProgressJumpM: number;
progressAmbiguityM: number;
};
/**
* Computes advisory route-following data from a planned line and one GPS fix.
* This function is stateless and performs no vessel control. Feeding the
* preceding progressM back as previousProgressM adds monotonic progress
* hysteresis and avoids jumping between distant parts of a crossing route.
*/
export function calculateRouteGuidance(
input: RouteGuidanceInput,
options: RouteGuidanceOptions = {}
): RouteGuidanceResult | null {
if (!isCoordinate(input.position)) {
return null;
}
const route = measureRoute(input.route);
if (!route) {
return null;
}
const resolved = resolveOptions(options);
const accuracyM = optionalNonNegative(input.accuracyM);
const speedKn = optionalNonNegative(input.speedKn) ?? 0;
const headingDeg = optionalFinite(input.headingDeg);
const previousProgressM = optionalFinite(input.previousProgressM);
const clampedPreviousProgressM = previousProgressM === null
? null
: clamp(previousProgressM, 0, route.totalDistanceM);
const projection = projectOntoRoute(
input.position,
route,
clampedPreviousProgressM,
resolved.maxProgressJumpM,
Math.max(resolved.progressAmbiguityM, (accuracyM ?? 0) * 1.5)
);
// Guidance is deliberately monotonic while a previous progress hint is
// supplied. Callers can omit the hint when intentionally restarting or
// travelling the planned route in reverse.
const progressM = clampedPreviousProgressM === null
? projection.routeDistanceM
: Math.max(clampedPreviousProgressM, projection.routeDistanceM);
const remainingRouteDistanceM = Math.max(0, route.totalDistanceM - progressM);
const nextTurn = findNextTurn(route, progressM, resolved);
const lookaheadRequestM = clamp(
resolved.minLookaheadM
+ speedKn * METERS_PER_SECOND_PER_KNOT * resolved.lookaheadTimeS
+ (accuracyM ?? 0) * resolved.accuracyLookaheadFactor,
resolved.minLookaheadM,
resolved.maxLookaheadM
);
// Never aim through a significant corner. Until the turn is reached, its
// vertex is the furthest guidance target; after reaching it courseAtDistance
// naturally switches to the outgoing leg.
const turnLimitedLookaheadM = nextTurn && nextTurn.distanceM <= lookaheadRequestM
? nextTurn.distanceM
: lookaheadRequestM;
const lookaheadDistanceM = Math.min(turnLimitedLookaheadM, remainingRouteDistanceM);
const lookaheadPoint = coordinateAtDistance(route, progressM + lookaheadDistanceM);
const distanceToLookaheadM = haversineDistanceM(input.position, lookaheadPoint);
const desiredCourseDeg = distanceToLookaheadM > COORDINATE_EPSILON_M
? initialBearingDeg(input.position, lookaheadPoint)
: courseAtDistance(route, progressM);
const normalizedHeadingDeg = headingDeg === null ? null : normalizeHeadingDeg(headingDeg);
const courseCorrectionDeg = normalizedHeadingDeg === null
? null
: signedCourseChangeDeg(normalizedHeadingDeg, desiredCourseDeg);
const conservativeDistanceToRouteM = Math.max(0, projection.distanceM - (accuracyM ?? 0));
const positionReliable = accuracyM === null || accuracyM <= resolved.maxReliableAccuracyM;
const isOffRoute = positionReliable
&& conservativeDistanceToRouteM > resolved.offRouteThresholdM;
const destination = route.coordinates.at(-1)!;
const distanceToDestinationM = haversineDistanceM(input.position, destination);
const arrivalProgressToleranceM = Math.max(
resolved.arrivalRadiusM * 2,
resolved.minLookaheadM
);
const arrived = positionReliable
&& distanceToDestinationM <= resolved.arrivalRadiusM + (accuracyM ?? 0)
&& remainingRouteDistanceM <= arrivalProgressToleranceM;
let status: RouteGuidanceStatus;
if (!positionReliable) {
status = "gps-unreliable";
} else if (arrived) {
status = "arrived";
} else if (isOffRoute) {
status = "off-route";
} else if (nextTurn && nextTurn.distanceM <= resolved.turnNoticeDistanceM) {
status = "approaching-turn";
} else {
status = "on-route";
}
const crossTrackSide = sideForError(projection.crossTrackErrorM);
return {
status,
desiredCourseDeg,
courseCorrectionDeg,
lookaheadDistanceM,
lookaheadPoint,
nearestRoutePoint: projection.coordinate,
nearestSegmentIndex: projection.segmentIndex,
distanceToRouteM: projection.distanceM,
conservativeDistanceToRouteM,
crossTrackErrorM: projection.crossTrackErrorM,
crossTrackSide,
progressM,
progressRatio: clamp(progressM / route.totalDistanceM, 0, 1),
routeLengthM: route.totalDistanceM,
remainingRouteDistanceM,
distanceToDestinationM,
isOffRoute,
positionReliable,
nextTurn
};
}
function measureRoute(source: RouteGuidanceRoute): MeasuredRoute | null {
const rawCoordinates = routeCoordinates(source);
if (!rawCoordinates || rawCoordinates.length < 2 || rawCoordinates.some((value) => !isGeoJsonCoordinate(value))) {
return null;
}
const coordinates: Coordinate[] = [];
for (const [lon, lat] of rawCoordinates) {
const coordinate = { lon, lat };
const previous = coordinates.at(-1);
if (!previous || haversineDistanceM(previous, coordinate) >= MIN_SEGMENT_LENGTH_M) {
coordinates.push(coordinate);
}
}
if (coordinates.length < 2) {
return null;
}
const segmentLengthsM: number[] = [];
const cumulativeDistanceM = [0];
for (let index = 1; index < coordinates.length; index += 1) {
const lengthM = haversineDistanceM(coordinates[index - 1]!, coordinates[index]!);
segmentLengthsM.push(lengthM);
cumulativeDistanceM.push(cumulativeDistanceM[index - 1]! + lengthM);
}
const totalDistanceM = cumulativeDistanceM.at(-1)!;
return totalDistanceM >= MIN_SEGMENT_LENGTH_M
? { coordinates, segmentLengthsM, cumulativeDistanceM, totalDistanceM }
: null;
}
function projectOntoRoute(
position: Coordinate,
route: MeasuredRoute,
previousProgressM: number | null,
maxProgressJumpM: number,
ambiguityM: number
): RouteProjection {
const candidates: RouteProjection[] = [];
for (let index = 0; index < route.segmentLengthsM.length; index += 1) {
const projection = projectOntoSegment(
position,
route.coordinates[index]!,
route.coordinates[index + 1]!,
route.segmentLengthsM[index]!
);
candidates.push({
...projection,
segmentIndex: index,
routeDistanceM:
route.cumulativeDistanceM[index]! + projection.segmentFraction * route.segmentLengthsM[index]!
});
}
candidates.sort(compareSpatialProjection);
const spatialBest = candidates[0]!;
if (
previousProgressM === null
|| Math.abs(spatialBest.routeDistanceM - previousProgressM) <= maxProgressJumpM
) {
return spatialBest;
}
const continuityCandidates = candidates
.filter((candidate) => candidate.distanceM <= spatialBest.distanceM + ambiguityM)
.filter((candidate) => Math.abs(candidate.routeDistanceM - previousProgressM) <= maxProgressJumpM)
.sort((a, b) =>
Math.abs(a.routeDistanceM - previousProgressM)
- Math.abs(b.routeDistanceM - previousProgressM)
|| compareSpatialProjection(a, b)
);
return continuityCandidates[0] ?? spatialBest;
}
function projectOntoSegment(
point: Coordinate,
start: Coordinate,
end: Coordinate,
segmentLengthM: number
): Omit<RouteProjection, "segmentIndex" | "routeDistanceM"> {
const segmentAngle = segmentLengthM / EARTH_RADIUS_M;
const pointAngle = angularDistanceRad(start, point);
const segmentBearing = initialBearingRad(start, end);
const pointBearing = initialBearingRad(start, point);
const bearingDelta = normalizeRadians(pointBearing - segmentBearing);
const crossTrackAngle = Math.asin(
clamp(Math.sin(pointAngle) * Math.sin(bearingDelta), -1, 1)
);
const alongTrackAngle = Math.atan2(
Math.sin(pointAngle) * Math.cos(bearingDelta),
Math.cos(pointAngle)
);
const segmentFraction = clamp(alongTrackAngle / segmentAngle, 0, 1);
const coordinate = coordinateAlongGreatCircle(start, segmentBearing, segmentAngle * segmentFraction);
const distanceM = segmentFraction > 0 && segmentFraction < 1
? Math.abs(crossTrackAngle) * EARTH_RADIUS_M
: haversineDistanceM(point, coordinate);
const crossTrackSign = Math.abs(crossTrackAngle) <= Number.EPSILON
? 0
: Math.sign(crossTrackAngle);
return {
coordinate,
segmentFraction,
distanceM,
crossTrackErrorM: crossTrackSign * distanceM
};
}
function findNextTurn(
route: MeasuredRoute,
progressM: number,
options: ResolvedOptions
): RouteGuidanceTurn | null {
for (let index = 1; index < route.coordinates.length - 1; index += 1) {
const routeDistanceM = route.cumulativeDistanceM[index]!;
const distanceM = routeDistanceM - progressM;
if (distanceM < -COORDINATE_EPSILON_M) {
continue;
}
if (distanceM > options.turnSearchDistanceM) {
break;
}
const incomingSampleDistanceM = Math.min(
options.turnSampleDistanceM,
routeDistanceM
);
const outgoingSampleDistanceM = Math.min(
options.turnSampleDistanceM,
route.totalDistanceM - routeDistanceM
);
if (
incomingSampleDistanceM < MIN_SEGMENT_LENGTH_M
|| outgoingSampleDistanceM < MIN_SEGMENT_LENGTH_M
) {
continue;
}
const turnCoordinate = route.coordinates[index]!;
const incomingPoint = coordinateAtDistance(
route,
routeDistanceM - incomingSampleDistanceM
);
const outgoingPoint = coordinateAtDistance(
route,
routeDistanceM + outgoingSampleDistanceM
);
const incomingCourseDeg = initialBearingDeg(incomingPoint, turnCoordinate);
const outgoingCourseDeg = initialBearingDeg(turnCoordinate, outgoingPoint);
const courseChangeDeg = signedCourseChangeDeg(incomingCourseDeg, outgoingCourseDeg);
if (Math.abs(courseChangeDeg) < options.turnMinimumChangeDeg) {
continue;
}
return {
direction: Math.abs(courseChangeDeg) >= 150
? "u-turn"
: courseChangeDeg > 0
? "starboard"
: "port",
courseChangeDeg,
distanceM: Math.max(0, distanceM),
coordinate: turnCoordinate,
incomingCourseDeg,
outgoingCourseDeg
};
}
return null;
}
function coordinateAtDistance(route: MeasuredRoute, distanceM: number): Coordinate {
const clampedDistanceM = clamp(distanceM, 0, route.totalDistanceM);
if (clampedDistanceM <= 0) {
return route.coordinates[0]!;
}
if (clampedDistanceM >= route.totalDistanceM) {
return route.coordinates.at(-1)!;
}
for (let index = 0; index < route.segmentLengthsM.length; index += 1) {
const segmentEndM = route.cumulativeDistanceM[index + 1]!;
if (clampedDistanceM <= segmentEndM) {
const segmentStartM = route.cumulativeDistanceM[index]!;
const fraction = (clampedDistanceM - segmentStartM) / route.segmentLengthsM[index]!;
return interpolateGreatCircle(
route.coordinates[index]!,
route.coordinates[index + 1]!,
fraction
);
}
}
return route.coordinates.at(-1)!;
}
function courseAtDistance(route: MeasuredRoute, distanceM: number): number {
const clampedDistanceM = clamp(distanceM, 0, route.totalDistanceM);
let segmentIndex = route.segmentLengthsM.length - 1;
for (let index = 0; index < route.segmentLengthsM.length; index += 1) {
if (clampedDistanceM < route.cumulativeDistanceM[index + 1]!) {
segmentIndex = index;
break;
}
}
return initialBearingDeg(
route.coordinates[segmentIndex]!,
route.coordinates[segmentIndex + 1]!
);
}
function interpolateGreatCircle(start: Coordinate, end: Coordinate, fraction: number): Coordinate {
if (fraction <= 0) {
return start;
}
if (fraction >= 1) {
return end;
}
const distance = angularDistanceRad(start, end);
return coordinateAlongGreatCircle(start, initialBearingRad(start, end), distance * fraction);
}
function coordinateAlongGreatCircle(
start: Coordinate,
bearingRad: number,
angularDistance: number
): Coordinate {
const startLat = toRadians(start.lat);
const startLon = toRadians(start.lon);
const sinStartLat = Math.sin(startLat);
const cosStartLat = Math.cos(startLat);
const sinDistance = Math.sin(angularDistance);
const cosDistance = Math.cos(angularDistance);
const latitude = Math.asin(clamp(
sinStartLat * cosDistance + cosStartLat * sinDistance * Math.cos(bearingRad),
-1,
1
));
const longitude = startLon + Math.atan2(
Math.sin(bearingRad) * sinDistance * cosStartLat,
cosDistance - sinStartLat * Math.sin(latitude)
);
return {
lat: toDegrees(latitude),
lon: normalizeLongitudeDeg(toDegrees(longitude))
};
}
function angularDistanceRad(a: Coordinate, b: Coordinate): number {
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);
const deltaLat = lat2 - lat1;
const deltaLon = normalizeRadians(toRadians(b.lon - a.lon));
const haversine = Math.sin(deltaLat / 2) ** 2
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 2 * Math.asin(Math.sqrt(clamp(haversine, 0, 1)));
}
function initialBearingRad(a: Coordinate, b: Coordinate): number {
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);
const deltaLon = normalizeRadians(toRadians(b.lon - a.lon));
return Math.atan2(
Math.sin(deltaLon) * Math.cos(lat2),
Math.cos(lat1) * Math.sin(lat2)
- Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon)
);
}
function signedCourseChangeDeg(fromDeg: number, toDeg: number): number {
const difference = ((toDeg - fromDeg + 540) % 360) - 180;
return difference === -180 ? 180 : difference;
}
function sideForError(errorM: number): CrossTrackSide {
if (Math.abs(errorM) < COORDINATE_EPSILON_M) {
return "on-route";
}
return errorM < 0 ? "port" : "starboard";
}
function routeCoordinates(
route: RouteGuidanceRoute
): readonly (readonly [number, number])[] | null {
if (Array.isArray(route)) {
return route as readonly (readonly [number, number])[];
}
if (route && typeof route === "object" && "geometry" in route) {
return route.geometry.coordinates;
}
if (route && typeof route === "object" && "coordinates" in route) {
return route.coordinates;
}
return null;
}
function isGeoJsonCoordinate(value: readonly unknown[]): value is readonly [number, number] {
return Array.isArray(value)
&& value.length >= 2
&& typeof value[0] === "number"
&& Number.isFinite(value[0])
&& value[0] >= -180
&& value[0] <= 180
&& typeof value[1] === "number"
&& Number.isFinite(value[1])
&& value[1] >= -90
&& value[1] <= 90;
}
function isCoordinate(value: Coordinate): boolean {
return Boolean(value)
&& Number.isFinite(value.lat)
&& value.lat >= -90
&& value.lat <= 90
&& Number.isFinite(value.lon)
&& value.lon >= -180
&& value.lon <= 180;
}
function resolveOptions(options: RouteGuidanceOptions): ResolvedOptions {
const minLookaheadM = positiveOrDefault(options.minLookaheadM, 50);
const maxLookaheadM = Math.max(
minLookaheadM,
positiveOrDefault(options.maxLookaheadM, 400)
);
return {
offRouteThresholdM: positiveOrDefault(options.offRouteThresholdM, 100),
maxReliableAccuracyM: positiveOrDefault(options.maxReliableAccuracyM, 100),
arrivalRadiusM: positiveOrDefault(options.arrivalRadiusM, 30),
minLookaheadM,
maxLookaheadM,
lookaheadTimeS: nonNegativeOrDefault(options.lookaheadTimeS, 20),
accuracyLookaheadFactor: nonNegativeOrDefault(options.accuracyLookaheadFactor, 1.5),
turnMinimumChangeDeg: clamp(positiveOrDefault(options.turnMinimumChangeDeg, 20), 1, 179),
turnSampleDistanceM: positiveOrDefault(options.turnSampleDistanceM, 40),
turnNoticeDistanceM: positiveOrDefault(options.turnNoticeDistanceM, 300),
turnSearchDistanceM: positiveOrDefault(options.turnSearchDistanceM, 2_000),
maxProgressJumpM: positiveOrDefault(options.maxProgressJumpM, 1_000),
progressAmbiguityM: nonNegativeOrDefault(options.progressAmbiguityM, 30)
};
}
function compareSpatialProjection(a: RouteProjection, b: RouteProjection): number {
return a.distanceM - b.distanceM
|| a.routeDistanceM - b.routeDistanceM
|| a.segmentIndex - b.segmentIndex;
}
function optionalFinite(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function optionalNonNegative(value: number | null | undefined): number | null {
const finite = optionalFinite(value);
return finite !== null && finite >= 0 ? finite : null;
}
function positiveOrDefault(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
}
function nonNegativeOrDefault(value: number | undefined, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
}
function normalizeLongitudeDeg(value: number): number {
return ((value + 540) % 360) - 180;
}
function normalizeRadians(value: number): number {
return ((value + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI;
}
function toRadians(value: number): number {
return value * Math.PI / 180;
}
function toDegrees(value: number): number {
return value * 180 / Math.PI;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+129
View File
@@ -0,0 +1,129 @@
import { coordinateToGeoJson, sumRouteDistanceNm } from "./geo.js";
import { buildFairwayRoute, type FairwayGraph } from "./fairway-routing.js";
import { EMDEN_HAMM_GRAPH } from "./inland-seed.js";
import type {
DepthSample,
RouteRequest,
RouteResult,
RouteWarning,
VesselProfile
} from "./types.js";
const DEFAULT_CRUISE_SPEED_KN = 6;
export function buildRoute(request: RouteRequest, graph?: FairwayGraph): RouteResult | null {
if (graph) {
return buildFairwayRoute(request, graph);
}
return buildFairwayRoute(request) ?? buildFairwayRoute(request, EMDEN_HAMM_GRAPH);
}
export function requiredDepthM(profile: VesselProfile): number {
return round(profile.draughtM + profile.safetyReserveM, 2);
}
export function assessDepthSamples(
samples: DepthSample[] | undefined,
profile: VesselProfile
): {
minKnownDepthM: number | null;
unknownDepthRatio: number;
warnings: RouteWarning[];
} {
if (!samples || samples.length === 0) {
return {
minKnownDepthM: null,
unknownDepthRatio: 1,
warnings: [
{
code: "DEPTH_UNKNOWN",
severity: "caution",
message: "Keine belastbaren Tiefendaten entlang der Route vorhanden."
}
]
};
}
const known = samples.filter((sample) => typeof sample.depthM === "number");
const unknownDepthRatio = round((samples.length - known.length) / samples.length, 2);
const minKnownDepthM =
known.length > 0 ? Math.min(...known.map((sample) => sample.depthM!)) : null;
const warnings: RouteWarning[] = [];
const required = requiredDepthM(profile);
if (unknownDepthRatio > 0) {
warnings.push({
code: "DEPTH_PARTIAL",
severity: unknownDepthRatio > 0.5 ? "caution" : "info",
message: `${Math.round(unknownDepthRatio * 100)}% der Route haben keine Tiefenprobe.`
});
}
if (minKnownDepthM !== null && minKnownDepthM < required) {
warnings.push({
code: "DEPTH_TOO_SHALLOW",
severity: "critical",
message: `Minimale bekannte Tiefe ${round(minKnownDepthM, 1)} m unterschreitet erforderliche Tiefe ${required} m.`
});
}
if (known.length === 0) {
warnings.push({
code: "NO_KNOWN_DEPTH",
severity: "caution",
message: "Alle geprüften Tiefenpunkte sind unbekannt."
});
}
return { minKnownDepthM, unknownDepthRatio, warnings };
}
export function buildManualRoute(request: RouteRequest): RouteResult {
const points = [
request.start,
...(request.waypoints ?? []),
request.destination
];
const distanceNm = round(sumRouteDistanceNm(points), 2);
const speedKn =
request.vesselProfile.cruiseSpeedKn && request.vesselProfile.cruiseSpeedKn > 0
? request.vesselProfile.cruiseSpeedKn
: DEFAULT_CRUISE_SPEED_KN;
const parsedDeparture = request.departureTime ? Date.parse(request.departureTime) : Number.NaN;
const departureTimestamp = Number.isFinite(parsedDeparture) ? parsedDeparture : Date.now();
const durationMinutes = Math.round((distanceNm / speedKn) * 60);
const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString();
const depthAssessment = assessDepthSamples(request.depthSamples, request.vesselProfile);
return {
geometry: {
type: "LineString",
coordinates: points.map(coordinateToGeoJson)
},
distanceNm,
eta,
departureTime: new Date(departureTimestamp).toISOString(),
durationMinutes,
warnings: [
{
code: "MANUAL_ROUTE",
severity: "info",
message: "MVP-Route nutzt direkte Wegpunktsegmente ohne automatische Fahrwasserlogik."
},
...depthAssessment.warnings
],
minKnownDepthM: depthAssessment.minKnownDepthM,
unknownDepthRatio: depthAssessment.unknownDepthRatio,
dataSources: [
"user-waypoints",
request.depthSamples?.length ? "submitted-depth-samples" : "depth-unavailable"
],
routingMode: "manual"
};
}
function round(value: number, digits: number): number {
const factor = 10 ** digits;
return Math.round(value * factor) / factor;
}
+200
View File
@@ -0,0 +1,200 @@
export type Coordinate = {
lat: number;
lon: number;
};
export type VesselProfile = {
draughtM: number;
safetyReserveM: number;
airDraftM?: number;
beamM?: number;
cruiseSpeedKn?: number;
};
export type RouteWarningSeverity = "info" | "caution" | "critical";
export type RouteWarning = {
code: string;
severity: RouteWarningSeverity;
message: string;
coordinate?: Coordinate;
};
export type GeoJsonLineString = {
type: "LineString";
coordinates: [number, number][];
};
type RouteDetails = {
geometry: GeoJsonLineString;
distanceNm: number;
eta: string | null;
departureTime?: string;
durationMinutes?: number;
warnings: RouteWarning[];
minKnownDepthM: number | null;
unknownDepthRatio: number;
dataSources: string[];
routingMode?: "manual" | "fairway";
};
export type RouteOption = RouteDetails & {
id: string;
name: string;
};
export type RouteResult = RouteDetails & {
id?: string;
name?: string;
alternatives?: RouteOption[];
};
export type MarineForecast = {
waveHeightM: number | null;
waveDirectionDeg: number | null;
wavePeriodS: number | null;
windSpeed: number | null;
windDirectionDeg: number | null;
weatherCode?: number | null;
temperatureC?: number | null;
oceanCurrentSpeedKn?: number | null;
oceanCurrentDirectionDeg?: number | null;
seaLevelHeightMslM?: number | null;
forecastTime?: string;
source: string;
updatedAt: string;
};
export type TideEvent = {
type: "high" | "low";
time: string;
heightM: number | null;
deviationM?: number | null;
};
export type TideCurvePoint = {
time: string;
predictedM: number | null;
measuredM?: number | null;
forecastM?: number | null;
};
export type TideSummary = {
station: string;
distanceKm: number;
nextHigh: TideEvent | null;
nextLow: TideEvent | null;
waterLevelCurve: TideCurvePoint[];
source: string;
updatedAt: string;
};
export type DepthSample = {
coordinate: Coordinate;
depthM: number | null;
};
export type RouteRequest = {
start: Coordinate;
destination: Coordinate;
waypoints?: Coordinate[];
departureTime?: string;
vesselProfile: VesselProfile;
depthSamples?: DepthSample[];
};
export type AppConfig = {
appName: string;
region: string;
disclaimer: string;
featureFlags: Record<string, boolean>;
layers: MapLayerConfig[];
attribution: string[];
};
export type MapLayerConfig =
| {
id: string;
name: string;
kind: "style";
url: string;
attribution: string;
defaultVisible: boolean;
}
| {
id: string;
name: string;
kind: "raster-tile" | "wms";
tileUrl: string;
attribution: string;
defaultVisible: boolean;
opacity: number;
};
export type NavigationSourceKind = "water-levels" | "lock-operations" | "notices";
export type NavigationSourceState = "live" | "cached" | "stale" | "unavailable" | "not-configured";
export type WaterLevelState = "low" | "normal" | "high" | "unknown" | "commented" | "out-dated";
export type WaterLevel = {
stationId: string;
stationNumber: string | null;
stationName: string;
waterway: string;
waterwayKm: number | null;
latitude: number | null;
longitude: number | null;
value: number;
unit: string;
measuredAt: string;
stateMnwMhw: WaterLevelState;
stateNswHsw: WaterLevelState;
agency: string | null;
sourceUrl: string;
};
export type LockOperationInfo = {
id: string;
name: string;
waterway: string | null;
regularHours: string | null;
operatingState: "open" | "closed" | "restricted" | "unknown";
validFrom: string | null;
validTo: string | null;
phone: string | null;
vhf: string | null;
note: string | null;
updatedAt: string | null;
sourceUrl: string;
};
export type NavigationNotice = {
id: string;
title: string;
waterway: string | null;
location: string | null;
severity: "information" | "restriction" | "closure";
validFrom: string | null;
validTo: string | null;
details: string | null;
updatedAt: string | null;
sourceUrl: string;
};
export type NavigationSourceStatus = {
kind: NavigationSourceKind;
id: string;
label: string;
sourceUrl: string;
state: NavigationSourceState;
checkedAt: string;
dataTimestamp: string | null;
warning: string | null;
};
export type NavigationDataSnapshot = {
waterLevels: WaterLevel[];
lockOperations: LockOperationInfo[];
notices: NavigationNotice[];
sources: NavigationSourceStatus[];
generatedAt: string;
};
+641
View File
@@ -0,0 +1,641 @@
import { haversineDistanceNm } from "./geo.js";
import type { Coordinate, RouteResult, RouteWarning } from "./types.js";
export const VOYAGE_AMENITIES = ["electricity", "water", "fuel", "waste", "overnight"] as const;
export type VoyageAmenity = (typeof VOYAGE_AMENITIES)[number];
export type VoyageAmenityAvailability = "available" | "unavailable" | "unknown";
export type VoyageHarbourKind = "harbour" | "marina";
export type VoyageWaypoint = {
id: string;
name: string;
coordinate: Coordinate;
};
export type OrderedVoyageWaypoint = VoyageWaypoint & {
sequence: number;
routeDistanceNm: number;
distanceFromRouteNm: number;
};
export type VoyageHarbour = {
id: string;
name: string;
coordinate: Coordinate;
kind: VoyageHarbourKind;
amenities?: Partial<
Record<VoyageAmenity, VoyageAmenityAvailability | boolean | null | undefined>
>;
phone?: string | null;
website?: string | null;
email?: string | null;
vhf?: string | null;
openingHours?: string | null;
operator?: string | null;
address?: string | null;
source?: string | null;
sourceUrl?: string | null;
updatedAt?: string | null;
};
export type ProjectedVoyageHarbour = Omit<VoyageHarbour, "amenities"> & {
amenities: Record<VoyageAmenity, VoyageAmenityAvailability>;
routeDistanceNm: number;
distanceFromRouteNm: number;
};
export type VoyageStop = {
type: "start" | "harbour" | "route" | "destination";
name: string;
coordinate: Coordinate;
routeDistanceNm: number;
distanceFromRouteNm: number;
harbour: ProjectedVoyageHarbour | null;
};
export type VoyageLeg = {
day: number;
start: VoyageStop;
end: VoyageStop;
routeDistanceNm: number;
distanceNm: number;
durationHours: number;
waypoints: OrderedVoyageWaypoint[];
};
export type VoyagePlanWarning = RouteWarning & {
day?: number;
};
export type VoyagePlan = {
legs: VoyageLeg[];
orderedWaypoints: OrderedVoyageWaypoint[];
warnings: VoyagePlanWarning[];
requiredAmenities: VoyageAmenity[];
totalRouteDistanceNm: number;
totalDistanceNm: number;
totalDurationHours: number;
maxCruisingDistancePerDayNm: number;
};
export type VoyagePlanningRequest = {
route: Pick<RouteResult, "geometry" | "distanceNm">;
cruiseSpeedKn: number;
maxCruisingHoursPerDay: number;
harbours?: VoyageHarbour[];
waypoints?: VoyageWaypoint[];
requiredAmenities?: VoyageAmenity[];
maxHarbourDetourNm?: number;
maxWaypointDistanceFromRouteNm?: number;
preferredMinimumDayProgressRatio?: number;
};
type RouteProjection = {
routeDistanceNm: number;
distanceFromRouteNm: number;
};
type RouteMeasure = {
coordinates: Coordinate[];
cumulativeDistanceNm: number[];
totalDistanceNm: number;
};
const DEFAULT_MAX_HARBOUR_DETOUR_NM = 1.5;
const DEFAULT_MAX_WAYPOINT_DISTANCE_NM = 2;
const DEFAULT_MINIMUM_DAY_PROGRESS_RATIO = 0.35;
const MIN_PROGRESS_NM = 0.05;
/**
* Divides a routed line into navigable daily legs. A harbour stop is only used
* when it can be reached inside the daily time budget and fulfils every
* explicitly required amenity. Missing amenity data never counts as available.
*/
export function buildVoyagePlan(request: VoyagePlanningRequest): VoyagePlan {
assertPositiveFinite(request.cruiseSpeedKn, "cruiseSpeedKn");
assertPositiveFinite(request.maxCruisingHoursPerDay, "maxCruisingHoursPerDay");
const route = measureRoute(request.route);
const maxDailyDistanceNm = request.cruiseSpeedKn * request.maxCruisingHoursPerDay;
const maxHarbourDetourNm = nonNegativeFiniteOrDefault(
request.maxHarbourDetourNm,
DEFAULT_MAX_HARBOUR_DETOUR_NM,
"maxHarbourDetourNm"
);
const maxWaypointDistanceNm = nonNegativeFiniteOrDefault(
request.maxWaypointDistanceFromRouteNm,
DEFAULT_MAX_WAYPOINT_DISTANCE_NM,
"maxWaypointDistanceFromRouteNm"
);
const preferredMinimumDayProgressRatio = ratioOrDefault(
request.preferredMinimumDayProgressRatio,
DEFAULT_MINIMUM_DAY_PROGRESS_RATIO
);
const requiredAmenities = uniqueAmenities(request.requiredAmenities ?? []);
const warnings: VoyagePlanWarning[] = [];
const orderedWaypoints = orderWaypointsAlongRoute(request.waypoints ?? [], request.route);
for (const waypoint of orderedWaypoints) {
if (waypoint.distanceFromRouteNm > maxWaypointDistanceNm) {
warnings.push({
code: "WAYPOINT_OFF_ROUTE",
severity: "caution",
message: `Wegpunkt „${waypoint.name}“ liegt ${formatNm(waypoint.distanceFromRouteNm)} sm von der Route entfernt.`,
coordinate: waypoint.coordinate
});
}
}
const projectedHarbours = projectHarbours(
request.harbours ?? [],
route,
maxHarbourDetourNm
);
const suitableHarbours = projectedHarbours.filter((harbour) =>
harbour.distanceFromRouteNm + MIN_PROGRESS_NM <= maxDailyDistanceNm &&
requiredAmenities.every((amenity) => harbour.amenities[amenity] === "available")
);
const start = route.coordinates[0]!;
const destination = route.coordinates.at(-1)!;
const legs: VoyageLeg[] = [];
const usedHarbours = new Set<string>();
let currentStop = routeStop("start", "Start", start, 0);
while (currentStop.routeDistanceNm < route.totalDistanceNm - MIN_PROGRESS_NM) {
const remainingRouteDistanceNm = route.totalDistanceNm - currentStop.routeDistanceNm;
const finalLegDistanceNm = currentStop.distanceFromRouteNm + remainingRouteDistanceNm;
if (finalLegDistanceNm <= maxDailyDistanceNm + MIN_PROGRESS_NM) {
const end = routeStop("destination", "Ziel", destination, route.totalDistanceNm);
legs.push(
makeLeg(
legs.length + 1,
currentStop,
end,
request.cruiseSpeedKn,
orderedWaypoints
)
);
currentStop = end;
continue;
}
const absoluteDailyLimitNm = Math.min(
route.totalDistanceNm,
currentStop.routeDistanceNm + maxDailyDistanceNm - currentStop.distanceFromRouteNm
);
const preferredMinimumProgressNm =
currentStop.routeDistanceNm +
Math.max(0, maxDailyDistanceNm - currentStop.distanceFromRouteNm) * preferredMinimumDayProgressRatio;
const reachable = suitableHarbours.filter((harbour) => {
if (usedHarbours.has(harbour.id)) {
return false;
}
const routeProgressNm = harbour.routeDistanceNm - currentStop.routeDistanceNm;
const legDistanceNm =
currentStop.distanceFromRouteNm + routeProgressNm + harbour.distanceFromRouteNm;
return routeProgressNm > MIN_PROGRESS_NM && legDistanceNm <= maxDailyDistanceNm + MIN_PROGRESS_NM;
});
const preferred = reachable.filter(
(harbour) => harbour.routeDistanceNm >= preferredMinimumProgressNm
);
const selectedHarbour = selectFarthestHarbour(preferred.length > 0 ? preferred : reachable);
let end: VoyageStop;
if (selectedHarbour) {
usedHarbours.add(selectedHarbour.id);
end = harbourStop(selectedHarbour);
if (preferred.length === 0) {
warnings.push({
code: "SHORT_STAGE_FOR_HARBOUR",
severity: "info",
message: `Etappe ${legs.length + 1} endet früh in „${selectedHarbour.name}“, da vor dem Tageslimit kein späterer geeigneter Hafen liegt.`,
coordinate: selectedHarbour.coordinate,
day: legs.length + 1
});
}
} else {
const fallbackProgressNm = Math.max(
currentStop.routeDistanceNm + MIN_PROGRESS_NM,
absoluteDailyLimitNm
);
const fallbackCoordinate = coordinateAtRouteDistance(route, fallbackProgressNm);
end = routeStop(
"route",
`Tagesziel auf der Route`,
fallbackCoordinate,
fallbackProgressNm
);
warnings.push({
code: "NO_SUITABLE_HARBOUR",
severity: "caution",
message: noSuitableHarbourMessage(legs.length + 1, requiredAmenities, maxHarbourDetourNm),
coordinate: fallbackCoordinate,
day: legs.length + 1
});
}
legs.push(
makeLeg(
legs.length + 1,
currentStop,
end,
request.cruiseSpeedKn,
orderedWaypoints
)
);
currentStop = end;
}
const totalDistanceNm = legs.reduce((sum, leg) => sum + leg.distanceNm, 0);
return {
legs,
orderedWaypoints,
warnings,
requiredAmenities,
totalRouteDistanceNm: route.totalDistanceNm,
totalDistanceNm,
totalDurationHours: totalDistanceNm / request.cruiseSpeedKn,
maxCruisingDistancePerDayNm: maxDailyDistanceNm
};
}
/** Sorts named waypoints by their first occurrence along the routed geometry. */
export function orderWaypointsAlongRoute(
waypoints: VoyageWaypoint[],
route: Pick<RouteResult, "geometry" | "distanceNm">
): OrderedVoyageWaypoint[] {
const measuredRoute = measureRoute(route);
return waypoints
.map((waypoint, originalIndex) => {
const projection = projectCoordinateOntoRoute(waypoint.coordinate, measuredRoute);
return {
...waypoint,
routeDistanceNm: projection.routeDistanceNm,
distanceFromRouteNm: projection.distanceFromRouteNm,
originalIndex
};
})
.sort(
(a, b) =>
a.routeDistanceNm - b.routeDistanceNm ||
a.originalIndex - b.originalIndex
)
.map(({ originalIndex: _originalIndex, ...waypoint }, index) => ({
...waypoint,
sequence: index + 1
}));
}
/** Normalizes common OSM-style harbour tags for use by the voyage planner. */
export function harbourAmenitiesFromProperties(
properties: Record<string, unknown>
): Record<VoyageAmenity, VoyageAmenityAvailability> {
const category = String(properties["seamark:small_craft_facility:category"] ?? "").toLowerCase();
return {
electricity: availabilityFromProperties(properties, [
"electricity",
"power_supply",
"shore_power",
"service:electricity"
], category, ["electricity", "shore_power", "power_supply"]),
water: availabilityFromProperties(properties, [
"drinking_water",
"water_point",
"service:water"
], category, ["drinking_water", "water_tap", "water"]),
fuel: specialAmenityAvailability(properties, "fuel", [
"fuel",
"fuel:diesel",
"service:fuel"
], category, ["fuel", "fuel_station"]),
waste: specialAmenityAvailability(properties, "waste_disposal", [
"waste_disposal",
"sanitary_dump_station",
"pump_out",
"service:waste"
], category, ["waste", "waste_disposal", "pump_out"]),
overnight: availabilityFromProperties(properties, [
"overnight",
"guest_berths",
"visitor_berths",
"guest_moorings"
], category, ["visitor_berth", "visitors_berth", "guest_berth"])
};
}
export function voyageAmenityLabel(amenity: VoyageAmenity): string {
switch (amenity) {
case "electricity":
return "Strom";
case "water":
return "Wasser";
case "fuel":
return "Treibstoff";
case "waste":
return "Entsorgung";
case "overnight":
return "Übernachtung";
}
}
function projectHarbours(
harbours: VoyageHarbour[],
route: RouteMeasure,
maxDetourNm: number
): ProjectedVoyageHarbour[] {
return harbours
.map((harbour) => {
const projection = projectCoordinateOntoRoute(harbour.coordinate, route);
return {
...harbour,
amenities: normalizeAmenities(harbour.amenities),
routeDistanceNm: projection.routeDistanceNm,
distanceFromRouteNm: projection.distanceFromRouteNm
};
})
.filter((harbour) => harbour.distanceFromRouteNm <= maxDetourNm)
.sort(
(a, b) =>
a.routeDistanceNm - b.routeDistanceNm ||
a.distanceFromRouteNm - b.distanceFromRouteNm ||
a.id.localeCompare(b.id)
);
}
function selectFarthestHarbour(harbours: ProjectedVoyageHarbour[]) {
return harbours.reduce<ProjectedVoyageHarbour | null>((selected, harbour) => {
if (!selected || harbour.routeDistanceNm > selected.routeDistanceNm) {
return harbour;
}
if (
harbour.routeDistanceNm === selected.routeDistanceNm &&
harbour.distanceFromRouteNm < selected.distanceFromRouteNm
) {
return harbour;
}
return selected;
}, null);
}
function makeLeg(
day: number,
start: VoyageStop,
end: VoyageStop,
cruiseSpeedKn: number,
waypoints: OrderedVoyageWaypoint[]
): VoyageLeg {
const routeDistanceNm = Math.max(0, end.routeDistanceNm - start.routeDistanceNm);
const distanceNm = start.distanceFromRouteNm + routeDistanceNm + end.distanceFromRouteNm;
return {
day,
start,
end,
routeDistanceNm,
distanceNm,
durationHours: distanceNm / cruiseSpeedKn,
waypoints: waypoints.filter(
(waypoint) =>
waypoint.routeDistanceNm > start.routeDistanceNm + MIN_PROGRESS_NM &&
waypoint.routeDistanceNm <= end.routeDistanceNm + MIN_PROGRESS_NM
)
};
}
function routeStop(
type: "start" | "route" | "destination",
name: string,
coordinate: Coordinate,
routeDistanceNm: number
): VoyageStop {
return {
type,
name,
coordinate,
routeDistanceNm,
distanceFromRouteNm: 0,
harbour: null
};
}
function harbourStop(harbour: ProjectedVoyageHarbour): VoyageStop {
return {
type: "harbour",
name: harbour.name,
coordinate: harbour.coordinate,
routeDistanceNm: harbour.routeDistanceNm,
distanceFromRouteNm: harbour.distanceFromRouteNm,
harbour
};
}
function measureRoute(route: Pick<RouteResult, "geometry" | "distanceNm">): RouteMeasure {
const coordinates = route.geometry.coordinates.map(([lon, lat]) => ({ lon, lat }));
if (
coordinates.length < 2 ||
coordinates.some(({ lat, lon }) => !Number.isFinite(lat) || !Number.isFinite(lon))
) {
throw new RangeError("route.geometry must contain at least two finite coordinates");
}
const cumulativeDistanceNm = [0];
for (let index = 1; index < coordinates.length; index += 1) {
cumulativeDistanceNm.push(
cumulativeDistanceNm[index - 1]! +
haversineDistanceNm(coordinates[index - 1]!, coordinates[index]!)
);
}
return {
coordinates,
cumulativeDistanceNm,
totalDistanceNm: cumulativeDistanceNm.at(-1)!
};
}
function projectCoordinateOntoRoute(point: Coordinate, route: RouteMeasure): RouteProjection {
let best: RouteProjection | null = null;
for (let index = 0; index < route.coordinates.length - 1; index += 1) {
const segmentStart = route.coordinates[index]!;
const segmentEnd = route.coordinates[index + 1]!;
const t = projectionFraction(point, segmentStart, segmentEnd);
const coordinate = {
lat: segmentStart.lat + (segmentEnd.lat - segmentStart.lat) * t,
lon: segmentStart.lon + (segmentEnd.lon - segmentStart.lon) * t
};
const distanceFromRouteNm = haversineDistanceNm(point, coordinate);
const segmentDistanceNm =
route.cumulativeDistanceNm[index + 1]! - route.cumulativeDistanceNm[index]!;
const projection = {
distanceFromRouteNm,
routeDistanceNm: route.cumulativeDistanceNm[index]! + segmentDistanceNm * t
};
if (
!best ||
projection.distanceFromRouteNm < best.distanceFromRouteNm ||
(projection.distanceFromRouteNm === best.distanceFromRouteNm &&
projection.routeDistanceNm < best.routeDistanceNm)
) {
best = projection;
}
}
return best!;
}
function projectionFraction(point: Coordinate, start: Coordinate, end: Coordinate) {
const meanLatitudeRad = ((point.lat + start.lat + end.lat) / 3) * (Math.PI / 180);
const longitudeScale = Math.cos(meanLatitudeRad);
const px = (point.lon - start.lon) * longitudeScale;
const py = point.lat - start.lat;
const ex = (end.lon - start.lon) * longitudeScale;
const ey = end.lat - start.lat;
const lengthSquared = ex * ex + ey * ey;
if (lengthSquared === 0) {
return 0;
}
return Math.max(0, Math.min(1, (px * ex + py * ey) / lengthSquared));
}
function coordinateAtRouteDistance(route: RouteMeasure, requestedDistanceNm: number): Coordinate {
const distanceNm = Math.max(0, Math.min(route.totalDistanceNm, requestedDistanceNm));
for (let index = 0; index < route.cumulativeDistanceNm.length - 1; index += 1) {
const segmentStartNm = route.cumulativeDistanceNm[index]!;
const segmentEndNm = route.cumulativeDistanceNm[index + 1]!;
if (distanceNm <= segmentEndNm) {
const segmentLengthNm = segmentEndNm - segmentStartNm;
const t = segmentLengthNm === 0 ? 0 : (distanceNm - segmentStartNm) / segmentLengthNm;
const start = route.coordinates[index]!;
const end = route.coordinates[index + 1]!;
return {
lat: start.lat + (end.lat - start.lat) * t,
lon: start.lon + (end.lon - start.lon) * t
};
}
}
return route.coordinates.at(-1)!;
}
function normalizeAmenities(
amenities: VoyageHarbour["amenities"]
): Record<VoyageAmenity, VoyageAmenityAvailability> {
return Object.fromEntries(
VOYAGE_AMENITIES.map((amenity) => [amenity, normalizeAvailability(amenities?.[amenity])])
) as Record<VoyageAmenity, VoyageAmenityAvailability>;
}
function normalizeAvailability(
value: VoyageAmenityAvailability | boolean | null | undefined
): VoyageAmenityAvailability {
if (value === true || value === "available") {
return "available";
}
if (value === false || value === "unavailable") {
return "unavailable";
}
return "unknown";
}
function availabilityFromProperties(
properties: Record<string, unknown>,
keys: string[],
category: string,
categoryTokens: string[]
): VoyageAmenityAvailability {
const values: VoyageAmenityAvailability[] = [];
for (const key of keys) {
if (Object.prototype.hasOwnProperty.call(properties, key)) {
values.push(availabilityFromUnknown(properties[key]));
}
}
if (categoryTokens.some((token) => category.includes(token)) || values.includes("available")) {
return "available";
}
return values.includes("unavailable") ? "unavailable" : "unknown";
}
function specialAmenityAvailability(
properties: Record<string, unknown>,
amenityValue: string,
keys: string[],
category: string,
categoryTokens: string[]
): VoyageAmenityAvailability {
if (String(properties.amenity ?? "").toLowerCase() === amenityValue) {
return "available";
}
return availabilityFromProperties(properties, keys, category, categoryTokens);
}
function availabilityFromUnknown(value: unknown): VoyageAmenityAvailability {
if (value === true || (typeof value === "number" && value > 0)) {
return "available";
}
if (value === false || value === 0) {
return "unavailable";
}
if (typeof value !== "string") {
return "unknown";
}
const normalized = value.trim().toLowerCase();
if (["yes", "true", "1", "available", "designated", "customers"].includes(normalized)) {
return "available";
}
if (["no", "false", "0", "none", "unavailable"].includes(normalized)) {
return "unavailable";
}
const numeric = Number(normalized);
return Number.isFinite(numeric) && numeric > 0 ? "available" : "unknown";
}
function uniqueAmenities(amenities: VoyageAmenity[]) {
const selected = new Set(amenities);
return VOYAGE_AMENITIES.filter((amenity) => selected.has(amenity));
}
function noSuitableHarbourMessage(
day: number,
requiredAmenities: VoyageAmenity[],
maxHarbourDetourNm: number
) {
const amenityText =
requiredAmenities.length > 0
? ` mit ${requiredAmenities.map(voyageAmenityLabel).join(", ")}`
: "";
return `Für Etappe ${day} wurde innerhalb von ${formatNm(maxHarbourDetourNm)} sm zur Route kein geeigneter Hafen${amenityText} gefunden. Das Tagesziel ist kein bestätigter Liegeplatz.`;
}
function assertPositiveFinite(value: number, name: string) {
if (!Number.isFinite(value) || value <= 0) {
throw new RangeError(`${name} must be a positive finite number`);
}
}
function nonNegativeFiniteOrDefault(value: number | undefined, fallback: number, name: string) {
if (value === undefined) {
return fallback;
}
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(`${name} must be a non-negative finite number`);
}
return value;
}
function ratioOrDefault(value: number | undefined, fallback: number) {
if (value === undefined) {
return fallback;
}
if (!Number.isFinite(value) || value < 0 || value > 1) {
throw new RangeError("preferredMinimumDayProgressRatio must be between 0 and 1");
}
return value;
}
function formatNm(value: number) {
return value.toLocaleString("de-DE", { maximumFractionDigits: 1 });
}
+288
View File
@@ -0,0 +1,288 @@
import { describe, expect, it } from "vitest";
import {
analyzeTideWindow,
calculateAnchorRodePlan,
evaluateAnchorWatch,
type AnchorTideWindowResult,
type TideCurvePoint,
type TideSummary
} from "../src/index.js";
const METRES_PER_EQUATOR_DEGREE = 111_195.08;
const START = Date.parse("2026-07-20T01:00:00.000Z");
function latitudeOffsetM(metres: number): number {
return metres / METRES_PER_EQUATOR_DEGREE;
}
function curve(): TideCurvePoint[] {
return [
{ time: "2026-07-20T00:00:00.000Z", predictedM: 1 },
{
time: "2026-07-20T02:00:00.000Z",
predictedM: 2,
forecastM: 2.5,
measuredM: 3
},
{ time: "2026-07-20T04:00:00.000Z", predictedM: 0 },
{ time: "2026-07-20T06:00:00.000Z", predictedM: 2 }
];
}
function completeTide(maximumRiseM = 1): AnchorTideWindowResult {
return {
coverage: "complete",
reason: null,
fromTime: "2026-07-20T00:00:00.000Z",
untilTime: "2026-07-20T06:00:00.000Z",
coveredUntilTime: "2026-07-20T06:00:00.000Z",
horizonHours: 6,
startHeightM: 0,
minimumHeightM: 0,
maximumHeightM: maximumRiseM,
maximumRiseM,
tidalRangeM: maximumRiseM,
sampleCount: 3
};
}
describe("anchor watch", () => {
it("alarms only when the entire GPS accuracy circle is outside the anchor radius", () => {
const result = evaluateAnchorWatch({
anchorPoint: { lat: 0, lon: 0 },
position: { lat: latitudeOffsetM(150), lon: 0 },
alarmRadiusM: 100,
accuracyM: 20,
maxReliableAccuracyM: 50
});
expect(result).not.toBeNull();
expect(result?.distanceFromAnchorM).toBeCloseTo(150, 0);
expect(result?.conservativeDistanceFromAnchorM).toBeCloseTo(130, 0);
expect(result?.positionReliable).toBe(true);
expect(result?.isOutsideAlarmRadius).toBe(true);
expect(result?.isConservativelyOutsideAlarmRadius).toBe(true);
expect(result?.alarmTriggered).toBe(true);
expect(result?.status).toBe("alarm");
});
it("does not alarm while GPS uncertainty still overlaps the permitted circle", () => {
const result = evaluateAnchorWatch({
anchorPoint: { lat: 0, lon: 0 },
position: { lat: latitudeOffsetM(110), lon: 0 },
alarmRadiusM: 100,
accuracyM: 20
});
expect(result?.isOutsideAlarmRadius).toBe(true);
expect(result?.conservativeDistanceFromAnchorM).toBeCloseTo(90, 0);
expect(result?.isConservativelyOutsideAlarmRadius).toBe(false);
expect(result?.alarmTriggered).toBe(false);
expect(result?.status).toBe("safe");
});
it("suppresses alarms for inaccurate or missing accuracy information", () => {
const inaccurate = evaluateAnchorWatch({
anchorPoint: { lat: 0, lon: 0 },
position: { lat: latitudeOffsetM(400), lon: 0 },
alarmRadiusM: 100,
accuracyM: 150,
maxReliableAccuracyM: 100
});
const missing = evaluateAnchorWatch({
anchorPoint: { lat: 0, lon: 0 },
position: { lat: latitudeOffsetM(400), lon: 0 },
alarmRadiusM: 100
});
expect(inaccurate?.isConservativelyOutsideAlarmRadius).toBe(true);
expect(inaccurate?.positionReliable).toBe(false);
expect(inaccurate?.alarmTriggered).toBe(false);
expect(inaccurate?.status).toBe("gps-unreliable");
expect(missing?.conservativeDistanceFromAnchorM).toBeNull();
expect(missing?.positionReliable).toBe(false);
expect(missing?.alarmTriggered).toBe(false);
});
it("rejects invalid watch geometry and configuration without throwing", () => {
expect(evaluateAnchorWatch({
anchorPoint: { lat: Number.NaN, lon: 7 },
position: { lat: 53, lon: 7 },
alarmRadiusM: 100,
accuracyM: 10
})).toBeNull();
expect(evaluateAnchorWatch({
anchorPoint: { lat: 53, lon: 7 },
position: { lat: 53, lon: 7 },
alarmRadiusM: 0,
accuracyM: 10
})).toBeNull();
});
});
describe("anchor tide window", () => {
it("interpolates both window boundaries and calculates rise and tidal range", () => {
const result = analyzeTideWindow(curve(), START, 4);
expect(result.coverage).toBe("complete");
expect(result.reason).toBeNull();
expect(result.startHeightM).toBeCloseTo(2, 6);
expect(result.minimumHeightM).toBe(0);
expect(result.maximumHeightM).toBe(3);
expect(result.maximumRiseM).toBe(1);
expect(result.tidalRangeM).toBe(3);
expect(result.coveredUntilTime).toBe("2026-07-20T05:00:00.000Z");
expect(result.sampleCount).toBe(4);
});
it("accepts a TideSummary and prioritises measurements over forecasts and predictions", () => {
const summary: TideSummary = {
station: "Test",
distanceKm: 1,
nextHigh: null,
nextLow: null,
waterLevelCurve: curve(),
source: "test",
updatedAt: "2026-07-20T00:00:00.000Z"
};
const result = analyzeTideWindow(summary, Date.parse("2026-07-20T02:00:00Z"), 1);
expect(result.startHeightM).toBe(3);
expect(result.maximumHeightM).toBe(3);
});
it("reports partial coverage and still describes only the known part", () => {
const result = analyzeTideWindow(curve(), START, 8);
expect(result.coverage).toBe("partial");
expect(result.reason).toBe("incomplete-horizon");
expect(result.untilTime).toBe("2026-07-20T09:00:00.000Z");
expect(result.coveredUntilTime).toBe("2026-07-20T06:00:00.000Z");
expect(result.maximumRiseM).toBe(1);
expect(result.tidalRangeM).toBe(3);
});
it("handles missing, malformed, and out-of-coverage tide data", () => {
expect(analyzeTideWindow(undefined, START, 6)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "missing-tide-data" })
);
expect(analyzeTideWindow([
{ time: "not-a-time", predictedM: 2 },
{ time: "2026-07-20T00:00:00Z", predictedM: null }
], START, 6)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "missing-tide-data" })
);
expect(analyzeTideWindow(curve(), Date.parse("2026-07-19T20:00:00Z"), 2)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "start-outside-coverage" })
);
expect(analyzeTideWindow(curve(), Number.NaN, 6)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "invalid-window" })
);
expect(analyzeTideWindow(curve(), Number.MAX_VALUE, 6)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "invalid-window", fromTime: null })
);
expect(analyzeTideWindow(curve(), START, 0)).toEqual(
expect.objectContaining({ coverage: "unavailable", reason: "invalid-window" })
);
});
it("never treats a falling tide as a negative future rise", () => {
const falling = analyzeTideWindow([
{ time: "2026-07-20T00:00:00Z", predictedM: 3 },
{ time: "2026-07-20T02:00:00Z", predictedM: 2 },
{ time: "2026-07-20T04:00:00Z", predictedM: 1 }
], Date.parse("2026-07-20T00:00:00Z"), 4);
expect(falling.coverage).toBe("complete");
expect(falling.maximumRiseM).toBe(0);
expect(falling.tidalRangeM).toBe(2);
});
});
describe("anchor rode plan", () => {
it("includes tide rise, bow roller, safety allowance, and selected scope", () => {
const plan = calculateAnchorRodePlan({
depthAtSetM: 4,
bowRollerHeightM: 1,
deployedRodeLengthM: 40,
scopeRatio: 5,
safetyAllowanceM: 0.5,
tideWindow: completeTide(1)
});
expect(plan).not.toBeNull();
expect(plan?.calculationComplete).toBe(true);
expect(plan?.verticalDistanceAtSetM).toBe(5);
expect(plan?.minimumRequiredRodeLengthM).toBe(27.5);
expect(plan?.maximumVerticalDistanceM).toBe(6);
expect(plan?.planningVerticalDistanceM).toBe(6.5);
expect(plan?.requiredRodeLengthM).toBe(32.5);
expect(plan?.rodeReserveM).toBe(7.5);
expect(plan?.hasSufficientRode).toBe(true);
expect(plan?.horizontalReachM).toBeCloseTo(Math.sqrt(40 ** 2 - 6 ** 2), 6);
expect(plan?.rodeReachesBottomAtMaximumTide).toBe(true);
});
it("reports a negative reserve and insufficient rode", () => {
const plan = calculateAnchorRodePlan({
depthAtSetM: 4,
bowRollerHeightM: 1,
deployedRodeLengthM: 30,
scopeRatio: 5,
safetyAllowanceM: 0.5,
tideWindow: completeTide(1)
});
expect(plan?.requiredRodeLengthM).toBe(32.5);
expect(plan?.rodeReserveM).toBe(-2.5);
expect(plan?.hasSufficientRode).toBe(false);
});
it("does not present an incomplete tide horizon as a safe future plan", () => {
const partialTide = { ...completeTide(1), coverage: "partial" as const, reason: "incomplete-horizon" as const };
const plan = calculateAnchorRodePlan({
depthAtSetM: 4,
bowRollerHeightM: 1,
deployedRodeLengthM: 40,
scopeRatio: 5,
tideWindow: partialTide
});
expect(plan?.calculationComplete).toBe(false);
expect(plan?.minimumRequiredRodeLengthM).toBe(27.5);
expect(plan?.requiredRodeLengthM).toBeNull();
expect(plan?.rodeReserveM).toBeNull();
expect(plan?.hasSufficientRode).toBeNull();
expect(plan?.horizontalReachAtSetM).toBeCloseTo(Math.sqrt(40 ** 2 - 5 ** 2), 6);
expect(plan?.horizontalReachM).toBeNull();
});
it("validates physical inputs and handles a rode shorter than the vertical drop", () => {
expect(calculateAnchorRodePlan({
depthAtSetM: -1,
bowRollerHeightM: 1,
deployedRodeLengthM: 20,
scopeRatio: 5,
tideWindow: completeTide()
})).toBeNull();
expect(calculateAnchorRodePlan({
depthAtSetM: 4,
bowRollerHeightM: 1,
deployedRodeLengthM: 20,
scopeRatio: 0,
tideWindow: completeTide()
})).toBeNull();
const tooShort = calculateAnchorRodePlan({
depthAtSetM: 8,
bowRollerHeightM: 2,
deployedRodeLengthM: 9,
scopeRatio: 3,
tideWindow: completeTide(1)
});
expect(tooShort?.horizontalReachAtSetM).toBe(0);
expect(tooShort?.horizontalReachM).toBe(0);
expect(tooShort?.rodeReachesBottomAtSet).toBe(false);
expect(tooShort?.rodeReachesBottomAtMaximumTide).toBe(false);
});
});
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { haversineDistanceNm, initialBearingDeg, normalizeHeadingDeg } from "../src/index.js";
describe("geo helpers", () => {
it("calculates a practical nautical-mile distance", () => {
const distance = haversineDistanceNm(
{ lat: 53.541, lon: 9.966 },
{ lat: 54.18, lon: 12.09 }
);
expect(distance).toBeGreaterThan(80);
expect(distance).toBeLessThan(95);
});
it("normalizes headings and bearings", () => {
expect(normalizeHeadingDeg(-10)).toBe(350);
expect(normalizeHeadingDeg(370)).toBe(10);
expect(initialBearingDeg({ lat: 53.5, lon: 9.9 }, { lat: 54, lon: 10.1 })).toBeGreaterThan(0);
});
});
@@ -0,0 +1,148 @@
import { describe, expect, it } from "vitest";
import {
canonicalizeMarinePois,
normalizedMarineFacilityName,
type MarinePoiCandidate
} from "../src/marine-poi-clustering.js";
const lock = (
id: string,
name: string | null,
lon: number,
properties: Record<string, unknown> = {},
overrides: Partial<MarinePoiCandidate> = {}
): MarinePoiCandidate => ({
id,
layer: "locks",
source: "osm",
sourceId: id,
name,
coordinate: { lon, lat: 51.695 },
properties,
...overrides
});
describe("marine POI canonicalization", () => {
it("normalizes facility type words but retains facility ordinals", () => {
expect(normalizedMarineFacilityName("Schleuse Werries")).toBe("werries");
expect(normalizedMarineFacilityName("Schleusengebiet Werries")).toBe("werries");
expect(normalizedMarineFacilityName("Werries Lock")).toBe("werries");
expect(normalizedMarineFacilityName("W.S.V. Sixhaven")).toBe("sixhaven");
expect(normalizedMarineFacilityName("Große Kammer Schleuse Ahsen")).toBe("ahsen");
expect(normalizedMarineFacilityName("Schleuse Ahsen Kammer 2")).toBe("ahsen");
expect(normalizedMarineFacilityName("Schleuse 55")).toBe("55");
expect(normalizedMarineFacilityName("Außentor")).toBeNull();
});
it("merges matching OSM and EuRIS locks and combines their contacts", () => {
const result = canonicalizeMarinePois([
lock("w-osm", "Schleuse Werries", 7.867, {
website: "https://osm.example/werries",
phone: "+49 2381 9019290"
}),
lock(
"w-euris",
"Werries",
7.86708,
{ "ref:EU:RIS": "DEHMM00301LOCKS00404", phone: "+49 2381 9019-290", vhf: "22" },
{ source: "euris", sourceId: "DEHMM00301LOCKS00404" }
)
]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
entityId: "marine-poi:locks:euris:DEHMM00301LOCKS00404",
canonicalSource: "euris",
memberCount: 2,
properties: {
phone: "+49 2381 9019-290",
website: "https://osm.example/werries",
vhf: "22",
source: "EuRIS + OpenStreetMap"
}
});
});
it("does not merge neighbouring facilities with different specific names", () => {
const result = canonicalizeMarinePois([
lock("a", "Wilhelminasluis", 4.724),
lock("b", "Hondsbossche Sluis", 4.72418)
]);
expect(result).toHaveLength(2);
});
it("merges duplicate facilities with the exact same name despite differing source websites", () => {
const result = canonicalizeMarinePois([
lock("relation", "Kesselschleuse Emden", 7.2, { website: "https://city.example/lock" }),
lock("way", "Kesselschleuse Emden", 7.20008, { website: "https://operator.example/lock" })
]);
expect(result).toHaveLength(1);
expect(result[0]?.properties.websites).toEqual([
"https://city.example/lock",
"https://operator.example/lock"
]);
});
it("does not merge equal names carrying conflicting official IDs", () => {
const result = canonicalizeMarinePois([
lock("a", "Schleuse 55", 7.1, { "ref:EU:RIS": "DE-55" }),
lock("b", "Schleuse 55", 7.1001, { "ref:EU:RIS": "DE-56" })
]);
expect(result).toHaveLength(2);
});
it("assigns an unnamed gate to exactly the nearest anchor without bridging facilities", () => {
const result = canonicalizeMarinePois([
lock("west", "Schleuse West", 7),
lock("east", "Schleuse Ost", 7.0018),
lock("gate", "Außentor", 7.0002, { waterway: "lock_gate" })
]);
expect(result).toHaveLength(2);
expect(result.find((poi) => poi.name === "Schleuse West")?.memberIds).toContain("gate");
expect(result.find((poi) => poi.name === "Schleuse Ost")?.memberIds).not.toContain("gate");
});
it("groups a named harbour anchor with its dock component", () => {
const result = canonicalizeMarinePois([
{
id: "marina",
layer: "harbours",
source: "osm",
sourceId: "w1",
name: "Stadthafen",
coordinate: { lon: 7.7, lat: 52 },
properties: { leisure: "marina" }
},
{
id: "dock",
layer: "harbours",
source: "osm",
sourceId: "w2",
name: "Stadthafen",
coordinate: { lon: 7.7004, lat: 52 },
properties: { waterway: "dock" }
}
]);
expect(result).toHaveLength(1);
expect(result[0]?.memberCount).toBe(2);
});
it("is independent of input order and adding a gate keeps the anchor ID stable", () => {
const anchor = lock("anchor", "Kesselschleuse Emden", 7.2, { lock: "yes" });
const gate = lock("gate", null, 7.2001, { waterway: "lock_gate" });
const forward = canonicalizeMarinePois([anchor, gate]);
const reverse = canonicalizeMarinePois([gate, anchor]);
const anchorOnly = canonicalizeMarinePois([anchor]);
expect(forward).toEqual(reverse);
expect(forward[0]?.entityId).toBe(anchorOnly[0]?.entityId);
});
it("keeps same-named facilities apart beyond the layer radius", () => {
const result = canonicalizeMarinePois([
lock("one", "Keersluis", 5),
lock("two", "Keersluis", 5.02)
]);
expect(result).toHaveLength(2);
});
});
@@ -0,0 +1,291 @@
import { describe, expect, it } from "vitest";
import {
calculateRouteGuidance,
type GeoJsonLineString,
type RouteGuidanceRoute
} from "../src/index.js";
const METRES_PER_EQUATOR_DEGREE = 111_195.08;
function latitudeOffsetM(metres: number): number {
return metres / METRES_PER_EQUATOR_DEGREE;
}
function requireGuidance(
route: RouteGuidanceRoute,
position = { lat: 0, lon: 0 },
input: Partial<Parameters<typeof calculateRouteGuidance>[0]> = {},
options: Parameters<typeof calculateRouteGuidance>[1] = {}
) {
const result = calculateRouteGuidance({ route, position, ...input }, options);
expect(result).not.toBeNull();
if (!result) {
throw new Error("Expected valid route guidance");
}
return result;
}
describe("route guidance", () => {
it("projects a GPS fix geodesically and steers back towards the route", () => {
const result = requireGuidance(
[[0, 0], [0.02, 0]],
{ lat: latitudeOffsetM(100), lon: 0.005 },
{ headingDeg: 90, accuracyM: 5 },
{ minLookaheadM: 100, maxLookaheadM: 100, offRouteThresholdM: 200 }
);
expect(result.distanceToRouteM).toBeCloseTo(100, 0);
expect(result.crossTrackErrorM).toBeCloseTo(-100, 0);
expect(result.crossTrackSide).toBe("port");
expect(result.nearestRoutePoint.lat).toBeCloseTo(0, 6);
expect(result.nearestRoutePoint.lon).toBeCloseTo(0.005, 5);
expect(result.desiredCourseDeg).toBeGreaterThan(90);
expect(result.desiredCourseDeg).toBeLessThan(180);
expect(result.courseCorrectionDeg).toBeGreaterThan(0);
expect(result.status).toBe("on-route");
});
it("adapts the interception course continuously to either side of the line", () => {
const route = [[0, 0], [0.02, 0]] as const;
const north = requireGuidance(
route,
{ lat: latitudeOffsetM(50), lon: 0.005 },
{},
{ minLookaheadM: 150, maxLookaheadM: 150 }
);
const south = requireGuidance(
route,
{ lat: latitudeOffsetM(-50), lon: 0.005 },
{},
{ minLookaheadM: 150, maxLookaheadM: 150 }
);
expect(north.desiredCourseDeg).toBeGreaterThan(90);
expect(south.desiredCourseDeg).toBeLessThan(90);
expect(north.crossTrackSide).toBe("port");
expect(south.crossTrackSide).toBe("starboard");
});
it("uses speed and GPS accuracy for lookahead and respects its upper bound", () => {
const route = [[0, 0], [0.2, 0]] as const;
const stationary = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 0, accuracyM: 0 });
const moving = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 10, accuracyM: 20 });
const capped = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 100, accuracyM: 20 });
expect(stationary.lookaheadDistanceM).toBeCloseTo(50, 6);
expect(moving.lookaheadDistanceM).toBeGreaterThan(stationary.lookaheadDistanceM);
expect(moving.lookaheadDistanceM).toBeCloseTo(182.89, 1);
expect(capped.lookaheadDistanceM).toBe(400);
});
it("caps its target at a significant bend instead of cutting across a canal corner", () => {
const result = requireGuidance(
[[0, 0], [0.001, 0], [0.001, -0.003]],
{ lat: 0, lon: 0 },
{ speedKn: 15 },
{
minLookaheadM: 50,
maxLookaheadM: 500,
lookaheadTimeS: 30,
turnSampleDistanceM: 30,
turnSearchDistanceM: 1_000
}
);
expect(result.nextTurn?.direction).toBe("starboard");
expect(result.lookaheadDistanceM).toBeCloseTo(111.2, 0);
expect(result.lookaheadPoint.lon).toBeCloseTo(0.001, 6);
expect(result.lookaheadPoint.lat).toBeCloseTo(0, 6);
expect(result.desiredCourseDeg).toBeCloseTo(90, 1);
});
it("returns a wrapped course correction and no invented correction without heading", () => {
const route = [[0, 0], [0.001, 0.02]] as const;
const withHeading = requireGuidance(route, { lat: 0, lon: 0 }, { headingDeg: 355 });
const withoutHeading = requireGuidance(route);
expect(withHeading.desiredCourseDeg).toBeGreaterThan(2);
expect(withHeading.desiredCourseDeg).toBeLessThan(4);
expect(withHeading.courseCorrectionDeg).toBeGreaterThan(7);
expect(withHeading.courseCorrectionDeg).toBeLessThan(9);
expect(withoutHeading.courseCorrectionDeg).toBeNull();
});
it("reports progress, route remainder, and direct destination distance", () => {
const result = requireGuidance(
[[0, 0], [0.01, 0], [0.02, 0]],
{ lat: 0, lon: 0.0075 }
);
expect(result.routeLengthM).toBeCloseTo(2_224, 0);
expect(result.progressM).toBeCloseTo(834, 0);
expect(result.progressRatio).toBeCloseTo(0.375, 3);
expect(result.remainingRouteDistanceM).toBeCloseTo(1_390, 0);
expect(result.distanceToDestinationM).toBeCloseTo(1_390, 0);
});
it("distinguishes confirmed off-route fixes from unreliable GPS fixes", () => {
const route = [[0, 0], [0.02, 0]] as const;
const offRoute = requireGuidance(
route,
{ lat: latitudeOffsetM(170), lon: 0.005 },
{ accuracyM: 20 },
{ offRouteThresholdM: 100, maxReliableAccuracyM: 100 }
);
const inaccurate = requireGuidance(
route,
{ lat: latitudeOffsetM(170), lon: 0.005 },
{ accuracyM: 150 },
{ offRouteThresholdM: 100, maxReliableAccuracyM: 100 }
);
expect(offRoute.conservativeDistanceToRouteM).toBeCloseTo(150, 0);
expect(offRoute.isOffRoute).toBe(true);
expect(offRoute.status).toBe("off-route");
expect(inaccurate.positionReliable).toBe(false);
expect(inaccurate.isOffRoute).toBe(false);
expect(inaccurate.status).toBe("gps-unreliable");
});
it("marks arrival only near the route end", () => {
const route = [[0, 0], [0.01, 0]] as const;
const arrived = requireGuidance(route, { lat: 0, lon: 0.0099 }, { accuracyM: 5 });
const nearDestinationButAtEarlyLoop = requireGuidance(
[[0, 0], [0.02, 0], [0.0001, 0]],
{ lat: 0, lon: 0 },
{ accuracyM: 2 }
);
expect(arrived.status).toBe("arrived");
expect(arrived.remainingRouteDistanceM).toBeLessThan(15);
expect(nearDestinationButAtEarlyLoop.status).not.toBe("arrived");
});
it("identifies starboard and port course changes with an advance warning", () => {
const options = {
turnSampleDistanceM: 40,
turnNoticeDistanceM: 200,
turnSearchDistanceM: 1_000,
minLookaheadM: 20,
maxLookaheadM: 20
};
const starboard = requireGuidance(
[[0, 0], [0.005, 0], [0.005, -0.005]],
{ lat: 0, lon: 0.004 },
{},
options
);
const port = requireGuidance(
[[0, 0], [0.005, 0], [0.005, 0.005]],
{ lat: 0, lon: 0.004 },
{},
options
);
expect(starboard.nextTurn).toEqual(expect.objectContaining({ direction: "starboard" }));
expect(starboard.nextTurn?.courseChangeDeg).toBeCloseTo(90, 0);
expect(starboard.nextTurn?.distanceM).toBeCloseTo(111, 0);
expect(starboard.status).toBe("approaching-turn");
expect(port.nextTurn).toEqual(expect.objectContaining({ direction: "port" }));
expect(port.nextTurn?.courseChangeDeg).toBeCloseTo(-90, 0);
});
it("does not call insignificant line noise a turn", () => {
const result = requireGuidance(
[[0, 0], [0.001, 0], [0.002, 0.00001], [0.003, 0]],
{ lat: 0, lon: 0 },
{},
{ turnMinimumChangeDeg: 20, turnSearchDistanceM: 1_000 }
);
expect(result.nextTurn).toBeNull();
expect(result.status).toBe("on-route");
});
it("handles geodesic projection and lookahead across the antimeridian", () => {
const result = requireGuidance(
[[179.9, 0], [-179.9, 0]],
{ lat: 0.001, lon: 180 },
{},
{ minLookaheadM: 100, maxLookaheadM: 100, offRouteThresholdM: 200 }
);
expect(result.distanceToRouteM).toBeCloseTo(111.2, 0);
expect(result.progressRatio).toBeCloseTo(0.5, 2);
expect(Math.abs(result.nearestRoutePoint.lon)).toBeCloseTo(180, 5);
expect(result.lookaheadPoint.lon).toBeLessThan(-179.9);
});
it("uses previous progress to disambiguate crossings without a distant forward jump", () => {
const crossingRoute = [
[-0.01, -0.01],
[0.01, 0.01],
[0.01, -0.01],
[-0.01, 0.01]
] as const;
// This point is exactly on the late diagonal and only a few metres from
// the early diagonal at their crossing.
const crossingPosition = { lat: -0.00002, lon: 0.00002 };
const early = requireGuidance(
crossingRoute,
crossingPosition,
{ previousProgressM: 1_500 },
{ maxProgressJumpM: 1_000, progressAmbiguityM: 30 }
);
const late = requireGuidance(
crossingRoute,
crossingPosition,
{ previousProgressM: 6_800 },
{ maxProgressJumpM: 1_000, progressAmbiguityM: 30 }
);
expect(early.progressM).toBeGreaterThan(1_500);
expect(early.progressM).toBeLessThan(2_000);
expect(late.progressM).toBeGreaterThan(6_500);
expect(early.nearestSegmentIndex).toBe(0);
expect(late.nearestSegmentIndex).toBe(2);
});
it("holds monotonic progress against small backwards GPS movement", () => {
const previousProgressM = 0.005 * METRES_PER_EQUATOR_DEGREE;
const result = requireGuidance(
[[0, 0], [0.02, 0]],
{ lat: 0, lon: 0.004 },
{ previousProgressM }
);
expect(result.progressM).toBeCloseTo(previousProgressM, 3);
expect(result.nearestRoutePoint.lon).toBeCloseTo(0.004, 6);
});
it("accepts GeoJSON and RouteResult shapes and rejects invalid or degenerate input", () => {
const geometry: GeoJsonLineString = {
type: "LineString",
coordinates: [[7, 53], [7.01, 53.01]]
};
const routeResult = {
geometry,
distanceNm: 1,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: []
};
expect(calculateRouteGuidance({ route: geometry, position: { lat: 53, lon: 7 } })).not.toBeNull();
expect(calculateRouteGuidance({ route: routeResult, position: { lat: 53, lon: 7 } })).not.toBeNull();
expect(calculateRouteGuidance({
route: [[7, 53]],
position: { lat: 53, lon: 7 }
})).toBeNull();
expect(calculateRouteGuidance({
route: [[7, 53], [7, 53]],
position: { lat: 53, lon: 7 }
})).toBeNull();
expect(calculateRouteGuidance({
route: [[7, 53], [7.01, 53.01]],
position: { lat: Number.NaN, lon: 7 }
})).toBeNull();
});
});
+299
View File
@@ -0,0 +1,299 @@
import { describe, expect, it } from "vitest";
import {
buildFairwayRoute,
buildFairwayRoutes,
buildManualRoute,
buildRoute,
haversineDistanceNm,
requiredDepthM,
type FairwayEdge,
type FairwayGraph
} from "../src/index.js";
const EMDEN_AUSSENHAFEN = { lat: 53.344167, lon: 7.186111 };
const BORKUM_REEDE = { lat: 53.563776, lon: 6.750562 };
const HAMM_INNENSTADT_MARINA = { lat: 51.6814536, lon: 7.8042615 };
const ALTERNATIVE_GRAPH: FairwayGraph = {
id: "alternative-test",
name: "Alternativen-Testnetz",
maxSnapDistanceNm: 0.2,
nodes: [
{ id: "start", coordinate: { lat: 52, lon: 7 } },
{ id: "branch-in", coordinate: { lat: 52, lon: 7.01 } },
{ id: "upper", coordinate: { lat: 52.012, lon: 7.03 } },
{ id: "lower", coordinate: { lat: 51.988, lon: 7.03 } },
{ id: "branch-out", coordinate: { lat: 52, lon: 7.05 } },
{ id: "destination", coordinate: { lat: 52, lon: 7.06 } }
],
edges: [
edge("start-access", "start", "branch-in", [{ lat: 52, lon: 7 }, { lat: 52, lon: 7.01 }]),
edge("main", "branch-in", "branch-out", [{ lat: 52, lon: 7.01 }, { lat: 52, lon: 7.05 }]),
edge("upper-in", "branch-in", "upper", [{ lat: 52, lon: 7.01 }, { lat: 52.012, lon: 7.03 }]),
edge("upper-out", "upper", "branch-out", [{ lat: 52.012, lon: 7.03 }, { lat: 52, lon: 7.05 }]),
edge("lower-in", "branch-in", "lower", [{ lat: 52, lon: 7.01 }, { lat: 51.988, lon: 7.03 }]),
edge("lower-out", "lower", "branch-out", [{ lat: 51.988, lon: 7.03 }, { lat: 52, lon: 7.05 }]),
edge("destination-access", "branch-out", "destination", [
{ lat: 52, lon: 7.05 },
{ lat: 52, lon: 7.06 }
])
]
};
function edge(
id: string,
from: string,
to: string,
coordinates: FairwayEdge["coordinates"],
restrictions: Partial<FairwayEdge> = {}
): FairwayEdge {
return {
id,
name: id,
from,
to,
coordinates,
minDepthM: 4,
source: "synthetic-test",
...restrictions
};
}
function singleEdgeGraph(restrictions: Partial<FairwayEdge>): FairwayGraph {
return {
id: "restriction-test",
name: "Restriktions-Testnetz",
maxSnapDistanceNm: 0.2,
nodes: [
{ id: "start", coordinate: { lat: 52, lon: 7 } },
{ id: "destination", coordinate: { lat: 52, lon: 7.04 } }
],
edges: [
edge(
"restricted",
"start",
"destination",
[{ lat: 52, lon: 7 }, { lat: 52, lon: 7.04 }],
restrictions
)
]
};
}
describe("route assessment", () => {
it("marks routes with no depth data as unknown", () => {
const result = buildManualRoute({
start: { lat: 54.18, lon: 12.08 },
destination: { lat: 54.32, lon: 12.22 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
});
expect(result.unknownDepthRatio).toBe(1);
expect(result.warnings.some((warning) => warning.code === "DEPTH_UNKNOWN")).toBe(true);
});
it("flags known shallow samples as critical", () => {
const result = buildManualRoute({
start: { lat: 54.18, lon: 12.08 },
destination: { lat: 54.32, lon: 12.22 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 },
depthSamples: [
{ coordinate: { lat: 54.2, lon: 12.1 }, depthM: 1.6 },
{ coordinate: { lat: 54.25, lon: 12.16 }, depthM: 2.4 }
]
});
expect(requiredDepthM({ draughtM: 1.4, safetyReserveM: 0.5 })).toBe(1.9);
expect(result.minKnownDepthM).toBe(1.6);
expect(result.warnings.some((warning) => warning.severity === "critical")).toBe(true);
});
it("routes Emden Außenhafen to Borkum Reede along the Ems fairway graph", () => {
const result = buildRoute({
start: EMDEN_AUSSENHAFEN,
destination: BORKUM_REEDE,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
});
expect(result).not.toBeNull();
if (!result) {
throw new Error("Expected fairway route");
}
const directDistanceNm = haversineDistanceNm(EMDEN_AUSSENHAFEN, BORKUM_REEDE);
const coordinates = result.geometry.coordinates;
const largestSegmentNm = coordinates.slice(1).reduce((largest, coordinate, index) => {
const previous = coordinates[index]!;
return Math.max(
largest,
haversineDistanceNm(
{ lon: previous[0], lat: previous[1] },
{ lon: coordinate[0], lat: coordinate[1] }
)
);
}, 0);
expect(result.routingMode).toBe("fairway");
expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed");
expect(result.warnings.some((warning) => warning.code === "FAIRWAY_ROUTE")).toBe(true);
expect(result.warnings.some((warning) => warning.code === "MANUAL_ROUTE")).toBe(false);
expect(coordinates.length).toBeGreaterThan(20);
expect(result.distanceNm).toBeGreaterThan(directDistanceNm * 1.2);
expect(largestSegmentNm).toBeLessThan(6);
expect(coordinates.some(([lon, lat]) => lon < 6.9 && lat < 53.45)).toBe(true);
});
it("snaps nearby Emden-Borkum clicks to fairway segments instead of requiring exact graph nodes", () => {
const clickedStart = { lat: 53.3418, lon: 7.1904 };
const clickedDestination = { lat: 53.5608, lon: 6.7548 };
const result = buildRoute({
start: clickedStart,
destination: clickedDestination,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
});
expect(result).not.toBeNull();
if (!result) {
throw new Error("Expected fairway route for nearby clicks");
}
expect(result.routingMode).toBe("fairway");
expect(result.geometry.coordinates.length).toBeGreaterThan(20);
expect(result.geometry.coordinates[0]).toEqual([clickedStart.lon, clickedStart.lat]);
expect(result.geometry.coordinates.at(-1)).toEqual([clickedDestination.lon, clickedDestination.lat]);
expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed");
});
it("does not fall back to a misleading straight line when no fairway graph matches", () => {
const result = buildRoute({
start: { lat: 54.1749, lon: 12.0731 },
destination: { lat: 54.1833, lon: 12.0928 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
});
expect(result).toBeNull();
});
it("routes Emden to Hamm via the Ems, Dortmund-Ems-Kanal and Datteln-Hamm-Kanal fallback", () => {
const result = buildRoute({
start: EMDEN_AUSSENHAFEN,
destination: HAMM_INNENSTADT_MARINA,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
});
expect(result).not.toBeNull();
expect(result?.distanceNm).toBeGreaterThan(145);
expect(result?.distanceNm).toBeLessThan(165);
expect(result?.geometry.coordinates.length).toBeGreaterThan(350);
expect(result?.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
expect(result?.dataSources).toContain("openstreetmap-geofabrik-curated-seed");
expect(result?.dataSources).toContain("openstreetmap-nominatim-curated-seed");
expect(result?.warnings.some((warning) => warning.code === "FAIRWAY_DATA_NOT_OFFICIAL")).toBe(true);
});
it("uses the requested departure time as the basis for duration and ETA", () => {
const departureTime = "2026-07-20T04:15:00.000Z";
const result = buildFairwayRoute(
{
start: { lat: 52, lon: 7 },
destination: { lat: 52, lon: 7.04 },
departureTime,
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, cruiseSpeedKn: 6 }
},
singleEdgeGraph({})
);
expect(result).not.toBeNull();
expect(result?.departureTime).toBe(departureTime);
expect(result?.durationMinutes).toBeGreaterThan(0);
expect(result?.eta).toBe(
new Date(Date.parse(departureTime) + (result?.durationMinutes ?? 0) * 60_000).toISOString()
);
});
it("returns a shortest route plus two genuinely different alternatives", () => {
const routes = buildFairwayRoutes(
{
start: { lat: 52, lon: 7 },
destination: { lat: 52, lon: 7.06 },
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, cruiseSpeedKn: 6 }
},
ALTERNATIVE_GRAPH
);
expect(routes).toHaveLength(3);
expect(routes.map((route) => route.id)).toEqual([
"alternative-test-route-1",
"alternative-test-route-2",
"alternative-test-route-3"
]);
expect(routes.map((route) => route.name)).toEqual(["Hauptroute", "Alternative 1", "Alternative 2"]);
expect(routes[0]!.distanceNm).toBeLessThan(routes[1]!.distanceNm);
expect(new Set(routes.map((route) => JSON.stringify(route.geometry.coordinates))).size).toBe(3);
expect(buildFairwayRoutes(
{
start: { lat: 52, lon: 7 },
destination: { lat: 52, lon: 7.06 },
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3 }
},
ALTERNATIVE_GRAPH,
1
)).toHaveLength(1);
});
it("filters edges that violate air-draft, beam, or maximum-draught restrictions", () => {
const baseRequest = {
start: { lat: 52, lon: 7 },
destination: { lat: 52, lon: 7.04 },
vesselProfile: {
draughtM: 1.4,
safetyReserveM: 0.3,
airDraftM: 3,
beamM: 3
}
};
expect(buildFairwayRoute(baseRequest, singleEdgeGraph({ maxAirDraftM: 2.5 }))).toBeNull();
expect(buildFairwayRoute(baseRequest, singleEdgeGraph({ maxBeamM: 2.5 }))).toBeNull();
expect(buildFairwayRoute(baseRequest, singleEdgeGraph({ maxDraughtM: 1.2 }))).toBeNull();
expect(
buildFairwayRoute(baseRequest, singleEdgeGraph({ maxAirDraftM: 3, maxBeamM: 3, maxDraughtM: 1.4 }))
).not.toBeNull();
});
it("uses an unrestricted detour when the shorter edge is too low for the vessel", () => {
const graph: FairwayGraph = {
...ALTERNATIVE_GRAPH,
edges: ALTERNATIVE_GRAPH.edges.map((candidate) =>
candidate.id === "main" ? { ...candidate, maxAirDraftM: 2 } : candidate
)
};
const route = buildFairwayRoute(
{
start: { lat: 52, lon: 7 },
destination: { lat: 52, lon: 7.06 },
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, airDraftM: 2.5 }
},
graph
);
expect(route).not.toBeNull();
expect(route?.geometry.coordinates.some(([, lat]) => lat !== 52)).toBe(true);
});
it("honours one-way fairway edges for direct and reverse travel", () => {
const graph = singleEdgeGraph({ oneway: true });
const profile = { draughtM: 1.2, safetyReserveM: 0.3 };
expect(
buildFairwayRoute(
{ start: { lat: 52, lon: 7 }, destination: { lat: 52, lon: 7.04 }, vesselProfile: profile },
graph
)
).not.toBeNull();
expect(
buildFairwayRoute(
{ start: { lat: 52, lon: 7.04 }, destination: { lat: 52, lon: 7 }, vesselProfile: profile },
graph
)
).toBeNull();
});
});
@@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import {
buildVoyagePlan,
harbourAmenitiesFromProperties,
orderWaypointsAlongRoute,
type RouteResult,
type VoyageHarbour
} from "../src/index.js";
function eastboundRoute(lengthDegrees = 1.8): Pick<RouteResult, "geometry" | "distanceNm"> {
return {
geometry: {
type: "LineString",
coordinates: [
[0, 0],
[lengthDegrees / 2, 0],
[lengthDegrees, 0]
]
},
distanceNm: lengthDegrees * 60
};
}
function harbour(
id: string,
lon: number,
amenities: VoyageHarbour["amenities"] = {}
): VoyageHarbour {
return {
id,
name: `Hafen ${id}`,
kind: "marina",
coordinate: { lat: 0.005, lon },
amenities
};
}
describe("voyage planning", () => {
it("orders named waypoints by their position along the route", () => {
const waypoints = orderWaypointsAlongRoute(
[
{ id: "late", name: "Später", coordinate: { lat: 0.01, lon: 1.2 } },
{ id: "early", name: "Früher", coordinate: { lat: -0.01, lon: 0.3 } }
],
eastboundRoute()
);
expect(waypoints.map(({ id }) => id)).toEqual(["early", "late"]);
expect(waypoints.map(({ sequence }) => sequence)).toEqual([1, 2]);
expect(waypoints[0]!.coordinate).toEqual({ lat: -0.01, lon: 0.3 });
expect(waypoints[0]!.routeDistanceNm).toBeCloseTo(18, 0);
expect(waypoints[0]!.distanceFromRouteNm).toBeCloseTo(0.6, 0);
});
it("creates daily stages at suitable nearby harbours and assigns crossed waypoints", () => {
const plan = buildVoyagePlan({
route: eastboundRoute(),
cruiseSpeedKn: 10,
maxCruisingHoursPerDay: 5,
requiredAmenities: ["water", "overnight"],
waypoints: [
{ id: "second", name: "Zweiter Wegpunkt", coordinate: { lat: 0, lon: 1.2 } },
{ id: "first", name: "Erster Wegpunkt", coordinate: { lat: 0, lon: 0.3 } }
],
harbours: [
harbour("unsuitable", 0.8, { overnight: true, water: false }),
harbour("day-one", 0.75, { overnight: true, water: true }),
harbour("day-two", 1.5, { overnight: "available", water: "available" })
]
});
expect(plan.legs).toHaveLength(3);
expect(plan.legs.map((leg) => leg.end.name)).toEqual([
"Hafen day-one",
"Hafen day-two",
"Ziel"
]);
expect(plan.legs.every((leg) => leg.durationHours <= 5.01)).toBe(true);
expect(plan.legs[0]!.waypoints.map(({ id }) => id)).toEqual(["first"]);
expect(plan.legs[1]!.waypoints.map(({ id }) => id)).toEqual(["second"]);
expect(plan.warnings.some(({ code }) => code === "NO_SUITABLE_HARBOUR")).toBe(false);
expect(plan.totalDistanceNm).toBeGreaterThan(plan.totalRouteDistanceNm);
});
it("uses an explicit route target and warns when no suitable harbour exists", () => {
const plan = buildVoyagePlan({
route: eastboundRoute(1.2),
cruiseSpeedKn: 6,
maxCruisingHoursPerDay: 5,
requiredAmenities: ["fuel"],
maxHarbourDetourNm: 1,
harbours: [
harbour("no-fuel", 0.4, { fuel: false }),
{ ...harbour("too-far", 0.45, { fuel: true }), coordinate: { lat: 0.1, lon: 0.45 } }
]
});
expect(plan.legs).toHaveLength(3);
expect(plan.legs[0]!.end.type).toBe("route");
expect(plan.legs[1]!.end.type).toBe("route");
expect(plan.legs[2]!.end.type).toBe("destination");
expect(plan.warnings.filter(({ code }) => code === "NO_SUITABLE_HARBOUR")).toHaveLength(2);
expect(plan.warnings[0]!.message).toContain("Treibstoff");
expect(plan.warnings[0]!.message).toContain("kein bestätigter Liegeplatz");
});
it("prefers a safe short harbour stage and reports why it ends early", () => {
const plan = buildVoyagePlan({
route: eastboundRoute(1.2),
cruiseSpeedKn: 10,
maxCruisingHoursPerDay: 5,
harbours: [harbour("early", 0.2)]
});
expect(plan.legs[0]!.end.name).toBe("Hafen early");
expect(plan.warnings.some(({ code }) => code === "SHORT_STAGE_FOR_HARBOUR")).toBe(true);
});
it("warns about waypoints that are implausibly far from the routed line", () => {
const plan = buildVoyagePlan({
route: eastboundRoute(0.2),
cruiseSpeedKn: 6,
maxCruisingHoursPerDay: 5,
maxWaypointDistanceFromRouteNm: 1,
waypoints: [
{ id: "off-route", name: "Falscher Abzweig", coordinate: { lat: 0.1, lon: 0.1 } }
]
});
expect(plan.warnings).toEqual(
expect.arrayContaining([
expect.objectContaining({ code: "WAYPOINT_OFF_ROUTE", severity: "caution" })
])
);
});
it("normalizes common OSM harbour amenity tags without treating missing data as available", () => {
expect(
harbourAmenitiesFromProperties({
power_supply: "yes",
drinking_water: true,
"fuel:diesel": "no",
amenity: "waste_disposal",
guest_berths: "12"
})
).toEqual({
electricity: "available",
water: "available",
fuel: "unavailable",
waste: "available",
overnight: "available"
});
expect(harbourAmenitiesFromProperties({}).overnight).toBe("unknown");
expect(harbourAmenitiesFromProperties({ fuel: "no", "fuel:diesel": "yes" }).fuel).toBe(
"available"
);
expect(harbourAmenitiesFromProperties({ drinking_water: null }).water).toBe("unknown");
});
it("rejects unsafe daily planning inputs", () => {
expect(() =>
buildVoyagePlan({
route: eastboundRoute(),
cruiseSpeedKn: 0,
maxCruisingHoursPerDay: 5
})
).toThrow(/cruiseSpeedKn/);
expect(() =>
buildVoyagePlan({
route: eastboundRoute(),
cruiseSpeedKn: 6,
maxCruisingHoursPerDay: Number.NaN
})
).toThrow(/maxCruisingHoursPerDay/);
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"rootDir": "src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false
},
"include": ["src/**/*.ts"]
}