diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index ef718fcb7ca..df7d6551c62 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 `ComponentWithMapTrait` to ease maps integration in [Live Components](https://symfony.com/bundles/ux-live-component/current/index.html) ## 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 f7f9ffd8096..382050b962a 100644 --- a/src/Map/assets/dist/abstract_map_controller.d.ts +++ b/src/Map/assets/dist/abstract_map_controller.d.ts @@ -3,15 +3,8 @@ export type Point = { lat: number; lng: number; }; -export type MapView = { - center: Point | null; - zoom: number | null; - fitBoundsToMarkers: boolean; - markers: Array>; - polygons: Array>; - options: Options; -}; export type MarkerDefinition = { + '@id': string; position: Point; title: string | null; infoWindow?: Omit, 'position'>; @@ -19,6 +12,7 @@ export type MarkerDefinition = { extra: Record; }; export type PolygonDefinition = { + '@id': string; infoWindow?: Omit, 'position'>; points: Array; title: string | null; @@ -37,13 +31,23 @@ 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; }; - viewValue: MapView; + centerValue: Point | null; + zoomValue: number | null; + fitBoundsToMarkersValue: boolean; + markersValue: Array>; + polygonsValue: Array>; + 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; @@ -51,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']; @@ -67,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 83cc772e76a..a49365650b9 100644 --- a/src/Map/assets/dist/abstract_map_controller.js +++ b/src/Map/assets/dist/abstract_map_controller.js @@ -3,23 +3,23 @@ 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 { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; + const options = this.optionsValue; this.dispatchEvent('pre-connect', { options }); - this.map = this.doCreateMap({ center, zoom, options }); - markers.forEach((marker) => this.createMarker(marker)); - polygons.forEach((polygon) => this.createPolygon(polygon)); - if (fitBoundsToMarkers) { + this.map = this.doCreateMap({ center: this.centerValue, zoom: this.zoomValue, options }); + this.markersValue.forEach((marker) => this.createMarker(marker)); + this.polygonsValue.forEach((polygon) => this.createPolygon(polygon)); + if (this.fitBoundsToMarkersValue) { this.doFitBoundsToMarkers(); } 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,50 @@ class default_1 extends Controller { this.infoWindows.push(infoWindow); return infoWindow; } + markersValueChanged() { + if (!this.map) { + return; + } + 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) { + return; + } + 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 bae763cc529..2d95f64a7f2 100644 --- a/src/Map/assets/src/abstract_map_controller.ts +++ b/src/Map/assets/src/abstract_map_controller.ts @@ -2,16 +2,8 @@ import { Controller } from '@hotwired/stimulus'; export type Point = { lat: number; lng: number }; -export type MapView = { - center: Point | null; - zoom: number | null; - fitBoundsToMarkers: boolean; - markers: Array>; - polygons: Array>; - options: Options; -}; - export type MarkerDefinition = { + '@id': string; position: Point; title: string | null; infoWindow?: Omit, 'position'>; @@ -29,6 +21,7 @@ export type MarkerDefinition = { }; export type PolygonDefinition = { + '@id': string; infoWindow?: Omit, 'position'>; points: Array; title: string | null; @@ -68,35 +61,45 @@ 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 viewValue: MapView; + declare centerValue: Point | null; + declare zoomValue: number | null; + declare fitBoundsToMarkersValue: boolean; + declare markersValue: Array>; + declare polygonsValue: Array>; + 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 { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; + const options = this.optionsValue; this.dispatchEvent('pre-connect', { options }); - this.map = this.doCreateMap({ center, zoom, options }); + this.map = this.doCreateMap({ center: this.centerValue, zoom: this.zoomValue, options }); - markers.forEach((marker) => this.createMarker(marker)); + this.markersValue.forEach((marker) => this.createMarker(marker)); - polygons.forEach((polygon) => this.createPolygon(polygon)); + this.polygonsValue.forEach((polygon) => this.createPolygon(polygon)); - if (fitBoundsToMarkers) { + if (this.fitBoundsToMarkersValue) { this.doFitBoundsToMarkers(); } this.dispatchEvent('connect', { map: this.map, - markers: this.markers, - polygons: this.polygons, + markers: [...this.markers.values()], + polygons: [...this.polygons.values()], infoWindows: this.infoWindows, }); } @@ -116,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({ @@ -166,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) { + return; + } + + 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(); + } + } + + public polygonsValueChanged(): void { + if (!this.map) { + return; + } + + 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); + } + }); + } } diff --git a/src/Map/assets/test/abstract_map_controller.test.ts b/src/Map/assets/test/abstract_map_controller.test.ts index c9e0e38aeba..118241f0220 100644 --- a/src/Map/assets/test/abstract_map_controller.test.ts +++ b/src/Map/assets/test/abstract_map_controller.test.ts @@ -60,62 +60,18 @@ describe('AbstractMapController', () => { beforeEach(() => { container = mountDOM(` -
-
+
`); }); @@ -132,24 +88,39 @@ describe('AbstractMapController', () => { const controller = application.getControllerForElementAndIdentifier(div, 'map'); expect(controller.map).toEqual({ map: 'map', center: { lat: 48.8566, lng: 2.3522 }, zoom: 4, options: {} }); - expect(controller.markers).toEqual([ - { marker: 'marker', title: 'Paris' }, - { marker: 'marker', title: 'Lyon' }, - ]); - expect(controller.polygons).toEqual([ - { polygon: 'polygon', title: 'Polygon 1' }, - { polygon: 'polygon', title: 'Polygon 2' }, - ]); + expect(controller.markers).toEqual( + new Map([ + ['a69f13edd2e571f3', { '@id': 'a69f13edd2e571f3', marker: 'marker', title: 'Paris' }], + ['cb9c1a30d562694b', { '@id': 'cb9c1a30d562694b', marker: 'marker', title: 'Lyon' }], + ['e6b3acef1325fb52', { '@id': 'e6b3acef1325fb52', marker: 'marker', title: 'Toulouse' }], + ]) + ); + expect(controller.polygons).toEqual( + new Map([ + ['228ae6f5c1b17cfd', { '@id': '228ae6f5c1b17cfd', polygon: 'polygon', title: null }], + ['9874334e4e8caa16', { '@id': '9874334e4e8caa16', polygon: 'polygon', title: null }], + ]) + ); expect(controller.infoWindows).toEqual([ { - headerContent: 'Lyon', + headerContent: 'Paris', + infoWindow: 'infoWindow', + marker: 'Paris', + }, + { + headerContent: 'Lyon', infoWindow: 'infoWindow', marker: 'Lyon', }, { - headerContent: 'Polygon 2', + headerContent: 'Toulouse', + infoWindow: 'infoWindow', + marker: 'Toulouse', + }, + { + headerContent: 'Polygon', infoWindow: 'infoWindow', - polygon: 'Polygon 2', + polygon: null, }, ]); }); diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst index b89dfef937f..8274a99ad5a 100644 --- a/src/Map/doc/index.rst +++ b/src/Map/doc/index.rst @@ -122,7 +122,7 @@ You can add markers to a map using the ``addMarker()`` method:: // You can also pass arbitrary data via the `extra` option in both the marker // and the infoWindow; you can later use this data in your custom Stimulus controllers ->addMarker(new Marker( - position: new Point(45.7740, 4.8351), + position: new Point(45.7740, 4.8351), extra: [ 'icon_mask_url' => 'https://maps.gstatic.com/mapfiles/place_api/icons/v2/tree_pinlet.svg', ], @@ -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`` attribute: + +.. 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 component template: + +.. code-block:: html+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:: html+twig + + + {{ ux_map(map, { style: 'height: 300px' }) }} + + + + Backward Compatibility promise ------------------------------ @@ -326,3 +416,4 @@ https://symfony.com/doc/current/contributing/code/bc.html .. _`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 .. _`Twig Component`: https://symfony.com/bundles/ux-twig-component/current/index.html +.. _`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 30fbe283118..4b199e56958 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.js @@ -1,26 +1,26 @@ 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 { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; + const options = this.optionsValue; this.dispatchEvent('pre-connect', { options }); - this.map = this.doCreateMap({ center, zoom, options }); - markers.forEach((marker) => this.createMarker(marker)); - polygons.forEach((polygon) => this.createPolygon(polygon)); - if (fitBoundsToMarkers) { + this.map = this.doCreateMap({ center: this.centerValue, zoom: this.zoomValue, options }); + this.markersValue.forEach((marker) => this.createMarker(marker)); + this.polygonsValue.forEach((polygon) => this.createPolygon(polygon)); + if (this.fitBoundsToMarkersValue) { this.doFitBoundsToMarkers(); } 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,54 @@ let default_1$1 = class default_1 extends Controller { this.infoWindows.push(infoWindow); return infoWindow; } -}; -default_1$1.values = { + markersValueChanged() { + if (!this.map) { + return; + } + 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) { + return; + } + 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 +135,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 +148,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 +235,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/assets/test/map_controller.test.ts b/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts index f1b08abba5c..76118c0511b 100644 --- a/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts +++ b/src/Map/src/Bridge/Google/assets/test/map_controller.test.ts @@ -28,7 +28,7 @@ class CheckController extends Controller { const startStimulus = () => { const application = Application.start(); application.register('check', CheckController); - application.register('google', GoogleController); + application.register('symfony--ux-google-map--map', GoogleController); }; describe('GoogleMapsController', () => { @@ -38,10 +38,15 @@ describe('GoogleMapsController', () => { container = mountDOM(`
`); }); diff --git a/src/Map/src/Bridge/Google/src/GoogleOptions.php b/src/Map/src/Bridge/Google/src/GoogleOptions.php index a241daa80e6..68d6bbd6810 100644 --- a/src/Map/src/Bridge/Google/src/GoogleOptions.php +++ b/src/Map/src/Bridge/Google/src/GoogleOptions.php @@ -132,6 +132,41 @@ public function fullscreenControlOptions(FullscreenControlOptions $fullscreenCon return $this; } + /** + * @internal + */ + public static function fromArray(array $array): self + { + $array += ['zoomControl' => false, 'mapTypeControl' => false, 'streetViewControl' => false, 'fullscreenControl' => false]; + + if (isset($array['zoomControlOptions'])) { + $array['zoomControl'] = true; + $array['zoomControlOptions'] = ZoomControlOptions::fromArray($array['zoomControlOptions']); + } + + if (isset($array['mapTypeControlOptions'])) { + $array['mapTypeControl'] = true; + $array['mapTypeControlOptions'] = MapTypeControlOptions::fromArray($array['mapTypeControlOptions']); + } + + if (isset($array['streetViewControlOptions'])) { + $array['streetViewControl'] = true; + $array['streetViewControlOptions'] = StreetViewControlOptions::fromArray($array['streetViewControlOptions']); + } + + if (isset($array['fullscreenControlOptions'])) { + $array['fullscreenControl'] = true; + $array['fullscreenControlOptions'] = FullscreenControlOptions::fromArray($array['fullscreenControlOptions']); + } + + $array['gestureHandling'] = GestureHandling::from($array['gestureHandling']); + + return new self(...$array); + } + + /** + * @internal + */ public function toArray(): array { $array = [ diff --git a/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php b/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php index 35256551f23..ed58a3c0051 100644 --- a/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/FullscreenControlOptions.php @@ -25,6 +25,19 @@ public function __construct( ) { } + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + position: ControlPosition::from($array['position']), + ); + } + + /** + * @internal + */ public function toArray(): array { return [ diff --git a/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php b/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php index 99e1fba1fb7..11dfe8279fb 100644 --- a/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/MapTypeControlOptions.php @@ -30,6 +30,21 @@ public function __construct( ) { } + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + mapTypeIds: $array['mapTypeIds'], + position: ControlPosition::from($array['position']), + style: MapTypeControlStyle::from($array['style']), + ); + } + + /** + * @internal + */ public function toArray(): array { return [ diff --git a/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php b/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php index 926b8831945..897c7467969 100644 --- a/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/StreetViewControlOptions.php @@ -25,6 +25,19 @@ public function __construct( ) { } + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + position: ControlPosition::from($array['position']), + ); + } + + /** + * @internal + */ public function toArray(): array { return [ diff --git a/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php b/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php index 979947a2354..644ba79f536 100644 --- a/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php +++ b/src/Map/src/Bridge/Google/src/Option/ZoomControlOptions.php @@ -25,6 +25,19 @@ public function __construct( ) { } + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + position: ControlPosition::from($array['position']), + ); + } + + /** + * @internal + */ public function toArray(): array { return [ diff --git a/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php b/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php index ccde8a72939..b5ed565dae0 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleOptionsTest.php @@ -43,12 +43,14 @@ public function testWithMinimalConfiguration(): void 'position' => ControlPosition::INLINE_END_BLOCK_START->value, ], ], $options->toArray()); + + self::assertEquals($options, GoogleOptions::fromArray($options->toArray())); } public function testWithMinimalConfigurationAndWithoutControls(): void { $options = new GoogleOptions( - mapId: '2b2d73ba4b8c7b41', + mapId: 'abcdefgh12345678', gestureHandling: GestureHandling::GREEDY, backgroundColor: '#f00', disableDoubleClickZoom: true, @@ -59,10 +61,12 @@ public function testWithMinimalConfigurationAndWithoutControls(): void ); self::assertSame([ - 'mapId' => '2b2d73ba4b8c7b41', + 'mapId' => 'abcdefgh12345678', 'gestureHandling' => GestureHandling::GREEDY->value, 'backgroundColor' => '#f00', 'disableDoubleClickZoom' => true, ], $options->toArray()); + + self::assertEquals($options, GoogleOptions::fromArray($options->toArray())); } } diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php index 358eb2fa353..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; @@ -29,34 +30,42 @@ public function provideTestRenderMap(): iterable ->zoom(12); yield 'simple map, with minimum options' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), 'map' => $map, ]; yield 'with every options' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key', id: 'gmap', language: 'fr', region: 'FR', nonce: 'abcd', retries: 10, url: 'https://maps.googleapis.com/maps/api/js', version: 'quarterly'), 'map' => $map, ]; yield 'with custom attributes' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), 'map' => $map, 'attributes' => ['data-controller' => 'my-custom-controller', 'class' => 'map'], ]; 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' => '
', + 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), 'map' => (clone $map) ->options(new GoogleOptions( @@ -68,7 +77,7 @@ public function provideTestRenderMap(): iterable ]; yield 'without controls enabled' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), apiKey: 'api_key'), 'map' => (clone $map) ->options(new GoogleOptions( @@ -80,18 +89,18 @@ public function provideTestRenderMap(): iterable ]; yield 'with default map id' => [ - 'expected_renderer' => '
', + 'expected_renderer' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), 'my_api_key', defaultMapId: 'DefaultMapId'), 'map' => (clone $map), ]; yield 'with default map id, when passing options (except the "mapId")' => [ - 'expected_renderer' => '
', + 'expected_renderer' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), 'my_api_key', defaultMapId: 'DefaultMapId'), 'map' => (clone $map) ->options(new GoogleOptions()), ]; yield 'with default map id overridden by option "mapId"' => [ - 'expected_renderer' => '
', + 'expected_renderer' => '
', 'renderer' => new GoogleRenderer(new StimulusHelper(null), 'my_api_key', defaultMapId: 'DefaultMapId'), 'map' => (clone $map) ->options(new GoogleOptions(mapId: 'CustomMapId')), 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 6d4a18371f0..cdbad0f8752 100644 --- a/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Leaflet/assets/dist/map_controller.js @@ -5,23 +5,23 @@ 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 { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; + const options = this.optionsValue; this.dispatchEvent('pre-connect', { options }); - this.map = this.doCreateMap({ center, zoom, options }); - markers.forEach((marker) => this.createMarker(marker)); - polygons.forEach((polygon) => this.createPolygon(polygon)); - if (fitBoundsToMarkers) { + this.map = this.doCreateMap({ center: this.centerValue, zoom: this.zoomValue, options }); + this.markersValue.forEach((marker) => this.createMarker(marker)); + this.polygonsValue.forEach((polygon) => this.createPolygon(polygon)); + if (this.fitBoundsToMarkersValue) { this.doFitBoundsToMarkers(); } 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,50 @@ class default_1 extends Controller { this.infoWindows.push(infoWindow); return infoWindow; } + markersValueChanged() { + if (!this.map) { + return; + } + 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) { + return; + } + 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 +127,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 +169,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/assets/test/map_controller.test.ts b/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts index 5a51bf5f8a0..c5378eaf46f 100644 --- a/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts +++ b/src/Map/src/Bridge/Leaflet/assets/test/map_controller.test.ts @@ -28,7 +28,7 @@ class CheckController extends Controller { const startStimulus = () => { const application = Application.start(); application.register('check', CheckController); - application.register('leaflet', LeafletController); + application.register('symfony--ux-leaflet-map--map', LeafletController); }; describe('LeafletController', () => { @@ -36,13 +36,19 @@ describe('LeafletController', () => { beforeEach(() => { container = mountDOM(` -
+ `); }); diff --git a/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php b/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php index 0450477339d..b0c85a82904 100644 --- a/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php +++ b/src/Map/src/Bridge/Leaflet/src/LeafletOptions.php @@ -34,6 +34,19 @@ public function tileLayer(TileLayer $tileLayer): self return $this; } + /** + * @internal + */ + public static function fromArray(array $array): MapOptionsInterface + { + return new self( + tileLayer: TileLayer::fromArray($array['tileLayer']), + ); + } + + /** + * @internal + */ public function toArray(): array { return [ diff --git a/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php b/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php index 526572538b3..8dfc9cfde50 100644 --- a/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php +++ b/src/Map/src/Bridge/Leaflet/src/Option/TileLayer.php @@ -30,12 +30,27 @@ public function __construct( ) { } + /** + * @internal + */ + public static function fromArray(array $array): self + { + return new self( + url: $array['url'], + attribution: $array['attribution'], + options: $array['options'], + ); + } + + /** + * @internal + */ public function toArray(): array { return [ 'url' => $this->url, 'attribution' => $this->attribution, - 'options' => (object) $this->options, + 'options' => $this->options, ]; } } diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php index a5d50fba7a7..4c22f97689f 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletOptionsTest.php @@ -21,15 +21,15 @@ public function testWithMinimalConfiguration(): void { $leafletOptions = new LeafletOptions(); - $array = $leafletOptions->toArray(); - self::assertSame([ 'tileLayer' => [ 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'attribution' => '© OpenStreetMap', - 'options' => $array['tileLayer']['options'], // stdClass + 'options' => [], ], - ], $array); + ], $leafletOptions->toArray()); + + self::assertEquals($leafletOptions, LeafletOptions::fromArray($leafletOptions->toArray())); } public function testWithMaximumConfiguration(): void @@ -47,18 +47,19 @@ public function testWithMaximumConfiguration(): void ), ); - $array = $leafletOptions->toArray(); - self::assertSame([ 'tileLayer' => [ 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'attribution' => '© OpenStreetMap', - 'options' => $array['tileLayer']['options'], // stdClass + 'options' => [ + 'maxZoom' => 19, + 'minZoom' => 1, + 'maxNativeZoom' => 18, + 'zoomOffset' => 0, + ], ], - ], $array); - self::assertSame(19, $array['tileLayer']['options']->maxZoom); - self::assertSame(1, $array['tileLayer']['options']->minZoom); - self::assertSame(18, $array['tileLayer']['options']->maxNativeZoom); - self::assertSame(0, $array['tileLayer']['options']->zoomOffset); + ], $leafletOptions->toArray()); + + self::assertEquals($leafletOptions, LeafletOptions::fromArray($leafletOptions->toArray())); } } diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php index a23f103ec53..b7e1394e3d0 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; @@ -28,24 +29,32 @@ public function provideTestRenderMap(): iterable ->zoom(12); yield 'simple map' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null)), 'map' => $map, ]; yield 'with custom attributes' => [ - 'expected_render' => '
', + 'expected_render' => '
', 'renderer' => new LeafletRenderer(new StimulusHelper(null)), 'map' => $map, 'attributes' => ['data-controller' => 'my-custom-controller', 'class' => 'map'], ]; 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/Bridge/Leaflet/tests/Option/TileLayerTest.php b/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php index c0c8f998d78..f8508b83d73 100644 --- a/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/Option/TileLayerTest.php @@ -26,13 +26,13 @@ public function testToArray() ], ); - $array = $tileLayer->toArray(); - self::assertSame([ 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 'attribution' => '© OpenStreetMap contributors', - 'options' => $array['options'], // stdClass - ], $array); - self::assertSame(19, $array['options']->maxZoom); + 'options' => [ + 'maxZoom' => 19, + ], + ], $tileLayer->toArray()); + self::assertEquals(TileLayer::fromArray($tileLayer->toArray()), $tileLayer); } } diff --git a/src/Map/src/Exception/UnableToDenormalizeOptionsException.php b/src/Map/src/Exception/UnableToDenormalizeOptionsException.php new file mode 100644 index 00000000000..73f5a782e9d --- /dev/null +++ b/src/Map/src/Exception/UnableToDenormalizeOptionsException.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +final class UnableToDenormalizeOptionsException extends LogicException +{ + public function __construct(string $message) + { + parent::__construct(\sprintf('Unable to denormalize the map options: %s', $message)); + } + + public static function missingProviderKey(string $key): self + { + return new self(\sprintf('the provider key "%s" is missing in the normalized options.', $key)); + } + + public static function unsupportedProvider(string $provider, array $supportedProviders): self + { + return new self(\sprintf('the provider "%s" is not supported. Supported providers are "%s".', $provider, implode('", "', $supportedProviders))); + } +} diff --git a/src/Map/src/Exception/UnableToNormalizeOptionsException.php b/src/Map/src/Exception/UnableToNormalizeOptionsException.php new file mode 100644 index 00000000000..ef29c1c4945 --- /dev/null +++ b/src/Map/src/Exception/UnableToNormalizeOptionsException.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +use Symfony\UX\Map\MapOptionsInterface; + +final class UnableToNormalizeOptionsException extends LogicException +{ + public function __construct(string $message) + { + parent::__construct(\sprintf('Unable to normalize the map options: %s', $message)); + } + + /** + * @param class-string $optionsClass + */ + public static function unsupportedProviderClass(string $optionsClass): self + { + return new self(\sprintf('the class "%s" is not supported.', $optionsClass)); + } +} diff --git a/src/Map/src/InfoWindow.php b/src/Map/src/InfoWindow.php index f98fbb6995e..f3f1cb9dd5e 100644 --- a/src/Map/src/InfoWindow.php +++ b/src/Map/src/InfoWindow.php @@ -50,7 +50,7 @@ public function toArray(): array 'position' => $this->position?->toArray(), 'opened' => $this->opened, 'autoClose' => $this->autoClose, - 'extra' => (object) $this->extra, + 'extra' => $this->extra, ]; } @@ -61,7 +61,7 @@ public function toArray(): array * position: array{lat: float, lng: float}|null, * opened: bool, * autoClose: bool, - * extra: object, + * extra: array, * } $data * * @internal diff --git a/src/Map/src/Live/ComponentWithMapTrait.php b/src/Map/src/Live/ComponentWithMapTrait.php new file mode 100644 index 00000000000..cd6322c7471 --- /dev/null +++ b/src/Map/src/Live/ComponentWithMapTrait.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Live; + +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\Map\Map; +use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; +use Symfony\UX\TwigComponent\Attribute\PostMount; + +/** + * @author Hugo Alliaume + * + * @experimental + */ +trait ComponentWithMapTrait +{ + /** + * @internal + */ + #[LiveProp(hydrateWith: 'hydrateMap', dehydrateWith: 'dehydrateMap')] + #[ExposeInTemplate(getter: 'getMap')] + public ?Map $map = null; + + abstract protected function instantiateMap(): Map; + + public function getMap(): Map + { + return $this->map ??= $this->instantiateMap(); + } + + /** + * @internal + */ + #[PostMount] + public function initializeMap(array $data): array + { + // allow the Map object to be passed into the component() as "map" + if (\array_key_exists('map', $data)) { + $this->map = $data['map']; + unset($data['map']); + } + + return $data; + } + + /** + * @internal + */ + public function hydrateMap(array $data): Map + { + return Map::fromArray($data); + } + + /** + * @internal + */ + public function dehydrateMap(Map $map): array + { + return $map->toArray(); + } +} diff --git a/src/Map/src/Map.php b/src/Map/src/Map.php index 3ab240ae1e2..374b7e1a8ad 100644 --- a/src/Map/src/Map.php +++ b/src/Map/src/Map.php @@ -111,7 +111,7 @@ public function toArray(): array 'center' => $this->center?->toArray(), 'zoom' => $this->zoom, 'fitBoundsToMarkers' => $this->fitBoundsToMarkers, - 'options' => (object) ($this->options?->toArray() ?? []), + 'options' => $this->options ? MapOptionsNormalizer::normalize($this->options) : [], 'markers' => array_map(static fn (Marker $marker) => $marker->toArray(), $this->markers), 'polygons' => array_map(static fn (Polygon $polygon) => $polygon->toArray(), $this->polygons), ]; @@ -124,7 +124,7 @@ public function toArray(): array * markers?: list, * polygons?: list, * fitBoundsToMarkers?: bool, - * options?: object, + * options?: array, * } $map * * @internal @@ -133,6 +133,10 @@ public static function fromArray(array $map): self { $map['fitBoundsToMarkers'] = true; + if (isset($map['options'])) { + $map['options'] = [] === $map['options'] ? null : MapOptionsNormalizer::denormalize($map['options']); + } + if (isset($map['center'])) { $map['center'] = Point::fromArray($map['center']); } diff --git a/src/Map/src/MapOptionsInterface.php b/src/Map/src/MapOptionsInterface.php index de7b1e20211..8e2343c7e8c 100644 --- a/src/Map/src/MapOptionsInterface.php +++ b/src/Map/src/MapOptionsInterface.php @@ -17,6 +17,13 @@ interface MapOptionsInterface { /** + * @internal + */ + public static function fromArray(array $array): self; + + /** + * @internal + * * @return array */ public function toArray(): array; diff --git a/src/Map/src/MapOptionsNormalizer.php b/src/Map/src/MapOptionsNormalizer.php new file mode 100644 index 00000000000..8233c00d71d --- /dev/null +++ b/src/Map/src/MapOptionsNormalizer.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +use Symfony\UX\Map\Bridge as MapBridge; +use Symfony\UX\Map\Exception\UnableToDenormalizeOptionsException; +use Symfony\UX\Map\Exception\UnableToNormalizeOptionsException; + +/** + * Normalizes and denormalizes map options. + * + * @internal + * + * @author Hugo Alliaume + */ +final class MapOptionsNormalizer +{ + private const string KEY_PROVIDER = '@provider'; + + /** + * @var array> + */ + public static array $providers = [ + 'google' => MapBridge\Google\GoogleOptions::class, + 'leaflet' => MapBridge\Leaflet\LeafletOptions::class, + ]; + + public static function denormalize(array $array): MapOptionsInterface + { + if (null === ($provider = $array[self::KEY_PROVIDER] ?? null)) { + throw UnableToDenormalizeOptionsException::missingProviderKey(self::KEY_PROVIDER); + } + + unset($array[self::KEY_PROVIDER]); + + if (null === $class = self::$providers[$provider] ?? null) { + throw UnableToDenormalizeOptionsException::unsupportedProvider($provider, array_keys(self::$providers)); + } + + return $class::fromArray($array); + } + + public static function normalize(MapOptionsInterface $options): array + { + $provider = array_search($options::class, self::$providers, true); + if (!\is_string($provider)) { + throw UnableToNormalizeOptionsException::unsupportedProviderClass($options::class); + } + + $array = $options->toArray(); + $array[self::KEY_PROVIDER] = $provider; + + return $array; + } +} diff --git a/src/Map/src/Marker.php b/src/Map/src/Marker.php index ac0dc0e0af6..f14082f9977 100644 --- a/src/Map/src/Marker.php +++ b/src/Map/src/Marker.php @@ -46,7 +46,7 @@ public function toArray(): array 'position' => $this->position->toArray(), 'title' => $this->title, 'infoWindow' => $this->infoWindow?->toArray(), - 'extra' => (object) $this->extra, + 'extra' => $this->extra, ]; } diff --git a/src/Map/src/Polygon.php b/src/Map/src/Polygon.php index 5d474346e7d..7dd4cc60ce6 100644 --- a/src/Map/src/Polygon.php +++ b/src/Map/src/Polygon.php @@ -40,7 +40,7 @@ public function toArray(): array 'points' => array_map(fn (Point $point) => $point->toArray(), $this->points), 'title' => $this->title, 'infoWindow' => $this->infoWindow?->toArray(), - 'extra' => (object) $this->extra, + 'extra' => $this->extra, ]; } diff --git a/src/Map/src/Renderer/AbstractRenderer.php b/src/Map/src/Renderer/AbstractRenderer.php index 4471f9be36a..b79d2e2c24b 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(), - 'view' => $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, JSON_THROW_ON_ERROR)); + + $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/DummyOptions.php b/src/Map/tests/DummyOptions.php new file mode 100644 index 00000000000..f04acc97a12 --- /dev/null +++ b/src/Map/tests/DummyOptions.php @@ -0,0 +1,52 @@ + + * + * 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 Symfony\UX\Map\MapOptionsInterface; +use Symfony\UX\Map\MapOptionsNormalizer; + +final readonly class DummyOptions implements MapOptionsInterface +{ + public function __construct( + private string $mapId, + private string $mapType, + ) { + } + + public static function registerToNormalizer(): void + { + MapOptionsNormalizer::$providers['dummy'] = self::class; + } + + public static function unregisterFromNormalizer(): void + { + unset(MapOptionsNormalizer::$providers['dummy']); + } + + public static function fromArray(array $array): MapOptionsInterface + { + return new self( + $array['mapId'], + $array['mapType'], + ); + } + + public function toArray(): array + { + return [ + 'mapId' => $this->mapId, + 'mapType' => $this->mapType, + ]; + } +} diff --git a/src/Map/tests/MapFactoryTest.php b/src/Map/tests/MapFactoryTest.php index fcff3b0539c..bdf737cb320 100644 --- a/src/Map/tests/MapFactoryTest.php +++ b/src/Map/tests/MapFactoryTest.php @@ -12,10 +12,24 @@ namespace Symfony\UX\Map\Tests; use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Map; +use Symfony\UX\Map\Marker; +use Symfony\UX\Map\Point; +use Symfony\UX\Map\Polygon; class MapFactoryTest extends TestCase { + protected function setUp(): void + { + DummyOptions::registerToNormalizer(); + } + + protected function tearDown(): void + { + DummyOptions::unregisterFromNormalizer(); + } + public function testFromArray(): void { $array = self::createMapArray(); @@ -41,6 +55,39 @@ public function testFromArray(): void $this->assertSame($array['polygons'][0]['infoWindow']['content'], $polygons[0]['infoWindow']['content']); } + public function testToArrayFromArray(): void + { + $map = (new Map()) + ->center(new Point(48.8566, 2.3522)) + ->zoom(12) + ->addMarker(new Marker( + position: new Point(48.8566, 2.3522), + title: 'Paris', + infoWindow: new InfoWindow('Welcome to Paris, the city of lights', extra: ['color' => 'red']), + extra: ['color' => 'blue'], + )) + ->addMarker(new Marker( + position: new Point(44.837789, -0.57918), + title: 'Bordeaux', + infoWindow: new InfoWindow('Welcome to Bordeaux, the city of wine', extra: ['color' => 'red']), + extra: ['color' => 'blue'], + )) + ->addPolygon(new Polygon( + points: [ + new Point(48.858844, 2.294351), + new Point(48.853, 2.3499), + new Point(48.8566, 2.3522), + ], + title: 'Polygon 1', + infoWindow: new InfoWindow('Polygon 1', 'Polygon 1', extra: ['color' => 'red']), + extra: ['color' => 'blue'], + )); + + $newMap = Map::fromArray($map->toArray()); + + $this->assertEquals($map->toArray(), $newMap->toArray()); + } + public function testFromArrayWithInvalidCenter(): void { $array = self::createMapArray(); diff --git a/src/Map/tests/MapOptionsNormalizerTest.php b/src/Map/tests/MapOptionsNormalizerTest.php new file mode 100644 index 00000000000..1348a4a464c --- /dev/null +++ b/src/Map/tests/MapOptionsNormalizerTest.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\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\UnableToDenormalizeOptionsException; +use Symfony\UX\Map\MapOptionsNormalizer; + +final class MapOptionsNormalizerTest extends TestCase +{ + protected function tearDown(): void + { + DummyOptions::unregisterFromNormalizer(); + } + + public function testDenormalizingWhenProviderKeyIsMissing(): void + { + $this->expectException(UnableToDenormalizeOptionsException::class); + $this->expectExceptionMessage(' the provider key "@provider" is missing in the normalized options.'); + + MapOptionsNormalizer::denormalize([]); + } + + public function testDenormalizingWhenProviderIsNotSupported(): void + { + $this->expectException(UnableToDenormalizeOptionsException::class); + $this->expectExceptionMessage(' the provider "foo" is not supported. Supported providers are "google", "leaflet".'); + + MapOptionsNormalizer::denormalize(['@provider' => 'foo']); + } + + public function testDenormalizingAndNormalizing(): void + { + DummyOptions::registerToNormalizer(); + + $options = MapOptionsNormalizer::denormalize([ + '@provider' => 'dummy', + 'mapId' => 'abcdef', + 'mapType' => 'satellite', + ]); + + self::assertInstanceOf(DummyOptions::class, $options); + self::assertEquals([ + 'mapId' => 'abcdef', + 'mapType' => 'satellite', + ], $options->toArray()); + + self::assertEquals([ + '@provider' => 'dummy', + 'mapId' => 'abcdef', + 'mapType' => 'satellite', + ], MapOptionsNormalizer::normalize($options)); + + self::assertEquals($options, MapOptionsNormalizer::denormalize(MapOptionsNormalizer::normalize($options))); + } +} diff --git a/src/Map/tests/MapTest.php b/src/Map/tests/MapTest.php index 95703724466..7445dbc8b52 100644 --- a/src/Map/tests/MapTest.php +++ b/src/Map/tests/MapTest.php @@ -15,13 +15,22 @@ use Symfony\UX\Map\Exception\InvalidArgumentException; use Symfony\UX\Map\InfoWindow; use Symfony\UX\Map\Map; -use Symfony\UX\Map\MapOptionsInterface; use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; use Symfony\UX\Map\Polygon; class MapTest extends TestCase { + protected function setUp(): void + { + DummyOptions::registerToNormalizer(); + } + + protected function tearDown(): void + { + DummyOptions::unregisterFromNormalizer(); + } + public function testCenterValidation(): void { self::expectException(InvalidArgumentException::class); @@ -86,18 +95,12 @@ public function testWithMaximumConfiguration(): void ->center(new Point(48.8566, 2.3522)) ->zoom(6) ->fitBoundsToMarkers() - ->options(new class implements MapOptionsInterface { - public function toArray(): array - { - return [ - 'mapTypeId' => 'roadmap', - ]; - } - }) + ->options(new DummyOptions(mapId: '1a2b3c4d5e', mapType: 'roadmap')) ->addMarker(new Marker( position: new Point(48.8566, 2.3522), title: 'Paris', - infoWindow: new InfoWindow(headerContent: 'Paris', content: 'Paris', position: new Point(48.8566, 2.3522)) + infoWindow: new InfoWindow(headerContent: 'Paris', content: 'Paris', position: new Point(48.8566, 2.3522), extra: ['baz' => 'qux']), + extra: ['foo' => 'bar'], )) ->addMarker(new Marker( position: new Point(45.764, 4.8357), @@ -135,13 +138,15 @@ public function toArray(): array )) ; - $array = $map->toArray(); - self::assertEquals([ 'center' => ['lat' => 48.8566, 'lng' => 2.3522], 'zoom' => 6.0, 'fitBoundsToMarkers' => true, - 'options' => $array['options'], + 'options' => [ + '@provider' => 'dummy', + 'mapId' => '1a2b3c4d5e', + 'mapType' => 'roadmap', + ], 'markers' => [ [ 'position' => ['lat' => 48.8566, 'lng' => 2.3522], @@ -152,9 +157,9 @@ public function toArray(): array 'position' => ['lat' => 48.8566, 'lng' => 2.3522], 'opened' => false, 'autoClose' => true, - 'extra' => $array['markers'][0]['infoWindow']['extra'], + 'extra' => ['baz' => 'qux'], ], - 'extra' => $array['markers'][0]['extra'], + 'extra' => ['foo' => 'bar'], ], [ 'position' => ['lat' => 45.764, 'lng' => 4.8357], @@ -165,9 +170,9 @@ public function toArray(): array 'position' => ['lat' => 45.764, 'lng' => 4.8357], 'opened' => true, 'autoClose' => true, - 'extra' => $array['markers'][1]['infoWindow']['extra'], + 'extra' => [], ], - 'extra' => $array['markers'][1]['extra'], + 'extra' => [], ], [ 'position' => ['lat' => 43.2965, 'lng' => 5.3698], @@ -178,9 +183,9 @@ public function toArray(): array 'position' => ['lat' => 43.2965, 'lng' => 5.3698], 'opened' => true, 'autoClose' => true, - 'extra' => $array['markers'][2]['infoWindow']['extra'], + 'extra' => [], ], - 'extra' => $array['markers'][2]['extra'], + 'extra' => [], ], ], 'polygons' => [ @@ -192,7 +197,7 @@ public function toArray(): array ], 'title' => 'Polygon 1', 'infoWindow' => null, - 'extra' => $array['polygons'][0]['extra'], + 'extra' => [], ], [ 'points' => [ @@ -207,13 +212,11 @@ public function toArray(): array 'position' => ['lat' => 45.764, 'lng' => 4.8357], 'opened' => true, 'autoClose' => true, - 'extra' => $array['polygons'][1]['infoWindow']['extra'], + 'extra' => [], ], - 'extra' => $array['polygons'][1]['extra'], + 'extra' => [], ], ], - ], $array); - - self::assertSame('roadmap', $array['options']->mapTypeId); + ], $map->toArray()); } }