Files
watermaps/apps/web/src/routeEvents.ts
T
BuTzZ 593dbd5f85
Test and publish container images / test (push) Successful in 2m26s
Test and publish container images / publish (push) Failing after 3s
optimized events
2026-07-28 14:36:47 +02:00

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