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
+13 -2
View File
@@ -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);
+7
View File
@@ -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" &&
+24
View File
@@ -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 });
+132
View File
@@ -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 ?? "") ||
+13 -1
View File
@@ -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",