947 lines
30 KiB
TypeScript
947 lines
30 KiB
TypeScript
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
|
|
import {
|
|
haversineDistanceNm,
|
|
initialBearingDeg,
|
|
type Coordinate,
|
|
type MarineForecast,
|
|
type RouteResult,
|
|
type VesselProfile
|
|
} from "@watermaps/shared";
|
|
|
|
export type RouteWeatherSample = {
|
|
label: "Start" | "Mitte" | "Ziel";
|
|
coordinate: Coordinate;
|
|
forecast: MarineForecast;
|
|
plannedTime?: string;
|
|
routeBearingDeg?: number | null;
|
|
currentAlongRouteKn?: number | null;
|
|
};
|
|
|
|
export type RouteWeatherReport = {
|
|
samples: RouteWeatherSample[];
|
|
maxWaveHeightM: number | null;
|
|
maxWindSpeedKn: number | null;
|
|
maxWavePeriodS: number | null;
|
|
strongestWindDirectionDeg: number | null;
|
|
highestWaveDirectionDeg: number | null;
|
|
severity: "ok" | "caution" | "critical";
|
|
summary: string;
|
|
source: string;
|
|
updatedAt: string;
|
|
unavailableSamples: number;
|
|
bridgeReport: RouteBridgeReport | null;
|
|
departureTime: string;
|
|
adjustedEta: string | null;
|
|
currentAdjustmentMinutes: number | null;
|
|
averageAlongRouteCurrentKn: number | null;
|
|
};
|
|
|
|
type FetchForecast = (coordinate: Coordinate, at?: string) => Promise<MarineForecast>;
|
|
type FetchFeatures = (params: { bbox: [number, number, number, number]; layers: string[] }) => Promise<FeatureCollection>;
|
|
type LonLat = [number, number];
|
|
type ProjectedPoint = { x: number; y: number };
|
|
|
|
export type RouteBridgeStatus = "passable" | "tight" | "too_low" | "unknown";
|
|
|
|
export type RouteBridgeAssessment = {
|
|
id: string;
|
|
name: string | null;
|
|
label: string;
|
|
coordinate: Coordinate;
|
|
distanceNm: number;
|
|
clearanceM: number | null;
|
|
clearanceLabel: string | null;
|
|
requiredAirDraftM: number | null;
|
|
marginM: number | null;
|
|
status: RouteBridgeStatus;
|
|
source: string;
|
|
phone?: string | null;
|
|
website?: string | null;
|
|
email?: string | null;
|
|
operator?: string | null;
|
|
};
|
|
|
|
export type RouteBridgeReport = {
|
|
bridges: RouteBridgeAssessment[];
|
|
requiredAirDraftM: number | null;
|
|
checkedCount: number;
|
|
unknownCount: number;
|
|
tooLowCount: number;
|
|
tightCount: number;
|
|
minClearanceM: number | null;
|
|
severity: "ok" | "caution" | "critical";
|
|
summary: string;
|
|
source: string;
|
|
updatedAt: string;
|
|
};
|
|
|
|
const SAMPLE_TARGETS: Array<{ label: RouteWeatherSample["label"]; ratio: number }> = [
|
|
{ label: "Start", ratio: 0 },
|
|
{ label: "Mitte", ratio: 0.5 },
|
|
{ label: "Ziel", ratio: 1 }
|
|
];
|
|
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,
|
|
fetchForecast: FetchForecast
|
|
): Promise<RouteWeatherReport>;
|
|
export async function createRouteWeatherReport(
|
|
route: RouteResult,
|
|
vesselProfile: VesselProfile,
|
|
fetchForecast: FetchForecast,
|
|
fetchFeatures?: FetchFeatures,
|
|
departureTime?: string
|
|
): Promise<RouteWeatherReport>;
|
|
export async function createRouteWeatherReport(
|
|
route: RouteResult,
|
|
vesselProfileOrFetchForecast: VesselProfile | FetchForecast,
|
|
maybeFetchForecast?: FetchForecast,
|
|
fetchFeatures?: FetchFeatures,
|
|
departureTime?: string
|
|
): Promise<RouteWeatherReport> {
|
|
const vesselProfile: VesselProfile =
|
|
typeof vesselProfileOrFetchForecast === "function"
|
|
? { draughtM: 0, safetyReserveM: 0 }
|
|
: vesselProfileOrFetchForecast;
|
|
const fetchForecast =
|
|
typeof vesselProfileOrFetchForecast === "function" ? vesselProfileOrFetchForecast : maybeFetchForecast;
|
|
if (!fetchForecast) {
|
|
throw new Error("Wetterbericht nicht erreichbar");
|
|
}
|
|
|
|
const plannedDeparture = validIso(departureTime ?? route.departureTime) ?? new Date().toISOString();
|
|
const cruiseSpeedKn = normalizeSpeed(vesselProfile.cruiseSpeedKn);
|
|
const samplePoints = sampleRoute(route).map((sample) => ({
|
|
...sample,
|
|
plannedTime: new Date(
|
|
Date.parse(plannedDeparture) + (route.distanceNm * sample.ratio / cruiseSpeedKn) * 60 * 60 * 1000
|
|
).toISOString()
|
|
}));
|
|
const [results, bridgeReport] = await Promise.all([
|
|
Promise.allSettled(
|
|
samplePoints.map(async (sample) => ({
|
|
...sample,
|
|
forecast: await fetchForecast(sample.coordinate, sample.plannedTime)
|
|
}))
|
|
),
|
|
fetchFeatures
|
|
? createRouteBridgeReport(route, vesselProfile, fetchFeatures).catch(() => unavailableBridgeReport(vesselProfile))
|
|
: Promise.resolve(null)
|
|
]);
|
|
const samples = results.flatMap((result) => (result.status === "fulfilled" ? [result.value] : []));
|
|
|
|
if (samples.length === 0) {
|
|
throw new Error("Wetterbericht nicht erreichbar");
|
|
}
|
|
|
|
const samplesWithCurrent = samples.map((sample) => ({
|
|
...sample,
|
|
currentAlongRouteKn: alongRouteCurrentKn(sample.forecast, sample.routeBearingDeg)
|
|
}));
|
|
|
|
return summarizeRouteWeather(samplesWithCurrent, results.length - samples.length, bridgeReport, {
|
|
departureTime: plannedDeparture,
|
|
distanceNm: route.distanceNm,
|
|
cruiseSpeedKn
|
|
});
|
|
}
|
|
|
|
export async function createRouteBridgeReport(
|
|
route: RouteResult,
|
|
vesselProfile: Pick<VesselProfile, "airDraftM">,
|
|
fetchFeatures: FetchFeatures
|
|
): Promise<RouteBridgeReport> {
|
|
const routeLine = routeLonLatLine(route);
|
|
if (routeLine.length === 0) {
|
|
return summarizeBridgeReport([], normalizeMeters(vesselProfile.airDraftM));
|
|
}
|
|
|
|
const features = await fetchFeatures({
|
|
bbox: bboxForLine(routeLine, ROUTE_BRIDGE_BBOX_MARGIN_DEG),
|
|
layers: ["bridges"]
|
|
});
|
|
const requiredAirDraftM = normalizeMeters(vesselProfile.airDraftM);
|
|
const bridges = features.features
|
|
.map((feature) => bridgeAssessmentFromFeature(feature, routeLine, requiredAirDraftM))
|
|
.filter((bridge): bridge is RouteBridgeAssessment => Boolean(bridge))
|
|
.filter((bridge) => bridge.distanceNm <= ROUTE_BRIDGE_MAX_DISTANCE_NM);
|
|
const deduped = dedupeBridges(bridges);
|
|
|
|
return summarizeBridgeReport(
|
|
deduped.sort((left, right) => left.distanceNm - right.distanceNm),
|
|
requiredAirDraftM
|
|
);
|
|
}
|
|
|
|
export function summarizeRouteWeather(
|
|
samples: RouteWeatherSample[],
|
|
unavailableSamples = 0,
|
|
bridgeReport: RouteBridgeReport | null = null,
|
|
planning?: { departureTime: string; distanceNm: number; cruiseSpeedKn: number }
|
|
): RouteWeatherReport {
|
|
const maxWaveHeightM = maxValue(samples.map((sample) => sample.forecast.waveHeightM));
|
|
const maxWindSpeedKn = maxValue(samples.map((sample) => sample.forecast.windSpeed));
|
|
const maxWavePeriodS = maxValue(samples.map((sample) => sample.forecast.wavePeriodS));
|
|
const strongestWind = maxBy(samples, (sample) => sample.forecast.windSpeed);
|
|
const highestWave = maxBy(samples, (sample) => sample.forecast.waveHeightM);
|
|
const severity = weatherSeverity(maxWindSpeedKn, maxWaveHeightM);
|
|
const currentComponents = samples
|
|
.map((sample) => sample.currentAlongRouteKn)
|
|
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
const averageAlongRouteCurrentKn =
|
|
currentComponents.length > 0
|
|
? currentComponents.reduce((sum, value) => sum + value, 0) / currentComponents.length
|
|
: null;
|
|
const currentTiming = currentAdjustedTiming(planning, averageAlongRouteCurrentKn);
|
|
|
|
return {
|
|
samples,
|
|
maxWaveHeightM,
|
|
maxWindSpeedKn,
|
|
maxWavePeriodS,
|
|
strongestWindDirectionDeg: strongestWind?.forecast.windDirectionDeg ?? null,
|
|
highestWaveDirectionDeg: highestWave?.forecast.waveDirectionDeg ?? null,
|
|
severity,
|
|
summary: weatherSummary(severity, maxWindSpeedKn, maxWaveHeightM),
|
|
source: unique(samples.map((sample) => sample.forecast.source)).join(", "),
|
|
updatedAt: latestIso(samples.map((sample) => sample.forecast.updatedAt)) ?? new Date().toISOString(),
|
|
unavailableSamples,
|
|
bridgeReport,
|
|
departureTime: planning?.departureTime ?? new Date().toISOString(),
|
|
adjustedEta: currentTiming.adjustedEta,
|
|
currentAdjustmentMinutes: currentTiming.adjustmentMinutes,
|
|
averageAlongRouteCurrentKn
|
|
};
|
|
}
|
|
|
|
function sampleRoute(route: RouteResult) {
|
|
const points = route.geometry.coordinates.map(([lon, lat]) => ({ lat, lon }));
|
|
const uniqueSamples = new Map<
|
|
string,
|
|
{
|
|
label: RouteWeatherSample["label"];
|
|
coordinate: Coordinate;
|
|
ratio: number;
|
|
routeBearingDeg: number | null;
|
|
}
|
|
>();
|
|
|
|
for (const target of SAMPLE_TARGETS) {
|
|
const coordinate = coordinateAtProgress(points, target.ratio);
|
|
const key = `${coordinate.lat.toFixed(3)}:${coordinate.lon.toFixed(3)}`;
|
|
uniqueSamples.set(key, {
|
|
label: target.label,
|
|
coordinate,
|
|
ratio: target.ratio,
|
|
routeBearingDeg: routeBearingAtProgress(points, target.ratio)
|
|
});
|
|
}
|
|
|
|
return [...uniqueSamples.values()];
|
|
}
|
|
|
|
function routeBearingAtProgress(points: Coordinate[], ratio: number): number | null {
|
|
if (points.length < 2) {
|
|
return null;
|
|
}
|
|
const before = coordinateAtProgress(points, Math.max(0, ratio - 0.01));
|
|
const after = coordinateAtProgress(points, Math.min(1, ratio + 0.01));
|
|
if (haversineDistanceNm(before, after) < 0.001) {
|
|
return null;
|
|
}
|
|
return initialBearingDeg(before, after);
|
|
}
|
|
|
|
function alongRouteCurrentKn(forecast: MarineForecast, routeBearingDeg?: number | null): number | null {
|
|
const speedKn = forecast.oceanCurrentSpeedKn;
|
|
const directionDeg = forecast.oceanCurrentDirectionDeg;
|
|
if (
|
|
typeof speedKn !== "number" ||
|
|
!Number.isFinite(speedKn) ||
|
|
typeof directionDeg !== "number" ||
|
|
!Number.isFinite(directionDeg) ||
|
|
typeof routeBearingDeg !== "number"
|
|
) {
|
|
return null;
|
|
}
|
|
const angleRad = (((directionDeg - routeBearingDeg + 540) % 360) - 180) * (Math.PI / 180);
|
|
return Math.round(speedKn * Math.cos(angleRad) * 100) / 100;
|
|
}
|
|
|
|
function currentAdjustedTiming(
|
|
planning: { departureTime: string; distanceNm: number; cruiseSpeedKn: number } | undefined,
|
|
averageCurrentKn: number | null
|
|
): { adjustedEta: string | null; adjustmentMinutes: number | null } {
|
|
if (!planning || averageCurrentKn === null) {
|
|
return { adjustedEta: null, adjustmentMinutes: null };
|
|
}
|
|
const effectiveSpeedKn = Math.max(0.5, planning.cruiseSpeedKn + averageCurrentKn);
|
|
const baseMinutes = (planning.distanceNm / planning.cruiseSpeedKn) * 60;
|
|
const adjustedMinutes = (planning.distanceNm / effectiveSpeedKn) * 60;
|
|
return {
|
|
adjustedEta: new Date(Date.parse(planning.departureTime) + adjustedMinutes * 60 * 1000).toISOString(),
|
|
adjustmentMinutes: Math.round(adjustedMinutes - baseMinutes)
|
|
};
|
|
}
|
|
|
|
function normalizeSpeed(value: number | undefined): number {
|
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 6;
|
|
}
|
|
|
|
function validIso(value: string | undefined): string | null {
|
|
const timestamp = value ? Date.parse(value) : Number.NaN;
|
|
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
|
}
|
|
|
|
function coordinateAtProgress(points: Coordinate[], ratio: number): Coordinate {
|
|
if (points.length === 0) {
|
|
return { lat: 0, lon: 0 };
|
|
}
|
|
|
|
if (ratio <= 0 || points.length === 1) {
|
|
return points[0]!;
|
|
}
|
|
|
|
if (ratio >= 1) {
|
|
return points.at(-1)!;
|
|
}
|
|
|
|
const segmentLengths = points.slice(1).map((point, index) => haversineDistanceNm(points[index]!, point));
|
|
const totalDistanceNm = segmentLengths.reduce((sum, length) => sum + length, 0);
|
|
const targetDistanceNm = totalDistanceNm * ratio;
|
|
let traveledNm = 0;
|
|
|
|
for (let index = 0; index < segmentLengths.length; index += 1) {
|
|
const segmentLengthNm = segmentLengths[index]!;
|
|
if (traveledNm + segmentLengthNm >= targetDistanceNm) {
|
|
const start = points[index]!;
|
|
const end = points[index + 1]!;
|
|
const segmentRatio = segmentLengthNm === 0 ? 0 : (targetDistanceNm - traveledNm) / segmentLengthNm;
|
|
return {
|
|
lat: start.lat + (end.lat - start.lat) * segmentRatio,
|
|
lon: start.lon + (end.lon - start.lon) * segmentRatio
|
|
};
|
|
}
|
|
traveledNm += segmentLengthNm;
|
|
}
|
|
|
|
return points.at(-1)!;
|
|
}
|
|
|
|
function maxValue(values: Array<number | null | undefined>) {
|
|
const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
return valid.length > 0 ? Math.max(...valid) : null;
|
|
}
|
|
|
|
function minValue(values: Array<number | null | undefined>) {
|
|
const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
return valid.length > 0 ? Math.min(...valid) : null;
|
|
}
|
|
|
|
function maxBy<T>(values: T[], selector: (value: T) => number | null | undefined) {
|
|
return values.reduce<T | null>((best, value) => {
|
|
const candidate = selector(value);
|
|
if (candidate === null || candidate === undefined || !Number.isFinite(candidate)) {
|
|
return best;
|
|
}
|
|
|
|
const bestValue = best ? selector(best) : null;
|
|
return bestValue === null || bestValue === undefined || candidate > bestValue ? value : best;
|
|
}, null);
|
|
}
|
|
|
|
function weatherSeverity(windSpeedKn: number | null, waveHeightM: number | null) {
|
|
if (windSpeedKn === null && waveHeightM === null) {
|
|
return "caution";
|
|
}
|
|
if ((windSpeedKn !== null && windSpeedKn >= 27) || (waveHeightM !== null && waveHeightM >= 2)) {
|
|
return "critical";
|
|
}
|
|
if ((windSpeedKn !== null && windSpeedKn >= 16) || (waveHeightM !== null && waveHeightM >= 1)) {
|
|
return "caution";
|
|
}
|
|
return "ok";
|
|
}
|
|
|
|
function weatherSummary(
|
|
severity: RouteWeatherReport["severity"],
|
|
windSpeedKn: number | null,
|
|
waveHeightM: number | null
|
|
) {
|
|
if (windSpeedKn === null && waveHeightM === null) {
|
|
return "Keine belastbare Wetter- oder Wellenprognose für die gewählte Abfahrtszeit.";
|
|
}
|
|
const wind = windSpeedKn !== null ? `${Math.round(windSpeedKn)} kn Wind` : "Wind unbekannt";
|
|
const wave = waveHeightM !== null ? `${waveHeightM.toFixed(1)} m Welle` : "Welle unbekannt";
|
|
|
|
if (severity === "critical") {
|
|
return `Kritische Bedingungen: bis ${wind}, ${wave}.`;
|
|
}
|
|
if (severity === "caution") {
|
|
return `Aufmerksam fahren: bis ${wind}, ${wave}.`;
|
|
}
|
|
return `Ruhige Bedingungen: bis ${wind}, ${wave}.`;
|
|
}
|
|
|
|
function latestIso(values: string[]) {
|
|
const timestamps = values.map((value) => Date.parse(value)).filter((value) => Number.isFinite(value));
|
|
return timestamps.length > 0 ? new Date(Math.max(...timestamps)).toISOString() : null;
|
|
}
|
|
|
|
function unique(values: string[]) {
|
|
return [...new Set(values.filter(Boolean))];
|
|
}
|
|
|
|
function routeLonLatLine(route: RouteResult): LonLat[] {
|
|
return route.geometry.coordinates.map(([lon, lat]) => [lon, lat]);
|
|
}
|
|
|
|
function bboxForLine(line: LonLat[], marginDeg: number): [number, number, number, number] {
|
|
const lons = line.map(([lon]) => lon);
|
|
const lats = line.map(([, lat]) => lat);
|
|
|
|
return [
|
|
Math.min(...lons) - marginDeg,
|
|
Math.min(...lats) - marginDeg,
|
|
Math.max(...lons) + marginDeg,
|
|
Math.max(...lats) + marginDeg
|
|
];
|
|
}
|
|
|
|
function bridgeAssessmentFromFeature(
|
|
feature: Feature,
|
|
routeLine: LonLat[],
|
|
requiredAirDraftM: number | null
|
|
): RouteBridgeAssessment | null {
|
|
if (!feature.geometry) {
|
|
return null;
|
|
}
|
|
|
|
const bridgeLines = geometryLineStrings(feature.geometry);
|
|
if (bridgeLines.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const distanceNm = minDistanceBetweenLinesNm(bridgeLines, routeLine);
|
|
if (!Number.isFinite(distanceNm)) {
|
|
return null;
|
|
}
|
|
|
|
const coordinate = centroid(bridgeLines.flat());
|
|
if (!coordinate) {
|
|
return null;
|
|
}
|
|
|
|
const properties = (feature.properties ?? {}) as Record<string, unknown>;
|
|
const clearanceM = normalizeMeters(properties.clearance_m);
|
|
const name = stringProperty(properties.name);
|
|
const clearanceLabel = stringProperty(properties.clearance_label) ?? (clearanceM !== null ? `H ${formatMeters(clearanceM)}` : null);
|
|
const label = stringProperty(properties.label) ?? name ?? clearanceLabel ?? "Brücke";
|
|
const status = bridgeStatus(clearanceM, requiredAirDraftM);
|
|
const marginM = clearanceM !== null && requiredAirDraftM !== null ? clearanceM - requiredAirDraftM : null;
|
|
const idCandidate = feature.id ?? properties.source_id ?? `${coordinate.lat.toFixed(5)}:${coordinate.lon.toFixed(5)}`;
|
|
|
|
return {
|
|
id: String(idCandidate),
|
|
name,
|
|
label,
|
|
coordinate,
|
|
distanceNm,
|
|
clearanceM,
|
|
clearanceLabel,
|
|
requiredAirDraftM,
|
|
marginM,
|
|
status,
|
|
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"
|
|
])
|
|
};
|
|
}
|
|
|
|
function summarizeBridgeReport(
|
|
bridges: RouteBridgeAssessment[],
|
|
requiredAirDraftM: number | null
|
|
): RouteBridgeReport {
|
|
const knownClearanceBridges = bridges.filter((bridge) => bridge.clearanceM !== null);
|
|
const unknownCount = bridges.length - knownClearanceBridges.length;
|
|
const checkedCount = requiredAirDraftM === null ? 0 : knownClearanceBridges.length;
|
|
const tooLowCount = bridges.filter((bridge) => bridge.status === "too_low").length;
|
|
const tightCount = bridges.filter((bridge) => bridge.status === "tight").length;
|
|
const minKnownClearanceM = minValue(knownClearanceBridges.map((bridge) => bridge.clearanceM));
|
|
const severity = bridgeSeverity(bridges);
|
|
|
|
return {
|
|
bridges,
|
|
requiredAirDraftM,
|
|
checkedCount,
|
|
unknownCount,
|
|
tooLowCount,
|
|
tightCount,
|
|
minClearanceM: minKnownClearanceM,
|
|
severity,
|
|
summary: bridgeSummary({
|
|
bridgeCount: bridges.length,
|
|
unknownCount,
|
|
tooLowCount,
|
|
tightCount,
|
|
minClearanceM: minKnownClearanceM,
|
|
requiredAirDraftM
|
|
}),
|
|
source: unique(bridges.map((bridge) => bridge.source)).join(", ") || "OSM/Geofabrik",
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
function unavailableBridgeReport(vesselProfile: Pick<VesselProfile, "airDraftM">): RouteBridgeReport {
|
|
return {
|
|
bridges: [],
|
|
requiredAirDraftM: normalizeMeters(vesselProfile.airDraftM),
|
|
checkedCount: 0,
|
|
unknownCount: 0,
|
|
tooLowCount: 0,
|
|
tightCount: 0,
|
|
minClearanceM: null,
|
|
severity: "caution",
|
|
summary: "Brückenprüfung nicht erreichbar. Durchfahrtshöhen vor Abfahrt extern prüfen.",
|
|
source: "OSM/Geofabrik",
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
}
|
|
|
|
function bridgeStatus(clearanceM: number | null, requiredAirDraftM: number | null): RouteBridgeStatus {
|
|
if (clearanceM === null || requiredAirDraftM === null) {
|
|
return "unknown";
|
|
}
|
|
|
|
const marginM = clearanceM - requiredAirDraftM;
|
|
if (marginM < 0) {
|
|
return "too_low";
|
|
}
|
|
if (marginM < BRIDGE_TIGHT_MARGIN_M) {
|
|
return "tight";
|
|
}
|
|
return "passable";
|
|
}
|
|
|
|
function bridgeSeverity(bridges: RouteBridgeAssessment[]): RouteBridgeReport["severity"] {
|
|
if (bridges.some((bridge) => bridge.status === "too_low")) {
|
|
return "critical";
|
|
}
|
|
if (bridges.some((bridge) => bridge.status === "tight" || bridge.status === "unknown")) {
|
|
return "caution";
|
|
}
|
|
return "ok";
|
|
}
|
|
|
|
function bridgeSummary({
|
|
bridgeCount,
|
|
unknownCount,
|
|
tooLowCount,
|
|
tightCount,
|
|
minClearanceM,
|
|
requiredAirDraftM
|
|
}: {
|
|
bridgeCount: number;
|
|
unknownCount: number;
|
|
tooLowCount: number;
|
|
tightCount: number;
|
|
minClearanceM: number | null;
|
|
requiredAirDraftM: number | null;
|
|
}) {
|
|
if (bridgeCount === 0) {
|
|
return "Keine Brücken im Routenkorridor erkannt.";
|
|
}
|
|
|
|
if (requiredAirDraftM === null) {
|
|
return `${bridgeCount} Brücken erkannt. Bootshöhe fehlt, Durchfahrt nicht bewertbar.`;
|
|
}
|
|
|
|
if (tooLowCount > 0) {
|
|
return `Nicht passierbar: ${tooLowCount} Brücke(n) niedriger als ${formatMeters(requiredAirDraftM)} Bootshöhe.`;
|
|
}
|
|
|
|
if (tightCount > 0) {
|
|
return `Knapp: ${tightCount} Brücke(n) mit weniger als ${formatMeters(BRIDGE_TIGHT_MARGIN_M)} Reserve.`;
|
|
}
|
|
|
|
if (unknownCount > 0) {
|
|
return `${unknownCount} Brücke(n) ohne Höhenangabe. Durchfahrt vor Abfahrt prüfen.`;
|
|
}
|
|
|
|
return `Brücken passierbar: ${bridgeCount} Brücke(n), min. ${formatMeters(minClearanceM ?? requiredAirDraftM)} Durchfahrt.`;
|
|
}
|
|
|
|
function dedupeBridges(bridges: RouteBridgeAssessment[]) {
|
|
const byId = new Map<string, RouteBridgeAssessment>();
|
|
for (const bridge of bridges) {
|
|
const existing = byId.get(bridge.id);
|
|
byId.set(bridge.id, existing ? mergeBridgeAssessments(existing, bridge) : bridge);
|
|
}
|
|
|
|
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[][] {
|
|
switch (geometry.type) {
|
|
case "Point":
|
|
return [singlePositionLine(geometry.coordinates)].filter((line) => line.length > 0);
|
|
case "MultiPoint":
|
|
return geometry.coordinates.map(singlePositionLine).filter((line) => line.length > 0);
|
|
case "LineString":
|
|
return [positionsToLine(geometry.coordinates)].filter((line) => line.length > 0);
|
|
case "MultiLineString":
|
|
return geometry.coordinates.map(positionsToLine).filter((line) => line.length > 0);
|
|
case "Polygon":
|
|
return geometry.coordinates.map(positionsToLine).filter((line) => line.length > 0);
|
|
case "MultiPolygon":
|
|
return geometry.coordinates.flat().map(positionsToLine).filter((line) => line.length > 0);
|
|
case "GeometryCollection":
|
|
return geometry.geometries.flatMap(geometryLineStrings);
|
|
}
|
|
}
|
|
|
|
function singlePositionLine(position: Position): LonLat[] {
|
|
const coordinate = positionToLonLat(position);
|
|
return coordinate ? [coordinate] : [];
|
|
}
|
|
|
|
function positionsToLine(positions: Position[]): LonLat[] {
|
|
return positions.map(positionToLonLat).filter((coordinate): coordinate is LonLat => Boolean(coordinate));
|
|
}
|
|
|
|
function positionToLonLat(position: Position): LonLat | null {
|
|
const [lon, lat] = position;
|
|
return typeof lon === "number" && typeof lat === "number" && Number.isFinite(lon) && Number.isFinite(lat)
|
|
? [lon, lat]
|
|
: null;
|
|
}
|
|
|
|
function minDistanceBetweenLinesNm(featureLines: LonLat[][], routeLine: LonLat[]) {
|
|
if (routeLine.length === 0) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
|
|
const origin = routeLine[0]!;
|
|
let minDistanceNm = Number.POSITIVE_INFINITY;
|
|
for (const featureLine of featureLines) {
|
|
if (featureLine.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
if (featureLine.length === 1) {
|
|
minDistanceNm = Math.min(minDistanceNm, minPointToLineDistanceNm(featureLine[0]!, routeLine, origin));
|
|
continue;
|
|
}
|
|
|
|
if (routeLine.length === 1) {
|
|
minDistanceNm = Math.min(minDistanceNm, minPointToLineDistanceNm(routeLine[0]!, featureLine, origin));
|
|
continue;
|
|
}
|
|
|
|
for (let featureIndex = 1; featureIndex < featureLine.length; featureIndex += 1) {
|
|
const featureStart = featureLine[featureIndex - 1]!;
|
|
const featureEnd = featureLine[featureIndex]!;
|
|
for (let routeIndex = 1; routeIndex < routeLine.length; routeIndex += 1) {
|
|
minDistanceNm = Math.min(
|
|
minDistanceNm,
|
|
segmentDistanceNm(featureStart, featureEnd, routeLine[routeIndex - 1]!, routeLine[routeIndex]!, origin)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return minDistanceNm;
|
|
}
|
|
|
|
function minPointToLineDistanceNm(point: LonLat, line: LonLat[], origin: LonLat) {
|
|
if (line.length === 0) {
|
|
return Number.POSITIVE_INFINITY;
|
|
}
|
|
if (line.length === 1) {
|
|
return distanceBetweenProjected(project(point, origin), project(line[0]!, origin));
|
|
}
|
|
|
|
let minDistanceNm = Number.POSITIVE_INFINITY;
|
|
for (let index = 1; index < line.length; index += 1) {
|
|
minDistanceNm = Math.min(
|
|
minDistanceNm,
|
|
pointToSegmentDistance(project(point, origin), project(line[index - 1]!, origin), project(line[index]!, origin))
|
|
);
|
|
}
|
|
return minDistanceNm;
|
|
}
|
|
|
|
function segmentDistanceNm(startA: LonLat, endA: LonLat, startB: LonLat, endB: LonLat, origin: LonLat) {
|
|
const a = project(startA, origin);
|
|
const b = project(endA, origin);
|
|
const c = project(startB, origin);
|
|
const d = project(endB, origin);
|
|
|
|
if (segmentsIntersect(a, b, c, d)) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.min(
|
|
pointToSegmentDistance(a, c, d),
|
|
pointToSegmentDistance(b, c, d),
|
|
pointToSegmentDistance(c, a, b),
|
|
pointToSegmentDistance(d, a, b)
|
|
);
|
|
}
|
|
|
|
function project([lon, lat]: LonLat, [originLon, originLat]: LonLat): ProjectedPoint {
|
|
const averageLatRad = ((lat + originLat) / 2) * (Math.PI / 180);
|
|
return {
|
|
x: (lon - originLon) * 60 * Math.cos(averageLatRad),
|
|
y: (lat - originLat) * 60
|
|
};
|
|
}
|
|
|
|
function segmentsIntersect(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint, d: ProjectedPoint) {
|
|
const o1 = orientation(a, b, c);
|
|
const o2 = orientation(a, b, d);
|
|
const o3 = orientation(c, d, a);
|
|
const o4 = orientation(c, d, b);
|
|
|
|
if (o1 !== o2 && o3 !== o4) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
(o1 === 0 && onSegment(a, c, b)) ||
|
|
(o2 === 0 && onSegment(a, d, b)) ||
|
|
(o3 === 0 && onSegment(c, a, d)) ||
|
|
(o4 === 0 && onSegment(c, b, d))
|
|
);
|
|
}
|
|
|
|
function orientation(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint) {
|
|
const value = (b.y - a.y) * (c.x - b.x) - (b.x - a.x) * (c.y - b.y);
|
|
if (Math.abs(value) < 1e-9) {
|
|
return 0;
|
|
}
|
|
return value > 0 ? 1 : 2;
|
|
}
|
|
|
|
function onSegment(a: ProjectedPoint, b: ProjectedPoint, c: ProjectedPoint) {
|
|
return (
|
|
b.x <= Math.max(a.x, c.x) + 1e-9 &&
|
|
b.x >= Math.min(a.x, c.x) - 1e-9 &&
|
|
b.y <= Math.max(a.y, c.y) + 1e-9 &&
|
|
b.y >= Math.min(a.y, c.y) - 1e-9
|
|
);
|
|
}
|
|
|
|
function pointToSegmentDistance(point: ProjectedPoint, start: ProjectedPoint, end: ProjectedPoint) {
|
|
const dx = end.x - start.x;
|
|
const dy = end.y - start.y;
|
|
if (dx === 0 && dy === 0) {
|
|
return distanceBetweenProjected(point, start);
|
|
}
|
|
|
|
const ratio = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / (dx * dx + dy * dy)));
|
|
return distanceBetweenProjected(point, {
|
|
x: start.x + ratio * dx,
|
|
y: start.y + ratio * dy
|
|
});
|
|
}
|
|
|
|
function distanceBetweenProjected(left: ProjectedPoint, right: ProjectedPoint) {
|
|
return Math.hypot(left.x - right.x, left.y - right.y);
|
|
}
|
|
|
|
function centroid(coordinates: LonLat[]): Coordinate | null {
|
|
if (coordinates.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const sum = coordinates.reduce(
|
|
(total, [lon, lat]) => ({
|
|
lon: total.lon + lon,
|
|
lat: total.lat + lat
|
|
}),
|
|
{ lon: 0, lat: 0 }
|
|
);
|
|
|
|
return {
|
|
lon: sum.lon / coordinates.length,
|
|
lat: sum.lat / coordinates.length
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (typeof value !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const match = value.replace(",", ".").match(/\d+(?:\.\d+)?/);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const parsed = Number(match[0]);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function formatMeters(value: number) {
|
|
return Number.isInteger(value) ? `${value} m` : `${value.toFixed(1)} m`;
|
|
}
|