diff --git a/README.md b/README.md index ca893d2..30456a5 100644 --- a/README.md +++ b/README.md @@ -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 Emden–Borkum und Emden–Hamm 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 Emden–Hamm-Seed besitzt keine belastbaren Tiefen- oder -Schleusenzeitdaten und ist bewusst auf Sportboote bis 2,5 m Tiefgang begrenzt. +zeichnen. Der Emden–Hamm-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/) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 96848f7..b0269c2 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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; const coordinateQuerySchema = z.object({ diff --git a/apps/api/src/services/fairways.ts b/apps/api/src/services/fairways.ts index b3aef73..a93ac57 100644 --- a/apps/api/src/services/fairways.ts +++ b/apps/api/src/services/fairways.ts @@ -54,7 +54,6 @@ export type FairwayRow = { source: string; source_id: string | null; name: string | null; - min_depth_m: string | number | null; properties?: Record | 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, coordinate: Coordinate) { return id; } -function parseDepth(tags: Record) { - 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) { return { maxAirDraftM: firstNumericTag(tags, [ diff --git a/apps/api/tests/api.test.ts b/apps/api/tests/api.test.ts index 3b548e7..36bb0b3 100644 --- a/apps/api/tests/api.test.ts +++ b/apps/api/tests/api.test.ts @@ -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({ diff --git a/apps/api/tests/fairways.test.ts b/apps/api/tests/fairways.test.ts index 556c746..544144e 100644 --- a/apps/api/tests/fairways.test.ts +++ b/apps/api/tests/fairways.test.ts @@ -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", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 45810c6..e49c3be 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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} /> diff --git a/apps/web/src/components/BoatProfilePanel.tsx b/apps/web/src/components/BoatProfilePanel.tsx index 4d06441..6d90242 100644 --- a/apps/web/src/components/BoatProfilePanel.tsx +++ b/apps/web/src/components/BoatProfilePanel.tsx @@ -190,7 +190,7 @@ export function BoatProfilePanel({ Abmessungen und Fahrtwerte

Bitte die Werte für den normalen Fahrzustand eintragen. Sie - beeinflussen Durchfahrt, Tiefenprüfung und Fahrtdauer. + beeinflussen bekannte Durchfahrtsbeschränkungen und Fahrtdauer.

@@ -232,7 +232,7 @@ export function BoatProfilePanel({ /> 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({ + +
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({ {result.routingMode === "fairway" ? "Fahrwasser" - : result.unknownDepthRatio > 0 - ? `${Math.round(result.unknownDepthRatio * 100)}% unbekannt` - : "Tiefe OK"} + : "Direkte Wegpunkte"} {(weatherReport?.adjustedEta ?? result.eta) && ( @@ -578,52 +583,7 @@ export function RoutePlanner({ )} - {(error || criticalWarnings.length > 0) && ( -
- Kritische Hinweise - {error && ( -

-

- )} - {criticalWarnings.map((warning, index) => ( -

-

- ))} -
- )} - - {otherWarnings.length > 0 && ( -
- Weitere Hinweise - {otherWarnings.slice(0, 3).map((warning, index) => ( -

-

- ))} - {otherWarnings.length > 3 && ( -
- {otherWarnings.length - 3} weitere Hinweise -
- {otherWarnings.slice(3).map((warning, index) => ( -

-

- ))} -
-
- )} -
- )} + {routeOptions.length > 1 && (
diff --git a/apps/web/src/components/RouteTidePanel.tsx b/apps/web/src/components/RouteTidePanel.tsx index a801fc8..35add5a 100644 --- a/apps/web/src/components/RouteTidePanel.tsx +++ b/apps/web/src/components/RouteTidePanel.tsx @@ -37,7 +37,10 @@ export function RouteTidePanel({ plan, loading, error }: RouteTidePanelProps) {
)} - Stationsabstand und Bezugsnull beachten; Wasserstände ersetzen keine amtliche Tiefenprüfung. + + Stationsabstand und Bezugsnull beachten; angezeigte Wasserstände geben keine befahrbare + Wassertiefe an. + ); } diff --git a/apps/web/src/components/RouteWarnings.tsx b/apps/web/src/components/RouteWarnings.tsx new file mode 100644 index 0000000..7031151 --- /dev/null +++ b/apps/web/src/components/RouteWarnings.tsx @@ -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) && ( +
+ {openWarnings.length > 0 && Hinweise ({openWarnings.length})} + {(error || criticalWarnings.length > 0) && ( +
+ Kritische Hinweise + {error && ( +

+

+ )} + {criticalWarnings.map((warning, index) => ( +

+

+ ))} +
+ )} + {cautionWarnings.map((warning, index) => ( +

+

+ ))} +
+ )} + + {informationalWarnings.length > 0 && ( +
+ Routingdetails ({informationalWarnings.length}) +
+ {informationalWarnings.map((warning, index) => ( +

+

+ ))} +
+
+ )} + + ); +} diff --git a/apps/web/src/lib/gpx.ts b/apps/web/src/lib/gpx.ts index e68ca88..1ac32e6 100644 --- a/apps/web/src/lib/gpx.ts +++ b/apps/web/src/lib/gpx.ts @@ -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); diff --git a/apps/web/src/lib/offline-route.ts b/apps/web/src/lib/offline-route.ts index bd9991d..80d3486 100644 --- a/apps/web/src/lib/offline-route.ts +++ b/apps/web/src/lib/offline-route.ts @@ -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[] { diff --git a/apps/web/src/lib/route-warnings.ts b/apps/web/src/lib/route-warnings.ts new file mode 100644 index 0000000..6823fce --- /dev/null +++ b/apps/web/src/lib/route-warnings.ts @@ -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"; +} diff --git a/apps/web/tests/course-assistant-hook.test.tsx b/apps/web/tests/course-assistant-hook.test.tsx index 540eb4d..ef742a0 100644 --- a/apps/web/tests/course-assistant-hook.test.tsx +++ b/apps/web/tests/course-assistant-hook.test.tsx @@ -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" }; diff --git a/apps/web/tests/course-assistant-panel.test.tsx b/apps/web/tests/course-assistant-panel.test.tsx index dd8ace9..e6b4286 100644 --- a/apps/web/tests/course-assistant-panel.test.tsx +++ b/apps/web/tests/course-assistant-panel.test.tsx @@ -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( + + ); + + 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(); + }); }); diff --git a/apps/web/tests/e2e/app.spec.ts b/apps/web/tests/e2e/app.spec.ts index b7def37..9c519a0 100644 --- a/apps/web/tests/e2e/app.spec.ts +++ b/apps/web/tests/e2e/app.spec.ts @@ -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" }) diff --git a/apps/web/tests/gpx.test.ts b/apps/web/tests/gpx.test.ts index f807191..bbf6f3c 100644 --- a/apps/web/tests/gpx.test.ts +++ b/apps/web/tests/gpx.test.ts @@ -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 "], routingMode: "fairway" }; diff --git a/apps/web/tests/map-view.test.tsx b/apps/web/tests/map-view.test.tsx index 6c4c5f2..a8faa3c 100644 --- a/apps/web/tests/map-view.test.tsx +++ b/apps/web/tests/map-view.test.tsx @@ -530,8 +530,6 @@ describe("MapView marine feature information", () => { distanceNm: 120, eta: null, warnings: [], - minKnownDepthM: null, - unknownDepthRatio: 1, dataSources: [], routingMode: "fairway" }; diff --git a/apps/web/tests/navigation-workspace.test.tsx b/apps/web/tests/navigation-workspace.test.tsx index 09ca4e8..37391ee 100644 --- a/apps/web/tests/navigation-workspace.test.tsx +++ b/apps/web/tests/navigation-workspace.test.tsx @@ -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"); }); diff --git a/apps/web/tests/offline-route.test.ts b/apps/web/tests/offline-route.test.ts index 838136f..bc60634 100644 --- a/apps/web/tests/offline-route.test.ts +++ b/apps/web/tests/offline-route.test.ts @@ -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"] }] }; diff --git a/apps/web/tests/route-events.test.ts b/apps/web/tests/route-events.test.ts index 77c72f6..ed639f6 100644 --- a/apps/web/tests/route-events.test.ts +++ b/apps/web/tests/route-events.test.ts @@ -22,8 +22,6 @@ const route: RouteResult = { distanceNm: 60, eta: null, warnings: [], - minKnownDepthM: null, - unknownDepthRatio: 1, dataSources: ["test"] }; diff --git a/apps/web/tests/route-planner.test.tsx b/apps/web/tests/route-planner.test.tsx index b58621d..1fe5d4c 100644 --- a/apps/web/tests/route-planner.test.tsx +++ b/apps/web/tests/route-planner.test.tsx @@ -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( + + ); + + 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( { 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 }; diff --git a/apps/web/tests/route-warnings.test.ts b/apps/web/tests/route-warnings.test.ts new file mode 100644 index 0000000..7196395 --- /dev/null +++ b/apps/web/tests/route-warnings.test.ts @@ -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); + }); +}); diff --git a/apps/web/tests/route-weather-report.test.ts b/apps/web/tests/route-weather-report.test.ts index 7e7b566..0c1085d 100644 --- a/apps/web/tests/route-weather-report.test.ts +++ b/apps/web/tests/route-weather-report.test.ts @@ -148,8 +148,6 @@ const routeFixture: RouteResult = { distanceNm: 25, eta: null, warnings: [], - minKnownDepthM: null, - unknownDepthRatio: 1, dataSources: [], routingMode: "fairway" }; diff --git a/apps/web/tests/voyage-navigation-tools.test.tsx b/apps/web/tests/voyage-navigation-tools.test.tsx index 5c0af4e..37472f8 100644 --- a/apps/web/tests/voyage-navigation-tools.test.tsx +++ b/apps/web/tests/voyage-navigation-tools.test.tsx @@ -126,8 +126,6 @@ const routeFixture: RouteResult = { distanceNm: 0.4, eta: null, warnings: [], - minKnownDepthM: null, - unknownDepthRatio: 1, dataSources: ["Test"], routingMode: "fairway" }; diff --git a/packages/shared/src/fairway-routing.ts b/packages/shared/src/fairway-routing.ts index 1985468..2955474 100644 --- a/packages/shared/src/fairway-routing.ts +++ b/packages/shared/src/fairway-routing.ts @@ -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(); 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) { diff --git a/packages/shared/src/inland-seed.ts b/packages/shared/src/inland-seed.ts index db88561..9d8a17d 100644 --- a/packages/shared/src/inland-seed.ts +++ b/packages/shared/src/inland-seed.ts @@ -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 }, diff --git a/packages/shared/src/route.ts b/packages/shared/src/route.ts index 5592e27..5effd81 100644 --- a/packages/shared/src/route.ts +++ b/packages/shared/src/route.ts @@ -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" }; } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8f8d342..419e3bf 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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 = { diff --git a/packages/shared/tests/route-guidance.test.ts b/packages/shared/tests/route-guidance.test.ts index 8bd3802..1578c09 100644 --- a/packages/shared/tests/route-guidance.test.ts +++ b/packages/shared/tests/route-guidance.test.ts @@ -268,8 +268,6 @@ describe("route guidance", () => { distanceNm: 1, eta: null, warnings: [], - minKnownDepthM: null, - unknownDepthRatio: 1, dataSources: [] }; diff --git a/packages/shared/tests/route.test.ts b/packages/shared/tests/route.test.ts index 4137dc0..463a833 100644 --- a/packages/shared/tests/route.test.ts +++ b/packages/shared/tests/route.test.ts @@ -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): 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", () => { diff --git a/scripts/build-local-fairways.py b/scripts/build-local-fairways.py index f8eb145..c7a8dea 100755 --- a/scripts/build-local-fairways.py +++ b/scripts/build-local-fairways.py @@ -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", diff --git a/scripts/tests/test_build_local_fairways.py b/scripts/tests/test_build_local_fairways.py index a0db137..5bf0fa9 100644 --- a/scripts/tests/test_build_local_fairways.py +++ b/scripts/tests/test_build_local_fairways.py @@ -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" )