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

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`;
}