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
+113 -14
View File
@@ -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<FastifyInstance>
}
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)
};
}
+1 -1
View File
@@ -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"
+68 -98
View File
@@ -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<FairwayGraph[]> {
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<FairwayGraphLookup> {
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<FairwaySourceId, "configuration">;
load: () => Promise<FairwayGraph | null>;
}> = [];
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<void> {
@@ -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<FairwayGraph | null> {
@@ -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<string, FairwayNode>, coordinate: Coordinate) {
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>) {
const candidates = [
tags["seamark:fairway:minimum_depth"],
@@ -699,10 +676,3 @@ function sourceFor(tags: Record<string, string>) {
}
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);
}
+284 -32
View File
@@ -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: "NorddeichNorderney",
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: []
};
}
}
});
+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 { 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<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", () => {
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);
});