import { haversineDistanceNm, sumRouteDistanceNm } from "./geo.js"; import type { Coordinate, DepthSample, RouteOption, RouteRequest, RouteResult, RouteWarning, VesselProfile } from "./types.js"; export type FairwayNode = { id: string; coordinate: Coordinate; }; export type FairwayEdge = { id: string; name: string; from: string; to: string; coordinates: Coordinate[]; minDepthM: number | null; maxAirDraftM?: number | null; maxBeamM?: number | null; maxDraughtM?: number | null; oneway?: boolean | "forward" | "backward"; source?: string; }; type EdgeSnap = { edge: FairwayEdge; coordinate: Coordinate; segmentIndex: number; t: number; distanceNm: number; }; export type FairwayGraph = { id: string; name: string; maxSnapDistanceNm: number; nodes: FairwayNode[]; edges: FairwayEdge[]; }; type PathStep = { edge: FairwayEdge; from: string; to: string; weightNm: number; }; type Adjacency = Map; type FairwayLeg = { coordinates: Coordinate[]; usedEdges: FairwayEdge[]; distanceNm: number; costNm: number; }; type FairwayRouteCandidate = { route: RouteResult; usedEdges: FairwayEdge[]; }; const DEFAULT_CRUISE_SPEED_KN = 6; const MAX_ALTERNATIVE_ROUTES = 3; const ALTERNATIVE_EDGE_PENALTY = 8; const MAX_ALTERNATIVE_DISTANCE_FACTOR = 1.75; const MIN_DIFFERENT_DISTANCE_NM = 0.25; const MIN_DIFFERENT_DISTANCE_RATIO = 0.05; const EMS_BORKUM_GRAPH: FairwayGraph = { id: "ems-borkum-seed", name: "Emsfahrwasser Emden-Borkum", maxSnapDistanceNm: 3, nodes: [ { id: "emden-aussenhafen", coordinate: { lat: 53.344167, lon: 7.186111 } }, { id: "emden-harbour-leading", coordinate: { lat: 53.333897, lon: 7.201469 } }, { id: "ems-fairway-inner", coordinate: { lat: 53.33532, lon: 7.132674 } }, { id: "knock-reach", coordinate: { lat: 53.327597, lon: 7.015002 } }, { id: "paapsand-reach", coordinate: { lat: 53.339, lon: 6.93 } }, { id: "westerems-south", coordinate: { lat: 53.376, lon: 6.865 } }, { id: "eemshaven-approach", coordinate: { lat: 53.442996, lon: 6.833146 } }, { id: "borkum-south-approach", coordinate: { lat: 53.505, lon: 6.765 } }, { id: "borkum-reede", coordinate: { lat: 53.563776, lon: 6.750562 } } ], edges: [ { id: "emden-harbour-exit", name: "Emden Außenhafen Ausfahrt", from: "emden-aussenhafen", to: "emden-harbour-leading", minDepthM: 8.5, coordinates: [ { lat: 53.344167, lon: 7.186111 }, { lat: 53.339, lon: 7.191 }, { lat: 53.333897, lon: 7.201469 } ] }, { id: "emden-leading-to-ems", name: "Emder Leitlinie zum Emsfahrwasser", from: "emden-harbour-leading", to: "ems-fairway-inner", minDepthM: 8.5, coordinates: [ { lat: 53.333897, lon: 7.201469 }, { lat: 53.332236, lon: 7.132959 }, { lat: 53.33532, lon: 7.132674 } ] }, { id: "inner-ems-to-knock", name: "Inneres Emsfahrwasser", from: "ems-fairway-inner", to: "knock-reach", minDepthM: 8.5, coordinates: [ { lat: 53.33532, lon: 7.132674 }, { lat: 53.333, lon: 7.075 }, { lat: 53.327597, lon: 7.015002 } ] }, { id: "knock-to-paapsand", name: "Außenems über Paapsand", from: "knock-reach", to: "paapsand-reach", minDepthM: 7.5, coordinates: [ { lat: 53.327597, lon: 7.015002 }, { lat: 53.323764, lon: 6.985 }, { lat: 53.329, lon: 6.955 }, { lat: 53.339, lon: 6.93 } ] }, { id: "paapsand-to-westerems", name: "Westerems Südansteuerung", from: "paapsand-reach", to: "westerems-south", minDepthM: 7, coordinates: [ { lat: 53.339, lon: 6.93 }, { lat: 53.348, lon: 6.9 }, { lat: 53.36, lon: 6.88 }, { lat: 53.376, lon: 6.865 } ] }, { id: "westerems-to-eemshaven", name: "Westerems Fahrwasser", from: "westerems-south", to: "eemshaven-approach", minDepthM: 7, coordinates: [ { lat: 53.376, lon: 6.865 }, { lat: 53.398, lon: 6.85 }, { lat: 53.421, lon: 6.838 }, { lat: 53.442996, lon: 6.833146 } ] }, { id: "eemshaven-to-borkum-south", name: "Borkum Südansteuerung", from: "eemshaven-approach", to: "borkum-south-approach", minDepthM: 6, coordinates: [ { lat: 53.442996, lon: 6.833146 }, { lat: 53.463, lon: 6.807 }, { lat: 53.483, lon: 6.785 }, { lat: 53.505, lon: 6.765 } ] }, { id: "borkum-south-to-reede", name: "Borkum Reede Ansteuerung", from: "borkum-south-approach", to: "borkum-reede", minDepthM: 5, coordinates: [ { lat: 53.505, lon: 6.765 }, { lat: 53.525, lon: 6.758 }, { lat: 53.545, lon: 6.753 }, { lat: 53.563776, lon: 6.750562 } ] } ] }; export function buildFairwayRoute(request: RouteRequest, graph = EMS_BORKUM_GRAPH): RouteResult | null { return buildFairwayRoutes(request, graph, 1)[0] ?? null; } export function buildFairwayRoutes( request: RouteRequest, graph = EMS_BORKUM_GRAPH, maxRoutes = MAX_ALTERNATIVE_ROUTES ): RouteOption[] { const routeLimit = Math.max(0, Math.min(MAX_ALTERNATIVE_ROUTES, Math.floor(maxRoutes))); if (routeLimit === 0) { return []; } const routableGraph = filterRestrictedEdges(graph, request); if (routableGraph.edges.length === 0) { return []; } const accepted: FairwayRouteCandidate[] = []; const penaltyCounts = new Map(); const maxAttempts = Math.max(8, routeLimit * 6); for (let attempt = 0; attempt < maxAttempts && accepted.length < routeLimit; attempt += 1) { const candidate = buildFairwayRouteCandidate(request, routableGraph, penaltyCounts); if (!candidate) { break; } const primaryDistanceNm = accepted[0]?.route.distanceNm; const isReasonableLength = primaryDistanceNm === undefined || candidate.route.distanceNm <= primaryDistanceNm * MAX_ALTERNATIVE_DISTANCE_FACTOR; if (isReasonableLength && isMeaningfullyDifferent(candidate, accepted)) { accepted.push(candidate); } for (const edge of candidate.usedEdges) { penaltyCounts.set(edge.id, (penaltyCounts.get(edge.id) ?? 0) + 1); } } const ordered = accepted.length > 1 ? [accepted[0]!, ...accepted.slice(1).sort((a, b) => a.route.distanceNm - b.route.distanceNm)] : accepted; return ordered.map((candidate, index) => ({ ...candidate.route, id: `${graph.id}-route-${index + 1}`, name: index === 0 ? "Hauptroute" : `Alternative ${index}` })); } function buildFairwayRouteCandidate( request: RouteRequest, routableGraph: FairwayGraph, penaltyCounts: ReadonlyMap ): FairwayRouteCandidate | null { const requestedPoints = [request.start, ...(request.waypoints ?? []), request.destination]; const routeCoordinates: Coordinate[] = []; const usedEdges = new Map(); const adjacency = buildAdjacency(routableGraph, penaltyCounts); for (let index = 0; index < requestedPoints.length - 1; index += 1) { const legStart = requestedPoints[index]!; const legDestination = requestedPoints[index + 1]!; const leg = buildFairwayLeg(routableGraph, legStart, legDestination, penaltyCounts, adjacency); if (!leg) { return null; } for (const coordinate of leg.coordinates) { appendCoordinate(routeCoordinates, coordinate); } for (const edge of leg.usedEdges) { usedEdges.set(edge.id, edge); } } const distanceNm = round(sumRouteDistanceNm(routeCoordinates), 2); const speedKn = request.vesselProfile.cruiseSpeedKn && request.vesselProfile.cruiseSpeedKn > 0 ? request.vesselProfile.cruiseSpeedKn : DEFAULT_CRUISE_SPEED_KN; const departureTimestamp = requestedDepartureTimestamp(request.departureTime); const durationMinutes = Math.round((distanceNm / speedKn) * 60); const eta = new Date(departureTimestamp + durationMinutes * 60 * 1000).toISOString(); const depthSamples = request.depthSamples ?? edgeDepthSamples([...usedEdges.values()]); const depthAssessment = assessFairwayDepthSamples(depthSamples, request.vesselProfile); const warnings: RouteWarning[] = [ { code: "FAIRWAY_ROUTE", severity: "info", message: `Route über bekannten Fahrwasser-Graphen: ${routableGraph.name}.` }, { code: "FAIRWAY_DATA_NOT_OFFICIAL", severity: "caution", message: "Fahrwasser-Graph ist eine MVP-Planungshilfe und keine amtliche Navigationsgrundlage." }, ...depthAssessment.warnings ]; const route: RouteResult = { geometry: { type: "LineString", coordinates: routeCoordinates.map((coordinate) => [coordinate.lon, coordinate.lat]) }, distanceNm, eta, departureTime: new Date(departureTimestamp).toISOString(), durationMinutes, warnings, minKnownDepthM: depthAssessment.minKnownDepthM, unknownDepthRatio: depthAssessment.unknownDepthRatio, dataSources: [ `fairway-graph:${routableGraph.id}`, ...uniqueSources([...usedEdges.values()]), request.depthSamples?.length ? "submitted-depth-samples" : [...usedEdges.values()].some((edge) => edge.minDepthM === null) ? "fairway-depth-unknown" : "fairway-depth-estimates" ], routingMode: "fairway" }; return { route, usedEdges: [...usedEdges.values()] }; } function requestedDepartureTimestamp(value?: string): number { const timestamp = value ? Date.parse(value) : Number.NaN; return Number.isFinite(timestamp) ? timestamp : Date.now(); } export function mergeFairwayGraphs(id: string, name: string, graphs: FairwayGraph[]): FairwayGraph { const nodes = new Map(); const edges = new Map(); const maxSnapDistanceNm = Math.max(...graphs.map((graph) => graph.maxSnapDistanceNm)); for (const graph of graphs) { for (const node of graph.nodes) { nodes.set(`${graph.id}:${node.id}`, { ...node, id: `${graph.id}:${node.id}` }); } for (const edge of graph.edges) { edges.set(`${graph.id}:${edge.id}`, { ...edge, id: `${graph.id}:${edge.id}`, from: `${graph.id}:${edge.from}`, to: `${graph.id}:${edge.to}` }); } } return { id, name, maxSnapDistanceNm, nodes: [...nodes.values()], edges: [...edges.values()] }; } function filterRestrictedEdges(graph: FairwayGraph, request: RouteRequest): FairwayGraph { const requiredDepthM = request.vesselProfile.draughtM + request.vesselProfile.safetyReserveM; return { ...graph, edges: graph.edges.filter((edge) => { if (edge.minDepthM !== null && edge.minDepthM < requiredDepthM) { return false; } if (edge.maxDraughtM != null && request.vesselProfile.draughtM > edge.maxDraughtM) { return false; } if ( edge.maxAirDraftM != null && request.vesselProfile.airDraftM != null && request.vesselProfile.airDraftM > edge.maxAirDraftM ) { return false; } if (edge.maxBeamM != null && request.vesselProfile.beamM != null && request.vesselProfile.beamM > edge.maxBeamM) { return false; } return true; }) }; } function buildFairwayLeg( graph: FairwayGraph, legStart: Coordinate, legDestination: Coordinate, penaltyCounts: ReadonlyMap, adjacency: Adjacency ): FairwayLeg | null { const startSnap = findNearestEdgeSnap(graph, legStart); const destinationSnap = findNearestEdgeSnap(graph, legDestination); if ( !startSnap || !destinationSnap || startSnap.distanceNm > graph.maxSnapDistanceNm || destinationSnap.distanceNm > graph.maxSnapDistanceNm ) { return null; } const candidates: FairwayLeg[] = []; if (startSnap.edge.id === destinationSnap.edge.id && canTraverseBetweenSnaps(startSnap, destinationSnap)) { const directOnEdge = edgePathBetweenSnaps(startSnap, destinationSnap); const coordinates: Coordinate[] = []; appendCoordinate(coordinates, legStart); for (const coordinate of directOnEdge) { appendCoordinate(coordinates, coordinate); } appendCoordinate(coordinates, legDestination); candidates.push({ coordinates, usedEdges: [startSnap.edge], distanceNm: sumRouteDistanceNm(coordinates), costNm: sumRouteDistanceNm(coordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) }); } for (const startNodeId of [startSnap.edge.from, startSnap.edge.to]) { if (!canTraverseFromSnapToNode(startSnap, startNodeId)) { continue; } for (const destinationNodeId of [destinationSnap.edge.from, destinationSnap.edge.to]) { if (!canTraverseFromNodeToSnap(destinationSnap, destinationNodeId)) { continue; } const path = shortestPath(graph, adjacency, startNodeId, destinationNodeId); if (!path) { continue; } const coordinates: Coordinate[] = []; const usedEdges = new Map(); appendCoordinate(coordinates, legStart); const startEdgeCoordinates = edgePathFromSnapToNode(startSnap, startNodeId); for (const coordinate of startEdgeCoordinates) { appendCoordinate(coordinates, coordinate); } usedEdges.set(startSnap.edge.id, startSnap.edge); for (const step of path) { usedEdges.set(step.edge.id, step.edge); for (const coordinate of edgeCoordinates(step)) { appendCoordinate(coordinates, coordinate); } } const destinationEdgeCoordinates = edgePathFromNodeToSnap(destinationSnap, destinationNodeId); for (const coordinate of destinationEdgeCoordinates) { appendCoordinate(coordinates, coordinate); } usedEdges.set(destinationSnap.edge.id, destinationSnap.edge); appendCoordinate(coordinates, legDestination); const distanceNm = sumRouteDistanceNm(coordinates); const costNm = sumRouteDistanceNm(startEdgeCoordinates) * edgePenaltyMultiplier(startSnap.edge, penaltyCounts) + path.reduce((total, step) => total + step.weightNm, 0) + sumRouteDistanceNm(destinationEdgeCoordinates) * edgePenaltyMultiplier(destinationSnap.edge, penaltyCounts); candidates.push({ coordinates, usedEdges: [...usedEdges.values()], distanceNm, costNm }); } } return candidates.sort((a, b) => a.costNm - b.costNm || a.distanceNm - b.distanceNm)[0] ?? null; } function findNearestEdgeSnap(graph: FairwayGraph, coordinate: Coordinate): EdgeSnap | null { let nearest: EdgeSnap | null = null; for (const edge of graph.edges) { for (let index = 0; index < edge.coordinates.length - 1; index += 1) { const start = edge.coordinates[index]!; const end = edge.coordinates[index + 1]!; const snap = closestPointOnSegment(coordinate, start, end); const distanceNm = haversineDistanceNm(coordinate, snap.coordinate); if (!nearest || distanceNm < nearest.distanceNm) { nearest = { edge, coordinate: snap.coordinate, segmentIndex: index, t: snap.t, distanceNm }; } } } return nearest; } function shortestPath( graph: FairwayGraph, adjacency: Adjacency, startId: string, destinationId: string ): PathStep[] | null { if (startId === destinationId) { return []; } const distances = new Map(); const previous = new Map(); const visited = new Set(); const queue = new MinHeap(); for (const node of graph.nodes) { distances.set(node.id, node.id === startId ? 0 : Number.POSITIVE_INFINITY); } queue.push({ nodeId: startId, distanceNm: 0 }); while (queue.size > 0) { const current = queue.pop(); if (!current || visited.has(current.nodeId)) { continue; } visited.add(current.nodeId); if (current.nodeId === destinationId) { break; } for (const next of adjacency.get(current.nodeId) ?? []) { if (visited.has(next.to)) { continue; } const alternative = current.distanceNm + next.weightNm; if (alternative < (distances.get(next.to) ?? Number.POSITIVE_INFINITY)) { distances.set(next.to, alternative); previous.set(next.to, next); queue.push({ nodeId: next.to, distanceNm: alternative }); } } } if (!previous.has(destinationId)) { return null; } const path: PathStep[] = []; let cursor = destinationId; while (cursor !== startId) { const step = previous.get(cursor); if (!step) { return null; } path.unshift(step); cursor = step.from; } return path; } function buildAdjacency(graph: FairwayGraph, penaltyCounts: ReadonlyMap): Adjacency { const adjacency: Adjacency = new Map(); for (const edge of graph.edges) { const weightNm = edgeLengthNm(edge) * edgePenaltyMultiplier(edge, penaltyCounts); const forward = { edge, from: edge.from, to: edge.to, weightNm }; const backward = { edge, from: edge.to, to: edge.from, weightNm }; if (canTraverseForward(edge)) { adjacency.set(edge.from, [...(adjacency.get(edge.from) ?? []), forward]); } if (canTraverseBackward(edge)) { adjacency.set(edge.to, [...(adjacency.get(edge.to) ?? []), backward]); } } return adjacency; } function canTraverseForward(edge: FairwayEdge) { return edge.oneway !== "backward"; } function canTraverseBackward(edge: FairwayEdge) { return edge.oneway !== true && edge.oneway !== "forward"; } function canTraverseBetweenSnaps(start: EdgeSnap, destination: EdgeSnap) { const startPosition = start.segmentIndex + start.t; const destinationPosition = destination.segmentIndex + destination.t; return startPosition <= destinationPosition ? canTraverseForward(start.edge) : canTraverseBackward(start.edge); } function canTraverseFromSnapToNode(snap: EdgeSnap, nodeId: string) { return nodeId === snap.edge.from ? canTraverseBackward(snap.edge) : canTraverseForward(snap.edge); } function canTraverseFromNodeToSnap(snap: EdgeSnap, nodeId: string) { return nodeId === snap.edge.from ? canTraverseForward(snap.edge) : canTraverseBackward(snap.edge); } function edgePenaltyMultiplier(edge: FairwayEdge, penaltyCounts: ReadonlyMap) { return 1 + (penaltyCounts.get(edge.id) ?? 0) * ALTERNATIVE_EDGE_PENALTY; } function isMeaningfullyDifferent(candidate: FairwayRouteCandidate, accepted: FairwayRouteCandidate[]) { if (accepted.length === 0) { return true; } const candidateGeometry = geometrySignature(candidate.route); const candidateEdges = edgeDistanceMap(candidate.usedEdges); const candidateEdgeSignature = [...candidateEdges.keys()].sort().join("|"); return accepted.every((existing) => { if (geometrySignature(existing.route) === candidateGeometry) { return false; } const existingEdges = edgeDistanceMap(existing.usedEdges); if ([...existingEdges.keys()].sort().join("|") === candidateEdgeSignature) { return false; } const candidateDistanceNm = sumMapValues(candidateEdges); const existingDistanceNm = sumMapValues(existingEdges); let differentDistanceNm = 0; for (const [edgeId, distanceNm] of candidateEdges) { if (!existingEdges.has(edgeId)) { differentDistanceNm += distanceNm; } } for (const [edgeId, distanceNm] of existingEdges) { if (!candidateEdges.has(edgeId)) { differentDistanceNm += distanceNm; } } const referenceDistanceNm = Math.min(candidateDistanceNm, existingDistanceNm); const minimumDifferenceNm = Math.max( MIN_DIFFERENT_DISTANCE_NM, referenceDistanceNm * MIN_DIFFERENT_DISTANCE_RATIO ); return differentDistanceNm >= minimumDifferenceNm; }); } function edgeDistanceMap(edges: FairwayEdge[]) { return new Map(edges.map((edge) => [edge.id, edgeLengthNm(edge)])); } function sumMapValues(values: Map) { let total = 0; for (const value of values.values()) { total += value; } return total; } function geometrySignature(route: RouteResult) { return route.geometry.coordinates .map(([lon, lat]) => `${lon.toFixed(5)},${lat.toFixed(5)}`) .join(";"); } class MinHeap { private readonly items: Array<{ nodeId: string; distanceNm: number }> = []; get size() { return this.items.length; } push(item: { nodeId: string; distanceNm: number }) { this.items.push(item); this.bubbleUp(this.items.length - 1); } pop() { const first = this.items[0]; const last = this.items.pop(); if (!first || !last) { return first; } if (this.items.length > 0) { this.items[0] = last; this.bubbleDown(0); } return first; } private bubbleUp(index: number) { let cursor = index; while (cursor > 0) { const parent = Math.floor((cursor - 1) / 2); if (this.items[parent]!.distanceNm <= this.items[cursor]!.distanceNm) { break; } this.swap(parent, cursor); cursor = parent; } } private bubbleDown(index: number) { let cursor = index; while (true) { const left = cursor * 2 + 1; const right = left + 1; let smallest = cursor; if (left < this.items.length && this.items[left]!.distanceNm < this.items[smallest]!.distanceNm) { smallest = left; } if (right < this.items.length && this.items[right]!.distanceNm < this.items[smallest]!.distanceNm) { smallest = right; } if (smallest === cursor) { break; } this.swap(cursor, smallest); cursor = smallest; } } private swap(a: number, b: number) { const temp = this.items[a]!; this.items[a] = this.items[b]!; this.items[b] = temp; } } function edgeCoordinates(step: PathStep) { return step.from === step.edge.from ? step.edge.coordinates : [...step.edge.coordinates].reverse(); } function edgePathFromSnapToNode(snap: EdgeSnap, nodeId: string): Coordinate[] { const coordinates = snap.edge.coordinates; if (nodeId === snap.edge.from) { return [ snap.coordinate, ...coordinates.slice(0, snap.segmentIndex + 1).reverse() ]; } return [ snap.coordinate, ...coordinates.slice(snap.segmentIndex + 1) ]; } function edgePathFromNodeToSnap(snap: EdgeSnap, nodeId: string): Coordinate[] { return [...edgePathFromSnapToNode(snap, nodeId)].reverse(); } function edgePathBetweenSnaps(a: EdgeSnap, b: EdgeSnap): Coordinate[] { if (a.edge.id !== b.edge.id) { return []; } const coordinates = a.edge.coordinates; const aPosition = a.segmentIndex + a.t; const bPosition = b.segmentIndex + b.t; if (aPosition <= bPosition) { return [ a.coordinate, ...coordinates.slice(a.segmentIndex + 1, b.segmentIndex + 1), b.coordinate ]; } return [ a.coordinate, ...coordinates.slice(b.segmentIndex + 1, a.segmentIndex + 1).reverse(), b.coordinate ]; } function edgeLengthNm(edge: FairwayEdge) { return sumRouteDistanceNm(edge.coordinates); } function edgeDepthSamples(edges: FairwayEdge[]): DepthSample[] { return edges.map((edge) => ({ coordinate: edge.coordinates[Math.floor(edge.coordinates.length / 2)]!, depthM: edge.minDepthM })); } function uniqueSources(edges: FairwayEdge[]) { const sources = new Set(); for (const edge of edges) { sources.add(edge.source ?? "openstreetmap-openseamap-derived-seamarks"); } return [...sources]; } function assessFairwayDepthSamples( samples: DepthSample[], profile: VesselProfile ): { minKnownDepthM: number | null; unknownDepthRatio: number; warnings: RouteWarning[]; } { const known = samples.filter((sample) => typeof sample.depthM === "number"); const unknownDepthRatio = samples.length > 0 ? round((samples.length - known.length) / samples.length, 2) : 1; const minKnownDepthM = known.length > 0 ? Math.min(...known.map((sample) => sample.depthM!)) : null; const requiredDepthM = round(profile.draughtM + profile.safetyReserveM, 2); const warnings: RouteWarning[] = []; if (unknownDepthRatio > 0) { warnings.push({ code: "DEPTH_PARTIAL", severity: unknownDepthRatio > 0.5 ? "caution" : "info", message: `${Math.round(unknownDepthRatio * 100)}% der Route haben keine Tiefenprobe.` }); } if (minKnownDepthM !== null && minKnownDepthM < requiredDepthM) { warnings.push({ code: "DEPTH_TOO_SHALLOW", severity: "critical", message: `Minimale bekannte Tiefe ${round(minKnownDepthM, 1)} m unterschreitet erforderliche Tiefe ${requiredDepthM} m.` }); } if (known.length === 0) { warnings.push({ code: "NO_KNOWN_DEPTH", severity: "caution", message: "Alle geprüften Tiefenpunkte sind unbekannt." }); } return { minKnownDepthM, unknownDepthRatio, warnings }; } function appendCoordinate(points: Coordinate[], coordinate: Coordinate) { const previous = points.at(-1); if (previous && haversineDistanceNm(previous, coordinate) < 0.005) { return; } points.push(coordinate); } function closestPointOnSegment(point: Coordinate, start: Coordinate, end: Coordinate): { coordinate: Coordinate; t: number } { const origin = start; const pointXY = toLocalNm(point, origin); const startXY = toLocalNm(start, origin); const endXY = toLocalNm(end, origin); const edgeX = endXY.x - startXY.x; const edgeY = endXY.y - startXY.y; const edgeLengthSquared = edgeX * edgeX + edgeY * edgeY; const rawT = edgeLengthSquared === 0 ? 0 : ((pointXY.x - startXY.x) * edgeX + (pointXY.y - startXY.y) * edgeY) / edgeLengthSquared; const t = Math.max(0, Math.min(1, rawT)); return { t, coordinate: { lat: start.lat + (end.lat - start.lat) * t, lon: start.lon + (end.lon - start.lon) * t } }; } function toLocalNm(coordinate: Coordinate, origin: Coordinate) { const meanLatRad = ((coordinate.lat + origin.lat) / 2) * (Math.PI / 180); return { x: (coordinate.lon - origin.lon) * 60 * Math.cos(meanLatRad), y: (coordinate.lat - origin.lat) * 60 }; } function round(value: number, digits: number): number { const factor = 10 ** digits; return Math.round(value * factor) / factor; }