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
+2 -1
View File
@@ -2,7 +2,8 @@ PORT=5174
HOST=0.0.0.0 HOST=0.0.0.0
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass
REDIS_URL=redis://localhost:6379 REDIS_URL=redis://localhost:6379
WATERMAPS_DEMO_DATA=true # Nur für bewusst grobe Demo-Marker und Seed-Routen auf true setzen.
WATERMAPS_DEMO_DATA=false
WATERMAPS_LOCAL_FAIRWAYS_PATH=data/local/germany-netherlands-fairways.json WATERMAPS_LOCAL_FAIRWAYS_PATH=data/local/germany-netherlands-fairways.json
WATERMAPS_LIVE_FAIRWAYS=false WATERMAPS_LIVE_FAIRWAYS=false
+27 -3
View File
@@ -163,14 +163,38 @@ laufende Overpass-Verbindung. Berücksichtigt werden unter anderem
explizit für Boote oder Schiffe freigegebene Flüsse. Gesperrte, private, explizit für Boote oder Schiffe freigegebene Flüsse. Gesperrte, private,
stillgelegte oder im Bau befindliche Wege werden ausgeschlossen. stillgelegte oder im Bau befindliche Wege werden ausgeschlossen.
Wenn freie Laufzeitdaten fehlen, stehen zwei klar als nicht amtlich markierte Fallback-Korridore bereit: Nur im ausdrücklich aktivierten Demo-Modus
`WATERMAPS_DEMO_DATA=true` stehen drei klar als nicht amtlich markierte
Seed-Korridore bereit:
- Emden Außenhafen → Borkum Reede - Emden Außenhafen → Borkum Reede
- Emden Außenhafen → östliche Unterems
- Emden Außenhafen → Ems/Dortmund-Ems-Kanal → Datteln → Datteln-Hamm-Kanal → Wasserwanderrastplatz Hamm-Innenstadt (rund 153 sm) - Emden Außenhafen → Ems/Dortmund-Ems-Kanal → Datteln → Datteln-Hamm-Kanal → Wasserwanderrastplatz Hamm-Innenstadt (rund 153 sm)
Der EmdenHamm-Fallback besitzt keine belastbaren Tiefen- oder Schleusenzeitdaten und ist bewusst auf Sportboote bis 2,5 m Tiefgang begrenzt. Vor der Fahrt sind aktuelle Sperrungen, Betriebszeiten, Wasserstände und amtliche Karten zu prüfen. Kostenfreie Inland-ENCs für den Dortmund-Ems- und Datteln-Hamm-Kanal stellt [ELWIS](https://www.elwis.de/DE/dynamisch/IENC/) bereit. Diese Seed-Geometrien dienen ausschließlich Entwicklung und Demonstration.
Produktiv und im regulären lokalen Containerbetrieb ist der Demo-Modus
deaktiviert. Fehlt dort der vollständige lokale Index beziehungsweise eine
andere konfigurierte Routingquelle, liefert die API
`503 fairway_sources_unavailable`, statt unbemerkt eine grobe Seed-Route zu
zeichnen. Der EmdenHamm-Seed besitzt keine belastbaren Tiefen- oder
Schleusenzeitdaten und ist bewusst auf Sportboote bis 2,5 m Tiefgang begrenzt.
Vor der Fahrt sind aktuelle Sperrungen, Betriebszeiten, Wasserstände und
amtliche Karten zu prüfen. Kostenfreie Inland-ENCs für den Dortmund-Ems- und
Datteln-Hamm-Kanal stellt [ELWIS](https://www.elwis.de/DE/dynamisch/IENC/)
bereit.
Wenn kein Graph passt, liefert die API bewusst `422 no_fairway_route`, damit keine irreführende Luftlinie als Bootsroute gezeichnet wird. `alternatives` im Ergebnis enthält bis zu zwei weitere, topologisch unterschiedliche Optionen. Wenn eine verfügbare Routingquelle keinen passenden Graphen enthält, liefert
die API bewusst `422 no_fairway_route`, damit keine irreführende Luftlinie als
Bootsroute gezeichnet wird. Start, Zwischenziele und Ziel werden höchstens
150 m vom Graphen entfernt zugeordnet. Die ausgegebene Liniengeometrie
beginnt und endet am tatsächlichen Fahrwasser-Snap; eine ungeprüfte gerade
Zufahrt vom angeklickten Punkt wird nicht als befahrbare Route ausgegeben.
`routeSnaps` enthält dafür Originalpunkt, Snap-Punkt und Abstand. Ab 10 m
erscheint zusätzlich ein sichtbarer Hinweis. `alternatives` im Ergebnis
enthält bis zu zwei weitere, topologisch unterschiedliche Optionen. Wird das
feste Rechenbudget in einem außergewöhnlich dichten Fahrwassernetz ohne
belastbares Ergebnis erreicht, antwortet die API ausdrücklich mit
`422 fairway_search_limited`, statt fälschlich „keine Route“ zu behaupten.
## Kursassistent entlang einer Route ## Kursassistent entlang einer Route
+116 -17
View File
@@ -3,8 +3,10 @@ import Fastify, { type FastifyInstance } from "fastify";
import { z } from "zod"; import { z } from "zod";
import { import {
buildFairwayRoutes, buildFairwayRoutes,
EMDEN_BORKUM_DEMO_GRAPH,
EMDEN_EAST_EMS_GRAPH, EMDEN_EAST_EMS_GRAPH,
EMDEN_HAMM_GRAPH, EMDEN_HAMM_GRAPH,
FairwayRoutingSearchLimitError,
type FairwayGraph, type FairwayGraph,
type RouteOption, type RouteOption,
type RouteRequest, type RouteRequest,
@@ -13,7 +15,10 @@ import {
import { loadEnv, type ApiEnv } from "./env.js"; import { loadEnv, type ApiEnv } from "./env.js";
import { createCache, type Cache } from "./services/cache.js"; import { createCache, type Cache } from "./services/cache.js";
import { appConfig } from "./services/config.js"; import { appConfig } from "./services/config.js";
import { FairwayService } from "./services/fairways.js"; import {
FairwayService,
type FairwayGraphLookup
} from "./services/fairways.js";
import { FeatureService } from "./services/features.js"; import { FeatureService } from "./services/features.js";
import { getNearestTideSummary } from "./services/tides.js"; import { getNearestTideSummary } from "./services/tides.js";
import { getMarineForecast } from "./services/weather.js"; import { getMarineForecast } from "./services/weather.js";
@@ -186,14 +191,45 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
} }
let sourceError: unknown; let sourceError: unknown;
const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => { const graphLookup = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => {
sourceError = error; sourceError = error;
app.log.warn({ error }, "fairway extraction failed"); app.log.warn({ error }, "fairway extraction failed");
return []; return { graphs: [], failures: [] } satisfies FairwayGraphLookup;
}); });
const route = buildRouteFromGraphs(parsed.data, dynamicGraphs); if (graphLookup.failures.length > 0) {
app.log.warn(
{
failures: graphLookup.failures.map(({ source, error }) => ({
source,
error
}))
},
"one or more fairway sources failed"
);
}
let route: RouteResult | null;
try {
route = buildRouteFromGraphs(parsed.data, graphLookup.graphs, env.demoData);
} catch (error) {
if (error instanceof FairwayRoutingSearchLimitError) {
if (sourceError || graphLookup.failures.length > 0) {
return reply.code(503).send({
error: "fairway_sources_unavailable",
message:
"Fahrwasserdaten sind momentan nicht verfügbar. Prüfe den lokalen Deutschland-/Niederlande-Index oder versuche es später erneut."
});
}
return reply.code(422).send({
error: "fairway_search_limited",
message:
"Das Fahrwassernetz ist an den gewählten Punkten zu dicht für eine verlässliche Routensuche. Setze Start, Ziel oder Zwischenziele eindeutiger auf das gewünschte Fahrwasser."
});
}
throw error;
}
if (!route) { if (!route) {
if (sourceError) { if (sourceError || graphLookup.failures.length > 0) {
return reply.code(503).send({ return reply.code(503).send({
error: "fairway_sources_unavailable", error: "fairway_sources_unavailable",
message: message:
@@ -227,32 +263,95 @@ function commaSeparatedQuery(maxItems: number) {
}); });
} }
function buildRouteFromGraphs(request: RouteRequest, graphs: FairwayGraph[]) { function buildRouteFromGraphs(
request: RouteRequest,
graphs: FairwayGraph[],
allowSeedGraphs: boolean
) {
let searchWasLimited = false;
for (const graph of graphs) { for (const graph of graphs) {
const routes = buildFairwayRoutes(request, graph); let routes: RouteOption[];
try {
routes = buildFairwayRoutes(request, graph);
} catch (error) {
if (error instanceof FairwayRoutingSearchLimitError) {
searchWasLimited = true;
continue;
}
throw error;
}
if (routes.length > 0) { if (routes.length > 0) {
return routeResultWithAlternatives(routes); return routeResultWithAlternatives(routes, searchWasLimited);
} }
} }
for (const graph of [undefined, EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH] as const) { if (!allowSeedGraphs) {
const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request); if (searchWasLimited) {
if (routes.length > 0) { throw new FairwayRoutingSearchLimitError();
return routeResultWithAlternatives(routes);
} }
}
return null; return null;
} }
function routeResultWithAlternatives(routes: RouteOption[]): RouteResult { for (const graph of [
EMDEN_BORKUM_DEMO_GRAPH,
EMDEN_EAST_EMS_GRAPH,
EMDEN_HAMM_GRAPH
]) {
let routes: RouteOption[];
try {
routes = buildFairwayRoutes(request, graph);
} catch (error) {
if (error instanceof FairwayRoutingSearchLimitError) {
searchWasLimited = true;
continue;
}
throw error;
}
if (routes.length > 0) {
return routeResultWithAlternatives(routes, searchWasLimited);
}
}
if (searchWasLimited) {
throw new FairwayRoutingSearchLimitError();
}
return null;
}
function routeResultWithAlternatives(
routes: RouteOption[],
searchWasLimited = false
): RouteResult {
const [primary, ...alternatives] = routes; const [primary, ...alternatives] = routes;
if (!primary) { if (!primary) {
throw new Error("routeResultWithAlternatives requires at least one route"); throw new Error("routeResultWithAlternatives requires at least one route");
} }
const annotate = (route: RouteOption): RouteOption => {
if (
!searchWasLimited ||
route.warnings.some((warning) => warning.code === "ROUTE_SEARCH_LIMITED")
) {
return route;
}
return {
...route,
warnings: [
...route.warnings,
{
code: "ROUTE_SEARCH_LIMITED",
severity: "caution",
message:
"Eine dichtere Datenquelle überschritt das feste Suchbudget. " +
"Diese Route stammt aus einer vollständig geprüften Alternative, ist aber möglicherweise nicht graphübergreifend die kürzeste."
}
]
};
};
const annotatedPrimary = annotate(primary);
return { return {
...primary, ...annotatedPrimary,
alternatives alternatives: alternatives.map(annotate)
}; };
} }
+1 -1
View File
@@ -20,7 +20,7 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
(env.NODE_ENV === "test" (env.NODE_ENV === "test"
? undefined ? undefined
: "data/local/germany-netherlands-fairways.json"), : "data/local/germany-netherlands-fairways.json"),
demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) !== "false", demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) === "true",
liveOsmFairways: liveOsmFairways:
(env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" && (env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" &&
env.NODE_ENV !== "test" env.NODE_ENV !== "test"
+67 -97
View File
@@ -37,6 +37,18 @@ type FairwayDeps = {
localDataPath?: string; localDataPath?: string;
}; };
export type FairwaySourceId = "configuration" | "postgis" | "local" | "live";
export type FairwaySourceFailure = {
source: FairwaySourceId;
error: unknown;
};
export type FairwayGraphLookup = {
graphs: FairwayGraph[];
failures: FairwaySourceFailure[];
};
export type FairwayRow = { export type FairwayRow = {
id: string; id: string;
source: string; source: string;
@@ -56,9 +68,8 @@ const MAX_BBOX_SPAN_DEG = 3;
const MAX_POSTGIS_BBOX_SPAN_DEG = 6; const MAX_POSTGIS_BBOX_SPAN_DEG = 6;
const MAX_LOCAL_BBOX_SPAN_DEG = 15; const MAX_LOCAL_BBOX_SPAN_DEG = 15;
const BBOX_MARGIN_DEG = 0.15; const BBOX_MARGIN_DEG = 0.15;
const ENDPOINT_SNAP_DEG = 0.0001; const ENDPOINT_SNAP_DEG = 0.000001;
const CONNECTOR_DISTANCE_NM = 0.08; const MAX_FAIRWAY_SNAP_DISTANCE_NM = 150 / 1852;
const CONNECTOR_GRID_DEG = 0.003;
export class FairwayService { export class FairwayService {
private readonly cache: Cache; private readonly cache: Cache;
@@ -76,29 +87,58 @@ export class FairwayService {
this.pool = deps.databaseUrl ? new pg.Pool({ connectionString: deps.databaseUrl }) : null; this.pool = deps.databaseUrl ? new pg.Pool({ connectionString: deps.databaseUrl }) : null;
} }
async getGraphsForRoute(request: RouteRequest): Promise<FairwayGraph[]> { async getGraphsForRoute(request: RouteRequest): Promise<FairwayGraphLookup> {
const results = await Promise.allSettled([ if (!this.pool && !this.localDataPath && !this.liveEnabled) {
this.getPostgisGraphForRoute(request), return {
this.getLocalGraphForRoute(request), graphs: [],
this.getLiveGraphForRoute(request) failures: [
]); {
const graphs = results.flatMap((result) => source: "configuration",
result.status === "fulfilled" && result.value ? [result.value] : [] error: new Error("No fairway source is configured")
); }
]
};
}
if (graphs.length === 0) { const sources: Array<{
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); source: Exclude<FairwaySourceId, "configuration">;
if (failures.length > 0) { load: () => Promise<FairwayGraph | null>;
throw new AggregateError(failures.map((failure) => failure.reason), "No fairway source produced a graph"); }> = [];
if (this.pool) {
sources.push({ source: "postgis", load: () => this.getPostgisGraphForRoute(request) });
}
if (this.localDataPath) {
sources.push({ source: "local", load: () => this.getLocalGraphForRoute(request) });
}
if (this.liveEnabled) {
sources.push({ source: "live", load: () => this.getLiveGraphForRoute(request) });
}
const results = await Promise.allSettled(sources.map(({ load }) => load()));
const graphs: FairwayGraph[] = [];
const failures: FairwaySourceFailure[] = [];
for (const [index, result] of results.entries()) {
if (result.status === "fulfilled") {
if (result.value) {
graphs.push(result.value);
}
} else {
failures.push({
source: sources[index]!.source,
error: result.reason
});
} }
} }
if (graphs.length < 2) { if (graphs.length < 2) {
return graphs; return { graphs, failures };
} }
const combined = mergeConnectedFairwayGraphs(graphs); const combined = mergeConnectedFairwayGraphs(graphs);
return combined ? [combined, ...graphs] : graphs; return {
graphs: combined ? [combined, ...graphs] : graphs,
failures
};
} }
async close(): Promise<void> { async close(): Promise<void> {
@@ -169,19 +209,18 @@ export class FairwayService {
throw new Error(`Unsupported local fairway data format: ${this.localDataPath}`); throw new Error(`Unsupported local fairway data format: ${this.localDataPath}`);
} }
return document; return document;
})
.catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") {
return null;
}
throw error;
}); });
} }
const document = await this.localDocument;
if (!document) { const currentLoad = this.localDocument;
try {
return await currentLoad;
} catch (error) {
if (this.localDocument === currentLoad) {
this.localDocument = null; this.localDocument = null;
} }
return document; throw error;
}
} }
private async getLiveGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> { private async getLiveGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
@@ -386,7 +425,6 @@ export function mergeConnectedFairwayGraphs(graphs: FairwayGraph[]): FairwayGrap
} }
} }
addEndpointConnectors(nodes, edges);
if (edges.size === 0) { if (edges.size === 0) {
return null; return null;
} }
@@ -445,8 +483,6 @@ function waysToGraph(
} }
} }
addEndpointConnectors(nodes, edges);
if (edges.size === 0) { if (edges.size === 0) {
return null; return null;
} }
@@ -454,7 +490,7 @@ function waysToGraph(
return { return {
id, id,
name, name,
maxSnapDistanceNm: 2, maxSnapDistanceNm: MAX_FAIRWAY_SNAP_DISTANCE_NM,
nodes: [...nodes.values()], nodes: [...nodes.values()],
edges: [...edges.values()] edges: [...edges.values()]
}; };
@@ -543,65 +579,6 @@ function nodeIdFor(nodes: Map<string, FairwayNode>, coordinate: Coordinate) {
return id; return id;
} }
function addEndpointConnectors(nodes: Map<string, FairwayNode>, edges: Map<string, FairwayEdge>) {
const degree = new Map<string, number>();
for (const edge of edges.values()) {
degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
}
const endpoints = [...nodes.values()].filter((node) => (degree.get(node.id) ?? 0) <= 1);
const buckets = new Map<string, FairwayNode[]>();
for (const endpoint of endpoints) {
const [x, y] = connectorCell(endpoint.coordinate);
const key = `${x}:${y}`;
buckets.set(key, [...(buckets.get(key) ?? []), endpoint]);
}
const connectorIds = new Set<string>();
for (const first of endpoints) {
const [cellX, cellY] = connectorCell(first.coordinate);
for (let dx = -1; dx <= 1; dx += 1) {
for (let dy = -1; dy <= 1; dy += 1) {
for (const second of buckets.get(`${cellX + dx}:${cellY + dy}`) ?? []) {
if (first.id >= second.id) {
continue;
}
const distanceNm = distanceApproxNm(first.coordinate, second.coordinate);
if (distanceNm === 0 || distanceNm > CONNECTOR_DISTANCE_NM) {
continue;
}
const connectorId = `connector-${first.id}-${second.id}`;
if (connectorIds.has(connectorId)) {
continue;
}
connectorIds.add(connectorId);
edges.set(connectorId, {
id: connectorId,
name: "Fahrwasser-Verbindung",
from: first.id,
to: second.id,
coordinates: [first.coordinate, second.coordinate],
minDepthM: null,
source: "fairway-graph-connectors"
});
}
}
}
}
}
function connectorCell(coordinate: Coordinate) {
return [
Math.floor(coordinate.lon / CONNECTOR_GRID_DEG),
Math.floor(coordinate.lat / CONNECTOR_GRID_DEG)
] as const;
}
function parseDepth(tags: Record<string, string>) { function parseDepth(tags: Record<string, string>) {
const candidates = [ const candidates = [
tags["seamark:fairway:minimum_depth"], tags["seamark:fairway:minimum_depth"],
@@ -699,10 +676,3 @@ function sourceFor(tags: Record<string, string>) {
} }
return "osm-overpass"; return "osm-overpass";
} }
function distanceApproxNm(a: Coordinate, b: Coordinate) {
const meanLatRad = ((a.lat + b.lat) / 2) * (Math.PI / 180);
const x = (a.lon - b.lon) * 60 * Math.cos(meanLatRad);
const y = (a.lat - b.lat) * 60;
return Math.sqrt(x * x + y * y);
}
+276 -24
View File
@@ -1,5 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import type { Coordinate, FairwayGraph } from "@watermaps/shared";
import { buildServer } from "../src/app.js"; import { buildServer } from "../src/app.js";
import { loadEnv } from "../src/env.js";
import { createCache } from "../src/services/cache.js"; import { createCache } from "../src/services/cache.js";
import type { import type {
LockOperationInfo, LockOperationInfo,
@@ -12,6 +14,79 @@ const jsonResponse = (body: unknown) =>
headers: { "content-type": "application/json" } headers: { "content-type": "application/json" }
}); });
const demoEnv = loadEnv({ NODE_ENV: "test", WATERMAPS_DEMO_DATA: "true" });
const productionEnv = loadEnv({ NODE_ENV: "test", WATERMAPS_DEMO_DATA: "false" });
const testFairwayGraph = (
start: Coordinate,
destination: Coordinate,
id = "test-fairway"
): FairwayGraph => ({
id,
name: "Test fairway",
maxSnapDistanceNm: 0.25,
nodes: [
{ id: `${id}-start`, coordinate: start },
{ id: `${id}-destination`, coordinate: destination }
],
edges: [
{
id: `${id}-edge`,
name: "Test fairway edge",
from: `${id}-start`,
to: `${id}-destination`,
coordinates: [start, destination],
minDepthM: null,
source: "test-fairway-source"
}
]
});
const boundedSnapSearchFixture = () => {
const start = { lat: 52.00035, 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: "api-bounded-snap-search",
name: "API bounded snap search",
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) => ({
id: `trap-${index + 1}`,
name: `Trap ${index + 1}`,
from: "junction",
to: `trap-${index + 1}`,
coordinates: [{ lat: 52, lon: 7 }, coordinate],
minDepthM: 4,
oneway: true as const,
source: `api-bounded-trap-${index + 1}`
})),
{
id: "ninth-main-route",
name: "Ninth main route",
from: "junction",
to: "destination",
coordinates: [{ lat: 52, lon: 7 }, destination],
minDepthM: 4,
oneway: true as const,
source: "api-ninth-main-route"
}
]
};
return { start, destination, graph };
};
describe("Watermaps API", () => { describe("Watermaps API", () => {
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -146,7 +221,7 @@ describe("Watermaps API", () => {
}); });
it("returns critical route warnings for shallow samples", async () => { it("returns critical route warnings for shallow samples", async () => {
const app = await buildServer({ cache: createCache() }); const app = await buildServer({ cache: createCache(), env: demoEnv });
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
url: "/api/routes", url: "/api/routes",
@@ -166,7 +241,16 @@ describe("Watermaps API", () => {
}); });
it("rejects routes without a known fairway instead of returning a straight line", async () => { it("rejects routes without a known fairway instead of returning a straight line", async () => {
const app = await buildServer({ cache: createCache() }); const app = await buildServer({
cache: createCache(),
env: productionEnv,
fairwayService: {
async getGraphsForRoute() {
return { graphs: [], failures: [] };
},
async close() {}
}
});
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
url: "/api/routes", url: "/api/routes",
@@ -187,6 +271,7 @@ describe("Watermaps API", () => {
it("reports unavailable fairway sources instead of claiming that no route exists", async () => { it("reports unavailable fairway sources instead of claiming that no route exists", async () => {
const app = await buildServer({ const app = await buildServer({
cache: createCache(), cache: createCache(),
env: productionEnv,
fairwayService: { fairwayService: {
async getGraphsForRoute() { async getGraphsForRoute() {
throw new AggregateError([new Error("local data missing"), new Error("Overpass timeout")]); throw new AggregateError([new Error("local data missing"), new Error("Overpass timeout")]);
@@ -198,8 +283,8 @@ describe("Watermaps API", () => {
method: "POST", method: "POST",
url: "/api/routes", url: "/api/routes",
payload: { payload: {
start: { lat: 54.1749, lon: 12.0731 }, start: { lat: 53.4498, lon: 7.4509 },
destination: { lat: 54.1833, lon: 12.0928 }, destination: { lat: 53.4646, lon: 7.4742 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 } vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
} }
}); });
@@ -209,8 +294,140 @@ describe("Watermaps API", () => {
await app.close(); await app.close();
}); });
it("reports a bounded dense-graph search instead of claiming that no route exists", async () => {
const { start, destination, graph } = boundedSnapSearchFixture();
const app = await buildServer({
cache: createCache(),
env: productionEnv,
fairwayService: {
async getGraphsForRoute() {
return { graphs: [graph], failures: [] };
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start,
destination,
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
}
});
expect(response.statusCode).toBe(422);
expect(response.json().error).toBe("fairway_search_limited");
await app.close();
});
it("tries another available graph after one graph exhausts its search budget", async () => {
const { start, destination, graph } = boundedSnapSearchFixture();
const app = await buildServer({
cache: createCache(),
env: productionEnv,
fairwayService: {
async getGraphsForRoute() {
return {
graphs: [graph, testFairwayGraph(start, destination, "bounded-search-fallback")],
failures: []
};
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start,
destination,
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
}
});
expect(response.statusCode).toBe(200);
expect(response.json().dataSources).toContain(
"fairway-graph:bounded-search-fallback"
);
expect(
response.json().warnings.some(
(warning: { code: string }) => warning.code === "ROUTE_SEARCH_LIMITED"
)
).toBe(true);
await app.close();
});
it("returns 503 when a remaining graph cannot route and another source failed", async () => {
const app = await buildServer({
cache: createCache(),
env: productionEnv,
fairwayService: {
async getGraphsForRoute() {
return {
graphs: [
testFairwayGraph(
{ lat: 52, lon: 7 },
{ lat: 52.01, lon: 7.01 },
"unrelated-live-graph"
)
],
failures: [{ source: "local", error: new Error("full local index unavailable") }]
};
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.4498, lon: 7.4509 },
destination: { lat: 53.4646, lon: 7.4742 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
expect(response.statusCode).toBe(503);
expect(response.json().error).toBe("fairway_sources_unavailable");
await app.close();
});
it("uses a routable graph even when another fairway source failed", async () => {
const start = { lat: 54, lon: 10 };
const destination = { lat: 54.04, lon: 10.1 };
const app = await buildServer({
cache: createCache(),
env: productionEnv,
fairwayService: {
async getGraphsForRoute() {
return {
graphs: [testFairwayGraph(start, destination, "available-live-graph")],
failures: [{ source: "local", error: new Error("full local index unavailable") }]
};
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start,
destination,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
expect(response.statusCode).toBe(200);
expect(response.json().dataSources).toContain(
"fairway-graph:available-live-graph"
);
await app.close();
});
it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => { it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
const app = await buildServer({ cache: createCache() }); const app = await buildServer({ cache: createCache(), env: demoEnv });
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
url: "/api/routes", url: "/api/routes",
@@ -235,7 +452,8 @@ describe("Watermaps API", () => {
cache: createCache(), cache: createCache(),
fairwayService: { fairwayService: {
async getGraphsForRoute() { async getGraphsForRoute() {
return [ return {
graphs: [
{ {
id: "norddeich-norderney-test", id: "norddeich-norderney-test",
name: "NorddeichNorderney", name: "NorddeichNorderney",
@@ -272,7 +490,9 @@ describe("Watermaps API", () => {
} }
] ]
} }
]; ],
failures: []
};
}, },
async close() {} async close() {}
} }
@@ -300,6 +520,7 @@ describe("Watermaps API", () => {
it("routes from Emden into the eastern lower Ems when all dynamic sources fail", async () => { it("routes from Emden into the eastern lower Ems when all dynamic sources fail", async () => {
const app = await buildServer({ const app = await buildServer({
cache: createCache(), cache: createCache(),
env: demoEnv,
fairwayService: { fairwayService: {
async getGraphsForRoute() { async getGraphsForRoute() {
throw new AggregateError([new Error("PostGIS unavailable"), new Error("Overpass timeout")]); throw new AggregateError([new Error("PostGIS unavailable"), new Error("Overpass timeout")]);
@@ -321,14 +542,21 @@ describe("Watermaps API", () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(body.routingMode).toBe("fairway"); expect(body.routingMode).toBe("fairway");
expect(body.dataSources).toContain("fairway-graph:emden-east-ems-seed"); expect(body.dataSources).toContain("fairway-graph:emden-east-ems-seed");
expect(body.geometry.coordinates[0]).toEqual([7.1871, 53.3422]); expect(body.routeSnaps.start.requested).toEqual({ lat: 53.3422, lon: 7.1871 });
expect(body.geometry.coordinates.at(-1)?.[0]).toBeCloseTo(7.4734, 3); expect(body.routeSnaps.destination.requested).toEqual({ lat: 53.465, lon: 7.4734 });
expect(body.geometry.coordinates.at(-1)?.[1]).toBeCloseTo(53.465, 3); expect(body.geometry.coordinates[0]).toEqual([
body.routeSnaps.start.snapped.lon,
body.routeSnaps.start.snapped.lat
]);
expect(body.geometry.coordinates.at(-1)).toEqual([
body.routeSnaps.destination.snapped.lon,
body.routeSnaps.destination.snapped.lat
]);
await app.close(); await app.close();
}); });
it("returns the inland fallback route from Emden to Hamm", async () => { it("returns the inland fallback route from Emden to Hamm", async () => {
const app = await buildServer({ cache: createCache() }); const app = await buildServer({ cache: createCache(), env: demoEnv });
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
url: "/api/routes", url: "/api/routes",
@@ -344,7 +572,14 @@ describe("Watermaps API", () => {
expect(body.distanceNm).toBeGreaterThan(145); expect(body.distanceNm).toBeGreaterThan(145);
expect(body.distanceNm).toBeLessThan(165); expect(body.distanceNm).toBeLessThan(165);
expect(body.dataSources).toContain("fairway-graph:emden-hamm-inland-seed"); expect(body.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
expect(body.geometry.coordinates.at(-1)).toEqual([7.8042615, 51.6814536]); expect(body.routeSnaps.destination.requested).toEqual({
lat: 51.6814536,
lon: 7.8042615
});
expect(body.geometry.coordinates.at(-1)).toEqual([
body.routeSnaps.destination.snapped.lon,
body.routeSnaps.destination.snapped.lat
]);
await app.close(); await app.close();
}); });
@@ -353,7 +588,8 @@ describe("Watermaps API", () => {
cache: createCache(), cache: createCache(),
fairwayService: { fairwayService: {
async getGraphsForRoute() { async getGraphsForRoute() {
return [ return {
graphs: [
{ {
id: "test-extracted", id: "test-extracted",
name: "Test Extracted Fairways", name: "Test Extracted Fairways",
@@ -390,7 +626,9 @@ describe("Watermaps API", () => {
} }
] ]
} }
]; ],
failures: []
};
} }
} }
}); });
@@ -418,7 +656,8 @@ describe("Watermaps API", () => {
cache: createCache(), cache: createCache(),
fairwayService: { fairwayService: {
async getGraphsForRoute() { async getGraphsForRoute() {
return [ return {
graphs: [
{ {
id: "local-geofabrik-component-snap", id: "local-geofabrik-component-snap",
name: "Lokaler Geofabrik-Komponententest", name: "Lokaler Geofabrik-Komponententest",
@@ -428,10 +667,10 @@ describe("Watermaps API", () => {
{ id: "start-decoy-b", coordinate: coordinate(53.342, 7.187) }, { id: "start-decoy-b", coordinate: coordinate(53.342, 7.187) },
{ id: "destination-decoy-a", coordinate: coordinate(53.3282, 6.9304) }, { id: "destination-decoy-a", coordinate: coordinate(53.3282, 6.9304) },
{ id: "destination-decoy-b", coordinate: coordinate(53.3286, 6.9294) }, { id: "destination-decoy-b", coordinate: coordinate(53.3286, 6.9294) },
{ id: "shared-start", coordinate: coordinate(53.3395697, 7.1848883) }, { id: "shared-start", coordinate: coordinate(53.34145, 7.18585) },
{ id: "shared-east", coordinate: coordinate(53.3321722, 7.1329034) }, { id: "shared-east", coordinate: coordinate(53.3321722, 7.1329034) },
{ id: "shared-south", coordinate: coordinate(53.313849, 7.0011017) }, { id: "shared-south", coordinate: coordinate(53.313849, 7.0011017) },
{ id: "shared-destination", coordinate: coordinate(53.3303531, 6.9334715) } { id: "shared-destination", coordinate: coordinate(53.32805, 6.9302) }
], ],
edges: [ edges: [
{ {
@@ -457,7 +696,7 @@ describe("Watermaps API", () => {
name: "Gemeinsamer lokaler Korridor Ost", name: "Gemeinsamer lokaler Korridor Ost",
from: "shared-start", from: "shared-start",
to: "shared-east", to: "shared-east",
coordinates: [coordinate(53.3395697, 7.1848883), coordinate(53.3321722, 7.1329034)], coordinates: [coordinate(53.34145, 7.18585), coordinate(53.3321722, 7.1329034)],
minDepthM: null, minDepthM: null,
source: "local-geofabrik-germany+netherlands" source: "local-geofabrik-germany+netherlands"
}, },
@@ -475,13 +714,15 @@ describe("Watermaps API", () => {
name: "Gemeinsamer lokaler Korridor West", name: "Gemeinsamer lokaler Korridor West",
from: "shared-south", from: "shared-south",
to: "shared-destination", to: "shared-destination",
coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.3303531, 6.9334715)], coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.32805, 6.9302)],
minDepthM: null, minDepthM: null,
source: "local-geofabrik-germany+netherlands" source: "local-geofabrik-germany+netherlands"
} }
] ]
} }
]; ],
failures: []
};
}, },
async close() {} async close() {}
} }
@@ -499,8 +740,16 @@ describe("Watermaps API", () => {
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(body.routingMode).toBe("fairway"); expect(body.routingMode).toBe("fairway");
expect(body.geometry.coordinates[0]).toEqual([7.186, 53.3416]); expect(body.geometry.coordinates[0]).toEqual([
expect(body.geometry.coordinates.at(-1)).toEqual([6.9304, 53.3282]); body.routeSnaps.start.snapped.lon,
body.routeSnaps.start.snapped.lat
]);
expect(body.geometry.coordinates.at(-1)).toEqual([
body.routeSnaps.destination.snapped.lon,
body.routeSnaps.destination.snapped.lat
]);
expect(body.routeSnaps.start.requested).toEqual({ lat: 53.3416, lon: 7.186 });
expect(body.routeSnaps.destination.requested).toEqual({ lat: 53.3282, lon: 6.9304 });
expect(body.dataSources).toContain("local-geofabrik-germany+netherlands"); expect(body.dataSources).toContain("local-geofabrik-germany+netherlands");
expect(body.dataSources).not.toContain("fairway-graph:ems-borkum-seed"); expect(body.dataSources).not.toContain("fairway-graph:ems-borkum-seed");
expect(body.dataSources).not.toContain("closer-but-disconnected-start"); expect(body.dataSources).not.toContain("closer-but-disconnected-start");
@@ -523,7 +772,8 @@ describe("Watermaps API", () => {
cache: createCache(), cache: createCache(),
fairwayService: { fairwayService: {
async getGraphsForRoute() { async getGraphsForRoute() {
return [ return {
graphs: [
{ {
id: "api-alternatives", id: "api-alternatives",
name: "API Alternativen", name: "API Alternativen",
@@ -546,7 +796,9 @@ describe("Watermaps API", () => {
edge("destination-access", "branch-out", "destination", [coordinate(52, 7.05), coordinate(52, 7.06)]) edge("destination-access", "branch-out", "destination", [coordinate(52, 7.05), coordinate(52, 7.06)])
] ]
} }
]; ],
failures: []
};
} }
} }
}); });
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { loadEnv } from "../src/env.js";
describe("API environment", () => {
it("keeps demo data and seed routes disabled unless explicitly enabled", () => {
expect(loadEnv({ NODE_ENV: "production" }).demoData).toBe(false);
expect(loadEnv({ NODE_ENV: "test" }).demoData).toBe(false);
expect(
loadEnv({ NODE_ENV: "production", WATERMAPS_DEMO_DATA: "false" }).demoData
).toBe(false);
});
it("accepts the current and legacy explicit demo switches", () => {
expect(
loadEnv({ NODE_ENV: "production", WATERMAPS_DEMO_DATA: "true" }).demoData
).toBe(true);
expect(
loadEnv({ NODE_ENV: "production", SEA_COMPASS_DEMO_DATA: "true" }).demoData
).toBe(true);
});
});
+295 -7
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { buildRoute } from "@watermaps/shared"; import { buildRoute, haversineDistanceNm } from "@watermaps/shared";
import { createCache } from "../src/services/cache.js"; import { createCache } from "../src/services/cache.js";
import { import {
FairwayService, FairwayService,
@@ -12,7 +12,180 @@ import {
overpassToGraph overpassToGraph
} from "../src/services/fairways.js"; } from "../src/services/fairways.js";
const EMDEN_CANAL_REGRESSION_WAYS: Parameters<typeof localFairwaysToGraph>[0] = [
{
id: "28021006-tail",
bbox: [7.4498965, 53.4492034, 7.4512561, 53.4500483],
tags: { name: "Ems-Jade-Kanal", width: "8", waterway: "canal" },
coordinates: [
[53.4492034, 7.4498965],
[53.4495624, 7.4505731],
[53.4499672, 7.4511607],
[53.4499875, 7.4511887],
[53.4500483, 7.4512561]
]
},
{
id: "278807710",
bbox: [7.4512561, 53.4500483, 7.4518959, 53.4504899],
tags: {
lock: "yes",
name: "Ems-Jade-Kanal",
width: "8",
waterway: "canal"
},
coordinates: [
[53.4500483, 7.4512561],
[53.4502057, 7.4514971],
[53.4504899, 7.4518959]
]
},
{
id: "278807709",
bbox: [7.4518959, 53.4449969, 7.5736929, 53.4650304],
tags: { name: "Ems-Jade-Kanal", width: "8", waterway: "canal" },
coordinates: [
[53.4504899, 7.4518959],
[53.4505236, 7.4519531],
[53.4509222, 7.4525426],
[53.4516529, 7.453623],
[53.4584592, 7.463384],
[53.4595046, 7.4648841],
[53.4599438, 7.465618],
[53.460753, 7.4673133],
[53.4616984, 7.46945],
[53.4620622, 7.4701175],
[53.4625348, 7.4707952],
[53.4627636, 7.4711325],
[53.4629466, 7.4713664],
[53.4630916, 7.4715145],
[53.4632388, 7.4716743],
[53.4633943, 7.4717923],
[53.4636131, 7.4718959],
[53.4639548, 7.4720761],
[53.4642607, 7.4722805],
[53.4646149, 7.4724784],
[53.4647858, 7.472612],
[53.4650071, 7.4729838],
[53.4650304, 7.4733641],
[53.4649736, 7.4737867],
[53.4645791, 7.4743077],
[53.4642518, 7.4745346],
[53.4640847, 7.4746604],
[53.4624017, 7.4759267],
[53.4618827, 7.4763916],
[53.4602632, 7.479245],
[53.4587935, 7.4819111],
[53.4585086, 7.482532],
[53.4583429, 7.482893],
[53.4570519, 7.4861185],
[53.455688, 7.4897057],
[53.4554283, 7.4910088],
[53.4543892, 7.4963485],
[53.4532243, 7.5021539],
[53.4531102, 7.5027225],
[53.4520897, 7.5077114],
[53.4519492, 7.5083984],
[53.4508683, 7.5126327],
[53.4490515, 7.5208022],
[53.4489869, 7.5211006],
[53.4485124, 7.5232942],
[53.4466401, 7.5319367],
[53.4451989, 7.5385911],
[53.4450014, 7.5399242],
[53.4449969, 7.5406774],
[53.4456267, 7.5463127],
[53.4457299, 7.5470025],
[53.446674, 7.5509229],
[53.4477404, 7.55515],
[53.4478348, 7.5554891],
[53.4491825, 7.5603283],
[53.4525244, 7.5722942],
[53.4529207, 7.5736929]
]
}
];
describe("fairway graph extraction", () => { describe("fairway graph extraction", () => {
it("reports that no routing source is configured", async () => {
const service = new FairwayService({
cache: createCache(),
fetcher: fetch,
liveEnabled: false
});
const lookup = await service.getGraphsForRoute({
start: { lat: 53.4498, lon: 7.4509 },
destination: { lat: 53.4646, lon: 7.4742 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
});
expect(lookup.graphs).toEqual([]);
expect(lookup.failures).toMatchObject([
{
source: "configuration",
error: expect.objectContaining({ message: "No fairway source is configured" })
}
]);
await service.close();
});
it("retries a configured local routing index after a missing file is repaired", async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-missing-index-"));
const localDataPath = join(temporaryDirectory, "missing-fairways.json");
const service = new FairwayService({
cache: createCache(),
fetcher: fetch,
liveEnabled: false,
localDataPath
});
const request = {
start: { lat: 53.4498, lon: 7.4509 },
destination: { lat: 53.4646, lon: 7.4742 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
};
try {
const missingLookup = await service.getGraphsForRoute(request);
expect(missingLookup.graphs).toEqual([]);
expect(missingLookup.failures).toMatchObject([
{
source: "local",
error: expect.objectContaining({ code: "ENOENT" })
}
]);
await writeFile(
localDataPath,
JSON.stringify({
version: 1,
source: "repaired-test",
ways: [
{
id: "repaired-way",
bbox: [7.4509, 53.4498, 7.4742, 53.4646],
tags: { waterway: "canal", name: "Repaired test fairway" },
coordinates: [
[53.4498, 7.4509],
[53.4646, 7.4742]
]
}
]
})
);
const repairedLookup = await service.getGraphsForRoute(request);
expect(repairedLookup.failures).toEqual([]);
expect(repairedLookup.graphs).toHaveLength(1);
expect(repairedLookup.graphs[0]?.edges[0]?.source).toBe(
"local-geofabrik-repaired-test"
);
} finally {
await service.close();
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
it("builds a routable graph from the persistent local Geofabrik format", () => { it("builds a routable graph from the persistent local Geofabrik format", () => {
const graph = localFairwaysToGraph( const graph = localFairwaysToGraph(
[ [
@@ -43,6 +216,110 @@ describe("fairway graph extraction", () => {
expect(route?.dataSources).toContain("local-geofabrik-germany-test.osm.pbf"); expect(route?.dataSources).toContain("local-geofabrik-germany-test.osm.pbf");
}); });
it("does not invent a traversable line between nearby disconnected waterway endpoints", () => {
const graph = localFairwaysToGraph(
[
{
id: "west",
bbox: [7, 52, 7.01, 52],
tags: { waterway: "canal" },
coordinates: [
[52, 7],
[52, 7.01]
]
},
{
id: "east",
bbox: [7.011, 52, 7.02, 52],
tags: { waterway: "canal" },
coordinates: [
[52, 7.011],
[52, 7.02]
]
}
],
[6.9, 51.9, 7.1, 52.1],
"disconnected-test"
);
expect(graph).not.toBeNull();
const route = buildRoute(
{
start: { lat: 52, lon: 7 },
destination: { lat: 52, lon: 7.02 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
},
graph ?? undefined
);
expect(route).toBeNull();
expect(graph?.edges.some((edge) => edge.source === "fairway-graph-connectors")).toBe(false);
});
it("keeps the reported Ems-Jade-Kanal route on the full source geometry", () => {
const start = { lat: 53.4498, lon: 7.4509 };
const destination = { lat: 53.4646, lon: 7.4742 };
const graph = localFairwaysToGraph(
EMDEN_CANAL_REGRESSION_WAYS,
[7.3, 53.3, 7.7, 53.6],
"emden-canal-regression"
);
expect(graph).not.toBeNull();
if (!graph) {
throw new Error("Expected Ems-Jade-Kanal regression graph");
}
const route = buildRoute(
{
start,
destination,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
},
graph
);
expect(route).not.toBeNull();
if (!route) {
throw new Error("Expected Ems-Jade-Kanal regression route");
}
const coordinates = route.geometry.coordinates;
const largestSegmentNm = coordinates.slice(1).reduce(
(largest, coordinate, index) =>
Math.max(
largest,
haversineDistanceNm(
{ lon: coordinates[index]![0], lat: coordinates[index]![1] },
{ lon: coordinate[0], lat: coordinate[1] }
)
),
0
);
expect(route.distanceNm).toBeGreaterThanOrEqual(1.2);
expect(route.distanceNm).toBeLessThanOrEqual(1.35);
expect(coordinates.length).toBeGreaterThanOrEqual(25);
expect(largestSegmentNm).toBeLessThan(0.55);
expect(coordinates).toContainEqual([7.4648841, 53.4595046]);
expect(coordinates).toContainEqual([7.46945, 53.4616984]);
expect(coordinates).toContainEqual([7.4724784, 53.4646149]);
expect(route.dataSources).toContain(
"local-geofabrik-emden-canal-regression"
);
expect(route.dataSources).not.toContain(
"fairway-graph:emden-east-ems-seed"
);
expect(route.routeSnaps?.start.distanceM).toBeLessThan(5);
expect(route.routeSnaps?.destination.distanceM).toBeLessThan(10);
expect(coordinates[0]).toEqual([
route.routeSnaps?.start.snapped.lon,
route.routeSnaps?.start.snapped.lat
]);
expect(coordinates.at(-1)).toEqual([
route.routeSnaps?.destination.snapped.lon,
route.routeSnaps?.destination.snapped.lat
]);
});
it("allows country-wide requests against the persistent local index", async () => { it("allows country-wide requests against the persistent local index", async () => {
const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-local-span-")); const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-local-span-"));
const localDataPath = join(temporaryDirectory, "germany-netherlands-fairways.json"); const localDataPath = join(temporaryDirectory, "germany-netherlands-fairways.json");
@@ -78,10 +355,11 @@ describe("fairway graph extraction", () => {
liveEnabled: false, liveEnabled: false,
localDataPath localDataPath
}); });
const graphs = await service.getGraphsForRoute(request); const lookup = await service.getGraphsForRoute(request);
const route = buildRoute(request, graphs[0]); const route = buildRoute(request, lookup.graphs[0]);
expect(graphs).toHaveLength(1); expect(lookup.failures).toEqual([]);
expect(lookup.graphs).toHaveLength(1);
expect(route?.routingMode).toBe("fairway"); expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain( expect(route?.dataSources).toContain(
"local-geofabrik-germany+netherlands" "local-geofabrik-germany+netherlands"
@@ -156,6 +434,7 @@ describe("fairway graph extraction", () => {
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: [ coordinates: [
[7.1752, 53.3306],
[7.1751368, 53.3331995], [7.1751368, 53.3331995],
[7.16, 53.42], [7.16, 53.42],
[7.1474, 53.55], [7.1474, 53.55],
@@ -173,7 +452,8 @@ describe("fairway graph extraction", () => {
type: "LineString", type: "LineString",
coordinates: [ coordinates: [
[7.1474, 53.55], [7.1474, 53.55],
[7.1414995, 53.6668156] [7.1414995, 53.6668156],
[7.1474, 53.6741]
] ]
} }
} }
@@ -194,8 +474,16 @@ describe("fairway graph extraction", () => {
expect(route).not.toBeNull(); expect(route).not.toBeNull();
expect(route?.routingMode).toBe("fairway"); expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain("postgis-osm"); expect(route?.dataSources).toContain("postgis-osm");
expect(route?.geometry.coordinates[0]).toEqual([start.lon, start.lat]); expect(route?.routeSnaps?.start.requested).toEqual(start);
expect(route?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]); expect(route?.routeSnaps?.destination.requested).toEqual(destination);
expect(route?.geometry.coordinates[0]).toEqual([
route?.routeSnaps?.start.snapped.lon,
route?.routeSnaps?.start.snapped.lat
]);
expect(route?.geometry.coordinates.at(-1)).toEqual([
route?.routeSnaps?.destination.snapped.lon,
route?.routeSnaps?.destination.snapped.lat
]);
expect(route?.geometry.coordinates.some(([lon, lat]) => lon === 7.1474 && lat === 53.55)).toBe(true); expect(route?.geometry.coordinates.some(([lon, lat]) => lon === 7.1474 && lat === 53.55)).toBe(true);
}); });
+37 -4
View File
@@ -1,4 +1,10 @@
import type { Coordinate, RouteResult, RouteWarning, VesselProfile } from "@watermaps/shared"; import type {
Coordinate,
RouteResult,
RouteSnaps,
RouteWarning,
VesselProfile
} from "@watermaps/shared";
export const OFFLINE_VOYAGES_STORAGE_KEY = "watermaps.offline-voyages.v1"; export const OFFLINE_VOYAGES_STORAGE_KEY = "watermaps.offline-voyages.v1";
export const LEGACY_OFFLINE_VOYAGES_STORAGE_KEY = "seacompass.offline-voyages.v1"; export const LEGACY_OFFLINE_VOYAGES_STORAGE_KEY = "seacompass.offline-voyages.v1";
@@ -145,8 +151,9 @@ export function createOfflineVoyageRecord(
throw new OfflineVoyageStorageError("Ungültige Kennung für die Offline-Route."); throw new OfflineVoyageStorageError("Ungültige Kennung für die Offline-Route.");
} }
const defaultStart = { lat: first[1], lon: first[0] }; const defaultStart = route.routeSnaps?.start.requested ?? { lat: first[1], lon: first[0] };
const defaultDestination = { lat: last[1], lon: last[0] }; const defaultDestination =
route.routeSnaps?.destination.requested ?? { lat: last[1], lon: last[0] };
const planInput = input.plan ?? {}; const planInput = input.plan ?? {};
return { return {
@@ -215,6 +222,8 @@ function normalizeRoute(value: unknown): RouteResult {
typeof value.departureTime === "string" ? normalizeIsoDate(value.departureTime) : undefined; typeof value.departureTime === "string" ? normalizeIsoDate(value.departureTime) : undefined;
const durationMinutes = const durationMinutes =
value.durationMinutes === undefined ? undefined : finiteNumber(value.durationMinutes, 0, 10_000_000); value.durationMinutes === undefined ? undefined : finiteNumber(value.durationMinutes, 0, 10_000_000);
const routeSnaps =
value.routeSnaps === undefined ? undefined : normalizeRouteSnaps(value.routeSnaps);
return { return {
...(id ? { id } : {}), ...(id ? { id } : {}),
@@ -228,7 +237,31 @@ function normalizeRoute(value: unknown): RouteResult {
dataSources, dataSources,
...(departureTime ? { departureTime } : {}), ...(departureTime ? { departureTime } : {}),
...(durationMinutes !== undefined ? { durationMinutes } : {}), ...(durationMinutes !== undefined ? { durationMinutes } : {}),
...(routingMode ? { routingMode } : {}) ...(routingMode ? { routingMode } : {}),
...(routeSnaps ? { routeSnaps } : {})
};
}
function normalizeRouteSnaps(value: unknown): RouteSnaps {
if (!isRecord(value) || !Array.isArray(value.waypoints) || value.waypoints.length > 25) {
throw new OfflineVoyageStorageError("Die Snap-Metadaten der Offline-Route sind ungültig.");
}
return {
start: normalizeRouteSnap(value.start),
waypoints: value.waypoints.map(normalizeRouteSnap),
destination: normalizeRouteSnap(value.destination)
};
}
function normalizeRouteSnap(value: unknown): RouteSnaps["start"] {
if (!isRecord(value)) {
throw new OfflineVoyageStorageError("Ein Snap-Punkt der Offline-Route ist ungültig.");
}
return {
requested: normalizeCoordinate(value.requested),
snapped: normalizeCoordinate(value.snapped),
distanceM: finiteNumber(value.distanceM, 0, 100_000)
}; };
} }
+15 -1
View File
@@ -38,6 +38,7 @@ describe("offline voyage storage", () => {
expect(saved.plan.destination).toEqual({ lat: 51.6814536, lon: 7.8042615 }); expect(saved.plan.destination).toEqual({ lat: 51.6814536, lon: 7.8042615 });
expect(saved.route.departureTime).toBe("2026-07-20T06:00:00.000Z"); expect(saved.route.departureTime).toBe("2026-07-20T06:00:00.000Z");
expect(saved.route.durationMinutes).toBe(120); expect(saved.route.durationMinutes).toBe(120);
expect(saved.route.routeSnaps).toEqual(routeFixture.routeSnaps);
expect(saved.route.alternatives).toBeUndefined(); expect(saved.route.alternatives).toBeUndefined();
expect(listOfflineVoyages(localStorage)).toHaveLength(1); expect(listOfflineVoyages(localStorage)).toHaveLength(1);
const restored = loadOfflineVoyage("voyage-test", localStorage); const restored = loadOfflineVoyage("voyage-test", localStorage);
@@ -83,7 +84,7 @@ const routeFixture: RouteResult = {
name: "Emden Hamm", name: "Emden Hamm",
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: [[7.186111, 53.344167], [7.1, 52.4], [7.8042615, 51.6814536]] coordinates: [[7.186, 53.344], [7.1, 52.4], [7.8042, 51.6817]]
}, },
distanceNm: 153.69, distanceNm: 153.69,
departureTime: "2026-07-20T06:00:00.000Z", departureTime: "2026-07-20T06:00:00.000Z",
@@ -94,6 +95,19 @@ const routeFixture: RouteResult = {
unknownDepthRatio: 1, unknownDepthRatio: 1,
dataSources: ["OSM"], dataSources: ["OSM"],
routingMode: "fairway", routingMode: "fairway",
routeSnaps: {
start: {
requested: { lat: 53.344167, lon: 7.186111 },
snapped: { lat: 53.344, lon: 7.186 },
distanceM: 20
},
waypoints: [],
destination: {
requested: { lat: 51.6814536, lon: 7.8042615 },
snapped: { lat: 51.6817, lon: 7.8042 },
distanceM: 28
}
},
alternatives: [{ alternatives: [{
id: "unused", id: "unused",
name: "Alternative", name: "Alternative",
+23 -5
View File
@@ -326,6 +326,22 @@ wm_smoke_test_route() {
destination: { lat: 53.465, lon: 7.4734 }, destination: { lat: 53.465, lon: 7.4734 },
minimumAlternatives: 2 minimumAlternatives: 2
}, },
{
name: "Ems-Jade-Kanal bei Rahe",
start: { lat: 53.4498, lon: 7.4509 },
destination: { lat: 53.4646, lon: 7.4742 },
minimumAlternatives: 0,
minimumCoordinates: 25,
minimumDistanceNm: 1.2,
maximumDistanceNm: 1.35,
maximumSegmentNm: 0.55,
corridorToleranceNm: 0.01,
corridorCoordinates: [
[7.4648841, 53.4595046],
[7.46945, 53.4616984],
[7.4724784, 53.4646149]
]
},
{ {
name: "NorddeichNorderney", name: "NorddeichNorderney",
start: { lat: 53.6234, lon: 7.1559 }, start: { lat: 53.6234, lon: 7.1559 },
@@ -334,8 +350,8 @@ wm_smoke_test_route() {
}, },
{ {
name: "EmdenDelfzijl", name: "EmdenDelfzijl",
start: { lat: 53.3416, lon: 7.186 }, start: { lat: 53.3395697, lon: 7.1848883 },
destination: { lat: 53.3282, lon: 6.9304 }, destination: { lat: 53.330353, lon: 6.9334717 },
minimumAlternatives: 0, minimumAlternatives: 0,
minimumCoordinates: 30, minimumCoordinates: 30,
minimumDistanceNm: 9.5, minimumDistanceNm: 9.5,
@@ -349,9 +365,9 @@ wm_smoke_test_route() {
] ]
}, },
{ {
name: "WeespUtrecht", name: "WeespUtrecht (Werkspoorhaven)",
start: { lat: 52.309, lon: 5.0423 }, start: { lat: 52.309, lon: 5.0423 },
destination: { lat: 52.105, lon: 5.085 }, destination: { lat: 52.1058659, lon: 5.0788596 },
minimumAlternatives: 2 minimumAlternatives: 2
}, },
{ {
@@ -411,7 +427,9 @@ wm_smoke_test_route() {
Array.isArray(routeCoordinates) && Array.isArray(routeCoordinates) &&
expectedCorridorCoordinates.every((expectedCoordinate) => expectedCorridorCoordinates.every((expectedCoordinate) =>
routeCoordinates.some( routeCoordinates.some(
(coordinate) => distanceNm(coordinate, expectedCoordinate) <= 0.15 (coordinate) =>
distanceNm(coordinate, expectedCoordinate) <=
(routeCheck.corridorToleranceNm ?? 0.15)
) )
) )
); );
+1 -1
View File
@@ -15,7 +15,7 @@ services:
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
WATERMAPS_LIVE_FAIRWAYS: "false" WATERMAPS_LIVE_FAIRWAYS: "false"
WATERMAPS_DEMO_DATA: "true" WATERMAPS_DEMO_DATA: "${WATERMAPS_DEMO_DATA:-false}"
volumes: volumes:
- type: bind - type: bind
source: ./data/local source: ./data/local
+298 -111
View File
@@ -5,6 +5,8 @@ import type {
RouteOption, RouteOption,
RouteRequest, RouteRequest,
RouteResult, RouteResult,
RouteSnap,
RouteSnaps,
RouteWarning, RouteWarning,
VesselProfile VesselProfile
} from "./types.js"; } from "./types.js";
@@ -36,12 +38,6 @@ type EdgeSnap = {
distanceNm: number; distanceNm: number;
}; };
type EdgeSnapPair = {
componentId: string;
start: EdgeSnap;
destination: EdgeSnap;
};
export type FairwayGraph = { export type FairwayGraph = {
id: string; id: string;
name: string; name: string;
@@ -50,6 +46,15 @@ export type FairwayGraph = {
edges: FairwayEdge[]; 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 = { type PathStep = {
edge: FairwayEdge; edge: FairwayEdge;
from: string; from: string;
@@ -71,15 +76,36 @@ type FairwayRouteCandidate = {
usedEdges: FairwayEdge[]; usedEdges: FairwayEdge[];
}; };
type FairwayPathState = {
currentSnap: EdgeSnap;
coordinates: Coordinate[];
usedEdges: FairwayEdge[];
costNm: number;
snaps: EdgeSnap[];
};
const DEFAULT_CRUISE_SPEED_KN = 6; const DEFAULT_CRUISE_SPEED_KN = 6;
const MAX_ALTERNATIVE_ROUTES = 3; const MAX_ALTERNATIVE_ROUTES = 3;
const ALTERNATIVE_EDGE_PENALTY = 8; const ALTERNATIVE_EDGE_PENALTY = 8;
const MAX_ALTERNATIVE_DISTANCE_FACTOR = 1.75; const MAX_ALTERNATIVE_DISTANCE_FACTOR = 1.75;
const MIN_DIFFERENT_DISTANCE_NM = 0.25; const MIN_DIFFERENT_DISTANCE_NM = 0.25;
const MIN_DIFFERENT_DISTANCE_RATIO = 0.05; 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", id: "ems-borkum-seed",
name: "Emsfahrwasser Emden-Borkum", name: "Emsfahrwasser Emden-Borkum",
maxSnapDistanceNm: 3, 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; return buildFairwayRoutes(request, graph, 1)[0] ?? null;
} }
export function buildFairwayRoutes( export function buildFairwayRoutes(
request: RouteRequest, request: RouteRequest,
graph = EMS_BORKUM_GRAPH, graph: FairwayGraph,
maxRoutes = MAX_ALTERNATIVE_ROUTES maxRoutes = MAX_ALTERNATIVE_ROUTES
): RouteOption[] { ): RouteOption[] {
if (!graph) {
return [];
}
const routeLimit = Math.max(0, Math.min(MAX_ALTERNATIVE_ROUTES, Math.floor(maxRoutes))); const routeLimit = Math.max(0, Math.min(MAX_ALTERNATIVE_ROUTES, Math.floor(maxRoutes)));
if (routeLimit === 0) { if (routeLimit === 0) {
return []; return [];
@@ -220,15 +250,13 @@ export function buildFairwayRoutes(
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination]; const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
const componentByNode = weaklyConnectedComponents(routableGraph); const componentByNode = weaklyConnectedComponents(routableGraph);
const legSnapPairs = requestedPoints.slice(0, -1).map((start, index) => const pointSnapsByComponent = requestedPoints.map((point) =>
findCompatibleEdgeSnapPairs( findNearestEdgeSnapsByComponent(routableGraph, point, componentByNode)
routableGraph,
start,
requestedPoints[index + 1]!,
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 []; return [];
} }
@@ -237,7 +265,24 @@ export function buildFairwayRoutes(
const maxAttempts = Math.max(8, routeLimit * 6); const maxAttempts = Math.max(8, routeLimit * 6);
for (let attempt = 0; attempt < maxAttempts && accepted.length < routeLimit; attempt += 1) { 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) { if (!candidate) {
break; break;
} }
@@ -269,38 +314,66 @@ function buildFairwayRouteCandidate(
request: RouteRequest, request: RouteRequest,
routableGraph: FairwayGraph, routableGraph: FairwayGraph,
penaltyCounts: ReadonlyMap<string, number>, penaltyCounts: ReadonlyMap<string, number>,
legSnapPairs: EdgeSnapPair[][] pointSnapsByComponent: Array<Map<string, EdgeSnap[]>>,
commonComponentIds: string[]
): FairwayRouteCandidate | null { ): FairwayRouteCandidate | null {
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination]; const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
const routeCoordinates: Coordinate[] = [];
const usedEdges = new Map<string, FairwayEdge>();
const adjacency = buildAdjacency(routableGraph, penaltyCounts); const adjacency = buildAdjacency(routableGraph, penaltyCounts);
const completedPaths: FairwayPathState[] = [];
for (let index = 0; index < requestedPoints.length - 1; index += 1) { let remainingPairEvaluations = MAX_SNAP_PAIR_EVALUATIONS_PER_ATTEMPT;
const legStart = requestedPoints[index]!; let searchTruncated = false;
const legDestination = requestedPoints[index + 1]!; let processedComponentCount = 0;
const leg = buildFairwayLeg( const orderedComponentIds = componentsBySnapDistance(
routableGraph, commonComponentIds,
legStart, pointSnapsByComponent
legDestination,
penaltyCounts,
adjacency,
legSnapPairs[index]!
); );
if (!leg) { 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;
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; return null;
} }
for (const coordinate of leg.coordinates) { const routeCoordinates = selectedPath.coordinates;
appendCoordinate(routeCoordinates, coordinate); const usedEdges = selectedPath.usedEdges;
} const routeSnaps = buildRouteSnaps(requestedPoints, selectedPath.snaps);
for (const edge of leg.usedEdges) {
usedEdges.set(edge.id, edge);
}
}
const distanceNm = round(sumRouteDistanceNm(routeCoordinates), 2); const distanceNm = round(sumRouteDistanceNm(routeCoordinates), 2);
const speedKn = const speedKn =
request.vesselProfile.cruiseSpeedKn && request.vesselProfile.cruiseSpeedKn > 0 request.vesselProfile.cruiseSpeedKn && request.vesselProfile.cruiseSpeedKn > 0
@@ -309,7 +382,7 @@ function buildFairwayRouteCandidate(
const departureTimestamp = requestedDepartureTimestamp(request.departureTime); const departureTimestamp = requestedDepartureTimestamp(request.departureTime);
const durationMinutes = Math.round((distanceNm / speedKn) * 60); const durationMinutes = Math.round((distanceNm / speedKn) * 60);
const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString(); 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 depthAssessment = assessFairwayDepthSamples(depthSamples, request.vesselProfile);
const warnings: RouteWarning[] = [ const warnings: RouteWarning[] = [
{ {
@@ -322,6 +395,18 @@ function buildFairwayRouteCandidate(
severity: "caution", severity: "caution",
message: "Fahrwasser-Graph ist eine MVP-Planungshilfe und keine amtliche Navigationsgrundlage." 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 ...depthAssessment.warnings
]; ];
@@ -339,17 +424,163 @@ function buildFairwayRouteCandidate(
unknownDepthRatio: depthAssessment.unknownDepthRatio, unknownDepthRatio: depthAssessment.unknownDepthRatio,
dataSources: [ dataSources: [
`fairway-graph:${routableGraph.id}`, `fairway-graph:${routableGraph.id}`,
...uniqueSources([...usedEdges.values()]), ...uniqueSources(usedEdges),
request.depthSamples?.length request.depthSamples?.length
? "submitted-depth-samples" ? "submitted-depth-samples"
: [...usedEdges.values()].some((edge) => edge.minDepthM === null) : usedEdges.some((edge) => edge.minDepthM === null)
? "fairway-depth-unknown" ? "fairway-depth-unknown"
: "fairway-depth-estimates" : "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 { function requestedDepartureTimestamp(value?: string): number {
@@ -392,6 +623,7 @@ function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): Fair
const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM; const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM;
return { return {
...graph, ...graph,
maxSnapDistanceNm: Math.min(graph.maxSnapDistanceNm, MAX_ROUTE_SNAP_DISTANCE_NM),
edges: graph.edges.filter((edge) => { edges: graph.edges.filter((edge) => {
if (edge.minDepthM !== null && edge.minDepthM < requiredDepthM) { if (edge.minDepthM !== null && edge.minDepthM < requiredDepthM) {
return false; return false;
@@ -414,39 +646,28 @@ function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): Fair
}; };
} }
function buildFairwayLeg( function buildFairwayLegBetweenSnaps(
graph: FairwayGraph, graph: FairwayGraph,
legStart: Coordinate, startSnap: EdgeSnap,
legDestination: Coordinate, destinationSnap: EdgeSnap,
penaltyCounts: ReadonlyMap<string, number>, penaltyCounts: ReadonlyMap<string, number>,
adjacency: Adjacency, adjacency: Adjacency
snapPairs: EdgeSnapPair[]
): FairwayLeg | null { ): FairwayLeg | null {
const candidates: FairwayLeg[] = []; const candidates: FairwayLeg[] = [];
const routedComponents = new Set<string>();
for (const { componentId, start: startSnap, destination: destinationSnap } of snapPairs) {
if (routedComponents.has(componentId)) {
continue;
}
const previousCandidateCount = candidates.length;
if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) { if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) {
const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap); const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap);
const coordinates: Coordinate[] = []; const coordinates: Coordinate[] = [];
appendCoordinate(coordinates, legStart);
for (const coordinate of directOnEdge) { for (const coordinate of directOnEdge) {
appendCoordinate(coordinates, coordinate); appendCoordinate(coordinates, coordinate);
} }
appendCoordinate(coordinates, legDestination);
candidates.push({ candidates.push({
coordinates, coordinates,
usedEdges: [startSnap.edge], usedEdges: [startSnap.edge],
distanceNm: sumRouteDistanceNm(coordinates), distanceNm: sumRouteDistanceNm(coordinates),
costNm: costNm:
startSnap.distanceNm + sumRouteDistanceNm(coordinates) *
sumRouteDistanceNm(directOnEdge) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) + edgePenaltyMultiplier(startSnap.edge, penaltyCounts)
destinationSnap.distanceNm
}); });
} }
@@ -466,7 +687,6 @@ function buildFairwayLeg(
const coordinates: Coordinate[] = []; const coordinates: Coordinate[] = [];
const usedEdges = new Map<string, FairwayEdge>(); const usedEdges = new Map<string, FairwayEdge>();
appendCoordinate(coordinates, legStart);
const startEdgeCoordinates = edgePathFromSnapToNode(startSnap, startNodeId); const startEdgeCoordinates = edgePathFromSnapToNode(startSnap, startNodeId);
for (const coordinate of startEdgeCoordinates) { for (const coordinate of startEdgeCoordinates) {
appendCoordinate(coordinates, coordinate); appendCoordinate(coordinates, coordinate);
@@ -485,15 +705,14 @@ function buildFairwayLeg(
appendCoordinate(coordinates, coordinate); appendCoordinate(coordinates, coordinate);
} }
usedEdges.set(destinationSnap.edge.id, destinationSnap.edge); usedEdges.set(destinationSnap.edge.id, destinationSnap.edge);
appendCoordinate(coordinates, legDestination);
const distanceNm = sumRouteDistanceNm(coordinates); const distanceNm = sumRouteDistanceNm(coordinates);
const costNm = const costNm =
startSnap.distanceNm + sumRouteDistanceNm(startEdgeCoordinates) *
sumRouteDistanceNm(startEdgeCoordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) + edgePenaltyMultiplier(startSnap.edge, penaltyCounts) +
path.reduce((total, step) => total + step.weightNm, 0) + path.reduce((total, step) => total + step.weightNm, 0) +
sumRouteDistanceNm(destinationEdgeCoordinates) * edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts) + sumRouteDistanceNm(destinationEdgeCoordinates) *
destinationSnap.distanceNm; edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts);
candidates.push({ candidates.push({
coordinates, coordinates,
usedEdges: [...usedEdges.values()], usedEdges: [...usedEdges.values()],
@@ -503,44 +722,9 @@ function buildFairwayLeg(
} }
} }
if (candidates.length > previousCandidateCount) {
routedComponents.add(componentId);
}
}
return candidates.sort((a, b) => a.costNm - b.costNm || a.distanceNm - b.distanceNm)[0] ?? null; 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( function findNearestEdgeSnapsByComponent(
graph: FairwayGraph, graph: FairwayGraph,
coordinate: Coordinate, coordinate: Coordinate,
@@ -578,14 +762,17 @@ function findNearestEdgeSnapsByComponent(
if (nearestOnEdge) { if (nearestOnEdge) {
const componentSnaps = nearestByComponent.get(componentId) ?? []; const componentSnaps = nearestByComponent.get(componentId) ?? [];
componentSnaps.push(nearestOnEdge); 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); 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; return nearestByComponent;
} }
@@ -970,7 +1157,7 @@ function assessFairwayDepthSamples(
function appendCoordinate(points: Coordinate[], coordinate: Coordinate) { function appendCoordinate(points: Coordinate[], coordinate: Coordinate) {
const previous = points.at(-1); const previous = points.at(-1);
if (previous && haversineDistanceNm(previous, coordinate) < 0.005) { if (previous && haversineDistanceNm(previous, coordinate) < 0.000001) {
return; return;
} }
+23 -5
View File
@@ -1,5 +1,9 @@
import { coordinateToGeoJson, sumRouteDistanceNm } from "./geo.js"; 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 { EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH } from "./inland-seed.js";
import type { import type {
DepthSample, DepthSample,
@@ -11,13 +15,27 @@ import type {
const DEFAULT_CRUISE_SPEED_KN = 6; const DEFAULT_CRUISE_SPEED_KN = 6;
export function buildRoute(request: RouteRequest, graph?: FairwayGraph): RouteResult | null { export type BuildRouteOptions = {
if (graph) { graph?: FairwayGraph;
return buildFairwayRoute(request, graph); 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 ( return (
buildFairwayRoute(request) ?? buildFairwayRoute(request, EMDEN_BORKUM_DEMO_GRAPH) ??
buildFairwayRoute(request, EMDEN_EAST_EMS_GRAPH) ?? buildFairwayRoute(request, EMDEN_EAST_EMS_GRAPH) ??
buildFairwayRoute(request, EMDEN_HAMM_GRAPH) buildFairwayRoute(request, EMDEN_HAMM_GRAPH)
); );
+13
View File
@@ -51,6 +51,18 @@ export type GeoJsonLineString = {
coordinates: [number, number][]; coordinates: [number, number][];
}; };
export type RouteSnap = {
requested: Coordinate;
snapped: Coordinate;
distanceM: number;
};
export type RouteSnaps = {
start: RouteSnap;
waypoints: RouteSnap[];
destination: RouteSnap;
};
type RouteDetails = { type RouteDetails = {
geometry: GeoJsonLineString; geometry: GeoJsonLineString;
distanceNm: number; distanceNm: number;
@@ -62,6 +74,7 @@ type RouteDetails = {
unknownDepthRatio: number; unknownDepthRatio: number;
dataSources: string[]; dataSources: string[];
routingMode?: "manual" | "fairway"; routingMode?: "manual" | "fairway";
routeSnaps?: RouteSnaps;
}; };
export type RouteOption = RouteDetails & { export type RouteOption = RouteDetails & {
+321 -21
View File
@@ -4,6 +4,7 @@ import {
buildFairwayRoutes, buildFairwayRoutes,
buildManualRoute, buildManualRoute,
buildRoute, buildRoute,
FairwayRoutingSearchLimitError,
haversineDistanceNm, haversineDistanceNm,
requiredDepthM, requiredDepthM,
type FairwayEdge, type FairwayEdge,
@@ -49,10 +50,10 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
{ id: "start-decoy-b", coordinate: { lat: 53.342, lon: 7.187 } }, { 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-a", coordinate: { lat: 53.3282, lon: 6.9304 } },
{ id: "destination-decoy-b", coordinate: { lat: 53.3286, lon: 6.9294 } }, { 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-east", coordinate: { lat: 53.3321722, lon: 7.1329034 } },
{ id: "shared-south", coordinate: { lat: 53.313849, lon: 7.0011017 } }, { 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: [ edges: [
edge("start-decoy", "start-decoy-a", "start-decoy-b", [ 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 } { lat: 53.3286, lon: 6.9294 }
], { source: "closer-but-disconnected-destination" }), ], { source: "closer-but-disconnected-destination" }),
edge("shared-east", "shared-start", "shared-east", [ 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 } { lat: 53.3321722, lon: 7.1329034 }
], { source: "shared-local-component" }), ], { source: "shared-local-component" }),
edge("shared-south", "shared-east", "shared-south", [ edge("shared-south", "shared-east", "shared-south", [
@@ -73,7 +74,7 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
], { source: "shared-local-component" }), ], { source: "shared-local-component" }),
edge("shared-west", "shared-south", "shared-destination", [ edge("shared-west", "shared-south", "shared-destination", [
{ lat: 53.313849, lon: 7.0011017 }, { lat: 53.313849, lon: 7.0011017 },
{ lat: 53.3303531, lon: 6.9334715 } { lat: 53.32845, lon: 6.9304 }
], { source: "shared-local-component" }) ], { source: "shared-local-component" })
] ]
}; };
@@ -147,11 +148,14 @@ describe("route assessment", () => {
}); });
it("routes Emden Außenhafen to Borkum Reede along the Ems fairway graph", () => { it("routes Emden Außenhafen to Borkum Reede along the Ems fairway graph", () => {
const result = buildRoute({ const result = buildRoute(
{
start: EMDEN_AUSSENHAFEN, start: EMDEN_AUSSENHAFEN,
destination: BORKUM_REEDE, destination: BORKUM_REEDE,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
}); },
{ allowDemoSeedGraphs: true }
);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
if (!result) { if (!result) {
throw new Error("Expected fairway route"); 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", () => { it("snaps nearby Emden-Borkum clicks to fairway segments instead of requiring exact graph nodes", () => {
const clickedStart = { lat: 53.3418, lon: 7.1904 }; const clickedStart = { lat: 53.341276, lon: 7.189146 };
const clickedDestination = { lat: 53.5608, lon: 6.7548 }; const clickedDestination = { lat: 53.560625, lon: 6.751271 };
const result = buildRoute({ const result = buildRoute(
{
start: clickedStart, start: clickedStart,
destination: clickedDestination, destination: clickedDestination,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
}); },
{ allowDemoSeedGraphs: true }
);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
if (!result) { if (!result) {
@@ -195,8 +202,26 @@ describe("route assessment", () => {
expect(result.routingMode).toBe("fairway"); expect(result.routingMode).toBe("fairway");
expect(result.geometry.coordinates.length).toBeGreaterThan(20); expect(result.geometry.coordinates.length).toBeGreaterThan(20);
expect(result.geometry.coordinates[0]).toEqual([clickedStart.lon, clickedStart.lat]); expect(result.routeSnaps?.start.requested).toEqual(clickedStart);
expect(result.geometry.coordinates.at(-1)).toEqual([clickedDestination.lon, clickedDestination.lat]); 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"); expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed");
}); });
@@ -213,13 +238,21 @@ describe("route assessment", () => {
); );
expect(routes).toHaveLength(1); expect(routes).toHaveLength(1);
expect(routes[0]?.geometry.coordinates[0]).toEqual([start.lon, start.lat]); expect(routes[0]?.geometry.coordinates[0]).toEqual([
expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]); 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).toContain("shared-local-component");
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-start"); expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-start");
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-destination"); expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-destination");
expect(routes[0]?.distanceNm).toBeGreaterThan(9.5); expect(routes[0]?.distanceNm).toBeGreaterThan(9.3);
expect(routes[0]?.distanceNm).toBeLessThan(10.5); expect(routes[0]?.distanceNm).toBeLessThan(9.7);
}); });
it("returns no route when start and destination have no shared component inside the snap radius", () => { it("returns no route when start and destination have no shared component inside the snap radius", () => {
@@ -241,6 +274,72 @@ describe("route assessment", () => {
).toBeNull(); ).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", () => { it("tries another snap in the same component when the nearest one-way branch cannot be exited", () => {
const graph: FairwayGraph = { const graph: FairwayGraph = {
id: "oneway-snap-fallback", id: "oneway-snap-fallback",
@@ -249,14 +348,14 @@ describe("route assessment", () => {
nodes: [ nodes: [
{ id: "junction", coordinate: { lat: 52, lon: 7 } }, { id: "junction", coordinate: { lat: 52, lon: 7 } },
{ id: "destination", coordinate: { lat: 52, lon: 7.04 } }, { 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: [ edges: [
edge( edge(
"oneway-trap", "oneway-trap",
"junction", "junction",
"oneway-dead-end", "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" } { oneway: true, source: "oneway-trap" }
), ),
edge( edge(
@@ -270,7 +369,7 @@ describe("route assessment", () => {
}; };
const route = buildFairwayRoute( const route = buildFairwayRoute(
{ {
start: { lat: 52.001, lon: 7 }, start: { lat: 52.00035, lon: 7 },
destination: { lat: 52, lon: 7.04 }, destination: { lat: 52, lon: 7.04 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
}, },
@@ -282,6 +381,183 @@ describe("route assessment", () => {
expect(route?.dataSources).not.toContain("oneway-trap"); 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", () => { it("does not fall back to a misleading straight line when no fairway graph matches", () => {
const result = buildRoute({ const result = buildRoute({
start: { lat: 54.1749, lon: 12.0731 }, start: { lat: 54.1749, lon: 12.0731 },
@@ -292,12 +568,36 @@ describe("route assessment", () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
it("does not silently use demo seed graphs when no routing source is provided", () => {
const request = {
start: EMDEN_AUSSENHAFEN,
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", () => { it("routes Emden to Hamm via the Ems, Dortmund-Ems-Kanal and Datteln-Hamm-Kanal fallback", () => {
const result = buildRoute({ const result = buildRoute(
{
start: EMDEN_AUSSENHAFEN, start: EMDEN_AUSSENHAFEN,
destination: HAMM_INNENSTADT_MARINA, destination: HAMM_INNENSTADT_MARINA,
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 } vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}); },
{ allowDemoSeedGraphs: true }
);
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result?.distanceNm).toBeGreaterThan(145); expect(result?.distanceNm).toBeGreaterThan(145);