From 593dbd5f851c9d34f112404d8efc283c42fbb412 Mon Sep 17 00:00:00 2001
From: BuTzZ
Date: Tue, 28 Jul 2026 14:36:47 +0200
Subject: [PATCH] optimized events
---
apps/api/src/app.ts | 48 +++-
apps/api/src/env.ts | 9 +-
apps/api/src/services/config.ts | 139 ++++++-----
apps/api/src/services/features.ts | 159 +++++++++---
apps/api/tests/api.test.ts | 204 ++++++++++++++-
apps/api/tests/env.test.ts | 11 +
apps/api/tests/features.test.ts | 193 ++++++++++++++-
apps/web/src/App.tsx | 84 ++-----
apps/web/src/api.ts | 41 ++-
apps/web/src/components/RoutePlanner.tsx | 86 ++++---
.../src/components/UpcomingEventsPanel.css | 9 +
.../src/components/UpcomingEventsPanel.tsx | 21 +-
apps/web/src/hooks/useRouteEventFeatures.ts | 233 ++++++++++++++++++
apps/web/src/routeEvents.ts | 51 +++-
apps/web/src/routeWeatherReport.ts | 164 +++++++++++-
apps/web/tests/api-features.test.ts | 106 ++++++++
.../tests/route-event-features-hook.test.tsx | 199 +++++++++++++++
apps/web/tests/route-events.test.ts | 61 +++++
apps/web/tests/route-planner.test.tsx | 1 +
apps/web/tests/route-weather-report.test.ts | 79 +++++-
apps/web/tests/upcoming-events-panel.test.tsx | 12 +-
deploy/.env.production.example | 10 +
deploy/README.md | 85 +++++--
deploy/compose.production.yml | 59 ++++-
deploy/route-data.Dockerfile | 25 +-
deploy/scripts/bootstrap-server.sh | 8 +
deploy/scripts/common.sh | 156 ++++++++++++
deploy/scripts/deploy.sh | 40 ++-
deploy/scripts/prepare-marine-features.sh | 158 ++++++++++++
.../scripts/tests/route-data-helpers.test.sh | 54 +++-
.../scripts/tests/update-route-data.test.sh | 34 +++
deploy/scripts/update-route-data.sh | 56 ++++-
deploy/scripts/upload-and-deploy.sh | 12 +-
deploy/systemd/watermaps-route-update.service | 3 +-
deploy/systemd/watermaps-route-update.timer | 2 +-
docker-compose.yml | 33 ++-
infra/opentofu/README.md | 3 +-
infra/opentofu/cloud-init.yaml.tftpl | 2 +-
infra/opentofu/outputs.tf | 4 +-
infra/opentofu/variables.tf | 4 +-
packages/shared/src/marine-poi-clustering.ts | 7 +
.../tests/marine-poi-clustering.test.ts | 23 ++
42 files changed, 2405 insertions(+), 283 deletions(-)
create mode 100644 apps/web/src/hooks/useRouteEventFeatures.ts
create mode 100644 apps/web/tests/api-features.test.ts
create mode 100644 apps/web/tests/route-event-features-hook.test.tsx
create mode 100755 deploy/scripts/prepare-marine-features.sh
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index b0269c2..56d8df7 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -14,12 +14,15 @@ import {
} from "@watermaps/shared";
import { loadEnv, type ApiEnv } from "./env.js";
import { createCache, type Cache } from "./services/cache.js";
-import { appConfig } from "./services/config.js";
+import { buildAppConfig } from "./services/config.js";
import {
FairwayService,
type FairwayGraphLookup
} from "./services/fairways.js";
-import { FeatureService } from "./services/features.js";
+import {
+ FeatureService,
+ FeatureSourceUnavailableError
+} from "./services/features.js";
import { getNearestTideSummary } from "./services/tides.js";
import { getMarineForecast } from "./services/weather.js";
import type { FetchLike } from "./services/http.js";
@@ -126,9 +129,33 @@ export async function buildServer(deps: AppDeps = {}): Promise
await fairwayService.close?.();
});
- app.get("/health", async () => ({ ok: true, service: "watermaps-api" }));
+ app.get("/health", async () => ({
+ ok: true,
+ service: "watermaps-api",
+ featureSource: featureService.sourceMode
+ }));
- app.get("/api/config", async () => appConfig);
+ app.get("/ready", async (_request, reply) => {
+ const featureSource = await featureService.checkReadiness();
+ const body = {
+ ok: featureSource.ready,
+ service: "watermaps-api",
+ checks: {
+ features: featureSource
+ }
+ };
+ return featureSource.ready
+ ? body
+ : reply.code(503).send(body);
+ });
+
+ app.get("/api/config", async () => {
+ const featureSource = await featureService.checkReadiness();
+ return buildAppConfig({
+ featureSource: featureSource.ready ? featureSource.source : "unavailable",
+ liveFairwayExtraction: env.liveOsmFairways
+ });
+ });
app.get("/api/weather/marine", async (request, reply) => {
const parsed = coordinateQuerySchema.safeParse(request.query);
@@ -172,7 +199,18 @@ export async function buildServer(deps: AppDeps = {}): Promise
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
}
- return featureService.getFeatures(parsed.data);
+ try {
+ return await featureService.getFeatures(parsed.data);
+ } catch (error) {
+ if (error instanceof FeatureSourceUnavailableError) {
+ app.log.warn({ error }, "feature source unavailable");
+ return reply.code(503).send({
+ error: error.code,
+ message: error.message
+ });
+ }
+ throw error;
+ }
});
app.post("/api/routes", async (request, reply) => {
diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts
index b311a83..0015c06 100644
--- a/apps/api/src/env.ts
+++ b/apps/api/src/env.ts
@@ -12,8 +12,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
return {
port: Number(env.PORT ?? 5174),
host: env.HOST ?? "0.0.0.0",
- databaseUrl: env.DATABASE_URL,
- redisUrl: env.REDIS_URL,
+ databaseUrl: nonEmptyString(env.DATABASE_URL),
+ redisUrl: nonEmptyString(env.REDIS_URL),
localFairwaysPath:
env.WATERMAPS_LOCAL_FAIRWAYS_PATH ??
env.SEA_COMPASS_LOCAL_FAIRWAYS_PATH ??
@@ -26,3 +26,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
env.NODE_ENV !== "test"
};
}
+
+function nonEmptyString(value: string | undefined) {
+ const normalized = value?.trim();
+ return normalized || undefined;
+}
diff --git a/apps/api/src/services/config.ts b/apps/api/src/services/config.ts
index ee62e10..27dcd3d 100644
--- a/apps/api/src/services/config.ts
+++ b/apps/api/src/services/config.ts
@@ -1,67 +1,78 @@
import type { AppConfig } from "@watermaps/shared";
+import type { FeatureSourceMode } from "./features.js";
-export const appConfig: AppConfig = {
- appName: "Watermaps",
- region: "Deutschland/EU",
- disclaimer:
- "Freie Karten- und Modelldaten sind eine Fahr- und Planungshilfe, aber kein Ersatz für amtlich zugelassene Seekarten und eigene Navigation.",
- featureFlags: {
- gpsTracking: true,
- compass: true,
- marineWeather: true,
- tides: true,
- manualRouting: true,
- liveFairwayExtraction: true,
- postgisFeatures: true,
- bridgeAndDepthOverlays: true,
- inlandWaterwayRouting: true,
- routeAlternatives: true,
- lockAndHarbourContacts: true,
- routeWaypoints: true,
- departureTimeForecasts: true,
- liveWaterLevels: true,
- voyageStages: true,
- gpxExport: true,
- offlineRoutes: true,
- offlineVisitedMapResources: true,
- routeDeviationAlarm: true,
- offlineTiles: true
- },
- layers: [
- {
- id: "openfreemap",
- name: "Basiskarte",
- kind: "style",
- url: "https://tiles.openfreemap.org/styles/bright",
- attribution: "Map data © OpenStreetMap contributors, style © OpenFreeMap",
- defaultVisible: true
- },
- {
- id: "openseamap-seamarks",
- name: "Seezeichen",
- kind: "raster-tile",
- tileUrl: "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
- attribution: "Seamarks © OpenSeaMap / OpenStreetMap contributors",
- defaultVisible: true,
- opacity: 0.95
- },
- {
- id: "emodnet-bathymetry",
- name: "Bathymetrie",
- kind: "wms",
- tileUrl:
- "https://ows.emodnet-bathymetry.eu/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=emodnet:mean_multicolour&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256",
- attribution: "Bathymetry © EMODnet Bathymetry",
- defaultVisible: false,
- opacity: 0.55
- }
- ],
- attribution: [
- "© OpenStreetMap contributors",
- "© OpenSeaMap contributors",
- "© EMODnet Bathymetry",
- "© Bundesamt für Seeschifffahrt und Hydrographie (BSH), CC BY 4.0",
- "Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE",
- "Weather, waves and current forecast © Open-Meteo"
- ]
+type AppConfigCapabilities = {
+ featureSource: FeatureSourceMode;
+ liveFairwayExtraction: boolean;
};
+
+export function buildAppConfig(capabilities: AppConfigCapabilities): AppConfig {
+ const postgisFeatures = capabilities.featureSource === "postgis";
+
+ return {
+ appName: "Watermaps",
+ region: "Deutschland/EU",
+ disclaimer:
+ "Freie Karten- und Modelldaten sind eine Fahr- und Planungshilfe, aber kein Ersatz für amtlich zugelassene Seekarten und eigene Navigation.",
+ featureFlags: {
+ gpsTracking: true,
+ compass: true,
+ marineWeather: true,
+ tides: true,
+ manualRouting: true,
+ liveFairwayExtraction: capabilities.liveFairwayExtraction,
+ postgisFeatures,
+ bridgeAndDepthOverlays: postgisFeatures,
+ inlandWaterwayRouting: true,
+ routeAlternatives: true,
+ lockAndHarbourContacts: postgisFeatures,
+ routeEvents: postgisFeatures,
+ routeWaypoints: true,
+ departureTimeForecasts: true,
+ liveWaterLevels: true,
+ voyageStages: true,
+ gpxExport: true,
+ offlineRoutes: true,
+ offlineVisitedMapResources: true,
+ routeDeviationAlarm: true,
+ offlineTiles: true
+ },
+ layers: [
+ {
+ id: "openfreemap",
+ name: "Basiskarte",
+ kind: "style",
+ url: "https://tiles.openfreemap.org/styles/bright",
+ attribution: "Map data © OpenStreetMap contributors, style © OpenFreeMap",
+ defaultVisible: true
+ },
+ {
+ id: "openseamap-seamarks",
+ name: "Seezeichen",
+ kind: "raster-tile",
+ tileUrl: "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
+ attribution: "Seamarks © OpenSeaMap / OpenStreetMap contributors",
+ defaultVisible: true,
+ opacity: 0.95
+ },
+ {
+ id: "emodnet-bathymetry",
+ name: "Bathymetrie",
+ kind: "wms",
+ tileUrl:
+ "https://ows.emodnet-bathymetry.eu/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=emodnet:mean_multicolour&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256",
+ attribution: "Bathymetry © EMODnet Bathymetry",
+ defaultVisible: false,
+ opacity: 0.55
+ }
+ ],
+ attribution: [
+ "© OpenStreetMap contributors",
+ "© OpenSeaMap contributors",
+ "© EMODnet Bathymetry",
+ "© Bundesamt für Seeschifffahrt und Hydrographie (BSH), CC BY 4.0",
+ "Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE",
+ "Weather, waves and current forecast © Open-Meteo"
+ ]
+ };
+}
diff --git a/apps/api/src/services/features.ts b/apps/api/src/services/features.ts
index fad5377..d3c02d7 100644
--- a/apps/api/src/services/features.ts
+++ b/apps/api/src/services/features.ts
@@ -11,6 +11,21 @@ export type FeatureQuery = {
layers: string[];
};
+export type FeatureSourceMode = "postgis" | "demo" | "unavailable";
+
+export type FeatureSourceReadiness = {
+ ready: boolean;
+ source: FeatureSourceMode;
+ detail?:
+ | "not_configured"
+ | "database_unreachable"
+ | "schema_missing"
+ | "data_missing";
+ missingLayers?: Array<"harbours" | "locks" | "bridges">;
+};
+
+export type FeatureDatabase = Pick;
+
type FeatureCollection = {
type: "FeatureCollection";
features: Array<{
@@ -20,7 +35,7 @@ type FeatureCollection = {
properties: Record;
}>;
metadata: {
- source: "postgis" | "demo" | "unavailable";
+ source: Exclude;
warning?: string;
deduplication?: {
inputPoiCount: number;
@@ -30,6 +45,15 @@ type FeatureCollection = {
};
};
+export class FeatureSourceUnavailableError extends Error {
+ readonly code = "feature_source_unavailable";
+
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "FeatureSourceUnavailableError";
+ }
+}
+
const demoFeatures = [
{
type: "Feature" as const,
@@ -65,29 +89,110 @@ const demoFeatures = [
];
export class FeatureService {
- private pool: pg.Pool | null;
+ private pool: FeatureDatabase | null;
private demoData: boolean;
- constructor(env: Pick) {
- this.pool = env.databaseUrl ? new pg.Pool({ connectionString: env.databaseUrl }) : null;
+ constructor(
+ env: Pick,
+ dependencies: { pool?: FeatureDatabase } = {}
+ ) {
+ this.pool =
+ env.databaseUrl && !env.demoData
+ ? dependencies.pool ??
+ new pg.Pool({
+ connectionString: env.databaseUrl,
+ connectionTimeoutMillis: 3_000,
+ query_timeout: 3_000
+ })
+ : null;
this.demoData = env.demoData;
}
+ get sourceMode(): FeatureSourceMode {
+ if (this.demoData) {
+ return "demo";
+ }
+ return this.pool ? "postgis" : "unavailable";
+ }
+
+ async checkReadiness(): Promise {
+ if (this.demoData) {
+ return { ready: true, source: "demo" };
+ }
+ if (!this.pool) {
+ return {
+ ready: false,
+ source: "unavailable",
+ detail: "not_configured"
+ };
+ }
+
+ try {
+ const result = await this.pool.query<{
+ marine_features: string | null;
+ marine_fairway_edges: string | null;
+ }>(`
+ SELECT
+ to_regclass('public.marine_features')::text AS marine_features,
+ to_regclass('public.marine_fairway_edges')::text AS marine_fairway_edges
+ `);
+ const schema = result.rows[0];
+ if (!schema?.marine_features || !schema.marine_fairway_edges) {
+ return {
+ ready: false,
+ source: "postgis",
+ detail: "schema_missing"
+ };
+ }
+
+ const dataResult = await this.pool.query<{
+ harbours: boolean;
+ locks: boolean;
+ bridges: boolean;
+ }>(`
+ SELECT
+ EXISTS(SELECT 1 FROM marine_features WHERE layer = 'harbours') AS harbours,
+ EXISTS(SELECT 1 FROM marine_features WHERE layer = 'locks') AS locks,
+ EXISTS(SELECT 1 FROM marine_features WHERE layer = 'bridges') AS bridges
+ `);
+ const data = dataResult.rows[0];
+ const missingLayers = (["harbours", "locks", "bridges"] as const).filter(
+ (layer) => !data?.[layer]
+ );
+ if (missingLayers.length > 0) {
+ return {
+ ready: false,
+ source: "postgis",
+ detail: "data_missing",
+ missingLayers
+ };
+ }
+ return { ready: true, source: "postgis" };
+ } catch {
+ return {
+ ready: false,
+ source: "postgis",
+ detail: "database_unreachable"
+ };
+ }
+ }
+
async getFeatures(query: FeatureQuery): Promise {
if (this.pool && !this.demoData) {
- return this.getPostgisFeatures(query);
+ try {
+ return await this.getPostgisFeatures(query);
+ } catch (error) {
+ throw new FeatureSourceUnavailableError(
+ "Die lokale Karten- und Ereignisdatenbank ist momentan nicht erreichbar.",
+ { cause: error }
+ );
+ }
}
if (!this.demoData) {
- return {
- type: "FeatureCollection",
- features: [],
- metadata: {
- source: "unavailable",
- warning:
- "Keine lokale Feature-Datenbank konfiguriert. Kartenkacheln und Live-Dienste bleiben davon unberührt."
- }
- };
+ throw new FeatureSourceUnavailableError(
+ "Keine lokale Karten- und Ereignisdatenbank konfiguriert."
+ );
}
const [minLon, minLat, maxLon, maxLat] = query.bbox;
@@ -151,16 +256,6 @@ export class FeatureService {
FROM marine_features
WHERE layer = ANY($1::text[])
AND geom && ST_MakeEnvelope($2, $3, $4, $5, 4326)
- AND (
- layer <> 'bridges'
- OR properties ? 'seamark:bridge:clearance_height'
- OR properties ? 'seamark:bridge:clearance_height_safe'
- OR properties ? 'maxheight'
- OR properties ? 'maxheight:physical'
- OR lower(COALESCE(properties->>'bridge', '')) = ANY(
- ARRAY['movable', 'bascule', 'lift', 'swing', 'drawbridge', 'retractable', 'submersible', 'opening']
- )
- )
)
SELECT
id::text,
@@ -364,8 +459,16 @@ export function normalizeMarineFeatureProperties(input: MarineFeaturePropertiesI
"seamark:lock_basin:communication_channel",
"seamark:radio_station:channel"
]);
- const openingHours = firstStringProperty(sourceProperties, ["opening_hours", "service_times"]);
- const operator = firstStringProperty(sourceProperties, ["operator"]);
+ const openingHours = firstStringProperty(sourceProperties, [
+ "opening_hours",
+ "lock:opening_hours",
+ "service_times"
+ ]);
+ const operator = firstStringProperty(sourceProperties, [
+ "operator",
+ "operator:name",
+ "owner"
+ ]);
const address = normalizedAddress(sourceProperties);
const sourceUrl = firstStringProperty(sourceProperties, [
"sourceUrl",
@@ -452,6 +555,7 @@ const MARINE_PROPERTY_KEYS = new Set([
"lock",
"obstacle",
"bridge",
+ "bridge:movable",
"maxheight",
"maxheight:physical",
"height",
@@ -529,8 +633,7 @@ function bridgeClearanceM(properties: Record) {
"seamark:bridge:clearance_height",
"seamark:bridge:clearance_height_safe",
"maxheight",
- "maxheight:physical",
- "height"
+ "maxheight:physical"
];
for (const key of candidates) {
diff --git a/apps/api/tests/api.test.ts b/apps/api/tests/api.test.ts
index 36bb0b3..9e66578 100644
--- a/apps/api/tests/api.test.ts
+++ b/apps/api/tests/api.test.ts
@@ -3,6 +3,10 @@ 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 FeatureDatabase,
+ FeatureService
+} from "../src/services/features.js";
import type {
LockOperationInfo,
NavigationDataAdapter
@@ -90,11 +94,209 @@ describe("Watermaps API", () => {
});
it("returns app config with map layers", async () => {
- const app = await buildServer({ cache: createCache() });
+ const app = await buildServer({ cache: createCache(), env: productionEnv });
const response = await app.inject({ method: "GET", url: "/api/config" });
expect(response.statusCode).toBe(200);
expect(response.json().layers).toHaveLength(3);
+ expect(response.json().featureFlags).toMatchObject({
+ liveFairwayExtraction: false,
+ postgisFeatures: false,
+ bridgeAndDepthOverlays: false,
+ lockAndHarbourContacts: false,
+ routeEvents: false
+ });
+ await app.close();
+ });
+
+ it("advertises feature and contact capabilities only for a configured PostGIS source", async () => {
+ const pool = {
+ query: vi.fn().mockImplementation((sql: unknown) =>
+ Promise.resolve(
+ String(sql).includes("to_regclass")
+ ? {
+ rows: [
+ {
+ marine_features: "marine_features",
+ marine_fairway_edges: "marine_fairway_edges"
+ }
+ ]
+ }
+ : {
+ rows: [{ harbours: true, locks: true, bridges: true }]
+ }
+ )
+ ),
+ end: vi.fn().mockResolvedValue(undefined)
+ } as unknown as FeatureDatabase;
+ const featureService = new FeatureService(
+ { databaseUrl: "postgres://features.test/watermaps", demoData: false },
+ { pool }
+ );
+ const app = await buildServer({
+ cache: createCache(),
+ env: { ...productionEnv, liveOsmFairways: true },
+ featureService
+ });
+ const response = await app.inject({ method: "GET", url: "/api/config" });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json().featureFlags).toMatchObject({
+ liveFairwayExtraction: true,
+ postgisFeatures: true,
+ bridgeAndDepthOverlays: true,
+ lockAndHarbourContacts: true,
+ routeEvents: true
+ });
+ await app.close();
+ });
+
+ it("keeps route-event capabilities disabled until all event layers contain data", async () => {
+ const pool = {
+ query: vi.fn().mockImplementation((sql: unknown) =>
+ Promise.resolve(
+ String(sql).includes("to_regclass")
+ ? {
+ rows: [
+ {
+ marine_features: "marine_features",
+ marine_fairway_edges: "marine_fairway_edges"
+ }
+ ]
+ }
+ : {
+ rows: [{ harbours: false, locks: false, bridges: false }]
+ }
+ )
+ ),
+ end: vi.fn().mockResolvedValue(undefined)
+ } as unknown as FeatureDatabase;
+ const featureService = new FeatureService(
+ { databaseUrl: "postgres://features.test/watermaps", demoData: false },
+ { pool }
+ );
+ const app = await buildServer({
+ cache: createCache(),
+ env: productionEnv,
+ featureService
+ });
+ const config = await app.inject({ method: "GET", url: "/api/config" });
+ const readiness = await app.inject({ method: "GET", url: "/ready" });
+
+ expect(config.statusCode).toBe(200);
+ expect(config.json().featureFlags).toMatchObject({
+ postgisFeatures: false,
+ bridgeAndDepthOverlays: false,
+ lockAndHarbourContacts: false,
+ routeEvents: false
+ });
+ expect(readiness.statusCode).toBe(503);
+ expect(readiness.json().checks.features).toEqual({
+ ready: false,
+ source: "postgis",
+ detail: "data_missing",
+ missingLayers: ["harbours", "locks", "bridges"]
+ });
+ await app.close();
+ });
+
+ it("does not advertise PostGIS capabilities when the configured database is unreachable", async () => {
+ const pool = {
+ query: vi.fn().mockRejectedValue(new Error("connection refused")),
+ end: vi.fn().mockResolvedValue(undefined)
+ } as unknown as FeatureDatabase;
+ const featureService = new FeatureService(
+ { databaseUrl: "postgres://features.test/watermaps", demoData: false },
+ { pool }
+ );
+ const app = await buildServer({
+ cache: createCache(),
+ env: productionEnv,
+ featureService
+ });
+ const config = await app.inject({ method: "GET", url: "/api/config" });
+ const readiness = await app.inject({ method: "GET", url: "/ready" });
+
+ expect(config.statusCode).toBe(200);
+ expect(config.json().featureFlags).toMatchObject({
+ postgisFeatures: false,
+ bridgeAndDepthOverlays: false,
+ lockAndHarbourContacts: false,
+ routeEvents: false
+ });
+ expect(readiness.statusCode).toBe(503);
+ expect(readiness.json().checks.features).toEqual({
+ ready: false,
+ source: "postgis",
+ detail: "database_unreachable"
+ });
+ await app.close();
+ });
+
+ it("returns 503 when no feature source is configured", async () => {
+ const app = await buildServer({ cache: createCache(), env: productionEnv });
+ const response = await app.inject({
+ method: "GET",
+ url: "/api/features?bbox=7.1,53.3,7.5,53.5&layers=harbours,locks,bridges"
+ });
+
+ expect(response.statusCode).toBe(503);
+ expect(response.json()).toEqual({
+ error: "feature_source_unavailable",
+ message: "Keine lokale Karten- und Ereignisdatenbank konfiguriert."
+ });
+ await app.close();
+ });
+
+ it("returns 503 when the configured feature database query fails", async () => {
+ const pool = {
+ query: vi.fn().mockRejectedValue(new Error("database unavailable")),
+ end: vi.fn().mockResolvedValue(undefined)
+ } as unknown as FeatureDatabase;
+ const featureService = new FeatureService(
+ { databaseUrl: "postgres://features.test/watermaps", demoData: false },
+ { pool }
+ );
+ const app = await buildServer({
+ cache: createCache(),
+ env: productionEnv,
+ featureService
+ });
+ const response = await app.inject({
+ method: "GET",
+ url: "/api/features?bbox=7.1,53.3,7.5,53.5&layers=harbours,locks,bridges"
+ });
+
+ expect(response.statusCode).toBe(503);
+ expect(response.json()).toEqual({
+ error: "feature_source_unavailable",
+ message: "Die lokale Karten- und Ereignisdatenbank ist momentan nicht erreichbar."
+ });
+ await app.close();
+ });
+
+ it("separates liveness from feature-data readiness", async () => {
+ const app = await buildServer({ cache: createCache(), env: productionEnv });
+ const health = await app.inject({ method: "GET", url: "/health" });
+ const readiness = await app.inject({ method: "GET", url: "/ready" });
+
+ expect(health.statusCode).toBe(200);
+ expect(health.json()).toMatchObject({
+ ok: true,
+ featureSource: "unavailable"
+ });
+ expect(readiness.statusCode).toBe(503);
+ expect(readiness.json()).toEqual({
+ ok: false,
+ service: "watermaps-api",
+ checks: {
+ features: {
+ ready: false,
+ source: "unavailable",
+ detail: "not_configured"
+ }
+ }
+ });
await app.close();
});
diff --git a/apps/api/tests/env.test.ts b/apps/api/tests/env.test.ts
index 629126f..7bfade5 100644
--- a/apps/api/tests/env.test.ts
+++ b/apps/api/tests/env.test.ts
@@ -18,4 +18,15 @@ describe("API environment", () => {
loadEnv({ NODE_ENV: "production", SEA_COMPASS_DEMO_DATA: "true" }).demoData
).toBe(true);
});
+
+ it("does not treat blank service URLs as configured capabilities", () => {
+ const env = loadEnv({
+ NODE_ENV: "production",
+ DATABASE_URL: " ",
+ REDIS_URL: "\t"
+ });
+
+ expect(env.databaseUrl).toBeUndefined();
+ expect(env.redisUrl).toBeUndefined();
+ });
});
diff --git a/apps/api/tests/features.test.ts b/apps/api/tests/features.test.ts
index d66c571..d2f7fb9 100644
--- a/apps/api/tests/features.test.ts
+++ b/apps/api/tests/features.test.ts
@@ -1,25 +1,154 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, it, vi } from "vitest";
import {
deduplicateMarineContactFeatures,
+ type FeatureDatabase,
FeatureService,
+ FeatureSourceUnavailableError,
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 () => {
+ it("fails explicitly when production has no configured feature source", async () => {
const service = new FeatureService({
databaseUrl: undefined,
demoData: false
});
- const result = await service.getFeatures({
- bbox: [5, 50, 15, 56],
- layers: ["seamarks", "locks", "harbours"]
+ await expect(
+ service.getFeatures({
+ bbox: [5, 50, 15, 56],
+ layers: ["seamarks", "locks", "harbours"]
+ })
+ ).rejects.toBeInstanceOf(FeatureSourceUnavailableError);
+ expect(service.sourceMode).toBe("unavailable");
+ expect(await service.checkReadiness()).toEqual({
+ ready: false,
+ source: "unavailable",
+ detail: "not_configured"
});
+ await service.close();
+ });
- expect(result.features).toEqual([]);
- expect(result.metadata.source).toBe("unavailable");
+ it("checks that the configured PostGIS schema is ready", async () => {
+ const pool = featurePool(
+ {
+ rows: [
+ {
+ marine_features: "marine_features",
+ marine_fairway_edges: "marine_fairway_edges"
+ }
+ ]
+ },
+ {
+ rows: [{ harbours: true, locks: true, bridges: true }]
+ }
+ );
+ const service = new FeatureService(
+ { databaseUrl: "postgres://features.test/watermaps", demoData: false },
+ { pool }
+ );
+
+ expect(service.sourceMode).toBe("postgis");
+ expect(await service.checkReadiness()).toEqual({
+ ready: true,
+ source: "postgis"
+ });
+ expect(pool.query).toHaveBeenCalledWith(expect.stringContaining("to_regclass"));
+ expect(pool.query).toHaveBeenCalledWith(expect.stringContaining("EXISTS"));
+ await service.close();
+ expect(pool.end).toHaveBeenCalledOnce();
+ });
+
+ it("reports a configured database with missing feature tables as not ready", async () => {
+ const pool = featurePool({
+ rows: [{ marine_features: "marine_features", marine_fairway_edges: null }]
+ });
+ const service = new FeatureService(
+ { databaseUrl: "postgres://features.test/watermaps", demoData: false },
+ { pool }
+ );
+
+ expect(await service.checkReadiness()).toEqual({
+ ready: false,
+ source: "postgis",
+ detail: "schema_missing"
+ });
+ await service.close();
+ });
+
+ it("reports initialized PostGIS tables without event data as not ready", async () => {
+ const pool = featurePool(
+ {
+ rows: [
+ {
+ marine_features: "marine_features",
+ marine_fairway_edges: "marine_fairway_edges"
+ }
+ ]
+ },
+ {
+ rows: [{ harbours: false, locks: true, bridges: false }]
+ }
+ );
+ const service = new FeatureService(
+ { databaseUrl: "postgres://features.test/watermaps", demoData: false },
+ { pool }
+ );
+
+ expect(await service.checkReadiness()).toEqual({
+ ready: false,
+ source: "postgis",
+ detail: "data_missing",
+ missingLayers: ["harbours", "bridges"]
+ });
+ await service.close();
+ });
+
+ it("returns ordinary bridges from the requested bbox for route-side filtering", async () => {
+ const pool = featurePool({
+ rows: [
+ {
+ id: "bridge-ordinary",
+ layer: "bridges",
+ name: "Normale Kanalbrücke",
+ source: "osm",
+ source_id: "w100",
+ properties: { bridge: "yes" },
+ updated_at: "2026-07-20T10:00:00.000Z",
+ geometry: {
+ type: "LineString",
+ coordinates: [
+ [7.2, 53.4],
+ [7.21, 53.4]
+ ]
+ }
+ }
+ ]
+ });
+ const service = new FeatureService(
+ { databaseUrl: "postgres://features.test/watermaps", demoData: false },
+ { pool }
+ );
+
+ const result = await service.getFeatures({
+ bbox: [7.1, 53.3, 7.5, 53.5],
+ layers: ["bridges"]
+ });
+ const sql = String(vi.mocked(pool.query).mock.calls[0]?.[0]);
+
+ expect(sql).not.toContain("properties ? 'maxheight'");
+ expect(sql).not.toContain("properties->>'bridge'");
+ expect(result.features).toEqual([
+ expect.objectContaining({
+ id: "bridge-ordinary",
+ properties: expect.objectContaining({
+ layer: "bridges",
+ name: "Normale Kanalbrücke",
+ clearance_m: null
+ })
+ })
+ ]);
await service.close();
});
});
@@ -58,6 +187,23 @@ describe("marine feature normalization", () => {
expect(properties.label).toBeNull();
});
+ it("does not mistake a bridge structure height for navigable clearance", () => {
+ const properties = normalizeMarineFeatureProperties({
+ layer: "bridges",
+ name: "Klappbrücke",
+ source: "osm",
+ sourceId: "w125",
+ properties: {
+ bridge: "movable",
+ height: "18"
+ }
+ });
+
+ expect(properties.clearance_m).toBeNull();
+ expect(properties.clearance_label).toBeNull();
+ expect(properties.label).toBe("Klappbrücke");
+ });
+
it("normalizes contact aliases, address and database timestamps", () => {
const properties = normalizeMarineFeatureProperties({
layer: "locks",
@@ -71,7 +217,7 @@ describe("marine feature normalization", () => {
"contact:email": "schleuse@example.test",
"seamark:lock_basin:communication_channel": "18",
opening_hours: "24/7",
- operator: "WSV",
+ "operator:name": "WSV",
"addr:street": "Fährstraße",
"addr:housenumber": "1",
"addr:postcode": "59071",
@@ -92,6 +238,20 @@ describe("marine feature normalization", () => {
expect(properties.updatedAt).toBe("2026-07-19T08:30:00.000Z");
});
+ it("normalizes lock-specific opening hours for event details", () => {
+ const properties = normalizeMarineFeatureProperties({
+ layer: "locks",
+ name: "Schleuse Rahe",
+ source: "osm",
+ sourceId: "w126",
+ properties: {
+ "lock:opening_hours": "Mo-Su 07:00-20:00"
+ }
+ });
+
+ expect(properties.openingHours).toBe("Mo-Su 07:00-20:00");
+ });
+
it("prefers direct contact fields and returns stable null values when details are absent", () => {
const properties = normalizeMarineFeatureProperties({
layer: "harbours",
@@ -153,6 +313,23 @@ describe("marine feature normalization", () => {
});
});
+function featurePool(...results: Array<{ rows: unknown[] }>): FeatureDatabase & {
+ query: ReturnType;
+ end: ReturnType;
+} {
+ const query = vi.fn();
+ for (const result of results) {
+ query.mockResolvedValueOnce(result);
+ }
+ return {
+ query,
+ end: vi.fn().mockResolvedValue(undefined)
+ } as unknown as FeatureDatabase & {
+ query: ReturnType;
+ end: ReturnType;
+ };
+}
+
describe("marine contact feature deduplication", () => {
it("returns one stable facility feature and reports how many raw objects were merged", () => {
const result = deduplicateMarineContactFeatures([
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index e49c3be..f93c40f 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -4,7 +4,6 @@ import {
calculateRouteGuidance,
haversineDistanceNm,
type BoatProfile,
- type VoyageHarbour,
type AppConfig,
type Coordinate,
type MarineForecast,
@@ -17,7 +16,6 @@ import {
import {
createRoute,
getConfig,
- getMapFeatures,
getMarineForecast,
getNavigationData,
getNearestTide
@@ -42,6 +40,7 @@ import { useBoatProfile } from "./hooks/useBoatProfile";
import { useCourseAssistant } from "./hooks/useCourseAssistant";
import { useGeolocation } from "./hooks/useGeolocation";
import { useMarineData } from "./hooks/useMarineData";
+import { useRouteEventFeatures } from "./hooks/useRouteEventFeatures";
import type { OfflineVoyage } from "./lib/offline-route";
import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile";
import { isOpenRouteWarning } from "./lib/route-warnings";
@@ -51,12 +50,6 @@ import {
type UpcomingRouteEvent
} from "./routeEvents";
import type { RouteWeatherReport } from "./routeWeatherReport";
-import {
- routeFeatureBounds,
- routeLocksFromFeatures,
- voyageHarboursFromFeatures,
- type RouteLock
-} from "./voyageHarbours";
type PickMode = "start" | "destination" | "waypoint" | null;
@@ -141,16 +134,21 @@ export function App() {
});
const [routeOptions, setRouteOptions] = useState([]);
const [activeVesselProfile, setActiveVesselProfile] = useState(null);
+ const routeEventFeatures = useRouteEventFeatures(
+ route,
+ activeVesselProfile ?? toVesselProfile(currentBoat)
+ );
+ const {
+ harbours: routeHarbours,
+ locks: routeLocks,
+ bridgeReport: routeBridgeReport
+ } = routeEventFeatures;
const [routeWeatherReport, setRouteWeatherReport] = useState(null);
const [routeWeatherLoading, setRouteWeatherLoading] = useState(false);
const [routeWeatherError, setRouteWeatherError] = useState(null);
const [navigationData, setNavigationData] = useState(null);
const [navigationDataLoading, setNavigationDataLoading] = useState(false);
const [navigationDataError, setNavigationDataError] = useState(null);
- const [routeHarbours, setRouteHarbours] = useState([]);
- const [routeLocks, setRouteLocks] = useState([]);
- const [routeHarboursLoading, setRouteHarboursLoading] = useState(false);
- const [routeHarboursError, setRouteHarboursError] = useState(null);
const [routeTides, setRouteTides] = useState(null);
const [routeTidesLoading, setRouteTidesLoading] = useState(false);
const [routeTidesError, setRouteTidesError] = useState(null);
@@ -262,44 +260,6 @@ export function App() {
};
}, [route]);
- useEffect(() => {
- if (!route) {
- setRouteHarbours([]);
- setRouteLocks([]);
- setRouteHarboursLoading(false);
- setRouteHarboursError(null);
- return;
- }
-
- let active = true;
- setRouteHarboursLoading(true);
- getMapFeatures({ bbox: routeFeatureBounds(route, 2.5), layers: ["harbours", "locks"] })
- .then((collection) => {
- if (!active) {
- return;
- }
- setRouteHarbours(voyageHarboursFromFeatures(collection));
- setRouteLocks(routeLocksFromFeatures(collection, route));
- setRouteHarboursError(null);
- })
- .catch((error) => {
- if (active) {
- setRouteHarbours([]);
- setRouteLocks([]);
- setRouteHarboursError(error instanceof Error ? error.message : "Häfen entlang der Route nicht erreichbar");
- }
- })
- .finally(() => {
- if (active) {
- setRouteHarboursLoading(false);
- }
- });
-
- return () => {
- active = false;
- };
- }, [route]);
-
const routeWarningCount = useMemo(
() => route?.warnings.filter(isOpenRouteWarning).length ?? 0,
[route]
@@ -433,7 +393,7 @@ export function App() {
route,
harbours: routeHarbours,
locks: routeLocks,
- bridges: routeWeatherReport?.bridgeReport?.bridges ?? [],
+ bridges: routeBridgeReport?.bridges ?? [],
progressNm: routeProgress.distanceNm,
etaBasis: routeEventEtaBasis
})
@@ -444,7 +404,7 @@ export function App() {
routeHarbours,
routeLocks,
routeProgress.distanceNm,
- routeWeatherReport?.bridgeReport?.bridges
+ routeBridgeReport?.bridges
]
);
const eventWarningCount = useMemo(
@@ -485,6 +445,8 @@ export function App() {
)
? "alarm"
: "caution"
+ : routeEventFeatures.error
+ ? "caution"
: route
? "active"
: "idle",
@@ -511,6 +473,7 @@ export function App() {
marineData.loading,
marineData.tide?.updatedAt,
route,
+ routeEventFeatures.error,
routeEvents,
routeWeatherReport?.severity
]
@@ -526,7 +489,7 @@ export function App() {
import("./routeWeatherReport")
.then(({ createRouteWeatherReport }) =>
- createRouteWeatherReport(result, vesselProfile, getMarineForecast, getMapFeatures)
+ createRouteWeatherReport(result, vesselProfile, getMarineForecast)
)
.then((report) => {
if (routeWeatherRequestId.current === reportRequestId) {
@@ -812,7 +775,7 @@ export function App() {
activeTool === "conditions"
? marineData.loading || routeWeatherLoading || routeTidesLoading
: activeTool === "upcoming"
- ? routeHarboursLoading || routeWeatherLoading
+ ? routeEventFeatures.loading
: activeTool === "route"
? routeLoading
: false;
@@ -905,7 +868,7 @@ export function App() {
badges={{
boat: boatProfileStored ? null : "!",
anchor: anchorAlarm ? "!" : null,
- upcoming: eventWarningCount,
+ upcoming: eventWarningCount || (routeEventFeatures.error ? "!" : null),
route: guidanceAlarm ? "!" : routeWarningCount
}}
/>
@@ -991,8 +954,8 @@ export function App() {
@@ -1034,9 +997,12 @@ export function App() {
navigationDataLoading={navigationDataLoading}
navigationDataError={navigationDataError}
routeHarbours={routeHarbours}
- routeHarboursLoading={routeHarboursLoading}
- routeHarboursError={routeHarboursError}
+ routeHarboursLoading={routeEventFeatures.harboursLoading}
+ routeHarboursError={routeEventFeatures.errors.harbours}
routeLocks={routeLocks}
+ bridgeReport={routeBridgeReport}
+ bridgesLoading={routeEventFeatures.bridgesLoading}
+ bridgesError={routeEventFeatures.errors.bridges}
routeTides={routeTides}
routeTidesLoading={routeTidesLoading}
routeTidesError={routeTidesError}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts
index 2088f2c..a67dfa2 100644
--- a/apps/web/src/api.ts
+++ b/apps/web/src/api.ts
@@ -9,10 +9,31 @@ import type {
} from "@watermaps/shared";
import type { FeatureCollection } from "geojson";
+export type MapFeatureMetadata = {
+ source?: "postgis" | "demo" | "unavailable" | string;
+ warning?: string;
+ deduplication?: {
+ inputPoiCount: number;
+ outputPoiCount: number;
+ mergedObjectCount: number;
+ };
+};
+
+export type MapFeatureCollection = FeatureCollection & {
+ metadata?: MapFeatureMetadata;
+};
+
+export class FeatureDataUnavailableError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "FeatureDataUnavailableError";
+ }
+}
+
async function getJson(url: string, init?: RequestInit): Promise {
const response = await fetch(url, init);
if (!response.ok) {
- throw new Error(`${response.status} ${response.statusText}`);
+ throw new Error(await responseErrorMessage(response));
}
return (await response.json()) as T;
}
@@ -84,10 +105,24 @@ export function getMapFeatures(params: {
bbox: [number, number, number, number];
layers: string[];
signal?: AbortSignal;
-}): Promise {
+}): Promise {
const search = new URLSearchParams({
bbox: params.bbox.join(","),
layers: params.layers.join(",")
});
- return getJson(`/api/features?${search.toString()}`, { signal: params.signal });
+ return getJson(`/api/features?${search.toString()}`, {
+ signal: params.signal
+ }).then(assertFeatureDataAvailable);
+}
+
+export function assertFeatureDataAvailable(
+ collection: MapFeatureCollection
+): MapFeatureCollection {
+ if (collection.metadata?.source === "unavailable") {
+ throw new FeatureDataUnavailableError(
+ collection.metadata.warning?.trim() ||
+ "Die lokale Ereignis-Datenquelle ist nicht konfiguriert."
+ );
+ }
+ return collection;
}
diff --git a/apps/web/src/components/RoutePlanner.tsx b/apps/web/src/components/RoutePlanner.tsx
index c3aa959..3a6afa4 100644
--- a/apps/web/src/components/RoutePlanner.tsx
+++ b/apps/web/src/components/RoutePlanner.tsx
@@ -31,7 +31,10 @@ import {
type VoyageAmenity,
type VoyageHarbour
} from "@watermaps/shared";
-import type { RouteWeatherReport } from "../routeWeatherReport";
+import type {
+ RouteBridgeReport,
+ RouteWeatherReport
+} from "../routeWeatherReport";
import type { OfflineVoyage } from "../lib/offline-route";
import {
boatCategoryLabel,
@@ -63,6 +66,9 @@ type RoutePlannerProps = {
routeHarboursLoading?: boolean;
routeHarboursError?: string | null;
routeLocks?: RouteLock[];
+ bridgeReport?: RouteBridgeReport | null;
+ bridgesLoading?: boolean;
+ bridgesError?: string | null;
routeTides?: RouteTidePlan | null;
routeTidesLoading?: boolean;
routeTidesError?: string | null;
@@ -120,6 +126,9 @@ export function RoutePlanner({
routeHarboursLoading = false,
routeHarboursError = null,
routeLocks = [],
+ bridgeReport = null,
+ bridgesLoading = false,
+ bridgesError = null,
routeTides = null,
routeTidesLoading = false,
routeTidesError = null,
@@ -739,7 +748,13 @@ export function RoutePlanner({
)}
- {operationalPanelsVisible && (weatherLoading || weatherReport || weatherError) && (
+ {operationalPanelsVisible &&
+ (weatherLoading ||
+ weatherReport ||
+ weatherError ||
+ bridgesLoading ||
+ bridgeReport ||
+ bridgesError) && (
@@ -748,6 +763,7 @@ export function RoutePlanner({
{weatherLoading && Wetterbericht wird geladen
}
+ {bridgesLoading && Brücken werden geladen
}
{weatherReport && (
<>
@@ -798,35 +814,6 @@ export function RoutePlanner({
{weatherReport.unavailableSamples} Messpunkt nicht erreichbar
)}
- {weatherReport.bridgeReport && (
-
-
-
- {weatherReport.bridgeReport.summary}
-
-
- Boot {formatMeters(weatherReport.bridgeReport.requiredAirDraftM)}
- Min {formatMeters(weatherReport.bridgeReport.minClearanceM)}
-
- {weatherReport.bridgeReport.checkedCount}/{weatherReport.bridgeReport.bridges.length} geprüft
-
-
- {weatherReport.bridgeReport.bridges.length > 0 && (
-
- {weatherReport.bridgeReport.bridges.slice(0, 4).map((bridge) => (
-
- {bridge.name ?? "Brücke"}
- {bridge.clearanceLabel ?? "H unbekannt"}
- {formatBridgeMargin(bridge.marginM)}
-
- ))}
- {weatherReport.bridgeReport.bridges.length > 4 && (
-
+{weatherReport.bridgeReport.bridges.length - 4} weitere Brücken
- )}
-
- )}
-
- )}
>
)}
@@ -836,6 +823,43 @@ export function RoutePlanner({
{weatherError}
)}
+
+ {bridgeReport && (
+
+
+
+ {bridgeReport.summary}
+
+
+ Boot {formatMeters(bridgeReport.requiredAirDraftM)}
+ Min {formatMeters(bridgeReport.minClearanceM)}
+
+ {bridgeReport.checkedCount}/{bridgeReport.bridges.length} geprüft
+
+
+ {bridgeReport.bridges.length > 0 && (
+
+ {bridgeReport.bridges.slice(0, 4).map((bridge) => (
+
+ {bridge.name ?? "Brücke"}
+ {bridge.clearanceLabel ?? "H unbekannt"}
+ {formatBridgeMargin(bridge.marginM)}
+
+ ))}
+ {bridgeReport.bridges.length > 4 && (
+
+{bridgeReport.bridges.length - 4} weitere Brücken
+ )}
+
+ )}
+
+ )}
+
+ {bridgesError && !bridgesLoading && (
+
+
+ {bridgesError}
+
+ )}
)}
diff --git a/apps/web/src/components/UpcomingEventsPanel.css b/apps/web/src/components/UpcomingEventsPanel.css
index d67a36c..8325de0 100644
--- a/apps/web/src/components/UpcomingEventsPanel.css
+++ b/apps/web/src/components/UpcomingEventsPanel.css
@@ -244,6 +244,15 @@
font-size: 12px;
}
+.upcoming-event-row-detail-action {
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ color: #196f5c;
+ font-size: 9px;
+ font-weight: 850;
+}
+
.upcoming-event-contact-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
diff --git a/apps/web/src/components/UpcomingEventsPanel.tsx b/apps/web/src/components/UpcomingEventsPanel.tsx
index 180b226..66f62cf 100644
--- a/apps/web/src/components/UpcomingEventsPanel.tsx
+++ b/apps/web/src/components/UpcomingEventsPanel.tsx
@@ -4,6 +4,7 @@ import {
CalendarClock,
Clock3,
Globe2,
+ Info,
Landmark,
LoaderCircle,
LockKeyhole,
@@ -308,6 +309,10 @@ function EventRow({
{formatDistance(event.remainingNm)}
{formatEta(event)}
+
+
+ Infos
+
);
@@ -456,6 +461,9 @@ function KindSpecificDetails({ event }: { event: UpcomingRouteEvent }) {
{bridgeMargin(event.feature.marginM)}
+
+ {event.feature.operator ?? "Nicht hinterlegt"}
+
>
);
}
@@ -566,14 +574,11 @@ function PanelState({
}
function eventContact(event: UpcomingRouteEvent) {
- if (event.kind === "harbour" || event.kind === "lock") {
- return {
- phone: event.feature.phone ?? null,
- website: event.feature.website ?? null,
- email: event.feature.email ?? null
- };
- }
- return { phone: null, website: null, email: null };
+ return {
+ phone: event.feature.phone ?? null,
+ website: event.feature.website ?? null,
+ email: event.feature.email ?? null
+ };
}
function importantFact(event: UpcomingRouteEvent) {
diff --git a/apps/web/src/hooks/useRouteEventFeatures.ts b/apps/web/src/hooks/useRouteEventFeatures.ts
new file mode 100644
index 0000000..e3a25f8
--- /dev/null
+++ b/apps/web/src/hooks/useRouteEventFeatures.ts
@@ -0,0 +1,233 @@
+import { useEffect, useMemo, useState } from "react";
+import type {
+ RouteResult,
+ VesselProfile,
+ VoyageHarbour
+} from "@watermaps/shared";
+import { getMapFeatures } from "../api";
+import type { RouteBridgeReport } from "../routeWeatherReport";
+import {
+ routeFeatureBounds,
+ routeLocksFromFeatures,
+ voyageHarboursFromFeatures,
+ type RouteLock
+} from "../voyageHarbours";
+
+type FeatureKind = "harbours" | "locks" | "bridges";
+
+type FeatureRequestState = Record;
+type FeatureErrorState = Record;
+
+export type RouteEventFeatureState = {
+ harbours: VoyageHarbour[];
+ locks: RouteLock[];
+ bridgeReport: RouteBridgeReport | null;
+ loading: boolean;
+ errors: FeatureErrorState;
+ error: string | null;
+ harboursLoading: boolean;
+ locksLoading: boolean;
+ bridgesLoading: boolean;
+};
+
+const EMPTY_LOADING: FeatureRequestState = {
+ harbours: false,
+ locks: false,
+ bridges: false
+};
+
+const EMPTY_ERRORS: FeatureErrorState = {
+ harbours: null,
+ locks: null,
+ bridges: null
+};
+
+/**
+ * Loads every event category independently. A failed harbour request must not
+ * hide working lock or bridge data, and bridges are intentionally fetched here
+ * instead of as a side effect of the weather report.
+ */
+export function useRouteEventFeatures(
+ route: RouteResult | null,
+ vesselProfile: Pick | null
+): RouteEventFeatureState {
+ const [harbours, setHarbours] = useState([]);
+ const [locks, setLocks] = useState([]);
+ const [bridgeReport, setBridgeReport] = useState(null);
+ const [loadingByKind, setLoadingByKind] =
+ useState(EMPTY_LOADING);
+ const [errors, setErrors] = useState(EMPTY_ERRORS);
+ const airDraftM = vesselProfile?.airDraftM;
+
+ useEffect(() => {
+ if (!route) {
+ setHarbours([]);
+ setLoadingByKind((current) => ({ ...current, harbours: false }));
+ setErrors((current) => ({ ...current, harbours: null }));
+ return;
+ }
+
+ let active = true;
+ const controller = new AbortController();
+ setHarbours([]);
+ setLoadingByKind((current) => ({ ...current, harbours: true }));
+ setErrors((current) => ({ ...current, harbours: null }));
+
+ void getMapFeatures({
+ bbox: routeFeatureBounds(route, 2.5),
+ layers: ["harbours"],
+ signal: controller.signal
+ })
+ .then((collection) => {
+ if (active) {
+ setHarbours(voyageHarboursFromFeatures(collection));
+ }
+ })
+ .catch((error) => {
+ if (active && !isAbortError(error)) {
+ setErrors((current) => ({
+ ...current,
+ harbours: featureErrorMessage("Häfen", error)
+ }));
+ }
+ })
+ .finally(() => {
+ if (active) {
+ setLoadingByKind((current) => ({ ...current, harbours: false }));
+ }
+ });
+
+ return () => {
+ active = false;
+ controller.abort();
+ };
+ }, [route]);
+
+ useEffect(() => {
+ if (!route) {
+ setLocks([]);
+ setLoadingByKind((current) => ({ ...current, locks: false }));
+ setErrors((current) => ({ ...current, locks: null }));
+ return;
+ }
+
+ let active = true;
+ const controller = new AbortController();
+ setLocks([]);
+ setLoadingByKind((current) => ({ ...current, locks: true }));
+ setErrors((current) => ({ ...current, locks: null }));
+
+ void getMapFeatures({
+ bbox: routeFeatureBounds(route, 0.5),
+ layers: ["locks"],
+ signal: controller.signal
+ })
+ .then((collection) => {
+ if (active) {
+ setLocks(routeLocksFromFeatures(collection, route));
+ }
+ })
+ .catch((error) => {
+ if (active && !isAbortError(error)) {
+ setErrors((current) => ({
+ ...current,
+ locks: featureErrorMessage("Schleusen", error)
+ }));
+ }
+ })
+ .finally(() => {
+ if (active) {
+ setLoadingByKind((current) => ({ ...current, locks: false }));
+ }
+ });
+
+ return () => {
+ active = false;
+ controller.abort();
+ };
+ }, [route]);
+
+ useEffect(() => {
+ if (!route) {
+ setBridgeReport(null);
+ setLoadingByKind((current) => ({ ...current, bridges: false }));
+ setErrors((current) => ({ ...current, bridges: null }));
+ return;
+ }
+
+ let active = true;
+ const controller = new AbortController();
+ setBridgeReport(null);
+ setLoadingByKind((current) => ({ ...current, bridges: true }));
+ setErrors((current) => ({ ...current, bridges: null }));
+
+ void import("../routeWeatherReport")
+ .then(({ createRouteBridgeReport }) =>
+ createRouteBridgeReport(
+ route,
+ { airDraftM },
+ (params) =>
+ getMapFeatures({
+ ...params,
+ signal: controller.signal
+ })
+ )
+ )
+ .then((report) => {
+ if (active) {
+ setBridgeReport(report);
+ }
+ })
+ .catch((error) => {
+ if (active && !isAbortError(error)) {
+ setErrors((current) => ({
+ ...current,
+ bridges: featureErrorMessage("Brücken", error)
+ }));
+ }
+ })
+ .finally(() => {
+ if (active) {
+ setLoadingByKind((current) => ({ ...current, bridges: false }));
+ }
+ });
+
+ return () => {
+ active = false;
+ controller.abort();
+ };
+ }, [airDraftM, route]);
+
+ const error = useMemo(
+ () =>
+ (["harbours", "locks", "bridges"] as const)
+ .map((kind) => errors[kind])
+ .filter((message): message is string => Boolean(message))
+ .join(" · ") || null,
+ [errors]
+ );
+
+ return {
+ harbours,
+ locks,
+ bridgeReport,
+ loading: Object.values(loadingByKind).some(Boolean),
+ errors,
+ error,
+ harboursLoading: loadingByKind.harbours,
+ locksLoading: loadingByKind.locks,
+ bridgesLoading: loadingByKind.bridges
+ };
+}
+
+function featureErrorMessage(label: string, error: unknown) {
+ const detail =
+ error instanceof Error && error.message.trim()
+ ? error.message.trim()
+ : "Datenquelle nicht erreichbar.";
+ return `${label} nicht erreichbar: ${detail}`;
+}
+
+function isAbortError(error: unknown) {
+ return error instanceof DOMException && error.name === "AbortError";
+}
diff --git a/apps/web/src/routeEvents.ts b/apps/web/src/routeEvents.ts
index 7a282c0..4c27a9c 100644
--- a/apps/web/src/routeEvents.ts
+++ b/apps/web/src/routeEvents.ts
@@ -1,4 +1,5 @@
import {
+ haversineDistanceNm,
orderWaypointsAlongRoute,
type Coordinate,
type RouteResult,
@@ -12,11 +13,13 @@ export type RouteEventKind = "harbour" | "lock" | "bridge";
export type RouteEventCorridors = Record;
export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly = Object.freeze({
- harbour: 1.5,
+ harbour: 0.5,
lock: 0.25,
bridge: 0.08
});
+const CROSS_KIND_DUPLICATE_DISTANCE_NM = 0.1;
+
export type RouteEventEtaSpeedSource = "gps-sog" | "vessel-cruise-speed";
export type RouteEventEtaReferenceSource = "current-time" | "route-departure";
@@ -148,7 +151,7 @@ export function upcomingRouteEvents(
input.route
);
- return projected.flatMap((projection) => {
+ const events = projected.flatMap((projection) => {
const candidate = candidatesByProjectionId.get(projection.id);
if (
!candidate ||
@@ -179,6 +182,8 @@ export function upcomingRouteEvents(
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
}
});
+
+ return withoutLockHarbourDuplicates(events);
}
export function nextRouteEventsByKind(
@@ -320,3 +325,45 @@ function timestampValue(value: string | number | Date): number | null {
: Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
+
+/**
+ * Some source objects are classified both as a lock and as a harbour. Preserve
+ * the operationally more specific lock event only when the normalized names
+ * match and both source coordinates clearly describe the same place.
+ */
+function withoutLockHarbourDuplicates(
+ events: UpcomingRouteEvent[]
+): UpcomingRouteEvent[] {
+ const locksByName = new Map();
+ for (const event of events) {
+ if (event.kind !== "lock") {
+ continue;
+ }
+ const name = normalizedFacilityName(event.name);
+ if (!name) {
+ continue;
+ }
+ locksByName.set(name, [...(locksByName.get(name) ?? []), event]);
+ }
+
+ return events.filter((event) => {
+ if (event.kind !== "harbour") {
+ return true;
+ }
+ const possibleLocks = locksByName.get(normalizedFacilityName(event.name)) ?? [];
+ return !possibleLocks.some(
+ (lock) =>
+ haversineDistanceNm(lock.coordinate, event.coordinate) <=
+ CROSS_KIND_DUPLICATE_DISTANCE_NM
+ );
+ });
+}
+
+function normalizedFacilityName(value: string) {
+ return value
+ .normalize("NFKD")
+ .replace(/\p{Diacritic}/gu, "")
+ .toLocaleLowerCase("de-DE")
+ .replace(/[^\p{Letter}\p{Number}]+/gu, " ")
+ .trim();
+}
diff --git a/apps/web/src/routeWeatherReport.ts b/apps/web/src/routeWeatherReport.ts
index 27499cf..bf98c4a 100644
--- a/apps/web/src/routeWeatherReport.ts
+++ b/apps/web/src/routeWeatherReport.ts
@@ -55,6 +55,10 @@ export type RouteBridgeAssessment = {
marginM: number | null;
status: RouteBridgeStatus;
source: string;
+ phone?: string | null;
+ website?: string | null;
+ email?: string | null;
+ operator?: string | null;
};
export type RouteBridgeReport = {
@@ -79,6 +83,9 @@ const SAMPLE_TARGETS: Array<{ label: RouteWeatherSample["label"]; ratio: number
const ROUTE_BRIDGE_BBOX_MARGIN_DEG = 0.02;
const ROUTE_BRIDGE_MAX_DISTANCE_NM = 0.08;
const BRIDGE_TIGHT_MARGIN_M = 0.5;
+const SAME_NAMED_BRIDGE_DISTANCE_NM = 0.03;
+const UNNAMED_BRIDGE_DISTANCE_NM = 0.005;
+const NAMED_TO_UNNAMED_BRIDGE_DISTANCE_NM = 0.01;
export async function createRouteWeatherReport(
route: RouteResult,
@@ -451,7 +458,29 @@ function bridgeAssessmentFromFeature(
requiredAirDraftM,
marginM,
status,
- source: stringProperty(properties.source) ?? "OSM/Geofabrik"
+ source: stringProperty(properties.source) ?? "OSM/Geofabrik",
+ phone: firstStringProperty(properties, [
+ "contact:phone",
+ "phone",
+ "telephone",
+ "contact_phone"
+ ]),
+ website: firstStringProperty(properties, [
+ "contact:website",
+ "website",
+ "url",
+ "contact_website"
+ ]),
+ email: firstStringProperty(properties, [
+ "contact:email",
+ "email",
+ "contact_email"
+ ]),
+ operator: firstStringProperty(properties, [
+ "operator",
+ "operator:name",
+ "owner"
+ ])
};
}
@@ -572,11 +601,123 @@ function dedupeBridges(bridges: RouteBridgeAssessment[]) {
const byId = new Map();
for (const bridge of bridges) {
const existing = byId.get(bridge.id);
- if (!existing || bridge.distanceNm < existing.distanceNm) {
- byId.set(bridge.id, bridge);
- }
+ byId.set(bridge.id, existing ? mergeBridgeAssessments(existing, bridge) : bridge);
}
- return [...byId.values()];
+
+ const merged: RouteBridgeAssessment[] = [];
+ for (const bridge of byId.values()) {
+ const duplicateIndex = merged.findIndex((candidate) =>
+ bridgeAssessmentsMatch(candidate, bridge)
+ );
+ if (duplicateIndex < 0) {
+ merged.push(bridge);
+ continue;
+ }
+ merged[duplicateIndex] = mergeBridgeAssessments(
+ merged[duplicateIndex]!,
+ bridge
+ );
+ }
+ return merged;
+}
+
+function bridgeAssessmentsMatch(
+ left: RouteBridgeAssessment,
+ right: RouteBridgeAssessment
+) {
+ const distanceNm = haversineDistanceNm(left.coordinate, right.coordinate);
+ const leftName = normalizedBridgeName(left.name);
+ const rightName = normalizedBridgeName(right.name);
+
+ if (leftName && rightName) {
+ return (
+ leftName === rightName &&
+ distanceNm <= SAME_NAMED_BRIDGE_DISTANCE_NM
+ );
+ }
+ if (!leftName && !rightName) {
+ return distanceNm <= UNNAMED_BRIDGE_DISTANCE_NM;
+ }
+ return distanceNm <= NAMED_TO_UNNAMED_BRIDGE_DISTANCE_NM;
+}
+
+function mergeBridgeAssessments(
+ left: RouteBridgeAssessment,
+ right: RouteBridgeAssessment
+): RouteBridgeAssessment {
+ const primary =
+ bridgeInformationScore(right) > bridgeInformationScore(left) ? right : left;
+ const secondary = primary === left ? right : left;
+ const clearanceSource = smallestClearanceBridge(left, right);
+ const clearanceM = clearanceSource?.clearanceM ?? null;
+ const requiredAirDraftM =
+ primary.requiredAirDraftM ?? secondary.requiredAirDraftM;
+ const marginM =
+ clearanceM !== null && requiredAirDraftM !== null
+ ? clearanceM - requiredAirDraftM
+ : null;
+ const closestToRoute =
+ left.distanceNm <= right.distanceNm ? left : right;
+ const name = primary.name ?? secondary.name;
+ const clearanceLabel =
+ clearanceSource?.clearanceLabel ??
+ (clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null);
+
+ return {
+ ...primary,
+ name,
+ label: [name, clearanceLabel].filter(Boolean).join(" ") || primary.label,
+ coordinate: closestToRoute.coordinate,
+ distanceNm: Math.min(left.distanceNm, right.distanceNm),
+ clearanceM,
+ clearanceLabel,
+ requiredAirDraftM,
+ marginM,
+ status: bridgeStatus(clearanceM, requiredAirDraftM),
+ source: unique([left.source, right.source]).join(", "),
+ phone: primary.phone ?? secondary.phone ?? null,
+ website: primary.website ?? secondary.website ?? null,
+ email: primary.email ?? secondary.email ?? null,
+ operator: primary.operator ?? secondary.operator ?? null
+ };
+}
+
+function smallestClearanceBridge(
+ left: RouteBridgeAssessment,
+ right: RouteBridgeAssessment
+) {
+ const candidates = [left, right].filter(
+ (bridge) =>
+ bridge.clearanceM !== null && Number.isFinite(bridge.clearanceM)
+ );
+ return candidates.sort(
+ (first, second) => first.clearanceM! - second.clearanceM!
+ )[0] ?? null;
+}
+
+function bridgeInformationScore(bridge: RouteBridgeAssessment) {
+ return [
+ bridge.name,
+ bridge.clearanceM,
+ bridge.phone,
+ bridge.website,
+ bridge.email,
+ bridge.operator
+ ].filter((value) => value !== null && value !== undefined && value !== "")
+ .length;
+}
+
+function normalizedBridgeName(value: string | null) {
+ if (!value) {
+ return "";
+ }
+ const normalized = value
+ .normalize("NFKD")
+ .replace(/\p{Diacritic}/gu, "")
+ .toLocaleLowerCase("de-DE")
+ .replace(/[^\p{Letter}\p{Number}]+/gu, " ")
+ .trim();
+ return normalized === "brucke" || normalized === "bridge" ? "" : normalized;
}
function geometryLineStrings(geometry: Geometry): LonLat[][] {
@@ -770,6 +911,19 @@ function stringProperty(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
+function firstStringProperty(
+ properties: Record,
+ keys: readonly string[]
+) {
+ for (const key of keys) {
+ const value = stringProperty(properties[key]);
+ if (value) {
+ return value;
+ }
+ }
+ return null;
+}
+
function normalizeMeters(value: unknown) {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
diff --git a/apps/web/tests/api-features.test.ts b/apps/web/tests/api-features.test.ts
new file mode 100644
index 0000000..5c7f253
--- /dev/null
+++ b/apps/web/tests/api-features.test.ts
@@ -0,0 +1,106 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ FeatureDataUnavailableError,
+ getMapFeatures
+} from "../src/api";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("getMapFeatures", () => {
+ it("rejects an unavailable feature source instead of treating it as an empty result", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () =>
+ new Response(
+ JSON.stringify({
+ type: "FeatureCollection",
+ features: [],
+ metadata: {
+ source: "unavailable",
+ warning: "Keine lokale Feature-Datenbank konfiguriert."
+ }
+ }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" }
+ }
+ )
+ )
+ );
+
+ await expect(
+ getMapFeatures({
+ bbox: [7, 53, 8, 54],
+ layers: ["harbours"]
+ })
+ ).rejects.toEqual(
+ expect.objectContaining({
+ name: "FeatureDataUnavailableError",
+ message: "Keine lokale Feature-Datenbank konfiguriert."
+ })
+ );
+ await expect(
+ getMapFeatures({
+ bbox: [7, 53, 8, 54],
+ layers: ["locks"]
+ })
+ ).rejects.toBeInstanceOf(FeatureDataUnavailableError);
+ });
+
+ it("keeps usable postgis and legacy feature collections intact", async () => {
+ const responses = [
+ {
+ type: "FeatureCollection",
+ features: [],
+ metadata: { source: "postgis" }
+ },
+ {
+ type: "FeatureCollection",
+ features: []
+ }
+ ];
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () =>
+ new Response(JSON.stringify(responses.shift()), {
+ status: 200,
+ headers: { "content-type": "application/json" }
+ })
+ )
+ );
+
+ await expect(
+ getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["bridges"] })
+ ).resolves.toMatchObject({ metadata: { source: "postgis" } });
+ await expect(
+ getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["bridges"] })
+ ).resolves.toMatchObject({ features: [] });
+ });
+
+ it("uses the API error message for an unavailable source", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () =>
+ new Response(
+ JSON.stringify({
+ error: "feature_source_unavailable",
+ message: "Keine lokale Karten- und Ereignisdatenbank konfiguriert."
+ }),
+ {
+ status: 503,
+ statusText: "Service Unavailable",
+ headers: { "content-type": "application/json" }
+ }
+ )
+ )
+ );
+
+ await expect(
+ getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["locks"] })
+ ).rejects.toThrow(
+ "Keine lokale Karten- und Ereignisdatenbank konfiguriert."
+ );
+ });
+});
diff --git a/apps/web/tests/route-event-features-hook.test.tsx b/apps/web/tests/route-event-features-hook.test.tsx
new file mode 100644
index 0000000..122e010
--- /dev/null
+++ b/apps/web/tests/route-event-features-hook.test.tsx
@@ -0,0 +1,199 @@
+import { cleanup, renderHook, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { RouteResult } from "@watermaps/shared";
+import { useRouteEventFeatures } from "../src/hooks/useRouteEventFeatures";
+
+const apiMocks = vi.hoisted(() => ({
+ getMapFeatures: vi.fn()
+}));
+
+vi.mock("../src/api", () => ({
+ getMapFeatures: apiMocks.getMapFeatures
+}));
+
+beforeEach(() => {
+ apiMocks.getMapFeatures.mockReset();
+});
+
+afterEach(cleanup);
+
+describe("useRouteEventFeatures", () => {
+ it("loads harbours, locks and bridges once and independently", async () => {
+ apiMocks.getMapFeatures.mockImplementation(
+ async ({ layers }: { layers: string[] }) =>
+ featureCollection(layers[0]!)
+ );
+
+ const activeRoute = route("first", 0);
+ const { result } = renderHook(() =>
+ useRouteEventFeatures(activeRoute, { airDraftM: 3 })
+ );
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3);
+ expect(
+ apiMocks.getMapFeatures.mock.calls.map(([request]) => request.layers)
+ ).toEqual(expect.arrayContaining([["harbours"], ["locks"], ["bridges"]]));
+ expect(result.current.harbours.map((harbour) => harbour.name)).toEqual([
+ "Testhafen"
+ ]);
+ expect(result.current.locks.map((lock) => lock.name)).toEqual([
+ "Testschleuse"
+ ]);
+ expect(result.current.bridgeReport?.bridges.map((bridge) => bridge.name)).toEqual([
+ "Testbrücke"
+ ]);
+ expect(result.current.error).toBeNull();
+ });
+
+ it("keeps working categories when one feature request fails", async () => {
+ apiMocks.getMapFeatures.mockImplementation(
+ async ({ layers }: { layers: string[] }) => {
+ if (layers[0] === "harbours") {
+ throw new Error("Feature-Datenbank fehlt");
+ }
+ return featureCollection(layers[0]!);
+ }
+ );
+
+ const activeRoute = route("partial", 0);
+ const { result } = renderHook(() =>
+ useRouteEventFeatures(activeRoute, { airDraftM: 3 })
+ );
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(result.current.harbours).toEqual([]);
+ expect(result.current.locks).toHaveLength(1);
+ expect(result.current.bridgeReport?.bridges).toHaveLength(1);
+ expect(result.current.errors.harbours).toContain(
+ "Häfen nicht erreichbar: Feature-Datenbank fehlt"
+ );
+ expect(result.current.errors.locks).toBeNull();
+ expect(result.current.errors.bridges).toBeNull();
+ });
+
+ it("reassesses only bridges when the boat height changes", async () => {
+ apiMocks.getMapFeatures.mockImplementation(
+ async ({ layers }: { layers: string[] }) =>
+ featureCollection(layers[0]!)
+ );
+ const activeRoute = route("height", 0);
+ const { result, rerender } = renderHook(
+ ({ airDraftM }) =>
+ useRouteEventFeatures(activeRoute, { airDraftM }),
+ { initialProps: { airDraftM: 3 } }
+ );
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ apiMocks.getMapFeatures.mockClear();
+
+ rerender({ airDraftM: 3.5 });
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(1);
+ expect(apiMocks.getMapFeatures).toHaveBeenCalledWith(
+ expect.objectContaining({ layers: ["bridges"] })
+ );
+ expect(result.current.harbours).toHaveLength(1);
+ expect(result.current.locks).toHaveLength(1);
+ expect(result.current.bridgeReport?.requiredAirDraftM).toBe(3.5);
+ });
+
+ it("aborts stale category requests on a route change and exposes only the new route", async () => {
+ const staleSignals: AbortSignal[] = [];
+ const currentSignals: AbortSignal[] = [];
+ let staleCalls = 0;
+ apiMocks.getMapFeatures.mockImplementation(
+ ({ layers, signal }: { layers: string[]; signal: AbortSignal }) => {
+ if (staleCalls < 3) {
+ staleCalls += 1;
+ staleSignals.push(signal);
+ return new Promise(() => undefined);
+ }
+ currentSignals.push(signal);
+ return Promise.resolve(featureCollection(layers[0]!, 0.5));
+ }
+ );
+ const firstRoute = route("first", 0);
+ const secondRoute = route("second", 0.5);
+ const { result, rerender, unmount } = renderHook(
+ ({ activeRoute }) =>
+ useRouteEventFeatures(activeRoute, { airDraftM: 3 }),
+ { initialProps: { activeRoute: firstRoute } }
+ );
+
+ await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3));
+ rerender({ activeRoute: secondRoute });
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+ expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(6);
+ expect(staleSignals).toHaveLength(3);
+ expect(staleSignals.every((signal) => signal.aborted)).toBe(true);
+ expect(result.current.harbours[0]?.coordinate.lon).toBeCloseTo(0.7);
+ expect(result.current.locks[0]?.coordinate.lon).toBeCloseTo(0.8);
+ expect(result.current.bridgeReport?.bridges[0]?.coordinate.lon).toBeCloseTo(0.9);
+
+ unmount();
+ expect(currentSignals).toHaveLength(3);
+ expect(currentSignals.every((signal) => signal.aborted)).toBe(true);
+ });
+});
+
+function featureCollection(layer: string, offset = 0) {
+ const fixtures = {
+ harbours: {
+ type: "Feature" as const,
+ id: `harbour-${offset}`,
+ properties: { layer: "harbours", name: "Testhafen" },
+ geometry: {
+ type: "Point" as const,
+ coordinates: [0.2 + offset, 0]
+ }
+ },
+ locks: {
+ type: "Feature" as const,
+ id: `lock-${offset}`,
+ properties: { layer: "locks", name: "Testschleuse" },
+ geometry: {
+ type: "Point" as const,
+ coordinates: [0.3 + offset, 0]
+ }
+ },
+ bridges: {
+ type: "Feature" as const,
+ id: `bridge-${offset}`,
+ properties: {
+ layer: "bridges",
+ name: "Testbrücke",
+ clearance_m: 4
+ },
+ geometry: {
+ type: "Point" as const,
+ coordinates: [0.4 + offset, 0]
+ }
+ }
+ };
+ return {
+ type: "FeatureCollection" as const,
+ features: [fixtures[layer as keyof typeof fixtures]]
+ };
+}
+
+function route(id: string, offset: number): RouteResult {
+ return {
+ id,
+ geometry: {
+ type: "LineString",
+ coordinates: [
+ [offset, 0],
+ [offset + 1, 0]
+ ]
+ },
+ distanceNm: 60,
+ eta: null,
+ warnings: [],
+ dataSources: ["test"],
+ routingMode: "fairway"
+ };
+}
diff --git a/apps/web/tests/route-events.test.ts b/apps/web/tests/route-events.test.ts
index ed639f6..4338de8 100644
--- a/apps/web/tests/route-events.test.ts
+++ b/apps/web/tests/route-events.test.ts
@@ -4,6 +4,7 @@ import type {
VoyageHarbour
} from "@watermaps/shared";
import {
+ DEFAULT_ROUTE_EVENT_CORRIDORS_NM,
nextRouteEventsByKind,
upcomingRouteEvents
} from "../src/routeEvents";
@@ -80,6 +81,66 @@ describe("upcomingRouteEvents", () => {
expect(events.map((event) => event.id)).toEqual(["detour"]);
});
+ it("uses a focused half-mile harbour corridor by default", () => {
+ expect(DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour).toBe(0.5);
+
+ const events = upcomingRouteEvents({
+ route,
+ harbours: [
+ harbour("near-harbour", 0.4, 0.008),
+ harbour("unrelated-harbour", 0.5, 0.01)
+ ]
+ });
+
+ expect(events.map((event) => event.id)).toEqual(["near-harbour"]);
+ });
+
+ it("prefers a nearby same-named lock over a duplicate harbour classification", () => {
+ const duplicateHarbour = {
+ ...harbour("harbour-lock", 0.4, 0.0005),
+ name: "Nesserländer Schleuse"
+ };
+ const duplicateLock = {
+ ...lock("lock", 0.4, 0, 0, 0),
+ name: "Nesserlander Schleuse"
+ };
+ const distinctHarbour = {
+ ...harbour("real-harbour", 0.6, 0),
+ name: "Stadthafen"
+ };
+
+ const events = upcomingRouteEvents({
+ route,
+ harbours: [duplicateHarbour, distinctHarbour],
+ locks: [duplicateLock]
+ });
+
+ expect(events.map((event) => `${event.kind}:${event.name}`)).toEqual([
+ "lock:Nesserlander Schleuse",
+ "harbour:Stadthafen"
+ ]);
+ });
+
+ it("does not merge same-named facilities that are spatially distinct", () => {
+ const events = upcomingRouteEvents({
+ route,
+ harbours: [
+ {
+ ...harbour("harbour", 0.4, 0.003),
+ name: "Kanalschleuse"
+ }
+ ],
+ locks: [
+ {
+ ...lock("lock", 0.4, 0, 0, 0),
+ name: "Kanalschleuse"
+ }
+ ]
+ });
+
+ expect(events.map((event) => event.kind).sort()).toEqual(["harbour", "lock"]);
+ });
+
it("calculates ETA and retains both speed and reference-time provenance", () => {
const [event] = upcomingRouteEvents({
route,
diff --git a/apps/web/tests/route-planner.test.tsx b/apps/web/tests/route-planner.test.tsx
index 1fe5d4c..02ea1e8 100644
--- a/apps/web/tests/route-planner.test.tsx
+++ b/apps/web/tests/route-planner.test.tsx
@@ -528,6 +528,7 @@ describe("RoutePlanner", () => {
weatherReport={weatherReportFixture}
weatherLoading={false}
weatherError={null}
+ bridgeReport={weatherReportFixture.bridgeReport}
loading={false}
error={null}
pickMode={null}
diff --git a/apps/web/tests/route-weather-report.test.ts b/apps/web/tests/route-weather-report.test.ts
index 0c1085d..abe2cb6 100644
--- a/apps/web/tests/route-weather-report.test.ts
+++ b/apps/web/tests/route-weather-report.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest";
import type { MarineForecast, RouteResult } from "@watermaps/shared";
-import { createRouteWeatherReport, summarizeRouteWeather } from "../src/routeWeatherReport";
+import {
+ createRouteBridgeReport,
+ createRouteWeatherReport,
+ summarizeRouteWeather
+} from "../src/routeWeatherReport";
describe("route weather report", () => {
it("samples start, middle and destination forecasts", async () => {
@@ -81,7 +85,9 @@ describe("route weather report", () => {
clearance_m: 2.7,
clearance_label: "H 2.7 m",
label: "Niedrige Brücke H 2.7 m",
- source: "OSM/Geofabrik"
+ source: "OSM/Geofabrik",
+ phone: "+49 491 234",
+ website: "https://bridge.example"
},
geometry: {
type: "LineString",
@@ -120,6 +126,75 @@ describe("route weather report", () => {
expect(report.bridgeReport?.checkedCount).toBe(2);
expect(report.bridgeReport?.summary).toContain("Nicht passierbar");
expect(report.bridgeReport?.bridges[0]?.name).toBe("Niedrige Brücke");
+ expect(report.bridgeReport?.bridges[0]?.phone).toBe("+49 491 234");
+ expect(report.bridgeReport?.bridges[0]?.website).toBe("https://bridge.example");
+ });
+
+ it("deduplicates only conservatively matching bridge ways and keeps the richest safe assessment", async () => {
+ const route: RouteResult = {
+ ...routeFixture,
+ geometry: {
+ type: "LineString",
+ coordinates: [[0, 0], [1, 0]]
+ },
+ distanceNm: 60
+ };
+ const point = (
+ id: string,
+ lon: number,
+ lat: number,
+ properties: Record = {}
+ ) => ({
+ type: "Feature" as const,
+ id,
+ properties: { layer: "bridges", ...properties },
+ geometry: { type: "Point" as const, coordinates: [lon, lat] }
+ });
+
+ const report = await createRouteBridgeReport(
+ route,
+ { airDraftM: 3 },
+ async () => ({
+ type: "FeatureCollection",
+ features: [
+ point("named-1", 0.2, 0, {
+ name: "Am Tonnenhof",
+ clearance_m: 4,
+ phone: "+49 491 111"
+ }),
+ point("named-2", 0.2, 0.0003, {
+ name: "Am Tonnenhof",
+ clearance_m: 3.5,
+ website: "https://tonnenhof.example"
+ }),
+ point("distinct-east", 0.4, 0, { name: "Klappbrücke Ost" }),
+ point("distinct-west", 0.4, 0.0001, { name: "Klappbrücke West" }),
+ point("unnamed-tight-1", 0.6, 0),
+ point("unnamed-tight-2", 0.6, 0.00005),
+ point("unnamed-separate-1", 0.8, 0),
+ point("unnamed-separate-2", 0.8, 0.0001),
+ point("named-with-way", 0.9, 0, { name: "Auricher Straße" }),
+ point("unnamed-with-name", 0.9, 0.0001)
+ ]
+ })
+ );
+
+ expect(report.bridges).toHaveLength(7);
+ const tonnenhof = report.bridges.find((bridge) => bridge.name === "Am Tonnenhof");
+ expect(tonnenhof).toMatchObject({
+ clearanceM: 3.5,
+ clearanceLabel: "H 3.5 m",
+ label: "Am Tonnenhof H 3.5 m",
+ marginM: 0.5,
+ phone: "+49 491 111",
+ website: "https://tonnenhof.example"
+ });
+ expect(
+ report.bridges.filter((bridge) => bridge.name?.startsWith("Klappbrücke"))
+ ).toHaveLength(2);
+ expect(
+ report.bridges.filter((bridge) => bridge.name === null)
+ ).toHaveLength(3);
});
it("marks critical weather when wind or wave thresholds are exceeded", () => {
diff --git a/apps/web/tests/upcoming-events-panel.test.tsx b/apps/web/tests/upcoming-events-panel.test.tsx
index 010dfb0..77b0723 100644
--- a/apps/web/tests/upcoming-events-panel.test.tsx
+++ b/apps/web/tests/upcoming-events-panel.test.tsx
@@ -26,6 +26,7 @@ describe("UpcomingEventsPanel", () => {
expect.stringContaining("Brücke Mitte"),
expect.stringContaining("Hafen Weit")
]);
+ expect(within(list).getAllByText("Infos")).toHaveLength(3);
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute(
@@ -81,6 +82,12 @@ describe("UpcomingEventsPanel", () => {
expect(within(detail).getByText("3.8 m")).toBeVisible();
const reserve = within(detail).getByText("Reserve").closest("div");
expect(reserve).toHaveTextContent("0.4 m Reserve");
+ expect(
+ within(detail).getByRole("link", { name: "Brücke Mitte anrufen" })
+ ).toHaveAttribute("href", "tel:+4949123457");
+ expect(
+ within(detail).getByRole("link", { name: "Website von Brücke Mitte öffnen" })
+ ).toHaveAttribute("href", "https://bridge.example/");
expect(within(detail).queryByRole("button", { name: "Auf Karte zeigen" })).not.toBeInTheDocument();
});
@@ -180,7 +187,10 @@ const events: UpcomingRouteEvent[] = [
requiredAirDraftM: 3.8,
marginM: 0.4,
status: "tight",
- source: "Test"
+ source: "Test",
+ phone: "+49 49 123457",
+ website: "bridge.example",
+ operator: "Brückenamt"
}
}
];
diff --git a/deploy/.env.production.example b/deploy/.env.production.example
index 1cbc5aa..07596a9 100644
--- a/deploy/.env.production.example
+++ b/deploy/.env.production.example
@@ -17,6 +17,12 @@ WATERMAPS_RUNTIME_DIR=/srv/watermaps-runtime
# Verhindert Speicherabbrüche beim vollständigen DE/NL-Indexaufbau auf 4-GB-Servern.
WATERMAPS_SWAP_SIZE_GB=4
+# Persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten.
+# Vor dem ersten Deployment beispielsweise mit `openssl rand -hex 32`
+# erzeugen. Das Secret muss mindestens 24 Zeichen lang sein.
+WATERMAPS_POSTGRES_PASSWORD=REPLACE_WITH_RANDOM_32_BYTE_HEX_SECRET
+WATERMAPS_POSTGRES_IMAGE=postgis/postgis:16-3.4
+
# Gitea Container Registry. Die commitgenauen App-Referenzen werden beim
# Deployment separat erzeugt und niemals hier von Hand auf `latest` gesetzt.
WATERMAPS_REGISTRY=gitea.incoso.eu
@@ -29,6 +35,10 @@ WATERMAPS_CERTBOT_IMAGE=certbot/certbot:v5.7.0
# Niederlande erneut geprüft und der Routingindex neu gebaut werden sollen.
WATERMAPS_REBUILD_ROUTE_DATA=false
+# Erzwingt unabhängig vom Bereitschaftsmarker einen vollständigen Neuimport
+# der Marine-Features aus den vorhandenen Deutschland-/Niederlande-PBFs.
+WATERMAPS_REBUILD_MARINE_DATA=false
+
# Für einen Test gegen Let's Encrypt Staging auf true setzen.
# Das damit ausgestellte Zertifikat ist im Browser nicht vertrauenswürdig.
WATERMAPS_CERTBOT_STAGING=false
diff --git a/deploy/README.md b/deploy/README.md
index fc0ef81..7ce1dec 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -1,9 +1,10 @@
# Watermaps-Produktion
-Dieser Stack hostet die Watermaps-App und die lokalen Fahrrouten für
-Deutschland und die Niederlande. Die sichtbaren Kartenkacheln bleiben externe
-Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; die
-Watermaps-App ist nur im internen Docker-Netz erreichbar.
+Dieser Stack hostet die Watermaps-App, die lokalen Fahrrouten sowie eine
+persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten in
+Deutschland und den Niederlanden. Die sichtbaren Kartenkacheln bleiben externe
+Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; App und
+PostGIS sind nur im internen Docker-Netz erreichbar.
## Konfiguration
@@ -12,11 +13,19 @@ cp deploy/.env.production.example deploy/.env.production
editor deploy/.env.production
```
-Mindestens `WATERMAPS_ACME_EMAIL` muss angepasst werden. Außerdem müssen
-`WATERMAPS_REGISTRY` und `WATERMAPS_REGISTRY_OWNER` auf die Gitea Container
-Registry zeigen. Der Hetzner-API-Token gehört **nicht** in diese Datei. Er
-bleibt lokal in der ignorierten Datei `infra/opentofu/terraform.tfvars`
-(alternativ kann der Provider `TF_VAR_hcloud_token` lesen).
+Mindestens `WATERMAPS_ACME_EMAIL` und `WATERMAPS_POSTGRES_PASSWORD` müssen
+angepasst werden. Für das Datenbankpasswort eignet sich ein URL-unabhängiges
+Hex-Secret:
+
+```bash
+openssl rand -hex 32
+```
+
+Außerdem müssen `WATERMAPS_REGISTRY` und `WATERMAPS_REGISTRY_OWNER` auf die
+Gitea Container Registry zeigen. Der Hetzner-API-Token gehört **nicht** in
+diese Datei. Er bleibt lokal in der ignorierten Datei
+`infra/opentofu/terraform.tfvars` (alternativ kann der Provider
+`TF_VAR_hcloud_token` lesen).
Die private SSH-Keydatei wird ebenfalls nicht gespeichert. Sie kann beim
Deployment mit `--identity` oder über `WATERMAPS_SSH_KEY` angegeben werden.
@@ -53,8 +62,8 @@ werden nur Tests ausgeführt; Registry-Secrets werden dabei nicht verwendet.
Nach einem erfolgreichen Image-Build liest das Skript standardmäßig den
OpenTofu-Output `server_ipv4`. Es erlaubt ausschließlich einen sauberen,
vollständig committeten Git-Stand und überträgt nur den kleinen Ordner
-`deploy/`, nicht den Anwendungsquellcode. Die Image-Tags entsprechen exakt
-`git rev-parse HEAD`.
+`deploy/` sowie das kanonische `database/schema.sql`, nicht den
+Anwendungsquellcode. Die Image-Tags entsprechen exakt `git rev-parse HEAD`.
Ist die Registry privat, werden einmalig beziehungsweise nach Tokenwechsel
lokale Pull-Zugangsdaten mitgegeben:
@@ -78,20 +87,36 @@ Anmeldung gültig ist:
--identity ~/.ssh/watermaps_hetzner_ed25519
```
-Das Serverskript lädt App, Routingdaten-Builder, Nginx und Certbot mit
-`docker compose pull`. Die beiden commitgenauen Gitea-Tags werden anschließend
-in ihre unveränderlichen Registry-Digests aufgelöst. Erst danach werden die
-Container mit `--no-build` gestartet. Der Produktionsserver benötigt deshalb
-weder Git noch Node/npm oder den Quellcode.
+Das Serverskript lädt App, den kombinierten Routing-/Feature-Daten-Builder,
+PostGIS, Nginx und Certbot mit `docker compose pull`. Die beiden commitgenauen
+Gitea-Tags werden anschließend in ihre unveränderlichen Registry-Digests
+aufgelöst. Erst danach werden die Container mit `--no-build` gestartet. Der
+Produktionsserver benötigt deshalb weder Git noch Node/npm oder den Quellcode.
Der erste Datenaufbau lädt die Geofabrik-Extrakte für Deutschland und die
-Niederlande herunter und kann entsprechend der Serverleistung längere Zeit
-dauern. Der produktive Index liegt auf dem Server unter:
+Niederlande, baut den Fahrroutenindex und importiert daraus die
+`marine_features`. Je nach Serverleistung kann dies längere Zeit dauern.
+App und Nginx werden beim ersten Release erst gestartet, wenn sowohl der
+Routingindex als auch ein nicht leerer, zur PBF-Prüfsumme passender Bestand an
+Häfen, Schleusen und Brücken geprüft wurde. Ein HTTP-Healthcheck allein kann
+damit keine leere Ereignisdatenbank freigeben.
+
+Die produktiven Daten liegen auf dem persistenten Servervolume:
```text
/srv/watermaps-data/local/germany-netherlands-fairways.json
+/srv/watermaps-data/local/.marine-features.ready
+/srv/watermaps-data/postgres/
+/srv/watermaps-data/tmp/
```
+PostGIS veröffentlicht keinen Host-Port. Die App verbindet sich im privaten
+Compose-Netz; das Passwort wird separat über `PGPASSWORD` übergeben und muss
+nicht URL-kodiert in `DATABASE_URL` dupliziert werden. Auch die temporären,
+potenziell mehrere Gigabyte großen Filter- und GeoJSON-Dateien des Imports
+liegen unter `tmp/` auf diesem Volume und nicht im Docker-Overlay der
+Rootdisk; nach jedem Importlauf werden sie entfernt.
+
Der Upload wartet zuerst auf SSH und den Abschluss von Cloud-init. Anschließend
startet und prüft er `watermaps-volume-setup.service`. Ohne tatsächlich unter
`/srv/watermaps-data` eingehängtes Volume wird kein Download gestartet, damit
@@ -108,7 +133,10 @@ HTTP-Anfragen erhalten 404.
Jedes erfolgreiche Release wird mit Commit-SHA und den aufgelösten
Image-Digests unter `/srv/watermaps-runtime/deployments/` gespeichert. Scheitert
ein Deployment nach dem Containerwechsel, startet `deploy.sh` automatisch das
-vorherige Release und führt die Health- und Routentests erneut aus.
+vorherige Release und führt die Health-, Routen- und Featuretests erneut aus.
+Ein Rollback wechselt nur die unveränderlichen App-/Builder-Images. Die
+persistente PostGIS-Datenbank bleibt erhalten; das Schema und der Import sind
+aufwärtskompatibel und idempotent.
Das unmittelbar vorherige Release lässt sich auch manuell aktivieren:
@@ -153,11 +181,26 @@ HTTPS um.
`bootstrap-server.sh` installiert zwei systemd-Timer:
- `watermaps-route-update.timer`: täglich neue Deutschland- und
- Niederlande-Daten; bei Build- oder Routentestfehler bleibt der vorherige
- Index aktiv.
+ Niederlande-Daten. Der Fahrroutenindex wird atomar aktiviert. Der
+ Feature-Importer aktualisiert beide Länder per Upsert und entfernt
+ verschwundene Datensätze erst, nachdem beide Importe vollständig waren;
+ dabei werden ausschließlich OSM-Zeilen bereinigt. EuRIS- und
+ Website-Anreicherungen bleiben erhalten.
- `watermaps-certbot-renew.timer`: zweimal täglich Certbot-Prüfung mit
anschließendem Nginx-Reload.
+Ein vollständiger manueller Feature-Neuimport lässt sich über die
+Option `--force-marine` erzwingen:
+
+```bash
+/opt/watermaps/deploy/scripts/update-route-data.sh --force-marine
+```
+
+`WATERMAPS_REBUILD_MARINE_DATA=true` in `.env.production` erzwingt den
+Neuimport stattdessen beim nächsten regulären Deployment. Der systemd-Dienst
+hat bewusst kein Startzeitlimit, da Download, Indexbau und Import auf kleineren
+Servern deutlich länger als 90 Sekunden dauern können.
+
Status und Logs:
```bash
diff --git a/deploy/compose.production.yml b/deploy/compose.production.yml
index fc06006..67f4d9b 100644
--- a/deploy/compose.production.yml
+++ b/deploy/compose.production.yml
@@ -7,7 +7,8 @@ services:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 5174
- DATABASE_URL: ""
+ DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
+ PGPASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt}
REDIS_URL: ""
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
@@ -20,6 +21,9 @@ services:
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/local
target: /data
read_only: true
+ depends_on:
+ postgres:
+ condition: service_healthy
restart: unless-stopped
init: true
security_opt:
@@ -32,7 +36,7 @@ services:
- node
- -e
- >-
- fetch('http://127.0.0.1:5174/health')
+ fetch('http://127.0.0.1:5174/ready')
.then(response => { if (!response.ok) process.exit(1) })
.catch(() => process.exit(1))
interval: 15s
@@ -40,6 +44,38 @@ services:
start_period: 15s
retries: 5
+ postgres:
+ image: ${WATERMAPS_POSTGRES_IMAGE:-postgis/postgis:16-3.4}
+ environment:
+ POSTGRES_DB: seacompass
+ POSTGRES_USER: seacompass
+ POSTGRES_PASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt}
+ expose:
+ - "5432"
+ volumes:
+ - type: bind
+ source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/postgres
+ target: /var/lib/postgresql/data
+ bind:
+ create_host_path: false
+ - type: bind
+ source: ./database/schema.sql
+ target: /docker-entrypoint-initdb.d/01-schema.sql
+ read_only: true
+ restart: unless-stopped
+ init: true
+ security_opt:
+ - no-new-privileges:true
+ healthcheck:
+ test:
+ - CMD-SHELL
+ - pg_isready --username "$$POSTGRES_USER" --dbname "$$POSTGRES_DB"
+ interval: 10s
+ timeout: 5s
+ start_period: 30s
+ retries: 10
+ stop_grace_period: 60s
+
nginx:
image: ${WATERMAPS_NGINX_IMAGE:-nginx:1.30.4-alpine}
depends_on:
@@ -92,3 +128,22 @@ services:
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}
target: /workspace/data
restart: "no"
+
+ marine-data:
+ image: ${WATERMAPS_ROUTE_DATA_IMAGE:?WATERMAPS_ROUTE_DATA_IMAGE fehlt}
+ profiles: ["maintenance"]
+ entrypoint: ["/workspace/deploy/scripts/prepare-marine-features.sh"]
+ environment:
+ DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
+ PGPASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt}
+ TMPDIR: /workspace/data/tmp
+ WATERMAPS_GEOFABRIK_DIR: /workspace/data/geofabrik
+ WATERMAPS_MARINE_MARKER_PATH: /workspace/data/local/.marine-features.ready
+ volumes:
+ - type: bind
+ source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}
+ target: /workspace/data
+ depends_on:
+ postgres:
+ condition: service_healthy
+ restart: "no"
diff --git a/deploy/route-data.Dockerfile b/deploy/route-data.Dockerfile
index 441b3e3..4f9d1b9 100644
--- a/deploy/route-data.Dockerfile
+++ b/deploy/route-data.Dockerfile
@@ -1,13 +1,30 @@
-FROM python:3.12-slim
+FROM node:22-bookworm-slim
RUN apt-get update \
- && apt-get install --yes --no-install-recommends ca-certificates curl coreutils libexpat1 \
- && rm -rf /var/lib/apt/lists/* \
- && python3 -m pip install --no-cache-dir "osmium==4.3.1"
+ && apt-get install --yes --no-install-recommends \
+ ca-certificates \
+ coreutils \
+ curl \
+ libexpat1 \
+ osmium-tool \
+ postgresql-client \
+ python3 \
+ python3-pip \
+ && rm -rf /var/lib/apt/lists/*
WORKDIR /workspace
+RUN python3 -m pip install \
+ --break-system-packages \
+ --disable-pip-version-check \
+ --no-cache-dir \
+ --target /workspace/.tools/python \
+ "osmium==4.3.1" \
+ && npm install --no-package-lock --no-save "pg@8.16.3"
+
COPY scripts ./scripts
+COPY database ./database
COPY deploy/scripts/prepare-route-data.sh ./deploy/scripts/prepare-route-data.sh
+COPY deploy/scripts/prepare-marine-features.sh ./deploy/scripts/prepare-marine-features.sh
ENTRYPOINT ["/workspace/deploy/scripts/prepare-route-data.sh"]
diff --git a/deploy/scripts/bootstrap-server.sh b/deploy/scripts/bootstrap-server.sh
index 02f3af2..6bcf7e8 100755
--- a/deploy/scripts/bootstrap-server.sh
+++ b/deploy/scripts/bootstrap-server.sh
@@ -87,6 +87,8 @@ install -d -m 0755 \
"$WATERMAPS_DATA_DIR" \
"$WATERMAPS_DATA_DIR/geofabrik" \
"$WATERMAPS_DATA_DIR/local" \
+ "$WATERMAPS_DATA_DIR/postgres" \
+ "$WATERMAPS_DATA_DIR/tmp" \
"$WATERMAPS_DATA_DIR/certbot" \
"$WATERMAPS_DATA_DIR/certbot/www" \
"$WATERMAPS_DATA_DIR/certbot/letsencrypt" \
@@ -95,6 +97,12 @@ install -d -m 0755 \
"$WATERMAPS_RUNTIME_DIR/nginx/conf.d" \
"$WATERMAPS_RUNTIME_DIR/locks"
+# postgis/postgis uses the Debian postgres UID/GID 999. Keeping PGDATA on the
+# mounted Hetzner volume makes event data survive image deployments and host
+# reboots; the official entrypoint can still repair ownership inside PGDATA.
+chown 999:999 "$WATERMAPS_DATA_DIR/postgres"
+chmod 0700 "$WATERMAPS_DATA_DIR/postgres"
+
if [[ ! -f "$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf" ]]; then
install -m 0644 \
"$WM_DEPLOY_DIR/nginx/bootstrap.conf" \
diff --git a/deploy/scripts/common.sh b/deploy/scripts/common.sh
index b4dde98..12b6fef 100755
--- a/deploy/scripts/common.sh
+++ b/deploy/scripts/common.sh
@@ -33,6 +33,16 @@ wm_load_env() {
wm_require_safe_absolute_dir "$WATERMAPS_DATA_DIR"
wm_require_safe_absolute_dir "$WATERMAPS_RUNTIME_DIR"
+ wm_validate_postgres_password
+}
+
+wm_validate_postgres_password() {
+ local password="${WATERMAPS_POSTGRES_PASSWORD:-}"
+
+ [[ "${#password}" -ge 24 &&
+ "$password" != *[[:space:]]* &&
+ "$password" != *REPLACE* ]] ||
+ wm_die "WATERMAPS_POSTGRES_PASSWORD muss ein gesetztes Secret mit mindestens 24 Zeichen ohne Leerzeichen sein."
}
wm_require_safe_absolute_dir() {
@@ -162,6 +172,10 @@ wm_route_marker() {
printf '%s/local/.germany-netherlands-fairways.ready\n' "$WATERMAPS_DATA_DIR"
}
+wm_marine_marker() {
+ printf '%s/local/.marine-features.ready\n' "$WATERMAPS_DATA_DIR"
+}
+
wm_acquire_route_lock() {
install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks"
exec 9>"$WATERMAPS_RUNTIME_DIR/locks/route-update.lock"
@@ -288,6 +302,107 @@ wm_route_data_files_ready() {
return 0
}
+wm_geofabrik_checksum() {
+ local region="$1"
+ local checksum_file="$WATERMAPS_DATA_DIR/geofabrik/${region}-latest.osm.pbf.md5"
+ local checksum
+
+ [[ -s "$checksum_file" ]] || return 1
+ checksum="$(awk 'NR == 1 { print tolower($1) }' "$checksum_file")"
+ [[ "$checksum" =~ ^[0-9a-f]{32}$ ]] || return 1
+ printf '%s\n' "$checksum"
+}
+
+wm_postgres_query() {
+ local query="$1"
+
+ wm_compose exec --no-TTY postgres \
+ psql \
+ --no-psqlrc \
+ --username seacompass \
+ --dbname seacompass \
+ --tuples-only \
+ --no-align \
+ --field-separator '|' \
+ --set ON_ERROR_STOP=1 \
+ --command "$query"
+}
+
+wm_marine_features_ready() {
+ local marker germany_checksum netherlands_checksum database_counts
+ local total_count harbour_count lock_count bridge_count
+ local marker_format marker_source marker_germany marker_netherlands
+ local marker_total marker_harbours marker_locks marker_bridges
+
+ marker="$(wm_marine_marker)"
+ if [[ ! -s "$marker" ]]; then
+ wm_log "Bereitschaftsmarker für Marine-Features fehlt: $marker"
+ return 1
+ fi
+
+ germany_checksum="$(wm_geofabrik_checksum germany 2>/dev/null || true)"
+ netherlands_checksum="$(wm_geofabrik_checksum netherlands 2>/dev/null || true)"
+ if [[ -z "$germany_checksum" || -z "$netherlands_checksum" ]]; then
+ wm_log "Geofabrik-Prüfsummen für den Marine-Feature-Stand fehlen oder sind ungültig."
+ return 1
+ fi
+
+ marker_format="$(wm_marker_value "$marker" format_version 2>/dev/null || true)"
+ marker_source="$(wm_marker_value "$marker" source 2>/dev/null || true)"
+ marker_germany="$(wm_marker_value "$marker" germany_md5 2>/dev/null || true)"
+ marker_netherlands="$(wm_marker_value "$marker" netherlands_md5 2>/dev/null || true)"
+ marker_total="$(wm_marker_value "$marker" total_osm_features 2>/dev/null || true)"
+ marker_harbours="$(wm_marker_value "$marker" harbour_count 2>/dev/null || true)"
+ marker_locks="$(wm_marker_value "$marker" lock_count 2>/dev/null || true)"
+ marker_bridges="$(wm_marker_value "$marker" bridge_count 2>/dev/null || true)"
+
+ if [[ "$marker_format" != "1" ||
+ "$marker_source" != "germany+netherlands" ||
+ "$marker_germany" != "$germany_checksum" ||
+ "$marker_netherlands" != "$netherlands_checksum" ]]; then
+ wm_log "Marine-Feature-Marker passt nicht zu den aktuellen Deutschland-/Niederlande-Snapshots."
+ return 1
+ fi
+
+ if ! database_counts="$(
+ wm_postgres_query "
+ SELECT
+ count(*)::bigint,
+ count(*) FILTER (WHERE layer = 'harbours')::bigint,
+ count(*) FILTER (WHERE layer = 'locks')::bigint,
+ count(*) FILTER (WHERE layer = 'bridges')::bigint
+ FROM marine_features
+ WHERE source = 'osm';
+ "
+ )"; then
+ wm_log "Marine-Feature-Tabellen sind in PostGIS nicht abfragbar."
+ return 1
+ fi
+ database_counts="${database_counts//[[:space:]]/}"
+ IFS='|' read -r total_count harbour_count lock_count bridge_count <<<"$database_counts"
+
+ for count in "$total_count" "$harbour_count" "$lock_count" "$bridge_count"; do
+ if [[ ! "$count" =~ ^[1-9][0-9]*$ ]]; then
+ wm_log "PostGIS enthält keine vollständigen OSM-Hafen-/Schleusen-/Brückendaten."
+ return 1
+ fi
+ done
+
+ if [[ "$marker_total" != "$total_count" ||
+ "$marker_harbours" != "$harbour_count" ||
+ "$marker_locks" != "$lock_count" ||
+ "$marker_bridges" != "$bridge_count" ]]; then
+ wm_log "Marine-Feature-Marker stimmt nicht mit den OSM-Zeilen in PostGIS überein."
+ return 1
+ fi
+ return 0
+}
+
+wm_assert_marine_features() {
+ wm_marine_features_ready ||
+ wm_die "Marine-Features sind nicht vollständig in PostGIS bereit."
+}
+
wm_wait_for_health() {
local service="$1"
local timeout_seconds="${2:-180}"
@@ -479,3 +594,44 @@ wm_smoke_test_route() {
}
'
}
+
+wm_smoke_test_features() {
+ wm_compose exec --no-TTY watermaps node --input-type=module --eval '
+ const params = new URLSearchParams({
+ bbox: "7.118483884871649,53.302596677614666,7.543960668999219,53.50669706666667",
+ layers: "harbours,locks,bridges"
+ });
+ const response = await fetch(`http://127.0.0.1:5174/api/features?${params}`);
+ if (!response.ok) {
+ console.error("Feature-Smoke-Test", response.status, await response.text());
+ process.exit(1);
+ }
+
+ const collection = await response.json();
+ const features = Array.isArray(collection.features) ? collection.features : [];
+ const layers = new Set(features.map((feature) => feature?.properties?.layer));
+ const contactFeature = features.find((feature) =>
+ ["harbours", "locks"].includes(feature?.properties?.layer)
+ && typeof feature?.properties?.phone === "string"
+ && feature.properties.phone.trim().length > 0
+ );
+ if (
+ collection?.metadata?.source !== "postgis"
+ || !layers.has("harbours")
+ || !layers.has("locks")
+ || !layers.has("bridges")
+ || !contactFeature
+ ) {
+ console.error(
+ "PostGIS-Feature-Prüfung für die Emden–Aurich-Teststrecke fehlgeschlagen.",
+ JSON.stringify({
+ source: collection?.metadata?.source,
+ featureCount: features.length,
+ layers: [...layers],
+ hasCallableHarbourOrLock: Boolean(contactFeature)
+ })
+ );
+ process.exit(1);
+ }
+ '
+}
diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh
index 833ea0e..bb89815 100755
--- a/deploy/scripts/deploy.sh
+++ b/deploy/scripts/deploy.sh
@@ -69,12 +69,16 @@ wm_finish_deployment() {
if [[ -n "$rollback_images_file" && -f "$rollback_images_file" ]]; then
wm_log "Deployment fehlgeschlagen; vorheriges Container-Release wird wiederhergestellt."
wm_use_images_env "$rollback_images_file"
- wm_compose up --detach --no-build --remove-orphans watermaps nginx
+ wm_compose up --detach --no-build --remove-orphans postgres watermaps nginx
+ wm_wait_for_health postgres 240
wm_wait_for_health watermaps 240
wm_wait_for_health nginx 120
if wm_route_data_ready; then
wm_smoke_test_route
fi
+ if wm_marine_features_ready; then
+ wm_smoke_test_features
+ fi
else
wm_log "Deployment fehlgeschlagen; für das erste Release existiert noch kein Rollback."
fi
@@ -99,7 +103,7 @@ requested_app_image="$(wm_env_value "$images_env_file" WATERMAPS_APP_IMAGE)"
requested_route_data_image="$(wm_env_value "$images_env_file" WATERMAPS_ROUTE_DATA_IMAGE)"
wm_log "Container-Images für Commit $revision werden aus den Registries geladen."
-wm_compose --profile maintenance pull watermaps nginx certbot route-data
+wm_compose --profile maintenance pull watermaps postgres nginx certbot route-data
resolved_app_image="$(wm_resolve_image_digest "$requested_app_image")"
resolved_route_data_image="$(wm_resolve_image_digest "$requested_route_data_image")"
@@ -114,30 +118,42 @@ resolved_images_file="$(
chmod 0644 "$resolved_images_file"
wm_use_images_env "$resolved_images_file"
-wm_log "Commit $revision wird mit unveränderlichen Image-Digests gestartet."
-wm_compose up --detach --no-build --remove-orphans watermaps nginx
+wm_log "Persistentes PostGIS wird vor App und Datenimport gestartet."
+wm_compose up --detach --no-build postgres
+wm_wait_for_health postgres 240
-wm_wait_for_health watermaps 240
-wm_wait_for_health nginx 120
-
-route_rebuild_required=false
+data_update_required=false
update_args=()
if [[ "${WATERMAPS_REBUILD_ROUTE_DATA:-false}" == "true" ]]; then
- route_rebuild_required=true
+ data_update_required=true
update_args+=(--force)
elif ! wm_route_data_ready; then
- route_rebuild_required=true
+ data_update_required=true
+fi
+if [[ "${WATERMAPS_REBUILD_MARINE_DATA:-false}" == "true" ]]; then
+ data_update_required=true
+ update_args+=(--force-marine)
+elif ! wm_marine_features_ready; then
+ data_update_required=true
fi
-if [[ "$route_rebuild_required" == "true" ]]; then
- wm_log "Deutschland- und Niederlande-Routingdaten werden sicher vorbereitet."
+if [[ "$data_update_required" == "true" ]]; then
+ wm_log "Deutschland-/Niederlande-Routingdaten und Marine-Features werden sicher vorbereitet."
WATERMAPS_ROUTE_LOCK_HELD=true \
"$WM_DEPLOY_DIR/scripts/update-route-data.sh" "${update_args[@]}"
fi
wm_assert_route_data
+wm_assert_marine_features
+
+wm_log "Commit $revision wird erst mit validierten Routing- und Ereignisdaten gestartet."
+wm_compose up --detach --no-build --remove-orphans postgres watermaps nginx
+
+wm_wait_for_health postgres 240
wm_wait_for_health watermaps 240
+wm_wait_for_health nginx 120
wm_smoke_test_route
+wm_smoke_test_features
release_images_file="$WATERMAPS_RUNTIME_DIR/deployments/$revision.env"
install -m 0644 "$resolved_images_file" "$release_images_file"
diff --git a/deploy/scripts/prepare-marine-features.sh b/deploy/scripts/prepare-marine-features.sh
new file mode 100755
index 0000000..d9e4e46
--- /dev/null
+++ b/deploy/scripts/prepare-marine-features.sh
@@ -0,0 +1,158 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+cd /workspace
+
+database_url="${DATABASE_URL:-}"
+geofabrik_dir="${WATERMAPS_GEOFABRIK_DIR:-/workspace/data/geofabrik}"
+marker_file="${WATERMAPS_MARINE_MARKER_PATH:-/workspace/data/local/.marine-features.ready}"
+temporary_root="${TMPDIR:-/tmp}"
+pbf_paths=(
+ "$geofabrik_dir/germany-latest.osm.pbf"
+ "$geofabrik_dir/netherlands-latest.osm.pbf"
+)
+
+if [[ -z "$database_url" ]]; then
+ printf 'Fehler: DATABASE_URL ist für den Marine-Feature-Import erforderlich.\n' >&2
+ exit 1
+fi
+
+if [[ ! -x /workspace/scripts/import-geofabrik.sh ]]; then
+ printf 'Fehler: scripts/import-geofabrik.sh fehlt oder ist nicht ausführbar.\n' >&2
+ exit 1
+fi
+
+mkdir -p "$(dirname "$marker_file")" "$temporary_root"
+export TMPDIR="$temporary_root"
+
+declare -A source_checksums
+for pbf_path in "${pbf_paths[@]}"; do
+ if [[ ! -s "$pbf_path" ]]; then
+ printf 'Fehler: Geofabrik-Snapshot fehlt oder ist leer: %s\n' "$pbf_path" >&2
+ exit 1
+ fi
+
+ checksum_file="${pbf_path}.md5"
+ if [[ ! -s "$checksum_file" ]]; then
+ printf 'Fehler: Geofabrik-Prüfsumme fehlt: %s\n' "$checksum_file" >&2
+ exit 1
+ fi
+
+ expected_checksum="$(awk 'NR == 1 { print tolower($1) }' "$checksum_file")"
+ if [[ ! "$expected_checksum" =~ ^[0-9a-f]{32}$ ]]; then
+ printf 'Fehler: Ungültige Geofabrik-Prüfsumme in %s\n' "$checksum_file" >&2
+ exit 1
+ fi
+
+ actual_checksum="$(md5sum "$pbf_path" | awk '{ print $1 }')"
+ if [[ "$actual_checksum" != "$expected_checksum" ]]; then
+ printf 'Fehler: Geofabrik-Snapshot stimmt nicht mit seiner Prüfsumme überein: %s\n' "$pbf_path" >&2
+ exit 1
+ fi
+ source_checksums["$(basename "$pbf_path" -latest.osm.pbf)"]="$actual_checksum"
+done
+
+# Every imported OSM row receives updated_at=now(). The shared database
+# timestamp lets us remove disappeared OSM objects only after both regional
+# imports completed and passed the sanity checks. Other sources such as EuRIS
+# and facility-website enrichments are deliberately preserved.
+import_started_epoch="$(
+ psql "$database_url" \
+ --no-psqlrc \
+ --tuples-only \
+ --no-align \
+ --set ON_ERROR_STOP=1 \
+ --command "SELECT extract(epoch FROM clock_timestamp());"
+)"
+import_started_epoch="${import_started_epoch//[[:space:]]/}"
+if [[ ! "$import_started_epoch" =~ ^[0-9]+([.][0-9]+)?$ ]]; then
+ printf 'Fehler: Datenbank lieferte keinen gültigen Importzeitpunkt.\n' >&2
+ exit 1
+fi
+
+for pbf_path in "${pbf_paths[@]}"; do
+ printf 'Importiere Marine-Features aus %s\n' "$(basename "$pbf_path")"
+ DATABASE_URL="$database_url" /workspace/scripts/import-geofabrik.sh "$pbf_path"
+done
+
+fresh_counts="$(
+ psql "$database_url" \
+ --no-psqlrc \
+ --tuples-only \
+ --no-align \
+ --field-separator '|' \
+ --set ON_ERROR_STOP=1 \
+ --command "
+ SELECT
+ count(*)::bigint,
+ count(*) FILTER (WHERE layer = 'harbours')::bigint,
+ count(*) FILTER (WHERE layer = 'locks')::bigint,
+ count(*) FILTER (WHERE layer = 'bridges')::bigint
+ FROM marine_features
+ WHERE source = 'osm'
+ AND updated_at >= to_timestamp($import_started_epoch);
+ "
+)"
+fresh_counts="${fresh_counts//[[:space:]]/}"
+IFS='|' read -r fresh_total fresh_harbours fresh_locks fresh_bridges <<<"$fresh_counts"
+for count in "$fresh_total" "$fresh_harbours" "$fresh_locks" "$fresh_bridges"; do
+ if [[ ! "$count" =~ ^[1-9][0-9]*$ ]]; then
+ printf 'Fehler: Der neue OSM-Import enthält keine vollständigen Hafen-/Schleusen-/Brückendaten.\n' >&2
+ exit 1
+ fi
+done
+
+psql "$database_url" \
+ --no-psqlrc \
+ --set ON_ERROR_STOP=1 \
+ --command "
+ BEGIN;
+ DELETE FROM marine_features
+ WHERE source = 'osm'
+ AND updated_at < to_timestamp($import_started_epoch);
+ DELETE FROM marine_fairway_edges
+ WHERE source = 'osm'
+ AND updated_at < to_timestamp($import_started_epoch);
+ COMMIT;
+ ANALYZE marine_features;
+ ANALYZE marine_fairway_edges;
+ "
+
+final_counts="$(
+ psql "$database_url" \
+ --no-psqlrc \
+ --tuples-only \
+ --no-align \
+ --field-separator '|' \
+ --set ON_ERROR_STOP=1 \
+ --command "
+ SELECT
+ count(*)::bigint,
+ count(*) FILTER (WHERE layer = 'harbours')::bigint,
+ count(*) FILTER (WHERE layer = 'locks')::bigint,
+ count(*) FILTER (WHERE layer = 'bridges')::bigint
+ FROM marine_features
+ WHERE source = 'osm';
+ "
+)"
+final_counts="${final_counts//[[:space:]]/}"
+IFS='|' read -r total_osm_features harbour_count lock_count bridge_count <<<"$final_counts"
+
+temporary_marker="$(mktemp "$(dirname "$marker_file")/.marine-features-ready.XXXXXX")"
+{
+ printf 'format_version=1\n'
+ printf 'generated_at=%s\n' "$(date --utc +%Y-%m-%dT%H:%M:%SZ)"
+ printf 'source=germany+netherlands\n'
+ printf 'germany_md5=%s\n' "${source_checksums[germany]}"
+ printf 'netherlands_md5=%s\n' "${source_checksums[netherlands]}"
+ printf 'total_osm_features=%s\n' "$total_osm_features"
+ printf 'harbour_count=%s\n' "$harbour_count"
+ printf 'lock_count=%s\n' "$lock_count"
+ printf 'bridge_count=%s\n' "$bridge_count"
+} >"$temporary_marker"
+chmod 0644 "$temporary_marker"
+mv -f "$temporary_marker" "$marker_file"
+
+printf 'Marine-Features sind bereit: %s OSM-Objekte (%s Häfen, %s Schleusen, %s Brücken)\n' \
+ "$total_osm_features" "$harbour_count" "$lock_count" "$bridge_count"
diff --git a/deploy/scripts/tests/route-data-helpers.test.sh b/deploy/scripts/tests/route-data-helpers.test.sh
index e573d0e..2e00d70 100755
--- a/deploy/scripts/tests/route-data-helpers.test.sh
+++ b/deploy/scripts/tests/route-data-helpers.test.sh
@@ -71,4 +71,56 @@ if wm_route_data_ready; then
exit 1
fi
-printf 'Routingdaten- und Markerprüfungen: OK\n'
+mkdir -p "$WATERMAPS_DATA_DIR/geofabrik"
+printf '%s germany-latest.osm.pbf\n' \
+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
+ >"$WATERMAPS_DATA_DIR/geofabrik/germany-latest.osm.pbf.md5"
+printf '%s netherlands-latest.osm.pbf\n' \
+ 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' \
+ >"$WATERMAPS_DATA_DIR/geofabrik/netherlands-latest.osm.pbf.md5"
+
+cat >"$(wm_marine_marker)" <<'MARKER'
+format_version=1
+generated_at=2026-07-28T00:00:00Z
+source=germany+netherlands
+germany_md5=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+netherlands_md5=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
+total_osm_features=100
+harbour_count=10
+lock_count=5
+bridge_count=20
+MARKER
+
+wm_postgres_query() {
+ printf '100|10|5|20\n'
+}
+wm_marine_features_ready
+
+wm_postgres_query() {
+ printf '100|10|0|20\n'
+}
+if wm_marine_features_ready; then
+ printf 'Marine-Feature-Marker wurde trotz fehlender Schleusen akzeptiert.\n' >&2
+ exit 1
+fi
+
+wm_postgres_query() {
+ printf '99|10|5|20\n'
+}
+if wm_marine_features_ready; then
+ printf 'Marine-Feature-Marker wurde trotz abweichendem Datenbankbestand akzeptiert.\n' >&2
+ exit 1
+fi
+
+if grep -Eq 'DATABASE_URL:[[:space:]]*""' \
+ "$ROOT_DIR/docker-compose.yml" \
+ "$ROOT_DIR/deploy/compose.production.yml"; then
+ printf 'Compose trennt die App weiterhin explizit von PostGIS.\n' >&2
+ exit 1
+fi
+grep -Fq 'condition: service_healthy' "$ROOT_DIR/deploy/compose.production.yml"
+grep -Fq '/ready' "$ROOT_DIR/deploy/compose.production.yml"
+grep -Fq 'source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/postgres' \
+ "$ROOT_DIR/deploy/compose.production.yml"
+
+printf 'Routingdaten-, Marine-Feature- und Compose-Prüfungen: OK\n'
diff --git a/deploy/scripts/tests/update-route-data.test.sh b/deploy/scripts/tests/update-route-data.test.sh
index dd74b2e..0409cfb 100755
--- a/deploy/scripts/tests/update-route-data.test.sh
+++ b/deploy/scripts/tests/update-route-data.test.sh
@@ -16,6 +16,7 @@ mkdir -p "$FAKE_BIN" "$DATA_DIR/local" "$RUNTIME_DIR"
cat >"$ENV_FILE" <"$IMAGES_ENV_FILE" <<'EOF'
@@ -48,6 +49,11 @@ if [[ "${1:-}" == "inspect" ]]; then
fi
arguments=" $* "
+if [[ "$arguments" == *" ps --quiet postgres "* ]]; then
+ printf 'postgres-test-container\n'
+ exit 0
+fi
+
if [[ "$arguments" == *" ps --quiet watermaps "* ]]; then
printf 'watermaps-test-container\n'
exit 0
@@ -55,6 +61,13 @@ fi
if [[ "$arguments" == *" run "*" route-data "* ||
"$arguments" == *" run "*" route-data" ]]; then
+ mkdir -p "$WATERMAPS_DATA_DIR/geofabrik"
+ printf '%s germany-latest.osm.pbf\n' \
+ 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
+ >"$WATERMAPS_DATA_DIR/geofabrik/germany-latest.osm.pbf.md5"
+ printf '%s netherlands-latest.osm.pbf\n' \
+ 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' \
+ >"$WATERMAPS_DATA_DIR/geofabrik/netherlands-latest.osm.pbf.md5"
route_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.json"
marker_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.ready"
cat >"$route_file" <"$WATERMAPS_DATA_DIR/local/.marine-features.ready" <<'MARKER'
+format_version=1
+generated_at=2026-07-28T00:00:00Z
+source=germany+netherlands
+germany_md5=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
+netherlands_md5=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
+total_osm_features=100
+harbour_count=10
+lock_count=5
+bridge_count=20
+MARKER
+ exit 0
+fi
+
+if [[ "$arguments" == *" exec --no-TTY postgres "* ]]; then
+ printf '100|10|5|20\n'
+ exit 0
+fi
+
if [[ "$arguments" == *" exec --no-TTY watermaps "* ]]; then
active_file="$WATERMAPS_DATA_DIR/local/germany-netherlands-fairways.json"
active_revision="$(jq --raw-output '.revision // 0' "$active_file" 2>/dev/null || true)"
diff --git a/deploy/scripts/update-route-data.sh b/deploy/scripts/update-route-data.sh
index 74dda20..566465d 100755
--- a/deploy/scripts/update-route-data.sh
+++ b/deploy/scripts/update-route-data.sh
@@ -13,11 +13,22 @@ if [[ "$(id -u)" -ne 0 ]]; then
fi
force_rebuild=false
-if [[ "${1:-}" == "--force" ]]; then
- force_rebuild=true
- shift
-fi
-[[ "$#" -eq 0 ]] || wm_die "Unbekannte Argumente für update-route-data.sh."
+force_marine_import=false
+while [[ "$#" -gt 0 ]]; do
+ case "$1" in
+ --force)
+ force_rebuild=true
+ shift
+ ;;
+ --force-marine)
+ force_marine_import=true
+ shift
+ ;;
+ *)
+ wm_die "Unbekanntes Argument für update-route-data.sh: $1"
+ ;;
+ esac
+done
install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks" "$WATERMAPS_DATA_DIR/local"
if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then
@@ -26,6 +37,9 @@ if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then
fi
fi
+wm_compose up --detach --no-build postgres
+wm_wait_for_health postgres 240
+
route_file="$(wm_route_file)"
route_marker="$(wm_route_marker)"
route_dir="$(dirname "$route_file")"
@@ -58,6 +72,16 @@ wm_watermaps_is_running() {
[[ -n "$(wm_compose ps --quiet watermaps)" ]]
}
+wm_update_marine_features() {
+ if [[ "$force_marine_import" == "true" ]] || ! wm_marine_features_ready; then
+ wm_log "Häfen, Schleusen und Brücken werden aus den verifizierten Geofabrik-Snapshots importiert."
+ wm_compose --profile maintenance run --rm marine-data
+ else
+ wm_log "Marine-Features in PostGIS sind bereits aktuell."
+ fi
+ wm_assert_marine_features
+}
+
wm_restore_previous_route() {
rollback_restored_previous=false
if [[ -s "$backup_file" && -s "$backup_marker" ]] &&
@@ -78,8 +102,8 @@ wm_restore_previous_route() {
wm_restart_after_restore() {
if ! wm_watermaps_is_running; then
- wm_log "Watermaps läuft nicht; wiederhergestellte Routingdaten können noch nicht getestet werden."
- return 1
+ wm_log "Watermaps läuft noch nicht; wiederhergestellte Routingdaten werden beim App-Start getestet."
+ return 0
fi
wm_compose restart watermaps
@@ -156,8 +180,10 @@ if ! wm_route_data_files_ready "$candidate_file" "$candidate_marker"; then
wm_die "Der neu gebaute Routingindex oder sein Bereitschaftsmarker ist ungültig."
fi
+wm_update_marine_features
+
if [[ "$previous_ready" == "true" ]] && cmp --silent "$candidate_file" "$route_file"; then
- wm_log "Geofabrik-Snapshots und Routingindex sind unverändert."
+ wm_log "Geofabrik-Snapshots, Routingindex und Marine-Features sind aktuell."
rm -f "$candidate_file" "$candidate_marker"
exit 0
fi
@@ -186,8 +212,18 @@ wm_write_route_marker "$route_file" "$route_marker"
wm_route_data_ready ||
wm_die "Der aktivierte Routingindex besitzt keinen gültigen Bereitschaftsmarker."
-wm_compose restart watermaps
-if wm_wait_for_health watermaps 240 && wm_smoke_test_route; then
+activation_valid=false
+if wm_watermaps_is_running; then
+ wm_compose restart watermaps
+ if wm_wait_for_health watermaps 240 && wm_smoke_test_route; then
+ activation_valid=true
+ fi
+else
+ wm_log "Watermaps läuft noch nicht; Routing- und Feature-API werden beim App-Start getestet."
+ activation_valid=true
+fi
+
+if [[ "$activation_valid" == "true" ]]; then
rm -f "$transaction_file"
transaction_active=false
rm -f "$backup_file" "$backup_marker"
diff --git a/deploy/scripts/upload-and-deploy.sh b/deploy/scripts/upload-and-deploy.sh
index 5d4c9dc..3b41715 100755
--- a/deploy/scripts/upload-and-deploy.sh
+++ b/deploy/scripts/upload-and-deploy.sh
@@ -124,9 +124,9 @@ printf '[watermaps] Warte auf Cloud-init und das persistente Hetzner-Volume.\n'
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
"cloud-init status --wait && ${remote_prefix}systemctl start watermaps-volume-setup.service && mountpoint --quiet /srv/watermaps-data"
-printf '[watermaps] Übertrage Deployment-Dateien nach %s:/opt/watermaps/deploy\n' "$WM_SSH_TARGET"
+printf '[watermaps] Übertrage Deployment-Dateien und Datenbankschema nach %s:/opt/watermaps\n' "$WM_SSH_TARGET"
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
- "${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy"
+ "${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy /opt/watermaps/database"
rsync \
--archive \
@@ -140,6 +140,14 @@ rsync \
"$WM_DEPLOY_DIR/" \
"$WM_SSH_TARGET:/opt/watermaps/deploy/"
+rsync \
+ --archive \
+ --chmod=F644 \
+ --rsync-path="$rsync_path" \
+ -e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
+ "$WM_LOCAL_ROOT_DIR/database/schema.sql" \
+ "$WM_SSH_TARGET:/opt/watermaps/database/schema.sql"
+
rsync \
--archive \
--chmod=F600 \
diff --git a/deploy/systemd/watermaps-route-update.service b/deploy/systemd/watermaps-route-update.service
index 2407b43..ce8f172 100644
--- a/deploy/systemd/watermaps-route-update.service
+++ b/deploy/systemd/watermaps-route-update.service
@@ -1,5 +1,5 @@
[Unit]
-Description=Watermaps Deutschland-/Niederlande-Routingdaten aktualisieren
+Description=Watermaps Deutschland-/Niederlande-Routing- und Ereignisdaten aktualisieren
Wants=network-online.target
After=network-online.target docker.service watermaps-volume-setup.service
Requires=docker.service
@@ -9,6 +9,7 @@ RequiresMountsFor=/srv/watermaps-data
Type=oneshot
WorkingDirectory=/opt/watermaps
ExecStart=/opt/watermaps/deploy/scripts/update-route-data.sh
+TimeoutStartSec=0
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
diff --git a/deploy/systemd/watermaps-route-update.timer b/deploy/systemd/watermaps-route-update.timer
index 7580435..3ff4cb0 100644
--- a/deploy/systemd/watermaps-route-update.timer
+++ b/deploy/systemd/watermaps-route-update.timer
@@ -1,5 +1,5 @@
[Unit]
-Description=Täglich Watermaps-Routingdaten aktualisieren
+Description=Täglich Watermaps-Routing- und Ereignisdaten aktualisieren
[Timer]
OnCalendar=*-*-* 06:15:00
diff --git a/docker-compose.yml b/docker-compose.yml
index 114ea70..64384ff 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,7 +10,8 @@ services:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 5174
- DATABASE_URL: ""
+ DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
+ PGPASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}"
REDIS_URL: ""
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
@@ -23,6 +24,9 @@ services:
read_only: true
bind:
create_host_path: false
+ depends_on:
+ postgres:
+ condition: service_healthy
restart: unless-stopped
healthcheck:
test:
@@ -30,7 +34,7 @@ services:
"CMD",
"node",
"-e",
- "fetch('http://127.0.0.1:5174/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
+ "fetch('http://127.0.0.1:5174/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
]
interval: 30s
timeout: 5s
@@ -38,12 +42,11 @@ services:
retries: 3
postgres:
- profiles: ["postgis"]
image: postgis/postgis:16-3.4
environment:
POSTGRES_DB: seacompass
POSTGRES_USER: seacompass
- POSTGRES_PASSWORD: seacompass
+ POSTGRES_PASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}"
ports:
- "${WATERMAPS_POSTGRES_PORT:-${SEA_COMPASS_POSTGRES_PORT:-55432}}:5432"
volumes:
@@ -55,6 +58,28 @@ services:
timeout: 5s
retries: 5
+ marine-data:
+ profiles: ["postgis"]
+ build:
+ context: .
+ dockerfile: deploy/route-data.Dockerfile
+ image: watermaps-route-data:local
+ entrypoint: ["/workspace/deploy/scripts/prepare-marine-features.sh"]
+ environment:
+ DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
+ PGPASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}"
+ TMPDIR: /workspace/data/tmp
+ WATERMAPS_GEOFABRIK_DIR: /workspace/data/geofabrik
+ WATERMAPS_MARINE_MARKER_PATH: /workspace/data/local/.marine-features.ready
+ volumes:
+ - type: bind
+ source: ./data
+ target: /workspace/data
+ depends_on:
+ postgres:
+ condition: service_healthy
+ restart: "no"
+
redis:
profiles: ["postgis"]
image: redis:7-alpine
diff --git a/infra/opentofu/README.md b/infra/opentofu/README.md
index ba9e2a5..c913584 100644
--- a/infra/opentofu/README.md
+++ b/infra/opentofu/README.md
@@ -2,7 +2,8 @@
Diese OpenTofu-Konfiguration erstellt einen einzelnen Ubuntu-24.04-Server mit
fester IPv4-Adresse, vorgeschalteter Hetzner-Firewall und einem persistenten
-ext4-Volume für die Routingdaten von Deutschland und den Niederlanden.
+ext4-Volume für Routingdaten, Geofabrik-Rohdaten und die PostGIS-Datenbank mit
+Häfen, Schleusen, Brücken und Kontaktdaten.
Kartenkacheln, DNS-Einträge und konkrete Anwendungsversionen sind bewusst nicht
Teil dieses Infrastrukturmoduls. Die Anwendung wird anschließend als
commitgenaues Container-Image aus der Gitea Registry deployt.
diff --git a/infra/opentofu/cloud-init.yaml.tftpl b/infra/opentofu/cloud-init.yaml.tftpl
index f1822a7..b4d2813 100644
--- a/infra/opentofu/cloud-init.yaml.tftpl
+++ b/infra/opentofu/cloud-init.yaml.tftpl
@@ -102,7 +102,7 @@ write_files:
permissions: "0644"
content: |
[Unit]
- Description=Prepare persistent Watermaps routing data volume
+ Description=Prepare persistent Watermaps routing and event data volume
Wants=network-online.target
After=network-online.target local-fs.target
diff --git a/infra/opentofu/outputs.tf b/infra/opentofu/outputs.tf
index 966b2a9..23e21ae 100644
--- a/infra/opentofu/outputs.tf
+++ b/infra/opentofu/outputs.tf
@@ -23,11 +23,11 @@ output "dns_a_record" {
}
output "routing_data_mount_path" {
- description = "Persistenter Pfad für heruntergeladene PBFs und erzeugte Fahrrouten."
+ description = "Persistenter Pfad für PBFs, Fahrrouten und PostGIS-Ereignisdaten."
value = "/srv/watermaps-data"
}
output "routing_volume_id" {
- description = "Hetzner-ID des persistenten Routingdaten-Volumes."
+ description = "Hetzner-ID des persistenten Routing- und Ereignisdaten-Volumes."
value = hcloud_volume.routing_data.id
}
diff --git a/infra/opentofu/variables.tf b/infra/opentofu/variables.tf
index 4e101d8..64c4772 100644
--- a/infra/opentofu/variables.tf
+++ b/infra/opentofu/variables.tf
@@ -44,7 +44,7 @@ variable "location" {
}
variable "server_type" {
- description = "Hetzner-Cloud-Servertyp. cx33 ist für den initialen Routingdaten-Import konservativ gewählt."
+ description = "Hetzner-Cloud-Servertyp. cx33 ist für den initialen Routing- und Ereignisdaten-Import konservativ gewählt."
type = string
default = "cx33"
@@ -101,7 +101,7 @@ variable "deploy_user" {
}
variable "routing_volume_size_gb" {
- description = "Größe des persistenten ext4-Volumes für Deutschland-/Niederlande-PBFs und den Routingindex."
+ description = "Größe des persistenten ext4-Volumes für Deutschland-/Niederlande-PBFs, Routingindex und PostGIS-Ereignisdaten."
type = number
default = 40
diff --git a/packages/shared/src/marine-poi-clustering.ts b/packages/shared/src/marine-poi-clustering.ts
index 66ef144..d46681f 100644
--- a/packages/shared/src/marine-poi-clustering.ts
+++ b/packages/shared/src/marine-poi-clustering.ts
@@ -432,6 +432,13 @@ function inferredRole(candidate: MarinePoiCandidate): MarinePoiRole {
function officialIds(properties: Record) {
const ids = new Map();
for (const key of OFFICIAL_ID_KEYS) {
+ // On an OSM waterway way, `lock_name` identifies the lock facility while
+ // `wikidata` commonly still identifies the underlying canal or river.
+ // Treating that waterway ID as a lock ID would split one physical lock into
+ // several events. EuRIS/ISRS identifiers remain authoritative.
+ if (key === "wikidata" && stringValue(properties.lock_name)) {
+ continue;
+ }
const value = stringValue(properties[key]);
if (value) ids.set(key, value.toLocaleUpperCase("en-US"));
}
diff --git a/packages/shared/tests/marine-poi-clustering.test.ts b/packages/shared/tests/marine-poi-clustering.test.ts
index 6c7d96e..a26dbc8 100644
--- a/packages/shared/tests/marine-poi-clustering.test.ts
+++ b/packages/shared/tests/marine-poi-clustering.test.ts
@@ -92,6 +92,29 @@ describe("marine POI canonicalization", () => {
expect(result).toHaveLength(2);
});
+ it("does not mistake a waterway Wikidata ID for the lock named by lock_name", () => {
+ const result = canonicalizeMarinePois([
+ lock("lock-node", "Kesselschleuse Emden", 7.2, {
+ wikidata: "Q965772"
+ }),
+ lock("canal-way", "Kesselschleuse Emden", 7.2002, {
+ name: "Fehntjer Tief",
+ lock_name: "Kesselschleuse Emden",
+ wikidata: "Q1401323",
+ phone: "+49 170 8512375"
+ })
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0]).toMatchObject({
+ name: "Kesselschleuse Emden",
+ memberCount: 2,
+ properties: {
+ phone: "+49 170 8512375"
+ }
+ });
+ });
+
it("assigns an unnamed gate to exactly the nearest anchor without bridging facilities", () => {
const result = canonicalizeMarinePois([
lock("west", "Schleuse West", 7),