Hinweise optimiert
Test and publish container images / test (push) Successful in 2m27s
Test and publish container images / publish (push) Failing after 4s

This commit is contained in:
BuTzZ
2026-07-27 13:04:32 +02:00
parent 9813a1fafb
commit b98ec8bc6f
34 changed files with 500 additions and 399 deletions
+18 -6
View File
@@ -9,10 +9,10 @@ iPhone-taugliche Browser-PWA für Bootsfahrer: Karte, GPS, Kompass, Wetter/Welle
- explizit gestarteter Kursassistent mit dynamischem Sollkurs über Grund, Querabweichung, Reststrecke und Kurswechsel-Hinweisen
- zweistufige Ankerwache mit festem Ankerpunkt, GPS-Schwojkreis, Alarm, Tidenanstieg und nachvollziehbarer Ketten-/Leinenreserve
- bis zu drei ausreichend unterschiedliche Routenvorschläge, sofern der Wasserstraßengraph echte Alternativen enthält
- Prüfung von Tiefgang plus Sicherheitsreserve, Bootshöhe, Breite und bekannten Einbahnregeln
- Prüfung explizit erfasster Tiefgangs-, Höhen- und Breitenbeschränkungen sowie bekannter Einbahnregeln
- Fahrwasser-Unterstützung für die Korridore EmdenBorkum und EmdenHamm nach manueller Auswahl von Start und Ziel
- antippbare Info-Buttons ab Kartenzoom 12 an Schleusen und Häfen mit Telefon, Website, E-Mail, VHF, Öffnungszeiten, Betreiber und Adresse, soweit in den Quelldaten vorhanden
- Brückenhöhen, bekannte Tiefen, Wetter/Wellen, Tide, GPS und Kompass
- Brückenhöhen, optionale Tiefenhinweise als Karteninformation, Wetter/Wellen, Tide, GPS und Kompass
- frei sortierbare Zwischenziele direkt von der Karte
- Abfahrtszeit-bezogene Wetter-, Wellen-, Strömungs- und Tidenplanung entlang der Route
- Live-Wasserstände aus der offiziellen PEGELONLINE-API der WSV mit Cache- und Veraltet-Status
@@ -24,10 +24,10 @@ iPhone-taugliche Browser-PWA für Bootsfahrer: Karte, GPS, Kompass, Wetter/Welle
## Aktuelles Boot
Über das Bootssymbol in der Navigationsleiste wird ein zentrales Bootsprofil
gepflegt. Routenplanung, Tiefen- und Brückenprüfung, Fahrtdauer,
gepflegt. Routenplanung, bekannte Durchfahrtsbeschränkungen, Fahrtdauer,
Etappenplanung sowie die bootsabhängigen Werte der Ankerwache verwenden dieses
Profil automatisch. Eine Änderung verwirft eine bereits berechnete Route, weil
deren Sicherheitsprüfung noch auf den alten Maßen beruht.
deren Routingprofil noch auf den alten Maßen beruht.
Das versionierte Profil wird für ein Jahr im Cookie
`watermaps.vessel-profile` dieses Browsers gespeichert. Es enthält nur
@@ -163,6 +163,17 @@ laufende Overpass-Verbindung. Berücksichtigt werden unter anderem
explizit für Boote oder Schiffe freigegebene Flüsse. Gesperrte, private,
stillgelegte oder im Bau befindliche Wege werden ausgeschlossen.
Eine automatische Tiefenfreigabe der Route erfolgt bewusst nicht. Frei
verfügbare Bathymetrie beschreibt häufig die Höhe des Gewässerbodens in
unterschiedlichen Bezugssystemen; ohne passenden aktuellen Wasserstand und
belastbare Aktualität ergibt sich daraus keine sichere befahrbare Wassertiefe.
Das Routing berücksichtigt deshalb nur ausdrücklich an einem Fahrwasser
erfasste maximale Tiefgänge (`maxDraughtM`) als Durchfahrtsbeschränkung und
erzeugt weder eine Tiefenabdeckung noch Warnungen über unbekannte
Tiefenpunkte. Die separate Tiefen-Kartenebene bleibt eine reine
Karteninformation und fließt nicht in die Route ein. Die bei der Ankerwache
manuell eingegebene örtliche Tiefe ist davon unabhängig.
Nur im ausdrücklich aktivierten Demo-Modus
`WATERMAPS_DEMO_DATA=true` stehen drei klar als nicht amtlich markierte
Seed-Korridore bereit:
@@ -176,8 +187,9 @@ Produktiv und im regulären lokalen Containerbetrieb ist der Demo-Modus
deaktiviert. Fehlt dort der vollständige lokale Index beziehungsweise eine
andere konfigurierte Routingquelle, liefert die API
`503 fairway_sources_unavailable`, statt unbemerkt eine grobe Seed-Route zu
zeichnen. Der EmdenHamm-Seed besitzt keine belastbaren Tiefen- oder
Schleusenzeitdaten und ist bewusst auf Sportboote bis 2,5 m Tiefgang begrenzt.
zeichnen. Der EmdenHamm-Seed besitzt keine belastbaren Tiefenprofile oder
Schleusenzeitdaten und enthält als explizite Demo-Beschränkung einen maximalen
Tiefgang von 2,5 m.
Vor der Fahrt sind aktuelle Sperrungen, Betriebszeiten, Wasserstände und
amtliche Karten zu prüfen. Kostenfreie Inland-ENCs für den Dortmund-Ems- und
Datteln-Hamm-Kanal stellt [ELWIS](https://www.elwis.de/DE/dynamisch/IENC/)
+1 -10
View File
@@ -56,16 +56,7 @@ const routeRequestSchema = z.object({
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({
-29
View File
@@ -54,7 +54,6 @@ export type FairwayRow = {
source: string;
source_id: string | null;
name: string | null;
min_depth_m: string | number | null;
properties?: Record<string, unknown> | null;
geometry: {
type: "LineString";
@@ -165,7 +164,6 @@ export class FairwayService {
source,
source_id,
name,
min_depth_m,
properties,
ST_AsGeoJSON(geom)::json AS geometry
FROM marine_fairway_edges
@@ -328,7 +326,6 @@ export function fairwayRowsToGraph(rows: FairwayRow[], bbox: [number, number, nu
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)
};
@@ -352,7 +349,6 @@ export function overpassToGraph(response: OverpassResponse, bbox: [number, numbe
id: `osm-way-${element.id}`,
name: tags.name ?? tags.ref ?? `OSM ${element.id}`,
coordinates,
minDepthM: parseDepth(tags),
source: sourceFor(tags),
...edgeRestrictions(tags)
};
@@ -386,7 +382,6 @@ export function localFairwaysToGraph(
id: `local-osm-way-${way.id}`,
name: tags.name ?? tags.ref ?? `OSM ${way.id}`,
coordinates,
minDepthM: parseDepth(tags),
source: `local-geofabrik-${source}`,
...edgeRestrictions(tags)
};
@@ -443,7 +438,6 @@ function waysToGraph(
id: string;
name: string;
coordinates: Coordinate[];
minDepthM: number | null;
source: string;
maxAirDraftM?: number | null;
maxBeamM?: number | null;
@@ -473,7 +467,6 @@ function waysToGraph(
from,
to,
coordinates: [start, end],
minDepthM: way.minDepthM,
source: way.source,
maxAirDraftM: way.maxAirDraftM,
maxBeamM: way.maxBeamM,
@@ -579,28 +572,6 @@ function nodeIdFor(nodes: Map<string, FairwayNode>, coordinate: Coordinate) {
return id;
}
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, [
-34
View File
@@ -36,7 +36,6 @@ const testFairwayGraph = (
from: `${id}-start`,
to: `${id}-destination`,
coordinates: [start, destination],
minDepthM: null,
source: "test-fairway-source"
}
]
@@ -68,7 +67,6 @@ const boundedSnapSearchFixture = () => {
from: "junction",
to: `trap-${index + 1}`,
coordinates: [{ lat: 52, lon: 7 }, coordinate],
minDepthM: 4,
oneway: true as const,
source: `api-bounded-trap-${index + 1}`
})),
@@ -78,7 +76,6 @@ const boundedSnapSearchFixture = () => {
from: "junction",
to: "destination",
coordinates: [{ lat: 52, lon: 7 }, destination],
minDepthM: 4,
oneway: true as const,
source: "api-ninth-main-route"
}
@@ -220,26 +217,6 @@ describe("Watermaps API", () => {
await app.close();
});
it("returns critical route warnings for shallow samples", async () => {
const app = await buildServer({ cache: createCache(), env: demoEnv });
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(),
@@ -443,7 +420,6 @@ describe("Watermaps API", () => {
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();
});
@@ -469,7 +445,6 @@ describe("Watermaps API", () => {
name: "Norddeich Fahrwasser",
from: "norddeich",
to: "fairway",
minDepthM: null,
source: "local-geofabrik-test",
coordinates: [
{ lat: 53.6234, lon: 7.1559 },
@@ -481,7 +456,6 @@ describe("Watermaps API", () => {
name: "Norderney Fahrwasser",
from: "fairway",
to: "norderney",
minDepthM: null,
source: "local-geofabrik-test",
coordinates: [
{ lat: 53.66, lon: 7.16 },
@@ -605,7 +579,6 @@ describe("Watermaps API", () => {
name: "AB",
from: "a",
to: "b",
minDepthM: 4,
source: "test-overpass",
coordinates: [
{ lat: 54, lon: 10 },
@@ -617,7 +590,6 @@ describe("Watermaps API", () => {
name: "BC",
from: "b",
to: "c",
minDepthM: 4,
source: "test-overpass",
coordinates: [
{ lat: 54.02, lon: 10.05 },
@@ -679,7 +651,6 @@ describe("Watermaps API", () => {
from: "start-decoy-a",
to: "start-decoy-b",
coordinates: [coordinate(53.3416, 7.186), coordinate(53.342, 7.187)],
minDepthM: null,
source: "closer-but-disconnected-start"
},
{
@@ -688,7 +659,6 @@ describe("Watermaps API", () => {
from: "destination-decoy-a",
to: "destination-decoy-b",
coordinates: [coordinate(53.3282, 6.9304), coordinate(53.3286, 6.9294)],
minDepthM: null,
source: "closer-but-disconnected-destination"
},
{
@@ -697,7 +667,6 @@ describe("Watermaps API", () => {
from: "shared-start",
to: "shared-east",
coordinates: [coordinate(53.34145, 7.18585), coordinate(53.3321722, 7.1329034)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands"
},
{
@@ -706,7 +675,6 @@ describe("Watermaps API", () => {
from: "shared-east",
to: "shared-south",
coordinates: [coordinate(53.3321722, 7.1329034), coordinate(53.313849, 7.0011017)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands"
},
{
@@ -715,7 +683,6 @@ describe("Watermaps API", () => {
from: "shared-south",
to: "shared-destination",
coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.32805, 6.9302)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands"
}
]
@@ -765,7 +732,6 @@ describe("Watermaps API", () => {
from,
to,
coordinates,
minDepthM: 4,
source: "test-alternatives"
});
const app = await buildServer({
+1 -9
View File
@@ -378,7 +378,6 @@ describe("fairway graph extraction", () => {
source: "osm",
source_id: "way-1",
name: "Harbour Reach",
min_depth_m: "4.2",
geometry: {
type: "LineString",
coordinates: [
@@ -392,7 +391,6 @@ describe("fairway graph extraction", () => {
source: "osm",
source_id: "way-2",
name: "Outer Reach",
min_depth_m: 4.2,
geometry: {
type: "LineString",
coordinates: [
@@ -430,7 +428,6 @@ describe("fairway graph extraction", () => {
source: "osm",
source_id: "way-main",
name: "Ems Fahrwasser",
min_depth_m: null,
geometry: {
type: "LineString",
coordinates: [
@@ -447,7 +444,6 @@ describe("fairway graph extraction", () => {
source: "osm",
source_id: "way-branch",
name: "Busetief",
min_depth_m: null,
geometry: {
type: "LineString",
coordinates: [
@@ -495,8 +491,7 @@ describe("fairway graph extraction", () => {
type: "way",
id: 123,
tags: {
"seamark:type": "navigation_line",
"seamark:navigation_line:minimum_depth": "3.5"
"seamark:type": "navigation_line"
},
geometry: [
{ lat: 54, lon: 10 },
@@ -510,7 +505,6 @@ describe("fairway graph extraction", () => {
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", () => {
@@ -553,7 +547,6 @@ describe("fairway graph extraction", () => {
source: "osm",
source_id: "north",
name: "Dortmund-Ems-Kanal",
min_depth_m: null,
geometry: {
type: "LineString",
coordinates: [
@@ -604,7 +597,6 @@ describe("fairway graph extraction", () => {
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",
+9 -3
View File
@@ -44,6 +44,7 @@ import { useGeolocation } from "./hooks/useGeolocation";
import { useMarineData } from "./hooks/useMarineData";
import type { OfflineVoyage } from "./lib/offline-route";
import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile";
import { isOpenRouteWarning } from "./lib/route-warnings";
import {
upcomingRouteEvents,
type RouteEventEtaBasis,
@@ -300,7 +301,7 @@ export function App() {
}, [route]);
const routeWarningCount = useMemo(
() => route?.warnings.filter((warning) => warning.severity !== "info").length ?? 0,
() => route?.warnings.filter(isOpenRouteWarning).length ?? 0,
[route]
);
const handleMapReady = useCallback(() => setMapReady(true), []);
@@ -487,9 +488,13 @@ export function App() {
: route
? "active"
: "idle",
route: guidanceAlarm || route?.warnings.some((warning) => warning.severity === "critical")
route: guidanceAlarm || route?.warnings.some(
(warning) => isOpenRouteWarning(warning) && warning.severity === "critical"
)
? "alarm"
: route?.warnings.some((warning) => warning.severity === "caution")
: route?.warnings.some(
(warning) => isOpenRouteWarning(warning) && warning.severity === "caution"
)
? "caution"
: courseAssistant.active || route
? "active"
@@ -1006,6 +1011,7 @@ export function App() {
headingSource={guidanceHeadingSource}
accuracyM={gps.accuracyM}
fixStale={courseAssistant.fixStale}
routeWarnings={route?.warnings ?? []}
onStop={courseAssistant.stop}
/>
</LazyContent>
+2 -2
View File
@@ -190,7 +190,7 @@ export function BoatProfilePanel({
<legend>Abmessungen und Fahrtwerte</legend>
<p>
Bitte die Werte für den normalen Fahrzustand eintragen. Sie
beeinflussen Durchfahrt, Tiefenprüfung und Fahrtdauer.
beeinflussen bekannte Durchfahrtsbeschränkungen und Fahrtdauer.
</p>
<div className="boat-profile-field-grid">
@@ -232,7 +232,7 @@ export function BoatProfilePanel({
/>
<NumberField
label="Sicherheitsreserve"
help="Zusätzlicher Abstand zum Grund beim Routing."
help="Wird im Bootsprofil gespeichert; eine automatische Routentiefenprüfung findet derzeit nicht statt."
unit="m"
min={0}
max={10}
@@ -1,5 +1,6 @@
import { AlertTriangle, Navigation, Square } from "lucide-react";
import type { RouteGuidanceResult } from "@watermaps/shared";
import type { RouteGuidanceResult, RouteWarning } from "@watermaps/shared";
import { RouteWarnings } from "./RouteWarnings";
import "./CourseAssistantPanel.css";
export type CourseAssistantPanelProps = {
@@ -9,6 +10,7 @@ export type CourseAssistantPanelProps = {
headingSource: "COG" | "HDG" | "--";
accuracyM: number | null;
fixStale: boolean;
routeWarnings?: RouteWarning[];
onStop: () => void;
};
@@ -19,6 +21,7 @@ export function CourseAssistantPanel({
headingSource,
accuracyM,
fixStale,
routeWarnings = [],
onStop
}: CourseAssistantPanelProps) {
const waitingForGps = !guidance && !fixStale;
@@ -47,6 +50,8 @@ export function CourseAssistantPanel({
</button>
</header>
<RouteWarnings warnings={routeWarnings} />
<div className="course-assistant-main">
<span
className="course-assistant-arrow"
+14 -54
View File
@@ -38,9 +38,11 @@ import {
DEFAULT_BOAT_PROFILE,
toVesselProfile
} from "../lib/boat-profile";
import { isOpenRouteWarning } from "../lib/route-warnings";
import type { RouteLock } from "../voyageHarbours";
import { LazyContent } from "./LazyContent";
import { NavigationDataPanel } from "./NavigationDataPanel";
import { RouteWarnings } from "./RouteWarnings";
import { RouteTidePanel, type RouteTidePlan } from "./RouteTidePanel";
import { VoyagePlan } from "./VoyagePlan";
@@ -173,10 +175,18 @@ export function RoutePlanner({
if (!result) {
return "idle";
}
if (result.warnings.some((warning) => warning.severity === "critical")) {
if (
result.warnings.some(
(warning) => isOpenRouteWarning(warning) && warning.severity === "critical"
)
) {
return "critical";
}
if (result.warnings.some((warning) => warning.severity === "caution")) {
if (
result.warnings.some(
(warning) => isOpenRouteWarning(warning) && warning.severity === "caution"
)
) {
return "caution";
}
return "ok";
@@ -207,9 +217,6 @@ export function RoutePlanner({
}
return new Date(timestamp + routeLocks.length * lockDelayMinutes * 60_000).toISOString();
}, [lockDelayMinutes, result?.eta, routeLocks.length, weatherReport?.adjustedEta]);
const criticalWarnings = result?.warnings.filter((warning) => warning.severity === "critical") ?? [];
const otherWarnings = result?.warnings.filter((warning) => warning.severity !== "critical") ?? [];
useEffect(() => {
if (!result) {
setPlannerView("input");
@@ -554,9 +561,7 @@ export function RoutePlanner({
<span>
{result.routingMode === "fairway"
? "Fahrwasser"
: result.unknownDepthRatio > 0
? `${Math.round(result.unknownDepthRatio * 100)}% unbekannt`
: "Tiefe OK"}
: "Direkte Wegpunkte"}
</span>
{(weatherReport?.adjustedEta ?? result.eta) && (
<span>
@@ -578,52 +583,7 @@ export function RoutePlanner({
</section>
)}
{(error || criticalWarnings.length > 0) && (
<section
className="warning-list route-priority-warnings"
aria-label="Kritische Hinweise"
role="alert"
>
<strong>Kritische Hinweise</strong>
{error && (
<p>
<AlertTriangle size={14} aria-hidden="true" />
{error}
</p>
)}
{criticalWarnings.map((warning, index) => (
<p key={`${warning.code}-${index}`}>
<AlertTriangle size={14} aria-hidden="true" />
{warning.message}
</p>
))}
</section>
)}
{otherWarnings.length > 0 && (
<section className="warning-list" aria-label="Weitere Hinweise">
<strong>Weitere Hinweise</strong>
{otherWarnings.slice(0, 3).map((warning, index) => (
<p key={`${warning.code}-${index}`}>
<AlertTriangle size={14} aria-hidden="true" />
{warning.message}
</p>
))}
{otherWarnings.length > 3 && (
<details className="voyage-tools-disclosure">
<summary>{otherWarnings.length - 3} weitere Hinweise</summary>
<div className="warning-list">
{otherWarnings.slice(3).map((warning, index) => (
<p key={`${warning.code}-${index + 3}`}>
<AlertTriangle size={14} aria-hidden="true" />
{warning.message}
</p>
))}
</div>
</details>
)}
</section>
)}
<RouteWarnings warnings={result.warnings} error={error} />
{routeOptions.length > 1 && (
<section className="route-options" aria-label="Routenalternativen">
+4 -1
View File
@@ -37,7 +37,10 @@ export function RouteTidePanel({ plan, loading, error }: RouteTidePanelProps) {
<TideLocation label="Ziel" summary={plan.destination} />
</div>
)}
<small>Stationsabstand und Bezugsnull beachten; Wasserstände ersetzen keine amtliche Tiefenprüfung.</small>
<small>
Stationsabstand und Bezugsnull beachten; angezeigte Wasserstände geben keine befahrbare
Wassertiefe an.
</small>
</section>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { AlertTriangle, Info } from "lucide-react";
import type { RouteWarning } from "@watermaps/shared";
import { isOpenRouteWarning, isRetainedRouteWarning } from "../lib/route-warnings";
export type RouteWarningsProps = {
warnings: RouteWarning[];
error?: string | null;
};
export function RouteWarnings({ warnings, error = null }: RouteWarningsProps) {
const openWarnings = warnings.filter(isOpenRouteWarning);
const criticalWarnings = openWarnings.filter((warning) => warning.severity === "critical");
const cautionWarnings = openWarnings.filter((warning) => warning.severity === "caution");
const informationalWarnings = warnings.filter(
(warning) => isRetainedRouteWarning(warning) && warning.severity === "info"
);
return (
<>
{(error || openWarnings.length > 0) && (
<section className="warning-list" aria-label="Routing-Hinweise">
{openWarnings.length > 0 && <strong>Hinweise ({openWarnings.length})</strong>}
{(error || criticalWarnings.length > 0) && (
<div
className="warning-list route-priority-warnings"
aria-label="Kritische Hinweise"
role="alert"
>
<strong>Kritische Hinweise</strong>
{error && (
<p>
<AlertTriangle size={14} aria-hidden="true" />
{error}
</p>
)}
{criticalWarnings.map((warning, index) => (
<p key={`${warning.code}-${index}`}>
<AlertTriangle size={14} aria-hidden="true" />
{warning.message}
</p>
))}
</div>
)}
{cautionWarnings.map((warning, index) => (
<p key={`${warning.code}-${index}`}>
<AlertTriangle size={14} aria-hidden="true" />
{warning.message}
</p>
))}
</section>
)}
{informationalWarnings.length > 0 && (
<details className="voyage-tools-disclosure">
<summary>Routingdetails ({informationalWarnings.length})</summary>
<div className="warning-list">
{informationalWarnings.map((warning, index) => (
<p key={`${warning.code}-${index}`}>
<Info size={14} aria-hidden="true" />
{warning.message}
</p>
))}
</div>
</details>
)}
</>
);
}
+5 -1
View File
@@ -1,4 +1,5 @@
import type { RouteResult } from "@watermaps/shared";
import { isRetainedRouteWarning } from "./route-warnings";
const DEFAULT_CREATOR = "Watermaps";
@@ -25,7 +26,10 @@ export function createRouteGpx(route: RouteResult, options: GpxExportOptions = {
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",
route.warnings
.filter(isRetainedRouteWarning)
.map((warning) => warning.message)
.join(" · ") || "Nicht amtliche Routenplanung",
1_000
);
const bounds = routeBounds(coordinates);
+20 -21
View File
@@ -5,6 +5,7 @@ import type {
RouteWarning,
VesselProfile
} from "@watermaps/shared";
import { isRetainedRouteWarning } from "./route-warnings";
export const OFFLINE_VOYAGES_STORAGE_KEY = "watermaps.offline-voyages.v1";
export const LEGACY_OFFLINE_VOYAGES_STORAGE_KEY = "seacompass.offline-voyages.v1";
@@ -211,8 +212,6 @@ function normalizeRoute(value: unknown): RouteResult {
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);
@@ -232,8 +231,6 @@ function normalizeRoute(value: unknown): RouteResult {
distanceNm,
eta,
warnings,
minKnownDepthM,
unknownDepthRatio,
dataSources,
...(departureTime ? { departureTime } : {}),
...(durationMinutes !== undefined ? { durationMinutes } : {}),
@@ -320,23 +317,25 @@ 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) })
};
});
return value
.map((warning): RouteWarning => {
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) })
};
})
.filter(isRetainedRouteWarning);
}
function normalizeStrings(value: unknown, maxItems: number, maxLength: number): string[] {
+18
View File
@@ -0,0 +1,18 @@
import type { RouteWarning } from "@watermaps/shared";
const REMOVED_ROUTE_WARNING_CODES = new Set([
"FAIRWAY_ROUTE",
"FAIRWAY_DATA_NOT_OFFICIAL",
"DEPTH_UNKNOWN",
"DEPTH_PARTIAL",
"NO_KNOWN_DEPTH",
"DEPTH_TOO_SHALLOW"
]);
export function isRetainedRouteWarning(warning: RouteWarning): boolean {
return !REMOVED_ROUTE_WARNING_CODES.has(warning.code);
}
export function isOpenRouteWarning(warning: RouteWarning): boolean {
return isRetainedRouteWarning(warning) && warning.severity !== "info";
}
@@ -98,8 +98,6 @@ function route(id: string, coordinates: [number, number][]): RouteResult {
distanceNm: 1,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"],
routingMode: "fairway"
};
@@ -80,4 +80,52 @@ describe("CourseAssistantPanel", () => {
expect(screen.getByText(/GPS-Fix ist veraltet/)).toBeVisible();
expect(screen.getByText("---")).toBeVisible();
});
it("keeps every open route warning visible while guidance is active", () => {
render(
<CourseAssistantPanel
guidance={null}
gpsStatus="tracking"
headingDeg={null}
headingSource="--"
accuracyM={null}
fixStale={false}
routeWarnings={[
{
code: "ROUTING_SOURCE",
severity: "info",
message: "Routingquelle: lokaler Graph."
},
{
code: "BRIDGE_TOO_LOW",
severity: "caution",
message: "Eine Brücke ist zu niedrig."
},
{
code: "ROUTE_SEARCH_LIMITED",
severity: "caution",
message: "Die Snap-Suche wurde begrenzt."
},
{
code: "START_SNAP_TOO_FAR",
severity: "caution",
message: "Der Startpunkt liegt weit vom Fahrwasser entfernt."
},
{
code: "DESTINATION_SNAP_TOO_FAR",
severity: "caution",
message: "Der Zielpunkt liegt weit vom Fahrwasser entfernt."
}
]}
onStop={vi.fn()}
/>
);
expect(screen.getByText("Hinweise (4)")).toBeVisible();
expect(screen.getByText("Eine Brücke ist zu niedrig.")).toBeVisible();
expect(screen.getByText("Die Snap-Suche wurde begrenzt.")).toBeVisible();
expect(screen.getByText("Der Startpunkt liegt weit vom Fahrwasser entfernt.")).toBeVisible();
expect(screen.getByText("Der Zielpunkt liegt weit vom Fahrwasser entfernt.")).toBeVisible();
expect(screen.getByText("Routingdetails (1)")).toBeVisible();
});
});
-2
View File
@@ -128,8 +128,6 @@ test("starts a visible dynamic course assistant only after route planning", asyn
distanceNm: 0.7,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["E2E"],
routingMode: "fairway"
})
+27 -2
View File
@@ -35,6 +35,33 @@ describe("GPX export", () => {
geometry: { type: "LineString", coordinates: [[7, 53], [181, 52]] }
})).toThrow(/ungültige Koordinaten/);
});
it("does not export retired route warnings", () => {
const gpx = createRouteGpx({
...routeFixture,
warnings: [
{
code: "FAIRWAY_ROUTE",
severity: "info",
message: "Alter Graph-Hinweis"
},
{
code: "NO_KNOWN_DEPTH",
severity: "caution",
message: "Alter Tiefen-Hinweis"
},
{
code: "ROUTE_SEARCH_LIMITED",
severity: "caution",
message: "Aktueller Such-Hinweis"
}
]
});
expect(gpx).not.toContain("Alter Graph-Hinweis");
expect(gpx).not.toContain("Alter Tiefen-Hinweis");
expect(gpx).toContain("Aktueller Such-Hinweis");
});
});
const routeFixture: RouteResult = {
@@ -47,8 +74,6 @@ const routeFixture: RouteResult = {
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"
};
-2
View File
@@ -530,8 +530,6 @@ describe("MapView marine feature information", () => {
distanceNm: 120,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
};
+6 -3
View File
@@ -22,9 +22,9 @@ describe("NavigationToolRail", () => {
anchor: "alarm",
conditions: "active",
upcoming: "caution",
route: "stale"
route: "caution"
}}
badges={{ upcoming: 123 }}
badges={{ upcoming: 123, route: 4 }}
/>
);
@@ -58,7 +58,10 @@ describe("NavigationToolRail", () => {
expect(upcoming).toHaveAttribute("data-status", "caution");
expect(within(upcoming).getByText("99+")).toBeVisible();
const route = screen.getByRole("button", { name: /Route, Daten veraltet, öffnen/ });
const route = screen.getByRole("button", {
name: /Route, Warnung, 4 Hinweise, öffnen/
});
expect(within(route).getByText("4")).toBeVisible();
fireEvent.click(route);
expect(onSelect).toHaveBeenCalledWith("route");
});
+65 -4
View File
@@ -68,6 +68,71 @@ describe("offline voyage storage", () => {
expect(listOfflineVoyages(localStorage)).toEqual([]);
});
it("removes retired warnings and depth fields from legacy offline routes", () => {
const record = createOfflineVoyageRecord(
{ route: routeFixture },
{ id: "legacy-voyage", savedAt: "2026-07-19T12:00:00.000Z" }
);
const legacyRecord = {
...record,
route: {
...record.route,
minKnownDepthM: null,
unknownDepthRatio: 1,
warnings: [
{
code: "FAIRWAY_ROUTE",
severity: "info",
message: "Alter Graph-Hinweis"
},
{
code: "FAIRWAY_DATA_NOT_OFFICIAL",
severity: "caution",
message: "Alter Daten-Hinweis"
},
{
code: "DEPTH_UNKNOWN",
severity: "caution",
message: "Alter Tiefenpunkt-Hinweis"
},
{
code: "DEPTH_PARTIAL",
severity: "caution",
message: "Alter Teil-Tiefenhinweis"
},
{
code: "NO_KNOWN_DEPTH",
severity: "caution",
message: "Alter Unbekannt-Hinweis"
},
{
code: "DEPTH_TOO_SHALLOW",
severity: "critical",
message: "Alter Flachwasser-Hinweis"
},
{
code: "ROUTE_SEARCH_LIMITED",
severity: "caution",
message: "Suche begrenzt"
}
]
}
};
localStorage.setItem(OFFLINE_VOYAGES_STORAGE_KEY, JSON.stringify([legacyRecord]));
const restored = loadOfflineVoyage("legacy-voyage", localStorage);
expect(restored?.route.warnings).toEqual([
{
code: "ROUTE_SEARCH_LIMITED",
severity: "caution",
message: "Suche begrenzt"
}
]);
expect(restored?.route).not.toHaveProperty("minKnownDepthM");
expect(restored?.route).not.toHaveProperty("unknownDepthRatio");
});
it("rejects unsafe route coordinates before writing", () => {
expect(() => createOfflineVoyageRecord({
route: {
@@ -91,8 +156,6 @@ const routeFixture: RouteResult = {
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",
routeSnaps: {
@@ -115,8 +178,6 @@ const routeFixture: RouteResult = {
distanceNm: 160,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OSM"]
}]
};
-2
View File
@@ -22,8 +22,6 @@ const route: RouteResult = {
distanceNm: 60,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["test"]
};
+85 -5
View File
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom/vitest";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RoutePlanner } from "../src/components/RoutePlanner";
import { DEFAULT_BOAT_PROFILE } from "../src/lib/boat-profile";
@@ -416,6 +416,7 @@ describe("RoutePlanner", () => {
);
expect(screen.getByText("Routenergebnis")).toBeVisible();
expect(screen.getByText("Hinweise (2)")).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();
@@ -426,6 +427,89 @@ describe("RoutePlanner", () => {
expect(screen.getByRole("button", { name: "Zur Route" })).toBeVisible();
});
it("filters retired fairway and depth warnings from route results", () => {
const result = {
...routeOption("primary", "Hauptroute", 24.48),
warnings: [
{
code: "FAIRWAY_ROUTE",
severity: "info" as const,
message: "Entfernter Graph-Hinweis."
},
{
code: "FAIRWAY_DATA_NOT_OFFICIAL",
severity: "caution" as const,
message: "Entfernter Daten-Hinweis."
},
{
code: "ROUTE_SEARCH_LIMITED",
severity: "caution" as const,
message: "Die Snap-Suche wurde begrenzt."
},
{
code: "DEPTH_PARTIAL",
severity: "caution" as const,
message: "Entfernter Teil-Tiefenhinweis."
},
{
code: "NO_KNOWN_DEPTH",
severity: "caution" as const,
message: "Entfernter Unbekannt-Hinweis."
},
{
code: "DEPTH_UNKNOWN",
severity: "caution" as const,
message: "Entfernter Tiefenpunkt-Hinweis."
},
{
code: "DEPTH_TOO_SHALLOW",
severity: "critical" as const,
message: "Entfernter Flachwasser-Hinweis."
}
]
};
render(
<RoutePlanner
startPoint={{ lat: 53.4647, lon: 7.474 }}
gpsPosition={null}
destination={{ lat: 53.5147, lon: 8.1203 }}
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()}
embedded
/>
);
const warnings = screen.getByRole("region", { name: "Routing-Hinweise" });
expect(within(warnings).getByText("Hinweise (1)")).toBeVisible();
expect(within(warnings).getByText("Die Snap-Suche wurde begrenzt.")).toBeVisible();
expect(screen.queryByText("Entfernter Graph-Hinweis.")).not.toBeInTheDocument();
expect(screen.queryByText("Entfernter Daten-Hinweis.")).not.toBeInTheDocument();
expect(screen.queryByText("Entfernter Teil-Tiefenhinweis.")).not.toBeInTheDocument();
expect(screen.queryByText("Entfernter Unbekannt-Hinweis.")).not.toBeInTheDocument();
expect(screen.queryByText("Entfernter Tiefenpunkt-Hinweis.")).not.toBeInTheDocument();
expect(screen.queryByText("Entfernter Flachwasser-Hinweis.")).not.toBeInTheDocument();
expect(screen.queryByText(/Routingdetails/)).not.toBeInTheDocument();
expect(screen.getByText("Hauptroute").closest(".route-result")).toHaveAttribute(
"data-severity",
"caution"
);
});
it("shows the route weather report after a route was calculated", () => {
render(
<RoutePlanner
@@ -437,8 +521,6 @@ describe("RoutePlanner", () => {
distanceNm: 4.2,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
}}
@@ -608,8 +690,6 @@ function routeOption(id: string, name: string, distanceNm: number) {
distanceNm,
eta: "2026-07-13T14:00:00.000Z",
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway" as const
};
+53
View File
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { RouteWarning } from "@watermaps/shared";
import {
isOpenRouteWarning,
isRetainedRouteWarning
} from "../src/lib/route-warnings";
describe("route warning filters", () => {
it("filters every retired fairway and depth warning code centrally", () => {
const retiredCodes = [
"FAIRWAY_ROUTE",
"FAIRWAY_DATA_NOT_OFFICIAL",
"DEPTH_UNKNOWN",
"DEPTH_PARTIAL",
"NO_KNOWN_DEPTH",
"DEPTH_TOO_SHALLOW"
];
for (const code of retiredCodes) {
const warning: RouteWarning = {
code,
severity: "critical",
message: `Alter Hinweis ${code}`
};
expect(isRetainedRouteWarning(warning)).toBe(false);
expect(isOpenRouteWarning(warning)).toBe(false);
}
});
it("retains current warnings and only counts non-informational ones as open", () => {
expect(
isRetainedRouteWarning({
code: "ROUTE_SEARCH_LIMITED",
severity: "caution",
message: "Suche begrenzt"
})
).toBe(true);
expect(
isOpenRouteWarning({
code: "ROUTE_SEARCH_LIMITED",
severity: "caution",
message: "Suche begrenzt"
})
).toBe(true);
expect(
isOpenRouteWarning({
code: "ROUTING_SOURCE",
severity: "info",
message: "Routingquelle"
})
).toBe(false);
});
});
@@ -148,8 +148,6 @@ const routeFixture: RouteResult = {
distanceNm: 25,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [],
routingMode: "fairway"
};
@@ -126,8 +126,6 @@ const routeFixture: RouteResult = {
distanceNm: 0.4,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"],
routingMode: "fairway"
};
+2 -84
View File
@@ -1,7 +1,6 @@
import { haversineDistanceNm, sumRouteDistanceNm } from "./geo.js";
import type {
Coordinate,
DepthSample,
RouteOption,
RouteRequest,
RouteResult,
@@ -22,7 +21,6 @@ export type FairwayEdge = {
from: string;
to: string;
coordinates: Coordinate[];
minDepthM: number | null;
maxAirDraftM?: number | null;
maxBeamM?: number | null;
maxDraughtM?: number | null;
@@ -126,7 +124,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Emden Außenhafen Ausfahrt",
from: "emden-aussenhafen",
to: "emden-harbour-leading",
minDepthM: 8.5,
coordinates: [
{ lat: 53.344167, lon: 7.186111 },
{ lat: 53.339, lon: 7.191 },
@@ -138,7 +135,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Emder Leitlinie zum Emsfahrwasser",
from: "emden-harbour-leading",
to: "ems-fairway-inner",
minDepthM: 8.5,
coordinates: [
{ lat: 53.333897, lon: 7.201469 },
{ lat: 53.332236, lon: 7.132959 },
@@ -150,7 +146,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Inneres Emsfahrwasser",
from: "ems-fairway-inner",
to: "knock-reach",
minDepthM: 8.5,
coordinates: [
{ lat: 53.33532, lon: 7.132674 },
{ lat: 53.333, lon: 7.075 },
@@ -162,7 +157,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Außenems über Paapsand",
from: "knock-reach",
to: "paapsand-reach",
minDepthM: 7.5,
coordinates: [
{ lat: 53.327597, lon: 7.015002 },
{ lat: 53.323764, lon: 6.985 },
@@ -175,7 +169,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Westerems Südansteuerung",
from: "paapsand-reach",
to: "westerems-south",
minDepthM: 7,
coordinates: [
{ lat: 53.339, lon: 6.93 },
{ lat: 53.348, lon: 6.9 },
@@ -188,7 +181,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Westerems Fahrwasser",
from: "westerems-south",
to: "eemshaven-approach",
minDepthM: 7,
coordinates: [
{ lat: 53.376, lon: 6.865 },
{ lat: 53.398, lon: 6.85 },
@@ -201,7 +193,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Borkum Südansteuerung",
from: "eemshaven-approach",
to: "borkum-south-approach",
minDepthM: 6,
coordinates: [
{ lat: 53.442996, lon: 6.833146 },
{ lat: 53.463, lon: 6.807 },
@@ -214,7 +205,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Borkum Reede Ansteuerung",
from: "borkum-south-approach",
to: "borkum-reede",
minDepthM: 5,
coordinates: [
{ lat: 53.505, lon: 6.765 },
{ lat: 53.525, lon: 6.758 },
@@ -382,19 +372,7 @@ function buildFairwayRouteCandidate(
const departureTimestamp = requestedDepartureTimestamp(request.departureTime);
const durationMinutes = Math.round((distanceNm / speedKn) * 60);
const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString();
const depthSamples = request.depthSamples ?? edgeDepthSamples(usedEdges);
const depthAssessment = assessFairwayDepthSamples(depthSamples, request.vesselProfile);
const warnings: RouteWarning[] = [
{
code: "FAIRWAY_ROUTE",
severity: "info",
message: `Route über bekannten Fahrwasser-Graphen: ${routableGraph.name}.`
},
{
code: "FAIRWAY_DATA_NOT_OFFICIAL",
severity: "caution",
message: "Fahrwasser-Graph ist eine MVP-Planungshilfe und keine amtliche Navigationsgrundlage."
},
...(searchTruncated
? [
{
@@ -406,8 +384,7 @@ function buildFairwayRouteCandidate(
}
]
: []),
...routeSnapWarnings(routeSnaps),
...depthAssessment.warnings
...routeSnapWarnings(routeSnaps)
];
const route: RouteResult = {
@@ -420,16 +397,9 @@ function buildFairwayRouteCandidate(
departureTime: new Date(departureTimestamp).toISOString(),
durationMinutes,
warnings,
minKnownDepthM: depthAssessment.minKnownDepthM,
unknownDepthRatio: depthAssessment.unknownDepthRatio,
dataSources: [
`fairway-graph:${routableGraph.id}`,
...uniqueSources(usedEdges),
request.depthSamples?.length
? "submitted-depth-samples"
: usedEdges.some((edge) => edge.minDepthM === null)
? "fairway-depth-unknown"
: "fairway-depth-estimates"
...uniqueSources(usedEdges)
],
routingMode: "fairway",
routeSnaps
@@ -620,14 +590,10 @@ export function mergeFairwayGraphs(id: string, name: string, graphs: FairwayGrap
}
function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): FairwayGraph {
const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM;
return {
...graph,
maxSnapDistanceNm: Math.min(graph.maxSnapDistanceNm, MAX_ROUTE_SNAP_DISTANCE_NM),
edges: graph.edges.filter((edge) => {
if (edge.minDepthM !== null && edge.minDepthM < requiredDepthM) {
return false;
}
if (edge.maxDraughtM != null && request.vesselProfile.draughtM > edge.maxDraughtM) {
return false;
}
@@ -1099,13 +1065,6 @@ function edgeLengthNm(edge: FairwayEdge) {
return sumRouteDistanceNm(edge.coordinates);
}
function edgeDepthSamples(edges: FairwayEdge[]): DepthSample[] {
return edges.map((edge) => ({
coordinate: edge.coordinates[Math.floor(edge.coordinates.length / 2)]!,
depthM: edge.minDepthM
}));
}
function uniqueSources(edges: FairwayEdge[]) {
const sources = new Set<string>();
for (const edge of edges) {
@@ -1114,47 +1073,6 @@ function uniqueSources(edges: FairwayEdge[]) {
return [...sources];
}
function assessFairwayDepthSamples(
samples: DepthSample[],
profile: VesselProfile
): {
minKnownDepthM: number | null;
unknownDepthRatio: number;
warnings: RouteWarning[];
} {
const known = samples.filter((sample) => typeof sample.depthM === "number");
const unknownDepthRatio = samples.length > 0 ? round((samples.length - known.length) / samples.length, 2) : 1;
const minKnownDepthM = known.length > 0 ? Math.min(...known.map((sample) => sample.depthM!)) : null;
const requiredDepthM = round(profile.draughtM + profile.safetyReserveM, 2);
const warnings: RouteWarning[] = [];
if (unknownDepthRatio > 0) {
warnings.push({
code: "DEPTH_PARTIAL",
severity: unknownDepthRatio > 0.5 ? "caution" : "info",
message: `${Math.round(unknownDepthRatio * 100)}% der Route haben keine Tiefenprobe.`
});
}
if (minKnownDepthM !== null && minKnownDepthM < requiredDepthM) {
warnings.push({
code: "DEPTH_TOO_SHALLOW",
severity: "critical",
message: `Minimale bekannte Tiefe ${round(minKnownDepthM, 1)} m unterschreitet erforderliche Tiefe ${requiredDepthM} m.`
});
}
if (known.length === 0) {
warnings.push({
code: "NO_KNOWN_DEPTH",
severity: "caution",
message: "Alle geprüften Tiefenpunkte sind unbekannt."
});
}
return { minKnownDepthM, unknownDepthRatio, warnings };
}
function appendCoordinate(points: Coordinate[], coordinate: Coordinate) {
const previous = points.at(-1);
if (previous && haversineDistanceNm(previous, coordinate) < 0.000001) {
-4
View File
@@ -274,7 +274,6 @@ export const EMDEN_HAMM_GRAPH: FairwayGraph = {
{ lat: 52.622597, lon: 7.3071821 },
{ lat: 52.6170897, lon: 7.3077526 }
],
minDepthM: null,
maxDraughtM: 2.5,
source: "openstreetmap-geofabrik-curated-seed"
},
@@ -454,7 +453,6 @@ export const EMDEN_HAMM_GRAPH: FairwayGraph = {
{ lat: 51.6572451, lon: 7.362679 },
{ lat: 51.6480728, lon: 7.3527065 }
],
minDepthM: null,
maxDraughtM: 2.5,
source: "openstreetmap-nominatim-curated-seed"
},
@@ -511,7 +509,6 @@ export const EMDEN_HAMM_GRAPH: FairwayGraph = {
{ lat: 51.6792115, lon: 7.9600807 },
{ lat: 51.677629, lon: 7.9640468 }
],
minDepthM: null,
maxDraughtM: 2.5,
source: "openstreetmap-nominatim-curated-seed"
}
@@ -537,7 +534,6 @@ export const EMDEN_EAST_EMS_GRAPH: FairwayGraph = {
name: "Unterems östlich von Emden",
from: "emden-east-start",
to: "emden-east-destination",
minDepthM: null,
source: "openstreetmap-geofabrik-curated-seed",
coordinates: [
{ lat: 53.3422, lon: 7.1871 },
+3 -74
View File
@@ -6,11 +6,8 @@ import {
} from "./fairway-routing.js";
import { EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH } from "./inland-seed.js";
import type {
DepthSample,
RouteRequest,
RouteResult,
RouteWarning,
VesselProfile
RouteResult
} from "./types.js";
const DEFAULT_CRUISE_SPEED_KN = 6;
@@ -41,66 +38,6 @@ export function buildRoute(
);
}
export function requiredDepthM(profile: VesselProfile): number {
return round(profile.draughtM + profile.safetyReserveM, 2);
}
export function assessDepthSamples(
samples: DepthSample[] | undefined,
profile: VesselProfile
): {
minKnownDepthM: number | null;
unknownDepthRatio: number;
warnings: RouteWarning[];
} {
if (!samples || samples.length === 0) {
return {
minKnownDepthM: null,
unknownDepthRatio: 1,
warnings: [
{
code: "DEPTH_UNKNOWN",
severity: "caution",
message: "Keine belastbaren Tiefendaten entlang der Route vorhanden."
}
]
};
}
const known = samples.filter((sample) => typeof sample.depthM === "number");
const unknownDepthRatio = round((samples.length - known.length) / samples.length, 2);
const minKnownDepthM =
known.length > 0 ? Math.min(...known.map((sample) => sample.depthM!)) : null;
const warnings: RouteWarning[] = [];
const required = requiredDepthM(profile);
if (unknownDepthRatio > 0) {
warnings.push({
code: "DEPTH_PARTIAL",
severity: unknownDepthRatio > 0.5 ? "caution" : "info",
message: `${Math.round(unknownDepthRatio * 100)}% der Route haben keine Tiefenprobe.`
});
}
if (minKnownDepthM !== null && minKnownDepthM < required) {
warnings.push({
code: "DEPTH_TOO_SHALLOW",
severity: "critical",
message: `Minimale bekannte Tiefe ${round(minKnownDepthM, 1)} m unterschreitet erforderliche Tiefe ${required} m.`
});
}
if (known.length === 0) {
warnings.push({
code: "NO_KNOWN_DEPTH",
severity: "caution",
message: "Alle geprüften Tiefenpunkte sind unbekannt."
});
}
return { minKnownDepthM, unknownDepthRatio, warnings };
}
export function buildManualRoute(request: RouteRequest): RouteResult {
const points = [
request.start,
@@ -116,8 +53,6 @@ export function buildManualRoute(request: RouteRequest): RouteResult {
const departureTimestamp = Number.isFinite(parsedDeparture) ? parsedDeparture : Date.now();
const durationMinutes = Math.round((distanceNm / speedKn) * 60);
const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString();
const depthAssessment = assessDepthSamples(request.depthSamples, request.vesselProfile);
return {
geometry: {
type: "LineString",
@@ -132,15 +67,9 @@ export function buildManualRoute(request: RouteRequest): RouteResult {
code: "MANUAL_ROUTE",
severity: "info",
message: "MVP-Route nutzt direkte Wegpunktsegmente ohne automatische Fahrwasserlogik."
},
...depthAssessment.warnings
],
minKnownDepthM: depthAssessment.minKnownDepthM,
unknownDepthRatio: depthAssessment.unknownDepthRatio,
dataSources: [
"user-waypoints",
request.depthSamples?.length ? "submitted-depth-samples" : "depth-unavailable"
}
],
dataSources: ["user-waypoints"],
routingMode: "manual"
};
}
-8
View File
@@ -70,8 +70,6 @@ type RouteDetails = {
departureTime?: string;
durationMinutes?: number;
warnings: RouteWarning[];
minKnownDepthM: number | null;
unknownDepthRatio: number;
dataSources: string[];
routingMode?: "manual" | "fairway";
routeSnaps?: RouteSnaps;
@@ -128,18 +126,12 @@ export type TideSummary = {
updatedAt: string;
};
export type DepthSample = {
coordinate: Coordinate;
depthM: number | null;
};
export type RouteRequest = {
start: Coordinate;
destination: Coordinate;
waypoints?: Coordinate[];
departureTime?: string;
vesselProfile: VesselProfile;
depthSamples?: DepthSample[];
};
export type AppConfig = {
@@ -268,8 +268,6 @@ describe("route guidance", () => {
distanceNm: 1,
eta: null,
warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: []
};
+25 -24
View File
@@ -6,7 +6,6 @@ import {
buildRoute,
FairwayRoutingSearchLimitError,
haversineDistanceNm,
requiredDepthM,
type FairwayEdge,
type FairwayGraph
} from "../src/index.js";
@@ -14,6 +13,14 @@ import {
const EMDEN_AUSSENHAFEN = { lat: 53.344167, lon: 7.186111 };
const BORKUM_REEDE = { lat: 53.563776, lon: 6.750562 };
const HAMM_INNENSTADT_MARINA = { lat: 51.6814536, lon: 7.8042615 };
const REMOVED_AUTOMATIC_ROUTE_WARNING_CODES = new Set([
"FAIRWAY_ROUTE",
"FAIRWAY_DATA_NOT_OFFICIAL",
"DEPTH_UNKNOWN",
"DEPTH_PARTIAL",
"NO_KNOWN_DEPTH",
"DEPTH_TOO_SHALLOW"
]);
const ALTERNATIVE_GRAPH: FairwayGraph = {
id: "alternative-test",
@@ -92,7 +99,6 @@ function edge(
from,
to,
coordinates,
minDepthM: 4,
source: "synthetic-test",
...restrictions
};
@@ -119,32 +125,17 @@ function singleEdgeGraph(restrictions: Partial<FairwayEdge>): FairwayGraph {
};
}
describe("route assessment", () => {
it("marks routes with no depth data as unknown", () => {
describe("route building", () => {
it("builds manual routes without synthesizing a depth status", () => {
const result = buildManualRoute({
start: { lat: 54.18, lon: 12.08 },
destination: { lat: 54.32, lon: 12.22 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
});
expect(result.unknownDepthRatio).toBe(1);
expect(result.warnings.some((warning) => warning.code === "DEPTH_UNKNOWN")).toBe(true);
});
it("flags known shallow samples as critical", () => {
const result = buildManualRoute({
start: { lat: 54.18, lon: 12.08 },
destination: { lat: 54.32, lon: 12.22 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 },
depthSamples: [
{ coordinate: { lat: 54.2, lon: 12.1 }, depthM: 1.6 },
{ coordinate: { lat: 54.25, lon: 12.16 }, depthM: 2.4 }
]
});
expect(requiredDepthM({ draughtM: 1.4, safetyReserveM: 0.5 })).toBe(1.9);
expect(result.minKnownDepthM).toBe(1.6);
expect(result.warnings.some((warning) => warning.severity === "critical")).toBe(true);
expect(result.warnings.map((warning) => warning.code)).toEqual(["MANUAL_ROUTE"]);
expect(result).not.toHaveProperty("minKnownDepthM");
expect(result).not.toHaveProperty("unknownDepthRatio");
});
it("routes Emden Außenhafen to Borkum Reede along the Ems fairway graph", () => {
@@ -175,8 +166,14 @@ describe("route assessment", () => {
expect(result.routingMode).toBe("fairway");
expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed");
expect(result.warnings.some((warning) => warning.code === "FAIRWAY_ROUTE")).toBe(true);
expect(result.warnings.some((warning) => warning.code === "MANUAL_ROUTE")).toBe(false);
expect(
result.warnings.some((warning) =>
REMOVED_AUTOMATIC_ROUTE_WARNING_CODES.has(warning.code)
)
).toBe(false);
expect(result).not.toHaveProperty("minKnownDepthM");
expect(result).not.toHaveProperty("unknownDepthRatio");
expect(coordinates.length).toBeGreaterThan(20);
expect(result.distanceNm).toBeGreaterThan(directDistanceNm * 1.2);
expect(largestSegmentNm).toBeLessThan(6);
@@ -606,7 +603,11 @@ describe("route assessment", () => {
expect(result?.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
expect(result?.dataSources).toContain("openstreetmap-geofabrik-curated-seed");
expect(result?.dataSources).toContain("openstreetmap-nominatim-curated-seed");
expect(result?.warnings.some((warning) => warning.code === "FAIRWAY_DATA_NOT_OFFICIAL")).toBe(true);
expect(
result?.warnings.some((warning) =>
REMOVED_AUTOMATIC_ROUTE_WARNING_CODES.has(warning.code)
)
).toBe(false);
});
it("uses the requested departure time as the basis for duration and ETA", () => {
-5
View File
@@ -37,7 +37,6 @@ KEPT_TAGS = {
"access",
"boat",
"construction",
"depth",
"disused",
"maxdraft",
"maxdraught",
@@ -45,7 +44,6 @@ KEPT_TAGS = {
"maxheight:physical",
"maxwidth",
"maxwidth:physical",
"min_depth",
"motor_vehicle",
"motorboat",
"name",
@@ -55,10 +53,7 @@ KEPT_TAGS = {
"route",
"seamark:bridge:clearance_height",
"seamark:bridge:clearance_height_safe",
"seamark:fairway:minimum_depth",
"seamark:lock:chamber_width",
"seamark:navigation_line:minimum_depth",
"seamark:recommended_track:minimum_depth",
"seamark:restriction:max_draught",
"seamark:type",
"ship",
+20 -1
View File
@@ -68,7 +68,18 @@ class BuildLocalFairwaysTest(unittest.TestCase):
(3, 7.2, 53.2),
],
[
(100, 1, [1, 2], {"waterway": "canal", "name": "DE Kanal"}),
(
100,
1,
[1, 2],
{
"waterway": "canal",
"name": "DE Kanal",
"depth": "4",
"seamark:fairway:minimum_depth": "3.5",
"maxdraught": "2.5",
},
),
(
200,
1,
@@ -143,6 +154,14 @@ class BuildLocalFairwaysTest(unittest.TestCase):
self.assertEqual(shared_way["tags"]["name"], "Nieuwe lijn")
self.assertEqual(len(shared_way["coordinates"]), 3)
canal_way = next(way for way in document["ways"] if way["id"] == "100")
self.assertEqual(canal_way["tags"]["maxdraught"], "2.5")
self.assertNotIn("depth", canal_way["tags"])
self.assertNotIn(
"seamark:fairway:minimum_depth",
canal_way["tags"],
)
cross_border_way = next(
way for way in document["ways"] if way["id"] == "500"
)