optimized events
Test and publish container images / test (push) Successful in 2m26s
Test and publish container images / publish (push) Failing after 3s

This commit is contained in:
BuTzZ
2026-07-28 14:36:47 +02:00
parent b98ec8bc6f
commit 593dbd5f85
42 changed files with 2405 additions and 283 deletions
+25 -59
View File
@@ -4,7 +4,6 @@ import {
calculateRouteGuidance,
haversineDistanceNm,
type BoatProfile,
type VoyageHarbour,
type AppConfig,
type Coordinate,
type MarineForecast,
@@ -17,7 +16,6 @@ import {
import {
createRoute,
getConfig,
getMapFeatures,
getMarineForecast,
getNavigationData,
getNearestTide
@@ -42,6 +40,7 @@ import { useBoatProfile } from "./hooks/useBoatProfile";
import { useCourseAssistant } from "./hooks/useCourseAssistant";
import { useGeolocation } from "./hooks/useGeolocation";
import { useMarineData } from "./hooks/useMarineData";
import { useRouteEventFeatures } from "./hooks/useRouteEventFeatures";
import type { OfflineVoyage } from "./lib/offline-route";
import { boatCategoryLabel, toVesselProfile } from "./lib/boat-profile";
import { isOpenRouteWarning } from "./lib/route-warnings";
@@ -51,12 +50,6 @@ import {
type UpcomingRouteEvent
} from "./routeEvents";
import type { RouteWeatherReport } from "./routeWeatherReport";
import {
routeFeatureBounds,
routeLocksFromFeatures,
voyageHarboursFromFeatures,
type RouteLock
} from "./voyageHarbours";
type PickMode = "start" | "destination" | "waypoint" | null;
@@ -141,16 +134,21 @@ export function App() {
});
const [routeOptions, setRouteOptions] = useState<RouteResult[]>([]);
const [activeVesselProfile, setActiveVesselProfile] = useState<VesselProfile | null>(null);
const routeEventFeatures = useRouteEventFeatures(
route,
activeVesselProfile ?? toVesselProfile(currentBoat)
);
const {
harbours: routeHarbours,
locks: routeLocks,
bridgeReport: routeBridgeReport
} = routeEventFeatures;
const [routeWeatherReport, setRouteWeatherReport] = useState<RouteWeatherReport | null>(null);
const [routeWeatherLoading, setRouteWeatherLoading] = useState(false);
const [routeWeatherError, setRouteWeatherError] = useState<string | null>(null);
const [navigationData, setNavigationData] = useState<NavigationDataSnapshot | null>(null);
const [navigationDataLoading, setNavigationDataLoading] = useState(false);
const [navigationDataError, setNavigationDataError] = useState<string | null>(null);
const [routeHarbours, setRouteHarbours] = useState<VoyageHarbour[]>([]);
const [routeLocks, setRouteLocks] = useState<RouteLock[]>([]);
const [routeHarboursLoading, setRouteHarboursLoading] = useState(false);
const [routeHarboursError, setRouteHarboursError] = useState<string | null>(null);
const [routeTides, setRouteTides] = useState<RouteTidePlan | null>(null);
const [routeTidesLoading, setRouteTidesLoading] = useState(false);
const [routeTidesError, setRouteTidesError] = useState<string | null>(null);
@@ -262,44 +260,6 @@ export function App() {
};
}, [route]);
useEffect(() => {
if (!route) {
setRouteHarbours([]);
setRouteLocks([]);
setRouteHarboursLoading(false);
setRouteHarboursError(null);
return;
}
let active = true;
setRouteHarboursLoading(true);
getMapFeatures({ bbox: routeFeatureBounds(route, 2.5), layers: ["harbours", "locks"] })
.then((collection) => {
if (!active) {
return;
}
setRouteHarbours(voyageHarboursFromFeatures(collection));
setRouteLocks(routeLocksFromFeatures(collection, route));
setRouteHarboursError(null);
})
.catch((error) => {
if (active) {
setRouteHarbours([]);
setRouteLocks([]);
setRouteHarboursError(error instanceof Error ? error.message : "Häfen entlang der Route nicht erreichbar");
}
})
.finally(() => {
if (active) {
setRouteHarboursLoading(false);
}
});
return () => {
active = false;
};
}, [route]);
const routeWarningCount = useMemo(
() => route?.warnings.filter(isOpenRouteWarning).length ?? 0,
[route]
@@ -433,7 +393,7 @@ export function App() {
route,
harbours: routeHarbours,
locks: routeLocks,
bridges: routeWeatherReport?.bridgeReport?.bridges ?? [],
bridges: routeBridgeReport?.bridges ?? [],
progressNm: routeProgress.distanceNm,
etaBasis: routeEventEtaBasis
})
@@ -444,7 +404,7 @@ export function App() {
routeHarbours,
routeLocks,
routeProgress.distanceNm,
routeWeatherReport?.bridgeReport?.bridges
routeBridgeReport?.bridges
]
);
const eventWarningCount = useMemo(
@@ -485,6 +445,8 @@ export function App() {
)
? "alarm"
: "caution"
: routeEventFeatures.error
? "caution"
: route
? "active"
: "idle",
@@ -511,6 +473,7 @@ export function App() {
marineData.loading,
marineData.tide?.updatedAt,
route,
routeEventFeatures.error,
routeEvents,
routeWeatherReport?.severity
]
@@ -526,7 +489,7 @@ export function App() {
import("./routeWeatherReport")
.then(({ createRouteWeatherReport }) =>
createRouteWeatherReport(result, vesselProfile, getMarineForecast, getMapFeatures)
createRouteWeatherReport(result, vesselProfile, getMarineForecast)
)
.then((report) => {
if (routeWeatherRequestId.current === reportRequestId) {
@@ -812,7 +775,7 @@ export function App() {
activeTool === "conditions"
? marineData.loading || routeWeatherLoading || routeTidesLoading
: activeTool === "upcoming"
? routeHarboursLoading || routeWeatherLoading
? routeEventFeatures.loading
: activeTool === "route"
? routeLoading
: false;
@@ -905,7 +868,7 @@ export function App() {
badges={{
boat: boatProfileStored ? null : "!",
anchor: anchorAlarm ? "!" : null,
upcoming: eventWarningCount,
upcoming: eventWarningCount || (routeEventFeatures.error ? "!" : null),
route: guidanceAlarm ? "!" : routeWarningCount
}}
/>
@@ -991,8 +954,8 @@ export function App() {
<LazyUpcomingEventsPanel
events={routeEvents}
hasRoute={Boolean(route)}
loading={routeHarboursLoading || routeWeatherLoading}
error={routeHarboursError}
loading={routeEventFeatures.loading}
error={routeEventFeatures.error}
onShowOnMap={showRouteEventOnMap}
/>
</LazyContent>
@@ -1034,9 +997,12 @@ export function App() {
navigationDataLoading={navigationDataLoading}
navigationDataError={navigationDataError}
routeHarbours={routeHarbours}
routeHarboursLoading={routeHarboursLoading}
routeHarboursError={routeHarboursError}
routeHarboursLoading={routeEventFeatures.harboursLoading}
routeHarboursError={routeEventFeatures.errors.harbours}
routeLocks={routeLocks}
bridgeReport={routeBridgeReport}
bridgesLoading={routeEventFeatures.bridgesLoading}
bridgesError={routeEventFeatures.errors.bridges}
routeTides={routeTides}
routeTidesLoading={routeTidesLoading}
routeTidesError={routeTidesError}
+38 -3
View File
@@ -9,10 +9,31 @@ import type {
} from "@watermaps/shared";
import type { FeatureCollection } from "geojson";
export type MapFeatureMetadata = {
source?: "postgis" | "demo" | "unavailable" | string;
warning?: string;
deduplication?: {
inputPoiCount: number;
outputPoiCount: number;
mergedObjectCount: number;
};
};
export type MapFeatureCollection = FeatureCollection & {
metadata?: MapFeatureMetadata;
};
export class FeatureDataUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "FeatureDataUnavailableError";
}
}
async function getJson<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
throw new Error(await responseErrorMessage(response));
}
return (await response.json()) as T;
}
@@ -84,10 +105,24 @@ export function getMapFeatures(params: {
bbox: [number, number, number, number];
layers: string[];
signal?: AbortSignal;
}): Promise<FeatureCollection> {
}): Promise<MapFeatureCollection> {
const search = new URLSearchParams({
bbox: params.bbox.join(","),
layers: params.layers.join(",")
});
return getJson<FeatureCollection>(`/api/features?${search.toString()}`, { signal: params.signal });
return getJson<MapFeatureCollection>(`/api/features?${search.toString()}`, {
signal: params.signal
}).then(assertFeatureDataAvailable);
}
export function assertFeatureDataAvailable(
collection: MapFeatureCollection
): MapFeatureCollection {
if (collection.metadata?.source === "unavailable") {
throw new FeatureDataUnavailableError(
collection.metadata.warning?.trim() ||
"Die lokale Ereignis-Datenquelle ist nicht konfiguriert."
);
}
return collection;
}
+55 -31
View File
@@ -31,7 +31,10 @@ import {
type VoyageAmenity,
type VoyageHarbour
} from "@watermaps/shared";
import type { RouteWeatherReport } from "../routeWeatherReport";
import type {
RouteBridgeReport,
RouteWeatherReport
} from "../routeWeatherReport";
import type { OfflineVoyage } from "../lib/offline-route";
import {
boatCategoryLabel,
@@ -63,6 +66,9 @@ type RoutePlannerProps = {
routeHarboursLoading?: boolean;
routeHarboursError?: string | null;
routeLocks?: RouteLock[];
bridgeReport?: RouteBridgeReport | null;
bridgesLoading?: boolean;
bridgesError?: string | null;
routeTides?: RouteTidePlan | null;
routeTidesLoading?: boolean;
routeTidesError?: string | null;
@@ -120,6 +126,9 @@ export function RoutePlanner({
routeHarboursLoading = false,
routeHarboursError = null,
routeLocks = [],
bridgeReport = null,
bridgesLoading = false,
bridgesError = null,
routeTides = null,
routeTidesLoading = false,
routeTidesError = null,
@@ -739,7 +748,13 @@ export function RoutePlanner({
</details>
)}
{operationalPanelsVisible && (weatherLoading || weatherReport || weatherError) && (
{operationalPanelsVisible &&
(weatherLoading ||
weatherReport ||
weatherError ||
bridgesLoading ||
bridgeReport ||
bridgesError) && (
<section className="route-weather-report" aria-label="Fahrtbericht">
<div className="route-weather-heading">
<CloudSun size={16} aria-hidden="true" />
@@ -748,6 +763,7 @@ export function RoutePlanner({
</div>
{weatherLoading && <p className="route-weather-message">Wetterbericht wird geladen</p>}
{bridgesLoading && <p className="route-weather-message">Brücken werden geladen</p>}
{weatherReport && (
<>
@@ -798,35 +814,6 @@ export function RoutePlanner({
{weatherReport.unavailableSamples} Messpunkt nicht erreichbar
</p>
)}
{weatherReport.bridgeReport && (
<div className="route-bridge-report" data-severity={weatherReport.bridgeReport.severity}>
<div className="route-bridge-summary">
<Landmark size={14} aria-hidden="true" />
<span>{weatherReport.bridgeReport.summary}</span>
</div>
<div className="route-bridge-metrics">
<span>Boot {formatMeters(weatherReport.bridgeReport.requiredAirDraftM)}</span>
<span>Min {formatMeters(weatherReport.bridgeReport.minClearanceM)}</span>
<span>
{weatherReport.bridgeReport.checkedCount}/{weatherReport.bridgeReport.bridges.length} geprüft
</span>
</div>
{weatherReport.bridgeReport.bridges.length > 0 && (
<div className="route-bridge-list">
{weatherReport.bridgeReport.bridges.slice(0, 4).map((bridge) => (
<div key={bridge.id} data-status={bridge.status}>
<strong>{bridge.name ?? "Brücke"}</strong>
<span>{bridge.clearanceLabel ?? "H unbekannt"}</span>
<span>{formatBridgeMargin(bridge.marginM)}</span>
</div>
))}
{weatherReport.bridgeReport.bridges.length > 4 && (
<p>+{weatherReport.bridgeReport.bridges.length - 4} weitere Brücken</p>
)}
</div>
)}
</div>
)}
</>
)}
@@ -836,6 +823,43 @@ export function RoutePlanner({
{weatherError}
</p>
)}
{bridgeReport && (
<div className="route-bridge-report" data-severity={bridgeReport.severity}>
<div className="route-bridge-summary">
<Landmark size={14} aria-hidden="true" />
<span>{bridgeReport.summary}</span>
</div>
<div className="route-bridge-metrics">
<span>Boot {formatMeters(bridgeReport.requiredAirDraftM)}</span>
<span>Min {formatMeters(bridgeReport.minClearanceM)}</span>
<span>
{bridgeReport.checkedCount}/{bridgeReport.bridges.length} geprüft
</span>
</div>
{bridgeReport.bridges.length > 0 && (
<div className="route-bridge-list">
{bridgeReport.bridges.slice(0, 4).map((bridge) => (
<div key={bridge.id} data-status={bridge.status}>
<strong>{bridge.name ?? "Brücke"}</strong>
<span>{bridge.clearanceLabel ?? "H unbekannt"}</span>
<span>{formatBridgeMargin(bridge.marginM)}</span>
</div>
))}
{bridgeReport.bridges.length > 4 && (
<p>+{bridgeReport.bridges.length - 4} weitere Brücken</p>
)}
</div>
)}
</div>
)}
{bridgesError && !bridgesLoading && (
<p className="route-weather-message">
<AlertTriangle size={14} aria-hidden="true" />
{bridgesError}
</p>
)}
</section>
)}
@@ -244,6 +244,15 @@
font-size: 12px;
}
.upcoming-event-row-detail-action {
display: inline-flex;
align-items: center;
gap: 3px;
color: #196f5c;
font-size: 9px;
font-weight: 850;
}
.upcoming-event-contact-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
@@ -4,6 +4,7 @@ import {
CalendarClock,
Clock3,
Globe2,
Info,
Landmark,
LoaderCircle,
LockKeyhole,
@@ -308,6 +309,10 @@ function EventRow({
<span className="upcoming-event-row-progress">
<strong>{formatDistance(event.remainingNm)}</strong>
<small>{formatEta(event)}</small>
<span className="upcoming-event-row-detail-action" aria-hidden="true">
<Info size={13} />
Infos
</span>
</span>
</button>
);
@@ -456,6 +461,9 @@ function KindSpecificDetails({ event }: { event: UpcomingRouteEvent }) {
<DetailRow label="Reserve">
{bridgeMargin(event.feature.marginM)}
</DetailRow>
<DetailRow label="Betreiber">
{event.feature.operator ?? "Nicht hinterlegt"}
</DetailRow>
</>
);
}
@@ -566,14 +574,11 @@ function PanelState({
}
function eventContact(event: UpcomingRouteEvent) {
if (event.kind === "harbour" || event.kind === "lock") {
return {
phone: event.feature.phone ?? null,
website: event.feature.website ?? null,
email: event.feature.email ?? null
};
}
return { phone: null, website: null, email: null };
return {
phone: event.feature.phone ?? null,
website: event.feature.website ?? null,
email: event.feature.email ?? null
};
}
function importantFact(event: UpcomingRouteEvent) {
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from "react";
import type {
RouteResult,
VesselProfile,
VoyageHarbour
} from "@watermaps/shared";
import { getMapFeatures } from "../api";
import type { RouteBridgeReport } from "../routeWeatherReport";
import {
routeFeatureBounds,
routeLocksFromFeatures,
voyageHarboursFromFeatures,
type RouteLock
} from "../voyageHarbours";
type FeatureKind = "harbours" | "locks" | "bridges";
type FeatureRequestState = Record<FeatureKind, boolean>;
type FeatureErrorState = Record<FeatureKind, string | null>;
export type RouteEventFeatureState = {
harbours: VoyageHarbour[];
locks: RouteLock[];
bridgeReport: RouteBridgeReport | null;
loading: boolean;
errors: FeatureErrorState;
error: string | null;
harboursLoading: boolean;
locksLoading: boolean;
bridgesLoading: boolean;
};
const EMPTY_LOADING: FeatureRequestState = {
harbours: false,
locks: false,
bridges: false
};
const EMPTY_ERRORS: FeatureErrorState = {
harbours: null,
locks: null,
bridges: null
};
/**
* Loads every event category independently. A failed harbour request must not
* hide working lock or bridge data, and bridges are intentionally fetched here
* instead of as a side effect of the weather report.
*/
export function useRouteEventFeatures(
route: RouteResult | null,
vesselProfile: Pick<VesselProfile, "airDraftM"> | null
): RouteEventFeatureState {
const [harbours, setHarbours] = useState<VoyageHarbour[]>([]);
const [locks, setLocks] = useState<RouteLock[]>([]);
const [bridgeReport, setBridgeReport] = useState<RouteBridgeReport | null>(null);
const [loadingByKind, setLoadingByKind] =
useState<FeatureRequestState>(EMPTY_LOADING);
const [errors, setErrors] = useState<FeatureErrorState>(EMPTY_ERRORS);
const airDraftM = vesselProfile?.airDraftM;
useEffect(() => {
if (!route) {
setHarbours([]);
setLoadingByKind((current) => ({ ...current, harbours: false }));
setErrors((current) => ({ ...current, harbours: null }));
return;
}
let active = true;
const controller = new AbortController();
setHarbours([]);
setLoadingByKind((current) => ({ ...current, harbours: true }));
setErrors((current) => ({ ...current, harbours: null }));
void getMapFeatures({
bbox: routeFeatureBounds(route, 2.5),
layers: ["harbours"],
signal: controller.signal
})
.then((collection) => {
if (active) {
setHarbours(voyageHarboursFromFeatures(collection));
}
})
.catch((error) => {
if (active && !isAbortError(error)) {
setErrors((current) => ({
...current,
harbours: featureErrorMessage("Häfen", error)
}));
}
})
.finally(() => {
if (active) {
setLoadingByKind((current) => ({ ...current, harbours: false }));
}
});
return () => {
active = false;
controller.abort();
};
}, [route]);
useEffect(() => {
if (!route) {
setLocks([]);
setLoadingByKind((current) => ({ ...current, locks: false }));
setErrors((current) => ({ ...current, locks: null }));
return;
}
let active = true;
const controller = new AbortController();
setLocks([]);
setLoadingByKind((current) => ({ ...current, locks: true }));
setErrors((current) => ({ ...current, locks: null }));
void getMapFeatures({
bbox: routeFeatureBounds(route, 0.5),
layers: ["locks"],
signal: controller.signal
})
.then((collection) => {
if (active) {
setLocks(routeLocksFromFeatures(collection, route));
}
})
.catch((error) => {
if (active && !isAbortError(error)) {
setErrors((current) => ({
...current,
locks: featureErrorMessage("Schleusen", error)
}));
}
})
.finally(() => {
if (active) {
setLoadingByKind((current) => ({ ...current, locks: false }));
}
});
return () => {
active = false;
controller.abort();
};
}, [route]);
useEffect(() => {
if (!route) {
setBridgeReport(null);
setLoadingByKind((current) => ({ ...current, bridges: false }));
setErrors((current) => ({ ...current, bridges: null }));
return;
}
let active = true;
const controller = new AbortController();
setBridgeReport(null);
setLoadingByKind((current) => ({ ...current, bridges: true }));
setErrors((current) => ({ ...current, bridges: null }));
void import("../routeWeatherReport")
.then(({ createRouteBridgeReport }) =>
createRouteBridgeReport(
route,
{ airDraftM },
(params) =>
getMapFeatures({
...params,
signal: controller.signal
})
)
)
.then((report) => {
if (active) {
setBridgeReport(report);
}
})
.catch((error) => {
if (active && !isAbortError(error)) {
setErrors((current) => ({
...current,
bridges: featureErrorMessage("Brücken", error)
}));
}
})
.finally(() => {
if (active) {
setLoadingByKind((current) => ({ ...current, bridges: false }));
}
});
return () => {
active = false;
controller.abort();
};
}, [airDraftM, route]);
const error = useMemo(
() =>
(["harbours", "locks", "bridges"] as const)
.map((kind) => errors[kind])
.filter((message): message is string => Boolean(message))
.join(" · ") || null,
[errors]
);
return {
harbours,
locks,
bridgeReport,
loading: Object.values(loadingByKind).some(Boolean),
errors,
error,
harboursLoading: loadingByKind.harbours,
locksLoading: loadingByKind.locks,
bridgesLoading: loadingByKind.bridges
};
}
function featureErrorMessage(label: string, error: unknown) {
const detail =
error instanceof Error && error.message.trim()
? error.message.trim()
: "Datenquelle nicht erreichbar.";
return `${label} nicht erreichbar: ${detail}`;
}
function isAbortError(error: unknown) {
return error instanceof DOMException && error.name === "AbortError";
}
+49 -2
View File
@@ -1,4 +1,5 @@
import {
haversineDistanceNm,
orderWaypointsAlongRoute,
type Coordinate,
type RouteResult,
@@ -12,11 +13,13 @@ 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,
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";
@@ -148,7 +151,7 @@ export function upcomingRouteEvents(
input.route
);
return projected.flatMap<UpcomingRouteEvent>((projection) => {
const events = projected.flatMap<UpcomingRouteEvent>((projection) => {
const candidate = candidatesByProjectionId.get(projection.id);
if (
!candidate ||
@@ -179,6 +182,8 @@ export function upcomingRouteEvents(
return [{ ...common, kind: candidate.kind, feature: candidate.feature }];
}
});
return withoutLockHarbourDuplicates(events);
}
export function nextRouteEventsByKind(
@@ -320,3 +325,45 @@ function timestampValue(value: string | number | Date): number | null {
: 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();
}
+159 -5
View File
@@ -55,6 +55,10 @@ export type RouteBridgeAssessment = {
marginM: number | null;
status: RouteBridgeStatus;
source: string;
phone?: string | null;
website?: string | null;
email?: string | null;
operator?: string | null;
};
export type RouteBridgeReport = {
@@ -79,6 +83,9 @@ const SAMPLE_TARGETS: Array<{ label: RouteWeatherSample["label"]; ratio: number
const ROUTE_BRIDGE_BBOX_MARGIN_DEG = 0.02;
const ROUTE_BRIDGE_MAX_DISTANCE_NM = 0.08;
const BRIDGE_TIGHT_MARGIN_M = 0.5;
const SAME_NAMED_BRIDGE_DISTANCE_NM = 0.03;
const UNNAMED_BRIDGE_DISTANCE_NM = 0.005;
const NAMED_TO_UNNAMED_BRIDGE_DISTANCE_NM = 0.01;
export async function createRouteWeatherReport(
route: RouteResult,
@@ -451,7 +458,29 @@ function bridgeAssessmentFromFeature(
requiredAirDraftM,
marginM,
status,
source: stringProperty(properties.source) ?? "OSM/Geofabrik"
source: stringProperty(properties.source) ?? "OSM/Geofabrik",
phone: firstStringProperty(properties, [
"contact:phone",
"phone",
"telephone",
"contact_phone"
]),
website: firstStringProperty(properties, [
"contact:website",
"website",
"url",
"contact_website"
]),
email: firstStringProperty(properties, [
"contact:email",
"email",
"contact_email"
]),
operator: firstStringProperty(properties, [
"operator",
"operator:name",
"owner"
])
};
}
@@ -572,11 +601,123 @@ function dedupeBridges(bridges: RouteBridgeAssessment[]) {
const byId = new Map<string, RouteBridgeAssessment>();
for (const bridge of bridges) {
const existing = byId.get(bridge.id);
if (!existing || bridge.distanceNm < existing.distanceNm) {
byId.set(bridge.id, bridge);
}
byId.set(bridge.id, existing ? mergeBridgeAssessments(existing, bridge) : bridge);
}
return [...byId.values()];
const merged: RouteBridgeAssessment[] = [];
for (const bridge of byId.values()) {
const duplicateIndex = merged.findIndex((candidate) =>
bridgeAssessmentsMatch(candidate, bridge)
);
if (duplicateIndex < 0) {
merged.push(bridge);
continue;
}
merged[duplicateIndex] = mergeBridgeAssessments(
merged[duplicateIndex]!,
bridge
);
}
return merged;
}
function bridgeAssessmentsMatch(
left: RouteBridgeAssessment,
right: RouteBridgeAssessment
) {
const distanceNm = haversineDistanceNm(left.coordinate, right.coordinate);
const leftName = normalizedBridgeName(left.name);
const rightName = normalizedBridgeName(right.name);
if (leftName && rightName) {
return (
leftName === rightName &&
distanceNm <= SAME_NAMED_BRIDGE_DISTANCE_NM
);
}
if (!leftName && !rightName) {
return distanceNm <= UNNAMED_BRIDGE_DISTANCE_NM;
}
return distanceNm <= NAMED_TO_UNNAMED_BRIDGE_DISTANCE_NM;
}
function mergeBridgeAssessments(
left: RouteBridgeAssessment,
right: RouteBridgeAssessment
): RouteBridgeAssessment {
const primary =
bridgeInformationScore(right) > bridgeInformationScore(left) ? right : left;
const secondary = primary === left ? right : left;
const clearanceSource = smallestClearanceBridge(left, right);
const clearanceM = clearanceSource?.clearanceM ?? null;
const requiredAirDraftM =
primary.requiredAirDraftM ?? secondary.requiredAirDraftM;
const marginM =
clearanceM !== null && requiredAirDraftM !== null
? clearanceM - requiredAirDraftM
: null;
const closestToRoute =
left.distanceNm <= right.distanceNm ? left : right;
const name = primary.name ?? secondary.name;
const clearanceLabel =
clearanceSource?.clearanceLabel ??
(clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null);
return {
...primary,
name,
label: [name, clearanceLabel].filter(Boolean).join(" ") || primary.label,
coordinate: closestToRoute.coordinate,
distanceNm: Math.min(left.distanceNm, right.distanceNm),
clearanceM,
clearanceLabel,
requiredAirDraftM,
marginM,
status: bridgeStatus(clearanceM, requiredAirDraftM),
source: unique([left.source, right.source]).join(", "),
phone: primary.phone ?? secondary.phone ?? null,
website: primary.website ?? secondary.website ?? null,
email: primary.email ?? secondary.email ?? null,
operator: primary.operator ?? secondary.operator ?? null
};
}
function smallestClearanceBridge(
left: RouteBridgeAssessment,
right: RouteBridgeAssessment
) {
const candidates = [left, right].filter(
(bridge) =>
bridge.clearanceM !== null && Number.isFinite(bridge.clearanceM)
);
return candidates.sort(
(first, second) => first.clearanceM! - second.clearanceM!
)[0] ?? null;
}
function bridgeInformationScore(bridge: RouteBridgeAssessment) {
return [
bridge.name,
bridge.clearanceM,
bridge.phone,
bridge.website,
bridge.email,
bridge.operator
].filter((value) => value !== null && value !== undefined && value !== "")
.length;
}
function normalizedBridgeName(value: string | null) {
if (!value) {
return "";
}
const normalized = value
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "")
.toLocaleLowerCase("de-DE")
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
.trim();
return normalized === "brucke" || normalized === "bridge" ? "" : normalized;
}
function geometryLineStrings(geometry: Geometry): LonLat[][] {
@@ -770,6 +911,19 @@ function stringProperty(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function firstStringProperty(
properties: Record<string, unknown>,
keys: readonly string[]
) {
for (const key of keys) {
const value = stringProperty(properties[key]);
if (value) {
return value;
}
}
return null;
}
function normalizeMeters(value: unknown) {
if (typeof value === "number") {
return Number.isFinite(value) ? value : null;