feat: add Docker/OpenTofu deployment and DE/NL routing
This commit is contained in:
@@ -13,8 +13,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/static": "^10.1.2",
|
||||
"@watermaps/shared": "0.1.0",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify": "^5.10.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"pg": "^8.16.3",
|
||||
"zod": "^3.25.76"
|
||||
|
||||
+13
-2
@@ -3,6 +3,7 @@ import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
buildFairwayRoutes,
|
||||
EMDEN_EAST_EMS_GRAPH,
|
||||
EMDEN_HAMM_GRAPH,
|
||||
type FairwayGraph,
|
||||
type RouteOption,
|
||||
@@ -110,7 +111,8 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
|
||||
cache,
|
||||
fetcher,
|
||||
liveEnabled: env.liveOsmFairways,
|
||||
databaseUrl: env.databaseUrl
|
||||
databaseUrl: env.databaseUrl,
|
||||
localDataPath: env.localFairwaysPath
|
||||
});
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -183,12 +185,21 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
|
||||
return reply.code(400).send({ error: "invalid_route", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
let sourceError: unknown;
|
||||
const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => {
|
||||
sourceError = error;
|
||||
app.log.warn({ error }, "fairway extraction failed");
|
||||
return [];
|
||||
});
|
||||
const route = buildRouteFromGraphs(parsed.data, dynamicGraphs);
|
||||
if (!route) {
|
||||
if (sourceError) {
|
||||
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: "no_fairway_route",
|
||||
message:
|
||||
@@ -224,7 +235,7 @@ function buildRouteFromGraphs(request: RouteRequest, graphs: FairwayGraph[]) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const graph of [undefined, EMDEN_HAMM_GRAPH] as const) {
|
||||
for (const graph of [undefined, EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH] as const) {
|
||||
const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request);
|
||||
if (routes.length > 0) {
|
||||
return routeResultWithAlternatives(routes);
|
||||
|
||||
@@ -3,6 +3,7 @@ export type ApiEnv = {
|
||||
host: string;
|
||||
databaseUrl?: string;
|
||||
redisUrl?: string;
|
||||
localFairwaysPath?: string;
|
||||
demoData: boolean;
|
||||
liveOsmFairways: boolean;
|
||||
};
|
||||
@@ -13,6 +14,12 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
|
||||
host: env.HOST ?? "0.0.0.0",
|
||||
databaseUrl: env.DATABASE_URL,
|
||||
redisUrl: env.REDIS_URL,
|
||||
localFairwaysPath:
|
||||
env.WATERMAPS_LOCAL_FAIRWAYS_PATH ??
|
||||
env.SEA_COMPASS_LOCAL_FAIRWAYS_PATH ??
|
||||
(env.NODE_ENV === "test"
|
||||
? undefined
|
||||
: "data/local/germany-netherlands-fairways.json"),
|
||||
demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) !== "false",
|
||||
liveOsmFairways:
|
||||
(env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" &&
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { buildServer } from "./app.js";
|
||||
@@ -12,6 +13,29 @@ for (const envFile of [resolve(process.cwd(), ".env"), resolve(process.cwd(), ".
|
||||
|
||||
const env = loadEnv();
|
||||
const app = await buildServer({ env });
|
||||
const configuredWebDist = process.env.WATERMAPS_WEB_DIST_PATH;
|
||||
const webDistCandidates = configuredWebDist
|
||||
? [resolve(configuredWebDist)]
|
||||
: [
|
||||
resolve(process.cwd(), "apps/web/dist"),
|
||||
resolve(process.cwd(), "../web/dist")
|
||||
];
|
||||
const webDistPath = webDistCandidates.find((candidate) =>
|
||||
existsSync(resolve(candidate, "index.html"))
|
||||
);
|
||||
|
||||
if (webDistPath) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: webDistPath,
|
||||
prefix: "/"
|
||||
});
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
if (request.method === "GET" && !request.url.startsWith("/api/")) {
|
||||
return reply.sendFile("index.html");
|
||||
}
|
||||
return reply.code(404).send({ error: "not_found" });
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await app.listen({ port: env.port, host: env.host });
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import pg from "pg";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { isAbsolute, resolve } from "node:path";
|
||||
import type { Coordinate, FairwayEdge, FairwayGraph, FairwayNode, RouteRequest } from "@watermaps/shared";
|
||||
import type { Cache } from "./cache.js";
|
||||
import type { FetchLike } from "./http.js";
|
||||
@@ -14,11 +16,25 @@ type OverpassResponse = {
|
||||
elements?: OverpassElement[];
|
||||
};
|
||||
|
||||
type LocalFairwayWay = {
|
||||
id: string;
|
||||
bbox: [number, number, number, number];
|
||||
tags?: Record<string, string>;
|
||||
coordinates: [number, number][];
|
||||
};
|
||||
|
||||
type LocalFairwayDocument = {
|
||||
version: number;
|
||||
source: string;
|
||||
ways: LocalFairwayWay[];
|
||||
};
|
||||
|
||||
type FairwayDeps = {
|
||||
cache: Cache;
|
||||
fetcher: FetchLike;
|
||||
liveEnabled: boolean;
|
||||
databaseUrl?: string;
|
||||
localDataPath?: string;
|
||||
};
|
||||
|
||||
export type FairwayRow = {
|
||||
@@ -38,6 +54,7 @@ const OVERPASS_URL = "https://overpass-api.de/api/interpreter";
|
||||
const CACHE_TTL_MS = 1000 * 60 * 60 * 12;
|
||||
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;
|
||||
@@ -48,17 +65,21 @@ export class FairwayService {
|
||||
private readonly fetcher: FetchLike;
|
||||
private readonly liveEnabled: boolean;
|
||||
private readonly pool: pg.Pool | null;
|
||||
private readonly localDataPath?: string;
|
||||
private localDocument: Promise<LocalFairwayDocument | null> | null = null;
|
||||
|
||||
constructor(deps: FairwayDeps) {
|
||||
this.cache = deps.cache;
|
||||
this.fetcher = deps.fetcher;
|
||||
this.liveEnabled = deps.liveEnabled;
|
||||
this.localDataPath = deps.localDataPath;
|
||||
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) =>
|
||||
@@ -118,6 +139,51 @@ export class FairwayService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getLocalGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
|
||||
const bbox = routeBbox(
|
||||
[request.start, ...(request.waypoints ?? []), request.destination],
|
||||
MAX_LOCAL_BBOX_SPAN_DEG
|
||||
);
|
||||
if (!bbox || !this.localDataPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const document = await this.loadLocalDocument();
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localFairwaysToGraph(
|
||||
document.ways.filter((way) => bboxesIntersect(way.bbox, bbox)),
|
||||
bbox,
|
||||
document.source
|
||||
);
|
||||
}
|
||||
|
||||
private async loadLocalDocument(): Promise<LocalFairwayDocument | null> {
|
||||
if (!this.localDocument) {
|
||||
this.localDocument = readFirstExistingFile(localDataPathCandidates(this.localDataPath!))
|
||||
.then((raw) => JSON.parse(raw) as LocalFairwayDocument)
|
||||
.then((document) => {
|
||||
if (document.version !== 1 || !Array.isArray(document.ways)) {
|
||||
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;
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
private async getLiveGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
|
||||
if (!this.liveEnabled) {
|
||||
return null;
|
||||
@@ -176,6 +242,29 @@ export class FairwayService {
|
||||
}
|
||||
}
|
||||
|
||||
async function readFirstExistingFile(paths: string[]): Promise<string> {
|
||||
let lastError: NodeJS.ErrnoException | undefined;
|
||||
for (const path of paths) {
|
||||
try {
|
||||
return await readFile(path, "utf8");
|
||||
} catch (error) {
|
||||
const fileError = error as NodeJS.ErrnoException;
|
||||
if (fileError.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
lastError = fileError;
|
||||
}
|
||||
}
|
||||
throw lastError ?? new Error("No local fairway data path configured");
|
||||
}
|
||||
|
||||
function localDataPathCandidates(path: string): string[] {
|
||||
if (isAbsolute(path)) {
|
||||
return [path];
|
||||
}
|
||||
return [...new Set([resolve(process.cwd(), path), resolve(process.cwd(), "../..", path)])];
|
||||
}
|
||||
|
||||
function routeBbox(points: Coordinate[], maxSpanDeg = MAX_BBOX_SPAN_DEG): [number, number, number, number] | null {
|
||||
const lons = points.map((point) => point.lon);
|
||||
const lats = points.map((point) => point.lat);
|
||||
@@ -238,6 +327,37 @@ export function overpassToGraph(response: OverpassResponse, bbox: [number, numbe
|
||||
);
|
||||
}
|
||||
|
||||
export function localFairwaysToGraph(
|
||||
ways: LocalFairwayWay[],
|
||||
bbox: [number, number, number, number],
|
||||
source: string
|
||||
): FairwayGraph | null {
|
||||
return waysToGraph(
|
||||
ways
|
||||
.map((way) => {
|
||||
const tags = way.tags ?? {};
|
||||
const coordinates = way.coordinates
|
||||
.map(([lat, lon]) => ({ lat, lon }))
|
||||
.filter(isValidCoordinate);
|
||||
if (coordinates.length < 2 || !isRoutableWay(tags, coordinates)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `local-osm-way-${way.id}`,
|
||||
name: tags.name ?? tags.ref ?? `OSM ${way.id}`,
|
||||
coordinates,
|
||||
minDepthM: parseDepth(tags),
|
||||
source: `local-geofabrik-${source}`,
|
||||
...edgeRestrictions(tags)
|
||||
};
|
||||
})
|
||||
.filter((way): way is NonNullable<typeof way> => way !== null),
|
||||
`local-geofabrik-${bbox.map((value) => value.toFixed(3)).join("-")}`,
|
||||
`Lokale Geofabrik-Fahrwasser (${source})`
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeConnectedFairwayGraphs(graphs: FairwayGraph[]): FairwayGraph | null {
|
||||
const nodes = new Map<string, FairwayNode>();
|
||||
const edges = new Map<string, FairwayEdge>();
|
||||
@@ -351,6 +471,18 @@ function isValidCoordinate(coordinate: Coordinate) {
|
||||
);
|
||||
}
|
||||
|
||||
function bboxesIntersect(
|
||||
first: [number, number, number, number],
|
||||
second: [number, number, number, number]
|
||||
) {
|
||||
return !(
|
||||
first[2] < second[0] ||
|
||||
first[0] > second[2] ||
|
||||
first[3] < second[1] ||
|
||||
first[1] > second[3]
|
||||
);
|
||||
}
|
||||
|
||||
function isRoutableWay(tags: Record<string, string>, coordinates: Coordinate[]) {
|
||||
if (
|
||||
["no", "private"].includes(tags.access ?? "") ||
|
||||
|
||||
@@ -20,7 +20,7 @@ type FeatureCollection = {
|
||||
properties: Record<string, unknown>;
|
||||
}>;
|
||||
metadata: {
|
||||
source: "postgis" | "demo";
|
||||
source: "postgis" | "demo" | "unavailable";
|
||||
warning?: string;
|
||||
deduplication?: {
|
||||
inputPoiCount: number;
|
||||
@@ -78,6 +78,18 @@ export class FeatureService {
|
||||
return this.getPostgisFeatures(query);
|
||||
}
|
||||
|
||||
if (!this.demoData) {
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
metadata: {
|
||||
source: "unavailable",
|
||||
warning:
|
||||
"Keine lokale Feature-Datenbank konfiguriert. Kartenkacheln und Live-Dienste bleiben davon unberührt."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const [minLon, minLat, maxLon, maxLat] = query.bbox;
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
|
||||
@@ -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 Norddeich–Norderney route for the reported coordinates", async () => {
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return [
|
||||
{
|
||||
id: "norddeich-norderney-test",
|
||||
name: "Norddeich–Norderney",
|
||||
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 }>) => ({
|
||||
|
||||
@@ -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: "Norddeich–Norderney" },
|
||||
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(
|
||||
[
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user