Optimized routing
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user