Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
calculateRouteGuidance,
|
||||
type GeoJsonLineString,
|
||||
type RouteGuidanceRoute
|
||||
} from "../src/index.js";
|
||||
|
||||
const METRES_PER_EQUATOR_DEGREE = 111_195.08;
|
||||
|
||||
function latitudeOffsetM(metres: number): number {
|
||||
return metres / METRES_PER_EQUATOR_DEGREE;
|
||||
}
|
||||
|
||||
function requireGuidance(
|
||||
route: RouteGuidanceRoute,
|
||||
position = { lat: 0, lon: 0 },
|
||||
input: Partial<Parameters<typeof calculateRouteGuidance>[0]> = {},
|
||||
options: Parameters<typeof calculateRouteGuidance>[1] = {}
|
||||
) {
|
||||
const result = calculateRouteGuidance({ route, position, ...input }, options);
|
||||
expect(result).not.toBeNull();
|
||||
if (!result) {
|
||||
throw new Error("Expected valid route guidance");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
describe("route guidance", () => {
|
||||
it("projects a GPS fix geodesically and steers back towards the route", () => {
|
||||
const result = requireGuidance(
|
||||
[[0, 0], [0.02, 0]],
|
||||
{ lat: latitudeOffsetM(100), lon: 0.005 },
|
||||
{ headingDeg: 90, accuracyM: 5 },
|
||||
{ minLookaheadM: 100, maxLookaheadM: 100, offRouteThresholdM: 200 }
|
||||
);
|
||||
|
||||
expect(result.distanceToRouteM).toBeCloseTo(100, 0);
|
||||
expect(result.crossTrackErrorM).toBeCloseTo(-100, 0);
|
||||
expect(result.crossTrackSide).toBe("port");
|
||||
expect(result.nearestRoutePoint.lat).toBeCloseTo(0, 6);
|
||||
expect(result.nearestRoutePoint.lon).toBeCloseTo(0.005, 5);
|
||||
expect(result.desiredCourseDeg).toBeGreaterThan(90);
|
||||
expect(result.desiredCourseDeg).toBeLessThan(180);
|
||||
expect(result.courseCorrectionDeg).toBeGreaterThan(0);
|
||||
expect(result.status).toBe("on-route");
|
||||
});
|
||||
|
||||
it("adapts the interception course continuously to either side of the line", () => {
|
||||
const route = [[0, 0], [0.02, 0]] as const;
|
||||
const north = requireGuidance(
|
||||
route,
|
||||
{ lat: latitudeOffsetM(50), lon: 0.005 },
|
||||
{},
|
||||
{ minLookaheadM: 150, maxLookaheadM: 150 }
|
||||
);
|
||||
const south = requireGuidance(
|
||||
route,
|
||||
{ lat: latitudeOffsetM(-50), lon: 0.005 },
|
||||
{},
|
||||
{ minLookaheadM: 150, maxLookaheadM: 150 }
|
||||
);
|
||||
|
||||
expect(north.desiredCourseDeg).toBeGreaterThan(90);
|
||||
expect(south.desiredCourseDeg).toBeLessThan(90);
|
||||
expect(north.crossTrackSide).toBe("port");
|
||||
expect(south.crossTrackSide).toBe("starboard");
|
||||
});
|
||||
|
||||
it("uses speed and GPS accuracy for lookahead and respects its upper bound", () => {
|
||||
const route = [[0, 0], [0.2, 0]] as const;
|
||||
const stationary = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 0, accuracyM: 0 });
|
||||
const moving = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 10, accuracyM: 20 });
|
||||
const capped = requireGuidance(route, { lat: 0, lon: 0 }, { speedKn: 100, accuracyM: 20 });
|
||||
|
||||
expect(stationary.lookaheadDistanceM).toBeCloseTo(50, 6);
|
||||
expect(moving.lookaheadDistanceM).toBeGreaterThan(stationary.lookaheadDistanceM);
|
||||
expect(moving.lookaheadDistanceM).toBeCloseTo(182.89, 1);
|
||||
expect(capped.lookaheadDistanceM).toBe(400);
|
||||
});
|
||||
|
||||
it("caps its target at a significant bend instead of cutting across a canal corner", () => {
|
||||
const result = requireGuidance(
|
||||
[[0, 0], [0.001, 0], [0.001, -0.003]],
|
||||
{ lat: 0, lon: 0 },
|
||||
{ speedKn: 15 },
|
||||
{
|
||||
minLookaheadM: 50,
|
||||
maxLookaheadM: 500,
|
||||
lookaheadTimeS: 30,
|
||||
turnSampleDistanceM: 30,
|
||||
turnSearchDistanceM: 1_000
|
||||
}
|
||||
);
|
||||
|
||||
expect(result.nextTurn?.direction).toBe("starboard");
|
||||
expect(result.lookaheadDistanceM).toBeCloseTo(111.2, 0);
|
||||
expect(result.lookaheadPoint.lon).toBeCloseTo(0.001, 6);
|
||||
expect(result.lookaheadPoint.lat).toBeCloseTo(0, 6);
|
||||
expect(result.desiredCourseDeg).toBeCloseTo(90, 1);
|
||||
});
|
||||
|
||||
it("returns a wrapped course correction and no invented correction without heading", () => {
|
||||
const route = [[0, 0], [0.001, 0.02]] as const;
|
||||
const withHeading = requireGuidance(route, { lat: 0, lon: 0 }, { headingDeg: 355 });
|
||||
const withoutHeading = requireGuidance(route);
|
||||
|
||||
expect(withHeading.desiredCourseDeg).toBeGreaterThan(2);
|
||||
expect(withHeading.desiredCourseDeg).toBeLessThan(4);
|
||||
expect(withHeading.courseCorrectionDeg).toBeGreaterThan(7);
|
||||
expect(withHeading.courseCorrectionDeg).toBeLessThan(9);
|
||||
expect(withoutHeading.courseCorrectionDeg).toBeNull();
|
||||
});
|
||||
|
||||
it("reports progress, route remainder, and direct destination distance", () => {
|
||||
const result = requireGuidance(
|
||||
[[0, 0], [0.01, 0], [0.02, 0]],
|
||||
{ lat: 0, lon: 0.0075 }
|
||||
);
|
||||
|
||||
expect(result.routeLengthM).toBeCloseTo(2_224, 0);
|
||||
expect(result.progressM).toBeCloseTo(834, 0);
|
||||
expect(result.progressRatio).toBeCloseTo(0.375, 3);
|
||||
expect(result.remainingRouteDistanceM).toBeCloseTo(1_390, 0);
|
||||
expect(result.distanceToDestinationM).toBeCloseTo(1_390, 0);
|
||||
});
|
||||
|
||||
it("distinguishes confirmed off-route fixes from unreliable GPS fixes", () => {
|
||||
const route = [[0, 0], [0.02, 0]] as const;
|
||||
const offRoute = requireGuidance(
|
||||
route,
|
||||
{ lat: latitudeOffsetM(170), lon: 0.005 },
|
||||
{ accuracyM: 20 },
|
||||
{ offRouteThresholdM: 100, maxReliableAccuracyM: 100 }
|
||||
);
|
||||
const inaccurate = requireGuidance(
|
||||
route,
|
||||
{ lat: latitudeOffsetM(170), lon: 0.005 },
|
||||
{ accuracyM: 150 },
|
||||
{ offRouteThresholdM: 100, maxReliableAccuracyM: 100 }
|
||||
);
|
||||
|
||||
expect(offRoute.conservativeDistanceToRouteM).toBeCloseTo(150, 0);
|
||||
expect(offRoute.isOffRoute).toBe(true);
|
||||
expect(offRoute.status).toBe("off-route");
|
||||
expect(inaccurate.positionReliable).toBe(false);
|
||||
expect(inaccurate.isOffRoute).toBe(false);
|
||||
expect(inaccurate.status).toBe("gps-unreliable");
|
||||
});
|
||||
|
||||
it("marks arrival only near the route end", () => {
|
||||
const route = [[0, 0], [0.01, 0]] as const;
|
||||
const arrived = requireGuidance(route, { lat: 0, lon: 0.0099 }, { accuracyM: 5 });
|
||||
const nearDestinationButAtEarlyLoop = requireGuidance(
|
||||
[[0, 0], [0.02, 0], [0.0001, 0]],
|
||||
{ lat: 0, lon: 0 },
|
||||
{ accuracyM: 2 }
|
||||
);
|
||||
|
||||
expect(arrived.status).toBe("arrived");
|
||||
expect(arrived.remainingRouteDistanceM).toBeLessThan(15);
|
||||
expect(nearDestinationButAtEarlyLoop.status).not.toBe("arrived");
|
||||
});
|
||||
|
||||
it("identifies starboard and port course changes with an advance warning", () => {
|
||||
const options = {
|
||||
turnSampleDistanceM: 40,
|
||||
turnNoticeDistanceM: 200,
|
||||
turnSearchDistanceM: 1_000,
|
||||
minLookaheadM: 20,
|
||||
maxLookaheadM: 20
|
||||
};
|
||||
const starboard = requireGuidance(
|
||||
[[0, 0], [0.005, 0], [0.005, -0.005]],
|
||||
{ lat: 0, lon: 0.004 },
|
||||
{},
|
||||
options
|
||||
);
|
||||
const port = requireGuidance(
|
||||
[[0, 0], [0.005, 0], [0.005, 0.005]],
|
||||
{ lat: 0, lon: 0.004 },
|
||||
{},
|
||||
options
|
||||
);
|
||||
|
||||
expect(starboard.nextTurn).toEqual(expect.objectContaining({ direction: "starboard" }));
|
||||
expect(starboard.nextTurn?.courseChangeDeg).toBeCloseTo(90, 0);
|
||||
expect(starboard.nextTurn?.distanceM).toBeCloseTo(111, 0);
|
||||
expect(starboard.status).toBe("approaching-turn");
|
||||
expect(port.nextTurn).toEqual(expect.objectContaining({ direction: "port" }));
|
||||
expect(port.nextTurn?.courseChangeDeg).toBeCloseTo(-90, 0);
|
||||
});
|
||||
|
||||
it("does not call insignificant line noise a turn", () => {
|
||||
const result = requireGuidance(
|
||||
[[0, 0], [0.001, 0], [0.002, 0.00001], [0.003, 0]],
|
||||
{ lat: 0, lon: 0 },
|
||||
{},
|
||||
{ turnMinimumChangeDeg: 20, turnSearchDistanceM: 1_000 }
|
||||
);
|
||||
|
||||
expect(result.nextTurn).toBeNull();
|
||||
expect(result.status).toBe("on-route");
|
||||
});
|
||||
|
||||
it("handles geodesic projection and lookahead across the antimeridian", () => {
|
||||
const result = requireGuidance(
|
||||
[[179.9, 0], [-179.9, 0]],
|
||||
{ lat: 0.001, lon: 180 },
|
||||
{},
|
||||
{ minLookaheadM: 100, maxLookaheadM: 100, offRouteThresholdM: 200 }
|
||||
);
|
||||
|
||||
expect(result.distanceToRouteM).toBeCloseTo(111.2, 0);
|
||||
expect(result.progressRatio).toBeCloseTo(0.5, 2);
|
||||
expect(Math.abs(result.nearestRoutePoint.lon)).toBeCloseTo(180, 5);
|
||||
expect(result.lookaheadPoint.lon).toBeLessThan(-179.9);
|
||||
});
|
||||
|
||||
it("uses previous progress to disambiguate crossings without a distant forward jump", () => {
|
||||
const crossingRoute = [
|
||||
[-0.01, -0.01],
|
||||
[0.01, 0.01],
|
||||
[0.01, -0.01],
|
||||
[-0.01, 0.01]
|
||||
] as const;
|
||||
// This point is exactly on the late diagonal and only a few metres from
|
||||
// the early diagonal at their crossing.
|
||||
const crossingPosition = { lat: -0.00002, lon: 0.00002 };
|
||||
const early = requireGuidance(
|
||||
crossingRoute,
|
||||
crossingPosition,
|
||||
{ previousProgressM: 1_500 },
|
||||
{ maxProgressJumpM: 1_000, progressAmbiguityM: 30 }
|
||||
);
|
||||
const late = requireGuidance(
|
||||
crossingRoute,
|
||||
crossingPosition,
|
||||
{ previousProgressM: 6_800 },
|
||||
{ maxProgressJumpM: 1_000, progressAmbiguityM: 30 }
|
||||
);
|
||||
|
||||
expect(early.progressM).toBeGreaterThan(1_500);
|
||||
expect(early.progressM).toBeLessThan(2_000);
|
||||
expect(late.progressM).toBeGreaterThan(6_500);
|
||||
expect(early.nearestSegmentIndex).toBe(0);
|
||||
expect(late.nearestSegmentIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("holds monotonic progress against small backwards GPS movement", () => {
|
||||
const previousProgressM = 0.005 * METRES_PER_EQUATOR_DEGREE;
|
||||
const result = requireGuidance(
|
||||
[[0, 0], [0.02, 0]],
|
||||
{ lat: 0, lon: 0.004 },
|
||||
{ previousProgressM }
|
||||
);
|
||||
|
||||
expect(result.progressM).toBeCloseTo(previousProgressM, 3);
|
||||
expect(result.nearestRoutePoint.lon).toBeCloseTo(0.004, 6);
|
||||
});
|
||||
|
||||
it("accepts GeoJSON and RouteResult shapes and rejects invalid or degenerate input", () => {
|
||||
const geometry: GeoJsonLineString = {
|
||||
type: "LineString",
|
||||
coordinates: [[7, 53], [7.01, 53.01]]
|
||||
};
|
||||
const routeResult = {
|
||||
geometry,
|
||||
distanceNm: 1,
|
||||
eta: null,
|
||||
warnings: [],
|
||||
minKnownDepthM: null,
|
||||
unknownDepthRatio: 1,
|
||||
dataSources: []
|
||||
};
|
||||
|
||||
expect(calculateRouteGuidance({ route: geometry, position: { lat: 53, lon: 7 } })).not.toBeNull();
|
||||
expect(calculateRouteGuidance({ route: routeResult, position: { lat: 53, lon: 7 } })).not.toBeNull();
|
||||
expect(calculateRouteGuidance({
|
||||
route: [[7, 53]],
|
||||
position: { lat: 53, lon: 7 }
|
||||
})).toBeNull();
|
||||
expect(calculateRouteGuidance({
|
||||
route: [[7, 53], [7, 53]],
|
||||
position: { lat: 53, lon: 7 }
|
||||
})).toBeNull();
|
||||
expect(calculateRouteGuidance({
|
||||
route: [[7, 53], [7.01, 53.01]],
|
||||
position: { lat: Number.NaN, lon: 7 }
|
||||
})).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user