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; export const DEFAULT_ROUTE_EVENT_CORRIDORS_NM: Readonly = 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; 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; 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((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 | 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; }