Skip to content

Commit f903389

Browse files
committed
[Map] Add Clustering Algorithms
Provide an interface and the first two PHP implementations of Clustering algorithms - GridClustering - Morton Performances: < 1ms per 1000 Point Next Step will require aditional JS code, but this is already very usefull to display maps from large amount of points
1 parent 9e5f700 commit f903389

9 files changed

+581
-0
lines changed

src/Map/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## 2.28
44

55
- Add `minZoom` and `maxZoom` options to `Map` to set the minimum and maximum zoom levels
6+
- Add `Cluster` class, interface for clustering algorithm and two implementations:
7+
`GridClusteringAlgorithm` and `MortonClusteringAlgorithm`.
68

79
## 2.27
810

src/Map/src/Cluster/Cluster.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Map\Cluster;
13+
14+
use Symfony\UX\Map\Point;
15+
16+
/**
17+
* Cluster representation.
18+
*
19+
* @author Simon André <smn.andre@gmail.com>
20+
*/
21+
final class Cluster
22+
{
23+
/**
24+
* @var Point[]
25+
*/
26+
private array $points = [];
27+
28+
private float $sumLat = 0.0;
29+
private float $sumLng = 0.0;
30+
private int $count = 0;
31+
32+
public function __construct(Point $initialPoint)
33+
{
34+
$this->addPoint($initialPoint);
35+
}
36+
37+
public function addPoint(Point $point): void
38+
{
39+
$this->points[] = $point;
40+
$this->sumLat += $point->getLatitude();
41+
$this->sumLng += $point->getLongitude();
42+
++$this->count;
43+
}
44+
45+
public function getCenterLat(): float
46+
{
47+
return $this->count > 0 ? $this->sumLat / $this->count : 0.0;
48+
}
49+
50+
public function getCenterLng(): float
51+
{
52+
return $this->count > 0 ? $this->sumLng / $this->count : 0.0;
53+
}
54+
55+
/**
56+
* @return Point[]
57+
*/
58+
public function getPoints(): array
59+
{
60+
return $this->points;
61+
}
62+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Map\Cluster;
13+
14+
use Symfony\UX\Map\Point;
15+
16+
/**
17+
* Interface for various Clustering implementations.
18+
*/
19+
interface ClusteringAlgorithmInterface
20+
{
21+
/**
22+
* Clusters a set of points.
23+
*
24+
* @param Point[] $points List of points to be clustered
25+
* @param float $zoom The zoom level, determining grid resolution
26+
*
27+
* @return Cluster[] An array of clusters, each containing grouped points
28+
*/
29+
public function cluster(array $points, float $zoom): array;
30+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Map\Cluster;
13+
14+
use Symfony\UX\Map\Point;
15+
16+
/**
17+
* Grid-based clustering algorithm for spatial data.
18+
*
19+
* This algorithm groups points into fixed-size grid cells based on the given zoom level.
20+
*
21+
* Best for:
22+
* - Fast, scalable clustering on large geographical datasets
23+
* - Real-time clustering where performance is critical
24+
* - Use cases where a simple, predictable grid structure is sufficient
25+
*
26+
* Slower for:
27+
* - Highly dynamic data that requires adaptive cluster sizes
28+
* - Scenarios where varying density should influence cluster sizes (e.g., DBSCAN-like approaches)
29+
* - Irregularly shaped clusters that do not fit a strict grid pattern
30+
*
31+
* @author Simon André <smn.andre@gmail.com>
32+
*/
33+
final readonly class GridClusteringAlgorithm implements ClusteringAlgorithmInterface
34+
{
35+
/**
36+
* Clusters a set of points using a fixed grid resolution based on the zoom level.
37+
*
38+
* @param Point[] $points List of points to be clustered
39+
* @param float $zoom The zoom level, determining grid resolution
40+
*
41+
* @return Cluster[] An array of clusters, each containing grouped points
42+
*/
43+
public function cluster(iterable $points, float $zoom): array
44+
{
45+
$gridResolution = 1 << (int) $zoom;
46+
$gridSize = 360 / $gridResolution;
47+
$invGridSize = 1 / $gridSize;
48+
49+
$cells = [];
50+
51+
foreach ($points as $point) {
52+
$lng = $point->getLongitude();
53+
$lat = $point->getLatitude();
54+
$gridX = (int) (($lng + 180) * $invGridSize);
55+
$gridY = (int) (($lat + 90) * $invGridSize);
56+
$key = ($gridX << 16) | $gridY;
57+
58+
if (!isset($cells[$key])) {
59+
$cells[$key] = new Cluster($point);
60+
} else {
61+
$cells[$key]->addPoint($point);
62+
}
63+
}
64+
65+
return array_values($cells);
66+
}
67+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Map\Cluster;
13+
14+
use Symfony\UX\Map\Point;
15+
16+
/**
17+
* Clustering algorithm based on Morton codes (Z-order curves).
18+
*
19+
* This approach is optimized for spatial data and preserves locality efficiently.
20+
*
21+
* Best for:
22+
* - Large-scale spatial clustering
23+
* - Hierarchical clustering with fast locality-based grouping
24+
* - Datasets where preserving spatial proximity is crucial
25+
*
26+
* Slower for:
27+
* - High-dimensional data (beyond 2D/3D) due to Morton code limitations
28+
* - Non-spatial or categorical data
29+
* - Scenarios requiring dynamic cluster adjustments (e.g., streaming data)
30+
*
31+
* @author Simon André <smn.andre@gmail.com>
32+
*/
33+
final readonly class MortonClusteringAlgorithm implements ClusteringAlgorithmInterface
34+
{
35+
/**
36+
* @param Point[] $points
37+
*
38+
* @return Cluster[]
39+
*/
40+
public function cluster(iterable $points, float $zoom): array
41+
{
42+
$resolution = 1 << (int) $zoom;
43+
$clustersMap = [];
44+
45+
foreach ($points as $point) {
46+
$xNorm = ($point->getLatitude() + 180) / 360;
47+
$yNorm = ($point->getLongitude() + 90) / 180;
48+
49+
$x = (int) floor($xNorm * $resolution);
50+
$y = (int) floor($yNorm * $resolution);
51+
52+
$x &= 0xFFFF;
53+
$y &= 0xFFFF;
54+
55+
$x = ($x | ($x << 8)) & 0x00FF00FF;
56+
$x = ($x | ($x << 4)) & 0x0F0F0F0F;
57+
$x = ($x | ($x << 2)) & 0x33333333;
58+
$x = ($x | ($x << 1)) & 0x55555555;
59+
60+
$y = ($y | ($y << 8)) & 0x00FF00FF;
61+
$y = ($y | ($y << 4)) & 0x0F0F0F0F;
62+
$y = ($y | ($y << 2)) & 0x33333333;
63+
$y = ($y | ($y << 1)) & 0x55555555;
64+
65+
$code = ($y << 1) | $x;
66+
67+
if (!isset($clustersMap[$code])) {
68+
$clustersMap[$code] = new Cluster($point);
69+
} else {
70+
$clustersMap[$code]->addPoint($point);
71+
}
72+
}
73+
74+
return array_values($clustersMap);
75+
}
76+
}

src/Map/tests/Cluster/ClusterTest.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Map\Tests\Cluster;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\UX\Map\Cluster\Cluster;
16+
use Symfony\UX\Map\Point;
17+
18+
class ClusterTest extends TestCase
19+
{
20+
public function testAddPointAndGetCenter(): void
21+
{
22+
$point1 = new Point(10.0, 20.0);
23+
$cluster = new Cluster($point1);
24+
25+
$this->assertEquals(10.0, $cluster->getCenterLat());
26+
$this->assertEquals(20.0, $cluster->getCenterLng());
27+
28+
$point2 = new Point(12.0, 22.0);
29+
$cluster->addPoint($point2);
30+
31+
$this->assertEquals(11.0, $cluster->getCenterLat());
32+
$this->assertEquals(21.0, $cluster->getCenterLng());
33+
}
34+
35+
public function testGetPoints(): void
36+
{
37+
$point1 = new Point(10.0, 20.0);
38+
$point2 = new Point(12.0, 22.0);
39+
$cluster = new Cluster($point1);
40+
$cluster->addPoint($point2);
41+
42+
$points = $cluster->getPoints();
43+
$this->assertCount(2, $points);
44+
$this->assertSame($point1, $points[0]);
45+
$this->assertSame($point2, $points[1]);
46+
}
47+
48+
public function testEmptyCluster(): void
49+
{
50+
$point1 = new Point(10.0, 20.0);
51+
$cluster = new Cluster($point1); // Start an empty cluster
52+
$points = $cluster->getPoints();
53+
$this->assertCount(1, $points);
54+
$this->assertEquals(10.0, $cluster->getCenterLat());
55+
$this->assertEquals(20.0, $cluster->getCenterLng());
56+
}
57+
}

0 commit comments

Comments
 (0)