Initial Watermaps import
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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";
|
||||
@@ -0,0 +1,519 @@
|
||||
import type { FairwayGraph } from "./fairway-routing.js";
|
||||
|
||||
/**
|
||||
* Offline fallback for the federal-waterway corridor Emden–Hamm.
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user