Initial Watermaps import
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user