Initial Watermaps import
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
import {
|
||||
orderWaypointsAlongRoute,
|
||||
type Coordinate,
|
||||
type RouteResult,
|
||||
type VoyageHarbour
|
||||
} from "@watermaps/shared";
|
||||
import type { RouteBridgeAssessment } from "./routeWeatherReport";
|
||||
import type { RouteLock } from "./voyageHarbours";
|
||||
|
||||
export type RouteEventKind = "harbour" | "lock" | "bridge";
|
||||
|
||||
export type RouteEventCorridors = Record<RouteEventKind, number>;
|
||||
|
||||
export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly<RouteEventCorridors> = Object.freeze({
|
||||
harbour: 1.5,
|
||||
lock: 0.25,
|
||||
bridge: 0.08
|
||||
});
|
||||
|
||||
export type RouteEventEtaSpeedSource = "gps-sog" | "vessel-cruise-speed";
|
||||
export type RouteEventEtaReferenceSource = "current-time" | "route-departure";
|
||||
|
||||
/**
|
||||
* ETA assumptions are deliberately supplied by the caller. This prevents a
|
||||
* stale GPS speed or a planned cruise speed from being presented without its
|
||||
* provenance.
|
||||
*/
|
||||
export type RouteEventEtaBasis = {
|
||||
speedKn: number;
|
||||
speedSource: RouteEventEtaSpeedSource;
|
||||
referenceTime: string | number | Date;
|
||||
referenceSource: RouteEventEtaReferenceSource;
|
||||
};
|
||||
|
||||
export type RouteEventEta = {
|
||||
estimatedAt: string;
|
||||
minutesFromProgress: number;
|
||||
speedKn: number;
|
||||
speedSource: RouteEventEtaSpeedSource;
|
||||
referenceTime: string;
|
||||
referenceSource: RouteEventEtaReferenceSource;
|
||||
};
|
||||
|
||||
type RouteEventBase = {
|
||||
kind: RouteEventKind;
|
||||
id: string;
|
||||
name: string;
|
||||
coordinate: Coordinate;
|
||||
/** Position of the projected feature along the routed geometry. */
|
||||
routeDistanceNm: number;
|
||||
/** Shortest lateral distance between the feature and the route. */
|
||||
distanceFromRouteNm: number;
|
||||
remainingNm: number;
|
||||
eta: RouteEventEta | null;
|
||||
};
|
||||
|
||||
export type HarbourRouteEvent = RouteEventBase & {
|
||||
kind: "harbour";
|
||||
feature: VoyageHarbour;
|
||||
};
|
||||
|
||||
export type LockRouteEvent = RouteEventBase & {
|
||||
kind: "lock";
|
||||
feature: RouteLock;
|
||||
};
|
||||
|
||||
export type BridgeRouteEvent = RouteEventBase & {
|
||||
kind: "bridge";
|
||||
feature: RouteBridgeAssessment;
|
||||
};
|
||||
|
||||
export type UpcomingRouteEvent =
|
||||
| HarbourRouteEvent
|
||||
| LockRouteEvent
|
||||
| BridgeRouteEvent;
|
||||
|
||||
export type NextRouteEventsByKind = {
|
||||
harbour: HarbourRouteEvent | null;
|
||||
lock: LockRouteEvent | null;
|
||||
bridge: BridgeRouteEvent | null;
|
||||
};
|
||||
|
||||
export type UpcomingRouteEventsInput = {
|
||||
route: Pick<RouteResult, "geometry" | "distanceNm">;
|
||||
harbours?: readonly VoyageHarbour[];
|
||||
locks?: readonly RouteLock[];
|
||||
bridges?: readonly RouteBridgeAssessment[];
|
||||
/** Progress along the route. Invalid or negative values resolve to zero. */
|
||||
progressNm?: number | null;
|
||||
corridorsNm?: Partial<RouteEventCorridors>;
|
||||
etaBasis?: RouteEventEtaBasis | null;
|
||||
};
|
||||
|
||||
type RouteEventCandidate =
|
||||
| {
|
||||
projectionId: string;
|
||||
kind: "harbour";
|
||||
id: string;
|
||||
name: string;
|
||||
coordinate: Coordinate;
|
||||
feature: VoyageHarbour;
|
||||
}
|
||||
| {
|
||||
projectionId: string;
|
||||
kind: "lock";
|
||||
id: string;
|
||||
name: string;
|
||||
coordinate: Coordinate;
|
||||
feature: RouteLock;
|
||||
}
|
||||
| {
|
||||
projectionId: string;
|
||||
kind: "bridge";
|
||||
id: string;
|
||||
name: string;
|
||||
coordinate: Coordinate;
|
||||
feature: RouteBridgeAssessment;
|
||||
};
|
||||
|
||||
/**
|
||||
* Projects all supplied facilities onto the route, applies a corridor per
|
||||
* facility type and returns only the current or upcoming facilities in route
|
||||
* order.
|
||||
*
|
||||
* Every feature is projected again. In particular,
|
||||
* RouteBridgeAssessment.distanceNm is intentionally ignored because it is the
|
||||
* bridge's lateral distance to the route, not its distance along the route.
|
||||
*/
|
||||
export function upcomingRouteEvents(
|
||||
input: UpcomingRouteEventsInput
|
||||
): UpcomingRouteEvent[] {
|
||||
const progressNm = normalizeProgress(input.progressNm);
|
||||
const corridors = resolveCorridors(input.corridorsNm);
|
||||
const candidates = routeEventCandidates(input);
|
||||
if (candidates.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidatesByProjectionId = new Map(
|
||||
candidates.map((candidate) => [candidate.projectionId, candidate])
|
||||
);
|
||||
const projected = orderWaypointsAlongRoute(
|
||||
candidates.map((candidate) => ({
|
||||
id: candidate.projectionId,
|
||||
name: candidate.name,
|
||||
coordinate: candidate.coordinate
|
||||
})),
|
||||
input.route
|
||||
);
|
||||
|
||||
return projected.flatMap<UpcomingRouteEvent>((projection) => {
|
||||
const candidate = candidatesByProjectionId.get(projection.id);
|
||||
if (
|
||||
!candidate ||
|
||||
projection.distanceFromRouteNm > corridors[candidate.kind] ||
|
||||
projection.routeDistanceNm < progressNm
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const remainingNm = Math.max(0, projection.routeDistanceNm - progressNm);
|
||||
const common = {
|
||||
kind: candidate.kind,
|
||||
id: candidate.id,
|
||||
name: candidate.name,
|
||||
coordinate: candidate.coordinate,
|
||||
routeDistanceNm: projection.routeDistanceNm,
|
||||
distanceFromRouteNm: projection.distanceFromRouteNm,
|
||||
remainingNm,
|
||||
eta: estimateRouteEventEta(remainingNm, input.etaBasis)
|
||||
};
|
||||
|
||||
switch (candidate.kind) {
|
||||
case "harbour":
|
||||
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
|
||||
case "lock":
|
||||
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
|
||||
case "bridge":
|
||||
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function nextRouteEventsByKind(
|
||||
events: readonly UpcomingRouteEvent[]
|
||||
): NextRouteEventsByKind {
|
||||
const next: NextRouteEventsByKind = {
|
||||
harbour: null,
|
||||
lock: null,
|
||||
bridge: null
|
||||
};
|
||||
|
||||
for (const event of events) {
|
||||
switch (event.kind) {
|
||||
case "harbour":
|
||||
next.harbour ??= event;
|
||||
break;
|
||||
case "lock":
|
||||
next.lock ??= event;
|
||||
break;
|
||||
case "bridge":
|
||||
next.bridge ??= event;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function routeEventCandidates(input: UpcomingRouteEventsInput): RouteEventCandidate[] {
|
||||
const candidates: RouteEventCandidate[] = [];
|
||||
|
||||
input.harbours?.forEach((feature, index) => {
|
||||
candidates.push({
|
||||
projectionId: projectionId("harbour", index, feature.id),
|
||||
kind: "harbour",
|
||||
id: feature.id,
|
||||
name: feature.name,
|
||||
coordinate: feature.coordinate,
|
||||
feature
|
||||
});
|
||||
});
|
||||
input.locks?.forEach((feature, index) => {
|
||||
candidates.push({
|
||||
projectionId: projectionId("lock", index, feature.id),
|
||||
kind: "lock",
|
||||
id: feature.id,
|
||||
name: feature.name,
|
||||
coordinate: feature.coordinate,
|
||||
feature
|
||||
});
|
||||
});
|
||||
input.bridges?.forEach((feature, index) => {
|
||||
candidates.push({
|
||||
projectionId: projectionId("bridge", index, feature.id),
|
||||
kind: "bridge",
|
||||
id: feature.id,
|
||||
name: feature.name ?? feature.label ?? "Brücke",
|
||||
coordinate: feature.coordinate,
|
||||
feature
|
||||
});
|
||||
});
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function projectionId(kind: RouteEventKind, index: number, featureId: string) {
|
||||
return `${kind}:${index}:${featureId}`;
|
||||
}
|
||||
|
||||
function resolveCorridors(
|
||||
overrides: Partial<RouteEventCorridors> | undefined
|
||||
): RouteEventCorridors {
|
||||
return {
|
||||
harbour: nonNegativeOrDefault(
|
||||
overrides?.harbour,
|
||||
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour
|
||||
),
|
||||
lock: nonNegativeOrDefault(
|
||||
overrides?.lock,
|
||||
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.lock
|
||||
),
|
||||
bridge: nonNegativeOrDefault(
|
||||
overrides?.bridge,
|
||||
DEFAULT_ROUTE_EVENT_CORRIDORS_NM.bridge
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProgress(value: number | null | undefined) {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? Math.max(0, value)
|
||||
: 0;
|
||||
}
|
||||
|
||||
function nonNegativeOrDefault(value: number | undefined, fallback: number) {
|
||||
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? value
|
||||
: fallback;
|
||||
}
|
||||
|
||||
function estimateRouteEventEta(
|
||||
remainingNm: number,
|
||||
basis: RouteEventEtaBasis | null | undefined
|
||||
): RouteEventEta | null {
|
||||
if (
|
||||
!basis ||
|
||||
!Number.isFinite(basis.speedKn) ||
|
||||
basis.speedKn <= 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const referenceTimestamp = timestampValue(basis.referenceTime);
|
||||
if (referenceTimestamp === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const minutesFromProgress = (remainingNm / basis.speedKn) * 60;
|
||||
const estimatedTimestamp =
|
||||
referenceTimestamp + minutesFromProgress * 60_000;
|
||||
if (!Number.isFinite(estimatedTimestamp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
estimatedAt: new Date(estimatedTimestamp).toISOString(),
|
||||
minutesFromProgress,
|
||||
speedKn: basis.speedKn,
|
||||
speedSource: basis.speedSource,
|
||||
referenceTime: new Date(referenceTimestamp).toISOString(),
|
||||
referenceSource: basis.referenceSource
|
||||
};
|
||||
}
|
||||
|
||||
function timestampValue(value: string | number | Date): number | null {
|
||||
const timestamp =
|
||||
value instanceof Date
|
||||
? value.getTime()
|
||||
: typeof value === "number"
|
||||
? value
|
||||
: Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : null;
|
||||
}
|
||||
Reference in New Issue
Block a user