optimized events
This commit is contained in:
+43
-5
@@ -14,12 +14,15 @@ import {
|
||||
} from "@watermaps/shared";
|
||||
import { loadEnv, type ApiEnv } from "./env.js";
|
||||
import { createCache, type Cache } from "./services/cache.js";
|
||||
import { appConfig } from "./services/config.js";
|
||||
import { buildAppConfig } from "./services/config.js";
|
||||
import {
|
||||
FairwayService,
|
||||
type FairwayGraphLookup
|
||||
} from "./services/fairways.js";
|
||||
import { FeatureService } from "./services/features.js";
|
||||
import {
|
||||
FeatureService,
|
||||
FeatureSourceUnavailableError
|
||||
} from "./services/features.js";
|
||||
import { getNearestTideSummary } from "./services/tides.js";
|
||||
import { getMarineForecast } from "./services/weather.js";
|
||||
import type { FetchLike } from "./services/http.js";
|
||||
@@ -126,9 +129,33 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
|
||||
await fairwayService.close?.();
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ ok: true, service: "watermaps-api" }));
|
||||
app.get("/health", async () => ({
|
||||
ok: true,
|
||||
service: "watermaps-api",
|
||||
featureSource: featureService.sourceMode
|
||||
}));
|
||||
|
||||
app.get("/api/config", async () => appConfig);
|
||||
app.get("/ready", async (_request, reply) => {
|
||||
const featureSource = await featureService.checkReadiness();
|
||||
const body = {
|
||||
ok: featureSource.ready,
|
||||
service: "watermaps-api",
|
||||
checks: {
|
||||
features: featureSource
|
||||
}
|
||||
};
|
||||
return featureSource.ready
|
||||
? body
|
||||
: reply.code(503).send(body);
|
||||
});
|
||||
|
||||
app.get("/api/config", async () => {
|
||||
const featureSource = await featureService.checkReadiness();
|
||||
return buildAppConfig({
|
||||
featureSource: featureSource.ready ? featureSource.source : "unavailable",
|
||||
liveFairwayExtraction: env.liveOsmFairways
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/weather/marine", async (request, reply) => {
|
||||
const parsed = coordinateQuerySchema.safeParse(request.query);
|
||||
@@ -172,7 +199,18 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
|
||||
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) => {
|
||||
|
||||
+7
-2
@@ -12,8 +12,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
|
||||
return {
|
||||
port: Number(env.PORT ?? 5174),
|
||||
host: env.HOST ?? "0.0.0.0",
|
||||
databaseUrl: env.DATABASE_URL,
|
||||
redisUrl: env.REDIS_URL,
|
||||
databaseUrl: nonEmptyString(env.DATABASE_URL),
|
||||
redisUrl: nonEmptyString(env.REDIS_URL),
|
||||
localFairwaysPath:
|
||||
env.WATERMAPS_LOCAL_FAIRWAYS_PATH ??
|
||||
env.SEA_COMPASS_LOCAL_FAIRWAYS_PATH ??
|
||||
@@ -26,3 +26,8 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
|
||||
env.NODE_ENV !== "test"
|
||||
};
|
||||
}
|
||||
|
||||
function nonEmptyString(value: string | undefined) {
|
||||
const normalized = value?.trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
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",
|
||||
region: "Deutschland/EU",
|
||||
disclaimer:
|
||||
@@ -11,12 +20,13 @@ export const appConfig: AppConfig = {
|
||||
marineWeather: true,
|
||||
tides: true,
|
||||
manualRouting: true,
|
||||
liveFairwayExtraction: true,
|
||||
postgisFeatures: true,
|
||||
bridgeAndDepthOverlays: true,
|
||||
liveFairwayExtraction: capabilities.liveFairwayExtraction,
|
||||
postgisFeatures,
|
||||
bridgeAndDepthOverlays: postgisFeatures,
|
||||
inlandWaterwayRouting: true,
|
||||
routeAlternatives: true,
|
||||
lockAndHarbourContacts: true,
|
||||
lockAndHarbourContacts: postgisFeatures,
|
||||
routeEvents: postgisFeatures,
|
||||
routeWaypoints: true,
|
||||
departureTimeForecasts: true,
|
||||
liveWaterLevels: true,
|
||||
@@ -64,4 +74,5 @@ export const appConfig: AppConfig = {
|
||||
"Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE",
|
||||
"Weather, waves and current forecast © Open-Meteo"
|
||||
]
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,21 @@ export type FeatureQuery = {
|
||||
layers: string[];
|
||||
};
|
||||
|
||||
export type FeatureSourceMode = "postgis" | "demo" | "unavailable";
|
||||
|
||||
export type FeatureSourceReadiness = {
|
||||
ready: boolean;
|
||||
source: FeatureSourceMode;
|
||||
detail?:
|
||||
| "not_configured"
|
||||
| "database_unreachable"
|
||||
| "schema_missing"
|
||||
| "data_missing";
|
||||
missingLayers?: Array<"harbours" | "locks" | "bridges">;
|
||||
};
|
||||
|
||||
export type FeatureDatabase = Pick<pg.Pool, "query" | "end">;
|
||||
|
||||
type FeatureCollection = {
|
||||
type: "FeatureCollection";
|
||||
features: Array<{
|
||||
@@ -20,7 +35,7 @@ type FeatureCollection = {
|
||||
properties: Record<string, unknown>;
|
||||
}>;
|
||||
metadata: {
|
||||
source: "postgis" | "demo" | "unavailable";
|
||||
source: Exclude<FeatureSourceMode, "unavailable">;
|
||||
warning?: string;
|
||||
deduplication?: {
|
||||
inputPoiCount: number;
|
||||
@@ -30,6 +45,15 @@ type FeatureCollection = {
|
||||
};
|
||||
};
|
||||
|
||||
export class FeatureSourceUnavailableError extends Error {
|
||||
readonly code = "feature_source_unavailable";
|
||||
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "FeatureSourceUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
const demoFeatures = [
|
||||
{
|
||||
type: "Feature" as const,
|
||||
@@ -65,29 +89,110 @@ const demoFeatures = [
|
||||
];
|
||||
|
||||
export class FeatureService {
|
||||
private pool: pg.Pool | null;
|
||||
private pool: FeatureDatabase | null;
|
||||
private demoData: boolean;
|
||||
|
||||
constructor(env: Pick<ApiEnv, "databaseUrl" | "demoData">) {
|
||||
this.pool = env.databaseUrl ? new pg.Pool({ connectionString: env.databaseUrl }) : null;
|
||||
constructor(
|
||||
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;
|
||||
}
|
||||
|
||||
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> {
|
||||
if (this.pool && !this.demoData) {
|
||||
return this.getPostgisFeatures(query);
|
||||
try {
|
||||
return await this.getPostgisFeatures(query);
|
||||
} catch (error) {
|
||||
throw new FeatureSourceUnavailableError(
|
||||
"Die lokale Karten- und Ereignisdatenbank ist momentan nicht erreichbar.",
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.demoData) {
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
metadata: {
|
||||
source: "unavailable",
|
||||
warning:
|
||||
"Keine lokale Feature-Datenbank konfiguriert. Kartenkacheln und Live-Dienste bleiben davon unberührt."
|
||||
}
|
||||
};
|
||||
throw new FeatureSourceUnavailableError(
|
||||
"Keine lokale Karten- und Ereignisdatenbank konfiguriert."
|
||||
);
|
||||
}
|
||||
|
||||
const [minLon, minLat, maxLon, maxLat] = query.bbox;
|
||||
@@ -151,16 +256,6 @@ export class FeatureService {
|
||||
FROM marine_features
|
||||
WHERE layer = ANY($1::text[])
|
||||
AND geom && ST_MakeEnvelope($2, $3, $4, $5, 4326)
|
||||
AND (
|
||||
layer <> 'bridges'
|
||||
OR properties ? 'seamark:bridge:clearance_height'
|
||||
OR properties ? 'seamark:bridge:clearance_height_safe'
|
||||
OR properties ? 'maxheight'
|
||||
OR properties ? 'maxheight:physical'
|
||||
OR lower(COALESCE(properties->>'bridge', '')) = ANY(
|
||||
ARRAY['movable', 'bascule', 'lift', 'swing', 'drawbridge', 'retractable', 'submersible', 'opening']
|
||||
)
|
||||
)
|
||||
)
|
||||
SELECT
|
||||
id::text,
|
||||
@@ -364,8 +459,16 @@ export function normalizeMarineFeatureProperties(input: MarineFeaturePropertiesI
|
||||
"seamark:lock_basin:communication_channel",
|
||||
"seamark:radio_station:channel"
|
||||
]);
|
||||
const openingHours = firstStringProperty(sourceProperties, ["opening_hours", "service_times"]);
|
||||
const operator = firstStringProperty(sourceProperties, ["operator"]);
|
||||
const openingHours = firstStringProperty(sourceProperties, [
|
||||
"opening_hours",
|
||||
"lock:opening_hours",
|
||||
"service_times"
|
||||
]);
|
||||
const operator = firstStringProperty(sourceProperties, [
|
||||
"operator",
|
||||
"operator:name",
|
||||
"owner"
|
||||
]);
|
||||
const address = normalizedAddress(sourceProperties);
|
||||
const sourceUrl = firstStringProperty(sourceProperties, [
|
||||
"sourceUrl",
|
||||
@@ -452,6 +555,7 @@ const MARINE_PROPERTY_KEYS = new Set([
|
||||
"lock",
|
||||
"obstacle",
|
||||
"bridge",
|
||||
"bridge:movable",
|
||||
"maxheight",
|
||||
"maxheight:physical",
|
||||
"height",
|
||||
@@ -529,8 +633,7 @@ function bridgeClearanceM(properties: Record<string, unknown>) {
|
||||
"seamark:bridge:clearance_height",
|
||||
"seamark:bridge:clearance_height_safe",
|
||||
"maxheight",
|
||||
"maxheight:physical",
|
||||
"height"
|
||||
"maxheight:physical"
|
||||
];
|
||||
|
||||
for (const key of candidates) {
|
||||
|
||||
+203
-1
@@ -3,6 +3,10 @@ import type { Coordinate, FairwayGraph } from "@watermaps/shared";
|
||||
import { buildServer } from "../src/app.js";
|
||||
import { loadEnv } from "../src/env.js";
|
||||
import { createCache } from "../src/services/cache.js";
|
||||
import {
|
||||
type FeatureDatabase,
|
||||
FeatureService
|
||||
} from "../src/services/features.js";
|
||||
import type {
|
||||
LockOperationInfo,
|
||||
NavigationDataAdapter
|
||||
@@ -90,11 +94,209 @@ describe("Watermaps API", () => {
|
||||
});
|
||||
|
||||
it("returns app config with map layers", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const app = await buildServer({ cache: createCache(), env: productionEnv });
|
||||
const response = await app.inject({ method: "GET", url: "/api/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().layers).toHaveLength(3);
|
||||
expect(response.json().featureFlags).toMatchObject({
|
||||
liveFairwayExtraction: false,
|
||||
postgisFeatures: false,
|
||||
bridgeAndDepthOverlays: false,
|
||||
lockAndHarbourContacts: false,
|
||||
routeEvents: false
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("advertises feature and contact capabilities only for a configured PostGIS source", async () => {
|
||||
const pool = {
|
||||
query: vi.fn().mockImplementation((sql: unknown) =>
|
||||
Promise.resolve(
|
||||
String(sql).includes("to_regclass")
|
||||
? {
|
||||
rows: [
|
||||
{
|
||||
marine_features: "marine_features",
|
||||
marine_fairway_edges: "marine_fairway_edges"
|
||||
}
|
||||
]
|
||||
}
|
||||
: {
|
||||
rows: [{ harbours: true, locks: true, bridges: true }]
|
||||
}
|
||||
)
|
||||
),
|
||||
end: vi.fn().mockResolvedValue(undefined)
|
||||
} as unknown as FeatureDatabase;
|
||||
const featureService = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: { ...productionEnv, liveOsmFairways: true },
|
||||
featureService
|
||||
});
|
||||
const response = await app.inject({ method: "GET", url: "/api/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().featureFlags).toMatchObject({
|
||||
liveFairwayExtraction: true,
|
||||
postgisFeatures: true,
|
||||
bridgeAndDepthOverlays: true,
|
||||
lockAndHarbourContacts: true,
|
||||
routeEvents: true
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("keeps route-event capabilities disabled until all event layers contain data", async () => {
|
||||
const pool = {
|
||||
query: vi.fn().mockImplementation((sql: unknown) =>
|
||||
Promise.resolve(
|
||||
String(sql).includes("to_regclass")
|
||||
? {
|
||||
rows: [
|
||||
{
|
||||
marine_features: "marine_features",
|
||||
marine_fairway_edges: "marine_fairway_edges"
|
||||
}
|
||||
]
|
||||
}
|
||||
: {
|
||||
rows: [{ harbours: false, locks: false, bridges: false }]
|
||||
}
|
||||
)
|
||||
),
|
||||
end: vi.fn().mockResolvedValue(undefined)
|
||||
} as unknown as FeatureDatabase;
|
||||
const featureService = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
featureService
|
||||
});
|
||||
const config = await app.inject({ method: "GET", url: "/api/config" });
|
||||
const readiness = await app.inject({ method: "GET", url: "/ready" });
|
||||
|
||||
expect(config.statusCode).toBe(200);
|
||||
expect(config.json().featureFlags).toMatchObject({
|
||||
postgisFeatures: false,
|
||||
bridgeAndDepthOverlays: false,
|
||||
lockAndHarbourContacts: false,
|
||||
routeEvents: false
|
||||
});
|
||||
expect(readiness.statusCode).toBe(503);
|
||||
expect(readiness.json().checks.features).toEqual({
|
||||
ready: false,
|
||||
source: "postgis",
|
||||
detail: "data_missing",
|
||||
missingLayers: ["harbours", "locks", "bridges"]
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("does not advertise PostGIS capabilities when the configured database is unreachable", async () => {
|
||||
const pool = {
|
||||
query: vi.fn().mockRejectedValue(new Error("connection refused")),
|
||||
end: vi.fn().mockResolvedValue(undefined)
|
||||
} as unknown as FeatureDatabase;
|
||||
const featureService = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
featureService
|
||||
});
|
||||
const config = await app.inject({ method: "GET", url: "/api/config" });
|
||||
const readiness = await app.inject({ method: "GET", url: "/ready" });
|
||||
|
||||
expect(config.statusCode).toBe(200);
|
||||
expect(config.json().featureFlags).toMatchObject({
|
||||
postgisFeatures: false,
|
||||
bridgeAndDepthOverlays: false,
|
||||
lockAndHarbourContacts: false,
|
||||
routeEvents: false
|
||||
});
|
||||
expect(readiness.statusCode).toBe(503);
|
||||
expect(readiness.json().checks.features).toEqual({
|
||||
ready: false,
|
||||
source: "postgis",
|
||||
detail: "database_unreachable"
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns 503 when no feature source is configured", async () => {
|
||||
const app = await buildServer({ cache: createCache(), env: productionEnv });
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/features?bbox=7.1,53.3,7.5,53.5&layers=harbours,locks,bridges"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json()).toEqual({
|
||||
error: "feature_source_unavailable",
|
||||
message: "Keine lokale Karten- und Ereignisdatenbank konfiguriert."
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns 503 when the configured feature database query fails", async () => {
|
||||
const pool = {
|
||||
query: vi.fn().mockRejectedValue(new Error("database unavailable")),
|
||||
end: vi.fn().mockResolvedValue(undefined)
|
||||
} as unknown as FeatureDatabase;
|
||||
const featureService = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
env: productionEnv,
|
||||
featureService
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/features?bbox=7.1,53.3,7.5,53.5&layers=harbours,locks,bridges"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(503);
|
||||
expect(response.json()).toEqual({
|
||||
error: "feature_source_unavailable",
|
||||
message: "Die lokale Karten- und Ereignisdatenbank ist momentan nicht erreichbar."
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("separates liveness from feature-data readiness", async () => {
|
||||
const app = await buildServer({ cache: createCache(), env: productionEnv });
|
||||
const health = await app.inject({ method: "GET", url: "/health" });
|
||||
const readiness = await app.inject({ method: "GET", url: "/ready" });
|
||||
|
||||
expect(health.statusCode).toBe(200);
|
||||
expect(health.json()).toMatchObject({
|
||||
ok: true,
|
||||
featureSource: "unavailable"
|
||||
});
|
||||
expect(readiness.statusCode).toBe(503);
|
||||
expect(readiness.json()).toEqual({
|
||||
ok: false,
|
||||
service: "watermaps-api",
|
||||
checks: {
|
||||
features: {
|
||||
ready: false,
|
||||
source: "unavailable",
|
||||
detail: "not_configured"
|
||||
}
|
||||
}
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -18,4 +18,15 @@ describe("API environment", () => {
|
||||
loadEnv({ NODE_ENV: "production", SEA_COMPASS_DEMO_DATA: "true" }).demoData
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat blank service URLs as configured capabilities", () => {
|
||||
const env = loadEnv({
|
||||
NODE_ENV: "production",
|
||||
DATABASE_URL: " ",
|
||||
REDIS_URL: "\t"
|
||||
});
|
||||
|
||||
expect(env.databaseUrl).toBeUndefined();
|
||||
expect(env.redisUrl).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,154 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
deduplicateMarineContactFeatures,
|
||||
type FeatureDatabase,
|
||||
FeatureService,
|
||||
FeatureSourceUnavailableError,
|
||||
normalizeDepthFeatureProperties,
|
||||
normalizeMarineFeatureProperties
|
||||
} from "../src/services/features.js";
|
||||
|
||||
describe("marine feature data modes", () => {
|
||||
it("does not expose demo markers when production disables demo data without PostGIS", async () => {
|
||||
it("fails explicitly when production has no configured feature source", async () => {
|
||||
const service = new FeatureService({
|
||||
databaseUrl: undefined,
|
||||
demoData: false
|
||||
});
|
||||
|
||||
const result = await service.getFeatures({
|
||||
await expect(
|
||||
service.getFeatures({
|
||||
bbox: [5, 50, 15, 56],
|
||||
layers: ["seamarks", "locks", "harbours"]
|
||||
})
|
||||
).rejects.toBeInstanceOf(FeatureSourceUnavailableError);
|
||||
expect(service.sourceMode).toBe("unavailable");
|
||||
expect(await service.checkReadiness()).toEqual({
|
||||
ready: false,
|
||||
source: "unavailable",
|
||||
detail: "not_configured"
|
||||
});
|
||||
await service.close();
|
||||
});
|
||||
|
||||
expect(result.features).toEqual([]);
|
||||
expect(result.metadata.source).toBe("unavailable");
|
||||
it("checks that the configured PostGIS schema is ready", async () => {
|
||||
const pool = featurePool(
|
||||
{
|
||||
rows: [
|
||||
{
|
||||
marine_features: "marine_features",
|
||||
marine_fairway_edges: "marine_fairway_edges"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [{ harbours: true, locks: true, bridges: true }]
|
||||
}
|
||||
);
|
||||
const service = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
|
||||
expect(service.sourceMode).toBe("postgis");
|
||||
expect(await service.checkReadiness()).toEqual({
|
||||
ready: true,
|
||||
source: "postgis"
|
||||
});
|
||||
expect(pool.query).toHaveBeenCalledWith(expect.stringContaining("to_regclass"));
|
||||
expect(pool.query).toHaveBeenCalledWith(expect.stringContaining("EXISTS"));
|
||||
await service.close();
|
||||
expect(pool.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("reports a configured database with missing feature tables as not ready", async () => {
|
||||
const pool = featurePool({
|
||||
rows: [{ marine_features: "marine_features", marine_fairway_edges: null }]
|
||||
});
|
||||
const service = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
|
||||
expect(await service.checkReadiness()).toEqual({
|
||||
ready: false,
|
||||
source: "postgis",
|
||||
detail: "schema_missing"
|
||||
});
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("reports initialized PostGIS tables without event data as not ready", async () => {
|
||||
const pool = featurePool(
|
||||
{
|
||||
rows: [
|
||||
{
|
||||
marine_features: "marine_features",
|
||||
marine_fairway_edges: "marine_fairway_edges"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
rows: [{ harbours: false, locks: true, bridges: false }]
|
||||
}
|
||||
);
|
||||
const service = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
|
||||
expect(await service.checkReadiness()).toEqual({
|
||||
ready: false,
|
||||
source: "postgis",
|
||||
detail: "data_missing",
|
||||
missingLayers: ["harbours", "bridges"]
|
||||
});
|
||||
await service.close();
|
||||
});
|
||||
|
||||
it("returns ordinary bridges from the requested bbox for route-side filtering", async () => {
|
||||
const pool = featurePool({
|
||||
rows: [
|
||||
{
|
||||
id: "bridge-ordinary",
|
||||
layer: "bridges",
|
||||
name: "Normale Kanalbrücke",
|
||||
source: "osm",
|
||||
source_id: "w100",
|
||||
properties: { bridge: "yes" },
|
||||
updated_at: "2026-07-20T10:00:00.000Z",
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[7.2, 53.4],
|
||||
[7.21, 53.4]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
const service = new FeatureService(
|
||||
{ databaseUrl: "postgres://features.test/watermaps", demoData: false },
|
||||
{ pool }
|
||||
);
|
||||
|
||||
const result = await service.getFeatures({
|
||||
bbox: [7.1, 53.3, 7.5, 53.5],
|
||||
layers: ["bridges"]
|
||||
});
|
||||
const sql = String(vi.mocked(pool.query).mock.calls[0]?.[0]);
|
||||
|
||||
expect(sql).not.toContain("properties ? 'maxheight'");
|
||||
expect(sql).not.toContain("properties->>'bridge'");
|
||||
expect(result.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "bridge-ordinary",
|
||||
properties: expect.objectContaining({
|
||||
layer: "bridges",
|
||||
name: "Normale Kanalbrücke",
|
||||
clearance_m: null
|
||||
})
|
||||
})
|
||||
]);
|
||||
await service.close();
|
||||
});
|
||||
});
|
||||
@@ -58,6 +187,23 @@ describe("marine feature normalization", () => {
|
||||
expect(properties.label).toBeNull();
|
||||
});
|
||||
|
||||
it("does not mistake a bridge structure height for navigable clearance", () => {
|
||||
const properties = normalizeMarineFeatureProperties({
|
||||
layer: "bridges",
|
||||
name: "Klappbrücke",
|
||||
source: "osm",
|
||||
sourceId: "w125",
|
||||
properties: {
|
||||
bridge: "movable",
|
||||
height: "18"
|
||||
}
|
||||
});
|
||||
|
||||
expect(properties.clearance_m).toBeNull();
|
||||
expect(properties.clearance_label).toBeNull();
|
||||
expect(properties.label).toBe("Klappbrücke");
|
||||
});
|
||||
|
||||
it("normalizes contact aliases, address and database timestamps", () => {
|
||||
const properties = normalizeMarineFeatureProperties({
|
||||
layer: "locks",
|
||||
@@ -71,7 +217,7 @@ describe("marine feature normalization", () => {
|
||||
"contact:email": "schleuse@example.test",
|
||||
"seamark:lock_basin:communication_channel": "18",
|
||||
opening_hours: "24/7",
|
||||
operator: "WSV",
|
||||
"operator:name": "WSV",
|
||||
"addr:street": "Fährstraße",
|
||||
"addr:housenumber": "1",
|
||||
"addr:postcode": "59071",
|
||||
@@ -92,6 +238,20 @@ describe("marine feature normalization", () => {
|
||||
expect(properties.updatedAt).toBe("2026-07-19T08:30:00.000Z");
|
||||
});
|
||||
|
||||
it("normalizes lock-specific opening hours for event details", () => {
|
||||
const properties = normalizeMarineFeatureProperties({
|
||||
layer: "locks",
|
||||
name: "Schleuse Rahe",
|
||||
source: "osm",
|
||||
sourceId: "w126",
|
||||
properties: {
|
||||
"lock:opening_hours": "Mo-Su 07:00-20:00"
|
||||
}
|
||||
});
|
||||
|
||||
expect(properties.openingHours).toBe("Mo-Su 07:00-20:00");
|
||||
});
|
||||
|
||||
it("prefers direct contact fields and returns stable null values when details are absent", () => {
|
||||
const properties = normalizeMarineFeatureProperties({
|
||||
layer: "harbours",
|
||||
@@ -153,6 +313,23 @@ describe("marine feature normalization", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function featurePool(...results: Array<{ rows: unknown[] }>): FeatureDatabase & {
|
||||
query: ReturnType<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", () => {
|
||||
it("returns one stable facility feature and reports how many raw objects were merged", () => {
|
||||
const result = deduplicateMarineContactFeatures([
|
||||
|
||||
+25
-59
@@ -4,7 +4,6 @@ import {
|
||||
calculateRouteGuidance,
|
||||
haversineDistanceNm,
|
||||
type BoatProfile,
|
||||
type VoyageHarbour,
|
||||
type AppConfig,
|
||||
type Coordinate,
|
||||
type MarineForecast,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
import {
|
||||
createRoute,
|
||||
getConfig,
|
||||
getMapFeatures,
|
||||
getMarineForecast,
|
||||
getNavigationData,
|
||||
getNearestTide
|
||||
@@ -42,6 +40,7 @@ import { useBoatProfile } from "./hooks/useBoatProfile";
|
||||
import { useCourseAssistant } from "./hooks/useCourseAssistant";
|
||||
import { useGeolocation } from "./hooks/useGeolocation";
|
||||
import { useMarineData } from "./hooks/useMarineData";
|
||||
import { useRouteEventFeatures } from "./hooks/useRouteEventFeatures";
|
||||
import type { OfflineVoyage } from "./lib/offline-route";
|
||||
import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile";
|
||||
import { isOpenRouteWarning } from "./lib/route-warnings";
|
||||
@@ -51,12 +50,6 @@ import {
|
||||
type UpcomingRouteEvent
|
||||
} from "./routeEvents";
|
||||
import type { RouteWeatherReport } from "./routeWeatherReport";
|
||||
import {
|
||||
routeFeatureBounds,
|
||||
routeLocksFromFeatures,
|
||||
voyageHarboursFromFeatures,
|
||||
type RouteLock
|
||||
} from "./voyageHarbours";
|
||||
|
||||
type PickMode = "start" | "destination" | "waypoint" | null;
|
||||
|
||||
@@ -141,16 +134,21 @@ export function App() {
|
||||
});
|
||||
const [routeOptions, setRouteOptions] = useState<RouteResult[]>([]);
|
||||
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 [routeWeatherLoading, setRouteWeatherLoading] = useState(false);
|
||||
const [routeWeatherError, setRouteWeatherError] = useState<string | null>(null);
|
||||
const [navigationData, setNavigationData] = useState<NavigationDataSnapshot | null>(null);
|
||||
const [navigationDataLoading, setNavigationDataLoading] = useState(false);
|
||||
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 [routeTidesLoading, setRouteTidesLoading] = useState(false);
|
||||
const [routeTidesError, setRouteTidesError] = useState<string | null>(null);
|
||||
@@ -262,44 +260,6 @@ export function App() {
|
||||
};
|
||||
}, [route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!route) {
|
||||
setRouteHarbours([]);
|
||||
setRouteLocks([]);
|
||||
setRouteHarboursLoading(false);
|
||||
setRouteHarboursError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setRouteHarboursLoading(true);
|
||||
getMapFeatures({ bbox: routeFeatureBounds(route, 2.5), layers: ["harbours", "locks"] })
|
||||
.then((collection) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
setRouteHarbours(voyageHarboursFromFeatures(collection));
|
||||
setRouteLocks(routeLocksFromFeatures(collection, route));
|
||||
setRouteHarboursError(null);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (active) {
|
||||
setRouteHarbours([]);
|
||||
setRouteLocks([]);
|
||||
setRouteHarboursError(error instanceof Error ? error.message : "Häfen entlang der Route nicht erreichbar");
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setRouteHarboursLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [route]);
|
||||
|
||||
const routeWarningCount = useMemo(
|
||||
() => route?.warnings.filter(isOpenRouteWarning).length ?? 0,
|
||||
[route]
|
||||
@@ -433,7 +393,7 @@ export function App() {
|
||||
route,
|
||||
harbours: routeHarbours,
|
||||
locks: routeLocks,
|
||||
bridges: routeWeatherReport?.bridgeReport?.bridges ?? [],
|
||||
bridges: routeBridgeReport?.bridges ?? [],
|
||||
progressNm: routeProgress.distanceNm,
|
||||
etaBasis: routeEventEtaBasis
|
||||
})
|
||||
@@ -444,7 +404,7 @@ export function App() {
|
||||
routeHarbours,
|
||||
routeLocks,
|
||||
routeProgress.distanceNm,
|
||||
routeWeatherReport?.bridgeReport?.bridges
|
||||
routeBridgeReport?.bridges
|
||||
]
|
||||
);
|
||||
const eventWarningCount = useMemo(
|
||||
@@ -485,6 +445,8 @@ export function App() {
|
||||
)
|
||||
? "alarm"
|
||||
: "caution"
|
||||
: routeEventFeatures.error
|
||||
? "caution"
|
||||
: route
|
||||
? "active"
|
||||
: "idle",
|
||||
@@ -511,6 +473,7 @@ export function App() {
|
||||
marineData.loading,
|
||||
marineData.tide?.updatedAt,
|
||||
route,
|
||||
routeEventFeatures.error,
|
||||
routeEvents,
|
||||
routeWeatherReport?.severity
|
||||
]
|
||||
@@ -526,7 +489,7 @@ export function App() {
|
||||
|
||||
import("./routeWeatherReport")
|
||||
.then(({ createRouteWeatherReport }) =>
|
||||
createRouteWeatherReport(result, vesselProfile, getMarineForecast, getMapFeatures)
|
||||
createRouteWeatherReport(result, vesselProfile, getMarineForecast)
|
||||
)
|
||||
.then((report) => {
|
||||
if (routeWeatherRequestId.current === reportRequestId) {
|
||||
@@ -812,7 +775,7 @@ export function App() {
|
||||
activeTool === "conditions"
|
||||
? marineData.loading || routeWeatherLoading || routeTidesLoading
|
||||
: activeTool === "upcoming"
|
||||
? routeHarboursLoading || routeWeatherLoading
|
||||
? routeEventFeatures.loading
|
||||
: activeTool === "route"
|
||||
? routeLoading
|
||||
: false;
|
||||
@@ -905,7 +868,7 @@ export function App() {
|
||||
badges={{
|
||||
boat: boatProfileStored ? null : "!",
|
||||
anchor: anchorAlarm ? "!" : null,
|
||||
upcoming: eventWarningCount,
|
||||
upcoming: eventWarningCount || (routeEventFeatures.error ? "!" : null),
|
||||
route: guidanceAlarm ? "!" : routeWarningCount
|
||||
}}
|
||||
/>
|
||||
@@ -991,8 +954,8 @@ export function App() {
|
||||
<LazyUpcomingEventsPanel
|
||||
events={routeEvents}
|
||||
hasRoute={Boolean(route)}
|
||||
loading={routeHarboursLoading || routeWeatherLoading}
|
||||
error={routeHarboursError}
|
||||
loading={routeEventFeatures.loading}
|
||||
error={routeEventFeatures.error}
|
||||
onShowOnMap={showRouteEventOnMap}
|
||||
/>
|
||||
</LazyContent>
|
||||
@@ -1034,9 +997,12 @@ export function App() {
|
||||
navigationDataLoading={navigationDataLoading}
|
||||
navigationDataError={navigationDataError}
|
||||
routeHarbours={routeHarbours}
|
||||
routeHarboursLoading={routeHarboursLoading}
|
||||
routeHarboursError={routeHarboursError}
|
||||
routeHarboursLoading={routeEventFeatures.harboursLoading}
|
||||
routeHarboursError={routeEventFeatures.errors.harbours}
|
||||
routeLocks={routeLocks}
|
||||
bridgeReport={routeBridgeReport}
|
||||
bridgesLoading={routeEventFeatures.bridgesLoading}
|
||||
bridgesError={routeEventFeatures.errors.bridges}
|
||||
routeTides={routeTides}
|
||||
routeTidesLoading={routeTidesLoading}
|
||||
routeTidesError={routeTidesError}
|
||||
|
||||
+38
-3
@@ -9,10 +9,31 @@ import type {
|
||||
} from "@watermaps/shared";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
|
||||
export type MapFeatureMetadata = {
|
||||
source?: "postgis" | "demo" | "unavailable" | string;
|
||||
warning?: string;
|
||||
deduplication?: {
|
||||
inputPoiCount: number;
|
||||
outputPoiCount: number;
|
||||
mergedObjectCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MapFeatureCollection = FeatureCollection & {
|
||||
metadata?: MapFeatureMetadata;
|
||||
};
|
||||
|
||||
export class FeatureDataUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "FeatureDataUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
async function getJson<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
throw new Error(await responseErrorMessage(response));
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -84,10 +105,24 @@ export function getMapFeatures(params: {
|
||||
bbox: [number, number, number, number];
|
||||
layers: string[];
|
||||
signal?: AbortSignal;
|
||||
}): Promise<FeatureCollection> {
|
||||
}): Promise<MapFeatureCollection> {
|
||||
const search = new URLSearchParams({
|
||||
bbox: params.bbox.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;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
type VoyageAmenity,
|
||||
type VoyageHarbour
|
||||
} from "@watermaps/shared";
|
||||
import type { RouteWeatherReport } from "../routeWeatherReport";
|
||||
import type {
|
||||
RouteBridgeReport,
|
||||
RouteWeatherReport
|
||||
} from "../routeWeatherReport";
|
||||
import type { OfflineVoyage } from "../lib/offline-route";
|
||||
import {
|
||||
boatCategoryLabel,
|
||||
@@ -63,6 +66,9 @@ type RoutePlannerProps = {
|
||||
routeHarboursLoading?: boolean;
|
||||
routeHarboursError?: string | null;
|
||||
routeLocks?: RouteLock[];
|
||||
bridgeReport?: RouteBridgeReport | null;
|
||||
bridgesLoading?: boolean;
|
||||
bridgesError?: string | null;
|
||||
routeTides?: RouteTidePlan | null;
|
||||
routeTidesLoading?: boolean;
|
||||
routeTidesError?: string | null;
|
||||
@@ -120,6 +126,9 @@ export function RoutePlanner({
|
||||
routeHarboursLoading = false,
|
||||
routeHarboursError = null,
|
||||
routeLocks = [],
|
||||
bridgeReport = null,
|
||||
bridgesLoading = false,
|
||||
bridgesError = null,
|
||||
routeTides = null,
|
||||
routeTidesLoading = false,
|
||||
routeTidesError = null,
|
||||
@@ -739,7 +748,13 @@ export function RoutePlanner({
|
||||
</details>
|
||||
)}
|
||||
|
||||
{operationalPanelsVisible && (weatherLoading || weatherReport || weatherError) && (
|
||||
{operationalPanelsVisible &&
|
||||
(weatherLoading ||
|
||||
weatherReport ||
|
||||
weatherError ||
|
||||
bridgesLoading ||
|
||||
bridgeReport ||
|
||||
bridgesError) && (
|
||||
<section className="route-weather-report" aria-label="Fahrtbericht">
|
||||
<div className="route-weather-heading">
|
||||
<CloudSun size={16} aria-hidden="true" />
|
||||
@@ -748,6 +763,7 @@ export function RoutePlanner({
|
||||
</div>
|
||||
|
||||
{weatherLoading && <p className="route-weather-message">Wetterbericht wird geladen</p>}
|
||||
{bridgesLoading && <p className="route-weather-message">Brücken werden geladen</p>}
|
||||
|
||||
{weatherReport && (
|
||||
<>
|
||||
@@ -798,35 +814,6 @@ export function RoutePlanner({
|
||||
{weatherReport.unavailableSamples} Messpunkt nicht erreichbar
|
||||
</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}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -244,6 +244,15 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.upcoming-event-row-detail-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: #196f5c;
|
||||
font-size: 9px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.upcoming-event-contact-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
CalendarClock,
|
||||
Clock3,
|
||||
Globe2,
|
||||
Info,
|
||||
Landmark,
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
@@ -308,6 +309,10 @@ function EventRow({
|
||||
<span className="upcoming-event-row-progress">
|
||||
<strong>{formatDistance(event.remainingNm)}</strong>
|
||||
<small>{formatEta(event)}</small>
|
||||
<span className="upcoming-event-row-detail-action" aria-hidden="true">
|
||||
<Info size={13} />
|
||||
Infos
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -456,6 +461,9 @@ function KindSpecificDetails({ event }: { event: UpcomingRouteEvent }) {
|
||||
<DetailRow label="Reserve">
|
||||
{bridgeMargin(event.feature.marginM)}
|
||||
</DetailRow>
|
||||
<DetailRow label="Betreiber">
|
||||
{event.feature.operator ?? "Nicht hinterlegt"}
|
||||
</DetailRow>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -566,14 +574,11 @@ function PanelState({
|
||||
}
|
||||
|
||||
function eventContact(event: UpcomingRouteEvent) {
|
||||
if (event.kind === "harbour" || event.kind === "lock") {
|
||||
return {
|
||||
phone: event.feature.phone ?? null,
|
||||
website: event.feature.website ?? null,
|
||||
email: event.feature.email ?? null
|
||||
};
|
||||
}
|
||||
return { phone: null, website: null, email: null };
|
||||
}
|
||||
|
||||
function importantFact(event: UpcomingRouteEvent) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
haversineDistanceNm,
|
||||
orderWaypointsAlongRoute,
|
||||
type Coordinate,
|
||||
type RouteResult,
|
||||
@@ -12,11 +13,13 @@ export type RouteEventKind = "harbour" | "lock" | "bridge";
|
||||
export type RouteEventCorridors = Record<RouteEventKind, number>;
|
||||
|
||||
export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly<RouteEventCorridors> = Object.freeze({
|
||||
harbour: 1.5,
|
||||
harbour: 0.5,
|
||||
lock: 0.25,
|
||||
bridge: 0.08
|
||||
});
|
||||
|
||||
const CROSS_KIND_DUPLICATE_DISTANCE_NM = 0.1;
|
||||
|
||||
export type RouteEventEtaSpeedSource = "gps-sog" | "vessel-cruise-speed";
|
||||
export type RouteEventEtaReferenceSource = "current-time" | "route-departure";
|
||||
|
||||
@@ -148,7 +151,7 @@ export function upcomingRouteEvents(
|
||||
input.route
|
||||
);
|
||||
|
||||
return projected.flatMap<UpcomingRouteEvent>((projection) => {
|
||||
const events = projected.flatMap<UpcomingRouteEvent>((projection) => {
|
||||
const candidate = candidatesByProjectionId.get(projection.id);
|
||||
if (
|
||||
!candidate ||
|
||||
@@ -179,6 +182,8 @@ export function upcomingRouteEvents(
|
||||
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
|
||||
}
|
||||
});
|
||||
|
||||
return withoutLockHarbourDuplicates(events);
|
||||
}
|
||||
|
||||
export function nextRouteEventsByKind(
|
||||
@@ -320,3 +325,45 @@ function timestampValue(value: string | number | Date): number | null {
|
||||
: Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Some source objects are classified both as a lock and as a harbour. Preserve
|
||||
* the operationally more specific lock event only when the normalized names
|
||||
* match and both source coordinates clearly describe the same place.
|
||||
*/
|
||||
function withoutLockHarbourDuplicates(
|
||||
events: UpcomingRouteEvent[]
|
||||
): UpcomingRouteEvent[] {
|
||||
const locksByName = new Map<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();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,10 @@ export type RouteBridgeAssessment = {
|
||||
marginM: number | null;
|
||||
status: RouteBridgeStatus;
|
||||
source: string;
|
||||
phone?: string | null;
|
||||
website?: string | null;
|
||||
email?: string | null;
|
||||
operator?: string | null;
|
||||
};
|
||||
|
||||
export type RouteBridgeReport = {
|
||||
@@ -79,6 +83,9 @@ const SAMPLE_TARGETS: Array<{ label: RouteWeatherSample["label"]; ratio: number
|
||||
const ROUTE_BRIDGE_BBOX_MARGIN_DEG = 0.02;
|
||||
const ROUTE_BRIDGE_MAX_DISTANCE_NM = 0.08;
|
||||
const BRIDGE_TIGHT_MARGIN_M = 0.5;
|
||||
const SAME_NAMED_BRIDGE_DISTANCE_NM = 0.03;
|
||||
const UNNAMED_BRIDGE_DISTANCE_NM = 0.005;
|
||||
const NAMED_TO_UNNAMED_BRIDGE_DISTANCE_NM = 0.01;
|
||||
|
||||
export async function createRouteWeatherReport(
|
||||
route: RouteResult,
|
||||
@@ -451,7 +458,29 @@ function bridgeAssessmentFromFeature(
|
||||
requiredAirDraftM,
|
||||
marginM,
|
||||
status,
|
||||
source: stringProperty(properties.source) ?? "OSM/Geofabrik"
|
||||
source: stringProperty(properties.source) ?? "OSM/Geofabrik",
|
||||
phone: firstStringProperty(properties, [
|
||||
"contact:phone",
|
||||
"phone",
|
||||
"telephone",
|
||||
"contact_phone"
|
||||
]),
|
||||
website: firstStringProperty(properties, [
|
||||
"contact:website",
|
||||
"website",
|
||||
"url",
|
||||
"contact_website"
|
||||
]),
|
||||
email: firstStringProperty(properties, [
|
||||
"contact:email",
|
||||
"email",
|
||||
"contact_email"
|
||||
]),
|
||||
operator: firstStringProperty(properties, [
|
||||
"operator",
|
||||
"operator:name",
|
||||
"owner"
|
||||
])
|
||||
};
|
||||
}
|
||||
|
||||
@@ -572,11 +601,123 @@ function dedupeBridges(bridges: RouteBridgeAssessment[]) {
|
||||
const byId = new Map<string, RouteBridgeAssessment>();
|
||||
for (const bridge of bridges) {
|
||||
const existing = byId.get(bridge.id);
|
||||
if (!existing || bridge.distanceNm < existing.distanceNm) {
|
||||
byId.set(bridge.id, bridge);
|
||||
byId.set(bridge.id, existing ? mergeBridgeAssessments(existing, bridge) : bridge);
|
||||
}
|
||||
|
||||
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[][] {
|
||||
@@ -770,6 +911,19 @@ function stringProperty(value: unknown) {
|
||||
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) {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
VoyageHarbour
|
||||
} from "@watermaps/shared";
|
||||
import {
|
||||
DEFAULT_ROUTE_EVENT_CORRIDORS_NM,
|
||||
nextRouteEventsByKind,
|
||||
upcomingRouteEvents
|
||||
} from "../src/routeEvents";
|
||||
@@ -80,6 +81,66 @@ describe("upcomingRouteEvents", () => {
|
||||
expect(events.map((event) => event.id)).toEqual(["detour"]);
|
||||
});
|
||||
|
||||
it("uses a focused half-mile harbour corridor by default", () => {
|
||||
expect(DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour).toBe(0.5);
|
||||
|
||||
const events = upcomingRouteEvents({
|
||||
route,
|
||||
harbours: [
|
||||
harbour("near-harbour", 0.4, 0.008),
|
||||
harbour("unrelated-harbour", 0.5, 0.01)
|
||||
]
|
||||
});
|
||||
|
||||
expect(events.map((event) => event.id)).toEqual(["near-harbour"]);
|
||||
});
|
||||
|
||||
it("prefers a nearby same-named lock over a duplicate harbour classification", () => {
|
||||
const duplicateHarbour = {
|
||||
...harbour("harbour-lock", 0.4, 0.0005),
|
||||
name: "Nesserländer Schleuse"
|
||||
};
|
||||
const duplicateLock = {
|
||||
...lock("lock", 0.4, 0, 0, 0),
|
||||
name: "Nesserlander Schleuse"
|
||||
};
|
||||
const distinctHarbour = {
|
||||
...harbour("real-harbour", 0.6, 0),
|
||||
name: "Stadthafen"
|
||||
};
|
||||
|
||||
const events = upcomingRouteEvents({
|
||||
route,
|
||||
harbours: [duplicateHarbour, distinctHarbour],
|
||||
locks: [duplicateLock]
|
||||
});
|
||||
|
||||
expect(events.map((event) => `${event.kind}:${event.name}`)).toEqual([
|
||||
"lock:Nesserlander Schleuse",
|
||||
"harbour:Stadthafen"
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not merge same-named facilities that are spatially distinct", () => {
|
||||
const events = upcomingRouteEvents({
|
||||
route,
|
||||
harbours: [
|
||||
{
|
||||
...harbour("harbour", 0.4, 0.003),
|
||||
name: "Kanalschleuse"
|
||||
}
|
||||
],
|
||||
locks: [
|
||||
{
|
||||
...lock("lock", 0.4, 0, 0, 0),
|
||||
name: "Kanalschleuse"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(events.map((event) => event.kind).sort()).toEqual(["harbour", "lock"]);
|
||||
});
|
||||
|
||||
it("calculates ETA and retains both speed and reference-time provenance", () => {
|
||||
const [event] = upcomingRouteEvents({
|
||||
route,
|
||||
|
||||
@@ -528,6 +528,7 @@ describe("RoutePlanner", () => {
|
||||
weatherReport={weatherReportFixture}
|
||||
weatherLoading={false}
|
||||
weatherError={null}
|
||||
bridgeReport={weatherReportFixture.bridgeReport}
|
||||
loading={false}
|
||||
error={null}
|
||||
pickMode={null}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MarineForecast, RouteResult } from "@watermaps/shared";
|
||||
import { createRouteWeatherReport, summarizeRouteWeather } from "../src/routeWeatherReport";
|
||||
import {
|
||||
createRouteBridgeReport,
|
||||
createRouteWeatherReport,
|
||||
summarizeRouteWeather
|
||||
} from "../src/routeWeatherReport";
|
||||
|
||||
describe("route weather report", () => {
|
||||
it("samples start, middle and destination forecasts", async () => {
|
||||
@@ -81,7 +85,9 @@ describe("route weather report", () => {
|
||||
clearance_m: 2.7,
|
||||
clearance_label: "H 2.7 m",
|
||||
label: "Niedrige Brücke H 2.7 m",
|
||||
source: "OSM/Geofabrik"
|
||||
source: "OSM/Geofabrik",
|
||||
phone: "+49 491 234",
|
||||
website: "https://bridge.example"
|
||||
},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
@@ -120,6 +126,75 @@ describe("route weather report", () => {
|
||||
expect(report.bridgeReport?.checkedCount).toBe(2);
|
||||
expect(report.bridgeReport?.summary).toContain("Nicht passierbar");
|
||||
expect(report.bridgeReport?.bridges[0]?.name).toBe("Niedrige Brücke");
|
||||
expect(report.bridgeReport?.bridges[0]?.phone).toBe("+49 491 234");
|
||||
expect(report.bridgeReport?.bridges[0]?.website).toBe("https://bridge.example");
|
||||
});
|
||||
|
||||
it("deduplicates only conservatively matching bridge ways and keeps the richest safe assessment", async () => {
|
||||
const route: RouteResult = {
|
||||
...routeFixture,
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [[0, 0], [1, 0]]
|
||||
},
|
||||
distanceNm: 60
|
||||
};
|
||||
const point = (
|
||||
id: string,
|
||||
lon: number,
|
||||
lat: number,
|
||||
properties: Record<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", () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("UpcomingEventsPanel", () => {
|
||||
expect.stringContaining("Brücke Mitte"),
|
||||
expect.stringContaining("Hafen Weit")
|
||||
]);
|
||||
expect(within(list).getAllByText("Infos")).toHaveLength(3);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
|
||||
expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute(
|
||||
@@ -81,6 +82,12 @@ describe("UpcomingEventsPanel", () => {
|
||||
expect(within(detail).getByText("3.8 m")).toBeVisible();
|
||||
const reserve = within(detail).getByText("Reserve").closest("div");
|
||||
expect(reserve).toHaveTextContent("0.4 m Reserve");
|
||||
expect(
|
||||
within(detail).getByRole("link", { name: "Brücke Mitte anrufen" })
|
||||
).toHaveAttribute("href", "tel:+4949123457");
|
||||
expect(
|
||||
within(detail).getByRole("link", { name: "Website von Brücke Mitte öffnen" })
|
||||
).toHaveAttribute("href", "https://bridge.example/");
|
||||
expect(within(detail).queryByRole("button", { name: "Auf Karte zeigen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -180,7 +187,10 @@ const events: UpcomingRouteEvent[] = [
|
||||
requiredAirDraftM: 3.8,
|
||||
marginM: 0.4,
|
||||
status: "tight",
|
||||
source: "Test"
|
||||
source: "Test",
|
||||
phone: "+49 49 123457",
|
||||
website: "bridge.example",
|
||||
operator: "Brückenamt"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -17,6 +17,12 @@ WATERMAPS_RUNTIME_DIR=/srv/watermaps-runtime
|
||||
# Verhindert Speicherabbrüche beim vollständigen DE/NL-Indexaufbau auf 4-GB-Servern.
|
||||
WATERMAPS_SWAP_SIZE_GB=4
|
||||
|
||||
# Persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten.
|
||||
# Vor dem ersten Deployment beispielsweise mit `openssl rand -hex 32`
|
||||
# erzeugen. Das Secret muss mindestens 24 Zeichen lang sein.
|
||||
WATERMAPS_POSTGRES_PASSWORD=REPLACE_WITH_RANDOM_32_BYTE_HEX_SECRET
|
||||
WATERMAPS_POSTGRES_IMAGE=postgis/postgis:16-3.4
|
||||
|
||||
# Gitea Container Registry. Die commitgenauen App-Referenzen werden beim
|
||||
# Deployment separat erzeugt und niemals hier von Hand auf `latest` gesetzt.
|
||||
WATERMAPS_REGISTRY=gitea.incoso.eu
|
||||
@@ -29,6 +35,10 @@ WATERMAPS_CERTBOT_IMAGE=certbot/certbot:v5.7.0
|
||||
# Niederlande erneut geprüft und der Routingindex neu gebaut werden sollen.
|
||||
WATERMAPS_REBUILD_ROUTE_DATA=false
|
||||
|
||||
# Erzwingt unabhängig vom Bereitschaftsmarker einen vollständigen Neuimport
|
||||
# der Marine-Features aus den vorhandenen Deutschland-/Niederlande-PBFs.
|
||||
WATERMAPS_REBUILD_MARINE_DATA=false
|
||||
|
||||
# Für einen Test gegen Let's Encrypt Staging auf true setzen.
|
||||
# Das damit ausgestellte Zertifikat ist im Browser nicht vertrauenswürdig.
|
||||
WATERMAPS_CERTBOT_STAGING=false
|
||||
|
||||
+64
-21
@@ -1,9 +1,10 @@
|
||||
# Watermaps-Produktion
|
||||
|
||||
Dieser Stack hostet die Watermaps-App und die lokalen Fahrrouten für
|
||||
Deutschland und die Niederlande. Die sichtbaren Kartenkacheln bleiben externe
|
||||
Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; die
|
||||
Watermaps-App ist nur im internen Docker-Netz erreichbar.
|
||||
Dieser Stack hostet die Watermaps-App, die lokalen Fahrrouten sowie eine
|
||||
persistente PostGIS-Datenbank für Häfen, Schleusen, Brücken und Kontaktdaten in
|
||||
Deutschland und den Niederlanden. Die sichtbaren Kartenkacheln bleiben externe
|
||||
Dienste. Öffentlich gebunden werden ausschließlich TCP 80 und 443; App und
|
||||
PostGIS sind nur im internen Docker-Netz erreichbar.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
@@ -12,11 +13,19 @@ cp deploy/.env.production.example deploy/.env.production
|
||||
editor deploy/.env.production
|
||||
```
|
||||
|
||||
Mindestens `WATERMAPS_ACME_EMAIL` muss angepasst werden. Außerdem müssen
|
||||
`WATERMAPS_REGISTRY` und `WATERMAPS_REGISTRY_OWNER` auf die Gitea Container
|
||||
Registry zeigen. Der Hetzner-API-Token gehört **nicht** in diese Datei. Er
|
||||
bleibt lokal in der ignorierten Datei `infra/opentofu/terraform.tfvars`
|
||||
(alternativ kann der Provider `TF_VAR_hcloud_token` lesen).
|
||||
Mindestens `WATERMAPS_ACME_EMAIL` und `WATERMAPS_POSTGRES_PASSWORD` müssen
|
||||
angepasst werden. Für das Datenbankpasswort eignet sich ein URL-unabhängiges
|
||||
Hex-Secret:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
```
|
||||
|
||||
Außerdem müssen `WATERMAPS_REGISTRY` und `WATERMAPS_REGISTRY_OWNER` auf die
|
||||
Gitea Container Registry zeigen. Der Hetzner-API-Token gehört **nicht** in
|
||||
diese Datei. Er bleibt lokal in der ignorierten Datei
|
||||
`infra/opentofu/terraform.tfvars` (alternativ kann der Provider
|
||||
`TF_VAR_hcloud_token` lesen).
|
||||
|
||||
Die private SSH-Keydatei wird ebenfalls nicht gespeichert. Sie kann beim
|
||||
Deployment mit `--identity` oder über `WATERMAPS_SSH_KEY` angegeben werden.
|
||||
@@ -53,8 +62,8 @@ werden nur Tests ausgeführt; Registry-Secrets werden dabei nicht verwendet.
|
||||
Nach einem erfolgreichen Image-Build liest das Skript standardmäßig den
|
||||
OpenTofu-Output `server_ipv4`. Es erlaubt ausschließlich einen sauberen,
|
||||
vollständig committeten Git-Stand und überträgt nur den kleinen Ordner
|
||||
`deploy/`, nicht den Anwendungsquellcode. Die Image-Tags entsprechen exakt
|
||||
`git rev-parse HEAD`.
|
||||
`deploy/` sowie das kanonische `database/schema.sql`, nicht den
|
||||
Anwendungsquellcode. Die Image-Tags entsprechen exakt `git rev-parse HEAD`.
|
||||
|
||||
Ist die Registry privat, werden einmalig beziehungsweise nach Tokenwechsel
|
||||
lokale Pull-Zugangsdaten mitgegeben:
|
||||
@@ -78,20 +87,36 @@ Anmeldung gültig ist:
|
||||
--identity ~/.ssh/watermaps_hetzner_ed25519
|
||||
```
|
||||
|
||||
Das Serverskript lädt App, Routingdaten-Builder, Nginx und Certbot mit
|
||||
`docker compose pull`. Die beiden commitgenauen Gitea-Tags werden anschließend
|
||||
in ihre unveränderlichen Registry-Digests aufgelöst. Erst danach werden die
|
||||
Container mit `--no-build` gestartet. Der Produktionsserver benötigt deshalb
|
||||
weder Git noch Node/npm oder den Quellcode.
|
||||
Das Serverskript lädt App, den kombinierten Routing-/Feature-Daten-Builder,
|
||||
PostGIS, Nginx und Certbot mit `docker compose pull`. Die beiden commitgenauen
|
||||
Gitea-Tags werden anschließend in ihre unveränderlichen Registry-Digests
|
||||
aufgelöst. Erst danach werden die Container mit `--no-build` gestartet. Der
|
||||
Produktionsserver benötigt deshalb weder Git noch Node/npm oder den Quellcode.
|
||||
|
||||
Der erste Datenaufbau lädt die Geofabrik-Extrakte für Deutschland und die
|
||||
Niederlande herunter und kann entsprechend der Serverleistung längere Zeit
|
||||
dauern. Der produktive Index liegt auf dem Server unter:
|
||||
Niederlande, baut den Fahrroutenindex und importiert daraus die
|
||||
`marine_features`. Je nach Serverleistung kann dies längere Zeit dauern.
|
||||
App und Nginx werden beim ersten Release erst gestartet, wenn sowohl der
|
||||
Routingindex als auch ein nicht leerer, zur PBF-Prüfsumme passender Bestand an
|
||||
Häfen, Schleusen und Brücken geprüft wurde. Ein HTTP-Healthcheck allein kann
|
||||
damit keine leere Ereignisdatenbank freigeben.
|
||||
|
||||
Die produktiven Daten liegen auf dem persistenten Servervolume:
|
||||
|
||||
```text
|
||||
/srv/watermaps-data/local/germany-netherlands-fairways.json
|
||||
/srv/watermaps-data/local/.marine-features.ready
|
||||
/srv/watermaps-data/postgres/
|
||||
/srv/watermaps-data/tmp/
|
||||
```
|
||||
|
||||
PostGIS veröffentlicht keinen Host-Port. Die App verbindet sich im privaten
|
||||
Compose-Netz; das Passwort wird separat über `PGPASSWORD` übergeben und muss
|
||||
nicht URL-kodiert in `DATABASE_URL` dupliziert werden. Auch die temporären,
|
||||
potenziell mehrere Gigabyte großen Filter- und GeoJSON-Dateien des Imports
|
||||
liegen unter `tmp/` auf diesem Volume und nicht im Docker-Overlay der
|
||||
Rootdisk; nach jedem Importlauf werden sie entfernt.
|
||||
|
||||
Der Upload wartet zuerst auf SSH und den Abschluss von Cloud-init. Anschließend
|
||||
startet und prüft er `watermaps-volume-setup.service`. Ohne tatsächlich unter
|
||||
`/srv/watermaps-data` eingehängtes Volume wird kein Download gestartet, damit
|
||||
@@ -108,7 +133,10 @@ HTTP-Anfragen erhalten 404.
|
||||
Jedes erfolgreiche Release wird mit Commit-SHA und den aufgelösten
|
||||
Image-Digests unter `/srv/watermaps-runtime/deployments/` gespeichert. Scheitert
|
||||
ein Deployment nach dem Containerwechsel, startet `deploy.sh` automatisch das
|
||||
vorherige Release und führt die Health- und Routentests erneut aus.
|
||||
vorherige Release und führt die Health-, Routen- und Featuretests erneut aus.
|
||||
Ein Rollback wechselt nur die unveränderlichen App-/Builder-Images. Die
|
||||
persistente PostGIS-Datenbank bleibt erhalten; das Schema und der Import sind
|
||||
aufwärtskompatibel und idempotent.
|
||||
|
||||
Das unmittelbar vorherige Release lässt sich auch manuell aktivieren:
|
||||
|
||||
@@ -153,11 +181,26 @@ HTTPS um.
|
||||
`bootstrap-server.sh` installiert zwei systemd-Timer:
|
||||
|
||||
- `watermaps-route-update.timer`: täglich neue Deutschland- und
|
||||
Niederlande-Daten; bei Build- oder Routentestfehler bleibt der vorherige
|
||||
Index aktiv.
|
||||
Niederlande-Daten. Der Fahrroutenindex wird atomar aktiviert. Der
|
||||
Feature-Importer aktualisiert beide Länder per Upsert und entfernt
|
||||
verschwundene Datensätze erst, nachdem beide Importe vollständig waren;
|
||||
dabei werden ausschließlich OSM-Zeilen bereinigt. EuRIS- und
|
||||
Website-Anreicherungen bleiben erhalten.
|
||||
- `watermaps-certbot-renew.timer`: zweimal täglich Certbot-Prüfung mit
|
||||
anschließendem Nginx-Reload.
|
||||
|
||||
Ein vollständiger manueller Feature-Neuimport lässt sich über die
|
||||
Option `--force-marine` erzwingen:
|
||||
|
||||
```bash
|
||||
/opt/watermaps/deploy/scripts/update-route-data.sh --force-marine
|
||||
```
|
||||
|
||||
`WATERMAPS_REBUILD_MARINE_DATA=true` in `.env.production` erzwingt den
|
||||
Neuimport stattdessen beim nächsten regulären Deployment. Der systemd-Dienst
|
||||
hat bewusst kein Startzeitlimit, da Download, Indexbau und Import auf kleineren
|
||||
Servern deutlich länger als 90 Sekunden dauern können.
|
||||
|
||||
Status und Logs:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -7,7 +7,8 @@ services:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 5174
|
||||
DATABASE_URL: ""
|
||||
DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
|
||||
PGPASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt}
|
||||
REDIS_URL: ""
|
||||
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
|
||||
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
|
||||
@@ -20,6 +21,9 @@ services:
|
||||
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/local
|
||||
target: /data
|
||||
read_only: true
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
security_opt:
|
||||
@@ -32,7 +36,7 @@ services:
|
||||
- node
|
||||
- -e
|
||||
- >-
|
||||
fetch('http://127.0.0.1:5174/health')
|
||||
fetch('http://127.0.0.1:5174/ready')
|
||||
.then(response => { if (!response.ok) process.exit(1) })
|
||||
.catch(() => process.exit(1))
|
||||
interval: 15s
|
||||
@@ -40,6 +44,38 @@ services:
|
||||
start_period: 15s
|
||||
retries: 5
|
||||
|
||||
postgres:
|
||||
image: ${WATERMAPS_POSTGRES_IMAGE:-postgis/postgis:16-3.4}
|
||||
environment:
|
||||
POSTGRES_DB: seacompass
|
||||
POSTGRES_USER: seacompass
|
||||
POSTGRES_PASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt}
|
||||
expose:
|
||||
- "5432"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}/postgres
|
||||
target: /var/lib/postgresql/data
|
||||
bind:
|
||||
create_host_path: false
|
||||
- type: bind
|
||||
source: ./database/schema.sql
|
||||
target: /docker-entrypoint-initdb.d/01-schema.sql
|
||||
read_only: true
|
||||
restart: unless-stopped
|
||||
init: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- pg_isready --username "$$POSTGRES_USER" --dbname "$$POSTGRES_DB"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
start_period: 30s
|
||||
retries: 10
|
||||
stop_grace_period: 60s
|
||||
|
||||
nginx:
|
||||
image: ${WATERMAPS_NGINX_IMAGE:-nginx:1.30.4-alpine}
|
||||
depends_on:
|
||||
@@ -92,3 +128,22 @@ services:
|
||||
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}
|
||||
target: /workspace/data
|
||||
restart: "no"
|
||||
|
||||
marine-data:
|
||||
image: ${WATERMAPS_ROUTE_DATA_IMAGE:?WATERMAPS_ROUTE_DATA_IMAGE fehlt}
|
||||
profiles: ["maintenance"]
|
||||
entrypoint: ["/workspace/deploy/scripts/prepare-marine-features.sh"]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
|
||||
PGPASSWORD: ${WATERMAPS_POSTGRES_PASSWORD:?WATERMAPS_POSTGRES_PASSWORD fehlt}
|
||||
TMPDIR: /workspace/data/tmp
|
||||
WATERMAPS_GEOFABRIK_DIR: /workspace/data/geofabrik
|
||||
WATERMAPS_MARINE_MARKER_PATH: /workspace/data/local/.marine-features.ready
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${WATERMAPS_DATA_DIR:-/srv/watermaps-data}
|
||||
target: /workspace/data
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
FROM python:3.12-slim
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes --no-install-recommends ca-certificates curl coreutils libexpat1 \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& python3 -m pip install --no-cache-dir "osmium==4.3.1"
|
||||
&& apt-get install --yes --no-install-recommends \
|
||||
ca-certificates \
|
||||
coreutils \
|
||||
curl \
|
||||
libexpat1 \
|
||||
osmium-tool \
|
||||
postgresql-client \
|
||||
python3 \
|
||||
python3-pip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
RUN python3 -m pip install \
|
||||
--break-system-packages \
|
||||
--disable-pip-version-check \
|
||||
--no-cache-dir \
|
||||
--target /workspace/.tools/python \
|
||||
"osmium==4.3.1" \
|
||||
&& npm install --no-package-lock --no-save "pg@8.16.3"
|
||||
|
||||
COPY scripts ./scripts
|
||||
COPY database ./database
|
||||
COPY deploy/scripts/prepare-route-data.sh ./deploy/scripts/prepare-route-data.sh
|
||||
COPY deploy/scripts/prepare-marine-features.sh ./deploy/scripts/prepare-marine-features.sh
|
||||
|
||||
ENTRYPOINT ["/workspace/deploy/scripts/prepare-route-data.sh"]
|
||||
|
||||
@@ -87,6 +87,8 @@ install -d -m 0755 \
|
||||
"$WATERMAPS_DATA_DIR" \
|
||||
"$WATERMAPS_DATA_DIR/geofabrik" \
|
||||
"$WATERMAPS_DATA_DIR/local" \
|
||||
"$WATERMAPS_DATA_DIR/postgres" \
|
||||
"$WATERMAPS_DATA_DIR/tmp" \
|
||||
"$WATERMAPS_DATA_DIR/certbot" \
|
||||
"$WATERMAPS_DATA_DIR/certbot/www" \
|
||||
"$WATERMAPS_DATA_DIR/certbot/letsencrypt" \
|
||||
@@ -95,6 +97,12 @@ install -d -m 0755 \
|
||||
"$WATERMAPS_RUNTIME_DIR/nginx/conf.d" \
|
||||
"$WATERMAPS_RUNTIME_DIR/locks"
|
||||
|
||||
# postgis/postgis uses the Debian postgres UID/GID 999. Keeping PGDATA on the
|
||||
# mounted Hetzner volume makes event data survive image deployments and host
|
||||
# reboots; the official entrypoint can still repair ownership inside PGDATA.
|
||||
chown 999:999 "$WATERMAPS_DATA_DIR/postgres"
|
||||
chmod 0700 "$WATERMAPS_DATA_DIR/postgres"
|
||||
|
||||
if [[ ! -f "$WATERMAPS_RUNTIME_DIR/nginx/conf.d/default.conf" ]]; then
|
||||
install -m 0644 \
|
||||
"$WM_DEPLOY_DIR/nginx/bootstrap.conf" \
|
||||
|
||||
@@ -33,6 +33,16 @@ wm_load_env() {
|
||||
|
||||
wm_require_safe_absolute_dir "$WATERMAPS_DATA_DIR"
|
||||
wm_require_safe_absolute_dir "$WATERMAPS_RUNTIME_DIR"
|
||||
wm_validate_postgres_password
|
||||
}
|
||||
|
||||
wm_validate_postgres_password() {
|
||||
local password="${WATERMAPS_POSTGRES_PASSWORD:-}"
|
||||
|
||||
[[ "${#password}" -ge 24 &&
|
||||
"$password" != *[[:space:]]* &&
|
||||
"$password" != *REPLACE* ]] ||
|
||||
wm_die "WATERMAPS_POSTGRES_PASSWORD muss ein gesetztes Secret mit mindestens 24 Zeichen ohne Leerzeichen sein."
|
||||
}
|
||||
|
||||
wm_require_safe_absolute_dir() {
|
||||
@@ -162,6 +172,10 @@ wm_route_marker() {
|
||||
printf '%s/local/.germany-netherlands-fairways.ready\n' "$WATERMAPS_DATA_DIR"
|
||||
}
|
||||
|
||||
wm_marine_marker() {
|
||||
printf '%s/local/.marine-features.ready\n' "$WATERMAPS_DATA_DIR"
|
||||
}
|
||||
|
||||
wm_acquire_route_lock() {
|
||||
install -d -m 0755 "$WATERMAPS_RUNTIME_DIR/locks"
|
||||
exec 9>"$WATERMAPS_RUNTIME_DIR/locks/route-update.lock"
|
||||
@@ -288,6 +302,107 @@ wm_route_data_files_ready() {
|
||||
return 0
|
||||
}
|
||||
|
||||
wm_geofabrik_checksum() {
|
||||
local region="$1"
|
||||
local checksum_file="$WATERMAPS_DATA_DIR/geofabrik/${region}-latest.osm.pbf.md5"
|
||||
local checksum
|
||||
|
||||
[[ -s "$checksum_file" ]] || return 1
|
||||
checksum="$(awk 'NR == 1 { print tolower($1) }' "$checksum_file")"
|
||||
[[ "$checksum" =~ ^[0-9a-f]{32}$ ]] || return 1
|
||||
printf '%s\n' "$checksum"
|
||||
}
|
||||
|
||||
wm_postgres_query() {
|
||||
local query="$1"
|
||||
|
||||
wm_compose exec --no-TTY postgres \
|
||||
psql \
|
||||
--no-psqlrc \
|
||||
--username seacompass \
|
||||
--dbname seacompass \
|
||||
--tuples-only \
|
||||
--no-align \
|
||||
--field-separator '|' \
|
||||
--set ON_ERROR_STOP=1 \
|
||||
--command "$query"
|
||||
}
|
||||
|
||||
wm_marine_features_ready() {
|
||||
local marker germany_checksum netherlands_checksum database_counts
|
||||
local total_count harbour_count lock_count bridge_count
|
||||
local marker_format marker_source marker_germany marker_netherlands
|
||||
local marker_total marker_harbours marker_locks marker_bridges
|
||||
|
||||
marker="$(wm_marine_marker)"
|
||||
if [[ ! -s "$marker" ]]; then
|
||||
wm_log "Bereitschaftsmarker für Marine-Features fehlt: $marker"
|
||||
return 1
|
||||
fi
|
||||
|
||||
germany_checksum="$(wm_geofabrik_checksum germany 2>/dev/null || true)"
|
||||
netherlands_checksum="$(wm_geofabrik_checksum netherlands 2>/dev/null || true)"
|
||||
if [[ -z "$germany_checksum" || -z "$netherlands_checksum" ]]; then
|
||||
wm_log "Geofabrik-Prüfsummen für den Marine-Feature-Stand fehlen oder sind ungültig."
|
||||
return 1
|
||||
fi
|
||||
|
||||
marker_format="$(wm_marker_value "$marker" format_version 2>/dev/null || true)"
|
||||
marker_source="$(wm_marker_value "$marker" source 2>/dev/null || true)"
|
||||
marker_germany="$(wm_marker_value "$marker" germany_md5 2>/dev/null || true)"
|
||||
marker_netherlands="$(wm_marker_value "$marker" netherlands_md5 2>/dev/null || true)"
|
||||
marker_total="$(wm_marker_value "$marker" total_osm_features 2>/dev/null || true)"
|
||||
marker_harbours="$(wm_marker_value "$marker" harbour_count 2>/dev/null || true)"
|
||||
marker_locks="$(wm_marker_value "$marker" lock_count 2>/dev/null || true)"
|
||||
marker_bridges="$(wm_marker_value "$marker" bridge_count 2>/dev/null || true)"
|
||||
|
||||
if [[ "$marker_format" != "1" ||
|
||||
"$marker_source" != "germany+netherlands" ||
|
||||
"$marker_germany" != "$germany_checksum" ||
|
||||
"$marker_netherlands" != "$netherlands_checksum" ]]; then
|
||||
wm_log "Marine-Feature-Marker passt nicht zu den aktuellen Deutschland-/Niederlande-Snapshots."
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! database_counts="$(
|
||||
wm_postgres_query "
|
||||
SELECT
|
||||
count(*)::bigint,
|
||||
count(*) FILTER (WHERE layer = 'harbours')::bigint,
|
||||
count(*) FILTER (WHERE layer = 'locks')::bigint,
|
||||
count(*) FILTER (WHERE layer = 'bridges')::bigint
|
||||
FROM marine_features
|
||||
WHERE source = 'osm';
|
||||
"
|
||||
)"; then
|
||||
wm_log "Marine-Feature-Tabellen sind in PostGIS nicht abfragbar."
|
||||
return 1
|
||||
fi
|
||||
database_counts="${database_counts//[[:space:]]/}"
|
||||
IFS='|' read -r total_count harbour_count lock_count bridge_count <<<"$database_counts"
|
||||
|
||||
for count in "$total_count" "$harbour_count" "$lock_count" "$bridge_count"; do
|
||||
if [[ ! "$count" =~ ^[1-9][0-9]*$ ]]; then
|
||||
wm_log "PostGIS enthält keine vollständigen OSM-Hafen-/Schleusen-/Brückendaten."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$marker_total" != "$total_count" ||
|
||||
"$marker_harbours" != "$harbour_count" ||
|
||||
"$marker_locks" != "$lock_count" ||
|
||||
"$marker_bridges" != "$bridge_count" ]]; then
|
||||
wm_log "Marine-Feature-Marker stimmt nicht mit den OSM-Zeilen in PostGIS überein."
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
wm_assert_marine_features() {
|
||||
wm_marine_features_ready ||
|
||||
wm_die "Marine-Features sind nicht vollständig in PostGIS bereit."
|
||||
}
|
||||
|
||||
wm_wait_for_health() {
|
||||
local service="$1"
|
||||
local timeout_seconds="${2:-180}"
|
||||
@@ -479,3 +594,44 @@ wm_smoke_test_route() {
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
wm_smoke_test_features() {
|
||||
wm_compose exec --no-TTY watermaps node --input-type=module --eval '
|
||||
const params = new URLSearchParams({
|
||||
bbox: "7.118483884871649,53.302596677614666,7.543960668999219,53.50669706666667",
|
||||
layers: "harbours,locks,bridges"
|
||||
});
|
||||
const response = await fetch(`http://127.0.0.1:5174/api/features?${params}`);
|
||||
if (!response.ok) {
|
||||
console.error("Feature-Smoke-Test", response.status, await response.text());
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const collection = await response.json();
|
||||
const features = Array.isArray(collection.features) ? collection.features : [];
|
||||
const layers = new Set(features.map((feature) => feature?.properties?.layer));
|
||||
const contactFeature = features.find((feature) =>
|
||||
["harbours", "locks"].includes(feature?.properties?.layer)
|
||||
&& typeof feature?.properties?.phone === "string"
|
||||
&& feature.properties.phone.trim().length > 0
|
||||
);
|
||||
if (
|
||||
collection?.metadata?.source !== "postgis"
|
||||
|| !layers.has("harbours")
|
||||
|| !layers.has("locks")
|
||||
|| !layers.has("bridges")
|
||||
|| !contactFeature
|
||||
) {
|
||||
console.error(
|
||||
"PostGIS-Feature-Prüfung für die Emden–Aurich-Teststrecke fehlgeschlagen.",
|
||||
JSON.stringify({
|
||||
source: collection?.metadata?.source,
|
||||
featureCount: features.length,
|
||||
layers: [...layers],
|
||||
hasCallableHarbourOrLock: Boolean(contactFeature)
|
||||
})
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
+28
-12
@@ -69,12 +69,16 @@ wm_finish_deployment() {
|
||||
if [[ -n "$rollback_images_file" && -f "$rollback_images_file" ]]; then
|
||||
wm_log "Deployment fehlgeschlagen; vorheriges Container-Release wird wiederhergestellt."
|
||||
wm_use_images_env "$rollback_images_file"
|
||||
wm_compose up --detach --no-build --remove-orphans watermaps nginx
|
||||
wm_compose up --detach --no-build --remove-orphans postgres watermaps nginx
|
||||
wm_wait_for_health postgres 240
|
||||
wm_wait_for_health watermaps 240
|
||||
wm_wait_for_health nginx 120
|
||||
if wm_route_data_ready; then
|
||||
wm_smoke_test_route
|
||||
fi
|
||||
if wm_marine_features_ready; then
|
||||
wm_smoke_test_features
|
||||
fi
|
||||
else
|
||||
wm_log "Deployment fehlgeschlagen; für das erste Release existiert noch kein Rollback."
|
||||
fi
|
||||
@@ -99,7 +103,7 @@ requested_app_image="$(wm_env_value "$images_env_file" WATERMAPS_APP_IMAGE)"
|
||||
requested_route_data_image="$(wm_env_value "$images_env_file" WATERMAPS_ROUTE_DATA_IMAGE)"
|
||||
|
||||
wm_log "Container-Images für Commit $revision werden aus den Registries geladen."
|
||||
wm_compose --profile maintenance pull watermaps nginx certbot route-data
|
||||
wm_compose --profile maintenance pull watermaps postgres nginx certbot route-data
|
||||
|
||||
resolved_app_image="$(wm_resolve_image_digest "$requested_app_image")"
|
||||
resolved_route_data_image="$(wm_resolve_image_digest "$requested_route_data_image")"
|
||||
@@ -114,30 +118,42 @@ resolved_images_file="$(
|
||||
chmod 0644 "$resolved_images_file"
|
||||
wm_use_images_env "$resolved_images_file"
|
||||
|
||||
wm_log "Commit $revision wird mit unveränderlichen Image-Digests gestartet."
|
||||
wm_compose up --detach --no-build --remove-orphans watermaps nginx
|
||||
wm_log "Persistentes PostGIS wird vor App und Datenimport gestartet."
|
||||
wm_compose up --detach --no-build postgres
|
||||
wm_wait_for_health postgres 240
|
||||
|
||||
wm_wait_for_health watermaps 240
|
||||
wm_wait_for_health nginx 120
|
||||
|
||||
route_rebuild_required=false
|
||||
data_update_required=false
|
||||
update_args=()
|
||||
if [[ "${WATERMAPS_REBUILD_ROUTE_DATA:-false}" == "true" ]]; then
|
||||
route_rebuild_required=true
|
||||
data_update_required=true
|
||||
update_args+=(--force)
|
||||
elif ! wm_route_data_ready; then
|
||||
route_rebuild_required=true
|
||||
data_update_required=true
|
||||
fi
|
||||
if [[ "${WATERMAPS_REBUILD_MARINE_DATA:-false}" == "true" ]]; then
|
||||
data_update_required=true
|
||||
update_args+=(--force-marine)
|
||||
elif ! wm_marine_features_ready; then
|
||||
data_update_required=true
|
||||
fi
|
||||
|
||||
if [[ "$route_rebuild_required" == "true" ]]; then
|
||||
wm_log "Deutschland- und Niederlande-Routingdaten werden sicher vorbereitet."
|
||||
if [[ "$data_update_required" == "true" ]]; then
|
||||
wm_log "Deutschland-/Niederlande-Routingdaten und Marine-Features werden sicher vorbereitet."
|
||||
WATERMAPS_ROUTE_LOCK_HELD=true \
|
||||
"$WM_DEPLOY_DIR/scripts/update-route-data.sh" "${update_args[@]}"
|
||||
fi
|
||||
|
||||
wm_assert_route_data
|
||||
wm_assert_marine_features
|
||||
|
||||
wm_log "Commit $revision wird erst mit validierten Routing- und Ereignisdaten gestartet."
|
||||
wm_compose up --detach --no-build --remove-orphans postgres watermaps nginx
|
||||
|
||||
wm_wait_for_health postgres 240
|
||||
wm_wait_for_health watermaps 240
|
||||
wm_wait_for_health nginx 120
|
||||
wm_smoke_test_route
|
||||
wm_smoke_test_features
|
||||
|
||||
release_images_file="$WATERMAPS_RUNTIME_DIR/deployments/$revision.env"
|
||||
install -m 0644 "$resolved_images_file" "$release_images_file"
|
||||
|
||||
Executable
+158
@@ -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
|
||||
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
|
||||
WATERMAPS_DATA_DIR=$DATA_DIR
|
||||
WATERMAPS_RUNTIME_DIR=$RUNTIME_DIR
|
||||
WATERMAPS_POSTGRES_PASSWORD=0123456789abcdef0123456789abcdef
|
||||
EOF
|
||||
|
||||
cat >"$IMAGES_ENV_FILE" <<'EOF'
|
||||
@@ -48,6 +49,11 @@ if [[ "${1:-}" == "inspect" ]]; then
|
||||
fi
|
||||
|
||||
arguments=" $* "
|
||||
if [[ "$arguments" == *" ps --quiet postgres "* ]]; then
|
||||
printf 'postgres-test-container\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$arguments" == *" ps --quiet watermaps "* ]]; then
|
||||
printf 'watermaps-test-container\n'
|
||||
exit 0
|
||||
@@ -55,6 +61,13 @@ fi
|
||||
|
||||
if [[ "$arguments" == *" run "*" route-data "* ||
|
||||
"$arguments" == *" run "*" route-data" ]]; then
|
||||
mkdir -p "$WATERMAPS_DATA_DIR/geofabrik"
|
||||
printf '%s germany-latest.osm.pbf\n' \
|
||||
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
|
||||
>"$WATERMAPS_DATA_DIR/geofabrik/germany-latest.osm.pbf.md5"
|
||||
printf '%s netherlands-latest.osm.pbf\n' \
|
||||
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' \
|
||||
>"$WATERMAPS_DATA_DIR/geofabrik/netherlands-latest.osm.pbf.md5"
|
||||
route_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.json"
|
||||
marker_file="$WATERMAPS_DATA_DIR/local/.germany-netherlands-fairways.candidate.ready"
|
||||
cat >"$route_file" <<JSON
|
||||
@@ -86,6 +99,27 @@ JSON
|
||||
exit 0
|
||||
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
|
||||
active_file="$WATERMAPS_DATA_DIR/local/germany-netherlands-fairways.json"
|
||||
active_revision="$(jq --raw-output '.revision // 0' "$active_file" 2>/dev/null || true)"
|
||||
|
||||
@@ -13,11 +13,22 @@ if [[ "$(id -u)" -ne 0 ]]; then
|
||||
fi
|
||||
|
||||
force_rebuild=false
|
||||
if [[ "${1:-}" == "--force" ]]; then
|
||||
force_marine_import=false
|
||||
while [[ "$#" -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--force)
|
||||
force_rebuild=true
|
||||
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"
|
||||
if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then
|
||||
@@ -26,6 +37,9 @@ if [[ "${WATERMAPS_ROUTE_LOCK_HELD:-false}" != "true" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
wm_compose up --detach --no-build postgres
|
||||
wm_wait_for_health postgres 240
|
||||
|
||||
route_file="$(wm_route_file)"
|
||||
route_marker="$(wm_route_marker)"
|
||||
route_dir="$(dirname "$route_file")"
|
||||
@@ -58,6 +72,16 @@ wm_watermaps_is_running() {
|
||||
[[ -n "$(wm_compose ps --quiet watermaps)" ]]
|
||||
}
|
||||
|
||||
wm_update_marine_features() {
|
||||
if [[ "$force_marine_import" == "true" ]] || ! wm_marine_features_ready; then
|
||||
wm_log "Häfen, Schleusen und Brücken werden aus den verifizierten Geofabrik-Snapshots importiert."
|
||||
wm_compose --profile maintenance run --rm marine-data
|
||||
else
|
||||
wm_log "Marine-Features in PostGIS sind bereits aktuell."
|
||||
fi
|
||||
wm_assert_marine_features
|
||||
}
|
||||
|
||||
wm_restore_previous_route() {
|
||||
rollback_restored_previous=false
|
||||
if [[ -s "$backup_file" && -s "$backup_marker" ]] &&
|
||||
@@ -78,8 +102,8 @@ wm_restore_previous_route() {
|
||||
|
||||
wm_restart_after_restore() {
|
||||
if ! wm_watermaps_is_running; then
|
||||
wm_log "Watermaps läuft nicht; wiederhergestellte Routingdaten können noch nicht getestet werden."
|
||||
return 1
|
||||
wm_log "Watermaps läuft noch nicht; wiederhergestellte Routingdaten werden beim App-Start getestet."
|
||||
return 0
|
||||
fi
|
||||
|
||||
wm_compose restart watermaps
|
||||
@@ -156,8 +180,10 @@ if ! wm_route_data_files_ready "$candidate_file" "$candidate_marker"; then
|
||||
wm_die "Der neu gebaute Routingindex oder sein Bereitschaftsmarker ist ungültig."
|
||||
fi
|
||||
|
||||
wm_update_marine_features
|
||||
|
||||
if [[ "$previous_ready" == "true" ]] && cmp --silent "$candidate_file" "$route_file"; then
|
||||
wm_log "Geofabrik-Snapshots und Routingindex sind unverändert."
|
||||
wm_log "Geofabrik-Snapshots, Routingindex und Marine-Features sind aktuell."
|
||||
rm -f "$candidate_file" "$candidate_marker"
|
||||
exit 0
|
||||
fi
|
||||
@@ -186,8 +212,18 @@ wm_write_route_marker "$route_file" "$route_marker"
|
||||
wm_route_data_ready ||
|
||||
wm_die "Der aktivierte Routingindex besitzt keinen gültigen Bereitschaftsmarker."
|
||||
|
||||
wm_compose restart watermaps
|
||||
if wm_wait_for_health watermaps 240 && wm_smoke_test_route; then
|
||||
activation_valid=false
|
||||
if wm_watermaps_is_running; then
|
||||
wm_compose restart watermaps
|
||||
if wm_wait_for_health watermaps 240 && wm_smoke_test_route; then
|
||||
activation_valid=true
|
||||
fi
|
||||
else
|
||||
wm_log "Watermaps läuft noch nicht; Routing- und Feature-API werden beim App-Start getestet."
|
||||
activation_valid=true
|
||||
fi
|
||||
|
||||
if [[ "$activation_valid" == "true" ]]; then
|
||||
rm -f "$transaction_file"
|
||||
transaction_active=false
|
||||
rm -f "$backup_file" "$backup_marker"
|
||||
|
||||
@@ -124,9 +124,9 @@ printf '[watermaps] Warte auf Cloud-init und das persistente Hetzner-Volume.\n'
|
||||
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
||||
"cloud-init status --wait && ${remote_prefix}systemctl start watermaps-volume-setup.service && mountpoint --quiet /srv/watermaps-data"
|
||||
|
||||
printf '[watermaps] Übertrage Deployment-Dateien nach %s:/opt/watermaps/deploy\n' "$WM_SSH_TARGET"
|
||||
printf '[watermaps] Übertrage Deployment-Dateien und Datenbankschema nach %s:/opt/watermaps\n' "$WM_SSH_TARGET"
|
||||
ssh "${WM_SSH_OPTIONS[@]}" "$WM_SSH_TARGET" \
|
||||
"${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy"
|
||||
"${remote_prefix}install -d -m 0755 /opt/watermaps /opt/watermaps/deploy /opt/watermaps/database"
|
||||
|
||||
rsync \
|
||||
--archive \
|
||||
@@ -140,6 +140,14 @@ rsync \
|
||||
"$WM_DEPLOY_DIR/" \
|
||||
"$WM_SSH_TARGET:/opt/watermaps/deploy/"
|
||||
|
||||
rsync \
|
||||
--archive \
|
||||
--chmod=F644 \
|
||||
--rsync-path="$rsync_path" \
|
||||
-e "ssh ${WM_SSH_OPTIONS[*]@Q}" \
|
||||
"$WM_LOCAL_ROOT_DIR/database/schema.sql" \
|
||||
"$WM_SSH_TARGET:/opt/watermaps/database/schema.sql"
|
||||
|
||||
rsync \
|
||||
--archive \
|
||||
--chmod=F600 \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Watermaps Deutschland-/Niederlande-Routingdaten aktualisieren
|
||||
Description=Watermaps Deutschland-/Niederlande-Routing- und Ereignisdaten aktualisieren
|
||||
Wants=network-online.target
|
||||
After=network-online.target docker.service watermaps-volume-setup.service
|
||||
Requires=docker.service
|
||||
@@ -9,6 +9,7 @@ RequiresMountsFor=/srv/watermaps-data
|
||||
Type=oneshot
|
||||
WorkingDirectory=/opt/watermaps
|
||||
ExecStart=/opt/watermaps/deploy/scripts/update-route-data.sh
|
||||
TimeoutStartSec=0
|
||||
Nice=10
|
||||
IOSchedulingClass=best-effort
|
||||
IOSchedulingPriority=7
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Täglich Watermaps-Routingdaten aktualisieren
|
||||
Description=Täglich Watermaps-Routing- und Ereignisdaten aktualisieren
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 06:15:00
|
||||
|
||||
+29
-4
@@ -10,7 +10,8 @@ services:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 5174
|
||||
DATABASE_URL: ""
|
||||
DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
|
||||
PGPASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}"
|
||||
REDIS_URL: ""
|
||||
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
|
||||
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
|
||||
@@ -23,6 +24,9 @@ services:
|
||||
read_only: true
|
||||
bind:
|
||||
create_host_path: false
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test:
|
||||
@@ -30,7 +34,7 @@ services:
|
||||
"CMD",
|
||||
"node",
|
||||
"-e",
|
||||
"fetch('http://127.0.0.1:5174/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
"fetch('http://127.0.0.1:5174/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
@@ -38,12 +42,11 @@ services:
|
||||
retries: 3
|
||||
|
||||
postgres:
|
||||
profiles: ["postgis"]
|
||||
image: postgis/postgis:16-3.4
|
||||
environment:
|
||||
POSTGRES_DB: seacompass
|
||||
POSTGRES_USER: seacompass
|
||||
POSTGRES_PASSWORD: seacompass
|
||||
POSTGRES_PASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}"
|
||||
ports:
|
||||
- "${WATERMAPS_POSTGRES_PORT:-${SEA_COMPASS_POSTGRES_PORT:-55432}}:5432"
|
||||
volumes:
|
||||
@@ -55,6 +58,28 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
marine-data:
|
||||
profiles: ["postgis"]
|
||||
build:
|
||||
context: .
|
||||
dockerfile: deploy/route-data.Dockerfile
|
||||
image: watermaps-route-data:local
|
||||
entrypoint: ["/workspace/deploy/scripts/prepare-marine-features.sh"]
|
||||
environment:
|
||||
DATABASE_URL: postgresql://seacompass@postgres:5432/seacompass
|
||||
PGPASSWORD: "${WATERMAPS_POSTGRES_PASSWORD:-seacompass}"
|
||||
TMPDIR: /workspace/data/tmp
|
||||
WATERMAPS_GEOFABRIK_DIR: /workspace/data/geofabrik
|
||||
WATERMAPS_MARINE_MARKER_PATH: /workspace/data/local/.marine-features.ready
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ./data
|
||||
target: /workspace/data
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: "no"
|
||||
|
||||
redis:
|
||||
profiles: ["postgis"]
|
||||
image: redis:7-alpine
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
Diese OpenTofu-Konfiguration erstellt einen einzelnen Ubuntu-24.04-Server mit
|
||||
fester IPv4-Adresse, vorgeschalteter Hetzner-Firewall und einem persistenten
|
||||
ext4-Volume für die Routingdaten von Deutschland und den Niederlanden.
|
||||
ext4-Volume für Routingdaten, Geofabrik-Rohdaten und die PostGIS-Datenbank mit
|
||||
Häfen, Schleusen, Brücken und Kontaktdaten.
|
||||
Kartenkacheln, DNS-Einträge und konkrete Anwendungsversionen sind bewusst nicht
|
||||
Teil dieses Infrastrukturmoduls. Die Anwendung wird anschließend als
|
||||
commitgenaues Container-Image aus der Gitea Registry deployt.
|
||||
|
||||
@@ -102,7 +102,7 @@ write_files:
|
||||
permissions: "0644"
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Prepare persistent Watermaps routing data volume
|
||||
Description=Prepare persistent Watermaps routing and event data volume
|
||||
Wants=network-online.target
|
||||
After=network-online.target local-fs.target
|
||||
|
||||
|
||||
@@ -23,11 +23,11 @@ output "dns_a_record" {
|
||||
}
|
||||
|
||||
output "routing_data_mount_path" {
|
||||
description = "Persistenter Pfad für heruntergeladene PBFs und erzeugte Fahrrouten."
|
||||
description = "Persistenter Pfad für PBFs, Fahrrouten und PostGIS-Ereignisdaten."
|
||||
value = "/srv/watermaps-data"
|
||||
}
|
||||
|
||||
output "routing_volume_id" {
|
||||
description = "Hetzner-ID des persistenten Routingdaten-Volumes."
|
||||
description = "Hetzner-ID des persistenten Routing- und Ereignisdaten-Volumes."
|
||||
value = hcloud_volume.routing_data.id
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ variable "location" {
|
||||
}
|
||||
|
||||
variable "server_type" {
|
||||
description = "Hetzner-Cloud-Servertyp. cx33 ist für den initialen Routingdaten-Import konservativ gewählt."
|
||||
description = "Hetzner-Cloud-Servertyp. cx33 ist für den initialen Routing- und Ereignisdaten-Import konservativ gewählt."
|
||||
type = string
|
||||
default = "cx33"
|
||||
|
||||
@@ -101,7 +101,7 @@ variable "deploy_user" {
|
||||
}
|
||||
|
||||
variable "routing_volume_size_gb" {
|
||||
description = "Größe des persistenten ext4-Volumes für Deutschland-/Niederlande-PBFs und den Routingindex."
|
||||
description = "Größe des persistenten ext4-Volumes für Deutschland-/Niederlande-PBFs, Routingindex und PostGIS-Ereignisdaten."
|
||||
type = number
|
||||
default = 40
|
||||
|
||||
|
||||
@@ -432,6 +432,13 @@ function inferredRole(candidate: MarinePoiCandidate): MarinePoiRole {
|
||||
function officialIds(properties: Record<string, unknown>) {
|
||||
const ids = new Map<string, string>();
|
||||
for (const key of OFFICIAL_ID_KEYS) {
|
||||
// On an OSM waterway way, `lock_name` identifies the lock facility while
|
||||
// `wikidata` commonly still identifies the underlying canal or river.
|
||||
// Treating that waterway ID as a lock ID would split one physical lock into
|
||||
// several events. EuRIS/ISRS identifiers remain authoritative.
|
||||
if (key === "wikidata" && stringValue(properties.lock_name)) {
|
||||
continue;
|
||||
}
|
||||
const value = stringValue(properties[key]);
|
||||
if (value) ids.set(key, value.toLocaleUpperCase("en-US"));
|
||||
}
|
||||
|
||||
@@ -92,6 +92,29 @@ describe("marine POI canonicalization", () => {
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not mistake a waterway Wikidata ID for the lock named by lock_name", () => {
|
||||
const result = canonicalizeMarinePois([
|
||||
lock("lock-node", "Kesselschleuse Emden", 7.2, {
|
||||
wikidata: "Q965772"
|
||||
}),
|
||||
lock("canal-way", "Kesselschleuse Emden", 7.2002, {
|
||||
name: "Fehntjer Tief",
|
||||
lock_name: "Kesselschleuse Emden",
|
||||
wikidata: "Q1401323",
|
||||
phone: "+49 170 8512375"
|
||||
})
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
name: "Kesselschleuse Emden",
|
||||
memberCount: 2,
|
||||
properties: {
|
||||
phone: "+49 170 8512375"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("assigns an unnamed gate to exactly the nearest anchor without bridging facilities", () => {
|
||||
const result = canonicalizeMarinePois([
|
||||
lock("west", "Schleuse West", 7),
|
||||
|
||||
Reference in New Issue
Block a user