Optimized routing
Test and publish container images / test (push) Successful in 2m21s
Test and publish container images / publish (push) Failing after 4s

This commit is contained in:
BuTzZ
2026-07-27 10:18:31 +02:00
parent 55ce94066e
commit 9813a1fafb
16 changed files with 1602 additions and 364 deletions
+349 -162
View File
@@ -5,6 +5,8 @@ import type {
RouteOption,
RouteRequest,
RouteResult,
RouteSnap,
RouteSnaps,
RouteWarning,
VesselProfile
} from "./types.js";
@@ -36,12 +38,6 @@ type EdgeSnap = {
distanceNm: number;
};
type EdgeSnapPair = {
componentId: string;
start: EdgeSnap;
destination: EdgeSnap;
};
export type FairwayGraph = {
id: string;
name: string;
@@ -50,6 +46,15 @@ export type FairwayGraph = {
edges: FairwayEdge[];
};
export class FairwayRoutingSearchLimitError extends Error {
constructor() {
super(
"Fairway routing search budget was exhausted before all snap candidates could be checked"
);
this.name = "FairwayRoutingSearchLimitError";
}
}
type PathStep = {
edge: FairwayEdge;
from: string;
@@ -71,15 +76,36 @@ type FairwayRouteCandidate = {
usedEdges: FairwayEdge[];
};
type FairwayPathState = {
currentSnap: EdgeSnap;
coordinates: Coordinate[];
usedEdges: FairwayEdge[];
costNm: number;
snaps: EdgeSnap[];
};
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 MAX_EDGE_SNAPS_PER_COMPONENT = 4;
const MAX_EDGE_SNAPS_PER_COMPONENT = 8;
// Bounds the quadratic snap-pair work across all candidate components in one
// route attempt. With the API limit of 25 waypoints (26 legs), six candidates
// still fit: 26 * 6² = 936. Truncation is surfaced instead of silently claiming
// that no fairway route exists.
const MAX_SNAP_PAIR_EVALUATIONS_PER_ATTEMPT = 1024;
// A snap is only an association with the graph; the gap is never emitted as a navigable line.
// 150 m still accepts harbour/ferry POIs that lie on the bank while rejecting remote waterways.
// This is a hard safety boundary; a graph may request a smaller radius, but never a larger one.
const MAX_ROUTE_SNAP_DISTANCE_M = 150;
const MAX_ROUTE_SNAP_DISTANCE_NM = MAX_ROUTE_SNAP_DISTANCE_M / 1852;
const SNAP_WARNING_DISTANCE_M = 10;
// Prefer the closest reachable fairway instead of shortening a route by ending early off the graph.
const SNAP_DISTANCE_COST_MULTIPLIER = 10;
const EMS_BORKUM_GRAPH: FairwayGraph = {
export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
id: "ems-borkum-seed",
name: "Emsfahrwasser Emden-Borkum",
maxSnapDistanceNm: 3,
@@ -199,15 +225,19 @@ const EMS_BORKUM_GRAPH: FairwayGraph = {
]
};
export function buildFairwayRoute(request: RouteRequest, graph = EMS_BORKUM_GRAPH): RouteResult | null {
export function buildFairwayRoute(request: RouteRequest, graph: FairwayGraph): RouteResult | null {
return buildFairwayRoutes(request, graph, 1)[0] ?? null;
}
export function buildFairwayRoutes(
request: RouteRequest,
graph = EMS_BORKUM_GRAPH,
graph: FairwayGraph,
maxRoutes = MAX_ALTERNATIVE_ROUTES
): RouteOption[] {
if (!graph) {
return [];
}
const routeLimit = Math.max(0, Math.min(MAX_ALTERNATIVE_ROUTES, Math.floor(maxRoutes)));
if (routeLimit === 0) {
return [];
@@ -220,15 +250,13 @@ export function buildFairwayRoutes(
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
const componentByNode = weaklyConnectedComponents(routableGraph);
const legSnapPairs = requestedPoints.slice(0, -1).map((start, index) =>
findCompatibleEdgeSnapPairs(
routableGraph,
start,
requestedPoints[index + 1]!,
componentByNode
)
const pointSnapsByComponent = requestedPoints.map((point) =>
findNearestEdgeSnapsByComponent(routableGraph, point, componentByNode)
);
if (legSnapPairs.some((pairs) => pairs.length === 0)) {
const commonComponentIds = [...(pointSnapsByComponent[0]?.keys() ?? [])].filter((componentId) =>
pointSnapsByComponent.every((snaps) => snaps.has(componentId))
);
if (commonComponentIds.length === 0) {
return [];
}
@@ -237,7 +265,24 @@ export function buildFairwayRoutes(
const maxAttempts = Math.max(8, routeLimit * 6);
for (let attempt = 0; attempt < maxAttempts && accepted.length < routeLimit; attempt += 1) {
const candidate = buildFairwayRouteCandidate(request, routableGraph, penaltyCounts, legSnapPairs);
let candidate: FairwayRouteCandidate | null;
try {
candidate = buildFairwayRouteCandidate(
request,
routableGraph,
penaltyCounts,
pointSnapsByComponent,
commonComponentIds
);
} catch (error) {
if (
error instanceof FairwayRoutingSearchLimitError &&
accepted.length > 0
) {
break;
}
throw error;
}
if (!candidate) {
break;
}
@@ -269,38 +314,66 @@ function buildFairwayRouteCandidate(
request: RouteRequest,
routableGraph: FairwayGraph,
penaltyCounts: ReadonlyMap<string, number>,
legSnapPairs: EdgeSnapPair[][]
pointSnapsByComponent: Array<Map<string, EdgeSnap[]>>,
commonComponentIds: string[]
): FairwayRouteCandidate | null {
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
const routeCoordinates: Coordinate[] = [];
const usedEdges = new Map<string, FairwayEdge>();
const adjacency = buildAdjacency(routableGraph, penaltyCounts);
const completedPaths: FairwayPathState[] = [];
let remainingPairEvaluations = MAX_SNAP_PAIR_EVALUATIONS_PER_ATTEMPT;
let searchTruncated = false;
let processedComponentCount = 0;
const orderedComponentIds = componentsBySnapDistance(
commonComponentIds,
pointSnapsByComponent
);
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,
legSnapPairs[index]!
for (const componentId of orderedComponentIds) {
const snapLayers = pointSnapsByComponent.map((snaps) => snaps.get(componentId) ?? []);
const maxCandidateCount = Math.max(...snapLayers.map((snaps) => snaps.length));
let candidateLimit = Math.min(MAX_EDGE_SNAPS_PER_COMPONENT, maxCandidateCount);
while (
candidateLimit > 0 &&
snapPairEvaluationCost(snapLayers, candidateLimit) > remainingPairEvaluations
) {
candidateLimit -= 1;
}
if (candidateLimit < 1) {
searchTruncated = true;
break;
}
processedComponentCount += 1;
searchTruncated ||= candidateLimit < maxCandidateCount;
remainingPairEvaluations -= snapPairEvaluationCost(snapLayers, candidateLimit);
const limitedSnapLayers = snapLayers.map((snaps) => snaps.slice(0, candidateLimit));
completedPaths.push(
...buildFairwayPathStates(
routableGraph,
penaltyCounts,
adjacency,
limitedSnapLayers
).filter((path) => path.coordinates.length >= 2)
);
}
searchTruncated ||= processedComponentCount < orderedComponentIds.length;
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 selectedPath =
completedPaths.sort(
(a, b) =>
a.costNm - b.costNm ||
sumRouteDistanceNm(a.coordinates) - sumRouteDistanceNm(b.coordinates)
)[0] ?? null;
if (!selectedPath) {
if (searchTruncated) {
throw new FairwayRoutingSearchLimitError();
}
return null;
}
const routeCoordinates = selectedPath.coordinates;
const usedEdges = selectedPath.usedEdges;
const routeSnaps = buildRouteSnaps(requestedPoints, selectedPath.snaps);
const distanceNm = round(sumRouteDistanceNm(routeCoordinates), 2);
const speedKn =
request.vesselProfile.cruiseSpeedKn && request.vesselProfile.cruiseSpeedKn > 0
@@ -309,7 +382,7 @@ function buildFairwayRouteCandidate(
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 depthSamples = request.depthSamples ?? edgeDepthSamples(usedEdges);
const depthAssessment = assessFairwayDepthSamples(depthSamples, request.vesselProfile);
const warnings: RouteWarning[] = [
{
@@ -322,6 +395,18 @@ function buildFairwayRouteCandidate(
severity: "caution",
message: "Fahrwasser-Graph ist eine MVP-Planungshilfe und keine amtliche Navigationsgrundlage."
},
...(searchTruncated
? [
{
code: "ROUTE_SEARCH_LIMITED",
severity: "caution" as const,
message:
"Die Snap-Suche wurde wegen eines dichten Fahrwassernetzes begrenzt. " +
"Die Route ist befahrbar verbunden, aber möglicherweise nicht die kürzeste Option."
}
]
: []),
...routeSnapWarnings(routeSnaps),
...depthAssessment.warnings
];
@@ -339,17 +424,163 @@ function buildFairwayRouteCandidate(
unknownDepthRatio: depthAssessment.unknownDepthRatio,
dataSources: [
`fairway-graph:${routableGraph.id}`,
...uniqueSources([...usedEdges.values()]),
...uniqueSources(usedEdges),
request.depthSamples?.length
? "submitted-depth-samples"
: [...usedEdges.values()].some((edge) => edge.minDepthM === null)
: usedEdges.some((edge) => edge.minDepthM === null)
? "fairway-depth-unknown"
: "fairway-depth-estimates"
],
routingMode: "fairway"
routingMode: "fairway",
routeSnaps
};
return { route, usedEdges: [...usedEdges.values()] };
return { route, usedEdges };
}
function componentsBySnapDistance(
componentIds: string[],
pointSnapsByComponent: Array<Map<string, EdgeSnap[]>>
): string[] {
return [...componentIds].sort((first, second) => {
const firstDistance = pointSnapsByComponent.reduce(
(total, snaps) => total + (snaps.get(first)?.[0]?.distanceNm ?? Number.POSITIVE_INFINITY),
0
);
const secondDistance = pointSnapsByComponent.reduce(
(total, snaps) => total + (snaps.get(second)?.[0]?.distanceNm ?? Number.POSITIVE_INFINITY),
0
);
return firstDistance - secondDistance || first.localeCompare(second);
});
}
function snapPairEvaluationCost(snapLayers: EdgeSnap[][], candidateLimit: number): number {
return snapLayers.slice(0, -1).reduce((total, snaps, index) => {
const currentCount = Math.min(candidateLimit, snaps.length);
const nextCount = Math.min(candidateLimit, snapLayers[index + 1]!.length);
return total + currentCount * nextCount;
}, 0);
}
function buildFairwayPathStates(
graph: FairwayGraph,
penaltyCounts: ReadonlyMap<string, number>,
adjacency: Adjacency,
snapLayers: EdgeSnap[][]
): FairwayPathState[] {
let states: FairwayPathState[] = (snapLayers[0] ?? []).map((startSnap) => ({
currentSnap: startSnap,
coordinates: [],
usedEdges: [],
costNm: startSnap.distanceNm * SNAP_DISTANCE_COST_MULTIPLIER,
snaps: [startSnap]
}));
for (let pointIndex = 1; pointIndex < snapLayers.length && states.length > 0; pointIndex += 1) {
const nextStates: FairwayPathState[] = [];
for (const destinationSnap of snapLayers[pointIndex]!) {
let bestState: FairwayPathState | null = null;
for (const state of states) {
const leg = buildFairwayLegBetweenSnaps(
graph,
state.currentSnap,
destinationSnap,
penaltyCounts,
adjacency
);
if (!leg) {
continue;
}
const coordinates = [...state.coordinates];
for (const coordinate of leg.coordinates) {
appendCoordinate(coordinates, coordinate);
}
const usedEdges = new Map(state.usedEdges.map((edge) => [edge.id, edge]));
for (const edge of leg.usedEdges) {
usedEdges.set(edge.id, edge);
}
const candidateState: FairwayPathState = {
currentSnap: destinationSnap,
coordinates,
usedEdges: [...usedEdges.values()],
costNm:
state.costNm +
leg.costNm +
destinationSnap.distanceNm * SNAP_DISTANCE_COST_MULTIPLIER,
snaps: [...state.snaps, destinationSnap]
};
if (
!bestState ||
candidateState.costNm < bestState.costNm ||
(
candidateState.costNm === bestState.costNm &&
sumRouteDistanceNm(candidateState.coordinates) <
sumRouteDistanceNm(bestState.coordinates)
)
) {
bestState = candidateState;
}
}
if (bestState) {
nextStates.push(bestState);
}
}
states = nextStates;
}
return states;
}
function buildRouteSnaps(requestedPoints: Coordinate[], snaps: EdgeSnap[]): RouteSnaps {
if (requestedPoints.length !== snaps.length || requestedPoints.length < 2) {
throw new Error("Route snap metadata does not match the requested route points");
}
const normalized = requestedPoints.map((requested, index): RouteSnap => {
const snap = snaps[index]!;
return {
requested,
snapped: snap.coordinate,
distanceM: round(snap.distanceNm * 1852, 1)
};
});
return {
start: normalized[0]!,
waypoints: normalized.slice(1, -1),
destination: normalized.at(-1)!
};
}
function routeSnapWarnings(routeSnaps: RouteSnaps): RouteWarning[] {
const points = [
{ label: "Startpunkt", snap: routeSnaps.start },
...routeSnaps.waypoints.map((snap, index) => ({
label: `Zwischenziel ${index + 1}`,
snap
})),
{ label: "Zielpunkt", snap: routeSnaps.destination }
];
return points.flatMap(({ label, snap }) => {
if (snap.distanceM < SNAP_WARNING_DISTANCE_M) {
return [];
}
return [
{
code: "ROUTE_POINT_SNAPPED",
severity: snap.distanceM >= 25 ? "caution" : "info",
message:
`${label} liegt ${Math.round(snap.distanceM)} m vom erfassten Fahrwasser entfernt. ` +
"Diese ungeprüfte Zufahrt ist nicht Teil der Routengeometrie.",
coordinate: snap.requested
} satisfies RouteWarning
];
});
}
function requestedDepartureTimestamp(value?: string): number {
@@ -392,6 +623,7 @@ function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): Fair
const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM;
return {
...graph,
maxSnapDistanceNm: Math.min(graph.maxSnapDistanceNm, MAX_ROUTE_SNAP_DISTANCE_NM),
edges: graph.edges.filter((edge) => {
if (edge.minDepthM !== null && edge.minDepthM < requiredDepthM) {
return false;
@@ -414,133 +646,85 @@ function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): Fair
};
}
function buildFairwayLeg(
function buildFairwayLegBetweenSnaps(
graph: FairwayGraph,
legStart: Coordinate,
legDestination: Coordinate,
startSnap: EdgeSnap,
destinationSnap: EdgeSnap,
penaltyCounts: ReadonlyMap<string, number>,
adjacency: Adjacency,
snapPairs: EdgeSnapPair[]
adjacency: Adjacency
): FairwayLeg | null {
const candidates: FairwayLeg[] = [];
const routedComponents = new Set<string>();
for (const { componentId, start: startSnap, destination: destinationSnap } of snapPairs) {
if (routedComponents.has(componentId)) {
if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) {
const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap);
const coordinates: Coordinate[] = [];
for (const coordinate of directOnEdge) {
appendCoordinate(coordinates, coordinate);
}
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;
}
const previousCandidateCount = candidates.length;
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:
startSnap.distanceNm +
sumRouteDistanceNm(directOnEdge) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) +
destinationSnap.distanceNm
});
}
for (const startNodeId of [startSnap.edge.from, startSnap.edge.to]) {
if (!canTraverseFromSnapToNode(startSnap, startNodeId)) {
for (const destinationNodeId of [destinationSnap.edge.from, destinationSnap.edge.to]) {
if (!canTraverseFromNodeToSnap(destinationSnap, destinationNodeId)) {
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 =
startSnap.distanceNm +
sumRouteDistanceNm(startEdgeCoordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) +
path.reduce((total, step) => total + step.weightNm, 0) +
sumRouteDistanceNm(destinationEdgeCoordinates) * edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts) +
destinationSnap.distanceNm;
candidates.push({
coordinates,
usedEdges: [...usedEdges.values()],
distanceNm,
costNm
});
const path = shortestPath(graph, adjacency, startNodeId, destinationNodeId);
if (!path) {
continue;
}
}
if (candidates.length > previousCandidateCount) {
routedComponents.add(componentId);
const coordinates: Coordinate[] = [];
const usedEdges = new Map<string, FairwayEdge>();
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);
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 findCompatibleEdgeSnapPairs(
graph: FairwayGraph,
start: Coordinate,
destination: Coordinate,
componentByNode: ReadonlyMap<string, string>
): EdgeSnapPair[] {
const startSnaps = findNearestEdgeSnapsByComponent(graph, start, componentByNode);
const destinationSnaps = findNearestEdgeSnapsByComponent(graph, destination, componentByNode);
const pairs: EdgeSnapPair[] = [];
for (const [componentId, componentStartSnaps] of startSnaps) {
const componentDestinationSnaps = destinationSnaps.get(componentId);
if (!componentDestinationSnaps) {
continue;
}
for (const startSnap of componentStartSnaps) {
for (const destinationSnap of componentDestinationSnaps) {
pairs.push({ componentId, start: startSnap, destination: destinationSnap });
}
}
}
return pairs.sort(
(a, b) =>
a.start.distanceNm + a.destination.distanceNm -
(b.start.distanceNm + b.destination.distanceNm)
);
}
function findNearestEdgeSnapsByComponent(
graph: FairwayGraph,
coordinate: Coordinate,
@@ -578,14 +762,17 @@ function findNearestEdgeSnapsByComponent(
if (nearestOnEdge) {
const componentSnaps = nearestByComponent.get(componentId) ?? [];
componentSnaps.push(nearestOnEdge);
componentSnaps.sort((a, b) => a.distanceNm - b.distanceNm);
if (componentSnaps.length > MAX_EDGE_SNAPS_PER_COMPONENT) {
componentSnaps.length = MAX_EDGE_SNAPS_PER_COMPONENT;
}
nearestByComponent.set(componentId, componentSnaps);
}
}
// Retain the complete pool inside the hard radius. The route search sorts it,
// then applies its global pair-evaluation budget so a fifth candidate remains
// reachable without allowing dense graphs to trigger unbounded Dijkstra work.
for (const componentSnaps of nearestByComponent.values()) {
componentSnaps.sort((a, b) => a.distanceNm - b.distanceNm);
}
return nearestByComponent;
}
@@ -970,7 +1157,7 @@ function assessFairwayDepthSamples(
function appendCoordinate(points: Coordinate[], coordinate: Coordinate) {
const previous = points.at(-1);
if (previous && haversineDistanceNm(previous, coordinate) < 0.005) {
if (previous && haversineDistanceNm(previous, coordinate) < 0.000001) {
return;
}
+23 -5
View File
@@ -1,5 +1,9 @@
import { coordinateToGeoJson, sumRouteDistanceNm } from "./geo.js";
import { buildFairwayRoute, type FairwayGraph } from "./fairway-routing.js";
import {
buildFairwayRoute,
EMDEN_BORKUM_DEMO_GRAPH,
type FairwayGraph
} from "./fairway-routing.js";
import { EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH } from "./inland-seed.js";
import type {
DepthSample,
@@ -11,13 +15,27 @@ import type {
const DEFAULT_CRUISE_SPEED_KN = 6;
export function buildRoute(request: RouteRequest, graph?: FairwayGraph): RouteResult | null {
if (graph) {
return buildFairwayRoute(request, graph);
export type BuildRouteOptions = {
graph?: FairwayGraph;
allowDemoSeedGraphs?: boolean;
};
export function buildRoute(
request: RouteRequest,
source?: FairwayGraph | BuildRouteOptions
): RouteResult | null {
const options = source && "nodes" in source ? { graph: source } : source;
if (options?.graph) {
return buildFairwayRoute(request, options.graph);
}
if (!options?.allowDemoSeedGraphs) {
return null;
}
return (
buildFairwayRoute(request) ??
buildFairwayRoute(request, EMDEN_BORKUM_DEMO_GRAPH) ??
buildFairwayRoute(request, EMDEN_EAST_EMS_GRAPH) ??
buildFairwayRoute(request, EMDEN_HAMM_GRAPH)
);
+13
View File
@@ -51,6 +51,18 @@ export type GeoJsonLineString = {
coordinates: [number, number][];
};
export type RouteSnap = {
requested: Coordinate;
snapped: Coordinate;
distanceM: number;
};
export type RouteSnaps = {
start: RouteSnap;
waypoints: RouteSnap[];
destination: RouteSnap;
};
type RouteDetails = {
geometry: GeoJsonLineString;
distanceNm: number;
@@ -62,6 +74,7 @@ type RouteDetails = {
unknownDepthRatio: number;
dataSources: string[];
routingMode?: "manual" | "fairway";
routeSnaps?: RouteSnaps;
};
export type RouteOption = RouteDetails & {
+330 -30
View File
@@ -4,6 +4,7 @@ import {
buildFairwayRoutes,
buildManualRoute,
buildRoute,
FairwayRoutingSearchLimitError,
haversineDistanceNm,
requiredDepthM,
type FairwayEdge,
@@ -49,10 +50,10 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
{ id: "start-decoy-b", coordinate: { lat: 53.342, lon: 7.187 } },
{ id: "destination-decoy-a", coordinate: { lat: 53.3282, lon: 6.9304 } },
{ id: "destination-decoy-b", coordinate: { lat: 53.3286, lon: 6.9294 } },
{ id: "shared-start", coordinate: { lat: 53.3395697, lon: 7.1848883 } },
{ id: "shared-start", coordinate: { lat: 53.34135, lon: 7.186 } },
{ id: "shared-east", coordinate: { lat: 53.3321722, lon: 7.1329034 } },
{ id: "shared-south", coordinate: { lat: 53.313849, lon: 7.0011017 } },
{ id: "shared-destination", coordinate: { lat: 53.3303531, lon: 6.9334715 } }
{ id: "shared-destination", coordinate: { lat: 53.32845, lon: 6.9304 } }
],
edges: [
edge("start-decoy", "start-decoy-a", "start-decoy-b", [
@@ -64,7 +65,7 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
{ lat: 53.3286, lon: 6.9294 }
], { source: "closer-but-disconnected-destination" }),
edge("shared-east", "shared-start", "shared-east", [
{ lat: 53.3395697, lon: 7.1848883 },
{ lat: 53.34135, lon: 7.186 },
{ lat: 53.3321722, lon: 7.1329034 }
], { source: "shared-local-component" }),
edge("shared-south", "shared-east", "shared-south", [
@@ -73,7 +74,7 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
], { source: "shared-local-component" }),
edge("shared-west", "shared-south", "shared-destination", [
{ lat: 53.313849, lon: 7.0011017 },
{ lat: 53.3303531, lon: 6.9334715 }
{ lat: 53.32845, lon: 6.9304 }
], { source: "shared-local-component" })
]
};
@@ -147,11 +148,14 @@ describe("route assessment", () => {
});
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 }
});
const result = buildRoute(
{
start: EMDEN_AUSSENHAFEN,
destination: BORKUM_REEDE,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
},
{ allowDemoSeedGraphs: true }
);
expect(result).not.toBeNull();
if (!result) {
throw new Error("Expected fairway route");
@@ -180,13 +184,16 @@ describe("route assessment", () => {
});
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 }
});
const clickedStart = { lat: 53.341276, lon: 7.189146 };
const clickedDestination = { lat: 53.560625, lon: 6.751271 };
const result = buildRoute(
{
start: clickedStart,
destination: clickedDestination,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
},
{ allowDemoSeedGraphs: true }
);
expect(result).not.toBeNull();
if (!result) {
@@ -195,8 +202,26 @@ describe("route assessment", () => {
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.routeSnaps?.start.requested).toEqual(clickedStart);
expect(result.routeSnaps?.destination.requested).toEqual(clickedDestination);
expect(result.routeSnaps?.start.distanceM).toBeGreaterThan(0);
expect(result.routeSnaps?.destination.distanceM).toBeGreaterThan(0);
expect(
result.warnings.some((warning) => warning.code === "ROUTE_POINT_SNAPPED")
).toBe(true);
expect(result.geometry.coordinates[0]).toEqual([
result.routeSnaps?.start.snapped.lon,
result.routeSnaps?.start.snapped.lat
]);
expect(result.geometry.coordinates.at(-1)).toEqual([
result.routeSnaps?.destination.snapped.lon,
result.routeSnaps?.destination.snapped.lat
]);
expect(result.geometry.coordinates[0]).not.toEqual([clickedStart.lon, clickedStart.lat]);
expect(result.geometry.coordinates.at(-1)).not.toEqual([
clickedDestination.lon,
clickedDestination.lat
]);
expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed");
});
@@ -213,13 +238,21 @@ describe("route assessment", () => {
);
expect(routes).toHaveLength(1);
expect(routes[0]?.geometry.coordinates[0]).toEqual([start.lon, start.lat]);
expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]);
expect(routes[0]?.geometry.coordinates[0]).toEqual([
routes[0]?.routeSnaps?.start.snapped.lon,
routes[0]?.routeSnaps?.start.snapped.lat
]);
expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([
routes[0]?.routeSnaps?.destination.snapped.lon,
routes[0]?.routeSnaps?.destination.snapped.lat
]);
expect(routes[0]?.routeSnaps?.start.requested).toEqual(start);
expect(routes[0]?.routeSnaps?.destination.requested).toEqual(destination);
expect(routes[0]?.dataSources).toContain("shared-local-component");
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-start");
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-destination");
expect(routes[0]?.distanceNm).toBeGreaterThan(9.5);
expect(routes[0]?.distanceNm).toBeLessThan(10.5);
expect(routes[0]?.distanceNm).toBeGreaterThan(9.3);
expect(routes[0]?.distanceNm).toBeLessThan(9.7);
});
it("returns no route when start and destination have no shared component inside the snap radius", () => {
@@ -241,6 +274,72 @@ describe("route assessment", () => {
).toBeNull();
});
it("accepts a nearby harbour click but hard-rejects snaps farther than 150 metres", () => {
const graph: FairwayGraph = {
...singleEdgeGraph({}),
maxSnapDistanceNm: 2
};
const nearbyRoute = buildFairwayRoute(
{
start: { lat: 52 + 140 / 1852 / 60, lon: 7 },
destination: { lat: 52, lon: 7.04 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
graph
);
const remoteRoute = buildFairwayRoute(
{
start: { lat: 52 + 160 / 1852 / 60, lon: 7 },
destination: { lat: 52, lon: 7.04 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
graph
);
expect(nearbyRoute?.routeSnaps?.start.distanceM).toBeCloseTo(140, 0);
expect(nearbyRoute?.geometry.coordinates[0]).toEqual([7, 52]);
expect(
nearbyRoute?.warnings.find((warning) => warning.code === "ROUTE_POINT_SNAPPED")
).toMatchObject({ severity: "caution" });
expect(remoteRoute).toBeNull();
});
it("uses one graph snap for an intermediate waypoint without drawing raw access segments", () => {
const waypoint = { lat: 52.00025, lon: 7.02 };
const graph: FairwayGraph = {
id: "waypoint-snap-test",
name: "Wegpunkt-Snap-Test",
maxSnapDistanceNm: 0.2,
nodes: [
{ id: "a", coordinate: { lat: 52, lon: 7 } },
{ id: "b", coordinate: { lat: 52, lon: 7.02 } },
{ id: "c", coordinate: { lat: 52, lon: 7.04 } }
],
edges: [
edge("ab", "a", "b", [{ lat: 52, lon: 7 }, { lat: 52, lon: 7.02 }]),
edge("bc", "b", "c", [{ lat: 52, lon: 7.02 }, { lat: 52, lon: 7.04 }])
]
};
const route = buildFairwayRoute(
{
start: { lat: 52, lon: 7 },
destination: { lat: 52, lon: 7.04 },
waypoints: [waypoint],
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
graph
);
expect(route).not.toBeNull();
expect(route?.routeSnaps?.waypoints).toHaveLength(1);
expect(route?.routeSnaps?.waypoints[0]).toMatchObject({
requested: waypoint,
snapped: { lat: 52, lon: 7.02 }
});
expect(route?.geometry.coordinates).not.toContainEqual([waypoint.lon, waypoint.lat]);
expect(route?.geometry.coordinates).toContainEqual([7.02, 52]);
});
it("tries another snap in the same component when the nearest one-way branch cannot be exited", () => {
const graph: FairwayGraph = {
id: "oneway-snap-fallback",
@@ -249,14 +348,14 @@ describe("route assessment", () => {
nodes: [
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
{ id: "destination", coordinate: { lat: 52, lon: 7.04 } },
{ id: "oneway-dead-end", coordinate: { lat: 52.001, lon: 7 } }
{ id: "oneway-dead-end", coordinate: { lat: 52.00035, lon: 7 } }
],
edges: [
edge(
"oneway-trap",
"junction",
"oneway-dead-end",
[{ lat: 52, lon: 7 }, { lat: 52.001, lon: 7 }],
[{ lat: 52, lon: 7 }, { lat: 52.00035, lon: 7 }],
{ oneway: true, source: "oneway-trap" }
),
edge(
@@ -270,7 +369,7 @@ describe("route assessment", () => {
};
const route = buildFairwayRoute(
{
start: { lat: 52.001, lon: 7 },
start: { lat: 52.00035, lon: 7 },
destination: { lat: 52, lon: 7.04 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
@@ -282,6 +381,183 @@ describe("route assessment", () => {
expect(route?.dataSources).not.toContain("oneway-trap");
});
it("keeps searching when only the fifth-nearest snap in a component is directionally routable", () => {
const start = { lat: 52.00035, lon: 7.0005 };
const graph: FairwayGraph = {
id: "fifth-directed-snap",
name: "Adaptiver gerichteter Snap-Test",
maxSnapDistanceNm: 1,
nodes: [
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
{ id: "trap-1", coordinate: start },
{ id: "trap-2", coordinate: { lat: 52.00036, lon: 7.0005 } },
{ id: "trap-3", coordinate: { lat: 52.00034, lon: 7.0005 } },
{ id: "trap-4", coordinate: { lat: 52.00035, lon: 7.00052 } },
{ id: "destination", coordinate: { lat: 52, lon: 7.04 } }
],
edges: [
edge("trap-1", "junction", "trap-1", [{ lat: 52, lon: 7 }, start], {
oneway: true,
source: "oneway-trap-1"
}),
edge("trap-2", "junction", "trap-2", [
{ lat: 52, lon: 7 },
{ lat: 52.00036, lon: 7.0005 }
], { oneway: true, source: "oneway-trap-2" }),
edge("trap-3", "junction", "trap-3", [
{ lat: 52, lon: 7 },
{ lat: 52.00034, lon: 7.0005 }
], { oneway: true, source: "oneway-trap-3" }),
edge("trap-4", "junction", "trap-4", [
{ lat: 52, lon: 7 },
{ lat: 52.00035, lon: 7.00052 }
], { oneway: true, source: "oneway-trap-4" }),
edge("main-route", "junction", "destination", [
{ lat: 52, lon: 7 },
{ lat: 52, lon: 7.04 }
], { source: "fifth-routable-edge" })
]
};
const route = buildFairwayRoute(
{
start,
destination: { lat: 52, lon: 7.04 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
graph
);
expect(route).not.toBeNull();
expect(route?.routeSnaps?.start.distanceM).toBeGreaterThan(30);
expect(route?.routeSnaps?.start.distanceM).toBeLessThan(50);
expect(route?.dataSources).toContain("fifth-routable-edge");
expect(route?.dataSources.some((source) => source.startsWith("oneway-trap"))).toBe(false);
});
it("prefers a much shorter route through the fifth-nearest snap", () => {
const start = { lat: 52, lon: 7 };
const offsetLat = (metres: number) => metres / 1852 / 60;
const longEntry = { lat: 52.08, lon: 7 };
const join = { lat: 52, lon: 7.08 };
const destination = { lat: 52, lon: 7.09 };
const trapStarts = [0, 1, 2, 3].map((metres) => ({
lat: start.lat + offsetLat(metres),
lon: start.lon
}));
const shortStart = { lat: start.lat + offsetLat(9), lon: start.lon };
const graph: FairwayGraph = {
id: "fifth-shorter-snap",
name: "Kostenbewusster Snap-Test",
maxSnapDistanceNm: 1,
nodes: [
...trapStarts.map((coordinate, index) => ({
id: `trap-start-${index + 1}`,
coordinate
})),
{ id: "short-start", coordinate: shortStart },
{ id: "long-entry", coordinate: longEntry },
{ id: "join", coordinate: join },
{ id: "destination", coordinate: destination }
],
edges: [
...trapStarts.map((coordinate, index) =>
edge(
`long-access-${index + 1}`,
`trap-start-${index + 1}`,
"long-entry",
[coordinate, longEntry],
{ oneway: true, source: `long-access-${index + 1}` }
)
),
edge("long-detour", "long-entry", "join", [
longEntry,
{ lat: 52.08, lon: 7.08 },
join
], { oneway: true, source: "long-detour" }),
edge("short-fifth", "short-start", "join", [
shortStart,
join
], { oneway: true, source: "short-fifth" }),
edge("destination-tail", "join", "destination", [
join,
destination
], { oneway: true, source: "destination-tail" })
]
};
const route = buildFairwayRoute(
{
start,
destination,
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
graph
);
expect(route).not.toBeNull();
expect(route?.distanceNm).toBeLessThan(5);
expect(route?.routeSnaps?.start.distanceM).toBeCloseTo(9, 0);
expect(route?.dataSources).toContain("short-fifth");
expect(route?.dataSources).not.toContain("long-detour");
});
it("reports bounded snap-search truncation instead of silently claiming no route", () => {
const start = { lat: 52.00035, lon: 7.0005 };
const mainStart = { lat: 52, lon: 7.0005 };
const destination = { lat: 52, lon: 7.04 };
const trapCoordinates = Array.from({ length: 8 }, (_, index) => ({
lat: start.lat + (index - 4) * 0.000001,
lon: start.lon
}));
const graph: FairwayGraph = {
id: "bounded-snap-search",
name: "Begrenzter Snap-Suchtest",
maxSnapDistanceNm: 1,
nodes: [
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
...trapCoordinates.map((coordinate, index) => ({
id: `trap-${index + 1}`,
coordinate
})),
{ id: "destination", coordinate: destination }
],
edges: [
...trapCoordinates.map((coordinate, index) =>
edge(
`trap-${index + 1}`,
"junction",
`trap-${index + 1}`,
[{ lat: 52, lon: 7 }, coordinate],
{ oneway: true, source: `bounded-trap-${index + 1}` }
)
),
edge("ninth-main-route", "junction", "destination", [
{ lat: 52, lon: 7 },
destination
], { oneway: true, source: "ninth-main-route" })
]
};
const request = {
start,
destination,
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
};
expect(() => buildFairwayRoute(request, graph)).toThrow(
FairwayRoutingSearchLimitError
);
const boundedRoute = buildFairwayRoute(
{ ...request, start: mainStart },
graph
);
expect(boundedRoute).not.toBeNull();
expect(
boundedRoute?.warnings.some((warning) => warning.code === "ROUTE_SEARCH_LIMITED")
).toBe(true);
});
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 },
@@ -292,12 +568,36 @@ describe("route assessment", () => {
expect(result).toBeNull();
});
it("routes Emden to Hamm via the Ems, Dortmund-Ems-Kanal and Datteln-Hamm-Kanal fallback", () => {
const result = buildRoute({
it("does not silently use demo seed graphs when no routing source is provided", () => {
const request = {
start: EMDEN_AUSSENHAFEN,
destination: HAMM_INNENSTADT_MARINA,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
});
destination: BORKUM_REEDE,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
};
const result = buildRoute(request);
expect(result).toBeNull();
const buildWithoutGraph = buildFairwayRoute as unknown as (
routeRequest: typeof request
) => ReturnType<typeof buildFairwayRoute>;
const buildManyWithoutGraph = buildFairwayRoutes as unknown as (
routeRequest: typeof request
) => ReturnType<typeof buildFairwayRoutes>;
expect(buildWithoutGraph(request)).toBeNull();
expect(buildManyWithoutGraph(request)).toEqual([]);
});
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 }
},
{ allowDemoSeedGraphs: true }
);
expect(result).not.toBeNull();
expect(result?.distanceNm).toBeGreaterThan(145);