Files
watermaps/packages/shared/src/fairway-routing.ts
T
2026-07-24 11:29:24 +02:00

895 lines
26 KiB
TypeScript

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;
}