Hinweise optimiert
This commit is contained in:
@@ -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 Emden–Borkum und Emden–Hamm nach manueller Auswahl von Start und Ziel
|
- 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
|
- 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 Emden–Hamm-Seed besitzt keine belastbaren Tiefen- oder
|
zeichnen. Der Emden–Hamm-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/)
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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, [
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,7 +317,8 @@ 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
|
||||||
|
.map((warning): RouteWarning => {
|
||||||
if (!isRecord(warning)) {
|
if (!isRecord(warning)) {
|
||||||
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
|
throw new OfflineVoyageStorageError("Eine Routenwarnung ist ungültig.");
|
||||||
}
|
}
|
||||||
@@ -336,7 +334,8 @@ function normalizeWarnings(value: unknown): RouteWarning[] {
|
|||||||
severity,
|
severity,
|
||||||
...(warning.coordinate === undefined ? {} : { coordinate: normalizeCoordinate(warning.coordinate) })
|
...(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[] {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"]
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user