From 2c5d9df2df154bd0263fe88f99ffb991e6d5c9f0 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Tue, 21 Jul 2026 11:14:36 +0200 Subject: [PATCH 1/5] Fix imported routes collapsing to straight lines on heatmap Polyline::simplify() defaulted to a tolerance of 0.4, but the tolerance is expressed in degrees (~44km). Douglas-Peucker therefore dropped every point within 0.4 degrees of the start-end chord, collapsing any normal GPS route to just its endpoints - a straight line on the heatmap. Affected all file imports (FIT/TCX/GPX), which run coordinates through simplify(); Strava import is unaffected as its polyline comes from the API. Lower the default tolerance to 0.0001 (~11m) and add a regression test pinning the default against a realistic GPS-scale route. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ValueObject/Geography/Polyline.php | 2 +- .../ValueObject/Geography/PolylineTest.php | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/Infrastructure/ValueObject/Geography/Polyline.php b/src/Infrastructure/ValueObject/Geography/Polyline.php index c419d0e6a..9c361a47e 100644 --- a/src/Infrastructure/ValueObject/Geography/Polyline.php +++ b/src/Infrastructure/ValueObject/Geography/Polyline.php @@ -22,7 +22,7 @@ public static function fromCoordinates(array $coordinates): self return new self($coordinates); } - public function simplify(float $tolerance = 0.4): self + public function simplify(float $tolerance = 0.0001): self { $points = $this->coordinates; diff --git a/tests/Infrastructure/ValueObject/Geography/PolylineTest.php b/tests/Infrastructure/ValueObject/Geography/PolylineTest.php index b1f1909ee..59880d89b 100644 --- a/tests/Infrastructure/ValueObject/Geography/PolylineTest.php +++ b/tests/Infrastructure/ValueObject/Geography/PolylineTest.php @@ -72,6 +72,29 @@ public function testSimplifyWithLargeToleranceKeepsOnlyEndpoints(): void ); } + public function testSimplifyWithDefaultToleranceKeepsGpsScaleRoute(): void + { + $centerLat = 51.2194; + $centerLng = 4.4025; + $radius = 0.03; + $numberOfPoints = 200; + + $coordinates = []; + for ($i = 0; $i < $numberOfPoints; ++$i) { + $angle = 2 * M_PI * $i / $numberOfPoints; + $coordinates[] = [ + $centerLat + $radius * sin($angle), + $centerLng + $radius * cos($angle), + ]; + } + + $simplified = Polyline::fromCoordinates($coordinates)->simplify(); + + $decodedPoints = EncodedPolyline::fromString((string) $simplified->encode())->decodeAndPairLatLng(); + + self::assertGreaterThan(10, count($decodedPoints)); + } + public function testEncodeReturnsEncodedPolyline(): void { $coordinates = [ From e0eb9da35f79d85ef1e452496e4715c0699b123f Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Tue, 21 Jul 2026 11:40:16 +0200 Subject: [PATCH 2/5] Add migration recomputing over-simplified polylines for file imports Activities already imported from FIT/TCX/GPX files have a collapsed 2-point polyline stored (the straight line caused by the simplify(0.4) bug). Recompute their polyline from the still-intact latlng stream, using the corrected simplify tolerance, so users don't have to re-import. Scoped to file imports (fitFile/tcxFile/gpxFile); Strava API polylines are left untouched. Irreversible data migration (down() is a no-op). Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/Version20260721000000.php | 70 ++++++++++ .../Migrations/Version20260721000000Test.php | 132 ++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 migrations/Version20260721000000.php create mode 100644 tests/Infrastructure/Doctrine/Migrations/Version20260721000000Test.php diff --git a/migrations/Version20260721000000.php b/migrations/Version20260721000000.php new file mode 100644 index 000000000..d6bbdeb73 --- /dev/null +++ b/migrations/Version20260721000000.php @@ -0,0 +1,70 @@ +connection->executeQuery( + 'SELECT activityId FROM Activity WHERE importSource IN (:importSources)', + ['importSources' => ['fitFile', 'tcxFile', 'gpxFile']], + ['importSources' => \Doctrine\DBAL\ArrayParameterType::STRING], + )->fetchFirstColumn(); + + foreach ($activities as $activityId) { + $data = $this->connection->executeQuery( + 'SELECT data FROM ActivityStream WHERE activityId = :activityId AND streamType = :streamType', + [ + 'activityId' => $activityId, + 'streamType' => StreamType::LAT_LNG->value, + ], + )->fetchOne(); + + if (false === $data) { + // No latlng stream stored for this activity, nothing to recompute. + continue; + } + + /** @var array $coordinates */ + $coordinates = array_values(array_filter( + Json::uncompressAndDecode($data), + is_array(...), + )); + + if ([] === $coordinates) { + continue; + } + + $this->connection->executeStatement( + 'UPDATE Activity SET polyline = :polyline WHERE activityId = :activityId', + [ + 'polyline' => (string) Polyline::fromCoordinates($coordinates)->simplify()->encode(), + 'activityId' => $activityId, + ], + ); + } + } + + public function down(Schema $schema): void + { + // Irreversible data migration: the original collapsed polylines were corrupt (a straight + // 2-point line) and cannot — and should not — be restored. + } +} diff --git a/tests/Infrastructure/Doctrine/Migrations/Version20260721000000Test.php b/tests/Infrastructure/Doctrine/Migrations/Version20260721000000Test.php new file mode 100644 index 000000000..cf4de62d5 --- /dev/null +++ b/tests/Infrastructure/Doctrine/Migrations/Version20260721000000Test.php @@ -0,0 +1,132 @@ +buildLoopCoordinates(); + + // A collapsed 2-point polyline, as produced by the buggy simplify(0.4) tolerance. + $collapsedPolyline = (string) EncodedPolyline::fromCoordinates([ + $coordinates[0], + $coordinates[count($coordinates) - 1], + ]); + + // File-imported activity: has the collapsed polyline stored + an intact latlng stream. + $fitActivityId = ActivityId::fromUnprefixed('1'); + $this->activityRepository->add(ActivityWithRawData::fromState( + ActivityBuilder::fromDefaults() + ->withActivityId($fitActivityId) + ->withImportSource(ImportSource::FIT_FILE) + ->withPolyline($collapsedPolyline) + ->build(), + ['raw' => 'data'], + )); + $this->activityStreamRepository->add( + ActivityStreamBuilder::fromDefaults() + ->withActivityId($fitActivityId) + ->withStreamType(StreamType::LAT_LNG) + ->withData($coordinates) + ->build() + ); + + // Strava-imported activity: must be left untouched. + $stravaActivityId = ActivityId::fromUnprefixed('2'); + $stravaPolyline = (string) Polyline::fromCoordinates($coordinates)->simplify()->encode(); + $this->activityRepository->add(ActivityWithRawData::fromState( + ActivityBuilder::fromDefaults() + ->withActivityId($stravaActivityId) + ->withImportSource(ImportSource::STRAVA_API) + ->withPolyline($stravaPolyline) + ->build(), + ['raw' => 'data'], + )); + $this->activityStreamRepository->add( + ActivityStreamBuilder::fromDefaults() + ->withActivityId($stravaActivityId) + ->withStreamType(StreamType::LAT_LNG) + ->withData($coordinates) + ->build() + ); + + $migration = new Version20260721000000($this->getConnection(), new NullLogger()); + $migration->up(new Schema()); + + // The file-imported activity's polyline was recomputed from the full stream. + $recomputed = $this->getStoredPolyline($fitActivityId); + $this->assertGreaterThan( + 10, + count(EncodedPolyline::fromString($recomputed)->decodeAndPairLatLng()), + ); + + // The Strava activity's polyline is unchanged. + $this->assertSame($stravaPolyline, $this->getStoredPolyline($stravaActivityId)); + } + + /** + * @return array + */ + private function buildLoopCoordinates(): array + { + $centerLat = 51.2194; + $centerLng = 4.4025; + $radius = 0.03; + $numberOfPoints = 200; + + $coordinates = []; + for ($i = 0; $i < $numberOfPoints; ++$i) { + $angle = 2 * M_PI * $i / $numberOfPoints; + $coordinates[] = [ + $centerLat + $radius * sin($angle), + $centerLng + $radius * cos($angle), + ]; + } + + return $coordinates; + } + + private function getStoredPolyline(ActivityId $activityId): string + { + return $this->getConnection()->executeQuery( + 'SELECT polyline FROM Activity WHERE activityId = :activityId', + ['activityId' => $activityId], + )->fetchOne(); + } + + #[\Override] + protected function setUp(): void + { + parent::setUp(); + + $this->activityRepository = new DbalActivityRepository($this->getConnection()); + $this->activityStreamRepository = new DbalActivityStreamRepository($this->getConnection()); + } +} From 5e41079aabc0cff4e3e082132d63f852ac197073 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Tue, 21 Jul 2026 12:50:17 +0200 Subject: [PATCH 3/5] Decouple geosop input from stored polyline to avoid MAX_ARG_STRLEN crash Lowering the simplify() tolerance for the heatmap made the stored polyline denser. That polyline is also fed to the geosop binary for country detection, where the route WKT is passed as a single CLI argument; a dense, high-frequency route produces a WKT exceeding the OS MAX_ARG_STRLEN (~128KB) and crashes the import with "Failed to run geosop" (regression of #2230). Simplify the geosop input on its own (aggressive ~1.1km tolerance) so the argument stays bounded regardless of the stored polyline's density. The stored polyline keeps full fidelity for the heatmap. Add a regression test with a 40k-point route that previously overflowed the argument. Also pin the data migration's simplify() tolerance explicitly so the historical migration is frozen against future default changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/Version20260721000000.php | 4 ++- .../Activity/Route/RouteGeographyAnalyzer.php | 23 ++++++++++++++++- .../Route/RouteGeographyAnalyzerTest.php | 25 +++++++++++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/migrations/Version20260721000000.php b/migrations/Version20260721000000.php index d6bbdeb73..cc6ef7360 100644 --- a/migrations/Version20260721000000.php +++ b/migrations/Version20260721000000.php @@ -55,7 +55,9 @@ public function up(Schema $schema): void $this->connection->executeStatement( 'UPDATE Activity SET polyline = :polyline WHERE activityId = :activityId', [ - 'polyline' => (string) Polyline::fromCoordinates($coordinates)->simplify()->encode(), + // Pin the tolerance explicitly so this historical migration stays frozen + // against future changes to the Polyline::simplify() default. + 'polyline' => (string) Polyline::fromCoordinates($coordinates)->simplify(0.0001)->encode(), 'activityId' => $activityId, ], ); diff --git a/src/Domain/Activity/Route/RouteGeographyAnalyzer.php b/src/Domain/Activity/Route/RouteGeographyAnalyzer.php index 837257f37..36205aa48 100644 --- a/src/Domain/Activity/Route/RouteGeographyAnalyzer.php +++ b/src/Domain/Activity/Route/RouteGeographyAnalyzer.php @@ -6,6 +6,7 @@ use App\Infrastructure\Serialization\Json; use App\Infrastructure\ValueObject\Geography\EncodedPolyline; +use App\Infrastructure\ValueObject\Geography\Polyline; use Brick\Geo\Engine\GeosOpEngine; use Brick\Geo\Exception\InvalidGeometryException; use Brick\Geo\Geometry; @@ -15,6 +16,17 @@ final readonly class RouteGeographyAnalyzer { + /** + * Tolerance (in degrees, ~1.1km) used to simplify the route BEFORE it is handed to + * geosop. The stored polyline is kept high-fidelity for the heatmap, but geosop + * receives the route's WKT as a single command-line argument. A dense, high-frequency + * route can produce a WKT larger than the OS MAX_ARG_STRLEN (~128KB), which crashes + * the import with a GeometryEngineException ("Failed to run geosop") (#2230). + * Simplifying the geosop input decouples it from the stored polyline: coarse enough to + * keep the argument bounded, fine enough to preserve country-level intersections. + */ + private const float GEOSOP_SIMPLIFY_TOLERANCE = 0.01; + private GeosOpEngine $engine; private GeoJsonReader $reader; /** @var array */ @@ -56,10 +68,19 @@ private function buildCountriesGeometry(): array public function analyzeForPolyline(EncodedPolyline $polyline): array { $passedCountries = []; + + // Aggressively simplify the route on its own before feeding it to geosop, so the + // WKT argument stays bounded regardless of how dense the stored polyline is (#2230). + /** @var array $coordinates */ + $coordinates = $polyline->decodeAndPairLatLng(); + $simplifiedPolyline = Polyline::fromCoordinates($coordinates) + ->simplify(self::GEOSOP_SIMPLIFY_TOLERANCE) + ->encode(); + try { $routeLineString = $this->reader->read(Json::encode([ 'type' => 'LineString', - 'coordinates' => $polyline->decodeAndPairLngLat(), + 'coordinates' => $simplifiedPolyline->decodeAndPairLngLat(), ])); } catch (InvalidGeometryException) { // Given polyline is somehow not a valid LineString. diff --git a/tests/Domain/Activity/Route/RouteGeographyAnalyzerTest.php b/tests/Domain/Activity/Route/RouteGeographyAnalyzerTest.php index 4b9f573aa..b32e2667a 100644 --- a/tests/Domain/Activity/Route/RouteGeographyAnalyzerTest.php +++ b/tests/Domain/Activity/Route/RouteGeographyAnalyzerTest.php @@ -18,6 +18,31 @@ public function testAnalyzeForPolyline(): void ); } + public function testAnalyzeForVeryDensePolyline(): void + { + $analyzer = new RouteGeographyAnalyzer(); + + // Build a very dense polyline that lies entirely within Belgium. + // The raw WKT for this many points exceeds the OS MAX_ARG_STRLEN (~128KB), + // which is passed to the geosop binary as a single CLI argument. Feeding the + // stored (high-fidelity) polyline straight into geosop therefore crashed the + // import with a GeometryEngineException ("Failed to run geosop") on long/high + // frequency activities (#2230). The analyzer must simplify its own geosop + // input so that country detection still succeeds regardless of route density. + $coordinates = []; + for ($i = 0; $i < 40000; ++$i) { + $coordinates[] = [ + 50.85 + ($i % 2) * 0.0001, // latitude + 4.35 + $i * 0.00001, // longitude + ]; + } + + $this->assertEquals( + ['BE'], + $analyzer->analyzeForPolyline(EncodedPolyline::fromCoordinates($coordinates)), + ); + } + public function testAnalyzeForInvalidPolyline(): void { $analyzer = new RouteGeographyAnalyzer(); From 806cea64f4538a7c272b3a5d9cab94d1006d0522 Mon Sep 17 00:00:00 2001 From: robiningelbrecht Date: Tue, 21 Jul 2026 16:01:42 +0200 Subject: [PATCH 4/5] ISSUE #2289: Fix imported routes collapsing to straight lines on heatmap --- migrations/Version20260721000000.php | 72 ---------- migrations/Version20260721135509.php | 67 +++++++++ .../Activity/Route/RouteGeographyAnalyzer.php | 25 +--- .../ValueObject/Geography/EncodedPolyline.php | 4 +- .../ValueObject/Geography/Polyline.php | 4 + .../Migrations/Version20260721000000Test.php | 132 ------------------ .../ValueObject/Geography/PolylineTest.php | 39 +++--- 7 files changed, 96 insertions(+), 247 deletions(-) delete mode 100644 migrations/Version20260721000000.php create mode 100644 migrations/Version20260721135509.php delete mode 100644 tests/Infrastructure/Doctrine/Migrations/Version20260721000000Test.php diff --git a/migrations/Version20260721000000.php b/migrations/Version20260721000000.php deleted file mode 100644 index cc6ef7360..000000000 --- a/migrations/Version20260721000000.php +++ /dev/null @@ -1,72 +0,0 @@ -connection->executeQuery( - 'SELECT activityId FROM Activity WHERE importSource IN (:importSources)', - ['importSources' => ['fitFile', 'tcxFile', 'gpxFile']], - ['importSources' => \Doctrine\DBAL\ArrayParameterType::STRING], - )->fetchFirstColumn(); - - foreach ($activities as $activityId) { - $data = $this->connection->executeQuery( - 'SELECT data FROM ActivityStream WHERE activityId = :activityId AND streamType = :streamType', - [ - 'activityId' => $activityId, - 'streamType' => StreamType::LAT_LNG->value, - ], - )->fetchOne(); - - if (false === $data) { - // No latlng stream stored for this activity, nothing to recompute. - continue; - } - - /** @var array $coordinates */ - $coordinates = array_values(array_filter( - Json::uncompressAndDecode($data), - is_array(...), - )); - - if ([] === $coordinates) { - continue; - } - - $this->connection->executeStatement( - 'UPDATE Activity SET polyline = :polyline WHERE activityId = :activityId', - [ - // Pin the tolerance explicitly so this historical migration stays frozen - // against future changes to the Polyline::simplify() default. - 'polyline' => (string) Polyline::fromCoordinates($coordinates)->simplify(0.0001)->encode(), - 'activityId' => $activityId, - ], - ); - } - } - - public function down(Schema $schema): void - { - // Irreversible data migration: the original collapsed polylines were corrupt (a straight - // 2-point line) and cannot — and should not — be restored. - } -} diff --git a/migrations/Version20260721135509.php b/migrations/Version20260721135509.php new file mode 100644 index 000000000..d8c9cab0b --- /dev/null +++ b/migrations/Version20260721135509.php @@ -0,0 +1,67 @@ +connection->fetchAllAssociative( + 'SELECT Activity.activityId, Activity.polyline, ActivityStream.data AS latLngStream + FROM Activity + INNER JOIN ActivityStream ON ActivityStream.activityId = Activity.activityId + AND ActivityStream.streamType = :streamType + WHERE Activity.polyline IS NOT NULL AND Activity.polyline != ""', + ['streamType' => StreamType::LAT_LNG->value] + ); + + foreach ($results as $result) { + if (count(EncodedPolyline::fromString($result['polyline'])->decodeAndPairLatLng()) >= 10) { + // Polyline was not collapsed, nothing to repair. + continue; + } + + try { + $latLngStream = Json::uncompressAndDecode($result['latLngStream']); + } catch (\JsonException) { + continue; + } + + /** @var array $coordinates */ + $coordinates = array_values(array_filter( + is_array($latLngStream) ? $latLngStream : [], + is_array(...), + )); + if (count($coordinates) < 10) { + // The polyline is legitimately this small. + continue; + } + + $this->addSql( + 'UPDATE Activity SET polyline = :polyline WHERE activityId = :activityId', + [ + 'polyline' => (string) Polyline::fromCoordinates($coordinates)->simplify()->encode(), + 'activityId' => $result['activityId'], + ] + ); + } + } + + public function down(Schema $schema): void + { + } +} diff --git a/src/Domain/Activity/Route/RouteGeographyAnalyzer.php b/src/Domain/Activity/Route/RouteGeographyAnalyzer.php index 36205aa48..3d180e439 100644 --- a/src/Domain/Activity/Route/RouteGeographyAnalyzer.php +++ b/src/Domain/Activity/Route/RouteGeographyAnalyzer.php @@ -16,17 +16,6 @@ final readonly class RouteGeographyAnalyzer { - /** - * Tolerance (in degrees, ~1.1km) used to simplify the route BEFORE it is handed to - * geosop. The stored polyline is kept high-fidelity for the heatmap, but geosop - * receives the route's WKT as a single command-line argument. A dense, high-frequency - * route can produce a WKT larger than the OS MAX_ARG_STRLEN (~128KB), which crashes - * the import with a GeometryEngineException ("Failed to run geosop") (#2230). - * Simplifying the geosop input decouples it from the stored polyline: coarse enough to - * keep the argument bounded, fine enough to preserve country-level intersections. - */ - private const float GEOSOP_SIMPLIFY_TOLERANCE = 0.01; - private GeosOpEngine $engine; private GeoJsonReader $reader; /** @var array */ @@ -68,16 +57,12 @@ private function buildCountriesGeometry(): array public function analyzeForPolyline(EncodedPolyline $polyline): array { $passedCountries = []; - - // Aggressively simplify the route on its own before feeding it to geosop, so the - // WKT argument stays bounded regardless of how dense the stored polyline is (#2230). - /** @var array $coordinates */ - $coordinates = $polyline->decodeAndPairLatLng(); - $simplifiedPolyline = Polyline::fromCoordinates($coordinates) - ->simplify(self::GEOSOP_SIMPLIFY_TOLERANCE) - ->encode(); - try { + $coordinates = $polyline->decodeAndPairLatLng(); + $simplifiedPolyline = Polyline::fromCoordinates($coordinates) + ->simplify(0.005) + ->encode(); + $routeLineString = $this->reader->read(Json::encode([ 'type' => 'LineString', 'coordinates' => $simplifiedPolyline->decodeAndPairLngLat(), diff --git a/src/Infrastructure/ValueObject/Geography/EncodedPolyline.php b/src/Infrastructure/ValueObject/Geography/EncodedPolyline.php index 64a4557e6..169f5dfc1 100644 --- a/src/Infrastructure/ValueObject/Geography/EncodedPolyline.php +++ b/src/Infrastructure/ValueObject/Geography/EncodedPolyline.php @@ -64,7 +64,7 @@ public function decode(): array } /** - * @return array> + * @return array */ public function decodeAndPairLngLat(): array { @@ -75,7 +75,7 @@ public function decodeAndPairLngLat(): array } /** - * @return array> + * @return array */ public function decodeAndPairLatLng(): array { diff --git a/src/Infrastructure/ValueObject/Geography/Polyline.php b/src/Infrastructure/ValueObject/Geography/Polyline.php index 9c361a47e..c52c882cb 100644 --- a/src/Infrastructure/ValueObject/Geography/Polyline.php +++ b/src/Infrastructure/ValueObject/Geography/Polyline.php @@ -22,6 +22,10 @@ public static function fromCoordinates(array $coordinates): self return new self($coordinates); } + /** + * The tolerance is expressed in degrees, the same unit as the coordinates + * (0.0001° ≈ 11m on the ground). + */ public function simplify(float $tolerance = 0.0001): self { $points = $this->coordinates; diff --git a/tests/Infrastructure/Doctrine/Migrations/Version20260721000000Test.php b/tests/Infrastructure/Doctrine/Migrations/Version20260721000000Test.php deleted file mode 100644 index cf4de62d5..000000000 --- a/tests/Infrastructure/Doctrine/Migrations/Version20260721000000Test.php +++ /dev/null @@ -1,132 +0,0 @@ -buildLoopCoordinates(); - - // A collapsed 2-point polyline, as produced by the buggy simplify(0.4) tolerance. - $collapsedPolyline = (string) EncodedPolyline::fromCoordinates([ - $coordinates[0], - $coordinates[count($coordinates) - 1], - ]); - - // File-imported activity: has the collapsed polyline stored + an intact latlng stream. - $fitActivityId = ActivityId::fromUnprefixed('1'); - $this->activityRepository->add(ActivityWithRawData::fromState( - ActivityBuilder::fromDefaults() - ->withActivityId($fitActivityId) - ->withImportSource(ImportSource::FIT_FILE) - ->withPolyline($collapsedPolyline) - ->build(), - ['raw' => 'data'], - )); - $this->activityStreamRepository->add( - ActivityStreamBuilder::fromDefaults() - ->withActivityId($fitActivityId) - ->withStreamType(StreamType::LAT_LNG) - ->withData($coordinates) - ->build() - ); - - // Strava-imported activity: must be left untouched. - $stravaActivityId = ActivityId::fromUnprefixed('2'); - $stravaPolyline = (string) Polyline::fromCoordinates($coordinates)->simplify()->encode(); - $this->activityRepository->add(ActivityWithRawData::fromState( - ActivityBuilder::fromDefaults() - ->withActivityId($stravaActivityId) - ->withImportSource(ImportSource::STRAVA_API) - ->withPolyline($stravaPolyline) - ->build(), - ['raw' => 'data'], - )); - $this->activityStreamRepository->add( - ActivityStreamBuilder::fromDefaults() - ->withActivityId($stravaActivityId) - ->withStreamType(StreamType::LAT_LNG) - ->withData($coordinates) - ->build() - ); - - $migration = new Version20260721000000($this->getConnection(), new NullLogger()); - $migration->up(new Schema()); - - // The file-imported activity's polyline was recomputed from the full stream. - $recomputed = $this->getStoredPolyline($fitActivityId); - $this->assertGreaterThan( - 10, - count(EncodedPolyline::fromString($recomputed)->decodeAndPairLatLng()), - ); - - // The Strava activity's polyline is unchanged. - $this->assertSame($stravaPolyline, $this->getStoredPolyline($stravaActivityId)); - } - - /** - * @return array - */ - private function buildLoopCoordinates(): array - { - $centerLat = 51.2194; - $centerLng = 4.4025; - $radius = 0.03; - $numberOfPoints = 200; - - $coordinates = []; - for ($i = 0; $i < $numberOfPoints; ++$i) { - $angle = 2 * M_PI * $i / $numberOfPoints; - $coordinates[] = [ - $centerLat + $radius * sin($angle), - $centerLng + $radius * cos($angle), - ]; - } - - return $coordinates; - } - - private function getStoredPolyline(ActivityId $activityId): string - { - return $this->getConnection()->executeQuery( - 'SELECT polyline FROM Activity WHERE activityId = :activityId', - ['activityId' => $activityId], - )->fetchOne(); - } - - #[\Override] - protected function setUp(): void - { - parent::setUp(); - - $this->activityRepository = new DbalActivityRepository($this->getConnection()); - $this->activityStreamRepository = new DbalActivityStreamRepository($this->getConnection()); - } -} diff --git a/tests/Infrastructure/ValueObject/Geography/PolylineTest.php b/tests/Infrastructure/ValueObject/Geography/PolylineTest.php index 59880d89b..e2aba9168 100644 --- a/tests/Infrastructure/ValueObject/Geography/PolylineTest.php +++ b/tests/Infrastructure/ValueObject/Geography/PolylineTest.php @@ -25,15 +25,19 @@ public function testSimplifyRemovesPointsOnStraightLine(): void { $coordinates = [ [0.0, 0.0], - [1.0, 1.0], - [2.0, 2.0], - [3.0, 3.0], + [0.5, 0.5], + [1.0, 1.02], + [1.5, 1.5], + [2.2, 2.2], + [2.95, 3.0], + [3.5, 3.5], + [4.0, 4.0], ]; self::assertSame( (string) EncodedPolyline::fromCoordinates([ [0.0, 0.0], - [3.0, 3.0], + [4.0, 4.0], ]), (string) Polyline::fromCoordinates($coordinates)->simplify(0.1)->encode(), ); @@ -43,32 +47,24 @@ public function testSimplifyKeepsCorner(): void { $coordinates = [ [0.0, 0.0], + [0.5, 0.0], + [1.0, 0.02], [1.0, 0.0], + [1.0, 0.5], + [1.02, 0.7], [1.0, 1.0], - [2.0, 1.0], - ]; - - self::assertSame( - (string) EncodedPolyline::fromCoordinates($coordinates), - (string) Polyline::fromCoordinates($coordinates)->simplify(0.1)->encode(), - ); - } - - public function testSimplifyWithLargeToleranceKeepsOnlyEndpoints(): void - { - $coordinates = [ - [0.0, 0.0], - [1.0, 0.0], - [1.0, 1.0], + [1.5, 1.0], [2.0, 1.0], ]; self::assertSame( (string) EncodedPolyline::fromCoordinates([ [0.0, 0.0], + [1.0, 0.0], + [1.0, 1.0], [2.0, 1.0], ]), - (string) Polyline::fromCoordinates($coordinates)->simplify(10)->encode(), + (string) Polyline::fromCoordinates($coordinates)->simplify(0.1)->encode(), ); } @@ -92,7 +88,8 @@ public function testSimplifyWithDefaultToleranceKeepsGpsScaleRoute(): void $decodedPoints = EncodedPolyline::fromString((string) $simplified->encode())->decodeAndPairLatLng(); - self::assertGreaterThan(10, count($decodedPoints)); + self::assertGreaterThan(40, count($decodedPoints)); + self::assertLessThan(150, count($decodedPoints)); } public function testEncodeReturnsEncodedPolyline(): void From 7fbb1afa6c83fb4b54387f368a6091f9c8182dc5 Mon Sep 17 00:00:00 2001 From: robiningelbrecht Date: Tue, 21 Jul 2026 16:03:19 +0200 Subject: [PATCH 5/5] ISSUE #2289: Fix imported routes collapsing to straight lines on heatmap --- .../Route/RouteGeographyAnalyzerTest.php | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/tests/Domain/Activity/Route/RouteGeographyAnalyzerTest.php b/tests/Domain/Activity/Route/RouteGeographyAnalyzerTest.php index b32e2667a..4b9f573aa 100644 --- a/tests/Domain/Activity/Route/RouteGeographyAnalyzerTest.php +++ b/tests/Domain/Activity/Route/RouteGeographyAnalyzerTest.php @@ -18,31 +18,6 @@ public function testAnalyzeForPolyline(): void ); } - public function testAnalyzeForVeryDensePolyline(): void - { - $analyzer = new RouteGeographyAnalyzer(); - - // Build a very dense polyline that lies entirely within Belgium. - // The raw WKT for this many points exceeds the OS MAX_ARG_STRLEN (~128KB), - // which is passed to the geosop binary as a single CLI argument. Feeding the - // stored (high-fidelity) polyline straight into geosop therefore crashed the - // import with a GeometryEngineException ("Failed to run geosop") on long/high - // frequency activities (#2230). The analyzer must simplify its own geosop - // input so that country detection still succeeds regardless of route density. - $coordinates = []; - for ($i = 0; $i < 40000; ++$i) { - $coordinates[] = [ - 50.85 + ($i % 2) * 0.0001, // latitude - 4.35 + $i * 0.00001, // longitude - ]; - } - - $this->assertEquals( - ['BE'], - $analyzer->analyzeForPolyline(EncodedPolyline::fromCoordinates($coordinates)), - ); - } - public function testAnalyzeForInvalidPolyline(): void { $analyzer = new RouteGeographyAnalyzer();