895 lines
26 KiB
TypeScript
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;
|
|
}
|