Skip to content

Commit 0b4eade

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 0b4eade

9 files changed

+625
-0
lines changed

src/Map/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 2.28
44

55
- Add `minZoom` and `maxZoom` options to `Map` to set the minimum and maximum zoom levels
6+
- Add `Cluster` class and `ClusteringAlgorithmInterface` with two implementations `GridClusteringAlgorithm` and `MortonClusteringAlgorithm`.
67

78
## 2.27
89

src/Map/src/Cluster/Cluster.php

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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+
* @implements \IteratorAggregate<int, Point>
20+
*
21+
* @author Simon André <[email protected]>
22+
*/
23+
final class Cluster implements \Countable, \IteratorAggregate
24+
{
25+
/**
26+
* @var Point[]
27+
*/
28+
private array $points = [];
29+
30+
private float $sumLat = 0.0;
31+
private float $sumLng = 0.0;
32+
private int $count = 0;
33+
34+
/**
35+
* Initializes the cluster with an initial point.
36+
*/
37+
public function __construct(Point $initialPoint)
38+
{
39+
$this->addPoint($initialPoint);
40+
}
41+
42+
public function addPoint(Point $point): void
43+
{
44+
$this->points[] = $point;
45+
$this->sumLat += $point->getLatitude();
46+
$this->sumLng += $point->getLongitude();
47+
++$this->count;
48+
}
49+
50+
/**
51+
* Returns the center of the cluster as a Point.
52+
*/
53+
public function getCenter(): Point
54+
{
55+
return new Point($this->sumLat / $this->count, $this->sumLng / $this->count);
56+
}
57+
58+
/**
59+
* @return non-empty-list<Point>
60+
*/
61+
public function getPoints(): array
62+
{
63+
return $this->points;
64+
}
65+
66+
/**
67+
* Returns the number of points in the cluster.
68+
*/
69+
public function count(): int
70+
{
71+
return $this->count;
72+
}
73+
74+
/**
75+
* @return \Traversable<int, Point>
76+
*/
77+
public function getIterator(): \Traversable
78+
{
79+
return new \ArrayIterator($this->points);
80+
}
81+
}
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 <[email protected]>
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 <[email protected]>
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é <[email protected]>
32+
*/
33+
final 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 <[email protected]>
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é <[email protected]>
32+
*/
33+
final 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: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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->getCenter()->getLatitude());
26+
$this->assertEquals(20.0, $cluster->getCenter()->getLongitude());
27+
28+
$point2 = new Point(12.0, 22.0);
29+
$cluster->addPoint($point2);
30+
31+
$this->assertEquals(11.0, $cluster->getCenter()->getLatitude());
32+
$this->assertEquals(21.0, $cluster->getCenter()->getLongitude());
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 testCount(): void
49+
{
50+
$cluster = new Cluster(new Point(10.0, 20.0));
51+
$this->assertCount(1, $cluster);
52+
53+
$cluster->addPoint(new Point(10.0, 20.0));
54+
$this->assertCount(2, $cluster);
55+
}
56+
57+
public function testIterator(): void
58+
{
59+
$point1 = new Point(10.0, 20.0);
60+
$point2 = new Point(12.0, 22.0);
61+
$cluster = new Cluster($point1);
62+
$cluster->addPoint($point2);
63+
64+
$points = iterator_to_array($cluster);
65+
$this->assertCount(2, $points);
66+
$this->assertSame($point1, $points[0]);
67+
$this->assertSame($point2, $points[1]);
68+
}
69+
70+
public function testCreateCluster(): void
71+
{
72+
$point1 = new Point(10.0, 20.0);
73+
$cluster = new Cluster($point1);
74+
75+
$this->assertCount(1, $cluster->getPoints());
76+
$this->assertEquals(10.0, $cluster->getCenter()->getLatitude());
77+
$this->assertEquals(20.0, $cluster->getCenter()->getLongitude());
78+
}
79+
}

0 commit comments

Comments
 (0)