feat: add Docker/OpenTofu deployment and DE/NL routing

This commit is contained in:
BuTzZ
2026-07-24 23:10:17 +02:00
parent 57f7b4dedb
commit 12eee8d211
59 changed files with 4452 additions and 148 deletions
+194 -74
View File
@@ -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(
+55
View File
@@ -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 }
]
}
]
};
+6 -2
View File
@@ -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 {
+120
View File
@@ -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 },