Initial Watermaps import

This commit is contained in:
BuTzZ
2026-07-24 11:29:24 +02:00
commit 57f7b4dedb
129 changed files with 43136 additions and 0 deletions
+247
View File
@@ -0,0 +1,247 @@
import cors from "@fastify/cors";
import Fastify, { type FastifyInstance } from "fastify";
import { z } from "zod";
import {
buildFairwayRoutes,
EMDEN_HAMM_GRAPH,
type FairwayGraph,
type RouteOption,
type RouteRequest,
type RouteResult
} from "@watermaps/shared";
import { loadEnv, type ApiEnv } from "./env.js";
import { createCache, type Cache } from "./services/cache.js";
import { appConfig } from "./services/config.js";
import { FairwayService } from "./services/fairways.js";
import { FeatureService } from "./services/features.js";
import { getNearestTideSummary } from "./services/tides.js";
import { getMarineForecast } from "./services/weather.js";
import type { FetchLike } from "./services/http.js";
import {
getNavigationData,
type NavigationDataAdapters
} from "./services/navigation-data.js";
export type AppDeps = {
env?: ApiEnv;
cache?: Cache;
fetcher?: FetchLike;
featureService?: FeatureService;
fairwayService?: Pick<FairwayService, "getGraphsForRoute" | "close">;
navigationAdapters?: NavigationDataAdapters;
};
const coordinateSchema = z.object({
lat: z.number().min(-90).max(90),
lon: z.number().min(-180).max(180)
});
const routeRequestSchema = z.object({
start: coordinateSchema,
destination: coordinateSchema,
waypoints: z.array(coordinateSchema).max(25).optional(),
departureTime: z
.string()
.refine((value) => Number.isFinite(Date.parse(value)), "departureTime must be an ISO timestamp")
.optional(),
vesselProfile: z.object({
draughtM: z.number().positive().max(15),
safetyReserveM: z.number().min(0).max(10),
airDraftM: z.number().positive().max(80).optional(),
beamM: z.number().positive().max(80).optional(),
cruiseSpeedKn: z.number().positive().max(80).optional()
}),
depthSamples: z
.array(
z.object({
coordinate: coordinateSchema,
depthM: z.number().nullable()
})
)
.max(500)
.optional()
}) satisfies z.ZodType<RouteRequest>;
const coordinateQuerySchema = z.object({
lat: z.coerce.number().min(-90).max(90),
lon: z.coerce.number().min(-180).max(180),
at: z
.string()
.refine((value) => Number.isFinite(Date.parse(value)), "at must be an ISO timestamp")
.optional()
});
const featuresQuerySchema = z.object({
bbox: z
.string()
.transform((value, ctx) => {
const parts = value.split(",").map(Number);
if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part))) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "bbox must be minLon,minLat,maxLon,maxLat" });
return z.NEVER;
}
return parts as [number, number, number, number];
}),
layers: z
.string()
.default("seamarks,bridges,locks,harbours")
.transform((value) =>
value
.split(",")
.map((layer) => layer.trim())
.filter(Boolean)
)
});
const navigationQuerySchema = z.object({
waterways: commaSeparatedQuery(12).optional(),
stationIds: commaSeparatedQuery(30).optional(),
lockIds: commaSeparatedQuery(30).optional()
});
export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance> {
const env = deps.env ?? loadEnv();
const cache = deps.cache ?? createCache(env.redisUrl);
const fetcher = deps.fetcher ?? fetch;
const featureService = deps.featureService ?? new FeatureService(env);
const fairwayService =
deps.fairwayService ??
new FairwayService({
cache,
fetcher,
liveEnabled: env.liveOsmFairways,
databaseUrl: env.databaseUrl
});
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? "info"
}
});
await app.register(cors, {
origin: true
});
app.addHook("onClose", async () => {
await cache.close();
await featureService.close();
await fairwayService.close?.();
});
app.get("/health", async () => ({ ok: true, service: "watermaps-api" }));
app.get("/api/config", async () => appConfig);
app.get("/api/weather/marine", async (request, reply) => {
const parsed = coordinateQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
}
return getMarineForecast(parsed.data, { cache, fetcher });
});
app.get("/api/tides/nearest", async (request, reply) => {
const parsed = coordinateQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
}
const summary = await getNearestTideSummary(parsed.data, { cache, fetcher });
if (!summary) {
return reply.code(404).send({ error: "no_tide_station_found" });
}
return summary;
});
app.get("/api/navigation/live", async (request, reply) => {
const parsed = navigationQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
}
return getNavigationData(parsed.data, {
cache,
fetcher,
adapters: deps.navigationAdapters
});
});
app.get("/api/features", async (request, reply) => {
const parsed = featuresQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.code(400).send({ error: "invalid_query", details: parsed.error.flatten() });
}
return featureService.getFeatures(parsed.data);
});
app.post("/api/routes", async (request, reply) => {
const parsed = routeRequestSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({ error: "invalid_route", details: parsed.error.flatten() });
}
const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => {
app.log.warn({ error }, "fairway extraction failed");
return [];
});
const route = buildRouteFromGraphs(parsed.data, dynamicGraphs);
if (!route) {
return reply.code(422).send({
error: "no_fairway_route",
message:
"Keine Fahrwasserroute für Start und Ziel gefunden. Setze Punkte näher an ein bekanntes Fahrwasser oder importiere weitere Fahrwasserdaten."
});
}
return route;
});
return app;
}
function commaSeparatedQuery(maxItems: number) {
return z.string().transform((value, ctx) => {
const items = [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))];
if (items.length > maxItems) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `too many values (maximum ${maxItems})`
});
return z.NEVER;
}
return items;
});
}
function buildRouteFromGraphs(request: RouteRequest, graphs: FairwayGraph[]) {
for (const graph of graphs) {
const routes = buildFairwayRoutes(request, graph);
if (routes.length > 0) {
return routeResultWithAlternatives(routes);
}
}
for (const graph of [undefined, EMDEN_HAMM_GRAPH] as const) {
const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request);
if (routes.length > 0) {
return routeResultWithAlternatives(routes);
}
}
return null;
}
function routeResultWithAlternatives(routes: RouteOption[]): RouteResult {
const [primary, ...alternatives] = routes;
if (!primary) {
throw new Error("routeResultWithAlternatives requires at least one route");
}
return {
...primary,
alternatives
};
}