Optimized routing
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
||||
buildFairwayRoutes,
|
||||
buildManualRoute,
|
||||
buildRoute,
|
||||
FairwayRoutingSearchLimitError,
|
||||
haversineDistanceNm,
|
||||
requiredDepthM,
|
||||
type FairwayEdge,
|
||||
@@ -49,10 +50,10 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
|
||||
{ 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-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-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: [
|
||||
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 }
|
||||
], { source: "closer-but-disconnected-destination" }),
|
||||
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 }
|
||||
], { source: "shared-local-component" }),
|
||||
edge("shared-south", "shared-east", "shared-south", [
|
||||
@@ -73,7 +74,7 @@ const COMPONENT_AWARE_SNAP_GRAPH: FairwayGraph = {
|
||||
], { source: "shared-local-component" }),
|
||||
edge("shared-west", "shared-south", "shared-destination", [
|
||||
{ lat: 53.313849, lon: 7.0011017 },
|
||||
{ lat: 53.3303531, lon: 6.9334715 }
|
||||
{ lat: 53.32845, lon: 6.9304 }
|
||||
], { source: "shared-local-component" })
|
||||
]
|
||||
};
|
||||
@@ -147,11 +148,14 @@ describe("route assessment", () => {
|
||||
});
|
||||
|
||||
it("routes Emden Außenhafen to Borkum Reede along the Ems fairway graph", () => {
|
||||
const result = buildRoute({
|
||||
start: EMDEN_AUSSENHAFEN,
|
||||
destination: BORKUM_REEDE,
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||
});
|
||||
const result = buildRoute(
|
||||
{
|
||||
start: EMDEN_AUSSENHAFEN,
|
||||
destination: BORKUM_REEDE,
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||
},
|
||||
{ allowDemoSeedGraphs: true }
|
||||
);
|
||||
expect(result).not.toBeNull();
|
||||
if (!result) {
|
||||
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", () => {
|
||||
const clickedStart = { lat: 53.3418, lon: 7.1904 };
|
||||
const clickedDestination = { lat: 53.5608, lon: 6.7548 };
|
||||
const result = buildRoute({
|
||||
start: clickedStart,
|
||||
destination: clickedDestination,
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||
});
|
||||
const clickedStart = { lat: 53.341276, lon: 7.189146 };
|
||||
const clickedDestination = { lat: 53.560625, lon: 6.751271 };
|
||||
const result = buildRoute(
|
||||
{
|
||||
start: clickedStart,
|
||||
destination: clickedDestination,
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 12 }
|
||||
},
|
||||
{ allowDemoSeedGraphs: true }
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
if (!result) {
|
||||
@@ -195,8 +202,26 @@ describe("route assessment", () => {
|
||||
|
||||
expect(result.routingMode).toBe("fairway");
|
||||
expect(result.geometry.coordinates.length).toBeGreaterThan(20);
|
||||
expect(result.geometry.coordinates[0]).toEqual([clickedStart.lon, clickedStart.lat]);
|
||||
expect(result.geometry.coordinates.at(-1)).toEqual([clickedDestination.lon, clickedDestination.lat]);
|
||||
expect(result.routeSnaps?.start.requested).toEqual(clickedStart);
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -213,13 +238,21 @@ describe("route assessment", () => {
|
||||
);
|
||||
|
||||
expect(routes).toHaveLength(1);
|
||||
expect(routes[0]?.geometry.coordinates[0]).toEqual([start.lon, start.lat]);
|
||||
expect(routes[0]?.geometry.coordinates.at(-1)).toEqual([destination.lon, destination.lat]);
|
||||
expect(routes[0]?.geometry.coordinates[0]).toEqual([
|
||||
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).not.toContain("closer-but-disconnected-start");
|
||||
expect(routes[0]?.dataSources).not.toContain("closer-but-disconnected-destination");
|
||||
expect(routes[0]?.distanceNm).toBeGreaterThan(9.5);
|
||||
expect(routes[0]?.distanceNm).toBeLessThan(10.5);
|
||||
expect(routes[0]?.distanceNm).toBeGreaterThan(9.3);
|
||||
expect(routes[0]?.distanceNm).toBeLessThan(9.7);
|
||||
});
|
||||
|
||||
it("returns no route when start and destination have no shared component inside the snap radius", () => {
|
||||
@@ -241,6 +274,72 @@ describe("route assessment", () => {
|
||||
).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", () => {
|
||||
const graph: FairwayGraph = {
|
||||
id: "oneway-snap-fallback",
|
||||
@@ -249,14 +348,14 @@ describe("route assessment", () => {
|
||||
nodes: [
|
||||
{ id: "junction", coordinate: { lat: 52, lon: 7 } },
|
||||
{ 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: [
|
||||
edge(
|
||||
"oneway-trap",
|
||||
"junction",
|
||||
"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" }
|
||||
),
|
||||
edge(
|
||||
@@ -270,7 +369,7 @@ describe("route assessment", () => {
|
||||
};
|
||||
const route = buildFairwayRoute(
|
||||
{
|
||||
start: { lat: 52.001, lon: 7 },
|
||||
start: { lat: 52.00035, lon: 7 },
|
||||
destination: { lat: 52, lon: 7.04 },
|
||||
vesselProfile: { draughtM: 1, safetyReserveM: 0.3 }
|
||||
},
|
||||
@@ -282,6 +381,183 @@ describe("route assessment", () => {
|
||||
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", () => {
|
||||
const result = buildRoute({
|
||||
start: { lat: 54.1749, lon: 12.0731 },
|
||||
@@ -292,12 +568,36 @@ describe("route assessment", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("routes Emden to Hamm via the Ems, Dortmund-Ems-Kanal and Datteln-Hamm-Kanal fallback", () => {
|
||||
const result = buildRoute({
|
||||
it("does not silently use demo seed graphs when no routing source is provided", () => {
|
||||
const request = {
|
||||
start: EMDEN_AUSSENHAFEN,
|
||||
destination: HAMM_INNENSTADT_MARINA,
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||
});
|
||||
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", () => {
|
||||
const result = buildRoute(
|
||||
{
|
||||
start: EMDEN_AUSSENHAFEN,
|
||||
destination: HAMM_INNENSTADT_MARINA,
|
||||
vesselProfile: { draughtM: 1.4, safetyReserveM: 0.5, cruiseSpeedKn: 6 }
|
||||
},
|
||||
{ allowDemoSeedGraphs: true }
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.distanceNm).toBeGreaterThan(145);
|
||||
|
||||
Reference in New Issue
Block a user