370 lines
9.8 KiB
TypeScript
370 lines
9.8 KiB
TypeScript
import {
|
|
haversineDistanceNm,
|
|
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: 0.5,
|
|
lock: 0.25,
|
|
bridge: 0.08
|
|
});
|
|
|
|
const CROSS_KIND_DUPLICATE_DISTANCE_NM = 0.1;
|
|
|
|
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
|
|
);
|
|
|
|
const events = 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 }];
|
|
}
|
|
});
|
|
|
|
return withoutLockHarbourDuplicates(events);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Some source objects are classified both as a lock and as a harbour. Preserve
|
|
* the operationally more specific lock event only when the normalized names
|
|
* match and both source coordinates clearly describe the same place.
|
|
*/
|
|
function withoutLockHarbourDuplicates(
|
|
events: UpcomingRouteEvent[]
|
|
): UpcomingRouteEvent[] {
|
|
const locksByName = new Map<string, LockRouteEvent[]>();
|
|
for (const event of events) {
|
|
if (event.kind !== "lock") {
|
|
continue;
|
|
}
|
|
const name = normalizedFacilityName(event.name);
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
locksByName.set(name, [...(locksByName.get(name) ?? []), event]);
|
|
}
|
|
|
|
return events.filter((event) => {
|
|
if (event.kind !== "harbour") {
|
|
return true;
|
|
}
|
|
const possibleLocks = locksByName.get(normalizedFacilityName(event.name)) ?? [];
|
|
return !possibleLocks.some(
|
|
(lock) =>
|
|
haversineDistanceNm(lock.coordinate, event.coordinate) <=
|
|
CROSS_KIND_DUPLICATE_DISTANCE_NM
|
|
);
|
|
});
|
|
}
|
|
|
|
function normalizedFacilityName(value: string) {
|
|
return value
|
|
.normalize("NFKD")
|
|
.replace(/\p{Diacritic}/gu, "")
|
|
.toLocaleLowerCase("de-DE")
|
|
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
|
|
.trim();
|
|
}
|