Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@watermaps/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@watermaps/shared": "0.1.0",
|
||||
"fastify": "^5.4.0",
|
||||
"ioredis": "^5.6.1",
|
||||
"pg": "^8.16.3",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/pg": "^8.15.4",
|
||||
"tsx": "^4.20.3",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import cors from "@fastify/cors";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
buildFairwayRoutes,
|
||||
EMDEN_HAMM_GRAPH,
|
||||
type FairwayGraph,
|
||||
type RouteOption,
|
||||
type RouteRequest,
|
||||
type RouteResult
|
||||
} 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 { FairwayService } from "./services/fairways.js";
|
||||
import { FeatureService } from "./services/features.js";
|
||||
import { getNearestTideSummary } from "./services/tides.js";
|
||||
import { getMarineForecast } from "./services/weather.js";
|
||||
import type { FetchLike } from "./services/http.js";
|
||||
import {
|
||||
getNavigationData,
|
||||
type NavigationDataAdapters
|
||||
} from "./services/navigation-data.js";
|
||||
|
||||
export type AppDeps = {
|
||||
env?: ApiEnv;
|
||||
cache?: Cache;
|
||||
fetcher?: FetchLike;
|
||||
featureService?: FeatureService;
|
||||
fairwayService?: Pick<FairwayService, "getGraphsForRoute" | "close">;
|
||||
navigationAdapters?: NavigationDataAdapters;
|
||||
};
|
||||
|
||||
const coordinateSchema = z.object({
|
||||
lat: z.number().min(-90).max(90),
|
||||
lon: z.number().min(-180).max(180)
|
||||
});
|
||||
|
||||
const routeRequestSchema = z.object({
|
||||
start: coordinateSchema,
|
||||
destination: coordinateSchema,
|
||||
waypoints: z.array(coordinateSchema).max(25).optional(),
|
||||
departureTime: z
|
||||
.string()
|
||||
.refine((value) => Number.isFinite(Date.parse(value)), "departureTime must be an ISO timestamp")
|
||||
.optional(),
|
||||
vesselProfile: z.object({
|
||||
draughtM: z.number().positive().max(15),
|
||||
safetyReserveM: z.number().min(0).max(10),
|
||||
airDraftM: z.number().positive().max(80).optional(),
|
||||
beamM: z.number().positive().max(80).optional(),
|
||||
cruiseSpeedKn: z.number().positive().max(80).optional()
|
||||
}),
|
||||
depthSamples: z
|
||||
.array(
|
||||
z.object({
|
||||
coordinate: coordinateSchema,
|
||||
depthM: z.number().nullable()
|
||||
})
|
||||
)
|
||||
.max(500)
|
||||
.optional()
|
||||
}) satisfies z.ZodType<RouteRequest>;
|
||||
|
||||
const coordinateQuerySchema = z.object({
|
||||
lat: z.coerce.number().min(-90).max(90),
|
||||
lon: z.coerce.number().min(-180).max(180),
|
||||
at: z
|
||||
.string()
|
||||
.refine((value) => Number.isFinite(Date.parse(value)), "at must be an ISO timestamp")
|
||||
.optional()
|
||||
});
|
||||
|
||||
const featuresQuerySchema = z.object({
|
||||
bbox: z
|
||||
.string()
|
||||
.transform((value, ctx) => {
|
||||
const parts = value.split(",").map(Number);
|
||||
if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "bbox must be minLon,minLat,maxLon,maxLat" });
|
||||
return z.NEVER;
|
||||
}
|
||||
return parts as [number, number, number, number];
|
||||
}),
|
||||
layers: z
|
||||
.string()
|
||||
.default("seamarks,bridges,locks,harbours")
|
||||
.transform((value) =>
|
||||
value
|
||||
.split(",")
|
||||
.map((layer) => layer.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
});
|
||||
|
||||
const navigationQuerySchema = z.object({
|
||||
waterways: commaSeparatedQuery(12).optional(),
|
||||
stationIds: commaSeparatedQuery(30).optional(),
|
||||
lockIds: commaSeparatedQuery(30).optional()
|
||||
});
|
||||
|
||||
export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance> {
|
||||
const env = deps.env ?? loadEnv();
|
||||
const cache = deps.cache ?? createCache(env.redisUrl);
|
||||
const fetcher = deps.fetcher ?? fetch;
|
||||
const featureService = deps.featureService ?? new FeatureService(env);
|
||||
const fairwayService =
|
||||
deps.fairwayService ??
|
||||
new FairwayService({
|
||||
cache,
|
||||
fetcher,
|
||||
liveEnabled: env.liveOsmFairways,
|
||||
databaseUrl: env.databaseUrl
|
||||
});
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL ?? "info"
|
||||
}
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
origin: true
|
||||
});
|
||||
|
||||
app.addHook("onClose", async () => {
|
||||
await cache.close();
|
||||
await featureService.close();
|
||||
await fairwayService.close?.();
|
||||
});
|
||||
|
||||
app.get("/health", async () => ({ ok: true, service: "watermaps-api" }));
|
||||
|
||||
app.get("/api/config", async () => appConfig);
|
||||
|
||||
app.get("/api/weather/marine", async (request, reply) => {
|
||||
const parsed = coordinateQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
return getMarineForecast(parsed.data, { cache, fetcher });
|
||||
});
|
||||
|
||||
app.get("/api/tides/nearest", async (request, reply) => {
|
||||
const parsed = coordinateQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const summary = await getNearestTideSummary(parsed.data, { cache, fetcher });
|
||||
if (!summary) {
|
||||
return reply.code(404).send({ error: "no_tide_station_found" });
|
||||
}
|
||||
|
||||
return summary;
|
||||
});
|
||||
|
||||
app.get("/api/navigation/live", async (request, reply) => {
|
||||
const parsed = navigationQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
return getNavigationData(parsed.data, {
|
||||
cache,
|
||||
fetcher,
|
||||
adapters: deps.navigationAdapters
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/features", async (request, reply) => {
|
||||
const parsed = featuresQuerySchema.safeParse(request.query);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
return featureService.getFeatures(parsed.data);
|
||||
});
|
||||
|
||||
app.post("/api/routes", async (request, reply) => {
|
||||
const parsed = routeRequestSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: "invalid_route", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => {
|
||||
app.log.warn({ error }, "fairway extraction failed");
|
||||
return [];
|
||||
});
|
||||
const route = buildRouteFromGraphs(parsed.data, dynamicGraphs);
|
||||
if (!route) {
|
||||
return reply.code(422).send({
|
||||
error: "no_fairway_route",
|
||||
message:
|
||||
"Keine Fahrwasserroute für Start und Ziel gefunden. Setze Punkte näher an ein bekanntes Fahrwasser oder importiere weitere Fahrwasserdaten."
|
||||
});
|
||||
}
|
||||
|
||||
return route;
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
function commaSeparatedQuery(maxItems: number) {
|
||||
return z.string().transform((value, ctx) => {
|
||||
const items = [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
|
||||
if (items.length > maxItems) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `too many values (maximum ${maxItems})`
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return items;
|
||||
});
|
||||
}
|
||||
|
||||
function buildRouteFromGraphs(request: RouteRequest, graphs: FairwayGraph[]) {
|
||||
for (const graph of graphs) {
|
||||
const routes = buildFairwayRoutes(request, graph);
|
||||
if (routes.length > 0) {
|
||||
return routeResultWithAlternatives(routes);
|
||||
}
|
||||
}
|
||||
|
||||
for (const graph of [undefined, EMDEN_HAMM_GRAPH] as const) {
|
||||
const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request);
|
||||
if (routes.length > 0) {
|
||||
return routeResultWithAlternatives(routes);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function routeResultWithAlternatives(routes: RouteOption[]): RouteResult {
|
||||
const [primary, ...alternatives] = routes;
|
||||
if (!primary) {
|
||||
throw new Error("routeResultWithAlternatives requires at least one route");
|
||||
}
|
||||
|
||||
return {
|
||||
...primary,
|
||||
alternatives
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export type ApiEnv = {
|
||||
port: number;
|
||||
host: string;
|
||||
databaseUrl?: string;
|
||||
redisUrl?: string;
|
||||
demoData: boolean;
|
||||
liveOsmFairways: boolean;
|
||||
};
|
||||
|
||||
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,
|
||||
demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) !== "false",
|
||||
liveOsmFairways:
|
||||
(env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" &&
|
||||
env.NODE_ENV !== "test"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { buildServer } from "./app.js";
|
||||
import { loadEnv } from "./env.js";
|
||||
|
||||
for (const envFile of [resolve(process.cwd(), ".env"), resolve(process.cwd(), "../..", ".env")]) {
|
||||
if (existsSync(envFile)) {
|
||||
process.loadEnvFile(envFile);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const env = loadEnv();
|
||||
const app = await buildServer({ env });
|
||||
|
||||
try {
|
||||
await app.listen({ port: env.port, host: env.host });
|
||||
} catch (error) {
|
||||
app.log.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Redis } from "ioredis";
|
||||
|
||||
export interface Cache {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set<T>(key: string, value: T, ttlMs: number): Promise<void>;
|
||||
getOrSet<T>(key: string, ttlMs: number, loader: () => Promise<T>): Promise<T>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export function createCache(redisUrl?: string): Cache {
|
||||
if (!redisUrl) {
|
||||
return new MemoryCache();
|
||||
}
|
||||
|
||||
const redis = new Redis(redisUrl, {
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 1,
|
||||
enableOfflineQueue: false
|
||||
});
|
||||
let redisReady = false;
|
||||
const memoryFallback = new MemoryCache();
|
||||
|
||||
redis.on("ready", () => {
|
||||
redisReady = true;
|
||||
});
|
||||
redis.on("error", () => {
|
||||
redisReady = false;
|
||||
});
|
||||
|
||||
void redis.connect().catch(() => {
|
||||
redisReady = false;
|
||||
});
|
||||
|
||||
return {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
if (!redisReady) {
|
||||
return memoryFallback.get<T>(key);
|
||||
}
|
||||
|
||||
const raw = await redis.get(key);
|
||||
return raw ? (JSON.parse(raw) as T) : null;
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlMs: number): Promise<void> {
|
||||
if (!redisReady) {
|
||||
return memoryFallback.set(key, value, ttlMs);
|
||||
}
|
||||
|
||||
await redis.set(key, JSON.stringify(value), "PX", ttlMs);
|
||||
},
|
||||
async getOrSet<T>(key: string, ttlMs: number, loader: () => Promise<T>): Promise<T> {
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const value = await loader();
|
||||
await this.set(key, value, ttlMs);
|
||||
return value;
|
||||
},
|
||||
async close(): Promise<void> {
|
||||
await memoryFallback.close();
|
||||
redis.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class MemoryCache implements Cache {
|
||||
private readonly entries = new Map<string, { expiresAt: number; value: unknown }>();
|
||||
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
const entry = this.entries.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entry.expiresAt < Date.now()) {
|
||||
this.entries.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.value as T;
|
||||
}
|
||||
|
||||
async set<T>(key: string, value: T, ttlMs: number): Promise<void> {
|
||||
this.entries.set(key, { value, expiresAt: Date.now() + ttlMs });
|
||||
}
|
||||
|
||||
async getOrSet<T>(key: string, ttlMs: number, loader: () => Promise<T>): Promise<T> {
|
||||
const cached = await this.get<T>(key);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const value = await loader();
|
||||
await this.set(key, value, ttlMs);
|
||||
return value;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.entries.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { AppConfig } from "@watermaps/shared";
|
||||
|
||||
export const appConfig: AppConfig = {
|
||||
appName: "Watermaps",
|
||||
region: "Deutschland/EU",
|
||||
disclaimer:
|
||||
"Freie Karten- und Modelldaten sind eine Fahr- und Planungshilfe, aber kein Ersatz für amtlich zugelassene Seekarten und eigene Navigation.",
|
||||
featureFlags: {
|
||||
gpsTracking: true,
|
||||
compass: true,
|
||||
marineWeather: true,
|
||||
tides: true,
|
||||
manualRouting: true,
|
||||
liveFairwayExtraction: true,
|
||||
postgisFeatures: true,
|
||||
bridgeAndDepthOverlays: true,
|
||||
inlandWaterwayRouting: true,
|
||||
routeAlternatives: true,
|
||||
lockAndHarbourContacts: true,
|
||||
routeWaypoints: true,
|
||||
departureTimeForecasts: true,
|
||||
liveWaterLevels: true,
|
||||
voyageStages: true,
|
||||
gpxExport: true,
|
||||
offlineRoutes: true,
|
||||
offlineVisitedMapResources: true,
|
||||
routeDeviationAlarm: true,
|
||||
offlineTiles: true
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: "openfreemap",
|
||||
name: "Basiskarte",
|
||||
kind: "style",
|
||||
url: "https://tiles.openfreemap.org/styles/bright",
|
||||
attribution: "Map data © OpenStreetMap contributors, style © OpenFreeMap",
|
||||
defaultVisible: true
|
||||
},
|
||||
{
|
||||
id: "openseamap-seamarks",
|
||||
name: "Seezeichen",
|
||||
kind: "raster-tile",
|
||||
tileUrl: "https://tiles.openseamap.org/seamark/{z}/{x}/{y}.png",
|
||||
attribution: "Seamarks © OpenSeaMap / OpenStreetMap contributors",
|
||||
defaultVisible: true,
|
||||
opacity: 0.95
|
||||
},
|
||||
{
|
||||
id: "emodnet-bathymetry",
|
||||
name: "Bathymetrie",
|
||||
kind: "wms",
|
||||
tileUrl:
|
||||
"https://ows.emodnet-bathymetry.eu/wms?SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&LAYERS=emodnet:mean_multicolour&STYLES=&FORMAT=image/png&TRANSPARENT=true&SRS=EPSG:3857&BBOX={bbox-epsg-3857}&WIDTH=256&HEIGHT=256",
|
||||
attribution: "Bathymetry © EMODnet Bathymetry",
|
||||
defaultVisible: false,
|
||||
opacity: 0.55
|
||||
}
|
||||
],
|
||||
attribution: [
|
||||
"© OpenStreetMap contributors",
|
||||
"© OpenSeaMap contributors",
|
||||
"© EMODnet Bathymetry",
|
||||
"© Bundesamt für Seeschifffahrt und Hydrographie (BSH), CC BY 4.0",
|
||||
"Wasserstände © Wasserstraßen- und Schifffahrtsverwaltung des Bundes / PEGELONLINE",
|
||||
"Weather, waves and current forecast © Open-Meteo"
|
||||
]
|
||||
};
|
||||
@@ -0,0 +1,576 @@
|
||||
import pg from "pg";
|
||||
import type { Coordinate, FairwayEdge, FairwayGraph, FairwayNode, RouteRequest } from "@watermaps/shared";
|
||||
import type { Cache } from "./cache.js";
|
||||
import type { FetchLike } from "./http.js";
|
||||
|
||||
type OverpassElement = {
|
||||
type: "way";
|
||||
id: number;
|
||||
geometry?: Coordinate[];
|
||||
tags?: Record<string, string>;
|
||||
};
|
||||
|
||||
type OverpassResponse = {
|
||||
elements?: OverpassElement[];
|
||||
};
|
||||
|
||||
type FairwayDeps = {
|
||||
cache: Cache;
|
||||
fetcher: FetchLike;
|
||||
liveEnabled: boolean;
|
||||
databaseUrl?: string;
|
||||
};
|
||||
|
||||
export type FairwayRow = {
|
||||
id: string;
|
||||
source: string;
|
||||
source_id: string | null;
|
||||
name: string | null;
|
||||
min_depth_m: string | number | null;
|
||||
properties?: Record<string, unknown> | null;
|
||||
geometry: {
|
||||
type: "LineString";
|
||||
coordinates: [number, number][];
|
||||
};
|
||||
};
|
||||
|
||||
const OVERPASS_URL = "https://overpass-api.de/api/interpreter";
|
||||
const CACHE_TTL_MS = 1000 * 60 * 60 * 12;
|
||||
const MAX_BBOX_SPAN_DEG = 3;
|
||||
const MAX_POSTGIS_BBOX_SPAN_DEG = 6;
|
||||
const BBOX_MARGIN_DEG = 0.15;
|
||||
const ENDPOINT_SNAP_DEG = 0.0001;
|
||||
const CONNECTOR_DISTANCE_NM = 0.08;
|
||||
const CONNECTOR_GRID_DEG = 0.003;
|
||||
|
||||
export class FairwayService {
|
||||
private readonly cache: Cache;
|
||||
private readonly fetcher: FetchLike;
|
||||
private readonly liveEnabled: boolean;
|
||||
private readonly pool: pg.Pool | null;
|
||||
|
||||
constructor(deps: FairwayDeps) {
|
||||
this.cache = deps.cache;
|
||||
this.fetcher = deps.fetcher;
|
||||
this.liveEnabled = deps.liveEnabled;
|
||||
this.pool = deps.databaseUrl ? new pg.Pool({ connectionString: deps.databaseUrl }) : null;
|
||||
}
|
||||
|
||||
async getGraphsForRoute(request: RouteRequest): Promise<FairwayGraph[]> {
|
||||
const results = await Promise.allSettled([
|
||||
this.getPostgisGraphForRoute(request),
|
||||
this.getLiveGraphForRoute(request)
|
||||
]);
|
||||
const graphs = results.flatMap((result) =>
|
||||
result.status === "fulfilled" && result.value ? [result.value] : []
|
||||
);
|
||||
|
||||
if (graphs.length === 0) {
|
||||
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
||||
if (failures.length > 0) {
|
||||
throw new AggregateError(failures.map((failure) => failure.reason), "No fairway source produced a graph");
|
||||
}
|
||||
}
|
||||
|
||||
if (graphs.length < 2) {
|
||||
return graphs;
|
||||
}
|
||||
|
||||
const combined = mergeConnectedFairwayGraphs(graphs);
|
||||
return combined ? [combined, ...graphs] : graphs;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool?.end();
|
||||
}
|
||||
|
||||
private async getPostgisGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
|
||||
if (!this.pool) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bbox = routeBbox([request.start, ...(request.waypoints ?? []), request.destination], MAX_POSTGIS_BBOX_SPAN_DEG);
|
||||
if (!bbox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `fairways:postgis:${bbox.map((value) => value.toFixed(3)).join(",")}`;
|
||||
return this.cache.getOrSet(cacheKey, CACHE_TTL_MS, async () => {
|
||||
const [minLon, minLat, maxLon, maxLat] = bbox;
|
||||
const result = await this.pool!.query<FairwayRow>(
|
||||
`
|
||||
SELECT
|
||||
id::text,
|
||||
source,
|
||||
source_id,
|
||||
name,
|
||||
min_depth_m,
|
||||
properties,
|
||||
ST_AsGeoJSON(geom)::json AS geometry
|
||||
FROM marine_fairway_edges
|
||||
WHERE geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)
|
||||
LIMIT 25000
|
||||
`,
|
||||
[minLon, minLat, maxLon, maxLat]
|
||||
);
|
||||
|
||||
return fairwayRowsToGraph(result.rows, bbox);
|
||||
});
|
||||
}
|
||||
|
||||
private async getLiveGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
|
||||
if (!this.liveEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bbox = routeBbox([request.start, ...(request.waypoints ?? []), request.destination]);
|
||||
if (!bbox) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheKey = `fairways:overpass:${bbox.map((value) => value.toFixed(3)).join(",")}`;
|
||||
return this.cache.getOrSet(cacheKey, CACHE_TTL_MS, async () => {
|
||||
const response = await this.fetchOverpass(bbox);
|
||||
return overpassToGraph(response, bbox);
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchOverpass(bbox: [number, number, number, number]): Promise<OverpassResponse> {
|
||||
const [minLon, minLat, maxLon, maxLat] = bbox;
|
||||
const query = `
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
way["seamark:type"~"^(navigation_line|recommended_track)$"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
way["seamark:type"="fairway"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
way["waterway"="fairway"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
way["waterway"="canal"]["access"!="no"]["access"!="private"]["boat"!="no"]["ship"!="no"]["motorboat"!="no"]["disused"!="yes"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
way["waterway"="river"]["boat"~"^(yes|designated|permissive)$"]["access"!="no"]["access"!="private"]["disused"!="yes"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
way["waterway"="river"]["ship"~"^(yes|designated|permissive)$"]["access"!="no"]["access"!="private"]["disused"!="yes"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
way["route"="ferry"](${minLat},${minLon},${maxLat},${maxLon});
|
||||
);
|
||||
out tags geom;
|
||||
`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 12_000);
|
||||
|
||||
try {
|
||||
const response = await this.fetcher(OVERPASS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
"user-agent": "Watermaps/0.1 fairway-extractor"
|
||||
},
|
||||
body: new URLSearchParams({ data: query }).toString(),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Overpass request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as OverpassResponse;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function routeBbox(points: Coordinate[], maxSpanDeg = MAX_BBOX_SPAN_DEG): [number, number, number, number] | null {
|
||||
const lons = points.map((point) => point.lon);
|
||||
const lats = points.map((point) => point.lat);
|
||||
const minLon = Math.max(-180, Math.min(...lons) - BBOX_MARGIN_DEG);
|
||||
const maxLon = Math.min(180, Math.max(...lons) + BBOX_MARGIN_DEG);
|
||||
const minLat = Math.max(-90, Math.min(...lats) - BBOX_MARGIN_DEG);
|
||||
const maxLat = Math.min(90, Math.max(...lats) + BBOX_MARGIN_DEG);
|
||||
|
||||
if (maxLon - minLon > maxSpanDeg || maxLat - minLat > maxSpanDeg) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [minLon, minLat, maxLon, maxLat];
|
||||
}
|
||||
|
||||
export function fairwayRowsToGraph(rows: FairwayRow[], bbox: [number, number, number, number]): FairwayGraph | null {
|
||||
return waysToGraph(
|
||||
rows
|
||||
.map((row) => {
|
||||
const tags = stringTags(row.properties);
|
||||
return {
|
||||
id: `postgis-edge-${row.id}`,
|
||||
name: row.name ?? row.source_id ?? `PostGIS ${row.id}`,
|
||||
coordinates: row.geometry.coordinates.map(([lon, lat]) => ({ lon, lat })),
|
||||
minDepthM: parseNumeric(row.min_depth_m),
|
||||
source: `postgis-${row.source}`,
|
||||
...edgeRestrictions(tags)
|
||||
};
|
||||
})
|
||||
.filter((way) => way.coordinates.length >= 2),
|
||||
`postgis-${bbox.map((value) => value.toFixed(3)).join("-")}`,
|
||||
"PostGIS Fahrwasser"
|
||||
);
|
||||
}
|
||||
|
||||
export function overpassToGraph(response: OverpassResponse, bbox: [number, number, number, number]): FairwayGraph | null {
|
||||
const ways = (response.elements ?? [])
|
||||
.map((element) => {
|
||||
const tags = element.tags ?? {};
|
||||
const coordinates = (element.geometry ?? []).filter(isValidCoordinate);
|
||||
if (coordinates.length < 2 || !isRoutableWay(tags, coordinates)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `osm-way-${element.id}`,
|
||||
name: tags.name ?? tags.ref ?? `OSM ${element.id}`,
|
||||
coordinates,
|
||||
minDepthM: parseDepth(tags),
|
||||
source: sourceFor(tags),
|
||||
...edgeRestrictions(tags)
|
||||
};
|
||||
})
|
||||
.filter((way): way is NonNullable<typeof way> => way !== null);
|
||||
|
||||
return waysToGraph(
|
||||
ways,
|
||||
`osm-overpass-${bbox.map((value) => value.toFixed(3)).join("-")}`,
|
||||
"OSM/OpenSeaMap Fahrwasser"
|
||||
);
|
||||
}
|
||||
|
||||
export function mergeConnectedFairwayGraphs(graphs: FairwayGraph[]): FairwayGraph | null {
|
||||
const nodes = new Map<string, FairwayNode>();
|
||||
const edges = new Map<string, FairwayEdge>();
|
||||
|
||||
for (const graph of graphs) {
|
||||
for (const edge of graph.edges) {
|
||||
const coordinates = edge.coordinates.filter(isValidCoordinate);
|
||||
for (let index = 0; index < coordinates.length - 1; index += 1) {
|
||||
const start = coordinates[index]!;
|
||||
const end = coordinates[index + 1]!;
|
||||
const from = nodeIdFor(nodes, start);
|
||||
const to = nodeIdFor(nodes, end);
|
||||
if (from === to) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const edgeId = `${graph.id}:${edge.id}:${index}`;
|
||||
edges.set(edgeId, {
|
||||
...edge,
|
||||
id: edgeId,
|
||||
from,
|
||||
to,
|
||||
coordinates: [start, end]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addEndpointConnectors(nodes, edges);
|
||||
if (edges.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `combined-${graphs.map((graph) => graph.id).join("+")}`,
|
||||
name: graphs.map((graph) => graph.name).join(" + "),
|
||||
maxSnapDistanceNm: Math.min(...graphs.map((graph) => graph.maxSnapDistanceNm)),
|
||||
nodes: [...nodes.values()],
|
||||
edges: [...edges.values()]
|
||||
};
|
||||
}
|
||||
|
||||
function waysToGraph(
|
||||
ways: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
coordinates: Coordinate[];
|
||||
minDepthM: number | null;
|
||||
source: string;
|
||||
maxAirDraftM?: number | null;
|
||||
maxBeamM?: number | null;
|
||||
maxDraughtM?: number | null;
|
||||
oneway?: boolean | "forward" | "backward";
|
||||
}>,
|
||||
id: string,
|
||||
name: string
|
||||
): FairwayGraph | null {
|
||||
const nodes = new Map<string, FairwayNode>();
|
||||
const edges = new Map<string, FairwayEdge>();
|
||||
|
||||
for (const way of ways) {
|
||||
const coordinates = way.coordinates.filter(isValidCoordinate);
|
||||
for (let index = 0; index < coordinates.length - 1; index += 1) {
|
||||
const start = coordinates[index]!;
|
||||
const end = coordinates[index + 1]!;
|
||||
const from = nodeIdFor(nodes, start);
|
||||
const to = nodeIdFor(nodes, end);
|
||||
if (from === to) {
|
||||
continue;
|
||||
}
|
||||
|
||||
edges.set(`${way.id}-segment-${index}`, {
|
||||
id: `${way.id}-segment-${index}`,
|
||||
name: way.name,
|
||||
from,
|
||||
to,
|
||||
coordinates: [start, end],
|
||||
minDepthM: way.minDepthM,
|
||||
source: way.source,
|
||||
maxAirDraftM: way.maxAirDraftM,
|
||||
maxBeamM: way.maxBeamM,
|
||||
maxDraughtM: way.maxDraughtM,
|
||||
oneway: way.oneway
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addEndpointConnectors(nodes, edges);
|
||||
|
||||
if (edges.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
maxSnapDistanceNm: 2,
|
||||
nodes: [...nodes.values()],
|
||||
edges: [...edges.values()]
|
||||
};
|
||||
}
|
||||
|
||||
function isValidCoordinate(coordinate: Coordinate) {
|
||||
return (
|
||||
Number.isFinite(coordinate.lat) &&
|
||||
Number.isFinite(coordinate.lon) &&
|
||||
coordinate.lat >= -90 &&
|
||||
coordinate.lat <= 90 &&
|
||||
coordinate.lon >= -180 &&
|
||||
coordinate.lon <= 180
|
||||
);
|
||||
}
|
||||
|
||||
function isRoutableWay(tags: Record<string, string>, coordinates: Coordinate[]) {
|
||||
if (
|
||||
["no", "private"].includes(tags.access ?? "") ||
|
||||
tags.boat === "no" ||
|
||||
tags.ship === "no" ||
|
||||
tags.motorboat === "no" ||
|
||||
tags.disused === "yes" ||
|
||||
tags.construction ||
|
||||
tags.proposed
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const seamarkType = tags["seamark:type"];
|
||||
if (seamarkType === "navigation_line" || seamarkType === "recommended_track") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tags.waterway === "fairway") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tags.waterway === "canal") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
tags.waterway === "river" &&
|
||||
[tags.boat, tags.ship, tags.motorboat].some((value) =>
|
||||
["yes", "designated", "permissive"].includes(value ?? "")
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tags.route === "ferry" && tags.ship !== "no" && tags.motor_vehicle !== "no") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return seamarkType === "fairway" && !isClosedWay(coordinates);
|
||||
}
|
||||
|
||||
function isClosedWay(coordinates: Coordinate[]) {
|
||||
const first = coordinates[0]!;
|
||||
const last = coordinates.at(-1)!;
|
||||
return Math.abs(first.lat - last.lat) < 0.00001 && Math.abs(first.lon - last.lon) < 0.00001;
|
||||
}
|
||||
|
||||
function nodeIdFor(nodes: Map<string, FairwayNode>, coordinate: Coordinate) {
|
||||
const id = `${Math.round(coordinate.lat / ENDPOINT_SNAP_DEG)}:${Math.round(coordinate.lon / ENDPOINT_SNAP_DEG)}`;
|
||||
if (!nodes.has(id)) {
|
||||
nodes.set(id, {
|
||||
id,
|
||||
coordinate
|
||||
});
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
function addEndpointConnectors(nodes: Map<string, FairwayNode>, edges: Map<string, FairwayEdge>) {
|
||||
const degree = new Map<string, number>();
|
||||
for (const edge of edges.values()) {
|
||||
degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
|
||||
degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
|
||||
}
|
||||
const endpoints = [...nodes.values()].filter((node) => (degree.get(node.id) ?? 0) <= 1);
|
||||
const buckets = new Map<string, FairwayNode[]>();
|
||||
|
||||
for (const endpoint of endpoints) {
|
||||
const [x, y] = connectorCell(endpoint.coordinate);
|
||||
const key = `${x}:${y}`;
|
||||
buckets.set(key, [...(buckets.get(key) ?? []), endpoint]);
|
||||
}
|
||||
|
||||
const connectorIds = new Set<string>();
|
||||
|
||||
for (const first of endpoints) {
|
||||
const [cellX, cellY] = connectorCell(first.coordinate);
|
||||
for (let dx = -1; dx <= 1; dx += 1) {
|
||||
for (let dy = -1; dy <= 1; dy += 1) {
|
||||
for (const second of buckets.get(`${cellX + dx}:${cellY + dy}`) ?? []) {
|
||||
if (first.id >= second.id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distanceNm = distanceApproxNm(first.coordinate, second.coordinate);
|
||||
if (distanceNm === 0 || distanceNm > CONNECTOR_DISTANCE_NM) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const connectorId = `connector-${first.id}-${second.id}`;
|
||||
if (connectorIds.has(connectorId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
connectorIds.add(connectorId);
|
||||
edges.set(connectorId, {
|
||||
id: connectorId,
|
||||
name: "Fahrwasser-Verbindung",
|
||||
from: first.id,
|
||||
to: second.id,
|
||||
coordinates: [first.coordinate, second.coordinate],
|
||||
minDepthM: null,
|
||||
source: "fairway-graph-connectors"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function connectorCell(coordinate: Coordinate) {
|
||||
return [
|
||||
Math.floor(coordinate.lon / CONNECTOR_GRID_DEG),
|
||||
Math.floor(coordinate.lat / CONNECTOR_GRID_DEG)
|
||||
] as const;
|
||||
}
|
||||
|
||||
function parseDepth(tags: Record<string, string>) {
|
||||
const candidates = [
|
||||
tags["seamark:fairway:minimum_depth"],
|
||||
tags["seamark:recommended_track:minimum_depth"],
|
||||
tags["seamark:navigation_line:minimum_depth"],
|
||||
tags["depth"],
|
||||
tags["min_depth"]
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseNumeric(candidate);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function edgeRestrictions(tags: Record<string, string>) {
|
||||
return {
|
||||
maxAirDraftM: firstNumericTag(tags, [
|
||||
"seamark:bridge:clearance_height_safe",
|
||||
"seamark:bridge:clearance_height",
|
||||
"maxheight:physical",
|
||||
"maxheight"
|
||||
]),
|
||||
maxBeamM: firstNumericTag(tags, ["maxwidth:physical", "maxwidth", "seamark:lock:chamber_width"]),
|
||||
maxDraughtM: firstNumericTag(tags, ["maxdraft", "maxdraught", "seamark:restriction:max_draught"]),
|
||||
oneway: parseOneway(tags.oneway)
|
||||
};
|
||||
}
|
||||
|
||||
function firstNumericTag(tags: Record<string, string>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = parseNumeric(tags[key]);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseOneway(value: string | undefined): boolean | "backward" | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (["yes", "true", "1"].includes(normalized ?? "")) {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "-1") {
|
||||
return "backward";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stringTags(properties: Record<string, unknown> | null | undefined) {
|
||||
const tags: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(properties ?? {})) {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
tags[key] = String(value);
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
}
|
||||
|
||||
function parseNumeric(value: string | number | null | undefined) {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(value.replace(",", ".").match(/[0-9]+(?:\.[0-9]+)?/)?.[0]);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function sourceFor(tags: Record<string, string>) {
|
||||
if (tags.route === "ferry") {
|
||||
return "osm-overpass-ferry-routes";
|
||||
}
|
||||
if (tags.waterway === "fairway") {
|
||||
return "osm-overpass-waterway-fairway";
|
||||
}
|
||||
if (tags.waterway === "canal") {
|
||||
return "osm-overpass-waterway-canal";
|
||||
}
|
||||
if (tags.waterway === "river") {
|
||||
return "osm-overpass-waterway-river";
|
||||
}
|
||||
if (tags["seamark:type"]) {
|
||||
return "osm-overpass-seamarks";
|
||||
}
|
||||
return "osm-overpass";
|
||||
}
|
||||
|
||||
function distanceApproxNm(a: Coordinate, b: Coordinate) {
|
||||
const meanLatRad = ((a.lat + b.lat) / 2) * (Math.PI / 180);
|
||||
const x = (a.lon - b.lon) * 60 * Math.cos(meanLatRad);
|
||||
const y = (a.lat - b.lat) * 60;
|
||||
return Math.sqrt(x * x + y * y);
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
import pg from "pg";
|
||||
import {
|
||||
canonicalizeMarinePois,
|
||||
type MarinePoiCandidate,
|
||||
type MarinePoiLayer
|
||||
} from "@watermaps/shared";
|
||||
import type { ApiEnv } from "../env.js";
|
||||
|
||||
export type FeatureQuery = {
|
||||
bbox: [number, number, number, number];
|
||||
layers: string[];
|
||||
};
|
||||
|
||||
type FeatureCollection = {
|
||||
type: "FeatureCollection";
|
||||
features: Array<{
|
||||
type: "Feature";
|
||||
id?: string;
|
||||
geometry: unknown;
|
||||
properties: Record<string, unknown>;
|
||||
}>;
|
||||
metadata: {
|
||||
source: "postgis" | "demo";
|
||||
warning?: string;
|
||||
deduplication?: {
|
||||
inputPoiCount: number;
|
||||
outputPoiCount: number;
|
||||
mergedObjectCount: number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const demoFeatures = [
|
||||
{
|
||||
type: "Feature" as const,
|
||||
id: "demo-seamark-warnemuende",
|
||||
geometry: { type: "Point", coordinates: [12.0886, 54.1798] },
|
||||
properties: {
|
||||
layer: "seamarks",
|
||||
name: "Warnemünde Mole",
|
||||
seamark_type: "light_minor",
|
||||
source: "demo"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Feature" as const,
|
||||
id: "demo-lock-kiel",
|
||||
geometry: { type: "Point", coordinates: [10.1424, 54.3665] },
|
||||
properties: {
|
||||
layer: "locks",
|
||||
name: "Schleuse Kiel-Holtenau",
|
||||
source: "demo"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Feature" as const,
|
||||
id: "demo-bridge-hamburg",
|
||||
geometry: { type: "Point", coordinates: [9.966, 53.541] },
|
||||
properties: {
|
||||
layer: "bridges",
|
||||
name: "Hamburg Brücke Demo",
|
||||
source: "demo"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export class FeatureService {
|
||||
private pool: pg.Pool | null;
|
||||
private demoData: boolean;
|
||||
|
||||
constructor(env: Pick<ApiEnv, "databaseUrl" | "demoData">) {
|
||||
this.pool = env.databaseUrl ? new pg.Pool({ connectionString: env.databaseUrl }) : null;
|
||||
this.demoData = env.demoData;
|
||||
}
|
||||
|
||||
async getFeatures(query: FeatureQuery): Promise<FeatureCollection> {
|
||||
if (this.pool && !this.demoData) {
|
||||
return this.getPostgisFeatures(query);
|
||||
}
|
||||
|
||||
const [minLon, minLat, maxLon, maxLat] = query.bbox;
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: demoFeatures.filter((feature) => {
|
||||
const [lon, lat] = feature.geometry.coordinates;
|
||||
if (typeof lon !== "number" || typeof lat !== "number") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
query.layers.includes(String(feature.properties.layer)) &&
|
||||
lon >= minLon &&
|
||||
lon <= maxLon &&
|
||||
lat >= minLat &&
|
||||
lat <= maxLat
|
||||
);
|
||||
}),
|
||||
metadata: {
|
||||
source: "demo",
|
||||
warning:
|
||||
"Demo-Features aktiv. Für Produktionsdaten DATABASE_URL setzen und WATERMAPS_DEMO_DATA=false verwenden."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool?.end();
|
||||
}
|
||||
|
||||
private async getPostgisFeatures(query: FeatureQuery): Promise<FeatureCollection> {
|
||||
const [minLon, minLat, maxLon, maxLat] = query.bbox;
|
||||
const requestedMarineLayers = query.layers.filter((layer) => layer !== "depths");
|
||||
const features: FeatureCollection["features"] = [];
|
||||
let deduplication: FeatureCollection["metadata"]["deduplication"];
|
||||
|
||||
if (requestedMarineLayers.length > 0) {
|
||||
const result = await this.pool!.query<{
|
||||
id: string;
|
||||
layer: string;
|
||||
name: string | null;
|
||||
source: string;
|
||||
source_id: string | null;
|
||||
properties: Record<string, unknown>;
|
||||
updated_at: Date | string;
|
||||
geometry: unknown;
|
||||
}>(
|
||||
`
|
||||
WITH ranked_features AS (
|
||||
SELECT
|
||||
id,
|
||||
layer,
|
||||
name,
|
||||
source,
|
||||
source_id,
|
||||
properties,
|
||||
updated_at,
|
||||
geom,
|
||||
row_number() OVER (PARTITION BY layer ORDER BY id) AS layer_rank
|
||||
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,
|
||||
layer,
|
||||
name,
|
||||
source,
|
||||
source_id,
|
||||
properties,
|
||||
updated_at,
|
||||
ST_AsGeoJSON(
|
||||
CASE
|
||||
WHEN layer IN ('locks', 'harbours') AND GeometryType(geom) = 'LINESTRING'
|
||||
THEN ST_LineInterpolatePoint(geom, 0.5)
|
||||
WHEN layer IN ('locks', 'harbours') THEN ST_PointOnSurface(geom)
|
||||
ELSE geom
|
||||
END
|
||||
)::json AS geometry
|
||||
FROM ranked_features
|
||||
WHERE layer_rank <= 1000
|
||||
`,
|
||||
[requestedMarineLayers, minLon, minLat, maxLon, maxLat]
|
||||
);
|
||||
|
||||
const normalizedFeatures = result.rows.map((row) => ({
|
||||
type: "Feature" as const,
|
||||
id: row.id,
|
||||
geometry: row.geometry,
|
||||
properties: normalizeMarineFeatureProperties({
|
||||
layer: row.layer,
|
||||
name: row.name,
|
||||
source: row.source,
|
||||
sourceId: row.source_id,
|
||||
updatedAt: row.updated_at,
|
||||
properties: row.properties
|
||||
})
|
||||
}));
|
||||
const canonicalized = deduplicateMarineContactFeatures(normalizedFeatures);
|
||||
features.push(...canonicalized.features);
|
||||
deduplication = canonicalized.metadata;
|
||||
}
|
||||
|
||||
if (query.layers.includes("depths")) {
|
||||
const result = await this.pool!.query<{
|
||||
id: string;
|
||||
source: string;
|
||||
source_id: string | null;
|
||||
name: string | null;
|
||||
min_depth_m: string | number | null;
|
||||
properties: Record<string, unknown>;
|
||||
geometry: unknown;
|
||||
}>(
|
||||
`
|
||||
SELECT
|
||||
id::text,
|
||||
source,
|
||||
source_id,
|
||||
name,
|
||||
min_depth_m,
|
||||
properties,
|
||||
ST_AsGeoJSON(geom)::json AS geometry
|
||||
FROM marine_fairway_edges
|
||||
WHERE min_depth_m IS NOT NULL
|
||||
AND geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)
|
||||
ORDER BY ST_Length(geom::geography) DESC
|
||||
LIMIT 1000
|
||||
`,
|
||||
[minLon, minLat, maxLon, maxLat]
|
||||
);
|
||||
|
||||
features.push(
|
||||
...result.rows.map((row) => ({
|
||||
type: "Feature" as const,
|
||||
id: `depth-${row.id}`,
|
||||
geometry: row.geometry,
|
||||
properties: normalizeDepthFeatureProperties({
|
||||
name: row.name,
|
||||
source: row.source,
|
||||
sourceId: row.source_id,
|
||||
minDepthM: row.min_depth_m,
|
||||
properties: row.properties
|
||||
})
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features,
|
||||
metadata: { source: "postgis", ...(deduplication ? { deduplication } : {}) }
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function deduplicateMarineContactFeatures(features: FeatureCollection["features"]): {
|
||||
features: FeatureCollection["features"];
|
||||
metadata: NonNullable<FeatureCollection["metadata"]["deduplication"]>;
|
||||
} {
|
||||
const candidates: MarinePoiCandidate[] = [];
|
||||
const passthrough: FeatureCollection["features"] = [];
|
||||
for (const feature of features) {
|
||||
const layer = feature.properties.layer;
|
||||
const coordinate = pointCoordinate(feature.geometry);
|
||||
if ((layer !== "locks" && layer !== "harbours") || !coordinate) {
|
||||
passthrough.push(feature);
|
||||
continue;
|
||||
}
|
||||
const source = stringProperty(feature.properties, "source");
|
||||
if (!source) {
|
||||
passthrough.push(feature);
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
id: String(feature.id ?? `${source}:${coordinate.lon}:${coordinate.lat}`),
|
||||
layer: layer as MarinePoiLayer,
|
||||
source,
|
||||
sourceId: firstStringProperty(feature.properties, ["sourceId", "source_id"]),
|
||||
name: stringProperty(feature.properties, "name"),
|
||||
coordinate,
|
||||
properties: feature.properties
|
||||
});
|
||||
}
|
||||
|
||||
const canonicalPois = canonicalizeMarinePois(candidates);
|
||||
const canonicalFeatures = canonicalPois.map((poi) => ({
|
||||
type: "Feature" as const,
|
||||
id: poi.entityId,
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [poi.coordinate.lon, poi.coordinate.lat]
|
||||
},
|
||||
properties: {
|
||||
...poi.properties,
|
||||
layer: poi.layer,
|
||||
name: poi.name,
|
||||
source_id: poi.canonicalSourceId,
|
||||
sourceId: poi.canonicalSourceId,
|
||||
dedupeMemberCount: poi.memberCount,
|
||||
dedupeMemberIds: poi.memberIds
|
||||
}
|
||||
}));
|
||||
return {
|
||||
features: [...passthrough, ...canonicalFeatures],
|
||||
metadata: {
|
||||
inputPoiCount: candidates.length,
|
||||
outputPoiCount: canonicalFeatures.length,
|
||||
mergedObjectCount: Math.max(0, candidates.length - canonicalFeatures.length)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function pointCoordinate(geometry: unknown) {
|
||||
if (!geometry || typeof geometry !== "object") return null;
|
||||
const candidate = geometry as { type?: unknown; coordinates?: unknown };
|
||||
if (candidate.type !== "Point" || !Array.isArray(candidate.coordinates)) return null;
|
||||
const [lon, lat] = candidate.coordinates;
|
||||
if (
|
||||
typeof lon !== "number" ||
|
||||
typeof lat !== "number" ||
|
||||
!Number.isFinite(lon) ||
|
||||
!Number.isFinite(lat) ||
|
||||
lon < -180 ||
|
||||
lon > 180 ||
|
||||
lat < -90 ||
|
||||
lat > 90
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { lon, lat };
|
||||
}
|
||||
|
||||
type MarineFeaturePropertiesInput = {
|
||||
layer: string;
|
||||
name: string | null;
|
||||
source: string;
|
||||
sourceId: string | null;
|
||||
updatedAt?: Date | string | null;
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type DepthFeaturePropertiesInput = {
|
||||
name: string | null;
|
||||
source: string;
|
||||
sourceId: string | null;
|
||||
minDepthM: string | number | null;
|
||||
properties: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function normalizeMarineFeatureProperties(input: MarineFeaturePropertiesInput): Record<string, unknown> {
|
||||
const sourceProperties = input.properties;
|
||||
const properties = selectedMarineProperties(sourceProperties);
|
||||
const clearanceM = input.layer === "bridges" ? bridgeClearanceM(sourceProperties) : null;
|
||||
const name = input.name ?? stringProperty(sourceProperties, "name");
|
||||
const label = bridgeLabel(name, clearanceM);
|
||||
const phone = firstStringProperty(sourceProperties, ["phone", "contact:phone"]);
|
||||
const website = firstStringProperty(sourceProperties, ["website", "contact:website", "url"]);
|
||||
const email = firstStringProperty(sourceProperties, ["email", "contact:email"]);
|
||||
const vhf = firstStringProperty(sourceProperties, [
|
||||
"vhf",
|
||||
"contact:vhf",
|
||||
"seamark:harbour:communication_channel",
|
||||
"seamark:lock_basin:communication_channel",
|
||||
"seamark:radio_station:channel"
|
||||
]);
|
||||
const openingHours = firstStringProperty(sourceProperties, ["opening_hours", "service_times"]);
|
||||
const operator = firstStringProperty(sourceProperties, ["operator"]);
|
||||
const address = normalizedAddress(sourceProperties);
|
||||
const sourceUrl = firstStringProperty(sourceProperties, [
|
||||
"sourceUrl",
|
||||
"source_url",
|
||||
"enrichmentSourceUrl"
|
||||
]);
|
||||
const updatedAt = normalizedTimestamp(
|
||||
input.updatedAt ?? firstStringProperty(sourceProperties, ["updatedAt", "updated_at", "@timestamp", "timestamp"])
|
||||
);
|
||||
|
||||
return {
|
||||
...properties,
|
||||
layer: input.layer,
|
||||
source: input.source,
|
||||
source_id: input.sourceId,
|
||||
sourceId: input.sourceId,
|
||||
updatedAt,
|
||||
name,
|
||||
phone,
|
||||
website,
|
||||
email,
|
||||
vhf,
|
||||
openingHours,
|
||||
operator,
|
||||
address,
|
||||
sourceUrl,
|
||||
clearance_m: clearanceM,
|
||||
clearance_label: clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null,
|
||||
label
|
||||
};
|
||||
}
|
||||
|
||||
// OSM objects can carry hundreds of tags. Only values consumed by the map,
|
||||
// voyage planner and facility resolver belong in the viewport GeoJSON.
|
||||
const MARINE_PROPERTY_KEYS = new Set([
|
||||
"@id",
|
||||
"@type",
|
||||
"id",
|
||||
"name",
|
||||
"lock_name",
|
||||
"official_name",
|
||||
"loc_name",
|
||||
"operator",
|
||||
"operator:name",
|
||||
"owner",
|
||||
"phone",
|
||||
"contact:phone",
|
||||
"website",
|
||||
"contact:website",
|
||||
"url",
|
||||
"email",
|
||||
"contact:email",
|
||||
"vhf",
|
||||
"contact:vhf",
|
||||
"vhf_channel",
|
||||
"radio_channel",
|
||||
"opening_hours",
|
||||
"lock:opening_hours",
|
||||
"service_times",
|
||||
"address",
|
||||
"addr:full",
|
||||
"contact:address",
|
||||
"addr:street",
|
||||
"addr:housenumber",
|
||||
"addr:postcode",
|
||||
"addr:city",
|
||||
"addr:place",
|
||||
"addr:country",
|
||||
"country",
|
||||
"seamark:type",
|
||||
"seamark:name",
|
||||
"seamark:gate:category",
|
||||
"seamark:harbour:communication_channel",
|
||||
"seamark:harbour:radio_channel",
|
||||
"seamark:lock_basin:communication_channel",
|
||||
"seamark:radio_station:channel",
|
||||
"seamark:small_craft_facility:category",
|
||||
"leisure",
|
||||
"harbour",
|
||||
"industrial",
|
||||
"landuse",
|
||||
"waterway",
|
||||
"water",
|
||||
"lock",
|
||||
"obstacle",
|
||||
"bridge",
|
||||
"maxheight",
|
||||
"maxheight:physical",
|
||||
"height",
|
||||
"seamark:bridge:clearance_height",
|
||||
"seamark:bridge:clearance_height_safe",
|
||||
"electricity",
|
||||
"power_supply",
|
||||
"shore_power",
|
||||
"service:electricity",
|
||||
"drinking_water",
|
||||
"water_point",
|
||||
"service:water",
|
||||
"fuel",
|
||||
"fuel:diesel",
|
||||
"service:fuel",
|
||||
"waste_disposal",
|
||||
"sanitary_dump_station",
|
||||
"pump_out",
|
||||
"service:waste",
|
||||
"overnight",
|
||||
"guest_berths",
|
||||
"visitor_berths",
|
||||
"guest_moorings",
|
||||
"ref",
|
||||
"ref:EU:RIS",
|
||||
"isrs",
|
||||
"wikidata",
|
||||
"waterwayName",
|
||||
"waterway_name",
|
||||
"hectom",
|
||||
"phones",
|
||||
"phone_raw",
|
||||
"upstreamSource",
|
||||
"upstream_source",
|
||||
"data_source",
|
||||
"sourceUrl",
|
||||
"source_url",
|
||||
"compact_source_url",
|
||||
"ris_source_url",
|
||||
"enrichmentSource",
|
||||
"enrichmentSourceUrl",
|
||||
"fetchedAt",
|
||||
"fetched_at",
|
||||
"updatedAt",
|
||||
"updated_at",
|
||||
"@timestamp",
|
||||
"timestamp"
|
||||
]);
|
||||
|
||||
function selectedMarineProperties(properties: Record<string, unknown>) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(properties).filter(([key, value]) => MARINE_PROPERTY_KEYS.has(key) && value !== undefined)
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeDepthFeatureProperties(input: DepthFeaturePropertiesInput): Record<string, unknown> {
|
||||
const depthM = parseNumber(input.minDepthM);
|
||||
const depthLabel = depthM !== null ? formatMeters(depthM) : null;
|
||||
const name = input.name ?? stringProperty(input.properties, "name");
|
||||
|
||||
return {
|
||||
...input.properties,
|
||||
layer: "depths",
|
||||
source: input.source,
|
||||
source_id: input.sourceId,
|
||||
name,
|
||||
depth_m: depthM,
|
||||
depth_label: depthLabel,
|
||||
label: name && depthLabel ? `${name} ${depthLabel}` : (depthLabel ?? name ?? "Tiefe")
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeClearanceM(properties: Record<string, unknown>) {
|
||||
const candidates = [
|
||||
"seamark:bridge:clearance_height",
|
||||
"seamark:bridge:clearance_height_safe",
|
||||
"maxheight",
|
||||
"maxheight:physical",
|
||||
"height"
|
||||
];
|
||||
|
||||
for (const key of candidates) {
|
||||
const value = parseNumber(properties[key]);
|
||||
if (value !== null) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function bridgeLabel(name: string | null | undefined, clearanceM: number | null) {
|
||||
const clearance = clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null;
|
||||
if (name && clearance) {
|
||||
return `${name} ${clearance}`;
|
||||
}
|
||||
return name ?? clearance ?? null;
|
||||
}
|
||||
|
||||
function stringProperty(properties: Record<string, unknown>, key: string) {
|
||||
const value = properties[key];
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function firstStringProperty(properties: Record<string, unknown>, keys: string[]) {
|
||||
for (const key of keys) {
|
||||
const value = stringProperty(properties, key);
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizedAddress(properties: Record<string, unknown>) {
|
||||
const fullAddress = firstStringProperty(properties, ["address", "addr:full", "contact:address"]);
|
||||
if (fullAddress) {
|
||||
return fullAddress;
|
||||
}
|
||||
|
||||
const street = firstStringProperty(properties, ["addr:street"]);
|
||||
const houseNumber = firstStringProperty(properties, ["addr:housenumber"]);
|
||||
const postcode = firstStringProperty(properties, ["addr:postcode"]);
|
||||
const locality = firstStringProperty(properties, ["addr:city", "addr:place"]);
|
||||
const country = firstStringProperty(properties, ["addr:country"]);
|
||||
const streetLine = [street, houseNumber].filter(Boolean).join(" ");
|
||||
const localityLine = [postcode, locality].filter(Boolean).join(" ");
|
||||
const address = [streetLine, localityLine, country].filter(Boolean).join(", ");
|
||||
return address || null;
|
||||
}
|
||||
|
||||
function normalizedTimestamp(value: unknown) {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = new Date(value);
|
||||
return Number.isNaN(timestamp.getTime()) ? value.trim() : timestamp.toISOString();
|
||||
}
|
||||
|
||||
function parseNumber(value: unknown) {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (!value.trim() || value.trim().toLowerCase() === "default") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = value.replace(",", ".").match(/\d+(?:\.\d+)?/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(match[0]);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function formatMeters(value: number) {
|
||||
return Number.isInteger(value) ? `${value} m` : `${value.toFixed(1)} m`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type FetchLike = typeof fetch;
|
||||
|
||||
export async function fetchJson<T>(
|
||||
fetcher: FetchLike,
|
||||
url: string,
|
||||
timeoutMs = 8000
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetcher(url, {
|
||||
headers: { accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with ${response.status} for ${url}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
import type { Cache } from "./cache.js";
|
||||
import type { FetchLike } from "./http.js";
|
||||
import type {
|
||||
LockOperationInfo,
|
||||
NavigationDataSnapshot,
|
||||
NavigationNotice,
|
||||
NavigationSourceKind,
|
||||
NavigationSourceStatus,
|
||||
WaterLevel,
|
||||
WaterLevelState
|
||||
} from "@watermaps/shared";
|
||||
|
||||
export type {
|
||||
LockOperationInfo,
|
||||
NavigationDataSnapshot,
|
||||
NavigationNotice,
|
||||
NavigationSourceKind,
|
||||
NavigationSourceStatus,
|
||||
WaterLevel,
|
||||
WaterLevelState
|
||||
} from "@watermaps/shared";
|
||||
|
||||
/**
|
||||
* Official reference pages. Only PEGELONLINE currently documents a public,
|
||||
* unauthenticated machine-readable API. ELWIS lock data and Notices to
|
||||
* Skippers are therefore exposed through optional adapters instead of being
|
||||
* scraped from unstable HTML.
|
||||
*/
|
||||
export const OFFICIAL_NAVIGATION_SOURCES = {
|
||||
pegelOnlineApi: "https://pegelonline.wsv.de/webservices/rest-api/v2",
|
||||
pegelOnlineDocumentation: "https://pegelonline.wsv.de/webservice/dokuRestapi",
|
||||
elwisLockInformation: "https://www.elwis.de/DE/dynamisch/Schleuseninformationen/",
|
||||
elwisNotices: "https://www.elwis.de/DE/dynamisch/Nfb/"
|
||||
} as const;
|
||||
|
||||
export type NavigationSourceState =
|
||||
| "live"
|
||||
| "cached"
|
||||
| "stale"
|
||||
| "unavailable"
|
||||
| "not-configured";
|
||||
|
||||
export type NavigationDataQuery = {
|
||||
/** Exact PEGELONLINE water names, for example EMS or RHEIN. */
|
||||
waterways?: string[];
|
||||
/** Stable PEGELONLINE station UUIDs. Prefer these when they are known. */
|
||||
stationIds?: string[];
|
||||
/** Provider-specific stable lock IDs made available to optional adapters. */
|
||||
lockIds?: string[];
|
||||
};
|
||||
|
||||
export type NavigationAdapterContext = {
|
||||
fetcher: FetchLike;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
export interface NavigationDataAdapter<T> {
|
||||
kind: NavigationSourceKind;
|
||||
id: string;
|
||||
label: string;
|
||||
/** Human-readable official documentation or source page. */
|
||||
sourceUrl: string;
|
||||
freshTtlMs?: number;
|
||||
staleTtlMs?: number;
|
||||
load(query: NavigationDataQuery, context: NavigationAdapterContext): Promise<readonly T[]>;
|
||||
}
|
||||
|
||||
export type NavigationDataAdapters = {
|
||||
/** `undefined` selects the built-in PEGELONLINE adapter; `null` disables it. */
|
||||
waterLevels?: NavigationDataAdapter<WaterLevel> | null;
|
||||
/** No public machine-readable ELWIS endpoint is assumed; configure explicitly. */
|
||||
lockOperations?: NavigationDataAdapter<LockOperationInfo> | null;
|
||||
/** No public machine-readable ELWIS endpoint is assumed; configure explicitly. */
|
||||
notices?: NavigationDataAdapter<NavigationNotice> | null;
|
||||
};
|
||||
|
||||
export type NavigationDataDependencies = {
|
||||
cache: Cache;
|
||||
fetcher: FetchLike;
|
||||
adapters?: NavigationDataAdapters;
|
||||
timeoutMs?: number;
|
||||
now?: () => Date;
|
||||
};
|
||||
|
||||
type SourceCacheEntry<T> = {
|
||||
cachedAt: string;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type LoadedSource<T> = {
|
||||
items: T[];
|
||||
status: NavigationSourceStatus;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 8_000;
|
||||
const DEFAULT_FRESH_TTL_MS = 60_000;
|
||||
const DEFAULT_STALE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
export async function getNavigationData(
|
||||
query: NavigationDataQuery,
|
||||
deps: NavigationDataDependencies
|
||||
): Promise<NavigationDataSnapshot> {
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const waterLevelAdapter =
|
||||
deps.adapters?.waterLevels === undefined
|
||||
? createPegelOnlineWaterLevelAdapter()
|
||||
: deps.adapters.waterLevels;
|
||||
const lockAdapter = deps.adapters?.lockOperations ?? null;
|
||||
const noticeAdapter = deps.adapters?.notices ?? null;
|
||||
|
||||
const [waterLevels, lockOperations, notices] = await Promise.all([
|
||||
waterLevelAdapter
|
||||
? loadSource(waterLevelAdapter, query, deps, now)
|
||||
: notConfiguredSource<WaterLevel>(
|
||||
"water-levels",
|
||||
"pegelonline-wsv",
|
||||
"PEGELONLINE der WSV",
|
||||
OFFICIAL_NAVIGATION_SOURCES.pegelOnlineDocumentation,
|
||||
now,
|
||||
"Wasserstandsdaten wurden deaktiviert."
|
||||
),
|
||||
lockAdapter
|
||||
? loadSource(lockAdapter, query, deps, now)
|
||||
: notConfiguredSource<LockOperationInfo>(
|
||||
"lock-operations",
|
||||
"elwis-lock-information",
|
||||
"ELWIS Schleuseninformationen",
|
||||
OFFICIAL_NAVIGATION_SOURCES.elwisLockInformation,
|
||||
now,
|
||||
"Keine dokumentierte öffentliche Maschinenschnittstelle konfiguriert; offizielle ELWIS-Seite verwenden."
|
||||
),
|
||||
noticeAdapter
|
||||
? loadSource(noticeAdapter, query, deps, now)
|
||||
: notConfiguredSource<NavigationNotice>(
|
||||
"notices",
|
||||
"elwis-notices-to-skippers",
|
||||
"ELWIS Nachrichten für die Binnenschifffahrt",
|
||||
OFFICIAL_NAVIGATION_SOURCES.elwisNotices,
|
||||
now,
|
||||
"Keine dokumentierte öffentliche Maschinenschnittstelle konfiguriert; NfB in ELWIS prüfen."
|
||||
)
|
||||
]);
|
||||
|
||||
return {
|
||||
waterLevels: waterLevels.items,
|
||||
lockOperations: lockOperations.items,
|
||||
notices: notices.items,
|
||||
sources: [waterLevels.status, lockOperations.status, notices.status],
|
||||
generatedAt: now().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
export function createPegelOnlineWaterLevelAdapter(
|
||||
baseUrl: string = OFFICIAL_NAVIGATION_SOURCES.pegelOnlineApi
|
||||
): NavigationDataAdapter<WaterLevel> {
|
||||
assertOfficialNavigationUrl(baseUrl);
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
|
||||
|
||||
return {
|
||||
kind: "water-levels",
|
||||
id: "pegelonline-wsv-v2",
|
||||
label: "PEGELONLINE REST-API v2",
|
||||
sourceUrl: OFFICIAL_NAVIGATION_SOURCES.pegelOnlineDocumentation,
|
||||
freshTtlMs: 60_000,
|
||||
staleTtlMs: 6 * 60 * 60 * 1000,
|
||||
async load(query, { fetcher, signal }) {
|
||||
const url = buildPegelOnlineUrl(query, normalizedBaseUrl);
|
||||
if (!url) {
|
||||
throw new AdapterNotConfiguredError(
|
||||
"Für PEGELONLINE werden mindestens eine Stations-UUID oder ein exakter Gewässername benötigt."
|
||||
);
|
||||
}
|
||||
|
||||
const payload = await fetchOfficialJson(fetcher, url, signal);
|
||||
return normalizePegelOnlineStations(payload, normalizedBaseUrl);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type OfficialJsonAdapterOptions<T> = {
|
||||
kind: NavigationSourceKind;
|
||||
id: string;
|
||||
label: string;
|
||||
sourceUrl: string;
|
||||
buildUrl: (query: NavigationDataQuery) => string;
|
||||
parse: (payload: unknown, query: NavigationDataQuery) => readonly T[];
|
||||
freshTtlMs?: number;
|
||||
staleTtlMs?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adapter boundary for a future documented ELWIS/WSV JSON feed. Both the
|
||||
* reference page and every generated endpoint are restricted to official
|
||||
* HTTPS ELWIS/WSV hosts.
|
||||
*/
|
||||
export function createOfficialJsonAdapter<T>(
|
||||
options: OfficialJsonAdapterOptions<T>
|
||||
): NavigationDataAdapter<T> {
|
||||
assertOfficialNavigationUrl(options.sourceUrl);
|
||||
|
||||
return {
|
||||
kind: options.kind,
|
||||
id: options.id,
|
||||
label: options.label,
|
||||
sourceUrl: options.sourceUrl,
|
||||
freshTtlMs: options.freshTtlMs,
|
||||
staleTtlMs: options.staleTtlMs,
|
||||
async load(query, { fetcher, signal }) {
|
||||
const endpoint = options.buildUrl(query);
|
||||
assertOfficialNavigationUrl(endpoint);
|
||||
const payload = await fetchOfficialJson(fetcher, endpoint, signal);
|
||||
return [...options.parse(payload, query)];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPegelOnlineUrl(
|
||||
query: NavigationDataQuery,
|
||||
baseUrl: string = OFFICIAL_NAVIGATION_SOURCES.pegelOnlineApi
|
||||
): string | null {
|
||||
const stationIds = normalizeQueryValues(query.stationIds);
|
||||
const waterways = normalizeQueryValues(query.waterways);
|
||||
if (stationIds.length === 0 && waterways.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
assertOfficialNavigationUrl(baseUrl);
|
||||
const url = new URL(`${baseUrl.replace(/\/$/, "")}/stations.json`);
|
||||
if (stationIds.length > 0) {
|
||||
url.searchParams.set("ids", stationIds.join(","));
|
||||
}
|
||||
if (waterways.length > 0) {
|
||||
url.searchParams.set("waters", waterways.join(","));
|
||||
}
|
||||
url.searchParams.set("timeseries", "W");
|
||||
url.searchParams.set("includeTimeseries", "true");
|
||||
url.searchParams.set("includeCurrentMeasurement", "true");
|
||||
url.searchParams.set("prettyprint", "false");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function normalizePegelOnlineStations(payload: unknown, baseUrl: string): WaterLevel[] {
|
||||
if (!Array.isArray(payload)) {
|
||||
throw new Error("PEGELONLINE-Antwort ist keine Stationsliste.");
|
||||
}
|
||||
|
||||
const levels: WaterLevel[] = [];
|
||||
for (const candidate of payload) {
|
||||
if (!isRecord(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const stationId = stringValue(candidate.uuid);
|
||||
const stationName = stringValue(candidate.shortname) ?? stringValue(candidate.longname);
|
||||
const water = isRecord(candidate.water) ? candidate.water : null;
|
||||
const waterway = water
|
||||
? stringValue(water.shortname) ?? stringValue(water.longname)
|
||||
: null;
|
||||
const timeseries = Array.isArray(candidate.timeseries) ? candidate.timeseries : [];
|
||||
const waterSeries = timeseries.find(
|
||||
(entry) => isRecord(entry) && stringValue(entry.shortname)?.toUpperCase() === "W"
|
||||
);
|
||||
if (!stationId || !stationName || !waterway || !isRecord(waterSeries)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const measurement = isRecord(waterSeries.currentMeasurement)
|
||||
? waterSeries.currentMeasurement
|
||||
: null;
|
||||
const value = measurement ? numberValue(measurement.value) : null;
|
||||
const measuredAt = measurement ? stringValue(measurement.timestamp) : null;
|
||||
const unit = stringValue(waterSeries.unit);
|
||||
if (value === null || !measuredAt || !unit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
levels.push({
|
||||
stationId,
|
||||
stationNumber: stringValue(candidate.number),
|
||||
stationName,
|
||||
waterway,
|
||||
waterwayKm: numberValue(candidate.km),
|
||||
latitude: numberValue(candidate.latitude),
|
||||
longitude: numberValue(candidate.longitude),
|
||||
value,
|
||||
unit,
|
||||
measuredAt,
|
||||
stateMnwMhw: waterLevelState(measurement?.stateMnwMhw),
|
||||
stateNswHsw: waterLevelState(measurement?.stateNswHsw),
|
||||
agency: stringValue(candidate.agency),
|
||||
sourceUrl: `${baseUrl.replace(/\/$/, "")}/stations/${encodeURIComponent(stationId)}.json`
|
||||
});
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
export function assertOfficialNavigationUrl(rawUrl: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error("Navigationsdatenquelle muss eine gültige URL sein.");
|
||||
}
|
||||
|
||||
const host = url.hostname.toLowerCase();
|
||||
const officialHost =
|
||||
host === "elwis.de" ||
|
||||
host.endsWith(".elwis.de") ||
|
||||
host === "wsv.de" ||
|
||||
host.endsWith(".wsv.de") ||
|
||||
host === "wsv.bund.de" ||
|
||||
host.endsWith(".wsv.bund.de");
|
||||
if (url.protocol !== "https:" || !officialHost) {
|
||||
throw new Error("Navigationsdatenadapter akzeptieren nur offizielle HTTPS-Quellen von ELWIS/WSV.");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSource<T>(
|
||||
adapter: NavigationDataAdapter<T>,
|
||||
query: NavigationDataQuery,
|
||||
deps: NavigationDataDependencies,
|
||||
now: () => Date
|
||||
): Promise<LoadedSource<T>> {
|
||||
assertOfficialNavigationUrl(adapter.sourceUrl);
|
||||
const queryHash = hashQuery(query);
|
||||
const prefix = `navigation-data:v1:${adapter.id}:${queryHash}`;
|
||||
const freshKey = `${prefix}:fresh`;
|
||||
const staleKey = `${prefix}:last-good`;
|
||||
const cached = await safeCacheGet<T>(deps.cache, freshKey);
|
||||
if (cached) {
|
||||
return sourceResult(adapter, cached.items, "cached", now, cached.cachedAt, null);
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await runWithTimeout(
|
||||
(signal) => adapter.load(query, { fetcher: officialOnlyFetcher(deps.fetcher), signal }),
|
||||
deps.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
adapter.label
|
||||
);
|
||||
const entry: SourceCacheEntry<T> = {
|
||||
cachedAt: now().toISOString(),
|
||||
items: [...items]
|
||||
};
|
||||
await Promise.all([
|
||||
safeCacheSet(deps.cache, freshKey, entry, adapter.freshTtlMs ?? DEFAULT_FRESH_TTL_MS),
|
||||
safeCacheSet(deps.cache, staleKey, entry, adapter.staleTtlMs ?? DEFAULT_STALE_TTL_MS)
|
||||
]);
|
||||
return sourceResult(adapter, entry.items, "live", now, entry.cachedAt, null);
|
||||
} catch (error) {
|
||||
if (error instanceof AdapterNotConfiguredError) {
|
||||
return sourceResult(adapter, [], "not-configured", now, null, error.message);
|
||||
}
|
||||
|
||||
const stale = await safeCacheGet<T>(deps.cache, staleKey);
|
||||
const message = toErrorMessage(error);
|
||||
if (stale) {
|
||||
return sourceResult(
|
||||
adapter,
|
||||
stale.items,
|
||||
"stale",
|
||||
now,
|
||||
stale.cachedAt,
|
||||
`Live-Quelle nicht erreichbar; letzter erfolgreicher Stand wird verwendet. ${message}`
|
||||
);
|
||||
}
|
||||
return sourceResult(adapter, [], "unavailable", now, null, message);
|
||||
}
|
||||
}
|
||||
|
||||
function sourceResult<T>(
|
||||
adapter: NavigationDataAdapter<T>,
|
||||
items: T[],
|
||||
state: NavigationSourceState,
|
||||
now: () => Date,
|
||||
dataTimestamp: string | null,
|
||||
warning: string | null
|
||||
): LoadedSource<T> {
|
||||
return {
|
||||
items,
|
||||
status: {
|
||||
kind: adapter.kind,
|
||||
id: adapter.id,
|
||||
label: adapter.label,
|
||||
sourceUrl: adapter.sourceUrl,
|
||||
state,
|
||||
checkedAt: now().toISOString(),
|
||||
dataTimestamp,
|
||||
warning
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function notConfiguredSource<T>(
|
||||
kind: NavigationSourceKind,
|
||||
id: string,
|
||||
label: string,
|
||||
sourceUrl: string,
|
||||
now: () => Date,
|
||||
warning: string
|
||||
): LoadedSource<T> {
|
||||
return {
|
||||
items: [],
|
||||
status: {
|
||||
kind,
|
||||
id,
|
||||
label,
|
||||
sourceUrl,
|
||||
state: "not-configured",
|
||||
checkedAt: now().toISOString(),
|
||||
dataTimestamp: null,
|
||||
warning
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchOfficialJson(fetcher: FetchLike, url: string, signal: AbortSignal): Promise<unknown> {
|
||||
assertOfficialNavigationUrl(url);
|
||||
const response = await fetcher(url, {
|
||||
headers: { accept: "application/json" },
|
||||
signal
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Offizielle Navigationsdatenquelle antwortet mit HTTP ${response.status}.`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function officialOnlyFetcher(fetcher: FetchLike): FetchLike {
|
||||
return ((input: Parameters<FetchLike>[0], init?: Parameters<FetchLike>[1]) => {
|
||||
const url = input instanceof Request ? input.url : String(input);
|
||||
assertOfficialNavigationUrl(url);
|
||||
return fetcher(input, init);
|
||||
}) as FetchLike;
|
||||
}
|
||||
|
||||
async function runWithTimeout<T>(
|
||||
task: (signal: AbortSignal) => Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error(`${label} hat das Zeitlimit von ${timeoutMs} ms überschritten.`));
|
||||
}, Math.max(1, timeoutMs));
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([task(controller.signal), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function safeCacheGet<T>(cache: Cache, key: string): Promise<SourceCacheEntry<T> | null> {
|
||||
try {
|
||||
const entry = await cache.get<SourceCacheEntry<T>>(key);
|
||||
return entry && Array.isArray(entry.items) && typeof entry.cachedAt === "string" ? entry : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function safeCacheSet<T>(
|
||||
cache: Cache,
|
||||
key: string,
|
||||
entry: SourceCacheEntry<T>,
|
||||
ttlMs: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await cache.set(key, entry, ttlMs);
|
||||
} catch {
|
||||
// A cache outage must never hide otherwise usable navigation data.
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQueryValues(values: string[] | undefined): string[] {
|
||||
return [...new Set((values ?? []).map((value) => value.trim()).filter(Boolean))].sort((a, b) =>
|
||||
a.localeCompare(b, "de")
|
||||
);
|
||||
}
|
||||
|
||||
function hashQuery(query: NavigationDataQuery): string {
|
||||
const normalized = JSON.stringify({
|
||||
waterways: normalizeQueryValues(query.waterways),
|
||||
stationIds: normalizeQueryValues(query.stationIds),
|
||||
lockIds: normalizeQueryValues(query.lockIds)
|
||||
});
|
||||
let hash = 2166136261;
|
||||
for (let index = 0; index < normalized.length; index += 1) {
|
||||
hash ^= normalized.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function waterLevelState(value: unknown): WaterLevelState {
|
||||
return value === "low" ||
|
||||
value === "normal" ||
|
||||
value === "high" ||
|
||||
value === "commented" ||
|
||||
value === "out-dated"
|
||||
? value
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
return error.message;
|
||||
}
|
||||
return "Offizielle Navigationsdatenquelle ist derzeit nicht erreichbar.";
|
||||
}
|
||||
|
||||
class AdapterNotConfiguredError extends Error {}
|
||||
@@ -0,0 +1,183 @@
|
||||
import { haversineDistanceM, type TideCurvePoint, type TideEvent, type TideSummary } from "@watermaps/shared";
|
||||
import type { Cache } from "./cache.js";
|
||||
import { fetchJson, type FetchLike } from "./http.js";
|
||||
|
||||
type BshFeatureCollection = {
|
||||
features?: BshFeature[];
|
||||
};
|
||||
|
||||
type BshFeature = {
|
||||
geometry?: {
|
||||
type: "Point";
|
||||
coordinates: [number, number];
|
||||
};
|
||||
properties?: {
|
||||
gauge_label?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
forecast_timestamp?: string;
|
||||
automated_curveforecast_timestamp?: string;
|
||||
high_water_low_water?: BshTideEvent[];
|
||||
curve?: BshCurvePoint[];
|
||||
};
|
||||
};
|
||||
|
||||
type BshTideEvent = {
|
||||
event_timestamp?: string;
|
||||
event?: "HW" | "NW" | string;
|
||||
forecast_value?: number;
|
||||
tidal_prediction_value?: string;
|
||||
forecast_deviation?: string;
|
||||
};
|
||||
|
||||
type BshCurvePoint = {
|
||||
timestamp?: string;
|
||||
tidal_prediction?: string;
|
||||
measurement?: string;
|
||||
forecast?: string | number;
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 15 * 60 * 1000;
|
||||
const BSH_URL =
|
||||
"https://gdi.bsh.de/ldproxy/rest/services/WaterLevelForecast/collections/waterlevelforecastdata/items?f=json&limit=500";
|
||||
|
||||
export async function getNearestTideSummary(
|
||||
params: { lat: number; lon: number; at?: string },
|
||||
deps: { cache: Cache; fetcher: FetchLike }
|
||||
): Promise<TideSummary | null> {
|
||||
const data = await deps.cache.getOrSet("bsh:water-level-forecast:all", CACHE_TTL_MS, () =>
|
||||
fetchJson<BshFeatureCollection>(deps.fetcher, BSH_URL, 12_000)
|
||||
);
|
||||
|
||||
const requestedTime = params.at ? new Date(params.at) : undefined;
|
||||
return normalizeNearestTideSummary(
|
||||
data,
|
||||
params,
|
||||
requestedTime && Number.isFinite(requestedTime.getTime()) ? requestedTime : new Date()
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeNearestTideSummary(
|
||||
data: BshFeatureCollection,
|
||||
params: { lat: number; lon: number },
|
||||
now = new Date()
|
||||
): TideSummary | null {
|
||||
const features = data.features ?? [];
|
||||
const nearest = features
|
||||
.map((feature) => {
|
||||
const coordinate = getFeatureCoordinate(feature);
|
||||
if (!coordinate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
feature,
|
||||
distanceKm: haversineDistanceM(params, coordinate) / 1000
|
||||
};
|
||||
})
|
||||
.filter((item): item is { feature: BshFeature; distanceKm: number } => item !== null)
|
||||
.sort((a, b) => a.distanceKm - b.distanceKm)[0];
|
||||
|
||||
if (!nearest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const props = nearest.feature.properties ?? {};
|
||||
const events = (props.high_water_low_water ?? [])
|
||||
.map(normalizeBshEvent)
|
||||
.filter((event): event is TideEvent => event !== null)
|
||||
.filter((event) => new Date(event.time).getTime() >= now.getTime());
|
||||
|
||||
return {
|
||||
station: props.gauge_label ?? "Unbekannte BSH-Station",
|
||||
distanceKm: round(nearest.distanceKm, 1),
|
||||
nextHigh: events.find((event) => event.type === "high") ?? null,
|
||||
nextLow: events.find((event) => event.type === "low") ?? null,
|
||||
waterLevelCurve: normalizeCurve(props.curve ?? []),
|
||||
source: "BSH WaterLevelForecast API, CC BY 4.0",
|
||||
updatedAt: toIso(props.automated_curveforecast_timestamp ?? props.forecast_timestamp) ?? new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function getFeatureCoordinate(feature: BshFeature) {
|
||||
if (feature.geometry?.coordinates) {
|
||||
return { lon: feature.geometry.coordinates[0], lat: feature.geometry.coordinates[1] };
|
||||
}
|
||||
|
||||
const lat = feature.properties?.latitude;
|
||||
const lon = feature.properties?.longitude;
|
||||
return typeof lat === "number" && typeof lon === "number" ? { lat, lon } : null;
|
||||
}
|
||||
|
||||
function normalizeBshEvent(event: BshTideEvent): TideEvent | null {
|
||||
const time = toIso(event.event_timestamp);
|
||||
if (!time || (event.event !== "HW" && event.event !== "NW")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const forecastM = cmToM(event.forecast_value);
|
||||
const predictedM = cmStringToM(event.tidal_prediction_value);
|
||||
|
||||
return {
|
||||
type: event.event === "HW" ? "high" : "low",
|
||||
time,
|
||||
heightM: forecastM ?? predictedM,
|
||||
deviationM: parseDeviationM(event.forecast_deviation)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCurve(curve: BshCurvePoint[]): TideCurvePoint[] {
|
||||
const stride = Math.max(1, Math.ceil(curve.length / 96));
|
||||
|
||||
return curve
|
||||
.filter((_, index) => index % stride === 0)
|
||||
.map((point) => ({
|
||||
time: toIso(point.timestamp) ?? new Date().toISOString(),
|
||||
predictedM: cmStringToM(point.tidal_prediction),
|
||||
measuredM: cmStringToM(point.measurement),
|
||||
forecastM:
|
||||
typeof point.forecast === "number" ? cmToM(point.forecast) : cmStringToM(point.forecast)
|
||||
}))
|
||||
.filter((point) => point.predictedM !== null || point.measuredM !== null || point.forecastM !== null);
|
||||
}
|
||||
|
||||
function toIso(value?: string): string | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.replace(" ", "T");
|
||||
const timestamp = new Date(normalized).getTime();
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
||||
}
|
||||
|
||||
function cmToM(value?: number): number | null {
|
||||
return typeof value === "number" ? round(value / 100, 2) : null;
|
||||
}
|
||||
|
||||
function cmStringToM(value?: string | number): number | null {
|
||||
if (typeof value === "number") {
|
||||
return cmToM(value);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number.parseFloat(value.replace(",", "."));
|
||||
return Number.isFinite(numeric) ? round(numeric / 100, 2) : null;
|
||||
}
|
||||
|
||||
function parseDeviationM(value?: string): number | null {
|
||||
if (!value || value.includes("+/-")) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const numeric = Number.parseFloat(value.replace(",", ".").replace("m", "").trim());
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function round(value: number, digits: number): number {
|
||||
const factor = 10 ** digits;
|
||||
return Math.round(value * factor) / factor;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { MarineForecast } from "@watermaps/shared";
|
||||
import type { Cache } from "./cache.js";
|
||||
import { fetchJson, type FetchLike } from "./http.js";
|
||||
|
||||
type OpenMeteoMarineResponse = {
|
||||
current?: {
|
||||
time?: string;
|
||||
wave_height?: number;
|
||||
wave_direction?: number;
|
||||
wave_period?: number;
|
||||
ocean_current_velocity?: number;
|
||||
ocean_current_direction?: number;
|
||||
sea_level_height_msl?: number;
|
||||
};
|
||||
hourly?: {
|
||||
time?: string[];
|
||||
wave_height?: Array<number | null>;
|
||||
wave_direction?: Array<number | null>;
|
||||
wave_period?: Array<number | null>;
|
||||
ocean_current_velocity?: Array<number | null>;
|
||||
ocean_current_direction?: Array<number | null>;
|
||||
sea_level_height_msl?: Array<number | null>;
|
||||
};
|
||||
};
|
||||
|
||||
type OpenMeteoWeatherResponse = {
|
||||
current?: {
|
||||
time?: string;
|
||||
wind_speed_10m?: number;
|
||||
wind_direction_10m?: number;
|
||||
weather_code?: number;
|
||||
temperature_2m?: number;
|
||||
};
|
||||
hourly?: {
|
||||
time?: string[];
|
||||
wind_speed_10m?: Array<number | null>;
|
||||
wind_direction_10m?: Array<number | null>;
|
||||
weather_code?: Array<number | null>;
|
||||
temperature_2m?: Array<number | null>;
|
||||
};
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
export async function getMarineForecast(
|
||||
params: { lat: number; lon: number; at?: string },
|
||||
deps: { cache: Cache; fetcher: FetchLike }
|
||||
): Promise<MarineForecast> {
|
||||
const requestedTimestamp = params.at ? Date.parse(params.at) : Number.NaN;
|
||||
const requestedHour = Number.isFinite(requestedTimestamp)
|
||||
? new Date(requestedTimestamp).toISOString().slice(0, 13)
|
||||
: "current";
|
||||
const cacheKey = `marine:${params.lat.toFixed(3)}:${params.lon.toFixed(3)}:${requestedHour}`;
|
||||
|
||||
return deps.cache.getOrSet(cacheKey, CACHE_TTL_MS, async () => {
|
||||
const search = new URLSearchParams({
|
||||
latitude: String(params.lat),
|
||||
longitude: String(params.lon),
|
||||
current:
|
||||
"wave_height,wave_direction,wave_period,ocean_current_velocity,ocean_current_direction,sea_level_height_msl",
|
||||
hourly:
|
||||
"wave_height,wave_direction,wave_period,ocean_current_velocity,ocean_current_direction,sea_level_height_msl",
|
||||
forecast_days: "8",
|
||||
timezone: "GMT",
|
||||
wind_speed_unit: "kn",
|
||||
cell_selection: "sea"
|
||||
});
|
||||
const weatherSearch = new URLSearchParams({
|
||||
latitude: String(params.lat),
|
||||
longitude: String(params.lon),
|
||||
current: "wind_speed_10m,wind_direction_10m,weather_code,temperature_2m",
|
||||
hourly: "wind_speed_10m,wind_direction_10m,weather_code,temperature_2m",
|
||||
forecast_days: "8",
|
||||
wind_speed_unit: "kn",
|
||||
timezone: "GMT"
|
||||
});
|
||||
|
||||
const [marineResult, weatherResult] = await Promise.allSettled([
|
||||
fetchJson<OpenMeteoMarineResponse>(
|
||||
deps.fetcher,
|
||||
`https://marine-api.open-meteo.com/v1/marine?${search.toString()}`,
|
||||
12_000
|
||||
),
|
||||
fetchJson<OpenMeteoWeatherResponse>(
|
||||
deps.fetcher,
|
||||
`https://api.open-meteo.com/v1/forecast?${weatherSearch.toString()}`,
|
||||
12_000
|
||||
)
|
||||
]);
|
||||
|
||||
return normalizeMarineForecast(
|
||||
marineResult.status === "fulfilled" ? marineResult.value : {},
|
||||
weatherResult.status === "fulfilled" ? weatherResult.value : {},
|
||||
{
|
||||
marineAvailable: marineResult.status === "fulfilled",
|
||||
weatherAvailable: weatherResult.status === "fulfilled"
|
||||
},
|
||||
Number.isFinite(requestedTimestamp) ? new Date(requestedTimestamp).toISOString() : undefined
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeMarineForecast(
|
||||
marine: OpenMeteoMarineResponse,
|
||||
weather: OpenMeteoWeatherResponse,
|
||||
availability = { marineAvailable: true, weatherAvailable: true },
|
||||
requestedTime?: string
|
||||
): MarineForecast {
|
||||
const first = <T>(values: Array<T | null | undefined> | undefined): T | null => {
|
||||
const value = values?.find((candidate) => candidate !== null && candidate !== undefined);
|
||||
return value ?? null;
|
||||
};
|
||||
|
||||
const nearestMarineIndex = requestedTime ? nearestTimeIndex(marine.hourly?.time, requestedTime) : -1;
|
||||
const nearestWeatherIndex = requestedTime ? nearestTimeIndex(weather.hourly?.time, requestedTime) : -1;
|
||||
const marineIndex = forecastIndexWithinWindow(marine.hourly?.time, nearestMarineIndex, requestedTime);
|
||||
const weatherIndex = forecastIndexWithinWindow(weather.hourly?.time, nearestWeatherIndex, requestedTime);
|
||||
const marineAt = <T>(values?: Array<T | null>) => valueAt(values, marineIndex);
|
||||
const weatherAt = <T>(values?: Array<T | null>) => valueAt(values, weatherIndex);
|
||||
const forecastTime =
|
||||
(marineIndex >= 0 ? marine.hourly?.time?.[marineIndex] : undefined) ??
|
||||
(weatherIndex >= 0 ? weather.hourly?.time?.[weatherIndex] : undefined) ??
|
||||
requestedTime ??
|
||||
new Date().toISOString();
|
||||
|
||||
return {
|
||||
waveHeightM: requestedTime ? marineAt(marine.hourly?.wave_height) : marine.current?.wave_height ?? first(marine.hourly?.wave_height),
|
||||
waveDirectionDeg: requestedTime
|
||||
? marineAt(marine.hourly?.wave_direction)
|
||||
: marine.current?.wave_direction ?? first(marine.hourly?.wave_direction),
|
||||
wavePeriodS: requestedTime ? marineAt(marine.hourly?.wave_period) : marine.current?.wave_period ?? first(marine.hourly?.wave_period),
|
||||
windSpeed: requestedTime ? weatherAt(weather.hourly?.wind_speed_10m) : weather.current?.wind_speed_10m ?? null,
|
||||
windDirectionDeg: requestedTime
|
||||
? weatherAt(weather.hourly?.wind_direction_10m)
|
||||
: weather.current?.wind_direction_10m ?? null,
|
||||
weatherCode: requestedTime ? weatherAt(weather.hourly?.weather_code) : weather.current?.weather_code ?? null,
|
||||
temperatureC: requestedTime ? weatherAt(weather.hourly?.temperature_2m) : weather.current?.temperature_2m ?? null,
|
||||
oceanCurrentSpeedKn: requestedTime
|
||||
? marineAt(marine.hourly?.ocean_current_velocity)
|
||||
: marine.current?.ocean_current_velocity ?? null,
|
||||
oceanCurrentDirectionDeg: requestedTime
|
||||
? marineAt(marine.hourly?.ocean_current_direction)
|
||||
: marine.current?.ocean_current_direction ?? null,
|
||||
seaLevelHeightMslM: requestedTime
|
||||
? marineAt(marine.hourly?.sea_level_height_msl)
|
||||
: marine.current?.sea_level_height_msl ?? null,
|
||||
forecastTime: normalizeForecastTime(forecastTime),
|
||||
source: sourceLabel(availability),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function nearestTimeIndex(values: string[] | undefined, requestedTime: string): number {
|
||||
const requestedTimestamp = Date.parse(requestedTime);
|
||||
if (!values?.length || !Number.isFinite(requestedTimestamp)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
let nearestIndex = -1;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
values.forEach((value, index) => {
|
||||
const timestamp = parseForecastTimestamp(value);
|
||||
const distance = Math.abs(timestamp - requestedTimestamp);
|
||||
if (Number.isFinite(distance) && distance < nearestDistance) {
|
||||
nearestIndex = index;
|
||||
nearestDistance = distance;
|
||||
}
|
||||
});
|
||||
return nearestIndex;
|
||||
}
|
||||
|
||||
function valueAt<T>(values: Array<T | null> | undefined, index: number): T | null {
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
return values?.[index] ?? null;
|
||||
}
|
||||
|
||||
function forecastIndexWithinWindow(values: string[] | undefined, index: number, requestedTime?: string): number {
|
||||
if (!requestedTime || index < 0 || !values?.[index]) {
|
||||
return -1;
|
||||
}
|
||||
const forecastTimestamp = parseForecastTimestamp(values[index]);
|
||||
const requestedTimestamp = Date.parse(requestedTime);
|
||||
return Number.isFinite(forecastTimestamp) &&
|
||||
Number.isFinite(requestedTimestamp) &&
|
||||
Math.abs(forecastTimestamp - requestedTimestamp) <= 2 * 60 * 60 * 1000
|
||||
? index
|
||||
: -1;
|
||||
}
|
||||
|
||||
function normalizeForecastTime(value: string): string {
|
||||
const timestamp = parseForecastTimestamp(value);
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : new Date().toISOString();
|
||||
}
|
||||
|
||||
function parseForecastTimestamp(value: string): number {
|
||||
const hasExplicitZone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value);
|
||||
return Date.parse(hasExplicitZone ? value : `${value}Z`);
|
||||
}
|
||||
|
||||
function sourceLabel(availability: { marineAvailable: boolean; weatherAvailable: boolean }) {
|
||||
if (availability.marineAvailable && availability.weatherAvailable) {
|
||||
return "Open-Meteo Marine + Forecast";
|
||||
}
|
||||
if (availability.marineAvailable) {
|
||||
return "Open-Meteo Marine";
|
||||
}
|
||||
if (availability.weatherAvailable) {
|
||||
return "Open-Meteo Forecast";
|
||||
}
|
||||
return "Open-Meteo nicht erreichbar";
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildServer } from "../src/app.js";
|
||||
import { createCache } from "../src/services/cache.js";
|
||||
import type {
|
||||
LockOperationInfo,
|
||||
NavigationDataAdapter
|
||||
} from "../src/services/navigation-data.js";
|
||||
|
||||
const jsonResponse = (body: unknown) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" }
|
||||
});
|
||||
|
||||
describe("Watermaps API", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns app config with map layers", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const response = await app.inject({ method: "GET", url: "/api/config" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().layers).toHaveLength(3);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("normalizes marine weather responses", async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
current: {
|
||||
wave_height: 0.8,
|
||||
wave_direction: 280,
|
||||
wave_period: 4.2
|
||||
}
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
current: {
|
||||
wind_speed_10m: 11,
|
||||
wind_direction_10m: 245,
|
||||
weather_code: 3,
|
||||
temperature_2m: 19
|
||||
}
|
||||
})
|
||||
);
|
||||
const app = await buildServer({ cache: createCache(), fetcher: fetcher as unknown as typeof fetch });
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/weather/marine?lat=54.18&lon=12.08"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
waveHeightM: 0.8,
|
||||
windSpeed: 11
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns partial marine weather when one provider request fails", async () => {
|
||||
const fetcher = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
current: {
|
||||
wave_height: 1.1,
|
||||
wave_direction: 290,
|
||||
wave_period: 5.3
|
||||
}
|
||||
})
|
||||
)
|
||||
.mockRejectedValueOnce(new Error("timeout"));
|
||||
const app = await buildServer({ cache: createCache(), fetcher: fetcher as unknown as typeof fetch });
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/weather/marine?lat=53.5&lon=7.1"
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toMatchObject({
|
||||
waveHeightM: 1.1,
|
||||
windSpeed: null,
|
||||
source: "Open-Meteo Marine"
|
||||
});
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("serves injected live navigation adapters and forwards bounded route filters", async () => {
|
||||
const lock: LockOperationInfo = {
|
||||
id: "lock-1",
|
||||
name: "Schleuse Hamm",
|
||||
waterway: "DHK",
|
||||
regularHours: "06:00-22:00",
|
||||
operatingState: "restricted",
|
||||
validFrom: "2026-07-20T04:00:00.000Z",
|
||||
validTo: "2026-07-20T20:00:00.000Z",
|
||||
phone: "+49 2381 1234",
|
||||
vhf: "Kanal 20",
|
||||
note: "Anmeldung erforderlich",
|
||||
updatedAt: "2026-07-19T12:00:00.000Z",
|
||||
sourceUrl: "https://www.elwis.de/DE/dynamisch/Schleuseninformationen/"
|
||||
};
|
||||
const load = vi.fn(async () => [lock]);
|
||||
const lockAdapter: NavigationDataAdapter<LockOperationInfo> = {
|
||||
kind: "lock-operations",
|
||||
id: "test-elwis-locks",
|
||||
label: "Test ELWIS locks",
|
||||
sourceUrl: "https://www.elwis.de/DE/dynamisch/Schleuseninformationen/",
|
||||
load
|
||||
};
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
navigationAdapters: {
|
||||
waterLevels: null,
|
||||
lockOperations: lockAdapter,
|
||||
notices: null
|
||||
}
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/navigation/live?waterways=EMS,DHK,EMS&lockIds=lock-1,lock-2"
|
||||
});
|
||||
const body = response.json();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
{ waterways: ["EMS", "DHK"], lockIds: ["lock-1", "lock-2"] },
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal), fetcher: expect.any(Function) })
|
||||
);
|
||||
expect(body.lockOperations).toEqual([lock]);
|
||||
expect(body.sources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: "lock-operations", id: "test-elwis-locks", state: "live" }),
|
||||
expect.objectContaining({ kind: "water-levels", state: "not-configured" }),
|
||||
expect.objectContaining({ kind: "notices", state: "not-configured" })
|
||||
])
|
||||
);
|
||||
expect(body.generatedAt).toEqual(expect.any(String));
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns critical route warnings for shallow samples", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start: { lat: 53.344167, lon: 7.186111 },
|
||||
destination: { lat: 53.563776, lon: 6.750562 },
|
||||
vesselProfile: { draughtM: 1.5, safetyReserveM: 0.4 },
|
||||
depthSamples: [{ coordinate: { lat: 53.442996, lon: 6.833146 }, depthM: 1.6 }]
|
||||
}
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().warnings.some((warning: { severity: string }) => warning.severity === "critical")).toBe(
|
||||
true
|
||||
);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("rejects routes without a known fairway instead of returning a straight line", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start: { lat: 54.1749, lon: 12.0731 },
|
||||
destination: { lat: 54.1833, lon: 12.0928 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||
}
|
||||
});
|
||||
const body = response.json();
|
||||
|
||||
expect(response.statusCode).toBe(422);
|
||||
expect(body.error).toBe("no_fairway_route");
|
||||
expect(body.message).toContain("Keine Fahrwasserroute");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start: { lat: 53.344167, lon: 7.186111 },
|
||||
destination: { lat: 53.563776, lon: 6.750562 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||
}
|
||||
});
|
||||
const body = response.json();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(body.routingMode).toBe("fairway");
|
||||
expect(body.dataSources).toContain("fairway-graph:ems-borkum-seed");
|
||||
expect(body.geometry.coordinates.length).toBeGreaterThan(20);
|
||||
expect(body.warnings.some((warning: { code: string }) => warning.code === "FAIRWAY_ROUTE")).toBe(true);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns the inland fallback route from Emden to Hamm", async () => {
|
||||
const app = await buildServer({ cache: createCache() });
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start: { lat: 53.344167, lon: 7.186111 },
|
||||
destination: { lat: 51.6814536, lon: 7.8042615 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||
}
|
||||
});
|
||||
const body = response.json();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(body.distanceNm).toBeGreaterThan(145);
|
||||
expect(body.distanceNm).toBeLessThan(165);
|
||||
expect(body.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
|
||||
expect(body.geometry.coordinates.at(-1)).toEqual([7.8042615, 51.6814536]);
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("uses an extracted fairway graph before the seed graph", async () => {
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return [
|
||||
{
|
||||
id: "test-extracted",
|
||||
name: "Test Extracted Fairways",
|
||||
maxSnapDistanceNm: 1,
|
||||
nodes: [
|
||||
{ id: "a", coordinate: { lat: 54, lon: 10 } },
|
||||
{ id: "b", coordinate: { lat: 54.02, lon: 10.05 } },
|
||||
{ id: "c", coordinate: { lat: 54.04, lon: 10.1 } }
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "ab",
|
||||
name: "AB",
|
||||
from: "a",
|
||||
to: "b",
|
||||
minDepthM: 4,
|
||||
source: "test-overpass",
|
||||
coordinates: [
|
||||
{ lat: 54, lon: 10 },
|
||||
{ lat: 54.02, lon: 10.05 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "bc",
|
||||
name: "BC",
|
||||
from: "b",
|
||||
to: "c",
|
||||
minDepthM: 4,
|
||||
source: "test-overpass",
|
||||
coordinates: [
|
||||
{ lat: 54.02, lon: 10.05 },
|
||||
{ lat: 54.04, lon: 10.1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start: { lat: 54, lon: 10 },
|
||||
destination: { lat: 54.04, lon: 10.1 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||
}
|
||||
});
|
||||
const body = response.json();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(body.routingMode).toBe("fairway");
|
||||
expect(body.dataSources).toContain("fairway-graph:test-extracted");
|
||||
expect(body.dataSources).toContain("test-overpass");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns distinct route alternatives when the waterway graph contains them", async () => {
|
||||
const coordinate = (lat: number, lon: number) => ({ lat, lon });
|
||||
const edge = (id: string, from: string, to: string, coordinates: Array<{ lat: number; lon: number }>) => ({
|
||||
id,
|
||||
name: id,
|
||||
from,
|
||||
to,
|
||||
coordinates,
|
||||
minDepthM: 4,
|
||||
source: "test-alternatives"
|
||||
});
|
||||
const app = await buildServer({
|
||||
cache: createCache(),
|
||||
fairwayService: {
|
||||
async getGraphsForRoute() {
|
||||
return [
|
||||
{
|
||||
id: "api-alternatives",
|
||||
name: "API Alternativen",
|
||||
maxSnapDistanceNm: 0.2,
|
||||
nodes: [
|
||||
{ id: "start", coordinate: coordinate(52, 7) },
|
||||
{ id: "branch-in", coordinate: coordinate(52, 7.01) },
|
||||
{ id: "upper", coordinate: coordinate(52.012, 7.03) },
|
||||
{ id: "lower", coordinate: coordinate(51.988, 7.03) },
|
||||
{ id: "branch-out", coordinate: coordinate(52, 7.05) },
|
||||
{ id: "destination", coordinate: coordinate(52, 7.06) }
|
||||
],
|
||||
edges: [
|
||||
edge("start-access", "start", "branch-in", [coordinate(52, 7), coordinate(52, 7.01)]),
|
||||
edge("main", "branch-in", "branch-out", [coordinate(52, 7.01), coordinate(52, 7.05)]),
|
||||
edge("upper-in", "branch-in", "upper", [coordinate(52, 7.01), coordinate(52.012, 7.03)]),
|
||||
edge("upper-out", "upper", "branch-out", [coordinate(52.012, 7.03), coordinate(52, 7.05)]),
|
||||
edge("lower-in", "branch-in", "lower", [coordinate(52, 7.01), coordinate(51.988, 7.03)]),
|
||||
edge("lower-out", "lower", "branch-out", [coordinate(51.988, 7.03), coordinate(52, 7.05)]),
|
||||
edge("destination-access", "branch-out", "destination", [coordinate(52, 7.05), coordinate(52, 7.06)])
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/routes",
|
||||
payload: {
|
||||
start: coordinate(52, 7),
|
||||
destination: coordinate(52, 7.06),
|
||||
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3, cruiseSpeedKn: 6 }
|
||||
}
|
||||
});
|
||||
const body = response.json();
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(body.name).toBe("Hauptroute");
|
||||
expect(body.alternatives).toHaveLength(2);
|
||||
expect(body.alternatives.map((route: { name: string }) => route.name)).toEqual(["Alternative 1", "Alternative 2"]);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
MAX_DETAIL_LIMIT,
|
||||
buildLockRecord,
|
||||
collectEurisLocks,
|
||||
compactLocksFilter,
|
||||
normalizePhones,
|
||||
parseCountries,
|
||||
parseDetailLimit,
|
||||
requestJson,
|
||||
risIndexFilter,
|
||||
runEurisSync,
|
||||
} from "../../../scripts/sync-euris-locks.mjs";
|
||||
|
||||
function jsonResponse(payload, init = {}) {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function paginatedFixtureFetch({ recordRequests = [] } = {}) {
|
||||
const compact = [
|
||||
{
|
||||
locode: "DELOCK001",
|
||||
objectName: "Testschleuse Nord",
|
||||
waterwayName: "Testkanal",
|
||||
contactPhone: "0049 201 12345",
|
||||
comcha: " 18 ",
|
||||
},
|
||||
{ locode: "DELOCK002", objectName: "Testschleuse Süd" },
|
||||
];
|
||||
const ris = [
|
||||
{
|
||||
isrs: "DELOCK001",
|
||||
objectName: "Testschleuse Nord",
|
||||
countryCode: "DE",
|
||||
lon: 7.1,
|
||||
lat: 51.5,
|
||||
source: "WSV, Wadaba",
|
||||
},
|
||||
{
|
||||
isrs: "DELOCK002",
|
||||
objectName: "Testschleuse Süd",
|
||||
countryCode: "DE",
|
||||
lon: 7.2,
|
||||
lat: 51.6,
|
||||
source: "WSV, Wadaba",
|
||||
},
|
||||
{
|
||||
isrs: "DELOCK003",
|
||||
objectName: "Nur im RIS-Index",
|
||||
countryCode: "DE",
|
||||
lon: 7.3,
|
||||
lat: 51.7,
|
||||
source: "WSV",
|
||||
},
|
||||
];
|
||||
|
||||
return async (input, init) => {
|
||||
const url = new URL(String(input));
|
||||
recordRequests.push({ url, init });
|
||||
const skip = Number(url.searchParams.get("$skip"));
|
||||
const top = Number(url.searchParams.get("$top"));
|
||||
|
||||
if (url.pathname.endsWith("/GetCompactLocks")) {
|
||||
return jsonResponse({ count: compact.length, items: compact.slice(skip, skip + top) });
|
||||
}
|
||||
if (url.pathname.endsWith("/GetRISIndexObjects")) {
|
||||
return jsonResponse({ count: ris.length, items: ris.slice(skip, skip + top) });
|
||||
}
|
||||
throw new Error(`Unerwartete Test-URL: ${url}`);
|
||||
};
|
||||
}
|
||||
|
||||
describe("EuRIS lock synchronization", () => {
|
||||
it("validates country filters and caps optional detail requests", () => {
|
||||
expect(parseCountries(" de,NL de ")).toEqual(["DE", "NL"]);
|
||||
expect(() => parseCountries("DEU")).toThrow(/ISO-Ländercode/u);
|
||||
expect(parseDetailLimit("999")).toBe(MAX_DETAIL_LIMIT);
|
||||
expect(compactLocksFilter(["DE", "NL"])).toContain("startswith(locode,'DE')");
|
||||
expect(risIndexFilter(["DE", "NL"])).toBe(
|
||||
"(countryCode eq 'DE' or countryCode eq 'NL') and function eq 'lokare'",
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes EuRIS contact data and always uses the RIS coordinate", () => {
|
||||
expect(normalizePhones("0049 201 12345; +49 201 67890")).toEqual([
|
||||
"+49 201 12345",
|
||||
"+49 201 67890",
|
||||
]);
|
||||
|
||||
const record = buildLockRecord({
|
||||
compact: {
|
||||
locode: "DELOCK001",
|
||||
objectName: "Lock",
|
||||
waterwayName: "Canal",
|
||||
contactPhone: "0049 201 12345",
|
||||
comcha: " 18 ",
|
||||
},
|
||||
ris: {
|
||||
isrs: "DELOCK001",
|
||||
lon: "7.123",
|
||||
lat: "51.456",
|
||||
source: "WSV, Wadaba",
|
||||
countryCode: "DE",
|
||||
},
|
||||
detail: {
|
||||
facility: {
|
||||
street: "Uferstraße 1",
|
||||
postCode: "12345",
|
||||
city: "Teststadt",
|
||||
country: "DE",
|
||||
contacts: [
|
||||
{
|
||||
company: "Wasserstraßenverwaltung",
|
||||
emails: ["lock@example.test"],
|
||||
phones: ["+49 201 999"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
fetchedAt: "2026-07-20T10:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(record).toMatchObject({
|
||||
sourceId: "DELOCK001",
|
||||
longitude: 7.123,
|
||||
latitude: 51.456,
|
||||
properties: {
|
||||
phone: "+49 201 12345",
|
||||
vhf: "18",
|
||||
waterway_name: "Canal",
|
||||
operator: "Wasserstraßenverwaltung",
|
||||
email: "lock@example.test",
|
||||
address: "Uferstraße 1, 12345, Teststadt, DE",
|
||||
upstream_source: "WSV, Wadaba",
|
||||
fetched_at: "2026-07-20T10:00:00.000Z",
|
||||
},
|
||||
});
|
||||
expect(record.properties.source_url).toContain("isrs=DELOCK001");
|
||||
});
|
||||
|
||||
it("honors Retry-After for throttled requests and sends an optional bearer token", async () => {
|
||||
const waits = [];
|
||||
const headers = [];
|
||||
let calls = 0;
|
||||
const fetchImpl = async (_url, init) => {
|
||||
calls += 1;
|
||||
headers.push(new Headers(init.headers));
|
||||
if (calls === 1) {
|
||||
return new Response("rate limited", {
|
||||
status: 429,
|
||||
headers: { "retry-after": "2" },
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ok: true });
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestJson("https://example.test/euris", {
|
||||
fetchImpl,
|
||||
token: "secret-token",
|
||||
sleepImpl: async (milliseconds) => waits.push(milliseconds),
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
expect(waits).toEqual([2_000]);
|
||||
expect(headers.every((entry) => entry.get("authorization") === "Bearer secret-token")).toBe(true);
|
||||
});
|
||||
|
||||
it("paginates compact and RIS data stably, joins by ISRS and keeps RIS-only locks", async () => {
|
||||
const requests = [];
|
||||
const result = await collectEurisLocks({
|
||||
countries: ["DE"],
|
||||
detailLimit: 0,
|
||||
pageSize: 1,
|
||||
fetchImpl: paginatedFixtureFetch({ recordRequests: requests }),
|
||||
token: "token",
|
||||
fetchedAt: "2026-07-20T10:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(result.records.map((record) => record.sourceId)).toEqual([
|
||||
"DELOCK001",
|
||||
"DELOCK002",
|
||||
"DELOCK003",
|
||||
]);
|
||||
expect(result.stats).toMatchObject({
|
||||
compactLocks: 2,
|
||||
risLocks: 3,
|
||||
joinedLocks: 2,
|
||||
risOnlyLocks: 1,
|
||||
storedLocks: 3,
|
||||
});
|
||||
expect(requests.every(({ url }) => Number(url.searchParams.get("$top")) <= 100)).toBe(true);
|
||||
expect(requests.every(({ url }) => url.searchParams.has("$orderby"))).toBe(true);
|
||||
expect(
|
||||
requests.every(({ init }) => new Headers(init.headers).get("authorization") === "Bearer token"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not invoke the database writer in dry-run mode", async () => {
|
||||
const writer = vi.fn();
|
||||
const result = await runEurisSync({
|
||||
dryRun: true,
|
||||
writer,
|
||||
collectorOptions: {
|
||||
countries: ["DE"],
|
||||
pageSize: 100,
|
||||
fetchImpl: paginatedFixtureFetch(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.records).toHaveLength(3);
|
||||
expect(writer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildRoute } from "@watermaps/shared";
|
||||
import {
|
||||
fairwayRowsToGraph,
|
||||
mergeConnectedFairwayGraphs,
|
||||
overpassToGraph
|
||||
} from "../src/services/fairways.js";
|
||||
|
||||
describe("fairway graph extraction", () => {
|
||||
it("builds a routable graph from PostGIS fairway rows", () => {
|
||||
const graph = fairwayRowsToGraph(
|
||||
[
|
||||
{
|
||||
id: "1",
|
||||
source: "osm",
|
||||
source_id: "way-1",
|
||||
name: "Harbour Reach",
|
||||
min_depth_m: "4.2",
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[10, 54],
|
||||
[10.04, 54.02]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
source: "osm",
|
||||
source_id: "way-2",
|
||||
name: "Outer Reach",
|
||||
min_depth_m: 4.2,
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[10.04, 54.02],
|
||||
[10.1, 54.04]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
[9.9, 53.9, 10.2, 54.1]
|
||||
);
|
||||
|
||||
expect(graph).not.toBeNull();
|
||||
const route = buildRoute(
|
||||
{
|
||||
start: { lat: 54, lon: 10 },
|
||||
destination: { lat: 54.04, lon: 10.1 },
|
||||
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.4 }
|
||||
},
|
||||
graph ?? undefined
|
||||
);
|
||||
|
||||
expect(route?.routingMode).toBe("fairway");
|
||||
expect(route?.dataSources).toContain("fairway-graph:postgis-9.900-53.900-10.200-54.100");
|
||||
expect(route?.dataSources).toContain("postgis-osm");
|
||||
});
|
||||
|
||||
it("routes the iPhone Emden coordinates through intermediate fairway vertices", () => {
|
||||
const start = { lat: 53.3306, lon: 7.1752 };
|
||||
const destination = { lat: 53.6741, lon: 7.1474 };
|
||||
const graph = fairwayRowsToGraph(
|
||||
[
|
||||
{
|
||||
id: "emden-main-reach",
|
||||
source: "osm",
|
||||
source_id: "way-main",
|
||||
name: "Ems Fahrwasser",
|
||||
min_depth_m: null,
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[7.1751368, 53.3331995],
|
||||
[7.16, 53.42],
|
||||
[7.1474, 53.55],
|
||||
[7.18, 53.61]
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "busetief-branch",
|
||||
source: "osm",
|
||||
source_id: "way-branch",
|
||||
name: "Busetief",
|
||||
min_depth_m: null,
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[7.1474, 53.55],
|
||||
[7.1414995, 53.6668156]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
[6.9974, 53.1806, 7.3252, 53.8241]
|
||||
);
|
||||
|
||||
expect(graph).not.toBeNull();
|
||||
const route = buildRoute(
|
||||
{
|
||||
start,
|
||||
destination,
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||
},
|
||||
graph ?? undefined
|
||||
);
|
||||
|
||||
expect(route).not.toBeNull();
|
||||
expect(route?.routingMode).toBe("fairway");
|
||||
expect(route?.dataSources).toContain("postgis-osm");
|
||||
expect(route?.geometry.coordinates[0]).toEqual([start.lon, start.lat]);
|
||||
expect(route?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]);
|
||||
expect(route?.geometry.coordinates.some(([lon, lat]) => lon === 7.1474 && lat === 53.55)).toBe(true);
|
||||
});
|
||||
|
||||
it("builds a graph from OSM/OpenSeaMap Overpass ways", () => {
|
||||
const graph = overpassToGraph(
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
type: "way",
|
||||
id: 123,
|
||||
tags: {
|
||||
"seamark:type": "navigation_line",
|
||||
"seamark:navigation_line:minimum_depth": "3.5"
|
||||
},
|
||||
geometry: [
|
||||
{ lat: 54, lon: 10 },
|
||||
{ lat: 54.02, lon: 10.05 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
[9.9, 53.9, 10.1, 54.1]
|
||||
);
|
||||
|
||||
expect(graph).not.toBeNull();
|
||||
expect(graph?.edges[0]?.source).toBe("osm-overpass-seamarks");
|
||||
expect(graph?.edges[0]?.minDepthM).toBe(3.5);
|
||||
});
|
||||
|
||||
it("accepts navigable canals but rejects explicitly closed waterways", () => {
|
||||
const graph = overpassToGraph(
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
type: "way",
|
||||
id: 201,
|
||||
tags: { waterway: "canal", boat: "yes", name: "Datteln-Hamm-Kanal" },
|
||||
geometry: [
|
||||
{ lat: 51.65, lon: 7.35 },
|
||||
{ lat: 51.66, lon: 7.4 }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: "way",
|
||||
id: 202,
|
||||
tags: { waterway: "canal", boat: "no", name: "Gesperrter Kanal" },
|
||||
geometry: [
|
||||
{ lat: 51.66, lon: 7.4 },
|
||||
{ lat: 51.67, lon: 7.45 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
[7.3, 51.6, 7.5, 51.7]
|
||||
);
|
||||
|
||||
expect(graph).not.toBeNull();
|
||||
expect(graph?.edges).toHaveLength(1);
|
||||
expect(graph?.edges[0]?.name).toBe("Datteln-Hamm-Kanal");
|
||||
});
|
||||
|
||||
it("topologically joins graph fragments from PostGIS and live data", () => {
|
||||
const first = fairwayRowsToGraph(
|
||||
[
|
||||
{
|
||||
id: "north",
|
||||
source: "osm",
|
||||
source_id: "north",
|
||||
name: "Dortmund-Ems-Kanal",
|
||||
min_depth_m: null,
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[7.3, 52.1],
|
||||
[7.35, 52]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
[7.2, 51.8, 7.6, 52.2]
|
||||
);
|
||||
const second = overpassToGraph(
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
type: "way",
|
||||
id: 301,
|
||||
tags: { waterway: "canal", boat: "yes", name: "Datteln-Hamm-Kanal" },
|
||||
geometry: [
|
||||
{ lat: 52, lon: 7.35 },
|
||||
{ lat: 51.9, lon: 7.5 }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
[7.2, 51.8, 7.6, 52.2]
|
||||
);
|
||||
const graph = mergeConnectedFairwayGraphs([first!, second!]);
|
||||
const route = buildRoute(
|
||||
{
|
||||
start: { lat: 52.1, lon: 7.3 },
|
||||
destination: { lat: 51.9, lon: 7.5 },
|
||||
vesselProfile: { draughtM: 1.2, safetyReserveM: 0.3 }
|
||||
},
|
||||
graph ?? undefined
|
||||
);
|
||||
|
||||
expect(route).not.toBeNull();
|
||||
expect(route?.dataSources).toContain("postgis-osm");
|
||||
expect(route?.dataSources).toContain("osm-overpass-waterway-canal");
|
||||
});
|
||||
|
||||
it("carries OSM vessel restrictions into the routing graph", () => {
|
||||
const graph = fairwayRowsToGraph(
|
||||
[
|
||||
{
|
||||
id: "restricted",
|
||||
source: "osm",
|
||||
source_id: "way-restricted",
|
||||
name: "Niedrige Durchfahrt",
|
||||
min_depth_m: "3.0",
|
||||
properties: { maxheight: "2.4 m", maxwidth: "3.2", maxdraft: "1.8", oneway: "yes" },
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[7, 52],
|
||||
[7.04, 52]
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
[6.9, 51.9, 7.1, 52.1]
|
||||
);
|
||||
|
||||
expect(graph?.edges[0]).toMatchObject({
|
||||
maxAirDraftM: 2.4,
|
||||
maxBeamM: 3.2,
|
||||
maxDraughtM: 1.8,
|
||||
oneway: true
|
||||
});
|
||||
expect(
|
||||
buildRoute(
|
||||
{
|
||||
start: { lat: 52, lon: 7 },
|
||||
destination: { lat: 52, lon: 7.04 },
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.3, airDraftM: 2.5, beamM: 3 }
|
||||
},
|
||||
graph ?? undefined
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
deduplicateMarineContactFeatures,
|
||||
normalizeDepthFeatureProperties,
|
||||
normalizeMarineFeatureProperties
|
||||
} from "../src/services/features.js";
|
||||
|
||||
describe("marine feature normalization", () => {
|
||||
it("formats bridge clearance labels from known OSM height tags", () => {
|
||||
const properties = normalizeMarineFeatureProperties({
|
||||
layer: "bridges",
|
||||
name: "Kaiser-Wilhelm-Brücke",
|
||||
source: "osm",
|
||||
sourceId: "w123",
|
||||
properties: {
|
||||
bridge: "movable",
|
||||
maxheight: "3"
|
||||
}
|
||||
});
|
||||
|
||||
expect(properties.clearance_m).toBe(3);
|
||||
expect(properties.clearance_label).toBe("H 3 m");
|
||||
expect(properties.label).toBe("Kaiser-Wilhelm-Brücke H 3 m");
|
||||
});
|
||||
|
||||
it("ignores non-numeric default bridge heights", () => {
|
||||
const properties = normalizeMarineFeatureProperties({
|
||||
layer: "bridges",
|
||||
name: null,
|
||||
source: "osm",
|
||||
sourceId: "w124",
|
||||
properties: {
|
||||
bridge: "yes",
|
||||
maxheight: "default"
|
||||
}
|
||||
});
|
||||
|
||||
expect(properties.clearance_m).toBeNull();
|
||||
expect(properties.label).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes contact aliases, address and database timestamps", () => {
|
||||
const properties = normalizeMarineFeatureProperties({
|
||||
layer: "locks",
|
||||
name: "Schleuse Hamm",
|
||||
source: "osm",
|
||||
sourceId: "w166568834",
|
||||
updatedAt: new Date("2026-07-19T08:30:00.000Z"),
|
||||
properties: {
|
||||
"contact:phone": "+49 2381 9019280",
|
||||
"contact:website": "https://example.test/schleuse-hamm",
|
||||
"contact:email": "schleuse@example.test",
|
||||
"seamark:lock_basin:communication_channel": "18",
|
||||
opening_hours: "24/7",
|
||||
operator: "WSV",
|
||||
"addr:street": "Fährstraße",
|
||||
"addr:housenumber": "1",
|
||||
"addr:postcode": "59071",
|
||||
"addr:city": "Hamm",
|
||||
"addr:country": "DE"
|
||||
}
|
||||
});
|
||||
|
||||
expect(properties.phone).toBe("+49 2381 9019280");
|
||||
expect(properties.website).toBe("https://example.test/schleuse-hamm");
|
||||
expect(properties.email).toBe("schleuse@example.test");
|
||||
expect(properties.vhf).toBe("18");
|
||||
expect(properties.openingHours).toBe("24/7");
|
||||
expect(properties.operator).toBe("WSV");
|
||||
expect(properties.address).toBe("Fährstraße 1, 59071 Hamm, DE");
|
||||
expect(properties.source).toBe("osm");
|
||||
expect(properties.sourceId).toBe("w166568834");
|
||||
expect(properties.updatedAt).toBe("2026-07-19T08:30:00.000Z");
|
||||
});
|
||||
|
||||
it("prefers direct contact fields and returns stable null values when details are absent", () => {
|
||||
const properties = normalizeMarineFeatureProperties({
|
||||
layer: "harbours",
|
||||
name: "Marina Emden",
|
||||
source: "osm",
|
||||
sourceId: "n1",
|
||||
properties: {
|
||||
phone: "+49 4921 123",
|
||||
"contact:phone": "+49 4921 999"
|
||||
}
|
||||
});
|
||||
|
||||
expect(properties.phone).toBe("+49 4921 123");
|
||||
expect(properties.website).toBeNull();
|
||||
expect(properties.email).toBeNull();
|
||||
expect(properties.vhf).toBeNull();
|
||||
expect(properties.openingHours).toBeNull();
|
||||
expect(properties.operator).toBeNull();
|
||||
expect(properties.address).toBeNull();
|
||||
expect(properties.updatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps navigation details but removes unrelated bulk OSM tags from viewport features", () => {
|
||||
const properties = normalizeMarineFeatureProperties({
|
||||
layer: "harbours",
|
||||
name: "Testhafen",
|
||||
source: "osm",
|
||||
sourceId: "w42",
|
||||
properties: {
|
||||
leisure: "marina",
|
||||
electricity: "yes",
|
||||
"contact:phone": "+49 40 123",
|
||||
"source:geometry": "survey",
|
||||
note: "A very large unrelated note that is not consumed by the client"
|
||||
}
|
||||
});
|
||||
|
||||
expect(properties.leisure).toBe("marina");
|
||||
expect(properties.electricity).toBe("yes");
|
||||
expect(properties.phone).toBe("+49 40 123");
|
||||
expect(properties).not.toHaveProperty("source:geometry");
|
||||
expect(properties).not.toHaveProperty("note");
|
||||
});
|
||||
|
||||
it("formats fairway depth labels", () => {
|
||||
const properties = normalizeDepthFeatureProperties({
|
||||
name: "Nord-Ostsee-Kanal",
|
||||
source: "osm",
|
||||
sourceId: "w456",
|
||||
minDepthM: "14",
|
||||
properties: {
|
||||
depth: "14"
|
||||
}
|
||||
});
|
||||
|
||||
expect(properties.depth_m).toBe(14);
|
||||
expect(properties.depth_label).toBe("14 m");
|
||||
expect(properties.label).toBe("Nord-Ostsee-Kanal 14 m");
|
||||
});
|
||||
});
|
||||
|
||||
describe("marine contact feature deduplication", () => {
|
||||
it("returns one stable facility feature and reports how many raw objects were merged", () => {
|
||||
const result = deduplicateMarineContactFeatures([
|
||||
{
|
||||
type: "Feature",
|
||||
id: "12",
|
||||
geometry: { type: "Point", coordinates: [7.867, 51.695] },
|
||||
properties: {
|
||||
layer: "locks",
|
||||
source: "osm",
|
||||
sourceId: "w12",
|
||||
name: "Schleuse Werries",
|
||||
website: "https://example.test/werries"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Feature",
|
||||
id: "99",
|
||||
geometry: { type: "Point", coordinates: [7.86708, 51.69508] },
|
||||
properties: {
|
||||
layer: "locks",
|
||||
source: "euris",
|
||||
sourceId: "DEHMM00301LOCKS00404",
|
||||
name: "Werries",
|
||||
phone: "+49 2381 9019-290",
|
||||
vhf: "22",
|
||||
"ref:EU:RIS": "DEHMM00301LOCKS00404"
|
||||
}
|
||||
},
|
||||
{
|
||||
type: "Feature",
|
||||
id: "bridge-1",
|
||||
geometry: { type: "LineString", coordinates: [[7.8, 51.6], [7.9, 51.7]] },
|
||||
properties: { layer: "bridges", source: "osm", name: "Testbrücke" }
|
||||
}
|
||||
]);
|
||||
|
||||
expect(result.metadata).toEqual({ inputPoiCount: 2, outputPoiCount: 1, mergedObjectCount: 1 });
|
||||
expect(result.features).toHaveLength(2);
|
||||
expect(result.features.find((feature) => feature.properties.layer === "locks")).toMatchObject({
|
||||
id: "marine-poi:locks:euris:DEHMM00301LOCKS00404",
|
||||
properties: {
|
||||
name: "Werries",
|
||||
phone: "+49 2381 9019-290",
|
||||
website: "https://example.test/werries",
|
||||
dedupeMemberCount: 2
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildFacilitySearchQuery,
|
||||
buildSearchEnrichmentRecord,
|
||||
enrichSearchCandidates,
|
||||
evaluateFacilityPageMatch,
|
||||
parseDuckDuckGoResults,
|
||||
runSearchEnrichment,
|
||||
searchBrave,
|
||||
searchDuckDuckGo,
|
||||
scoreSearchResult,
|
||||
unwrapDuckDuckGoUrl,
|
||||
} from "../../../scripts/enrich-marine-search.mjs";
|
||||
|
||||
const MATCH_THRESHOLD = 70;
|
||||
const FETCHED_AT = "2026-07-23T09:30:00.000Z";
|
||||
|
||||
function searchCandidate(overrides = {}) {
|
||||
return {
|
||||
id: "42",
|
||||
layer: "locks",
|
||||
source: "osm",
|
||||
sourceId: "w123",
|
||||
name: "Schleuse Werries",
|
||||
properties: {
|
||||
"addr:city": "Hamm",
|
||||
"addr:country": "DE",
|
||||
waterway_name: "Datteln-Hamm-Kanal",
|
||||
},
|
||||
enrichmentProperties: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const matchingResult = {
|
||||
url: "https://www.wsa.example/schleuse-werries",
|
||||
title: "Schleuse Werries | WSA Westdeutsche Kanäle",
|
||||
snippet: "Offizielle Informationen und Kontakt zur Schleuse Werries in Hamm am Datteln-Hamm-Kanal.",
|
||||
};
|
||||
|
||||
const matchingPageHtml = `
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<title>Schleuse Werries | WSA Westdeutsche Kanäle</title>
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "GovernmentOrganization",
|
||||
"name": "WSA Westdeutsche Kanäle",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressLocality": "Hamm",
|
||||
"addressCountry": "DE"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Schleuse Werries</h1>
|
||||
<p>Datteln-Hamm-Kanal in Hamm</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const identityOnlyPageHtml = `
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head><title>Schleuse Werries | WSA Westdeutsche Kanäle</title></head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Schleuse Werries</h1>
|
||||
<p>Datteln-Hamm-Kanal in Hamm</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
describe("marine facility search discovery", () => {
|
||||
it("builds a stable, specific query and refuses generic facility names", () => {
|
||||
const query = buildFacilitySearchQuery(searchCandidate());
|
||||
|
||||
expect(query).toContain('"Schleuse Werries"');
|
||||
expect(query).toContain("Hamm");
|
||||
expect(query).toContain("Datteln-Hamm-Kanal");
|
||||
expect(query).toMatch(/Kontakt/iu);
|
||||
expect(query).not.toMatch(/\b(?:undefined|null)\b/iu);
|
||||
|
||||
expect(
|
||||
buildFacilitySearchQuery(
|
||||
searchCandidate({
|
||||
name: "Hafen",
|
||||
layer: "harbours",
|
||||
properties: { "addr:city": "Hamm" },
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(buildFacilitySearchQuery(searchCandidate({ name: "Schleuse", properties: {} }))).toBeNull();
|
||||
expect(buildFacilitySearchQuery(searchCandidate({ name: null, properties: {} }))).toBeNull();
|
||||
});
|
||||
|
||||
it("unwraps DuckDuckGo targets but rejects internal and unsafe links", () => {
|
||||
const target = "https://www.wsa.example/schleuse-werries?view=contact";
|
||||
const wrapped =
|
||||
`//duckduckgo.com/l/?uddg=${encodeURIComponent(target)}` +
|
||||
"&rut=0123456789";
|
||||
|
||||
expect(unwrapDuckDuckGoUrl(wrapped)).toBe(target);
|
||||
expect(unwrapDuckDuckGoUrl(target)).toBe(target);
|
||||
expect(unwrapDuckDuckGoUrl("/html/?q=schleuse+werries")).toBeNull();
|
||||
expect(unwrapDuckDuckGoUrl("javascript:alert(1)")).toBeNull();
|
||||
expect(unwrapDuckDuckGoUrl("mailto:test@example.test")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses organic DuckDuckGo results, decodes text and removes ads and duplicates", () => {
|
||||
const wrappedTarget =
|
||||
"//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.wsa.example%2Fschleuse-werries%23kontakt" +
|
||||
"&rut=abc";
|
||||
const html = `
|
||||
<div class="result results_links results_links_deep web-result">
|
||||
<h2 class="result__title">
|
||||
<a rel="nofollow" class="result__a" href="${wrappedTarget}">
|
||||
Schleuse <b>Werries</b> & Kontakt
|
||||
</a>
|
||||
</h2>
|
||||
<a class="result__snippet">
|
||||
Offizielle Informationen für Hamm. Telefon & E-Mail.
|
||||
</a>
|
||||
</div>
|
||||
<div class="result result--ad">
|
||||
<h2>
|
||||
<a class="result__a" href="https://advertising.example/werries">
|
||||
Anzeige für Werries
|
||||
</a>
|
||||
</h2>
|
||||
<span class="result__badge">Ad</span>
|
||||
</div>
|
||||
<div class="result">
|
||||
<a class="result__a" href="https://www.wsa.example/schleuse-werries#anfahrt">
|
||||
Derselbe Treffer ein zweites Mal
|
||||
</a>
|
||||
<a class="result__snippet">Duplikat</a>
|
||||
</div>
|
||||
<div class="result">
|
||||
<a class="result__a" href="https://hafen.example/kontakt?lang=de">
|
||||
Hafenservice Hamm
|
||||
</a>
|
||||
<a class="result__snippet">Ein zweiter organischer Treffer.</a>
|
||||
</div>
|
||||
<a href="javascript:alert(1)" class="result__a">Unsicher</a>
|
||||
`;
|
||||
|
||||
expect(parseDuckDuckGoResults(html, { limit: 10 })).toEqual([
|
||||
{
|
||||
url: "https://www.wsa.example/schleuse-werries",
|
||||
title: "Schleuse Werries & Kontakt",
|
||||
snippet: "Offizielle Informationen für Hamm. Telefon & E-Mail.",
|
||||
rank: 1,
|
||||
},
|
||||
{
|
||||
url: "https://hafen.example/kontakt?lang=de",
|
||||
title: "Hafenservice Hamm",
|
||||
snippet: "Ein zweiter organischer Treffer.",
|
||||
rank: 2,
|
||||
},
|
||||
]);
|
||||
expect(parseDuckDuckGoResults(html, { limit: 1 })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("treats a DuckDuckGo browser challenge as a provider block, not as no results", async () => {
|
||||
await expect(
|
||||
searchDuckDuckGo("Schleuse Werries Hamm", {
|
||||
fetchPageImpl: async () => ({
|
||||
status: 202,
|
||||
finalUrl: "https://html.duckduckgo.com/html/",
|
||||
html: '<form id="challenge-form"><script src="/anomaly.js"></script></form>',
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "PROVIDER_BLOCKED", status: 202 });
|
||||
});
|
||||
|
||||
it("supports the authenticated Brave API through the same normalized result shape", async () => {
|
||||
const fetchImpl = vi.fn(async (_url, init) => {
|
||||
expect(init.headers["X-Subscription-Token"]).toBe("test-token");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: "Schleuse Werries",
|
||||
url: "https://www.wsa.example/schleuse-werries",
|
||||
description: "Kontakt in Hamm",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
searchBrave("Schleuse Werries Hamm", {
|
||||
apiKey: "test-token",
|
||||
fetchImpl,
|
||||
limit: 3,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
title: "Schleuse Werries",
|
||||
url: "https://www.wsa.example/schleuse-werries",
|
||||
snippet: "Kontakt in Hamm",
|
||||
rank: 1,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires a distinctive name plus matching place evidence before accepting a result", () => {
|
||||
const accepted = scoreSearchResult(searchCandidate(), matchingResult);
|
||||
const wrongPlace = scoreSearchResult(searchCandidate(), {
|
||||
...matchingResult,
|
||||
url: "https://tourismus.example/amsterdam/werries",
|
||||
title: "Schleuse Werries in Amsterdam",
|
||||
snippet: "Besuchen Sie die historische Schleuse in Amsterdam, Noord-Holland.",
|
||||
});
|
||||
const missingName = scoreSearchResult(searchCandidate(), {
|
||||
url: "https://www.hamm.example/schleusen",
|
||||
title: "Wasserstraßen und Schleusen in Hamm",
|
||||
snippet: "Kontakt für den Datteln-Hamm-Kanal.",
|
||||
});
|
||||
const genericName = scoreSearchResult(
|
||||
searchCandidate({ name: "Schleuse", properties: { "addr:city": "Hamm" } }),
|
||||
matchingResult,
|
||||
);
|
||||
|
||||
expect(accepted.accepted).toBe(true);
|
||||
expect(accepted.score).toBeGreaterThanOrEqual(MATCH_THRESHOLD);
|
||||
expect(accepted.evidence.length).toBeGreaterThan(0);
|
||||
|
||||
expect(wrongPlace.accepted).toBe(false);
|
||||
expect(wrongPlace.score).toBeLessThan(MATCH_THRESHOLD);
|
||||
expect(missingName.accepted).toBe(false);
|
||||
expect(genericName).toMatchObject({ accepted: false, score: 0 });
|
||||
});
|
||||
|
||||
it("validates the fetched page itself and rejects a misleading redirect or generic homepage", () => {
|
||||
const accepted = evaluateFacilityPageMatch(searchCandidate(), {
|
||||
html: matchingPageHtml,
|
||||
finalUrl: "https://www.wsa.example/schleuse-werries",
|
||||
});
|
||||
const genericHomepage = evaluateFacilityPageMatch(searchCandidate(), {
|
||||
html: `
|
||||
<html>
|
||||
<head><title>WSA Westdeutsche Kanäle</title></head>
|
||||
<body><h1>Willkommen</h1><p>Allgemeine Informationen zur Wasserstraßenverwaltung.</p></body>
|
||||
</html>
|
||||
`,
|
||||
finalUrl: "https://www.wsa.example/",
|
||||
});
|
||||
const wrongFacility = evaluateFacilityPageMatch(searchCandidate(), {
|
||||
html: `
|
||||
<html>
|
||||
<head><title>Schleuse Werries Amsterdam</title></head>
|
||||
<body><h1>Schleuse Werries</h1><address>Amsterdam, NL</address></body>
|
||||
</html>
|
||||
`,
|
||||
finalUrl: "https://tourismus.example/amsterdam/werries",
|
||||
});
|
||||
|
||||
expect(accepted.accepted).toBe(true);
|
||||
expect(accepted.score).toBeGreaterThanOrEqual(MATCH_THRESHOLD);
|
||||
expect(accepted.evidence.length).toBeGreaterThan(0);
|
||||
expect(genericHomepage.accepted).toBe(false);
|
||||
expect(wrongFacility.accepted).toBe(false);
|
||||
});
|
||||
|
||||
it("stores the verified website with auditable provenance and never overwrites existing contacts", () => {
|
||||
const candidate = searchCandidate({
|
||||
properties: {
|
||||
"addr:city": "Hamm",
|
||||
phone: "+49 2381 100",
|
||||
},
|
||||
enrichmentProperties: {
|
||||
operator: "Vorhandener Betreiber",
|
||||
},
|
||||
});
|
||||
const query = buildFacilitySearchQuery(candidate);
|
||||
const page = {
|
||||
html: matchingPageHtml,
|
||||
finalUrl: "https://www.wsa.example/anlagen/schleuse-werries",
|
||||
};
|
||||
const match = evaluateFacilityPageMatch(candidate, page);
|
||||
const record = buildSearchEnrichmentRecord({
|
||||
candidate,
|
||||
query,
|
||||
providerId: "duckduckgo",
|
||||
searchResult: matchingResult,
|
||||
page,
|
||||
match,
|
||||
extracted: {
|
||||
phone: "+49 2381 999",
|
||||
email: "schleuse-werries@example.test",
|
||||
operator: "Anderer Betreiber",
|
||||
address: null,
|
||||
},
|
||||
fetchedAt: FETCHED_AT,
|
||||
});
|
||||
|
||||
expect(record).toMatchObject({
|
||||
originalId: "42",
|
||||
sourceId: "osm:w123",
|
||||
properties: {
|
||||
website: "https://www.wsa.example/anlagen/schleuse-werries",
|
||||
phone: "+49 2381 100",
|
||||
email: "schleuse-werries@example.test",
|
||||
operator: "Vorhandener Betreiber",
|
||||
original_source: "osm",
|
||||
original_source_id: "w123",
|
||||
enrichmentSource: "facility-search",
|
||||
enrichmentProvider: "duckduckgo",
|
||||
searchQuery: query,
|
||||
searchResultUrl: matchingResult.url,
|
||||
source_url: page.finalUrl,
|
||||
fetched_at: FETCHED_AT,
|
||||
},
|
||||
});
|
||||
expect(record.properties.searchScore).toBeGreaterThanOrEqual(MATCH_THRESHOLD);
|
||||
expect(record.properties.enriched_fields).toEqual(
|
||||
expect.arrayContaining(["website", "email"]),
|
||||
);
|
||||
expect(record.properties.enriched_fields).not.toContain("phone");
|
||||
expect(record.properties.enriched_fields).not.toContain("operator");
|
||||
});
|
||||
|
||||
it("persists a verified discovered website even when the page exposes no contact fields", async () => {
|
||||
const search = vi.fn(async () => [matchingResult]);
|
||||
const fetchPageImpl = vi.fn(async () => ({
|
||||
html: identityOnlyPageHtml,
|
||||
finalUrl: matchingResult.url,
|
||||
redirects: 0,
|
||||
}));
|
||||
|
||||
const result = await enrichSearchCandidates([searchCandidate()], {
|
||||
searchProvider: { id: "duckduckgo", search },
|
||||
fetchPageImpl,
|
||||
concurrency: 1,
|
||||
hostDelayMs: 0,
|
||||
maxResults: 5,
|
||||
maxPages: 2,
|
||||
fetchedAt: FETCHED_AT,
|
||||
logger: { warn: vi.fn() },
|
||||
});
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(1);
|
||||
expect(search.mock.calls[0][0]).toContain("Schleuse Werries");
|
||||
expect(fetchPageImpl).toHaveBeenCalledTimes(1);
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.records[0]).toMatchObject({
|
||||
originalId: "42",
|
||||
sourceId: "osm:w123",
|
||||
properties: {
|
||||
website: matchingResult.url,
|
||||
enrichmentProvider: "duckduckgo",
|
||||
fetched_at: FETCHED_AT,
|
||||
},
|
||||
});
|
||||
expect(result.records[0].properties.enriched_fields).toContain("website");
|
||||
});
|
||||
|
||||
it("opens the provider circuit after a block response and does not continue querying", async () => {
|
||||
const blockedError = Object.assign(new Error("DuckDuckGo hat weitere Anfragen blockiert."), {
|
||||
code: "SEARCH_PROVIDER_BLOCKED",
|
||||
status: 429,
|
||||
});
|
||||
const search = vi.fn(async () => {
|
||||
throw blockedError;
|
||||
});
|
||||
const fetchPageImpl = vi.fn();
|
||||
const logger = { warn: vi.fn() };
|
||||
|
||||
const result = await enrichSearchCandidates(
|
||||
[
|
||||
searchCandidate(),
|
||||
searchCandidate({
|
||||
id: "43",
|
||||
sourceId: "w124",
|
||||
name: "Schleuse Uentrop",
|
||||
properties: { "addr:city": "Hamm" },
|
||||
}),
|
||||
],
|
||||
{
|
||||
searchProvider: { id: "duckduckgo", search },
|
||||
fetchPageImpl,
|
||||
concurrency: 1,
|
||||
hostDelayMs: 0,
|
||||
fetchedAt: FETCHED_AT,
|
||||
logger,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.records).toEqual([]);
|
||||
expect(search).toHaveBeenCalledTimes(1);
|
||||
expect(fetchPageImpl).not.toHaveBeenCalled();
|
||||
expect(logger.warn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the database untouched in the default dry-run", async () => {
|
||||
const writer = vi.fn();
|
||||
const search = vi.fn(async () => [matchingResult]);
|
||||
const fetchPageImpl = vi.fn(async () => ({
|
||||
html: identityOnlyPageHtml,
|
||||
finalUrl: matchingResult.url,
|
||||
redirects: 0,
|
||||
}));
|
||||
|
||||
const result = await runSearchEnrichment({
|
||||
candidates: [searchCandidate()],
|
||||
writer,
|
||||
enrichmentOptions: {
|
||||
searchProvider: { id: "duckduckgo", search },
|
||||
fetchPageImpl,
|
||||
concurrency: 1,
|
||||
hostDelayMs: 0,
|
||||
fetchedAt: FETCHED_AT,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.dryRun).toBe(true);
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(writer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes validated records and checkpoint attempts to the writer only when enabled", async () => {
|
||||
const writer = vi.fn(async () => ({ enrichments: 1, attempts: 1 }));
|
||||
const result = await runSearchEnrichment({
|
||||
dryRun: false,
|
||||
candidates: [searchCandidate()],
|
||||
writer,
|
||||
enrichmentOptions: {
|
||||
searchProvider: { id: "duckduckgo", search: async () => [matchingResult] },
|
||||
fetchPageImpl: async () => ({
|
||||
html: identityOnlyPageHtml,
|
||||
finalUrl: matchingResult.url,
|
||||
redirects: 0,
|
||||
}),
|
||||
concurrency: 1,
|
||||
hostDelayMs: 0,
|
||||
fetchedAt: FETCHED_AT,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.attempts).toHaveLength(1);
|
||||
expect(result.attempts[0]).toMatchObject({
|
||||
status: "success",
|
||||
provider: "duckduckgo",
|
||||
originalSource: "osm",
|
||||
originalSourceId: "w123",
|
||||
});
|
||||
expect(writer).toHaveBeenCalledTimes(1);
|
||||
expect(writer.mock.calls[0][0]).toMatchObject({
|
||||
records: [expect.objectContaining({ sourceId: "osm:w123" })],
|
||||
attempts: [expect.objectContaining({ status: "success" })],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DEFAULT_LIMIT,
|
||||
MAX_HTML_BYTES,
|
||||
assertPublicHttpUrl,
|
||||
buildEnrichmentRecord,
|
||||
createHostLimiter,
|
||||
createPinnedLookup,
|
||||
extractContactsFromHtml,
|
||||
fetchHtmlPage,
|
||||
parseBooleanDefault,
|
||||
runWebsiteEnrichment,
|
||||
} from "../../../scripts/enrich-marine-websites.mjs";
|
||||
|
||||
const publicDns = async () => [{ address: "93.184.216.34", family: 4 }];
|
||||
|
||||
function htmlResponse(html, init = {}) {
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function candidate(overrides = {}) {
|
||||
return {
|
||||
id: "42",
|
||||
layer: "harbours",
|
||||
source: "osm",
|
||||
sourceId: "w123",
|
||||
name: "Testhafen",
|
||||
properties: { website: "https://marina.example/contact" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("marine facility website enrichment", () => {
|
||||
it("defaults to dry-run and validates explicit boolean values", () => {
|
||||
expect(DEFAULT_LIMIT).toBe(25);
|
||||
expect(parseBooleanDefault(undefined)).toBe(true);
|
||||
expect(parseBooleanDefault("false")).toBe(false);
|
||||
expect(() => parseBooleanDefault("maybe")).toThrow(/true oder false/u);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"http://127.0.0.1/admin",
|
||||
"http://[::1]/admin",
|
||||
"http://localhost/admin",
|
||||
"http://service.local/admin",
|
||||
])("blocks local URL %s before fetching", async (url) => {
|
||||
await expect(assertPublicHttpUrl(url, { lookupImpl: publicDns })).rejects.toMatchObject({
|
||||
name: "WebsiteEnrichmentError",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a public hostname if any DNS result is private", async () => {
|
||||
const lookupImpl = vi.fn(async () => [
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "10.0.0.8", family: 4 },
|
||||
]);
|
||||
await expect(
|
||||
assertPublicHttpUrl("https://marina.example", { lookupImpl }),
|
||||
).rejects.toMatchObject({ code: "SSRF_BLOCKED_DNS" });
|
||||
});
|
||||
|
||||
it("pins the socket lookup to the already validated DNS addresses", async () => {
|
||||
const lookup = createPinnedLookup([{ address: "93.184.216.34", family: 4 }]);
|
||||
const addresses = await new Promise((resolve, reject) => {
|
||||
lookup("a-second-dns-name.example", { all: true }, (error, result) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
expect(addresses).toEqual([{ address: "93.184.216.34", family: 4 }]);
|
||||
expect(() => createPinnedLookup([{ address: "127.0.0.1", family: 4 }])).toThrow(
|
||||
/gepinnt/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("checks a redirect target again and never requests a private redirect", async () => {
|
||||
const fetchImpl = vi.fn(async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://169.254.169.254/latest/meta-data" },
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
fetchHtmlPage("https://marina.example", {
|
||||
fetchImpl,
|
||||
lookupImpl: publicDns,
|
||||
beforeRequest: async () => {},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "SSRF_BLOCKED_IP" });
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
expect(fetchImpl.mock.calls[0][1]).toMatchObject({ redirect: "manual" });
|
||||
});
|
||||
|
||||
it("requires HTML and stops streamed responses above 512 KiB", async () => {
|
||||
await expect(
|
||||
fetchHtmlPage("https://marina.example/file.pdf", {
|
||||
lookupImpl: publicDns,
|
||||
beforeRequest: async () => {},
|
||||
fetchImpl: async () =>
|
||||
new Response("pdf", { status: 200, headers: { "content-type": "application/pdf" } }),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "UNSUPPORTED_CONTENT_TYPE" });
|
||||
|
||||
await expect(
|
||||
fetchHtmlPage("https://marina.example/huge", {
|
||||
lookupImpl: publicDns,
|
||||
beforeRequest: async () => {},
|
||||
fetchImpl: async () => htmlResponse("x".repeat(MAX_HTML_BYTES + 1)),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
it("aborts a hanging HTTP request at the configured timeout", async () => {
|
||||
const fetchImpl = async (_url, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal.addEventListener("abort", () => reject(init.signal.reason), { once: true });
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchHtmlPage("https://marina.example/hangs", {
|
||||
fetchImpl,
|
||||
lookupImpl: publicDns,
|
||||
beforeRequest: async () => {},
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FETCH_FAILED" });
|
||||
});
|
||||
|
||||
it("also bounds a hanging DNS lookup", async () => {
|
||||
const fetchImpl = vi.fn();
|
||||
await expect(
|
||||
fetchHtmlPage("https://marina.example/hangs", {
|
||||
fetchImpl,
|
||||
lookupImpl: async () => new Promise(() => {}),
|
||||
beforeRequest: async () => {},
|
||||
timeoutMs: 5,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "DNS_TIMEOUT" });
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers JSON-LD and otherwise accepts only tel/mailto links", () => {
|
||||
const html = `
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Marina",
|
||||
"name": "Hafenbetrieb Musterstadt",
|
||||
"telephone": "+49 201 11111",
|
||||
"email": "hafen@example.test",
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": "Ufer 1",
|
||||
"postalCode": "12345",
|
||||
"addressLocality": "Musterstadt",
|
||||
"addressCountry": "DE"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<a href="tel:+49-201-99999">Alternative</a>
|
||||
<a href="mailto:other@example.test">Alternative</a>
|
||||
<p>Telefon 01234 567890</p>
|
||||
`;
|
||||
|
||||
expect(extractContactsFromHtml(html)).toEqual({
|
||||
phone: "+49 201 11111",
|
||||
email: "hafen@example.test",
|
||||
operator: "Hafenbetrieb Musterstadt",
|
||||
address: "Ufer 1, 12345 Musterstadt, DE",
|
||||
});
|
||||
expect(extractContactsFromHtml("<p>Telefon 01234 567890</p>")).toEqual({
|
||||
phone: null,
|
||||
email: null,
|
||||
operator: null,
|
||||
address: null,
|
||||
});
|
||||
expect(
|
||||
extractContactsFromHtml(
|
||||
'<a href="tel:0049%20201%20777">Anrufen</a><a href="mailto:lock%40example.test">Mail</a>',
|
||||
),
|
||||
).toMatchObject({ phone: "+49 201 777", email: "lock@example.test" });
|
||||
expect(
|
||||
extractContactsFromHtml('<a title="some href=\'tel:+49999\'">Kein Kontaktlink</a>'),
|
||||
).toMatchObject({ phone: null });
|
||||
expect(extractContactsFromHtml('<a href="tel:+49201�">X</a>')).toMatchObject({
|
||||
phone: null,
|
||||
});
|
||||
expect(
|
||||
extractContactsFromHtml(
|
||||
'<script type="application/ld+json">{"@type":"Marina","name":"Hafen\\u0000","email":"bad@example.test\\u0000"}</script>',
|
||||
),
|
||||
).toMatchObject({ email: null, operator: null });
|
||||
expect(
|
||||
extractContactsFromHtml(
|
||||
'<script type="application/ld+json">{"@type":"Organization","name":"Schleusenbetrieb Nord"}</script><a href="tel:+49-201-555">Telefon</a>',
|
||||
),
|
||||
).toMatchObject({ phone: "+49-201-555", operator: "Schleusenbetrieb Nord" });
|
||||
});
|
||||
|
||||
it("keeps existing contact values and only fills missing fields", () => {
|
||||
const record = buildEnrichmentRecord({
|
||||
candidate: candidate({
|
||||
properties: {
|
||||
website: "https://marina.example/contact",
|
||||
phone: "+49 201 100",
|
||||
},
|
||||
enrichmentProperties: { operator: "Vorhandener Hafenbetreiber" },
|
||||
}),
|
||||
website: {
|
||||
key: "website",
|
||||
original: "https://marina.example/contact",
|
||||
url: "https://marina.example/contact",
|
||||
},
|
||||
page: { finalUrl: "https://marina.example/kontakt" },
|
||||
extracted: {
|
||||
phone: "+49 201 999",
|
||||
email: "hafen@example.test",
|
||||
},
|
||||
fetchedAt: "2026-07-20T11:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(record).toMatchObject({
|
||||
sourceId: "osm:w123",
|
||||
properties: {
|
||||
website: "https://marina.example/contact",
|
||||
phone: "+49 201 100",
|
||||
email: "hafen@example.test",
|
||||
operator: "Vorhandener Hafenbetreiber",
|
||||
source_url: "https://marina.example/kontakt",
|
||||
fetchedAt: "2026-07-20T11:00:00.000Z",
|
||||
enriched_fields: ["email"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("spaces starts to the same host while allowing a deterministic injected clock", async () => {
|
||||
let currentTime = 1_000;
|
||||
const waits = [];
|
||||
const limiter = createHostLimiter({
|
||||
delayMs: 500,
|
||||
now: () => currentTime,
|
||||
sleepImpl: async (milliseconds) => {
|
||||
waits.push(milliseconds);
|
||||
currentTime += milliseconds;
|
||||
},
|
||||
});
|
||||
|
||||
await limiter(new URL("https://marina.example/one"));
|
||||
await limiter(new URL("https://marina.example/two"));
|
||||
await limiter(new URL("https://other.example/one"));
|
||||
expect(waits).toEqual([500]);
|
||||
});
|
||||
|
||||
it("does not invoke the database writer during the default dry-run", async () => {
|
||||
const writer = vi.fn();
|
||||
const fetchImpl = vi.fn(async (_url, init) => {
|
||||
expect(init.headers["User-Agent"]).toContain("Watermaps");
|
||||
return htmlResponse('<a href="tel:+49-201-12345">Schleuse anrufen</a>');
|
||||
});
|
||||
|
||||
const result = await runWebsiteEnrichment({
|
||||
candidates: [
|
||||
candidate(),
|
||||
candidate({ id: "43", sourceId: "w124", properties: { website: "https://other.example" } }),
|
||||
],
|
||||
limit: 1,
|
||||
writer,
|
||||
enrichmentOptions: {
|
||||
fetchImpl,
|
||||
lookupImpl: publicDns,
|
||||
hostDelayMs: 0,
|
||||
fetchedAt: "2026-07-20T11:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.dryRun).toBe(true);
|
||||
expect(result.records).toHaveLength(1);
|
||||
expect(result.records[0]).toMatchObject({
|
||||
originalId: "42",
|
||||
sourceId: "osm:w123",
|
||||
properties: { phone: "+49-201-12345" },
|
||||
});
|
||||
expect(writer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCache, type Cache } from "../src/services/cache.js";
|
||||
import type { FetchLike } from "../src/services/http.js";
|
||||
import {
|
||||
assertOfficialNavigationUrl,
|
||||
buildPegelOnlineUrl,
|
||||
createOfficialJsonAdapter,
|
||||
getNavigationData,
|
||||
type NavigationDataAdapter,
|
||||
type WaterLevel
|
||||
} from "../src/services/navigation-data.js";
|
||||
|
||||
const openCaches: Cache[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(openCaches.splice(0).map((cache) => cache.close()));
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("official navigation data", () => {
|
||||
it("normalizes current PEGELONLINE water levels and caches the result", async () => {
|
||||
const cache = memoryCache();
|
||||
const fetcher = vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
uuid: "edfdf747-be92-462f-87ed-53d228a33172",
|
||||
number: "3970010",
|
||||
shortname: "EMDEN NEUE SEESCHLEUSE",
|
||||
agency: "STANDORT EMDEN",
|
||||
longitude: 7.186348,
|
||||
latitude: 53.336781,
|
||||
km: 40.45,
|
||||
water: { shortname: "EMS", longname: "EMS" },
|
||||
timeseries: [
|
||||
{
|
||||
shortname: "W",
|
||||
unit: "cm",
|
||||
currentMeasurement: {
|
||||
timestamp: "2026-07-19T13:18:00+02:00",
|
||||
value: 564,
|
||||
stateMnwMhw: "normal",
|
||||
stateNswHsw: "unknown"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
)) as unknown as FetchLike;
|
||||
const now = () => new Date("2026-07-19T11:20:00.000Z");
|
||||
|
||||
const first = await getNavigationData(
|
||||
{ waterways: [" EMS ", "EMS"] },
|
||||
{ cache, fetcher, now }
|
||||
);
|
||||
const second = await getNavigationData(
|
||||
{ waterways: ["EMS"] },
|
||||
{ cache, fetcher, now }
|
||||
);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(String(vi.mocked(fetcher).mock.calls[0]?.[0])).toContain("waters=EMS");
|
||||
expect(first.waterLevels).toEqual([
|
||||
expect.objectContaining({
|
||||
stationId: "edfdf747-be92-462f-87ed-53d228a33172",
|
||||
stationName: "EMDEN NEUE SEESCHLEUSE",
|
||||
waterway: "EMS",
|
||||
value: 564,
|
||||
unit: "cm",
|
||||
stateMnwMhw: "normal"
|
||||
})
|
||||
]);
|
||||
expect(first.sources.map((source) => source.state)).toEqual([
|
||||
"live",
|
||||
"not-configured",
|
||||
"not-configured"
|
||||
]);
|
||||
expect(second.sources[0]?.state).toBe("cached");
|
||||
});
|
||||
|
||||
it("uses last-good data when a live source fails", async () => {
|
||||
const cache = memoryCache();
|
||||
let sourceAvailable = true;
|
||||
const adapter: NavigationDataAdapter<WaterLevel> = {
|
||||
kind: "water-levels",
|
||||
id: "test-wsv-levels",
|
||||
label: "Test WSV levels",
|
||||
sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi",
|
||||
freshTtlMs: 1,
|
||||
staleTtlMs: 60_000,
|
||||
async load() {
|
||||
if (!sourceAvailable) {
|
||||
throw new Error("WSV test outage");
|
||||
}
|
||||
return [waterLevelFixture()];
|
||||
}
|
||||
};
|
||||
|
||||
const live = await getNavigationData(
|
||||
{ waterways: ["EMS"] },
|
||||
{ cache, fetcher: vi.fn() as unknown as FetchLike, adapters: { waterLevels: adapter } }
|
||||
);
|
||||
sourceAvailable = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const fallback = await getNavigationData(
|
||||
{ waterways: ["EMS"] },
|
||||
{ cache, fetcher: vi.fn() as unknown as FetchLike, adapters: { waterLevels: adapter } }
|
||||
);
|
||||
|
||||
expect(live.sources[0]?.state).toBe("live");
|
||||
expect(fallback.waterLevels).toEqual([waterLevelFixture()]);
|
||||
expect(fallback.sources[0]).toEqual(
|
||||
expect.objectContaining({ state: "stale", warning: expect.stringContaining("letzter erfolgreicher Stand") })
|
||||
);
|
||||
});
|
||||
|
||||
it("times out an adapter and returns an explicit unavailable state", async () => {
|
||||
const cache = memoryCache();
|
||||
const adapter: NavigationDataAdapter<WaterLevel> = {
|
||||
kind: "water-levels",
|
||||
id: "slow-wsv-source",
|
||||
label: "Slow official source",
|
||||
sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi",
|
||||
load: () => new Promise(() => undefined)
|
||||
};
|
||||
|
||||
const result = await getNavigationData(
|
||||
{ waterways: ["EMS"] },
|
||||
{
|
||||
cache,
|
||||
fetcher: vi.fn() as unknown as FetchLike,
|
||||
adapters: { waterLevels: adapter },
|
||||
timeoutMs: 5
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.waterLevels).toEqual([]);
|
||||
expect(result.sources[0]).toEqual(
|
||||
expect.objectContaining({ state: "unavailable", warning: expect.stringContaining("Zeitlimit") })
|
||||
);
|
||||
});
|
||||
|
||||
it("does not download the nationwide station list without a route filter", async () => {
|
||||
const cache = memoryCache();
|
||||
const fetcher = vi.fn() as unknown as FetchLike;
|
||||
|
||||
const result = await getNavigationData({}, { cache, fetcher });
|
||||
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect(result.sources[0]).toEqual(
|
||||
expect.objectContaining({ state: "not-configured", warning: expect.stringContaining("Stations-UUID") })
|
||||
);
|
||||
});
|
||||
|
||||
it("supports explicitly configured adapters for documented official JSON endpoints", async () => {
|
||||
const cache = memoryCache();
|
||||
const level = waterLevelFixture();
|
||||
const adapter = createOfficialJsonAdapter<WaterLevel>({
|
||||
kind: "water-levels",
|
||||
id: "configured-pegelonline-feed",
|
||||
label: "Configured PEGELONLINE feed",
|
||||
sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi",
|
||||
buildUrl: () =>
|
||||
"https://pegelonline.wsv.de/webservices/rest-api/v2/stations.json?waters=EMS",
|
||||
parse: (payload) => (payload as { levels: WaterLevel[] }).levels
|
||||
});
|
||||
const fetcher = vi.fn(async () =>
|
||||
new Response(JSON.stringify({ levels: [level] }), { status: 200 })) as unknown as FetchLike;
|
||||
|
||||
const result = await getNavigationData(
|
||||
{ waterways: ["EMS"] },
|
||||
{ cache, fetcher, adapters: { waterLevels: adapter } }
|
||||
);
|
||||
|
||||
expect(result.waterLevels).toEqual([level]);
|
||||
expect(result.sources.find((source) => source.kind === "water-levels")?.state).toBe("live");
|
||||
});
|
||||
|
||||
it("rejects unofficial or insecure configured endpoints", () => {
|
||||
expect(() => assertOfficialNavigationUrl("http://www.elwis.de/feed.json")).toThrow(
|
||||
/offizielle HTTPS-Quellen/
|
||||
);
|
||||
expect(() => assertOfficialNavigationUrl("https://elwis.de.example.org/feed.json")).toThrow(
|
||||
/offizielle HTTPS-Quellen/
|
||||
);
|
||||
expect(() =>
|
||||
createOfficialJsonAdapter({
|
||||
kind: "notices",
|
||||
id: "unofficial",
|
||||
label: "Unofficial",
|
||||
sourceUrl: "https://example.org/feed",
|
||||
buildUrl: () => "https://example.org/feed",
|
||||
parse: () => []
|
||||
})
|
||||
).toThrow(/offizielle HTTPS-Quellen/);
|
||||
});
|
||||
|
||||
it("blocks unofficial network requests made by a custom adapter", async () => {
|
||||
const cache = memoryCache();
|
||||
const networkFetcher = vi.fn() as unknown as FetchLike;
|
||||
const adapter: NavigationDataAdapter<WaterLevel> = {
|
||||
kind: "water-levels",
|
||||
id: "misconfigured-custom-adapter",
|
||||
label: "Misconfigured adapter",
|
||||
sourceUrl: "https://pegelonline.wsv.de/webservice/dokuRestapi",
|
||||
async load(_query, { fetcher }) {
|
||||
await fetcher("https://example.org/not-official.json");
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const result = await getNavigationData(
|
||||
{ waterways: ["EMS"] },
|
||||
{ cache, fetcher: networkFetcher, adapters: { waterLevels: adapter } }
|
||||
);
|
||||
|
||||
expect(networkFetcher).not.toHaveBeenCalled();
|
||||
expect(result.sources[0]).toEqual(
|
||||
expect.objectContaining({ state: "unavailable", warning: expect.stringContaining("offizielle HTTPS-Quellen") })
|
||||
);
|
||||
});
|
||||
|
||||
it("builds a stable, bounded PEGELONLINE query", () => {
|
||||
const url = new URL(
|
||||
buildPegelOnlineUrl({ stationIds: ["b", "a", "a"], waterways: ["RHEIN", "EMS"] }) ?? ""
|
||||
);
|
||||
|
||||
expect(url.origin).toBe("https://pegelonline.wsv.de");
|
||||
expect(url.searchParams.get("ids")).toBe("a,b");
|
||||
expect(url.searchParams.get("waters")).toBe("EMS,RHEIN");
|
||||
expect(url.searchParams.get("timeseries")).toBe("W");
|
||||
expect(url.searchParams.get("includeCurrentMeasurement")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
function memoryCache(): Cache {
|
||||
const cache = createCache();
|
||||
openCaches.push(cache);
|
||||
return cache;
|
||||
}
|
||||
|
||||
function waterLevelFixture(): WaterLevel {
|
||||
return {
|
||||
stationId: "station-1",
|
||||
stationNumber: "3970010",
|
||||
stationName: "EMDEN NEUE SEESCHLEUSE",
|
||||
waterway: "EMS",
|
||||
waterwayKm: 40.45,
|
||||
latitude: 53.336781,
|
||||
longitude: 7.186348,
|
||||
value: 564,
|
||||
unit: "cm",
|
||||
measuredAt: "2026-07-19T13:18:00+02:00",
|
||||
stateMnwMhw: "normal",
|
||||
stateNswHsw: "unknown",
|
||||
agency: "STANDORT EMDEN",
|
||||
sourceUrl: "https://pegelonline.wsv.de/webservices/rest-api/v2/stations/station-1.json"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
featureName,
|
||||
isLockFeature,
|
||||
sourceId
|
||||
} from "../../../scripts/osm-marine-classification.mjs";
|
||||
|
||||
describe("OSM marine import classification", () => {
|
||||
it.each([
|
||||
{ lock: "yes" },
|
||||
{ waterway: "lock_gate" },
|
||||
{ waterway: "lock" },
|
||||
{ natural: "water", water: "lock" },
|
||||
{ obstacle: "lock" },
|
||||
{ "seamark:type": "lock_basin" },
|
||||
{ "seamark:type": "gate", "seamark:gate:category": "lock" }
|
||||
])("recognizes a lock encoded as %o", (properties) => {
|
||||
expect(isLockFeature(properties)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not classify an unrelated gate as a lock", () => {
|
||||
expect(isLockFeature({ "seamark:type": "gate", "seamark:gate:category": "flood_barrage" })).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the canonical OSM id for converted area features", () => {
|
||||
expect(
|
||||
sourceId(
|
||||
{ id: "a69307610", properties: { "@type": "way", "@id": 34653805 } },
|
||||
1,
|
||||
"nordrhein-westfalen-latest"
|
||||
)
|
||||
).toBe("w34653805");
|
||||
});
|
||||
|
||||
it("prefers the lock name and falls back to the seamark name", () => {
|
||||
expect(featureName({ lock_name: "Schleuse Test" })).toBe("Schleuse Test");
|
||||
expect(featureName({ name: "Testkanal", lock_name: "Schleuse Test" })).toBe("Schleuse Test");
|
||||
expect(featureName({ "seamark:name": "Test Lock" })).toBe("Test Lock");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCache } from "../src/services/cache.js";
|
||||
import type { FetchLike } from "../src/services/http.js";
|
||||
import { getNearestTideSummary, normalizeNearestTideSummary } from "../src/services/tides.js";
|
||||
|
||||
describe("BSH tide normalization", () => {
|
||||
it("selects the nearest station and upcoming events", () => {
|
||||
const summary = normalizeNearestTideSummary(
|
||||
{
|
||||
features: [
|
||||
{
|
||||
geometry: { type: "Point", coordinates: [12.1, 54.2] },
|
||||
properties: {
|
||||
gauge_label: "Demo Pegel",
|
||||
forecast_timestamp: "2026-07-09 09:00:00+02:00",
|
||||
high_water_low_water: [
|
||||
{
|
||||
event_timestamp: "2026-07-09 10:00:00+02:00",
|
||||
event: "HW",
|
||||
forecast_value: 620,
|
||||
forecast_deviation: "+0,2 m"
|
||||
},
|
||||
{
|
||||
event_timestamp: "2026-07-09 15:30:00+02:00",
|
||||
event: "NW",
|
||||
tidal_prediction_value: "370"
|
||||
}
|
||||
],
|
||||
curve: [
|
||||
{
|
||||
timestamp: "2026-07-09 10:00:00+02:00",
|
||||
tidal_prediction: "620",
|
||||
measurement: "618"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{ lat: 54.2, lon: 12.1 },
|
||||
new Date("2026-07-09T08:00:00+02:00")
|
||||
);
|
||||
|
||||
expect(summary?.station).toBe("Demo Pegel");
|
||||
expect(summary?.nextHigh?.heightM).toBe(6.2);
|
||||
expect(summary?.nextLow?.heightM).toBe(3.7);
|
||||
expect(summary?.waterLevelCurve[0]?.predictedM).toBe(6.2);
|
||||
});
|
||||
|
||||
it("uses params.at instead of wall-clock time when filtering upcoming tide events", async () => {
|
||||
const cache = createCache();
|
||||
const fetcher = vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
features: [
|
||||
{
|
||||
geometry: { type: "Point", coordinates: [7.18, 53.34] },
|
||||
properties: {
|
||||
gauge_label: "Emden",
|
||||
forecast_timestamp: "2030-01-01T09:00:00.000Z",
|
||||
high_water_low_water: [
|
||||
{
|
||||
event_timestamp: "2030-01-01T10:00:00.000Z",
|
||||
event: "HW",
|
||||
forecast_value: 610
|
||||
},
|
||||
{
|
||||
event_timestamp: "2030-01-01T13:00:00.000Z",
|
||||
event: "NW",
|
||||
forecast_value: 350
|
||||
},
|
||||
{
|
||||
event_timestamp: "2030-01-01T16:00:00.000Z",
|
||||
event: "HW",
|
||||
forecast_value: 625
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
)) as unknown as FetchLike;
|
||||
|
||||
try {
|
||||
const summary = await getNearestTideSummary(
|
||||
{ lat: 53.34, lon: 7.18, at: "2030-01-01T12:00:00.000Z" },
|
||||
{ cache, fetcher }
|
||||
);
|
||||
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(summary?.nextLow).toEqual(
|
||||
expect.objectContaining({ time: "2030-01-01T13:00:00.000Z", heightM: 3.5 })
|
||||
);
|
||||
expect(summary?.nextHigh).toEqual(
|
||||
expect.objectContaining({ time: "2030-01-01T16:00:00.000Z", heightM: 6.25 })
|
||||
);
|
||||
} finally {
|
||||
await cache.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeMarineForecast } from "../src/services/weather.js";
|
||||
|
||||
describe("departure-time marine forecast", () => {
|
||||
it("selects waves, wind and ocean current nearest the requested passage time", () => {
|
||||
const result = normalizeMarineForecast(
|
||||
{
|
||||
hourly: {
|
||||
time: ["2026-07-20T08:00", "2026-07-20T09:00"],
|
||||
wave_height: [0.6, 0.9],
|
||||
wave_direction: [280, 290],
|
||||
wave_period: [4, 5],
|
||||
ocean_current_velocity: [0.4, 1.1],
|
||||
ocean_current_direction: [90, 100],
|
||||
sea_level_height_msl: [0.2, 0.4]
|
||||
}
|
||||
},
|
||||
{
|
||||
hourly: {
|
||||
time: ["2026-07-20T08:00", "2026-07-20T09:00"],
|
||||
wind_speed_10m: [8, 12],
|
||||
wind_direction_10m: [240, 250],
|
||||
weather_code: [2, 3],
|
||||
temperature_2m: [18, 19]
|
||||
}
|
||||
},
|
||||
{ marineAvailable: true, weatherAvailable: true },
|
||||
"2026-07-20T08:40:00.000Z"
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
waveHeightM: 0.9,
|
||||
windSpeed: 12,
|
||||
oceanCurrentSpeedKn: 1.1,
|
||||
oceanCurrentDirectionDeg: 100,
|
||||
seaLevelHeightMslM: 0.4,
|
||||
forecastTime: "2026-07-20T09:00:00.000Z"
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse the edge of the forecast as if it covered a much later departure", () => {
|
||||
const result = normalizeMarineForecast(
|
||||
{
|
||||
hourly: {
|
||||
time: ["2026-07-20T08:00"],
|
||||
wave_height: [0.6],
|
||||
wave_direction: [280],
|
||||
wave_period: [4],
|
||||
ocean_current_velocity: [0.4],
|
||||
ocean_current_direction: [90],
|
||||
sea_level_height_msl: [0.2]
|
||||
}
|
||||
},
|
||||
{
|
||||
hourly: {
|
||||
time: ["2026-07-20T08:00"],
|
||||
wind_speed_10m: [8],
|
||||
wind_direction_10m: [240],
|
||||
weather_code: [2],
|
||||
temperature_2m: [18]
|
||||
}
|
||||
},
|
||||
{ marineAvailable: true, weatherAvailable: true },
|
||||
"2026-07-24T08:00:00.000Z"
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
waveHeightM: null,
|
||||
windSpeed: null,
|
||||
oceanCurrentSpeedKn: null,
|
||||
oceanCurrentDirectionDeg: null
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user