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; type FetchFeatures = (params: { bbox: [number, number, number, number]; layers: string[] }) => Promise; 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; }; 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; export async function createRouteWeatherReport( route: RouteResult, fetchForecast: FetchForecast ): Promise; export async function createRouteWeatherReport( route: RouteResult, vesselProfile: VesselProfile, fetchForecast: FetchForecast, fetchFeatures?: FetchFeatures, departureTime?: string ): Promise; export async function createRouteWeatherReport( route: RouteResult, vesselProfileOrFetchForecast: VesselProfile | FetchForecast, maybeFetchForecast?: FetchForecast, fetchFeatures?: FetchFeatures, departureTime?: string ): Promise { 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, fetchFeatures: FetchFeatures ): Promise { 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) { 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) { const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); return valid.length > 0 ? Math.min(...valid) : null; } function maxBy(values: T[], selector: (value: T) => number | null | undefined) { return values.reduce((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; 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" }; } 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): 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(); for (const bridge of bridges) { const existing = byId.get(bridge.id); if (!existing || bridge.distanceNm < existing.distanceNm) { byId.set(bridge.id, bridge); } } return [...byId.values()]; } 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 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`; }