From 1aef9bbdbc8143228b2b116c71b046454a198214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kristian=20St=C3=B6ckel?= Date: Fri, 25 Sep 2026 22:38:17 +0200 Subject: [PATCH] :bug: fix snapping of route segments on long lines without points --- app/Services/GeodesicService.php | 24 +++++++++++++++++++ app/Services/Trip/ProviderPolylineService.php | 24 ++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/app/Services/GeodesicService.php b/app/Services/GeodesicService.php index e888d9cf0..5289988d2 100644 --- a/app/Services/GeodesicService.php +++ b/app/Services/GeodesicService.php @@ -72,6 +72,30 @@ public function haversineDistance(Coordinate $from, Coordinate $to): int return (int) round($this->exactHaversineDistance($from, $to)); } + /** + * Distance in meters from a coordinate to the closest place on the straight line between two + * others. Projects onto a local flat plane, which is exact enough for lines of a few dozen + * kilometres. + */ + public function distanceToSegment(Coordinate $needle, Coordinate $from, Coordinate $to): int + { + $metersPerDegree = self::EARTH_RADIUS_METERS * M_PI / 180; + $metersPerDegreeLongitude = $metersPerDegree * cos(deg2rad($needle->latitude)); + + $fromX = ($from->longitude - $needle->longitude) * $metersPerDegreeLongitude; + $fromY = ($from->latitude - $needle->latitude) * $metersPerDegree; + $deltaX = ($to->longitude - $from->longitude) * $metersPerDegreeLongitude; + $deltaY = ($to->latitude - $from->latitude) * $metersPerDegree; + + $lengthSquared = $deltaX * $deltaX + $deltaY * $deltaY; + // Share of the way from $from to $to where the perpendicular from $needle hits the line + $share = $lengthSquared > 0.0 + ? max(0.0, min(1.0, -($fromX * $deltaX + $fromY * $deltaY) / $lengthSquared)) + : 0.0; + + return (int) round(hypot($fromX + $share * $deltaX, $fromY + $share * $deltaY)); + } + /** * Find the index of the path point closest to the given coordinate. * diff --git a/app/Services/Trip/ProviderPolylineService.php b/app/Services/Trip/ProviderPolylineService.php index e1ade5521..f80f9c3d4 100644 --- a/app/Services/Trip/ProviderPolylineService.php +++ b/app/Services/Trip/ProviderPolylineService.php @@ -339,7 +339,7 @@ private function snapStopoversToGeometry(Collection $stopovers, array $coordinat return null; } - $snapDistance = $this->geodesicService->haversineDistance($location, $coordinates[$bestIndex]); + $snapDistance = $this->distanceToGeometryAround($location, $coordinates, $bestIndex); if ($snapDistance > self::MAX_SNAP_DISTANCE_METERS) { Log::debug('ProviderPolyline: Stopover too far away from geometry', [ 'station' => $stopover->station?->name, @@ -388,6 +388,28 @@ private function snapStopoversToGeometry(Collection $stopovers, array $coordinat return $indices; } + /** + * Distance from a location to the line leading into and out of the given geometry point. On + * straight stretches the provider leaves kilometres between two points, so a stop right on the + * line can still be far away from the closest point itself. + * + * (Example: Long AMTRAK tracks in the US) + * + * @param Coordinate[] $coordinates + */ + private function distanceToGeometryAround(Coordinate $location, array $coordinates, int $index): int + { + $distance = $this->geodesicService->haversineDistance($location, $coordinates[$index]); + + foreach ([$index - 1, $index + 1] as $neighbour) { + if (isset($coordinates[$neighbour])) { + $distance = min($distance, $this->geodesicService->distanceToSegment($location, $coordinates[$index], $coordinates[$neighbour])); + } + } + + return $distance; + } + /** * Straight-line distance between two stopovers, used to judge how much of a detour the * sliced shape describes. Returns 0 when either side has no usable coordinates.