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

This commit is contained in:
BuTzZ
2026-07-24 23:10:17 +02:00
parent 57f7b4dedb
commit 12eee8d211
59 changed files with 4452 additions and 148 deletions
+218
View File
@@ -184,6 +184,31 @@ describe("Watermaps API", () => {
await app.close();
});
it("reports unavailable fairway sources instead of claiming that no route exists", async () => {
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
throw new AggregateError([new Error("local data missing"), new Error("Overpass timeout")]);
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 54.1749, lon: 12.0731 },
destination: { lat: 54.1833, lon: 12.0928 },
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("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({
@@ -205,6 +230,103 @@ describe("Watermaps API", () => {
await app.close();
});
it("plans the NorddeichNorderney route for the reported coordinates", async () => {
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
return [
{
id: "norddeich-norderney-test",
name: "NorddeichNorderney",
maxSnapDistanceNm: 0.5,
nodes: [
{ id: "norddeich", coordinate: { lat: 53.6234, lon: 7.1559 } },
{ id: "fairway", coordinate: { lat: 53.66, lon: 7.16 } },
{ id: "norderney", coordinate: { lat: 53.7023, lon: 7.1658 } }
],
edges: [
{
id: "norddeich-approach",
name: "Norddeich Fahrwasser",
from: "norddeich",
to: "fairway",
minDepthM: null,
source: "local-geofabrik-test",
coordinates: [
{ lat: 53.6234, lon: 7.1559 },
{ lat: 53.66, lon: 7.16 }
]
},
{
id: "norderney-approach",
name: "Norderney Fahrwasser",
from: "fairway",
to: "norderney",
minDepthM: null,
source: "local-geofabrik-test",
coordinates: [
{ lat: 53.66, lon: 7.16 },
{ lat: 53.7023, lon: 7.1658 }
]
}
]
}
];
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.6234, lon: 7.1559 },
destination: { lat: 53.7023, lon: 7.1658 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
const body = response.json();
expect(response.statusCode).toBe(200);
expect(body.routingMode).toBe("fairway");
expect(body.geometry.coordinates[0]).toEqual([7.1559, 53.6234]);
expect(body.geometry.coordinates.at(-1)).toEqual([7.1658, 53.7023]);
expect(body.distanceNm).toBeGreaterThan(4);
expect(body.distanceNm).toBeLessThan(6);
await app.close();
});
it("routes from Emden into the eastern lower Ems when all dynamic sources fail", async () => {
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
throw new AggregateError([new Error("PostGIS unavailable"), new Error("Overpass timeout")]);
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.3422, lon: 7.1871 },
destination: { lat: 53.465, lon: 7.4734 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
}
});
const body = response.json();
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);
await app.close();
});
it("returns the inland fallback route from Emden to Hamm", async () => {
const app = await buildServer({ cache: createCache() });
const response = await app.inject({
@@ -290,6 +412,102 @@ describe("Watermaps API", () => {
await app.close();
});
it("uses a shared local component for the reported Emden-Delfzijl coordinates", async () => {
const coordinate = (lat: number, lon: number) => ({ lat, lon });
const app = await buildServer({
cache: createCache(),
fairwayService: {
async getGraphsForRoute() {
return [
{
id: "local-geofabrik-component-snap",
name: "Lokaler Geofabrik-Komponententest",
maxSnapDistanceNm: 0.3,
nodes: [
{ id: "start-decoy-a", coordinate: coordinate(53.3416, 7.186) },
{ 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-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) }
],
edges: [
{
id: "start-decoy",
name: "Nähere getrennte Startkante",
from: "start-decoy-a",
to: "start-decoy-b",
coordinates: [coordinate(53.3416, 7.186), coordinate(53.342, 7.187)],
minDepthM: null,
source: "closer-but-disconnected-start"
},
{
id: "destination-decoy",
name: "Nähere getrennte Zielkante",
from: "destination-decoy-a",
to: "destination-decoy-b",
coordinates: [coordinate(53.3282, 6.9304), coordinate(53.3286, 6.9294)],
minDepthM: null,
source: "closer-but-disconnected-destination"
},
{
id: "shared-east",
name: "Gemeinsamer lokaler Korridor Ost",
from: "shared-start",
to: "shared-east",
coordinates: [coordinate(53.3395697, 7.1848883), coordinate(53.3321722, 7.1329034)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands"
},
{
id: "shared-south",
name: "Gemeinsamer lokaler Korridor Süd",
from: "shared-east",
to: "shared-south",
coordinates: [coordinate(53.3321722, 7.1329034), coordinate(53.313849, 7.0011017)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands"
},
{
id: "shared-west",
name: "Gemeinsamer lokaler Korridor West",
from: "shared-south",
to: "shared-destination",
coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.3303531, 6.9334715)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands"
}
]
}
];
},
async close() {}
}
});
const response = await app.inject({
method: "POST",
url: "/api/routes",
payload: {
start: { lat: 53.3416, lon: 7.186 },
destination: { lat: 53.3282, lon: 6.9304 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
}
});
const body = response.json();
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.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");
expect(body.dataSources).not.toContain("closer-but-disconnected-destination");
await app.close();
});
it("returns distinct route alternatives when the waterway graph contains them", async () => {
const coordinate = (lat: number, lon: number) => ({ lat, lon });
const edge = (id: string, from: string, to: string, coordinates: Array<{ lat: number; lon: number }>) => ({
+85
View File
@@ -1,12 +1,97 @@
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 { createCache } from "../src/services/cache.js";
import {
FairwayService,
fairwayRowsToGraph,
localFairwaysToGraph,
mergeConnectedFairwayGraphs,
overpassToGraph
} from "../src/services/fairways.js";
describe("fairway graph extraction", () => {
it("builds a routable graph from the persistent local Geofabrik format", () => {
const graph = localFairwaysToGraph(
[
{
id: "local-1",
bbox: [7.15, 53.62, 7.17, 53.71],
tags: { route: "ferry", name: "NorddeichNorderney" },
coordinates: [
[53.6234, 7.1559],
[53.66, 7.16],
[53.7023, 7.1658]
]
}
],
[7.0, 53.47, 7.32, 53.85],
"germany-test.osm.pbf"
);
const route = buildRoute(
{
start: { lat: 53.6234, lon: 7.1559 },
destination: { lat: 53.7023, lon: 7.1658 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
},
graph ?? undefined
);
expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain("local-geofabrik-germany-test.osm.pbf");
});
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");
const request = {
start: { lat: 52, lon: 4 },
destination: { lat: 52, lon: 12 },
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
};
try {
await writeFile(
localDataPath,
JSON.stringify({
version: 1,
source: "germany+netherlands",
ways: [
{
id: "country-wide-test",
bbox: [4, 52, 12, 52],
tags: { waterway: "canal", name: "Country-wide test fairway" },
coordinates: [
[52, 4],
[52, 12]
]
}
]
})
);
const service = new FairwayService({
cache: createCache(),
fetcher: fetch,
liveEnabled: false,
localDataPath
});
const graphs = await service.getGraphsForRoute(request);
const route = buildRoute(request, graphs[0]);
expect(graphs).toHaveLength(1);
expect(route?.routingMode).toBe("fairway");
expect(route?.dataSources).toContain(
"local-geofabrik-germany+netherlands"
);
await service.close();
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
});
it("builds a routable graph from PostGIS fairway rows", () => {
const graph = fairwayRowsToGraph(
[
+19
View File
@@ -1,10 +1,29 @@
import { describe, expect, it } from "vitest";
import {
deduplicateMarineContactFeatures,
FeatureService,
normalizeDepthFeatureProperties,
normalizeMarineFeatureProperties
} from "../src/services/features.js";
describe("marine feature data modes", () => {
it("does not expose demo markers when production disables demo data without PostGIS", async () => {
const service = new FeatureService({
databaseUrl: undefined,
demoData: false
});
const result = await service.getFeatures({
bbox: [5, 50, 15, 56],
layers: ["seamarks", "locks", "harbours"]
});
expect(result.features).toEqual([]);
expect(result.metadata.source).toBe("unavailable");
await service.close();
});
});
describe("marine feature normalization", () => {
it("formats bridge clearance labels from known OSM height tags", () => {
const properties = normalizeMarineFeatureProperties({