diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index cdad7ae91dd..ab25bfdbefc 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -1,4 +1,17 @@ # CHANGELOG + +## 2.24 + +- Add `Cluster` class, interface for clustering algorithm and two implementations: + `GridClusteringAlgorithm` and `MortonClusteringAlgorithm`. + +## 2.23 + +- Add `DistanceUnit` to represent distance units (`m`, `km`, `miles`, `nmi`) and + ease conversion between units. +- Add `DistanceCalculatorInterface` interface and three implementations: + `HaversineDistanceCalculator`, `SphericalCosineDistanceCalculator` and `VincentyDistanceCalculator`. +- Add `CoordinateUtils` helper, to convert decimal coordinates (`43.2109`) in DMS (`56° 78' 90"`) ## 2.22 diff --git a/src/Map/src/Cluster/Cluster.php b/src/Map/src/Cluster/Cluster.php new file mode 100644 index 00000000000..8e5e934c4ae --- /dev/null +++ b/src/Map/src/Cluster/Cluster.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Cluster; + +use Symfony\UX\Map\Point; + +/** + * Cluster representation. + * + * @author Simon André + */ +final class Cluster +{ + /** + * @var Point[] + */ + private array $points = []; + + private float $sumLat = 0.0; + private float $sumLng = 0.0; + private int $count = 0; + + public function __construct(Point $initialPoint) + { + $this->addPoint($initialPoint); + } + + public function addPoint(Point $point): void + { + $this->points[] = $point; + $this->sumLat += $point->getLatitude(); + $this->sumLng += $point->getLongitude(); + ++$this->count; + } + + public function getCenterLat(): float + { + return $this->count > 0 ? $this->sumLat / $this->count : 0.0; + } + + public function getCenterLng(): float + { + return $this->count > 0 ? $this->sumLng / $this->count : 0.0; + } + + /** + * @return Point[] + */ + public function getPoints(): array + { + return $this->points; + } +} diff --git a/src/Map/src/Cluster/ClusteringAlgorithmInterface.php b/src/Map/src/Cluster/ClusteringAlgorithmInterface.php new file mode 100644 index 00000000000..cf60456d486 --- /dev/null +++ b/src/Map/src/Cluster/ClusteringAlgorithmInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Cluster; + +use Symfony\UX\Map\Point; + +/** + * Interface for various Clustering implementations. + */ +interface ClusteringAlgorithmInterface +{ + /** + * Clusters a set of points. + * + * @param Point[] $points List of points to be clustered + * @param float $zoom The zoom level, determining grid resolution + * + * @return Cluster[] An array of clusters, each containing grouped points + */ + public function cluster(array $points, float $zoom): array; +} diff --git a/src/Map/src/Cluster/GridClusteringAlgorithm.php b/src/Map/src/Cluster/GridClusteringAlgorithm.php new file mode 100644 index 00000000000..c5a3c02a6e2 --- /dev/null +++ b/src/Map/src/Cluster/GridClusteringAlgorithm.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Cluster; + +use Symfony\UX\Map\Point; + +/** + * Grid-based clustering algorithm for spatial data. + * + * This algorithm groups points into fixed-size grid cells based on the given zoom level. + * + * Best for: + * - Fast, scalable clustering on large geographical datasets + * - Real-time clustering where performance is critical + * - Use cases where a simple, predictable grid structure is sufficient + * + * Slower for: + * - Highly dynamic data that requires adaptive cluster sizes + * - Scenarios where varying density should influence cluster sizes (e.g., DBSCAN-like approaches) + * - Irregularly shaped clusters that do not fit a strict grid pattern + * + * @author Simon André + */ +final readonly class GridClusteringAlgorithm implements ClusteringAlgorithmInterface +{ + /** + * Clusters a set of points using a fixed grid resolution based on the zoom level. + * + * @param Point[] $points List of points to be clustered + * @param float $zoom The zoom level, determining grid resolution + * + * @return Cluster[] An array of clusters, each containing grouped points + */ + public function cluster(iterable $points, float $zoom): array + { + $gridResolution = 1 << (int) $zoom; + $gridSize = 360 / $gridResolution; + $invGridSize = 1 / $gridSize; + + $cells = []; + + foreach ($points as $point) { + $lng = $point->getLongitude(); + $lat = $point->getLatitude(); + $gridX = (int) (($lng + 180) * $invGridSize); + $gridY = (int) (($lat + 90) * $invGridSize); + $key = ($gridX << 16) | $gridY; + + if (!isset($cells[$key])) { + $cells[$key] = new Cluster($point); + } else { + $cells[$key]->addPoint($point); + } + } + + return array_values($cells); + } +} diff --git a/src/Map/src/Cluster/MortonClusteringAlgorithm.php b/src/Map/src/Cluster/MortonClusteringAlgorithm.php new file mode 100644 index 00000000000..4e8014c7f5b --- /dev/null +++ b/src/Map/src/Cluster/MortonClusteringAlgorithm.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Cluster; + +use Symfony\UX\Map\Point; + +/** + * Clustering algorithm based on Morton codes (Z-order curves). + * + * This approach is optimized for spatial data and preserves locality efficiently. + * + * Best for: + * - Large-scale spatial clustering + * - Hierarchical clustering with fast locality-based grouping + * - Datasets where preserving spatial proximity is crucial + * + * Slower for: + * - High-dimensional data (beyond 2D/3D) due to Morton code limitations + * - Non-spatial or categorical data + * - Scenarios requiring dynamic cluster adjustments (e.g., streaming data) + * + * @author Simon André + */ +final readonly class MortonClusteringAlgorithm implements ClusteringAlgorithmInterface +{ + /** + * @param Point[] $points + * + * @return Cluster[] + */ + public function cluster(iterable $points, float $zoom): array + { + $resolution = 1 << (int) $zoom; + $clustersMap = []; + + foreach ($points as $point) { + $xNorm = ($point->getLatitude() + 180) / 360; + $yNorm = ($point->getLongitude() + 90) / 180; + + $x = (int) floor($xNorm * $resolution); + $y = (int) floor($yNorm * $resolution); + + $x &= 0xFFFF; + $y &= 0xFFFF; + + $x = ($x | ($x << 8)) & 0x00FF00FF; + $x = ($x | ($x << 4)) & 0x0F0F0F0F; + $x = ($x | ($x << 2)) & 0x33333333; + $x = ($x | ($x << 1)) & 0x55555555; + + $y = ($y | ($y << 8)) & 0x00FF00FF; + $y = ($y | ($y << 4)) & 0x0F0F0F0F; + $y = ($y | ($y << 2)) & 0x33333333; + $y = ($y | ($y << 1)) & 0x55555555; + + $code = ($y << 1) | $x; + + if (!isset($clustersMap[$code])) { + $clustersMap[$code] = new Cluster($point); + } else { + $clustersMap[$code]->addPoint($point); + } + } + + return array_values($clustersMap); + } +} diff --git a/src/Map/src/Point.php b/src/Map/src/Point.php index f34f37a2387..864041e2620 100644 --- a/src/Map/src/Point.php +++ b/src/Map/src/Point.php @@ -33,6 +33,16 @@ public function __construct( } } + public function getLatitude(): float + { + return $this->latitude; + } + + public function getLongitude(): float + { + return $this->longitude; + } + /** * @return array{lat: float, lng: float} */ diff --git a/src/Map/tests/Cluster/ClusterTest.php b/src/Map/tests/Cluster/ClusterTest.php new file mode 100644 index 00000000000..9180026e651 --- /dev/null +++ b/src/Map/tests/Cluster/ClusterTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Cluster; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Cluster\Cluster; +use Symfony\UX\Map\Point; + +class ClusterTest extends TestCase +{ + public function testAddPointAndGetCenter(): void + { + $point1 = new Point(10.0, 20.0); + $cluster = new Cluster($point1); + + $this->assertEquals(10.0, $cluster->getCenterLat()); + $this->assertEquals(20.0, $cluster->getCenterLng()); + + $point2 = new Point(12.0, 22.0); + $cluster->addPoint($point2); + + $this->assertEquals(11.0, $cluster->getCenterLat()); + $this->assertEquals(21.0, $cluster->getCenterLng()); + } + + public function testGetPoints(): void + { + $point1 = new Point(10.0, 20.0); + $point2 = new Point(12.0, 22.0); + $cluster = new Cluster($point1); + $cluster->addPoint($point2); + + $points = $cluster->getPoints(); + $this->assertCount(2, $points); + $this->assertSame($point1, $points[0]); + $this->assertSame($point2, $points[1]); + } + + public function testEmptyCluster(): void + { + $point1 = new Point(10.0, 20.0); + $cluster = new Cluster($point1); // Start an empty cluster + $points = $cluster->getPoints(); + $this->assertCount(1, $points); + $this->assertEquals(10.0, $cluster->getCenterLat()); + $this->assertEquals(20.0, $cluster->getCenterLng()); + } +} diff --git a/src/Map/tests/Cluster/ClusteringPerformanceTest.php b/src/Map/tests/Cluster/ClusteringPerformanceTest.php new file mode 100644 index 00000000000..9a0103ac1ba --- /dev/null +++ b/src/Map/tests/Cluster/ClusteringPerformanceTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Cluster; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Cluster\ClusteringAlgorithmInterface; +use Symfony\UX\Map\Cluster\GridClusteringAlgorithm; +use Symfony\UX\Map\Cluster\MortonClusteringAlgorithm; +use Symfony\UX\Map\Point; + +class ClusteringPerformanceTest extends TestCase +{ + private const array ZOOMS = [ + 2.0, + 5.0, + 8.0, + ]; + + private const array ALGORITHMS = [ + GridClusteringAlgorithm::class, + MortonClusteringAlgorithm::class, + ]; + + /** + * @return iterable + */ + public static function algorithmProvider(): iterable + { + foreach (self::ZOOMS as $zoom) { + foreach (self::ALGORITHMS as $algorithm) { + yield $algorithm.' '.$zoom => [new $algorithm(), $zoom]; + } + } + } + + /** + * Scenario 1: Large number of points (50,000), concentrated area (Paris region). + * + * @dataProvider algorithmProvider + */ + public function testScenarioRegion50000(ClusteringAlgorithmInterface $algorithm, float $zoom): void + { + $points = $this->generatePoints(50000, 48.8, 49, 2.2, 2.5); + $this->runPerformanceTest($algorithm, $points, $zoom); + } + + /** + * Scenario 2: Moderate number of points (5,000), broad area (France and surroundings). + * + * @dataProvider algorithmProvider + */ + public function testScenarioCountry50000(ClusteringAlgorithmInterface $algorithm, float $zoom): void + { + $points = $this->generatePoints(500000, 30, 60, -10, 35); + + $this->runPerformanceTest($algorithm, $points, $zoom); + } + + /** + * Scenario 3: Very large number of points (100,000), global distribution. + * + * @dataProvider algorithmProvider + */ + public function testScenarioWorld100000(ClusteringAlgorithmInterface $algorithm, float $zoom): void + { + $points = $this->generatePoints(100000, -90, 90, -180, 180); + $this->runPerformanceTest($algorithm, $points, $zoom); + } + + /** + * @param array $points + */ + private function runPerformanceTest(ClusteringAlgorithmInterface $algorithm, array $points, float $zoom): void + { + $startTime = microtime(true); + $algorithm->cluster($points, $zoom); + $elapsed = microtime(true) - $startTime; + + $this->assertLessThan(2.0, $elapsed, $algorithm::class." took too long: {$elapsed} seconds (zoom {$zoom}, ".\count($points).' points)'); + // echo $algorithm::class." ($zoom): ".($elapsed * 1000)." ms\n"; + } + + private function generatePoints(int $count, float $latMin, float $latMax, float $lngMin, float $lngMax): array + { + $points = []; + for ($i = 0; $i < $count; ++$i) { + $lat = mt_rand((int) ($latMin * 100), (int) ($latMax * 100)) / 100.0; + $lng = mt_rand((int) ($lngMin * 100), (int) ($lngMax * 100)) / 100.0; + $points[] = new Point($lat, $lng); + } + + return $points; + } +} diff --git a/src/Map/tests/Cluster/GridClusteringAlgorithmTest.php b/src/Map/tests/Cluster/GridClusteringAlgorithmTest.php new file mode 100644 index 00000000000..f092b702c79 --- /dev/null +++ b/src/Map/tests/Cluster/GridClusteringAlgorithmTest.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Cluster; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Cluster\Cluster; +use Symfony\UX\Map\Cluster\GridClusteringAlgorithm; +use Symfony\UX\Map\Point; + +class GridClusteringAlgorithmTest extends TestCase +{ + public function testSinglePointCreatesSingleCluster(): void + { + $point = new Point(10.0, 20.0); + $algorithm = new GridClusteringAlgorithm(); + $clusters = $algorithm->cluster([$point], 1.0); + + $this->assertCount(1, $clusters); + + /** @var Cluster $cluster */ + $cluster = $clusters[0]; + + $this->assertEquals(10.0, $cluster->getCenterLat()); + $this->assertEquals(20.0, $cluster->getCenterLng()); + $this->assertCount(1, $cluster->getPoints()); + } + + public function testPointsInSameGridAreClusteredTogether(): void + { + $point1 = new Point(10.0, 20.0); + $point2 = new Point(10.1, 20.1); + $algorithm = new GridClusteringAlgorithm(); + + // Low zoom + $clusters = $algorithm->cluster([$point1, $point2], 1.0); + + $this->assertCount(1, $clusters); + + $cluster = $clusters[0]; + + $this->assertCount(2, $cluster->getPoints()); + $this->assertEqualsWithDelta(10.05, $cluster->getCenterLat(), 0.0001); + $this->assertEqualsWithDelta(20.05, $cluster->getCenterLng(), 0.0001); + } + + public function testPointsInDifferentGridsAreNotClustered(): void + { + $point1 = new Point(10.0, 20.0); + $point2 = new Point(-10.0, -20.0); // Far away + $algorithm = new GridClusteringAlgorithm(); + + // High zoom + // Expect two separate clusters + $clusters = $algorithm->cluster([$point1, $point2], 5.0); + + $this->assertCount(2, $clusters); + } + + public function testEmptyPointsArray(): void + { + $algorithm = new GridClusteringAlgorithm(); + + // Empty points array + $clusters = $algorithm->cluster([], 2.0); + + $this->assertCount(0, $clusters); + } + + public function testLargeCoordinates(): void + { + $point1 = new Point(89.9, 179.9); + $point2 = new Point(-89.9, -179.9); + $algorithm = new GridClusteringAlgorithm(); + + $clusters = $algorithm->cluster([$point1, $point2], 3.0); + + $this->assertGreaterThanOrEqual(1, \count($clusters)); + } + + public function testZeroZoomLevel(): void + { + $point1 = new Point(10, 20); + $point2 = new Point(30, 40); + $algorithm = new GridClusteringAlgorithm(); + + // With zoom 0, everything should be in one big cluster. + $clusters = $algorithm->cluster([$point1, $point2], 0.0); + + $this->assertCount(1, $clusters); + $this->assertCount(2, $clusters[0]->getPoints()); + } +} diff --git a/src/Map/tests/Cluster/MortonClusteringAlgorithmTest.php b/src/Map/tests/Cluster/MortonClusteringAlgorithmTest.php new file mode 100644 index 00000000000..5627f4c00d0 --- /dev/null +++ b/src/Map/tests/Cluster/MortonClusteringAlgorithmTest.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Cluster; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Cluster\Cluster; +use Symfony\UX\Map\Cluster\MortonClusteringAlgorithm; +use Symfony\UX\Map\Point; + +class MortonClusteringAlgorithmTest extends TestCase +{ + public function testSinglePointCreatesSingleCluster(): void + { + $point = new Point(10.0, 20.0); + $algorithm = new MortonClusteringAlgorithm(); + $clusters = $algorithm->cluster([$point], 1.0); + + $this->assertCount(1, $clusters); + + /** @var Cluster $cluster */ + $cluster = $clusters[0]; + + $this->assertEquals(10.0, $cluster->getCenterLat()); + $this->assertEquals(20.0, $cluster->getCenterLng()); + $this->assertCount(1, $cluster->getPoints()); + } + + public function testPointsWithSameMortonCodeAreClustered(): void + { + // These points should have the same Morton code at zoom level 1 + $point1 = new Point(45.0, 90.0); + $point2 = new Point(45.1, 90.1); + $algorithm = new MortonClusteringAlgorithm(); + + $clusters = $algorithm->cluster([$point1, $point2], 1.0); + + $this->assertCount(1, $clusters); + $this->assertCount(2, $clusters[0]->getPoints()); + } + + public function testPointsWithDifferentMortonCodeAreNotClustered(): void + { + // These points will have different Morton codes at zoom level 5 + $point1 = new Point(45.0, 90.0); + $point2 = new Point(-45.0, -90.0); + $algorithm = new MortonClusteringAlgorithm(); + + $clusters = $algorithm->cluster([$point1, $point2], 5.0); + + $this->assertCount(2, $clusters); + } + + public function testEmptyPointsArray(): void + { + $algorithm = new MortonClusteringAlgorithm(); + + $clusters = $algorithm->cluster([], 2.0); + + $this->assertCount(0, $clusters); + } + + public function testZeroZoomLevel(): void + { + $point1 = new Point(10, 20); + $point2 = new Point(30, 40); + $algorithm = new MortonClusteringAlgorithm(); + + $clusters = $algorithm->cluster([$point1, $point2], 0.0); + + // With zoom 0, everything should be in one big cluster + $this->assertCount(1, $clusters); + $this->assertCount(2, $clusters[0]->getPoints()); + } +}