optimized events
Test and publish container images / test (push) Successful in 2m26s
Test and publish container images / publish (push) Failing after 3s

This commit is contained in:
BuTzZ
2026-07-28 14:36:47 +02:00
parent b98ec8bc6f
commit 593dbd5f85
42 changed files with 2405 additions and 283 deletions
+43 -5
View File
@@ -14,12 +14,15 @@ import {
} from "@watermaps/shared"; } from "@watermaps/shared";
import { loadEnv, type ApiEnv } from "./env.js"; import { loadEnv, type ApiEnv } from "./env.js";
import { createCache, type Cache } from "./services/cache.js"; import { createCache, type Cache } from "./services/cache.js";
import { appConfig } from "./services/config.js"; import { buildAppConfig } from "./services/config.js";
import { import {
FairwayService, FairwayService,
type FairwayGraphLookup type FairwayGraphLookup
} from "./services/fairways.js"; } from "./services/fairways.js";
import { FeatureService } from "./services/features.js"; import {
FeatureService,
FeatureSourceUnavailableError
} from "./services/features.js";
import { getNearestTideSummary } from "./services/tides.js"; import { getNearestTideSummary } from "./services/tides.js";
import { getMarineForecast } from "./services/weather.js"; import { getMarineForecast } from "./services/weather.js";
import type { FetchLike } from "./services/http.js"; import type { FetchLike } from "./services/http.js";
@@ -126,9 +129,33 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
await fairwayService.close?.(); 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) => { app.get("/api/weather/marine", async (request, reply) => {
const parsed = coordinateQuerySchema.safeParse(request.query); const parsed = coordinateQuerySchema.safeParse(request.query);
@@ -172,7 +199,18 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() }); 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) => { app.post("/api/routes", async (request, reply) => {
+7 -2
View File
@@ -12,8 +12,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
return { return {
port: Number(env.PORT ?? 5174), port: Number(env.PORT ?? 5174),
host: env.HOST ?? "0.0.0.0", host: env.HOST ?? "0.0.0.0",
databaseUrl: env.DATABASE_URL, databaseUrl: nonEmptyString(env.DATABASE_URL),
redisUrl: env.REDIS_URL, redisUrl: nonEmptyString(env.REDIS_URL),
localFairwaysPath: localFairwaysPath:
env.WATERMAPS_LOCAL_FAIRWAYS_PATH ?? env.WATERMAPS_LOCAL_FAIRWAYS_PATH ??
env.SEA_COMPASS_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" env.NODE_ENV !== "test"
}; };
} }
function nonEmptyString(value: string | undefined) {
const normalized = value?.trim();
return normalized || undefined;
}
+17 -6
View File
@@ -1,6 +1,15 @@
import type { AppConfig } from "@watermaps/shared"; import type { AppConfig } from "@watermaps/shared";
import type { FeatureSourceMode } from "./features.js";
export const appConfig: AppConfig = { type AppConfigCapabilities = {
featureSource: FeatureSourceMode;
liveFairwayExtraction: boolean;
};
export function buildAppConfig(capabilities: AppConfigCapabilities): AppConfig {
const postgisFeatures = capabilities.featureSource === "postgis";
return {
appName: "Watermaps", appName: "Watermaps",
region: "Deutschland/EU", region: "Deutschland/EU",
disclaimer: disclaimer:
@@ -11,12 +20,13 @@ export const appConfig: AppConfig = {
marineWeather: true, marineWeather: true,
tides: true, tides: true,
manualRouting: true, manualRouting: true,
liveFairwayExtraction: true, liveFairwayExtraction: capabilities.liveFairwayExtraction,
postgisFeatures: true, postgisFeatures,
bridgeAndDepthOverlays: true, bridgeAndDepthOverlays: postgisFeatures,
inlandWaterwayRouting: true, inlandWaterwayRouting: true,
routeAlternatives: true, routeAlternatives: true,
lockAndHarbourContacts: true, lockAndHarbourContacts: postgisFeatures,
routeEvents: postgisFeatures,
routeWaypoints: true, routeWaypoints: true,
departureTimeForecasts: true, departureTimeForecasts: true,
liveWaterLevels: true, liveWaterLevels: true,
@@ -64,4 +74,5 @@ export const appConfig: AppConfig = {
"Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE", "Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE",
"Weather, waves and current forecast © Open-Meteo" "Weather, waves and current forecast © Open-Meteo"
] ]
}; };
}
+131 -28
View File
@@ -11,6 +11,21 @@ export type FeatureQuery = {
layers: string[]; 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<pg.Pool, "query" | "end">;
type FeatureCollection = { type FeatureCollection = {
type: "FeatureCollection"; type: "FeatureCollection";
features: Array<{ features: Array<{
@@ -20,7 +35,7 @@ type FeatureCollection = {
properties: Record<string, unknown>; properties: Record<string, unknown>;
}>; }>;
metadata: { metadata: {
source: "postgis" | "demo" | "unavailable"; source: Exclude<FeatureSourceMode, "unavailable">;
warning?: string; warning?: string;
deduplication?: { deduplication?: {
inputPoiCount: number; 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 = [ const demoFeatures = [
{ {
type: "Feature" as const, type: "Feature" as const,
@@ -65,29 +89,110 @@ const demoFeatures = [
]; ];
export class FeatureService { export class FeatureService {
private pool: pg.Pool | null; private pool: FeatureDatabase | null;
private demoData: boolean; private demoData: boolean;
constructor(env: Pick<ApiEnv, "databaseUrl" | "demoData">) { constructor(
this.pool = env.databaseUrl ? new pg.Pool({ connectionString: env.databaseUrl }) : null; env: Pick<ApiEnv, "databaseUrl" | "demoData">,
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; this.demoData = env.demoData;
} }
get sourceMode(): FeatureSourceMode {
if (this.demoData) {
return "demo";
}
return this.pool ? "postgis" : "unavailable";
}
async checkReadiness(): Promise<FeatureSourceReadiness> {
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<FeatureCollection> { async getFeatures(query: FeatureQuery): Promise<FeatureCollection> {
if (this.pool && !this.demoData) { 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) { if (!this.demoData) {
return { throw new FeatureSourceUnavailableError(
type: "FeatureCollection", "Keine lokale Karten- und Ereignisdatenbank konfiguriert."
features: [], );
metadata: {
source: "unavailable",
warning:
"Keine lokale Feature-Datenbank konfiguriert. Kartenkacheln und Live-Dienste bleiben davon unberührt."
}
};
} }
const [minLon, minLat, maxLon, maxLat] = query.bbox; const [minLon, minLat, maxLon, maxLat] = query.bbox;
@@ -151,16 +256,6 @@ export class FeatureService {
FROM marine_features FROM marine_features
WHERE layer = ANY($1::text[]) WHERE layer = ANY($1::text[])
AND geom && ST_MakeEnvelope($2, $3, $4, $5, 4326) 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 SELECT
id::text, id::text,
@@ -364,8 +459,16 @@ export function normalizeMarineFeatureProperties(input: MarineFeaturePropertiesI
"seamark:lock_basin:communication_channel", "seamark:lock_basin:communication_channel",
"seamark:radio_station:channel" "seamark:radio_station:channel"
]); ]);
const openingHours = firstStringProperty(sourceProperties, ["opening_hours", "service_times"]); const openingHours = firstStringProperty(sourceProperties, [
const operator = firstStringProperty(sourceProperties, ["operator"]); "opening_hours",
"lock:opening_hours",
"service_times"
]);
const operator = firstStringProperty(sourceProperties, [
"operator",
"operator:name",
"owner"
]);
const address = normalizedAddress(sourceProperties); const address = normalizedAddress(sourceProperties);
const sourceUrl = firstStringProperty(sourceProperties, [ const sourceUrl = firstStringProperty(sourceProperties, [
"sourceUrl", "sourceUrl",
@@ -452,6 +555,7 @@ const MARINE_PROPERTY_KEYS = new Set([
"lock", "lock",
"obstacle", "obstacle",
"bridge", "bridge",
"bridge:movable",
"maxheight", "maxheight",
"maxheight:physical", "maxheight:physical",
"height", "height",
@@ -529,8 +633,7 @@ function bridgeClearanceM(properties: Record<string, unknown>) {
"seamark:bridge:clearance_height", "seamark:bridge:clearance_height",
"seamark:bridge:clearance_height_safe", "seamark:bridge:clearance_height_safe",
"maxheight", "maxheight",
"maxheight:physical", "maxheight:physical"
"height"
]; ];
for (const key of candidates) { for (const key of candidates) {
+203 -1
View File
@@ -3,6 +3,10 @@ import type { Coordinate, FairwayGraph } from "@watermaps/shared";
import { buildServer } from "../src/app.js"; import { buildServer } from "../src/app.js";
import { loadEnv } from "../src/env.js"; import { loadEnv } from "../src/env.js";
import { createCache } from "../src/services/cache.js"; import { createCache } from "../src/services/cache.js";
import {
type FeatureDatabase,
FeatureService
} from "../src/services/features.js";
import type { import type {
LockOperationInfo, LockOperationInfo,
NavigationDataAdapter NavigationDataAdapter
@@ -90,11 +94,209 @@ describe("Watermaps API", () => {
}); });
it("returns app config with map layers", async () => { 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" }); const response = await app.inject({ method: "GET", url: "/api/config" });
expect(response.statusCode).toBe(200); expect(response.statusCode).toBe(200);
expect(response.json().layers).toHaveLength(3); 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(); await app.close();
}); });
+11
View File
@@ -18,4 +18,15 @@ describe("API environment", () => {
loadEnv({ NODE_ENV: "production", SEA_COMPASS_DEMO_DATA: "true" }).demoData loadEnv({ NODE_ENV: "production", SEA_COMPASS_DEMO_DATA: "true" }).demoData
).toBe(true); ).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();
});
}); });
+183 -6
View File
@@ -1,25 +1,154 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { import {
deduplicateMarineContactFeatures, deduplicateMarineContactFeatures,
type FeatureDatabase,
FeatureService, FeatureService,
FeatureSourceUnavailableError,
normalizeDepthFeatureProperties, normalizeDepthFeatureProperties,
normalizeMarineFeatureProperties normalizeMarineFeatureProperties
} from "../src/services/features.js"; } from "../src/services/features.js";
describe("marine feature data modes", () => { 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({ const service = new FeatureService({
databaseUrl: undefined, databaseUrl: undefined,
demoData: false demoData: false
}); });
const result = await service.getFeatures({ await expect(
service.getFeatures({
bbox: [5, 50, 15, 56], bbox: [5, 50, 15, 56],
layers: ["seamarks", "locks", "harbours"] 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([]); it("checks that the configured PostGIS schema is ready", async () => {
expect(result.metadata.source).toBe("unavailable"); 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(); await service.close();
}); });
}); });
@@ -58,6 +187,23 @@ describe("marine feature normalization", () => {
expect(properties.label).toBeNull(); 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", () => { it("normalizes contact aliases, address and database timestamps", () => {
const properties = normalizeMarineFeatureProperties({ const properties = normalizeMarineFeatureProperties({
layer: "locks", layer: "locks",
@@ -71,7 +217,7 @@ describe("marine feature normalization", () => {
"contact:email": "schleuse@example.test", "contact:email": "schleuse@example.test",
"seamark:lock_basin:communication_channel": "18", "seamark:lock_basin:communication_channel": "18",
opening_hours: "24/7", opening_hours: "24/7",
operator: "WSV", "operator:name": "WSV",
"addr:street": "Fährstraße", "addr:street": "Fährstraße",
"addr:housenumber": "1", "addr:housenumber": "1",
"addr:postcode": "59071", "addr:postcode": "59071",
@@ -92,6 +238,20 @@ describe("marine feature normalization", () => {
expect(properties.updatedAt).toBe("2026-07-19T08:30:00.000Z"); 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", () => { it("prefers direct contact fields and returns stable null values when details are absent", () => {
const properties = normalizeMarineFeatureProperties({ const properties = normalizeMarineFeatureProperties({
layer: "harbours", layer: "harbours",
@@ -153,6 +313,23 @@ describe("marine feature normalization", () => {
}); });
}); });
function featurePool(...results: Array<{ rows: unknown[] }>): FeatureDatabase & {
query: ReturnType<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
} {
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<typeof vi.fn>;
end: ReturnType<typeof vi.fn>;
};
}
describe("marine contact feature deduplication", () => { describe("marine contact feature deduplication", () => {
it("returns one stable facility feature and reports how many raw objects were merged", () => { it("returns one stable facility feature and reports how many raw objects were merged", () => {
const result = deduplicateMarineContactFeatures([ const result = deduplicateMarineContactFeatures([
+25 -59
View File
@@ -4,7 +4,6 @@ import {
calculateRouteGuidance, calculateRouteGuidance,
haversineDistanceNm, haversineDistanceNm,
type BoatProfile, type BoatProfile,
type VoyageHarbour,
type AppConfig, type AppConfig,
type Coordinate, type Coordinate,
type MarineForecast, type MarineForecast,
@@ -17,7 +16,6 @@ import {
import { import {
createRoute, createRoute,
getConfig, getConfig,
getMapFeatures,
getMarineForecast, getMarineForecast,
getNavigationData, getNavigationData,
getNearestTide getNearestTide
@@ -42,6 +40,7 @@ import { useBoatProfile } from "./hooks/useBoatProfile";
import { useCourseAssistant } from "./hooks/useCourseAssistant"; import { useCourseAssistant } from "./hooks/useCourseAssistant";
import { useGeolocation } from "./hooks/useGeolocation"; import { useGeolocation } from "./hooks/useGeolocation";
import { useMarineData } from "./hooks/useMarineData"; import { useMarineData } from "./hooks/useMarineData";
import { useRouteEventFeatures } from "./hooks/useRouteEventFeatures";
import type { OfflineVoyage } from "./lib/offline-route"; import type { OfflineVoyage } from "./lib/offline-route";
import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile"; import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile";
import { isOpenRouteWarning } from "./lib/route-warnings"; import { isOpenRouteWarning } from "./lib/route-warnings";
@@ -51,12 +50,6 @@ import {
type UpcomingRouteEvent type UpcomingRouteEvent
} from "./routeEvents"; } from "./routeEvents";
import type { RouteWeatherReport } from "./routeWeatherReport"; import type { RouteWeatherReport } from "./routeWeatherReport";
import {
routeFeatureBounds,
routeLocksFromFeatures,
voyageHarboursFromFeatures,
type RouteLock
} from "./voyageHarbours";
type PickMode = "start" | "destination" | "waypoint" | null; type PickMode = "start" | "destination" | "waypoint" | null;
@@ -141,16 +134,21 @@ export function App() {
}); });
const [routeOptions, setRouteOptions] = useState<RouteResult[]>([]); const [routeOptions, setRouteOptions] = useState<RouteResult[]>([]);
const [activeVesselProfile, setActiveVesselProfile] = useState<VesselProfile | null>(null); const [activeVesselProfile, setActiveVesselProfile] = useState<VesselProfile | null>(null);
const routeEventFeatures = useRouteEventFeatures(
route,
activeVesselProfile ?? toVesselProfile(currentBoat)
);
const {
harbours: routeHarbours,
locks: routeLocks,
bridgeReport: routeBridgeReport
} = routeEventFeatures;
const [routeWeatherReport, setRouteWeatherReport] = useState<RouteWeatherReport | null>(null); const [routeWeatherReport, setRouteWeatherReport] = useState<RouteWeatherReport | null>(null);
const [routeWeatherLoading, setRouteWeatherLoading] = useState(false); const [routeWeatherLoading, setRouteWeatherLoading] = useState(false);
const [routeWeatherError, setRouteWeatherError] = useState<string | null>(null); const [routeWeatherError, setRouteWeatherError] = useState<string | null>(null);
const [navigationData, setNavigationData] = useState<NavigationDataSnapshot | null>(null); const [navigationData, setNavigationData] = useState<NavigationDataSnapshot | null>(null);
const [navigationDataLoading, setNavigationDataLoading] = useState(false); const [navigationDataLoading, setNavigationDataLoading] = useState(false);
const [navigationDataError, setNavigationDataError] = useState<string | null>(null); const [navigationDataError, setNavigationDataError] = useState<string | null>(null);
const [routeHarbours, setRouteHarbours] = useState<VoyageHarbour[]>([]);
const [routeLocks, setRouteLocks] = useState<RouteLock[]>([]);
const [routeHarboursLoading, setRouteHarboursLoading] = useState(false);
const [routeHarboursError, setRouteHarboursError] = useState<string | null>(null);
const [routeTides, setRouteTides] = useState<RouteTidePlan | null>(null); const [routeTides, setRouteTides] = useState<RouteTidePlan | null>(null);
const [routeTidesLoading, setRouteTidesLoading] = useState(false); const [routeTidesLoading, setRouteTidesLoading] = useState(false);
const [routeTidesError, setRouteTidesError] = useState<string | null>(null); const [routeTidesError, setRouteTidesError] = useState<string | null>(null);
@@ -262,44 +260,6 @@ export function App() {
}; };
}, [route]); }, [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( const routeWarningCount = useMemo(
() => route?.warnings.filter(isOpenRouteWarning).length ?? 0, () => route?.warnings.filter(isOpenRouteWarning).length ?? 0,
[route] [route]
@@ -433,7 +393,7 @@ export function App() {
route, route,
harbours: routeHarbours, harbours: routeHarbours,
locks: routeLocks, locks: routeLocks,
bridges: routeWeatherReport?.bridgeReport?.bridges ?? [], bridges: routeBridgeReport?.bridges ?? [],
progressNm: routeProgress.distanceNm, progressNm: routeProgress.distanceNm,
etaBasis: routeEventEtaBasis etaBasis: routeEventEtaBasis
}) })
@@ -444,7 +404,7 @@ export function App() {
routeHarbours, routeHarbours,
routeLocks, routeLocks,
routeProgress.distanceNm, routeProgress.distanceNm,
routeWeatherReport?.bridgeReport?.bridges routeBridgeReport?.bridges
] ]
); );
const eventWarningCount = useMemo( const eventWarningCount = useMemo(
@@ -485,6 +445,8 @@ export function App() {
) )
? "alarm" ? "alarm"
: "caution" : "caution"
: routeEventFeatures.error
? "caution"
: route : route
? "active" ? "active"
: "idle", : "idle",
@@ -511,6 +473,7 @@ export function App() {
marineData.loading, marineData.loading,
marineData.tide?.updatedAt, marineData.tide?.updatedAt,
route, route,
routeEventFeatures.error,
routeEvents, routeEvents,
routeWeatherReport?.severity routeWeatherReport?.severity
] ]
@@ -526,7 +489,7 @@ export function App() {
import("./routeWeatherReport") import("./routeWeatherReport")
.then(({ createRouteWeatherReport }) => .then(({ createRouteWeatherReport }) =>
createRouteWeatherReport(result, vesselProfile, getMarineForecast, getMapFeatures) createRouteWeatherReport(result, vesselProfile, getMarineForecast)
) )
.then((report) => { .then((report) => {
if (routeWeatherRequestId.current === reportRequestId) { if (routeWeatherRequestId.current === reportRequestId) {
@@ -812,7 +775,7 @@ export function App() {
activeTool === "conditions" activeTool === "conditions"
? marineData.loading || routeWeatherLoading || routeTidesLoading ? marineData.loading || routeWeatherLoading || routeTidesLoading
: activeTool === "upcoming" : activeTool === "upcoming"
? routeHarboursLoading || routeWeatherLoading ? routeEventFeatures.loading
: activeTool === "route" : activeTool === "route"
? routeLoading ? routeLoading
: false; : false;
@@ -905,7 +868,7 @@ export function App() {
badges={{ badges={{
boat: boatProfileStored ? null : "!", boat: boatProfileStored ? null : "!",
anchor: anchorAlarm ? "!" : null, anchor: anchorAlarm ? "!" : null,
upcoming: eventWarningCount, upcoming: eventWarningCount || (routeEventFeatures.error ? "!" : null),
route: guidanceAlarm ? "!" : routeWarningCount route: guidanceAlarm ? "!" : routeWarningCount
}} }}
/> />
@@ -991,8 +954,8 @@ export function App() {
<LazyUpcomingEventsPanel <LazyUpcomingEventsPanel
events={routeEvents} events={routeEvents}
hasRoute={Boolean(route)} hasRoute={Boolean(route)}
loading={routeHarboursLoading || routeWeatherLoading} loading={routeEventFeatures.loading}
error={routeHarboursError} error={routeEventFeatures.error}
onShowOnMap={showRouteEventOnMap} onShowOnMap={showRouteEventOnMap}
/> />
</LazyContent> </LazyContent>
@@ -1034,9 +997,12 @@ export function App() {
navigationDataLoading={navigationDataLoading} navigationDataLoading={navigationDataLoading}
navigationDataError={navigationDataError} navigationDataError={navigationDataError}
routeHarbours={routeHarbours} routeHarbours={routeHarbours}
routeHarboursLoading={routeHarboursLoading} routeHarboursLoading={routeEventFeatures.harboursLoading}
routeHarboursError={routeHarboursError} routeHarboursError={routeEventFeatures.errors.harbours}
routeLocks={routeLocks} routeLocks={routeLocks}
bridgeReport={routeBridgeReport}
bridgesLoading={routeEventFeatures.bridgesLoading}
bridgesError={routeEventFeatures.errors.bridges}
routeTides={routeTides} routeTides={routeTides}
routeTidesLoading={routeTidesLoading} routeTidesLoading={routeTidesLoading}
routeTidesError={routeTidesError} routeTidesError={routeTidesError}
+38 -3
View File
@@ -9,10 +9,31 @@ import type {
} from "@watermaps/shared"; } from "@watermaps/shared";
import type { FeatureCollection } from "geojson"; 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<T>(url: string, init?: RequestInit): Promise<T> { async function getJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init); const response = await fetch(url, init);
if (!response.ok) { if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`); throw new Error(await responseErrorMessage(response));
} }
return (await response.json()) as T; return (await response.json()) as T;
} }
@@ -84,10 +105,24 @@ export function getMapFeatures(params: {
bbox: [number, number, number, number]; bbox: [number, number, number, number];
layers: string[]; layers: string[];
signal?: AbortSignal; signal?: AbortSignal;
}): Promise<FeatureCollection> { }): Promise<MapFeatureCollection> {
const search = new URLSearchParams({ const search = new URLSearchParams({
bbox: params.bbox.join(","), bbox: params.bbox.join(","),
layers: params.layers.join(",") layers: params.layers.join(",")
}); });
return getJson<FeatureCollection>(`/api/features?${search.toString()}`, { signal: params.signal }); return getJson<MapFeatureCollection>(`/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;
} }
+55 -31
View File
@@ -31,7 +31,10 @@ import {
type VoyageAmenity, type VoyageAmenity,
type VoyageHarbour type VoyageHarbour
} from "@watermaps/shared"; } from "@watermaps/shared";
import type { RouteWeatherReport } from "../routeWeatherReport"; import type {
RouteBridgeReport,
RouteWeatherReport
} from "../routeWeatherReport";
import type { OfflineVoyage } from "../lib/offline-route"; import type { OfflineVoyage } from "../lib/offline-route";
import { import {
boatCategoryLabel, boatCategoryLabel,
@@ -63,6 +66,9 @@ type RoutePlannerProps = {
routeHarboursLoading?: boolean; routeHarboursLoading?: boolean;
routeHarboursError?: string | null; routeHarboursError?: string | null;
routeLocks?: RouteLock[]; routeLocks?: RouteLock[];
bridgeReport?: RouteBridgeReport | null;
bridgesLoading?: boolean;
bridgesError?: string | null;
routeTides?: RouteTidePlan | null; routeTides?: RouteTidePlan | null;
routeTidesLoading?: boolean; routeTidesLoading?: boolean;
routeTidesError?: string | null; routeTidesError?: string | null;
@@ -120,6 +126,9 @@ export function RoutePlanner({
routeHarboursLoading = false, routeHarboursLoading = false,
routeHarboursError = null, routeHarboursError = null,
routeLocks = [], routeLocks = [],
bridgeReport = null,
bridgesLoading = false,
bridgesError = null,
routeTides = null, routeTides = null,
routeTidesLoading = false, routeTidesLoading = false,
routeTidesError = null, routeTidesError = null,
@@ -739,7 +748,13 @@ export function RoutePlanner({
</details> </details>
)} )}
{operationalPanelsVisible && (weatherLoading || weatherReport || weatherError) && ( {operationalPanelsVisible &&
(weatherLoading ||
weatherReport ||
weatherError ||
bridgesLoading ||
bridgeReport ||
bridgesError) && (
<section className="route-weather-report" aria-label="Fahrtbericht"> <section className="route-weather-report" aria-label="Fahrtbericht">
<div className="route-weather-heading"> <div className="route-weather-heading">
<CloudSun size={16} aria-hidden="true" /> <CloudSun size={16} aria-hidden="true" />
@@ -748,6 +763,7 @@ export function RoutePlanner({
</div> </div>
{weatherLoading && <p className="route-weather-message">Wetterbericht wird geladen</p>} {weatherLoading && <p className="route-weather-message">Wetterbericht wird geladen</p>}
{bridgesLoading && <p className="route-weather-message">Brücken werden geladen</p>}
{weatherReport && ( {weatherReport && (
<> <>
@@ -798,35 +814,6 @@ export function RoutePlanner({
{weatherReport.unavailableSamples} Messpunkt nicht erreichbar {weatherReport.unavailableSamples} Messpunkt nicht erreichbar
</p> </p>
)} )}
{weatherReport.bridgeReport && (
<div className="route-bridge-report" data-severity={weatherReport.bridgeReport.severity}>
<div className="route-bridge-summary">
<Landmark size={14} aria-hidden="true" />
<span>{weatherReport.bridgeReport.summary}</span>
</div>
<div className="route-bridge-metrics">
<span>Boot {formatMeters(weatherReport.bridgeReport.requiredAirDraftM)}</span>
<span>Min {formatMeters(weatherReport.bridgeReport.minClearanceM)}</span>
<span>
{weatherReport.bridgeReport.checkedCount}/{weatherReport.bridgeReport.bridges.length} geprüft
</span>
</div>
{weatherReport.bridgeReport.bridges.length > 0 && (
<div className="route-bridge-list">
{weatherReport.bridgeReport.bridges.slice(0, 4).map((bridge) => (
<div key={bridge.id} data-status={bridge.status}>
<strong>{bridge.name ?? "Brücke"}</strong>
<span>{bridge.clearanceLabel ?? "H unbekannt"}</span>
<span>{formatBridgeMargin(bridge.marginM)}</span>
</div>
))}
{weatherReport.bridgeReport.bridges.length > 4 && (
<p>+{weatherReport.bridgeReport.bridges.length - 4} weitere Brücken</p>
)}
</div>
)}
</div>
)}
</> </>
)} )}
@@ -836,6 +823,43 @@ export function RoutePlanner({
{weatherError} {weatherError}
</p> </p>
)} )}
{bridgeReport && (
<div className="route-bridge-report" data-severity={bridgeReport.severity}>
<div className="route-bridge-summary">
<Landmark size={14} aria-hidden="true" />
<span>{bridgeReport.summary}</span>
</div>
<div className="route-bridge-metrics">
<span>Boot {formatMeters(bridgeReport.requiredAirDraftM)}</span>
<span>Min {formatMeters(bridgeReport.minClearanceM)}</span>
<span>
{bridgeReport.checkedCount}/{bridgeReport.bridges.length} geprüft
</span>
</div>
{bridgeReport.bridges.length > 0 && (
<div className="route-bridge-list">
{bridgeReport.bridges.slice(0, 4).map((bridge) => (
<div key={bridge.id} data-status={bridge.status}>
<strong>{bridge.name ?? "Brücke"}</strong>
<span>{bridge.clearanceLabel ?? "H unbekannt"}</span>
<span>{formatBridgeMargin(bridge.marginM)}</span>
</div>
))}
{bridgeReport.bridges.length > 4 && (
<p>+{bridgeReport.bridges.length - 4} weitere Brücken</p>
)}
</div>
)}
</div>
)}
{bridgesError && !bridgesLoading && (
<p className="route-weather-message">
<AlertTriangle size={14} aria-hidden="true" />
{bridgesError}
</p>
)}
</section> </section>
)} )}
@@ -244,6 +244,15 @@
font-size: 12px; 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 { .upcoming-event-contact-actions {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
@@ -4,6 +4,7 @@ import {
CalendarClock, CalendarClock,
Clock3, Clock3,
Globe2, Globe2,
Info,
Landmark, Landmark,
LoaderCircle, LoaderCircle,
LockKeyhole, LockKeyhole,
@@ -308,6 +309,10 @@ function EventRow({
<span className="upcoming-event-row-progress"> <span className="upcoming-event-row-progress">
<strong>{formatDistance(event.remainingNm)}</strong> <strong>{formatDistance(event.remainingNm)}</strong>
<small>{formatEta(event)}</small> <small>{formatEta(event)}</small>
<span className="upcoming-event-row-detail-action" aria-hidden="true">
<Info size={13} />
Infos
</span>
</span> </span>
</button> </button>
); );
@@ -456,6 +461,9 @@ function KindSpecificDetails({ event }: { event: UpcomingRouteEvent }) {
<DetailRow label="Reserve"> <DetailRow label="Reserve">
{bridgeMargin(event.feature.marginM)} {bridgeMargin(event.feature.marginM)}
</DetailRow> </DetailRow>
<DetailRow label="Betreiber">
{event.feature.operator ?? "Nicht hinterlegt"}
</DetailRow>
</> </>
); );
} }
@@ -566,14 +574,11 @@ function PanelState({
} }
function eventContact(event: UpcomingRouteEvent) { function eventContact(event: UpcomingRouteEvent) {
if (event.kind === "harbour" || event.kind === "lock") {
return { return {
phone: event.feature.phone ?? null, phone: event.feature.phone ?? null,
website: event.feature.website ?? null, website: event.feature.website ?? null,
email: event.feature.email ?? null email: event.feature.email ?? null
}; };
}
return { phone: null, website: null, email: null };
} }
function importantFact(event: UpcomingRouteEvent) { function importantFact(event: UpcomingRouteEvent) {
+233
View File
@@ -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<FeatureKind, boolean>;
type FeatureErrorState = Record<FeatureKind, string | null>;
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<VesselProfile, "airDraftM"> | null
): RouteEventFeatureState {
const [harbours, setHarbours] = useState<VoyageHarbour[]>([]);
const [locks, setLocks] = useState<RouteLock[]>([]);
const [bridgeReport, setBridgeReport] = useState<RouteBridgeReport | null>(null);
const [loadingByKind, setLoadingByKind] =
useState<FeatureRequestState>(EMPTY_LOADING);
const [errors, setErrors] = useState<FeatureErrorState>(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";
}
+49 -2
View File
@@ -1,4 +1,5 @@
import { import {
haversineDistanceNm,
orderWaypointsAlongRoute, orderWaypointsAlongRoute,
type Coordinate, type Coordinate,
type RouteResult, type RouteResult,
@@ -12,11 +13,13 @@ export type RouteEventKind = "harbour" | "lock" | "bridge";
export type RouteEventCorridors = Record<RouteEventKind, number>; export type RouteEventCorridors = Record<RouteEventKind, number>;
export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly<RouteEventCorridors> = Object.freeze({ export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly<RouteEventCorridors> = Object.freeze({
harbour: 1.5, harbour: 0.5,
lock: 0.25, lock: 0.25,
bridge: 0.08 bridge: 0.08
}); });
const CROSS_KIND_DUPLICATE_DISTANCE_NM = 0.1;
export type RouteEventEtaSpeedSource = "gps-sog" | "vessel-cruise-speed"; export type RouteEventEtaSpeedSource = "gps-sog" | "vessel-cruise-speed";
export type RouteEventEtaReferenceSource = "current-time" | "route-departure"; export type RouteEventEtaReferenceSource = "current-time" | "route-departure";
@@ -148,7 +151,7 @@ export function upcomingRouteEvents(
input.route input.route
); );
return projected.flatMap<UpcomingRouteEvent>((projection) => { const events = projected.flatMap<UpcomingRouteEvent>((projection) => {
const candidate = candidatesByProjectionId.get(projection.id); const candidate = candidatesByProjectionId.get(projection.id);
if ( if (
!candidate || !candidate ||
@@ -179,6 +182,8 @@ export function upcomingRouteEvents(
return [{ ...common, kind: candidate.kind, feature: candidate.feature }]; return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
} }
}); });
return withoutLockHarbourDuplicates(events);
} }
export function nextRouteEventsByKind( export function nextRouteEventsByKind(
@@ -320,3 +325,45 @@ function timestampValue(value: string | number | Date): number | null {
: Date.parse(value); : Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null; 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<string, LockRouteEvent[]>();
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();
}
+158 -4
View File
@@ -55,6 +55,10 @@ export type RouteBridgeAssessment = {
marginM: number | null; marginM: number | null;
status: RouteBridgeStatus; status: RouteBridgeStatus;
source: string; source: string;
phone?: string | null;
website?: string | null;
email?: string | null;
operator?: string | null;
}; };
export type RouteBridgeReport = { 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_BBOX_MARGIN_DEG = 0.02;
const ROUTE_BRIDGE_MAX_DISTANCE_NM = 0.08; const ROUTE_BRIDGE_MAX_DISTANCE_NM = 0.08;
const BRIDGE_TIGHT_MARGIN_M = 0.5; 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( export async function createRouteWeatherReport(
route: RouteResult, route: RouteResult,
@@ -451,7 +458,29 @@ function bridgeAssessmentFromFeature(
requiredAirDraftM, requiredAirDraftM,
marginM, marginM,
status, 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<string, RouteBridgeAssessment>(); const byId = new Map<string, RouteBridgeAssessment>();
for (const bridge of bridges) { for (const bridge of bridges) {
const existing = byId.get(bridge.id); const existing = byId.get(bridge.id);
if (!existing || bridge.distanceNm < existing.distanceNm) { byId.set(bridge.id, existing ? mergeBridgeAssessments(existing, bridge) : bridge);
byId.set(bridge.id, bridge);
} }
const merged: RouteBridgeAssessment[] = [];
for (const bridge of byId.values()) {
const duplicateIndex = merged.findIndex((candidate) =>
bridgeAssessmentsMatch(candidate, bridge)
);
if (duplicateIndex < 0) {
merged.push(bridge);
continue;
} }
return [...byId.values()]; 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[][] { function geometryLineStrings(geometry: Geometry): LonLat[][] {
@@ -770,6 +911,19 @@ function stringProperty(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null; return typeof value === "string" && value.trim() ? value.trim() : null;
} }
function firstStringProperty(
properties: Record<string, unknown>,
keys: readonly string[]
) {
for (const key of keys) {
const value = stringProperty(properties[key]);
if (value) {
return value;
}
}
return null;
}
function normalizeMeters(value: unknown) { function normalizeMeters(value: unknown) {
if (typeof value === "number") { if (typeof value === "number") {
return Number.isFinite(value) ? value : null; return Number.isFinite(value) ? value : null;
+106
View File
@@ -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."
);
});
});
@@ -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"
};
}
+61
View File
@@ -4,6 +4,7 @@ import type {
VoyageHarbour VoyageHarbour
} from "@watermaps/shared"; } from "@watermaps/shared";
import { import {
DEFAULT_ROUTE_EVENT_CORRIDORS_NM,
nextRouteEventsByKind, nextRouteEventsByKind,
upcomingRouteEvents upcomingRouteEvents
} from "../src/routeEvents"; } from "../src/routeEvents";
@@ -80,6 +81,66 @@ describe("upcomingRouteEvents", () => {
expect(events.map((event) => event.id)).toEqual(["detour"]); 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", () => { it("calculates ETA and retains both speed and reference-time provenance", () => {
const [event] = upcomingRouteEvents({ const [event] = upcomingRouteEvents({
route, route,
+1
View File
@@ -528,6 +528,7 @@ describe("RoutePlanner", () => {
weatherReport={weatherReportFixture} weatherReport={weatherReportFixture}
weatherLoading={false} weatherLoading={false}
weatherError={null} weatherError={null}
bridgeReport={weatherReportFixture.bridgeReport}
loading={false} loading={false}
error={null} error={null}
pickMode={null} pickMode={null}
+77 -2
View File
@@ -1,6 +1,10 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { MarineForecast, RouteResult } from "@watermaps/shared"; import type { MarineForecast, RouteResult } from "@watermaps/shared";
import { createRouteWeatherReport, summarizeRouteWeather } from "../src/routeWeatherReport"; import {
createRouteBridgeReport,
createRouteWeatherReport,
summarizeRouteWeather
} from "../src/routeWeatherReport";
describe("route weather report", () => { describe("route weather report", () => {
it("samples start, middle and destination forecasts", async () => { it("samples start, middle and destination forecasts", async () => {
@@ -81,7 +85,9 @@ describe("route weather report", () => {
clearance_m: 2.7, clearance_m: 2.7,
clearance_label: "H 2.7 m", clearance_label: "H 2.7 m",
label: "Niedrige Brücke 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: { geometry: {
type: "LineString", type: "LineString",
@@ -120,6 +126,75 @@ describe("route weather report", () => {
expect(report.bridgeReport?.checkedCount).toBe(2); expect(report.bridgeReport?.checkedCount).toBe(2);
expect(report.bridgeReport?.summary).toContain("Nicht passierbar"); expect(report.bridgeReport?.summary).toContain("Nicht passierbar");
expect(report.bridgeReport?.bridges[0]?.name).toBe("Niedrige Brücke"); 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<string, unknown> = {}
) => ({
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", () => { it("marks critical weather when wind or wave thresholds are exceeded", () => {
+11 -1
View File
@@ -26,6 +26,7 @@ describe("UpcomingEventsPanel", () => {
expect.stringContaining("Brücke Mitte"), expect.stringContaining("Brücke Mitte"),
expect.stringContaining("Hafen Weit") expect.stringContaining("Hafen Weit")
]); ]);
expect(within(list).getAllByText("Infos")).toHaveLength(3);
fireEvent.click(screen.getByRole("button", { name: /Brücken/ })); fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute( expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute(
@@ -81,6 +82,12 @@ describe("UpcomingEventsPanel", () => {
expect(within(detail).getByText("3.8 m")).toBeVisible(); expect(within(detail).getByText("3.8 m")).toBeVisible();
const reserve = within(detail).getByText("Reserve").closest("div"); const reserve = within(detail).getByText("Reserve").closest("div");
expect(reserve).toHaveTextContent("0.4 m Reserve"); 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(); expect(within(detail).queryByRole("button", { name: "Auf Karte zeigen" })).not.toBeInTheDocument();
}); });
@@ -180,7 +187,10 @@ const events: UpcomingRouteEvent[] = [
requiredAirDraftM: 3.8, requiredAirDraftM: 3.8,
marginM: 0.4, marginM: 0.4,
status: "tight", status: "tight",
source: "Test" source: "Test",
phone: "+49 49 123457",
website: "bridge.example",
operator: "Brückenamt"
} }
} }
]; ];
+10
View File
@@ -17,6 +17,12 @@ WATERMAPS_RUNTIME_DIR=/srv/watermaps-runtime
# Verhindert Speicherabbrüche beim vollständigen DE/NL-Indexaufbau auf 4-GB-Servern. # Verhindert Speicherabbrüche beim vollständigen DE/NL-Indexaufbau auf 4-GB-Servern.
WATERMAPS_SWAP_SIZE_GB=4 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 # Gitea Container Registry. Die commitgenauen App-Referenzen werden beim
# Deployment separat erzeugt und niemals hier von Hand auf `latest` gesetzt. # Deployment separat erzeugt und niemals hier von Hand auf `latest` gesetzt.
WATERMAPS_REGISTRY=gitea.incoso.eu 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. # Niederlande erneut geprüft und der Routingindex neu gebaut werden sollen.
WATERMAPS_REBUILD_ROUTE_DATA=false 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. # Für einen Test gegen Let's Encrypt Staging auf true setzen.
# Das damit ausgestellte Zertifikat ist im Browser nicht vertrauenswürdig. # Das damit ausgestellte Zertifikat ist im Browser nicht vertrauenswürdig.
WATERMAPS_CERTBOT_STAGING=false WATERMAPS_CERTBOT_STAGING=false
+64 -21
View File
@@ -1,9 +1,10 @@
# Watermaps-Produktion # Watermaps-Produktion
Dieser Stack hostet die Watermaps-App und die lokalen Fahrrouten für Dieser Stack hostet die Watermaps-App, die lokalen Fahrrouten sowie eine
Deutschland und die Niederlande. Die sichtbaren Kartenkacheln bleiben externe persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten in
Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; die Deutschland und den Niederlanden. Die sichtbaren Kartenkacheln bleiben externe
Watermaps-App ist nur im internen Docker-Netz erreichbar. Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; App und
PostGIS sind nur im internen Docker-Netz erreichbar.
## Konfiguration ## Konfiguration
@@ -12,11 +13,19 @@ cp deploy/.env.production.example deploy/.env.production
editor deploy/.env.production editor deploy/.env.production
``` ```
Mindestens `WATERMAPS_ACME_EMAIL` muss angepasst werden. Außerdem müssen Mindestens `WATERMAPS_ACME_EMAIL` und `WATERMAPS_POSTGRES_PASSWORD` müssen
`WATERMAPS_REGISTRY` und `WATERMAPS_REGISTRY_OWNER` auf die Gitea Container angepasst werden. Für das Datenbankpasswort eignet sich ein URL-unabhängiges
Registry zeigen. Der Hetzner-API-Token gehört **nicht** in diese Datei. Er Hex-Secret:
bleibt lokal in der ignorierten Datei `infra/opentofu/terraform.tfvars`
(alternativ kann der Provider `TF_VAR_hcloud_token` lesen). ```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 Die private SSH-Keydatei wird ebenfalls nicht gespeichert. Sie kann beim
Deployment mit `--identity` oder über `WATERMAPS_SSH_KEY` angegeben werden. 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 Nach einem erfolgreichen Image-Build liest das Skript standardmäßig den
OpenTofu-Output `server_ipv4`. Es erlaubt ausschließlich einen sauberen, OpenTofu-Output `server_ipv4`. Es erlaubt ausschließlich einen sauberen,
vollständig committeten Git-Stand und überträgt nur den kleinen Ordner vollständig committeten Git-Stand und überträgt nur den kleinen Ordner
`deploy/`, nicht den Anwendungsquellcode. Die Image-Tags entsprechen exakt `deploy/` sowie das kanonische `database/schema.sql`, nicht den
`git rev-parse HEAD`. Anwendungsquellcode. Die Image-Tags entsprechen exakt `git rev-parse HEAD`.
Ist die Registry privat, werden einmalig beziehungsweise nach Tokenwechsel Ist die Registry privat, werden einmalig beziehungsweise nach Tokenwechsel
lokale Pull-Zugangsdaten mitgegeben: lokale Pull-Zugangsdaten mitgegeben:
@@ -78,20 +87,36 @@ Anmeldung gültig ist:
--identity ~/.ssh/watermaps_hetzner_ed25519 --identity ~/.ssh/watermaps_hetzner_ed25519
``` ```
Das Serverskript lädt App, Routingdaten-Builder, Nginx und Certbot mit Das Serverskript lädt App, den kombinierten Routing-/Feature-Daten-Builder,
`docker compose pull`. Die beiden commitgenauen Gitea-Tags werden anschließend PostGIS, Nginx und Certbot mit `docker compose pull`. Die beiden commitgenauen
in ihre unveränderlichen Registry-Digests aufgelöst. Erst danach werden die Gitea-Tags werden anschließend in ihre unveränderlichen Registry-Digests
Container mit `--no-build` gestartet. Der Produktionsserver benötigt deshalb aufgelöst. Erst danach werden die Container mit `--no-build` gestartet. Der
weder Git noch Node/npm oder den Quellcode. 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 Der erste Datenaufbau lädt die Geofabrik-Extrakte für Deutschland und die
Niederlande herunter und kann entsprechend der Serverleistung längere Zeit Niederlande, baut den Fahrroutenindex und importiert daraus die
dauern. Der produktive Index liegt auf dem Server unter: `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 ```text
/srv/watermaps-data/local/germany-netherlands-fairways.json /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 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 startet und prüft er `watermaps-volume-setup.service`. Ohne tatsächlich unter
`/srv/watermaps-data` eingehängtes Volume wird kein Download gestartet, damit `/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 Jedes erfolgreiche Release wird mit Commit-SHA und den aufgelösten
Image-Digests unter `/srv/watermaps-runtime/deployments/` gespeichert. Scheitert Image-Digests unter `/srv/watermaps-runtime/deployments/` gespeichert. Scheitert
ein Deployment nach dem Containerwechsel, startet `deploy.sh` automatisch das 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: Das unmittelbar vorherige Release lässt sich auch manuell aktivieren:
@@ -153,11 +181,26 @@ HTTPS um.
`bootstrap-server.sh` installiert zwei systemd-Timer: `bootstrap-server.sh` installiert zwei systemd-Timer:
- `watermaps-route-update.timer`: täglich neue Deutschland- und - `watermaps-route-update.timer`: täglich neue Deutschland- und
Niederlande-Daten; bei Build- oder Routentestfehler bleibt der vorherige Niederlande-Daten. Der Fahrroutenindex wird atomar aktiviert. Der
Index aktiv. 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 - `watermaps-certbot-renew.timer`: zweimal täglich Certbot-Prüfung mit
anschließendem Nginx-Reload. 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: Status und Logs:
```bash ```bash
+57 -2
View File
@@ -7,7 +7,8 @@ services:
NODE_ENV: production NODE_ENV: production
HOST: 0.0.0.0 HOST: 0.0.0.0
PORT: 5174 PORT: 5174
DATABASE_URL: "" DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
PGPASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt}
REDIS_URL: "" REDIS_URL: ""
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
@@ -20,6 +21,9 @@ services:
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/local source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/local
target: /data target: /data
read_only: true read_only: true
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
init: true init: true
security_opt: security_opt:
@@ -32,7 +36,7 @@ services:
- node - node
- -e - -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) }) .then(response => { if (!response.ok) process.exit(1) })
.catch(() => process.exit(1)) .catch(() => process.exit(1))
interval: 15s interval: 15s
@@ -40,6 +44,38 @@ services:
start_period: 15s start_period: 15s
retries: 5 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: nginx:
image: ${WATERMAPS_NGINX_IMAGE:-nginx:1.30.4-alpine} image: ${WATERMAPS_NGINX_IMAGE:-nginx:1.30.4-alpine}
depends_on: depends_on:
@@ -92,3 +128,22 @@ services:
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data} source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}
target: /workspace/data target: /workspace/data
restart: "no" 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"
+21 -4
View File
@@ -1,13 +1,30 @@
FROM python:3.12-slim FROM node:22-bookworm-slim
RUN apt-get update \ RUN apt-get update \
&& apt-get install --yes --no-install-recommends ca-certificates curl coreutils libexpat1 \ && apt-get install --yes --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \ ca-certificates \
&& python3 -m pip install --no-cache-dir "osmium==4.3.1" coreutils \
curl \
libexpat1 \
osmium-tool \
postgresql-client \
python3 \
python3-pip \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace 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 scripts ./scripts
COPY database ./database
COPY deploy/scripts/prepare-route-data.sh ./deploy/scripts/prepare-route-data.sh 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"] ENTRYPOINT ["/workspace/deploy/scripts/prepare-route-data.sh"]
+8
View File
@@ -87,6 +87,8 @@ install -d -m 0755 \
"$WATERMAPS_DATA_DIR" \ "$WATERMAPS_DATA_DIR" \
"$WATERMAPS_DATA_DIR/geofabrik" \ "$WATERMAPS_DATA_DIR/geofabrik" \
"$WATERMAPS_DATA_DIR/local" \ "$WATERMAPS_DATA_DIR/local" \
"$WATERMAPS_DATA_DIR/postgres" \
"$WATERMAPS_DATA_DIR/tmp" \
"$WATERMAPS_DATA_DIR/certbot" \ "$WATERMAPS_DATA_DIR/certbot" \
"$WATERMAPS_DATA_DIR/certbot/www" \ "$WATERMAPS_DATA_DIR/certbot/www" \
"$WATERMAPS_DATA_DIR/certbot/letsencrypt" \ "$WATERMAPS_DATA_DIR/certbot/letsencrypt" \
@@ -95,6 +97,12 @@ install -d -m 0755 \
"$WATERMAPS_RUNTIME_DIR/nginx/conf.d" \ "$WATERMAPS_RUNTIME_DIR/nginx/conf.d" \
"$WATERMAPS_RUNTIME_DIR/locks" "$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 if [[ ! -f "$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf" ]]; then
install -m 0644 \ install -m 0644 \
"$WM_DEPLOY_DIR/nginx/bootstrap.conf" \ "$WM_DEPLOY_DIR/nginx/bootstrap.conf" \
+156
View File
@@ -33,6 +33,16 @@ wm_load_env() {
wm_require_safe_absolute_dir "$WATERMAPS_DATA_DIR" wm_require_safe_absolute_dir "$WATERMAPS_DATA_DIR"
wm_require_safe_absolute_dir "$WATERMAPS_RUNTIME_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() { wm_require_safe_absolute_dir() {
@@ -162,6 +172,10 @@ wm_route_marker() {
printf '%s/local/.germany-netherlands-fairways.ready\n' "$WATERMAPS_DATA_DIR" 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() { wm_acquire_route_lock() {
install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks" install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks"
exec 9>"$WATERMAPS_RUNTIME_DIR/locks/route-update.lock" exec 9>"$WATERMAPS_RUNTIME_DIR/locks/route-update.lock"
@@ -288,6 +302,107 @@ wm_route_data_files_ready() {
return 0 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() { wm_wait_for_health() {
local service="$1" local service="$1"
local timeout_seconds="${2:-180}" 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 EmdenAurich-Teststrecke fehlgeschlagen.",
JSON.stringify({
source: collection?.metadata?.source,
featureCount: features.length,
layers: [...layers],
hasCallableHarbourOrLock: Boolean(contactFeature)
})
);
process.exit(1);
}
'
}
+28 -12
View File
@@ -69,12 +69,16 @@ wm_finish_deployment() {
if [[ -n "$rollback_images_file" && -f "$rollback_images_file" ]]; then if [[ -n "$rollback_images_file" && -f "$rollback_images_file" ]]; then
wm_log "Deployment fehlgeschlagen; vorheriges Container-Release wird wiederhergestellt." wm_log "Deployment fehlgeschlagen; vorheriges Container-Release wird wiederhergestellt."
wm_use_images_env "$rollback_images_file" 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 watermaps 240
wm_wait_for_health nginx 120 wm_wait_for_health nginx 120
if wm_route_data_ready; then if wm_route_data_ready; then
wm_smoke_test_route wm_smoke_test_route
fi fi
if wm_marine_features_ready; then
wm_smoke_test_features
fi
else else
wm_log "Deployment fehlgeschlagen; für das erste Release existiert noch kein Rollback." wm_log "Deployment fehlgeschlagen; für das erste Release existiert noch kein Rollback."
fi 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)" 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_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_app_image="$(wm_resolve_image_digest "$requested_app_image")"
resolved_route_data_image="$(wm_resolve_image_digest "$requested_route_data_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" chmod 0644 "$resolved_images_file"
wm_use_images_env "$resolved_images_file" wm_use_images_env "$resolved_images_file"
wm_log "Commit $revision wird mit unveränderlichen Image-Digests gestartet." wm_log "Persistentes PostGIS wird vor App und Datenimport gestartet."
wm_compose up --detach --no-build --remove-orphans watermaps nginx wm_compose up --detach --no-build postgres
wm_wait_for_health postgres 240
wm_wait_for_health watermaps 240 data_update_required=false
wm_wait_for_health nginx 120
route_rebuild_required=false
update_args=() update_args=()
if [[ "${WATERMAPS_REBUILD_ROUTE_DATA:-false}" == "true" ]]; then if [[ "${WATERMAPS_REBUILD_ROUTE_DATA:-false}" == "true" ]]; then
route_rebuild_required=true data_update_required=true
update_args+=(--force) update_args+=(--force)
elif ! wm_route_data_ready; then 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 fi
if [[ "$route_rebuild_required" == "true" ]]; then if [[ "$data_update_required" == "true" ]]; then
wm_log "Deutschland- und Niederlande-Routingdaten werden sicher vorbereitet." wm_log "Deutschland-/Niederlande-Routingdaten und Marine-Features werden sicher vorbereitet."
WATERMAPS_ROUTE_LOCK_HELD=true \ WATERMAPS_ROUTE_LOCK_HELD=true \
"$WM_DEPLOY_DIR/scripts/update-route-data.sh" "${update_args[@]}" "$WM_DEPLOY_DIR/scripts/update-route-data.sh" "${update_args[@]}"
fi fi
wm_assert_route_data 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 watermaps 240
wm_wait_for_health nginx 120
wm_smoke_test_route wm_smoke_test_route
wm_smoke_test_features
release_images_file="$WATERMAPS_RUNTIME_DIR/deployments/$revision.env" release_images_file="$WATERMAPS_RUNTIME_DIR/deployments/$revision.env"
install -m 0644 "$resolved_images_file" "$release_images_file" install -m 0644 "$resolved_images_file" "$release_images_file"
+158
View File
@@ -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"
@@ -71,4 +71,56 @@ if wm_route_data_ready; then
exit 1 exit 1
fi 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'
@@ -16,6 +16,7 @@ mkdir -p "$FAKE_BIN" "$DATA_DIR/local" "$RUNTIME_DIR"
cat >"$ENV_FILE" <<EOF cat >"$ENV_FILE" <<EOF
WATERMAPS_DATA_DIR=$DATA_DIR WATERMAPS_DATA_DIR=$DATA_DIR
WATERMAPS_RUNTIME_DIR=$RUNTIME_DIR WATERMAPS_RUNTIME_DIR=$RUNTIME_DIR
WATERMAPS_POSTGRES_PASSWORD=0123456789abcdef0123456789abcdef
EOF EOF
cat >"$IMAGES_ENV_FILE" <<'EOF' cat >"$IMAGES_ENV_FILE" <<'EOF'
@@ -48,6 +49,11 @@ if [[ "${1:-}" == "inspect" ]]; then
fi fi
arguments=" $* " arguments=" $* "
if [[ "$arguments" == *" ps --quiet postgres "* ]]; then
printf 'postgres-test-container\n'
exit 0
fi
if [[ "$arguments" == *" ps --quiet watermaps "* ]]; then if [[ "$arguments" == *" ps --quiet watermaps "* ]]; then
printf 'watermaps-test-container\n' printf 'watermaps-test-container\n'
exit 0 exit 0
@@ -55,6 +61,13 @@ fi
if [[ "$arguments" == *" run "*" route-data "* || if [[ "$arguments" == *" run "*" route-data "* ||
"$arguments" == *" run "*" route-data" ]]; then "$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" route_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.json"
marker_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.ready" marker_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.ready"
cat >"$route_file" <<JSON cat >"$route_file" <<JSON
@@ -86,6 +99,27 @@ JSON
exit 0 exit 0
fi fi
if [[ "$arguments" == *" run "*" marine-data "* ||
"$arguments" == *" run "*" marine-data" ]]; then
cat >"$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 if [[ "$arguments" == *" exec --no-TTY watermaps "* ]]; then
active_file="$WATERMAPS_DATA_DIR/local/germany-netherlands-fairways.json" active_file="$WATERMAPS_DATA_DIR/local/germany-netherlands-fairways.json"
active_revision="$(jq --raw-output '.revision // 0' "$active_file" 2>/dev/null || true)" active_revision="$(jq --raw-output '.revision // 0' "$active_file" 2>/dev/null || true)"
+44 -8
View File
@@ -13,11 +13,22 @@ if [[ "$(id -u)" -ne 0 ]]; then
fi fi
force_rebuild=false force_rebuild=false
if [[ "${1:-}" == "--force" ]]; then force_marine_import=false
while [[ "$#" -gt 0 ]]; do
case "$1" in
--force)
force_rebuild=true force_rebuild=true
shift shift
fi ;;
[[ "$#" -eq 0 ]] || wm_die "Unbekannte Argumente für update-route-data.sh." --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" install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks" "$WATERMAPS_DATA_DIR/local"
if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then
@@ -26,6 +37,9 @@ if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then
fi fi
fi fi
wm_compose up --detach --no-build postgres
wm_wait_for_health postgres 240
route_file="$(wm_route_file)" route_file="$(wm_route_file)"
route_marker="$(wm_route_marker)" route_marker="$(wm_route_marker)"
route_dir="$(dirname "$route_file")" route_dir="$(dirname "$route_file")"
@@ -58,6 +72,16 @@ wm_watermaps_is_running() {
[[ -n "$(wm_compose ps --quiet watermaps)" ]] [[ -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() { wm_restore_previous_route() {
rollback_restored_previous=false rollback_restored_previous=false
if [[ -s "$backup_file" && -s "$backup_marker" ]] && if [[ -s "$backup_file" && -s "$backup_marker" ]] &&
@@ -78,8 +102,8 @@ wm_restore_previous_route() {
wm_restart_after_restore() { wm_restart_after_restore() {
if ! wm_watermaps_is_running; then if ! wm_watermaps_is_running; then
wm_log "Watermaps läuft nicht; wiederhergestellte Routingdaten können noch nicht getestet werden." wm_log "Watermaps läuft noch nicht; wiederhergestellte Routingdaten werden beim App-Start getestet."
return 1 return 0
fi fi
wm_compose restart watermaps 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." wm_die "Der neu gebaute Routingindex oder sein Bereitschaftsmarker ist ungültig."
fi fi
wm_update_marine_features
if [[ "$previous_ready" == "true" ]] && cmp --silent "$candidate_file" "$route_file"; then 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" rm -f "$candidate_file" "$candidate_marker"
exit 0 exit 0
fi fi
@@ -186,8 +212,18 @@ wm_write_route_marker "$route_file" "$route_marker"
wm_route_data_ready || wm_route_data_ready ||
wm_die "Der aktivierte Routingindex besitzt keinen gültigen Bereitschaftsmarker." wm_die "Der aktivierte Routingindex besitzt keinen gültigen Bereitschaftsmarker."
wm_compose restart watermaps activation_valid=false
if wm_wait_for_health watermaps 240 && wm_smoke_test_route; then 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" rm -f "$transaction_file"
transaction_active=false transaction_active=false
rm -f "$backup_file" "$backup_marker" rm -f "$backup_file" "$backup_marker"
+10 -2
View File
@@ -124,9 +124,9 @@ printf '[watermaps] Warte auf Cloud-init und das persistente Hetzner-Volume.\n'
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \ ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
"cloud-init status --wait && ${remote_prefix}systemctl start watermaps-volume-setup.service && mountpoint --quiet /srv/watermaps-data" "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" \ 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 \ rsync \
--archive \ --archive \
@@ -140,6 +140,14 @@ rsync \
"$WM_DEPLOY_DIR/" \ "$WM_DEPLOY_DIR/" \
"$WM_SSH_TARGET:/opt/watermaps/deploy/" "$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 \ rsync \
--archive \ --archive \
--chmod=F600 \ --chmod=F600 \
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Watermaps Deutschland-/Niederlande-Routingdaten aktualisieren Description=Watermaps Deutschland-/Niederlande-Routing- und Ereignisdaten aktualisieren
Wants=network-online.target Wants=network-online.target
After=network-online.target docker.service watermaps-volume-setup.service After=network-online.target docker.service watermaps-volume-setup.service
Requires=docker.service Requires=docker.service
@@ -9,6 +9,7 @@ RequiresMountsFor=/srv/watermaps-data
Type=oneshot Type=oneshot
WorkingDirectory=/opt/watermaps WorkingDirectory=/opt/watermaps
ExecStart=/opt/watermaps/deploy/scripts/update-route-data.sh ExecStart=/opt/watermaps/deploy/scripts/update-route-data.sh
TimeoutStartSec=0
Nice=10 Nice=10
IOSchedulingClass=best-effort IOSchedulingClass=best-effort
IOSchedulingPriority=7 IOSchedulingPriority=7
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Täglich Watermaps-Routingdaten aktualisieren Description=Täglich Watermaps-Routing- und Ereignisdaten aktualisieren
[Timer] [Timer]
OnCalendar=*-*-* 06:15:00 OnCalendar=*-*-* 06:15:00
+29 -4
View File
@@ -10,7 +10,8 @@ services:
NODE_ENV: production NODE_ENV: production
HOST: 0.0.0.0 HOST: 0.0.0.0
PORT: 5174 PORT: 5174
DATABASE_URL: "" DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
PGPASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}"
REDIS_URL: "" REDIS_URL: ""
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
@@ -23,6 +24,9 @@ services:
read_only: true read_only: true
bind: bind:
create_host_path: false create_host_path: false
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: test:
@@ -30,7 +34,7 @@ services:
"CMD", "CMD",
"node", "node",
"-e", "-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 interval: 30s
timeout: 5s timeout: 5s
@@ -38,12 +42,11 @@ services:
retries: 3 retries: 3
postgres: postgres:
profiles: ["postgis"]
image: postgis/postgis:16-3.4 image: postgis/postgis:16-3.4
environment: environment:
POSTGRES_DB: seacompass POSTGRES_DB: seacompass
POSTGRES_USER: seacompass POSTGRES_USER: seacompass
POSTGRES_PASSWORD: seacompass POSTGRES_PASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}"
ports: ports:
- "${WATERMAPS_POSTGRES_PORT:-${SEA_COMPASS_POSTGRES_PORT:-55432}}:5432" - "${WATERMAPS_POSTGRES_PORT:-${SEA_COMPASS_POSTGRES_PORT:-55432}}:5432"
volumes: volumes:
@@ -55,6 +58,28 @@ services:
timeout: 5s timeout: 5s
retries: 5 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: redis:
profiles: ["postgis"] profiles: ["postgis"]
image: redis:7-alpine image: redis:7-alpine
+2 -1
View File
@@ -2,7 +2,8 @@
Diese OpenTofu-Konfiguration erstellt einen einzelnen Ubuntu-24.04-Server mit Diese OpenTofu-Konfiguration erstellt einen einzelnen Ubuntu-24.04-Server mit
fester IPv4-Adresse, vorgeschalteter Hetzner-Firewall und einem persistenten 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 Kartenkacheln, DNS-Einträge und konkrete Anwendungsversionen sind bewusst nicht
Teil dieses Infrastrukturmoduls. Die Anwendung wird anschließend als Teil dieses Infrastrukturmoduls. Die Anwendung wird anschließend als
commitgenaues Container-Image aus der Gitea Registry deployt. commitgenaues Container-Image aus der Gitea Registry deployt.
+1 -1
View File
@@ -102,7 +102,7 @@ write_files:
permissions: "0644" permissions: "0644"
content: | content: |
[Unit] [Unit]
Description=Prepare persistent Watermaps routing data volume Description=Prepare persistent Watermaps routing and event data volume
Wants=network-online.target Wants=network-online.target
After=network-online.target local-fs.target After=network-online.target local-fs.target
+2 -2
View File
@@ -23,11 +23,11 @@ output "dns_a_record" {
} }
output "routing_data_mount_path" { 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" value = "/srv/watermaps-data"
} }
output "routing_volume_id" { 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 value = hcloud_volume.routing_data.id
} }
+2 -2
View File
@@ -44,7 +44,7 @@ variable "location" {
} }
variable "server_type" { 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 type = string
default = "cx33" default = "cx33"
@@ -101,7 +101,7 @@ variable "deploy_user" {
} }
variable "routing_volume_size_gb" { 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 type = number
default = 40 default = 40
@@ -432,6 +432,13 @@ function inferredRole(candidate: MarinePoiCandidate): MarinePoiRole {
function officialIds(properties: Record<string, unknown>) { function officialIds(properties: Record<string, unknown>) {
const ids = new Map<string, string>(); const ids = new Map<string, string>();
for (const key of OFFICIAL_ID_KEYS) { 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]); const value = stringValue(properties[key]);
if (value) ids.set(key, value.toLocaleUpperCase("en-US")); if (value) ids.set(key, value.toLocaleUpperCase("en-US"));
} }
@@ -92,6 +92,29 @@ describe("marine POI canonicalization", () => {
expect(result).toHaveLength(2); 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", () => { it("assigns an unnamed gate to exactly the nearest anchor without bridging facilities", () => {
const result = canonicalizeMarinePois([ const result = canonicalizeMarinePois([
lock("west", "Schleuse West", 7), lock("west", "Schleuse West", 7),