diff --git a/.env.example b/.env.example index cd069e3..3d5dfd7 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,8 @@ PORT=5174 HOST=0.0.0.0 DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass 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_LIVE_FAIRWAYS=false diff --git a/README.md b/README.md index c8b4204..ca893d2 100644 --- a/README.md +++ b/README.md @@ -163,14 +163,38 @@ laufende Overpass-Verbindung. Berücksichtigt werden unter anderem explizit für Boote oder Schiffe freigegebene Flüsse. Gesperrte, private, 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 → östliche Unterems - Emden Außenhafen → Ems/Dortmund-Ems-Kanal → Datteln → Datteln-Hamm-Kanal → Wasserwanderrastplatz Hamm-Innenstadt (rund 153 sm) -Der Emden–Hamm-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 Emden–Hamm-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 diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 4600158..96848f7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -3,8 +3,10 @@ import Fastify, { type FastifyInstance } from "fastify"; import { z } from "zod"; import { buildFairwayRoutes, + EMDEN_BORKUM_DEMO_GRAPH, EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH, + FairwayRoutingSearchLimitError, type FairwayGraph, type RouteOption, type RouteRequest, @@ -13,7 +15,10 @@ import { import { loadEnv, type ApiEnv } from "./env.js"; import { createCache, type Cache } from "./services/cache.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 { getNearestTideSummary } from "./services/tides.js"; import { getMarineForecast } from "./services/weather.js"; @@ -186,14 +191,45 @@ export async function buildServer(deps: AppDeps = {}): Promise } let sourceError: unknown; - const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => { + const graphLookup = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => { sourceError = error; 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 (sourceError) { + if (sourceError || graphLookup.failures.length > 0) { return reply.code(503).send({ error: "fairway_sources_unavailable", 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) { - 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) { - return routeResultWithAlternatives(routes); + return routeResultWithAlternatives(routes, searchWasLimited); } } - for (const graph of [undefined, EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH] as const) { - const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request); + if (!allowSeedGraphs) { + if (searchWasLimited) { + throw new FairwayRoutingSearchLimitError(); + } + return null; + } + + 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); + return routeResultWithAlternatives(routes, searchWasLimited); } } + if (searchWasLimited) { + throw new FairwayRoutingSearchLimitError(); + } return null; } -function routeResultWithAlternatives(routes: RouteOption[]): RouteResult { +function routeResultWithAlternatives( + routes: RouteOption[], + searchWasLimited = false +): RouteResult { const [primary, ...alternatives] = routes; if (!primary) { 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 { - ...primary, - alternatives + ...annotatedPrimary, + alternatives: alternatives.map(annotate) }; } diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index 2b8a771..b311a83 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -20,7 +20,7 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv { (env.NODE_ENV === "test" ? undefined : "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: (env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" && env.NODE_ENV !== "test" diff --git a/apps/api/src/services/fairways.ts b/apps/api/src/services/fairways.ts index 10af572..b3aef73 100644 --- a/apps/api/src/services/fairways.ts +++ b/apps/api/src/services/fairways.ts @@ -37,6 +37,18 @@ type FairwayDeps = { 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 = { id: string; source: string; @@ -56,9 +68,8 @@ const MAX_BBOX_SPAN_DEG = 3; const MAX_POSTGIS_BBOX_SPAN_DEG = 6; const MAX_LOCAL_BBOX_SPAN_DEG = 15; const BBOX_MARGIN_DEG = 0.15; -const ENDPOINT_SNAP_DEG = 0.0001; -const CONNECTOR_DISTANCE_NM = 0.08; -const CONNECTOR_GRID_DEG = 0.003; +const ENDPOINT_SNAP_DEG = 0.000001; +const MAX_FAIRWAY_SNAP_DISTANCE_NM = 150 / 1852; export class FairwayService { private readonly cache: Cache; @@ -76,29 +87,58 @@ export class FairwayService { this.pool = deps.databaseUrl ? new pg.Pool({ connectionString: deps.databaseUrl }) : null; } - async getGraphsForRoute(request: RouteRequest): Promise { - const results = await Promise.allSettled([ - this.getPostgisGraphForRoute(request), - this.getLocalGraphForRoute(request), - this.getLiveGraphForRoute(request) - ]); - const graphs = results.flatMap((result) => - result.status === "fulfilled" && result.value ? [result.value] : [] - ); + async getGraphsForRoute(request: RouteRequest): Promise { + if (!this.pool && !this.localDataPath && !this.liveEnabled) { + return { + graphs: [], + failures: [ + { + source: "configuration", + error: new Error("No fairway source is configured") + } + ] + }; + } - if (graphs.length === 0) { - const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected"); - if (failures.length > 0) { - throw new AggregateError(failures.map((failure) => failure.reason), "No fairway source produced a graph"); + const sources: Array<{ + source: Exclude; + load: () => Promise; + }> = []; + 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) { - return graphs; + return { graphs, failures }; } const combined = mergeConnectedFairwayGraphs(graphs); - return combined ? [combined, ...graphs] : graphs; + return { + graphs: combined ? [combined, ...graphs] : graphs, + failures + }; } async close(): Promise { @@ -169,19 +209,18 @@ export class FairwayService { throw new Error(`Unsupported local fairway data format: ${this.localDataPath}`); } return document; - }) - .catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") { - return null; - } - throw error; }); } - const document = await this.localDocument; - if (!document) { - this.localDocument = null; + + const currentLoad = this.localDocument; + try { + return await currentLoad; + } catch (error) { + if (this.localDocument === currentLoad) { + this.localDocument = null; + } + throw error; } - return document; } private async getLiveGraphForRoute(request: RouteRequest): Promise { @@ -386,7 +425,6 @@ export function mergeConnectedFairwayGraphs(graphs: FairwayGraph[]): FairwayGrap } } - addEndpointConnectors(nodes, edges); if (edges.size === 0) { return null; } @@ -445,8 +483,6 @@ function waysToGraph( } } - addEndpointConnectors(nodes, edges); - if (edges.size === 0) { return null; } @@ -454,7 +490,7 @@ function waysToGraph( return { id, name, - maxSnapDistanceNm: 2, + maxSnapDistanceNm: MAX_FAIRWAY_SNAP_DISTANCE_NM, nodes: [...nodes.values()], edges: [...edges.values()] }; @@ -543,65 +579,6 @@ function nodeIdFor(nodes: Map, coordinate: Coordinate) { return id; } -function addEndpointConnectors(nodes: Map, edges: Map) { - const degree = new Map(); - 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(); - - 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(); - - 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) { const candidates = [ tags["seamark:fairway:minimum_depth"], @@ -699,10 +676,3 @@ function sourceFor(tags: Record) { } 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); -} diff --git a/apps/api/tests/api.test.ts b/apps/api/tests/api.test.ts index 3251196..3b548e7 100644 --- a/apps/api/tests/api.test.ts +++ b/apps/api/tests/api.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Coordinate, FairwayGraph } from "@watermaps/shared"; import { buildServer } from "../src/app.js"; +import { loadEnv } from "../src/env.js"; import { createCache } from "../src/services/cache.js"; import type { LockOperationInfo, @@ -12,6 +14,79 @@ const jsonResponse = (body: unknown) => 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", () => { afterEach(() => { vi.restoreAllMocks(); @@ -146,7 +221,7 @@ describe("Watermaps API", () => { }); 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({ method: "POST", url: "/api/routes", @@ -166,7 +241,16 @@ describe("Watermaps API", () => { }); 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({ method: "POST", url: "/api/routes", @@ -187,6 +271,7 @@ describe("Watermaps API", () => { it("reports unavailable fairway sources instead of claiming that no route exists", async () => { const app = await buildServer({ cache: createCache(), + env: productionEnv, fairwayService: { async getGraphsForRoute() { throw new AggregateError([new Error("local data missing"), new Error("Overpass timeout")]); @@ -198,8 +283,8 @@ describe("Watermaps API", () => { method: "POST", url: "/api/routes", payload: { - start: { lat: 54.1749, lon: 12.0731 }, - destination: { lat: 54.1833, lon: 12.0928 }, + start: { lat: 53.4498, lon: 7.4509 }, + destination: { lat: 53.4646, lon: 7.4742 }, vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 } } }); @@ -209,8 +294,140 @@ describe("Watermaps API", () => { 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 () => { - const app = await buildServer({ cache: createCache() }); + const app = await buildServer({ cache: createCache(), env: demoEnv }); const response = await app.inject({ method: "POST", url: "/api/routes", @@ -235,8 +452,9 @@ describe("Watermaps API", () => { cache: createCache(), fairwayService: { async getGraphsForRoute() { - return [ - { + return { + graphs: [ + { id: "norddeich-norderney-test", name: "Norddeich–Norderney", maxSnapDistanceNm: 0.5, @@ -271,8 +489,10 @@ describe("Watermaps API", () => { ] } ] - } - ]; + } + ], + failures: [] + }; }, async close() {} } @@ -300,6 +520,7 @@ describe("Watermaps API", () => { it("routes from Emden into the eastern lower Ems when all dynamic sources fail", async () => { const app = await buildServer({ cache: createCache(), + env: demoEnv, fairwayService: { async getGraphsForRoute() { throw new AggregateError([new Error("PostGIS unavailable"), new Error("Overpass timeout")]); @@ -321,14 +542,21 @@ describe("Watermaps API", () => { expect(response.statusCode).toBe(200); expect(body.routingMode).toBe("fairway"); expect(body.dataSources).toContain("fairway-graph:emden-east-ems-seed"); - expect(body.geometry.coordinates[0]).toEqual([7.1871, 53.3422]); - expect(body.geometry.coordinates.at(-1)?.[0]).toBeCloseTo(7.4734, 3); - expect(body.geometry.coordinates.at(-1)?.[1]).toBeCloseTo(53.465, 3); + expect(body.routeSnaps.start.requested).toEqual({ lat: 53.3422, lon: 7.1871 }); + expect(body.routeSnaps.destination.requested).toEqual({ lat: 53.465, lon: 7.4734 }); + 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(); }); 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({ method: "POST", url: "/api/routes", @@ -344,7 +572,14 @@ describe("Watermaps API", () => { expect(body.distanceNm).toBeGreaterThan(145); expect(body.distanceNm).toBeLessThan(165); 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(); }); @@ -353,8 +588,9 @@ describe("Watermaps API", () => { cache: createCache(), fairwayService: { async getGraphsForRoute() { - return [ - { + return { + graphs: [ + { id: "test-extracted", name: "Test Extracted Fairways", maxSnapDistanceNm: 1, @@ -389,8 +625,10 @@ describe("Watermaps API", () => { ] } ] - } - ]; + } + ], + failures: [] + }; } } }); @@ -418,8 +656,9 @@ describe("Watermaps API", () => { cache: createCache(), fairwayService: { async getGraphsForRoute() { - return [ - { + return { + graphs: [ + { id: "local-geofabrik-component-snap", name: "Lokaler Geofabrik-Komponententest", maxSnapDistanceNm: 0.3, @@ -428,10 +667,10 @@ describe("Watermaps API", () => { { id: "start-decoy-b", coordinate: coordinate(53.342, 7.187) }, { id: "destination-decoy-a", coordinate: coordinate(53.3282, 6.9304) }, { 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-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: [ { @@ -457,7 +696,7 @@ describe("Watermaps API", () => { name: "Gemeinsamer lokaler Korridor Ost", from: "shared-start", 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, source: "local-geofabrik-germany+netherlands" }, @@ -475,13 +714,15 @@ describe("Watermaps API", () => { name: "Gemeinsamer lokaler Korridor West", from: "shared-south", 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, source: "local-geofabrik-germany+netherlands" } ] - } - ]; + } + ], + failures: [] + }; }, async close() {} } @@ -499,8 +740,16 @@ describe("Watermaps API", () => { expect(response.statusCode).toBe(200); expect(body.routingMode).toBe("fairway"); - expect(body.geometry.coordinates[0]).toEqual([7.186, 53.3416]); - expect(body.geometry.coordinates.at(-1)).toEqual([6.9304, 53.3282]); + 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 + ]); + 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).not.toContain("fairway-graph:ems-borkum-seed"); expect(body.dataSources).not.toContain("closer-but-disconnected-start"); @@ -523,8 +772,9 @@ describe("Watermaps API", () => { cache: createCache(), fairwayService: { async getGraphsForRoute() { - return [ - { + return { + graphs: [ + { id: "api-alternatives", name: "API Alternativen", maxSnapDistanceNm: 0.2, @@ -545,8 +795,10 @@ describe("Watermaps API", () => { edge("lower-out", "lower", "branch-out", [coordinate(51.988, 7.03), coordinate(52, 7.05)]), edge("destination-access", "branch-out", "destination", [coordinate(52, 7.05), coordinate(52, 7.06)]) ] - } - ]; + } + ], + failures: [] + }; } } }); diff --git a/apps/api/tests/env.test.ts b/apps/api/tests/env.test.ts new file mode 100644 index 0000000..629126f --- /dev/null +++ b/apps/api/tests/env.test.ts @@ -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); + }); +}); diff --git a/apps/api/tests/fairways.test.ts b/apps/api/tests/fairways.test.ts index 48c39a4..556c746 100644 --- a/apps/api/tests/fairways.test.ts +++ b/apps/api/tests/fairways.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { buildRoute } from "@watermaps/shared"; +import { buildRoute, haversineDistanceNm } from "@watermaps/shared"; import { createCache } from "../src/services/cache.js"; import { FairwayService, @@ -12,7 +12,180 @@ import { overpassToGraph } from "../src/services/fairways.js"; +const EMDEN_CANAL_REGRESSION_WAYS: Parameters[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", () => { + 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", () => { const graph = localFairwaysToGraph( [ @@ -43,6 +216,110 @@ describe("fairway graph extraction", () => { 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 () => { const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-local-span-")); const localDataPath = join(temporaryDirectory, "germany-netherlands-fairways.json"); @@ -78,10 +355,11 @@ describe("fairway graph extraction", () => { liveEnabled: false, localDataPath }); - const graphs = await service.getGraphsForRoute(request); - const route = buildRoute(request, graphs[0]); + const lookup = await service.getGraphsForRoute(request); + 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?.dataSources).toContain( "local-geofabrik-germany+netherlands" @@ -156,6 +434,7 @@ describe("fairway graph extraction", () => { geometry: { type: "LineString", coordinates: [ + [7.1752, 53.3306], [7.1751368, 53.3331995], [7.16, 53.42], [7.1474, 53.55], @@ -173,7 +452,8 @@ describe("fairway graph extraction", () => { type: "LineString", coordinates: [ [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?.routingMode).toBe("fairway"); expect(route?.dataSources).toContain("postgis-osm"); - expect(route?.geometry.coordinates[0]).toEqual([start.lon, start.lat]); - expect(route?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]); + expect(route?.routeSnaps?.start.requested).toEqual(start); + 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); }); diff --git a/apps/web/src/lib/offline-route.ts b/apps/web/src/lib/offline-route.ts index cbb0022..bd9991d 100644 --- a/apps/web/src/lib/offline-route.ts +++ b/apps/web/src/lib/offline-route.ts @@ -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 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."); } - const defaultStart = { lat: first[1], lon: first[0] }; - const defaultDestination = { lat: last[1], lon: last[0] }; + const defaultStart = route.routeSnaps?.start.requested ?? { lat: first[1], lon: first[0] }; + const defaultDestination = + route.routeSnaps?.destination.requested ?? { lat: last[1], lon: last[0] }; const planInput = input.plan ?? {}; return { @@ -215,6 +222,8 @@ function normalizeRoute(value: unknown): RouteResult { typeof value.departureTime === "string" ? normalizeIsoDate(value.departureTime) : undefined; const durationMinutes = value.durationMinutes === undefined ? undefined : finiteNumber(value.durationMinutes, 0, 10_000_000); + const routeSnaps = + value.routeSnaps === undefined ? undefined : normalizeRouteSnaps(value.routeSnaps); return { ...(id ? { id } : {}), @@ -228,7 +237,31 @@ function normalizeRoute(value: unknown): RouteResult { dataSources, ...(departureTime ? { departureTime } : {}), ...(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) }; } diff --git a/apps/web/tests/offline-route.test.ts b/apps/web/tests/offline-route.test.ts index 31a0eb3..838136f 100644 --- a/apps/web/tests/offline-route.test.ts +++ b/apps/web/tests/offline-route.test.ts @@ -38,6 +38,7 @@ describe("offline voyage storage", () => { 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.durationMinutes).toBe(120); + expect(saved.route.routeSnaps).toEqual(routeFixture.routeSnaps); expect(saved.route.alternatives).toBeUndefined(); expect(listOfflineVoyages(localStorage)).toHaveLength(1); const restored = loadOfflineVoyage("voyage-test", localStorage); @@ -83,7 +84,7 @@ const routeFixture: RouteResult = { name: "Emden – Hamm", geometry: { 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, departureTime: "2026-07-20T06:00:00.000Z", @@ -94,6 +95,19 @@ const routeFixture: RouteResult = { unknownDepthRatio: 1, dataSources: ["OSM"], 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: [{ id: "unused", name: "Alternative", diff --git a/deploy/scripts/common.sh b/deploy/scripts/common.sh index 438a215..b4dde98 100755 --- a/deploy/scripts/common.sh +++ b/deploy/scripts/common.sh @@ -326,6 +326,22 @@ wm_smoke_test_route() { destination: { lat: 53.465, lon: 7.4734 }, 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: "Norddeich–Norderney", start: { lat: 53.6234, lon: 7.1559 }, @@ -334,8 +350,8 @@ wm_smoke_test_route() { }, { name: "Emden–Delfzijl", - start: { lat: 53.3416, lon: 7.186 }, - destination: { lat: 53.3282, lon: 6.9304 }, + start: { lat: 53.3395697, lon: 7.1848883 }, + destination: { lat: 53.330353, lon: 6.9334717 }, minimumAlternatives: 0, minimumCoordinates: 30, minimumDistanceNm: 9.5, @@ -349,9 +365,9 @@ wm_smoke_test_route() { ] }, { - name: "Weesp–Utrecht", + name: "Weesp–Utrecht (Werkspoorhaven)", start: { lat: 52.309, lon: 5.0423 }, - destination: { lat: 52.105, lon: 5.085 }, + destination: { lat: 52.1058659, lon: 5.0788596 }, minimumAlternatives: 2 }, { @@ -411,7 +427,9 @@ wm_smoke_test_route() { Array.isArray(routeCoordinates) && expectedCorridorCoordinates.every((expectedCoordinate) => routeCoordinates.some( - (coordinate) => distanceNm(coordinate, expectedCoordinate) <= 0.15 + (coordinate) => + distanceNm(coordinate, expectedCoordinate) <= + (routeCheck.corridorToleranceNm ?? 0.15) ) ) ); diff --git a/docker-compose.yml b/docker-compose.yml index 905446c..114ea70 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json WATERMAPS_LIVE_FAIRWAYS: "false" - WATERMAPS_DEMO_DATA: "true" + WATERMAPS_DEMO_DATA: "${WATERMAPS_DEMO_DATA:-false}" volumes: - type: bind source: ./data/local diff --git a/packages/shared/src/fairway-routing.ts b/packages/shared/src/fairway-routing.ts index 769eb7b..1985468 100644 --- a/packages/shared/src/fairway-routing.ts +++ b/packages/shared/src/fairway-routing.ts @@ -5,6 +5,8 @@ import type { RouteOption, RouteRequest, RouteResult, + RouteSnap, + RouteSnaps, RouteWarning, VesselProfile } from "./types.js"; @@ -36,12 +38,6 @@ type EdgeSnap = { distanceNm: number; }; -type EdgeSnapPair = { - componentId: string; - start: EdgeSnap; - destination: EdgeSnap; -}; - export type FairwayGraph = { id: string; name: string; @@ -50,6 +46,15 @@ export type FairwayGraph = { edges: FairwayEdge[]; }; +export class FairwayRoutingSearchLimitError extends Error { + constructor() { + super( + "Fairway routing search budget was exhausted before all snap candidates could be checked" + ); + this.name = "FairwayRoutingSearchLimitError"; + } +} + type PathStep = { edge: FairwayEdge; from: string; @@ -71,15 +76,36 @@ type FairwayRouteCandidate = { usedEdges: FairwayEdge[]; }; +type FairwayPathState = { + currentSnap: EdgeSnap; + coordinates: Coordinate[]; + usedEdges: FairwayEdge[]; + costNm: number; + snaps: EdgeSnap[]; +}; + const DEFAULT_CRUISE_SPEED_KN = 6; const MAX_ALTERNATIVE_ROUTES = 3; const ALTERNATIVE_EDGE_PENALTY = 8; const MAX_ALTERNATIVE_DISTANCE_FACTOR = 1.75; const MIN_DIFFERENT_DISTANCE_NM = 0.25; const MIN_DIFFERENT_DISTANCE_RATIO = 0.05; -const MAX_EDGE_SNAPS_PER_COMPONENT = 4; +const MAX_EDGE_SNAPS_PER_COMPONENT = 8; +// Bounds the quadratic snap-pair work across all candidate components in one +// route attempt. With the API limit of 25 waypoints (26 legs), six candidates +// still fit: 26 * 6² = 936. Truncation is surfaced instead of silently claiming +// that no fairway route exists. +const MAX_SNAP_PAIR_EVALUATIONS_PER_ATTEMPT = 1024; +// A snap is only an association with the graph; the gap is never emitted as a navigable line. +// 150 m still accepts harbour/ferry POIs that lie on the bank while rejecting remote waterways. +// This is a hard safety boundary; a graph may request a smaller radius, but never a larger one. +const MAX_ROUTE_SNAP_DISTANCE_M = 150; +const MAX_ROUTE_SNAP_DISTANCE_NM = MAX_ROUTE_SNAP_DISTANCE_M / 1852; +const SNAP_WARNING_DISTANCE_M = 10; +// Prefer the closest reachable fairway instead of shortening a route by ending early off the graph. +const SNAP_DISTANCE_COST_MULTIPLIER = 10; -const EMS_BORKUM_GRAPH: FairwayGraph = { +export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = { id: "ems-borkum-seed", name: "Emsfahrwasser Emden-Borkum", maxSnapDistanceNm: 3, @@ -199,15 +225,19 @@ const EMS_BORKUM_GRAPH: FairwayGraph = { ] }; -export function buildFairwayRoute(request: RouteRequest, graph = EMS_BORKUM_GRAPH): RouteResult | null { +export function buildFairwayRoute(request: RouteRequest, graph: FairwayGraph): RouteResult | null { return buildFairwayRoutes(request, graph, 1)[0] ?? null; } export function buildFairwayRoutes( request: RouteRequest, - graph = EMS_BORKUM_GRAPH, + graph: FairwayGraph, maxRoutes = MAX_ALTERNATIVE_ROUTES ): RouteOption[] { + if (!graph) { + return []; + } + const routeLimit = Math.max(0, Math.min(MAX_ALTERNATIVE_ROUTES, Math.floor(maxRoutes))); if (routeLimit === 0) { return []; @@ -220,15 +250,13 @@ export function buildFairwayRoutes( const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination]; const componentByNode = weaklyConnectedComponents(routableGraph); - const legSnapPairs = requestedPoints.slice(0, -1).map((start, index) => - findCompatibleEdgeSnapPairs( - routableGraph, - start, - requestedPoints[index + 1]!, - componentByNode - ) + const pointSnapsByComponent = requestedPoints.map((point) => + findNearestEdgeSnapsByComponent(routableGraph, point, componentByNode) ); - if (legSnapPairs.some((pairs) => pairs.length === 0)) { + const commonComponentIds = [...(pointSnapsByComponent[0]?.keys() ?? [])].filter((componentId) => + pointSnapsByComponent.every((snaps) => snaps.has(componentId)) + ); + if (commonComponentIds.length === 0) { return []; } @@ -237,7 +265,24 @@ export function buildFairwayRoutes( const maxAttempts = Math.max(8, routeLimit * 6); for (let attempt = 0; attempt < maxAttempts && accepted.length < routeLimit; attempt += 1) { - const candidate = buildFairwayRouteCandidate(request, routableGraph, penaltyCounts, legSnapPairs); + let candidate: FairwayRouteCandidate | null; + try { + candidate = buildFairwayRouteCandidate( + request, + routableGraph, + penaltyCounts, + pointSnapsByComponent, + commonComponentIds + ); + } catch (error) { + if ( + error instanceof FairwayRoutingSearchLimitError && + accepted.length > 0 + ) { + break; + } + throw error; + } if (!candidate) { break; } @@ -269,38 +314,66 @@ function buildFairwayRouteCandidate( request: RouteRequest, routableGraph: FairwayGraph, penaltyCounts: ReadonlyMap, - legSnapPairs: EdgeSnapPair[][] + pointSnapsByComponent: Array>, + commonComponentIds: string[] ): FairwayRouteCandidate | null { const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination]; - const routeCoordinates: Coordinate[] = []; - const usedEdges = new Map(); const adjacency = buildAdjacency(routableGraph, penaltyCounts); + const completedPaths: FairwayPathState[] = []; + let remainingPairEvaluations = MAX_SNAP_PAIR_EVALUATIONS_PER_ATTEMPT; + let searchTruncated = false; + let processedComponentCount = 0; + const orderedComponentIds = componentsBySnapDistance( + commonComponentIds, + pointSnapsByComponent + ); - for (let index = 0; index < requestedPoints.length - 1; index += 1) { - const legStart = requestedPoints[index]!; - const legDestination = requestedPoints[index + 1]!; - const leg = buildFairwayLeg( - routableGraph, - legStart, - legDestination, - penaltyCounts, - adjacency, - legSnapPairs[index]! + for (const componentId of orderedComponentIds) { + const snapLayers = pointSnapsByComponent.map((snaps) => snaps.get(componentId) ?? []); + const maxCandidateCount = Math.max(...snapLayers.map((snaps) => snaps.length)); + let candidateLimit = Math.min(MAX_EDGE_SNAPS_PER_COMPONENT, maxCandidateCount); + while ( + candidateLimit > 0 && + snapPairEvaluationCost(snapLayers, candidateLimit) > remainingPairEvaluations + ) { + candidateLimit -= 1; + } + if (candidateLimit < 1) { + searchTruncated = true; + break; + } + + processedComponentCount += 1; + searchTruncated ||= candidateLimit < maxCandidateCount; + remainingPairEvaluations -= snapPairEvaluationCost(snapLayers, candidateLimit); + const limitedSnapLayers = snapLayers.map((snaps) => snaps.slice(0, candidateLimit)); + completedPaths.push( + ...buildFairwayPathStates( + routableGraph, + penaltyCounts, + adjacency, + limitedSnapLayers + ).filter((path) => path.coordinates.length >= 2) ); + } + searchTruncated ||= processedComponentCount < orderedComponentIds.length; - if (!leg) { - return null; - } - - for (const coordinate of leg.coordinates) { - appendCoordinate(routeCoordinates, coordinate); - } - - for (const edge of leg.usedEdges) { - usedEdges.set(edge.id, edge); + const selectedPath = + completedPaths.sort( + (a, b) => + a.costNm - b.costNm || + sumRouteDistanceNm(a.coordinates) - sumRouteDistanceNm(b.coordinates) + )[0] ?? null; + if (!selectedPath) { + if (searchTruncated) { + throw new FairwayRoutingSearchLimitError(); } + return null; } + const routeCoordinates = selectedPath.coordinates; + const usedEdges = selectedPath.usedEdges; + const routeSnaps = buildRouteSnaps(requestedPoints, selectedPath.snaps); const distanceNm = round(sumRouteDistanceNm(routeCoordinates), 2); const speedKn = request.vesselProfile.cruiseSpeedKn && request.vesselProfile.cruiseSpeedKn > 0 @@ -309,7 +382,7 @@ function buildFairwayRouteCandidate( const departureTimestamp = requestedDepartureTimestamp(request.departureTime); const durationMinutes = Math.round((distanceNm / speedKn) * 60); const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString(); - const depthSamples = request.depthSamples ?? edgeDepthSamples([...usedEdges.values()]); + const depthSamples = request.depthSamples ?? edgeDepthSamples(usedEdges); const depthAssessment = assessFairwayDepthSamples(depthSamples, request.vesselProfile); const warnings: RouteWarning[] = [ { @@ -322,6 +395,18 @@ function buildFairwayRouteCandidate( severity: "caution", message: "Fahrwasser-Graph ist eine MVP-Planungshilfe und keine amtliche Navigationsgrundlage." }, + ...(searchTruncated + ? [ + { + code: "ROUTE_SEARCH_LIMITED", + severity: "caution" as const, + message: + "Die Snap-Suche wurde wegen eines dichten Fahrwassernetzes begrenzt. " + + "Die Route ist befahrbar verbunden, aber möglicherweise nicht die kürzeste Option." + } + ] + : []), + ...routeSnapWarnings(routeSnaps), ...depthAssessment.warnings ]; @@ -339,17 +424,163 @@ function buildFairwayRouteCandidate( unknownDepthRatio: depthAssessment.unknownDepthRatio, dataSources: [ `fairway-graph:${routableGraph.id}`, - ...uniqueSources([...usedEdges.values()]), + ...uniqueSources(usedEdges), request.depthSamples?.length ? "submitted-depth-samples" - : [...usedEdges.values()].some((edge) => edge.minDepthM === null) + : usedEdges.some((edge) => edge.minDepthM === null) ? "fairway-depth-unknown" : "fairway-depth-estimates" ], - routingMode: "fairway" + routingMode: "fairway", + routeSnaps }; - return { route, usedEdges: [...usedEdges.values()] }; + return { route, usedEdges }; +} + +function componentsBySnapDistance( + componentIds: string[], + pointSnapsByComponent: Array> +): 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, + adjacency: Adjacency, + snapLayers: EdgeSnap[][] +): FairwayPathState[] { + let states: FairwayPathState[] = (snapLayers[0] ?? []).map((startSnap) => ({ + currentSnap: startSnap, + coordinates: [], + usedEdges: [], + costNm: startSnap.distanceNm * SNAP_DISTANCE_COST_MULTIPLIER, + snaps: [startSnap] + })); + + for (let pointIndex = 1; pointIndex < snapLayers.length && states.length > 0; pointIndex += 1) { + const nextStates: FairwayPathState[] = []; + for (const destinationSnap of snapLayers[pointIndex]!) { + let bestState: FairwayPathState | null = null; + + for (const state of states) { + const leg = buildFairwayLegBetweenSnaps( + graph, + state.currentSnap, + destinationSnap, + penaltyCounts, + adjacency + ); + if (!leg) { + continue; + } + + const coordinates = [...state.coordinates]; + for (const coordinate of leg.coordinates) { + appendCoordinate(coordinates, coordinate); + } + const usedEdges = new Map(state.usedEdges.map((edge) => [edge.id, edge])); + for (const edge of leg.usedEdges) { + usedEdges.set(edge.id, edge); + } + const candidateState: FairwayPathState = { + currentSnap: destinationSnap, + coordinates, + usedEdges: [...usedEdges.values()], + costNm: + state.costNm + + leg.costNm + + destinationSnap.distanceNm * SNAP_DISTANCE_COST_MULTIPLIER, + snaps: [...state.snaps, destinationSnap] + }; + if ( + !bestState || + candidateState.costNm < bestState.costNm || + ( + candidateState.costNm === bestState.costNm && + sumRouteDistanceNm(candidateState.coordinates) < + sumRouteDistanceNm(bestState.coordinates) + ) + ) { + bestState = candidateState; + } + } + + if (bestState) { + nextStates.push(bestState); + } + } + states = nextStates; + } + + return states; +} + +function buildRouteSnaps(requestedPoints: Coordinate[], snaps: EdgeSnap[]): RouteSnaps { + if (requestedPoints.length !== snaps.length || requestedPoints.length < 2) { + throw new Error("Route snap metadata does not match the requested route points"); + } + + const normalized = requestedPoints.map((requested, index): RouteSnap => { + const snap = snaps[index]!; + return { + requested, + snapped: snap.coordinate, + distanceM: round(snap.distanceNm * 1852, 1) + }; + }); + + return { + start: normalized[0]!, + waypoints: normalized.slice(1, -1), + destination: normalized.at(-1)! + }; +} + +function routeSnapWarnings(routeSnaps: RouteSnaps): RouteWarning[] { + const points = [ + { label: "Startpunkt", snap: routeSnaps.start }, + ...routeSnaps.waypoints.map((snap, index) => ({ + label: `Zwischenziel ${index + 1}`, + snap + })), + { label: "Zielpunkt", snap: routeSnaps.destination } + ]; + + return points.flatMap(({ label, snap }) => { + if (snap.distanceM < SNAP_WARNING_DISTANCE_M) { + return []; + } + return [ + { + code: "ROUTE_POINT_SNAPPED", + severity: snap.distanceM >= 25 ? "caution" : "info", + message: + `${label} liegt ${Math.round(snap.distanceM)} m vom erfassten Fahrwasser entfernt. ` + + "Diese ungeprüfte Zufahrt ist nicht Teil der Routengeometrie.", + coordinate: snap.requested + } satisfies RouteWarning + ]; + }); } function requestedDepartureTimestamp(value?: string): number { @@ -392,6 +623,7 @@ function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): Fair const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM; return { ...graph, + maxSnapDistanceNm: Math.min(graph.maxSnapDistanceNm, MAX_ROUTE_SNAP_DISTANCE_NM), edges: graph.edges.filter((edge) => { if (edge.minDepthM !== null && edge.minDepthM < requiredDepthM) { return false; @@ -414,133 +646,85 @@ function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): Fair }; } -function buildFairwayLeg( +function buildFairwayLegBetweenSnaps( graph: FairwayGraph, - legStart: Coordinate, - legDestination: Coordinate, + startSnap: EdgeSnap, + destinationSnap: EdgeSnap, penaltyCounts: ReadonlyMap, - adjacency: Adjacency, - snapPairs: EdgeSnapPair[] + adjacency: Adjacency ): FairwayLeg | null { const candidates: FairwayLeg[] = []; - const routedComponents = new Set(); - for (const { componentId, start: startSnap, destination: destinationSnap } of snapPairs) { - if (routedComponents.has(componentId)) { + if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) { + const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap); + const coordinates: Coordinate[] = []; + for (const coordinate of directOnEdge) { + appendCoordinate(coordinates, coordinate); + } + candidates.push({ + coordinates, + usedEdges: [startSnap.edge], + distanceNm: sumRouteDistanceNm(coordinates), + costNm: + sumRouteDistanceNm(coordinates) * + edgePenaltyMultiplier(startSnap.edge, penaltyCounts) + }); + } + + for (const startNodeId of [startSnap.edge.from, startSnap.edge.to]) { + if (!canTraverseFromSnapToNode(startSnap, startNodeId)) { continue; } - const previousCandidateCount = candidates.length; - - if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) { - const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap); - const coordinates: Coordinate[] = []; - appendCoordinate(coordinates, legStart); - for (const coordinate of directOnEdge) { - appendCoordinate(coordinates, coordinate); - } - appendCoordinate(coordinates, legDestination); - candidates.push({ - coordinates, - usedEdges: [startSnap.edge], - distanceNm: sumRouteDistanceNm(coordinates), - costNm: - startSnap.distanceNm + - sumRouteDistanceNm(directOnEdge) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) + - destinationSnap.distanceNm - }); - } - - for (const startNodeId of [startSnap.edge.from, startSnap.edge.to]) { - if (!canTraverseFromSnapToNode(startSnap, startNodeId)) { + for (const destinationNodeId of [destinationSnap.edge.from, destinationSnap.edge.to]) { + if (!canTraverseFromNodeToSnap(destinationSnap, destinationNodeId)) { continue; } - for (const destinationNodeId of [destinationSnap.edge.from, destinationSnap.edge.to]) { - if (!canTraverseFromNodeToSnap(destinationSnap, destinationNodeId)) { - continue; - } - const path = shortestPath(graph, adjacency, startNodeId, destinationNodeId); - if (!path) { - continue; - } - - const coordinates: Coordinate[] = []; - const usedEdges = new Map(); - - appendCoordinate(coordinates, legStart); - const startEdgeCoordinates = edgePathFromSnapToNode(startSnap, startNodeId); - for (const coordinate of startEdgeCoordinates) { - appendCoordinate(coordinates, coordinate); - } - usedEdges.set(startSnap.edge.id, startSnap.edge); - - for (const step of path) { - usedEdges.set(step.edge.id, step.edge); - for (const coordinate of edgeCoordinates(step)) { - appendCoordinate(coordinates, coordinate); - } - } - - const destinationEdgeCoordinates = edgePathFromNodeToSnap(destinationSnap, destinationNodeId); - for (const coordinate of destinationEdgeCoordinates) { - appendCoordinate(coordinates, coordinate); - } - usedEdges.set(destinationSnap.edge.id, destinationSnap.edge); - appendCoordinate(coordinates, legDestination); - - const distanceNm = sumRouteDistanceNm(coordinates); - const costNm = - startSnap.distanceNm + - sumRouteDistanceNm(startEdgeCoordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) + - path.reduce((total, step) => total + step.weightNm, 0) + - sumRouteDistanceNm(destinationEdgeCoordinates) * edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts) + - destinationSnap.distanceNm; - candidates.push({ - coordinates, - usedEdges: [...usedEdges.values()], - distanceNm, - costNm - }); + const path = shortestPath(graph, adjacency, startNodeId, destinationNodeId); + if (!path) { + continue; } - } - if (candidates.length > previousCandidateCount) { - routedComponents.add(componentId); + const coordinates: Coordinate[] = []; + const usedEdges = new Map(); + + const startEdgeCoordinates = edgePathFromSnapToNode(startSnap, startNodeId); + for (const coordinate of startEdgeCoordinates) { + appendCoordinate(coordinates, coordinate); + } + usedEdges.set(startSnap.edge.id, startSnap.edge); + + for (const step of path) { + usedEdges.set(step.edge.id, step.edge); + for (const coordinate of edgeCoordinates(step)) { + appendCoordinate(coordinates, coordinate); + } + } + + const destinationEdgeCoordinates = edgePathFromNodeToSnap(destinationSnap, destinationNodeId); + for (const coordinate of destinationEdgeCoordinates) { + appendCoordinate(coordinates, coordinate); + } + usedEdges.set(destinationSnap.edge.id, destinationSnap.edge); + + const distanceNm = sumRouteDistanceNm(coordinates); + const costNm = + sumRouteDistanceNm(startEdgeCoordinates) * + edgePenaltyMultiplier(startSnap.edge, penaltyCounts) + + path.reduce((total, step) => total + step.weightNm, 0) + + sumRouteDistanceNm(destinationEdgeCoordinates) * + edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts); + candidates.push({ + coordinates, + usedEdges: [...usedEdges.values()], + distanceNm, + costNm + }); } } return candidates.sort((a, b) => a.costNm - b.costNm || a.distanceNm - b.distanceNm)[0] ?? null; } -function findCompatibleEdgeSnapPairs( - graph: FairwayGraph, - start: Coordinate, - destination: Coordinate, - componentByNode: ReadonlyMap -): EdgeSnapPair[] { - const startSnaps = findNearestEdgeSnapsByComponent(graph, start, componentByNode); - const destinationSnaps = findNearestEdgeSnapsByComponent(graph, destination, componentByNode); - const pairs: EdgeSnapPair[] = []; - - for (const [componentId, componentStartSnaps] of startSnaps) { - const componentDestinationSnaps = destinationSnaps.get(componentId); - if (!componentDestinationSnaps) { - continue; - } - - for (const startSnap of componentStartSnaps) { - for (const destinationSnap of componentDestinationSnaps) { - pairs.push({ componentId, start: startSnap, destination: destinationSnap }); - } - } - } - - return pairs.sort( - (a, b) => - a.start.distanceNm + a.destination.distanceNm - - (b.start.distanceNm + b.destination.distanceNm) - ); -} - function findNearestEdgeSnapsByComponent( graph: FairwayGraph, coordinate: Coordinate, @@ -578,14 +762,17 @@ function findNearestEdgeSnapsByComponent( if (nearestOnEdge) { const componentSnaps = nearestByComponent.get(componentId) ?? []; componentSnaps.push(nearestOnEdge); - componentSnaps.sort((a, b) => a.distanceNm - b.distanceNm); - if (componentSnaps.length > MAX_EDGE_SNAPS_PER_COMPONENT) { - componentSnaps.length = MAX_EDGE_SNAPS_PER_COMPONENT; - } nearestByComponent.set(componentId, componentSnaps); } } + // Retain the complete pool inside the hard radius. The route search sorts it, + // then applies its global pair-evaluation budget so a fifth candidate remains + // reachable without allowing dense graphs to trigger unbounded Dijkstra work. + for (const componentSnaps of nearestByComponent.values()) { + componentSnaps.sort((a, b) => a.distanceNm - b.distanceNm); + } + return nearestByComponent; } @@ -970,7 +1157,7 @@ function assessFairwayDepthSamples( function appendCoordinate(points: Coordinate[], coordinate: Coordinate) { const previous = points.at(-1); - if (previous && haversineDistanceNm(previous, coordinate) < 0.005) { + if (previous && haversineDistanceNm(previous, coordinate) < 0.000001) { return; } diff --git a/packages/shared/src/route.ts b/packages/shared/src/route.ts index a2af4b1..5592e27 100644 --- a/packages/shared/src/route.ts +++ b/packages/shared/src/route.ts @@ -1,5 +1,9 @@ import { coordinateToGeoJson, sumRouteDistanceNm } from "./geo.js"; -import { buildFairwayRoute, type FairwayGraph } from "./fairway-routing.js"; +import { + buildFairwayRoute, + EMDEN_BORKUM_DEMO_GRAPH, + type FairwayGraph +} from "./fairway-routing.js"; import { EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH } from "./inland-seed.js"; import type { DepthSample, @@ -11,13 +15,27 @@ import type { const DEFAULT_CRUISE_SPEED_KN = 6; -export function buildRoute(request: RouteRequest, graph?: FairwayGraph): RouteResult | null { - if (graph) { - return buildFairwayRoute(request, graph); +export type BuildRouteOptions = { + graph?: FairwayGraph; + allowDemoSeedGraphs?: boolean; +}; + +export function buildRoute( + request: RouteRequest, + source?: FairwayGraph | BuildRouteOptions +): RouteResult | null { + const options = source && "nodes" in source ? { graph: source } : source; + + if (options?.graph) { + return buildFairwayRoute(request, options.graph); + } + + if (!options?.allowDemoSeedGraphs) { + return null; } return ( - buildFairwayRoute(request) ?? + buildFairwayRoute(request, EMDEN_BORKUM_DEMO_GRAPH) ?? buildFairwayRoute(request, EMDEN_EAST_EMS_GRAPH) ?? buildFairwayRoute(request, EMDEN_HAMM_GRAPH) ); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index d4c3e85..8f8d342 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -51,6 +51,18 @@ export type GeoJsonLineString = { coordinates: [number, number][]; }; +export type RouteSnap = { + requested: Coordinate; + snapped: Coordinate; + distanceM: number; +}; + +export type RouteSnaps = { + start: RouteSnap; + waypoints: RouteSnap[]; + destination: RouteSnap; +}; + type RouteDetails = { geometry: GeoJsonLineString; distanceNm: number; @@ -62,6 +74,7 @@ type RouteDetails = { unknownDepthRatio: number; dataSources: string[]; routingMode?: "manual" | "fairway"; + routeSnaps?: RouteSnaps; }; export type RouteOption = RouteDetails & { diff --git a/packages/shared/tests/route.test.ts b/packages/shared/tests/route.test.ts index d415b9d..4137dc0 100644 --- a/packages/shared/tests/route.test.ts +++ b/packages/shared/tests/route.test.ts @@ -4,6 +4,7 @@ import { buildFairwayRoutes, buildManualRoute, buildRoute, + FairwayRoutingSearchLimitError, haversineDistanceNm, requiredDepthM, type FairwayEdge, @@ -49,10 +50,10 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = { { id: "start-decoy-b", coordinate: { lat: 53.342, lon: 7.187 } }, { id: "destination-decoy-a", coordinate: { lat: 53.3282, lon: 6.9304 } }, { id: "destination-decoy-b", coordinate: { lat: 53.3286, lon: 6.9294 } }, - { id: "shared-start", coordinate: { lat: 53.3395697, lon: 7.1848883 } }, + { id: "shared-start", coordinate: { lat: 53.34135, lon: 7.186 } }, { id: "shared-east", coordinate: { lat: 53.3321722, lon: 7.1329034 } }, { id: "shared-south", coordinate: { lat: 53.313849, lon: 7.0011017 } }, - { id: "shared-destination", coordinate: { lat: 53.3303531, lon: 6.9334715 } } + { id: "shared-destination", coordinate: { lat: 53.32845, lon: 6.9304 } } ], edges: [ edge("start-decoy", "start-decoy-a", "start-decoy-b", [ @@ -64,7 +65,7 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = { { lat: 53.3286, lon: 6.9294 } ], { source: "closer-but-disconnected-destination" }), edge("shared-east", "shared-start", "shared-east", [ - { lat: 53.3395697, lon: 7.1848883 }, + { lat: 53.34135, lon: 7.186 }, { lat: 53.3321722, lon: 7.1329034 } ], { source: "shared-local-component" }), edge("shared-south", "shared-east", "shared-south", [ @@ -73,7 +74,7 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = { ], { source: "shared-local-component" }), edge("shared-west", "shared-south", "shared-destination", [ { lat: 53.313849, lon: 7.0011017 }, - { lat: 53.3303531, lon: 6.9334715 } + { lat: 53.32845, lon: 6.9304 } ], { source: "shared-local-component" }) ] }; @@ -147,11 +148,14 @@ describe("route assessment", () => { }); it("routes Emden Außenhafen to Borkum Reede along the Ems fairway graph", () => { - const result = buildRoute({ - start: EMDEN_AUSSENHAFEN, - destination: BORKUM_REEDE, - vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } - }); + const result = buildRoute( + { + start: EMDEN_AUSSENHAFEN, + destination: BORKUM_REEDE, + vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } + }, + { allowDemoSeedGraphs: true } + ); expect(result).not.toBeNull(); if (!result) { throw new Error("Expected fairway route"); @@ -180,13 +184,16 @@ describe("route assessment", () => { }); it("snaps nearby Emden-Borkum clicks to fairway segments instead of requiring exact graph nodes", () => { - const clickedStart = { lat: 53.3418, lon: 7.1904 }; - const clickedDestination = { lat: 53.5608, lon: 6.7548 }; - const result = buildRoute({ - start: clickedStart, - destination: clickedDestination, - vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } - }); + const clickedStart = { lat: 53.341276, lon: 7.189146 }; + const clickedDestination = { lat: 53.560625, lon: 6.751271 }; + const result = buildRoute( + { + start: clickedStart, + destination: clickedDestination, + vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } + }, + { allowDemoSeedGraphs: true } + ); expect(result).not.toBeNull(); if (!result) { @@ -195,8 +202,26 @@ describe("route assessment", () => { expect(result.routingMode).toBe("fairway"); expect(result.geometry.coordinates.length).toBeGreaterThan(20); - expect(result.geometry.coordinates[0]).toEqual([clickedStart.lon, clickedStart.lat]); - expect(result.geometry.coordinates.at(-1)).toEqual([clickedDestination.lon, clickedDestination.lat]); + expect(result.routeSnaps?.start.requested).toEqual(clickedStart); + expect(result.routeSnaps?.destination.requested).toEqual(clickedDestination); + expect(result.routeSnaps?.start.distanceM).toBeGreaterThan(0); + expect(result.routeSnaps?.destination.distanceM).toBeGreaterThan(0); + expect( + result.warnings.some((warning) => warning.code === "ROUTE_POINT_SNAPPED") + ).toBe(true); + expect(result.geometry.coordinates[0]).toEqual([ + result.routeSnaps?.start.snapped.lon, + result.routeSnaps?.start.snapped.lat + ]); + expect(result.geometry.coordinates.at(-1)).toEqual([ + result.routeSnaps?.destination.snapped.lon, + result.routeSnaps?.destination.snapped.lat + ]); + expect(result.geometry.coordinates[0]).not.toEqual([clickedStart.lon, clickedStart.lat]); + expect(result.geometry.coordinates.at(-1)).not.toEqual([ + clickedDestination.lon, + clickedDestination.lat + ]); expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed"); }); @@ -213,13 +238,21 @@ describe("route assessment", () => { ); expect(routes).toHaveLength(1); - expect(routes[0]?.geometry.coordinates[0]).toEqual([start.lon, start.lat]); - expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]); + expect(routes[0]?.geometry.coordinates[0]).toEqual([ + routes[0]?.routeSnaps?.start.snapped.lon, + routes[0]?.routeSnaps?.start.snapped.lat + ]); + expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([ + routes[0]?.routeSnaps?.destination.snapped.lon, + routes[0]?.routeSnaps?.destination.snapped.lat + ]); + expect(routes[0]?.routeSnaps?.start.requested).toEqual(start); + expect(routes[0]?.routeSnaps?.destination.requested).toEqual(destination); expect(routes[0]?.dataSources).toContain("shared-local-component"); expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-start"); expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-destination"); - expect(routes[0]?.distanceNm).toBeGreaterThan(9.5); - expect(routes[0]?.distanceNm).toBeLessThan(10.5); + expect(routes[0]?.distanceNm).toBeGreaterThan(9.3); + expect(routes[0]?.distanceNm).toBeLessThan(9.7); }); it("returns no route when start and destination have no shared component inside the snap radius", () => { @@ -241,6 +274,72 @@ describe("route assessment", () => { ).toBeNull(); }); + it("accepts a nearby harbour click but hard-rejects snaps farther than 150 metres", () => { + const graph: FairwayGraph = { + ...singleEdgeGraph({}), + maxSnapDistanceNm: 2 + }; + const nearbyRoute = buildFairwayRoute( + { + start: { lat: 52 + 140 / 1852 / 60, lon: 7 }, + destination: { lat: 52, lon: 7.04 }, + vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } + }, + graph + ); + const remoteRoute = buildFairwayRoute( + { + start: { lat: 52 + 160 / 1852 / 60, lon: 7 }, + destination: { lat: 52, lon: 7.04 }, + vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } + }, + graph + ); + + expect(nearbyRoute?.routeSnaps?.start.distanceM).toBeCloseTo(140, 0); + expect(nearbyRoute?.geometry.coordinates[0]).toEqual([7, 52]); + expect( + nearbyRoute?.warnings.find((warning) => warning.code === "ROUTE_POINT_SNAPPED") + ).toMatchObject({ severity: "caution" }); + expect(remoteRoute).toBeNull(); + }); + + it("uses one graph snap for an intermediate waypoint without drawing raw access segments", () => { + const waypoint = { lat: 52.00025, lon: 7.02 }; + const graph: FairwayGraph = { + id: "waypoint-snap-test", + name: "Wegpunkt-Snap-Test", + maxSnapDistanceNm: 0.2, + nodes: [ + { id: "a", coordinate: { lat: 52, lon: 7 } }, + { id: "b", coordinate: { lat: 52, lon: 7.02 } }, + { id: "c", coordinate: { lat: 52, lon: 7.04 } } + ], + edges: [ + edge("ab", "a", "b", [{ lat: 52, lon: 7 }, { lat: 52, lon: 7.02 }]), + edge("bc", "b", "c", [{ lat: 52, lon: 7.02 }, { lat: 52, lon: 7.04 }]) + ] + }; + const route = buildFairwayRoute( + { + start: { lat: 52, lon: 7 }, + destination: { lat: 52, lon: 7.04 }, + waypoints: [waypoint], + vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } + }, + graph + ); + + expect(route).not.toBeNull(); + expect(route?.routeSnaps?.waypoints).toHaveLength(1); + expect(route?.routeSnaps?.waypoints[0]).toMatchObject({ + requested: waypoint, + snapped: { lat: 52, lon: 7.02 } + }); + expect(route?.geometry.coordinates).not.toContainEqual([waypoint.lon, waypoint.lat]); + expect(route?.geometry.coordinates).toContainEqual([7.02, 52]); + }); + it("tries another snap in the same component when the nearest one-way branch cannot be exited", () => { const graph: FairwayGraph = { id: "oneway-snap-fallback", @@ -249,14 +348,14 @@ describe("route assessment", () => { nodes: [ { id: "junction", coordinate: { lat: 52, lon: 7 } }, { id: "destination", coordinate: { lat: 52, lon: 7.04 } }, - { id: "oneway-dead-end", coordinate: { lat: 52.001, lon: 7 } } + { id: "oneway-dead-end", coordinate: { lat: 52.00035, lon: 7 } } ], edges: [ edge( "oneway-trap", "junction", "oneway-dead-end", - [{ lat: 52, lon: 7 }, { lat: 52.001, lon: 7 }], + [{ lat: 52, lon: 7 }, { lat: 52.00035, lon: 7 }], { oneway: true, source: "oneway-trap" } ), edge( @@ -270,7 +369,7 @@ describe("route assessment", () => { }; const route = buildFairwayRoute( { - start: { lat: 52.001, lon: 7 }, + start: { lat: 52.00035, lon: 7 }, destination: { lat: 52, lon: 7.04 }, vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } }, @@ -282,6 +381,183 @@ describe("route assessment", () => { expect(route?.dataSources).not.toContain("oneway-trap"); }); + it("keeps searching when only the fifth-nearest snap in a component is directionally routable", () => { + const start = { lat: 52.00035, lon: 7.0005 }; + const graph: FairwayGraph = { + id: "fifth-directed-snap", + name: "Adaptiver gerichteter Snap-Test", + maxSnapDistanceNm: 1, + nodes: [ + { id: "junction", coordinate: { lat: 52, lon: 7 } }, + { id: "trap-1", coordinate: start }, + { id: "trap-2", coordinate: { lat: 52.00036, lon: 7.0005 } }, + { id: "trap-3", coordinate: { lat: 52.00034, lon: 7.0005 } }, + { id: "trap-4", coordinate: { lat: 52.00035, lon: 7.00052 } }, + { id: "destination", coordinate: { lat: 52, lon: 7.04 } } + ], + edges: [ + edge("trap-1", "junction", "trap-1", [{ lat: 52, lon: 7 }, start], { + oneway: true, + source: "oneway-trap-1" + }), + edge("trap-2", "junction", "trap-2", [ + { lat: 52, lon: 7 }, + { lat: 52.00036, lon: 7.0005 } + ], { oneway: true, source: "oneway-trap-2" }), + edge("trap-3", "junction", "trap-3", [ + { lat: 52, lon: 7 }, + { lat: 52.00034, lon: 7.0005 } + ], { oneway: true, source: "oneway-trap-3" }), + edge("trap-4", "junction", "trap-4", [ + { lat: 52, lon: 7 }, + { lat: 52.00035, lon: 7.00052 } + ], { oneway: true, source: "oneway-trap-4" }), + edge("main-route", "junction", "destination", [ + { lat: 52, lon: 7 }, + { lat: 52, lon: 7.04 } + ], { source: "fifth-routable-edge" }) + ] + }; + + const route = buildFairwayRoute( + { + start, + destination: { lat: 52, lon: 7.04 }, + vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } + }, + graph + ); + + expect(route).not.toBeNull(); + expect(route?.routeSnaps?.start.distanceM).toBeGreaterThan(30); + expect(route?.routeSnaps?.start.distanceM).toBeLessThan(50); + expect(route?.dataSources).toContain("fifth-routable-edge"); + expect(route?.dataSources.some((source) => source.startsWith("oneway-trap"))).toBe(false); + }); + + it("prefers a much shorter route through the fifth-nearest snap", () => { + const start = { lat: 52, lon: 7 }; + const offsetLat = (metres: number) => metres / 1852 / 60; + const longEntry = { lat: 52.08, lon: 7 }; + const join = { lat: 52, lon: 7.08 }; + const destination = { lat: 52, lon: 7.09 }; + const trapStarts = [0, 1, 2, 3].map((metres) => ({ + lat: start.lat + offsetLat(metres), + lon: start.lon + })); + const shortStart = { lat: start.lat + offsetLat(9), lon: start.lon }; + const graph: FairwayGraph = { + id: "fifth-shorter-snap", + name: "Kostenbewusster Snap-Test", + maxSnapDistanceNm: 1, + nodes: [ + ...trapStarts.map((coordinate, index) => ({ + id: `trap-start-${index + 1}`, + coordinate + })), + { id: "short-start", coordinate: shortStart }, + { id: "long-entry", coordinate: longEntry }, + { id: "join", coordinate: join }, + { id: "destination", coordinate: destination } + ], + edges: [ + ...trapStarts.map((coordinate, index) => + edge( + `long-access-${index + 1}`, + `trap-start-${index + 1}`, + "long-entry", + [coordinate, longEntry], + { oneway: true, source: `long-access-${index + 1}` } + ) + ), + edge("long-detour", "long-entry", "join", [ + longEntry, + { lat: 52.08, lon: 7.08 }, + join + ], { oneway: true, source: "long-detour" }), + edge("short-fifth", "short-start", "join", [ + shortStart, + join + ], { oneway: true, source: "short-fifth" }), + edge("destination-tail", "join", "destination", [ + join, + destination + ], { oneway: true, source: "destination-tail" }) + ] + }; + + const route = buildFairwayRoute( + { + start, + destination, + vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } + }, + graph + ); + + expect(route).not.toBeNull(); + expect(route?.distanceNm).toBeLessThan(5); + expect(route?.routeSnaps?.start.distanceM).toBeCloseTo(9, 0); + expect(route?.dataSources).toContain("short-fifth"); + expect(route?.dataSources).not.toContain("long-detour"); + }); + + it("reports bounded snap-search truncation instead of silently claiming no route", () => { + const start = { lat: 52.00035, lon: 7.0005 }; + const mainStart = { lat: 52, lon: 7.0005 }; + const destination = { lat: 52, lon: 7.04 }; + const trapCoordinates = Array.from({ length: 8 }, (_, index) => ({ + lat: start.lat + (index - 4) * 0.000001, + lon: start.lon + })); + const graph: FairwayGraph = { + id: "bounded-snap-search", + name: "Begrenzter Snap-Suchtest", + maxSnapDistanceNm: 1, + nodes: [ + { id: "junction", coordinate: { lat: 52, lon: 7 } }, + ...trapCoordinates.map((coordinate, index) => ({ + id: `trap-${index + 1}`, + coordinate + })), + { id: "destination", coordinate: destination } + ], + edges: [ + ...trapCoordinates.map((coordinate, index) => + edge( + `trap-${index + 1}`, + "junction", + `trap-${index + 1}`, + [{ lat: 52, lon: 7 }, coordinate], + { oneway: true, source: `bounded-trap-${index + 1}` } + ) + ), + edge("ninth-main-route", "junction", "destination", [ + { lat: 52, lon: 7 }, + destination + ], { oneway: true, source: "ninth-main-route" }) + ] + }; + const request = { + start, + destination, + vesselProfile: { draughtM: 1, safetyReserveM: 0.3 } + }; + + expect(() => buildFairwayRoute(request, graph)).toThrow( + FairwayRoutingSearchLimitError + ); + + const boundedRoute = buildFairwayRoute( + { ...request, start: mainStart }, + graph + ); + expect(boundedRoute).not.toBeNull(); + expect( + boundedRoute?.warnings.some((warning) => warning.code === "ROUTE_SEARCH_LIMITED") + ).toBe(true); + }); + it("does not fall back to a misleading straight line when no fairway graph matches", () => { const result = buildRoute({ start: { lat: 54.1749, lon: 12.0731 }, @@ -292,12 +568,36 @@ describe("route assessment", () => { expect(result).toBeNull(); }); - it("routes Emden to Hamm via the Ems, Dortmund-Ems-Kanal and Datteln-Hamm-Kanal fallback", () => { - const result = buildRoute({ + it("does not silently use demo seed graphs when no routing source is provided", () => { + const request = { start: EMDEN_AUSSENHAFEN, - destination: HAMM_INNENSTADT_MARINA, - vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 } - }); + destination: BORKUM_REEDE, + vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 } + }; + const result = buildRoute(request); + + expect(result).toBeNull(); + + const buildWithoutGraph = buildFairwayRoute as unknown as ( + routeRequest: typeof request + ) => ReturnType; + const buildManyWithoutGraph = buildFairwayRoutes as unknown as ( + routeRequest: typeof request + ) => ReturnType; + + expect(buildWithoutGraph(request)).toBeNull(); + expect(buildManyWithoutGraph(request)).toEqual([]); + }); + + it("routes Emden to Hamm via the Ems, Dortmund-Ems-Kanal and Datteln-Hamm-Kanal fallback", () => { + const result = buildRoute( + { + start: EMDEN_AUSSENHAFEN, + destination: HAMM_INNENSTADT_MARINA, + vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 } + }, + { allowDemoSeedGraphs: true } + ); expect(result).not.toBeNull(); expect(result?.distanceNm).toBeGreaterThan(145);