feat: add Docker/OpenTofu deployment and DE/NL routing
This commit is contained in:
@@ -36,6 +36,12 @@ type EdgeSnap = {
|
||||
distanceNm: number;
|
||||
};
|
||||
|
||||
type EdgeSnapPair = {
|
||||
componentId: string;
|
||||
start: EdgeSnap;
|
||||
destination: EdgeSnap;
|
||||
};
|
||||
|
||||
export type FairwayGraph = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -71,6 +77,7 @@ 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 EMS_BORKUM_GRAPH: FairwayGraph = {
|
||||
id: "ems-borkum-seed",
|
||||
@@ -211,12 +218,26 @@ export function buildFairwayRoutes(
|
||||
return [];
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
);
|
||||
if (legSnapPairs.some((pairs) => pairs.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);
|
||||
const candidate = buildFairwayRouteCandidate(request, routableGraph, penaltyCounts, legSnapPairs);
|
||||
if (!candidate) {
|
||||
break;
|
||||
}
|
||||
@@ -247,7 +268,8 @@ export function buildFairwayRoutes(
|
||||
function buildFairwayRouteCandidate(
|
||||
request: RouteRequest,
|
||||
routableGraph: FairwayGraph,
|
||||
penaltyCounts: ReadonlyMap<string, number>
|
||||
penaltyCounts: ReadonlyMap<string, number>,
|
||||
legSnapPairs: EdgeSnapPair[][]
|
||||
): FairwayRouteCandidate | null {
|
||||
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
|
||||
const routeCoordinates: Coordinate[] = [];
|
||||
@@ -257,7 +279,14 @@ function buildFairwayRouteCandidate(
|
||||
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);
|
||||
const leg = buildFairwayLeg(
|
||||
routableGraph,
|
||||
legStart,
|
||||
legDestination,
|
||||
penaltyCounts,
|
||||
adjacency,
|
||||
legSnapPairs[index]!
|
||||
);
|
||||
|
||||
if (!leg) {
|
||||
return null;
|
||||
@@ -390,104 +419,153 @@ function buildFairwayLeg(
|
||||
legStart: Coordinate,
|
||||
legDestination: Coordinate,
|
||||
penaltyCounts: ReadonlyMap<string, number>,
|
||||
adjacency: Adjacency
|
||||
adjacency: Adjacency,
|
||||
snapPairs: EdgeSnapPair[]
|
||||
): 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[] = [];
|
||||
const routedComponents = new Set<string>();
|
||||
|
||||
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)) {
|
||||
for (const { componentId, start: startSnap, destination: destinationSnap } of snapPairs) {
|
||||
if (routedComponents.has(componentId)) {
|
||||
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 previousCandidateCount = candidates.length;
|
||||
|
||||
if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) {
|
||||
const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap);
|
||||
const coordinates: Coordinate[] = [];
|
||||
const usedEdges = new Map<string, FairwayEdge>();
|
||||
|
||||
appendCoordinate(coordinates, legStart);
|
||||
const startEdgeCoordinates = edgePathFromSnapToNode(startSnap, startNodeId);
|
||||
for (const coordinate of startEdgeCoordinates) {
|
||||
for (const coordinate of directOnEdge) {
|
||||
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
|
||||
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)) {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length > previousCandidateCount) {
|
||||
routedComponents.add(componentId);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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,
|
||||
componentByNode: ReadonlyMap<string, string>
|
||||
): Map<string, EdgeSnap[]> {
|
||||
const nearestByComponent = new Map<string, EdgeSnap[]>();
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
const componentId = componentByNode.get(edge.from);
|
||||
if (!componentId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let nearestOnEdge: EdgeSnap | null = null;
|
||||
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 = {
|
||||
if (
|
||||
distanceNm <= graph.maxSnapDistanceNm &&
|
||||
(!nearestOnEdge || distanceNm < nearestOnEdge.distanceNm)
|
||||
) {
|
||||
nearestOnEdge = {
|
||||
edge,
|
||||
coordinate: snap.coordinate,
|
||||
segmentIndex: index,
|
||||
@@ -496,9 +574,51 @@ function findNearestEdgeSnap(graph: FairwayGraph, coordinate: Coordinate): EdgeS
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
return nearestByComponent;
|
||||
}
|
||||
|
||||
function weaklyConnectedComponents(graph: FairwayGraph): Map<string, string> {
|
||||
const neighbours = new Map<string, string[]>();
|
||||
|
||||
for (const edge of graph.edges) {
|
||||
neighbours.set(edge.from, [...(neighbours.get(edge.from) ?? []), edge.to]);
|
||||
neighbours.set(edge.to, [...(neighbours.get(edge.to) ?? []), edge.from]);
|
||||
}
|
||||
|
||||
const componentByNode = new Map<string, string>();
|
||||
for (const startNodeId of neighbours.keys()) {
|
||||
if (componentByNode.has(startNodeId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const componentId = startNodeId;
|
||||
const pending = [startNodeId];
|
||||
componentByNode.set(startNodeId, componentId);
|
||||
|
||||
while (pending.length > 0) {
|
||||
const nodeId = pending.pop()!;
|
||||
for (const neighbourId of neighbours.get(nodeId) ?? []) {
|
||||
if (!componentByNode.has(neighbourId)) {
|
||||
componentByNode.set(neighbourId, componentId);
|
||||
pending.push(neighbourId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return componentByNode;
|
||||
}
|
||||
|
||||
function shortestPath(
|
||||
|
||||
@@ -517,3 +517,58 @@ export const EMDEN_HAMM_GRAPH: FairwayGraph = {
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
* Offline fallback for the eastern Ems corridor from Emden Außenhafen towards
|
||||
* the lower Ems. The geometry is a simplified extract of OSM/Geofabrik data
|
||||
* (ODbL). Runtime PostGIS/OSM graphs take precedence whenever available.
|
||||
*/
|
||||
export const EMDEN_EAST_EMS_GRAPH: FairwayGraph = {
|
||||
id: "emden-east-ems-seed",
|
||||
name: "Emden Außenhafen – Unterems",
|
||||
maxSnapDistanceNm: 0.6,
|
||||
nodes: [
|
||||
{ id: "emden-east-start", coordinate: { lat: 53.3422, lon: 7.1871 } },
|
||||
{ id: "emden-east-destination", coordinate: { lat: 53.4650304, lon: 7.4733641 } }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "emden-east-ems",
|
||||
name: "Unterems östlich von Emden",
|
||||
from: "emden-east-start",
|
||||
to: "emden-east-destination",
|
||||
minDepthM: null,
|
||||
source: "openstreetmap-geofabrik-curated-seed",
|
||||
coordinates: [
|
||||
{ lat: 53.3422, lon: 7.1871 },
|
||||
{ lat: 53.3473059, lon: 7.1911316 },
|
||||
{ lat: 53.3606114, lon: 7.2037359 },
|
||||
{ lat: 53.3644551, lon: 7.2082274 },
|
||||
{ lat: 53.3661998, lon: 7.21042 },
|
||||
{ lat: 53.3666164, lon: 7.2167288 },
|
||||
{ lat: 53.3678346, lon: 7.2249094 },
|
||||
{ lat: 53.3691393, lon: 7.2329392 },
|
||||
{ lat: 53.3707491, lon: 7.2378392 },
|
||||
{ lat: 53.373502, lon: 7.2424648 },
|
||||
{ lat: 53.3759113, lon: 7.2504687 },
|
||||
{ lat: 53.3764243, lon: 7.2562576 },
|
||||
{ lat: 53.3799364, lon: 7.2603821 },
|
||||
{ lat: 53.3834496, lon: 7.2598983 },
|
||||
{ lat: 53.3874288, lon: 7.2662906 },
|
||||
{ lat: 53.3920681, lon: 7.2704136 },
|
||||
{ lat: 53.395175, lon: 7.2798374 },
|
||||
{ lat: 53.3987926, lon: 7.2914857 },
|
||||
{ lat: 53.4001184, lon: 7.3043088 },
|
||||
{ lat: 53.4023614, lon: 7.3192701 },
|
||||
{ lat: 53.4079972, lon: 7.3347423 },
|
||||
{ lat: 53.414927, lon: 7.3450734 },
|
||||
{ lat: 53.420342, lon: 7.3654351 },
|
||||
{ lat: 53.4267192, lon: 7.3910633 },
|
||||
{ lat: 53.4359646, lon: 7.420754 },
|
||||
{ lat: 53.4504899, lon: 7.4518959 },
|
||||
{ lat: 53.4630916, lon: 7.4715145 },
|
||||
{ lat: 53.4650304, lon: 7.4733641 }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { coordinateToGeoJson, sumRouteDistanceNm } from "./geo.js";
|
||||
import { buildFairwayRoute, type FairwayGraph } from "./fairway-routing.js";
|
||||
import { EMDEN_HAMM_GRAPH } from "./inland-seed.js";
|
||||
import { EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH } from "./inland-seed.js";
|
||||
import type {
|
||||
DepthSample,
|
||||
RouteRequest,
|
||||
@@ -16,7 +16,11 @@ export function buildRoute(request: RouteRequest, graph?: FairwayGraph): RouteRe
|
||||
return buildFairwayRoute(request, graph);
|
||||
}
|
||||
|
||||
return buildFairwayRoute(request) ?? buildFairwayRoute(request, EMDEN_HAMM_GRAPH);
|
||||
return (
|
||||
buildFairwayRoute(request) ??
|
||||
buildFairwayRoute(request, EMDEN_EAST_EMS_GRAPH) ??
|
||||
buildFairwayRoute(request, EMDEN_HAMM_GRAPH)
|
||||
);
|
||||
}
|
||||
|
||||
export function requiredDepthM(profile: VesselProfile): number {
|
||||
|
||||
@@ -40,6 +40,44 @@ const ALTERNATIVE_GRAPH: FairwayGraph = {
|
||||
]
|
||||
};
|
||||
|
||||
const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
|
||||
id: "component-aware-snap-test",
|
||||
name: "Komponentenbewusster Snap-Test",
|
||||
maxSnapDistanceNm: 0.3,
|
||||
nodes: [
|
||||
{ id: "start-decoy-a", coordinate: { lat: 53.3416, lon: 7.186 } },
|
||||
{ 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-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 } }
|
||||
],
|
||||
edges: [
|
||||
edge("start-decoy", "start-decoy-a", "start-decoy-b", [
|
||||
{ lat: 53.3416, lon: 7.186 },
|
||||
{ lat: 53.342, lon: 7.187 }
|
||||
], { source: "closer-but-disconnected-start" }),
|
||||
edge("destination-decoy", "destination-decoy-a", "destination-decoy-b", [
|
||||
{ lat: 53.3282, lon: 6.9304 },
|
||||
{ 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.3321722, lon: 7.1329034 }
|
||||
], { source: "shared-local-component" }),
|
||||
edge("shared-south", "shared-east", "shared-south", [
|
||||
{ lat: 53.3321722, lon: 7.1329034 },
|
||||
{ lat: 53.313849, lon: 7.0011017 }
|
||||
], { source: "shared-local-component" }),
|
||||
edge("shared-west", "shared-south", "shared-destination", [
|
||||
{ lat: 53.313849, lon: 7.0011017 },
|
||||
{ lat: 53.3303531, lon: 6.9334715 }
|
||||
], { source: "shared-local-component" })
|
||||
]
|
||||
};
|
||||
|
||||
function edge(
|
||||
id: string,
|
||||
from: string,
|
||||
@@ -162,6 +200,88 @@ describe("route assessment", () => {
|
||||
expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed");
|
||||
});
|
||||
|
||||
it("uses a shared reachable component when the individually nearest edges are disconnected", () => {
|
||||
const start = { lat: 53.3416, lon: 7.186 };
|
||||
const destination = { lat: 53.3282, lon: 6.9304 };
|
||||
const routes = buildFairwayRoutes(
|
||||
{
|
||||
start,
|
||||
destination,
|
||||
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||
},
|
||||
COMPONENT_AWARE_SNAP_GRAPH
|
||||
);
|
||||
|
||||
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]?.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);
|
||||
});
|
||||
|
||||
it("returns no route when start and destination have no shared component inside the snap radius", () => {
|
||||
const disconnectedGraph: FairwayGraph = {
|
||||
...COMPONENT_AWARE_SNAP_GRAPH,
|
||||
nodes: COMPONENT_AWARE_SNAP_GRAPH.nodes.slice(0, 4),
|
||||
edges: COMPONENT_AWARE_SNAP_GRAPH.edges.slice(0, 2)
|
||||
};
|
||||
|
||||
expect(
|
||||
buildFairwayRoute(
|
||||
{
|
||||
start: { lat: 53.3416, lon: 7.186 },
|
||||
destination: { lat: 53.3282, lon: 6.9304 },
|
||||
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||
},
|
||||
disconnectedGraph
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("tries another snap in the same component when the nearest one-way branch cannot be exited", () => {
|
||||
const graph: FairwayGraph = {
|
||||
id: "oneway-snap-fallback",
|
||||
name: "Einbahnstraßen-Snap-Fallback",
|
||||
maxSnapDistanceNm: 0.2,
|
||||
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 } }
|
||||
],
|
||||
edges: [
|
||||
edge(
|
||||
"oneway-trap",
|
||||
"junction",
|
||||
"oneway-dead-end",
|
||||
[{ lat: 52, lon: 7 }, { lat: 52.001, lon: 7 }],
|
||||
{ oneway: true, source: "oneway-trap" }
|
||||
),
|
||||
edge(
|
||||
"main-route",
|
||||
"junction",
|
||||
"destination",
|
||||
[{ lat: 52, lon: 7 }, { lat: 52, lon: 7.04 }],
|
||||
{ source: "routable-main-edge" }
|
||||
)
|
||||
]
|
||||
};
|
||||
const route = buildFairwayRoute(
|
||||
{
|
||||
start: { lat: 52.001, lon: 7 },
|
||||
destination: { lat: 52, lon: 7.04 },
|
||||
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||
},
|
||||
graph
|
||||
);
|
||||
|
||||
expect(route).not.toBeNull();
|
||||
expect(route?.dataSources).toContain("routable-main-edge");
|
||||
expect(route?.dataSources).not.toContain("oneway-trap");
|
||||
});
|
||||
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user