Repository: symfony/ux
Pull request #2762 (2025-05-22 09:52:00)

[Map] Add multipolygon support
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes <!-- please update src/**/CHANGELOG.md files -->
| Docs?         | yes/no <!-- required for new features -->
| Issues        | Fix #... <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead -->
| License       | MIT

<!--
Replace this notice by a description of your feature/bugfix.
This will help reviewers and should be a good start for the documentation.

Additionally (see https://symfony.com/releases):
 - Always add tests and ensure they pass.
 - For new features, provide some code snippets to help understand usage.
 - Features and deprecations must be submitted against branch main.
 - Update/add documentation as required (we can help!)
 - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry
 - Never break backward compatibility (see https://symfony.com/bc).
-->

Add support for MultiPolygon https://leafletjs.com/reference.html#polygon & https://developers.google.com/maps/documentation/javascript/shapes?hl=fr#polygon_hole

<img width="834" alt="image" src="https://github.com/user-attachments/assets/145ce748-2feb-449f-b39e-87c07cb25d1e" />
<img width="817" alt="image" src="https://github.com/user-attachments/assets/e1d83b77-26b1-46be-9093-108739dd5584" />


carsonbot: It looks like you unchecked the "Allow edits from maintainer" box. That is fine, but please note that if you have multiple commits, you'll need to squash your commits into one before this can be merged. Or, you can check the "Allow edits from maintainers" box and the maintainer can squash for you.

Cheers!

Carsonbot
dannyvw: > Thanks for your contribution!
> 
> Is it possible to have some preview of what it renders? Because I don't see anything on Leaflet website :/ Also, does it work with Google Maps?

@Kocal It is the example under “Additionally, you can pass a multi-dimensional array to represent a MultiPolygon shape.” with the outer ring and hole.

Google Maps https://developers.google.com/maps/documentation/javascript/shapes#polygon_hole
Kocal: I meant a visual preview, with a map screenshot :D But that's fine, the Google Maps demo and tweaking Leaflet page understand what's a multipolygon:
![image](https://github.com/user-attachments/assets/0d0c5559-8052-4498-80f3-a315d269533e) helped me to 

So, to move forward, there are still modifications to do:
1. Improve the CHANGELOG, by quickly explaining what's a MultiPolygon with a small code example
2. Change the documentation too, to show how to create a MultiPolygon
3. Types for methods `Polygon::__construct()`, `Polygon::toArray()` and `Polygon::fromArray()` must be updated
4. Types for `PolygonDefinition` TypeScript type must also been updated

Feel free to ask if you need help, thanks! :) 
github-actions[bot]: <h1>📊 Packages dist files size difference</h1>

Thanks for the PR! Here is the difference in size of the packages dist files between the base branch and the PR.
Please review the changes and make sure they are expected.

<table>
    <thead><tr><th>File</th><th>Before (Size / Gzip)</th><th>After (Size / Gzip)</th></tr></thead>
    <tbody><tr><td colspan="3"><a href="https://github.com/insiders/ux/tree/multipolygon/src/Map/assets/dist"><b>Map</b></a></td></tr><tr>
                <td><a href="https://github.com/insiders/ux/blob/multipolygon/src/Map/assets/dist/abstract_map_controller.d.ts"><code>abstract_map_controller.d.ts</code></a></td>
            <td>
                    <code>4.95 kB</code>
                    / <code>1.15 kB</code> 
                </td><td>
                    <code>4.97 kB</code><sup>0%</sup>
                    / <code>1.16 kB</code><sup>+1% 📈</sup>
                </td></tr></tbody>
</table>

<!-- Sticky Pull Request Comment -->
dannyvw: @Kocal I pushed some changes. Is this fine or should I do something more?
dannyvw: @Kocal I don’t see that option. I think this is because the fork is in a organisation repo?

Patch:
diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md
index dfb22e55..c400f59c 100644
--- a/src/Map/CHANGELOG.md
+++ b/src/Map/CHANGELOG.md
@@ -1,5 +1,29 @@
 # CHANGELOG
 
+## 2.26
+
+-  Add support for creating `Polygon` with holes, by passing an array of `array<Point>` as `points` parameter to the `Polygon` constructor, e.g.:
+```php
+// Draw a polygon with a hole in it, on the French map
+$map->addPolygon(new Polygon(points: [
+    // First path, the outer boundary of the polygon
+    [
+        new Point(48.117266, -1.677792), // Rennes
+        new Point(50.629250, 3.057256), // Lille
+        new Point(48.573405, 7.752111), // Strasbourg
+        new Point(43.296482, 5.369780), // Marseille
+        new Point(44.837789, -0.579180), // Bordeaux
+    ],
+    // Second path, it will make a hole in the previous one
+    [
+        new Point(45.833619, 1.261105), // Limoges
+        new Point(45.764043, 4.835659), // Lyon
+        new Point(49.258329, 4.031696), // Reims
+        new Point(48.856613, 2.352222), // Paris
+    ],
+]));
+```
+
 ## 2.25
 
 -  Downgrade PHP requirement from 8.3 to 8.1
diff --git a/src/Map/assets/dist/abstract_map_controller.d.ts b/src/Map/assets/dist/abstract_map_controller.d.ts
index 9eb15e76..a593f9ee 100644
--- a/src/Map/assets/dist/abstract_map_controller.d.ts
+++ b/src/Map/assets/dist/abstract_map_controller.d.ts
@@ -36,7 +36,7 @@ export type MarkerDefinition<MarkerOptions, InfoWindowOptions> = WithIdentifier<
 }>;
 export type PolygonDefinition<PolygonOptions, InfoWindowOptions> = WithIdentifier<{
     infoWindow?: InfoWindowWithoutPositionDefinition<InfoWindowOptions>;
-    points: Array<Point>;
+    points: Array<Point> | Array<Array<Point>>;
     title: string | null;
     rawOptions?: PolygonOptions;
     extra: Record<string, unknown>;
diff --git a/src/Map/assets/src/abstract_map_controller.ts b/src/Map/assets/src/abstract_map_controller.ts
index b73f0bcc..f41134fa 100644
--- a/src/Map/assets/src/abstract_map_controller.ts
+++ b/src/Map/assets/src/abstract_map_controller.ts
@@ -48,7 +48,7 @@ export type MarkerDefinition<MarkerOptions, InfoWindowOptions> = WithIdentifier<
 
 export type PolygonDefinition<PolygonOptions, InfoWindowOptions> = WithIdentifier<{
     infoWindow?: InfoWindowWithoutPositionDefinition<InfoWindowOptions>;
-    points: Array<Point>;
+    points: Array<Point> | Array<Array<Point>>;
     title: string | null;
     /**
      * Raw options passed to the marker constructor, specific to the map provider (e.g.: `L.marker()` for Leaflet).
diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst
index b45ae091..2f3eebf9 100644
--- a/src/Map/doc/index.rst
+++ b/src/Map/doc/index.rst
@@ -71,7 +71,7 @@ Start by creating a new map instance::
     use Symfony\UX\Map\Map;
 
     // Create a new map instance
-    $myMap = (new Map());
+    $map = new Map();
 
 Center and zoom
 ~~~~~~~~~~~~~~~
@@ -81,7 +81,7 @@ You can set the center and zoom of the map using the ``center()`` and ``zoom()``
     use Symfony\UX\Map\Map;
     use Symfony\UX\Map\Point;
 
-    $myMap
+    $map
         // Explicitly set the center and zoom
         ->center(new Point(46.903354, 1.888334))
         ->zoom(6)
@@ -95,7 +95,7 @@ Add markers
 
 You can add markers to a map using the ``addMarker()`` method::
 
-    $myMap
+    $map
         ->addMarker(new Marker(
             position: new Point(48.8566, 2.3522),
             title: 'Paris'
@@ -154,7 +154,7 @@ Add Polygons
 
 You can also add Polygons, which represents an area enclosed by a series of ``Point`` instances::
 
-    $myMap->addPolygon(new Polygon(
+    $map->addPolygon(new Polygon(
         points: [
             new Point(48.8566, 2.3522),
             new Point(45.7640, 4.8357),
@@ -166,12 +166,37 @@ You can also add Polygons, which represents an area enclosed by a series of ``Po
         ),
     ));
 
+.. versionadded:: 2.26
+
+    `Polygon` with holes is available since UX Map 2.26.
+
+Since UX Map 2.26, you can also create polygons with holes in them, by passing an array of `array<Point>` to `points` parameter::
+
+    // Draw a polygon with a hole in it, on the French map
+    $map->addPolygon(new Polygon(points: [
+        // First path, the outer boundary of the polygon
+        [
+            new Point(48.117266, -1.677792), // Rennes
+            new Point(50.629250, 3.057256), // Lille
+            new Point(48.573405, 7.752111), // Strasbourg
+            new Point(43.296482, 5.369780), // Marseille
+            new Point(44.837789, -0.579180), // Bordeaux
+        ],
+        // Second path, it will make a hole in the previous one
+        [
+            new Point(45.833619, 1.261105), // Limoges
+            new Point(45.764043, 4.835659), // Lyon
+            new Point(49.258329, 4.031696), // Reims
+            new Point(48.856613, 2.352222), // Paris
+        ],
+    ]));
+
 Add Polylines
 ~~~~~~~~~~~~~
 
 You can add Polylines, which represents a path made by a series of ``Point`` instances::
 
-    $myMap->addPolyline(new Polyline(
+    $map->addPolyline(new Polyline(
         points: [
             new Point(48.8566, 2.3522),
             new Point(45.7640, 4.8357),
diff --git a/src/Map/src/Polygon.php b/src/Map/src/Polygon.php
index 1d526a1c..4eb6747f 100644
--- a/src/Map/src/Polygon.php
+++ b/src/Map/src/Polygon.php
@@ -21,7 +21,8 @@ use Symfony\UX\Map\Exception\InvalidArgumentException;
 final class Polygon implements Element
 {
     /**
-     * @param array<string, mixed> $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side
+     * @param array<Point>|array<array<Point>> $points A list of point representing the polygon, or a list of paths (each path is an array of points) representing a polygon with holes.
+     * @param array<string, mixed>             $extra  Extra data, can be used by the developer to store additional information and use them later JavaScript side
      */
     public function __construct(
         private readonly array $points,
@@ -36,7 +37,7 @@ final class Polygon implements Element
      * Convert the polygon to an array representation.
      *
      * @return array{
-     *     points: array<array{lat: float, lng: float}>,
+     *     points: array<array{lat: float, lng: float}>|array<array{lat: float, lng: float}>,
      *     title: string|null,
      *     infoWindow: array<string, mixed>|null,
      *     extra: array,
@@ -46,7 +47,9 @@ final class Polygon implements Element
     public function toArray(): array
     {
         return [
-            'points' => array_map(fn (Point $point) => $point->toArray(), $this->points),
+            'points' => current($this->points) instanceof Point
+                ? array_map(fn (Point $point) => $point->toArray(), $this->points)
+                : array_map(fn (array $path) => array_map(fn (Point $point) => $point->toArray(), $path), $this->points),
             'title' => $this->title,
             'infoWindow' => $this->infoWindow?->toArray(),
             'extra' => $this->extra,
@@ -56,7 +59,7 @@ final class Polygon implements Element
 
     /**
      * @param array{
-     *     points: array<array{lat: float, lng: float}>,
+     *     points: array<array{lat: float, lng: float}|array<array{lat: float, lng: float}>>,
      *     title: string|null,
      *     infoWindow: array<string, mixed>|null,
      *     extra: array,
@@ -70,7 +73,10 @@ final class Polygon implements Element
         if (!isset($polygon['points'])) {
             throw new InvalidArgumentException('The "points" parameter is required.');
         }
-        $polygon['points'] = array_map(Point::fromArray(...), $polygon['points']);
+
+        $polygon['points'] = isset($polygon['points'][0]['lat'], $polygon['points'][0]['lng'])
+            ? array_map(Point::fromArray(...), $polygon['points'])
+            : array_map(fn(array $points) => array_map(Point::fromArray(...), $points), $polygon['points']);
 
         if (isset($polygon['infoWindow'])) {
             $polygon['infoWindow'] = InfoWindow::fromArray($polygon['infoWindow']);


Test patch:
diff --git a/src/Map/tests/PolygonTest.php b/src/Map/tests/PolygonTest.php
new file mode 100644
index 00000000..ccd9755f
--- /dev/null
+++ b/src/Map/tests/PolygonTest.php
@@ -0,0 +1,152 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Map\Tests;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\Map\Exception\InvalidArgumentException;
+use Symfony\UX\Map\InfoWindow;
+use Symfony\UX\Map\Point;
+use Symfony\UX\Map\Polygon;
+
+class PolygonTest extends TestCase
+{
+    public function testToArray()
+    {
+        $point1 = new Point(1.1, 2.2);
+        $point2 = new Point(3.3, 4.4);
+
+        $infoWindow = new InfoWindow('info content');
+
+        $polygon = new Polygon(
+            points: [$point1, $point2],
+            title: 'Test Polygon',
+            infoWindow: $infoWindow,
+            extra: ['foo' => 'bar'],
+            id: 'poly1'
+        );
+
+        $array = $polygon->toArray();
+        $this->assertSame([
+            'points' => [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]],
+            'title' => 'Test Polygon',
+            'infoWindow' => [
+                'headerContent' => 'info content',
+                'content' => null,
+                'position' => null,
+                'opened' => false,
+                'autoClose' => true,
+                'extra' => $array['infoWindow']['extra'],
+            ],
+            'extra' => ['foo' => 'bar'],
+            'id' => 'poly1',
+        ], $array);
+    }
+
+    public function testToArrayMultidimensional()
+    {
+        $point1 = new Point(1.1, 2.2);
+        $point2 = new Point(3.3, 4.4);
+        $point3 = new Point(5.5, 6.6);
+
+        $polygon = new Polygon(
+            points: [[$point1, $point2], [$point3]],
+        );
+
+        $array = $polygon->toArray();
+        $this->assertSame([
+            'points' => [
+                [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]],
+                [['lat' => 5.5, 'lng' => 6.6]],
+            ],
+            'title' => null,
+            'infoWindow' => null,
+            'extra' => $array['extra'],
+            'id' => null,
+        ], $array);
+    }
+
+    public function testFromArray()
+    {
+        $data = [
+            'points' => [
+                ['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4],
+            ],
+            'title' => 'Test Polygon',
+            'infoWindow' => ['content' => 'info content'],
+            'extra' => ['foo' => 'bar'],
+            'id' => 'poly1',
+        ];
+
+        $polygon = Polygon::fromArray($data);
+
+        $this->assertInstanceOf(Polygon::class, $polygon);
+
+        $array = $polygon->toArray();
+        $this->assertSame([
+            'points' => [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]],
+            'title' => 'Test Polygon',
+            'infoWindow' => [
+                'headerContent' => null,
+                'content' => 'info content',
+                'position' => null,
+                'opened' => false,
+                'autoClose' => true,
+                'extra' => $array['infoWindow']['extra'],
+            ],
+            'extra' => ['foo' => 'bar'],
+            'id' => 'poly1',
+        ], $array);
+    }
+
+    public function testFromArrayMultidimensional()
+    {
+        $data = [
+            'points' => [
+                [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]],
+                [['lat' => 5.5, 'lng' => 6.6]],
+            ],
+            'title' => 'Test Polygon',
+            'infoWindow' => ['content' => 'info content'],
+            'extra' => ['foo' => 'bar'],
+            'id' => 'poly1',
+        ];
+
+        $polygon = Polygon::fromArray($data);
+
+        $this->assertInstanceOf(Polygon::class, $polygon);
+
+        $array = $polygon->toArray();
+        $this->assertSame([
+            'points' => [
+                [['lat' => 1.1, 'lng' => 2.2], ['lat' => 3.3, 'lng' => 4.4]],
+                [['lat' => 5.5, 'lng' => 6.6]],
+            ],
+            'title' => 'Test Polygon',
+            'infoWindow' => [
+                'headerContent' => null,
+                'content' => 'info content',
+                'position' => null,
+                'opened' => false,
+                'autoClose' => true,
+                'extra' => $array['infoWindow']['extra'],
+            ],
+            'extra' => ['foo' => 'bar'],
+            'id' => 'poly1',
+        ], $array);
+    }
+
+    public function testFromArrayThrowsExceptionIfPointsMissing()
+    {
+        $this->expectException(InvalidArgumentException::class);
+        Polygon::fromArray(['invalid' => 'No points']);
+    }
+}