diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index f4b4accdb34..6d688242e65 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -3,6 +3,7 @@ ## 2.28 - Add `minZoom` and `maxZoom` options to `Map` to set the minimum and maximum zoom levels +- Add `Cluster` class and `ClusteringAlgorithmInterface` with two implementations `GridClusteringAlgorithm` and `MortonClusteringAlgorithm`. ## 2.27 diff --git a/src/Map/src/Cluster/Cluster.php b/src/Map/src/Cluster/Cluster.php new file mode 100644 index 00000000000..bdb4194c282 --- /dev/null +++ b/src/Map/src/Cluster/Cluster.php @@ -0,0 +1,81 @@ + + * + * 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. + * + * @implements \IteratorAggregate + * + * @author Simon André + */ +final class Cluster implements \Countable, \IteratorAggregate +{ + /** + * @var Point[] + */ + private array $points = []; + + private float $sumLat = 0.0; + private float $sumLng = 0.0; + private int $count = 0; + + /** + * Initializes the cluster with an initial point. + */ + 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; + } + + /** + * Returns the center of the cluster as a Point. + */ + public function getCenter(): Point + { + return new Point($this->sumLat / $this->count, $this->sumLng / $this->count); + } + + /** + * @return non-empty-list + */ + public function getPoints(): array + { + return $this->points; + } + + /** + * Returns the number of points in the cluster. + */ + public function count(): int + { + return $this->count; + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + return new \ArrayIterator($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..28416a2cdd1 --- /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 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..f2242c25b9d --- /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 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/tests/Cluster/ClusterTest.php b/src/Map/tests/Cluster/ClusterTest.php new file mode 100644 index 00000000000..b7fd7fec88c --- /dev/null +++ b/src/Map/tests/Cluster/ClusterTest.php @@ -0,0 +1,79 @@ + + * + * 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->getCenter()->getLatitude()); + $this->assertEquals(20.0, $cluster->getCenter()->getLongitude()); + + $point2 = new Point(12.0, 22.0); + $cluster->addPoint($point2); + + $this->assertEquals(11.0, $cluster->getCenter()->getLatitude()); + $this->assertEquals(21.0, $cluster->getCenter()->getLongitude()); + } + + 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 testCount(): void + { + $cluster = new Cluster(new Point(10.0, 20.0)); + $this->assertCount(1, $cluster); + + $cluster->addPoint(new Point(10.0, 20.0)); + $this->assertCount(2, $cluster); + } + + public function testIterator(): void + { + $point1 = new Point(10.0, 20.0); + $point2 = new Point(12.0, 22.0); + $cluster = new Cluster($point1); + $cluster->addPoint($point2); + + $points = iterator_to_array($cluster); + $this->assertCount(2, $points); + $this->assertSame($point1, $points[0]); + $this->assertSame($point2, $points[1]); + } + + public function testCreateCluster(): void + { + $point1 = new Point(10.0, 20.0); + $cluster = new Cluster($point1); + + $this->assertCount(1, $cluster->getPoints()); + $this->assertEquals(10.0, $cluster->getCenter()->getLatitude()); + $this->assertEquals(20.0, $cluster->getCenter()->getLongitude()); + } +} diff --git a/src/Map/tests/Cluster/ClusteringPerformanceTest.php b/src/Map/tests/Cluster/ClusteringPerformanceTest.php new file mode 100644 index 00000000000..8daf4873d47 --- /dev/null +++ b/src/Map/tests/Cluster/ClusteringPerformanceTest.php @@ -0,0 +1,110 @@ + + * + * 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 +{ + /** + * @const array + */ + private const ZOOMS = [ + 2.0, + 5.0, + 8.0, + ]; + + /** + * @const array + */ + private const 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 testScenarioCountry5000(ClusteringAlgorithmInterface $algorithm, float $zoom): void + { + $points = $this->generatePoints(5000, 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)'); + } + + private function generatePoints(int $count, float $latMin, float $latMax, float $lngMin, float $lngMax): array + { + $points = []; + for ($i = 0; $i < $count; ++$i) { + $lat = random_int((int) ($latMin * 100), (int) ($latMax * 100)) / 100.0; + $lng = random_int((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..171601423ec --- /dev/null +++ b/src/Map/tests/Cluster/GridClusteringAlgorithmTest.php @@ -0,0 +1,98 @@ + + * + * 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->getCenter()->getLatitude()); + $this->assertEquals(20.0, $cluster->getCenter()->getLongitude()); + $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(); + + $clusters = $algorithm->cluster([$point1, $point2], 1.0); + + $this->assertCount(1, $clusters, 'One cluster should have been created due to the low zoom value.'); + + $cluster = $clusters[0]; + + $this->assertCount(2, $cluster->getPoints()); + $this->assertEqualsWithDelta(10.05, $cluster->getCenter()->getLatitude(), 0.0001); + $this->assertEqualsWithDelta(20.05, $cluster->getCenter()->getLongitude(), 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(); + + $clusters = $algorithm->cluster([$point1, $point2], 5.0); + + $this->assertCount(2, $clusters, 'Two clusters should have created due to the high zoom value.'); + } + + 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..60fc3136326 --- /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->getCenter()->getLatitude()); + $this->assertEquals(20.0, $cluster->getCenter()->getLongitude()); + $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()); + } +}