optimized events
This commit is contained in:
+25
-59
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
FeatureDataUnavailableError,
|
||||
getMapFeatures
|
||||
} from "../src/api";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("getMapFeatures", () => {
|
||||
it("rejects an unavailable feature source instead of treating it as an empty result", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
metadata: {
|
||||
source: "unavailable",
|
||||
warning: "Keine lokale Feature-Datenbank konfiguriert."
|
||||
}
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" }
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
getMapFeatures({
|
||||
bbox: [7, 53, 8, 54],
|
||||
layers: ["harbours"]
|
||||
})
|
||||
).rejects.toEqual(
|
||||
expect.objectContaining({
|
||||
name: "FeatureDataUnavailableError",
|
||||
message: "Keine lokale Feature-Datenbank konfiguriert."
|
||||
})
|
||||
);
|
||||
await expect(
|
||||
getMapFeatures({
|
||||
bbox: [7, 53, 8, 54],
|
||||
layers: ["locks"]
|
||||
})
|
||||
).rejects.toBeInstanceOf(FeatureDataUnavailableError);
|
||||
});
|
||||
|
||||
it("keeps usable postgis and legacy feature collections intact", async () => {
|
||||
const responses = [
|
||||
{
|
||||
type: "FeatureCollection",
|
||||
features: [],
|
||||
metadata: { source: "postgis" }
|
||||
},
|
||||
{
|
||||
type: "FeatureCollection",
|
||||
features: []
|
||||
}
|
||||
];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(JSON.stringify(responses.shift()), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" }
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["bridges"] })
|
||||
).resolves.toMatchObject({ metadata: { source: "postgis" } });
|
||||
await expect(
|
||||
getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["bridges"] })
|
||||
).resolves.toMatchObject({ features: [] });
|
||||
});
|
||||
|
||||
it("uses the API error message for an unavailable source", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "feature_source_unavailable",
|
||||
message: "Keine lokale Karten- und Ereignisdatenbank konfiguriert."
|
||||
}),
|
||||
{
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
headers: { "content-type": "application/json" }
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
await expect(
|
||||
getMapFeatures({ bbox: [7, 53, 8, 54], layers: ["locks"] })
|
||||
).rejects.toThrow(
|
||||
"Keine lokale Karten- und Ereignisdatenbank konfiguriert."
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RouteResult } from "@watermaps/shared";
|
||||
import { useRouteEventFeatures } from "../src/hooks/useRouteEventFeatures";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getMapFeatures: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../src/api", () => ({
|
||||
getMapFeatures: apiMocks.getMapFeatures
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
apiMocks.getMapFeatures.mockReset();
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("useRouteEventFeatures", () => {
|
||||
it("loads harbours, locks and bridges once and independently", async () => {
|
||||
apiMocks.getMapFeatures.mockImplementation(
|
||||
async ({ layers }: { layers: string[] }) =>
|
||||
featureCollection(layers[0]!)
|
||||
);
|
||||
|
||||
const activeRoute = route("first", 0);
|
||||
const { result } = renderHook(() =>
|
||||
useRouteEventFeatures(activeRoute, { airDraftM: 3 })
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3);
|
||||
expect(
|
||||
apiMocks.getMapFeatures.mock.calls.map(([request]) => request.layers)
|
||||
).toEqual(expect.arrayContaining([["harbours"], ["locks"], ["bridges"]]));
|
||||
expect(result.current.harbours.map((harbour) => harbour.name)).toEqual([
|
||||
"Testhafen"
|
||||
]);
|
||||
expect(result.current.locks.map((lock) => lock.name)).toEqual([
|
||||
"Testschleuse"
|
||||
]);
|
||||
expect(result.current.bridgeReport?.bridges.map((bridge) => bridge.name)).toEqual([
|
||||
"Testbrücke"
|
||||
]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps working categories when one feature request fails", async () => {
|
||||
apiMocks.getMapFeatures.mockImplementation(
|
||||
async ({ layers }: { layers: string[] }) => {
|
||||
if (layers[0] === "harbours") {
|
||||
throw new Error("Feature-Datenbank fehlt");
|
||||
}
|
||||
return featureCollection(layers[0]!);
|
||||
}
|
||||
);
|
||||
|
||||
const activeRoute = route("partial", 0);
|
||||
const { result } = renderHook(() =>
|
||||
useRouteEventFeatures(activeRoute, { airDraftM: 3 })
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.harbours).toEqual([]);
|
||||
expect(result.current.locks).toHaveLength(1);
|
||||
expect(result.current.bridgeReport?.bridges).toHaveLength(1);
|
||||
expect(result.current.errors.harbours).toContain(
|
||||
"Häfen nicht erreichbar: Feature-Datenbank fehlt"
|
||||
);
|
||||
expect(result.current.errors.locks).toBeNull();
|
||||
expect(result.current.errors.bridges).toBeNull();
|
||||
});
|
||||
|
||||
it("reassesses only bridges when the boat height changes", async () => {
|
||||
apiMocks.getMapFeatures.mockImplementation(
|
||||
async ({ layers }: { layers: string[] }) =>
|
||||
featureCollection(layers[0]!)
|
||||
);
|
||||
const activeRoute = route("height", 0);
|
||||
const { result, rerender } = renderHook(
|
||||
({ airDraftM }) =>
|
||||
useRouteEventFeatures(activeRoute, { airDraftM }),
|
||||
{ initialProps: { airDraftM: 3 } }
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
apiMocks.getMapFeatures.mockClear();
|
||||
|
||||
rerender({ airDraftM: 3.5 });
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.getMapFeatures).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ layers: ["bridges"] })
|
||||
);
|
||||
expect(result.current.harbours).toHaveLength(1);
|
||||
expect(result.current.locks).toHaveLength(1);
|
||||
expect(result.current.bridgeReport?.requiredAirDraftM).toBe(3.5);
|
||||
});
|
||||
|
||||
it("aborts stale category requests on a route change and exposes only the new route", async () => {
|
||||
const staleSignals: AbortSignal[] = [];
|
||||
const currentSignals: AbortSignal[] = [];
|
||||
let staleCalls = 0;
|
||||
apiMocks.getMapFeatures.mockImplementation(
|
||||
({ layers, signal }: { layers: string[]; signal: AbortSignal }) => {
|
||||
if (staleCalls < 3) {
|
||||
staleCalls += 1;
|
||||
staleSignals.push(signal);
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
currentSignals.push(signal);
|
||||
return Promise.resolve(featureCollection(layers[0]!, 0.5));
|
||||
}
|
||||
);
|
||||
const firstRoute = route("first", 0);
|
||||
const secondRoute = route("second", 0.5);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ activeRoute }) =>
|
||||
useRouteEventFeatures(activeRoute, { airDraftM: 3 }),
|
||||
{ initialProps: { activeRoute: firstRoute } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(3));
|
||||
rerender({ activeRoute: secondRoute });
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(apiMocks.getMapFeatures).toHaveBeenCalledTimes(6);
|
||||
expect(staleSignals).toHaveLength(3);
|
||||
expect(staleSignals.every((signal) => signal.aborted)).toBe(true);
|
||||
expect(result.current.harbours[0]?.coordinate.lon).toBeCloseTo(0.7);
|
||||
expect(result.current.locks[0]?.coordinate.lon).toBeCloseTo(0.8);
|
||||
expect(result.current.bridgeReport?.bridges[0]?.coordinate.lon).toBeCloseTo(0.9);
|
||||
|
||||
unmount();
|
||||
expect(currentSignals).toHaveLength(3);
|
||||
expect(currentSignals.every((signal) => signal.aborted)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function featureCollection(layer: string, offset = 0) {
|
||||
const fixtures = {
|
||||
harbours: {
|
||||
type: "Feature" as const,
|
||||
id: `harbour-${offset}`,
|
||||
properties: { layer: "harbours", name: "Testhafen" },
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: [0.2 + offset, 0]
|
||||
}
|
||||
},
|
||||
locks: {
|
||||
type: "Feature" as const,
|
||||
id: `lock-${offset}`,
|
||||
properties: { layer: "locks", name: "Testschleuse" },
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: [0.3 + offset, 0]
|
||||
}
|
||||
},
|
||||
bridges: {
|
||||
type: "Feature" as const,
|
||||
id: `bridge-${offset}`,
|
||||
properties: {
|
||||
layer: "bridges",
|
||||
name: "Testbrücke",
|
||||
clearance_m: 4
|
||||
},
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: [0.4 + offset, 0]
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
type: "FeatureCollection" as const,
|
||||
features: [fixtures[layer as keyof typeof fixtures]]
|
||||
};
|
||||
}
|
||||
|
||||
function route(id: string, offset: number): RouteResult {
|
||||
return {
|
||||
id,
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[offset, 0],
|
||||
[offset + 1, 0]
|
||||
]
|
||||
},
|
||||
distanceNm: 60,
|
||||
eta: null,
|
||||
warnings: [],
|
||||
dataSources: ["test"],
|
||||
routingMode: "fairway"
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
VoyageHarbour
|
||||
} from "@watermaps/shared";
|
||||
import {
|
||||
DEFAULT_ROUTE_EVENT_CORRIDORS_NM,
|
||||
nextRouteEventsByKind,
|
||||
upcomingRouteEvents
|
||||
} from "../src/routeEvents";
|
||||
@@ -80,6 +81,66 @@ describe("upcomingRouteEvents", () => {
|
||||
expect(events.map((event) => event.id)).toEqual(["detour"]);
|
||||
});
|
||||
|
||||
it("uses a focused half-mile harbour corridor by default", () => {
|
||||
expect(DEFAULT_ROUTE_EVENT_CORRIDORS_NM.harbour).toBe(0.5);
|
||||
|
||||
const events = upcomingRouteEvents({
|
||||
route,
|
||||
harbours: [
|
||||
harbour("near-harbour", 0.4, 0.008),
|
||||
harbour("unrelated-harbour", 0.5, 0.01)
|
||||
]
|
||||
});
|
||||
|
||||
expect(events.map((event) => event.id)).toEqual(["near-harbour"]);
|
||||
});
|
||||
|
||||
it("prefers a nearby same-named lock over a duplicate harbour classification", () => {
|
||||
const duplicateHarbour = {
|
||||
...harbour("harbour-lock", 0.4, 0.0005),
|
||||
name: "Nesserländer Schleuse"
|
||||
};
|
||||
const duplicateLock = {
|
||||
...lock("lock", 0.4, 0, 0, 0),
|
||||
name: "Nesserlander Schleuse"
|
||||
};
|
||||
const distinctHarbour = {
|
||||
...harbour("real-harbour", 0.6, 0),
|
||||
name: "Stadthafen"
|
||||
};
|
||||
|
||||
const events = upcomingRouteEvents({
|
||||
route,
|
||||
harbours: [duplicateHarbour, distinctHarbour],
|
||||
locks: [duplicateLock]
|
||||
});
|
||||
|
||||
expect(events.map((event) => `${event.kind}:${event.name}`)).toEqual([
|
||||
"lock:Nesserlander Schleuse",
|
||||
"harbour:Stadthafen"
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not merge same-named facilities that are spatially distinct", () => {
|
||||
const events = upcomingRouteEvents({
|
||||
route,
|
||||
harbours: [
|
||||
{
|
||||
...harbour("harbour", 0.4, 0.003),
|
||||
name: "Kanalschleuse"
|
||||
}
|
||||
],
|
||||
locks: [
|
||||
{
|
||||
...lock("lock", 0.4, 0, 0, 0),
|
||||
name: "Kanalschleuse"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(events.map((event) => event.kind).sort()).toEqual(["harbour", "lock"]);
|
||||
});
|
||||
|
||||
it("calculates ETA and retains both speed and reference-time provenance", () => {
|
||||
const [event] = upcomingRouteEvents({
|
||||
route,
|
||||
|
||||
@@ -528,6 +528,7 @@ describe("RoutePlanner", () => {
|
||||
weatherReport={weatherReportFixture}
|
||||
weatherLoading={false}
|
||||
weatherError={null}
|
||||
bridgeReport={weatherReportFixture.bridgeReport}
|
||||
loading={false}
|
||||
error={null}
|
||||
pickMode={null}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { MarineForecast, RouteResult } from "@watermaps/shared";
|
||||
import { createRouteWeatherReport, summarizeRouteWeather } from "../src/routeWeatherReport";
|
||||
import {
|
||||
createRouteBridgeReport,
|
||||
createRouteWeatherReport,
|
||||
summarizeRouteWeather
|
||||
} from "../src/routeWeatherReport";
|
||||
|
||||
describe("route weather report", () => {
|
||||
it("samples start, middle and destination forecasts", async () => {
|
||||
@@ -81,7 +85,9 @@ describe("route weather report", () => {
|
||||
clearance_m: 2.7,
|
||||
clearance_label: "H 2.7 m",
|
||||
label: "Niedrige Brücke H 2.7 m",
|
||||
source: "OSM/Geofabrik"
|
||||
source: "OSM/Geofabrik",
|
||||
phone: "+49 491 234",
|
||||
website: "https://bridge.example"
|
||||
},
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
@@ -120,6 +126,75 @@ describe("route weather report", () => {
|
||||
expect(report.bridgeReport?.checkedCount).toBe(2);
|
||||
expect(report.bridgeReport?.summary).toContain("Nicht passierbar");
|
||||
expect(report.bridgeReport?.bridges[0]?.name).toBe("Niedrige Brücke");
|
||||
expect(report.bridgeReport?.bridges[0]?.phone).toBe("+49 491 234");
|
||||
expect(report.bridgeReport?.bridges[0]?.website).toBe("https://bridge.example");
|
||||
});
|
||||
|
||||
it("deduplicates only conservatively matching bridge ways and keeps the richest safe assessment", async () => {
|
||||
const route: RouteResult = {
|
||||
...routeFixture,
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [[0, 0], [1, 0]]
|
||||
},
|
||||
distanceNm: 60
|
||||
};
|
||||
const point = (
|
||||
id: string,
|
||||
lon: number,
|
||||
lat: number,
|
||||
properties: Record<string, unknown> = {}
|
||||
) => ({
|
||||
type: "Feature" as const,
|
||||
id,
|
||||
properties: { layer: "bridges", ...properties },
|
||||
geometry: { type: "Point" as const, coordinates: [lon, lat] }
|
||||
});
|
||||
|
||||
const report = await createRouteBridgeReport(
|
||||
route,
|
||||
{ airDraftM: 3 },
|
||||
async () => ({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
point("named-1", 0.2, 0, {
|
||||
name: "Am Tonnenhof",
|
||||
clearance_m: 4,
|
||||
phone: "+49 491 111"
|
||||
}),
|
||||
point("named-2", 0.2, 0.0003, {
|
||||
name: "Am Tonnenhof",
|
||||
clearance_m: 3.5,
|
||||
website: "https://tonnenhof.example"
|
||||
}),
|
||||
point("distinct-east", 0.4, 0, { name: "Klappbrücke Ost" }),
|
||||
point("distinct-west", 0.4, 0.0001, { name: "Klappbrücke West" }),
|
||||
point("unnamed-tight-1", 0.6, 0),
|
||||
point("unnamed-tight-2", 0.6, 0.00005),
|
||||
point("unnamed-separate-1", 0.8, 0),
|
||||
point("unnamed-separate-2", 0.8, 0.0001),
|
||||
point("named-with-way", 0.9, 0, { name: "Auricher Straße" }),
|
||||
point("unnamed-with-name", 0.9, 0.0001)
|
||||
]
|
||||
})
|
||||
);
|
||||
|
||||
expect(report.bridges).toHaveLength(7);
|
||||
const tonnenhof = report.bridges.find((bridge) => bridge.name === "Am Tonnenhof");
|
||||
expect(tonnenhof).toMatchObject({
|
||||
clearanceM: 3.5,
|
||||
clearanceLabel: "H 3.5 m",
|
||||
label: "Am Tonnenhof H 3.5 m",
|
||||
marginM: 0.5,
|
||||
phone: "+49 491 111",
|
||||
website: "https://tonnenhof.example"
|
||||
});
|
||||
expect(
|
||||
report.bridges.filter((bridge) => bridge.name?.startsWith("Klappbrücke"))
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
report.bridges.filter((bridge) => bridge.name === null)
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("marks critical weather when wind or wave thresholds are exceeded", () => {
|
||||
|
||||
@@ -26,6 +26,7 @@ describe("UpcomingEventsPanel", () => {
|
||||
expect.stringContaining("Brücke Mitte"),
|
||||
expect.stringContaining("Hafen Weit")
|
||||
]);
|
||||
expect(within(list).getAllByText("Infos")).toHaveLength(3);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Brücken/ }));
|
||||
expect(screen.getByRole("button", { name: /Brücken/ })).toHaveAttribute(
|
||||
@@ -81,6 +82,12 @@ describe("UpcomingEventsPanel", () => {
|
||||
expect(within(detail).getByText("3.8 m")).toBeVisible();
|
||||
const reserve = within(detail).getByText("Reserve").closest("div");
|
||||
expect(reserve).toHaveTextContent("0.4 m Reserve");
|
||||
expect(
|
||||
within(detail).getByRole("link", { name: "Brücke Mitte anrufen" })
|
||||
).toHaveAttribute("href", "tel:+4949123457");
|
||||
expect(
|
||||
within(detail).getByRole("link", { name: "Website von Brücke Mitte öffnen" })
|
||||
).toHaveAttribute("href", "https://bridge.example/");
|
||||
expect(within(detail).queryByRole("button", { name: "Auf Karte zeigen" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -180,7 +187,10 @@ const events: UpcomingRouteEvent[] = [
|
||||
requiredAirDraftM: 3.8,
|
||||
marginM: 0.4,
|
||||
status: "tight",
|
||||
source: "Test"
|
||||
source: "Test",
|
||||
phone: "+49 49 123457",
|
||||
website: "bridge.example",
|
||||
operator: "Brückenamt"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user