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
+28
View File
@@ -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"
}
}
+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";
}
+352
View File
@@ -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();
});
});
+217
View File
@@ -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();
});
});
+265
View File
@@ -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();
});
});
+186
View File
@@ -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)}` +
"&amp;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" +
"&amp;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> &amp; Kontakt
</a>
</h2>
<a class="result__snippet">
Offizielle Informationen f&uuml;r Hamm. Telefon &amp; E-Mail.
</a>
</div>
<div class="result result--ad">
<h2>
<a class="result__a" href="https://advertising.example/werries">
Anzeige f&uuml;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&#999999999999;">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();
});
});
+260
View File
@@ -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");
});
});
+102
View File
@@ -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();
}
});
});
+74
View File
@@ -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
});
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f4c5c" />
<title>Watermaps</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@watermaps/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5173",
"dev:https": "WATERMAPS_HTTPS=true vite --host 0.0.0.0 --port 5173",
"build": "tsc -b && vite build && node scripts/check-chunks.mjs",
"preview": "vite preview --host 0.0.0.0 --port 4173",
"test": "vitest run",
"test:e2e": "playwright test",
"typecheck": "tsc -b --noEmit"
},
"dependencies": {
"@watermaps/shared": "0.1.0",
"lucide-react": "^0.468.0",
"maplibre-gl": "^5.6.1",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@playwright/test": "^1.54.1",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/geojson": "^7946.0.16",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-basic-ssl": "^2.1.0",
"@vitejs/plugin-react": "^4.6.0",
"jsdom": "^26.1.0",
"vite": "^6.3.5",
"vite-plugin-pwa": "^1.0.1",
"vitest": "^3.2.4"
}
}
+39
View File
@@ -0,0 +1,39 @@
import { devices, defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
timeout: 30_000,
use: {
baseURL: "http://127.0.0.1:5173",
trace: "on-first-retry"
},
webServer: [
{
command: "WATERMAPS_LIVE_FAIRWAYS=false npm run dev --workspace @watermaps/api",
url: "http://127.0.0.1:5174/health",
reuseExistingServer: true,
timeout: 30_000
},
{
command: "npm run dev --workspace @watermaps/web",
url: "http://127.0.0.1:5173",
reuseExistingServer: true,
timeout: 30_000
}
],
projects: [
{
name: "iphone",
testIgnore: /desktop-layout\.spec\.ts/,
use: { ...devices["iPhone 15"] }
},
{
name: "desktop",
testMatch: /desktop-layout\.spec\.ts/,
use: {
...devices["Desktop Safari"],
viewport: { width: 1440, height: 900 }
}
}
]
});
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#0f4c5c"/>
<circle cx="32" cy="32" r="22" fill="#f6f9f7"/>
<path d="M32 10l8 25-8 19-8-19 8-25z" fill="#cc3333"/>
<path d="M32 10l8 25-8-5V10z" fill="#0f4c5c"/>
<circle cx="32" cy="32" r="4" fill="#10242b"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

+141
View File
@@ -0,0 +1,141 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distRoot = path.join(webRoot, "dist");
const manifestPath = path.join(distRoot, ".vite", "manifest.json");
assert(existsSync(manifestPath), "Vite-Manifest fehlt. Zuerst den Produktions-Build ausführen.");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const entry = manifest["index.html"];
assert(entry?.isEntry, "Der Web-Einstieg fehlt im Vite-Manifest.");
const expectedDynamicEntries = [
"src/components/MapView.tsx",
"src/components/AnchorWatchPanel.tsx",
"src/components/CourseAssistantPanel.tsx",
"src/components/VoyageNavigationTools.tsx",
"src/routeWeatherReport.ts"
];
for (const key of expectedDynamicEntries) {
const chunk = manifest[key];
assert(chunk?.isDynamicEntry, `${key} ist kein dynamischer Einstieg mehr.`);
assertFile(chunk.file);
}
const mapEngineEntry = Object.entries(manifest).find(([, chunk]) => chunk.name === "map-engine");
assert(mapEngineEntry, "Der isolierte MapLibre-Chunk fehlt.");
const [mapEngineKey, mapEngine] = mapEngineEntry;
assertFile(mapEngine.file);
assert(
!entry.imports?.includes(mapEngineKey),
"Der MapLibre-Chunk wird wieder statisch vom App-Einstieg geladen."
);
assert(
manifest["src/components/MapView.tsx"].imports?.includes(mapEngineKey),
"MapView verweist nicht auf den isolierten MapLibre-Chunk."
);
const mapViewChunk = manifest["src/components/MapView.tsx"];
assert(mapViewChunk.css?.length, "Das MapLibre-Stylesheet ist nicht mehr an MapView gekoppelt.");
assert(
mapViewChunk.css.every((file) => !entry.css?.includes(file)),
"Das MapLibre-Stylesheet wird wieder vom App-Einstieg geladen."
);
const initialChunkKeys = collectStaticImports("index.html");
const initialBytes = [...initialChunkKeys].reduce(
(total, key) => total + fileSize(manifest[key].file),
0
);
assert(
initialBytes <= 350_000,
`Initiales JavaScript ist mit ${formatKb(initialBytes)} größer als das Budget von 350 kB.`
);
const mapEngineBytes = fileSize(mapEngine.file);
assert(
mapEngineBytes <= 1_100_000,
`Der MapLibre-Chunk ist mit ${formatKb(mapEngineBytes)} unerwartet gewachsen.`
);
for (const chunk of Object.values(manifest)) {
if (chunk.file?.endsWith(".js") && chunk.file !== mapEngine.file) {
const bytes = fileSize(chunk.file);
assert(
bytes <= 350_000,
`${chunk.file} ist mit ${formatKb(bytes)} zu groß und sollte weiter aufgeteilt werden.`
);
}
}
const initialCssBytes = (entry.css ?? []).reduce(
(total, file) => total + fileSize(file),
0
);
assert(
initialCssBytes <= 40_000,
`Initiales CSS ist mit ${formatKb(initialCssBytes)} größer als das Budget von 40 kB.`
);
const html = readFileSync(path.join(distRoot, "index.html"), "utf8");
assert(
!html.includes(path.basename(mapEngine.file)),
"index.html lädt den dynamischen MapLibre-Chunk per modulepreload."
);
const serviceWorkerPath = path.join(distRoot, "sw.js");
assert(existsSync(serviceWorkerPath), "Der PWA-Service-Worker fehlt.");
const serviceWorker = readFileSync(serviceWorkerPath, "utf8");
const offlineFiles = [
mapEngine.file,
...mapViewChunk.css,
...expectedDynamicEntries.map((key) => manifest[key].file)
];
for (const file of offlineFiles) {
assert(
serviceWorker.includes(file),
`${file} fehlt im PWA-Precache und wäre offline nicht zuverlässig verfügbar.`
);
}
console.log(
`Chunk-Prüfung erfolgreich: initial ${formatKb(initialBytes)} JS + ${formatKb(initialCssBytes)} CSS, Karte ${formatKb(mapEngineBytes)}, ${expectedDynamicEntries.length} dynamische Funktionsmodule.`
);
function collectStaticImports(rootKey) {
const collected = new Set();
const visit = (key) => {
if (collected.has(key)) {
return;
}
const chunk = manifest[key];
assert(chunk, `Manifest-Verweis ${key} fehlt.`);
collected.add(key);
for (const dependency of chunk.imports ?? []) {
visit(dependency);
}
};
visit(rootKey);
return collected;
}
function assertFile(relativePath) {
assert(
typeof relativePath === "string" && existsSync(path.join(distRoot, relativePath)),
`Chunk-Datei ${relativePath ?? "(unbekannt)"} fehlt.`
);
}
function fileSize(relativePath) {
return statSync(path.join(distRoot, relativePath)).size;
}
function formatKb(bytes) {
return `${(bytes / 1_000).toFixed(1)} kB`;
}
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
+1287
View File
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
import type {
AppConfig,
Coordinate,
MarineForecast,
NavigationDataSnapshot,
RouteRequest,
RouteResult,
TideSummary
} from "@watermaps/shared";
import type { FeatureCollection } from "geojson";
async function getJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
}
async function postJson<T>(url: string, body: unknown): Promise<T> {
const response = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response));
}
return (await response.json()) as T;
}
async function responseErrorMessage(response: Response) {
try {
const body = (await response.json()) as { message?: string; error?: string };
return body.message ?? body.error ?? `${response.status} ${response.statusText}`;
} catch {
return `${response.status} ${response.statusText}`;
}
}
export function getConfig(): Promise<AppConfig> {
return getJson<AppConfig>("/api/config");
}
export function getMarineForecast(position: Coordinate, at?: string): Promise<MarineForecast> {
const search = new URLSearchParams({ lat: String(position.lat), lon: String(position.lon) });
if (at) {
search.set("at", at);
}
return getJson<MarineForecast>(`/api/weather/marine?${search.toString()}`);
}
export function getNearestTide(position: Coordinate, at?: string): Promise<TideSummary> {
const search = new URLSearchParams({ lat: String(position.lat), lon: String(position.lon) });
if (at) {
search.set("at", at);
}
return getJson<TideSummary>(`/api/tides/nearest?${search.toString()}`);
}
export function getNavigationData(params: {
waterways?: string[];
stationIds?: string[];
lockIds?: string[];
}): Promise<NavigationDataSnapshot> {
const search = new URLSearchParams();
if (params.waterways?.length) {
search.set("waterways", params.waterways.join(","));
}
if (params.stationIds?.length) {
search.set("stationIds", params.stationIds.join(","));
}
if (params.lockIds?.length) {
search.set("lockIds", params.lockIds.join(","));
}
return getJson<NavigationDataSnapshot>(`/api/navigation/live?${search.toString()}`);
}
export function createRoute(request: RouteRequest): Promise<RouteResult> {
return postJson<RouteResult>("/api/routes", request);
}
export function getMapFeatures(params: {
bbox: [number, number, number, number];
layers: string[];
signal?: AbortSignal;
}): Promise<FeatureCollection> {
const search = new URLSearchParams({
bbox: params.bbox.join(","),
layers: params.layers.join(",")
});
return getJson<FeatureCollection>(`/api/features?${search.toString()}`, { signal: params.signal });
}
@@ -0,0 +1,462 @@
.anchor-watch-panel {
position: absolute;
z-index: 9;
left: 10px;
right: 10px;
bottom: calc(86px + env(safe-area-inset-bottom));
width: min(520px, calc(100vw - 20px));
max-height: min(74vh, calc(100vh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 118px));
max-height: min(74dvh, calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 118px));
margin: 0 auto;
overflow-y: auto;
overscroll-behavior: contain;
border: 2px solid rgba(15, 76, 92, 0.42);
border-radius: 14px;
padding: 11px;
display: grid;
gap: 9px;
background: rgba(246, 249, 247, 0.98);
color: #10242b;
box-shadow: 0 14px 38px rgba(7, 25, 29, 0.28);
backdrop-filter: blur(18px);
}
.anchor-watch-panel[data-alert="true"] {
border-color: #c44a30;
}
.anchor-watch-header,
.anchor-watch-header > span,
.anchor-point-summary,
.anchor-gps-readiness,
.anchor-inline-warning,
.anchor-inline-alert,
.anchor-tide-card,
.anchor-rode-card,
.anchor-primary-action,
.anchor-secondary-action,
.anchor-acknowledge-action {
display: flex;
align-items: center;
}
.anchor-watch-header {
min-height: 38px;
justify-content: space-between;
gap: 8px;
}
.anchor-watch-header > span {
gap: 7px;
color: #0f4c5c;
}
.anchor-watch-header button,
.anchor-point-summary button {
width: 44px;
height: 44px;
flex: 0 0 auto;
border-radius: 9px;
display: grid;
place-items: center;
background: #e2ece9;
color: #23434c;
}
.anchor-capture,
.anchor-watch-setup,
.anchor-watch-active {
display: grid;
gap: 9px;
}
.anchor-capture > p {
margin: 0;
color: #334e56;
font-size: 13px;
font-weight: 700;
line-height: 1.4;
}
.anchor-gps-readiness,
.anchor-point-summary {
min-height: 52px;
gap: 9px;
border-radius: 10px;
padding: 8px 10px;
background: #edf3f1;
color: #526a72;
}
.anchor-gps-readiness[data-ready="true"] {
background: #dceee6;
color: #196f5c;
}
.anchor-gps-readiness > span,
.anchor-point-summary > span {
min-width: 0;
flex: 1;
display: grid;
gap: 2px;
}
.anchor-gps-readiness strong,
.anchor-point-summary strong {
color: #16323a;
font-size: 13px;
}
.anchor-gps-readiness small,
.anchor-point-summary small {
overflow-wrap: anywhere;
font-size: 10px;
line-height: 1.35;
}
.anchor-primary-action,
.anchor-secondary-action,
.anchor-acknowledge-action,
.anchor-stop-action {
min-height: 44px;
border-radius: 9px;
justify-content: center;
gap: 7px;
font-size: 13px;
font-weight: 850;
}
.anchor-primary-action {
background: #0f4c5c;
color: #ffffff;
}
.anchor-secondary-action {
background: #e2ece9;
color: #23434c;
}
.anchor-acknowledge-action {
width: 100%;
background: #c44a30;
color: #ffffff;
}
.anchor-stop-action {
min-width: 150px;
background: #f1ded9;
color: #8b3024;
}
.anchor-safety-note {
display: block;
color: #607278;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.anchor-settings {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.anchor-settings label {
min-width: 0;
min-height: 58px;
border-radius: 9px;
padding: 6px 8px;
display: grid;
gap: 4px;
background: #edf3f1;
color: #526a72;
font-size: 12px;
font-weight: 850;
}
.anchor-settings label > span:last-child {
display: flex;
align-items: center;
gap: 5px;
color: #16323a;
}
.anchor-settings input,
.anchor-settings select {
width: 100%;
min-width: 0;
height: 44px;
border: 1px solid #bdcfca;
border-radius: 7px;
padding: 0 7px;
background: #ffffff;
color: #10242b;
font-size: 13px;
font-weight: 800;
}
.anchor-settings[data-compact="true"] {
margin-top: 8px;
}
.anchor-planning-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.anchor-tide-card,
.anchor-rode-card {
min-width: 0;
min-height: 80px;
align-items: flex-start;
gap: 7px;
border-radius: 9px;
padding: 8px;
background: #dceee6;
color: #196f5c;
}
.anchor-tide-card[data-incomplete="true"],
.anchor-tide-card[data-far="true"],
.anchor-rode-card[data-state="unknown"] {
background: #fff1cc;
color: #805900;
}
.anchor-rode-card[data-state="short"] {
background: #ffe1dc;
color: #9d2c22;
}
.anchor-card-icon {
flex: 0 0 auto;
padding-top: 2px;
}
.anchor-tide-card > div,
.anchor-rode-card > div {
min-width: 0;
display: grid;
gap: 3px;
}
.anchor-tide-card small,
.anchor-rode-card small,
.anchor-live-metrics small {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.04em;
}
.anchor-tide-card strong,
.anchor-rode-card strong {
display: flex;
align-items: center;
gap: 4px;
color: currentColor;
font-size: 12px;
line-height: 1.25;
}
.anchor-tide-card span,
.anchor-rode-card span,
.anchor-tide-card em {
color: currentColor;
font-size: 11px;
font-weight: 750;
line-height: 1.3;
}
.anchor-tide-card em {
font-style: normal;
}
.anchor-spinner {
animation: anchor-spin 1s linear infinite;
}
@keyframes anchor-spin {
to { transform: rotate(360deg); }
}
.anchor-inline-warning,
.anchor-inline-alert {
margin: 0;
gap: 6px;
border-radius: 9px;
padding: 8px 9px;
font-size: 11px;
font-weight: 800;
line-height: 1.35;
}
.anchor-inline-warning {
background: #fff1cc;
color: #805900;
}
.anchor-inline-alert {
background: #ffe1dc;
color: #9d2c22;
}
.anchor-setup-actions,
.anchor-stop-actions {
display: grid;
grid-template-columns: minmax(0, 0.75fr) minmax(0, 1.25fr);
gap: 7px;
}
.anchor-distance-hero {
min-height: 78px;
border-radius: 11px;
padding: 9px 12px;
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
align-items: center;
gap: 8px;
background: #0f4c5c;
color: #ffffff;
}
.anchor-distance-hero[data-tone="warning"] {
background: #805900;
}
.anchor-distance-hero[data-tone="alarm"] {
background: #8b3024;
}
.anchor-distance-hero > span {
color: #ffce66;
}
.anchor-distance-hero > div {
min-width: 0;
display: grid;
}
.anchor-distance-hero small {
font-size: 10px;
font-weight: 900;
letter-spacing: 0.06em;
}
.anchor-distance-hero strong {
font-size: clamp(30px, 10vw, 44px);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.anchor-distance-hero em {
font-size: 16px;
font-style: normal;
white-space: nowrap;
}
.anchor-watch-status {
margin: 0;
border-radius: 9px;
padding: 7px 9px;
display: grid;
gap: 2px;
background: #dceee6;
color: #196f5c;
font-size: 11px;
line-height: 1.3;
}
.anchor-watch-status[data-tone="warning"] {
background: #fff1cc;
color: #805900;
}
.anchor-watch-status[data-tone="alarm"] {
background: #ffe1dc;
color: #9d2c22;
}
.anchor-live-metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
}
.anchor-live-metrics > span {
min-width: 0;
border-radius: 8px;
padding: 6px;
display: grid;
gap: 2px;
background: #edf3f1;
}
.anchor-live-metrics small {
color: #607278;
}
.anchor-live-metrics strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: #16323a;
font-size: 11px;
white-space: nowrap;
}
.anchor-acknowledged {
margin: 0;
color: #9d2c22;
font-size: 10px;
font-weight: 800;
}
.anchor-active-details {
border-radius: 9px;
padding: 8px;
background: #edf3f1;
}
.anchor-active-details summary {
cursor: pointer;
color: #23434c;
font-size: 11px;
font-weight: 850;
}
.app-shell[data-anchor-watch-active="true"] .data-badge,
.app-shell[data-anchor-panel-open="true"] .data-badge {
top: calc(env(safe-area-inset-top) + 266px);
bottom: auto;
}
@media (min-width: 720px) {
.anchor-watch-panel {
left: auto;
right: 12px;
width: 430px;
margin: 0;
}
}
@media (max-width: 390px) {
.anchor-planning-summary {
grid-template-columns: 1fr;
}
.anchor-settings {
gap: 5px;
}
.anchor-live-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.anchor-spinner {
animation: none;
}
}
@@ -0,0 +1,438 @@
import {
AlertTriangle,
Anchor,
BellRing,
CheckCircle2,
Crosshair,
LoaderCircle,
MapPin,
RefreshCw,
ShieldCheck,
Waves,
X
} from "lucide-react";
import { useState } from "react";
import type { GpsState } from "../hooks/useGeolocation";
import { type AnchorWatchSettings, useAnchorWatch } from "../hooks/useAnchorWatch";
import "./AnchorWatchPanel.css";
type AnchorWatchModel = ReturnType<typeof useAnchorWatch>;
export type AnchorWatchPanelProps = {
watch: AnchorWatchModel;
gps: Pick<GpsState, "status" | "accuracyM" | "timestampMs">;
onStartGps: () => void;
onClose: () => void;
};
export function AnchorWatchPanel({ watch, gps, onStartGps, onClose }: AnchorWatchPanelProps) {
const [confirmStop, setConfirmStop] = useState(false);
const armed = watch.phase === "armed";
const hasAlarm = armed && (watch.positionAlarm || watch.rodeShortfall);
const distanceM = watch.watchResult?.distanceFromAnchorM ?? null;
const nearLimit = Boolean(
armed &&
!watch.positionAlarm &&
distanceM !== null &&
distanceM >= watch.settings.alarmRadiusM * 0.8
);
const status = anchorStatus(watch, gps.status, nearLimit);
const cancel = () => {
watch.reset();
onClose();
};
const stop = () => {
if (!confirmStop) {
setConfirmStop(true);
return;
}
cancel();
};
return (
<aside
className="anchor-watch-panel"
aria-label="Ankerwache"
data-phase={watch.phase}
data-alert={hasAlarm}
data-near-limit={nearLimit}
>
<header className="anchor-watch-header">
<span>
<Anchor size={19} aria-hidden="true" />
<strong>{armed ? "Ankerwache aktiv" : watch.phase === "set" ? "Ankerwache einrichten" : "Ankerwache"}</strong>
</span>
{!armed && (
<button type="button" onClick={cancel} aria-label="Ankerwache schließen">
<X size={18} aria-hidden="true" />
</button>
)}
</header>
{watch.phase === "idle" ? (
<AnchorCapture watch={watch} gps={gps} onStartGps={onStartGps} />
) : armed ? (
<AnchorWatchActive
watch={watch}
status={status}
nearLimit={nearLimit}
confirmStop={confirmStop}
onCancelStop={() => setConfirmStop(false)}
onStop={stop}
/>
) : (
<AnchorWatchSetup watch={watch} onCancel={cancel} />
)}
</aside>
);
}
function AnchorCapture({
watch,
gps,
onStartGps
}: {
watch: AnchorWatchModel;
gps: AnchorWatchPanelProps["gps"];
onStartGps: () => void;
}) {
const gpsReady = gps.status === "tracking" && gps.accuracyM !== null;
return (
<div className="anchor-capture">
<p>
Setze den Punkt genau dann, wenn der Anker den Grund erreicht. Watermaps verschiebt ihn danach nicht mit dem Boot.
</p>
<div className="anchor-gps-readiness" data-ready={gpsReady}>
<Crosshair size={18} aria-hidden="true" />
<span>
<strong>{gpsReady ? `GPS ±${Math.round(gps.accuracyM ?? 0)} m` : gpsLabel(gps.status)}</strong>
<small>Erforderlich: frischer Fix mit höchstens ±{watch.maxCaptureAccuracyM} m</small>
</span>
</div>
{gps.status !== "tracking" && (
<button className="anchor-secondary-action" type="button" onClick={onStartGps}>
<Crosshair size={17} aria-hidden="true" />
GPS starten
</button>
)}
<button className="anchor-primary-action" type="button" onClick={watch.captureAnchor}>
<Anchor size={18} aria-hidden="true" />
Anker gefallen Position jetzt setzen
</button>
{watch.operationError && <p className="anchor-inline-alert" role="alert">{watch.operationError}</p>}
<small className="anchor-safety-note">
Keine automatische Ankererkennung: Ein Browser-GPS kann das Fallenlassen nicht zuverlässig erkennen.
</small>
</div>
);
}
function AnchorWatchSetup({ watch, onCancel }: { watch: AnchorWatchModel; onCancel: () => void }) {
const suggestedReachM = watch.rodePlan
? Math.ceil(Math.max(
watch.rodePlan.horizontalReachAtSetM,
watch.rodePlan.horizontalReachM ?? 0
) + 5)
: null;
const radiusTooSmall = suggestedReachM !== null && watch.settings.alarmRadiusM < suggestedReachM;
return (
<div className="anchor-watch-setup">
<div className="anchor-point-summary">
<MapPin size={17} aria-hidden="true" />
<span>
<strong>Ankerpunkt gespeichert</strong>
<small>
{watch.anchorPoint ? formatCoordinate(watch.anchorPoint) : ""}
{watch.anchorCaptureAccuracyM !== null && ` · GPS ±${Math.round(watch.anchorCaptureAccuracyM)} m`}
{watch.anchorSetAtMs !== null && ` · ${formatClock(watch.anchorSetAtMs)}`}
</small>
</span>
<button type="button" onClick={watch.captureAnchor} aria-label="Ankerpunkt an aktueller Position neu setzen">
<RefreshCw size={16} aria-hidden="true" />
</button>
</div>
<AnchorSettingsForm watch={watch} />
<TideAndRodeSummary watch={watch} />
{radiusTooSmall && (
<p className="anchor-inline-warning">
<AlertTriangle size={15} aria-hidden="true" />
Der Radius liegt unter der rechnerischen horizontalen Reichweite von etwa {suggestedReachM} m. Normales Schwojen kann bereits alarmieren; Bootslänge und GPS-Unsicherheit kommen noch hinzu.
</p>
)}
{watch.settingsError && <p className="anchor-inline-alert" role="alert">{watch.settingsError}</p>}
{watch.operationError && <p className="anchor-inline-alert" role="alert">{watch.operationError}</p>}
<div className="anchor-setup-actions">
<button className="anchor-secondary-action" type="button" onClick={onCancel}>Abbrechen</button>
<button className="anchor-primary-action" type="button" onClick={() => void watch.arm()}>
<BellRing size={17} aria-hidden="true" />
Wache starten
</button>
</div>
<small className="anchor-safety-note">
Tidendaten und Scope-Rechnung sind Planungshilfen. Grund, Anker, Wind, Wellen, Strom, Schwell und Abstand zu Gefahren müssen vor Ort beurteilt werden.
</small>
</div>
);
}
function AnchorSettingsForm({ watch, compact = false }: { watch: AnchorWatchModel; compact?: boolean }) {
const fields: Array<{
key: keyof AnchorWatchSettings;
label: string;
unit: string;
min: number;
max: number;
step: number;
}> = [
{ key: "depthAtSetM", label: "Tiefe beim Setzen", unit: "m", min: 0.1, max: 200, step: 0.1 },
{ key: "bowRollerHeightM", label: "Bugrolle über Wasser", unit: "m", min: 0, max: 20, step: 0.1 },
{ key: "deployedRodeLengthM", label: "Kette / Leine draußen", unit: "m", min: 1, max: 2_000, step: 1 },
{ key: "safetyAllowanceM", label: "Wasserstandsreserve", unit: "m", min: 0, max: 10, step: 0.1 },
{ key: "alarmRadiusM", label: "Alarmradius ab Anker", unit: "m", min: 10, max: 2_000, step: 5 }
];
return (
<div className="anchor-settings" data-compact={compact}>
{fields.map((field) => (
<label key={field.key} htmlFor={`anchor-${field.key}`}>
<span>{field.label}</span>
<span>
<input
id={`anchor-${field.key}`}
type="number"
inputMode="decimal"
min={field.min}
max={field.max}
step={field.step}
value={watch.settings[field.key]}
onChange={(event) => watch.updateSettings({ [field.key]: Number(event.target.value) })}
/>
{field.unit}
</span>
</label>
))}
<label htmlFor="anchor-scopeRatio">
<span>Gewähltes Verhältnis</span>
<select
id="anchor-scopeRatio"
value={watch.settings.scopeRatio}
onChange={(event) => watch.updateSettings({ scopeRatio: Number(event.target.value) })}
>
{[3, 4, 5, 6, 7, 8, 10].map((value) => <option key={value} value={value}>{value}:1</option>)}
</select>
</label>
<label htmlFor="anchor-horizonHours">
<span>Tidenzeitraum</span>
<select
id="anchor-horizonHours"
value={watch.settings.horizonHours}
onChange={(event) => watch.updateSettings({ horizonHours: Number(event.target.value) })}
>
<option value={12}>12 Stunden</option>
<option value={24}>24 Stunden</option>
<option value={48}>48 Stunden</option>
</select>
</label>
</div>
);
}
function TideAndRodeSummary({ watch, active = false }: { watch: AnchorWatchModel; active?: boolean }) {
const tideWindow = active ? watch.remainingTideWindow : watch.tideWindow;
const plan = watch.rodePlan;
const complete = tideWindow?.coverage === "complete" && plan?.calculationComplete;
const stationFar = Boolean(watch.tide && watch.tide.distanceKm > 30);
return (
<div className="anchor-planning-summary">
<section className="anchor-tide-card" data-incomplete={!complete} data-far={stationFar}>
<span className="anchor-card-icon"><Waves size={18} aria-hidden="true" /></span>
<div>
<small>{active ? "TIDE AB JETZT" : "TIDE AB ANKERSETZEN"}</small>
{watch.tideLoading && !watch.tide ? (
<strong><LoaderCircle className="anchor-spinner" size={15} aria-hidden="true" /> Wird geladen </strong>
) : tideWindow?.maximumRiseM !== null && tideWindow?.maximumRiseM !== undefined ? (
<strong>max. +{tideWindow.maximumRiseM.toFixed(2)} m · Hub {formatNullable(tideWindow.tidalRangeM)} m</strong>
) : (
<strong>Nicht berechenbar</strong>
)}
<span>
{watch.tide
? `${watch.tide.station} · ${watch.tide.distanceKm.toFixed(1)} km entfernt · Stand ${formatUpdatedAt(watch.tide.updatedAt)}`
: watch.tideError
? "Stationsprognose nicht erreichbar nicht als 0 m angesetzt"
: "Warte auf Stationsprognose"}
</span>
{tideWindow?.coverage === "partial" && <em>Prognose deckt den gewählten Zeitraum nur teilweise ab.</em>}
{stationFar && <em>Entfernter Pegel: lokale Tide kann deutlich abweichen.</em>}
</div>
</section>
<section
className="anchor-rode-card"
data-state={!plan?.calculationComplete ? "unknown" : plan.hasSufficientRode ? "safe" : "short"}
>
<span className="anchor-card-icon">
{plan?.calculationComplete && plan.hasSufficientRode
? <ShieldCheck size={18} aria-hidden="true" />
: <AlertTriangle size={18} aria-hidden="true" />}
</span>
<div>
<small>ANKERLEINEN-RESERVE</small>
{plan?.calculationComplete && plan.requiredRodeLengthM !== null && plan.rodeReserveM !== null ? (
<>
<strong>{plan.rodeReserveM >= 0 ? "+" : ""}{plan.rodeReserveM.toFixed(1)} m Reserve</strong>
<span>Rechnerisch {plan.requiredRodeLengthM.toFixed(1)} m bei {watch.settings.scopeRatio}:1 erforderlich</span>
</>
) : plan ? (
<>
<strong>Nicht bestätigt</strong>
<span>Ohne vollständige Tide mindestens {plan.minimumRequiredRodeLengthM.toFixed(1)} m; Zukunftsbedarf offen</span>
</>
) : (
<strong>Eingaben prüfen</strong>
)}
</div>
</section>
</div>
);
}
function AnchorWatchActive({
watch,
status,
nearLimit,
confirmStop,
onCancelStop,
onStop
}: {
watch: AnchorWatchModel;
status: { tone: string; title: string; detail: string };
nearLimit: boolean;
confirmStop: boolean;
onCancelStop: () => void;
onStop: () => void;
}) {
const distance = watch.watchResult?.distanceFromAnchorM;
const elapsedMs = watch.anchorSetAtMs === null ? 0 : Math.max(0, Date.now() - watch.anchorSetAtMs);
const remainingWindow = watch.remainingTideWindow;
return (
<div className="anchor-watch-active">
<section className="anchor-distance-hero" data-tone={status.tone} aria-live="polite">
<span>
{status.tone === "safe" ? <CheckCircle2 size={26} aria-hidden="true" /> : <AlertTriangle size={26} aria-hidden="true" />}
</span>
<div>
<small>ABSTAND / ALARMRADIUS</small>
<strong>{distance === null || distance === undefined ? "---" : Math.round(distance)} <em>/ {Math.round(watch.settings.alarmRadiusM)} m</em></strong>
</div>
</section>
<p className="anchor-watch-status" data-tone={status.tone} role={watch.positionAlarm ? "alert" : "status"}>
<strong>{status.title}</strong>
<span>{status.detail}</span>
</p>
<div className="anchor-live-metrics">
<span><small>GPS</small><strong>{watch.watchResult?.accuracyM === null || watch.watchResult?.accuracyM === undefined ? "--" : `±${Math.round(watch.watchResult.accuracyM)} m`}</strong></span>
<span><small>SEIT</small><strong>{formatDuration(elapsedMs)}</strong></span>
<span><small>TIDE NOCH</small><strong>{remainingWindow?.coverage === "complete" && remainingWindow.maximumRiseM !== null ? `+${remainingWindow.maximumRiseM.toFixed(2)} m` : "offen"}</strong></span>
<span><small>LEINE</small><strong>{watch.rodePlan?.rodeReserveM === null || watch.rodePlan?.rodeReserveM === undefined ? "offen" : `${watch.rodePlan.rodeReserveM >= 0 ? "+" : ""}${watch.rodePlan.rodeReserveM.toFixed(1)} m`}</strong></span>
</div>
{watch.positionAlarm && !watch.alarmAcknowledged && (
<button className="anchor-acknowledge-action" type="button" onClick={watch.acknowledgeAlarm}>
<BellRing size={17} aria-hidden="true" />
Alarm quittieren
</button>
)}
{watch.positionAlarm && watch.alarmAcknowledged && (
<p className="anchor-acknowledged">Alarmton quittiert · rote Warnanzeige bleibt aktiv</p>
)}
{watch.rodeShortfall && (
<p className="anchor-inline-alert" role="alert">
Nach der aktuellen Stationsprognose ist die eingegebene Kette/Leine rechnerisch zu kurz.
</p>
)}
{nearLimit && <p className="anchor-inline-warning">80 % des Alarmradius erreicht.</p>}
<details className="anchor-active-details">
<summary>Radius, Tide und Leinenrechnung</summary>
<AnchorSettingsForm watch={watch} compact />
<TideAndRodeSummary watch={watch} active />
</details>
<div className="anchor-stop-actions">
{confirmStop && <button className="anchor-secondary-action" type="button" onClick={onCancelStop}>Weiter überwachen</button>}
<button className="anchor-stop-action" type="button" onClick={onStop}>
{confirmStop ? "Wirklich beenden" : "Ankerwache beenden"}
</button>
</div>
<small className="anchor-safety-note">
App sichtbar und Display an lassen. Browser und Betriebssystem können GPS, Ton und Mitteilungen im Hintergrund anhalten. Watermaps ersetzt keine Ankerpeilung und keinen Ausguck.
</small>
</div>
);
}
function anchorStatus(watch: AnchorWatchModel, gpsStatus: GpsState["status"], nearLimit: boolean) {
if (watch.fixStale) {
return { tone: "alarm", title: "GPS-Fix veraltet", detail: "Die Ankerposition wird gerade nicht sicher überwacht." };
}
if (gpsStatus !== "tracking") {
return { tone: "alarm", title: "GPS ausgefallen", detail: "Position prüfen und GPS-Berechtigung wiederherstellen." };
}
if (watch.gpsUnreliable) {
return { tone: "alarm", title: "GPS zu ungenau", detail: "Keine sichere Aussage zum Schwojradius möglich." };
}
if (watch.watchResult?.alarmTriggered) {
return {
tone: "alarm",
title: "Außerhalb des Alarmradius",
detail: `Auch nach Abzug der GPS-Ungenauigkeit noch ${Math.round(watch.watchResult.conservativeDistanceFromAnchorM ?? 0)} m vom Ankerpunkt.`
};
}
if (nearLimit) {
return { tone: "warning", title: "Nahe am Alarmradius", detail: "Position und Peilmarken aufmerksam beobachten." };
}
return { tone: "safe", title: "Im überwachten Schwojkreis", detail: "Abstand wird mit jedem neuen GPS-Fix geprüft." };
}
function gpsLabel(status: GpsState["status"]) {
if (status === "requesting") return "GPS-Freigabe wird angefragt";
if (status === "denied") return "GPS-Freigabe abgelehnt";
if (status === "unavailable") return "GPS nicht verfügbar";
if (status === "error") return "GPS-Fehler";
return "GPS noch nicht gestartet";
}
function formatCoordinate(coordinate: { lat: number; lon: number }) {
return `${coordinate.lat.toFixed(5)}, ${coordinate.lon.toFixed(5)}`;
}
function formatClock(timestampMs: number) {
return new Intl.DateTimeFormat("de-DE", { hour: "2-digit", minute: "2-digit" }).format(timestampMs);
}
function formatUpdatedAt(value: string) {
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? formatClock(timestamp) : "unbekannt";
}
function formatDuration(milliseconds: number) {
const totalMinutes = Math.floor(milliseconds / 60_000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes} min`;
}
function formatNullable(value: number | null) {
return value === null ? "" : value.toFixed(2);
}
+42
View File
@@ -0,0 +1,42 @@
import { Compass } from "lucide-react";
type CompassDialProps = {
headingDeg: number | null;
source: string;
status: string;
targetHeadingDeg?: number | null;
onRequest: () => void;
};
export function CompassDial({ headingDeg, source, status, targetHeadingDeg = null, onRequest }: CompassDialProps) {
const displayHeading = headingDeg ?? 0;
return (
<button
className="compass-dial"
type="button"
title="Kompass aktivieren"
aria-label={
targetHeadingDeg === null
? "Kompass aktivieren"
: `Kompass aktivieren, Sollkurs ${Math.round(targetHeadingDeg)} Grad`
}
onClick={onRequest}
data-status={status}
>
<span className="compass-ring">
{targetHeadingDeg !== null && (
<span
className="compass-course-marker"
style={{ transform: `rotate(${targetHeadingDeg}deg)` }}
aria-hidden="true"
/>
)}
<span className="compass-needle" style={{ transform: `rotate(${displayHeading}deg)` }} />
<Compass size={18} aria-hidden="true" />
</span>
<span className="compass-value">{headingDeg === null ? "--" : Math.round(headingDeg)}</span>
<span className="compass-source">{source}</span>
</button>
);
}
+317
View File
@@ -0,0 +1,317 @@
/* Weather and tide --------------------------------------------------------- */
.conditions-panel {
display: grid;
gap: 10px;
}
.conditions-panel-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.conditions-panel-header > div {
min-width: 0;
}
.conditions-panel-header h2 {
margin: 3px 0;
font-size: 20px;
}
.conditions-panel-kicker,
.conditions-position-source,
.conditions-section-heading,
.conditions-station,
.conditions-panel-state,
.conditions-data-provenance,
.upcoming-events-message {
display: flex;
align-items: center;
}
.conditions-panel-kicker {
gap: 6px;
color: #0f4c5c;
font-size: 11px;
font-weight: 900;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.conditions-position-source {
width: fit-content;
min-height: 28px;
margin: 0;
border-radius: 999px;
padding: 0 9px;
gap: 5px;
background: #dceee6;
color: #196f5c;
font-size: 11px;
font-weight: 850;
}
.conditions-position-source[data-source="fallback"],
.conditions-position-source[data-source="unknown"] {
background: #fff1cc;
color: #805900;
}
.conditions-panel-close {
width: 44px;
height: 44px;
flex: 0 0 auto;
border-radius: 9px;
display: grid;
place-items: center;
background: #e2ece9;
color: #23434c;
}
.conditions-panel-state {
min-height: 44px;
margin: 0;
border-radius: 9px;
padding: 8px 10px;
gap: 7px;
background: #edf3f1;
color: #526a72;
font-size: 12px;
font-weight: 750;
line-height: 1.35;
}
.conditions-panel-state[data-state="warning"] {
background: #fff1cc;
color: #805900;
}
.conditions-panel-state[data-state="empty"] {
border: 1px dashed #b9cbc7;
background: transparent;
}
.conditions-section {
border: 1px solid rgba(15, 76, 92, 0.12);
border-radius: 11px;
padding: 10px;
display: grid;
gap: 9px;
background: rgba(237, 243, 241, 0.72);
}
.conditions-section-heading {
margin: 0;
gap: 7px;
color: #17343c;
font-size: 13px;
}
.conditions-metric-grid,
.conditions-route-sample > dl {
margin: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.conditions-metric {
min-width: 0;
min-height: 58px;
border-radius: 9px;
padding: 7px 8px;
display: grid;
align-content: center;
gap: 3px;
background: #ffffff;
}
.conditions-metric dt {
display: flex;
align-items: center;
gap: 5px;
color: #607278;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.conditions-metric dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #16323a;
font-size: 13px;
font-weight: 850;
}
.conditions-data-provenance {
flex-wrap: wrap;
margin: 0;
gap: 3px 10px;
color: #607278;
font-size: 10px;
font-weight: 700;
}
.conditions-station {
flex-wrap: wrap;
margin: 0;
gap: 5px;
color: #526a72;
font-size: 12px;
}
.conditions-station strong {
color: #17343c;
}
.conditions-tide-events {
margin: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.conditions-tide-event {
min-height: 62px;
border-radius: 9px;
padding: 8px;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
background: #ffffff;
}
.conditions-tide-event dt {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
background: #dceee6;
color: #196f5c;
font-size: 11px;
font-weight: 900;
}
.conditions-tide-event dd {
min-width: 0;
margin: 0;
display: grid;
gap: 2px;
color: #526a72;
font-size: 10px;
font-weight: 750;
}
.conditions-tide-event time {
color: #17343c;
font-size: 11px;
font-weight: 850;
}
.conditions-route-assessment {
border-radius: 9px;
padding: 8px;
background: #dceee6;
color: #196f5c;
}
.conditions-route-assessment[data-severity="caution"] {
background: #fff1cc;
color: #805900;
}
.conditions-route-assessment[data-severity="critical"] {
background: #ffe1dc;
color: #9d2c22;
}
.conditions-route-assessment > p {
margin: 0;
font-size: 12px;
line-height: 1.35;
}
.conditions-route-assessment .conditions-data-provenance {
margin-top: 5px;
color: currentColor;
}
.conditions-route-samples {
display: grid;
gap: 7px;
}
.conditions-route-sample {
border: 1px solid rgba(15, 76, 92, 0.1);
border-radius: 10px;
padding: 9px;
display: grid;
gap: 8px;
background: #ffffff;
}
.conditions-route-sample > header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.conditions-route-sample h4 {
margin: 0;
color: #17343c;
font-size: 13px;
}
.conditions-route-sample > header time {
color: #607278;
font-size: 10px;
font-weight: 750;
}
.conditions-route-sample .conditions-metric {
min-height: 48px;
padding: 4px 0;
}
.conditions-route-sample .conditions-metric:nth-child(3) {
grid-column: 1 / -1;
}
.conditions-route-tide {
border-top: 1px solid rgba(15, 76, 92, 0.1);
padding-top: 7px;
display: grid;
gap: 2px;
color: #526a72;
font-size: 10px;
}
.conditions-route-tide strong {
color: #17343c;
font-size: 11px;
}
.conditions-inline-empty {
margin: 0;
color: #607278;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
}
.conditions-panel-disclaimer {
margin: 0;
color: #607278;
font-size: 10px;
font-weight: 700;
line-height: 1.4;
}
+616
View File
@@ -0,0 +1,616 @@
import {
AlertTriangle,
CloudSun,
MapPin,
Navigation2,
Thermometer,
Waves,
Wind,
X
} from "lucide-react";
import { useId, type ReactNode } from "react";
import type { MarineForecast, TideEvent, TideSummary } from "@watermaps/shared";
import "./ConditionsPanel.css";
export type ConditionsPositionSource = {
kind: "gps" | "fallback" | "unknown";
label?: string | null;
};
export type ConditionsRouteWeatherSample = {
label: "Start" | "Mitte" | "Ziel";
forecast: MarineForecast;
plannedTime?: string;
currentAlongRouteKn?: number | null;
};
/**
* Deliberately narrower than RouteWeatherReport. The complete report remains
* assignable while this panel stays independent of the report generator.
*/
export type ConditionsRouteWeatherReport = {
samples: ReadonlyArray<ConditionsRouteWeatherSample>;
severity?: "ok" | "caution" | "critical";
summary?: string | null;
source?: string | null;
updatedAt?: string | null;
unavailableSamples?: number;
};
export type ConditionsRouteTides = {
start: TideSummary | null;
middle?: TideSummary | null;
destination: TideSummary | null;
};
export type ConditionsPanelProps = {
forecast: MarineForecast | null;
tide: TideSummary | null;
positionSource?: ConditionsPositionSource;
currentLoading?: boolean;
currentError?: string | null;
routeWeatherReport?: ConditionsRouteWeatherReport | null;
routeTides?: ConditionsRouteTides | null;
routeLoading?: boolean;
routeError?: string | null;
onClose?: () => void;
className?: string;
/** Optional clock for deterministic consumers and tests. */
now?: number;
};
const ROUTE_SAMPLE_LABELS = ["Start", "Mitte", "Ziel"] as const;
export function ConditionsPanel({
forecast,
tide,
positionSource = { kind: "unknown" },
currentLoading = false,
currentError = null,
routeWeatherReport = null,
routeTides = null,
routeLoading = false,
routeError = null,
onClose,
className,
now = Date.now()
}: ConditionsPanelProps) {
const titleId = useId();
const currentTitleId = useId();
const tideTitleId = useId();
const routeTitleId = useId();
const hasCurrentData = Boolean(forecast || tide);
const hasRouteData = Boolean(routeWeatherReport || routeTides);
return (
<aside
className={joinClassNames("conditions-panel", className)}
aria-labelledby={titleId}
aria-busy={currentLoading || routeLoading}
data-position-source={positionSource.kind}
>
<header className="conditions-panel-header">
<div>
<span className="conditions-panel-kicker">
<CloudSun size={16} aria-hidden="true" />
Bedingungen
</span>
<h2 id={titleId}>Wetter &amp; Tide</h2>
<PositionSource source={positionSource} />
</div>
{onClose && (
<button
className="conditions-panel-close"
type="button"
onClick={onClose}
aria-label="Wetter und Tide schließen"
title="Schließen"
>
<X size={18} aria-hidden="true" />
</button>
)}
</header>
{currentLoading && (
<p className="conditions-panel-state" role="status" aria-live="polite">
Bedingungen für die aktuelle Position werden geladen
</p>
)}
{currentError && (
<p className="conditions-panel-state" data-state="warning" role="alert">
<AlertTriangle size={15} aria-hidden="true" />
{currentError}
</p>
)}
{!hasCurrentData && !currentLoading && !currentError && (
<p className="conditions-panel-state" data-state="empty">
Für diese Position liegen noch keine Wetter- oder Tidendaten vor.
</p>
)}
{(forecast || (hasCurrentData && !currentLoading)) && (
<section className="conditions-section" aria-labelledby={currentTitleId}>
<SectionHeading id={currentTitleId} icon={<Wind size={16} aria-hidden="true" />}>
Aktuelle Bedingungen
</SectionHeading>
{forecast ? (
<>
<dl className="conditions-metric-grid">
<Metric
icon={<Wind size={15} aria-hidden="true" />}
label="Wind"
value={formatWind(forecast)}
/>
<Metric
icon={<Waves size={15} aria-hidden="true" />}
label="Welle"
value={formatWave(forecast)}
/>
<Metric
icon={<Navigation2 size={15} aria-hidden="true" />}
label="Strömung"
value={formatCurrent(forecast)}
/>
<Metric
icon={<Thermometer size={15} aria-hidden="true" />}
label="Temperatur"
value={formatTemperature(forecast.temperatureC)}
/>
</dl>
<DataProvenance
source={forecast.source}
updatedAt={forecast.updatedAt}
validAt={forecast.forecastTime}
now={now}
/>
</>
) : (
<p className="conditions-inline-empty">Aktuelle Wetter- und Strömungsdaten fehlen.</p>
)}
</section>
)}
{(tide || (hasCurrentData && !currentLoading)) && (
<section className="conditions-section" aria-labelledby={tideTitleId}>
<SectionHeading id={tideTitleId} icon={<Waves size={16} aria-hidden="true" />}>
Tide an der Position
</SectionHeading>
{tide ? (
<>
<p className="conditions-station">
<MapPin size={14} aria-hidden="true" />
<strong>{safeText(tide.station, "Station unbekannt")}</strong>
<span>{formatDistanceKm(tide.distanceKm)} entfernt</span>
</p>
<dl className="conditions-tide-events">
<TideEventRow label="Nächstes Hochwasser" shortLabel="HW" event={tide.nextHigh} />
<TideEventRow label="Nächstes Niedrigwasser" shortLabel="NW" event={tide.nextLow} />
</dl>
<DataProvenance source={tide.source} updatedAt={tide.updatedAt} now={now} />
</>
) : (
<p className="conditions-inline-empty">Für diese Position fehlt eine passende Tidenstation.</p>
)}
</section>
)}
<section className="conditions-section conditions-route" aria-labelledby={routeTitleId}>
<SectionHeading id={routeTitleId} icon={<Navigation2 size={16} aria-hidden="true" />}>
Bedingungen auf der Strecke
</SectionHeading>
{routeLoading && (
<p className="conditions-panel-state" role="status" aria-live="polite">
Streckenprognose wird geladen
</p>
)}
{routeError && (
<p className="conditions-panel-state" data-state="warning" role="alert">
<AlertTriangle size={15} aria-hidden="true" />
{routeError}
</p>
)}
{routeWeatherReport && (
<RouteAssessment report={routeWeatherReport} now={now} />
)}
{hasRouteData ? (
<div className="conditions-route-samples">
{ROUTE_SAMPLE_LABELS.map((label) => (
<RouteSampleCard
key={label}
label={label}
sample={routeWeatherReport?.samples.find((candidate) => candidate.label === label) ?? null}
tide={
label === "Start"
? routeTides?.start ?? null
: label === "Mitte"
? routeTides?.middle ?? null
: label === "Ziel"
? routeTides?.destination ?? null
: null
}
routeTidesAvailable={Boolean(routeTides)}
now={now}
/>
))}
</div>
) : (
!routeLoading && (
<p className="conditions-panel-state" data-state="empty">
Nach der Routenberechnung erscheinen hier Start, Mitte und Ziel.
</p>
)
)}
</section>
<p className="conditions-panel-disclaimer">
Prognosen und entfernte Tidenstationen können lokal abweichen. Amtliche Warnungen, Pegel und
Befahrensregeln zusätzlich prüfen.
</p>
</aside>
);
}
function PositionSource({ source }: { source: ConditionsPositionSource }) {
const label =
source.kind === "gps"
? source.label
? `GPS · ${source.label}`
: "Aktuelle GPS-Position"
: source.kind === "fallback"
? source.label
? `Fallback · ${source.label}`
: "Fallback-Position"
: source.label || "Positionsquelle noch offen";
return (
<p className="conditions-position-source" data-source={source.kind}>
{source.kind === "gps" ? (
<Navigation2 size={14} aria-hidden="true" />
) : (
<MapPin size={14} aria-hidden="true" />
)}
<span>{label}</span>
</p>
);
}
function SectionHeading({
id,
icon,
children
}: {
id: string;
icon: ReactNode;
children: ReactNode;
}) {
return (
<h3 id={id} className="conditions-section-heading">
{icon}
{children}
</h3>
);
}
function Metric({
icon,
label,
value
}: {
icon: ReactNode;
label: string;
value: string;
}) {
return (
<div className="conditions-metric">
<dt>
{icon}
{label}
</dt>
<dd>{value}</dd>
</div>
);
}
function TideEventRow({
label,
shortLabel,
event
}: {
label: string;
shortLabel: "HW" | "NW";
event: TideEvent | null;
}) {
return (
<div className="conditions-tide-event">
<dt>
<abbr title={label}>{shortLabel}</abbr>
</dt>
<dd>
{event ? (
<>
{isValidDate(event.time) ? (
<time dateTime={event.time}>{formatDateTime(event.time)}</time>
) : (
<span>Zeit offen</span>
)}
<span>{formatTideHeight(event.heightM)}</span>
</>
) : (
<span>Zeit und Höhe offen</span>
)}
</dd>
</div>
);
}
function DataProvenance({
source,
updatedAt,
validAt,
now
}: {
source?: string | null;
updatedAt?: string | null;
validAt?: string | null;
now: number;
}) {
return (
<p className="conditions-data-provenance">
<span>Quelle: {safeText(source, "nicht angegeben")}</span>
<span>
Stand:{" "}
{updatedAt && isValidDate(updatedAt) ? (
<time dateTime={updatedAt} title={formatDateTime(updatedAt)}>
{formatAge(updatedAt, now)}
</time>
) : (
"Zeit unbekannt"
)}
</span>
{validAt && isValidDate(validAt) && (
<span>
Gültig: <time dateTime={validAt}>{formatDateTime(validAt)}</time>
</span>
)}
</p>
);
}
function RouteAssessment({
report,
now
}: {
report: ConditionsRouteWeatherReport;
now: number;
}) {
const unavailableSamples = finiteNumber(report.unavailableSamples);
const hasUnavailableSamples = unavailableSamples !== null && unavailableSamples > 0;
return (
<div className="conditions-route-assessment" data-severity={report.severity ?? "unknown"}>
{(report.severity || report.summary) && (
<p>
{report.severity && <strong>{severityLabel(report.severity)}: </strong>}
{report.summary || "Streckenbedingungen teilweise verfügbar."}
</p>
)}
{hasUnavailableSamples && (
<p className="conditions-panel-state" data-state="warning">
<AlertTriangle size={14} aria-hidden="true" />
{unavailableSamples === 1
? "Für einen Streckenpunkt fehlt die Prognose."
: `Für ${unavailableSamples} Streckenpunkte fehlt die Prognose.`}
</p>
)}
{(report.source || report.updatedAt) && (
<DataProvenance source={report.source} updatedAt={report.updatedAt} now={now} />
)}
</div>
);
}
function RouteSampleCard({
label,
sample,
tide,
routeTidesAvailable,
now
}: {
label: (typeof ROUTE_SAMPLE_LABELS)[number];
sample: ConditionsRouteWeatherSample | null;
tide: TideSummary | null;
routeTidesAvailable: boolean;
now: number;
}) {
return (
<article className="conditions-route-sample" data-sample={label.toLowerCase()}>
<header>
<h4>{label}</h4>
{sample?.plannedTime && isValidDate(sample.plannedTime) && (
<time dateTime={sample.plannedTime}>{formatDateTime(sample.plannedTime)}</time>
)}
</header>
{sample ? (
<>
<dl>
<Metric label="Wind" value={formatWind(sample.forecast)} icon={<Wind size={14} aria-hidden="true" />} />
<Metric label="Welle" value={formatWave(sample.forecast)} icon={<Waves size={14} aria-hidden="true" />} />
<Metric
label="Strom"
value={formatRouteCurrent(sample)}
icon={<Navigation2 size={14} aria-hidden="true" />}
/>
</dl>
<DataProvenance
source={sample.forecast.source}
updatedAt={sample.forecast.updatedAt}
validAt={sample.forecast.forecastTime}
now={now}
/>
</>
) : (
<p className="conditions-inline-empty">Keine Wetterprognose für diesen Streckenpunkt.</p>
)}
{tide ? (
<div className="conditions-route-tide">
<strong>
{safeText(tide.station, "Tidenstation")} · {formatDistanceKm(tide.distanceKm)}
</strong>
<span>{formatCompactTideEvent("HW", tide.nextHigh)}</span>
<span>{formatCompactTideEvent("NW", tide.nextLow)}</span>
</div>
) : (
routeTidesAvailable && (
<p className="conditions-inline-empty">Keine passende Tide für {label.toLowerCase()}.</p>
)
)}
</article>
);
}
function formatWind(forecast: MarineForecast) {
return joinMeasurements(
formatMeasurement(forecast.windSpeed, "kn", 0),
formatDirection(forecast.windDirectionDeg)
);
}
function formatWave(forecast: MarineForecast) {
return joinMeasurements(
formatMeasurement(forecast.waveHeightM, "m", 1),
formatMeasurement(forecast.wavePeriodS, "s", 0),
formatDirection(forecast.waveDirectionDeg)
);
}
function formatCurrent(forecast: MarineForecast) {
return joinMeasurements(
formatMeasurement(forecast.oceanCurrentSpeedKn, "kn", 1),
formatDirection(forecast.oceanCurrentDirectionDeg)
);
}
function formatRouteCurrent(sample: ConditionsRouteWeatherSample) {
const alongRoute = finiteNumber(sample.currentAlongRouteKn);
if (alongRoute !== null) {
const sign = alongRoute > 0 ? "+" : "";
return `${sign}${alongRoute.toFixed(1)} kn entlang Route`;
}
return formatCurrent(sample.forecast);
}
function formatTemperature(value: number | null | undefined) {
const normalized = finiteNumber(value);
return normalized === null ? "Keine Daten" : `${normalized.toFixed(1)} °C`;
}
function formatMeasurement(value: number | null | undefined, unit: string, digits: number) {
const normalized = finiteNumber(value);
return normalized === null ? null : `${normalized.toFixed(digits)} ${unit}`;
}
function formatDirection(value: number | null | undefined) {
const normalized = finiteNumber(value);
if (normalized === null) {
return null;
}
const heading = ((normalized % 360) + 360) % 360;
const cardinal = ["N", "NO", "O", "SO", "S", "SW", "W", "NW"][
Math.round(heading / 45) % 8
];
return `${String(Math.round(heading)).padStart(3, "0")}° ${cardinal}`;
}
function formatDistanceKm(value: number) {
const normalized = finiteNumber(value);
return normalized === null
? "Entfernung unbekannt"
: `${normalized.toLocaleString("de-DE", { maximumFractionDigits: 1 })} km`;
}
function formatTideHeight(value: number | null | undefined) {
const normalized = finiteNumber(value);
return normalized === null ? "Höhe offen" : `${normalized.toFixed(2)} m`;
}
function formatCompactTideEvent(label: "HW" | "NW", event: TideEvent | null) {
if (!event) {
return `${label} offen`;
}
return `${label} ${formatDateTime(event.time)} · ${formatTideHeight(event.heightM)}`;
}
function formatDateTime(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
: "Zeit offen";
}
function formatAge(value: string, now: number) {
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp) || !Number.isFinite(now)) {
return "Zeit unbekannt";
}
if (timestamp - now > 60_000) {
return formatDateTime(value);
}
const ageMs = Math.max(0, now - timestamp);
const minutes = Math.floor(ageMs / 60_000);
if (minutes < 1) {
return "gerade aktualisiert";
}
if (minutes < 60) {
return `vor ${minutes} Min.`;
}
const hours = Math.floor(minutes / 60);
if (hours < 48) {
return `vor ${hours} Std.`;
}
return `vor ${Math.floor(hours / 24)} Tagen`;
}
function severityLabel(severity: NonNullable<ConditionsRouteWeatherReport["severity"]>) {
switch (severity) {
case "critical":
return "Kritisch";
case "caution":
return "Achtung";
case "ok":
return "Unauffällig";
}
}
function joinMeasurements(...parts: Array<string | null>) {
const available = parts.filter((part): part is string => Boolean(part));
return available.length > 0 ? available.join(" · ") : "Keine Daten";
}
function finiteNumber(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function isValidDate(value: string) {
return Number.isFinite(Date.parse(value));
}
function safeText(value: string | null | undefined, fallback: string) {
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function joinClassNames(...values: Array<string | null | undefined | false>) {
return values.filter(Boolean).join(" ");
}
@@ -0,0 +1,238 @@
.course-assistant-panel {
position: absolute;
z-index: 8;
left: 10px;
right: 10px;
bottom: calc(86px + env(safe-area-inset-bottom));
width: min(430px, calc(100vw - 20px));
margin: 0 auto;
border: 2px solid rgba(15, 76, 92, 0.42);
border-radius: 14px;
padding: 10px;
display: grid;
gap: 7px;
background: rgba(246, 249, 247, 0.97);
color: #10242b;
box-shadow: 0 14px 38px rgba(7, 25, 29, 0.28);
backdrop-filter: blur(18px);
}
.course-assistant-panel[data-alert="true"] {
border-color: #c44a30;
}
.course-assistant-header,
.course-assistant-header > span,
.course-assistant-header button,
.course-assistant-status {
display: flex;
align-items: center;
}
.course-assistant-header {
min-height: 36px;
justify-content: space-between;
gap: 8px;
}
.course-assistant-header > span {
gap: 7px;
color: #0f4c5c;
font-size: 14px;
}
.course-assistant-header button {
min-width: 72px;
min-height: 44px;
justify-content: center;
gap: 6px;
border-radius: 9px;
background: #e2ece9;
color: #23434c;
font-size: 12px;
font-weight: 850;
}
.course-assistant-main {
min-height: 72px;
display: grid;
grid-template-columns: 54px minmax(0, 1fr);
gap: 10px;
align-items: center;
border-radius: 11px;
padding: 7px 10px;
background: #0f4c5c;
color: #ffffff;
}
.course-assistant-panel[data-alert="true"] .course-assistant-main {
background: #7c3327;
}
.course-assistant-arrow {
width: 48px;
height: 48px;
display: grid;
place-items: center;
transform-origin: 50% 50%;
color: #ffce66;
}
.course-assistant-arrow[data-muted="true"] {
opacity: 0.38;
}
.course-assistant-arrow svg {
transform: rotate(-45deg);
}
.course-assistant-course {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
column-gap: 8px;
}
.course-assistant-course > span {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.08em;
}
.course-assistant-course strong {
grid-row: 1 / span 2;
grid-column: 2;
font-variant-numeric: tabular-nums;
font-size: clamp(29px, 9vw, 42px);
line-height: 1;
white-space: nowrap;
}
.course-assistant-course small {
min-width: 0;
overflow: hidden;
line-height: 1.1;
color: #d9edeb;
font-size: 12px;
font-weight: 850;
}
.course-assistant-status,
.course-assistant-turn,
.course-assistant-safety {
margin: 0;
}
.course-assistant-status {
min-height: 30px;
gap: 6px;
border-radius: 8px;
padding: 6px 8px;
background: #dceee6;
color: #196f5c;
font-size: 12px;
font-weight: 850;
line-height: 1.3;
}
.course-assistant-panel[data-alert="true"] .course-assistant-status {
background: #ffe1dc;
color: #9d2c22;
}
.course-assistant-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 5px;
}
.course-assistant-metrics > span {
min-width: 0;
border-radius: 8px;
padding: 5px 7px;
display: grid;
gap: 2px;
background: #edf3f1;
}
.course-assistant-metrics small {
color: #607278;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.04em;
}
.course-assistant-metrics strong {
overflow: hidden;
text-overflow: ellipsis;
color: #16323a;
font-size: 14px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.course-assistant-progress {
height: 5px;
overflow: hidden;
border-radius: 99px;
background: #d5e1dd;
}
.course-assistant-progress > span {
display: block;
height: 100%;
border-radius: inherit;
background: #d89c28;
}
.course-assistant-turn {
border-radius: 8px;
padding: 6px 8px;
background: #fff1cc;
color: #805900;
font-size: 12px;
font-weight: 850;
}
.course-assistant-safety {
color: #607278;
font-size: 11px;
font-weight: 700;
line-height: 1.35;
}
.app-shell[data-guidance-active="true"] .route-panel-toggle {
top: calc(env(safe-area-inset-top) + 62px);
bottom: auto;
}
.app-shell[data-guidance-active="true"] .data-badge {
top: calc(env(safe-area-inset-top) + 266px);
bottom: auto;
}
@media (min-width: 720px) {
.course-assistant-panel {
left: auto;
right: 12px;
width: 390px;
margin: 0;
}
}
@media (max-height: 680px) {
.course-assistant-safety {
display: none;
}
.course-assistant-panel {
gap: 5px;
}
}
@media (prefers-reduced-motion: reduce) {
.course-assistant-arrow {
transition: none;
}
}
@@ -0,0 +1,178 @@
import { AlertTriangle, Navigation, Square } from "lucide-react";
import type { RouteGuidanceResult } from "@watermaps/shared";
import "./CourseAssistantPanel.css";
export type CourseAssistantPanelProps = {
guidance: RouteGuidanceResult | null;
gpsStatus: string;
headingDeg: number | null;
headingSource: "COG" | "HDG" | "--";
accuracyM: number | null;
fixStale: boolean;
onStop: () => void;
};
export function CourseAssistantPanel({
guidance,
gpsStatus,
headingDeg,
headingSource,
accuracyM,
fixStale,
onStop
}: CourseAssistantPanelProps) {
const waitingForGps = !guidance && !fixStale;
const suppressSteering =
!guidance || fixStale || guidance.status === "gps-unreliable" || guidance.status === "arrived";
const alertState = Boolean(fixStale || guidance?.status === "off-route" || guidance?.status === "gps-unreliable");
const correction = guidance?.courseCorrectionDeg ?? null;
const progress = guidance ? Math.round(guidance.progressRatio * 100) : 0;
const statusText = guidanceStatusText(guidance, fixStale, gpsStatus);
return (
<aside
className="course-assistant-panel"
aria-label="Kursassistent"
data-alert={alertState}
data-status={fixStale ? "stale-fix" : guidance?.status ?? "waiting-gps"}
>
<header className="course-assistant-header">
<span>
<Navigation size={17} aria-hidden="true" />
<strong>Kursassistent</strong>
</span>
<button type="button" onClick={onStop} aria-label="Kursassistent stoppen">
<Square size={14} aria-hidden="true" />
Stop
</button>
</header>
<div className="course-assistant-main">
<span
className="course-assistant-arrow"
data-muted={suppressSteering}
style={{ transform: `rotate(${suppressSteering || correction === null ? 0 : correction}deg)` }}
aria-hidden="true"
>
<Navigation size={32} />
</span>
<div className="course-assistant-course">
<span>SOLL ÜBER GRUND</span>
<strong>{suppressSteering ? "---" : formatCourse(guidance.desiredCourseDeg)}</strong>
<small>{suppressSteering ? "Keine verlässliche Steueranweisung" : correctionText(correction)}</small>
</div>
</div>
<p className="course-assistant-status" aria-live="polite">
{alertState && <AlertTriangle size={14} aria-hidden="true" />}
<span>{waitingForGps ? "GPS-Fix wird ermittelt …" : statusText}</span>
</p>
{guidance && (
<>
<div className="course-assistant-metrics">
<span>
<small>IST {headingSource}</small>
<strong>{headingDeg === null ? "---" : formatActualHeading(headingDeg, headingSource)}</strong>
</span>
<span>
<small>QUERABSTAND</small>
<strong>{formatCrossTrack(guidance)}</strong>
</span>
<span>
<small>REST</small>
<strong>{formatNauticalMiles(guidance.remainingRouteDistanceM)}</strong>
</span>
</div>
<div
className="course-assistant-progress"
role="progressbar"
aria-label="Routenfortschritt"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
aria-valuetext={`${progress} Prozent`}
>
<span style={{ width: `${progress}%` }} />
</div>
{guidance.nextTurn && guidance.status !== "arrived" && (
<p className="course-assistant-turn">
{turnLabel(guidance.nextTurn.direction)} in {formatDistance(guidance.nextTurn.distanceM)}
{` · danach ${formatCourse(guidance.nextTurn.outgoingCourseDeg)}`}
</p>
)}
</>
)}
<small className="course-assistant-safety">
Steuert das Boot nicht. Sollkurs über Grund; Ufer, Tonnen, Verkehr und amtliche Unterlagen haben Vorrang.
{accuracyM !== null ? ` GPS ±${Math.round(accuracyM)} m.` : ""}
</small>
</aside>
);
}
function guidanceStatusText(guidance: RouteGuidanceResult | null, fixStale: boolean, gpsStatus: string) {
if (fixStale) return "GPS-Fix ist veraltet Kursanweisung pausiert.";
if (!guidance) {
if (gpsStatus === "denied") return "GPS-Freigabe wurde abgelehnt.";
if (gpsStatus === "unavailable") return "GPS ist auf diesem Gerät nicht verfügbar.";
if (gpsStatus === "error") return "GPS-Position konnte nicht gelesen werden.";
return "GPS-Fix wird ermittelt …";
}
switch (guidance.status) {
case "arrived":
return "Ziel erreicht.";
case "gps-unreliable":
return "GPS zu ungenau Kursanweisung pausiert.";
case "off-route":
return `Route um ${Math.round(guidance.distanceToRouteM)} m verlassen nur im freien Fahrwasser zurückkehren.`;
case "approaching-turn":
return guidance.nextTurn
? `${turnLabel(guidance.nextTurn.direction)} in ${formatDistance(guidance.nextTurn.distanceM)}.`
: "Kursänderung voraus.";
default:
return "Auf Route Sollkurs wird mit jedem GPS-Fix angepasst.";
}
}
function correctionText(value: number | null) {
if (value === null || !Number.isFinite(value)) return "COG noch nicht verfügbar";
const rounded = Math.round(Math.abs(value));
if (rounded <= 4) return "Kurs halten";
return `${rounded}° nach ${value > 0 ? "Steuerbord" : "Backbord"}`;
}
function formatCrossTrack(guidance: RouteGuidanceResult) {
const distance = Math.round(guidance.distanceToRouteM);
if (distance <= 3 || guidance.crossTrackSide === "on-route") return "auf Linie";
return `${distance} m ${guidance.crossTrackSide === "port" ? "Backbord" : "Steuerbord"}`;
}
function turnLabel(direction: NonNullable<RouteGuidanceResult["nextTurn"]>["direction"]) {
if (direction === "port") return "Backbord-Kursänderung";
if (direction === "starboard") return "Steuerbord-Kursänderung";
return "Wenden";
}
function formatCourse(value: number) {
const normalized = Math.round(((value % 360) + 360) % 360);
return `${String(normalized === 360 ? 0 : normalized).padStart(3, "0")}°T`;
}
function formatActualHeading(value: number, source: CourseAssistantPanelProps["headingSource"]) {
const formatted = formatCourse(value);
return source === "COG" ? formatted : formatted.replace("°T", "°");
}
function formatNauticalMiles(meters: number) {
const remainingM = Math.max(0, meters);
if (remainingM < 185) return `${Math.round(remainingM)} m`;
return `${(remainingM / 1852).toFixed(remainingM < 18_520 ? 1 : 0)} sm`;
}
function formatDistance(meters: number) {
return meters < 1000 ? `${Math.max(0, Math.round(meters))} m` : `${(meters / 1000).toFixed(1)} km`;
}
+44
View File
@@ -0,0 +1,44 @@
import { Component, Suspense, type ErrorInfo, type ReactNode } from "react";
type LazyContentProps = {
children: ReactNode;
pending: ReactNode;
failed: ReactNode;
};
type LazyLoadErrorBoundaryProps = {
children: ReactNode;
fallback: ReactNode;
};
type LazyLoadErrorBoundaryState = {
failed: boolean;
};
export function LazyContent({ children, pending, failed }: LazyContentProps) {
return (
<LazyLoadErrorBoundary fallback={failed}>
<Suspense fallback={pending}>{children}</Suspense>
</LazyLoadErrorBoundary>
);
}
class LazyLoadErrorBoundary extends Component<
LazyLoadErrorBoundaryProps,
LazyLoadErrorBoundaryState
> {
state: LazyLoadErrorBoundaryState = { failed: false };
static getDerivedStateFromError(): LazyLoadErrorBoundaryState {
return { failed: true };
}
componentDidCatch(_error: unknown, _errorInfo: ErrorInfo) {
// The local fallback keeps the rest of the navigation UI usable. A reload
// can then pick up a newer PWA chunk after a deployment.
}
render() {
return this.state.failed ? this.props.fallback : this.props.children;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,305 @@
import { useEffect, useRef } from "react";
import { Globe2, Mail, Phone, X } from "lucide-react";
import type { Coordinate } from "@watermaps/shared";
export type MarineFeatureDetails = {
id: string;
layer: "locks" | "harbours";
name: string;
typeLabel: "Schleuse" | "Hafen";
coordinate: Coordinate;
phone: string | null;
website: string | null;
email: string | null;
vhf: string | null;
openingHours: string | null;
operator: string | null;
address: string | null;
source: string | null;
sourceUrl?: string | null;
updatedAt: string | null;
memberCount?: number | null;
};
type MarineFeatureInfoProps = {
feature: MarineFeatureDetails;
onClose: () => void;
};
export function MarineFeatureInfo({ feature, onClose }: MarineFeatureInfoProps) {
const dialogRef = useRef<HTMLElement | null>(null);
const closeButtonRef = useRef<HTMLButtonElement | null>(null);
const previouslyFocusedElementRef = useRef<HTMLElement | null>(null);
const onCloseRef = useRef(onClose);
const phone = presentValue(feature.phone);
const website = presentValue(feature.website);
const email = presentValue(feature.email);
const vhf = presentValue(feature.vhf);
const openingHours = presentValue(feature.openingHours);
const operator = presentValue(feature.operator);
const address = presentValue(feature.address);
const source = presentValue(feature.source);
const updatedAt = presentValue(feature.updatedAt);
const phoneHref = phone ? telephoneHref(phone) : null;
const websiteHref = website ? websiteUrl(website) : null;
const sourceHref = feature.sourceUrl ? websiteUrl(feature.sourceUrl) : null;
const hasContactActions = Boolean(phoneHref || websiteHref || email);
const hasOperatingDetails = Boolean(vhf || openingHours || operator || address);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
previouslyFocusedElementRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
closeButtonRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onCloseRef.current();
return;
}
if (event.key !== "Tab" || !dialogRef.current) {
return;
}
const focusableElements = getFocusableElements(dialogRef.current);
if (focusableElements.length === 0) {
event.preventDefault();
dialogRef.current.focus();
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (!firstElement || !lastElement) {
return;
}
const activeElement = document.activeElement;
const focusIsOutsideDialog =
!(activeElement instanceof Node) || !dialogRef.current.contains(activeElement);
if (event.shiftKey && (activeElement === firstElement || focusIsOutsideDialog)) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && (activeElement === lastElement || focusIsOutsideDialog)) {
event.preventDefault();
firstElement.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
const previouslyFocusedElement = previouslyFocusedElementRef.current;
if (previouslyFocusedElement?.isConnected) {
previouslyFocusedElement.focus();
}
previouslyFocusedElementRef.current = null;
};
}, []);
return (
<div
className="marine-feature-info-layer"
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onPointerUp={(event) => event.stopPropagation()}
onTouchStart={(event) => event.stopPropagation()}
onTouchEnd={(event) => event.stopPropagation()}
>
<button
className="marine-feature-info-backdrop"
type="button"
aria-label="Detailansicht schließen"
tabIndex={-1}
onClick={onClose}
/>
<section
ref={dialogRef}
className="marine-feature-info"
role="dialog"
aria-modal="true"
aria-labelledby="marine-feature-info-title"
aria-describedby="marine-feature-info-contact-note"
tabIndex={-1}
>
<header className="marine-feature-info-header">
<div>
<span className="marine-feature-type">{feature.typeLabel}</span>
<h2 id="marine-feature-info-title">{feature.name}</h2>
</div>
<button
ref={closeButtonRef}
className="marine-feature-info-close"
type="button"
aria-label="Informationen schließen"
title="Informationen schließen"
onClick={onClose}
>
<X size={20} aria-hidden="true" />
</button>
</header>
{hasContactActions ? (
<div className="marine-feature-actions" role="group" aria-label="Kontaktmöglichkeiten">
{phoneHref && phone && (
<a
className="marine-feature-action"
data-action="phone"
href={phoneHref}
aria-label={`${feature.name} anrufen: ${phone}`}
>
<Phone size={20} aria-hidden="true" />
<span>
<strong>Anrufen</strong>
<small>{phone}</small>
</span>
</a>
)}
{websiteHref && (
<a
className="marine-feature-action"
data-action="website"
href={websiteHref}
target="_blank"
rel="noreferrer"
aria-label={`Website von ${feature.name} in einem neuen Tab öffnen`}
>
<Globe2 size={20} aria-hidden="true" />
<span>
<strong>Website</strong>
<small>Öffnen</small>
</span>
</a>
)}
{email && (
<a
className="marine-feature-action"
data-action="email"
href={`mailto:${email}`}
aria-label={`E-Mail an ${feature.name}: ${email}`}
>
<Mail size={20} aria-hidden="true" />
<span>
<strong>E-Mail</strong>
<small>{email}</small>
</span>
</a>
)}
</div>
) : (
<p className="marine-feature-contact-empty">Keine direkten Kontaktdaten hinterlegt.</p>
)}
{hasOperatingDetails && (
<dl className="marine-feature-details marine-feature-operating-details">
{vhf && <DetailRow label="UKW / VHF">{vhf}</DetailRow>}
{openingHours && <DetailRow label="Öffnungszeiten">{openingHours}</DetailRow>}
{operator && <DetailRow label="Betreiber">{operator}</DetailRow>}
{address && <DetailRow label="Adresse">{address}</DetailRow>}
</dl>
)}
<details className="marine-feature-metadata">
<summary>Daten &amp; Quelle</summary>
<dl className="marine-feature-details">
<DetailRow label="Koordinaten">{formatCoordinate(feature.coordinate)}</DetailRow>
{(source || sourceHref) && (
<DetailRow label="Quelle">
{sourceHref ? (
<a href={sourceHref} target="_blank" rel="noreferrer">
{source ?? "Quelldatensatz öffnen"}
</a>
) : (
source
)}
</DetailRow>
)}
{updatedAt && <DetailRow label="Datenstand">{formatTimestamp(updatedAt)}</DetailRow>}
{(feature.memberCount ?? 1) > 1 && (
<DetailRow label="Zusammengeführt">{feature.memberCount} Kartenobjekte</DetailRow>
)}
</dl>
</details>
<p id="marine-feature-info-contact-note" className="marine-feature-contact-note">
Kontaktdaten können unvollständig oder veraltet sein. Vor der Fahrt bei der zuständigen Stelle prüfen.
</p>
</section>
</div>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function formatCoordinate(coordinate: Coordinate) {
return `${coordinate.lat.toFixed(5)}°, ${coordinate.lon.toFixed(5)}°`;
}
function presentValue(value: string | null | undefined) {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function getFocusableElements(container: HTMLElement) {
const selector = [
"a[href]",
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"summary",
'[tabindex]:not([tabindex="-1"])',
].join(",");
return Array.from(container.querySelectorAll<HTMLElement>(selector)).filter((element) => {
if (element.getAttribute("aria-hidden") === "true" || element.closest("[hidden]")) {
return false;
}
const closedDetails = element.closest("details:not([open])");
return !closedDetails || element.tagName === "SUMMARY";
});
}
function telephoneHref(value: string) {
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
return compact ? `tel:${compact}` : null;
}
function websiteUrl(value: string) {
const trimmed = value.trim().split(/[;,]/)[0]?.trim();
if (!trimmed) {
return null;
}
try {
const url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
}
}
function formatTimestamp(value: string | null) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })
: value;
}
@@ -0,0 +1,154 @@
import { AlertTriangle, ExternalLink, Gauge, Radio, ShipWheel } from "lucide-react";
import type { NavigationDataSnapshot, NavigationSourceStatus, WaterLevel } from "@watermaps/shared";
type NavigationDataPanelProps = {
snapshot: NavigationDataSnapshot | null;
loading: boolean;
error: string | null;
};
export function NavigationDataPanel({ snapshot, loading, error }: NavigationDataPanelProps) {
if (!loading && !snapshot && !error) {
return null;
}
return (
<section className="navigation-data-panel" aria-label="Live-Fahrtdaten">
<header>
<Radio size={15} aria-hidden="true" />
<strong>Live-Fahrtdaten</strong>
{snapshot && <span>{formatClock(snapshot.generatedAt)}</span>}
</header>
{loading && <p>WSV-Daten werden geladen</p>}
{error && (
<p className="navigation-data-warning">
<AlertTriangle size={14} aria-hidden="true" /> {error}
</p>
)}
{snapshot && (
<>
{snapshot.waterLevels.length > 0 ? (
<div className="water-level-list">
{snapshot.waterLevels.slice(0, 8).map((level) => (
<WaterLevelRow key={level.stationId} level={level} />
))}
</div>
) : (
<p>Keine passenden PEGELONLINE-Messstellen gefunden.</p>
)}
{snapshot.lockOperations.map((lock) => (
<div className="navigation-operation" key={lock.id} data-state={lock.operatingState}>
<ShipWheel size={14} aria-hidden="true" />
<span>
<strong>{lock.name}</strong>
{lock.regularHours ?? lock.note ?? "Betriebsinformation ohne Zeitangabe"}
</span>
</div>
))}
{snapshot.notices.map((notice) => (
<a className="navigation-notice" key={notice.id} href={notice.sourceUrl} target="_blank" rel="noreferrer">
<AlertTriangle size={14} aria-hidden="true" />
<span>
<strong>{notice.title}</strong>
{notice.location ?? notice.waterway ?? "ELWIS-Nachricht"}
</span>
</a>
))}
<div className="navigation-source-list">
{snapshot.sources.map((source) => (
<SourceLink key={`${source.kind}:${source.id}`} source={source} />
))}
</div>
<p className="navigation-data-disclaimer">
Live-Daten können verzögert oder unvollständig sein. Schleusenabweichungen und Sperrungen vor Abfahrt
zusätzlich in ELWIS prüfen.
</p>
</>
)}
</section>
);
}
function WaterLevelRow({ level }: { level: WaterLevel }) {
return (
<a href={level.sourceUrl} target="_blank" rel="noreferrer" data-state={level.stateMnwMhw}>
<Gauge size={14} aria-hidden="true" />
<span>
<strong>{level.stationName}</strong>
{level.waterway} {level.waterwayKm !== null ? `km ${formatNumber(level.waterwayKm)}` : ""}
</span>
<span>
<strong>
{formatNumber(level.value)} {level.unit}
</strong>
{waterLevelLabel(level.stateMnwMhw)} · {formatDateTime(level.measuredAt)}
</span>
</a>
);
}
function SourceLink({ source }: { source: NavigationSourceStatus }) {
return (
<a href={source.sourceUrl} target="_blank" rel="noreferrer" data-state={source.state}>
<span>{source.label}</span>
<span>{sourceStateLabel(source.state)}</span>
<ExternalLink size={12} aria-hidden="true" />
{source.warning && <small>{source.warning}</small>}
</a>
);
}
function sourceStateLabel(state: NavigationSourceStatus["state"]) {
switch (state) {
case "live":
return "Live";
case "cached":
return "Cache";
case "stale":
return "Veraltet";
case "unavailable":
return "Nicht erreichbar";
default:
return "Offiziell prüfen";
}
}
function waterLevelLabel(state: WaterLevel["stateMnwMhw"]) {
switch (state) {
case "low":
return "niedrig";
case "normal":
return "normal";
case "high":
return "hoch";
case "out-dated":
return "veraltet";
case "commented":
return "kommentiert";
default:
return "Status offen";
}
}
function formatNumber(value: number) {
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(value);
}
function formatDateTime(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
: "Zeit offen";
}
function formatClock(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
: "";
}
@@ -0,0 +1,134 @@
import { Anchor, Bell, CloudSun, Route as RouteIcon, type LucideIcon } from "lucide-react";
export const NAVIGATION_TOOL_ORDER = [
"anchor",
"conditions",
"upcoming",
"route"
] as const;
export type NavigationToolId = (typeof NAVIGATION_TOOL_ORDER)[number];
export type ActiveTool = NavigationToolId | null;
export type NavigationToolStatus = "idle" | "active" | "caution" | "alarm" | "stale";
type NavigationToolDefinition = {
id: NavigationToolId;
label: string;
Icon: LucideIcon;
};
const NAVIGATION_TOOLS: readonly NavigationToolDefinition[] = [
{ id: "anchor", label: "Ankerwache", Icon: Anchor },
{ id: "conditions", label: "Wetter und Tide", Icon: CloudSun },
{ id: "upcoming", label: "Als Nächstes", Icon: Bell },
{ id: "route", label: "Route", Icon: RouteIcon }
];
export type NavigationToolRailProps = {
activeTool: ActiveTool;
onSelect: (tool: NavigationToolId) => void;
statuses?: Partial<Record<NavigationToolId, NavigationToolStatus>>;
badges?: Partial<Record<NavigationToolId, number | string | null>>;
disabledTools?: Partial<Record<NavigationToolId, boolean>>;
workspaceId?: string;
className?: string;
ariaLabel?: string;
};
export function NavigationToolRail({
activeTool,
onSelect,
statuses = {},
badges = {},
disabledTools = {},
workspaceId = "navigation-workspace",
className,
ariaLabel = "Kartenwerkzeuge"
}: NavigationToolRailProps) {
return (
<nav className={classNames("navigation-tool-rail", className)} aria-label={ariaLabel}>
{NAVIGATION_TOOLS.map(({ id, label, Icon }) => {
const active = activeTool === id;
const status = statuses[id] ?? "idle";
const badge = formatBadge(badges[id]);
const accessibleLabel = toolAriaLabel(label, status, badge, active);
return (
<button
key={id}
className="navigation-tool-rail-button"
type="button"
disabled={Boolean(disabledTools[id])}
data-tool={id}
data-status={status}
data-active={active}
aria-controls={workspaceId}
aria-expanded={active}
aria-pressed={active}
aria-label={accessibleLabel}
title={accessibleLabel}
onClick={() => onSelect(id)}
>
<Icon size={21} aria-hidden="true" />
<span className="navigation-tool-rail-label" aria-hidden="true">
{label}
</span>
{badge && (
<span className="navigation-tool-rail-badge" aria-hidden="true">
{badge}
</span>
)}
</button>
);
})}
</nav>
);
}
export function navigationToolLabel(tool: NavigationToolId) {
return NAVIGATION_TOOLS.find((definition) => definition.id === tool)?.label ?? tool;
}
function toolAriaLabel(
label: string,
status: NavigationToolStatus,
badge: string | null,
active: boolean
) {
const parts = [label, statusLabel(status)];
if (badge) {
parts.push(`${badge} Hinweise`);
}
parts.push(active ? "geöffnet" : "öffnen");
return parts.join(", ");
}
function statusLabel(status: NavigationToolStatus) {
switch (status) {
case "active":
return "aktiv";
case "caution":
return "Warnung";
case "alarm":
return "Alarm";
case "stale":
return "Daten veraltet";
default:
return "bereit";
}
}
function formatBadge(value: number | string | null | undefined) {
if (typeof value === "number") {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
return value > 99 ? "99+" : String(Math.floor(value));
}
const normalized = value?.trim();
return normalized || null;
}
function classNames(...values: Array<string | null | undefined | false>) {
return values.filter(Boolean).join(" ");
}
@@ -0,0 +1,188 @@
import {
ArrowDown,
ArrowLeft,
ArrowUp,
X
} from "lucide-react";
import type { ReactNode } from "react";
import {
navigationToolLabel,
type ActiveTool,
type NavigationToolStatus
} from "./NavigationToolRail";
export type NavigationSheetState = "compact" | "half" | "full";
export type NavigationWorkspacePresentation =
| "responsive"
| "bottom-sheet"
| "overlay-drawer"
| "docked-drawer";
export type NavigationWorkspaceProps = {
activeTool: ActiveTool;
children: ReactNode;
onClose: () => void;
id?: string;
title?: string;
summary?: ReactNode;
leading?: ReactNode;
footer?: ReactNode;
status?: NavigationToolStatus;
sheetState?: NavigationSheetState;
onSheetStateChange?: (state: NavigationSheetState) => void;
presentation?: NavigationWorkspacePresentation;
onBack?: () => void;
backLabel?: string;
closeLabel?: string;
busy?: boolean;
className?: string;
};
export function NavigationWorkspace({
activeTool,
children,
onClose,
id = "navigation-workspace",
title,
summary,
leading,
footer,
status = "idle",
sheetState = "half",
onSheetStateChange,
presentation = "responsive",
onBack,
backLabel = "Zurück",
closeLabel,
busy = false,
className
}: NavigationWorkspaceProps) {
if (!activeTool) {
return null;
}
const toolLabel = navigationToolLabel(activeTool);
const heading = title ?? toolLabel;
const headingId = `${id}-title`;
const bodyId = `${id}-body`;
const sizeAction = workspaceSizeAction(sheetState);
const compact = sheetState === "compact";
return (
<aside
id={id}
className={classNames(
"navigation-workspace",
`navigation-workspace--${presentation}`,
className
)}
aria-labelledby={headingId}
aria-busy={busy}
data-tool={activeTool}
data-status={status}
data-presentation={presentation}
data-sheet-state={sheetState}
>
<span className="navigation-workspace-grip" aria-hidden="true">
</span>
<header className="navigation-workspace-header">
{onBack && (
<button
className="navigation-workspace-back"
type="button"
onClick={onBack}
aria-label={backLabel}
title={backLabel}
>
<ArrowLeft size={19} aria-hidden="true" />
</button>
)}
{leading && (
<span className="navigation-workspace-leading" aria-hidden="true">
{leading}
</span>
)}
<div className="navigation-workspace-heading">
<strong id={headingId}>{heading}</strong>
{summary && <div className="navigation-workspace-summary">{summary}</div>}
</div>
{onSheetStateChange && (
<button
className="navigation-workspace-size-action"
type="button"
onClick={() => onSheetStateChange(sizeAction.nextState)}
aria-controls={bodyId}
aria-label={sizeAction.label}
title={sizeAction.label}
>
{sizeAction.direction === "up" ? (
<ArrowUp size={19} aria-hidden="true" />
) : (
<ArrowDown size={19} aria-hidden="true" />
)}
</button>
)}
<button
className="navigation-workspace-close"
type="button"
onClick={onClose}
aria-label={closeLabel ?? `${toolLabel} schließen`}
title={closeLabel ?? `${toolLabel} schließen`}
>
<X size={19} aria-hidden="true" />
</button>
</header>
<div
id={bodyId}
className="navigation-workspace-body"
hidden={compact}
data-collapsed={compact}
>
{children}
</div>
{footer && (
<footer className="navigation-workspace-footer" hidden={compact}>
{footer}
</footer>
)}
</aside>
);
}
function workspaceSizeAction(state: NavigationSheetState): {
nextState: NavigationSheetState;
label: string;
direction: "up" | "down";
} {
if (state === "compact") {
return {
nextState: "half",
label: "Arbeitsbereich auf halbe Höhe vergrößern",
direction: "up"
};
}
if (state === "half") {
return {
nextState: "full",
label: "Arbeitsbereich auf volle Höhe vergrößern",
direction: "up"
};
}
return {
nextState: "compact",
label: "Arbeitsbereich auf kompakte Höhe verkleinern",
direction: "down"
};
}
function classNames(...values: Array<string | null | undefined | false>) {
return values.filter(Boolean).join(" ");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
import { AlertTriangle, Waves } from "lucide-react";
import type { TideEvent, TideSummary } from "@watermaps/shared";
export type RouteTidePlan = {
start: TideSummary | null;
middle?: TideSummary | null;
destination: TideSummary | null;
};
type RouteTidePanelProps = {
plan: RouteTidePlan | null;
loading: boolean;
error: string | null;
};
export function RouteTidePanel({ plan, loading, error }: RouteTidePanelProps) {
if (!loading && !plan && !error) {
return null;
}
return (
<section className="route-tide-panel" aria-label="Tidenplanung">
<header>
<Waves size={15} aria-hidden="true" />
<strong>Tide zur Fahrtzeit</strong>
</header>
{loading && <p>Tidenfenster werden geladen.</p>}
{error && (
<p className="route-tide-warning">
<AlertTriangle size={14} aria-hidden="true" /> {error}
</p>
)}
{plan && (
<div className="route-tide-locations">
<TideLocation label="Start" summary={plan.start} />
<TideLocation label="Mitte" summary={plan.middle ?? null} />
<TideLocation label="Ziel" summary={plan.destination} />
</div>
)}
<small>Stationsabstand und Bezugsnull beachten; Wasserstände ersetzen keine amtliche Tiefenprüfung.</small>
</section>
);
}
function TideLocation({ label, summary }: { label: string; summary: TideSummary | null }) {
return (
<article>
<strong>{label}</strong>
{summary ? (
<>
<span>
{summary.station} · {summary.distanceKm.toLocaleString("de-DE", { maximumFractionDigits: 1 })} km
</span>
<span>{formatEvent("HW", summary.nextHigh)}</span>
<span>{formatEvent("NW", summary.nextLow)}</span>
</>
) : (
<span>Keine passende Vorhersage</span>
)}
</article>
);
}
function formatEvent(label: string, event: TideEvent | null) {
if (!event) {
return `${label} offen`;
}
const date = new Date(event.time);
const time = Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", { weekday: "short", hour: "2-digit", minute: "2-digit" })
: "Zeit offen";
const height = event.heightM !== null ? ` · ${event.heightM.toFixed(2)} m` : "";
return `${label} ${time}${height}`;
}
+209
View File
@@ -0,0 +1,209 @@
import { Activity, AlertTriangle, Anchor, Navigation2, Waves, Wind } from "lucide-react";
import type { ReactNode } from "react";
import type { MarineForecast, RouteGuidanceResult, TideSummary } from "@watermaps/shared";
import type { GpsState } from "../hooks/useGeolocation";
type StatusBarProps = {
gps: GpsState;
forecast: MarineForecast | null;
tide: TideSummary | null;
routeWarningCount: number;
mode?: "planning" | "route" | "guidance" | "anchor";
guidance?: RouteGuidanceResult | null;
anchor?: {
distanceFromAnchorM: number | null;
alarmRadiusM: number;
alarm: boolean;
maximumTideRiseM: number | null;
} | null;
};
export function StatusBar({
gps,
forecast,
tide,
routeWarningCount,
mode,
guidance = null,
anchor = null
}: StatusBarProps) {
const resolvedMode =
mode ??
(routeWarningCount > 0
? "route"
: gps.status === "requesting" || gps.status === "tracking"
? "guidance"
: "planning");
const gpsValue = gps.position
? gps.accuracyM === null
? "Position aktiv"
: `±${gps.accuracyM} m`
: gpsStatusLabel(gps.status);
const speedValue = gps.speedKn === null ? "-- kn" : `${gps.speedKn.toFixed(1)} kn`;
const tideValue = tide?.nextHigh
? `HW ${new Date(tide.nextHigh.time).toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit"
})}`
: "Keine Daten";
const conditionsValue = formatConditions(forecast);
const items =
resolvedMode === "anchor"
? [
{
icon: anchor?.alarm
? <AlertTriangle size={15} aria-hidden="true" />
: <Anchor size={15} aria-hidden="true" />,
label: "Anker",
value: anchor?.alarm
? "Alarm"
: anchor?.distanceFromAnchorM === null || anchor?.distanceFromAnchorM === undefined
? `Radius ${Math.round(anchor?.alarmRadiusM ?? 0)} m`
: `${Math.round(anchor.distanceFromAnchorM)} / ${Math.round(anchor.alarmRadiusM)} m`
},
{
icon: <Navigation2 size={15} aria-hidden="true" />,
label: "GPS",
value: gpsValue
},
{
icon: <Waves size={15} aria-hidden="true" />,
label: "Tidenanstieg",
value:
anchor?.maximumTideRiseM === null || anchor?.maximumTideRiseM === undefined
? "Keine Daten"
: `+${anchor.maximumTideRiseM.toFixed(1)} m`
}
]
: resolvedMode === "guidance"
? [
{
icon: <Navigation2 size={15} aria-hidden="true" />,
label: "Sollkurs",
value: guidance ? formatCourse(guidance.desiredCourseDeg) : "Warte auf GPS"
},
{
icon: <Activity size={15} aria-hidden="true" />,
label: "Abweichung",
value: guidance ? formatCrossTrack(guidance) : "--"
},
routeWarningCount > 0
? {
icon: <AlertTriangle size={15} aria-hidden="true" />,
label: "Warnungen",
value: `${routeWarningCount} offen`
}
: {
icon: <Activity size={15} aria-hidden="true" />,
label: "SOG",
value: speedValue
}
]
: resolvedMode === "route"
? [
{
icon:
routeWarningCount > 0
? <AlertTriangle size={15} aria-hidden="true" />
: <Navigation2 size={15} aria-hidden="true" />,
label: "Warnungen",
value: routeWarningCount > 0 ? `${routeWarningCount} offen` : "Keine offenen"
},
{
icon: <Navigation2 size={15} aria-hidden="true" />,
label: "GPS",
value: gpsValue
},
gps.position
? {
icon: <Activity size={15} aria-hidden="true" />,
label: "SOG",
value: speedValue
}
: {
icon: <Waves size={15} aria-hidden="true" />,
label: "Tide",
value: tideValue
}
]
: [
{
icon: <Navigation2 size={15} aria-hidden="true" />,
label: "GPS",
value: gpsValue
},
{
icon: <Wind size={15} aria-hidden="true" />,
label: "Wetter",
value: conditionsValue
},
{
icon: <Waves size={15} aria-hidden="true" />,
label: "Tide",
value: tideValue
}
];
return (
<footer
className="status-bar"
aria-label="Navigationsstatus"
style={{ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}
>
{items.map((item) => (
<StatusItem key={item.label} icon={item.icon} label={item.label} value={item.value} />
))}
</footer>
);
}
function StatusItem({ icon, label, value }: { icon?: ReactNode; label: string; value: string }) {
return (
<span className="status-item">
{icon}
<span className="status-label">{label}</span>
<strong>{value}</strong>
</span>
);
}
function gpsStatusLabel(status: GpsState["status"]): string {
switch (status) {
case "idle":
return "Aus";
case "requesting":
return "Position wird gesucht";
case "tracking":
return "Position aktiv";
case "denied":
return "Zugriff verweigert";
case "unavailable":
return "Nicht verfügbar";
case "error":
return "Fehler";
}
}
function formatConditions(forecast: MarineForecast | null): string {
if (!forecast || (forecast.windSpeed === null && forecast.waveHeightM === null)) {
return "Keine Daten";
}
const wind = forecast.windSpeed === null ? null : `${Math.round(forecast.windSpeed)} kn`;
const wave = forecast.waveHeightM === null ? null : `${forecast.waveHeightM.toFixed(1)} m`;
return [wind, wave].filter((value): value is string => value !== null).join(" · ");
}
function formatCourse(value: number) {
const normalized = Math.round(((value % 360) + 360) % 360) % 360;
return `${String(normalized).padStart(3, "0")}°T`;
}
function formatCrossTrack(guidance: RouteGuidanceResult) {
const distanceM = Math.round(guidance.distanceToRouteM);
if (distanceM <= 3 || guidance.crossTrackSide === "on-route") {
return "Auf Linie";
}
return `${distanceM} m ${guidance.crossTrackSide === "port" ? "Bb" : "Stb"}`;
}
@@ -0,0 +1,435 @@
/* Upcoming route events ---------------------------------------------------- */
.upcoming-events-panel {
display: grid;
gap: 10px;
}
.upcoming-events-header {
min-height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.upcoming-events-header > span {
display: flex;
align-items: center;
gap: 7px;
color: #0f4c5c;
}
.upcoming-events-header small {
color: #607278;
font-size: 10px;
font-weight: 800;
}
.upcoming-events-message {
min-height: 40px;
margin: 0;
border-radius: 9px;
padding: 7px 9px;
gap: 7px;
background: #fff1cc;
color: #805900;
font-size: 11px;
font-weight: 800;
}
.upcoming-events-filters {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 5px;
}
.upcoming-events-filters button {
min-width: 0;
min-height: 44px;
border: 1px solid rgba(15, 76, 92, 0.12);
border-radius: 9px;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
background: #edf3f1;
color: #526a72;
font-size: 10px;
font-weight: 850;
}
.upcoming-events-filters button[data-active="true"] {
border-color: #0f4c5c;
background: #dceee6;
color: #196f5c;
}
.upcoming-events-filters button small {
min-width: 18px;
height: 18px;
border-radius: 999px;
display: grid;
place-items: center;
background: rgba(15, 76, 92, 0.1);
font-size: 9px;
}
.upcoming-event-hero {
border: 1px solid rgba(15, 76, 92, 0.15);
border-radius: 12px;
padding: 10px;
display: grid;
gap: 7px;
background: #dceee6;
}
.upcoming-event-hero[data-status="caution"] {
border-color: #d89c28;
background: #fff1cc;
}
.upcoming-event-hero[data-status="alarm"] {
border-color: #c44a30;
background: #ffe1dc;
}
.upcoming-event-hero > small {
color: #526a72;
font-size: 10px;
font-weight: 900;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.upcoming-event-hero-main {
min-width: 0;
min-height: 74px;
border-radius: 9px;
padding: 8px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
grid-template-rows: auto auto auto;
align-items: center;
gap: 2px 8px;
background: rgba(255, 255, 255, 0.72);
color: #17343c;
text-align: left;
}
.upcoming-event-hero-main > svg {
grid-row: 1 / span 3;
}
.upcoming-event-hero-main > span:nth-of-type(1) {
min-width: 0;
display: grid;
gap: 1px;
}
.upcoming-event-hero-main > span:nth-of-type(1) strong {
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.upcoming-event-hero-main > span:nth-of-type(1) span,
.upcoming-event-hero-eta,
.upcoming-event-hero-fact {
color: #607278;
font-size: 10px;
font-weight: 750;
}
.upcoming-event-hero-distance {
color: #0f4c5c;
font-size: 15px;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.upcoming-event-hero-eta {
grid-column: 3;
}
.upcoming-event-hero-fact {
grid-column: 2 / -1;
overflow-wrap: anywhere;
}
.upcoming-events-list-section {
display: grid;
gap: 6px;
}
.upcoming-events-list-section h3 {
margin: 0;
color: #526a72;
font-size: 11px;
}
.upcoming-events-list {
margin: 0;
padding: 0;
display: grid;
gap: 5px;
list-style: none;
}
.upcoming-event-row {
width: 100%;
min-height: 60px;
border: 1px solid rgba(15, 76, 92, 0.1);
border-radius: 10px;
padding: 7px 8px;
display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
background: #ffffff;
color: #17343c;
text-align: left;
}
.upcoming-event-row[data-status="caution"] {
border-color: #d89c28;
background: #fff9e8;
}
.upcoming-event-row[data-status="alarm"] {
border-color: #c44a30;
background: #fff0ed;
}
.upcoming-event-row-icon {
width: 36px;
height: 36px;
border-radius: 9px;
display: grid;
place-items: center;
background: #dceee6;
color: #196f5c;
}
.upcoming-event-row-content,
.upcoming-event-row-progress {
min-width: 0;
display: grid;
gap: 2px;
}
.upcoming-event-row-content strong {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.upcoming-event-row-content small,
.upcoming-event-row-progress small {
color: #607278;
font-size: 9px;
font-weight: 700;
}
.upcoming-event-row-progress {
justify-items: end;
font-variant-numeric: tabular-nums;
}
.upcoming-event-row-progress strong {
color: #0f4c5c;
font-size: 12px;
}
.upcoming-event-contact-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 6px;
}
.upcoming-event-contact-actions a,
.upcoming-event-map-action {
min-height: 44px;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
background: #0f4c5c;
color: #ffffff;
font-size: 12px;
font-weight: 850;
text-decoration: none;
}
.upcoming-event-contact-actions[data-compact="true"] a {
background: rgba(15, 76, 92, 0.9);
}
.upcoming-event-detail-header {
min-height: 50px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
align-items: center;
gap: 8px;
}
.upcoming-event-detail-header > button {
width: 44px;
height: 44px;
border-radius: 9px;
display: grid;
place-items: center;
background: #e2ece9;
color: #23434c;
}
.upcoming-event-detail-header > span,
.upcoming-event-detail-header > span > span {
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.upcoming-event-detail-header > span > span {
display: grid;
gap: 1px;
}
.upcoming-event-detail-header small {
color: #607278;
font-size: 10px;
font-weight: 850;
}
.upcoming-event-detail-header strong {
overflow: hidden;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.upcoming-event-detail-hero {
border-radius: 11px;
padding: 10px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
background: #dceee6;
}
.upcoming-event-detail[data-status="caution"] .upcoming-event-detail-hero {
background: #fff1cc;
}
.upcoming-event-detail[data-status="alarm"] .upcoming-event-detail-hero {
background: #ffe1dc;
}
.upcoming-event-detail-hero > span {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 2px 6px;
color: #526a72;
font-size: 10px;
}
.upcoming-event-detail-hero > span > svg {
grid-row: 1 / span 2;
}
.upcoming-event-detail-hero strong {
color: #17343c;
font-size: 15px;
}
.upcoming-event-detail-facts {
margin: 0;
border: 1px solid rgba(15, 76, 92, 0.1);
border-radius: 10px;
overflow: hidden;
}
.upcoming-event-detail-facts > div {
min-height: 44px;
border-bottom: 1px solid rgba(15, 76, 92, 0.08);
padding: 7px 9px;
display: grid;
grid-template-columns: minmax(100px, 0.75fr) minmax(0, 1.25fr);
align-items: center;
gap: 8px;
}
.upcoming-event-detail-facts > div:last-child {
border-bottom: 0;
}
.upcoming-event-detail-facts dt {
color: #607278;
font-size: 10px;
font-weight: 850;
}
.upcoming-event-detail-facts dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
color: #17343c;
font-size: 12px;
font-weight: 750;
}
.upcoming-event-radio-value {
display: inline-flex;
align-items: center;
gap: 5px;
}
.upcoming-event-map-action {
width: 100%;
background: #e2ece9;
color: #23434c;
}
.upcoming-events-state {
min-height: 210px;
place-content: center;
justify-items: center;
color: #526a72;
text-align: center;
}
.upcoming-events-state > svg {
color: #0f4c5c;
}
.upcoming-events-state > strong {
color: #17343c;
font-size: 15px;
}
.upcoming-events-state > p,
.upcoming-events-empty-filter {
max-width: 32rem;
margin: 0;
color: #607278;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
}
.upcoming-events-empty-filter {
min-height: 96px;
border: 1px dashed #b9cbc7;
border-radius: 10px;
padding: 16px;
display: grid;
place-content: center;
text-align: center;
}
@@ -0,0 +1,759 @@
import {
AlertTriangle,
ArrowLeft,
CalendarClock,
Clock3,
Globe2,
Landmark,
LoaderCircle,
LockKeyhole,
Mail,
MapPin,
Phone,
Radio,
Route as RouteIcon,
Ship,
type LucideIcon
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
type RouteEventKind,
type UpcomingRouteEvent
} from "../routeEvents";
import "./UpcomingEventsPanel.css";
type EventFilter = "all" | RouteEventKind;
type EventFilterDefinition = {
id: EventFilter;
label: string;
};
const EVENT_FILTERS: readonly EventFilterDefinition[] = [
{ id: "all", label: "Alle" },
{ id: "harbour", label: "Häfen" },
{ id: "lock", label: "Schleusen" },
{ id: "bridge", label: "Brücken" }
];
const EVENT_KIND_LABELS: Record<RouteEventKind, string> = {
harbour: "Hafen",
lock: "Schleuse",
bridge: "Brücke"
};
const EVENT_KIND_ICONS: Record<RouteEventKind, LucideIcon> = {
harbour: Ship,
lock: LockKeyhole,
bridge: Landmark
};
export type UpcomingEventsPanelProps = {
events: readonly UpcomingRouteEvent[];
hasRoute: boolean;
loading?: boolean;
error?: string | null;
onShowOnMap?: (event: UpcomingRouteEvent) => void;
className?: string;
};
export function UpcomingEventsPanel({
events,
hasRoute,
loading = false,
error = null,
onShowOnMap,
className
}: UpcomingEventsPanelProps) {
const [filter, setFilter] = useState<EventFilter>("all");
const [selectedEventKey, setSelectedEventKey] = useState<string | null>(null);
const orderedEvents = useMemo(
() =>
events
.map((event, index) => ({ event, index }))
.sort(
(left, right) =>
finiteDistance(left.event.remainingNm) - finiteDistance(right.event.remainingNm) ||
left.index - right.index
)
.map(({ event }) => event),
[events]
);
const selectedEvent =
orderedEvents.find((event) => routeEventKey(event) === selectedEventKey) ?? null;
const filteredEvents = useMemo(
() =>
filter === "all"
? orderedEvents
: orderedEvents.filter((event) => event.kind === filter),
[filter, orderedEvents]
);
const counts = useMemo(
() => ({
all: orderedEvents.length,
harbour: orderedEvents.filter((event) => event.kind === "harbour").length,
lock: orderedEvents.filter((event) => event.kind === "lock").length,
bridge: orderedEvents.filter((event) => event.kind === "bridge").length
}),
[orderedEvents]
);
useEffect(() => {
if (!hasRoute || (selectedEventKey && !selectedEvent)) {
setSelectedEventKey(null);
}
}, [hasRoute, selectedEvent, selectedEventKey]);
if (!hasRoute) {
return (
<PanelState
className={className}
icon={RouteIcon}
title="Noch keine Route"
message="Plane zuerst eine Route. Danach erscheinen Häfen, Schleusen und Brücken in Fahrtreihenfolge."
/>
);
}
if (loading && orderedEvents.length === 0) {
return (
<PanelState
className={className}
icon={LoaderCircle}
title="Ereignisse werden geladen"
message="Die nächsten Häfen, Schleusen und Brücken entlang der Route werden ermittelt."
live
/>
);
}
if (error && orderedEvents.length === 0) {
return (
<PanelState
className={className}
icon={AlertTriangle}
title="Ereignisse nicht erreichbar"
message={error}
alert
/>
);
}
if (orderedEvents.length === 0) {
return (
<PanelState
className={className}
icon={CalendarClock}
title="Keine bevorstehenden Ereignisse"
message="Auf dem verbleibenden Routenabschnitt wurden keine Häfen, Schleusen oder Brücken gefunden."
/>
);
}
if (selectedEvent) {
return (
<EventDrilldown
className={className}
event={selectedEvent}
onBack={() => setSelectedEventKey(null)}
onShowOnMap={onShowOnMap}
/>
);
}
const nextEvent = filteredEvents[0] ?? null;
return (
<section
className={classNames("upcoming-events-panel", className)}
aria-label="Bevorstehende Ereignisse"
aria-busy={loading}
>
<header className="upcoming-events-header">
<span>
<CalendarClock size={18} aria-hidden="true" />
<strong>Bevorstehend</strong>
</span>
<small>{orderedEvents.length} auf der Route</small>
</header>
{loading && (
<p className="upcoming-events-message" role="status" aria-live="polite">
<LoaderCircle size={16} aria-hidden="true" />
Ereignisse werden aktualisiert.
</p>
)}
{error && (
<p className="upcoming-events-message" role="alert">
<AlertTriangle size={16} aria-hidden="true" />
{error}
</p>
)}
<div className="upcoming-events-filters" role="group" aria-label="Ereignisse filtern">
{EVENT_FILTERS.map(({ id, label }) => (
<button
key={id}
type="button"
data-active={filter === id}
aria-pressed={filter === id}
onClick={() => setFilter(id)}
>
<span>{label}</span>
<small aria-label={`${counts[id]} Ereignisse`}>{counts[id]}</small>
</button>
))}
</div>
{nextEvent ? (
<>
<NextEventHero
event={nextEvent}
onOpen={() => setSelectedEventKey(routeEventKey(nextEvent))}
/>
<section className="upcoming-events-list-section" aria-labelledby="upcoming-events-list-title">
<h3 id="upcoming-events-list-title">
{filter === "all"
? "Alle Ereignisse in Fahrtreihenfolge"
: `${filterLabel(filter)} in Fahrtreihenfolge`}
</h3>
<ol className="upcoming-events-list">
{filteredEvents.map((event) => (
<li key={routeEventKey(event)}>
<EventRow
event={event}
onOpen={() => setSelectedEventKey(routeEventKey(event))}
/>
</li>
))}
</ol>
</section>
</>
) : (
<p className="upcoming-events-empty-filter" role="status">
Keine {filterLabel(filter)} auf dem verbleibenden Routenabschnitt.
</p>
)}
</section>
);
}
function NextEventHero({
event,
onOpen
}: {
event: UpcomingRouteEvent;
onOpen: () => void;
}) {
const Icon = EVENT_KIND_ICONS[event.kind];
const contact = eventContact(event);
return (
<article
className="upcoming-event-hero"
data-kind={event.kind}
data-status={eventStatus(event)}
aria-labelledby={`next-event-${safeDomId(routeEventKey(event))}`}
>
<small>Nächstes Ereignis</small>
<button className="upcoming-event-hero-main" type="button" onClick={onOpen}>
<Icon size={24} aria-hidden="true" />
<span>
<strong id={`next-event-${safeDomId(routeEventKey(event))}`}>{event.name}</strong>
<span>{EVENT_KIND_LABELS[event.kind]}</span>
</span>
<span className="upcoming-event-hero-distance">{formatDistance(event.remainingNm)}</span>
<span className="upcoming-event-hero-eta">{formatEta(event)}</span>
<span className="upcoming-event-hero-fact">{importantFact(event)}</span>
</button>
<ContactActions
name={event.name}
phone={contact.phone}
website={contact.website}
email={contact.email}
compact
/>
</article>
);
}
function EventRow({
event,
onOpen
}: {
event: UpcomingRouteEvent;
onOpen: () => void;
}) {
const Icon = EVENT_KIND_ICONS[event.kind];
return (
<button
className="upcoming-event-row"
type="button"
onClick={onOpen}
data-kind={event.kind}
data-status={eventStatus(event)}
aria-label={`${EVENT_KIND_LABELS[event.kind]} ${event.name}, ${formatDistance(
event.remainingNm
)}, ${formatEta(event)}, ${importantFact(event)}. Details öffnen`}
>
<span className="upcoming-event-row-icon">
<Icon size={19} aria-hidden="true" />
</span>
<span className="upcoming-event-row-content">
<strong>{event.name}</strong>
<small>{importantFact(event)}</small>
</span>
<span className="upcoming-event-row-progress">
<strong>{formatDistance(event.remainingNm)}</strong>
<small>{formatEta(event)}</small>
</span>
</button>
);
}
function EventDrilldown({
event,
onBack,
onShowOnMap,
className
}: {
event: UpcomingRouteEvent;
onBack: () => void;
onShowOnMap?: (event: UpcomingRouteEvent) => void;
className?: string;
}) {
const Icon = EVENT_KIND_ICONS[event.kind];
const contact = eventContact(event);
return (
<section
className={classNames("upcoming-events-panel", "upcoming-event-detail", className)}
aria-labelledby="upcoming-event-detail-title"
data-kind={event.kind}
data-status={eventStatus(event)}
>
<header className="upcoming-event-detail-header">
<button type="button" onClick={onBack} aria-label="Zurück zur Ereignisliste" title="Zurück">
<ArrowLeft size={19} aria-hidden="true" />
</button>
<span>
<Icon size={20} aria-hidden="true" />
<span>
<small>{EVENT_KIND_LABELS[event.kind]}</small>
<strong id="upcoming-event-detail-title">{event.name}</strong>
</span>
</span>
</header>
<div className="upcoming-event-detail-hero">
<span>
<RouteIcon size={17} aria-hidden="true" />
<strong>{formatDistance(event.remainingNm)}</strong>
verbleibend
</span>
<span>
<Clock3 size={17} aria-hidden="true" />
<strong>{formatEta(event)}</strong>
</span>
</div>
<dl className="upcoming-event-detail-facts">
<DetailRow label="Wichtigster Hinweis">{importantFact(event)}</DetailRow>
<DetailRow label="Abstand von der Route">
{formatDistance(event.distanceFromRouteNm)}
</DetailRow>
{event.eta && (
<DetailRow label="ETA-Grundlage">{etaBasisLabel(event)}</DetailRow>
)}
<KindSpecificDetails event={event} />
<EventDataDetails event={event} />
</dl>
<ContactActions
name={event.name}
phone={contact.phone}
website={contact.website}
email={contact.email}
/>
{onShowOnMap && (
<button
className="upcoming-event-map-action"
type="button"
onClick={() => onShowOnMap(event)}
>
<MapPin size={18} aria-hidden="true" />
Auf Karte zeigen
</button>
)}
</section>
);
}
function KindSpecificDetails({ event }: { event: UpcomingRouteEvent }) {
if (event.kind === "harbour") {
const amenities = availableAmenities(event);
return (
<>
<DetailRow label="Typ">{event.feature.kind === "marina" ? "Marina" : "Hafen"}</DetailRow>
<DetailRow label="Öffnungszeiten">
{event.feature.openingHours ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="UKW / VHF">
{event.feature.vhf ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="Betreiber">
{event.feature.operator ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="Adresse">
{event.feature.address ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="Ausstattung">
{amenities.length > 0 ? amenities.join(" · ") : "Nicht hinterlegt"}
</DetailRow>
</>
);
}
if (event.kind === "lock") {
return (
<>
<DetailRow label="Öffnungszeiten">
{event.feature.openingHours ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="UKW / VHF">
{event.feature.vhf ? (
<span className="upcoming-event-radio-value">
<Radio size={15} aria-hidden="true" />
{event.feature.vhf}
</span>
) : (
"Nicht hinterlegt"
)}
</DetailRow>
<DetailRow label="Betreiber">
{event.feature.operator ?? "Nicht hinterlegt"}
</DetailRow>
<DetailRow label="Adresse">
{event.feature.address ?? "Nicht hinterlegt"}
</DetailRow>
</>
);
}
return (
<>
<DetailRow label="Durchfahrtshöhe">
{event.feature.clearanceLabel ??
formatMeters(event.feature.clearanceM) ??
"Nicht bekannt"}
</DetailRow>
<DetailRow label="Benötigte Höhe">
{formatMeters(event.feature.requiredAirDraftM) ?? "Nicht bekannt"}
</DetailRow>
<DetailRow label="Reserve">
{bridgeMargin(event.feature.marginM)}
</DetailRow>
</>
);
}
function EventDataDetails({ event }: { event: UpcomingRouteEvent }) {
const source =
event.kind === "bridge" ? event.feature.source : event.feature.source ?? null;
const updatedAt =
event.kind === "bridge" ? null : event.feature.updatedAt ?? null;
return (
<>
<DetailRow label="Koordinaten">
{event.coordinate.lat.toFixed(5)}, {event.coordinate.lon.toFixed(5)}
</DetailRow>
<DetailRow label="Quelle">{source || "Nicht angegeben"}</DetailRow>
{updatedAt && (
<DetailRow label="Datenstand">{formatTimestamp(updatedAt)}</DetailRow>
)}
</>
);
}
function ContactActions({
name,
phone,
website,
email,
compact = false
}: {
name: string;
phone: string | null;
website: string | null;
email: string | null;
compact?: boolean;
}) {
const phoneHref = phone ? telephoneHref(phone) : null;
const safeWebsite = website ? websiteHref(website) : null;
const safeEmail = email?.trim() || null;
if (!phoneHref && !safeWebsite && !safeEmail) {
return null;
}
return (
<div className="upcoming-event-contact-actions" data-compact={compact}>
{phoneHref && (
<a href={phoneHref} aria-label={`${name} anrufen`}>
<Phone size={17} aria-hidden="true" />
Anrufen
</a>
)}
{safeWebsite && (
<a
href={safeWebsite}
target="_blank"
rel="noreferrer"
aria-label={`Website von ${name} öffnen`}
>
<Globe2 size={17} aria-hidden="true" />
Website
</a>
)}
{safeEmail && (
<a href={`mailto:${safeEmail}`} aria-label={`E-Mail an ${name} schreiben`}>
<Mail size={17} aria-hidden="true" />
E-Mail
</a>
)}
</div>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function PanelState({
icon: Icon,
title,
message,
live = false,
alert = false,
className
}: {
icon: LucideIcon;
title: string;
message: string;
live?: boolean;
alert?: boolean;
className?: string;
}) {
return (
<section
className={classNames("upcoming-events-panel", "upcoming-events-state", className)}
aria-label="Bevorstehende Ereignisse"
role={alert ? "alert" : live ? "status" : undefined}
aria-live={live ? "polite" : undefined}
>
<Icon size={26} aria-hidden="true" />
<strong>{title}</strong>
<p>{message}</p>
</section>
);
}
function eventContact(event: UpcomingRouteEvent) {
if (event.kind === "harbour" || event.kind === "lock") {
return {
phone: event.feature.phone ?? null,
website: event.feature.website ?? null,
email: event.feature.email ?? null
};
}
return { phone: null, website: null, email: null };
}
function importantFact(event: UpcomingRouteEvent) {
if (event.kind === "harbour") {
const amenities = availableAmenities(event);
if (amenities.length > 0) {
return amenities.slice(0, 3).join(" · ");
}
if (event.feature.phone || event.feature.website) {
return "Kontaktdaten vorhanden";
}
return "Ausstattung nicht hinterlegt";
}
if (event.kind === "lock") {
if (event.feature.openingHours) {
return `Öffnung: ${event.feature.openingHours}`;
}
if (event.feature.vhf) {
return `UKW / VHF ${event.feature.vhf}`;
}
return "Betriebszeiten nicht hinterlegt";
}
if (event.feature.marginM !== null) {
return event.feature.marginM < 0
? `${Math.abs(event.feature.marginM).toFixed(1)} m zu niedrig`
: `${event.feature.marginM.toFixed(1)} m Reserve`;
}
if (event.feature.clearanceLabel) {
return `Durchfahrt ${event.feature.clearanceLabel}`;
}
if (event.feature.clearanceM !== null) {
return `Durchfahrt ${event.feature.clearanceM.toFixed(1)} m`;
}
return "Durchfahrtshöhe unbekannt";
}
function eventStatus(event: UpcomingRouteEvent) {
if (event.kind !== "bridge") {
return "idle";
}
switch (event.feature.status) {
case "too_low":
return "alarm";
case "tight":
return "caution";
case "unknown":
return "stale";
default:
return "idle";
}
}
function availableAmenities(event: Extract<UpcomingRouteEvent, { kind: "harbour" }>) {
const amenities = event.feature.amenities ?? {};
return [
["fuel", "Kraftstoff"],
["water", "Wasser"],
["electricity", "Strom"],
["overnight", "Übernachtung"],
["waste", "Entsorgung"]
].flatMap(([id, label]) => {
const availability = amenities[id as keyof typeof amenities];
return availability === true || availability === "available" ? [label] : [];
});
}
function formatDistance(value: number) {
if (!Number.isFinite(value)) {
return "-- sm";
}
if (value > 0 && value < 0.1) {
return "< 0,1 sm";
}
return `${value.toLocaleString("de-DE", {
minimumFractionDigits: value < 10 ? 1 : 0,
maximumFractionDigits: 1
})} sm`;
}
function formatEta(event: UpcomingRouteEvent) {
if (!event.eta) {
return "ETA offen";
}
const date = new Date(event.eta.estimatedAt);
if (!Number.isFinite(date.getTime())) {
return "ETA offen";
}
const today = new Date();
const sameDay =
date.getFullYear() === today.getFullYear() &&
date.getMonth() === today.getMonth() &&
date.getDate() === today.getDate();
return `ETA ${date.toLocaleString("de-DE", {
weekday: sameDay ? undefined : "short",
day: sameDay ? undefined : "2-digit",
month: sameDay ? undefined : "2-digit",
hour: "2-digit",
minute: "2-digit"
})}`;
}
function etaBasisLabel(event: UpcomingRouteEvent) {
if (!event.eta) {
return "Nicht verfügbar";
}
const speed =
event.eta.speedSource === "gps-sog"
? "GPS-Fahrt über Grund"
: "geplante Bootsgeschwindigkeit";
const reference =
event.eta.referenceSource === "current-time"
? "ab jetzt"
: "ab geplanter Abfahrt";
return `${event.eta.speedKn.toFixed(1)} kn ${speed}, ${reference}`;
}
function bridgeMargin(value: number | null) {
if (value === null || !Number.isFinite(value)) {
return "Nicht bekannt";
}
return value < 0
? `${Math.abs(value).toFixed(1)} m zu niedrig`
: `${value.toFixed(1)} m Reserve`;
}
function formatMeters(value: number | null) {
return value !== null && Number.isFinite(value) ? `${value.toFixed(1)} m` : null;
}
function formatTimestamp(value: string) {
const date = new Date(value);
return Number.isFinite(date.getTime())
? date.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
})
: value;
}
function filterLabel(filter: EventFilter) {
return EVENT_FILTERS.find((definition) => definition.id === filter)?.label ?? "Ereignisse";
}
function finiteDistance(value: number) {
return Number.isFinite(value) ? Math.max(0, value) : Number.POSITIVE_INFINITY;
}
function routeEventKey(event: UpcomingRouteEvent) {
return `${event.kind}:${event.id}`;
}
function safeDomId(value: string) {
return value.replace(/[^a-zA-Z0-9_-]/g, "-");
}
function telephoneHref(value: string) {
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
return compact ? `tel:${compact}` : null;
}
function websiteHref(value: string) {
const normalized = value.trim();
if (!normalized) {
return null;
}
try {
const url = new URL(
/^[a-z][a-z\d+.-]*:/i.test(normalized) ? normalized : `https://${normalized}`
);
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
} catch {
return null;
}
}
function classNames(...values: Array<string | null | undefined | false>) {
return values.filter(Boolean).join(" ");
}
@@ -0,0 +1,154 @@
.voyage-navigation-tools {
border-top: 1px solid rgba(18, 46, 55, 0.12);
padding-top: 9px;
display: grid;
gap: 8px;
}
.voyage-tools-heading {
min-height: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #10242b;
font-size: 13px;
}
.voyage-tools-heading span {
color: #4c6269;
font-size: 10px;
font-weight: 800;
}
.voyage-tools-actions,
.deviation-alarm-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px;
}
.voyage-tools-actions button,
.offline-voyage-picker button,
.deviation-alarm-button {
min-height: 36px;
border-radius: 8px;
padding: 0 9px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: #e6f1ed;
color: #0f4c5c;
font-size: 11px;
font-weight: 850;
}
.offline-voyage-picker {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 38px;
gap: 6px;
}
.offline-voyage-picker label {
grid-column: 1 / -1;
color: #4c6269;
font-size: 10px;
font-weight: 800;
}
.offline-voyage-picker select {
min-width: 0;
height: 36px;
border: 1px solid rgba(18, 46, 55, 0.18);
border-radius: 8px;
padding: 0 8px;
background: #ffffff;
color: #10242b;
font: inherit;
font-size: 11px;
font-weight: 750;
}
.offline-voyage-picker .danger-action {
width: 38px;
padding: 0;
background: #ffe1dc;
color: #9d2c22;
}
.deviation-alarm-controls label {
min-height: 42px;
border-radius: 8px;
padding: 4px 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
background: #eef3f0;
color: #4c6269;
font-size: 10px;
font-weight: 800;
}
.deviation-alarm-controls label span {
display: inline-flex;
align-items: center;
gap: 3px;
}
.deviation-alarm-controls input {
width: 61px;
height: 30px;
border: 1px solid rgba(18, 46, 55, 0.18);
border-radius: 7px;
padding: 0 6px;
background: #fff;
color: #10242b;
text-align: right;
}
.deviation-alarm-button {
min-height: 42px;
background: #0f4c5c;
color: #fff;
}
.deviation-alarm-button[data-active="true"] {
background: #196f5c;
}
.deviation-alarm-status,
.offline-voyage-status {
margin: 0;
border-radius: 8px;
padding: 7px 9px;
background: #dceee6;
color: #196f5c;
font-size: 11px;
font-weight: 800;
line-height: 1.35;
}
.deviation-alarm-status[data-off-route="true"] {
background: #ffe1dc;
color: #9d2c22;
}
.offline-voyage-status {
background: #eef3f0;
color: #4c6269;
}
.voyage-privacy-note {
color: #607278;
font-size: 9px;
font-weight: 650;
line-height: 1.4;
}
@media (max-width: 390px) {
.voyage-tools-heading span {
display: none;
}
}
@@ -0,0 +1,229 @@
import { Bell, BellOff, Download, FolderOpen, Save, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { RouteResult } from "@watermaps/shared";
import { useRouteDeviationAlarm } from "../hooks/useRouteDeviationAlarm";
import { downloadRouteGpx } from "../lib/gpx";
import {
deleteOfflineVoyage,
listOfflineVoyages,
loadOfflineVoyage,
requestPersistentOfflineStorage,
saveOfflineVoyage,
type OfflineVoyage,
type OfflineVoyagePlanInput
} from "../lib/offline-route";
import "./VoyageNavigationTools.css";
export type VoyageNavigationToolsProps = {
route: RouteResult | null;
plan?: OfflineVoyagePlanInput;
defaultDeviationThresholdM?: number;
courseAssistantActive?: boolean;
onLoadOfflineVoyage?: (voyage: OfflineVoyage) => void;
};
export function VoyageNavigationTools({
route,
plan,
defaultDeviationThresholdM = 100,
courseAssistantActive = false,
onLoadOfflineVoyage
}: VoyageNavigationToolsProps) {
const [savedVoyages, setSavedVoyages] = useState<OfflineVoyage[]>([]);
const [selectedVoyageId, setSelectedVoyageId] = useState("");
const [storageMessage, setStorageMessage] = useState<string | null>(null);
const [thresholdM, setThresholdM] = useState(() => clampThreshold(defaultDeviationThresholdM));
const alarm = useRouteDeviationAlarm(route, thresholdM);
useEffect(() => {
if (courseAssistantActive && alarm.active) {
alarm.stop();
}
}, [alarm.active, alarm.stop, courseAssistantActive]);
const refreshSavedVoyages = () => {
try {
const next = listOfflineVoyages();
setSavedVoyages(next);
setSelectedVoyageId((current) => next.some((voyage) => voyage.id === current) ? current : next[0]?.id ?? "");
} catch (error) {
setStorageMessage(errorMessage(error));
}
};
useEffect(refreshSavedVoyages, []);
const selectedVoyage = useMemo(
() => savedVoyages.find((voyage) => voyage.id === selectedVoyageId) ?? null,
[savedVoyages, selectedVoyageId]
);
const exportGpx = () => {
if (!route) {
return;
}
try {
downloadRouteGpx(route);
setStorageMessage("GPX-Datei wurde zum Download bereitgestellt.");
} catch (error) {
setStorageMessage(errorMessage(error));
}
};
const saveRoute = async () => {
if (!route) {
return;
}
try {
const voyage = saveOfflineVoyage({ route, plan });
void requestPersistentOfflineStorage();
refreshSavedVoyages();
setSelectedVoyageId(voyage.id);
setStorageMessage(`${voyage.name}“ ist auf diesem Gerät offline verfügbar.`);
} catch (error) {
setStorageMessage(errorMessage(error));
}
};
const loadRoute = () => {
if (!selectedVoyage) {
return;
}
try {
const voyage = loadOfflineVoyage(selectedVoyage.id);
if (!voyage) {
setStorageMessage("Die gespeicherte Route ist nicht mehr verfügbar.");
refreshSavedVoyages();
return;
}
onLoadOfflineVoyage?.(voyage);
setStorageMessage(`${voyage.name}“ wurde offline geladen.`);
} catch (error) {
setStorageMessage(errorMessage(error));
}
};
const removeRoute = () => {
if (!selectedVoyage) {
return;
}
try {
deleteOfflineVoyage(selectedVoyage.id);
setStorageMessage(`${selectedVoyage.name}“ wurde vom Gerät gelöscht.`);
refreshSavedVoyages();
} catch (error) {
setStorageMessage(errorMessage(error));
}
};
return (
<section className="voyage-navigation-tools" aria-label="Navigation und Offline-Route">
<div className="voyage-tools-heading">
<strong>Unterwegs</strong>
<span>GPX · Offline · Kursalarm</span>
</div>
<div className="voyage-tools-actions">
<button type="button" onClick={exportGpx} disabled={!route} aria-label="Route als GPX exportieren">
<Download size={15} aria-hidden="true" />
GPX
</button>
<button type="button" onClick={saveRoute} disabled={!route} aria-label="Route offline speichern">
<Save size={15} aria-hidden="true" />
Offline speichern
</button>
</div>
{savedVoyages.length > 0 && (
<div className="offline-voyage-picker">
<label htmlFor="offline-voyage-select">Gespeicherte Route</label>
<select
id="offline-voyage-select"
value={selectedVoyageId}
onChange={(event) => setSelectedVoyageId(event.target.value)}
>
{savedVoyages.map((voyage) => (
<option key={voyage.id} value={voyage.id}>
{voyage.name} · {new Date(voyage.savedAt).toLocaleDateString("de-DE")}
</option>
))}
</select>
<button type="button" onClick={loadRoute} aria-label="Offline-Route laden">
<FolderOpen size={15} aria-hidden="true" />
Laden
</button>
<button className="danger-action" type="button" onClick={removeRoute} aria-label="Offline-Route löschen">
<Trash2 size={15} aria-hidden="true" />
</button>
</div>
)}
<div className="deviation-alarm-controls">
<label htmlFor="route-deviation-threshold">
Warnen ab
<span>
<input
id="route-deviation-threshold"
type="number"
inputMode="numeric"
min={25}
max={2_000}
step={25}
value={thresholdM}
disabled={alarm.active || courseAssistantActive}
onChange={(event) => setThresholdM(clampThreshold(Number(event.target.value)))}
/>
m
</span>
</label>
<button
type="button"
className="deviation-alarm-button"
data-active={alarm.active}
disabled={courseAssistantActive || (!route && !alarm.active)}
onClick={alarm.active ? alarm.stop : alarm.start}
aria-label={
courseAssistantActive
? "Kursalarm ist im Kursassistenten enthalten"
: alarm.active
? "Abweichungsalarm stoppen"
: "Abweichungsalarm starten"
}
>
{alarm.active ? <BellOff size={16} aria-hidden="true" /> : <Bell size={16} aria-hidden="true" />}
{courseAssistantActive ? "Im Assistenten aktiv" : alarm.active ? "Alarm stoppen" : "Kursalarm starten"}
</button>
</div>
{courseAssistantActive && (
<p className="deviation-alarm-status" role="status">
Querabweichung und einmalige Warnsignale werden vom Kursassistenten übernommen.
</p>
)}
{alarm.message && (
<p className="deviation-alarm-status" data-off-route={alarm.isOffRoute} role={alarm.isOffRoute ? "alert" : "status"}>
{alarm.message}
{alarm.accuracyM !== null && alarm.reliable && ` · GPS ±${Math.round(alarm.accuracyM)} m`}
</p>
)}
{storageMessage && <p className="offline-voyage-status" role="status">{storageMessage}</p>}
<small className="voyage-privacy-note">
GPS wird erst nach Kursalarm starten für diesen separaten Alarm angefragt; der Kursassistent nutzt den ebenfalls bewusst
gestarteten Live-GPS-Datenstrom der Karte. Positionen bleiben im Browser und werden weder gespeichert noch übertragen.
Bereits aufgerufene Kartenausschnitte kann die installierte App zeitlich begrenzt zwischenspeichern.
</small>
</section>
);
}
function clampThreshold(value: number): number {
if (!Number.isFinite(value)) {
return 100;
}
return Math.min(2_000, Math.max(25, Math.round(value)));
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Offline-Funktion nicht verfügbar.";
}
+167
View File
@@ -0,0 +1,167 @@
.voyage-plan {
display: grid;
gap: 9px;
border-top: 1px solid rgba(18, 46, 55, 0.12);
padding-top: 10px;
color: #10242b;
}
.voyage-plan-header {
display: flex;
align-items: end;
justify-content: space-between;
gap: 10px;
}
.voyage-plan-header h2 {
margin: 2px 0 0;
font-size: 15px;
}
.voyage-plan-summary,
.voyage-plan-requirements,
.voyage-plan-leg-route,
.voyage-plan-waypoints,
.voyage-plan-unconfirmed-stop,
.voyage-plan-harbour-contact {
margin: 0;
}
.voyage-plan-summary {
color: #314b54;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.voyage-plan-requirements {
color: #4c6269;
font-size: 11px;
line-height: 1.4;
}
.voyage-plan-warnings,
.voyage-plan-legs,
.voyage-plan-amenities {
margin: 0;
padding: 0;
list-style: none;
}
.voyage-plan-warnings {
display: grid;
gap: 5px;
}
.voyage-plan-warnings li {
border-radius: 8px;
padding: 7px 9px;
background: #e8f0f1;
color: #314b54;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
}
.voyage-plan-warnings li[data-severity="caution"] {
background: #fff1cc;
color: #805900;
}
.voyage-plan-warnings li[data-severity="critical"] {
background: #ffe1dc;
color: #9d2c22;
}
.voyage-plan-legs {
display: grid;
gap: 7px;
counter-reset: voyage-day;
}
.voyage-plan-leg {
border: 1px solid rgba(18, 46, 55, 0.12);
border-radius: 9px;
padding: 9px;
background: #f8faf9;
}
.voyage-plan-leg article,
.voyage-plan-harbour {
display: grid;
gap: 6px;
}
.voyage-plan-leg article > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 12px;
}
.voyage-plan-leg article > header span,
.voyage-plan-waypoints {
color: #60737a;
font-size: 10px;
font-weight: 750;
}
.voyage-plan-leg-route {
color: #10242b;
font-size: 12px;
font-weight: 850;
}
.voyage-plan-amenities {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.voyage-plan-amenities li {
min-height: 24px;
border-radius: 999px;
padding: 4px 7px;
background: #eef3f0;
color: #60737a;
font-size: 9px;
font-weight: 800;
}
.voyage-plan-amenities li[data-availability="available"] {
background: #dceee6;
color: #196f5c;
}
.voyage-plan-amenities li[data-availability="unavailable"] {
background: #f0e8e4;
color: #74483d;
}
.voyage-plan-unconfirmed-stop {
border-radius: 7px;
padding: 6px 8px;
background: #fff1cc;
color: #805900;
font-size: 10px;
font-weight: 850;
}
.voyage-plan-harbour-contact {
font-size: 11px;
font-weight: 800;
}
.voyage-plan-harbour-contact a {
color: #075f78;
text-underline-offset: 2px;
}
@media (max-width: 460px) {
.voyage-plan-header {
align-items: start;
flex-direction: column;
gap: 4px;
}
}
+144
View File
@@ -0,0 +1,144 @@
import {
VOYAGE_AMENITIES,
voyageAmenityLabel,
type ProjectedVoyageHarbour,
type VoyageAmenity,
type VoyageAmenityAvailability,
type VoyagePlan as VoyagePlanResult
} from "@watermaps/shared";
import "./VoyagePlan.css";
type VoyagePlanProps = {
plan: VoyagePlanResult | null;
};
export function VoyagePlan({ plan }: VoyagePlanProps) {
if (!plan) {
return null;
}
return (
<section className="voyage-plan" aria-labelledby="voyage-plan-title">
<header className="voyage-plan-header">
<div>
<span className="eyebrow">Reiseplanung</span>
<h2 id="voyage-plan-title">Etappenplan</h2>
</div>
<p className="voyage-plan-summary">
{plan.legs.length} {plan.legs.length === 1 ? "Tag" : "Tage"} · {formatNm(plan.totalDistanceNm)} ·{" "}
{formatDuration(plan.totalDurationHours)}
</p>
</header>
{plan.requiredAmenities.length > 0 ? (
<p className="voyage-plan-requirements">
Benötigte Versorgung: {plan.requiredAmenities.map(voyageAmenityLabel).join(", ")}
</p>
) : null}
{plan.warnings.length > 0 ? (
<ul className="voyage-plan-warnings" aria-label="Hinweise zum Etappenplan">
{plan.warnings.map((warning, index) => (
<li key={`${warning.code}-${warning.day ?? 0}-${index}`} data-severity={warning.severity}>
{warning.message}
</li>
))}
</ul>
) : null}
<ol className="voyage-plan-legs">
{plan.legs.map((leg) => (
<li key={leg.day} className="voyage-plan-leg">
<article>
<header>
<strong>Tag {leg.day}</strong>
<span>
{formatNm(leg.distanceNm)} · {formatDuration(leg.durationHours)}
</span>
</header>
<p className="voyage-plan-leg-route">
{leg.start.name} {leg.end.name}
</p>
{leg.waypoints.length > 0 ? (
<p className="voyage-plan-waypoints">
Via: {leg.waypoints.map((waypoint) => waypoint.name).join(" → ")}
</p>
) : null}
{leg.end.harbour ? <HarbourSupply harbour={leg.end.harbour} /> : null}
{leg.end.type === "route" ? (
<p className="voyage-plan-unconfirmed-stop">Kein bestätigter Liegeplatz</p>
) : null}
</article>
</li>
))}
</ol>
</section>
);
}
function HarbourSupply({ harbour }: { harbour: ProjectedVoyageHarbour }) {
return (
<div className="voyage-plan-harbour">
<ul className="voyage-plan-amenities" aria-label={`Versorgung in ${harbour.name}`}>
{VOYAGE_AMENITIES.map((amenity) => (
<Amenity key={amenity} amenity={amenity} availability={harbour.amenities[amenity]} />
))}
</ul>
{harbour.phone || harbour.website ? (
<p className="voyage-plan-harbour-contact">
{harbour.phone ? <a href={telephoneHref(harbour.phone)}>Hafen anrufen</a> : null}
{harbour.phone && harbour.website ? " · " : null}
{harbour.website ? (
<a href={websiteHref(harbour.website)} target="_blank" rel="noreferrer">
Website
</a>
) : null}
</p>
) : null}
</div>
);
}
function Amenity({
amenity,
availability
}: {
amenity: VoyageAmenity;
availability: VoyageAmenityAvailability;
}) {
const status =
availability === "available"
? "verfügbar"
: availability === "unavailable"
? "nicht verfügbar"
: "unbekannt";
const symbol = availability === "available" ? "✓" : availability === "unavailable" ? "" : "?";
return (
<li data-availability={availability} aria-label={`${voyageAmenityLabel(amenity)}: ${status}`}>
<span aria-hidden="true">{symbol}</span> {voyageAmenityLabel(amenity)}
</li>
);
}
function formatNm(value: number) {
return `${value.toLocaleString("de-DE", { maximumFractionDigits: 1 })} sm`;
}
function formatDuration(hours: number) {
const totalMinutes = Math.max(0, Math.round(hours * 60));
const fullHours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
return `${fullHours} h ${String(minutes).padStart(2, "0")} min`;
}
function telephoneHref(value: string) {
const compact = value.trim().split(/[;,/]/)[0]?.replace(/(?!^)\+|[^\d+]/g, "") ?? "";
return `tel:${compact}`;
}
function websiteHref(value: string) {
const trimmed = value.trim();
return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
}
+463
View File
@@ -0,0 +1,463 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
analyzeTideWindow,
calculateAnchorRodePlan,
evaluateAnchorWatch,
type Coordinate,
type TideSummary
} from "@watermaps/shared";
import { getNearestTide } from "../api";
import type { GpsState } from "./useGeolocation";
export type AnchorWatchPhase = "idle" | "set" | "armed";
export type AnchorWatchSettings = {
depthAtSetM: number;
bowRollerHeightM: number;
deployedRodeLengthM: number;
scopeRatio: number;
safetyAllowanceM: number;
alarmRadiusM: number;
horizonHours: number;
};
export const DEFAULT_ANCHOR_WATCH_SETTINGS: AnchorWatchSettings = {
depthAtSetM: 3,
bowRollerHeightM: 1,
deployedRodeLengthM: 30,
scopeRatio: 6,
safetyAllowanceM: 0.5,
alarmRadiusM: 35,
horizonHours: 24
};
const MAX_CAPTURE_ACCURACY_M = 30;
const MAX_FIX_AGE_MS = 10_000;
const STALE_FIX_AFTER_MS = 20_000;
const TIDE_REFRESH_MS = 15 * 60 * 1_000;
const REPEAT_ALARM_MS = 60_000;
type WakeLockSentinelLike = {
released?: boolean;
release: () => Promise<void>;
};
type NavigatorWithWakeLock = Navigator & {
wakeLock?: {
request: (type: "screen") => Promise<WakeLockSentinelLike>;
};
};
export function useAnchorWatch(gps: Pick<
GpsState,
"status" | "position" | "accuracyM" | "timestampMs"
>) {
const [phase, setPhase] = useState<AnchorWatchPhase>("idle");
const [anchorPoint, setAnchorPoint] = useState<Coordinate | null>(null);
const [anchorSetAtMs, setAnchorSetAtMs] = useState<number | null>(null);
const [anchorCaptureAccuracyM, setAnchorCaptureAccuracyM] = useState<number | null>(null);
const [settings, setSettings] = useState<AnchorWatchSettings>(DEFAULT_ANCHOR_WATCH_SETTINGS);
const [operationError, setOperationError] = useState<string | null>(null);
const [tide, setTide] = useState<TideSummary | null>(null);
const [tideLoading, setTideLoading] = useState(false);
const [tideError, setTideError] = useState<string | null>(null);
const [clockMs, setClockMs] = useState(() => Date.now());
const [alarmAcknowledged, setAlarmAcknowledged] = useState(false);
const tideRequestIdRef = useRef(0);
const previousAlarmRef = useRef(false);
const previousRodeShortfallRef = useRef(false);
const audioContextRef = useRef<AudioContext | null>(null);
const updateSettings = useCallback((next: Partial<AnchorWatchSettings>) => {
setSettings((current) => ({ ...current, ...next }));
setOperationError(null);
}, []);
const captureAnchor = useCallback(() => {
const now = Date.now();
if (gps.status !== "tracking" || !gps.position || gps.timestampMs === null) {
setOperationError("Für den Ankerpunkt wird zuerst ein aktueller GPS-Fix benötigt.");
return false;
}
if (now - gps.timestampMs > MAX_FIX_AGE_MS) {
setOperationError("Der GPS-Fix ist älter als 10 Sekunden. Bitte auf einen neuen Fix warten.");
return false;
}
if (gps.accuracyM === null || gps.accuracyM > MAX_CAPTURE_ACCURACY_M) {
setOperationError(
`GPS noch zu ungenau${gps.accuracyM === null ? "" : `${Math.round(gps.accuracyM)} m)`}. Ankerpunkt erst bei höchstens ±${MAX_CAPTURE_ACCURACY_M} m setzen.`
);
return false;
}
tideRequestIdRef.current += 1;
setAnchorPoint({ ...gps.position });
setAnchorSetAtMs(gps.timestampMs);
setAnchorCaptureAccuracyM(gps.accuracyM);
setPhase("set");
setOperationError(null);
setTide(null);
setTideError(null);
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
previousRodeShortfallRef.current = false;
return true;
}, [gps.accuracyM, gps.position, gps.status, gps.timestampMs]);
const reset = useCallback(() => {
tideRequestIdRef.current += 1;
setPhase("idle");
setAnchorPoint(null);
setAnchorSetAtMs(null);
setAnchorCaptureAccuracyM(null);
setOperationError(null);
setTide(null);
setTideLoading(false);
setTideError(null);
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
previousRodeShortfallRef.current = false;
const audioContext = audioContextRef.current;
audioContextRef.current = null;
void audioContext?.close().catch(() => undefined);
}, []);
const settingsError = useMemo(() => validateSettings(settings), [settings]);
const arm = useCallback(async () => {
if (!anchorPoint || anchorSetAtMs === null) {
setOperationError("Zuerst „Anker gefallen“ wählen und den Ankerpunkt setzen.");
return false;
}
const validationError = validateSettings(settings);
if (validationError) {
setOperationError(validationError);
return false;
}
setOperationError(null);
setClockMs(Date.now());
setAlarmAcknowledged(false);
previousAlarmRef.current = false;
setPhase("armed");
audioContextRef.current = createAlarmAudioContext();
void audioContextRef.current?.resume().catch(() => undefined);
if (typeof Notification !== "undefined" && Notification.permission === "default") {
try {
await Notification.requestPermission();
} catch {
// The persistent in-app warning remains available when notifications
// are unsupported or denied.
}
}
return true;
}, [anchorPoint, anchorSetAtMs, settings]);
useEffect(() => {
if (!anchorPoint || anchorSetAtMs === null || phase === "idle") {
return;
}
let active = true;
const refresh = async () => {
const requestId = tideRequestIdRef.current + 1;
tideRequestIdRef.current = requestId;
setTideLoading(true);
try {
const summary = await getNearestTide(anchorPoint, new Date(anchorSetAtMs).toISOString());
if (active && tideRequestIdRef.current === requestId) {
setTide(summary);
setTideError(null);
}
} catch (error) {
if (active && tideRequestIdRef.current === requestId) {
setTide(null);
setTideError(error instanceof Error ? error.message : "Tidenprognose nicht erreichbar");
}
} finally {
if (active && tideRequestIdRef.current === requestId) {
setTideLoading(false);
}
}
};
void refresh();
const intervalId = window.setInterval(refresh, TIDE_REFRESH_MS);
return () => {
active = false;
window.clearInterval(intervalId);
};
}, [anchorPoint, anchorSetAtMs, phase]);
useEffect(() => {
if (phase === "idle") {
return;
}
setClockMs(Date.now());
const intervalId = window.setInterval(() => setClockMs(Date.now()), 5_000);
return () => window.clearInterval(intervalId);
}, [phase, gps.timestampMs]);
const tideWindow = useMemo(
() => tide && anchorSetAtMs !== null
? analyzeTideWindow(tide, anchorSetAtMs, settings.horizonHours)
: null,
[anchorSetAtMs, settings.horizonHours, tide]
);
const remainingTideWindow = useMemo(
() => tide ? analyzeTideWindow(tide, clockMs, settings.horizonHours) : null,
[clockMs, settings.horizonHours, tide]
);
const rodePlan = useMemo(
() => calculateAnchorRodePlan({
depthAtSetM: settings.depthAtSetM,
bowRollerHeightM: settings.bowRollerHeightM,
deployedRodeLengthM: settings.deployedRodeLengthM,
scopeRatio: settings.scopeRatio,
safetyAllowanceM: settings.safetyAllowanceM,
tideWindow
}),
[settings, tideWindow]
);
const watchResult = useMemo(
() => anchorPoint && gps.position
? evaluateAnchorWatch({
anchorPoint,
position: gps.position,
alarmRadiusM: settings.alarmRadiusM,
accuracyM: gps.accuracyM,
maxReliableAccuracyM: MAX_CAPTURE_ACCURACY_M
})
: null,
[anchorPoint, gps.accuracyM, gps.position, settings.alarmRadiusM]
);
const fixStale = phase === "armed" && (
gps.timestampMs === null || Math.max(0, clockMs - gps.timestampMs) > STALE_FIX_AFTER_MS
);
const gpsUnavailable = phase === "armed" && gps.status !== "tracking";
const gpsUnreliable = phase === "armed" && Boolean(watchResult && !watchResult.positionReliable);
const positionAlarm = phase === "armed" && (
fixStale || gpsUnavailable || gpsUnreliable || !watchResult || watchResult.alarmTriggered
);
const rodeShortfall = Boolean(
phase === "armed" && rodePlan?.calculationComplete && (rodePlan.rodeReserveM ?? 0) < 0
);
useEffect(() => {
if (phase !== "armed") {
previousAlarmRef.current = false;
return;
}
if (!positionAlarm) {
previousAlarmRef.current = false;
setAlarmAcknowledged(false);
return;
}
if (!previousAlarmRef.current) {
setAlarmAcknowledged(false);
emitAnchorAlert(
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
audioContextRef.current
);
}
previousAlarmRef.current = true;
}, [fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
useEffect(() => {
if (phase !== "armed" || !positionAlarm || alarmAcknowledged) {
return;
}
const intervalId = window.setInterval(() => {
emitAnchorAlert(
anchorAlertMessage({ fixStale, gpsUnavailable, gpsUnreliable, watchResult }),
audioContextRef.current
);
}, REPEAT_ALARM_MS);
return () => window.clearInterval(intervalId);
}, [alarmAcknowledged, fixStale, gpsUnavailable, gpsUnreliable, phase, positionAlarm, watchResult]);
useEffect(() => {
if (phase === "armed" && rodeShortfall && !previousRodeShortfallRef.current) {
const shortfallM = Math.abs(rodePlan?.rodeReserveM ?? 0);
emitAnchorAlert(
`Nach Stationsprognose fehlen rechnerisch etwa ${shortfallM.toFixed(1)} m Ankerleine oder Kette.`,
audioContextRef.current
);
}
previousRodeShortfallRef.current = rodeShortfall;
}, [phase, rodePlan?.rodeReserveM, rodeShortfall]);
useEffect(() => {
if (phase !== "armed" || typeof navigator === "undefined") {
return;
}
let active = true;
let sentinel: WakeLockSentinelLike | null = null;
const acquire = async () => {
const wakeLock = (navigator as NavigatorWithWakeLock).wakeLock;
if (!wakeLock || document.visibilityState !== "visible") {
return;
}
try {
sentinel = await wakeLock.request("screen");
if (!active) {
await sentinel.release();
}
} catch {
sentinel = null;
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible" && (!sentinel || sentinel.released)) {
void acquire();
}
};
void acquire();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
active = false;
document.removeEventListener("visibilitychange", handleVisibilityChange);
void sentinel?.release().catch(() => undefined);
};
}, [phase]);
useEffect(() => () => {
const audioContext = audioContextRef.current;
audioContextRef.current = null;
void audioContext?.close().catch(() => undefined);
}, []);
const acknowledgeAlarm = useCallback(() => setAlarmAcknowledged(true), []);
return {
phase,
anchorPoint,
anchorSetAtMs,
anchorCaptureAccuracyM,
settings,
settingsError,
tide,
tideLoading,
tideError,
tideWindow,
remainingTideWindow,
rodePlan,
watchResult,
fixStale,
gpsUnreliable,
positionAlarm,
rodeShortfall,
alarmAcknowledged,
operationError,
maxCaptureAccuracyM: MAX_CAPTURE_ACCURACY_M,
captureAnchor,
updateSettings,
arm,
acknowledgeAlarm,
reset
};
}
function validateSettings(settings: AnchorWatchSettings): string | null {
if (!positive(settings.depthAtSetM)) return "Die Tiefe beim Setzen muss größer als 0 m sein.";
if (!nonNegative(settings.bowRollerHeightM)) return "Die Höhe der Bugrolle darf nicht negativ sein.";
if (!positive(settings.deployedRodeLengthM)) return "Die ausgesteckte Länge muss größer als 0 m sein.";
if (!Number.isFinite(settings.scopeRatio) || settings.scopeRatio < 2 || settings.scopeRatio > 15) {
return "Das gewählte Verhältnis muss zwischen 2:1 und 15:1 liegen.";
}
if (!nonNegative(settings.safetyAllowanceM)) return "Die Wasserstandsreserve darf nicht negativ sein.";
if (!Number.isFinite(settings.alarmRadiusM) || settings.alarmRadiusM < 10 || settings.alarmRadiusM > 2_000) {
return "Der Alarmradius muss zwischen 10 m und 2.000 m liegen.";
}
if (!Number.isFinite(settings.horizonHours) || settings.horizonHours < 6 || settings.horizonHours > 72) {
return "Der Tidenzeitraum muss zwischen 6 und 72 Stunden liegen.";
}
return null;
}
function positive(value: number) {
return Number.isFinite(value) && value > 0;
}
function nonNegative(value: number) {
return Number.isFinite(value) && value >= 0;
}
function anchorAlertMessage({
fixStale,
gpsUnavailable,
gpsUnreliable,
watchResult
}: {
fixStale: boolean;
gpsUnavailable: boolean;
gpsUnreliable: boolean;
watchResult: ReturnType<typeof evaluateAnchorWatch>;
}) {
if (fixStale) return "Kein aktueller GPS-Fix Ankerposition kann nicht sicher überwacht werden.";
if (gpsUnavailable) return "GPS ist ausgefallen Ankerposition kann nicht überwacht werden.";
if (gpsUnreliable) return "GPS ist zu ungenau Ankerposition kann nicht sicher überwacht werden.";
if (watchResult?.alarmTriggered) {
return `Ankeralarm: ${Math.round(watchResult.distanceFromAnchorM)} m vom gesetzten Ankerpunkt entfernt.`;
}
return "Ankerwache hat keine auswertbare Position.";
}
function emitAnchorAlert(message: string, audioContext: AudioContext | null) {
if (typeof navigator !== "undefined" && typeof navigator.vibrate === "function") {
navigator.vibrate([300, 120, 300, 120, 500]);
}
playAlarmTone(audioContext);
if (typeof Notification !== "undefined" && Notification.permission === "granted") {
try {
new Notification("Watermaps Ankerwache", {
body: message,
tag: "watermaps-anchor-watch",
requireInteraction: true
});
} catch {
// The live panel remains the primary warning surface.
}
}
}
function createAlarmAudioContext(): AudioContext | null {
if (typeof window === "undefined") {
return null;
}
const AudioContextConstructor = window.AudioContext ?? (
window as typeof window & { webkitAudioContext?: typeof AudioContext }
).webkitAudioContext;
if (!AudioContextConstructor) {
return null;
}
try {
return new AudioContextConstructor();
} catch {
return null;
}
}
function playAlarmTone(audioContext: AudioContext | null) {
if (!audioContext || audioContext.state === "closed") {
return;
}
void audioContext.resume().then(() => {
const startAt = audioContext.currentTime;
for (const offset of [0, 0.32, 0.64]) {
const oscillator = audioContext.createOscillator();
const gain = audioContext.createGain();
oscillator.type = "square";
oscillator.frequency.value = 880;
gain.gain.setValueAtTime(0.0001, startAt + offset);
gain.gain.exponentialRampToValueAtTime(0.18, startAt + offset + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, startAt + offset + 0.2);
oscillator.connect(gain);
gain.connect(audioContext.destination);
oscillator.start(startAt + offset);
oscillator.stop(startAt + offset + 0.21);
}
}).catch(() => undefined);
}
+86
View File
@@ -0,0 +1,86 @@
import { useCallback, useEffect, useState } from "react";
import { normalizeHeadingDeg } from "@watermaps/shared";
type DeviceOrientationWithWebkit = DeviceOrientationEvent & {
webkitCompassHeading?: number;
};
type DeviceOrientationConstructor = typeof DeviceOrientationEvent & {
requestPermission?: (absolute?: boolean) => Promise<"granted" | "denied">;
};
export type CompassState = {
status: "idle" | "requesting" | "active" | "denied" | "unavailable" | "error";
headingDeg: number | null;
message: string | null;
};
export function useCompass(fallbackCourseDeg: number | null) {
const [state, setState] = useState<CompassState>({
status: "idle",
headingDeg: null,
message: null
});
const handleOrientation = useCallback((event: DeviceOrientationEvent) => {
const orientation = event as DeviceOrientationWithWebkit;
const heading =
typeof orientation.webkitCompassHeading === "number"
? orientation.webkitCompassHeading
: typeof event.alpha === "number"
? 360 - event.alpha
: null;
if (heading !== null) {
setState({
status: "active",
headingDeg: Math.round(normalizeHeadingDeg(heading)),
message: null
});
}
}, []);
const removeOrientationListeners = useCallback(() => {
window.removeEventListener("deviceorientationabsolute", handleOrientation);
window.removeEventListener("deviceorientation", handleOrientation);
}, [handleOrientation]);
const request = useCallback(async () => {
if (typeof window === "undefined" || !("DeviceOrientationEvent" in window)) {
setState((current) => ({ ...current, status: "unavailable", message: "Kompass nicht verfügbar" }));
return;
}
try {
setState((current) => ({ ...current, status: "requesting", message: null }));
const ctor = DeviceOrientationEvent as DeviceOrientationConstructor;
if (typeof ctor.requestPermission === "function") {
const permission = await ctor.requestPermission(true);
if (permission !== "granted") {
setState((current) => ({ ...current, status: "denied", message: "Kompass gesperrt" }));
return;
}
}
removeOrientationListeners();
window.addEventListener("deviceorientationabsolute", handleOrientation);
window.addEventListener("deviceorientation", handleOrientation);
setState((current) => ({ ...current, status: "active" }));
} catch (error) {
setState((current) => ({
...current,
status: "error",
message: error instanceof Error ? error.message : "Kompassfehler"
}));
}
}, [handleOrientation, removeOrientationListeners]);
useEffect(() => removeOrientationListeners, [removeOrientationListeners]);
return {
...state,
headingDeg: state.headingDeg ?? fallbackCourseDeg,
source: state.headingDeg !== null ? "HDG" : fallbackCourseDeg !== null ? "COG" : "N",
request
};
}
+114
View File
@@ -0,0 +1,114 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
calculateRouteGuidance,
type Coordinate,
type RouteGuidanceResult,
type RouteResult
} from "@watermaps/shared";
export type CourseAssistantInput = {
route: RouteResult | null;
position: Coordinate | null;
accuracyM: number | null;
speedKn: number | null;
headingDeg: number | null;
fixTimestampMs: number | null;
};
const STALE_FIX_AFTER_MS = 15_000;
/**
* Keeps route-following state in memory while reusing the app's single GPS
* stream. It never starts a sensor or transmits a position by itself.
*/
export function useCourseAssistant({
route,
position,
accuracyM,
speedKn,
headingDeg,
fixTimestampMs
}: CourseAssistantInput) {
const [active, setActive] = useState(false);
const [clockMs, setClockMs] = useState(() => Date.now());
const activeRouteRef = useRef<RouteResult | null>(null);
const progressMRef = useRef<number | null>(null);
const previousSignalRef = useRef<string | null>(null);
const stop = useCallback(() => {
activeRouteRef.current = null;
progressMRef.current = null;
previousSignalRef.current = null;
setActive(false);
}, []);
const start = useCallback(() => {
if (!route) return;
activeRouteRef.current = route;
progressMRef.current = null;
previousSignalRef.current = null;
setClockMs(Date.now());
setActive(true);
}, [route]);
useEffect(() => {
if (activeRouteRef.current && activeRouteRef.current !== route) {
stop();
}
}, [route, stop]);
useEffect(() => {
if (!active) return;
setClockMs(Date.now());
const intervalId = window.setInterval(() => setClockMs(Date.now()), 5_000);
return () => window.clearInterval(intervalId);
}, [active, fixTimestampMs]);
const fixStale = Boolean(
active &&
position &&
fixTimestampMs !== null &&
Math.max(0, clockMs - fixTimestampMs) > STALE_FIX_AFTER_MS
);
const guidance = useMemo<RouteGuidanceResult | null>(() => {
if (!active || activeRouteRef.current !== route || !route || !position || fixStale) {
return null;
}
return calculateRouteGuidance({
route,
position,
accuracyM,
speedKn,
headingDeg,
previousProgressM: progressMRef.current
});
}, [accuracyM, active, fixStale, headingDeg, position, route, speedKn]);
useEffect(() => {
if (guidance) {
progressMRef.current = guidance.progressM;
}
}, [guidance]);
useEffect(() => {
if (!active || !guidance) return;
const signal = guidanceSignal(guidance);
if (signal !== previousSignalRef.current && typeof navigator.vibrate === "function") {
if (guidance.status === "off-route") navigator.vibrate([200, 100, 200]);
if (guidance.status === "arrived") navigator.vibrate([300, 150, 300]);
if (guidance.status === "approaching-turn") navigator.vibrate(120);
}
previousSignalRef.current = signal;
}, [active, guidance]);
return { active, guidance, fixStale, start, stop };
}
function guidanceSignal(guidance: RouteGuidanceResult) {
if (guidance.status === "approaching-turn" && guidance.nextTurn) {
const turn = guidance.nextTurn.coordinate;
return `turn:${turn.lat.toFixed(5)}:${turn.lon.toFixed(5)}`;
}
return guidance.status;
}
+129
View File
@@ -0,0 +1,129 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { haversineDistanceM, initialBearingDeg, type Coordinate } from "@watermaps/shared";
export type GpsState = {
status: "idle" | "requesting" | "tracking" | "denied" | "unavailable" | "error";
position: Coordinate | null;
accuracyM: number | null;
speedKn: number | null;
courseDeg: number | null;
timestampMs: number | null;
message: string | null;
};
const MS_TO_KN = 1.94384449;
export function useGeolocation() {
const watchId = useRef<number | null>(null);
const courseAnchor = useRef<{ coord: Coordinate; accuracyM: number } | null>(null);
const [state, setState] = useState<GpsState>({
status: "idle",
position: null,
accuracyM: null,
speedKn: null,
courseDeg: null,
timestampMs: null,
message: null
});
const clearActiveWatch = useCallback(() => {
if (watchId.current !== null) {
navigator.geolocation.clearWatch(watchId.current);
watchId.current = null;
}
}, []);
const stop = useCallback(() => {
clearActiveWatch();
courseAnchor.current = null;
setState({
status: "idle",
position: null,
accuracyM: null,
speedKn: null,
courseDeg: null,
timestampMs: null,
message: null
});
}, [clearActiveWatch]);
const start = useCallback(() => {
if (typeof window !== "undefined" && window.isSecureContext === false) {
setState((current) => ({
...current,
status: "unavailable",
message: "GPS benötigt HTTPS oder localhost. Öffne die App auf dem iPhone über einen HTTPS-Link."
}));
return;
}
if (!("geolocation" in navigator)) {
setState((current) => ({ ...current, status: "unavailable", message: "GPS nicht verfügbar" }));
return;
}
clearActiveWatch();
courseAnchor.current = null;
setState((current) => ({ ...current, status: "requesting", message: null }));
watchId.current = navigator.geolocation.watchPosition(
(position) => {
// Freshness is based on when this watch callback reached the app. Some
// embedded/WebKit implementations expose a non-epoch timestamp even
// though the DOM type is an EpochTimeStamp. maximumAge below already
// limits how old the accepted sensor fix may be.
const receivedAtMs = Date.now();
const coord = {
lat: position.coords.latitude,
lon: position.coords.longitude
};
const nativeCourse =
typeof position.coords.heading === "number" && Number.isFinite(position.coords.heading)
? position.coords.heading
: null;
const speedKn =
typeof position.coords.speed === "number" && Number.isFinite(position.coords.speed)
? position.coords.speed * MS_TO_KN
: null;
const accuracyM = Math.max(0, position.coords.accuracy);
const anchor = courseAnchor.current;
let derivedCourse: number | null = nativeCourse;
if (nativeCourse !== null) {
courseAnchor.current = { coord, accuracyM };
} else if (!anchor) {
courseAnchor.current = { coord, accuracyM };
} else {
const movementM = haversineDistanceM(anchor.coord, coord);
const minimumMovementM = Math.max(3, Math.min(12, Math.max(anchor.accuracyM, accuracyM) * 0.5));
if (movementM >= minimumMovementM) {
derivedCourse = initialBearingDeg(anchor.coord, coord);
courseAnchor.current = { coord, accuracyM };
}
}
setState((current) => ({
status: "tracking",
position: coord,
accuracyM: Math.round(accuracyM),
speedKn,
courseDeg: derivedCourse ?? current.courseDeg,
timestampMs: receivedAtMs,
message: null
}));
},
(error) => {
const status = error.code === error.PERMISSION_DENIED ? "denied" : "error";
setState((current) => ({ ...current, status, message: error.message }));
},
{
enableHighAccuracy: true,
timeout: 12_000,
maximumAge: 2_000
}
);
}, [clearActiveWatch]);
useEffect(() => clearActiveWatch, [clearActiveWatch]);
return { ...state, start, stop };
}
+110
View File
@@ -0,0 +1,110 @@
import { useEffect, useMemo, useState } from "react";
import type { Coordinate, MarineForecast, TideSummary } from "@watermaps/shared";
import { getMarineForecast, getNearestTide } from "../api";
export type MarineDataState = {
forecast: MarineForecast | null;
tide: TideSummary | null;
loading: boolean;
error: string | null;
forecastError: string | null;
tideError: string | null;
queryPosition: Coordinate | null;
refreshedAt: string | null;
};
const REFRESH_INTERVAL_MS = 10 * 60_000;
export function useMarineData(position: Coordinate | null): MarineDataState {
const key = useMemo(() => {
if (!position) {
return null;
}
return `${position.lat.toFixed(2)}:${position.lon.toFixed(2)}`;
}, [position]);
const queryPosition = useMemo<Coordinate | null>(() => {
if (!key) {
return null;
}
const [lat, lon] = key.split(":").map(Number);
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat: lat!, lon: lon! } : null;
}, [key]);
const [state, setState] = useState<MarineDataState>({
forecast: null,
tide: null,
loading: false,
error: null,
forecastError: null,
tideError: null,
queryPosition: null,
refreshedAt: null
});
useEffect(() => {
if (!queryPosition || !key) {
return;
}
let cancelled = false;
const refresh = () => {
setState((current) => {
const sameQuery =
current.queryPosition?.lat === queryPosition.lat &&
current.queryPosition?.lon === queryPosition.lon;
return {
...current,
forecast: sameQuery ? current.forecast : null,
tide: sameQuery ? current.tide : null,
loading: true,
error: null,
forecastError: null,
tideError: null,
queryPosition
};
});
void Promise.allSettled([
getMarineForecast(queryPosition),
getNearestTide(queryPosition)
]).then(([forecastResult, tideResult]) => {
if (cancelled) {
return;
}
setState((current) => {
const forecastError =
forecastResult.status === "rejected" ? "Wetterdaten nicht erreichbar" : null;
const tideError =
tideResult.status === "rejected" ? "Tidendaten nicht erreichbar" : null;
return {
...current,
forecast:
forecastResult.status === "fulfilled"
? forecastResult.value
: current.forecast,
tide:
tideResult.status === "fulfilled"
? tideResult.value
: current.tide,
loading: false,
error:
forecastError && tideError ? "Metocean-Daten nicht erreichbar" : null,
forecastError,
tideError,
queryPosition,
refreshedAt: new Date().toISOString()
};
});
});
};
refresh();
const intervalId = window.setInterval(refresh, REFRESH_INTERVAL_MS);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [key, queryPosition]);
return state;
}
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { Coordinate, RouteResult } from "@watermaps/shared";
import { evaluateRouteDeviation } from "../lib/route-deviation";
export type RouteDeviationAlarmStatus =
| "idle"
| "requesting"
| "tracking"
| "denied"
| "unavailable"
| "error";
export type RouteDeviationAlarmState = {
status: RouteDeviationAlarmStatus;
position: Coordinate | null;
accuracyM: number | null;
distanceM: number | null;
reliable: boolean;
isOffRoute: boolean;
message: string | null;
};
const INITIAL_STATE: RouteDeviationAlarmState = {
status: "idle",
position: null,
accuracyM: null,
distanceM: null,
reliable: true,
isOffRoute: false,
message: null
};
/**
* Watches the device position only after start() is called from a user action.
* Coordinates are evaluated in memory and are never persisted or transmitted.
*/
export function useRouteDeviationAlarm(route: RouteResult | null, thresholdM: number) {
const [state, setState] = useState<RouteDeviationAlarmState>(INITIAL_STATE);
const watchId = useRef<number | null>(null);
const activeRoute = useRef<RouteResult | null>(route);
const threshold = useRef(thresholdM);
const offRoute = useRef(false);
const monitoringGeneration = useRef(0);
activeRoute.current = route;
threshold.current = thresholdM;
const stop = useCallback(() => {
monitoringGeneration.current += 1;
if (watchId.current !== null && typeof navigator !== "undefined" && "geolocation" in navigator) {
navigator.geolocation.clearWatch(watchId.current);
watchId.current = null;
}
offRoute.current = false;
setState(INITIAL_STATE);
}, []);
const start = useCallback(() => {
if (!activeRoute.current) {
setState({ ...INITIAL_STATE, status: "error", message: "Zuerst eine Route berechnen oder offline laden." });
return;
}
if (typeof window !== "undefined" && window.isSecureContext === false) {
setState({ ...INITIAL_STATE, status: "unavailable", message: "Der Abweichungsalarm benötigt HTTPS oder localhost." });
return;
}
if (typeof navigator === "undefined" || !("geolocation" in navigator)) {
setState({ ...INITIAL_STATE, status: "unavailable", message: "GPS ist auf diesem Gerät nicht verfügbar." });
return;
}
if (watchId.current !== null) {
navigator.geolocation.clearWatch(watchId.current);
}
monitoringGeneration.current += 1;
const generation = monitoringGeneration.current;
offRoute.current = false;
setState({ ...INITIAL_STATE, status: "requesting", message: "GPS-Freigabe wird angefragt …" });
try {
watchId.current = navigator.geolocation.watchPosition(
(position) => {
if (monitoringGeneration.current !== generation) {
return;
}
const coordinate = { lat: position.coords.latitude, lon: position.coords.longitude };
const result = activeRoute.current
? evaluateRouteDeviation(coordinate, activeRoute.current, {
thresholdM: threshold.current,
accuracyM: position.coords.accuracy
})
: null;
if (!result) {
setState({
...INITIAL_STATE,
status: "error",
message: "Der Abstand zu dieser Route konnte nicht bestimmt werden."
});
return;
}
if (result.isOffRoute && !offRoute.current && typeof navigator.vibrate === "function") {
navigator.vibrate([200, 100, 200]);
}
offRoute.current = result.isOffRoute;
setState({
status: "tracking",
position: coordinate,
accuracyM: result.accuracyM,
distanceM: result.distanceM,
reliable: result.reliable,
isOffRoute: result.isOffRoute,
message: result.reliable
? result.isOffRoute
? `Achtung: ${Math.round(result.distanceM)} m von der Route entfernt.`
: `Auf Kurs · ${Math.round(result.distanceM)} m zur Route.`
: `GPS noch zu ungenau (±${Math.round(result.accuracyM ?? 0)} m) kein Alarm.`
});
},
(error) => {
if (monitoringGeneration.current !== generation) {
return;
}
watchId.current = null;
const denied = error.code === error.PERMISSION_DENIED;
setState({
...INITIAL_STATE,
status: denied ? "denied" : "error",
message: denied ? "GPS-Freigabe wurde abgelehnt." : error.message || "GPS-Position nicht verfügbar."
});
},
{
enableHighAccuracy: true,
timeout: 15_000,
maximumAge: 3_000
}
);
} catch {
watchId.current = null;
setState({ ...INITIAL_STATE, status: "unavailable", message: "GPS konnte nicht gestartet werden." });
}
}, []);
useEffect(() => stop, [stop]);
// A newly selected route requires a deliberate restart, so the monitor can
// never silently continue against a different voyage.
const previousRoute = useRef(route);
useEffect(() => {
if (previousRoute.current !== route) {
previousRoute.current = route;
if (watchId.current !== null) {
stop();
}
}
}, [route, stop]);
return {
...state,
active: watchId.current !== null,
start,
stop
};
}
+157
View File
@@ -0,0 +1,157 @@
import type { RouteResult } from "@watermaps/shared";
const DEFAULT_CREATOR = "Watermaps";
export type GpxExportOptions = {
name?: string;
description?: string;
creator?: string;
createdAt?: Date | string;
};
/**
* Creates a standards-compliant GPX 1.1 document containing both the planned
* route and a track. Keeping both makes the export useful in navigation apps
* which support only one of the two GPX representations.
*/
export function createRouteGpx(route: RouteResult, options: GpxExportOptions = {}): string {
const coordinates = validRouteCoordinates(route);
const name = cleanText(options.name ?? route.name ?? "Watermaps Bootsroute", 120);
const description = cleanText(
options.description ?? `${route.distanceNm.toFixed(1)} sm · ${route.routingMode === "fairway" ? "Fahrwasserroute" : "Bootsroute"}`,
500
);
const creator = cleanText(options.creator ?? DEFAULT_CREATOR, 120);
const createdAt = normalizeDate(options.createdAt);
const source = cleanText(route.dataSources.join(", ") || DEFAULT_CREATOR, 500);
const warningSummary = cleanText(
route.warnings.map((warning) => warning.message).join(" · ") || "Nicht amtliche Routenplanung",
1_000
);
const bounds = routeBounds(coordinates);
const routePoints = coordinates.map(([lon, lat]) => ` <rtept lat="${formatCoordinate(lat)}" lon="${formatCoordinate(lon)}"/>`).join("\n");
const trackPoints = coordinates.map(([lon, lat]) => ` <trkpt lat="${formatCoordinate(lat)}" lon="${formatCoordinate(lon)}"/>`).join("\n");
return [
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
`<gpx version="1.1" creator="${escapeXml(creator)}" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">`,
" <metadata>",
` <name>${escapeXml(name)}</name>`,
` <desc>${escapeXml(description)}</desc>`,
" <author>",
` <name>${escapeXml(creator)}</name>`,
" </author>",
` <time>${createdAt}</time>`,
" <keywords>Watermaps, Bootsroute, Navigation</keywords>",
` <bounds minlat="${formatCoordinate(bounds.minLat)}" minlon="${formatCoordinate(bounds.minLon)}" maxlat="${formatCoordinate(bounds.maxLat)}" maxlon="${formatCoordinate(bounds.maxLon)}"/>`,
" </metadata>",
" <rte>",
` <name>${escapeXml(name)}</name>`,
` <cmt>${escapeXml(warningSummary)}</cmt>`,
` <desc>${escapeXml(description)}</desc>`,
` <src>${escapeXml(source)}</src>`,
" <number>1</number>",
" <type>Motorboating</type>",
routePoints,
" </rte>",
" <trk>",
` <name>${escapeXml(name)}</name>`,
` <cmt>${escapeXml(warningSummary)}</cmt>`,
` <desc>${escapeXml(description)}</desc>`,
` <src>${escapeXml(source)}</src>`,
" <number>1</number>",
" <type>Motorboating</type>",
" <trkseg>",
trackPoints,
" </trkseg>",
" </trk>",
"</gpx>",
""
].join("\n");
}
export function downloadRouteGpx(route: RouteResult, options: GpxExportOptions = {}): void {
if (typeof document === "undefined" || typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
throw new Error("GPX-Download wird von diesem Browser nicht unterstützt.");
}
const name = cleanText(options.name ?? route.name ?? "Watermaps-Route", 120);
const filename = `${safeFilename(name)}.gpx`;
const blobUrl = URL.createObjectURL(new Blob([createRouteGpx(route, options)], { type: "application/gpx+xml;charset=utf-8" }));
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
link.rel = "noopener";
link.hidden = true;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
}
function validRouteCoordinates(route: RouteResult): [number, number][] {
if (route.geometry.type !== "LineString" || route.geometry.coordinates.length < 2) {
throw new Error("Die Route enthält nicht genügend Punkte für einen GPX-Export.");
}
const coordinates = route.geometry.coordinates.map((coordinate) => {
const [lon, lat] = coordinate;
if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
throw new Error("Die Route enthält ungültige Koordinaten.");
}
return [lon, lat] as [number, number];
});
return coordinates;
}
function routeBounds(coordinates: [number, number][]) {
let minLat = 90;
let maxLat = -90;
let minLon = 180;
let maxLon = -180;
for (const [lon, lat] of coordinates) {
minLat = Math.min(minLat, lat);
maxLat = Math.max(maxLat, lat);
minLon = Math.min(minLon, lon);
maxLon = Math.max(maxLon, lon);
}
return { minLat, maxLat, minLon, maxLon };
}
function normalizeDate(value: Date | string | undefined): string {
const date = value instanceof Date ? value : value ? new Date(value) : new Date();
if (!Number.isFinite(date.getTime())) {
throw new Error("Ungültiger Zeitstempel für den GPX-Export.");
}
return date.toISOString();
}
function formatCoordinate(value: number): string {
return value.toFixed(7).replace(/\.?0+$/, "");
}
function cleanText(value: string, maxLength: number): string {
const cleaned = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, " ").trim();
return cleaned.slice(0, maxLength) || DEFAULT_CREATOR;
}
function escapeXml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function safeFilename(value: string): string {
const normalized = value
.normalize("NFKD")
.replace(/[\\/:*?"<>|\u0000-\u001F]/g, "-")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.replace(/^[-.]+|[-.]+$/g, "")
.slice(0, 80);
return normalized || "Watermaps-Route";
}
+351
View File
@@ -0,0 +1,351 @@
import type { Coordinate, RouteResult, RouteWarning, VesselProfile } from "@watermaps/shared";
export const OFFLINE_VOYAGES_STORAGE_KEY = "watermaps.offline-voyages.v1";
export const LEGACY_OFFLINE_VOYAGES_STORAGE_KEY = "seacompass.offline-voyages.v1";
export const MAX_OFFLINE_VOYAGES = 20;
const MAX_SERIALIZED_BYTES = 4_000_000;
const MAX_ROUTE_POINTS = 25_000;
export type OfflineVoyagePlan = {
start: Coordinate;
destination: Coordinate;
waypoints: Coordinate[];
vesselProfile: VesselProfile | null;
departureAt: string | null;
notes: string | null;
};
export type OfflineVoyagePlanInput = Partial<OfflineVoyagePlan>;
export type OfflineVoyage = {
schemaVersion: 1;
id: string;
name: string;
savedAt: string;
plan: OfflineVoyagePlan;
route: RouteResult;
};
export type SaveOfflineVoyageInput = {
route: RouteResult;
name?: string;
plan?: OfflineVoyagePlanInput;
};
export type OfflineVoyageRecordOptions = {
id?: string;
savedAt?: Date | string;
};
export class OfflineVoyageStorageError extends Error {
override name = "OfflineVoyageStorageError";
}
/**
* Saves one validated snapshot atomically. Only route-planning fields are
* whitelisted; live GPS positions are deliberately never persisted.
*/
export function saveOfflineVoyage(
input: SaveOfflineVoyageInput,
storage: Storage = browserStorage(),
options: OfflineVoyageRecordOptions = {}
): OfflineVoyage {
const record = createOfflineVoyageRecord(input, options);
const records = listOfflineVoyages(storage).filter((item) => item.id !== record.id);
const next = [record, ...records].slice(0, MAX_OFFLINE_VOYAGES);
const serialized = JSON.stringify(next);
if (serialized.length > MAX_SERIALIZED_BYTES) {
throw new OfflineVoyageStorageError("Die Route ist zu groß für den Offline-Speicher.");
}
try {
storage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, serialized);
} catch {
throw new OfflineVoyageStorageError("Die Route konnte auf diesem Gerät nicht gespeichert werden.");
}
return record;
}
export function listOfflineVoyages(storage: Storage = browserStorage()): OfflineVoyage[] {
let serialized: string | null;
try {
serialized =
storage.getItem(OFFLINE_VOYAGES_STORAGE_KEY) ??
storage.getItem(LEGACY_OFFLINE_VOYAGES_STORAGE_KEY);
} catch {
throw new OfflineVoyageStorageError("Der Offline-Speicher ist nicht verfügbar.");
}
if (!serialized || serialized.length > MAX_SERIALIZED_BYTES) {
return [];
}
try {
const parsed: unknown = JSON.parse(serialized);
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.slice(0, MAX_OFFLINE_VOYAGES)
.map(parseOfflineVoyage)
.filter((item): item is OfflineVoyage => item !== null)
.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
} catch {
return [];
}
}
export function loadOfflineVoyage(id: string, storage: Storage = browserStorage()): OfflineVoyage | null {
const safeId = limitedString(id, 160);
if (!safeId) {
return null;
}
return listOfflineVoyages(storage).find((record) => record.id === safeId) ?? null;
}
export function deleteOfflineVoyage(id: string, storage: Storage = browserStorage()): boolean {
const records = listOfflineVoyages(storage);
const next = records.filter((record) => record.id !== id);
if (next.length === records.length) {
return false;
}
try {
storage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, JSON.stringify(next));
} catch {
throw new OfflineVoyageStorageError("Die Offline-Route konnte nicht gelöscht werden.");
}
return true;
}
/** Best-effort protection against automatic browser eviction after a user saves a route. */
export async function requestPersistentOfflineStorage(): Promise<boolean | null> {
if (typeof navigator === "undefined" || !navigator.storage?.persist) {
return null;
}
try {
if (await navigator.storage.persisted()) {
return true;
}
return await navigator.storage.persist();
} catch {
return null;
}
}
export function createOfflineVoyageRecord(
input: SaveOfflineVoyageInput,
options: OfflineVoyageRecordOptions = {}
): OfflineVoyage {
const route = normalizeRoute(input.route);
const first = route.geometry.coordinates[0]!;
const last = route.geometry.coordinates.at(-1)!;
const savedAt = normalizeIsoDate(options.savedAt ?? new Date());
const name = limitedString(input.name ?? route.name ?? "Offline-Bootsroute", 120) || "Offline-Bootsroute";
const id = limitedString(options.id ?? createId(savedAt), 160);
if (!id) {
throw new OfflineVoyageStorageError("Ungültige Kennung für die Offline-Route.");
}
const defaultStart = { lat: first[1], lon: first[0] };
const defaultDestination = { lat: last[1], lon: last[0] };
const planInput = input.plan ?? {};
return {
schemaVersion: 1,
id,
name,
savedAt,
plan: {
start: normalizeCoordinate(planInput.start ?? defaultStart),
destination: normalizeCoordinate(planInput.destination ?? defaultDestination),
waypoints: normalizeCoordinateList(planInput.waypoints ?? []),
vesselProfile: planInput.vesselProfile ? normalizeVesselProfile(planInput.vesselProfile) : null,
departureAt: planInput.departureAt ? normalizeIsoDate(planInput.departureAt) : null,
notes: planInput.notes ? limitedString(planInput.notes, 2_000) : null
},
route
};
}
function parseOfflineVoyage(value: unknown): OfflineVoyage | null {
if (!isRecord(value) || value.schemaVersion !== 1) {
return null;
}
try {
const id = limitedString(value.id, 160);
const name = limitedString(value.name, 120);
if (!id || !name || !isRecord(value.plan)) {
return null;
}
const plan = value.plan;
return {
schemaVersion: 1,
id,
name,
savedAt: normalizeIsoDate(value.savedAt),
plan: {
start: normalizeCoordinate(plan.start),
destination: normalizeCoordinate(plan.destination),
waypoints: normalizeCoordinateList(plan.waypoints),
vesselProfile: plan.vesselProfile === null ? null : normalizeVesselProfile(plan.vesselProfile),
departureAt: plan.departureAt === null ? null : normalizeIsoDate(plan.departureAt),
notes: plan.notes === null ? null : limitedString(plan.notes, 2_000)
},
route: normalizeRoute(value.route)
};
} catch {
return null;
}
}
function normalizeRoute(value: unknown): RouteResult {
if (!isRecord(value) || !isRecord(value.geometry) || value.geometry.type !== "LineString") {
throw new OfflineVoyageStorageError("Die Offline-Route hat ein ungültiges Format.");
}
const coordinates = normalizeLineCoordinates(value.geometry.coordinates);
const distanceNm = finiteNumber(value.distanceNm, 0, 100_000);
const eta = value.eta === null ? null : limitedString(value.eta, 100);
const minKnownDepthM = value.minKnownDepthM === null ? null : finiteNumber(value.minKnownDepthM, 0, 20_000);
const unknownDepthRatio = finiteNumber(value.unknownDepthRatio, 0, 1);
const warnings = normalizeWarnings(value.warnings);
const dataSources = normalizeStrings(value.dataSources, 100, 500);
const id = value.id === undefined ? undefined : limitedString(value.id, 160);
const name = value.name === undefined ? undefined : limitedString(value.name, 120);
const routingMode = value.routingMode === "manual" || value.routingMode === "fairway" ? value.routingMode : undefined;
const departureTime =
typeof value.departureTime === "string" ? normalizeIsoDate(value.departureTime) : undefined;
const durationMinutes =
value.durationMinutes === undefined ? undefined : finiteNumber(value.durationMinutes, 0, 10_000_000);
return {
...(id ? { id } : {}),
...(name ? { name } : {}),
geometry: { type: "LineString", coordinates },
distanceNm,
eta,
warnings,
minKnownDepthM,
unknownDepthRatio,
dataSources,
...(departureTime ? { departureTime } : {}),
...(durationMinutes !== undefined ? { durationMinutes } : {}),
...(routingMode ? { routingMode } : {})
};
}
function normalizeLineCoordinates(value: unknown): [number, number][] {
if (!Array.isArray(value) || value.length < 2 || value.length > MAX_ROUTE_POINTS) {
throw new OfflineVoyageStorageError("Die Offline-Route enthält keine gültige Liniengeometrie.");
}
return value.map((item) => {
if (!Array.isArray(item) || item.length < 2) {
throw new OfflineVoyageStorageError("Die Offline-Route enthält ungültige Koordinaten.");
}
const lon = finiteNumber(item[0], -180, 180);
const lat = finiteNumber(item[1], -90, 90);
return [lon, lat];
});
}
function normalizeCoordinateList(value: unknown): Coordinate[] {
if (!Array.isArray(value) || value.length > 1_000) {
throw new OfflineVoyageStorageError("Die Wegpunktliste ist ungültig.");
}
return value.map(normalizeCoordinate);
}
function normalizeCoordinate(value: unknown): Coordinate {
if (!isRecord(value)) {
throw new OfflineVoyageStorageError("Eine Plankoordinate ist ungültig.");
}
return {
lat: finiteNumber(value.lat, -90, 90),
lon: finiteNumber(value.lon, -180, 180)
};
}
function normalizeVesselProfile(value: unknown): VesselProfile {
if (!isRecord(value)) {
throw new OfflineVoyageStorageError("Das Bootsprofil ist ungültig.");
}
const profile: VesselProfile = {
draughtM: finiteNumber(value.draughtM, 0, 100),
safetyReserveM: finiteNumber(value.safetyReserveM, 0, 100)
};
if (value.airDraftM !== undefined) {
profile.airDraftM = finiteNumber(value.airDraftM, 0, 200);
}
if (value.beamM !== undefined) {
profile.beamM = finiteNumber(value.beamM, 0, 200);
}
if (value.cruiseSpeedKn !== undefined) {
profile.cruiseSpeedKn = finiteNumber(value.cruiseSpeedKn, 0.1, 200);
}
return profile;
}
function normalizeWarnings(value: unknown): RouteWarning[] {
if (!Array.isArray(value) || value.length > 500) {
throw new OfflineVoyageStorageError("Die Routenwarnungen sind ungültig.");
}
return value.map((warning) => {
if (!isRecord(warning)) {
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
}
const code = limitedString(warning.code, 100);
const message = limitedString(warning.message, 1_000);
const severity = warning.severity;
if (!code || !message || (severity !== "info" && severity !== "caution" && severity !== "critical")) {
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
}
return {
code,
message,
severity,
...(warning.coordinate === undefined ? {} : { coordinate: normalizeCoordinate(warning.coordinate) })
};
});
}
function normalizeStrings(value: unknown, maxItems: number, maxLength: number): string[] {
if (!Array.isArray(value) || value.length > maxItems) {
throw new OfflineVoyageStorageError("Die Quellenangaben der Route sind ungültig.");
}
return value.map((item) => limitedString(item, maxLength)).filter((item) => item.length > 0);
}
function finiteNumber(value: unknown, min: number, max: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < min || value > max) {
throw new OfflineVoyageStorageError("Die Offline-Route enthält einen ungültigen Zahlenwert.");
}
return value;
}
function limitedString(value: unknown, maxLength: number): string {
return typeof value === "string" ? value.replace(/[\u0000-\u001F\u007F]/g, " ").trim().slice(0, maxLength) : "";
}
function normalizeIsoDate(value: unknown): string {
const date = value instanceof Date ? value : typeof value === "string" ? new Date(value) : new Date(Number.NaN);
if (!Number.isFinite(date.getTime())) {
throw new OfflineVoyageStorageError("Der Zeitstempel der Offline-Route ist ungültig.");
}
return date.toISOString();
}
function createId(savedAt: string): string {
const randomId = typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: Math.random().toString(36).slice(2, 14);
return `voyage-${savedAt.replace(/\D/g, "").slice(0, 14)}-${randomId}`;
}
function browserStorage(): Storage {
if (typeof window === "undefined" || !window.localStorage) {
throw new OfflineVoyageStorageError("Der Offline-Speicher wird von diesem Browser nicht unterstützt.");
}
return window.localStorage;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+176
View File
@@ -0,0 +1,176 @@
import type { Coordinate, GeoJsonLineString, RouteResult } from "@watermaps/shared";
const EARTH_RADIUS_M = 6_371_008.8;
const MIN_SEGMENT_ANGLE = 1e-12;
export type RouteDeviationOptions = {
thresholdM?: number;
accuracyM?: number | null;
maxAccuracyM?: number;
};
export type RouteDeviationResult = {
distanceM: number;
conservativeDistanceM: number;
thresholdM: number;
accuracyM: number | null;
reliable: boolean;
isOffRoute: boolean;
};
/** Returns the shortest geodesic distance from a point to the route segments. */
export function distanceToRouteM(
position: Coordinate,
route: RouteResult | GeoJsonLineString | readonly [number, number][]
): number | null {
if (!isCoordinate(position)) {
return null;
}
const coordinates = routeCoordinates(route);
if (coordinates.length === 0 || coordinates.some((coordinate) => !isGeoJsonCoordinate(coordinate))) {
return null;
}
if (coordinates.length === 1) {
return angularDistance(position, toCoordinate(coordinates[0]!)) * EARTH_RADIUS_M;
}
let closestM = Number.POSITIVE_INFINITY;
for (let index = 1; index < coordinates.length; index += 1) {
const start = toCoordinate(coordinates[index - 1]!);
const end = toCoordinate(coordinates[index]!);
closestM = Math.min(closestM, distanceToGreatCircleSegmentM(position, start, end));
}
return Number.isFinite(closestM) ? closestM : null;
}
/**
* Applies GPS accuracy conservatively: an alarm is emitted only when even the
* nearest edge of the reported accuracy circle is outside the corridor.
*/
export function evaluateRouteDeviation(
position: Coordinate,
route: RouteResult | GeoJsonLineString | readonly [number, number][],
options: RouteDeviationOptions = {}
): RouteDeviationResult | null {
const thresholdM = finiteRange(options.thresholdM ?? 100, 10, 10_000);
const distanceM = distanceToRouteM(position, route);
if (distanceM === null) {
return null;
}
const accuracyM = typeof options.accuracyM === "number" && Number.isFinite(options.accuracyM) && options.accuracyM >= 0
? options.accuracyM
: null;
const maxAccuracyM = finiteRange(options.maxAccuracyM ?? Math.max(100, thresholdM * 2), 10, 20_000);
const reliable = accuracyM === null || accuracyM <= maxAccuracyM;
const conservativeDistanceM = Math.max(0, distanceM - (accuracyM ?? 0));
return {
distanceM,
conservativeDistanceM,
thresholdM,
accuracyM,
reliable,
isOffRoute: reliable && conservativeDistanceM > thresholdM
};
}
function distanceToGreatCircleSegmentM(point: Coordinate, start: Coordinate, end: Coordinate): number {
const segmentAngle = angularDistance(start, end);
if (segmentAngle < MIN_SEGMENT_ANGLE) {
return angularDistance(point, start) * EARTH_RADIUS_M;
}
const pointAngle = angularDistance(start, point);
if (pointAngle < MIN_SEGMENT_ANGLE) {
return 0;
}
const segmentBearing = initialBearingRad(start, end);
const pointBearing = initialBearingRad(start, point);
const bearingDelta = pointBearing - segmentBearing;
const crossTrackAngle = Math.asin(clamp(Math.sin(pointAngle) * Math.sin(bearingDelta), -1, 1));
const alongTrackAngle = Math.atan2(
Math.sin(pointAngle) * Math.cos(bearingDelta),
Math.cos(pointAngle)
);
if (alongTrackAngle <= 0) {
return pointAngle * EARTH_RADIUS_M;
}
if (alongTrackAngle >= segmentAngle) {
return angularDistance(point, end) * EARTH_RADIUS_M;
}
return Math.abs(crossTrackAngle) * EARTH_RADIUS_M;
}
function angularDistance(a: Coordinate, b: Coordinate): number {
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);
const deltaLat = lat2 - lat1;
const deltaLon = normalizeRadians(toRadians(b.lon - a.lon));
const haversine = Math.sin(deltaLat / 2) ** 2
+ Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
return 2 * Math.asin(Math.sqrt(clamp(haversine, 0, 1)));
}
function initialBearingRad(a: Coordinate, b: Coordinate): number {
const lat1 = toRadians(a.lat);
const lat2 = toRadians(b.lat);
const deltaLon = normalizeRadians(toRadians(b.lon - a.lon));
return Math.atan2(
Math.sin(deltaLon) * Math.cos(lat2),
Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(deltaLon)
);
}
function routeCoordinates(route: RouteResult | GeoJsonLineString | readonly [number, number][]): readonly [number, number][] {
if (Array.isArray(route)) {
return route as readonly [number, number][];
}
if ("geometry" in route) {
return route.geometry.coordinates;
}
return (route as GeoJsonLineString).coordinates;
}
function isGeoJsonCoordinate(value: unknown): value is [number, number] {
return Array.isArray(value)
&& value.length >= 2
&& typeof value[0] === "number"
&& Number.isFinite(value[0])
&& value[0] >= -180
&& value[0] <= 180
&& typeof value[1] === "number"
&& Number.isFinite(value[1])
&& value[1] >= -90
&& value[1] <= 90;
}
function isCoordinate(value: Coordinate): boolean {
return Number.isFinite(value.lat)
&& value.lat >= -90
&& value.lat <= 90
&& Number.isFinite(value.lon)
&& value.lon >= -180
&& value.lon <= 180;
}
function toCoordinate(value: [number, number]): Coordinate {
return { lon: value[0], lat: value[1] };
}
function toRadians(value: number): number {
return value * Math.PI / 180;
}
function normalizeRadians(value: number): number {
return ((value + Math.PI) % (2 * Math.PI) + 2 * Math.PI) % (2 * Math.PI) - Math.PI;
}
function finiteRange(value: number, min: number, max: number): number {
return Number.isFinite(value) ? clamp(value, min, max) : min;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
+10
View File
@@ -0,0 +1,10 @@
import "./styles/app.css";
import React from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+322
View File
@@ -0,0 +1,322 @@
import {
orderWaypointsAlongRoute,
type Coordinate,
type RouteResult,
type VoyageHarbour
} from "@watermaps/shared";
import type { RouteBridgeAssessment } from "./routeWeatherReport";
import type { RouteLock } from "./voyageHarbours";
export type RouteEventKind = "harbour" | "lock" | "bridge";
export type RouteEventCorridors = Record<RouteEventKind, number>;
export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly<RouteEventCorridors> = Object.freeze({
harbour: 1.5,
lock: 0.25,
bridge: 0.08
});
export type RouteEventEtaSpeedSource = "gps-sog" | "vessel-cruise-speed";
export type RouteEventEtaReferenceSource = "current-time" | "route-departure";
/**
* ETA assumptions are deliberately supplied by the caller. This prevents a
* stale GPS speed or a planned cruise speed from being presented without its
* provenance.
*/
export type RouteEventEtaBasis = {
speedKn: number;
speedSource: RouteEventEtaSpeedSource;
referenceTime: string | number | Date;
referenceSource: RouteEventEtaReferenceSource;
};
export type RouteEventEta = {
estimatedAt: string;
minutesFromProgress: number;
speedKn: number;
speedSource: RouteEventEtaSpeedSource;
referenceTime: string;
referenceSource: RouteEventEtaReferenceSource;
};
type RouteEventBase = {
kind: RouteEventKind;
id: string;
name: string;
coordinate: Coordinate;
/** Position of the projected feature along the routed geometry. */
routeDistanceNm: number;
/** Shortest lateral distance between the feature and the route. */
distanceFromRouteNm: number;
remainingNm: number;
eta: RouteEventEta | null;
};
export type HarbourRouteEvent = RouteEventBase & {
kind: "harbour";
feature: VoyageHarbour;
};
export type LockRouteEvent = RouteEventBase & {
kind: "lock";
feature: RouteLock;
};
export type BridgeRouteEvent = RouteEventBase & {
kind: "bridge";
feature: RouteBridgeAssessment;
};
export type UpcomingRouteEvent =
| HarbourRouteEvent
| LockRouteEvent
| BridgeRouteEvent;
export type NextRouteEventsByKind = {
harbour: HarbourRouteEvent | null;
lock: LockRouteEvent | null;
bridge: BridgeRouteEvent | null;
};
export type UpcomingRouteEventsInput = {
route: Pick<RouteResult, "geometry" | "distanceNm">;
harbours?: readonly VoyageHarbour[];
locks?: readonly RouteLock[];
bridges?: readonly RouteBridgeAssessment[];
/** Progress along the route. Invalid or negative values resolve to zero. */
progressNm?: number | null;
corridorsNm?: Partial<RouteEventCorridors>;
etaBasis?: RouteEventEtaBasis | null;
};
type RouteEventCandidate =
| {
projectionId: string;
kind: "harbour";
id: string;
name: string;
coordinate: Coordinate;
feature: VoyageHarbour;
}
| {
projectionId: string;
kind: "lock";
id: string;
name: string;
coordinate: Coordinate;
feature: RouteLock;
}
| {
projectionId: string;
kind: "bridge";
id: string;
name: string;
coordinate: Coordinate;
feature: RouteBridgeAssessment;
};
/**
* Projects all supplied facilities onto the route, applies a corridor per
* facility type and returns only the current or upcoming facilities in route
* order.
*
* Every feature is projected again. In particular,
* RouteBridgeAssessment.distanceNm is intentionally ignored because it is the
* bridge's lateral distance to the route, not its distance along the route.
*/
export function upcomingRouteEvents(
input: UpcomingRouteEventsInput
): UpcomingRouteEvent[] {
const progressNm = normalizeProgress(input.progressNm);
const corridors = resolveCorridors(input.corridorsNm);
const candidates = routeEventCandidates(input);
if (candidates.length === 0) {
return [];
}
const candidatesByProjectionId = new Map(
candidates.map((candidate) => [candidate.projectionId, candidate])
);
const projected = orderWaypointsAlongRoute(
candidates.map((candidate) => ({
id: candidate.projectionId,
name: candidate.name,
coordinate: candidate.coordinate
})),
input.route
);
return projected.flatMap<UpcomingRouteEvent>((projection) => {
const candidate = candidatesByProjectionId.get(projection.id);
if (
!candidate ||
projection.distanceFromRouteNm > corridors[candidate.kind] ||
projection.routeDistanceNm < progressNm
) {
return [];
}
const remainingNm = Math.max(0, projection.routeDistanceNm - progressNm);
const common = {
kind: candidate.kind,
id: candidate.id,
name: candidate.name,
coordinate: candidate.coordinate,
routeDistanceNm: projection.routeDistanceNm,
distanceFromRouteNm: projection.distanceFromRouteNm,
remainingNm,
eta: estimateRouteEventEta(remainingNm, input.etaBasis)
};
switch (candidate.kind) {
case "harbour":
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
case "lock":
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
case "bridge":
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
}
});
}
export function nextRouteEventsByKind(
events: readonly UpcomingRouteEvent[]
): NextRouteEventsByKind {
const next: NextRouteEventsByKind = {
harbour: null,
lock: null,
bridge: null
};
for (const event of events) {
switch (event.kind) {
case "harbour":
next.harbour ??= event;
break;
case "lock":
next.lock ??= event;
break;
case "bridge":
next.bridge ??= event;
break;
}
}
return next;
}
function routeEventCandidates(input: UpcomingRouteEventsInput): RouteEventCandidate[] {
const candidates: RouteEventCandidate[] = [];
input.harbours?.forEach((feature, index) => {
candidates.push({
projectionId: projectionId("harbour", index, feature.id),
kind: "harbour",
id: feature.id,
name: feature.name,
coordinate: feature.coordinate,
feature
});
});
input.locks?.forEach((feature, index) => {
candidates.push({
projectionId: projectionId("lock", index, feature.id),
kind: "lock",
id: feature.id,
name: feature.name,
coordinate: feature.coordinate,
feature
});
});
input.bridges?.forEach((feature, index) => {
candidates.push({
projectionId: projectionId("bridge", index, feature.id),
kind: "bridge",
id: feature.id,
name: feature.name ?? feature.label ?? "Brücke",
coordinate: feature.coordinate,
feature
});
});
return candidates;
}
function projectionId(kind: RouteEventKind, index: number, featureId: string) {
return `${kind}:${index}:${featureId}`;
}
function resolveCorridors(
overrides: Partial<RouteEventCorridors> | undefined
): RouteEventCorridors {
return {
harbour: nonNegativeOrDefault(
overrides?.harbour,
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour
),
lock: nonNegativeOrDefault(
overrides?.lock,
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.lock
),
bridge: nonNegativeOrDefault(
overrides?.bridge,
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.bridge
)
};
}
function normalizeProgress(value: number | null | undefined) {
return typeof value === "number" && Number.isFinite(value)
? Math.max(0, value)
: 0;
}
function nonNegativeOrDefault(value: number | undefined, fallback: number) {
return typeof value === "number" && Number.isFinite(value) && value >= 0
? value
: fallback;
}
function estimateRouteEventEta(
remainingNm: number,
basis: RouteEventEtaBasis | null | undefined
): RouteEventEta | null {
if (
!basis ||
!Number.isFinite(basis.speedKn) ||
basis.speedKn <= 0
) {
return null;
}
const referenceTimestamp = timestampValue(basis.referenceTime);
if (referenceTimestamp === null) {
return null;
}
const minutesFromProgress = (remainingNm / basis.speedKn) * 60;
const estimatedTimestamp =
referenceTimestamp + minutesFromProgress * 60_000;
if (!Number.isFinite(estimatedTimestamp)) {
return null;
}
return {
estimatedAt: new Date(estimatedTimestamp).toISOString(),
minutesFromProgress,
speedKn: basis.speedKn,
speedSource: basis.speedSource,
referenceTime: new Date(referenceTimestamp).toISOString(),
referenceSource: basis.referenceSource
};
}
function timestampValue(value: string | number | Date): number | null {
const timestamp =
value instanceof Date
? value.getTime()
: typeof value === "number"
? value
: Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}
+792
View File
@@ -0,0 +1,792 @@
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
import {
haversineDistanceNm,
initialBearingDeg,
type Coordinate,
type MarineForecast,
type RouteResult,
type VesselProfile
} from "@watermaps/shared";
export type RouteWeatherSample = {
label: "Start" | "Mitte" | "Ziel";
coordinate: Coordinate;
forecast: MarineForecast;
plannedTime?: string;
routeBearingDeg?: number | null;
currentAlongRouteKn?: number | null;
};
export type RouteWeatherReport = {
samples: RouteWeatherSample[];
maxWaveHeightM: number | null;
maxWindSpeedKn: number | null;
maxWavePeriodS: number | null;
strongestWindDirectionDeg: number | null;
highestWaveDirectionDeg: number | null;
severity: "ok" | "caution" | "critical";
summary: string;
source: string;
updatedAt: string;
unavailableSamples: number;
bridgeReport: RouteBridgeReport | null;
departureTime: string;
adjustedEta: string | null;
currentAdjustmentMinutes: number | null;
averageAlongRouteCurrentKn: number | null;
};
type FetchForecast = (coordinate: Coordinate, at?: string) => Promise<MarineForecast>;
type FetchFeatures = (params: { bbox: [number, number, number, number]; layers: string[] }) => Promise<FeatureCollection>;
type LonLat = [number, number];
type ProjectedPoint = { x: number; y: number };
export type RouteBridgeStatus = "passable" | "tight" | "too_low" | "unknown";
export type RouteBridgeAssessment = {
id: string;
name: string | null;
label: string;
coordinate: Coordinate;
distanceNm: number;
clearanceM: number | null;
clearanceLabel: string | null;
requiredAirDraftM: number | null;
marginM: number | null;
status: RouteBridgeStatus;
source: string;
};
export type RouteBridgeReport = {
bridges: RouteBridgeAssessment[];
requiredAirDraftM: number | null;
checkedCount: number;
unknownCount: number;
tooLowCount: number;
tightCount: number;
minClearanceM: number | null;
severity: "ok" | "caution" | "critical";
summary: string;
source: string;
updatedAt: string;
};
const SAMPLE_TARGETS: Array<{ label: RouteWeatherSample["label"]; ratio: number }> = [
{ label: "Start", ratio: 0 },
{ label: "Mitte", ratio: 0.5 },
{ label: "Ziel", ratio: 1 }
];
const ROUTE_BRIDGE_BBOX_MARGIN_DEG = 0.02;
const ROUTE_BRIDGE_MAX_DISTANCE_NM = 0.08;
const BRIDGE_TIGHT_MARGIN_M = 0.5;
export async function createRouteWeatherReport(
route: RouteResult,
fetchForecast: FetchForecast
): Promise<RouteWeatherReport>;
export async function createRouteWeatherReport(
route: RouteResult,
vesselProfile: VesselProfile,
fetchForecast: FetchForecast,
fetchFeatures?: FetchFeatures,
departureTime?: string
): Promise<RouteWeatherReport>;
export async function createRouteWeatherReport(
route: RouteResult,
vesselProfileOrFetchForecast: VesselProfile | FetchForecast,
maybeFetchForecast?: FetchForecast,
fetchFeatures?: FetchFeatures,
departureTime?: string
): Promise<RouteWeatherReport> {
const vesselProfile: VesselProfile =
typeof vesselProfileOrFetchForecast === "function"
? { draughtM: 0, safetyReserveM: 0 }
: vesselProfileOrFetchForecast;
const fetchForecast =
typeof vesselProfileOrFetchForecast === "function" ? vesselProfileOrFetchForecast : maybeFetchForecast;
if (!fetchForecast) {
throw new Error("Wetterbericht nicht erreichbar");
}
const plannedDeparture = validIso(departureTime ?? route.departureTime) ?? new Date().toISOString();
const cruiseSpeedKn = normalizeSpeed(vesselProfile.cruiseSpeedKn);
const samplePoints = sampleRoute(route).map((sample) => ({
...sample,
plannedTime: new Date(
Date.parse(plannedDeparture) + (route.distanceNm * sample.ratio / cruiseSpeedKn) * 60 * 60 * 1000
).toISOString()
}));
const [results, bridgeReport] = await Promise.all([
Promise.allSettled(
samplePoints.map(async (sample) => ({
...sample,
forecast: await fetchForecast(sample.coordinate, sample.plannedTime)
}))
),
fetchFeatures
? createRouteBridgeReport(route, vesselProfile, fetchFeatures).catch(() => unavailableBridgeReport(vesselProfile))
: Promise.resolve(null)
]);
const samples = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : []));
if (samples.length === 0) {
throw new Error("Wetterbericht nicht erreichbar");
}
const samplesWithCurrent = samples.map((sample) => ({
...sample,
currentAlongRouteKn: alongRouteCurrentKn(sample.forecast, sample.routeBearingDeg)
}));
return summarizeRouteWeather(samplesWithCurrent, results.length - samples.length, bridgeReport, {
departureTime: plannedDeparture,
distanceNm: route.distanceNm,
cruiseSpeedKn
});
}
export async function createRouteBridgeReport(
route: RouteResult,
vesselProfile: Pick<VesselProfile, "airDraftM">,
fetchFeatures: FetchFeatures
): Promise<RouteBridgeReport> {
const routeLine = routeLonLatLine(route);
if (routeLine.length === 0) {
return summarizeBridgeReport([], normalizeMeters(vesselProfile.airDraftM));
}
const features = await fetchFeatures({
bbox: bboxForLine(routeLine, ROUTE_BRIDGE_BBOX_MARGIN_DEG),
layers: ["bridges"]
});
const requiredAirDraftM = normalizeMeters(vesselProfile.airDraftM);
const bridges = features.features
.map((feature) => bridgeAssessmentFromFeature(feature, routeLine, requiredAirDraftM))
.filter((bridge): bridge is RouteBridgeAssessment => Boolean(bridge))
.filter((bridge) => bridge.distanceNm <= ROUTE_BRIDGE_MAX_DISTANCE_NM);
const deduped = dedupeBridges(bridges);
return summarizeBridgeReport(
deduped.sort((left, right) => left.distanceNm - right.distanceNm),
requiredAirDraftM
);
}
export function summarizeRouteWeather(
samples: RouteWeatherSample[],
unavailableSamples = 0,
bridgeReport: RouteBridgeReport | null = null,
planning?: { departureTime: string; distanceNm: number; cruiseSpeedKn: number }
): RouteWeatherReport {
const maxWaveHeightM = maxValue(samples.map((sample) => sample.forecast.waveHeightM));
const maxWindSpeedKn = maxValue(samples.map((sample) => sample.forecast.windSpeed));
const maxWavePeriodS = maxValue(samples.map((sample) => sample.forecast.wavePeriodS));
const strongestWind = maxBy(samples, (sample) => sample.forecast.windSpeed);
const highestWave = maxBy(samples, (sample) => sample.forecast.waveHeightM);
const severity = weatherSeverity(maxWindSpeedKn, maxWaveHeightM);
const currentComponents = samples
.map((sample) => sample.currentAlongRouteKn)
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
const averageAlongRouteCurrentKn =
currentComponents.length > 0
? currentComponents.reduce((sum, value) => sum + value, 0) / currentComponents.length
: null;
const currentTiming = currentAdjustedTiming(planning, averageAlongRouteCurrentKn);
return {
samples,
maxWaveHeightM,
maxWindSpeedKn,
maxWavePeriodS,
strongestWindDirectionDeg: strongestWind?.forecast.windDirectionDeg ?? null,
highestWaveDirectionDeg: highestWave?.forecast.waveDirectionDeg ?? null,
severity,
summary: weatherSummary(severity, maxWindSpeedKn, maxWaveHeightM),
source: unique(samples.map((sample) => sample.forecast.source)).join(", "),
updatedAt: latestIso(samples.map((sample) => sample.forecast.updatedAt)) ?? new Date().toISOString(),
unavailableSamples,
bridgeReport,
departureTime: planning?.departureTime ?? new Date().toISOString(),
adjustedEta: currentTiming.adjustedEta,
currentAdjustmentMinutes: currentTiming.adjustmentMinutes,
averageAlongRouteCurrentKn
};
}
function sampleRoute(route: RouteResult) {
const points = route.geometry.coordinates.map(([lon, lat]) => ({ lat, lon }));
const uniqueSamples = new Map<
string,
{
label: RouteWeatherSample["label"];
coordinate: Coordinate;
ratio: number;
routeBearingDeg: number | null;
}
>();
for (const target of SAMPLE_TARGETS) {
const coordinate = coordinateAtProgress(points, target.ratio);
const key = `${coordinate.lat.toFixed(3)}:${coordinate.lon.toFixed(3)}`;
uniqueSamples.set(key, {
label: target.label,
coordinate,
ratio: target.ratio,
routeBearingDeg: routeBearingAtProgress(points, target.ratio)
});
}
return [...uniqueSamples.values()];
}
function routeBearingAtProgress(points: Coordinate[], ratio: number): number | null {
if (points.length < 2) {
return null;
}
const before = coordinateAtProgress(points, Math.max(0, ratio - 0.01));
const after = coordinateAtProgress(points, Math.min(1, ratio + 0.01));
if (haversineDistanceNm(before, after) < 0.001) {
return null;
}
return initialBearingDeg(before, after);
}
function alongRouteCurrentKn(forecast: MarineForecast, routeBearingDeg?: number | null): number | null {
const speedKn = forecast.oceanCurrentSpeedKn;
const directionDeg = forecast.oceanCurrentDirectionDeg;
if (
typeof speedKn !== "number" ||
!Number.isFinite(speedKn) ||
typeof directionDeg !== "number" ||
!Number.isFinite(directionDeg) ||
typeof routeBearingDeg !== "number"
) {
return null;
}
const angleRad = (((directionDeg - routeBearingDeg + 540) % 360) - 180) * (Math.PI / 180);
return Math.round(speedKn * Math.cos(angleRad) * 100) / 100;
}
function currentAdjustedTiming(
planning: { departureTime: string; distanceNm: number; cruiseSpeedKn: number } | undefined,
averageCurrentKn: number | null
): { adjustedEta: string | null; adjustmentMinutes: number | null } {
if (!planning || averageCurrentKn === null) {
return { adjustedEta: null, adjustmentMinutes: null };
}
const effectiveSpeedKn = Math.max(0.5, planning.cruiseSpeedKn + averageCurrentKn);
const baseMinutes = (planning.distanceNm / planning.cruiseSpeedKn) * 60;
const adjustedMinutes = (planning.distanceNm / effectiveSpeedKn) * 60;
return {
adjustedEta: new Date(Date.parse(planning.departureTime) + adjustedMinutes * 60 * 1000).toISOString(),
adjustmentMinutes: Math.round(adjustedMinutes - baseMinutes)
};
}
function normalizeSpeed(value: number | undefined): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 6;
}
function validIso(value: string | undefined): string | null {
const timestamp = value ? Date.parse(value) : Number.NaN;
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
}
function coordinateAtProgress(points: Coordinate[], ratio: number): Coordinate {
if (points.length === 0) {
return { lat: 0, lon: 0 };
}
if (ratio <= 0 || points.length === 1) {
return points[0]!;
}
if (ratio >= 1) {
return points.at(-1)!;
}
const segmentLengths = points.slice(1).map((point, index) => haversineDistanceNm(points[index]!, point));
const totalDistanceNm = segmentLengths.reduce((sum, length) => sum + length, 0);
const targetDistanceNm = totalDistanceNm * ratio;
let traveledNm = 0;
for (let index = 0; index < segmentLengths.length; index += 1) {
const segmentLengthNm = segmentLengths[index]!;
if (traveledNm + segmentLengthNm >= targetDistanceNm) {
const start = points[index]!;
const end = points[index + 1]!;
const segmentRatio = segmentLengthNm === 0 ? 0 : (targetDistanceNm - traveledNm) / segmentLengthNm;
return {
lat: start.lat + (end.lat - start.lat) * segmentRatio,
lon: start.lon + (end.lon - start.lon) * segmentRatio
};
}
traveledNm += segmentLengthNm;
}
return points.at(-1)!;
}
function maxValue(values: Array<number | null | undefined>) {
const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return valid.length > 0 ? Math.max(...valid) : null;
}
function minValue(values: Array<number | null | undefined>) {
const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return valid.length > 0 ? Math.min(...valid) : null;
}
function maxBy<T>(values: T[], selector: (value: T) => number | null | undefined) {
return values.reduce<T | null>((best, value) => {
const candidate = selector(value);
if (candidate === null || candidate === undefined || !Number.isFinite(candidate)) {
return best;
}
const bestValue = best ? selector(best) : null;
return bestValue === null || bestValue === undefined || candidate > bestValue ? value : best;
}, null);
}
function weatherSeverity(windSpeedKn: number | null, waveHeightM: number | null) {
if (windSpeedKn === null && waveHeightM === null) {
return "caution";
}
if ((windSpeedKn !== null && windSpeedKn >= 27) || (waveHeightM !== null && waveHeightM >= 2)) {
return "critical";
}
if ((windSpeedKn !== null && windSpeedKn >= 16) || (waveHeightM !== null && waveHeightM >= 1)) {
return "caution";
}
return "ok";
}
function weatherSummary(
severity: RouteWeatherReport["severity"],
windSpeedKn: number | null,
waveHeightM: number | null
) {
if (windSpeedKn === null && waveHeightM === null) {
return "Keine belastbare Wetter- oder Wellenprognose für die gewählte Abfahrtszeit.";
}
const wind = windSpeedKn !== null ? `${Math.round(windSpeedKn)} kn Wind` : "Wind unbekannt";
const wave = waveHeightM !== null ? `${waveHeightM.toFixed(1)} m Welle` : "Welle unbekannt";
if (severity === "critical") {
return `Kritische Bedingungen: bis ${wind}, ${wave}.`;
}
if (severity === "caution") {
return `Aufmerksam fahren: bis ${wind}, ${wave}.`;
}
return `Ruhige Bedingungen: bis ${wind}, ${wave}.`;
}
function latestIso(values: string[]) {
const timestamps = values.map((value) => Date.parse(value)).filter((value) => Number.isFinite(value));
return timestamps.length > 0 ? new Date(Math.max(...timestamps)).toISOString() : null;
}
function unique(values: string[]) {
return [...new Set(values.filter(Boolean))];
}
function routeLonLatLine(route: RouteResult): LonLat[] {
return route.geometry.coordinates.map(([lon, lat]) => [lon, lat]);
}
function bboxForLine(line: LonLat[], marginDeg: number): [number, number, number, number] {
const lons = line.map(([lon]) => lon);
const lats = line.map(([, lat]) => lat);
return [
Math.min(...lons) - marginDeg,
Math.min(...lats) - marginDeg,
Math.max(...lons) + marginDeg,
Math.max(...lats) + marginDeg
];
}
function bridgeAssessmentFromFeature(
feature: Feature,
routeLine: LonLat[],
requiredAirDraftM: number | null
): RouteBridgeAssessment | null {
if (!feature.geometry) {
return null;
}
const bridgeLines = geometryLineStrings(feature.geometry);
if (bridgeLines.length === 0) {
return null;
}
const distanceNm = minDistanceBetweenLinesNm(bridgeLines, routeLine);
if (!Number.isFinite(distanceNm)) {
return null;
}
const coordinate = centroid(bridgeLines.flat());
if (!coordinate) {
return null;
}
const properties = (feature.properties ?? {}) as Record<string, unknown>;
const clearanceM = normalizeMeters(properties.clearance_m);
const name = stringProperty(properties.name);
const clearanceLabel = stringProperty(properties.clearance_label) ?? (clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null);
const label = stringProperty(properties.label) ?? name ?? clearanceLabel ?? "Brücke";
const status = bridgeStatus(clearanceM, requiredAirDraftM);
const marginM = clearanceM !== null && requiredAirDraftM !== null ? clearanceM - requiredAirDraftM : null;
const idCandidate = feature.id ?? properties.source_id ?? `${coordinate.lat.toFixed(5)}:${coordinate.lon.toFixed(5)}`;
return {
id: String(idCandidate),
name,
label,
coordinate,
distanceNm,
clearanceM,
clearanceLabel,
requiredAirDraftM,
marginM,
status,
source: stringProperty(properties.source) ?? "OSM/Geofabrik"
};
}
function summarizeBridgeReport(
bridges: RouteBridgeAssessment[],
requiredAirDraftM: number | null
): RouteBridgeReport {
const knownClearanceBridges = bridges.filter((bridge) => bridge.clearanceM !== null);
const unknownCount = bridges.length - knownClearanceBridges.length;
const checkedCount = requiredAirDraftM === null ? 0 : knownClearanceBridges.length;
const tooLowCount = bridges.filter((bridge) => bridge.status === "too_low").length;
const tightCount = bridges.filter((bridge) => bridge.status === "tight").length;
const minKnownClearanceM = minValue(knownClearanceBridges.map((bridge) => bridge.clearanceM));
const severity = bridgeSeverity(bridges);
return {
bridges,
requiredAirDraftM,
checkedCount,
unknownCount,
tooLowCount,
tightCount,
minClearanceM: minKnownClearanceM,
severity,
summary: bridgeSummary({
bridgeCount: bridges.length,
unknownCount,
tooLowCount,
tightCount,
minClearanceM: minKnownClearanceM,
requiredAirDraftM
}),
source: unique(bridges.map((bridge) => bridge.source)).join(", ") || "OSM/Geofabrik",
updatedAt: new Date().toISOString()
};
}
function unavailableBridgeReport(vesselProfile: Pick<VesselProfile, "airDraftM">): RouteBridgeReport {
return {
bridges: [],
requiredAirDraftM: normalizeMeters(vesselProfile.airDraftM),
checkedCount: 0,
unknownCount: 0,
tooLowCount: 0,
tightCount: 0,
minClearanceM: null,
severity: "caution",
summary: "Brückenprüfung nicht erreichbar. Durchfahrtshöhen vor Abfahrt extern prüfen.",
source: "OSM/Geofabrik",
updatedAt: new Date().toISOString()
};
}
function bridgeStatus(clearanceM: number | null, requiredAirDraftM: number | null): RouteBridgeStatus {
if (clearanceM === null || requiredAirDraftM === null) {
return "unknown";
}
const marginM = clearanceM - requiredAirDraftM;
if (marginM < 0) {
return "too_low";
}
if (marginM < BRIDGE_TIGHT_MARGIN_M) {
return "tight";
}
return "passable";
}
function bridgeSeverity(bridges: RouteBridgeAssessment[]): RouteBridgeReport["severity"] {
if (bridges.some((bridge) => bridge.status === "too_low")) {
return "critical";
}
if (bridges.some((bridge) => bridge.status === "tight" || bridge.status === "unknown")) {
return "caution";
}
return "ok";
}
function bridgeSummary({
bridgeCount,
unknownCount,
tooLowCount,
tightCount,
minClearanceM,
requiredAirDraftM
}: {
bridgeCount: number;
unknownCount: number;
tooLowCount: number;
tightCount: number;
minClearanceM: number | null;
requiredAirDraftM: number | null;
}) {
if (bridgeCount === 0) {
return "Keine Brücken im Routenkorridor erkannt.";
}
if (requiredAirDraftM === null) {
return `${bridgeCount} Brücken erkannt. Bootshöhe fehlt, Durchfahrt nicht bewertbar.`;
}
if (tooLowCount > 0) {
return `Nicht passierbar: ${tooLowCount} Brücke(n) niedriger als ${formatMeters(requiredAirDraftM)} Bootshöhe.`;
}
if (tightCount > 0) {
return `Knapp: ${tightCount} Brücke(n) mit weniger als ${formatMeters(BRIDGE_TIGHT_MARGIN_M)} Reserve.`;
}
if (unknownCount > 0) {
return `${unknownCount} Brücke(n) ohne Höhenangabe. Durchfahrt vor Abfahrt prüfen.`;
}
return `Brücken passierbar: ${bridgeCount} Brücke(n), min. ${formatMeters(minClearanceM ?? requiredAirDraftM)} Durchfahrt.`;
}
function dedupeBridges(bridges: RouteBridgeAssessment[]) {
const byId = new Map<string, RouteBridgeAssessment>();
for (const bridge of bridges) {
const existing = byId.get(bridge.id);
if (!existing || bridge.distanceNm < existing.distanceNm) {
byId.set(bridge.id, bridge);
}
}
return [...byId.values()];
}
function geometryLineStrings(geometry: Geometry): LonLat[][] {
switch (geometry.type) {
case "Point":
return [singlePositionLine(geometry.coordinates)].filter((line) => line.length > 0);
case "MultiPoint":
return geometry.coordinates.map(singlePositionLine).filter((line) => line.length > 0);
case "LineString":
return [positionsToLine(geometry.coordinates)].filter((line) => line.length > 0);
case "MultiLineString":
return geometry.coordinates.map(positionsToLine).filter((line) => line.length > 0);
case "Polygon":
return geometry.coordinates.map(positionsToLine).filter((line) => line.length > 0);
case "MultiPolygon":
return geometry.coordinates.flat().map(positionsToLine).filter((line) => line.length > 0);
case "GeometryCollection":
return geometry.geometries.flatMap(geometryLineStrings);
}
}
function singlePositionLine(position: Position): LonLat[] {
const coordinate = positionToLonLat(position);
return coordinate ? [coordinate] : [];
}
function positionsToLine(positions: Position[]): LonLat[] {
return positions.map(positionToLonLat).filter((coordinate): coordinate is LonLat => Boolean(coordinate));
}
function positionToLonLat(position: Position): LonLat | null {
const [lon, lat] = position;
return typeof lon === "number" && typeof lat === "number" && Number.isFinite(lon) && Number.isFinite(lat)
? [lon, lat]
: null;
}
function minDistanceBetweenLinesNm(featureLines: LonLat[][], routeLine: LonLat[]) {
if (routeLine.length === 0) {
return Number.POSITIVE_INFINITY;
}
const origin = routeLine[0]!;
let minDistanceNm = Number.POSITIVE_INFINITY;
for (const featureLine of featureLines) {
if (featureLine.length === 0) {
continue;
}
if (featureLine.length === 1) {
minDistanceNm = Math.min(minDistanceNm, minPointToLineDistanceNm(featureLine[0]!, routeLine, origin));
continue;
}
if (routeLine.length === 1) {
minDistanceNm = Math.min(minDistanceNm, minPointToLineDistanceNm(routeLine[0]!, featureLine, origin));
continue;
}
for (let featureIndex = 1; featureIndex < featureLine.length; featureIndex += 1) {
const featureStart = featureLine[featureIndex - 1]!;
const featureEnd = featureLine[featureIndex]!;
for (let routeIndex = 1; routeIndex < routeLine.length; routeIndex += 1) {
minDistanceNm = Math.min(
minDistanceNm,
segmentDistanceNm(featureStart, featureEnd, routeLine[routeIndex - 1]!, routeLine[routeIndex]!, origin)
);
}
}
}
return minDistanceNm;
}
function minPointToLineDistanceNm(point: LonLat, line: LonLat[], origin: LonLat) {
if (line.length === 0) {
return Number.POSITIVE_INFINITY;
}
if (line.length === 1) {
return distanceBetweenProjected(project(point, origin), project(line[0]!, origin));
}
let minDistanceNm = Number.POSITIVE_INFINITY;
for (let index = 1; index < line.length; index += 1) {
minDistanceNm = Math.min(
minDistanceNm,
pointToSegmentDistance(project(point, origin), project(line[index - 1]!, origin), project(line[index]!, origin))
);
}
return minDistanceNm;
}
function segmentDistanceNm(startA: LonLat, endA: LonLat, startB: LonLat, endB: LonLat, origin: LonLat) {
const a = project(startA, origin);
const b = project(endA, origin);
const c = project(startB, origin);
const d = project(endB, origin);
if (segmentsIntersect(a, b, c, d)) {
return 0;
}
return Math.min(
pointToSegmentDistance(a, c, d),
pointToSegmentDistance(b, c, d),
pointToSegmentDistance(c, a, b),
pointToSegmentDistance(d, a, b)
);
}
function project([lon, lat]: LonLat, [originLon, originLat]: LonLat): ProjectedPoint {
const averageLatRad = ((lat + originLat) / 2) * (Math.PI / 180);
return {
x: (lon - originLon) * 60 * Math.cos(averageLatRad),
y: (lat - originLat) * 60
};
}
function segmentsIntersect(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint, d: ProjectedPoint) {
const o1 = orientation(a, b, c);
const o2 = orientation(a, b, d);
const o3 = orientation(c, d, a);
const o4 = orientation(c, d, b);
if (o1 !== o2 && o3 !== o4) {
return true;
}
return (
(o1 === 0 && onSegment(a, c, b)) ||
(o2 === 0 && onSegment(a, d, b)) ||
(o3 === 0 && onSegment(c, a, d)) ||
(o4 === 0 && onSegment(c, b, d))
);
}
function orientation(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint) {
const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
if (Math.abs(value) < 1e-9) {
return 0;
}
return value > 0 ? 1 : 2;
}
function onSegment(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint) {
return (
b.x <= Math.max(a.x, c.x) + 1e-9 &&
b.x >= Math.min(a.x, c.x) - 1e-9 &&
b.y <= Math.max(a.y, c.y) + 1e-9 &&
b.y >= Math.min(a.y, c.y) - 1e-9
);
}
function pointToSegmentDistance(point: ProjectedPoint, start: ProjectedPoint, end: ProjectedPoint) {
const dx = end.x - start.x;
const dy = end.y - start.y;
if (dx === 0 && dy === 0) {
return distanceBetweenProjected(point, start);
}
const ratio = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
return distanceBetweenProjected(point, {
x: start.x + ratio * dx,
y: start.y + ratio * dy
});
}
function distanceBetweenProjected(left: ProjectedPoint, right: ProjectedPoint) {
return Math.hypot(left.x - right.x, left.y - right.y);
}
function centroid(coordinates: LonLat[]): Coordinate | null {
if (coordinates.length === 0) {
return null;
}
const sum = coordinates.reduce(
(total, [lon, lat]) => ({
lon: total.lon + lon,
lat: total.lat + lat
}),
{ lon: 0, lat: 0 }
);
return {
lon: sum.lon / coordinates.length,
lat: sum.lat / coordinates.length
};
}
function stringProperty(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function normalizeMeters(value: unknown) {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;
}
if (typeof value !== "string") {
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`;
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+218
View File
@@ -0,0 +1,218 @@
import {
harbourAmenitiesFromProperties,
orderWaypointsAlongRoute,
type RouteResult,
type VoyageHarbour
} from "@watermaps/shared";
import type { FeatureCollection, GeoJsonProperties, Geometry } from "geojson";
export type RouteLock = {
id: string;
name: string;
coordinate: { lat: number; lon: number };
routeDistanceNm: number;
distanceFromRouteNm: number;
openingHours: string | null;
phone: string | null;
email?: string | null;
vhf: string | null;
website: string | null;
operator?: string | null;
address?: string | null;
source?: string | null;
sourceUrl?: string | null;
updatedAt?: string | null;
};
/** Converts the normalized harbour layer returned by /api/features for planning. */
export function voyageHarboursFromFeatures(
collection: FeatureCollection<Geometry, GeoJsonProperties>
): VoyageHarbour[] {
const harbours = new Map<string, VoyageHarbour>();
for (const feature of collection.features) {
if (feature.geometry?.type !== "Point" || feature.properties?.layer !== "harbours") {
continue;
}
const [lon, lat] = feature.geometry.coordinates;
if (typeof lon !== "number" || typeof lat !== "number" || !Number.isFinite(lon) || !Number.isFinite(lat)) {
continue;
}
const properties = feature.properties;
const id = String(feature.id ?? properties.sourceId ?? properties.source_id ?? `${lat}:${lon}`);
const seamarkType = stringValue(properties["seamark:type"]);
const kind =
stringValue(properties.leisure) === "marina" || seamarkType === "marina"
? "marina"
: "harbour";
harbours.set(id, {
id,
name: displayString(properties.name) ?? (kind === "marina" ? "Marina" : "Hafen"),
coordinate: { lat, lon },
kind,
amenities: harbourAmenitiesFromProperties(properties),
phone: firstString(properties, ["contact:phone", "phone", "telephone", "contact_phone"]),
website: firstString(properties, ["contact:website", "website", "url", "contact_website"]),
email: firstString(properties, ["contact:email", "email", "contact_email"]),
vhf: firstString(properties, [
"vhf",
"vhf_channel",
"radio_channel",
"contact:vhf",
"seamark:harbour:radio_channel"
]),
openingHours: firstString(properties, ["openingHours", "opening_hours", "service_times"]),
operator: firstString(properties, ["operator", "operator:name", "owner"]),
address: featureAddress(properties),
source: firstString(properties, ["source", "data_source", "attribution"]),
sourceUrl: firstString(properties, ["sourceUrl", "source_url", "enrichmentSourceUrl"]),
updatedAt: firstString(properties, [
"updatedAt",
"updated_at",
"fetchedAt",
"fetched_at",
"timestamp",
"@timestamp"
])
});
}
return [...harbours.values()];
}
/** Returns lock points that are close enough to plausibly lie on the routed waterway. */
export function routeLocksFromFeatures(
collection: FeatureCollection<Geometry, GeoJsonProperties>,
route: Pick<RouteResult, "geometry" | "distanceNm">,
maxDistanceFromRouteNm = 0.25
): RouteLock[] {
const details = new Map<
string,
Omit<RouteLock, "routeDistanceNm" | "distanceFromRouteNm">
>();
for (const feature of collection.features) {
if (feature.geometry?.type !== "Point" || feature.properties?.layer !== "locks") {
continue;
}
const [lon, lat] = feature.geometry.coordinates;
if (typeof lon !== "number" || typeof lat !== "number" || !Number.isFinite(lon) || !Number.isFinite(lat)) {
continue;
}
const properties = feature.properties;
const id = String(feature.id ?? properties.sourceId ?? properties.source_id ?? `${lat}:${lon}`);
details.set(id, {
id,
name: displayString(properties.name) ?? "Schleuse",
coordinate: { lat, lon },
openingHours: displayString(properties.openingHours) ?? displayString(properties.opening_hours),
phone: firstString(properties, ["contact:phone", "phone", "telephone", "contact_phone"]),
email: firstString(properties, ["contact:email", "email", "contact_email"]),
vhf: firstString(properties, [
"vhf",
"vhf_channel",
"radio_channel",
"contact:vhf",
"seamark:radio_station:channel"
]),
website: firstString(properties, ["contact:website", "website", "url", "contact_website"]),
operator: firstString(properties, ["operator", "operator:name", "owner"]),
address: featureAddress(properties),
source: firstString(properties, ["source", "data_source", "attribution"]),
sourceUrl: firstString(properties, ["sourceUrl", "source_url", "enrichmentSourceUrl"]),
updatedAt: firstString(properties, [
"updatedAt",
"updated_at",
"fetchedAt",
"fetched_at",
"timestamp",
"@timestamp"
])
});
}
const ordered = orderWaypointsAlongRoute(
[...details.values()].map(({ id, name, coordinate }) => ({ id, name, coordinate })),
route
);
return ordered
.filter((lock) => lock.distanceFromRouteNm <= maxDistanceFromRouteNm)
.map((lock) => ({ ...details.get(lock.id)!, routeDistanceNm: lock.routeDistanceNm, distanceFromRouteNm: lock.distanceFromRouteNm }));
}
/** Returns a padded request box that covers every point of a routed line. */
export function routeFeatureBounds(
route: Pick<RouteResult, "geometry">,
paddingNm = 2
): [number, number, number, number] {
if (!Number.isFinite(paddingNm) || paddingNm < 0) {
throw new RangeError("paddingNm must be a non-negative finite number");
}
const coordinates = route.geometry.coordinates;
if (coordinates.length === 0) {
throw new RangeError("route.geometry must contain coordinates");
}
let minLon = Infinity;
let minLat = Infinity;
let maxLon = -Infinity;
let maxLat = -Infinity;
for (const [lon, lat] of coordinates) {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
throw new RangeError("route.geometry must contain finite coordinates");
}
minLon = Math.min(minLon, lon);
minLat = Math.min(minLat, lat);
maxLon = Math.max(maxLon, lon);
maxLat = Math.max(maxLat, lat);
}
const latitudePadding = paddingNm / 60;
const highestAbsoluteLatitude = Math.min(89, Math.max(Math.abs(minLat), Math.abs(maxLat)));
const longitudePadding = paddingNm / (60 * Math.cos(highestAbsoluteLatitude * (Math.PI / 180)));
return [
Math.max(-180, minLon - longitudePadding),
Math.max(-90, minLat - latitudePadding),
Math.min(180, maxLon + longitudePadding),
Math.min(90, maxLat + latitudePadding)
];
}
function displayString(value: unknown) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed || null;
}
function stringValue(value: unknown) {
return displayString(value)?.toLowerCase() ?? "";
}
function firstString(properties: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const value = displayString(properties[key]);
if (value) {
return value;
}
}
return null;
}
function featureAddress(properties: Record<string, unknown>) {
const fullAddress = firstString(properties, ["contact:address", "addr:full", "address"]);
if (fullAddress) {
return fullAddress;
}
const streetLine = [
firstString(properties, ["addr:street"]),
firstString(properties, ["addr:housenumber"])
].filter(Boolean).join(" ");
const cityLine = [
firstString(properties, ["addr:postcode"]),
firstString(properties, ["addr:city", "addr:place"])
].filter(Boolean).join(" ");
return [streetLine, cityLine].filter(Boolean).join(", ") || null;
}
+148
View File
@@ -0,0 +1,148 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TideSummary } from "@watermaps/shared";
import type { GpsState } from "../src/hooks/useGeolocation";
const apiMocks = vi.hoisted(() => ({ getNearestTide: vi.fn() }));
vi.mock("../src/api", () => apiMocks);
import { useAnchorWatch } from "../src/hooks/useAnchorWatch";
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
beforeEach(() => {
apiMocks.getNearestTide.mockReset();
apiMocks.getNearestTide.mockResolvedValue(tideFixture());
});
afterEach(() => {
cleanup();
vi.useRealTimers();
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
else Reflect.deleteProperty(navigator, "vibrate");
});
describe("useAnchorWatch", () => {
it("captures an immutable anchor point only after an explicit action", async () => {
const gps = gpsFix({ lat: 53.2, lon: 7.1 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: gps } }
);
expect(result.current.phase).toBe("idle");
expect(result.current.anchorPoint).toBeNull();
act(() => expect(result.current.captureAnchor()).toBe(true));
expect(result.current.phase).toBe("set");
expect(result.current.anchorPoint).toEqual({ lat: 53.2, lon: 7.1 });
rerender({ value: gpsFix({ lat: 53.21, lon: 7.11 }) });
expect(result.current.anchorPoint).toEqual({ lat: 53.2, lon: 7.1 });
await waitFor(() => expect(result.current.tide?.station).toBe("Testpegel"));
expect(apiMocks.getNearestTide).toHaveBeenCalledWith(
{ lat: 53.2, lon: 7.1 },
expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/)
);
});
it("rejects stale and inaccurate fixes instead of silently setting a point", () => {
const stale = gpsFix({ lat: 53.2, lon: 7.1 }, { timestampMs: Date.now() - 20_000 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: stale } }
);
act(() => expect(result.current.captureAnchor()).toBe(false));
expect(result.current.operationError).toMatch(/älter als 10 Sekunden/);
expect(result.current.anchorPoint).toBeNull();
rerender({ value: gpsFix({ lat: 53.2, lon: 7.1 }, { accuracyM: 55 }) });
act(() => expect(result.current.captureAnchor()).toBe(false));
expect(result.current.operationError).toMatch(/GPS noch zu ungenau/);
expect(result.current.anchorPoint).toBeNull();
});
it("warns and vibrates once a conservative GPS distance exceeds the radius", async () => {
const vibrate = vi.fn();
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
const initial = gpsFix({ lat: 0, lon: 0 });
const { result, rerender } = renderHook(
({ value }) => useAnchorWatch(value),
{ initialProps: { value: initial } }
);
act(() => {
result.current.captureAnchor();
result.current.updateSettings({ alarmRadiusM: 100, deployedRodeLengthM: 100 });
});
await act(async () => {
expect(await result.current.arm()).toBe(true);
});
expect(result.current.positionAlarm).toBe(false);
rerender({
value: gpsFix(
{ lat: 150 / 111_195.08, lon: 0 },
{ accuracyM: 10, timestampMs: Date.now() }
)
});
await waitFor(() => expect(result.current.positionAlarm).toBe(true));
expect(result.current.watchResult?.alarmTriggered).toBe(true);
expect(result.current.watchResult?.conservativeDistanceFromAnchorM).toBeGreaterThan(100);
expect(vibrate).toHaveBeenCalledTimes(1);
rerender({
value: gpsFix(
{ lat: 155 / 111_195.08, lon: 0 },
{ accuracyM: 10, timestampMs: Date.now() }
)
});
expect(vibrate).toHaveBeenCalledTimes(1);
});
it("keeps the positional watch usable but never confirms rode sufficiency from partial tide data", async () => {
apiMocks.getNearestTide.mockResolvedValue(tideFixture(6));
const { result } = renderHook(() => useAnchorWatch(gpsFix({ lat: 53.2, lon: 7.1 })));
act(() => result.current.captureAnchor());
await waitFor(() => expect(result.current.tideWindow?.coverage).toBe("partial"));
expect(result.current.rodePlan?.calculationComplete).toBe(false);
expect(result.current.rodePlan?.rodeReserveM).toBeNull();
await act(async () => {
expect(await result.current.arm()).toBe(true);
});
expect(result.current.phase).toBe("armed");
});
});
function gpsFix(
position: { lat: number; lon: number },
overrides: Partial<Pick<GpsState, "accuracyM" | "timestampMs" | "status">> = {}
): Pick<GpsState, "status" | "position" | "accuracyM" | "timestampMs"> {
return {
status: overrides.status ?? "tracking",
position,
accuracyM: overrides.accuracyM ?? 5,
timestampMs: overrides.timestampMs ?? Date.now()
};
}
function tideFixture(hours = 30): TideSummary {
const now = Date.now();
return {
station: "Testpegel",
distanceKm: 3.2,
nextHigh: null,
nextLow: null,
waterLevelCurve: [
{ time: new Date(now - 60 * 60_000).toISOString(), predictedM: 1 },
{ time: new Date(now + 6 * 60 * 60_000).toISOString(), predictedM: 2 },
{ time: new Date(now + hours * 60 * 60_000).toISOString(), predictedM: 0.5 }
],
source: "Test",
updatedAt: new Date(now).toISOString()
};
}
+229
View File
@@ -0,0 +1,229 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { MarineForecast, TideSummary } from "@watermaps/shared";
import {
ConditionsPanel,
type ConditionsRouteWeatherReport,
type ConditionsRouteWeatherSample
} from "../src/components/ConditionsPanel";
afterEach(cleanup);
const NOW = Date.parse("2026-07-23T10:00:00.000Z");
describe("ConditionsPanel", () => {
it("labels GPS as the source and shows the current marine weather metrics", () => {
render(
<ConditionsPanel
forecast={forecast()}
tide={null}
positionSource={{ kind: "gps" }}
now={NOW}
/>
);
expect(screen.getByRole("complementary", { name: "Wetter & Tide" })).toHaveAttribute(
"data-position-source",
"gps"
);
expect(screen.getByText("Aktuelle GPS-Position")).toBeVisible();
const currentConditions = screen
.getByRole("heading", { name: "Aktuelle Bedingungen" })
.closest("section");
expect(currentConditions).not.toBeNull();
expect(within(currentConditions!).getByText("12 kn · 270° W")).toBeVisible();
expect(within(currentConditions!).getByText("0.8 m · 6 s · 225° SW")).toBeVisible();
expect(within(currentConditions!).getByText("0.7 kn · 090° O")).toBeVisible();
expect(within(currentConditions!).getByText("14.2 °C")).toBeVisible();
expect(within(currentConditions!).getByText("vor 15 Min.")).toBeVisible();
expect(within(currentConditions!).getByText("Quelle: Test-Meteo")).toBeVisible();
});
it("shows the nearest tide station and both high- and low-water events", () => {
render(
<ConditionsPanel
forecast={null}
tide={tideSummary("Pegel Emden", 4.2, 1.72, 0.31)}
positionSource={{ kind: "gps", label: "Außenhafen" }}
now={NOW}
/>
);
expect(screen.getByText("GPS · Außenhafen")).toBeVisible();
expect(screen.getByText("Pegel Emden")).toBeVisible();
expect(screen.getByText("4,2 km entfernt")).toBeVisible();
const tideEvents = document.querySelector(".conditions-tide-events");
expect(tideEvents).not.toBeNull();
expect(tideEvents).toHaveTextContent(/HW.*1\.72 m/);
expect(tideEvents).toHaveTextContent(/NW.*0\.31 m/);
expect(within(tideEvents!).getByTitle("Nächstes Hochwasser")).toHaveTextContent("HW");
expect(within(tideEvents!).getByTitle("Nächstes Niedrigwasser")).toHaveTextContent("NW");
});
it("renders Start, Mitte and Ziel with weather and the middle tide", () => {
const routeWeatherReport: ConditionsRouteWeatherReport = {
severity: "caution",
summary: "Wind nimmt zur Mitte der Strecke zu.",
source: "Streckenprognose",
updatedAt: "2026-07-23T09:40:00.000Z",
unavailableSamples: 0,
samples: [
routeSample("Start", { windSpeed: 8, waveHeightM: 0.3 }),
routeSample("Mitte", { windSpeed: 17, waveHeightM: 0.9 }, -0.2),
routeSample("Ziel", { windSpeed: 11, waveHeightM: 0.5 })
]
};
render(
<ConditionsPanel
forecast={null}
tide={null}
positionSource={{ kind: "fallback", label: "Routenstart" }}
routeWeatherReport={routeWeatherReport}
routeTides={{
start: tideSummary("Startpegel", 2.1, 1.1, 0.2),
middle: tideSummary("Mittelplate", 7.5, 1.48, 0.18),
destination: tideSummary("Zielpegel", 3.4, 1.25, 0.14)
}}
now={NOW}
/>
);
expect(screen.getByText("Fallback · Routenstart")).toBeVisible();
expect(screen.getByText("Wind nimmt zur Mitte der Strecke zu.")).toBeVisible();
const start = routeCard("Start");
const middle = routeCard("Mitte");
const destination = routeCard("Ziel");
expect(start).toHaveTextContent("Startpegel · 2,1 km");
expect(middle).toHaveTextContent("17 kn");
expect(middle).toHaveTextContent("-0.2 kn entlang Route");
expect(middle).toHaveTextContent("Mittelplate · 7,5 km");
expect(middle).toHaveTextContent(/HW.*1\.48 m/);
expect(middle).toHaveTextContent(/NW.*0\.18 m/);
expect(destination).toHaveTextContent("Zielpegel · 3,4 km");
});
it("keeps partial data visible while reporting current and route failures", () => {
const partialReport: ConditionsRouteWeatherReport = {
severity: "caution",
summary: "Streckenprognose ist unvollständig.",
unavailableSamples: 2,
samples: [routeSample("Start", { windSpeed: 9, waveHeightM: 0.4 })]
};
render(
<ConditionsPanel
forecast={forecast({ windSpeed: 9 })}
tide={null}
positionSource={{ kind: "fallback", label: "Letzte GPS-Position" }}
currentError="Tidendaten nicht erreichbar."
routeWeatherReport={partialReport}
routeTides={{ start: null, middle: null, destination: null }}
routeError="Streckentiden nur teilweise erreichbar."
now={NOW}
/>
);
expect(screen.getAllByRole("alert")).toHaveLength(2);
expect(screen.getByText("Tidendaten nicht erreichbar.")).toBeVisible();
expect(screen.getByText("Streckentiden nur teilweise erreichbar.")).toBeVisible();
const currentConditions = screen
.getByRole("heading", { name: "Aktuelle Bedingungen" })
.closest("section");
expect(currentConditions).not.toBeNull();
expect(within(currentConditions!).getByText("9 kn · 270° W")).toBeVisible();
expect(screen.getByText("Für diese Position fehlt eine passende Tidenstation.")).toBeVisible();
expect(screen.getByText("Für 2 Streckenpunkte fehlt die Prognose.")).toBeVisible();
expect(routeCard("Start")).toHaveTextContent("9 kn");
expect(routeCard("Mitte")).toHaveTextContent("Keine Wetterprognose für diesen Streckenpunkt.");
expect(routeCard("Ziel")).toHaveTextContent("Keine Wetterprognose für diesen Streckenpunkt.");
expect(screen.getAllByText(/Keine passende Tide für/)).toHaveLength(3);
});
it("shows useful empty states when neither position nor route conditions exist", () => {
render(
<ConditionsPanel
forecast={null}
tide={null}
positionSource={{ kind: "unknown" }}
now={NOW}
/>
);
expect(screen.getByText("Positionsquelle noch offen")).toBeVisible();
expect(
screen.getByText("Für diese Position liegen noch keine Wetter- oder Tidendaten vor.")
).toBeVisible();
expect(
screen.getByText("Nach der Routenberechnung erscheinen hier Start, Mitte und Ziel.")
).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
function routeCard(label: "Start" | "Mitte" | "Ziel") {
const card = screen.getByRole("heading", { name: label, level: 4 }).closest("article");
expect(card).not.toBeNull();
return card!;
}
function routeSample(
label: ConditionsRouteWeatherSample["label"],
overrides: Partial<MarineForecast>,
currentAlongRouteKn: number | null = 0.3
): ConditionsRouteWeatherSample {
return {
label,
plannedTime: `2026-07-23T${label === "Start" ? "10" : label === "Mitte" ? "12" : "14"}:00:00.000Z`,
currentAlongRouteKn,
forecast: forecast(overrides)
};
}
function forecast(overrides: Partial<MarineForecast> = {}): MarineForecast {
return {
waveHeightM: 0.8,
waveDirectionDeg: 225,
wavePeriodS: 5.6,
windSpeed: 12.4,
windDirectionDeg: 270,
temperatureC: 14.2,
oceanCurrentSpeedKn: 0.7,
oceanCurrentDirectionDeg: 90,
forecastTime: "2026-07-23T10:00:00.000Z",
source: "Test-Meteo",
updatedAt: "2026-07-23T09:45:00.000Z",
...overrides
};
}
function tideSummary(
station: string,
distanceKm: number,
highWaterM: number,
lowWaterM: number
): TideSummary {
return {
station,
distanceKm,
nextHigh: {
type: "high",
time: "2026-07-23T12:30:00.000Z",
heightM: highWaterM
},
nextLow: {
type: "low",
time: "2026-07-23T18:45:00.000Z",
heightM: lowWaterM
},
waterLevelCurve: [],
source: "Test-Tide",
updatedAt: "2026-07-23T09:50:00.000Z"
};
}
@@ -0,0 +1,108 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { useCourseAssistant, type CourseAssistantInput } from "../src/hooks/useCourseAssistant";
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
afterEach(() => {
cleanup();
vi.useRealTimers();
if (originalVibrate) Object.defineProperty(navigator, "vibrate", originalVibrate);
else Reflect.deleteProperty(navigator, "vibrate");
});
describe("useCourseAssistant", () => {
it("never starts automatically and calculates only after an explicit start", () => {
const { result } = renderHook(() => useCourseAssistant(input()));
expect(result.current.active).toBe(false);
expect(result.current.guidance).toBeNull();
act(() => result.current.start());
expect(result.current.active).toBe(true);
expect(result.current.guidance?.status).toBe("on-route");
expect(result.current.guidance?.desiredCourseDeg).toBeCloseTo(90, 0);
});
it("stops deliberately when a different route is selected", () => {
const first = route("first", [[0, 0], [0.02, 0]]);
const second = route("second", [[0, 0], [0, 0.02]]);
const { result, rerender } = renderHook(
({ currentRoute }) => useCourseAssistant(input(currentRoute)),
{ initialProps: { currentRoute: first } }
);
act(() => result.current.start());
expect(result.current.active).toBe(true);
rerender({ currentRoute: second });
expect(result.current.active).toBe(false);
expect(result.current.guidance).toBeNull();
});
it("pauses guidance when no fresh GPS fix arrives", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-07-20T10:00:00.000Z"));
const fixTimestampMs = Date.now();
const { result } = renderHook(() => useCourseAssistant(input(routeFixture, fixTimestampMs)));
act(() => result.current.start());
expect(result.current.guidance).not.toBeNull();
act(() => vi.advanceTimersByTime(20_000));
expect(result.current.fixStale).toBe(true);
expect(result.current.guidance).toBeNull();
});
it("vibrates once when crossing into the off-route state", () => {
const vibrate = vi.fn();
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
const { result, rerender } = renderHook(
({ lat }) => useCourseAssistant(input(routeFixture, Date.now(), lat)),
{ initialProps: { lat: 0 } }
);
act(() => result.current.start());
expect(vibrate).not.toHaveBeenCalled();
rerender({ lat: 0.003 });
expect(result.current.guidance?.status).toBe("off-route");
expect(vibrate).toHaveBeenCalledTimes(1);
rerender({ lat: 0.0031 });
expect(vibrate).toHaveBeenCalledTimes(1);
});
});
function input(
currentRoute: RouteResult = routeFixture,
fixTimestampMs = Date.now(),
lat = 0
): CourseAssistantInput {
return {
route: currentRoute,
position: { lon: 0.001, lat },
accuracyM: 5,
speedKn: 6,
headingDeg: 90,
fixTimestampMs
};
}
function route(id: string, coordinates: [number, number][]): RouteResult {
return {
id,
name: id,
geometry: { type: "LineString", coordinates },
distanceNm: 1,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"],
routingMode: "fairway"
};
}
const routeFixture = route("test-route", [[0, 0], [0.02, 0]]);
@@ -0,0 +1,83 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { calculateRouteGuidance } from "@watermaps/shared";
import { CourseAssistantPanel } from "../src/components/CourseAssistantPanel";
afterEach(cleanup);
describe("CourseAssistantPanel", () => {
it("shows the continuously calculated target course and correction", () => {
const guidance = calculateRouteGuidance({
route: [[0, 0], [0.02, 0]],
position: { lon: 0.001, lat: 0 },
headingDeg: 80,
speedKn: 6,
accuracyM: 5
});
const onStop = vi.fn();
render(
<CourseAssistantPanel
guidance={guidance}
gpsStatus="tracking"
headingDeg={80}
headingSource="COG"
accuracyM={5}
fixStale={false}
onStop={onStop}
/>
);
expect(screen.getByRole("complementary", { name: "Kursassistent" })).toHaveTextContent("090°T");
expect(screen.getByText("10° nach Steuerbord")).toBeVisible();
expect(screen.getByText("Auf Route Sollkurs wird mit jedem GPS-Fix angepasst.")).toBeVisible();
expect(screen.getByText("IST COG").parentElement).toHaveTextContent("080°T");
fireEvent.click(screen.getByRole("button", { name: "Kursassistent stoppen" }));
expect(onStop).toHaveBeenCalledTimes(1);
});
it("suppresses steering advice while GPS accuracy is insufficient", () => {
const guidance = calculateRouteGuidance({
route: [[0, 0], [0.02, 0]],
position: { lon: 0.001, lat: 0 },
headingDeg: 80,
speedKn: 6,
accuracyM: 250
});
render(
<CourseAssistantPanel
guidance={guidance}
gpsStatus="tracking"
headingDeg={80}
headingSource="COG"
accuracyM={250}
fixStale={false}
onStop={vi.fn()}
/>
);
expect(guidance?.status).toBe("gps-unreliable");
expect(screen.getByText("Keine verlässliche Steueranweisung")).toBeVisible();
expect(screen.getByText(/GPS zu ungenau/)).toBeVisible();
expect(screen.getByRole("complementary", { name: "Kursassistent" })).toHaveAttribute("data-alert", "true");
});
it("pauses when the latest GPS fix is stale", () => {
render(
<CourseAssistantPanel
guidance={null}
gpsStatus="tracking"
headingDeg={null}
headingSource="--"
accuracyM={null}
fixStale
onStop={vi.fn()}
/>
);
expect(screen.getByText(/GPS-Fix ist veraltet/)).toBeVisible();
expect(screen.getByText("---")).toBeVisible();
});
});
+219
View File
@@ -0,0 +1,219 @@
import { expect, test } from "@playwright/test";
test("renders the iPhone PWA shell without overlapping core controls", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
await page.goto("/");
await page.getByRole("button", { name: "GPS starten" }).click();
await expect(page.getByText("Watermaps")).toBeVisible();
await expect(page.getByTestId("map-container")).toBeVisible();
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible();
await page.getByText("Routenoptionen: Boot · Zwischenziele").click();
await expect(page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" })).toBeVisible();
await expect(page.getByLabel("Abfahrt")).toBeVisible();
await page.getByText("Unterwegs: GPX · Offline · Kursalarm").click();
await expect(page.getByRole("region", { name: "Navigation und Offline-Route" })).toBeVisible();
await page.getByRole("button", { name: "Layer" }).click();
await expect(page.getByLabel("Layer Auswahl").getByText("Brücken")).toBeVisible();
await expect(page.getByLabel("Layer Auswahl").getByText("Tiefen")).toBeVisible();
await expect(page.getByLabel("Layer Auswahl").getByText("Schleusen")).toBeVisible();
await expect(page.getByLabel("Layer Auswahl").getByText("Häfen")).toBeVisible();
});
test("sets start and destination only after explicit map picking actions", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
await page.goto("/");
await expect(page.getByText("Start und Ziel setzen")).toBeVisible();
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
const mapBox = await page.getByTestId("map-container").boundingBox();
if (!mapBox) {
throw new Error("Map container not visible");
}
const mapTapY = mapBox.y + Math.min(132, mapBox.height * 0.2);
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
await expect(page.getByText("Start und Ziel setzen")).toBeVisible();
await page.getByRole("button", { name: "Start auf Karte setzen" }).click();
await expect(page.getByText("Startpunkt auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + mapBox.width / 3, mapTapY);
await expect(page.getByText(/^Start \d/)).toBeVisible();
await page.getByRole("button", { name: "Ziel auf Karte setzen" }).click();
await expect(page.getByText("Ziel auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + (mapBox.width * 2) / 3, mapTapY);
await expect(page.getByText(/^Ziel \d/)).toBeVisible();
await expect(page.getByText("Route bereit zur Prüfung")).toBeVisible();
await page.getByText("Routenoptionen: Boot · Zwischenziele").click();
await page.getByRole("button", { name: "Zwischenziel auf der Karte hinzufügen" }).click();
await expect(page.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
await page.touchscreen.tap(mapBox.x + mapBox.width / 2, mapTapY);
await expect(page.getByText(/^Z1 /)).toBeVisible();
});
test("does not expose Borkum or Hamm as direct route shortcuts", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("button", { name: /Borkum/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Hamm/i })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
await expect(page.getByRole("button", { name: "Ziel auf Karte setzen" })).toBeVisible();
});
test("can hide and restore the route planner for a map-only view", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Routenfenster ausblenden" }).click();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
await expect(page.getByRole("button", { name: "Routenfenster einblenden" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeHidden();
await page.getByRole("button", { name: "Routenfenster einblenden" }).click();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
});
test("cycles the mobile route sheet through half, full, and compact heights", async ({ page }) => {
await page.goto("/");
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
await expect(routePanel).toHaveAttribute("data-sheet-state", "half");
const halfBox = await routePanel.boundingBox();
await page
.getByRole("button", { name: "Routenfenster auf volle Höhe vergrößern" })
.click();
await expect(routePanel).toHaveAttribute("data-sheet-state", "full");
const fullBox = await routePanel.boundingBox();
expect(fullBox && halfBox && fullBox.height > halfBox.height + 80).toBe(true);
await page
.getByRole("button", { name: "Routenfenster auf kompakte Höhe verkleinern" })
.click();
await expect(routePanel).toHaveAttribute("data-sheet-state", "compact");
const compactBox = await routePanel.boundingBox();
expect(compactBox && halfBox && compactBox.height < halfBox.height - 80).toBe(true);
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeHidden();
await page.setViewportSize({ width: 900, height: 844 });
await expect(routePanel).toHaveAttribute("data-sheet-state", "half");
await expect(page.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
});
test("starts a visible dynamic course assistant only after route planning", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
await page.route("**/api/routes", async (request) => {
await request.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "guidance-e2e",
name: "Testkurs Ost",
geometry: { type: "LineString", coordinates: [[12.09, 54.18], [12.11, 54.18]] },
distanceNm: 0.7,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["E2E"],
routingMode: "fairway"
})
});
});
await page.goto("/");
await expect(page.getByRole("button", { name: "Layer" })).toBeVisible();
const mapBox = await page.getByTestId("map-container").boundingBox();
if (!mapBox) throw new Error("Map container not visible");
const tapY = mapBox.y + Math.min(132, mapBox.height * 0.2);
await page.getByRole("button", { name: "Start auf Karte setzen" }).click();
await page.touchscreen.tap(mapBox.x + mapBox.width / 3, tapY);
await page.getByRole("button", { name: "Ziel auf Karte setzen" }).click();
await page.touchscreen.tap(mapBox.x + (mapBox.width * 2) / 3, tapY);
await page.getByRole("button", { name: "Route berechnen" }).click();
const startAssistant = page.getByRole("button", { name: "Navigation starten" });
await expect(startAssistant).toBeVisible();
await startAssistant.click();
const assistant = page.getByRole("complementary", { name: "Kursassistent" });
await expect(assistant).toBeVisible();
await expect(assistant.getByText("SOLL ÜBER GRUND")).toBeVisible();
await expect(assistant.getByRole("button", { name: "Kursassistent stoppen" })).toBeVisible();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
const assistantBox = await assistant.boundingBox();
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
expect(assistantBox && statusBox && assistantBox.y + assistantBox.height <= statusBox.y + 1).toBe(true);
});
test("sets and arms the anchor watch only through the explicit two-step flow", async ({ page, context }) => {
const now = Date.now();
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 53.2159, longitude: 6.5766 });
await page.route("**/api/tides/nearest?*", async (request) => {
await request.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
station: "Testpegel Emden",
distanceKm: 4.2,
nextHigh: null,
nextLow: null,
waterLevelCurve: [
{ time: new Date(now - 60 * 60_000).toISOString(), predictedM: 1 },
{ time: new Date(now + 6 * 60 * 60_000).toISOString(), predictedM: 2 },
{ time: new Date(now + 30 * 60 * 60_000).toISOString(), predictedM: 0.5 }
],
source: "E2E",
updatedAt: new Date(now).toISOString()
})
});
});
await page.goto("/");
await expect(page.getByRole("complementary", { name: "Ankerwache" })).toHaveCount(0);
await page.getByRole("button", { name: "GPS starten" }).click();
await page.getByRole("button", { name: "Ankerwache öffnen" }).click();
const panel = page.getByRole("complementary", { name: "Ankerwache" });
await expect(panel).toBeVisible();
await expect(panel.getByText(/GPS ±\d+ m/)).toBeVisible();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeHidden();
await panel.getByRole("button", { name: "Anker gefallen Position jetzt setzen" }).click();
await expect(panel.getByText("Ankerpunkt gespeichert")).toBeVisible();
await panel.getByLabel("Tiefe beim Setzen").fill("4");
await panel.getByLabel("Bugrolle über Wasser").fill("1");
await panel.getByLabel("Kette / Leine draußen").fill("40");
await panel.getByLabel("Gewähltes Verhältnis").selectOption("5");
await expect(panel.getByText(/Testpegel Emden · 4\.2 km entfernt/)).toBeVisible();
await expect(panel.getByText(/m Reserve/)).toBeVisible();
await panel.getByRole("button", { name: "Wache starten" }).click();
await expect(panel.getByText("Ankerwache aktiv")).toBeVisible();
await expect(panel.getByText("Im überwachten Schwojkreis")).toBeVisible();
const panelBox = await panel.boundingBox();
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
expect(panelBox && statusBox && panelBox.y + panelBox.height <= statusBox.y + 1).toBe(true);
await panel.getByRole("button", { name: "Ankerwache beenden" }).click();
await expect(panel.getByRole("button", { name: "Wirklich beenden" })).toBeVisible();
await panel.getByRole("button", { name: "Wirklich beenden" }).click();
await expect(panel).toBeHidden();
await expect(page.getByRole("complementary", { name: "Routenplanung" })).toBeVisible();
});
+123
View File
@@ -0,0 +1,123 @@
import { expect, test } from "@playwright/test";
test("docks the route planner beside the map and releases the full map when collapsed", async ({
page
}) => {
await page.goto("/");
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
const map = page.getByTestId("map-container");
const status = page.getByLabel("Navigationsstatus");
await expect(routePanel).toBeVisible();
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
const dockedPanelBox = await routePanel.boundingBox();
const dockedMapBox = await map.boundingBox();
const statusBox = await status.boundingBox();
expect(dockedPanelBox).not.toBeNull();
expect(dockedMapBox).not.toBeNull();
expect(statusBox).not.toBeNull();
expect(dockedPanelBox!.x).toBe(0);
expect(dockedPanelBox!.width).toBe(400);
expect(dockedPanelBox!.x + dockedPanelBox!.width).toBeLessThanOrEqual(dockedMapBox!.x);
expect(dockedPanelBox!.y + dockedPanelBox!.height).toBeLessThanOrEqual(statusBox!.y + 1);
await routePanel.getByRole("button", { name: "Routenfenster ausblenden" }).click();
await expect(routePanel).toBeHidden();
await expect.poll(async () => (await map.boundingBox())?.x).toBe(0);
await expect.poll(async () => {
return page.locator(".maplibregl-canvas").evaluate((node) => {
const canvas = node as HTMLCanvasElement;
return Math.abs(canvas.width / window.devicePixelRatio - canvas.clientWidth);
});
}).toBeLessThan(2);
await page.getByRole("button", { name: "Routenfenster einblenden" }).click();
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
});
test("switches from floating tablet panel to docked desktop sidebar at 1100 pixels", async ({
page
}) => {
await page.goto("/");
const routePanel = page.getByRole("complementary", { name: "Routenplanung" });
const map = page.getByTestId("map-container");
await page.setViewportSize({ width: 1099, height: 900 });
await expect.poll(async () => (await map.boundingBox())?.x).toBe(0);
const floatingPanelBox = await routePanel.boundingBox();
expect(floatingPanelBox?.x).toBe(10);
expect(floatingPanelBox?.width).toBe(360);
await page.setViewportSize({ width: 1100, height: 900 });
await expect.poll(async () => (await map.boundingBox())?.x).toBe(400);
const dockedPanelBox = await routePanel.boundingBox();
expect(dockedPanelBox?.x).toBe(0);
expect(dockedPanelBox?.width).toBe(400);
});
test("shows marine contact details as a right-hand desktop drawer", async ({ page, context }) => {
await context.grantPermissions(["geolocation"]);
await context.setGeolocation({ latitude: 54.18, longitude: 12.09 });
const requestedFeatureLayers: string[][] = [];
await page.route("**/api/features**", async (route) => {
const requestedLayers =
new URL(route.request().url()).searchParams.get("layers")?.split(",") ?? [];
requestedFeatureLayers.push(requestedLayers);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
type: "FeatureCollection",
features: requestedLayers.includes("harbours")
? [
{
type: "Feature",
id: "e2e-test-harbour",
geometry: { type: "Point", coordinates: [12.09, 54.18] },
properties: {
layer: "harbours",
name: "Testhafen Rostock",
phone: "+49 381 123456",
website: "https://example.com",
vhf: "Kanal 12",
source: "E2E"
}
}
]
: []
})
});
});
await page.goto("/");
await expect(page.locator(".data-badge")).toHaveAttribute("data-ready", "true");
await page.getByRole("button", { name: "GPS starten" }).click();
await page.getByRole("button", { name: "Position zentrieren" }).click();
await expect.poll(() => requestedFeatureLayers.flat().sort().join(",")).toContain("harbours");
const featureButton = page.getByRole("button", {
name: "Informationen zu Hafen Testhafen Rostock",
includeHidden: true
});
await expect(featureButton).toBeAttached({ timeout: 10_000 });
await featureButton.focus();
await featureButton.press("Enter");
const drawer = page.getByRole("dialog", { name: "Testhafen Rostock" });
await expect(drawer).toBeVisible();
await expect(drawer.getByRole("link", { name: /Testhafen Rostock anrufen/ })).toBeVisible();
const drawerBox = await drawer.boundingBox();
const statusBox = await page.getByLabel("Navigationsstatus").boundingBox();
expect(drawerBox).not.toBeNull();
expect(statusBox).not.toBeNull();
expect(drawerBox!.x + drawerBox!.width).toBeGreaterThanOrEqual(1426);
expect(drawerBox!.y).toBeGreaterThanOrEqual(62);
expect(drawerBox!.y + drawerBox!.height).toBeLessThanOrEqual(statusBox!.y);
await page.keyboard.press("Escape");
await expect(drawer).toBeHidden();
await expect(featureButton).toBeFocused();
});
+61
View File
@@ -0,0 +1,61 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useGeolocation } from "../src/hooks/useGeolocation";
const originalGeolocation = Object.getOwnPropertyDescriptor(navigator, "geolocation");
afterEach(() => {
cleanup();
if (originalGeolocation) Object.defineProperty(navigator, "geolocation", originalGeolocation);
else Reflect.deleteProperty(navigator, "geolocation");
});
describe("useGeolocation", () => {
it("derives COG only after movement exceeds GPS noise and clears the watch on stop", () => {
let success: PositionCallback | undefined;
const watchPosition = vi.fn((callback: PositionCallback) => {
success = callback;
return 23;
});
const clearWatch = vi.fn();
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: { watchPosition, clearWatch, getCurrentPosition: vi.fn() }
});
const { result } = renderHook(() => useGeolocation());
act(() => result.current.start());
act(() => success?.(positionAt(53, 7, 5, 1_000)));
expect(result.current.courseDeg).toBeNull();
act(() => success?.(positionAt(53, 7.00001, 5, 2_000)));
expect(result.current.courseDeg).toBeNull();
const receivedAtMs = Date.now();
act(() => success?.(positionAt(53, 7.0001, 5, 3_000)));
expect(result.current.courseDeg).toBeCloseTo(90, 0);
expect(result.current.timestampMs).toBeGreaterThanOrEqual(receivedAtMs);
act(() => result.current.stop());
expect(clearWatch).toHaveBeenCalledWith(23);
expect(result.current.status).toBe("idle");
expect(result.current.position).toBeNull();
});
});
function positionAt(lat: number, lon: number, accuracy: number, timestamp: number): GeolocationPosition {
return {
coords: {
latitude: lat,
longitude: lon,
accuracy,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: 3,
toJSON: () => ({})
},
timestamp,
toJSON: () => ({})
};
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { createRouteGpx } from "../src/lib/gpx";
describe("GPX export", () => {
it("creates parseable GPX 1.1 route and track metadata", () => {
const gpx = createRouteGpx(routeFixture, {
name: "Emden & Hamm <Test>",
createdAt: "2026-07-19T12:00:00.000Z"
});
const document = new DOMParser().parseFromString(gpx, "application/xml");
const root = document.documentElement;
expect(document.querySelector("parsererror")).toBeNull();
expect(root.localName).toBe("gpx");
expect(root.getAttribute("version")).toBe("1.1");
expect(root.namespaceURI).toBe("http://www.topografix.com/GPX/1/1");
expect(document.getElementsByTagNameNS(root.namespaceURI, "metadata")).toHaveLength(1);
expect(document.getElementsByTagNameNS(root.namespaceURI, "rte")).toHaveLength(1);
expect(document.getElementsByTagNameNS(root.namespaceURI, "trk")).toHaveLength(1);
expect(document.getElementsByTagNameNS(root.namespaceURI, "rtept")).toHaveLength(3);
expect(document.getElementsByTagNameNS(root.namespaceURI, "trkpt")).toHaveLength(3);
expect(document.getElementsByTagNameNS(root.namespaceURI, "time")[0]?.textContent).toBe("2026-07-19T12:00:00.000Z");
expect(gpx).toContain("Emden &amp; Hamm &lt;Test&gt;");
expect(gpx).toContain("minlat=\"51.6814536\"");
});
it("rejects invalid or incomplete geometry", () => {
expect(() => createRouteGpx({
...routeFixture,
geometry: { type: "LineString", coordinates: [[7, 53]] }
})).toThrow(/nicht genügend Punkte/);
expect(() => createRouteGpx({
...routeFixture,
geometry: { type: "LineString", coordinates: [[7, 53], [181, 52]] }
})).toThrow(/ungültige Koordinaten/);
});
});
const routeFixture: RouteResult = {
id: "emden-hamm",
name: "Emden Hamm",
geometry: {
type: "LineString",
coordinates: [[7.186111, 53.344167], [7.1, 52.4], [7.8042615, 51.6814536]]
},
distanceNm: 153.69,
eta: null,
warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich & vor Ort prüfen" }],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OpenStreetMap <curated>"],
routingMode: "fairway"
};
+64
View File
@@ -0,0 +1,64 @@
import "@testing-library/jest-dom/vitest";
import { lazy } from "react";
import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { LazyContent } from "../src/components/LazyContent";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("LazyContent", () => {
it("keeps the surrounding app shell visible while a feature chunk loads", async () => {
let resolveModule: ((module: { default: () => React.JSX.Element }) => void) | undefined;
const Feature = lazy(
() =>
new Promise<{ default: () => React.JSX.Element }>((resolve) => {
resolveModule = resolve;
})
);
render(
<main>
<h1>Watermaps</h1>
<LazyContent
pending={<p role="status">Modul wird geladen </p>}
failed={<p role="alert">Modul fehlt.</p>}
>
<Feature />
</LazyContent>
</main>
);
expect(screen.getByRole("heading", { name: "Watermaps" })).toBeVisible();
expect(screen.getByRole("status")).toHaveTextContent("Modul wird geladen");
await act(async () => {
resolveModule?.({ default: () => <section>Funktion bereit</section> });
});
expect(await screen.findByText("Funktion bereit")).toBeVisible();
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
it("contains a failed lazy import inside its local fallback", async () => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
const BrokenFeature = lazy(() => Promise.reject(new Error("Chunk fehlt")));
render(
<main>
<h1>Watermaps</h1>
<LazyContent
pending={<p role="status">Modul wird geladen </p>}
failed={<p role="alert">Modul konnte nicht geladen werden.</p>}
>
<BrokenFeature />
</LazyContent>
</main>
);
expect(await screen.findByRole("alert")).toHaveTextContent("nicht geladen");
expect(screen.getByRole("heading", { name: "Watermaps" })).toBeVisible();
});
});
+866
View File
@@ -0,0 +1,866 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { AppConfig, RouteResult } from "@watermaps/shared";
import { MarineFeatureInfo, type MarineFeatureDetails } from "../src/components/MarineFeatureInfo";
const maplibreState = vi.hoisted(() => ({ current: null as any, markerConstructions: 0 }));
const apiMocks = vi.hoisted(() => ({ getMapFeatures: vi.fn() }));
vi.mock("../src/api", () => apiMocks);
vi.mock("maplibre-gl", () => {
class MockLngLatBounds {
extend = vi.fn(() => this);
}
class MockMap {
handlers = new globalThis.Map<string, Set<(event?: any) => void>>();
sources = new globalThis.Map<
string,
{
setData: ReturnType<typeof vi.fn>;
getClusterExpansionZoom: ReturnType<typeof vi.fn>;
}
>();
sourceDefinitions = new globalThis.Map<string, any>();
layers = new Set<string>();
layerDefinitions = new globalThis.Map<string, any>();
renderedFeatures: any[] = [];
zoom = 13;
container: HTMLElement;
fitBounds = vi.fn(() => this);
easeTo = vi.fn(() => this);
flyTo = vi.fn(() => this);
setLayoutProperty = vi.fn();
moveLayer = vi.fn();
remove = vi.fn();
constructor(options: { container: HTMLElement }) {
this.container = options.container;
this.container.addEventListener("click", () => {
this.emit("click", { lngLat: { lng: 7.8, lat: 51.7 }, point: { x: 10, y: 10 } });
});
maplibreState.current = this;
}
addControl() {
return this;
}
on(eventName: string, handler: (event?: any) => void) {
this.handlers.set(eventName, new Set([...(this.handlers.get(eventName) ?? []), handler]));
return this;
}
off(eventName: string, handler: (event?: any) => void) {
this.handlers.get(eventName)?.delete(handler);
return this;
}
emit(eventName: string, event?: any) {
for (const handler of this.handlers.get(eventName) ?? []) {
handler(event);
}
}
addSource(id: string, definition: any) {
this.sourceDefinitions.set(id, definition);
this.sources.set(id, {
setData: vi.fn(),
getClusterExpansionZoom: vi.fn(async () => 15)
});
}
getSource(id: string) {
return this.sources.get(id);
}
addLayer(layer: { id: string }) {
this.layers.add(layer.id);
this.layerDefinitions.set(layer.id, layer);
}
getLayer(id: string) {
return this.layers.has(id) ? { id } : undefined;
}
getCanvas() {
return this.container;
}
getCanvasContainer() {
return this.container;
}
getContainer() {
return this.container;
}
unproject() {
return { lng: 7.8, lat: 51.7 };
}
project() {
return { x: 10, y: 10 };
}
queryRenderedFeatures() {
return this.renderedFeatures;
}
isStyleLoaded() {
return true;
}
getZoom() {
return this.zoom;
}
getBounds() {
return {
getWest: () => 7,
getSouth: () => 51,
getEast: () => 9,
getNorth: () => 54
};
}
}
class MockMarker {
element: HTMLElement;
constructor(options: { element: HTMLElement }) {
maplibreState.markerConstructions += 1;
this.element = options.element;
}
setLngLat() {
return this;
}
addTo(map: MockMap) {
map.container.append(this.element);
return this;
}
remove() {
this.element.remove();
return this;
}
}
class MockControl {}
return {
default: {
Map: MockMap,
Marker: MockMarker,
LngLatBounds: MockLngLatBounds,
AttributionControl: MockControl,
ScaleControl: MockControl
}
};
});
import { MapView } from "../src/components/MapView";
const config: AppConfig = {
appName: "Watermaps",
region: "Test",
disclaimer: "Test",
featureFlags: {},
layers: [
{
id: "base",
name: "Basiskarte",
kind: "style",
url: "https://example.test/style.json",
attribution: "Test",
defaultVisible: true
}
],
attribution: []
};
const marineFeatures = [
{
type: "Feature" as const,
id: "lock-1",
geometry: { type: "Point" as const, coordinates: [7.61, 52.01] },
properties: {
layer: "locks",
name: "Schleuse Nord",
"contact:phone": "+49 123 456",
website: "https://schleuse.example",
email: "schleuse@example.test",
vhf_channel: "Kanal 20",
opening_hours: "Mo-Su 06:00-22:00",
operator: "WSV",
"addr:street": "Am Kanal",
"addr:housenumber": "1",
"addr:postcode": "12345",
"addr:city": "Hafenstadt",
source: "OSM/Geofabrik"
}
},
{
type: "Feature" as const,
id: "harbour-1",
geometry: { type: "Point" as const, coordinates: [7.72, 51.82] },
properties: {
layer: "harbours",
name: "Stadthafen",
source: "Hafenbetreiber"
}
}
];
beforeEach(() => {
maplibreState.current = null;
maplibreState.markerConstructions = 0;
apiMocks.getMapFeatures.mockReset();
apiMocks.getMapFeatures.mockImplementation(async ({ layers }: { layers: string[] }) => ({
type: "FeatureCollection",
features: marineFeatures.filter((feature) => layers.includes(feature.properties.layer))
}));
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("MapView marine feature information", () => {
it("loads clustered lock and harbour info layers only at a close zoom level", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
maplibreState.current.zoom = 9;
await act(async () => maplibreState.current.emit("load"));
await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: /Informationen zu (Schleuse|Hafen)/ })).not.toBeInTheDocument();
expect(apiMocks.getMapFeatures).toHaveBeenLastCalledWith(
expect.objectContaining({ layers: expect.not.arrayContaining(["locks", "harbours"]) })
);
expect(maplibreState.current.sourceDefinitions.get("marine-contact-pois")).toEqual(
expect.objectContaining({ type: "geojson", cluster: true, clusterMaxZoom: 14 })
);
expect(maplibreState.current.layerDefinitions.get("lock-info-circles")).toEqual(
expect.objectContaining({ type: "circle", minzoom: 12, source: "marine-contact-pois" })
);
expect(maplibreState.current.layerDefinitions.get("contact-cluster-count")).toEqual(
expect.objectContaining({ type: "symbol", minzoom: 12, source: "marine-contact-pois" })
);
apiMocks.getMapFeatures.mockClear();
maplibreState.current.zoom = 12;
await act(async () => maplibreState.current.emit("zoomend"));
await act(async () => maplibreState.current.emit("moveend"));
expect(await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Informationen zu Hafen Stadthafen" })).toBeInTheDocument();
expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(1);
expect(maplibreState.markerConstructions).toBe(0);
expect(maplibreState.current.handlers.get("move")?.size ?? 0).toBe(0);
maplibreState.current.zoom = 11;
await act(async () => maplibreState.current.emit("zoom"));
expect(screen.queryByRole("button", { name: /Informationen zu (Schleuse|Hafen)/ })).not.toBeInTheDocument();
});
it("opens a rendered POI before route-coordinate picking and keeps static accessible buttons", async () => {
const onPickCoordinate = vi.fn();
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode="destination"
route={null}
onPickCoordinate={onPickCoordinate}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" });
expect(screen.getByRole("button", { name: "Informationen zu Hafen Stadthafen" })).toBeInTheDocument();
expect(apiMocks.getMapFeatures).toHaveBeenCalledWith(
expect.objectContaining({ layers: expect.arrayContaining(["locks", "harbours"]) })
);
maplibreState.current.renderedFeatures = [marineFeatures[0]];
await act(async () =>
maplibreState.current.emit("click", {
point: { x: 12, y: 18 },
lngLat: { lng: 7.61, lat: 52.01 }
})
);
expect(onPickCoordinate).not.toHaveBeenCalled();
expect(screen.getByRole("dialog", { name: "Schleuse Nord" })).toBeVisible();
expect(screen.getByRole("link", { name: /Schleuse Nord anrufen/i })).toHaveAttribute(
"href",
"tel:+49123456"
);
expect(screen.getByRole("link", { name: /Website von Schleuse Nord/i })).toHaveAttribute(
"href",
"https://schleuse.example/"
);
expect(screen.getByText("Kanal 20")).toBeVisible();
expect(screen.getByText("Am Kanal 1, 12345 Hafenstadt")).toBeVisible();
});
it("expands a contact cluster instead of treating it as a picked route coordinate", async () => {
const onPickCoordinate = vi.fn();
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode="destination"
route={null}
onPickCoordinate={onPickCoordinate}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
maplibreState.current.renderedFeatures = [
{
type: "Feature",
geometry: { type: "Point", coordinates: [7.7, 52] },
properties: { cluster: true, cluster_id: 42, point_count: 12 }
}
];
await act(async () =>
maplibreState.current.emit("click", {
point: { x: 20, y: 20 },
lngLat: { lng: 7.7, lat: 52 }
})
);
const contactSource = maplibreState.current.getSource("marine-contact-pois");
expect(contactSource.getClusterExpansionZoom).toHaveBeenCalledWith(42);
await waitFor(() =>
expect(maplibreState.current.easeTo).toHaveBeenCalledWith(
expect.objectContaining({ center: [7.7, 52], zoom: 15, essential: true })
)
);
expect(onPickCoordinate).not.toHaveBeenCalled();
});
it("removes accessible POIs and closes their panel when a feature layer is disabled", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
fireEvent.click(await screen.findByRole("button", { name: "Informationen zu Hafen Stadthafen" }));
expect(screen.getByRole("dialog", { name: "Stadthafen" })).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "Layer" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Häfen ab Zoom 12" }));
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Informationen zu Hafen Stadthafen" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Stadthafen" })).not.toBeInTheDocument();
});
expect(screen.getByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).toBeVisible();
});
it("replaces stale static POI controls after the visible map area is refreshed", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
fireEvent.click(await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" }));
expect(screen.getByRole("dialog", { name: "Schleuse Nord" })).toBeVisible();
apiMocks.getMapFeatures.mockResolvedValue({
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "lock-2",
geometry: { type: "Point", coordinates: [8.1, 52.2] },
properties: { layer: "locks", name: "Schleuse Süd", source: "Test" }
}
]
});
await act(async () => maplibreState.current.emit("moveend"));
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Schleuse Nord" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Informationen zu Schleuse Schleuse Süd" })).toBeVisible();
});
});
it("keeps map movement handlers constant for a dense contact response", async () => {
apiMocks.getMapFeatures.mockResolvedValue({
type: "FeatureCollection",
features: Array.from({ length: 250 }, (_, index) => ({
type: "Feature",
id: `lock-${index}`,
geometry: { type: "Point", coordinates: [7 + index / 10_000, 52] },
properties: { layer: "locks", name: `Schleuse ${index}` }
}))
});
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
await waitFor(() => expect(maplibreState.current.getSource("marine-contact-pois").setData).toHaveBeenCalled());
expect(maplibreState.markerConstructions).toBe(0);
expect(maplibreState.current.handlers.get("move")?.size ?? 0).toBe(0);
expect(maplibreState.current.handlers.get("moveend")?.size ?? 0).toBe(1);
});
it("aborts a superseded feature request", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
await screen.findByRole("button", { name: "Informationen zu Schleuse Schleuse Nord" });
let firstSignal: AbortSignal | undefined;
apiMocks.getMapFeatures.mockImplementationOnce(
({ signal }: { signal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
firstSignal = signal;
signal?.addEventListener("abort", () => reject(new DOMException("Abgebrochen", "AbortError")), {
once: true
});
})
);
await act(async () => maplibreState.current.emit("moveend"));
await waitFor(() => expect(firstSignal).toBeDefined());
apiMocks.getMapFeatures.mockResolvedValueOnce({ type: "FeatureCollection", features: marineFeatures });
await act(async () => maplibreState.current.emit("moveend"));
expect(firstSignal?.aborted).toBe(true);
await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3));
});
it("fits the map to a newly calculated route", async () => {
const stableOnMapReady = vi.fn();
const view = render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
const route: RouteResult = {
geometry: {
type: "LineString",
coordinates: [
[7.18, 53.34],
[7.81, 51.68]
]
},
distanceNm: 120,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
};
view.rerender(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={route}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
expect(maplibreState.current.fitBounds).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ maxZoom: 14, essential: true })
);
});
it("focuses a route event requested by the shared navigation workspace", async () => {
const stableOnMapReady = vi.fn();
const view = render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={null}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
view.rerender(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={{
key: "lock:test:1",
coordinate: { lat: 52.1, lon: 7.4 },
zoom: 15
}}
onPickCoordinate={vi.fn()}
onMapReady={stableOnMapReady}
/>
);
expect(maplibreState.current.flyTo).toHaveBeenCalledWith({
center: [7.4, 52.1],
zoom: 15,
essential: true
});
});
it("applies an existing route-event focus after the map style has loaded", async () => {
render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={null}
destination={null}
pickMode={null}
route={null}
focusRequest={{
key: "harbour:early:1",
coordinate: { lat: 53.2, lon: 6.9 },
zoom: 13
}}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
expect(maplibreState.current.flyTo).not.toHaveBeenCalled();
await act(async () => maplibreState.current.emit("load"));
expect(maplibreState.current.flyTo).toHaveBeenCalledWith({
center: [6.9, 53.2],
zoom: 13,
essential: true
});
});
it("draws the live guidance vector and lookahead target without DOM markers", async () => {
render(
<MapView
config={config}
position={{ lat: 52, lon: 7 }}
accuracyM={6}
startPoint={{ lat: 52, lon: 7 }}
destination={{ lat: 52, lon: 7.1 }}
pickMode={null}
route={null}
guidanceActive
guidanceTarget={{ lat: 52.001, lon: 7.01 }}
onPickCoordinate={vi.fn()}
onMapReady={vi.fn()}
/>
);
await act(async () => maplibreState.current.emit("load"));
const definition = maplibreState.current.sourceDefinitions.get("route-guidance");
expect(definition).toEqual(expect.objectContaining({ type: "geojson" }));
expect(definition.data.features).toHaveLength(2);
expect(definition.data.features[0].geometry).toEqual({
type: "LineString",
coordinates: [[7, 52], [7.01, 52.001]]
});
expect(maplibreState.current.layerDefinitions.get("route-guidance-line")).toEqual(
expect.objectContaining({ type: "line", source: "route-guidance" })
);
expect(maplibreState.markerConstructions).toBe(0);
});
});
describe("MapView anchor watch", () => {
it("renders one geodesic metre-based alarm ring and updates it without markers", async () => {
const onPickCoordinate = vi.fn();
const onMapReady = vi.fn();
const { rerender } = render(
<MapView
config={config}
position={{ lat: 53.2001, lon: 7.1001 }}
accuracyM={5}
startPoint={null}
destination={null}
pickMode={null}
route={null}
anchorPoint={{ lat: 53.2, lon: 7.1 }}
anchorAlarmRadiusM={80}
anchorWatchActive
anchorAlarm={false}
onPickCoordinate={onPickCoordinate}
onMapReady={onMapReady}
/>
);
await act(async () => maplibreState.current.emit("load"));
const initial = maplibreState.current.sourceDefinitions.get("anchor-watch").data;
const radius = initial.features.find((feature: any) => feature.properties.kind === "radius");
expect(radius.geometry.type).toBe("Polygon");
expect(radius.geometry.coordinates[0]).toHaveLength(65);
expect(initial.features.filter((feature: any) => feature.properties.kind === "anchor")).toHaveLength(1);
expect(initial.features.filter((feature: any) => feature.properties.kind === "distance")).toHaveLength(1);
expect(maplibreState.current.layerDefinitions.get("anchor-watch-radius-fill")).toEqual(
expect.objectContaining({ type: "fill", source: "anchor-watch" })
);
expect(maplibreState.current.easeTo).toHaveBeenCalledWith(
expect.objectContaining({ center: [7.1, 53.2], zoom: 15 })
);
expect(maplibreState.markerConstructions).toBe(0);
rerender(
<MapView
config={config}
position={{ lat: 53.2015, lon: 7.1 }}
accuracyM={5}
startPoint={null}
destination={null}
pickMode={null}
route={null}
anchorPoint={{ lat: 53.2, lon: 7.1 }}
anchorAlarmRadiusM={100}
anchorWatchActive
anchorAlarm
onPickCoordinate={onPickCoordinate}
onMapReady={onMapReady}
/>
);
const source = maplibreState.current.sources.get("anchor-watch");
await waitFor(() => expect(source.setData).toHaveBeenCalled());
const updated = source.setData.mock.calls.at(-1)?.[0];
expect(updated.features.every((feature: any) => feature.properties.alarm === true)).toBe(true);
expect(updated.features.find((feature: any) => feature.properties.kind === "radius").geometry.coordinates[0]).toHaveLength(65);
});
});
describe("MapView waypoints", () => {
it("creates a labelled waypoint source, updates it, and shows the waypoint picking hint", async () => {
const stableOnPickCoordinate = vi.fn();
const stableOnMapReady = vi.fn();
const view = render(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={{ lat: 53.34, lon: 7.18 }}
destination={{ lat: 51.68, lon: 7.8 }}
waypoints={[
{ lat: 52.8, lon: 7.25 },
{ lat: 52.1, lon: 7.5 }
]}
pickMode="waypoint"
route={null}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
expect(screen.getByText("Zwischenziel auf der Karte anklicken")).toBeVisible();
await act(async () => maplibreState.current.emit("load"));
expect(maplibreState.current.sourceDefinitions.get("waypoints")).toEqual({
type: "geojson",
data: {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { label: "Z1" },
geometry: { type: "Point", coordinates: [7.25, 52.8] }
},
{
type: "Feature",
properties: { label: "Z2" },
geometry: { type: "Point", coordinates: [7.5, 52.1] }
}
]
}
});
expect(maplibreState.current.layerDefinitions.get("waypoint-label")).toEqual(
expect.objectContaining({ source: "waypoints" })
);
const waypointSource = maplibreState.current.getSource("waypoints");
view.rerender(
<MapView
config={config}
position={null}
accuracyM={null}
startPoint={{ lat: 53.34, lon: 7.18 }}
destination={{ lat: 51.68, lon: 7.8 }}
waypoints={[{ lat: 52.4, lon: 7.65 }]}
pickMode="waypoint"
route={null}
onPickCoordinate={stableOnPickCoordinate}
onMapReady={stableOnMapReady}
/>
);
expect(waypointSource?.setData).toHaveBeenLastCalledWith({
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: { label: "Z1" },
geometry: { type: "Point", coordinates: [7.65, 52.4] }
}
]
});
});
});
describe("MarineFeatureInfo", () => {
it("summarizes missing contact information without rendering empty data rows and closes with Escape", () => {
const onClose = vi.fn();
const feature: MarineFeatureDetails = {
id: "harbour-missing",
layer: "harbours",
name: "Unbemannter Hafen",
typeLabel: "Hafen",
coordinate: { lat: 51.7, lon: 7.8 },
phone: null,
website: null,
email: null,
vhf: null,
openingHours: null,
operator: null,
address: null,
source: null,
updatedAt: null
};
render(<MarineFeatureInfo feature={feature} onClose={onClose} />);
expect(screen.getByText("Keine direkten Kontaktdaten hinterlegt.")).toBeVisible();
expect(screen.queryByText("Nicht hinterlegt")).not.toBeInTheDocument();
expect(screen.getByText("Daten & Quelle")).toBeVisible();
fireEvent.keyDown(document, { key: "Escape" });
expect(onClose).toHaveBeenCalledTimes(1);
});
it("links the enrichment provenance and explains merged map objects", () => {
const feature: MarineFeatureDetails = {
id: "lock-enriched",
layer: "locks",
name: "Schleuse Werries",
typeLabel: "Schleuse",
coordinate: { lat: 51.69508, lon: 7.86708 },
phone: "+49 2381 9019-290",
website: null,
email: null,
vhf: "22",
openingHours: null,
operator: "WSV",
address: null,
source: "EuRIS + OpenStreetMap",
sourceUrl: "https://www.eurisportal.eu/visuris/api/Locks_v2/GetLock?isrs=DEHMM00301LOCKS00404",
updatedAt: "2026-07-20T10:00:00.000Z",
memberCount: 4
};
render(<MarineFeatureInfo feature={feature} onClose={vi.fn()} />);
expect(screen.getByRole("link", { name: /Schleuse Werries anrufen/i })).toHaveAttribute(
"href",
"tel:+4923819019290"
);
fireEvent.click(screen.getByText("Daten & Quelle"));
expect(screen.getByRole("link", { name: "EuRIS + OpenStreetMap" })).toHaveAttribute(
"href",
expect.stringContaining("DEHMM00301LOCKS00404")
);
expect(screen.getByText("4 Kartenobjekte")).toBeVisible();
});
});
+68
View File
@@ -0,0 +1,68 @@
import { cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const apiMocks = vi.hoisted(() => ({
getMarineForecast: vi.fn(),
getNearestTide: vi.fn()
}));
vi.mock("../src/api", () => apiMocks);
import { useMarineData } from "../src/hooks/useMarineData";
beforeEach(() => {
apiMocks.getMarineForecast.mockResolvedValue({ source: "test", updatedAt: new Date().toISOString() });
apiMocks.getNearestTide.mockResolvedValue({
station: "test",
distanceKm: 1,
nextHigh: null,
nextLow: null,
waterLevelCurve: [],
source: "test",
updatedAt: new Date().toISOString()
});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("useMarineData", () => {
it("does not refetch weather and tide for every GPS jitter inside the same rounded cell", async () => {
const { result, rerender } = renderHook(
({ position }) => useMarineData(position),
{ initialProps: { position: { lat: 53.201, lon: 7.101 } } }
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(1);
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(1);
rerender({ position: { lat: 53.203, lon: 7.103 } });
await Promise.resolve();
expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(1);
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(1);
rerender({ position: { lat: 53.216, lon: 7.116 } });
await waitFor(() => expect(apiMocks.getMarineForecast).toHaveBeenCalledTimes(2));
expect(apiMocks.getNearestTide).toHaveBeenCalledTimes(2);
});
it("keeps partial data visible and exposes the failed source separately", async () => {
apiMocks.getMarineForecast.mockRejectedValueOnce(new Error("forecast offline"));
const { result } = renderHook(() =>
useMarineData({ lat: 53.2159, lon: 6.5766 })
);
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.forecast).toBeNull();
expect(result.current.tide?.station).toBe("test");
expect(result.current.forecastError).toBe("Wetterdaten nicht erreichbar");
expect(result.current.tideError).toBeNull();
expect(result.current.error).toBeNull();
expect(result.current.queryPosition).toEqual({ lat: 53.22, lon: 6.58 });
expect(result.current.refreshedAt).toEqual(expect.any(String));
});
});
@@ -0,0 +1,120 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
NavigationToolRail,
type NavigationToolId
} from "../src/components/NavigationToolRail";
import { NavigationWorkspace } from "../src/components/NavigationWorkspace";
afterEach(cleanup);
describe("NavigationToolRail", () => {
it("renders the fixed tool order with accessible state and badge information", () => {
const onSelect = vi.fn<(tool: NavigationToolId) => void>();
render(
<NavigationToolRail
activeTool="conditions"
onSelect={onSelect}
workspaceId="test-workspace"
statuses={{
anchor: "alarm",
conditions: "active",
upcoming: "caution",
route: "stale"
}}
badges={{ upcoming: 123 }}
/>
);
const rail = screen.getByRole("navigation", { name: "Kartenwerkzeuge" });
const buttons = within(rail).getAllByRole("button");
expect(buttons.map((button) => button.dataset.tool)).toEqual([
"anchor",
"conditions",
"upcoming",
"route"
]);
const anchor = screen.getByRole("button", { name: /Ankerwache, Alarm, öffnen/ });
expect(anchor).toHaveAttribute("data-status", "alarm");
expect(anchor).toHaveAttribute("aria-controls", "test-workspace");
expect(anchor).toHaveAttribute("aria-expanded", "false");
expect(anchor).toHaveAttribute("aria-pressed", "false");
const conditions = screen.getByRole("button", {
name: /Wetter und Tide, aktiv, geöffnet/
});
expect(conditions).toHaveAttribute("aria-expanded", "true");
expect(conditions).toHaveAttribute("aria-pressed", "true");
const upcoming = screen.getByRole("button", {
name: /Als Nächstes, Warnung, 99\+ Hinweise, öffnen/
});
expect(upcoming).toHaveAttribute("data-status", "caution");
expect(within(upcoming).getByText("99+")).toBeVisible();
const route = screen.getByRole("button", { name: /Route, Daten veraltet, öffnen/ });
fireEvent.click(route);
expect(onSelect).toHaveBeenCalledWith("route");
});
});
describe("NavigationWorkspace", () => {
it("keeps compact content inaccessible and exposes controlled size and close actions", () => {
const onSheetStateChange = vi.fn();
const onClose = vi.fn();
const onBack = vi.fn();
const { container, rerender } = render(
<NavigationWorkspace
id="test-workspace"
activeTool="route"
title="Routenübersicht"
summary="12 sm bis zum Ziel"
status="active"
sheetState="compact"
presentation="bottom-sheet"
onSheetStateChange={onSheetStateChange}
onBack={onBack}
onClose={onClose}
footer={<button type="button">Navigation starten</button>}
>
<button type="button">Route bearbeiten</button>
</NavigationWorkspace>
);
const workspace = screen.getByRole("complementary", { name: "Routenübersicht" });
expect(workspace).toHaveAttribute("data-tool", "route");
expect(workspace).toHaveAttribute("data-status", "active");
expect(workspace).toHaveAttribute("data-presentation", "bottom-sheet");
expect(workspace).toHaveAttribute("data-sheet-state", "compact");
expect(container.querySelector(".navigation-workspace-body")).toHaveAttribute("hidden");
expect(container.querySelector(".navigation-workspace-footer")).toHaveAttribute("hidden");
expect(screen.queryByRole("button", { name: "Route bearbeiten" })).not.toBeInTheDocument();
fireEvent.click(
screen.getByRole("button", { name: "Arbeitsbereich auf halbe Höhe vergrößern" })
);
expect(onSheetStateChange).toHaveBeenCalledWith("half");
fireEvent.click(screen.getByRole("button", { name: "Zurück" }));
expect(onBack).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Route schließen" }));
expect(onClose).toHaveBeenCalledTimes(1);
rerender(
<NavigationWorkspace
id="test-workspace"
activeTool="route"
title="Routenübersicht"
sheetState="half"
onClose={onClose}
>
<button type="button">Route bearbeiten</button>
</NavigationWorkspace>
);
expect(screen.getByRole("button", { name: "Route bearbeiten" })).toBeVisible();
expect(container.querySelector(".navigation-workspace-body")).not.toHaveAttribute("hidden");
});
});
+108
View File
@@ -0,0 +1,108 @@
import { beforeEach, describe, expect, it } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import {
OFFLINE_VOYAGES_STORAGE_KEY,
createOfflineVoyageRecord,
deleteOfflineVoyage,
listOfflineVoyages,
loadOfflineVoyage,
saveOfflineVoyage
} from "../src/lib/offline-route";
beforeEach(() => localStorage.clear());
describe("offline voyage storage", () => {
it("stores and restores a validated route and voyage plan", () => {
const saved = saveOfflineVoyage(
{
route: routeFixture,
name: "Törn Emden Hamm",
plan: {
vesselProfile: {
draughtM: 1.4,
safetyReserveM: 0.5,
airDraftM: 2.8,
beamM: 3.2,
cruiseSpeedKn: 6
},
waypoints: [{ lat: 51.65, lon: 7.34 }],
departureAt: "2026-07-20T06:00:00.000Z",
notes: "Schleusen vor Abfahrt prüfen"
}
},
localStorage,
{ id: "voyage-test", savedAt: "2026-07-19T12:00:00.000Z" }
);
expect(saved.plan.start).toEqual({ lat: 53.344167, lon: 7.186111 });
expect(saved.plan.destination).toEqual({ lat: 51.6814536, lon: 7.8042615 });
expect(saved.route.departureTime).toBe("2026-07-20T06:00:00.000Z");
expect(saved.route.durationMinutes).toBe(120);
expect(saved.route.alternatives).toBeUndefined();
expect(listOfflineVoyages(localStorage)).toHaveLength(1);
const restored = loadOfflineVoyage("voyage-test", localStorage);
expect(restored).toEqual(saved);
expect(restored?.route).toEqual(
expect.objectContaining({
departureTime: "2026-07-20T06:00:00.000Z",
durationMinutes: 120
})
);
expect(deleteOfflineVoyage("voyage-test", localStorage)).toBe(true);
expect(loadOfflineVoyage("voyage-test", localStorage)).toBeNull();
});
it("ignores corrupted or untrusted persisted values", () => {
localStorage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, "not-json");
expect(listOfflineVoyages(localStorage)).toEqual([]);
localStorage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, JSON.stringify([{
schemaVersion: 1,
id: "bad",
name: "Bad",
savedAt: "no date",
plan: {},
route: { geometry: { type: "LineString", coordinates: [[999, 0], [0, 0]] } }
}]));
expect(listOfflineVoyages(localStorage)).toEqual([]);
});
it("rejects unsafe route coordinates before writing", () => {
expect(() => createOfflineVoyageRecord({
route: {
...routeFixture,
geometry: { type: "LineString", coordinates: [[7, 53], [Number.NaN, 52]] }
}
})).toThrow(/ungültige Koordinaten|Zahlenwert/);
expect(localStorage.getItem(OFFLINE_VOYAGES_STORAGE_KEY)).toBeNull();
});
});
const routeFixture: RouteResult = {
id: "emden-hamm",
name: "Emden Hamm",
geometry: {
type: "LineString",
coordinates: [[7.186111, 53.344167], [7.1, 52.4], [7.8042615, 51.6814536]]
},
distanceNm: 153.69,
departureTime: "2026-07-20T06:00:00.000Z",
durationMinutes: 120,
eta: "2026-07-20T08:00:00.000Z",
warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich" }],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OSM"],
routingMode: "fairway",
alternatives: [{
id: "unused",
name: "Alternative",
geometry: { type: "LineString", coordinates: [[7, 53], [7.1, 52]] },
distanceNm: 160,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OSM"]
}]
};
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { distanceToRouteM, evaluateRouteDeviation } from "../src/lib/route-deviation";
describe("route deviation", () => {
it("calculates the cross-track distance to a route segment", () => {
const distance = distanceToRouteM(
{ lat: 0.001, lon: 0.005 },
[[0, 0], [0.01, 0]]
);
expect(distance).toBeGreaterThan(110);
expect(distance).toBeLessThan(112.5);
});
it("uses the endpoint when the nearest point lies beyond the segment", () => {
const distance = distanceToRouteM(
{ lat: 0, lon: 0.02 },
[[0, 0], [0.01, 0]]
);
expect(distance).toBeGreaterThan(1_110);
expect(distance).toBeLessThan(1_113);
});
it("handles a short segment crossing the antimeridian", () => {
const distance = distanceToRouteM(
{ lat: 0.001, lon: 180 },
[[179.9, 0], [-179.9, 0]]
);
expect(distance).toBeGreaterThan(110);
expect(distance).toBeLessThan(113);
});
it("accounts conservatively for GPS accuracy and suppresses unreliable alarms", () => {
const route = [[0, 0], [0.01, 0]] as const;
const near = evaluateRouteDeviation({ lat: 0.001, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 30 });
const away = evaluateRouteDeviation({ lat: 0.002, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 30 });
const inaccurate = evaluateRouteDeviation({ lat: 0.01, lon: 0.005 }, route, { thresholdM: 100, accuracyM: 500 });
expect(near?.isOffRoute).toBe(false);
expect(near?.conservativeDistanceM).toBeLessThan(100);
expect(away?.isOffRoute).toBe(true);
expect(inaccurate?.reliable).toBe(false);
expect(inaccurate?.isOffRoute).toBe(false);
});
it("returns null instead of alarming for invalid or missing route data", () => {
expect(distanceToRouteM({ lat: Number.NaN, lon: 7 }, [[7, 53], [7.1, 53.1]])).toBeNull();
expect(distanceToRouteM({ lat: 53, lon: 7 }, [])).toBeNull();
});
});
+202
View File
@@ -0,0 +1,202 @@
import { describe, expect, it } from "vitest";
import type {
RouteResult,
VoyageHarbour
} from "@watermaps/shared";
import {
nextRouteEventsByKind,
upcomingRouteEvents
} from "../src/routeEvents";
import type { RouteBridgeAssessment } from "../src/routeWeatherReport";
import type { RouteLock } from "../src/voyageHarbours";
const route: RouteResult = {
id: "eastbound",
geometry: {
type: "LineString",
coordinates: [
[0, 0],
[1, 0]
]
},
distanceNm: 60,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["test"]
};
describe("upcomingRouteEvents", () => {
it("projects mixed facilities, filters passed and off-corridor entries, and orders them along the route", () => {
const events = upcomingRouteEvents({
route,
progressNm: 12,
harbours: [
harbour("passed-harbour", 0.1, 0),
harbour("next-harbour", 0.6, 0),
harbour("off-route-harbour", 0.5, 0.05)
],
locks: [
lock("next-lock", 0.4, 0, 999, 0),
lock("off-route-lock", 0.5, 0.01, 1, 0)
],
bridges: [
bridge("next-bridge", 0.3, 0.0005, 99),
bridge("off-route-bridge", 0.35, 0.01, 0.001)
]
});
expect(events.map((event) => `${event.kind}:${event.id}`)).toEqual([
"bridge:next-bridge",
"lock:next-lock",
"harbour:next-harbour"
]);
expect(events.every((event) => event.routeDistanceNm >= 12)).toBe(true);
expect(events.every((event) => event.remainingNm >= 0)).toBe(true);
});
it("reprojects locks and bridges instead of trusting their existing distance fields", () => {
const [projectedLock, projectedBridge] = upcomingRouteEvents({
route,
locks: [lock("lock", 0.25, 0, 999, 777)],
bridges: [bridge("bridge", 0.75, 0, 999)]
});
expect(projectedLock?.kind).toBe("lock");
expect(projectedLock?.routeDistanceNm).toBeCloseTo(15, 0);
expect(projectedLock?.distanceFromRouteNm).toBeCloseTo(0, 6);
expect(projectedBridge?.kind).toBe("bridge");
expect(projectedBridge?.routeDistanceNm).toBeCloseTo(45, 0);
expect(projectedBridge?.distanceFromRouteNm).toBeCloseTo(0, 6);
});
it("uses a corridor override without changing the defaults for other kinds", () => {
const events = upcomingRouteEvents({
route,
corridorsNm: { harbour: 4 },
harbours: [harbour("detour", 0.5, 0.05)],
locks: [lock("off-route-lock", 0.5, 0.01, 0, 0)]
});
expect(events.map((event) => event.id)).toEqual(["detour"]);
});
it("calculates ETA and retains both speed and reference-time provenance", () => {
const [event] = upcomingRouteEvents({
route,
progressNm: 6,
locks: [lock("lock", 0.5, 0, 0, 0)],
etaBasis: {
speedKn: 6,
speedSource: "vessel-cruise-speed",
referenceTime: "2026-07-23T08:00:00.000Z",
referenceSource: "route-departure"
}
});
expect(event).toBeDefined();
expect(event!.remainingNm).toBeCloseTo(24, 0);
expect(event!.eta).toMatchObject({
speedKn: 6,
speedSource: "vessel-cruise-speed",
referenceTime: "2026-07-23T08:00:00.000Z",
referenceSource: "route-departure"
});
expect(event!.eta!.minutesFromProgress).toBeCloseTo(
(event!.remainingNm / 6) * 60,
8
);
expect(Date.parse(event!.eta!.estimatedAt)).toBeCloseTo(
Date.parse("2026-07-23T08:00:00.000Z") +
event!.eta!.minutesFromProgress * 60_000,
0
);
});
it("omits ETA for an invalid assumption rather than inventing a speed", () => {
const [event] = upcomingRouteEvents({
route,
harbours: [harbour("harbour", 0.5, 0)],
etaBasis: {
speedKn: 0,
speedSource: "gps-sog",
referenceTime: "not-a-time",
referenceSource: "current-time"
}
});
expect(event?.eta).toBeNull();
});
it("selects the first upcoming event of each kind from an ordered list", () => {
const events = upcomingRouteEvents({
route,
harbours: [
harbour("harbour-2", 0.8, 0),
harbour("harbour-1", 0.2, 0)
],
locks: [lock("lock-1", 0.3, 0, 0, 0)]
});
const next = nextRouteEventsByKind(events);
expect(next.harbour?.id).toBe("harbour-1");
expect(next.lock?.id).toBe("lock-1");
expect(next.bridge).toBeNull();
});
});
function harbour(
id: string,
lon: number,
lat: number
): VoyageHarbour {
return {
id,
name: id,
kind: "harbour",
coordinate: { lon, lat }
};
}
function lock(
id: string,
lon: number,
lat: number,
routeDistanceNm: number,
distanceFromRouteNm: number
): RouteLock {
return {
id,
name: id,
coordinate: { lon, lat },
routeDistanceNm,
distanceFromRouteNm,
openingHours: null,
phone: null,
vhf: null,
website: null
};
}
function bridge(
id: string,
lon: number,
lat: number,
distanceNm: number
): RouteBridgeAssessment {
return {
id,
name: id,
label: id,
coordinate: { lon, lat },
distanceNm,
clearanceM: null,
clearanceLabel: null,
requiredAirDraftM: null,
marginM: null,
status: "unknown",
source: "test"
};
}
+605
View File
@@ -0,0 +1,605 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RoutePlanner } from "../src/components/RoutePlanner";
import type { RouteWeatherReport } from "../src/routeWeatherReport";
afterEach(() => cleanup());
describe("RoutePlanner", () => {
it("submits vessel profile when start and destination exist", () => {
const onSubmit = vi.fn();
render(
<RoutePlanner
startPoint={{ lat: 54.18, lon: 12.08 }}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={{ lat: 54.2, lon: 12.1 }}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={onSubmit}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Route berechnen" }));
expect(onSubmit).toHaveBeenCalledWith({
start: { lat: 54.18, lon: 12.08 },
destination: { lat: 54.2, lon: 12.1 },
waypoints: [],
departureTime: expect.any(String),
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
});
});
it("submits ordered waypoints together with the selected departure time", () => {
const onSubmit = vi.fn();
const waypoints = [
{ lat: 52.4, lon: 7.1 },
{ lat: 51.9, lon: 7.45 }
];
render(
<RoutePlanner
startPoint={{ lat: 53.344167, lon: 7.186111 }}
gpsPosition={null}
destination={{ lat: 51.6814536, lon: 7.8042615 }}
waypoints={waypoints}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={onSubmit}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onPickWaypoint={vi.fn()}
onRemoveWaypoint={vi.fn()}
onMoveWaypoint={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
const localDeparture = "2026-07-20T08:30";
fireEvent.change(screen.getByLabelText("Abfahrt"), { target: { value: localDeparture } });
fireEvent.click(screen.getByRole("button", { name: "Route berechnen" }));
expect(onSubmit).toHaveBeenCalledWith({
start: { lat: 53.344167, lon: 7.186111 },
destination: { lat: 51.6814536, lon: 7.8042615 },
waypoints,
departureTime: new Date(localDeparture).toISOString(),
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 2.5, beamM: 3.2, cruiseSpeedKn: 6 }
});
});
it("offers an explicit start picking action", () => {
const onPickStart = vi.fn();
render(
<RoutePlanner
startPoint={null}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={onPickStart}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Start auf Karte setzen" }));
expect(onPickStart).toHaveBeenCalledTimes(1);
});
it("offers an explicit destination picking action", () => {
const onPickDestination = vi.fn();
render(
<RoutePlanner
startPoint={null}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={onPickDestination}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Ziel auf Karte setzen" }));
expect(onPickDestination).toHaveBeenCalledTimes(1);
});
it("does not offer Borkum or Hamm as direct destination shortcuts", () => {
render(
<RoutePlanner
startPoint={null}
gpsPosition={null}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
expect(screen.queryByRole("button", { name: /Borkum/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /Hamm/i })).not.toBeInTheDocument();
});
it("lets the skipper switch between returned route alternatives", () => {
const onSelectRoute = vi.fn();
const primary = routeOption("primary", "Hauptroute", 12);
const alternative = routeOption("alternative", "Alternative 1", 14.5);
render(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={primary}
routeOptions={[primary, alternative]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={onSelectRoute}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: /Alternative 1/ }));
expect(onSelectRoute).toHaveBeenCalledWith("alternative");
});
it("can collapse the route planner", () => {
const onCollapse = vi.fn();
render(
<RoutePlanner
startPoint={null}
gpsPosition={null}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={onCollapse}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Routenfenster ausblenden" }));
expect(onCollapse).toHaveBeenCalledTimes(1);
});
it("controls mobile sheet heights and leaves compact mode when the desktop layout starts", () => {
let desktopLayout = false;
let changeListener: (() => void) | null = null;
const originalMatchMedia = window.matchMedia;
const mediaQuery = {
get matches() {
return desktopLayout;
},
media: "(min-width: 720px)",
onchange: null,
addEventListener: vi.fn((_type: string, listener: () => void) => {
changeListener = listener;
}),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn()
} as unknown as MediaQueryList;
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: vi.fn(() => mediaQuery)
});
const { container, unmount } = render(
<RoutePlanner
startPoint={null}
gpsPosition={null}
destination={null}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
const panel = container.querySelector("aside.route-panel");
const body = container.querySelector(".route-panel-body");
expect(panel).toHaveAttribute("data-sheet-state", "half");
fireEvent.click(screen.getByRole("button", { name: "Routenfenster auf volle Höhe vergrößern" }));
expect(panel).toHaveAttribute("data-sheet-state", "full");
fireEvent.click(screen.getByRole("button", { name: "Routenfenster auf kompakte Höhe verkleinern" }));
expect(panel).toHaveAttribute("data-sheet-state", "compact");
expect(body).toHaveAttribute("hidden");
desktopLayout = true;
act(() => changeListener?.());
expect(panel).toHaveAttribute("data-sheet-state", "half");
expect(body).not.toHaveAttribute("hidden");
unmount();
Object.defineProperty(window, "matchMedia", {
configurable: true,
value: originalMatchMedia
});
});
it("offers the course assistant only after a route was planned", () => {
const onStartGuidance = vi.fn();
const { rerender } = render(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={null}
routeOptions={[]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onStartGuidance={onStartGuidance}
onCollapse={vi.fn()}
/>
);
expect(screen.queryByRole("button", { name: "Navigation starten" })).not.toBeInTheDocument();
rerender(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={routeOption("primary", "Hauptroute", 4)}
routeOptions={[routeOption("primary", "Hauptroute", 4)]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onStartGuidance={onStartGuidance}
onCollapse={vi.fn()}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Navigation starten" }));
expect(onStartGuidance).toHaveBeenCalledTimes(1);
});
it("opens the compact result view and keeps critical warnings above secondary route details", () => {
const result = {
...routeOption("primary", "Hauptroute", 4),
warnings: [
{ code: "weather", severity: "caution" as const, message: "Wind aufmerksam beobachten." },
{ code: "bridge", severity: "critical" as const, message: "Brücke ist zu niedrig." }
]
};
render(
<RoutePlanner
startPoint={{ lat: 52, lon: 7 }}
gpsPosition={null}
destination={{ lat: 52, lon: 7.1 }}
result={result}
routeOptions={[result]}
weatherReport={null}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
expect(screen.getByText("Routenergebnis")).toBeVisible();
expect(screen.getByText("Brücke ist zu niedrig.")).toBeVisible();
expect(screen.getByText("Wind aufmerksam beobachten.")).toBeVisible();
expect(screen.queryByRole("button", { name: "Start auf Karte setzen" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Plan ändern" }));
expect(screen.getByText("Route planen")).toBeVisible();
expect(screen.getByRole("button", { name: "Start auf Karte setzen" })).toBeVisible();
expect(screen.getByRole("button", { name: "Zur Route" })).toBeVisible();
});
it("shows the route weather report after a route was calculated", () => {
render(
<RoutePlanner
startPoint={{ lat: 54.18, lon: 12.08 }}
gpsPosition={{ lat: 54.18, lon: 12.08 }}
destination={{ lat: 54.2, lon: 12.1 }}
result={{
geometry: { type: "LineString", coordinates: [[12.08, 54.18], [12.1, 54.2]] },
distanceNm: 4.2,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
}}
routeOptions={[]}
weatherReport={weatherReportFixture}
weatherLoading={false}
weatherError={null}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
/>
);
expect(screen.getByRole("region", { name: "Fahrtbericht" })).toBeVisible();
expect(screen.getByText(/Aufmerksam fahren/)).toBeVisible();
expect(screen.getByText("18 kn 270°")).toBeVisible();
expect(screen.getByText("1.2 m 290°")).toBeVisible();
expect(screen.getByText(/Nicht passierbar/)).toBeVisible();
expect(screen.getByText("Niedrige Brücke")).toBeVisible();
});
it("keeps lock-delay planning and the current-adjusted ETA in the embedded route tool", () => {
const adjustedReport: RouteWeatherReport = {
...weatherReportFixture,
adjustedEta: "2026-07-13T14:45:00.000Z",
currentAdjustmentMinutes: 45,
averageAlongRouteCurrentKn: -0.6
};
const result = routeOption("primary", "Hauptroute", 18);
render(
<RoutePlanner
startPoint={{ lat: 53.3, lon: 7.2 }}
gpsPosition={null}
destination={{ lat: 52.9, lon: 7.4 }}
result={result}
routeOptions={[result]}
weatherReport={adjustedReport}
weatherLoading={false}
weatherError={null}
routeLocks={[
{
id: "lock-1",
name: "Testschleuse",
coordinate: { lat: 53.1, lon: 7.3 },
routeDistanceNm: 8,
distanceFromRouteNm: 0.02,
openingHours: "06:00-22:00",
phone: "+49 123 456",
vhf: "20",
website: null
}
]}
loading={false}
error={null}
pickMode={null}
onSubmit={vi.fn()}
onPickStart={vi.fn()}
onPickDestination={vi.fn()}
onClearStart={vi.fn()}
onClearDestination={vi.fn()}
onUseGpsAsStart={vi.fn()}
onSelectRoute={vi.fn()}
onCollapse={vi.fn()}
operationalPanelsVisible={false}
embedded
/>
);
expect(screen.getByText(/Strömungs-ETA/)).toBeVisible();
fireEvent.click(screen.getByText("Schleusenplanung · 1 auf der Route"));
expect(screen.getByRole("spinbutton", { name: /Pauschale/ })).toHaveValue(20);
expect(screen.getByText(/Plan-ETA inkl. Schleusenpuffer/)).toBeVisible();
expect(screen.queryByRole("region", { name: "Fahrtbericht" })).not.toBeInTheDocument();
});
});
const weatherReportFixture: RouteWeatherReport = {
samples: [
{
label: "Start",
coordinate: { lat: 54.18, lon: 12.08 },
forecast: {
waveHeightM: 0.8,
waveDirectionDeg: 280,
wavePeriodS: 4,
windSpeed: 12,
windDirectionDeg: 260,
weatherCode: 2,
temperatureC: 18,
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z"
}
},
{
label: "Ziel",
coordinate: { lat: 54.2, lon: 12.1 },
forecast: {
waveHeightM: 1.2,
waveDirectionDeg: 290,
wavePeriodS: 5,
windSpeed: 18,
windDirectionDeg: 270,
weatherCode: 3,
temperatureC: 18,
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z"
}
}
],
maxWaveHeightM: 1.2,
maxWindSpeedKn: 18,
maxWavePeriodS: 5,
strongestWindDirectionDeg: 270,
highestWaveDirectionDeg: 290,
severity: "caution",
summary: "Aufmerksam fahren: bis 18 kn Wind, 1.2 m Welle.",
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z",
unavailableSamples: 0,
departureTime: "2026-07-13T12:00:00.000Z",
adjustedEta: null,
currentAdjustmentMinutes: null,
averageAlongRouteCurrentKn: null,
bridgeReport: {
bridges: [
{
id: "bridge-low",
name: "Niedrige Brücke",
label: "Niedrige Brücke H 2.2 m",
coordinate: { lat: 54.19, lon: 12.09 },
distanceNm: 0,
clearanceM: 2.2,
clearanceLabel: "H 2.2 m",
requiredAirDraftM: 2.5,
marginM: -0.3,
status: "too_low",
source: "OSM/Geofabrik"
}
],
requiredAirDraftM: 2.5,
checkedCount: 1,
unknownCount: 0,
tooLowCount: 1,
tightCount: 0,
minClearanceM: 2.2,
severity: "critical",
summary: "Nicht passierbar: 1 Brücke(n) niedriger als 2.5 m Bootshöhe.",
source: "OSM/Geofabrik",
updatedAt: "2026-07-13T12:00:00.000Z"
}
};
function routeOption(id: string, name: string, distanceNm: number) {
return {
id,
name,
geometry: { type: "LineString" as const, coordinates: [[7, 52], [7.1, 52]] as [number, number][] },
distanceNm,
eta: "2026-07-13T14:00:00.000Z",
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway" as const
};
}
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, it } from "vitest";
import type { MarineForecast, RouteResult } from "@watermaps/shared";
import { createRouteWeatherReport, summarizeRouteWeather } from "../src/routeWeatherReport";
describe("route weather report", () => {
it("samples start, middle and destination forecasts", async () => {
const requested: Array<{ lat: number; lon: number }> = [];
const report = await createRouteWeatherReport(routeFixture, async (coordinate) => {
requested.push(coordinate);
return forecast({
windSpeed: coordinate.lon > 7.1 ? 18 : 10,
waveHeightM: coordinate.lon > 7.1 ? 1.2 : 0.4
});
});
expect(requested).toHaveLength(3);
expect(report.maxWindSpeedKn).toBe(18);
expect(report.maxWaveHeightM).toBe(1.2);
expect(report.severity).toBe("caution");
expect(report.summary).toContain("Aufmerksam fahren");
expect(report.bridgeReport).toBeNull();
});
it("projects forecasts along the route timeline and adjusts ETA for along-route current", async () => {
const requestedTimes: Array<string | undefined> = [];
const departureTime = "2026-07-20T06:00:00.000Z";
const timedRoute: RouteResult = {
...routeFixture,
geometry: {
type: "LineString",
coordinates: [[7, 52], [7.2, 52], [7.4, 52]]
},
distanceNm: 20,
departureTime,
durationMinutes: 240,
eta: "2026-07-20T10:00:00.000Z"
};
const report = await createRouteWeatherReport(
timedRoute,
{ draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 5 },
async (_coordinate, at) => {
requestedTimes.push(at);
return forecast({
oceanCurrentSpeedKn: 1,
oceanCurrentDirectionDeg: 90,
forecastTime: at
});
}
);
expect(requestedTimes).toEqual([
"2026-07-20T06:00:00.000Z",
"2026-07-20T08:00:00.000Z",
"2026-07-20T10:00:00.000Z"
]);
expect(report.samples.map((sample) => sample.plannedTime)).toEqual(requestedTimes);
expect(report.samples.map((sample) => sample.currentAlongRouteKn)).toEqual([1, 1, 1]);
expect(report.departureTime).toBe(departureTime);
expect(report.averageAlongRouteCurrentKn).toBe(1);
expect(report.currentAdjustmentMinutes).toBe(-40);
expect(report.adjustedEta).toBe("2026-07-20T09:20:00.000Z");
});
it("adds a bridge report and marks a route as blocked by a low bridge", async () => {
const report = await createRouteWeatherReport(
routeFixture,
{ draughtM: 1.4, safetyReserveM: 0.5, airDraftM: 3 },
async () => forecast({}),
async (params) => {
expect(params.layers).toEqual(["bridges"]);
return {
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "bridge-low",
properties: {
layer: "bridges",
name: "Niedrige Brücke",
clearance_m: 2.7,
clearance_label: "H 2.7 m",
label: "Niedrige Brücke H 2.7 m",
source: "OSM/Geofabrik"
},
geometry: {
type: "LineString",
coordinates: [
[6.99, 53.5],
[7.21, 53.5]
]
}
},
{
type: "Feature",
id: "bridge-high",
properties: {
layer: "bridges",
name: "Hohe Brücke",
clearance_m: 4.2,
clearance_label: "H 4.2 m",
label: "Hohe Brücke H 4.2 m",
source: "OSM/Geofabrik"
},
geometry: {
type: "LineString",
coordinates: [
[7.18, 53.68],
[7.22, 53.68]
]
}
}
]
};
}
);
expect(report.bridgeReport?.severity).toBe("critical");
expect(report.bridgeReport?.tooLowCount).toBe(1);
expect(report.bridgeReport?.checkedCount).toBe(2);
expect(report.bridgeReport?.summary).toContain("Nicht passierbar");
expect(report.bridgeReport?.bridges[0]?.name).toBe("Niedrige Brücke");
});
it("marks critical weather when wind or wave thresholds are exceeded", () => {
const report = summarizeRouteWeather([
{
label: "Mitte",
coordinate: { lat: 53.5, lon: 7.1 },
forecast: forecast({ windSpeed: 28, waveHeightM: 1.1 })
}
]);
expect(report.severity).toBe("critical");
expect(report.summary).toContain("Kritische Bedingungen");
});
});
const routeFixture: RouteResult = {
geometry: {
type: "LineString",
coordinates: [
[7, 53.3],
[7.1, 53.5],
[7.2, 53.7]
]
},
distanceNm: 25,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
};
function forecast(overrides: Partial<MarineForecast>): MarineForecast {
return {
waveHeightM: 0.4,
waveDirectionDeg: 280,
wavePeriodS: 4,
windSpeed: 10,
windDirectionDeg: 260,
weatherCode: 2,
temperatureC: 18,
source: "Test",
updatedAt: "2026-07-13T12:00:00.000Z",
...overrides
};
}
+86
View File
@@ -0,0 +1,86 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { MarineForecast, RouteGuidanceResult } from "@watermaps/shared";
import { StatusBar } from "../src/components/StatusBar";
import type { GpsState } from "../src/hooks/useGeolocation";
afterEach(cleanup);
const idleGps: GpsState = {
status: "idle",
position: null,
accuracyM: null,
speedKn: null,
courseDeg: null,
timestampMs: null,
message: null
};
describe("StatusBar", () => {
it("shows only three planning values without claiming that a route is safe", () => {
const forecast = {
windSpeed: 12,
waveHeightM: 0.8
} as MarineForecast;
const { container } = render(
<StatusBar
gps={idleGps}
forecast={forecast}
tide={null}
routeWarningCount={0}
mode="planning"
/>
);
expect(container.querySelectorAll(".status-item")).toHaveLength(3);
expect(screen.getByText("12 kn · 0.8 m")).toBeVisible();
expect(screen.queryByText("Route OK")).not.toBeInTheDocument();
});
it("prioritizes course, cross-track error and open warnings during guidance", () => {
const guidance = {
desiredCourseDeg: 87,
distanceToRouteM: 24,
crossTrackSide: "starboard"
} as RouteGuidanceResult;
render(
<StatusBar
gps={{ ...idleGps, status: "tracking", speedKn: 6.2 }}
forecast={null}
tide={null}
routeWarningCount={2}
mode="guidance"
guidance={guidance}
/>
);
expect(screen.getByText("087°T")).toBeVisible();
expect(screen.getByText("24 m Stb")).toBeVisible();
expect(screen.getByText("2 offen")).toBeVisible();
});
it("shows anchor drift, GPS and the expected tide rise in anchor mode", () => {
render(
<StatusBar
gps={{ ...idleGps, status: "tracking", position: { lat: 53.2, lon: 7.1 }, accuracyM: 5 }}
forecast={null}
tide={null}
routeWarningCount={0}
mode="anchor"
anchor={{
distanceFromAnchorM: 11.5,
alarmRadiusM: 40,
alarm: false,
maximumTideRiseM: 1.3
}}
/>
);
expect(screen.getByText("12 / 40 m")).toBeVisible();
expect(screen.getByText("±5 m")).toBeVisible();
expect(screen.getByText("+1.3 m")).toBeVisible();
});
});
@@ -0,0 +1,197 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { UpcomingRouteEvent } from "../src/routeEvents";
import { UpcomingEventsPanel } from "../src/components/UpcomingEventsPanel";
afterEach(cleanup);
describe("UpcomingEventsPanel", () => {
it("shows the nearest event first, preserves chronological list order and filters by kind", () => {
render(<UpcomingEventsPanel events={events} hasRoute />);
const hero = screen.getByText("Nächstes Ereignis").closest("article");
expect(hero).not.toBeNull();
expect(within(hero!).getByText("Schleuse Nah")).toBeVisible();
expect(within(hero!).getByText("3,0 sm")).toBeVisible();
expect(within(hero!).getByText(/ETA/)).toBeVisible();
const list = screen.getByRole("list");
expect(
within(list)
.getAllByRole("button")
.map((button) => button.textContent)
).toEqual([
expect.stringContaining("Schleuse Nah"),
expect.stringContaining("Brücke Mitte"),
expect.stringContaining("Hafen Weit")
]);
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute(
"aria-pressed",
"true"
);
expect(screen.getAllByText("Brücke Mitte")).toHaveLength(2);
expect(screen.queryByText("Schleuse Nah")).not.toBeInTheDocument();
expect(screen.queryByText("Hafen Weit")).not.toBeInTheDocument();
expect(within(screen.getByRole("list")).getAllByRole("button")).toHaveLength(1);
});
it("opens a lock drilldown with direct contact, VHF, hours and map actions", () => {
const onShowOnMap = vi.fn();
render(
<UpcomingEventsPanel
events={events}
hasRoute
onShowOnMap={onShowOnMap}
/>
);
const hero = screen.getByText("Nächstes Ereignis").closest("article");
fireEvent.click(within(hero!).getByRole("button", { name: /Schleuse Nah/ }));
const detail = screen.getByRole("region", { name: "Schleuse Nah" });
expect(within(detail).getByText("MoFr 08:0018:00")).toBeVisible();
expect(within(detail).getByText("Kanal 12")).toBeVisible();
expect(within(detail).getByRole("link", { name: "Schleuse Nah anrufen" })).toHaveAttribute(
"href",
"tel:+4949123456"
);
expect(
within(detail).getByRole("link", { name: "Website von Schleuse Nah öffnen" })
).toHaveAttribute("href", "https://lock.example/");
fireEvent.click(within(detail).getByRole("button", { name: "Auf Karte zeigen" }));
expect(onShowOnMap).toHaveBeenCalledWith(events[0]);
fireEvent.click(within(detail).getByRole("button", { name: "Zurück zur Ereignisliste" }));
expect(screen.getByText("Nächstes Ereignis")).toBeVisible();
});
it("shows bridge clearance, required height and reserve in its drilldown", () => {
render(<UpcomingEventsPanel events={events} hasRoute />);
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
const hero = screen.getByText("Nächstes Ereignis").closest("article");
fireEvent.click(within(hero!).getByRole("button", { name: /Brücke Mitte/ }));
const detail = screen.getByRole("region", { name: "Brücke Mitte" });
expect(within(detail).getByText("H 4,2 m")).toBeVisible();
expect(within(detail).getByText("3.8 m")).toBeVisible();
const reserve = within(detail).getByText("Reserve").closest("div");
expect(reserve).toHaveTextContent("0.4 m Reserve");
expect(within(detail).queryByRole("button", { name: "Auf Karte zeigen" })).not.toBeInTheDocument();
});
it.each([
{
props: { hasRoute: false, events: [] as UpcomingRouteEvent[] },
text: "Noch keine Route",
role: undefined
},
{
props: { hasRoute: true, events: [] as UpcomingRouteEvent[], loading: true },
text: "Ereignisse werden geladen",
role: "status"
},
{
props: {
hasRoute: true,
events: [] as UpcomingRouteEvent[],
error: "Datenquelle antwortet nicht"
},
text: "Ereignisse nicht erreichbar",
role: "alert"
},
{
props: { hasRoute: true, events: [] as UpcomingRouteEvent[] },
text: "Keine bevorstehenden Ereignisse",
role: undefined
}
])("renders the state '$text'", ({ props, text, role }) => {
render(<UpcomingEventsPanel {...props} />);
expect(screen.getByText(text)).toBeVisible();
if (role) {
expect(screen.getByRole(role)).toBeVisible();
}
});
});
const events: UpcomingRouteEvent[] = [
{
kind: "lock",
id: "lock-near",
name: "Schleuse Nah",
coordinate: { lat: 53.1, lon: 7.1 },
routeDistanceNm: 8,
distanceFromRouteNm: 0.02,
remainingNm: 3,
eta: eta("2026-07-23T12:30:00.000Z", 30),
feature: {
id: "lock-near",
name: "Schleuse Nah",
coordinate: { lat: 53.1, lon: 7.1 },
routeDistanceNm: 8,
distanceFromRouteNm: 0.02,
openingHours: "MoFr 08:0018:00",
phone: "+49 49 123456",
vhf: "Kanal 12",
website: "lock.example"
}
},
{
kind: "harbour",
id: "harbour-far",
name: "Hafen Weit",
coordinate: { lat: 53.3, lon: 7.3 },
routeDistanceNm: 17,
distanceFromRouteNm: 0.4,
remainingNm: 12,
eta: eta("2026-07-23T14:00:00.000Z", 120),
feature: {
id: "harbour-far",
name: "Hafen Weit",
coordinate: { lat: 53.3, lon: 7.3 },
kind: "marina",
amenities: { water: "available", electricity: true },
phone: null,
website: null
}
},
{
kind: "bridge",
id: "bridge-middle",
name: "Brücke Mitte",
coordinate: { lat: 53.2, lon: 7.2 },
routeDistanceNm: 11,
distanceFromRouteNm: 0.01,
remainingNm: 6,
eta: eta("2026-07-23T13:00:00.000Z", 60),
feature: {
id: "bridge-middle",
name: "Brücke Mitte",
label: "Brücke Mitte H 4,2 m",
coordinate: { lat: 53.2, lon: 7.2 },
distanceNm: 0.01,
clearanceM: 4.2,
clearanceLabel: "H 4,2 m",
requiredAirDraftM: 3.8,
marginM: 0.4,
status: "tight",
source: "Test"
}
}
];
function eta(estimatedAt: string, minutesFromProgress: number) {
return {
estimatedAt,
minutesFromProgress,
speedKn: 6,
speedSource: "vessel-cruise-speed" as const,
referenceTime: "2026-07-23T12:00:00.000Z",
referenceSource: "current-time" as const
};
}
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from "vitest";
import type { FeatureCollection, Geometry } from "geojson";
import {
routeFeatureBounds,
routeLocksFromFeatures,
voyageHarboursFromFeatures
} from "../src/voyageHarbours";
describe("voyage harbour feature adapter", () => {
it("turns normalized point features into amenity-aware harbour candidates", () => {
const collection: FeatureCollection<Geometry> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "hamm-marina",
geometry: { type: "Point", coordinates: [7.8, 51.68] },
properties: {
layer: "harbours",
name: "Marina Hamm",
leisure: "marina",
phone: "+49 2381 123",
website: "hamm.example",
email: "hafen@hamm.example",
vhf: "12",
opening_hours: "täglich 08:00-20:00",
operator: "Hafen Hamm",
"addr:street": "Uferweg",
"addr:housenumber": "4",
"addr:postcode": "59000",
"addr:city": "Hamm",
power_supply: "yes",
drinking_water: "yes",
guest_berths: 8
}
},
{
type: "Feature",
geometry: { type: "LineString", coordinates: [[7, 52], [8, 52]] },
properties: { layer: "harbours", name: "Keine Punktgeometrie" }
},
{
type: "Feature",
geometry: { type: "Point", coordinates: [7.7, 51.7] },
properties: { layer: "locks", name: "Keine Marina" }
}
]
};
expect(voyageHarboursFromFeatures(collection)).toEqual([
expect.objectContaining({
id: "hamm-marina",
name: "Marina Hamm",
kind: "marina",
coordinate: { lat: 51.68, lon: 7.8 },
email: "hafen@hamm.example",
vhf: "12",
openingHours: "täglich 08:00-20:00",
operator: "Hafen Hamm",
address: "Uferweg 4, 59000 Hamm",
amenities: expect.objectContaining({
electricity: "available",
water: "available",
overnight: "available"
})
})
]);
});
it("calculates a nautical-mile padded feature request box", () => {
const bounds = routeFeatureBounds(
{
geometry: {
type: "LineString",
coordinates: [[7, 52], [8, 53]]
}
},
6
);
expect(bounds[0]).toBeLessThan(6.84);
expect(bounds[1]).toBeCloseTo(51.9, 5);
expect(bounds[2]).toBeGreaterThan(8.16);
expect(bounds[3]).toBeCloseTo(53.1, 5);
});
it("filters and orders route locks while preserving opening hours and phone", () => {
const collection: FeatureCollection<Geometry> = {
type: "FeatureCollection",
features: [
{
type: "Feature",
id: "lock-late",
geometry: { type: "Point", coordinates: [7.8, 52] },
properties: {
layer: "locks",
name: "Schleuse Ost",
openingHours: "Mo-Su 06:00-22:00",
phone: "+49 2381 200"
}
},
{
type: "Feature",
id: "lock-off-route",
geometry: { type: "Point", coordinates: [7.5, 52.02] },
properties: { layer: "locks", name: "Entfernte Schleuse" }
},
{
type: "Feature",
id: "lock-early",
geometry: { type: "Point", coordinates: [7.2, 52] },
properties: {
layer: "locks",
name: "Schleuse West",
opening_hours: "nach Anmeldung",
phone: "+49 2381 100"
}
},
{
type: "Feature",
id: "harbour-on-route",
geometry: { type: "Point", coordinates: [7.4, 52] },
properties: { layer: "harbours", name: "Kein Schleusenpunkt" }
}
]
};
const locks = routeLocksFromFeatures(collection, {
geometry: {
type: "LineString",
coordinates: [[7, 52], [8, 52]]
},
distanceNm: 41
});
expect(locks.map((lock) => lock.id)).toEqual(["lock-early", "lock-late"]);
expect(locks[0]).toEqual(
expect.objectContaining({
name: "Schleuse West",
openingHours: "nach Anmeldung",
phone: "+49 2381 100",
distanceFromRouteNm: 0
})
);
expect(locks[1]).toEqual(
expect.objectContaining({
name: "Schleuse Ost",
openingHours: "Mo-Su 06:00-22:00",
phone: "+49 2381 200",
distanceFromRouteNm: 0
})
);
expect(locks[0]!.routeDistanceNm).toBeLessThan(locks[1]!.routeDistanceNm);
});
});
@@ -0,0 +1,133 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { RouteResult } from "@watermaps/shared";
import { VoyageNavigationTools } from "../src/components/VoyageNavigationTools";
const originalGeolocation = Object.getOwnPropertyDescriptor(navigator, "geolocation");
const originalVibrate = Object.getOwnPropertyDescriptor(navigator, "vibrate");
beforeEach(() => localStorage.clear());
afterEach(() => {
cleanup();
localStorage.clear();
restoreNavigatorProperty("geolocation", originalGeolocation);
restoreNavigatorProperty("vibrate", originalVibrate);
});
describe("VoyageNavigationTools", () => {
it("does not request geolocation before the skipper starts the alarm", () => {
const watchPosition = vi.fn(() => 17);
installGeolocation(watchPosition, vi.fn());
render(<VoyageNavigationTools route={routeFixture} />);
expect(watchPosition).not.toHaveBeenCalled();
expect(screen.getByText(/GPS wird erst nach/)).toBeInTheDocument();
});
it("does not open a second GPS watch while the course assistant is active", () => {
const watchPosition = vi.fn(() => 17);
installGeolocation(watchPosition, vi.fn());
render(<VoyageNavigationTools route={routeFixture} courseAssistantActive />);
expect(screen.getByRole("button", { name: "Kursalarm ist im Kursassistenten enthalten" })).toBeDisabled();
expect(screen.getByText(/Querabweichung.*Kursassistenten/)).toBeVisible();
expect(watchPosition).not.toHaveBeenCalled();
});
it("starts explicitly and warns when the vessel leaves the route", () => {
let success: PositionCallback | undefined;
const watchPosition = vi.fn((next: PositionCallback) => {
success = next;
return 42;
});
const clearWatch = vi.fn();
const vibrate = vi.fn();
installGeolocation(watchPosition, clearWatch);
Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate });
render(<VoyageNavigationTools route={routeFixture} />);
fireEvent.click(screen.getByRole("button", { name: "Abweichungsalarm starten" }));
expect(watchPosition).toHaveBeenCalledTimes(1);
expect(screen.getByRole("button", { name: "Abweichungsalarm stoppen" })).toBeInTheDocument();
act(() => success?.(positionAt(53.01, 7.005, 5)));
expect(screen.getByRole("alert")).toHaveTextContent(/von der Route entfernt/);
expect(vibrate).toHaveBeenCalledWith([200, 100, 200]);
fireEvent.click(screen.getByRole("button", { name: "Abweichungsalarm stoppen" }));
expect(clearWatch).toHaveBeenCalledWith(42);
});
it("saves and restores the route and plan on this device", () => {
const onLoad = vi.fn();
render(
<VoyageNavigationTools
route={routeFixture}
plan={{
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 },
departureAt: "2026-07-20T06:00:00.000Z"
}}
onLoadOfflineVoyage={onLoad}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Route offline speichern" }));
expect(screen.getByText(/ist auf diesem Gerät offline verfügbar/)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Offline-Route laden" }));
expect(onLoad).toHaveBeenCalledTimes(1);
expect(onLoad.mock.calls[0]?.[0].route.geometry).toEqual(routeFixture.geometry);
expect(onLoad.mock.calls[0]?.[0].plan.vesselProfile.cruiseSpeedKn).toBe(6);
});
});
function installGeolocation(watchPosition: typeof navigator.geolocation.watchPosition, clearWatch: typeof navigator.geolocation.clearWatch) {
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: { watchPosition, clearWatch, getCurrentPosition: vi.fn() }
});
}
function restoreNavigatorProperty(name: "geolocation" | "vibrate", descriptor: PropertyDescriptor | undefined) {
if (descriptor) {
Object.defineProperty(navigator, name, descriptor);
} else {
Reflect.deleteProperty(navigator, name);
}
}
function positionAt(lat: number, lon: number, accuracy: number): GeolocationPosition {
return {
coords: {
latitude: lat,
longitude: lon,
accuracy,
altitude: null,
altitudeAccuracy: null,
heading: null,
speed: null,
toJSON: () => ({})
},
timestamp: Date.now(),
toJSON: () => ({})
};
}
const routeFixture: RouteResult = {
id: "test-route",
name: "Testfahrt",
geometry: { type: "LineString", coordinates: [[7, 53], [7.01, 53]] },
distanceNm: 0.4,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"],
routingMode: "fairway"
};
+142
View File
@@ -0,0 +1,142 @@
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import type { VoyagePlan as VoyagePlanResult } from "@watermaps/shared";
import { VoyagePlan } from "../src/components/VoyagePlan";
afterEach(() => cleanup());
describe("VoyagePlan", () => {
it("shows daily legs, waypoints, harbour supply and contacts", () => {
render(<VoyagePlan plan={plan()} />);
expect(screen.getByRole("heading", { name: "Etappenplan" })).toBeInTheDocument();
expect(screen.getByText(/2 Tage/)).toHaveTextContent("2 Tage · 72,5 sm · 12 h 05 min");
expect(screen.getByText("Start → Marina Mitte")).toBeInTheDocument();
expect(screen.getByText("Via: Schleuse Eins")).toBeInTheDocument();
const supply = screen.getByRole("list", { name: "Versorgung in Marina Mitte" });
expect(within(supply).getByLabelText("Strom: verfügbar")).toBeInTheDocument();
expect(within(supply).getByLabelText("Treibstoff: nicht verfügbar")).toBeInTheDocument();
expect(within(supply).getByLabelText("Entsorgung: unbekannt")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Hafen anrufen" })).toHaveAttribute("href", "tel:+4923811234");
expect(screen.getByRole("link", { name: "Website" })).toHaveAttribute("href", "https://hafen.example");
});
it("renders unconfirmed stops and planning warnings clearly", () => {
const result = plan();
result.legs[0] = {
...result.legs[0]!,
end: {
type: "route",
name: "Tagesziel auf der Route",
coordinate: { lat: 52, lon: 7.5 },
routeDistanceNm: 40,
distanceFromRouteNm: 0,
harbour: null
}
};
result.warnings = [
{
code: "NO_SUITABLE_HARBOUR",
severity: "caution",
message: "Kein geeigneter Hafen gefunden.",
day: 1
}
];
render(<VoyagePlan plan={result} />);
expect(screen.getByText("Kein bestätigter Liegeplatz")).toBeInTheDocument();
expect(screen.getByRole("list", { name: "Hinweise zum Etappenplan" })).toHaveTextContent(
"Kein geeigneter Hafen gefunden."
);
});
it("renders nothing until a plan exists", () => {
const { container } = render(<VoyagePlan plan={null} />);
expect(container).toBeEmptyDOMElement();
});
});
function plan(): VoyagePlanResult {
const start = {
type: "start" as const,
name: "Start",
coordinate: { lat: 52, lon: 7 },
routeDistanceNm: 0,
distanceFromRouteNm: 0,
harbour: null
};
const harbour = {
id: "marina-mitte",
name: "Marina Mitte",
kind: "marina" as const,
coordinate: { lat: 52, lon: 7.5 },
phone: "+49 (2381) 1234",
website: "hafen.example",
amenities: {
electricity: "available" as const,
water: "available" as const,
fuel: "unavailable" as const,
waste: "unknown" as const,
overnight: "available" as const
},
routeDistanceNm: 40,
distanceFromRouteNm: 0.25
};
const middle = {
type: "harbour" as const,
name: harbour.name,
coordinate: harbour.coordinate,
routeDistanceNm: harbour.routeDistanceNm,
distanceFromRouteNm: harbour.distanceFromRouteNm,
harbour
};
const destination = {
type: "destination" as const,
name: "Ziel",
coordinate: { lat: 52, lon: 8 },
routeDistanceNm: 72,
distanceFromRouteNm: 0,
harbour: null
};
return {
legs: [
{
day: 1,
start,
end: middle,
routeDistanceNm: 40,
distanceNm: 40.25,
durationHours: 6.708,
waypoints: [
{
id: "lock-one",
name: "Schleuse Eins",
coordinate: { lat: 52, lon: 7.2 },
sequence: 1,
routeDistanceNm: 12,
distanceFromRouteNm: 0
}
]
},
{
day: 2,
start: middle,
end: destination,
routeDistanceNm: 32,
distanceNm: 32.25,
durationHours: 5.375,
waypoints: []
}
],
orderedWaypoints: [],
warnings: [],
requiredAmenities: ["water", "overnight"],
totalRouteDistanceNm: 72,
totalDistanceNm: 72.5,
totalDurationHours: 12.0833,
maxCruisingDistancePerDayNm: 42
};
}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts", "vitest.config.ts"]
}
+113
View File
@@ -0,0 +1,113 @@
import basicSsl from "@vitejs/plugin-basic-ssl";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
const useHttps =
process.env.WATERMAPS_HTTPS === "true" ||
process.env.SEACOMPASS_HTTPS === "true" ||
process.env.HTTPS === "true";
export default defineConfig({
plugins: [
react(),
...(useHttps ? [basicSsl()] : []),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.svg"],
manifest: {
name: "Watermaps",
short_name: "Watermaps",
description: "Bootsrouten, Etappen, Live-Wasserstände, Offline-Navigation, GPS, Wetter und Tide.",
theme_color: "#0f4c5c",
background_color: "#f5f7f4",
display: "standalone",
orientation: "portrait",
scope: "/",
start_url: "/",
icons: [
{
src: "/favicon.svg",
sizes: "any",
type: "image/svg+xml",
purpose: "any maskable"
}
]
},
workbox: {
navigateFallback: "/",
runtimeCaching: [
{
urlPattern: /^https:\/\/tiles\.openseamap\.org\/.*/i,
handler: "CacheFirst",
options: {
cacheName: "openseamap-tiles",
cacheableResponse: {
statuses: [0, 200]
},
expiration: {
maxEntries: 500,
maxAgeSeconds: 60 * 60 * 24 * 14
}
}
},
{
urlPattern: /^https:\/\/tiles\.openfreemap\.org\/.*/i,
handler: "StaleWhileRevalidate",
options: {
// Runtime caching sees only resources requested during normal map
// use. It deliberately does not prefetch whole route corridors.
cacheName: "openfreemap-visited-resources",
cacheableResponse: {
statuses: [0, 200]
},
expiration: {
maxEntries: 1_200,
maxAgeSeconds: 60 * 60 * 24 * 30
}
}
}
]
}
})
],
build: {
manifest: true,
// MapLibre is delivered upstream as one pre-bundled module. Its isolated
// chunk is intentionally large and checked separately after each build.
chunkSizeWarningLimit: 1_100,
rollupOptions: {
output: {
onlyExplicitManualChunks: true,
manualChunks(id) {
const normalizedId = id.replaceAll("\\", "/");
if (
normalizedId.includes("/node_modules/maplibre-gl/") &&
!normalizedId.endsWith(".css")
) {
return "map-engine";
}
if (
normalizedId.includes("/node_modules/react/") ||
normalizedId.includes("/node_modules/react-dom/") ||
normalizedId.includes("/node_modules/scheduler/")
) {
return "react-vendor";
}
}
}
}
},
server: {
proxy: {
"/api": {
target: "http://localhost:5174",
changeOrigin: true
},
"/health": {
target: "http://localhost:5174",
changeOrigin: true
}
}
}
});
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
exclude: ["tests/e2e/**", "node_modules/**", "dist/**"]
}
});