Initial Watermaps import

This commit is contained in:
BuTzZ
2026-07-24 11:29:24 +02:00
commit 57f7b4dedb
129 changed files with 43136 additions and 0 deletions
+247
View File
@@ -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
};
}
+21
View File
@@ -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"
};
}
+21
View File
@@ -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);
}
+102
View File
@@ -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();
}
}
+67
View File
@@ -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"
]
};
+576
View File
@@ -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);
}
+608
View File
@@ -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`;
}
+25
View File
@@ -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);
}
}
+537
View File
@@ -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 {}
+183
View File
@@ -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;
}
+213
View File
@@ -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";
}