Optimized routing
This commit is contained in:
+2
-1
@@ -2,7 +2,8 @@ PORT=5174
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass
|
DATABASE_URL=postgres://seacompass:seacompass@localhost:55432/seacompass
|
||||||
REDIS_URL=redis://localhost:6379
|
REDIS_URL=redis://localhost:6379
|
||||||
WATERMAPS_DEMO_DATA=true
|
# Nur für bewusst grobe Demo-Marker und Seed-Routen auf true setzen.
|
||||||
|
WATERMAPS_DEMO_DATA=false
|
||||||
WATERMAPS_LOCAL_FAIRWAYS_PATH=data/local/germany-netherlands-fairways.json
|
WATERMAPS_LOCAL_FAIRWAYS_PATH=data/local/germany-netherlands-fairways.json
|
||||||
WATERMAPS_LIVE_FAIRWAYS=false
|
WATERMAPS_LIVE_FAIRWAYS=false
|
||||||
|
|
||||||
|
|||||||
@@ -163,14 +163,38 @@ 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.
|
||||||
|
|
||||||
Wenn freie Laufzeitdaten fehlen, stehen zwei klar als nicht amtlich markierte Fallback-Korridore bereit:
|
Nur im ausdrücklich aktivierten Demo-Modus
|
||||||
|
`WATERMAPS_DEMO_DATA=true` stehen drei klar als nicht amtlich markierte
|
||||||
|
Seed-Korridore bereit:
|
||||||
|
|
||||||
- Emden Außenhafen → Borkum Reede
|
- Emden Außenhafen → Borkum Reede
|
||||||
|
- Emden Außenhafen → östliche Unterems
|
||||||
- Emden Außenhafen → Ems/Dortmund-Ems-Kanal → Datteln → Datteln-Hamm-Kanal → Wasserwanderrastplatz Hamm-Innenstadt (rund 153 sm)
|
- Emden Außenhafen → Ems/Dortmund-Ems-Kanal → Datteln → Datteln-Hamm-Kanal → Wasserwanderrastplatz Hamm-Innenstadt (rund 153 sm)
|
||||||
|
|
||||||
Der Emden–Hamm-Fallback besitzt keine belastbaren Tiefen- oder Schleusenzeitdaten und ist bewusst auf Sportboote bis 2,5 m Tiefgang begrenzt. 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/) bereit.
|
Diese Seed-Geometrien dienen ausschließlich Entwicklung und Demonstration.
|
||||||
|
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.
|
||||||
|
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/)
|
||||||
|
bereit.
|
||||||
|
|
||||||
Wenn kein Graph passt, liefert die API bewusst `422 no_fairway_route`, damit keine irreführende Luftlinie als Bootsroute gezeichnet wird. `alternatives` im Ergebnis enthält bis zu zwei weitere, topologisch unterschiedliche Optionen.
|
Wenn eine verfügbare Routingquelle keinen passenden Graphen enthält, liefert
|
||||||
|
die API bewusst `422 no_fairway_route`, damit keine irreführende Luftlinie als
|
||||||
|
Bootsroute gezeichnet wird. Start, Zwischenziele und Ziel werden höchstens
|
||||||
|
150 m vom Graphen entfernt zugeordnet. Die ausgegebene Liniengeometrie
|
||||||
|
beginnt und endet am tatsächlichen Fahrwasser-Snap; eine ungeprüfte gerade
|
||||||
|
Zufahrt vom angeklickten Punkt wird nicht als befahrbare Route ausgegeben.
|
||||||
|
`routeSnaps` enthält dafür Originalpunkt, Snap-Punkt und Abstand. Ab 10 m
|
||||||
|
erscheint zusätzlich ein sichtbarer Hinweis. `alternatives` im Ergebnis
|
||||||
|
enthält bis zu zwei weitere, topologisch unterschiedliche Optionen. Wird das
|
||||||
|
feste Rechenbudget in einem außergewöhnlich dichten Fahrwassernetz ohne
|
||||||
|
belastbares Ergebnis erreicht, antwortet die API ausdrücklich mit
|
||||||
|
`422 fairway_search_limited`, statt fälschlich „keine Route“ zu behaupten.
|
||||||
|
|
||||||
## Kursassistent entlang einer Route
|
## Kursassistent entlang einer Route
|
||||||
|
|
||||||
|
|||||||
+116
-17
@@ -3,8 +3,10 @@ import Fastify, { type FastifyInstance } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import {
|
||||||
buildFairwayRoutes,
|
buildFairwayRoutes,
|
||||||
|
EMDEN_BORKUM_DEMO_GRAPH,
|
||||||
EMDEN_EAST_EMS_GRAPH,
|
EMDEN_EAST_EMS_GRAPH,
|
||||||
EMDEN_HAMM_GRAPH,
|
EMDEN_HAMM_GRAPH,
|
||||||
|
FairwayRoutingSearchLimitError,
|
||||||
type FairwayGraph,
|
type FairwayGraph,
|
||||||
type RouteOption,
|
type RouteOption,
|
||||||
type RouteRequest,
|
type RouteRequest,
|
||||||
@@ -13,7 +15,10 @@ import {
|
|||||||
import { loadEnv, type ApiEnv } from "./env.js";
|
import { loadEnv, type ApiEnv } from "./env.js";
|
||||||
import { createCache, type Cache } from "./services/cache.js";
|
import { createCache, type Cache } from "./services/cache.js";
|
||||||
import { appConfig } from "./services/config.js";
|
import { appConfig } from "./services/config.js";
|
||||||
import { FairwayService } from "./services/fairways.js";
|
import {
|
||||||
|
FairwayService,
|
||||||
|
type FairwayGraphLookup
|
||||||
|
} from "./services/fairways.js";
|
||||||
import { FeatureService } from "./services/features.js";
|
import { FeatureService } from "./services/features.js";
|
||||||
import { getNearestTideSummary } from "./services/tides.js";
|
import { getNearestTideSummary } from "./services/tides.js";
|
||||||
import { getMarineForecast } from "./services/weather.js";
|
import { getMarineForecast } from "./services/weather.js";
|
||||||
@@ -186,14 +191,45 @@ export async function buildServer(deps: AppDeps = {}): Promise<FastifyInstance>
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sourceError: unknown;
|
let sourceError: unknown;
|
||||||
const dynamicGraphs = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => {
|
const graphLookup = await fairwayService.getGraphsForRoute(parsed.data).catch((error) => {
|
||||||
sourceError = error;
|
sourceError = error;
|
||||||
app.log.warn({ error }, "fairway extraction failed");
|
app.log.warn({ error }, "fairway extraction failed");
|
||||||
return [];
|
return { graphs: [], failures: [] } satisfies FairwayGraphLookup;
|
||||||
});
|
});
|
||||||
const route = buildRouteFromGraphs(parsed.data, dynamicGraphs);
|
if (graphLookup.failures.length > 0) {
|
||||||
|
app.log.warn(
|
||||||
|
{
|
||||||
|
failures: graphLookup.failures.map(({ source, error }) => ({
|
||||||
|
source,
|
||||||
|
error
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
"one or more fairway sources failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let route: RouteResult | null;
|
||||||
|
try {
|
||||||
|
route = buildRouteFromGraphs(parsed.data, graphLookup.graphs, env.demoData);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FairwayRoutingSearchLimitError) {
|
||||||
|
if (sourceError || graphLookup.failures.length > 0) {
|
||||||
|
return reply.code(503).send({
|
||||||
|
error: "fairway_sources_unavailable",
|
||||||
|
message:
|
||||||
|
"Fahrwasserdaten sind momentan nicht verfügbar. Prüfe den lokalen Deutschland-/Niederlande-Index oder versuche es später erneut."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return reply.code(422).send({
|
||||||
|
error: "fairway_search_limited",
|
||||||
|
message:
|
||||||
|
"Das Fahrwassernetz ist an den gewählten Punkten zu dicht für eine verlässliche Routensuche. Setze Start, Ziel oder Zwischenziele eindeutiger auf das gewünschte Fahrwasser."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!route) {
|
if (!route) {
|
||||||
if (sourceError) {
|
if (sourceError || graphLookup.failures.length > 0) {
|
||||||
return reply.code(503).send({
|
return reply.code(503).send({
|
||||||
error: "fairway_sources_unavailable",
|
error: "fairway_sources_unavailable",
|
||||||
message:
|
message:
|
||||||
@@ -227,32 +263,95 @@ function commaSeparatedQuery(maxItems: number) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRouteFromGraphs(request: RouteRequest, graphs: FairwayGraph[]) {
|
function buildRouteFromGraphs(
|
||||||
|
request: RouteRequest,
|
||||||
|
graphs: FairwayGraph[],
|
||||||
|
allowSeedGraphs: boolean
|
||||||
|
) {
|
||||||
|
let searchWasLimited = false;
|
||||||
|
|
||||||
for (const graph of graphs) {
|
for (const graph of graphs) {
|
||||||
const routes = buildFairwayRoutes(request, graph);
|
let routes: RouteOption[];
|
||||||
|
try {
|
||||||
|
routes = buildFairwayRoutes(request, graph);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FairwayRoutingSearchLimitError) {
|
||||||
|
searchWasLimited = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (routes.length > 0) {
|
if (routes.length > 0) {
|
||||||
return routeResultWithAlternatives(routes);
|
return routeResultWithAlternatives(routes, searchWasLimited);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const graph of [undefined, EMDEN_EAST_EMS_GRAPH, EMDEN_HAMM_GRAPH] as const) {
|
if (!allowSeedGraphs) {
|
||||||
const routes = graph ? buildFairwayRoutes(request, graph) : buildFairwayRoutes(request);
|
if (searchWasLimited) {
|
||||||
if (routes.length > 0) {
|
throw new FairwayRoutingSearchLimitError();
|
||||||
return routeResultWithAlternatives(routes);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function routeResultWithAlternatives(routes: RouteOption[]): RouteResult {
|
for (const graph of [
|
||||||
|
EMDEN_BORKUM_DEMO_GRAPH,
|
||||||
|
EMDEN_EAST_EMS_GRAPH,
|
||||||
|
EMDEN_HAMM_GRAPH
|
||||||
|
]) {
|
||||||
|
let routes: RouteOption[];
|
||||||
|
try {
|
||||||
|
routes = buildFairwayRoutes(request, graph);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof FairwayRoutingSearchLimitError) {
|
||||||
|
searchWasLimited = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
if (routes.length > 0) {
|
||||||
|
return routeResultWithAlternatives(routes, searchWasLimited);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (searchWasLimited) {
|
||||||
|
throw new FairwayRoutingSearchLimitError();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeResultWithAlternatives(
|
||||||
|
routes: RouteOption[],
|
||||||
|
searchWasLimited = false
|
||||||
|
): RouteResult {
|
||||||
const [primary, ...alternatives] = routes;
|
const [primary, ...alternatives] = routes;
|
||||||
if (!primary) {
|
if (!primary) {
|
||||||
throw new Error("routeResultWithAlternatives requires at least one route");
|
throw new Error("routeResultWithAlternatives requires at least one route");
|
||||||
}
|
}
|
||||||
|
const annotate = (route: RouteOption): RouteOption => {
|
||||||
|
if (
|
||||||
|
!searchWasLimited ||
|
||||||
|
route.warnings.some((warning) => warning.code === "ROUTE_SEARCH_LIMITED")
|
||||||
|
) {
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
warnings: [
|
||||||
|
...route.warnings,
|
||||||
|
{
|
||||||
|
code: "ROUTE_SEARCH_LIMITED",
|
||||||
|
severity: "caution",
|
||||||
|
message:
|
||||||
|
"Eine dichtere Datenquelle überschritt das feste Suchbudget. " +
|
||||||
|
"Diese Route stammt aus einer vollständig geprüften Alternative, ist aber möglicherweise nicht graphübergreifend die kürzeste."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const annotatedPrimary = annotate(primary);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...primary,
|
...annotatedPrimary,
|
||||||
alternatives
|
alternatives: alternatives.map(annotate)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ export function loadEnv(env: NodeJS.ProcessEnv = process.env): ApiEnv {
|
|||||||
(env.NODE_ENV === "test"
|
(env.NODE_ENV === "test"
|
||||||
? undefined
|
? undefined
|
||||||
: "data/local/germany-netherlands-fairways.json"),
|
: "data/local/germany-netherlands-fairways.json"),
|
||||||
demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) !== "false",
|
demoData: (env.WATERMAPS_DEMO_DATA ?? env.SEA_COMPASS_DEMO_DATA) === "true",
|
||||||
liveOsmFairways:
|
liveOsmFairways:
|
||||||
(env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" &&
|
(env.WATERMAPS_LIVE_FAIRWAYS ?? env.SEA_COMPASS_LIVE_FAIRWAYS) !== "false" &&
|
||||||
env.NODE_ENV !== "test"
|
env.NODE_ENV !== "test"
|
||||||
|
|||||||
@@ -37,6 +37,18 @@ type FairwayDeps = {
|
|||||||
localDataPath?: string;
|
localDataPath?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FairwaySourceId = "configuration" | "postgis" | "local" | "live";
|
||||||
|
|
||||||
|
export type FairwaySourceFailure = {
|
||||||
|
source: FairwaySourceId;
|
||||||
|
error: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FairwayGraphLookup = {
|
||||||
|
graphs: FairwayGraph[];
|
||||||
|
failures: FairwaySourceFailure[];
|
||||||
|
};
|
||||||
|
|
||||||
export type FairwayRow = {
|
export type FairwayRow = {
|
||||||
id: string;
|
id: string;
|
||||||
source: string;
|
source: string;
|
||||||
@@ -56,9 +68,8 @@ const MAX_BBOX_SPAN_DEG = 3;
|
|||||||
const MAX_POSTGIS_BBOX_SPAN_DEG = 6;
|
const MAX_POSTGIS_BBOX_SPAN_DEG = 6;
|
||||||
const MAX_LOCAL_BBOX_SPAN_DEG = 15;
|
const MAX_LOCAL_BBOX_SPAN_DEG = 15;
|
||||||
const BBOX_MARGIN_DEG = 0.15;
|
const BBOX_MARGIN_DEG = 0.15;
|
||||||
const ENDPOINT_SNAP_DEG = 0.0001;
|
const ENDPOINT_SNAP_DEG = 0.000001;
|
||||||
const CONNECTOR_DISTANCE_NM = 0.08;
|
const MAX_FAIRWAY_SNAP_DISTANCE_NM = 150 / 1852;
|
||||||
const CONNECTOR_GRID_DEG = 0.003;
|
|
||||||
|
|
||||||
export class FairwayService {
|
export class FairwayService {
|
||||||
private readonly cache: Cache;
|
private readonly cache: Cache;
|
||||||
@@ -76,29 +87,58 @@ export class FairwayService {
|
|||||||
this.pool = deps.databaseUrl ? new pg.Pool({ connectionString: deps.databaseUrl }) : null;
|
this.pool = deps.databaseUrl ? new pg.Pool({ connectionString: deps.databaseUrl }) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getGraphsForRoute(request: RouteRequest): Promise<FairwayGraph[]> {
|
async getGraphsForRoute(request: RouteRequest): Promise<FairwayGraphLookup> {
|
||||||
const results = await Promise.allSettled([
|
if (!this.pool && !this.localDataPath && !this.liveEnabled) {
|
||||||
this.getPostgisGraphForRoute(request),
|
return {
|
||||||
this.getLocalGraphForRoute(request),
|
graphs: [],
|
||||||
this.getLiveGraphForRoute(request)
|
failures: [
|
||||||
]);
|
{
|
||||||
const graphs = results.flatMap((result) =>
|
source: "configuration",
|
||||||
result.status === "fulfilled" && result.value ? [result.value] : []
|
error: new Error("No fairway source is configured")
|
||||||
);
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (graphs.length === 0) {
|
const sources: Array<{
|
||||||
const failures = results.filter((result): result is PromiseRejectedResult => result.status === "rejected");
|
source: Exclude<FairwaySourceId, "configuration">;
|
||||||
if (failures.length > 0) {
|
load: () => Promise<FairwayGraph | null>;
|
||||||
throw new AggregateError(failures.map((failure) => failure.reason), "No fairway source produced a graph");
|
}> = [];
|
||||||
|
if (this.pool) {
|
||||||
|
sources.push({ source: "postgis", load: () => this.getPostgisGraphForRoute(request) });
|
||||||
|
}
|
||||||
|
if (this.localDataPath) {
|
||||||
|
sources.push({ source: "local", load: () => this.getLocalGraphForRoute(request) });
|
||||||
|
}
|
||||||
|
if (this.liveEnabled) {
|
||||||
|
sources.push({ source: "live", load: () => this.getLiveGraphForRoute(request) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(sources.map(({ load }) => load()));
|
||||||
|
const graphs: FairwayGraph[] = [];
|
||||||
|
const failures: FairwaySourceFailure[] = [];
|
||||||
|
for (const [index, result] of results.entries()) {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
if (result.value) {
|
||||||
|
graphs.push(result.value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
failures.push({
|
||||||
|
source: sources[index]!.source,
|
||||||
|
error: result.reason
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (graphs.length < 2) {
|
if (graphs.length < 2) {
|
||||||
return graphs;
|
return { graphs, failures };
|
||||||
}
|
}
|
||||||
|
|
||||||
const combined = mergeConnectedFairwayGraphs(graphs);
|
const combined = mergeConnectedFairwayGraphs(graphs);
|
||||||
return combined ? [combined, ...graphs] : graphs;
|
return {
|
||||||
|
graphs: combined ? [combined, ...graphs] : graphs,
|
||||||
|
failures
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
@@ -169,19 +209,18 @@ export class FairwayService {
|
|||||||
throw new Error(`Unsupported local fairway data format: ${this.localDataPath}`);
|
throw new Error(`Unsupported local fairway data format: ${this.localDataPath}`);
|
||||||
}
|
}
|
||||||
return document;
|
return document;
|
||||||
})
|
|
||||||
.catch((error: NodeJS.ErrnoException) => {
|
|
||||||
if (error.code === "ENOENT") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const document = await this.localDocument;
|
|
||||||
if (!document) {
|
const currentLoad = this.localDocument;
|
||||||
|
try {
|
||||||
|
return await currentLoad;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.localDocument === currentLoad) {
|
||||||
this.localDocument = null;
|
this.localDocument = null;
|
||||||
}
|
}
|
||||||
return document;
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getLiveGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
|
private async getLiveGraphForRoute(request: RouteRequest): Promise<FairwayGraph | null> {
|
||||||
@@ -386,7 +425,6 @@ export function mergeConnectedFairwayGraphs(graphs: FairwayGraph[]): FairwayGrap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addEndpointConnectors(nodes, edges);
|
|
||||||
if (edges.size === 0) {
|
if (edges.size === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -445,8 +483,6 @@ function waysToGraph(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addEndpointConnectors(nodes, edges);
|
|
||||||
|
|
||||||
if (edges.size === 0) {
|
if (edges.size === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -454,7 +490,7 @@ function waysToGraph(
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
maxSnapDistanceNm: 2,
|
maxSnapDistanceNm: MAX_FAIRWAY_SNAP_DISTANCE_NM,
|
||||||
nodes: [...nodes.values()],
|
nodes: [...nodes.values()],
|
||||||
edges: [...edges.values()]
|
edges: [...edges.values()]
|
||||||
};
|
};
|
||||||
@@ -543,65 +579,6 @@ function nodeIdFor(nodes: Map<string, FairwayNode>, coordinate: Coordinate) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addEndpointConnectors(nodes: Map<string, FairwayNode>, edges: Map<string, FairwayEdge>) {
|
|
||||||
const degree = new Map<string, number>();
|
|
||||||
for (const edge of edges.values()) {
|
|
||||||
degree.set(edge.from, (degree.get(edge.from) ?? 0) + 1);
|
|
||||||
degree.set(edge.to, (degree.get(edge.to) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
const endpoints = [...nodes.values()].filter((node) => (degree.get(node.id) ?? 0) <= 1);
|
|
||||||
const buckets = new Map<string, FairwayNode[]>();
|
|
||||||
|
|
||||||
for (const endpoint of endpoints) {
|
|
||||||
const [x, y] = connectorCell(endpoint.coordinate);
|
|
||||||
const key = `${x}:${y}`;
|
|
||||||
buckets.set(key, [...(buckets.get(key) ?? []), endpoint]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectorIds = new Set<string>();
|
|
||||||
|
|
||||||
for (const first of endpoints) {
|
|
||||||
const [cellX, cellY] = connectorCell(first.coordinate);
|
|
||||||
for (let dx = -1; dx <= 1; dx += 1) {
|
|
||||||
for (let dy = -1; dy <= 1; dy += 1) {
|
|
||||||
for (const second of buckets.get(`${cellX + dx}:${cellY + dy}`) ?? []) {
|
|
||||||
if (first.id >= second.id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const distanceNm = distanceApproxNm(first.coordinate, second.coordinate);
|
|
||||||
if (distanceNm === 0 || distanceNm > CONNECTOR_DISTANCE_NM) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectorId = `connector-${first.id}-${second.id}`;
|
|
||||||
if (connectorIds.has(connectorId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectorIds.add(connectorId);
|
|
||||||
edges.set(connectorId, {
|
|
||||||
id: connectorId,
|
|
||||||
name: "Fahrwasser-Verbindung",
|
|
||||||
from: first.id,
|
|
||||||
to: second.id,
|
|
||||||
coordinates: [first.coordinate, second.coordinate],
|
|
||||||
minDepthM: null,
|
|
||||||
source: "fairway-graph-connectors"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectorCell(coordinate: Coordinate) {
|
|
||||||
return [
|
|
||||||
Math.floor(coordinate.lon / CONNECTOR_GRID_DEG),
|
|
||||||
Math.floor(coordinate.lat / CONNECTOR_GRID_DEG)
|
|
||||||
] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseDepth(tags: Record<string, string>) {
|
function parseDepth(tags: Record<string, string>) {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
tags["seamark:fairway:minimum_depth"],
|
tags["seamark:fairway:minimum_depth"],
|
||||||
@@ -699,10 +676,3 @@ function sourceFor(tags: Record<string, string>) {
|
|||||||
}
|
}
|
||||||
return "osm-overpass";
|
return "osm-overpass";
|
||||||
}
|
}
|
||||||
|
|
||||||
function distanceApproxNm(a: Coordinate, b: Coordinate) {
|
|
||||||
const meanLatRad = ((a.lat + b.lat) / 2) * (Math.PI / 180);
|
|
||||||
const x = (a.lon - b.lon) * 60 * Math.cos(meanLatRad);
|
|
||||||
const y = (a.lat - b.lat) * 60;
|
|
||||||
return Math.sqrt(x * x + y * y);
|
|
||||||
}
|
|
||||||
|
|||||||
+276
-24
@@ -1,5 +1,7 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Coordinate, FairwayGraph } from "@watermaps/shared";
|
||||||
import { buildServer } from "../src/app.js";
|
import { buildServer } from "../src/app.js";
|
||||||
|
import { loadEnv } from "../src/env.js";
|
||||||
import { createCache } from "../src/services/cache.js";
|
import { createCache } from "../src/services/cache.js";
|
||||||
import type {
|
import type {
|
||||||
LockOperationInfo,
|
LockOperationInfo,
|
||||||
@@ -12,6 +14,79 @@ const jsonResponse = (body: unknown) =>
|
|||||||
headers: { "content-type": "application/json" }
|
headers: { "content-type": "application/json" }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const demoEnv = loadEnv({ NODE_ENV: "test", WATERMAPS_DEMO_DATA: "true" });
|
||||||
|
const productionEnv = loadEnv({ NODE_ENV: "test", WATERMAPS_DEMO_DATA: "false" });
|
||||||
|
|
||||||
|
const testFairwayGraph = (
|
||||||
|
start: Coordinate,
|
||||||
|
destination: Coordinate,
|
||||||
|
id = "test-fairway"
|
||||||
|
): FairwayGraph => ({
|
||||||
|
id,
|
||||||
|
name: "Test fairway",
|
||||||
|
maxSnapDistanceNm: 0.25,
|
||||||
|
nodes: [
|
||||||
|
{ id: `${id}-start`, coordinate: start },
|
||||||
|
{ id: `${id}-destination`, coordinate: destination }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{
|
||||||
|
id: `${id}-edge`,
|
||||||
|
name: "Test fairway edge",
|
||||||
|
from: `${id}-start`,
|
||||||
|
to: `${id}-destination`,
|
||||||
|
coordinates: [start, destination],
|
||||||
|
minDepthM: null,
|
||||||
|
source: "test-fairway-source"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const boundedSnapSearchFixture = () => {
|
||||||
|
const start = { lat: 52.00035, lon: 7.0005 };
|
||||||
|
const destination = { lat: 52, lon: 7.04 };
|
||||||
|
const trapCoordinates = Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
lat: start.lat + (index - 4) * 0.000001,
|
||||||
|
lon: start.lon
|
||||||
|
}));
|
||||||
|
const graph: FairwayGraph = {
|
||||||
|
id: "api-bounded-snap-search",
|
||||||
|
name: "API bounded snap search",
|
||||||
|
maxSnapDistanceNm: 1,
|
||||||
|
nodes: [
|
||||||
|
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
|
||||||
|
...trapCoordinates.map((coordinate, index) => ({
|
||||||
|
id: `trap-${index + 1}`,
|
||||||
|
coordinate
|
||||||
|
})),
|
||||||
|
{ id: "destination", coordinate: destination }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
...trapCoordinates.map((coordinate, index) => ({
|
||||||
|
id: `trap-${index + 1}`,
|
||||||
|
name: `Trap ${index + 1}`,
|
||||||
|
from: "junction",
|
||||||
|
to: `trap-${index + 1}`,
|
||||||
|
coordinates: [{ lat: 52, lon: 7 }, coordinate],
|
||||||
|
minDepthM: 4,
|
||||||
|
oneway: true as const,
|
||||||
|
source: `api-bounded-trap-${index + 1}`
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
id: "ninth-main-route",
|
||||||
|
name: "Ninth main route",
|
||||||
|
from: "junction",
|
||||||
|
to: "destination",
|
||||||
|
coordinates: [{ lat: 52, lon: 7 }, destination],
|
||||||
|
minDepthM: 4,
|
||||||
|
oneway: true as const,
|
||||||
|
source: "api-ninth-main-route"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
return { start, destination, graph };
|
||||||
|
};
|
||||||
|
|
||||||
describe("Watermaps API", () => {
|
describe("Watermaps API", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -146,7 +221,7 @@ describe("Watermaps API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns critical route warnings for shallow samples", async () => {
|
it("returns critical route warnings for shallow samples", async () => {
|
||||||
const app = await buildServer({ cache: createCache() });
|
const app = await buildServer({ cache: createCache(), env: demoEnv });
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/routes",
|
url: "/api/routes",
|
||||||
@@ -166,7 +241,16 @@ describe("Watermaps API", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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({ cache: createCache() });
|
const app = await buildServer({
|
||||||
|
cache: createCache(),
|
||||||
|
env: productionEnv,
|
||||||
|
fairwayService: {
|
||||||
|
async getGraphsForRoute() {
|
||||||
|
return { graphs: [], failures: [] };
|
||||||
|
},
|
||||||
|
async close() {}
|
||||||
|
}
|
||||||
|
});
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/routes",
|
url: "/api/routes",
|
||||||
@@ -187,6 +271,7 @@ describe("Watermaps API", () => {
|
|||||||
it("reports unavailable fairway sources instead of claiming that no route exists", async () => {
|
it("reports unavailable fairway sources instead of claiming that no route exists", async () => {
|
||||||
const app = await buildServer({
|
const app = await buildServer({
|
||||||
cache: createCache(),
|
cache: createCache(),
|
||||||
|
env: productionEnv,
|
||||||
fairwayService: {
|
fairwayService: {
|
||||||
async getGraphsForRoute() {
|
async getGraphsForRoute() {
|
||||||
throw new AggregateError([new Error("local data missing"), new Error("Overpass timeout")]);
|
throw new AggregateError([new Error("local data missing"), new Error("Overpass timeout")]);
|
||||||
@@ -198,8 +283,8 @@ describe("Watermaps API", () => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/routes",
|
url: "/api/routes",
|
||||||
payload: {
|
payload: {
|
||||||
start: { lat: 54.1749, lon: 12.0731 },
|
start: { lat: 53.4498, lon: 7.4509 },
|
||||||
destination: { lat: 54.1833, lon: 12.0928 },
|
destination: { lat: 53.4646, lon: 7.4742 },
|
||||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -209,8 +294,140 @@ describe("Watermaps API", () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports a bounded dense-graph search instead of claiming that no route exists", async () => {
|
||||||
|
const { start, destination, graph } = boundedSnapSearchFixture();
|
||||||
|
const app = await buildServer({
|
||||||
|
cache: createCache(),
|
||||||
|
env: productionEnv,
|
||||||
|
fairwayService: {
|
||||||
|
async getGraphsForRoute() {
|
||||||
|
return { graphs: [graph], failures: [] };
|
||||||
|
},
|
||||||
|
async close() {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/routes",
|
||||||
|
payload: {
|
||||||
|
start,
|
||||||
|
destination,
|
||||||
|
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(422);
|
||||||
|
expect(response.json().error).toBe("fairway_search_limited");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tries another available graph after one graph exhausts its search budget", async () => {
|
||||||
|
const { start, destination, graph } = boundedSnapSearchFixture();
|
||||||
|
const app = await buildServer({
|
||||||
|
cache: createCache(),
|
||||||
|
env: productionEnv,
|
||||||
|
fairwayService: {
|
||||||
|
async getGraphsForRoute() {
|
||||||
|
return {
|
||||||
|
graphs: [graph, testFairwayGraph(start, destination, "bounded-search-fallback")],
|
||||||
|
failures: []
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async close() {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/routes",
|
||||||
|
payload: {
|
||||||
|
start,
|
||||||
|
destination,
|
||||||
|
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().dataSources).toContain(
|
||||||
|
"fairway-graph:bounded-search-fallback"
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
response.json().warnings.some(
|
||||||
|
(warning: { code: string }) => warning.code === "ROUTE_SEARCH_LIMITED"
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 503 when a remaining graph cannot route and another source failed", async () => {
|
||||||
|
const app = await buildServer({
|
||||||
|
cache: createCache(),
|
||||||
|
env: productionEnv,
|
||||||
|
fairwayService: {
|
||||||
|
async getGraphsForRoute() {
|
||||||
|
return {
|
||||||
|
graphs: [
|
||||||
|
testFairwayGraph(
|
||||||
|
{ lat: 52, lon: 7 },
|
||||||
|
{ lat: 52.01, lon: 7.01 },
|
||||||
|
"unrelated-live-graph"
|
||||||
|
)
|
||||||
|
],
|
||||||
|
failures: [{ source: "local", error: new Error("full local index unavailable") }]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async close() {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/routes",
|
||||||
|
payload: {
|
||||||
|
start: { lat: 53.4498, lon: 7.4509 },
|
||||||
|
destination: { lat: 53.4646, lon: 7.4742 },
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(503);
|
||||||
|
expect(response.json().error).toBe("fairway_sources_unavailable");
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a routable graph even when another fairway source failed", async () => {
|
||||||
|
const start = { lat: 54, lon: 10 };
|
||||||
|
const destination = { lat: 54.04, lon: 10.1 };
|
||||||
|
const app = await buildServer({
|
||||||
|
cache: createCache(),
|
||||||
|
env: productionEnv,
|
||||||
|
fairwayService: {
|
||||||
|
async getGraphsForRoute() {
|
||||||
|
return {
|
||||||
|
graphs: [testFairwayGraph(start, destination, "available-live-graph")],
|
||||||
|
failures: [{ source: "local", error: new Error("full local index unavailable") }]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async close() {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/routes",
|
||||||
|
payload: {
|
||||||
|
start,
|
||||||
|
destination,
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().dataSources).toContain(
|
||||||
|
"fairway-graph:available-live-graph"
|
||||||
|
);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
|
it("returns a fairway route from Emden Außenhafen to Borkum Reede", async () => {
|
||||||
const app = await buildServer({ cache: createCache() });
|
const app = await buildServer({ cache: createCache(), env: demoEnv });
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/routes",
|
url: "/api/routes",
|
||||||
@@ -235,7 +452,8 @@ describe("Watermaps API", () => {
|
|||||||
cache: createCache(),
|
cache: createCache(),
|
||||||
fairwayService: {
|
fairwayService: {
|
||||||
async getGraphsForRoute() {
|
async getGraphsForRoute() {
|
||||||
return [
|
return {
|
||||||
|
graphs: [
|
||||||
{
|
{
|
||||||
id: "norddeich-norderney-test",
|
id: "norddeich-norderney-test",
|
||||||
name: "Norddeich–Norderney",
|
name: "Norddeich–Norderney",
|
||||||
@@ -272,7 +490,9 @@ describe("Watermaps API", () => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
],
|
||||||
|
failures: []
|
||||||
|
};
|
||||||
},
|
},
|
||||||
async close() {}
|
async close() {}
|
||||||
}
|
}
|
||||||
@@ -300,6 +520,7 @@ describe("Watermaps API", () => {
|
|||||||
it("routes from Emden into the eastern lower Ems when all dynamic sources fail", async () => {
|
it("routes from Emden into the eastern lower Ems when all dynamic sources fail", async () => {
|
||||||
const app = await buildServer({
|
const app = await buildServer({
|
||||||
cache: createCache(),
|
cache: createCache(),
|
||||||
|
env: demoEnv,
|
||||||
fairwayService: {
|
fairwayService: {
|
||||||
async getGraphsForRoute() {
|
async getGraphsForRoute() {
|
||||||
throw new AggregateError([new Error("PostGIS unavailable"), new Error("Overpass timeout")]);
|
throw new AggregateError([new Error("PostGIS unavailable"), new Error("Overpass timeout")]);
|
||||||
@@ -321,14 +542,21 @@ describe("Watermaps API", () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(body.routingMode).toBe("fairway");
|
expect(body.routingMode).toBe("fairway");
|
||||||
expect(body.dataSources).toContain("fairway-graph:emden-east-ems-seed");
|
expect(body.dataSources).toContain("fairway-graph:emden-east-ems-seed");
|
||||||
expect(body.geometry.coordinates[0]).toEqual([7.1871, 53.3422]);
|
expect(body.routeSnaps.start.requested).toEqual({ lat: 53.3422, lon: 7.1871 });
|
||||||
expect(body.geometry.coordinates.at(-1)?.[0]).toBeCloseTo(7.4734, 3);
|
expect(body.routeSnaps.destination.requested).toEqual({ lat: 53.465, lon: 7.4734 });
|
||||||
expect(body.geometry.coordinates.at(-1)?.[1]).toBeCloseTo(53.465, 3);
|
expect(body.geometry.coordinates[0]).toEqual([
|
||||||
|
body.routeSnaps.start.snapped.lon,
|
||||||
|
body.routeSnaps.start.snapped.lat
|
||||||
|
]);
|
||||||
|
expect(body.geometry.coordinates.at(-1)).toEqual([
|
||||||
|
body.routeSnaps.destination.snapped.lon,
|
||||||
|
body.routeSnaps.destination.snapped.lat
|
||||||
|
]);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns the inland fallback route from Emden to Hamm", async () => {
|
it("returns the inland fallback route from Emden to Hamm", async () => {
|
||||||
const app = await buildServer({ cache: createCache() });
|
const app = await buildServer({ cache: createCache(), env: demoEnv });
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/api/routes",
|
url: "/api/routes",
|
||||||
@@ -344,7 +572,14 @@ describe("Watermaps API", () => {
|
|||||||
expect(body.distanceNm).toBeGreaterThan(145);
|
expect(body.distanceNm).toBeGreaterThan(145);
|
||||||
expect(body.distanceNm).toBeLessThan(165);
|
expect(body.distanceNm).toBeLessThan(165);
|
||||||
expect(body.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
|
expect(body.dataSources).toContain("fairway-graph:emden-hamm-inland-seed");
|
||||||
expect(body.geometry.coordinates.at(-1)).toEqual([7.8042615, 51.6814536]);
|
expect(body.routeSnaps.destination.requested).toEqual({
|
||||||
|
lat: 51.6814536,
|
||||||
|
lon: 7.8042615
|
||||||
|
});
|
||||||
|
expect(body.geometry.coordinates.at(-1)).toEqual([
|
||||||
|
body.routeSnaps.destination.snapped.lon,
|
||||||
|
body.routeSnaps.destination.snapped.lat
|
||||||
|
]);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -353,7 +588,8 @@ describe("Watermaps API", () => {
|
|||||||
cache: createCache(),
|
cache: createCache(),
|
||||||
fairwayService: {
|
fairwayService: {
|
||||||
async getGraphsForRoute() {
|
async getGraphsForRoute() {
|
||||||
return [
|
return {
|
||||||
|
graphs: [
|
||||||
{
|
{
|
||||||
id: "test-extracted",
|
id: "test-extracted",
|
||||||
name: "Test Extracted Fairways",
|
name: "Test Extracted Fairways",
|
||||||
@@ -390,7 +626,9 @@ describe("Watermaps API", () => {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
],
|
||||||
|
failures: []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -418,7 +656,8 @@ describe("Watermaps API", () => {
|
|||||||
cache: createCache(),
|
cache: createCache(),
|
||||||
fairwayService: {
|
fairwayService: {
|
||||||
async getGraphsForRoute() {
|
async getGraphsForRoute() {
|
||||||
return [
|
return {
|
||||||
|
graphs: [
|
||||||
{
|
{
|
||||||
id: "local-geofabrik-component-snap",
|
id: "local-geofabrik-component-snap",
|
||||||
name: "Lokaler Geofabrik-Komponententest",
|
name: "Lokaler Geofabrik-Komponententest",
|
||||||
@@ -428,10 +667,10 @@ describe("Watermaps API", () => {
|
|||||||
{ id: "start-decoy-b", coordinate: coordinate(53.342, 7.187) },
|
{ id: "start-decoy-b", coordinate: coordinate(53.342, 7.187) },
|
||||||
{ id: "destination-decoy-a", coordinate: coordinate(53.3282, 6.9304) },
|
{ id: "destination-decoy-a", coordinate: coordinate(53.3282, 6.9304) },
|
||||||
{ id: "destination-decoy-b", coordinate: coordinate(53.3286, 6.9294) },
|
{ id: "destination-decoy-b", coordinate: coordinate(53.3286, 6.9294) },
|
||||||
{ id: "shared-start", coordinate: coordinate(53.3395697, 7.1848883) },
|
{ id: "shared-start", coordinate: coordinate(53.34145, 7.18585) },
|
||||||
{ id: "shared-east", coordinate: coordinate(53.3321722, 7.1329034) },
|
{ id: "shared-east", coordinate: coordinate(53.3321722, 7.1329034) },
|
||||||
{ id: "shared-south", coordinate: coordinate(53.313849, 7.0011017) },
|
{ id: "shared-south", coordinate: coordinate(53.313849, 7.0011017) },
|
||||||
{ id: "shared-destination", coordinate: coordinate(53.3303531, 6.9334715) }
|
{ id: "shared-destination", coordinate: coordinate(53.32805, 6.9302) }
|
||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
{
|
{
|
||||||
@@ -457,7 +696,7 @@ describe("Watermaps API", () => {
|
|||||||
name: "Gemeinsamer lokaler Korridor Ost",
|
name: "Gemeinsamer lokaler Korridor Ost",
|
||||||
from: "shared-start",
|
from: "shared-start",
|
||||||
to: "shared-east",
|
to: "shared-east",
|
||||||
coordinates: [coordinate(53.3395697, 7.1848883), coordinate(53.3321722, 7.1329034)],
|
coordinates: [coordinate(53.34145, 7.18585), coordinate(53.3321722, 7.1329034)],
|
||||||
minDepthM: null,
|
minDepthM: null,
|
||||||
source: "local-geofabrik-germany+netherlands"
|
source: "local-geofabrik-germany+netherlands"
|
||||||
},
|
},
|
||||||
@@ -475,13 +714,15 @@ describe("Watermaps API", () => {
|
|||||||
name: "Gemeinsamer lokaler Korridor West",
|
name: "Gemeinsamer lokaler Korridor West",
|
||||||
from: "shared-south",
|
from: "shared-south",
|
||||||
to: "shared-destination",
|
to: "shared-destination",
|
||||||
coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.3303531, 6.9334715)],
|
coordinates: [coordinate(53.313849, 7.0011017), coordinate(53.32805, 6.9302)],
|
||||||
minDepthM: null,
|
minDepthM: null,
|
||||||
source: "local-geofabrik-germany+netherlands"
|
source: "local-geofabrik-germany+netherlands"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
],
|
||||||
|
failures: []
|
||||||
|
};
|
||||||
},
|
},
|
||||||
async close() {}
|
async close() {}
|
||||||
}
|
}
|
||||||
@@ -499,8 +740,16 @@ describe("Watermaps API", () => {
|
|||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(body.routingMode).toBe("fairway");
|
expect(body.routingMode).toBe("fairway");
|
||||||
expect(body.geometry.coordinates[0]).toEqual([7.186, 53.3416]);
|
expect(body.geometry.coordinates[0]).toEqual([
|
||||||
expect(body.geometry.coordinates.at(-1)).toEqual([6.9304, 53.3282]);
|
body.routeSnaps.start.snapped.lon,
|
||||||
|
body.routeSnaps.start.snapped.lat
|
||||||
|
]);
|
||||||
|
expect(body.geometry.coordinates.at(-1)).toEqual([
|
||||||
|
body.routeSnaps.destination.snapped.lon,
|
||||||
|
body.routeSnaps.destination.snapped.lat
|
||||||
|
]);
|
||||||
|
expect(body.routeSnaps.start.requested).toEqual({ lat: 53.3416, lon: 7.186 });
|
||||||
|
expect(body.routeSnaps.destination.requested).toEqual({ lat: 53.3282, lon: 6.9304 });
|
||||||
expect(body.dataSources).toContain("local-geofabrik-germany+netherlands");
|
expect(body.dataSources).toContain("local-geofabrik-germany+netherlands");
|
||||||
expect(body.dataSources).not.toContain("fairway-graph:ems-borkum-seed");
|
expect(body.dataSources).not.toContain("fairway-graph:ems-borkum-seed");
|
||||||
expect(body.dataSources).not.toContain("closer-but-disconnected-start");
|
expect(body.dataSources).not.toContain("closer-but-disconnected-start");
|
||||||
@@ -523,7 +772,8 @@ describe("Watermaps API", () => {
|
|||||||
cache: createCache(),
|
cache: createCache(),
|
||||||
fairwayService: {
|
fairwayService: {
|
||||||
async getGraphsForRoute() {
|
async getGraphsForRoute() {
|
||||||
return [
|
return {
|
||||||
|
graphs: [
|
||||||
{
|
{
|
||||||
id: "api-alternatives",
|
id: "api-alternatives",
|
||||||
name: "API Alternativen",
|
name: "API Alternativen",
|
||||||
@@ -546,7 +796,9 @@ describe("Watermaps API", () => {
|
|||||||
edge("destination-access", "branch-out", "destination", [coordinate(52, 7.05), coordinate(52, 7.06)])
|
edge("destination-access", "branch-out", "destination", [coordinate(52, 7.05), coordinate(52, 7.06)])
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
];
|
],
|
||||||
|
failures: []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { loadEnv } from "../src/env.js";
|
||||||
|
|
||||||
|
describe("API environment", () => {
|
||||||
|
it("keeps demo data and seed routes disabled unless explicitly enabled", () => {
|
||||||
|
expect(loadEnv({ NODE_ENV: "production" }).demoData).toBe(false);
|
||||||
|
expect(loadEnv({ NODE_ENV: "test" }).demoData).toBe(false);
|
||||||
|
expect(
|
||||||
|
loadEnv({ NODE_ENV: "production", WATERMAPS_DEMO_DATA: "false" }).demoData
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts the current and legacy explicit demo switches", () => {
|
||||||
|
expect(
|
||||||
|
loadEnv({ NODE_ENV: "production", WATERMAPS_DEMO_DATA: "true" }).demoData
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
loadEnv({ NODE_ENV: "production", SEA_COMPASS_DEMO_DATA: "true" }).demoData
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { buildRoute } from "@watermaps/shared";
|
import { buildRoute, haversineDistanceNm } from "@watermaps/shared";
|
||||||
import { createCache } from "../src/services/cache.js";
|
import { createCache } from "../src/services/cache.js";
|
||||||
import {
|
import {
|
||||||
FairwayService,
|
FairwayService,
|
||||||
@@ -12,7 +12,180 @@ import {
|
|||||||
overpassToGraph
|
overpassToGraph
|
||||||
} from "../src/services/fairways.js";
|
} from "../src/services/fairways.js";
|
||||||
|
|
||||||
|
const EMDEN_CANAL_REGRESSION_WAYS: Parameters<typeof localFairwaysToGraph>[0] = [
|
||||||
|
{
|
||||||
|
id: "28021006-tail",
|
||||||
|
bbox: [7.4498965, 53.4492034, 7.4512561, 53.4500483],
|
||||||
|
tags: { name: "Ems-Jade-Kanal", width: "8", waterway: "canal" },
|
||||||
|
coordinates: [
|
||||||
|
[53.4492034, 7.4498965],
|
||||||
|
[53.4495624, 7.4505731],
|
||||||
|
[53.4499672, 7.4511607],
|
||||||
|
[53.4499875, 7.4511887],
|
||||||
|
[53.4500483, 7.4512561]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "278807710",
|
||||||
|
bbox: [7.4512561, 53.4500483, 7.4518959, 53.4504899],
|
||||||
|
tags: {
|
||||||
|
lock: "yes",
|
||||||
|
name: "Ems-Jade-Kanal",
|
||||||
|
width: "8",
|
||||||
|
waterway: "canal"
|
||||||
|
},
|
||||||
|
coordinates: [
|
||||||
|
[53.4500483, 7.4512561],
|
||||||
|
[53.4502057, 7.4514971],
|
||||||
|
[53.4504899, 7.4518959]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "278807709",
|
||||||
|
bbox: [7.4518959, 53.4449969, 7.5736929, 53.4650304],
|
||||||
|
tags: { name: "Ems-Jade-Kanal", width: "8", waterway: "canal" },
|
||||||
|
coordinates: [
|
||||||
|
[53.4504899, 7.4518959],
|
||||||
|
[53.4505236, 7.4519531],
|
||||||
|
[53.4509222, 7.4525426],
|
||||||
|
[53.4516529, 7.453623],
|
||||||
|
[53.4584592, 7.463384],
|
||||||
|
[53.4595046, 7.4648841],
|
||||||
|
[53.4599438, 7.465618],
|
||||||
|
[53.460753, 7.4673133],
|
||||||
|
[53.4616984, 7.46945],
|
||||||
|
[53.4620622, 7.4701175],
|
||||||
|
[53.4625348, 7.4707952],
|
||||||
|
[53.4627636, 7.4711325],
|
||||||
|
[53.4629466, 7.4713664],
|
||||||
|
[53.4630916, 7.4715145],
|
||||||
|
[53.4632388, 7.4716743],
|
||||||
|
[53.4633943, 7.4717923],
|
||||||
|
[53.4636131, 7.4718959],
|
||||||
|
[53.4639548, 7.4720761],
|
||||||
|
[53.4642607, 7.4722805],
|
||||||
|
[53.4646149, 7.4724784],
|
||||||
|
[53.4647858, 7.472612],
|
||||||
|
[53.4650071, 7.4729838],
|
||||||
|
[53.4650304, 7.4733641],
|
||||||
|
[53.4649736, 7.4737867],
|
||||||
|
[53.4645791, 7.4743077],
|
||||||
|
[53.4642518, 7.4745346],
|
||||||
|
[53.4640847, 7.4746604],
|
||||||
|
[53.4624017, 7.4759267],
|
||||||
|
[53.4618827, 7.4763916],
|
||||||
|
[53.4602632, 7.479245],
|
||||||
|
[53.4587935, 7.4819111],
|
||||||
|
[53.4585086, 7.482532],
|
||||||
|
[53.4583429, 7.482893],
|
||||||
|
[53.4570519, 7.4861185],
|
||||||
|
[53.455688, 7.4897057],
|
||||||
|
[53.4554283, 7.4910088],
|
||||||
|
[53.4543892, 7.4963485],
|
||||||
|
[53.4532243, 7.5021539],
|
||||||
|
[53.4531102, 7.5027225],
|
||||||
|
[53.4520897, 7.5077114],
|
||||||
|
[53.4519492, 7.5083984],
|
||||||
|
[53.4508683, 7.5126327],
|
||||||
|
[53.4490515, 7.5208022],
|
||||||
|
[53.4489869, 7.5211006],
|
||||||
|
[53.4485124, 7.5232942],
|
||||||
|
[53.4466401, 7.5319367],
|
||||||
|
[53.4451989, 7.5385911],
|
||||||
|
[53.4450014, 7.5399242],
|
||||||
|
[53.4449969, 7.5406774],
|
||||||
|
[53.4456267, 7.5463127],
|
||||||
|
[53.4457299, 7.5470025],
|
||||||
|
[53.446674, 7.5509229],
|
||||||
|
[53.4477404, 7.55515],
|
||||||
|
[53.4478348, 7.5554891],
|
||||||
|
[53.4491825, 7.5603283],
|
||||||
|
[53.4525244, 7.5722942],
|
||||||
|
[53.4529207, 7.5736929]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
describe("fairway graph extraction", () => {
|
describe("fairway graph extraction", () => {
|
||||||
|
it("reports that no routing source is configured", async () => {
|
||||||
|
const service = new FairwayService({
|
||||||
|
cache: createCache(),
|
||||||
|
fetcher: fetch,
|
||||||
|
liveEnabled: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const lookup = await service.getGraphsForRoute({
|
||||||
|
start: { lat: 53.4498, lon: 7.4509 },
|
||||||
|
destination: { lat: 53.4646, lon: 7.4742 },
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lookup.graphs).toEqual([]);
|
||||||
|
expect(lookup.failures).toMatchObject([
|
||||||
|
{
|
||||||
|
source: "configuration",
|
||||||
|
error: expect.objectContaining({ message: "No fairway source is configured" })
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
await service.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a configured local routing index after a missing file is repaired", async () => {
|
||||||
|
const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-missing-index-"));
|
||||||
|
const localDataPath = join(temporaryDirectory, "missing-fairways.json");
|
||||||
|
const service = new FairwayService({
|
||||||
|
cache: createCache(),
|
||||||
|
fetcher: fetch,
|
||||||
|
liveEnabled: false,
|
||||||
|
localDataPath
|
||||||
|
});
|
||||||
|
const request = {
|
||||||
|
start: { lat: 53.4498, lon: 7.4509 },
|
||||||
|
destination: { lat: 53.4646, lon: 7.4742 },
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5 }
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const missingLookup = await service.getGraphsForRoute(request);
|
||||||
|
expect(missingLookup.graphs).toEqual([]);
|
||||||
|
expect(missingLookup.failures).toMatchObject([
|
||||||
|
{
|
||||||
|
source: "local",
|
||||||
|
error: expect.objectContaining({ code: "ENOENT" })
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
localDataPath,
|
||||||
|
JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
source: "repaired-test",
|
||||||
|
ways: [
|
||||||
|
{
|
||||||
|
id: "repaired-way",
|
||||||
|
bbox: [7.4509, 53.4498, 7.4742, 53.4646],
|
||||||
|
tags: { waterway: "canal", name: "Repaired test fairway" },
|
||||||
|
coordinates: [
|
||||||
|
[53.4498, 7.4509],
|
||||||
|
[53.4646, 7.4742]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const repairedLookup = await service.getGraphsForRoute(request);
|
||||||
|
expect(repairedLookup.failures).toEqual([]);
|
||||||
|
expect(repairedLookup.graphs).toHaveLength(1);
|
||||||
|
expect(repairedLookup.graphs[0]?.edges[0]?.source).toBe(
|
||||||
|
"local-geofabrik-repaired-test"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await service.close();
|
||||||
|
await rm(temporaryDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("builds a routable graph from the persistent local Geofabrik format", () => {
|
it("builds a routable graph from the persistent local Geofabrik format", () => {
|
||||||
const graph = localFairwaysToGraph(
|
const graph = localFairwaysToGraph(
|
||||||
[
|
[
|
||||||
@@ -43,6 +216,110 @@ describe("fairway graph extraction", () => {
|
|||||||
expect(route?.dataSources).toContain("local-geofabrik-germany-test.osm.pbf");
|
expect(route?.dataSources).toContain("local-geofabrik-germany-test.osm.pbf");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not invent a traversable line between nearby disconnected waterway endpoints", () => {
|
||||||
|
const graph = localFairwaysToGraph(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: "west",
|
||||||
|
bbox: [7, 52, 7.01, 52],
|
||||||
|
tags: { waterway: "canal" },
|
||||||
|
coordinates: [
|
||||||
|
[52, 7],
|
||||||
|
[52, 7.01]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "east",
|
||||||
|
bbox: [7.011, 52, 7.02, 52],
|
||||||
|
tags: { waterway: "canal" },
|
||||||
|
coordinates: [
|
||||||
|
[52, 7.011],
|
||||||
|
[52, 7.02]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[6.9, 51.9, 7.1, 52.1],
|
||||||
|
"disconnected-test"
|
||||||
|
);
|
||||||
|
expect(graph).not.toBeNull();
|
||||||
|
|
||||||
|
const route = buildRoute(
|
||||||
|
{
|
||||||
|
start: { lat: 52, lon: 7 },
|
||||||
|
destination: { lat: 52, lon: 7.02 },
|
||||||
|
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||||
|
},
|
||||||
|
graph ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(route).toBeNull();
|
||||||
|
expect(graph?.edges.some((edge) => edge.source === "fairway-graph-connectors")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the reported Ems-Jade-Kanal route on the full source geometry", () => {
|
||||||
|
const start = { lat: 53.4498, lon: 7.4509 };
|
||||||
|
const destination = { lat: 53.4646, lon: 7.4742 };
|
||||||
|
const graph = localFairwaysToGraph(
|
||||||
|
EMDEN_CANAL_REGRESSION_WAYS,
|
||||||
|
[7.3, 53.3, 7.7, 53.6],
|
||||||
|
"emden-canal-regression"
|
||||||
|
);
|
||||||
|
expect(graph).not.toBeNull();
|
||||||
|
if (!graph) {
|
||||||
|
throw new Error("Expected Ems-Jade-Kanal regression graph");
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = buildRoute(
|
||||||
|
{
|
||||||
|
start,
|
||||||
|
destination,
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||||
|
},
|
||||||
|
graph
|
||||||
|
);
|
||||||
|
expect(route).not.toBeNull();
|
||||||
|
if (!route) {
|
||||||
|
throw new Error("Expected Ems-Jade-Kanal regression route");
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = route.geometry.coordinates;
|
||||||
|
const largestSegmentNm = coordinates.slice(1).reduce(
|
||||||
|
(largest, coordinate, index) =>
|
||||||
|
Math.max(
|
||||||
|
largest,
|
||||||
|
haversineDistanceNm(
|
||||||
|
{ lon: coordinates[index]![0], lat: coordinates[index]![1] },
|
||||||
|
{ lon: coordinate[0], lat: coordinate[1] }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(route.distanceNm).toBeGreaterThanOrEqual(1.2);
|
||||||
|
expect(route.distanceNm).toBeLessThanOrEqual(1.35);
|
||||||
|
expect(coordinates.length).toBeGreaterThanOrEqual(25);
|
||||||
|
expect(largestSegmentNm).toBeLessThan(0.55);
|
||||||
|
expect(coordinates).toContainEqual([7.4648841, 53.4595046]);
|
||||||
|
expect(coordinates).toContainEqual([7.46945, 53.4616984]);
|
||||||
|
expect(coordinates).toContainEqual([7.4724784, 53.4646149]);
|
||||||
|
expect(route.dataSources).toContain(
|
||||||
|
"local-geofabrik-emden-canal-regression"
|
||||||
|
);
|
||||||
|
expect(route.dataSources).not.toContain(
|
||||||
|
"fairway-graph:emden-east-ems-seed"
|
||||||
|
);
|
||||||
|
expect(route.routeSnaps?.start.distanceM).toBeLessThan(5);
|
||||||
|
expect(route.routeSnaps?.destination.distanceM).toBeLessThan(10);
|
||||||
|
expect(coordinates[0]).toEqual([
|
||||||
|
route.routeSnaps?.start.snapped.lon,
|
||||||
|
route.routeSnaps?.start.snapped.lat
|
||||||
|
]);
|
||||||
|
expect(coordinates.at(-1)).toEqual([
|
||||||
|
route.routeSnaps?.destination.snapped.lon,
|
||||||
|
route.routeSnaps?.destination.snapped.lat
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows country-wide requests against the persistent local index", async () => {
|
it("allows country-wide requests against the persistent local index", async () => {
|
||||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-local-span-"));
|
const temporaryDirectory = await mkdtemp(join(tmpdir(), "watermaps-local-span-"));
|
||||||
const localDataPath = join(temporaryDirectory, "germany-netherlands-fairways.json");
|
const localDataPath = join(temporaryDirectory, "germany-netherlands-fairways.json");
|
||||||
@@ -78,10 +355,11 @@ describe("fairway graph extraction", () => {
|
|||||||
liveEnabled: false,
|
liveEnabled: false,
|
||||||
localDataPath
|
localDataPath
|
||||||
});
|
});
|
||||||
const graphs = await service.getGraphsForRoute(request);
|
const lookup = await service.getGraphsForRoute(request);
|
||||||
const route = buildRoute(request, graphs[0]);
|
const route = buildRoute(request, lookup.graphs[0]);
|
||||||
|
|
||||||
expect(graphs).toHaveLength(1);
|
expect(lookup.failures).toEqual([]);
|
||||||
|
expect(lookup.graphs).toHaveLength(1);
|
||||||
expect(route?.routingMode).toBe("fairway");
|
expect(route?.routingMode).toBe("fairway");
|
||||||
expect(route?.dataSources).toContain(
|
expect(route?.dataSources).toContain(
|
||||||
"local-geofabrik-germany+netherlands"
|
"local-geofabrik-germany+netherlands"
|
||||||
@@ -156,6 +434,7 @@ describe("fairway graph extraction", () => {
|
|||||||
geometry: {
|
geometry: {
|
||||||
type: "LineString",
|
type: "LineString",
|
||||||
coordinates: [
|
coordinates: [
|
||||||
|
[7.1752, 53.3306],
|
||||||
[7.1751368, 53.3331995],
|
[7.1751368, 53.3331995],
|
||||||
[7.16, 53.42],
|
[7.16, 53.42],
|
||||||
[7.1474, 53.55],
|
[7.1474, 53.55],
|
||||||
@@ -173,7 +452,8 @@ describe("fairway graph extraction", () => {
|
|||||||
type: "LineString",
|
type: "LineString",
|
||||||
coordinates: [
|
coordinates: [
|
||||||
[7.1474, 53.55],
|
[7.1474, 53.55],
|
||||||
[7.1414995, 53.6668156]
|
[7.1414995, 53.6668156],
|
||||||
|
[7.1474, 53.6741]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,8 +474,16 @@ describe("fairway graph extraction", () => {
|
|||||||
expect(route).not.toBeNull();
|
expect(route).not.toBeNull();
|
||||||
expect(route?.routingMode).toBe("fairway");
|
expect(route?.routingMode).toBe("fairway");
|
||||||
expect(route?.dataSources).toContain("postgis-osm");
|
expect(route?.dataSources).toContain("postgis-osm");
|
||||||
expect(route?.geometry.coordinates[0]).toEqual([start.lon, start.lat]);
|
expect(route?.routeSnaps?.start.requested).toEqual(start);
|
||||||
expect(route?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]);
|
expect(route?.routeSnaps?.destination.requested).toEqual(destination);
|
||||||
|
expect(route?.geometry.coordinates[0]).toEqual([
|
||||||
|
route?.routeSnaps?.start.snapped.lon,
|
||||||
|
route?.routeSnaps?.start.snapped.lat
|
||||||
|
]);
|
||||||
|
expect(route?.geometry.coordinates.at(-1)).toEqual([
|
||||||
|
route?.routeSnaps?.destination.snapped.lon,
|
||||||
|
route?.routeSnaps?.destination.snapped.lat
|
||||||
|
]);
|
||||||
expect(route?.geometry.coordinates.some(([lon, lat]) => lon === 7.1474 && lat === 53.55)).toBe(true);
|
expect(route?.geometry.coordinates.some(([lon, lat]) => lon === 7.1474 && lat === 53.55)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import type { Coordinate, RouteResult, RouteWarning, VesselProfile } from "@watermaps/shared";
|
import type {
|
||||||
|
Coordinate,
|
||||||
|
RouteResult,
|
||||||
|
RouteSnaps,
|
||||||
|
RouteWarning,
|
||||||
|
VesselProfile
|
||||||
|
} from "@watermaps/shared";
|
||||||
|
|
||||||
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";
|
||||||
@@ -145,8 +151,9 @@ export function createOfflineVoyageRecord(
|
|||||||
throw new OfflineVoyageStorageError("Ungültige Kennung für die Offline-Route.");
|
throw new OfflineVoyageStorageError("Ungültige Kennung für die Offline-Route.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultStart = { lat: first[1], lon: first[0] };
|
const defaultStart = route.routeSnaps?.start.requested ?? { lat: first[1], lon: first[0] };
|
||||||
const defaultDestination = { lat: last[1], lon: last[0] };
|
const defaultDestination =
|
||||||
|
route.routeSnaps?.destination.requested ?? { lat: last[1], lon: last[0] };
|
||||||
const planInput = input.plan ?? {};
|
const planInput = input.plan ?? {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -215,6 +222,8 @@ function normalizeRoute(value: unknown): RouteResult {
|
|||||||
typeof value.departureTime === "string" ? normalizeIsoDate(value.departureTime) : undefined;
|
typeof value.departureTime === "string" ? normalizeIsoDate(value.departureTime) : undefined;
|
||||||
const durationMinutes =
|
const durationMinutes =
|
||||||
value.durationMinutes === undefined ? undefined : finiteNumber(value.durationMinutes, 0, 10_000_000);
|
value.durationMinutes === undefined ? undefined : finiteNumber(value.durationMinutes, 0, 10_000_000);
|
||||||
|
const routeSnaps =
|
||||||
|
value.routeSnaps === undefined ? undefined : normalizeRouteSnaps(value.routeSnaps);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(id ? { id } : {}),
|
...(id ? { id } : {}),
|
||||||
@@ -228,7 +237,31 @@ function normalizeRoute(value: unknown): RouteResult {
|
|||||||
dataSources,
|
dataSources,
|
||||||
...(departureTime ? { departureTime } : {}),
|
...(departureTime ? { departureTime } : {}),
|
||||||
...(durationMinutes !== undefined ? { durationMinutes } : {}),
|
...(durationMinutes !== undefined ? { durationMinutes } : {}),
|
||||||
...(routingMode ? { routingMode } : {})
|
...(routingMode ? { routingMode } : {}),
|
||||||
|
...(routeSnaps ? { routeSnaps } : {})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRouteSnaps(value: unknown): RouteSnaps {
|
||||||
|
if (!isRecord(value) || !Array.isArray(value.waypoints) || value.waypoints.length > 25) {
|
||||||
|
throw new OfflineVoyageStorageError("Die Snap-Metadaten der Offline-Route sind ungültig.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: normalizeRouteSnap(value.start),
|
||||||
|
waypoints: value.waypoints.map(normalizeRouteSnap),
|
||||||
|
destination: normalizeRouteSnap(value.destination)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRouteSnap(value: unknown): RouteSnaps["start"] {
|
||||||
|
if (!isRecord(value)) {
|
||||||
|
throw new OfflineVoyageStorageError("Ein Snap-Punkt der Offline-Route ist ungültig.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
requested: normalizeCoordinate(value.requested),
|
||||||
|
snapped: normalizeCoordinate(value.snapped),
|
||||||
|
distanceM: finiteNumber(value.distanceM, 0, 100_000)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe("offline voyage storage", () => {
|
|||||||
expect(saved.plan.destination).toEqual({ lat: 51.6814536, lon: 7.8042615 });
|
expect(saved.plan.destination).toEqual({ lat: 51.6814536, lon: 7.8042615 });
|
||||||
expect(saved.route.departureTime).toBe("2026-07-20T06:00:00.000Z");
|
expect(saved.route.departureTime).toBe("2026-07-20T06:00:00.000Z");
|
||||||
expect(saved.route.durationMinutes).toBe(120);
|
expect(saved.route.durationMinutes).toBe(120);
|
||||||
|
expect(saved.route.routeSnaps).toEqual(routeFixture.routeSnaps);
|
||||||
expect(saved.route.alternatives).toBeUndefined();
|
expect(saved.route.alternatives).toBeUndefined();
|
||||||
expect(listOfflineVoyages(localStorage)).toHaveLength(1);
|
expect(listOfflineVoyages(localStorage)).toHaveLength(1);
|
||||||
const restored = loadOfflineVoyage("voyage-test", localStorage);
|
const restored = loadOfflineVoyage("voyage-test", localStorage);
|
||||||
@@ -83,7 +84,7 @@ const routeFixture: RouteResult = {
|
|||||||
name: "Emden – Hamm",
|
name: "Emden – Hamm",
|
||||||
geometry: {
|
geometry: {
|
||||||
type: "LineString",
|
type: "LineString",
|
||||||
coordinates: [[7.186111, 53.344167], [7.1, 52.4], [7.8042615, 51.6814536]]
|
coordinates: [[7.186, 53.344], [7.1, 52.4], [7.8042, 51.6817]]
|
||||||
},
|
},
|
||||||
distanceNm: 153.69,
|
distanceNm: 153.69,
|
||||||
departureTime: "2026-07-20T06:00:00.000Z",
|
departureTime: "2026-07-20T06:00:00.000Z",
|
||||||
@@ -94,6 +95,19 @@ const routeFixture: RouteResult = {
|
|||||||
unknownDepthRatio: 1,
|
unknownDepthRatio: 1,
|
||||||
dataSources: ["OSM"],
|
dataSources: ["OSM"],
|
||||||
routingMode: "fairway",
|
routingMode: "fairway",
|
||||||
|
routeSnaps: {
|
||||||
|
start: {
|
||||||
|
requested: { lat: 53.344167, lon: 7.186111 },
|
||||||
|
snapped: { lat: 53.344, lon: 7.186 },
|
||||||
|
distanceM: 20
|
||||||
|
},
|
||||||
|
waypoints: [],
|
||||||
|
destination: {
|
||||||
|
requested: { lat: 51.6814536, lon: 7.8042615 },
|
||||||
|
snapped: { lat: 51.6817, lon: 7.8042 },
|
||||||
|
distanceM: 28
|
||||||
|
}
|
||||||
|
},
|
||||||
alternatives: [{
|
alternatives: [{
|
||||||
id: "unused",
|
id: "unused",
|
||||||
name: "Alternative",
|
name: "Alternative",
|
||||||
|
|||||||
@@ -326,6 +326,22 @@ wm_smoke_test_route() {
|
|||||||
destination: { lat: 53.465, lon: 7.4734 },
|
destination: { lat: 53.465, lon: 7.4734 },
|
||||||
minimumAlternatives: 2
|
minimumAlternatives: 2
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Ems-Jade-Kanal bei Rahe",
|
||||||
|
start: { lat: 53.4498, lon: 7.4509 },
|
||||||
|
destination: { lat: 53.4646, lon: 7.4742 },
|
||||||
|
minimumAlternatives: 0,
|
||||||
|
minimumCoordinates: 25,
|
||||||
|
minimumDistanceNm: 1.2,
|
||||||
|
maximumDistanceNm: 1.35,
|
||||||
|
maximumSegmentNm: 0.55,
|
||||||
|
corridorToleranceNm: 0.01,
|
||||||
|
corridorCoordinates: [
|
||||||
|
[7.4648841, 53.4595046],
|
||||||
|
[7.46945, 53.4616984],
|
||||||
|
[7.4724784, 53.4646149]
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Norddeich–Norderney",
|
name: "Norddeich–Norderney",
|
||||||
start: { lat: 53.6234, lon: 7.1559 },
|
start: { lat: 53.6234, lon: 7.1559 },
|
||||||
@@ -334,8 +350,8 @@ wm_smoke_test_route() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Emden–Delfzijl",
|
name: "Emden–Delfzijl",
|
||||||
start: { lat: 53.3416, lon: 7.186 },
|
start: { lat: 53.3395697, lon: 7.1848883 },
|
||||||
destination: { lat: 53.3282, lon: 6.9304 },
|
destination: { lat: 53.330353, lon: 6.9334717 },
|
||||||
minimumAlternatives: 0,
|
minimumAlternatives: 0,
|
||||||
minimumCoordinates: 30,
|
minimumCoordinates: 30,
|
||||||
minimumDistanceNm: 9.5,
|
minimumDistanceNm: 9.5,
|
||||||
@@ -349,9 +365,9 @@ wm_smoke_test_route() {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Weesp–Utrecht",
|
name: "Weesp–Utrecht (Werkspoorhaven)",
|
||||||
start: { lat: 52.309, lon: 5.0423 },
|
start: { lat: 52.309, lon: 5.0423 },
|
||||||
destination: { lat: 52.105, lon: 5.085 },
|
destination: { lat: 52.1058659, lon: 5.0788596 },
|
||||||
minimumAlternatives: 2
|
minimumAlternatives: 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -411,7 +427,9 @@ wm_smoke_test_route() {
|
|||||||
Array.isArray(routeCoordinates) &&
|
Array.isArray(routeCoordinates) &&
|
||||||
expectedCorridorCoordinates.every((expectedCoordinate) =>
|
expectedCorridorCoordinates.every((expectedCoordinate) =>
|
||||||
routeCoordinates.some(
|
routeCoordinates.some(
|
||||||
(coordinate) => distanceNm(coordinate, expectedCoordinate) <= 0.15
|
(coordinate) =>
|
||||||
|
distanceNm(coordinate, expectedCoordinate) <=
|
||||||
|
(routeCheck.corridorToleranceNm ?? 0.15)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ services:
|
|||||||
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
|
WATERMAPS_WEB_DIST_PATH: /app/apps/web/dist
|
||||||
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
|
WATERMAPS_LOCAL_FAIRWAYS_PATH: /data/germany-netherlands-fairways.json
|
||||||
WATERMAPS_LIVE_FAIRWAYS: "false"
|
WATERMAPS_LIVE_FAIRWAYS: "false"
|
||||||
WATERMAPS_DEMO_DATA: "true"
|
WATERMAPS_DEMO_DATA: "${WATERMAPS_DEMO_DATA:-false}"
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: ./data/local
|
source: ./data/local
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type {
|
|||||||
RouteOption,
|
RouteOption,
|
||||||
RouteRequest,
|
RouteRequest,
|
||||||
RouteResult,
|
RouteResult,
|
||||||
|
RouteSnap,
|
||||||
|
RouteSnaps,
|
||||||
RouteWarning,
|
RouteWarning,
|
||||||
VesselProfile
|
VesselProfile
|
||||||
} from "./types.js";
|
} from "./types.js";
|
||||||
@@ -36,12 +38,6 @@ type EdgeSnap = {
|
|||||||
distanceNm: number;
|
distanceNm: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EdgeSnapPair = {
|
|
||||||
componentId: string;
|
|
||||||
start: EdgeSnap;
|
|
||||||
destination: EdgeSnap;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FairwayGraph = {
|
export type FairwayGraph = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -50,6 +46,15 @@ export type FairwayGraph = {
|
|||||||
edges: FairwayEdge[];
|
edges: FairwayEdge[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class FairwayRoutingSearchLimitError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
"Fairway routing search budget was exhausted before all snap candidates could be checked"
|
||||||
|
);
|
||||||
|
this.name = "FairwayRoutingSearchLimitError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type PathStep = {
|
type PathStep = {
|
||||||
edge: FairwayEdge;
|
edge: FairwayEdge;
|
||||||
from: string;
|
from: string;
|
||||||
@@ -71,15 +76,36 @@ type FairwayRouteCandidate = {
|
|||||||
usedEdges: FairwayEdge[];
|
usedEdges: FairwayEdge[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type FairwayPathState = {
|
||||||
|
currentSnap: EdgeSnap;
|
||||||
|
coordinates: Coordinate[];
|
||||||
|
usedEdges: FairwayEdge[];
|
||||||
|
costNm: number;
|
||||||
|
snaps: EdgeSnap[];
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_CRUISE_SPEED_KN = 6;
|
const DEFAULT_CRUISE_SPEED_KN = 6;
|
||||||
const MAX_ALTERNATIVE_ROUTES = 3;
|
const MAX_ALTERNATIVE_ROUTES = 3;
|
||||||
const ALTERNATIVE_EDGE_PENALTY = 8;
|
const ALTERNATIVE_EDGE_PENALTY = 8;
|
||||||
const MAX_ALTERNATIVE_DISTANCE_FACTOR = 1.75;
|
const MAX_ALTERNATIVE_DISTANCE_FACTOR = 1.75;
|
||||||
const MIN_DIFFERENT_DISTANCE_NM = 0.25;
|
const MIN_DIFFERENT_DISTANCE_NM = 0.25;
|
||||||
const MIN_DIFFERENT_DISTANCE_RATIO = 0.05;
|
const MIN_DIFFERENT_DISTANCE_RATIO = 0.05;
|
||||||
const MAX_EDGE_SNAPS_PER_COMPONENT = 4;
|
const MAX_EDGE_SNAPS_PER_COMPONENT = 8;
|
||||||
|
// Bounds the quadratic snap-pair work across all candidate components in one
|
||||||
|
// route attempt. With the API limit of 25 waypoints (26 legs), six candidates
|
||||||
|
// still fit: 26 * 6² = 936. Truncation is surfaced instead of silently claiming
|
||||||
|
// that no fairway route exists.
|
||||||
|
const MAX_SNAP_PAIR_EVALUATIONS_PER_ATTEMPT = 1024;
|
||||||
|
// A snap is only an association with the graph; the gap is never emitted as a navigable line.
|
||||||
|
// 150 m still accepts harbour/ferry POIs that lie on the bank while rejecting remote waterways.
|
||||||
|
// This is a hard safety boundary; a graph may request a smaller radius, but never a larger one.
|
||||||
|
const MAX_ROUTE_SNAP_DISTANCE_M = 150;
|
||||||
|
const MAX_ROUTE_SNAP_DISTANCE_NM = MAX_ROUTE_SNAP_DISTANCE_M / 1852;
|
||||||
|
const SNAP_WARNING_DISTANCE_M = 10;
|
||||||
|
// Prefer the closest reachable fairway instead of shortening a route by ending early off the graph.
|
||||||
|
const SNAP_DISTANCE_COST_MULTIPLIER = 10;
|
||||||
|
|
||||||
const EMS_BORKUM_GRAPH: FairwayGraph = {
|
export const EMDEN_BORKUM_DEMO_GRAPH: FairwayGraph = {
|
||||||
id: "ems-borkum-seed",
|
id: "ems-borkum-seed",
|
||||||
name: "Emsfahrwasser Emden-Borkum",
|
name: "Emsfahrwasser Emden-Borkum",
|
||||||
maxSnapDistanceNm: 3,
|
maxSnapDistanceNm: 3,
|
||||||
@@ -199,15 +225,19 @@ const EMS_BORKUM_GRAPH: FairwayGraph = {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildFairwayRoute(request: RouteRequest, graph = EMS_BORKUM_GRAPH): RouteResult | null {
|
export function buildFairwayRoute(request: RouteRequest, graph: FairwayGraph): RouteResult | null {
|
||||||
return buildFairwayRoutes(request, graph, 1)[0] ?? null;
|
return buildFairwayRoutes(request, graph, 1)[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFairwayRoutes(
|
export function buildFairwayRoutes(
|
||||||
request: RouteRequest,
|
request: RouteRequest,
|
||||||
graph = EMS_BORKUM_GRAPH,
|
graph: FairwayGraph,
|
||||||
maxRoutes = MAX_ALTERNATIVE_ROUTES
|
maxRoutes = MAX_ALTERNATIVE_ROUTES
|
||||||
): RouteOption[] {
|
): RouteOption[] {
|
||||||
|
if (!graph) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const routeLimit = Math.max(0, Math.min(MAX_ALTERNATIVE_ROUTES, Math.floor(maxRoutes)));
|
const routeLimit = Math.max(0, Math.min(MAX_ALTERNATIVE_ROUTES, Math.floor(maxRoutes)));
|
||||||
if (routeLimit === 0) {
|
if (routeLimit === 0) {
|
||||||
return [];
|
return [];
|
||||||
@@ -220,15 +250,13 @@ export function buildFairwayRoutes(
|
|||||||
|
|
||||||
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
|
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
|
||||||
const componentByNode = weaklyConnectedComponents(routableGraph);
|
const componentByNode = weaklyConnectedComponents(routableGraph);
|
||||||
const legSnapPairs = requestedPoints.slice(0, -1).map((start, index) =>
|
const pointSnapsByComponent = requestedPoints.map((point) =>
|
||||||
findCompatibleEdgeSnapPairs(
|
findNearestEdgeSnapsByComponent(routableGraph, point, componentByNode)
|
||||||
routableGraph,
|
|
||||||
start,
|
|
||||||
requestedPoints[index + 1]!,
|
|
||||||
componentByNode
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
if (legSnapPairs.some((pairs) => pairs.length === 0)) {
|
const commonComponentIds = [...(pointSnapsByComponent[0]?.keys() ?? [])].filter((componentId) =>
|
||||||
|
pointSnapsByComponent.every((snaps) => snaps.has(componentId))
|
||||||
|
);
|
||||||
|
if (commonComponentIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +265,24 @@ export function buildFairwayRoutes(
|
|||||||
const maxAttempts = Math.max(8, routeLimit * 6);
|
const maxAttempts = Math.max(8, routeLimit * 6);
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts && accepted.length < routeLimit; attempt += 1) {
|
for (let attempt = 0; attempt < maxAttempts && accepted.length < routeLimit; attempt += 1) {
|
||||||
const candidate = buildFairwayRouteCandidate(request, routableGraph, penaltyCounts, legSnapPairs);
|
let candidate: FairwayRouteCandidate | null;
|
||||||
|
try {
|
||||||
|
candidate = buildFairwayRouteCandidate(
|
||||||
|
request,
|
||||||
|
routableGraph,
|
||||||
|
penaltyCounts,
|
||||||
|
pointSnapsByComponent,
|
||||||
|
commonComponentIds
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof FairwayRoutingSearchLimitError &&
|
||||||
|
accepted.length > 0
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
if (!candidate) {
|
if (!candidate) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -269,38 +314,66 @@ function buildFairwayRouteCandidate(
|
|||||||
request: RouteRequest,
|
request: RouteRequest,
|
||||||
routableGraph: FairwayGraph,
|
routableGraph: FairwayGraph,
|
||||||
penaltyCounts: ReadonlyMap<string, number>,
|
penaltyCounts: ReadonlyMap<string, number>,
|
||||||
legSnapPairs: EdgeSnapPair[][]
|
pointSnapsByComponent: Array<Map<string, EdgeSnap[]>>,
|
||||||
|
commonComponentIds: string[]
|
||||||
): FairwayRouteCandidate | null {
|
): FairwayRouteCandidate | null {
|
||||||
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
|
const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination];
|
||||||
const routeCoordinates: Coordinate[] = [];
|
|
||||||
const usedEdges = new Map<string, FairwayEdge>();
|
|
||||||
const adjacency = buildAdjacency(routableGraph, penaltyCounts);
|
const adjacency = buildAdjacency(routableGraph, penaltyCounts);
|
||||||
|
const completedPaths: FairwayPathState[] = [];
|
||||||
for (let index = 0; index < requestedPoints.length - 1; index += 1) {
|
let remainingPairEvaluations = MAX_SNAP_PAIR_EVALUATIONS_PER_ATTEMPT;
|
||||||
const legStart = requestedPoints[index]!;
|
let searchTruncated = false;
|
||||||
const legDestination = requestedPoints[index + 1]!;
|
let processedComponentCount = 0;
|
||||||
const leg = buildFairwayLeg(
|
const orderedComponentIds = componentsBySnapDistance(
|
||||||
routableGraph,
|
commonComponentIds,
|
||||||
legStart,
|
pointSnapsByComponent
|
||||||
legDestination,
|
|
||||||
penaltyCounts,
|
|
||||||
adjacency,
|
|
||||||
legSnapPairs[index]!
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!leg) {
|
for (const componentId of orderedComponentIds) {
|
||||||
|
const snapLayers = pointSnapsByComponent.map((snaps) => snaps.get(componentId) ?? []);
|
||||||
|
const maxCandidateCount = Math.max(...snapLayers.map((snaps) => snaps.length));
|
||||||
|
let candidateLimit = Math.min(MAX_EDGE_SNAPS_PER_COMPONENT, maxCandidateCount);
|
||||||
|
while (
|
||||||
|
candidateLimit > 0 &&
|
||||||
|
snapPairEvaluationCost(snapLayers, candidateLimit) > remainingPairEvaluations
|
||||||
|
) {
|
||||||
|
candidateLimit -= 1;
|
||||||
|
}
|
||||||
|
if (candidateLimit < 1) {
|
||||||
|
searchTruncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
processedComponentCount += 1;
|
||||||
|
searchTruncated ||= candidateLimit < maxCandidateCount;
|
||||||
|
remainingPairEvaluations -= snapPairEvaluationCost(snapLayers, candidateLimit);
|
||||||
|
const limitedSnapLayers = snapLayers.map((snaps) => snaps.slice(0, candidateLimit));
|
||||||
|
completedPaths.push(
|
||||||
|
...buildFairwayPathStates(
|
||||||
|
routableGraph,
|
||||||
|
penaltyCounts,
|
||||||
|
adjacency,
|
||||||
|
limitedSnapLayers
|
||||||
|
).filter((path) => path.coordinates.length >= 2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
searchTruncated ||= processedComponentCount < orderedComponentIds.length;
|
||||||
|
|
||||||
|
const selectedPath =
|
||||||
|
completedPaths.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.costNm - b.costNm ||
|
||||||
|
sumRouteDistanceNm(a.coordinates) - sumRouteDistanceNm(b.coordinates)
|
||||||
|
)[0] ?? null;
|
||||||
|
if (!selectedPath) {
|
||||||
|
if (searchTruncated) {
|
||||||
|
throw new FairwayRoutingSearchLimitError();
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const coordinate of leg.coordinates) {
|
const routeCoordinates = selectedPath.coordinates;
|
||||||
appendCoordinate(routeCoordinates, coordinate);
|
const usedEdges = selectedPath.usedEdges;
|
||||||
}
|
const routeSnaps = buildRouteSnaps(requestedPoints, selectedPath.snaps);
|
||||||
|
|
||||||
for (const edge of leg.usedEdges) {
|
|
||||||
usedEdges.set(edge.id, edge);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const distanceNm = round(sumRouteDistanceNm(routeCoordinates), 2);
|
const distanceNm = round(sumRouteDistanceNm(routeCoordinates), 2);
|
||||||
const speedKn =
|
const speedKn =
|
||||||
request.vesselProfile.cruiseSpeedKn && request.vesselProfile.cruiseSpeedKn > 0
|
request.vesselProfile.cruiseSpeedKn && request.vesselProfile.cruiseSpeedKn > 0
|
||||||
@@ -309,7 +382,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.values()]);
|
const depthSamples = request.depthSamples ?? edgeDepthSamples(usedEdges);
|
||||||
const depthAssessment = assessFairwayDepthSamples(depthSamples, request.vesselProfile);
|
const depthAssessment = assessFairwayDepthSamples(depthSamples, request.vesselProfile);
|
||||||
const warnings: RouteWarning[] = [
|
const warnings: RouteWarning[] = [
|
||||||
{
|
{
|
||||||
@@ -322,6 +395,18 @@ function buildFairwayRouteCandidate(
|
|||||||
severity: "caution",
|
severity: "caution",
|
||||||
message: "Fahrwasser-Graph ist eine MVP-Planungshilfe und keine amtliche Navigationsgrundlage."
|
message: "Fahrwasser-Graph ist eine MVP-Planungshilfe und keine amtliche Navigationsgrundlage."
|
||||||
},
|
},
|
||||||
|
...(searchTruncated
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
code: "ROUTE_SEARCH_LIMITED",
|
||||||
|
severity: "caution" as const,
|
||||||
|
message:
|
||||||
|
"Die Snap-Suche wurde wegen eines dichten Fahrwassernetzes begrenzt. " +
|
||||||
|
"Die Route ist befahrbar verbunden, aber möglicherweise nicht die kürzeste Option."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...routeSnapWarnings(routeSnaps),
|
||||||
...depthAssessment.warnings
|
...depthAssessment.warnings
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -339,17 +424,163 @@ function buildFairwayRouteCandidate(
|
|||||||
unknownDepthRatio: depthAssessment.unknownDepthRatio,
|
unknownDepthRatio: depthAssessment.unknownDepthRatio,
|
||||||
dataSources: [
|
dataSources: [
|
||||||
`fairway-graph:${routableGraph.id}`,
|
`fairway-graph:${routableGraph.id}`,
|
||||||
...uniqueSources([...usedEdges.values()]),
|
...uniqueSources(usedEdges),
|
||||||
request.depthSamples?.length
|
request.depthSamples?.length
|
||||||
? "submitted-depth-samples"
|
? "submitted-depth-samples"
|
||||||
: [...usedEdges.values()].some((edge) => edge.minDepthM === null)
|
: usedEdges.some((edge) => edge.minDepthM === null)
|
||||||
? "fairway-depth-unknown"
|
? "fairway-depth-unknown"
|
||||||
: "fairway-depth-estimates"
|
: "fairway-depth-estimates"
|
||||||
],
|
],
|
||||||
routingMode: "fairway"
|
routingMode: "fairway",
|
||||||
|
routeSnaps
|
||||||
};
|
};
|
||||||
|
|
||||||
return { route, usedEdges: [...usedEdges.values()] };
|
return { route, usedEdges };
|
||||||
|
}
|
||||||
|
|
||||||
|
function componentsBySnapDistance(
|
||||||
|
componentIds: string[],
|
||||||
|
pointSnapsByComponent: Array<Map<string, EdgeSnap[]>>
|
||||||
|
): string[] {
|
||||||
|
return [...componentIds].sort((first, second) => {
|
||||||
|
const firstDistance = pointSnapsByComponent.reduce(
|
||||||
|
(total, snaps) => total + (snaps.get(first)?.[0]?.distanceNm ?? Number.POSITIVE_INFINITY),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const secondDistance = pointSnapsByComponent.reduce(
|
||||||
|
(total, snaps) => total + (snaps.get(second)?.[0]?.distanceNm ?? Number.POSITIVE_INFINITY),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
return firstDistance - secondDistance || first.localeCompare(second);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapPairEvaluationCost(snapLayers: EdgeSnap[][], candidateLimit: number): number {
|
||||||
|
return snapLayers.slice(0, -1).reduce((total, snaps, index) => {
|
||||||
|
const currentCount = Math.min(candidateLimit, snaps.length);
|
||||||
|
const nextCount = Math.min(candidateLimit, snapLayers[index + 1]!.length);
|
||||||
|
return total + currentCount * nextCount;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFairwayPathStates(
|
||||||
|
graph: FairwayGraph,
|
||||||
|
penaltyCounts: ReadonlyMap<string, number>,
|
||||||
|
adjacency: Adjacency,
|
||||||
|
snapLayers: EdgeSnap[][]
|
||||||
|
): FairwayPathState[] {
|
||||||
|
let states: FairwayPathState[] = (snapLayers[0] ?? []).map((startSnap) => ({
|
||||||
|
currentSnap: startSnap,
|
||||||
|
coordinates: [],
|
||||||
|
usedEdges: [],
|
||||||
|
costNm: startSnap.distanceNm * SNAP_DISTANCE_COST_MULTIPLIER,
|
||||||
|
snaps: [startSnap]
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (let pointIndex = 1; pointIndex < snapLayers.length && states.length > 0; pointIndex += 1) {
|
||||||
|
const nextStates: FairwayPathState[] = [];
|
||||||
|
for (const destinationSnap of snapLayers[pointIndex]!) {
|
||||||
|
let bestState: FairwayPathState | null = null;
|
||||||
|
|
||||||
|
for (const state of states) {
|
||||||
|
const leg = buildFairwayLegBetweenSnaps(
|
||||||
|
graph,
|
||||||
|
state.currentSnap,
|
||||||
|
destinationSnap,
|
||||||
|
penaltyCounts,
|
||||||
|
adjacency
|
||||||
|
);
|
||||||
|
if (!leg) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coordinates = [...state.coordinates];
|
||||||
|
for (const coordinate of leg.coordinates) {
|
||||||
|
appendCoordinate(coordinates, coordinate);
|
||||||
|
}
|
||||||
|
const usedEdges = new Map(state.usedEdges.map((edge) => [edge.id, edge]));
|
||||||
|
for (const edge of leg.usedEdges) {
|
||||||
|
usedEdges.set(edge.id, edge);
|
||||||
|
}
|
||||||
|
const candidateState: FairwayPathState = {
|
||||||
|
currentSnap: destinationSnap,
|
||||||
|
coordinates,
|
||||||
|
usedEdges: [...usedEdges.values()],
|
||||||
|
costNm:
|
||||||
|
state.costNm +
|
||||||
|
leg.costNm +
|
||||||
|
destinationSnap.distanceNm * SNAP_DISTANCE_COST_MULTIPLIER,
|
||||||
|
snaps: [...state.snaps, destinationSnap]
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!bestState ||
|
||||||
|
candidateState.costNm < bestState.costNm ||
|
||||||
|
(
|
||||||
|
candidateState.costNm === bestState.costNm &&
|
||||||
|
sumRouteDistanceNm(candidateState.coordinates) <
|
||||||
|
sumRouteDistanceNm(bestState.coordinates)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
bestState = candidateState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestState) {
|
||||||
|
nextStates.push(bestState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
states = nextStates;
|
||||||
|
}
|
||||||
|
|
||||||
|
return states;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouteSnaps(requestedPoints: Coordinate[], snaps: EdgeSnap[]): RouteSnaps {
|
||||||
|
if (requestedPoints.length !== snaps.length || requestedPoints.length < 2) {
|
||||||
|
throw new Error("Route snap metadata does not match the requested route points");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = requestedPoints.map((requested, index): RouteSnap => {
|
||||||
|
const snap = snaps[index]!;
|
||||||
|
return {
|
||||||
|
requested,
|
||||||
|
snapped: snap.coordinate,
|
||||||
|
distanceM: round(snap.distanceNm * 1852, 1)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: normalized[0]!,
|
||||||
|
waypoints: normalized.slice(1, -1),
|
||||||
|
destination: normalized.at(-1)!
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeSnapWarnings(routeSnaps: RouteSnaps): RouteWarning[] {
|
||||||
|
const points = [
|
||||||
|
{ label: "Startpunkt", snap: routeSnaps.start },
|
||||||
|
...routeSnaps.waypoints.map((snap, index) => ({
|
||||||
|
label: `Zwischenziel ${index + 1}`,
|
||||||
|
snap
|
||||||
|
})),
|
||||||
|
{ label: "Zielpunkt", snap: routeSnaps.destination }
|
||||||
|
];
|
||||||
|
|
||||||
|
return points.flatMap(({ label, snap }) => {
|
||||||
|
if (snap.distanceM < SNAP_WARNING_DISTANCE_M) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
code: "ROUTE_POINT_SNAPPED",
|
||||||
|
severity: snap.distanceM >= 25 ? "caution" : "info",
|
||||||
|
message:
|
||||||
|
`${label} liegt ${Math.round(snap.distanceM)} m vom erfassten Fahrwasser entfernt. ` +
|
||||||
|
"Diese ungeprüfte Zufahrt ist nicht Teil der Routengeometrie.",
|
||||||
|
coordinate: snap.requested
|
||||||
|
} satisfies RouteWarning
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestedDepartureTimestamp(value?: string): number {
|
function requestedDepartureTimestamp(value?: string): number {
|
||||||
@@ -392,6 +623,7 @@ function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): Fair
|
|||||||
const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM;
|
const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM;
|
||||||
return {
|
return {
|
||||||
...graph,
|
...graph,
|
||||||
|
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) {
|
if (edge.minDepthM !== null && edge.minDepthM < requiredDepthM) {
|
||||||
return false;
|
return false;
|
||||||
@@ -414,39 +646,28 @@ function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): Fair
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFairwayLeg(
|
function buildFairwayLegBetweenSnaps(
|
||||||
graph: FairwayGraph,
|
graph: FairwayGraph,
|
||||||
legStart: Coordinate,
|
startSnap: EdgeSnap,
|
||||||
legDestination: Coordinate,
|
destinationSnap: EdgeSnap,
|
||||||
penaltyCounts: ReadonlyMap<string, number>,
|
penaltyCounts: ReadonlyMap<string, number>,
|
||||||
adjacency: Adjacency,
|
adjacency: Adjacency
|
||||||
snapPairs: EdgeSnapPair[]
|
|
||||||
): FairwayLeg | null {
|
): FairwayLeg | null {
|
||||||
const candidates: FairwayLeg[] = [];
|
const candidates: FairwayLeg[] = [];
|
||||||
const routedComponents = new Set<string>();
|
|
||||||
|
|
||||||
for (const { componentId, start: startSnap, destination: destinationSnap } of snapPairs) {
|
|
||||||
if (routedComponents.has(componentId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const previousCandidateCount = candidates.length;
|
|
||||||
|
|
||||||
if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) {
|
if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) {
|
||||||
const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap);
|
const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap);
|
||||||
const coordinates: Coordinate[] = [];
|
const coordinates: Coordinate[] = [];
|
||||||
appendCoordinate(coordinates, legStart);
|
|
||||||
for (const coordinate of directOnEdge) {
|
for (const coordinate of directOnEdge) {
|
||||||
appendCoordinate(coordinates, coordinate);
|
appendCoordinate(coordinates, coordinate);
|
||||||
}
|
}
|
||||||
appendCoordinate(coordinates, legDestination);
|
|
||||||
candidates.push({
|
candidates.push({
|
||||||
coordinates,
|
coordinates,
|
||||||
usedEdges: [startSnap.edge],
|
usedEdges: [startSnap.edge],
|
||||||
distanceNm: sumRouteDistanceNm(coordinates),
|
distanceNm: sumRouteDistanceNm(coordinates),
|
||||||
costNm:
|
costNm:
|
||||||
startSnap.distanceNm +
|
sumRouteDistanceNm(coordinates) *
|
||||||
sumRouteDistanceNm(directOnEdge) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) +
|
edgePenaltyMultiplier(startSnap.edge, penaltyCounts)
|
||||||
destinationSnap.distanceNm
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,7 +687,6 @@ function buildFairwayLeg(
|
|||||||
const coordinates: Coordinate[] = [];
|
const coordinates: Coordinate[] = [];
|
||||||
const usedEdges = new Map<string, FairwayEdge>();
|
const usedEdges = new Map<string, FairwayEdge>();
|
||||||
|
|
||||||
appendCoordinate(coordinates, legStart);
|
|
||||||
const startEdgeCoordinates = edgePathFromSnapToNode(startSnap, startNodeId);
|
const startEdgeCoordinates = edgePathFromSnapToNode(startSnap, startNodeId);
|
||||||
for (const coordinate of startEdgeCoordinates) {
|
for (const coordinate of startEdgeCoordinates) {
|
||||||
appendCoordinate(coordinates, coordinate);
|
appendCoordinate(coordinates, coordinate);
|
||||||
@@ -485,15 +705,14 @@ function buildFairwayLeg(
|
|||||||
appendCoordinate(coordinates, coordinate);
|
appendCoordinate(coordinates, coordinate);
|
||||||
}
|
}
|
||||||
usedEdges.set(destinationSnap.edge.id, destinationSnap.edge);
|
usedEdges.set(destinationSnap.edge.id, destinationSnap.edge);
|
||||||
appendCoordinate(coordinates, legDestination);
|
|
||||||
|
|
||||||
const distanceNm = sumRouteDistanceNm(coordinates);
|
const distanceNm = sumRouteDistanceNm(coordinates);
|
||||||
const costNm =
|
const costNm =
|
||||||
startSnap.distanceNm +
|
sumRouteDistanceNm(startEdgeCoordinates) *
|
||||||
sumRouteDistanceNm(startEdgeCoordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) +
|
edgePenaltyMultiplier(startSnap.edge, penaltyCounts) +
|
||||||
path.reduce((total, step) => total + step.weightNm, 0) +
|
path.reduce((total, step) => total + step.weightNm, 0) +
|
||||||
sumRouteDistanceNm(destinationEdgeCoordinates) * edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts) +
|
sumRouteDistanceNm(destinationEdgeCoordinates) *
|
||||||
destinationSnap.distanceNm;
|
edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts);
|
||||||
candidates.push({
|
candidates.push({
|
||||||
coordinates,
|
coordinates,
|
||||||
usedEdges: [...usedEdges.values()],
|
usedEdges: [...usedEdges.values()],
|
||||||
@@ -503,44 +722,9 @@ function buildFairwayLeg(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (candidates.length > previousCandidateCount) {
|
|
||||||
routedComponents.add(componentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates.sort((a, b) => a.costNm - b.costNm || a.distanceNm - b.distanceNm)[0] ?? null;
|
return candidates.sort((a, b) => a.costNm - b.costNm || a.distanceNm - b.distanceNm)[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findCompatibleEdgeSnapPairs(
|
|
||||||
graph: FairwayGraph,
|
|
||||||
start: Coordinate,
|
|
||||||
destination: Coordinate,
|
|
||||||
componentByNode: ReadonlyMap<string, string>
|
|
||||||
): EdgeSnapPair[] {
|
|
||||||
const startSnaps = findNearestEdgeSnapsByComponent(graph, start, componentByNode);
|
|
||||||
const destinationSnaps = findNearestEdgeSnapsByComponent(graph, destination, componentByNode);
|
|
||||||
const pairs: EdgeSnapPair[] = [];
|
|
||||||
|
|
||||||
for (const [componentId, componentStartSnaps] of startSnaps) {
|
|
||||||
const componentDestinationSnaps = destinationSnaps.get(componentId);
|
|
||||||
if (!componentDestinationSnaps) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const startSnap of componentStartSnaps) {
|
|
||||||
for (const destinationSnap of componentDestinationSnaps) {
|
|
||||||
pairs.push({ componentId, start: startSnap, destination: destinationSnap });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pairs.sort(
|
|
||||||
(a, b) =>
|
|
||||||
a.start.distanceNm + a.destination.distanceNm -
|
|
||||||
(b.start.distanceNm + b.destination.distanceNm)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNearestEdgeSnapsByComponent(
|
function findNearestEdgeSnapsByComponent(
|
||||||
graph: FairwayGraph,
|
graph: FairwayGraph,
|
||||||
coordinate: Coordinate,
|
coordinate: Coordinate,
|
||||||
@@ -578,14 +762,17 @@ function findNearestEdgeSnapsByComponent(
|
|||||||
if (nearestOnEdge) {
|
if (nearestOnEdge) {
|
||||||
const componentSnaps = nearestByComponent.get(componentId) ?? [];
|
const componentSnaps = nearestByComponent.get(componentId) ?? [];
|
||||||
componentSnaps.push(nearestOnEdge);
|
componentSnaps.push(nearestOnEdge);
|
||||||
componentSnaps.sort((a, b) => a.distanceNm - b.distanceNm);
|
|
||||||
if (componentSnaps.length > MAX_EDGE_SNAPS_PER_COMPONENT) {
|
|
||||||
componentSnaps.length = MAX_EDGE_SNAPS_PER_COMPONENT;
|
|
||||||
}
|
|
||||||
nearestByComponent.set(componentId, componentSnaps);
|
nearestByComponent.set(componentId, componentSnaps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retain the complete pool inside the hard radius. The route search sorts it,
|
||||||
|
// then applies its global pair-evaluation budget so a fifth candidate remains
|
||||||
|
// reachable without allowing dense graphs to trigger unbounded Dijkstra work.
|
||||||
|
for (const componentSnaps of nearestByComponent.values()) {
|
||||||
|
componentSnaps.sort((a, b) => a.distanceNm - b.distanceNm);
|
||||||
|
}
|
||||||
|
|
||||||
return nearestByComponent;
|
return nearestByComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -970,7 +1157,7 @@ function assessFairwayDepthSamples(
|
|||||||
|
|
||||||
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.005) {
|
if (previous && haversineDistanceNm(previous, coordinate) < 0.000001) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { coordinateToGeoJson, sumRouteDistanceNm } from "./geo.js";
|
import { coordinateToGeoJson, sumRouteDistanceNm } from "./geo.js";
|
||||||
import { buildFairwayRoute, type FairwayGraph } from "./fairway-routing.js";
|
import {
|
||||||
|
buildFairwayRoute,
|
||||||
|
EMDEN_BORKUM_DEMO_GRAPH,
|
||||||
|
type FairwayGraph
|
||||||
|
} 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,
|
DepthSample,
|
||||||
@@ -11,13 +15,27 @@ import type {
|
|||||||
|
|
||||||
const DEFAULT_CRUISE_SPEED_KN = 6;
|
const DEFAULT_CRUISE_SPEED_KN = 6;
|
||||||
|
|
||||||
export function buildRoute(request: RouteRequest, graph?: FairwayGraph): RouteResult | null {
|
export type BuildRouteOptions = {
|
||||||
if (graph) {
|
graph?: FairwayGraph;
|
||||||
return buildFairwayRoute(request, graph);
|
allowDemoSeedGraphs?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildRoute(
|
||||||
|
request: RouteRequest,
|
||||||
|
source?: FairwayGraph | BuildRouteOptions
|
||||||
|
): RouteResult | null {
|
||||||
|
const options = source && "nodes" in source ? { graph: source } : source;
|
||||||
|
|
||||||
|
if (options?.graph) {
|
||||||
|
return buildFairwayRoute(request, options.graph);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options?.allowDemoSeedGraphs) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
buildFairwayRoute(request) ??
|
buildFairwayRoute(request, EMDEN_BORKUM_DEMO_GRAPH) ??
|
||||||
buildFairwayRoute(request, EMDEN_EAST_EMS_GRAPH) ??
|
buildFairwayRoute(request, EMDEN_EAST_EMS_GRAPH) ??
|
||||||
buildFairwayRoute(request, EMDEN_HAMM_GRAPH)
|
buildFairwayRoute(request, EMDEN_HAMM_GRAPH)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,6 +51,18 @@ export type GeoJsonLineString = {
|
|||||||
coordinates: [number, number][];
|
coordinates: [number, number][];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RouteSnap = {
|
||||||
|
requested: Coordinate;
|
||||||
|
snapped: Coordinate;
|
||||||
|
distanceM: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteSnaps = {
|
||||||
|
start: RouteSnap;
|
||||||
|
waypoints: RouteSnap[];
|
||||||
|
destination: RouteSnap;
|
||||||
|
};
|
||||||
|
|
||||||
type RouteDetails = {
|
type RouteDetails = {
|
||||||
geometry: GeoJsonLineString;
|
geometry: GeoJsonLineString;
|
||||||
distanceNm: number;
|
distanceNm: number;
|
||||||
@@ -62,6 +74,7 @@ type RouteDetails = {
|
|||||||
unknownDepthRatio: number;
|
unknownDepthRatio: number;
|
||||||
dataSources: string[];
|
dataSources: string[];
|
||||||
routingMode?: "manual" | "fairway";
|
routingMode?: "manual" | "fairway";
|
||||||
|
routeSnaps?: RouteSnaps;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RouteOption = RouteDetails & {
|
export type RouteOption = RouteDetails & {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
buildFairwayRoutes,
|
buildFairwayRoutes,
|
||||||
buildManualRoute,
|
buildManualRoute,
|
||||||
buildRoute,
|
buildRoute,
|
||||||
|
FairwayRoutingSearchLimitError,
|
||||||
haversineDistanceNm,
|
haversineDistanceNm,
|
||||||
requiredDepthM,
|
requiredDepthM,
|
||||||
type FairwayEdge,
|
type FairwayEdge,
|
||||||
@@ -49,10 +50,10 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
|
|||||||
{ id: "start-decoy-b", coordinate: { lat: 53.342, lon: 7.187 } },
|
{ id: "start-decoy-b", coordinate: { lat: 53.342, lon: 7.187 } },
|
||||||
{ id: "destination-decoy-a", coordinate: { lat: 53.3282, lon: 6.9304 } },
|
{ id: "destination-decoy-a", coordinate: { lat: 53.3282, lon: 6.9304 } },
|
||||||
{ id: "destination-decoy-b", coordinate: { lat: 53.3286, lon: 6.9294 } },
|
{ id: "destination-decoy-b", coordinate: { lat: 53.3286, lon: 6.9294 } },
|
||||||
{ id: "shared-start", coordinate: { lat: 53.3395697, lon: 7.1848883 } },
|
{ id: "shared-start", coordinate: { lat: 53.34135, lon: 7.186 } },
|
||||||
{ id: "shared-east", coordinate: { lat: 53.3321722, lon: 7.1329034 } },
|
{ id: "shared-east", coordinate: { lat: 53.3321722, lon: 7.1329034 } },
|
||||||
{ id: "shared-south", coordinate: { lat: 53.313849, lon: 7.0011017 } },
|
{ id: "shared-south", coordinate: { lat: 53.313849, lon: 7.0011017 } },
|
||||||
{ id: "shared-destination", coordinate: { lat: 53.3303531, lon: 6.9334715 } }
|
{ id: "shared-destination", coordinate: { lat: 53.32845, lon: 6.9304 } }
|
||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
edge("start-decoy", "start-decoy-a", "start-decoy-b", [
|
edge("start-decoy", "start-decoy-a", "start-decoy-b", [
|
||||||
@@ -64,7 +65,7 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
|
|||||||
{ lat: 53.3286, lon: 6.9294 }
|
{ lat: 53.3286, lon: 6.9294 }
|
||||||
], { source: "closer-but-disconnected-destination" }),
|
], { source: "closer-but-disconnected-destination" }),
|
||||||
edge("shared-east", "shared-start", "shared-east", [
|
edge("shared-east", "shared-start", "shared-east", [
|
||||||
{ lat: 53.3395697, lon: 7.1848883 },
|
{ lat: 53.34135, lon: 7.186 },
|
||||||
{ lat: 53.3321722, lon: 7.1329034 }
|
{ lat: 53.3321722, lon: 7.1329034 }
|
||||||
], { source: "shared-local-component" }),
|
], { source: "shared-local-component" }),
|
||||||
edge("shared-south", "shared-east", "shared-south", [
|
edge("shared-south", "shared-east", "shared-south", [
|
||||||
@@ -73,7 +74,7 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
|
|||||||
], { source: "shared-local-component" }),
|
], { source: "shared-local-component" }),
|
||||||
edge("shared-west", "shared-south", "shared-destination", [
|
edge("shared-west", "shared-south", "shared-destination", [
|
||||||
{ lat: 53.313849, lon: 7.0011017 },
|
{ lat: 53.313849, lon: 7.0011017 },
|
||||||
{ lat: 53.3303531, lon: 6.9334715 }
|
{ lat: 53.32845, lon: 6.9304 }
|
||||||
], { source: "shared-local-component" })
|
], { source: "shared-local-component" })
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -147,11 +148,14 @@ describe("route assessment", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
||||||
const result = buildRoute({
|
const result = buildRoute(
|
||||||
|
{
|
||||||
start: EMDEN_AUSSENHAFEN,
|
start: EMDEN_AUSSENHAFEN,
|
||||||
destination: BORKUM_REEDE,
|
destination: BORKUM_REEDE,
|
||||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||||
});
|
},
|
||||||
|
{ allowDemoSeedGraphs: true }
|
||||||
|
);
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
if (!result) {
|
if (!result) {
|
||||||
throw new Error("Expected fairway route");
|
throw new Error("Expected fairway route");
|
||||||
@@ -180,13 +184,16 @@ describe("route assessment", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("snaps nearby Emden-Borkum clicks to fairway segments instead of requiring exact graph nodes", () => {
|
it("snaps nearby Emden-Borkum clicks to fairway segments instead of requiring exact graph nodes", () => {
|
||||||
const clickedStart = { lat: 53.3418, lon: 7.1904 };
|
const clickedStart = { lat: 53.341276, lon: 7.189146 };
|
||||||
const clickedDestination = { lat: 53.5608, lon: 6.7548 };
|
const clickedDestination = { lat: 53.560625, lon: 6.751271 };
|
||||||
const result = buildRoute({
|
const result = buildRoute(
|
||||||
|
{
|
||||||
start: clickedStart,
|
start: clickedStart,
|
||||||
destination: clickedDestination,
|
destination: clickedDestination,
|
||||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||||
});
|
},
|
||||||
|
{ allowDemoSeedGraphs: true }
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -195,8 +202,26 @@ describe("route assessment", () => {
|
|||||||
|
|
||||||
expect(result.routingMode).toBe("fairway");
|
expect(result.routingMode).toBe("fairway");
|
||||||
expect(result.geometry.coordinates.length).toBeGreaterThan(20);
|
expect(result.geometry.coordinates.length).toBeGreaterThan(20);
|
||||||
expect(result.geometry.coordinates[0]).toEqual([clickedStart.lon, clickedStart.lat]);
|
expect(result.routeSnaps?.start.requested).toEqual(clickedStart);
|
||||||
expect(result.geometry.coordinates.at(-1)).toEqual([clickedDestination.lon, clickedDestination.lat]);
|
expect(result.routeSnaps?.destination.requested).toEqual(clickedDestination);
|
||||||
|
expect(result.routeSnaps?.start.distanceM).toBeGreaterThan(0);
|
||||||
|
expect(result.routeSnaps?.destination.distanceM).toBeGreaterThan(0);
|
||||||
|
expect(
|
||||||
|
result.warnings.some((warning) => warning.code === "ROUTE_POINT_SNAPPED")
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.geometry.coordinates[0]).toEqual([
|
||||||
|
result.routeSnaps?.start.snapped.lon,
|
||||||
|
result.routeSnaps?.start.snapped.lat
|
||||||
|
]);
|
||||||
|
expect(result.geometry.coordinates.at(-1)).toEqual([
|
||||||
|
result.routeSnaps?.destination.snapped.lon,
|
||||||
|
result.routeSnaps?.destination.snapped.lat
|
||||||
|
]);
|
||||||
|
expect(result.geometry.coordinates[0]).not.toEqual([clickedStart.lon, clickedStart.lat]);
|
||||||
|
expect(result.geometry.coordinates.at(-1)).not.toEqual([
|
||||||
|
clickedDestination.lon,
|
||||||
|
clickedDestination.lat
|
||||||
|
]);
|
||||||
expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed");
|
expect(result.dataSources).toContain("fairway-graph:ems-borkum-seed");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -213,13 +238,21 @@ describe("route assessment", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(routes).toHaveLength(1);
|
expect(routes).toHaveLength(1);
|
||||||
expect(routes[0]?.geometry.coordinates[0]).toEqual([start.lon, start.lat]);
|
expect(routes[0]?.geometry.coordinates[0]).toEqual([
|
||||||
expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]);
|
routes[0]?.routeSnaps?.start.snapped.lon,
|
||||||
|
routes[0]?.routeSnaps?.start.snapped.lat
|
||||||
|
]);
|
||||||
|
expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([
|
||||||
|
routes[0]?.routeSnaps?.destination.snapped.lon,
|
||||||
|
routes[0]?.routeSnaps?.destination.snapped.lat
|
||||||
|
]);
|
||||||
|
expect(routes[0]?.routeSnaps?.start.requested).toEqual(start);
|
||||||
|
expect(routes[0]?.routeSnaps?.destination.requested).toEqual(destination);
|
||||||
expect(routes[0]?.dataSources).toContain("shared-local-component");
|
expect(routes[0]?.dataSources).toContain("shared-local-component");
|
||||||
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-start");
|
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-start");
|
||||||
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-destination");
|
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-destination");
|
||||||
expect(routes[0]?.distanceNm).toBeGreaterThan(9.5);
|
expect(routes[0]?.distanceNm).toBeGreaterThan(9.3);
|
||||||
expect(routes[0]?.distanceNm).toBeLessThan(10.5);
|
expect(routes[0]?.distanceNm).toBeLessThan(9.7);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns no route when start and destination have no shared component inside the snap radius", () => {
|
it("returns no route when start and destination have no shared component inside the snap radius", () => {
|
||||||
@@ -241,6 +274,72 @@ describe("route assessment", () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts a nearby harbour click but hard-rejects snaps farther than 150 metres", () => {
|
||||||
|
const graph: FairwayGraph = {
|
||||||
|
...singleEdgeGraph({}),
|
||||||
|
maxSnapDistanceNm: 2
|
||||||
|
};
|
||||||
|
const nearbyRoute = buildFairwayRoute(
|
||||||
|
{
|
||||||
|
start: { lat: 52 + 140 / 1852 / 60, lon: 7 },
|
||||||
|
destination: { lat: 52, lon: 7.04 },
|
||||||
|
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||||
|
},
|
||||||
|
graph
|
||||||
|
);
|
||||||
|
const remoteRoute = buildFairwayRoute(
|
||||||
|
{
|
||||||
|
start: { lat: 52 + 160 / 1852 / 60, lon: 7 },
|
||||||
|
destination: { lat: 52, lon: 7.04 },
|
||||||
|
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||||
|
},
|
||||||
|
graph
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(nearbyRoute?.routeSnaps?.start.distanceM).toBeCloseTo(140, 0);
|
||||||
|
expect(nearbyRoute?.geometry.coordinates[0]).toEqual([7, 52]);
|
||||||
|
expect(
|
||||||
|
nearbyRoute?.warnings.find((warning) => warning.code === "ROUTE_POINT_SNAPPED")
|
||||||
|
).toMatchObject({ severity: "caution" });
|
||||||
|
expect(remoteRoute).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses one graph snap for an intermediate waypoint without drawing raw access segments", () => {
|
||||||
|
const waypoint = { lat: 52.00025, lon: 7.02 };
|
||||||
|
const graph: FairwayGraph = {
|
||||||
|
id: "waypoint-snap-test",
|
||||||
|
name: "Wegpunkt-Snap-Test",
|
||||||
|
maxSnapDistanceNm: 0.2,
|
||||||
|
nodes: [
|
||||||
|
{ id: "a", coordinate: { lat: 52, lon: 7 } },
|
||||||
|
{ id: "b", coordinate: { lat: 52, lon: 7.02 } },
|
||||||
|
{ id: "c", coordinate: { lat: 52, lon: 7.04 } }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge("ab", "a", "b", [{ lat: 52, lon: 7 }, { lat: 52, lon: 7.02 }]),
|
||||||
|
edge("bc", "b", "c", [{ lat: 52, lon: 7.02 }, { lat: 52, lon: 7.04 }])
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const route = buildFairwayRoute(
|
||||||
|
{
|
||||||
|
start: { lat: 52, lon: 7 },
|
||||||
|
destination: { lat: 52, lon: 7.04 },
|
||||||
|
waypoints: [waypoint],
|
||||||
|
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||||
|
},
|
||||||
|
graph
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(route).not.toBeNull();
|
||||||
|
expect(route?.routeSnaps?.waypoints).toHaveLength(1);
|
||||||
|
expect(route?.routeSnaps?.waypoints[0]).toMatchObject({
|
||||||
|
requested: waypoint,
|
||||||
|
snapped: { lat: 52, lon: 7.02 }
|
||||||
|
});
|
||||||
|
expect(route?.geometry.coordinates).not.toContainEqual([waypoint.lon, waypoint.lat]);
|
||||||
|
expect(route?.geometry.coordinates).toContainEqual([7.02, 52]);
|
||||||
|
});
|
||||||
|
|
||||||
it("tries another snap in the same component when the nearest one-way branch cannot be exited", () => {
|
it("tries another snap in the same component when the nearest one-way branch cannot be exited", () => {
|
||||||
const graph: FairwayGraph = {
|
const graph: FairwayGraph = {
|
||||||
id: "oneway-snap-fallback",
|
id: "oneway-snap-fallback",
|
||||||
@@ -249,14 +348,14 @@ describe("route assessment", () => {
|
|||||||
nodes: [
|
nodes: [
|
||||||
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
|
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
|
||||||
{ id: "destination", coordinate: { lat: 52, lon: 7.04 } },
|
{ id: "destination", coordinate: { lat: 52, lon: 7.04 } },
|
||||||
{ id: "oneway-dead-end", coordinate: { lat: 52.001, lon: 7 } }
|
{ id: "oneway-dead-end", coordinate: { lat: 52.00035, lon: 7 } }
|
||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
edge(
|
edge(
|
||||||
"oneway-trap",
|
"oneway-trap",
|
||||||
"junction",
|
"junction",
|
||||||
"oneway-dead-end",
|
"oneway-dead-end",
|
||||||
[{ lat: 52, lon: 7 }, { lat: 52.001, lon: 7 }],
|
[{ lat: 52, lon: 7 }, { lat: 52.00035, lon: 7 }],
|
||||||
{ oneway: true, source: "oneway-trap" }
|
{ oneway: true, source: "oneway-trap" }
|
||||||
),
|
),
|
||||||
edge(
|
edge(
|
||||||
@@ -270,7 +369,7 @@ describe("route assessment", () => {
|
|||||||
};
|
};
|
||||||
const route = buildFairwayRoute(
|
const route = buildFairwayRoute(
|
||||||
{
|
{
|
||||||
start: { lat: 52.001, lon: 7 },
|
start: { lat: 52.00035, lon: 7 },
|
||||||
destination: { lat: 52, lon: 7.04 },
|
destination: { lat: 52, lon: 7.04 },
|
||||||
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||||
},
|
},
|
||||||
@@ -282,6 +381,183 @@ describe("route assessment", () => {
|
|||||||
expect(route?.dataSources).not.toContain("oneway-trap");
|
expect(route?.dataSources).not.toContain("oneway-trap");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps searching when only the fifth-nearest snap in a component is directionally routable", () => {
|
||||||
|
const start = { lat: 52.00035, lon: 7.0005 };
|
||||||
|
const graph: FairwayGraph = {
|
||||||
|
id: "fifth-directed-snap",
|
||||||
|
name: "Adaptiver gerichteter Snap-Test",
|
||||||
|
maxSnapDistanceNm: 1,
|
||||||
|
nodes: [
|
||||||
|
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
|
||||||
|
{ id: "trap-1", coordinate: start },
|
||||||
|
{ id: "trap-2", coordinate: { lat: 52.00036, lon: 7.0005 } },
|
||||||
|
{ id: "trap-3", coordinate: { lat: 52.00034, lon: 7.0005 } },
|
||||||
|
{ id: "trap-4", coordinate: { lat: 52.00035, lon: 7.00052 } },
|
||||||
|
{ id: "destination", coordinate: { lat: 52, lon: 7.04 } }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
edge("trap-1", "junction", "trap-1", [{ lat: 52, lon: 7 }, start], {
|
||||||
|
oneway: true,
|
||||||
|
source: "oneway-trap-1"
|
||||||
|
}),
|
||||||
|
edge("trap-2", "junction", "trap-2", [
|
||||||
|
{ lat: 52, lon: 7 },
|
||||||
|
{ lat: 52.00036, lon: 7.0005 }
|
||||||
|
], { oneway: true, source: "oneway-trap-2" }),
|
||||||
|
edge("trap-3", "junction", "trap-3", [
|
||||||
|
{ lat: 52, lon: 7 },
|
||||||
|
{ lat: 52.00034, lon: 7.0005 }
|
||||||
|
], { oneway: true, source: "oneway-trap-3" }),
|
||||||
|
edge("trap-4", "junction", "trap-4", [
|
||||||
|
{ lat: 52, lon: 7 },
|
||||||
|
{ lat: 52.00035, lon: 7.00052 }
|
||||||
|
], { oneway: true, source: "oneway-trap-4" }),
|
||||||
|
edge("main-route", "junction", "destination", [
|
||||||
|
{ lat: 52, lon: 7 },
|
||||||
|
{ lat: 52, lon: 7.04 }
|
||||||
|
], { source: "fifth-routable-edge" })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = buildFairwayRoute(
|
||||||
|
{
|
||||||
|
start,
|
||||||
|
destination: { lat: 52, lon: 7.04 },
|
||||||
|
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||||
|
},
|
||||||
|
graph
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(route).not.toBeNull();
|
||||||
|
expect(route?.routeSnaps?.start.distanceM).toBeGreaterThan(30);
|
||||||
|
expect(route?.routeSnaps?.start.distanceM).toBeLessThan(50);
|
||||||
|
expect(route?.dataSources).toContain("fifth-routable-edge");
|
||||||
|
expect(route?.dataSources.some((source) => source.startsWith("oneway-trap"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a much shorter route through the fifth-nearest snap", () => {
|
||||||
|
const start = { lat: 52, lon: 7 };
|
||||||
|
const offsetLat = (metres: number) => metres / 1852 / 60;
|
||||||
|
const longEntry = { lat: 52.08, lon: 7 };
|
||||||
|
const join = { lat: 52, lon: 7.08 };
|
||||||
|
const destination = { lat: 52, lon: 7.09 };
|
||||||
|
const trapStarts = [0, 1, 2, 3].map((metres) => ({
|
||||||
|
lat: start.lat + offsetLat(metres),
|
||||||
|
lon: start.lon
|
||||||
|
}));
|
||||||
|
const shortStart = { lat: start.lat + offsetLat(9), lon: start.lon };
|
||||||
|
const graph: FairwayGraph = {
|
||||||
|
id: "fifth-shorter-snap",
|
||||||
|
name: "Kostenbewusster Snap-Test",
|
||||||
|
maxSnapDistanceNm: 1,
|
||||||
|
nodes: [
|
||||||
|
...trapStarts.map((coordinate, index) => ({
|
||||||
|
id: `trap-start-${index + 1}`,
|
||||||
|
coordinate
|
||||||
|
})),
|
||||||
|
{ id: "short-start", coordinate: shortStart },
|
||||||
|
{ id: "long-entry", coordinate: longEntry },
|
||||||
|
{ id: "join", coordinate: join },
|
||||||
|
{ id: "destination", coordinate: destination }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
...trapStarts.map((coordinate, index) =>
|
||||||
|
edge(
|
||||||
|
`long-access-${index + 1}`,
|
||||||
|
`trap-start-${index + 1}`,
|
||||||
|
"long-entry",
|
||||||
|
[coordinate, longEntry],
|
||||||
|
{ oneway: true, source: `long-access-${index + 1}` }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
edge("long-detour", "long-entry", "join", [
|
||||||
|
longEntry,
|
||||||
|
{ lat: 52.08, lon: 7.08 },
|
||||||
|
join
|
||||||
|
], { oneway: true, source: "long-detour" }),
|
||||||
|
edge("short-fifth", "short-start", "join", [
|
||||||
|
shortStart,
|
||||||
|
join
|
||||||
|
], { oneway: true, source: "short-fifth" }),
|
||||||
|
edge("destination-tail", "join", "destination", [
|
||||||
|
join,
|
||||||
|
destination
|
||||||
|
], { oneway: true, source: "destination-tail" })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const route = buildFairwayRoute(
|
||||||
|
{
|
||||||
|
start,
|
||||||
|
destination,
|
||||||
|
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||||
|
},
|
||||||
|
graph
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(route).not.toBeNull();
|
||||||
|
expect(route?.distanceNm).toBeLessThan(5);
|
||||||
|
expect(route?.routeSnaps?.start.distanceM).toBeCloseTo(9, 0);
|
||||||
|
expect(route?.dataSources).toContain("short-fifth");
|
||||||
|
expect(route?.dataSources).not.toContain("long-detour");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports bounded snap-search truncation instead of silently claiming no route", () => {
|
||||||
|
const start = { lat: 52.00035, lon: 7.0005 };
|
||||||
|
const mainStart = { lat: 52, lon: 7.0005 };
|
||||||
|
const destination = { lat: 52, lon: 7.04 };
|
||||||
|
const trapCoordinates = Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
lat: start.lat + (index - 4) * 0.000001,
|
||||||
|
lon: start.lon
|
||||||
|
}));
|
||||||
|
const graph: FairwayGraph = {
|
||||||
|
id: "bounded-snap-search",
|
||||||
|
name: "Begrenzter Snap-Suchtest",
|
||||||
|
maxSnapDistanceNm: 1,
|
||||||
|
nodes: [
|
||||||
|
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
|
||||||
|
...trapCoordinates.map((coordinate, index) => ({
|
||||||
|
id: `trap-${index + 1}`,
|
||||||
|
coordinate
|
||||||
|
})),
|
||||||
|
{ id: "destination", coordinate: destination }
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
...trapCoordinates.map((coordinate, index) =>
|
||||||
|
edge(
|
||||||
|
`trap-${index + 1}`,
|
||||||
|
"junction",
|
||||||
|
`trap-${index + 1}`,
|
||||||
|
[{ lat: 52, lon: 7 }, coordinate],
|
||||||
|
{ oneway: true, source: `bounded-trap-${index + 1}` }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
edge("ninth-main-route", "junction", "destination", [
|
||||||
|
{ lat: 52, lon: 7 },
|
||||||
|
destination
|
||||||
|
], { oneway: true, source: "ninth-main-route" })
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const request = {
|
||||||
|
start,
|
||||||
|
destination,
|
||||||
|
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(() => buildFairwayRoute(request, graph)).toThrow(
|
||||||
|
FairwayRoutingSearchLimitError
|
||||||
|
);
|
||||||
|
|
||||||
|
const boundedRoute = buildFairwayRoute(
|
||||||
|
{ ...request, start: mainStart },
|
||||||
|
graph
|
||||||
|
);
|
||||||
|
expect(boundedRoute).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
boundedRoute?.warnings.some((warning) => warning.code === "ROUTE_SEARCH_LIMITED")
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not fall back to a misleading straight line when no fairway graph matches", () => {
|
it("does not fall back to a misleading straight line when no fairway graph matches", () => {
|
||||||
const result = buildRoute({
|
const result = buildRoute({
|
||||||
start: { lat: 54.1749, lon: 12.0731 },
|
start: { lat: 54.1749, lon: 12.0731 },
|
||||||
@@ -292,12 +568,36 @@ describe("route assessment", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not silently use demo seed graphs when no routing source is provided", () => {
|
||||||
|
const request = {
|
||||||
|
start: EMDEN_AUSSENHAFEN,
|
||||||
|
destination: BORKUM_REEDE,
|
||||||
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||||
|
};
|
||||||
|
const result = buildRoute(request);
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
|
||||||
|
const buildWithoutGraph = buildFairwayRoute as unknown as (
|
||||||
|
routeRequest: typeof request
|
||||||
|
) => ReturnType<typeof buildFairwayRoute>;
|
||||||
|
const buildManyWithoutGraph = buildFairwayRoutes as unknown as (
|
||||||
|
routeRequest: typeof request
|
||||||
|
) => ReturnType<typeof buildFairwayRoutes>;
|
||||||
|
|
||||||
|
expect(buildWithoutGraph(request)).toBeNull();
|
||||||
|
expect(buildManyWithoutGraph(request)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("routes Emden to Hamm via the Ems, Dortmund-Ems-Kanal and Datteln-Hamm-Kanal fallback", () => {
|
it("routes Emden to Hamm via the Ems, Dortmund-Ems-Kanal and Datteln-Hamm-Kanal fallback", () => {
|
||||||
const result = buildRoute({
|
const result = buildRoute(
|
||||||
|
{
|
||||||
start: EMDEN_AUSSENHAFEN,
|
start: EMDEN_AUSSENHAFEN,
|
||||||
destination: HAMM_INNENSTADT_MARINA,
|
destination: HAMM_INNENSTADT_MARINA,
|
||||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||||
});
|
},
|
||||||
|
{ allowDemoSeedGraphs: true }
|
||||||
|
);
|
||||||
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result?.distanceNm).toBeGreaterThan(145);
|
expect(result?.distanceNm).toBeGreaterThan(145);
|
||||||
|
|||||||
Reference in New Issue
Block a user