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 - 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 - 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 - 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 - 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 - 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 - frei sortierbare Zwischenziele direkt von der Karte
- Abfahrtszeit-bezogene Wetter-, Wellen-, Strömungs- und Tidenplanung entlang der Route - 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 - 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 ## Aktuelles Boot
Über das Bootssymbol in der Navigationsleiste wird ein zentrales Bootsprofil Ü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 Etappenplanung sowie die bootsabhängigen Werte der Ankerwache verwenden dieses
Profil automatisch. Eine Änderung verwirft eine bereits berechnete Route, weil 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 Das versionierte Profil wird für ein Jahr im Cookie
`watermaps.vessel-profile` dieses Browsers gespeichert. Es enthält nur `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, explizit für Boote oder Schiffe freigegebene Flüsse. Gesperrte, private,
stillgelegte oder im Bau befindliche Wege werden ausgeschlossen. 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 Nur im ausdrücklich aktivierten Demo-Modus
`WATERMAPS_DEMO_DATA=true` stehen drei klar als nicht amtlich markierte `WATERMAPS_DEMO_DATA=true` stehen drei klar als nicht amtlich markierte
Seed-Korridore bereit: 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 deaktiviert. Fehlt dort der vollständige lokale Index beziehungsweise eine
andere konfigurierte Routingquelle, liefert die API andere konfigurierte Routingquelle, liefert die API
`503 fairway_sources_unavailable`, statt unbemerkt eine grobe Seed-Route zu `503 fairway_sources_unavailable`, statt unbemerkt eine grobe Seed-Route zu
zeichnen. Der EmdenHamm-Seed besitzt keine belastbaren Tiefen- oder zeichnen. Der EmdenHamm-Seed besitzt keine belastbaren Tiefenprofile oder
Schleusenzeitdaten und ist bewusst auf Sportboote bis 2,5 m Tiefgang begrenzt. 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 Vor der Fahrt sind aktuelle Sperrungen, Betriebszeiten, Wasserstände und
amtliche Karten zu prüfen. Kostenfreie Inland-ENCs für den Dortmund-Ems- 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/) 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(), airDraftM: z.number().positive().max(80).optional(),
beamM: z.number().positive().max(80).optional(), beamM: z.number().positive().max(80).optional(),
cruiseSpeedKn: 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>; }) satisfies z.ZodType<RouteRequest>;
const coordinateQuerySchema = z.object({ const coordinateQuerySchema = z.object({
-29
View File
@@ -54,7 +54,6 @@ export type FairwayRow = {
source: string; source: string;
source_id: string | null; source_id: string | null;
name: string | null; name: string | null;
min_depth_m: string | number | null;
properties?: Record<string, unknown> | null; properties?: Record<string, unknown> | null;
geometry: { geometry: {
type: "LineString"; type: "LineString";
@@ -165,7 +164,6 @@ export class FairwayService {
source, source,
source_id, source_id,
name, name,
min_depth_m,
properties, properties,
ST_AsGeoJSON(geom)::json AS geometry ST_AsGeoJSON(geom)::json AS geometry
FROM marine_fairway_edges FROM marine_fairway_edges
@@ -328,7 +326,6 @@ export function fairwayRowsToGraph(rows: FairwayRow[], bbox: [number, number, nu
id: `postgis-edge-${row.id}`, id: `postgis-edge-${row.id}`,
name: row.name ?? row.source_id ?? `PostGIS ${row.id}`, name: row.name ?? row.source_id ?? `PostGIS ${row.id}`,
coordinates: row.geometry.coordinates.map(([lon, lat]) => ({ lon, lat })), coordinates: row.geometry.coordinates.map(([lon, lat]) => ({ lon, lat })),
minDepthM: parseNumeric(row.min_depth_m),
source: `postgis-${row.source}`, source: `postgis-${row.source}`,
...edgeRestrictions(tags) ...edgeRestrictions(tags)
}; };
@@ -352,7 +349,6 @@ export function overpassToGraph(response: OverpassResponse, bbox: [number, numbe
id: `osm-way-${element.id}`, id: `osm-way-${element.id}`,
name: tags.name ?? tags.ref ?? `OSM ${element.id}`, name: tags.name ?? tags.ref ?? `OSM ${element.id}`,
coordinates, coordinates,
minDepthM: parseDepth(tags),
source: sourceFor(tags), source: sourceFor(tags),
...edgeRestrictions(tags) ...edgeRestrictions(tags)
}; };
@@ -386,7 +382,6 @@ export function localFairwaysToGraph(
id: `local-osm-way-${way.id}`, id: `local-osm-way-${way.id}`,
name: tags.name ?? tags.ref ?? `OSM ${way.id}`, name: tags.name ?? tags.ref ?? `OSM ${way.id}`,
coordinates, coordinates,
minDepthM: parseDepth(tags),
source: `local-geofabrik-${source}`, source: `local-geofabrik-${source}`,
...edgeRestrictions(tags) ...edgeRestrictions(tags)
}; };
@@ -443,7 +438,6 @@ function waysToGraph(
id: string; id: string;
name: string; name: string;
coordinates: Coordinate[]; coordinates: Coordinate[];
minDepthM: number | null;
source: string; source: string;
maxAirDraftM?: number | null; maxAirDraftM?: number | null;
maxBeamM?: number | null; maxBeamM?: number | null;
@@ -473,7 +467,6 @@ function waysToGraph(
from, from,
to, to,
coordinates: [start, end], coordinates: [start, end],
minDepthM: way.minDepthM,
source: way.source, source: way.source,
maxAirDraftM: way.maxAirDraftM, maxAirDraftM: way.maxAirDraftM,
maxBeamM: way.maxBeamM, maxBeamM: way.maxBeamM,
@@ -579,28 +572,6 @@ function nodeIdFor(nodes: Map<string, FairwayNode>, coordinate: Coordinate) {
return id; 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>) { function edgeRestrictions(tags: Record<string, string>) {
return { return {
maxAirDraftM: firstNumericTag(tags, [ maxAirDraftM: firstNumericTag(tags, [
-34
View File
@@ -36,7 +36,6 @@ const testFairwayGraph = (
from: `${id}-start`, from: `${id}-start`,
to: `${id}-destination`, to: `${id}-destination`,
coordinates: [start, destination], coordinates: [start, destination],
minDepthM: null,
source: "test-fairway-source" source: "test-fairway-source"
} }
] ]
@@ -68,7 +67,6 @@ const boundedSnapSearchFixture = () => {
from: "junction", from: "junction",
to: `trap-${index + 1}`, to: `trap-${index + 1}`,
coordinates: [{ lat: 52, lon: 7 }, coordinate], coordinates: [{ lat: 52, lon: 7 }, coordinate],
minDepthM: 4,
oneway: true as const, oneway: true as const,
source: `api-bounded-trap-${index + 1}` source: `api-bounded-trap-${index + 1}`
})), })),
@@ -78,7 +76,6 @@ const boundedSnapSearchFixture = () => {
from: "junction", from: "junction",
to: "destination", to: "destination",
coordinates: [{ lat: 52, lon: 7 }, destination], coordinates: [{ lat: 52, lon: 7 }, destination],
minDepthM: 4,
oneway: true as const, oneway: true as const,
source: "api-ninth-main-route" source: "api-ninth-main-route"
} }
@@ -220,26 +217,6 @@ describe("Watermaps API", () => {
await app.close(); 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 () => { it("rejects routes without a known fairway instead of returning a straight line", async () => {
const app = await buildServer({ const app = await buildServer({
cache: createCache(), cache: createCache(),
@@ -443,7 +420,6 @@ describe("Watermaps API", () => {
expect(body.routingMode).toBe("fairway"); expect(body.routingMode).toBe("fairway");
expect(body.dataSources).toContain("fairway-graph:ems-borkum-seed"); expect(body.dataSources).toContain("fairway-graph:ems-borkum-seed");
expect(body.geometry.coordinates.length).toBeGreaterThan(20); expect(body.geometry.coordinates.length).toBeGreaterThan(20);
expect(body.warnings.some((warning: { code: string }) => warning.code === "FAIRWAY_ROUTE")).toBe(true);
await app.close(); await app.close();
}); });
@@ -469,7 +445,6 @@ describe("Watermaps API", () => {
name: "Norddeich Fahrwasser", name: "Norddeich Fahrwasser",
from: "norddeich", from: "norddeich",
to: "fairway", to: "fairway",
minDepthM: null,
source: "local-geofabrik-test", source: "local-geofabrik-test",
coordinates: [ coordinates: [
{ lat: 53.6234, lon: 7.1559 }, { lat: 53.6234, lon: 7.1559 },
@@ -481,7 +456,6 @@ describe("Watermaps API", () => {
name: "Norderney Fahrwasser", name: "Norderney Fahrwasser",
from: "fairway", from: "fairway",
to: "norderney", to: "norderney",
minDepthM: null,
source: "local-geofabrik-test", source: "local-geofabrik-test",
coordinates: [ coordinates: [
{ lat: 53.66, lon: 7.16 }, { lat: 53.66, lon: 7.16 },
@@ -605,7 +579,6 @@ describe("Watermaps API", () => {
name: "AB", name: "AB",
from: "a", from: "a",
to: "b", to: "b",
minDepthM: 4,
source: "test-overpass", source: "test-overpass",
coordinates: [ coordinates: [
{ lat: 54, lon: 10 }, { lat: 54, lon: 10 },
@@ -617,7 +590,6 @@ describe("Watermaps API", () => {
name: "BC", name: "BC",
from: "b", from: "b",
to: "c", to: "c",
minDepthM: 4,
source: "test-overpass", source: "test-overpass",
coordinates: [ coordinates: [
{ lat: 54.02, lon: 10.05 }, { lat: 54.02, lon: 10.05 },
@@ -679,7 +651,6 @@ describe("Watermaps API", () => {
from: "start-decoy-a", from: "start-decoy-a",
to: "start-decoy-b", to: "start-decoy-b",
coordinates: [coordinate(53.3416, 7.186), coordinate(53.342, 7.187)], coordinates: [coordinate(53.3416, 7.186), coordinate(53.342, 7.187)],
minDepthM: null,
source: "closer-but-disconnected-start" source: "closer-but-disconnected-start"
}, },
{ {
@@ -688,7 +659,6 @@ describe("Watermaps API", () => {
from: "destination-decoy-a", from: "destination-decoy-a",
to: "destination-decoy-b", to: "destination-decoy-b",
coordinates: [coordinate(53.3282, 6.9304), coordinate(53.3286, 6.9294)], coordinates: [coordinate(53.3282, 6.9304), coordinate(53.3286, 6.9294)],
minDepthM: null,
source: "closer-but-disconnected-destination" source: "closer-but-disconnected-destination"
}, },
{ {
@@ -697,7 +667,6 @@ describe("Watermaps API", () => {
from: "shared-start", from: "shared-start",
to: "shared-east", to: "shared-east",
coordinates: [coordinate(53.34145, 7.18585), coordinate(53.3321722, 7.1329034)], coordinates: [coordinate(53.34145, 7.18585), coordinate(53.3321722, 7.1329034)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands" source: "local-geofabrik-germany+netherlands"
}, },
{ {
@@ -706,7 +675,6 @@ describe("Watermaps API", () => {
from: "shared-east", from: "shared-east",
to: "shared-south", to: "shared-south",
coordinates: [coordinate(53.3321722, 7.1329034), coordinate(53.313849, 7.0011017)], coordinates: [coordinate(53.3321722, 7.1329034), coordinate(53.313849, 7.0011017)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands" source: "local-geofabrik-germany+netherlands"
}, },
{ {
@@ -715,7 +683,6 @@ describe("Watermaps API", () => {
from: "shared-south", from: "shared-south",
to: "shared-destination", to: "shared-destination",
coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.32805, 6.9302)], coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.32805, 6.9302)],
minDepthM: null,
source: "local-geofabrik-germany+netherlands" source: "local-geofabrik-germany+netherlands"
} }
] ]
@@ -765,7 +732,6 @@ describe("Watermaps API", () => {
from, from,
to, to,
coordinates, coordinates,
minDepthM: 4,
source: "test-alternatives" source: "test-alternatives"
}); });
const app = await buildServer({ const app = await buildServer({
+1 -9
View File
@@ -378,7 +378,6 @@ describe("fairway graph extraction", () => {
source: "osm", source: "osm",
source_id: "way-1", source_id: "way-1",
name: "Harbour Reach", name: "Harbour Reach",
min_depth_m: "4.2",
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: [ coordinates: [
@@ -392,7 +391,6 @@ describe("fairway graph extraction", () => {
source: "osm", source: "osm",
source_id: "way-2", source_id: "way-2",
name: "Outer Reach", name: "Outer Reach",
min_depth_m: 4.2,
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: [ coordinates: [
@@ -430,7 +428,6 @@ describe("fairway graph extraction", () => {
source: "osm", source: "osm",
source_id: "way-main", source_id: "way-main",
name: "Ems Fahrwasser", name: "Ems Fahrwasser",
min_depth_m: null,
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: [ coordinates: [
@@ -447,7 +444,6 @@ describe("fairway graph extraction", () => {
source: "osm", source: "osm",
source_id: "way-branch", source_id: "way-branch",
name: "Busetief", name: "Busetief",
min_depth_m: null,
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: [ coordinates: [
@@ -495,8 +491,7 @@ describe("fairway graph extraction", () => {
type: "way", type: "way",
id: 123, id: 123,
tags: { tags: {
"seamark:type": "navigation_line", "seamark:type": "navigation_line"
"seamark:navigation_line:minimum_depth": "3.5"
}, },
geometry: [ geometry: [
{ lat: 54, lon: 10 }, { lat: 54, lon: 10 },
@@ -510,7 +505,6 @@ describe("fairway graph extraction", () => {
expect(graph).not.toBeNull(); expect(graph).not.toBeNull();
expect(graph?.edges[0]?.source).toBe("osm-overpass-seamarks"); 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", () => { it("accepts navigable canals but rejects explicitly closed waterways", () => {
@@ -553,7 +547,6 @@ describe("fairway graph extraction", () => {
source: "osm", source: "osm",
source_id: "north", source_id: "north",
name: "Dortmund-Ems-Kanal", name: "Dortmund-Ems-Kanal",
min_depth_m: null,
geometry: { geometry: {
type: "LineString", type: "LineString",
coordinates: [ coordinates: [
@@ -604,7 +597,6 @@ describe("fairway graph extraction", () => {
source: "osm", source: "osm",
source_id: "way-restricted", source_id: "way-restricted",
name: "Niedrige Durchfahrt", name: "Niedrige Durchfahrt",
min_depth_m: "3.0",
properties: { maxheight: "2.4 m", maxwidth: "3.2", maxdraft: "1.8", oneway: "yes" }, properties: { maxheight: "2.4 m", maxwidth: "3.2", maxdraft: "1.8", oneway: "yes" },
geometry: { geometry: {
type: "LineString", type: "LineString",
+9 -3
View File
@@ -44,6 +44,7 @@ import { useGeolocation } from "./hooks/useGeolocation";
import { useMarineData } from "./hooks/useMarineData"; import { useMarineData } from "./hooks/useMarineData";
import type { OfflineVoyage } from "./lib/offline-route"; import type { OfflineVoyage } from "./lib/offline-route";
import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile"; import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile";
import { isOpenRouteWarning } from "./lib/route-warnings";
import { import {
upcomingRouteEvents, upcomingRouteEvents,
type RouteEventEtaBasis, type RouteEventEtaBasis,
@@ -300,7 +301,7 @@ export function App() {
}, [route]); }, [route]);
const routeWarningCount = useMemo( const routeWarningCount = useMemo(
() => route?.warnings.filter((warning) => warning.severity !== "info").length ?? 0, () => route?.warnings.filter(isOpenRouteWarning).length ?? 0,
[route] [route]
); );
const handleMapReady = useCallback(() => setMapReady(true), []); const handleMapReady = useCallback(() => setMapReady(true), []);
@@ -487,9 +488,13 @@ export function App() {
: route : route
? "active" ? "active"
: "idle", : "idle",
route: guidanceAlarm || route?.warnings.some((warning) => warning.severity === "critical") route: guidanceAlarm || route?.warnings.some(
(warning) => isOpenRouteWarning(warning) && warning.severity === "critical"
)
? "alarm" ? "alarm"
: route?.warnings.some((warning) => warning.severity === "caution") : route?.warnings.some(
(warning) => isOpenRouteWarning(warning) && warning.severity === "caution"
)
? "caution" ? "caution"
: courseAssistant.active || route : courseAssistant.active || route
? "active" ? "active"
@@ -1006,6 +1011,7 @@ export function App() {
headingSource={guidanceHeadingSource} headingSource={guidanceHeadingSource}
accuracyM={gps.accuracyM} accuracyM={gps.accuracyM}
fixStale={courseAssistant.fixStale} fixStale={courseAssistant.fixStale}
routeWarnings={route?.warnings ?? []}
onStop={courseAssistant.stop} onStop={courseAssistant.stop}
/> />
</LazyContent> </LazyContent>
+2 -2
View File
@@ -190,7 +190,7 @@ export function BoatProfilePanel({
<legend>Abmessungen und Fahrtwerte</legend> <legend>Abmessungen und Fahrtwerte</legend>
<p> <p>
Bitte die Werte für den normalen Fahrzustand eintragen. Sie Bitte die Werte für den normalen Fahrzustand eintragen. Sie
beeinflussen Durchfahrt, Tiefenprüfung und Fahrtdauer. beeinflussen bekannte Durchfahrtsbeschränkungen und Fahrtdauer.
</p> </p>
<div className="boat-profile-field-grid"> <div className="boat-profile-field-grid">
@@ -232,7 +232,7 @@ export function BoatProfilePanel({
/> />
<NumberField <NumberField
label="Sicherheitsreserve" 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" unit="m"
min={0} min={0}
max={10} max={10}
@@ -1,5 +1,6 @@
import { AlertTriangle, Navigation, Square } from "lucide-react"; 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"; import "./CourseAssistantPanel.css";
export type CourseAssistantPanelProps = { export type CourseAssistantPanelProps = {
@@ -9,6 +10,7 @@ export type CourseAssistantPanelProps = {
headingSource: "COG" | "HDG" | "--"; headingSource: "COG" | "HDG" | "--";
accuracyM: number | null; accuracyM: number | null;
fixStale: boolean; fixStale: boolean;
routeWarnings?: RouteWarning[];
onStop: () => void; onStop: () => void;
}; };
@@ -19,6 +21,7 @@ export function CourseAssistantPanel({
headingSource, headingSource,
accuracyM, accuracyM,
fixStale, fixStale,
routeWarnings = [],
onStop onStop
}: CourseAssistantPanelProps) { }: CourseAssistantPanelProps) {
const waitingForGps = !guidance && !fixStale; const waitingForGps = !guidance && !fixStale;
@@ -47,6 +50,8 @@ export function CourseAssistantPanel({
</button> </button>
</header> </header>
<RouteWarnings warnings={routeWarnings} />
<div className="course-assistant-main"> <div className="course-assistant-main">
<span <span
className="course-assistant-arrow" className="course-assistant-arrow"
+14 -54
View File
@@ -38,9 +38,11 @@ import {
DEFAULT_BOAT_PROFILE, DEFAULT_BOAT_PROFILE,
toVesselProfile toVesselProfile
} from "../lib/boat-profile"; } from "../lib/boat-profile";
import { isOpenRouteWarning } from "../lib/route-warnings";
import type { RouteLock } from "../voyageHarbours"; import type { RouteLock } from "../voyageHarbours";
import { LazyContent } from "./LazyContent"; import { LazyContent } from "./LazyContent";
import { NavigationDataPanel } from "./NavigationDataPanel"; import { NavigationDataPanel } from "./NavigationDataPanel";
import { RouteWarnings } from "./RouteWarnings";
import { RouteTidePanel, type RouteTidePlan } from "./RouteTidePanel"; import { RouteTidePanel, type RouteTidePlan } from "./RouteTidePanel";
import { VoyagePlan } from "./VoyagePlan"; import { VoyagePlan } from "./VoyagePlan";
@@ -173,10 +175,18 @@ export function RoutePlanner({
if (!result) { if (!result) {
return "idle"; return "idle";
} }
if (result.warnings.some((warning) => warning.severity === "critical")) { if (
result.warnings.some(
(warning) => isOpenRouteWarning(warning) && warning.severity === "critical"
)
) {
return "critical"; return "critical";
} }
if (result.warnings.some((warning) => warning.severity === "caution")) { if (
result.warnings.some(
(warning) => isOpenRouteWarning(warning) && warning.severity === "caution"
)
) {
return "caution"; return "caution";
} }
return "ok"; return "ok";
@@ -207,9 +217,6 @@ export function RoutePlanner({
} }
return new Date(timestamp + routeLocks.length * lockDelayMinutes * 60_000).toISOString(); return new Date(timestamp + routeLocks.length * lockDelayMinutes * 60_000).toISOString();
}, [lockDelayMinutes, result?.eta, routeLocks.length, weatherReport?.adjustedEta]); }, [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(() => { useEffect(() => {
if (!result) { if (!result) {
setPlannerView("input"); setPlannerView("input");
@@ -554,9 +561,7 @@ export function RoutePlanner({
<span> <span>
{result.routingMode === "fairway" {result.routingMode === "fairway"
? "Fahrwasser" ? "Fahrwasser"
: result.unknownDepthRatio > 0 : "Direkte Wegpunkte"}
? `${Math.round(result.unknownDepthRatio * 100)}% unbekannt`
: "Tiefe OK"}
</span> </span>
{(weatherReport?.adjustedEta ?? result.eta) && ( {(weatherReport?.adjustedEta ?? result.eta) && (
<span> <span>
@@ -578,52 +583,7 @@ export function RoutePlanner({
</section> </section>
)} )}
{(error || criticalWarnings.length > 0) && ( <RouteWarnings warnings={result.warnings} error={error} />
<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>
)}
{routeOptions.length > 1 && ( {routeOptions.length > 1 && (
<section className="route-options" aria-label="Routenalternativen"> <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} /> <TideLocation label="Ziel" summary={plan.destination} />
</div> </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> </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 type { RouteResult } from "@watermaps/shared";
import { isRetainedRouteWarning } from "./route-warnings";
const DEFAULT_CREATOR = "Watermaps"; const DEFAULT_CREATOR = "Watermaps";
@@ -25,7 +26,10 @@ export function createRouteGpx(route: RouteResult, options: GpxExportOptions = {
const createdAt = normalizeDate(options.createdAt); const createdAt = normalizeDate(options.createdAt);
const source = cleanText(route.dataSources.join(", ") || DEFAULT_CREATOR, 500); const source = cleanText(route.dataSources.join(", ") || DEFAULT_CREATOR, 500);
const warningSummary = cleanText( 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 1_000
); );
const bounds = routeBounds(coordinates); const bounds = routeBounds(coordinates);
+20 -21
View File
@@ -5,6 +5,7 @@ import type {
RouteWarning, RouteWarning,
VesselProfile VesselProfile
} from "@watermaps/shared"; } from "@watermaps/shared";
import { isRetainedRouteWarning } from "./route-warnings";
export const OFFLINE_VOYAGES_STORAGE_KEY = "watermaps.offline-voyages.v1"; export const OFFLINE_VOYAGES_STORAGE_KEY = "watermaps.offline-voyages.v1";
export const LEGACY_OFFLINE_VOYAGES_STORAGE_KEY = "seacompass.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 coordinates = normalizeLineCoordinates(value.geometry.coordinates);
const distanceNm = finiteNumber(value.distanceNm, 0, 100_000); const distanceNm = finiteNumber(value.distanceNm, 0, 100_000);
const eta = value.eta === null ? null : limitedString(value.eta, 100); 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 warnings = normalizeWarnings(value.warnings);
const dataSources = normalizeStrings(value.dataSources, 100, 500); const dataSources = normalizeStrings(value.dataSources, 100, 500);
const id = value.id === undefined ? undefined : limitedString(value.id, 160); const id = value.id === undefined ? undefined : limitedString(value.id, 160);
@@ -232,8 +231,6 @@ function normalizeRoute(value: unknown): RouteResult {
distanceNm, distanceNm,
eta, eta,
warnings, warnings,
minKnownDepthM,
unknownDepthRatio,
dataSources, dataSources,
...(departureTime ? { departureTime } : {}), ...(departureTime ? { departureTime } : {}),
...(durationMinutes !== undefined ? { durationMinutes } : {}), ...(durationMinutes !== undefined ? { durationMinutes } : {}),
@@ -320,23 +317,25 @@ function normalizeWarnings(value: unknown): RouteWarning[] {
if (!Array.isArray(value) || value.length > 500) { if (!Array.isArray(value) || value.length > 500) {
throw new OfflineVoyageStorageError("Die Routenwarnungen sind ungültig."); throw new OfflineVoyageStorageError("Die Routenwarnungen sind ungültig.");
} }
return value.map((warning) => { return value
if (!isRecord(warning)) { .map((warning): RouteWarning => {
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig."); 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 code = limitedString(warning.code, 100);
const severity = warning.severity; const message = limitedString(warning.message, 1_000);
if (!code || !message || (severity !== "info" && severity !== "caution" && severity !== "critical")) { const severity = warning.severity;
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig."); if (!code || !message || (severity !== "info" && severity !== "caution" && severity !== "critical")) {
} throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
return { }
code, return {
message, code,
severity, message,
...(warning.coordinate === undefined ? {} : { coordinate: normalizeCoordinate(warning.coordinate) }) severity,
}; ...(warning.coordinate === undefined ? {} : { coordinate: normalizeCoordinate(warning.coordinate) })
}); };
})
.filter(isRetainedRouteWarning);
} }
function normalizeStrings(value: unknown, maxItems: number, maxLength: number): string[] { 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, distanceNm: 1,
eta: null, eta: null,
warnings: [], warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"], dataSources: ["Test"],
routingMode: "fairway" routingMode: "fairway"
}; };
@@ -80,4 +80,52 @@ describe("CourseAssistantPanel", () => {
expect(screen.getByText(/GPS-Fix ist veraltet/)).toBeVisible(); expect(screen.getByText(/GPS-Fix ist veraltet/)).toBeVisible();
expect(screen.getByText("---")).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, distanceNm: 0.7,
eta: null, eta: null,
warnings: [], warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["E2E"], dataSources: ["E2E"],
routingMode: "fairway" routingMode: "fairway"
}) })
+27 -2
View File
@@ -35,6 +35,33 @@ describe("GPX export", () => {
geometry: { type: "LineString", coordinates: [[7, 53], [181, 52]] } geometry: { type: "LineString", coordinates: [[7, 53], [181, 52]] }
})).toThrow(/ungültige Koordinaten/); })).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 = { const routeFixture: RouteResult = {
@@ -47,8 +74,6 @@ const routeFixture: RouteResult = {
distanceNm: 153.69, distanceNm: 153.69,
eta: null, eta: null,
warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich & vor Ort prüfen" }], warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich & vor Ort prüfen" }],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OpenStreetMap <curated>"], dataSources: ["OpenStreetMap <curated>"],
routingMode: "fairway" routingMode: "fairway"
}; };
-2
View File
@@ -530,8 +530,6 @@ describe("MapView marine feature information", () => {
distanceNm: 120, distanceNm: 120,
eta: null, eta: null,
warnings: [], warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [], dataSources: [],
routingMode: "fairway" routingMode: "fairway"
}; };
+6 -3
View File
@@ -22,9 +22,9 @@ describe("NavigationToolRail", () => {
anchor: "alarm", anchor: "alarm",
conditions: "active", conditions: "active",
upcoming: "caution", 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(upcoming).toHaveAttribute("data-status", "caution");
expect(within(upcoming).getByText("99+")).toBeVisible(); 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); fireEvent.click(route);
expect(onSelect).toHaveBeenCalledWith("route"); expect(onSelect).toHaveBeenCalledWith("route");
}); });
+65 -4
View File
@@ -68,6 +68,71 @@ describe("offline voyage storage", () => {
expect(listOfflineVoyages(localStorage)).toEqual([]); 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", () => { it("rejects unsafe route coordinates before writing", () => {
expect(() => createOfflineVoyageRecord({ expect(() => createOfflineVoyageRecord({
route: { route: {
@@ -91,8 +156,6 @@ const routeFixture: RouteResult = {
durationMinutes: 120, durationMinutes: 120,
eta: "2026-07-20T08:00:00.000Z", eta: "2026-07-20T08:00:00.000Z",
warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich" }], warnings: [{ code: "NOT_OFFICIAL", severity: "caution", message: "Nicht amtlich" }],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OSM"], dataSources: ["OSM"],
routingMode: "fairway", routingMode: "fairway",
routeSnaps: { routeSnaps: {
@@ -115,8 +178,6 @@ const routeFixture: RouteResult = {
distanceNm: 160, distanceNm: 160,
eta: null, eta: null,
warnings: [], warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["OSM"] dataSources: ["OSM"]
}] }]
}; };
-2
View File
@@ -22,8 +22,6 @@ const route: RouteResult = {
distanceNm: 60, distanceNm: 60,
eta: null, eta: null,
warnings: [], warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["test"] dataSources: ["test"]
}; };
+85 -5
View File
@@ -1,5 +1,5 @@
import "@testing-library/jest-dom/vitest"; 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 { afterEach, describe, expect, it, vi } from "vitest";
import { RoutePlanner } from "../src/components/RoutePlanner"; import { RoutePlanner } from "../src/components/RoutePlanner";
import { DEFAULT_BOAT_PROFILE } from "../src/lib/boat-profile"; import { DEFAULT_BOAT_PROFILE } from "../src/lib/boat-profile";
@@ -416,6 +416,7 @@ describe("RoutePlanner", () => {
); );
expect(screen.getByText("Routenergebnis")).toBeVisible(); expect(screen.getByText("Routenergebnis")).toBeVisible();
expect(screen.getByText("Hinweise (2)")).toBeVisible();
expect(screen.getByText("Brücke ist zu niedrig.")).toBeVisible(); expect(screen.getByText("Brücke ist zu niedrig.")).toBeVisible();
expect(screen.getByText("Wind aufmerksam beobachten.")).toBeVisible(); expect(screen.getByText("Wind aufmerksam beobachten.")).toBeVisible();
expect(screen.queryByRole("button", { name: "Start auf Karte setzen" })).not.toBeInTheDocument(); 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(); 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", () => { it("shows the route weather report after a route was calculated", () => {
render( render(
<RoutePlanner <RoutePlanner
@@ -437,8 +521,6 @@ describe("RoutePlanner", () => {
distanceNm: 4.2, distanceNm: 4.2,
eta: null, eta: null,
warnings: [], warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [], dataSources: [],
routingMode: "fairway" routingMode: "fairway"
}} }}
@@ -608,8 +690,6 @@ function routeOption(id: string, name: string, distanceNm: number) {
distanceNm, distanceNm,
eta: "2026-07-13T14:00:00.000Z", eta: "2026-07-13T14:00:00.000Z",
warnings: [], warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [], dataSources: [],
routingMode: "fairway" as const 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, distanceNm: 25,
eta: null, eta: null,
warnings: [], warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [], dataSources: [],
routingMode: "fairway" routingMode: "fairway"
}; };
@@ -126,8 +126,6 @@ const routeFixture: RouteResult = {
distanceNm: 0.4, distanceNm: 0.4,
eta: null, eta: null,
warnings: [], warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: ["Test"], dataSources: ["Test"],
routingMode: "fairway" routingMode: "fairway"
}; };
+2 -84
View File
@@ -1,7 +1,6 @@
import { haversineDistanceNm, sumRouteDistanceNm } from "./geo.js"; import { haversineDistanceNm, sumRouteDistanceNm } from "./geo.js";
import type { import type {
Coordinate, Coordinate,
DepthSample,
RouteOption, RouteOption,
RouteRequest, RouteRequest,
RouteResult, RouteResult,
@@ -22,7 +21,6 @@ export type FairwayEdge = {
from: string; from: string;
to: string; to: string;
coordinates: Coordinate[]; coordinates: Coordinate[];
minDepthM: number | null;
maxAirDraftM?: number | null; maxAirDraftM?: number | null;
maxBeamM?: number | null; maxBeamM?: number | null;
maxDraughtM?: number | null; maxDraughtM?: number | null;
@@ -126,7 +124,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Emden Außenhafen Ausfahrt", name: "Emden Außenhafen Ausfahrt",
from: "emden-aussenhafen", from: "emden-aussenhafen",
to: "emden-harbour-leading", to: "emden-harbour-leading",
minDepthM: 8.5,
coordinates: [ coordinates: [
{ lat: 53.344167, lon: 7.186111 }, { lat: 53.344167, lon: 7.186111 },
{ lat: 53.339, lon: 7.191 }, { lat: 53.339, lon: 7.191 },
@@ -138,7 +135,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Emder Leitlinie zum Emsfahrwasser", name: "Emder Leitlinie zum Emsfahrwasser",
from: "emden-harbour-leading", from: "emden-harbour-leading",
to: "ems-fairway-inner", to: "ems-fairway-inner",
minDepthM: 8.5,
coordinates: [ coordinates: [
{ lat: 53.333897, lon: 7.201469 }, { lat: 53.333897, lon: 7.201469 },
{ lat: 53.332236, lon: 7.132959 }, { lat: 53.332236, lon: 7.132959 },
@@ -150,7 +146,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Inneres Emsfahrwasser", name: "Inneres Emsfahrwasser",
from: "ems-fairway-inner", from: "ems-fairway-inner",
to: "knock-reach", to: "knock-reach",
minDepthM: 8.5,
coordinates: [ coordinates: [
{ lat: 53.33532, lon: 7.132674 }, { lat: 53.33532, lon: 7.132674 },
{ lat: 53.333, lon: 7.075 }, { lat: 53.333, lon: 7.075 },
@@ -162,7 +157,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Außenems über Paapsand", name: "Außenems über Paapsand",
from: "knock-reach", from: "knock-reach",
to: "paapsand-reach", to: "paapsand-reach",
minDepthM: 7.5,
coordinates: [ coordinates: [
{ lat: 53.327597, lon: 7.015002 }, { lat: 53.327597, lon: 7.015002 },
{ lat: 53.323764, lon: 6.985 }, { lat: 53.323764, lon: 6.985 },
@@ -175,7 +169,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Westerems Südansteuerung", name: "Westerems Südansteuerung",
from: "paapsand-reach", from: "paapsand-reach",
to: "westerems-south", to: "westerems-south",
minDepthM: 7,
coordinates: [ coordinates: [
{ lat: 53.339, lon: 6.93 }, { lat: 53.339, lon: 6.93 },
{ lat: 53.348, lon: 6.9 }, { lat: 53.348, lon: 6.9 },
@@ -188,7 +181,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Westerems Fahrwasser", name: "Westerems Fahrwasser",
from: "westerems-south", from: "westerems-south",
to: "eemshaven-approach", to: "eemshaven-approach",
minDepthM: 7,
coordinates: [ coordinates: [
{ lat: 53.376, lon: 6.865 }, { lat: 53.376, lon: 6.865 },
{ lat: 53.398, lon: 6.85 }, { lat: 53.398, lon: 6.85 },
@@ -201,7 +193,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Borkum Südansteuerung", name: "Borkum Südansteuerung",
from: "eemshaven-approach", from: "eemshaven-approach",
to: "borkum-south-approach", to: "borkum-south-approach",
minDepthM: 6,
coordinates: [ coordinates: [
{ lat: 53.442996, lon: 6.833146 }, { lat: 53.442996, lon: 6.833146 },
{ lat: 53.463, lon: 6.807 }, { lat: 53.463, lon: 6.807 },
@@ -214,7 +205,6 @@ export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
name: "Borkum Reede Ansteuerung", name: "Borkum Reede Ansteuerung",
from: "borkum-south-approach", from: "borkum-south-approach",
to: "borkum-reede", to: "borkum-reede",
minDepthM: 5,
coordinates: [ coordinates: [
{ lat: 53.505, lon: 6.765 }, { lat: 53.505, lon: 6.765 },
{ lat: 53.525, lon: 6.758 }, { lat: 53.525, lon: 6.758 },
@@ -382,19 +372,7 @@ function buildFairwayRouteCandidate(
const departureTimestamp = requestedDepartureTimestamp(request.departureTime); const departureTimestamp = requestedDepartureTimestamp(request.departureTime);
const durationMinutes = Math.round((distanceNm / speedKn) * 60); const durationMinutes = Math.round((distanceNm / speedKn) * 60);
const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString(); const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString();
const depthSamples = request.depthSamples ?? edgeDepthSamples(usedEdges);
const depthAssessment = assessFairwayDepthSamples(depthSamples, request.vesselProfile);
const warnings: RouteWarning[] = [ 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 ...(searchTruncated
? [ ? [
{ {
@@ -406,8 +384,7 @@ function buildFairwayRouteCandidate(
} }
] ]
: []), : []),
...routeSnapWarnings(routeSnaps), ...routeSnapWarnings(routeSnaps)
...depthAssessment.warnings
]; ];
const route: RouteResult = { const route: RouteResult = {
@@ -420,16 +397,9 @@ function buildFairwayRouteCandidate(
departureTime: new Date(departureTimestamp).toISOString(), departureTime: new Date(departureTimestamp).toISOString(),
durationMinutes, durationMinutes,
warnings, warnings,
minKnownDepthM: depthAssessment.minKnownDepthM,
unknownDepthRatio: depthAssessment.unknownDepthRatio,
dataSources: [ dataSources: [
`fairway-graph:${routableGraph.id}`, `fairway-graph:${routableGraph.id}`,
...uniqueSources(usedEdges), ...uniqueSources(usedEdges)
request.depthSamples?.length
? "submitted-depth-samples"
: usedEdges.some((edge) => edge.minDepthM === null)
? "fairway-depth-unknown"
: "fairway-depth-estimates"
], ],
routingMode: "fairway", routingMode: "fairway",
routeSnaps routeSnaps
@@ -620,14 +590,10 @@ export function mergeFairwayGraphs(id: string, name: string, graphs: FairwayGrap
} }
function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): FairwayGraph { function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): FairwayGraph {
const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM;
return { return {
...graph, ...graph,
maxSnapDistanceNm: Math.min(graph.maxSnapDistanceNm, MAX_ROUTE_SNAP_DISTANCE_NM), maxSnapDistanceNm: Math.min(graph.maxSnapDistanceNm, MAX_ROUTE_SNAP_DISTANCE_NM),
edges: graph.edges.filter((edge) => { edges: graph.edges.filter((edge) => {
if (edge.minDepthM !== null && edge.minDepthM < requiredDepthM) {
return false;
}
if (edge.maxDraughtM != null && request.vesselProfile.draughtM > edge.maxDraughtM) { if (edge.maxDraughtM != null && request.vesselProfile.draughtM > edge.maxDraughtM) {
return false; return false;
} }
@@ -1099,13 +1065,6 @@ function edgeLengthNm(edge: FairwayEdge) {
return sumRouteDistanceNm(edge.coordinates); 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[]) { function uniqueSources(edges: FairwayEdge[]) {
const sources = new Set<string>(); const sources = new Set<string>();
for (const edge of edges) { for (const edge of edges) {
@@ -1114,47 +1073,6 @@ function uniqueSources(edges: FairwayEdge[]) {
return [...sources]; 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) { function appendCoordinate(points: Coordinate[], coordinate: Coordinate) {
const previous = points.at(-1); const previous = points.at(-1);
if (previous && haversineDistanceNm(previous, coordinate) < 0.000001) { 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.622597, lon: 7.3071821 },
{ lat: 52.6170897, lon: 7.3077526 } { lat: 52.6170897, lon: 7.3077526 }
], ],
minDepthM: null,
maxDraughtM: 2.5, maxDraughtM: 2.5,
source: "openstreetmap-geofabrik-curated-seed" source: "openstreetmap-geofabrik-curated-seed"
}, },
@@ -454,7 +453,6 @@ export const EMDEN_HAMM_GRAPH: FairwayGraph = {
{ lat: 51.6572451, lon: 7.362679 }, { lat: 51.6572451, lon: 7.362679 },
{ lat: 51.6480728, lon: 7.3527065 } { lat: 51.6480728, lon: 7.3527065 }
], ],
minDepthM: null,
maxDraughtM: 2.5, maxDraughtM: 2.5,
source: "openstreetmap-nominatim-curated-seed" source: "openstreetmap-nominatim-curated-seed"
}, },
@@ -511,7 +509,6 @@ export const EMDEN_HAMM_GRAPH: FairwayGraph = {
{ lat: 51.6792115, lon: 7.9600807 }, { lat: 51.6792115, lon: 7.9600807 },
{ lat: 51.677629, lon: 7.9640468 } { lat: 51.677629, lon: 7.9640468 }
], ],
minDepthM: null,
maxDraughtM: 2.5, maxDraughtM: 2.5,
source: "openstreetmap-nominatim-curated-seed" source: "openstreetmap-nominatim-curated-seed"
} }
@@ -537,7 +534,6 @@ export const EMDEN_EAST_EMS_GRAPH: FairwayGraph = {
name: "Unterems östlich von Emden", name: "Unterems östlich von Emden",
from: "emden-east-start", from: "emden-east-start",
to: "emden-east-destination", to: "emden-east-destination",
minDepthM: null,
source: "openstreetmap-geofabrik-curated-seed", source: "openstreetmap-geofabrik-curated-seed",
coordinates: [ coordinates: [
{ lat: 53.3422, lon: 7.1871 }, { lat: 53.3422, lon: 7.1871 },
+3 -74
View File
@@ -6,11 +6,8 @@ import {
} from "./fairway-routing.js"; } from "./fairway-routing.js";
import { EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH } from "./inland-seed.js"; import { EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH } from "./inland-seed.js";
import type { import type {
DepthSample,
RouteRequest, RouteRequest,
RouteResult, RouteResult
RouteWarning,
VesselProfile
} from "./types.js"; } from "./types.js";
const DEFAULT_CRUISE_SPEED_KN = 6; 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 { export function buildManualRoute(request: RouteRequest): RouteResult {
const points = [ const points = [
request.start, request.start,
@@ -116,8 +53,6 @@ export function buildManualRoute(request: RouteRequest): RouteResult {
const departureTimestamp = Number.isFinite(parsedDeparture) ? parsedDeparture : Date.now(); const departureTimestamp = Number.isFinite(parsedDeparture) ? parsedDeparture : Date.now();
const durationMinutes = Math.round((distanceNm / speedKn) * 60); const durationMinutes = Math.round((distanceNm / speedKn) * 60);
const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString(); const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString();
const depthAssessment = assessDepthSamples(request.depthSamples, request.vesselProfile);
return { return {
geometry: { geometry: {
type: "LineString", type: "LineString",
@@ -132,15 +67,9 @@ export function buildManualRoute(request: RouteRequest): RouteResult {
code: "MANUAL_ROUTE", code: "MANUAL_ROUTE",
severity: "info", severity: "info",
message: "MVP-Route nutzt direkte Wegpunktsegmente ohne automatische Fahrwasserlogik." 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" routingMode: "manual"
}; };
} }
-8
View File
@@ -70,8 +70,6 @@ type RouteDetails = {
departureTime?: string; departureTime?: string;
durationMinutes?: number; durationMinutes?: number;
warnings: RouteWarning[]; warnings: RouteWarning[];
minKnownDepthM: number | null;
unknownDepthRatio: number;
dataSources: string[]; dataSources: string[];
routingMode?: "manual" | "fairway"; routingMode?: "manual" | "fairway";
routeSnaps?: RouteSnaps; routeSnaps?: RouteSnaps;
@@ -128,18 +126,12 @@ export type TideSummary = {
updatedAt: string; updatedAt: string;
}; };
export type DepthSample = {
coordinate: Coordinate;
depthM: number | null;
};
export type RouteRequest = { export type RouteRequest = {
start: Coordinate; start: Coordinate;
destination: Coordinate; destination: Coordinate;
waypoints?: Coordinate[]; waypoints?: Coordinate[];
departureTime?: string; departureTime?: string;
vesselProfile: VesselProfile; vesselProfile: VesselProfile;
depthSamples?: DepthSample[];
}; };
export type AppConfig = { export type AppConfig = {
@@ -268,8 +268,6 @@ describe("route guidance", () => {
distanceNm: 1, distanceNm: 1,
eta: null, eta: null,
warnings: [], warnings: [],
minKnownDepthM: null,
unknownDepthRatio: 1,
dataSources: [] dataSources: []
}; };
+25 -24
View File
@@ -6,7 +6,6 @@ import {
buildRoute, buildRoute,
FairwayRoutingSearchLimitError, FairwayRoutingSearchLimitError,
haversineDistanceNm, haversineDistanceNm,
requiredDepthM,
type FairwayEdge, type FairwayEdge,
type FairwayGraph type FairwayGraph
} from "../src/index.js"; } from "../src/index.js";
@@ -14,6 +13,14 @@ import {
const EMDEN_AUSSENHAFEN = { lat: 53.344167, lon: 7.186111 }; const EMDEN_AUSSENHAFEN = { lat: 53.344167, lon: 7.186111 };
const BORKUM_REEDE = { lat: 53.563776, lon: 6.750562 }; const BORKUM_REEDE = { lat: 53.563776, lon: 6.750562 };
const HAMM_INNENSTADT_MARINA = { lat: 51.6814536, lon: 7.8042615 }; 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 = { const ALTERNATIVE_GRAPH: FairwayGraph = {
id: "alternative-test", id: "alternative-test",
@@ -92,7 +99,6 @@ function edge(
from, from,
to, to,
coordinates, coordinates,
minDepthM: 4,
source: "synthetic-test", source: "synthetic-test",
...restrictions ...restrictions
}; };
@@ -119,32 +125,17 @@ function singleEdgeGraph(restrictions: Partial<FairwayEdge>): FairwayGraph {
}; };
} }
describe("route assessment", () => { describe("route building", () => {
it("marks routes with no depth data as unknown", () => { it("builds manual routes without synthesizing a depth status", () => {
const result = buildManualRoute({ const result = buildManualRoute({
start: { lat: 54.18, lon: 12.08 }, start: { lat: 54.18, lon: 12.08 },
destination: { lat: 54.32, lon: 12.22 }, destination: { lat: 54.32, lon: 12.22 },
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 } vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
}); });
expect(result.unknownDepthRatio).toBe(1); expect(result.warnings.map((warning) => warning.code)).toEqual(["MANUAL_ROUTE"]);
expect(result.warnings.some((warning) => warning.code === "DEPTH_UNKNOWN")).toBe(true); expect(result).not.toHaveProperty("minKnownDepthM");
}); expect(result).not.toHaveProperty("unknownDepthRatio");
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);
}); });
it("routes Emden Außenhafen to Borkum Reede along the Ems fairway graph", () => { 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.routingMode).toBe("fairway");
expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed"); 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) => 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(coordinates.length).toBeGreaterThan(20);
expect(result.distanceNm).toBeGreaterThan(directDistanceNm * 1.2); expect(result.distanceNm).toBeGreaterThan(directDistanceNm * 1.2);
expect(largestSegmentNm).toBeLessThan(6); 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("fairway-graph:emden-hamm-inland-seed");
expect(result?.dataSources).toContain("openstreetmap-geofabrik-curated-seed"); expect(result?.dataSources).toContain("openstreetmap-geofabrik-curated-seed");
expect(result?.dataSources).toContain("openstreetmap-nominatim-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", () => { it("uses the requested departure time as the basis for duration and ETA", () => {
-5
View File
@@ -37,7 +37,6 @@ KEPT_TAGS = {
"access", "access",
"boat", "boat",
"construction", "construction",
"depth",
"disused", "disused",
"maxdraft", "maxdraft",
"maxdraught", "maxdraught",
@@ -45,7 +44,6 @@ KEPT_TAGS = {
"maxheight:physical", "maxheight:physical",
"maxwidth", "maxwidth",
"maxwidth:physical", "maxwidth:physical",
"min_depth",
"motor_vehicle", "motor_vehicle",
"motorboat", "motorboat",
"name", "name",
@@ -55,10 +53,7 @@ KEPT_TAGS = {
"route", "route",
"seamark:bridge:clearance_height", "seamark:bridge:clearance_height",
"seamark:bridge:clearance_height_safe", "seamark:bridge:clearance_height_safe",
"seamark:fairway:minimum_depth",
"seamark:lock:chamber_width", "seamark:lock:chamber_width",
"seamark:navigation_line:minimum_depth",
"seamark:recommended_track:minimum_depth",
"seamark:restriction:max_draught", "seamark:restriction:max_draught",
"seamark:type", "seamark:type",
"ship", "ship",
+20 -1
View File
@@ -68,7 +68,18 @@ class BuildLocalFairwaysTest(unittest.TestCase):
(3, 7.2, 53.2), (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, 200,
1, 1,
@@ -143,6 +154,14 @@ class BuildLocalFairwaysTest(unittest.TestCase):
self.assertEqual(shared_way["tags"]["name"], "Nieuwe lijn") self.assertEqual(shared_way["tags"]["name"], "Nieuwe lijn")
self.assertEqual(len(shared_way["coordinates"]), 3) 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( cross_border_way = next(
way for way in document["ways"] if way["id"] == "500" way for way in document["ways"] if way["id"] == "500"
) )