From 67118d6d1cddf969b1fc41f92f44ead9de93a225 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Sun, 17 Nov 2024 01:31:55 +0100 Subject: [PATCH] [Map] Listen for zoom, center, markers and polygons changes --- src/Map/CHANGELOG.md | 1 + .../assets/dist/abstract_map_controller.d.ts | 20 +++- .../assets/dist/abstract_map_controller.js | 54 +++++++++-- src/Map/assets/src/abstract_map_controller.ts | 80 ++++++++++++++-- src/Map/doc/index.rst | 91 +++++++++++++++++++ .../Google/assets/dist/map_controller.d.ts | 13 ++- .../Google/assets/dist/map_controller.js | 84 +++++++++++++---- .../Google/assets/src/map_controller.ts | 41 ++++++--- .../Google/tests/GoogleRendererTest.php | 11 ++- .../Leaflet/assets/dist/map_controller.d.ts | 14 ++- .../Leaflet/assets/dist/map_controller.js | 71 +++++++++++++-- .../Leaflet/assets/src/map_controller.ts | 31 +++++-- .../Leaflet/tests/LeafletRendererTest.php | 11 ++- .../UnableToDenormalizeOptionsException.php | 2 - src/Map/src/Live/ComponentWithMapTrait.php | 1 - src/Map/src/MapOptionsNormalizer.php | 1 + src/Map/src/Renderer/AbstractRenderer.php | 19 +++- src/Map/tests/MapOptionsNormalizerTest.php | 10 +- 18 files changed, 472 insertions(+), 83 deletions(-) diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index ef718fcb7ca..160d3c886c4 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -4,6 +4,7 @@ - Add method `Symfony\UX\Map\Renderer\AbstractRenderer::tapOptions()`, to allow Renderer to modify options before rendering a Map. - Add `ux_map.google_maps.default_map_id` configuration to set the Google ``Map ID`` +- Add compatibility with [Live Components](https://symfony.com/bundles/ux-live-component/current/index.html), for the moment only zoom, center, markers and polygons are supported. ## 2.20 diff --git a/src/Map/assets/dist/abstract_map_controller.d.ts b/src/Map/assets/dist/abstract_map_controller.d.ts index 329f3d72396..382050b962a 100644 --- a/src/Map/assets/dist/abstract_map_controller.d.ts +++ b/src/Map/assets/dist/abstract_map_controller.d.ts @@ -4,6 +4,7 @@ export type Point = { lng: number; }; export type MarkerDefinition = { + '@id': string; position: Point; title: string | null; infoWindow?: Omit, 'position'>; @@ -11,6 +12,7 @@ export type MarkerDefinition = { extra: Record; }; export type PolygonDefinition = { + '@id': string; infoWindow?: Omit, 'position'>; points: Array; title: string | null; @@ -29,7 +31,12 @@ export type InfoWindowDefinition = { export default abstract class extends Controller { static values: { providerOptions: ObjectConstructor; - view: ObjectConstructor; + center: ObjectConstructor; + zoom: NumberConstructor; + fitBoundsToMarkers: BooleanConstructor; + markers: ArrayConstructor; + polygons: ArrayConstructor; + options: ObjectConstructor; }; centerValue: Point | null; zoomValue: number | null; @@ -38,9 +45,9 @@ export default abstract class>; optionsValue: MapOptions; protected map: Map; - protected markers: Array; + protected markers: globalThis.Map; protected infoWindows: Array; - protected polygons: Array; + protected polygons: globalThis.Map; connect(): void; protected abstract doCreateMap({ center, zoom, options, }: { center: Point | null; @@ -48,8 +55,9 @@ export default abstract class): Marker; - createPolygon(definition: PolygonDefinition): Polygon; + protected abstract removeMarker(marker: Marker): void; protected abstract doCreateMarker(definition: MarkerDefinition): Marker; + createPolygon(definition: PolygonDefinition): Polygon; protected abstract doCreatePolygon(definition: PolygonDefinition): Polygon; protected createInfoWindow({ definition, element, }: { definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; @@ -64,4 +72,8 @@ export default abstract class): void; + abstract centerValueChanged(): void; + abstract zoomValueChanged(): void; + markersValueChanged(): void; + polygonsValueChanged(): void; } diff --git a/src/Map/assets/dist/abstract_map_controller.js b/src/Map/assets/dist/abstract_map_controller.js index 1cb15a522a4..81d6f7299b4 100644 --- a/src/Map/assets/dist/abstract_map_controller.js +++ b/src/Map/assets/dist/abstract_map_controller.js @@ -3,9 +3,9 @@ import { Controller } from '@hotwired/stimulus'; class default_1 extends Controller { constructor() { super(...arguments); - this.markers = []; + this.markers = new Map(); this.infoWindows = []; - this.polygons = []; + this.polygons = new Map(); } connect() { const options = this.optionsValue; @@ -18,8 +18,8 @@ class default_1 extends Controller { } this.dispatchEvent('connect', { map: this.map, - markers: this.markers, - polygons: this.polygons, + markers: [...this.markers.values()], + polygons: [...this.polygons.values()], infoWindows: this.infoWindows, }); } @@ -27,14 +27,16 @@ class default_1 extends Controller { this.dispatchEvent('marker:before-create', { definition }); const marker = this.doCreateMarker(definition); this.dispatchEvent('marker:after-create', { marker }); - this.markers.push(marker); + marker['@id'] = definition['@id']; + this.markers.set(definition['@id'], marker); return marker; } createPolygon(definition) { this.dispatchEvent('polygon:before-create', { definition }); const polygon = this.doCreatePolygon(definition); this.dispatchEvent('polygon:after-create', { polygon }); - this.polygons.push(polygon); + polygon['@id'] = definition['@id']; + this.polygons.set(definition['@id'], polygon); return polygon; } createInfoWindow({ definition, element, }) { @@ -44,10 +46,48 @@ class default_1 extends Controller { this.infoWindows.push(infoWindow); return infoWindow; } + markersValueChanged() { + if (this.map) { + this.markers.forEach((marker) => { + if (!this.markersValue.find((m) => m['@id'] === marker['@id'])) { + this.removeMarker(marker); + this.markers.delete(marker['@id']); + } + }); + this.markersValue.forEach((marker) => { + if (!this.markers.has(marker['@id'])) { + this.createMarker(marker); + } + }); + if (this.fitBoundsToMarkersValue) { + this.doFitBoundsToMarkers(); + } + } + } + polygonsValueChanged() { + if (this.map) { + this.polygons.forEach((polygon) => { + if (!this.polygonsValue.find((p) => p['@id'] === polygon['@id'])) { + polygon.remove(); + this.polygons.delete(polygon['@id']); + } + }); + this.polygonsValue.forEach((polygon) => { + if (!this.polygons.has(polygon['@id'])) { + this.createPolygon(polygon); + } + }); + } + } } default_1.values = { providerOptions: Object, - view: Object, + center: Object, + zoom: Number, + fitBoundsToMarkers: Boolean, + markers: Array, + polygons: Array, + options: Object, }; export { default_1 as default }; diff --git a/src/Map/assets/src/abstract_map_controller.ts b/src/Map/assets/src/abstract_map_controller.ts index 6bf49a73391..9cbcf67dd14 100644 --- a/src/Map/assets/src/abstract_map_controller.ts +++ b/src/Map/assets/src/abstract_map_controller.ts @@ -3,6 +3,7 @@ import { Controller } from '@hotwired/stimulus'; export type Point = { lat: number; lng: number }; export type MarkerDefinition = { + '@id': string; position: Point; title: string | null; infoWindow?: Omit, 'position'>; @@ -20,6 +21,7 @@ export type MarkerDefinition = { }; export type PolygonDefinition = { + '@id': string; infoWindow?: Omit, 'position'>; points: Array; title: string | null; @@ -59,7 +61,12 @@ export default abstract class< > extends Controller { static values = { providerOptions: Object, - view: Object, + center: Object, + zoom: Number, + fitBoundsToMarkers: Boolean, + markers: Array, + polygons: Array, + options: Object, }; declare centerValue: Point | null; @@ -70,9 +77,9 @@ export default abstract class< declare optionsValue: MapOptions; protected map: Map; - protected markers: Array = []; + protected markers = new Map(); protected infoWindows: Array = []; - protected polygons: Array = []; + protected polygons = new Map(); connect() { const options = this.optionsValue; @@ -91,8 +98,8 @@ export default abstract class< this.dispatchEvent('connect', { map: this.map, - markers: this.markers, - polygons: this.polygons, + markers: [...this.markers.values()], + polygons: [...this.polygons.values()], infoWindows: this.infoWindows, }); } @@ -112,20 +119,29 @@ export default abstract class< const marker = this.doCreateMarker(definition); this.dispatchEvent('marker:after-create', { marker }); - this.markers.push(marker); + marker['@id'] = definition['@id']; + + this.markers.set(definition['@id'], marker); return marker; } - createPolygon(definition: PolygonDefinition): Polygon { + protected abstract removeMarker(marker: Marker): void; + + protected abstract doCreateMarker(definition: MarkerDefinition): Marker; + + public createPolygon(definition: PolygonDefinition): Polygon { this.dispatchEvent('polygon:before-create', { definition }); const polygon = this.doCreatePolygon(definition); this.dispatchEvent('polygon:after-create', { polygon }); - this.polygons.push(polygon); + + polygon['@id'] = definition['@id']; + + this.polygons.set(definition['@id'], polygon); + return polygon; } - protected abstract doCreateMarker(definition: MarkerDefinition): Marker; protected abstract doCreatePolygon(definition: PolygonDefinition): Polygon; protected createInfoWindow({ @@ -162,4 +178,50 @@ export default abstract class< protected abstract doFitBoundsToMarkers(): void; protected abstract dispatchEvent(name: string, payload: Record): void; + + public abstract centerValueChanged(): void; + + public abstract zoomValueChanged(): void; + + public markersValueChanged(): void { + if (this.map) { + // Remove markers that are not in the new list + this.markers.forEach((marker) => { + if (!this.markersValue.find((m) => m['@id'] === marker['@id'])) { + this.removeMarker(marker); + this.markers.delete(marker['@id']); + } + }); + + // Add new markers + this.markersValue.forEach((marker) => { + if (!this.markers.has(marker['@id'])) { + this.createMarker(marker); + } + }); + + if (this.fitBoundsToMarkersValue) { + this.doFitBoundsToMarkers(); + } + } + } + + public polygonsValueChanged(): void { + if (this.map) { + // Remove polygons that are not in the new list + this.polygons.forEach((polygon) => { + if (!this.polygonsValue.find((p) => p['@id'] === polygon['@id'])) { + polygon.remove(); + this.polygons.delete(polygon['@id']); + } + }); + + // Add new polygons + this.polygonsValue.forEach((polygon) => { + if (!this.polygons.has(polygon['@id'])) { + this.createPolygon(polygon); + } + }); + } + } } diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst index 2876d8e6e84..13dcdeb5269 100644 --- a/src/Map/doc/index.rst +++ b/src/Map/doc/index.rst @@ -313,6 +313,96 @@ Then, you can use this controller in your template: `Symfony UX Map Google Maps brige docs`_ to learn about the exact code needed to customize the markers. +Usage with Live Components +-------------------------- + +.. versionadded:: 2.22 + + The ability to render and interact with a Map inside a Live Component was added in Map 2.22. + +To use a Map inside a Live Component, you need to use the ``ComponentWithMapTrait`` trait +and implement the method ``instantiateMap`` to return a ``Map`` instance. + +You can interact with the Map by using `LiveAction` + +.. code-block:: + + namespace App\Twig\Components; + + use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; + use Symfony\UX\LiveComponent\Attribute\LiveAction; + use Symfony\UX\LiveComponent\DefaultActionTrait; + use Symfony\UX\Map\InfoWindow; + use Symfony\UX\Map\Live\ComponentWithMapTrait; + use Symfony\UX\Map\Map; + use Symfony\UX\Map\Marker; + use Symfony\UX\Map\Point; + + #[AsLiveComponent] + final class MapLivePlayground + { + use DefaultActionTrait; + use ComponentWithMapTrait; + + protected function instantiateMap(): Map + { + return (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(7) + ->addMarker(new Marker(position: new Point(48.8566, 2.3522), title: 'Paris', infoWindow: new InfoWindow('Paris'))) + ->addMarker(new Marker(position: new Point(45.75, 4.85), title: 'Lyon', infoWindow: new InfoWindow('Lyon'))) + ; + } + } + +Then, you can render the map with ``ux_map()`` in your template: + +.. code-block:: twig + + + {{ ux_map(map, { style: 'height: 300px' }) }} + + +Then, you can define `Live Actions`_ to interact with the map from the client-side. +You can retrieve the map instance using the ``getMap()`` method, and change the map center, zoom, add markers, etc. + +.. code-block:: + + #[LiveAction] + public function doSomething(): void + { + // Change the map center + $this->getMap()->center(new Point(45.7640, 4.8357)); + + // Change the map zoom + $this->getMap()->zoom(6); + + // Add a new marker + $this->getMap()->addMarker(new Marker(position: new Point(43.2965, 5.3698), title: 'Marseille', infoWindow: new InfoWindow('Marseille'))); + + // Add a new polygon + $this->getMap()->addPolygon(new Polygon(points: [ + new Point(48.8566, 2.3522), + new Point(45.7640, 4.8357), + new Point(43.2965, 5.3698), + new Point(44.8378, -0.5792), + ], infoWindow: new InfoWindow('Paris, Lyon, Marseille, Bordeaux'))); + } + +.. code-block:: twig + + + {{ ux_map(map, { style: 'height: 300px' }) }} + + + + Backward Compatibility promise ------------------------------ @@ -325,3 +415,4 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`Leaflet`: https://github.com/symfony/ux-leaflet-map .. _`Symfony UX Map Google Maps brige docs`: https://github.com/symfony/ux/blob/2.x/src/Map/src/Bridge/Google/README.md .. _`Symfony UX Map Leaflet bridge docs`: https://github.com/symfony/ux/blob/2.x/src/Map/src/Bridge/Leaflet/README.md +.. _`Live Actions`: https://symfony.com/bundles/ux-live-component/current/index.html#actions diff --git a/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts b/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts index 5095762fc07..110c2ff7f14 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts @@ -3,9 +3,6 @@ import type { Point, MarkerDefinition, PolygonDefinition } from '@symfony/ux-map import type { LoaderOptions } from '@googlemaps/js-api-loader'; type MapOptions = Pick; export default class extends AbstractMapController { - static values: { - providerOptions: ObjectConstructor; - }; providerOptionsValue: Pick; connect(): Promise; protected dispatchEvent(name: string, payload?: Record): void; @@ -15,13 +12,19 @@ export default class extends AbstractMapController): google.maps.marker.AdvancedMarkerElement; + protected removeMarker(marker: google.maps.marker.AdvancedMarkerElement): void; protected doCreatePolygon(definition: PolygonDefinition): google.maps.Polygon; protected doCreateInfoWindow({ definition, element, }: { - definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; - element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon; + definition: MarkerDefinition['infoWindow']; + element: google.maps.marker.AdvancedMarkerElement; + } | { + definition: PolygonDefinition['infoWindow']; + element: google.maps.Polygon; }): google.maps.InfoWindow; private createTextOrElement; private closeInfoWindowsExcept; protected doFitBoundsToMarkers(): void; + centerValueChanged(): void; + zoomValueChanged(): void; } export {}; diff --git a/src/Map/src/Bridge/Google/assets/dist/map_controller.js b/src/Map/src/Bridge/Google/assets/dist/map_controller.js index e0780666b33..bdd3b747cca 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.js @@ -1,12 +1,12 @@ import { Controller } from '@hotwired/stimulus'; import { Loader } from '@googlemaps/js-api-loader'; -let default_1$1 = class default_1 extends Controller { +class default_1 extends Controller { constructor() { super(...arguments); - this.markers = []; + this.markers = new Map(); this.infoWindows = []; - this.polygons = []; + this.polygons = new Map(); } connect() { const options = this.optionsValue; @@ -19,8 +19,8 @@ let default_1$1 = class default_1 extends Controller { } this.dispatchEvent('connect', { map: this.map, - markers: this.markers, - polygons: this.polygons, + markers: [...this.markers.values()], + polygons: [...this.polygons.values()], infoWindows: this.infoWindows, }); } @@ -28,14 +28,16 @@ let default_1$1 = class default_1 extends Controller { this.dispatchEvent('marker:before-create', { definition }); const marker = this.doCreateMarker(definition); this.dispatchEvent('marker:after-create', { marker }); - this.markers.push(marker); + marker['@id'] = definition['@id']; + this.markers.set(definition['@id'], marker); return marker; } createPolygon(definition) { this.dispatchEvent('polygon:before-create', { definition }); const polygon = this.doCreatePolygon(definition); this.dispatchEvent('polygon:after-create', { polygon }); - this.polygons.push(polygon); + polygon['@id'] = definition['@id']; + this.polygons.set(definition['@id'], polygon); return polygon; } createInfoWindow({ definition, element, }) { @@ -45,14 +47,52 @@ let default_1$1 = class default_1 extends Controller { this.infoWindows.push(infoWindow); return infoWindow; } -}; -default_1$1.values = { + markersValueChanged() { + if (this.map) { + this.markers.forEach((marker) => { + if (!this.markersValue.find((m) => m['@id'] === marker['@id'])) { + this.removeMarker(marker); + this.markers.delete(marker['@id']); + } + }); + this.markersValue.forEach((marker) => { + if (!this.markers.has(marker['@id'])) { + this.createMarker(marker); + } + }); + if (this.fitBoundsToMarkersValue) { + this.doFitBoundsToMarkers(); + } + } + } + polygonsValueChanged() { + if (this.map) { + this.polygons.forEach((polygon) => { + if (!this.polygonsValue.find((p) => p['@id'] === polygon['@id'])) { + polygon.remove(); + this.polygons.delete(polygon['@id']); + } + }); + this.polygonsValue.forEach((polygon) => { + if (!this.polygons.has(polygon['@id'])) { + this.createPolygon(polygon); + } + }); + } + } +} +default_1.values = { providerOptions: Object, - view: Object, + center: Object, + zoom: Number, + fitBoundsToMarkers: Boolean, + markers: Array, + polygons: Array, + options: Object, }; let _google; -class default_1 extends default_1$1 { +class map_controller extends default_1 { async connect() { if (!_google) { _google = { maps: {} }; @@ -93,7 +133,7 @@ class default_1 extends default_1$1 { }); } doCreateMarker(definition) { - const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; + const { '@id': _id, position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; const marker = new _google.maps.marker.AdvancedMarkerElement({ position, title, @@ -106,8 +146,11 @@ class default_1 extends default_1$1 { } return marker; } + removeMarker(marker) { + marker.map = null; + } doCreatePolygon(definition) { - const { points, title, infoWindow, rawOptions = {} } = definition; + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; const polygon = new _google.maps.Polygon({ ...rawOptions, paths: points, @@ -190,9 +233,16 @@ class default_1 extends default_1$1 { }); this.map.fitBounds(bounds); } + centerValueChanged() { + if (this.map && this.centerValue) { + this.map.setCenter(this.centerValue); + } + } + zoomValueChanged() { + if (this.map && this.zoomValue) { + this.map.setZoom(this.zoomValue); + } + } } -default_1.values = { - providerOptions: Object, -}; -export { default_1 as default }; +export { map_controller as default }; diff --git a/src/Map/src/Bridge/Google/assets/src/map_controller.ts b/src/Map/src/Bridge/Google/assets/src/map_controller.ts index 05116d80253..6aa9ff8b1f7 100644 --- a/src/Map/src/Bridge/Google/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Google/assets/src/map_controller.ts @@ -40,10 +40,6 @@ export default class extends AbstractMapController< google.maps.PolygonOptions, google.maps.Polygon > { - static values = { - providerOptions: Object, - }; - declare providerOptionsValue: Pick< LoaderOptions, 'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version' | 'libraries' @@ -114,7 +110,7 @@ export default class extends AbstractMapController< protected doCreateMarker( definition: MarkerDefinition ): google.maps.marker.AdvancedMarkerElement { - const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; + const { '@id': _id, position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; const marker = new _google.maps.marker.AdvancedMarkerElement({ position, @@ -131,10 +127,14 @@ export default class extends AbstractMapController< return marker; } + protected removeMarker(marker: google.maps.marker.AdvancedMarkerElement): void { + marker.map = null; + } + protected doCreatePolygon( definition: PolygonDefinition ): google.maps.Polygon { - const { points, title, infoWindow, rawOptions = {} } = definition; + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; const polygon = new _google.maps.Polygon({ ...rawOptions, @@ -156,15 +156,18 @@ export default class extends AbstractMapController< protected doCreateInfoWindow({ definition, element, - }: { - definition: - | MarkerDefinition< + }: + | { + definition: MarkerDefinition< google.maps.marker.AdvancedMarkerElementOptions, google.maps.InfoWindowOptions - >['infoWindow'] - | PolygonDefinition['infoWindow']; - element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon; - }): google.maps.InfoWindow { + >['infoWindow']; + element: google.maps.marker.AdvancedMarkerElement; + } + | { + definition: PolygonDefinition['infoWindow']; + element: google.maps.Polygon; + }): google.maps.InfoWindow { const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition; const infoWindow = new _google.maps.InfoWindow({ @@ -246,4 +249,16 @@ export default class extends AbstractMapController< this.map.fitBounds(bounds); } + + public centerValueChanged(): void { + if (this.map && this.centerValue) { + this.map.setCenter(this.centerValue); + } + } + + public zoomValueChanged(): void { + if (this.map && this.zoomValue) { + this.map.setZoom(this.zoomValue); + } + } } diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php index df73d90c463..c64045c1e68 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php @@ -17,6 +17,7 @@ use Symfony\UX\Map\Map; use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; +use Symfony\UX\Map\Polygon; use Symfony\UX\Map\Test\RendererTestCase; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -48,13 +49,21 @@ public function provideTestRenderMap(): iterable ]; yield 'with markers and infoWindows' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), 'map' => (clone $map) ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Paris')) ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'))), ]; + yield 'with polygons and infoWindows' => [ + 'expected_render' => '
', + 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), + 'map' => (clone $map) + ->addPolygon(new Polygon(points: [new Point(48.8566, 2.3522), new Point(48.8566, 2.3522), new Point(48.8566, 2.3522)])) + ->addPolygon(new Polygon(points: [new Point(1.1, 2.2), new Point(3.3, 4.4), new Point(5.5, 6.6)], infoWindow: new InfoWindow(content: 'Polygon'))), + ]; + yield 'with controls enabled' => [ 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts index 6b32a8df45b..1caf28c123e 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.d.ts @@ -18,12 +18,18 @@ export default class extends AbstractMapController): L.Marker; + protected removeMarker(marker: L.Marker): void; + protected doCreatePolygon(definition: PolygonDefinition): L.Polygon; protected doCreateInfoWindow({ definition, element, }: { - definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; - element: L.Marker | L.Polygon; + definition: MarkerDefinition; + element: L.Marker; + } | { + definition: PolygonDefinition; + element: L.Polygon; }): L.Popup; protected doFitBoundsToMarkers(): void; + centerValueChanged(): void; + zoomValueChanged(): void; } export {}; diff --git a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js index a3d06fd51e7..7aa3e7fbfe6 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js @@ -5,9 +5,9 @@ import * as L from 'leaflet'; class default_1 extends Controller { constructor() { super(...arguments); - this.markers = []; + this.markers = new Map(); this.infoWindows = []; - this.polygons = []; + this.polygons = new Map(); } connect() { const options = this.optionsValue; @@ -20,8 +20,8 @@ class default_1 extends Controller { } this.dispatchEvent('connect', { map: this.map, - markers: this.markers, - polygons: this.polygons, + markers: [...this.markers.values()], + polygons: [...this.polygons.values()], infoWindows: this.infoWindows, }); } @@ -29,14 +29,16 @@ class default_1 extends Controller { this.dispatchEvent('marker:before-create', { definition }); const marker = this.doCreateMarker(definition); this.dispatchEvent('marker:after-create', { marker }); - this.markers.push(marker); + marker['@id'] = definition['@id']; + this.markers.set(definition['@id'], marker); return marker; } createPolygon(definition) { this.dispatchEvent('polygon:before-create', { definition }); const polygon = this.doCreatePolygon(definition); this.dispatchEvent('polygon:after-create', { polygon }); - this.polygons.push(polygon); + polygon['@id'] = definition['@id']; + this.polygons.set(definition['@id'], polygon); return polygon; } createInfoWindow({ definition, element, }) { @@ -46,10 +48,48 @@ class default_1 extends Controller { this.infoWindows.push(infoWindow); return infoWindow; } + markersValueChanged() { + if (this.map) { + this.markers.forEach((marker) => { + if (!this.markersValue.find((m) => m['@id'] === marker['@id'])) { + this.removeMarker(marker); + this.markers.delete(marker['@id']); + } + }); + this.markersValue.forEach((marker) => { + if (!this.markers.has(marker['@id'])) { + this.createMarker(marker); + } + }); + if (this.fitBoundsToMarkersValue) { + this.doFitBoundsToMarkers(); + } + } + } + polygonsValueChanged() { + if (this.map) { + this.polygons.forEach((polygon) => { + if (!this.polygonsValue.find((p) => p['@id'] === polygon['@id'])) { + polygon.remove(); + this.polygons.delete(polygon['@id']); + } + }); + this.polygonsValue.forEach((polygon) => { + if (!this.polygons.has(polygon['@id'])) { + this.createPolygon(polygon); + } + }); + } + } } default_1.values = { providerOptions: Object, - view: Object, + center: Object, + zoom: Number, + fitBoundsToMarkers: Boolean, + markers: Array, + polygons: Array, + options: Object, }; class map_controller extends default_1 { @@ -85,15 +125,18 @@ class map_controller extends default_1 { return map; } doCreateMarker(definition) { - const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; + const { '@id': _id, position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; const marker = L.marker(position, { title, ...otherOptions, ...rawOptions }).addTo(this.map); if (infoWindow) { this.createInfoWindow({ definition: infoWindow, element: marker }); } return marker; } + removeMarker(marker) { + marker.remove(); + } doCreatePolygon(definition) { - const { points, title, infoWindow, rawOptions = {} } = definition; + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; const polygon = L.polygon(points, { ...rawOptions }).addTo(this.map); if (title) { polygon.bindPopup(title); @@ -124,6 +167,16 @@ class map_controller extends default_1 { return [position.lat, position.lng]; })); } + centerValueChanged() { + if (this.map) { + this.map.setView(this.centerValue, this.zoomValue); + } + } + zoomValueChanged() { + if (this.map) { + this.map.setZoom(this.zoomValue); + } + } } export { map_controller as default }; diff --git a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts index 12ed1f2922f..eb392d2178e 100644 --- a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts @@ -59,8 +59,8 @@ export default class extends AbstractMapController< return map; } - protected doCreateMarker(definition: MarkerDefinition): L.Marker { - const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; + protected doCreateMarker(definition: MarkerDefinition): L.Marker { + const { '@id': _id, position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; const marker = L.marker(position, { title, ...otherOptions, ...rawOptions }).addTo(this.map); @@ -71,8 +71,12 @@ export default class extends AbstractMapController< return marker; } - protected doCreatePolygon(definition: PolygonDefinition): L.Polygon { - const { points, title, infoWindow, rawOptions = {} } = definition; + protected removeMarker(marker: L.Marker): void { + marker.remove(); + } + + protected doCreatePolygon(definition: PolygonDefinition): L.Polygon { + const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; const polygon = L.polygon(points, { ...rawOptions }).addTo(this.map); @@ -90,10 +94,9 @@ export default class extends AbstractMapController< protected doCreateInfoWindow({ definition, element, - }: { - definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; - element: L.Marker | L.Polygon; - }): L.Popup { + }: + | { definition: MarkerDefinition; element: L.Marker } + | { definition: PolygonDefinition; element: L.Polygon }): L.Popup { const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; element.bindPopup([headerContent, content].filter((x) => x).join('
'), { ...otherOptions, ...rawOptions }); @@ -123,4 +126,16 @@ export default class extends AbstractMapController< }) ); } + + public centerValueChanged(): void { + if (this.map) { + this.map.setView(this.centerValue, this.zoomValue); + } + } + + public zoomValueChanged(): void { + if (this.map) { + this.map.setZoom(this.zoomValue); + } + } } diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php index e73d4b3c6da..0963dbefc09 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php @@ -16,6 +16,7 @@ use Symfony\UX\Map\Map; use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; +use Symfony\UX\Map\Polygon; use Symfony\UX\Map\Test\RendererTestCase; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; @@ -41,11 +42,19 @@ public function provideTestRenderMap(): iterable ]; yield 'with markers and infoWindows' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null)), 'map' => (clone $map) ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Paris')) ->addMarker(new Marker(new Point(48.8566, 2.3522), 'Lyon', infoWindow: new InfoWindow(content: 'Lyon'))), ]; + + yield 'with polygons and infoWindows' => [ + 'expected_render' => '
', + 'renderer' => new LeafletRenderer(new StimulusHelper(null)), + 'map' => (clone $map) + ->addPolygon(new Polygon(points: [new Point(48.8566, 2.3522), new Point(48.8566, 2.3522), new Point(48.8566, 2.3522)])) + ->addPolygon(new Polygon(points: [new Point(1.1, 2.2), new Point(3.3, 4.4), new Point(5.5, 6.6)], infoWindow: new InfoWindow(content: 'Polygon'))), + ]; } } diff --git a/src/Map/src/Exception/UnableToDenormalizeOptionsException.php b/src/Map/src/Exception/UnableToDenormalizeOptionsException.php index 6fa13a43e33..095dd12972c 100644 --- a/src/Map/src/Exception/UnableToDenormalizeOptionsException.php +++ b/src/Map/src/Exception/UnableToDenormalizeOptionsException.php @@ -13,8 +13,6 @@ namespace Symfony\UX\Map\Exception; -use Symfony\UX\Map\MapOptionsInterface; - final class UnableToDenormalizeOptionsException extends LogicException { public function __construct(string $message) diff --git a/src/Map/src/Live/ComponentWithMapTrait.php b/src/Map/src/Live/ComponentWithMapTrait.php index cb0a50ae4c5..869d6edf1eb 100644 --- a/src/Map/src/Live/ComponentWithMapTrait.php +++ b/src/Map/src/Live/ComponentWithMapTrait.php @@ -19,7 +19,6 @@ use Symfony\UX\TwigComponent\Attribute\PostMount; /** - * * @author Hugo Alliaume */ trait ComponentWithMapTrait diff --git a/src/Map/src/MapOptionsNormalizer.php b/src/Map/src/MapOptionsNormalizer.php index f0e5abcfac8..214a25f5fa0 100644 --- a/src/Map/src/MapOptionsNormalizer.php +++ b/src/Map/src/MapOptionsNormalizer.php @@ -21,6 +21,7 @@ * Normalizes and denormalizes map options. * * @internal + * * @author Hugo Alliaume */ final class MapOptionsNormalizer diff --git a/src/Map/src/Renderer/AbstractRenderer.php b/src/Map/src/Renderer/AbstractRenderer.php index f89a277a7f3..252e3eb333e 100644 --- a/src/Map/src/Renderer/AbstractRenderer.php +++ b/src/Map/src/Renderer/AbstractRenderer.php @@ -59,7 +59,7 @@ final public function renderMap(Map $map, array $attributes = []): string } $controllers['@symfony/ux-'.$this->getName().'-map/map'] = [ 'provider-options' => (object) $this->getProviderOptions(), - ...$map->toArray(), + ...$this->getMapAttributes($map), ]; $stimulusAttributes = $this->stimulus->createStimulusAttributes(); @@ -81,4 +81,21 @@ final public function renderMap(Map $map, array $attributes = []): string return \sprintf('
', $stimulusAttributes); } + + private function getMapAttributes(Map $map): array + { + $computeId = fn (array $array) => hash('xxh3', json_encode($array)); + + $attrs = $map->toArray(); + + foreach ($attrs['markers'] as $key => $marker) { + $attrs['markers'][$key]['@id'] = $computeId($marker); + } + + foreach ($attrs['polygons'] as $key => $polygon) { + $attrs['polygons'][$key]['@id'] = $computeId($polygon); + } + + return $attrs; + } } diff --git a/src/Map/tests/MapOptionsNormalizerTest.php b/src/Map/tests/MapOptionsNormalizerTest.php index de859f45d7e..cbb214856e3 100644 --- a/src/Map/tests/MapOptionsNormalizerTest.php +++ b/src/Map/tests/MapOptionsNormalizerTest.php @@ -2,8 +2,16 @@ declare(strict_types=1); -namespace Symfony\UX\Map\Tests; +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * 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\UnableToDenormalizeOptionsException;