Files
watermaps/apps/web/src/routeEvents.ts
T
2026-07-24 11:29:24 +02:00

323 lines
8.5 KiB
TypeScript

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;
}