diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index de37e53e988..3883fe057d4 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -9,6 +9,7 @@ - Add `` Twig component - The importmap entry `@symfony/ux-map/abstract-map-controller` can be removed from your importmap, it is no longer needed. +- Add `Polygon` support ## 2.19 diff --git a/src/Map/assets/dist/abstract_map_controller.d.ts b/src/Map/assets/dist/abstract_map_controller.d.ts index 142c2f697b7..f7f9ffd8096 100644 --- a/src/Map/assets/dist/abstract_map_controller.d.ts +++ b/src/Map/assets/dist/abstract_map_controller.d.ts @@ -3,11 +3,12 @@ export type Point = { lat: number; lng: number; }; -export type MapView = { +export type MapView = { center: Point | null; zoom: number | null; fitBoundsToMarkers: boolean; markers: Array>; + polygons: Array>; options: Options; }; export type MarkerDefinition = { @@ -17,6 +18,13 @@ export type MarkerDefinition = { rawOptions?: MarkerOptions; extra: Record; }; +export type PolygonDefinition = { + infoWindow?: Omit, 'position'>; + points: Array; + title: string | null; + rawOptions?: PolygonOptions; + extra: Record; +}; export type InfoWindowDefinition = { headerContent: string | null; content: string | null; @@ -26,15 +34,16 @@ export type InfoWindowDefinition = { rawOptions?: InfoWindowOptions; extra: Record; }; -export default abstract class extends Controller { +export default abstract class extends Controller { static values: { providerOptions: ObjectConstructor; view: ObjectConstructor; }; - viewValue: MapView; + viewValue: MapView; protected map: Map; protected markers: Array; protected infoWindows: Array; + protected polygons: Array; connect(): void; protected abstract doCreateMap({ center, zoom, options, }: { center: Point | null; @@ -42,14 +51,19 @@ export default abstract class): Marker; + createPolygon(definition: PolygonDefinition): Polygon; protected abstract doCreateMarker(definition: MarkerDefinition): Marker; - protected createInfoWindow({ definition, marker, }: { - definition: MarkerDefinition['infoWindow']; - marker: Marker; + protected abstract doCreatePolygon(definition: PolygonDefinition): Polygon; + protected createInfoWindow({ definition, element, }: { + definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; + element: Marker | Polygon; }): InfoWindow; - protected abstract doCreateInfoWindow({ definition, marker, }: { + protected abstract doCreateInfoWindow({ definition, element, }: { definition: MarkerDefinition['infoWindow']; - marker: Marker; + element: Marker; + } | { + definition: PolygonDefinition['infoWindow']; + element: Polygon; }): InfoWindow; protected abstract doFitBoundsToMarkers(): void; protected abstract dispatchEvent(name: string, payload: Record): void; diff --git a/src/Map/assets/dist/abstract_map_controller.js b/src/Map/assets/dist/abstract_map_controller.js index 9d2e3024024..83cc772e76a 100644 --- a/src/Map/assets/dist/abstract_map_controller.js +++ b/src/Map/assets/dist/abstract_map_controller.js @@ -5,18 +5,21 @@ class default_1 extends Controller { super(...arguments); this.markers = []; this.infoWindows = []; + this.polygons = []; } connect() { - const { center, zoom, options, markers, fitBoundsToMarkers } = this.viewValue; + const { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; 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.doFitBoundsToMarkers(); } this.dispatchEvent('connect', { map: this.map, markers: this.markers, + polygons: this.polygons, infoWindows: this.infoWindows, }); } @@ -27,10 +30,17 @@ class default_1 extends Controller { this.markers.push(marker); return marker; } - createInfoWindow({ definition, marker, }) { - this.dispatchEvent('info-window:before-create', { definition, marker }); - const infoWindow = this.doCreateInfoWindow({ definition, marker }); - this.dispatchEvent('info-window:after-create', { infoWindow, marker }); + createPolygon(definition) { + this.dispatchEvent('polygon:before-create', { definition }); + const polygon = this.doCreatePolygon(definition); + this.dispatchEvent('polygon:after-create', { polygon }); + this.polygons.push(polygon); + return polygon; + } + createInfoWindow({ definition, element, }) { + this.dispatchEvent('info-window:before-create', { definition, element }); + const infoWindow = this.doCreateInfoWindow({ definition, element }); + this.dispatchEvent('info-window:after-create', { infoWindow, element }); this.infoWindows.push(infoWindow); return infoWindow; } diff --git a/src/Map/assets/src/abstract_map_controller.ts b/src/Map/assets/src/abstract_map_controller.ts index 802b6477875..bae763cc529 100644 --- a/src/Map/assets/src/abstract_map_controller.ts +++ b/src/Map/assets/src/abstract_map_controller.ts @@ -2,11 +2,12 @@ import { Controller } from '@hotwired/stimulus'; export type Point = { lat: number; lng: number }; -export type MapView = { +export type MapView = { center: Point | null; zoom: number | null; fitBoundsToMarkers: boolean; markers: Array>; + polygons: Array>; options: Options; }; @@ -27,6 +28,14 @@ export type MarkerDefinition = { extra: Record; }; +export type PolygonDefinition = { + infoWindow?: Omit, 'position'>; + points: Array; + title: string | null; + rawOptions?: PolygonOptions; + extra: Record; +}; + export type InfoWindowDefinition = { headerContent: string | null; content: string | null; @@ -54,20 +63,23 @@ export default abstract class< Marker, InfoWindowOptions, InfoWindow, + PolygonOptions, + Polygon, > extends Controller { static values = { providerOptions: Object, view: Object, }; - declare viewValue: MapView; + declare viewValue: MapView; protected map: Map; protected markers: Array = []; protected infoWindows: Array = []; + protected polygons: Array = []; connect() { - const { center, zoom, options, markers, fitBoundsToMarkers } = this.viewValue; + const { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; this.dispatchEvent('pre-connect', { options }); @@ -75,6 +87,8 @@ export default abstract class< markers.forEach((marker) => this.createMarker(marker)); + polygons.forEach((polygon) => this.createPolygon(polygon)); + if (fitBoundsToMarkers) { this.doFitBoundsToMarkers(); } @@ -82,6 +96,7 @@ export default abstract class< this.dispatchEvent('connect', { map: this.map, markers: this.markers, + polygons: this.polygons, infoWindows: this.infoWindows, }); } @@ -106,18 +121,29 @@ export default abstract class< return marker; } + 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); + return polygon; + } + protected abstract doCreateMarker(definition: MarkerDefinition): Marker; + protected abstract doCreatePolygon(definition: PolygonDefinition): Polygon; protected createInfoWindow({ definition, - marker, + element, }: { - definition: MarkerDefinition['infoWindow']; - marker: Marker; + definition: + | MarkerDefinition['infoWindow'] + | PolygonDefinition['infoWindow']; + element: Marker | Polygon; }): InfoWindow { - this.dispatchEvent('info-window:before-create', { definition, marker }); - const infoWindow = this.doCreateInfoWindow({ definition, marker }); - this.dispatchEvent('info-window:after-create', { infoWindow, marker }); + this.dispatchEvent('info-window:before-create', { definition, element }); + const infoWindow = this.doCreateInfoWindow({ definition, element }); + this.dispatchEvent('info-window:after-create', { infoWindow, element }); this.infoWindows.push(infoWindow); @@ -126,11 +152,16 @@ export default abstract class< protected abstract doCreateInfoWindow({ definition, - marker, - }: { - definition: MarkerDefinition['infoWindow']; - marker: Marker; - }): InfoWindow; + element, + }: + | { + definition: MarkerDefinition['infoWindow']; + element: Marker; + } + | { + definition: PolygonDefinition['infoWindow']; + element: Polygon; + }): InfoWindow; protected abstract doFitBoundsToMarkers(): void; diff --git a/src/Map/assets/test/abstract_map_controller.test.ts b/src/Map/assets/test/abstract_map_controller.test.ts index 0beadef2ee2..c9e0e38aeba 100644 --- a/src/Map/assets/test/abstract_map_controller.test.ts +++ b/src/Map/assets/test/abstract_map_controller.test.ts @@ -20,14 +20,28 @@ class MyMapController extends AbstractMapController { const marker = { marker: 'marker', title: definition.title }; if (definition.infoWindow) { - this.createInfoWindow({ definition: definition.infoWindow, marker }); + this.createInfoWindow({ definition: definition.infoWindow, element: marker }); } return marker; } - doCreateInfoWindow({ definition, marker }) { - return { infoWindow: 'infoWindow', headerContent: definition.headerContent, marker: marker.title }; + doCreatePolygon(definition) { + const polygon = { polygon: 'polygon', title: definition.title }; + + if (definition.infoWindow) { + this.createInfoWindow({ definition: definition.infoWindow, element: polygon }); + } + return polygon; + } + + doCreateInfoWindow({ definition, element }) { + if (element.marker) { + return { infoWindow: 'infoWindow', headerContent: definition.headerContent, marker: element.title }; + } + if (element.polygon) { + return { infoWindow: 'infoWindow', headerContent: definition.headerContent, polygon: element.title }; + } } doFitBoundsToMarkers() { @@ -47,12 +61,61 @@ describe('AbstractMapController', () => { beforeEach(() => { container = mountDOM(`
+ data-testid="map" + data-controller="map" + style="height: 700px; margin: 10px;" + data-map-provider-options-value="{}" + data-map-view-value='{ + "center": { "lat": 48.8566, "lng": 2.3522 }, + "zoom": 4, + "fitBoundsToMarkers": true, + "options": {}, + "markers": [ + { + "position": { "lat": 48.8566, "lng": 2.3522 }, + "title": "Paris", + "infoWindow": null + }, + { + "position": { "lat": 45.764, "lng": 4.8357 }, + "title": "Lyon", + "infoWindow": { + "headerContent": "Lyon", + "content": "The French town in the historic Rhône-Alpes region, located at the junction of the Rhône and Saône rivers.", + "position": null, + "opened": false, + "autoClose": true + } + } + ], + "polygons": [ + { + "coordinates": [ + { "lat": 48.858844, "lng": 2.294351 }, + { "lat": 48.853, "lng": 2.3499 }, + { "lat": 48.8566, "lng": 2.3522 } + ], + "title": "Polygon 1", + "infoWindow": null + }, + { + "coordinates": [ + { "lat": 45.764043, "lng": 4.835659 }, + { "lat": 45.750000, "lng": 4.850000 }, + { "lat": 45.770000, "lng": 4.820000 } + ], + "title": "Polygon 2", + "infoWindow": { + "headerContent": "Polygon 2", + "content": "A polygon around Lyon with some additional info.", + "position": null, + "opened": false, + "autoClose": true + } + } + ] + }'> + `); }); @@ -60,7 +123,7 @@ describe('AbstractMapController', () => { clearDOM(); }); - it('connect and create map, marker and info window', async () => { + it('connect and create map, marker, polygon and info window', async () => { const div = getByTestId(container, 'map'); expect(div).not.toHaveClass('connected'); @@ -73,12 +136,21 @@ describe('AbstractMapController', () => { { marker: 'marker', title: 'Paris' }, { marker: 'marker', title: 'Lyon' }, ]); + expect(controller.polygons).toEqual([ + { polygon: 'polygon', title: 'Polygon 1' }, + { polygon: 'polygon', title: 'Polygon 2' }, + ]); expect(controller.infoWindows).toEqual([ { headerContent: 'Lyon', infoWindow: 'infoWindow', marker: 'Lyon', }, + { + headerContent: 'Polygon 2', + infoWindow: 'infoWindow', + polygon: 'Polygon 2', + }, ]); }); }); diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst index a6d70a80d68..be16614c0ed 100644 --- a/src/Map/doc/index.rst +++ b/src/Map/doc/index.rst @@ -118,8 +118,21 @@ A map is created by calling ``new Map()``. You can configure the center, zoom, a ), ) ; - - // 3. And inject the map in your template to render it + + // 3. You can also add Polygons, which represents an area enclosed by a series of `Point` instances + $map->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( + content: 'Paris, Lyon, Marseille, Bordeaux', + ), + )); + + // 4. And inject the map in your template to render it return $this->render('contact/index.html.twig', [ 'my_map' => $myMap, ]); 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 8a2d2abca23..5095762fc07 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 @@ -1,8 +1,8 @@ import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition } from '@symfony/ux-map'; +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 { +export default class extends AbstractMapController { static values: { providerOptions: ObjectConstructor; }; @@ -15,9 +15,10 @@ export default class extends AbstractMapController): google.maps.marker.AdvancedMarkerElement; - protected doCreateInfoWindow({ definition, marker, }: { - definition: MarkerDefinition['infoWindow']; - marker: google.maps.marker.AdvancedMarkerElement; + protected doCreatePolygon(definition: PolygonDefinition): google.maps.Polygon; + protected doCreateInfoWindow({ definition, element, }: { + definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; + element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon; }): google.maps.InfoWindow; private createTextOrElement; private closeInfoWindowsExcept; 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 d35e6512d3b..30fbe283118 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.js @@ -6,18 +6,21 @@ let default_1$1 = class default_1 extends Controller { super(...arguments); this.markers = []; this.infoWindows = []; + this.polygons = []; } connect() { - const { center, zoom, options, markers, fitBoundsToMarkers } = this.viewValue; + const { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; 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.doFitBoundsToMarkers(); } this.dispatchEvent('connect', { map: this.map, markers: this.markers, + polygons: this.polygons, infoWindows: this.infoWindows, }); } @@ -28,10 +31,17 @@ let default_1$1 = class default_1 extends Controller { this.markers.push(marker); return marker; } - createInfoWindow({ definition, marker, }) { - this.dispatchEvent('info-window:before-create', { definition, marker }); - const infoWindow = this.doCreateInfoWindow({ definition, marker }); - this.dispatchEvent('info-window:after-create', { infoWindow, marker }); + createPolygon(definition) { + this.dispatchEvent('polygon:before-create', { definition }); + const polygon = this.doCreatePolygon(definition); + this.dispatchEvent('polygon:after-create', { polygon }); + this.polygons.push(polygon); + return polygon; + } + createInfoWindow({ definition, element, }) { + this.dispatchEvent('info-window:before-create', { definition, element }); + const infoWindow = this.doCreateInfoWindow({ definition, element }); + this.dispatchEvent('info-window:after-create', { infoWindow, element }); this.infoWindows.push(infoWindow); return infoWindow; } @@ -92,11 +102,26 @@ class default_1 extends default_1$1 { map: this.map, }); if (infoWindow) { - this.createInfoWindow({ definition: infoWindow, marker }); + this.createInfoWindow({ definition: infoWindow, element: marker }); } return marker; } - doCreateInfoWindow({ definition, marker, }) { + doCreatePolygon(definition) { + const { points, title, infoWindow, rawOptions = {} } = definition; + const polygon = new _google.maps.Polygon({ + ...rawOptions, + paths: points, + map: this.map, + }); + if (title) { + polygon.set('title', title); + } + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polygon }); + } + return polygon; + } + doCreateInfoWindow({ definition, element, }) { const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition; const infoWindow = new _google.maps.InfoWindow({ headerContent: this.createTextOrElement(headerContent), @@ -104,22 +129,34 @@ class default_1 extends default_1$1 { ...otherOptions, ...rawOptions, }); - if (definition.opened) { - infoWindow.open({ - map: this.map, - shouldFocus: false, - anchor: marker, + if (element instanceof google.maps.marker.AdvancedMarkerElement) { + element.addListener('click', () => { + if (definition.autoClose) { + this.closeInfoWindowsExcept(infoWindow); + } + infoWindow.open({ map: this.map, anchor: element }); }); - } - marker.addListener('click', () => { - if (definition.autoClose) { - this.closeInfoWindowsExcept(infoWindow); + if (definition.opened) { + infoWindow.open({ map: this.map, anchor: element }); } - infoWindow.open({ - map: this.map, - anchor: marker, + } + else if (element instanceof google.maps.Polygon) { + element.addListener('click', (event) => { + if (definition.autoClose) { + this.closeInfoWindowsExcept(infoWindow); + } + infoWindow.setPosition(event.latLng); + infoWindow.open(this.map); }); - }); + if (definition.opened) { + const bounds = new google.maps.LatLngBounds(); + element.getPath().forEach((point) => { + bounds.extend(point); + }); + infoWindow.setPosition(bounds.getCenter()); + infoWindow.open({ map: this.map, anchor: element }); + } + } return infoWindow; } createTextOrElement(content) { 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 7eed733fc9c..05116d80253 100644 --- a/src/Map/src/Bridge/Google/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Google/assets/src/map_controller.ts @@ -8,7 +8,7 @@ */ import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition } from '@symfony/ux-map'; +import type { Point, MarkerDefinition, PolygonDefinition } from '@symfony/ux-map'; import type { LoaderOptions } from '@googlemaps/js-api-loader'; import { Loader } from '@googlemaps/js-api-loader'; @@ -33,8 +33,12 @@ let _google: typeof google; export default class extends AbstractMapController< MapOptions, google.maps.Map, + google.maps.marker.AdvancedMarkerElementOptions, google.maps.marker.AdvancedMarkerElement, - google.maps.InfoWindow + google.maps.InfoWindowOptions, + google.maps.InfoWindow, + google.maps.PolygonOptions, + google.maps.Polygon > { static values = { providerOptions: Object, @@ -121,21 +125,45 @@ export default class extends AbstractMapController< }); if (infoWindow) { - this.createInfoWindow({ definition: infoWindow, marker }); + this.createInfoWindow({ definition: infoWindow, element: marker }); } return marker; } + protected doCreatePolygon( + definition: PolygonDefinition + ): google.maps.Polygon { + const { points, title, infoWindow, rawOptions = {} } = definition; + + const polygon = new _google.maps.Polygon({ + ...rawOptions, + paths: points, + map: this.map, + }); + + if (title) { + polygon.set('title', title); + } + + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polygon }); + } + + return polygon; + } + protected doCreateInfoWindow({ definition, - marker, + element, }: { - definition: MarkerDefinition< - google.maps.marker.AdvancedMarkerElementOptions, - google.maps.InfoWindowOptions - >['infoWindow']; - marker: google.maps.marker.AdvancedMarkerElement; + definition: + | MarkerDefinition< + google.maps.marker.AdvancedMarkerElementOptions, + google.maps.InfoWindowOptions + >['infoWindow'] + | PolygonDefinition['infoWindow']; + element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon; }): google.maps.InfoWindow { const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition; @@ -146,24 +174,35 @@ export default class extends AbstractMapController< ...rawOptions, }); - if (definition.opened) { - infoWindow.open({ - map: this.map, - shouldFocus: false, - anchor: marker, + if (element instanceof google.maps.marker.AdvancedMarkerElement) { + element.addListener('click', () => { + if (definition.autoClose) { + this.closeInfoWindowsExcept(infoWindow); + } + infoWindow.open({ map: this.map, anchor: element }); }); - } - marker.addListener('click', () => { - if (definition.autoClose) { - this.closeInfoWindowsExcept(infoWindow); + if (definition.opened) { + infoWindow.open({ map: this.map, anchor: element }); } - - infoWindow.open({ - map: this.map, - anchor: marker, + } else if (element instanceof google.maps.Polygon) { + element.addListener('click', (event: any) => { + if (definition.autoClose) { + this.closeInfoWindowsExcept(infoWindow); + } + infoWindow.setPosition(event.latLng); + infoWindow.open(this.map); }); - }); + + if (definition.opened) { + const bounds = new google.maps.LatLngBounds(); + element.getPath().forEach((point: google.maps.LatLng) => { + bounds.extend(point); + }); + infoWindow.setPosition(bounds.getCenter()); + infoWindow.open({ map: this.map, anchor: element }); + } + } return infoWindow; } 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 1db8edfff7b..f1b08abba5c 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 @@ -41,7 +41,7 @@ describe('GoogleMapsController', () => { data-controller="check google" style="height: 700px; margin: 10px" data-google-provider-options-value="{"version":"weekly","libraries":["maps","marker"],"apiKey":""}" - data-google-view-value="{"center":{"lat":48.8566,"lng":2.3522},"zoom":4,"fitBoundsToMarkers":true,"options":{"mapId":"YOUR_MAP_ID","gestureHandling":"auto","backgroundColor":null,"disableDoubleClickZoom":false,"zoomControl":true,"zoomControlOptions":{"position":22},"mapTypeControl":true,"mapTypeControlOptions":{"mapTypeIds":[],"position":14,"style":0},"streetViewControl":true,"streetViewControlOptions":{"position":22},"fullscreenControl":true,"fullscreenControlOptions":{"position":20}},"markers":[{"position":{"lat":48.8566,"lng":2.3522},"title":"Paris","infoWindow":null},{"position":{"lat":45.764,"lng":4.8357},"title":"Lyon","infoWindow":{"headerContent":"<b>Lyon<\/b>","content":"The French town in the historic Rh\u00f4ne-Alpes region, located at the junction of the Rh\u00f4ne and Sa\u00f4ne rivers.","position":null,"opened":false,"autoClose":true}}]}" + data-google-view-value="{"center":{"lat":48.8566,"lng":2.3522},"zoom":4,"fitBoundsToMarkers":true,"options":{"mapId":"YOUR_MAP_ID","gestureHandling":"auto","backgroundColor":null,"disableDoubleClickZoom":false,"zoomControl":true,"zoomControlOptions":{"position":22},"mapTypeControl":true,"mapTypeControlOptions":{"mapTypeIds":[],"position":14,"style":0},"streetViewControl":true,"streetViewControlOptions":{"position":22},"fullscreenControl":true,"fullscreenControlOptions":{"position":20}},"markers":[{"position":{"lat":48.8566,"lng":2.3522},"title":"Paris","infoWindow":null},{"position":{"lat":45.764,"lng":4.8357},"title":"Lyon","infoWindow":{"headerContent":"<b>Lyon<\/b>","content":"The French town in the historic Rh\u00f4ne-Alpes region, located at the junction of the Rh\u00f4ne and Sa\u00f4ne rivers.","position":null,"opened":false,"autoClose":true}}],"polygons":[]}" > `); }); diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php index db011e30998..32ca96df600 100644 --- a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php +++ b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php @@ -29,26 +29,26 @@ 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')) @@ -56,7 +56,7 @@ public function provideTestRenderMap(): iterable ]; 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 +68,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( 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 04e533b4bd9..6b32a8df45b 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 @@ -1,8 +1,8 @@ import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition } from '@symfony/ux-map'; +import type { Point, MarkerDefinition, PolygonDefinition } from '@symfony/ux-map'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; -import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions } from 'leaflet'; +import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions, PolygonOptions } from 'leaflet'; type MapOptions = Pick & { tileLayer: { url: string; @@ -10,7 +10,7 @@ type MapOptions = Pick & { options: Record; }; }; -export default class extends AbstractMapController { +export default class extends AbstractMapController { connect(): void; protected dispatchEvent(name: string, payload?: Record): void; protected doCreateMap({ center, zoom, options, }: { @@ -19,9 +19,10 @@ export default class extends AbstractMapController this.createMarker(marker)); + polygons.forEach((polygon) => this.createPolygon(polygon)); if (fitBoundsToMarkers) { this.doFitBoundsToMarkers(); } this.dispatchEvent('connect', { map: this.map, markers: this.markers, + polygons: this.polygons, infoWindows: this.infoWindows, }); } @@ -29,10 +32,17 @@ class default_1 extends Controller { this.markers.push(marker); return marker; } - createInfoWindow({ definition, marker, }) { - this.dispatchEvent('info-window:before-create', { definition, marker }); - const infoWindow = this.doCreateInfoWindow({ definition, marker }); - this.dispatchEvent('info-window:after-create', { infoWindow, marker }); + createPolygon(definition) { + this.dispatchEvent('polygon:before-create', { definition }); + const polygon = this.doCreatePolygon(definition); + this.dispatchEvent('polygon:after-create', { polygon }); + this.polygons.push(polygon); + return polygon; + } + createInfoWindow({ definition, element, }) { + this.dispatchEvent('info-window:before-create', { definition, element }); + const infoWindow = this.doCreateInfoWindow({ definition, element }); + this.dispatchEvent('info-window:after-create', { infoWindow, element }); this.infoWindows.push(infoWindow); return infoWindow; } @@ -78,19 +88,30 @@ class map_controller extends default_1 { const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition; const marker = L.marker(position, { title, ...otherOptions, ...rawOptions }).addTo(this.map); if (infoWindow) { - this.createInfoWindow({ definition: infoWindow, marker }); + this.createInfoWindow({ definition: infoWindow, element: marker }); } return marker; } - doCreateInfoWindow({ definition, marker, }) { - const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition; - marker.bindPopup([headerContent, content].filter((x) => x).join('
'), { ...otherOptions, ...rawOptions }); + doCreatePolygon(definition) { + const { points, title, infoWindow, rawOptions = {} } = definition; + const polygon = L.polygon(points, { ...rawOptions }).addTo(this.map); + if (title) { + polygon.bindPopup(title); + } + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polygon }); + } + return polygon; + } + doCreateInfoWindow({ definition, element, }) { + const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; + element.bindPopup([headerContent, content].filter((x) => x).join('
'), { ...otherOptions, ...rawOptions }); if (definition.opened) { - marker.openPopup(); + element.openPopup(); } - const popup = marker.getPopup(); + const popup = element.getPopup(); if (!popup) { - throw new Error('Unable to get the Popup associated to the Marker, this should not happens.'); + throw new Error('Unable to get the Popup associated with the element.'); } return popup; } 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 3e342b77b60..12ed1f2922f 100644 --- a/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts +++ b/src/Map/src/Bridge/Leaflet/assets/src/map_controller.ts @@ -1,8 +1,8 @@ import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition } from '@symfony/ux-map'; +import type { Point, MarkerDefinition, PolygonDefinition } from '@symfony/ux-map'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; -import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions } from 'leaflet'; +import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions, PolygonOptions } from 'leaflet'; type MapOptions = Pick & { tileLayer: { url: string; attribution: string; options: Record }; @@ -13,8 +13,10 @@ export default class extends AbstractMapController< typeof L.Map, MarkerOptions, typeof L.Marker, + PopupOptions, typeof L.Popup, - PopupOptions + PolygonOptions, + typeof L.Polygon > { connect(): void { L.Marker.prototype.options.icon = L.divIcon({ @@ -63,30 +65,48 @@ export default class extends AbstractMapController< const marker = L.marker(position, { title, ...otherOptions, ...rawOptions }).addTo(this.map); if (infoWindow) { - this.createInfoWindow({ definition: infoWindow, marker }); + this.createInfoWindow({ definition: infoWindow, element: marker }); } return marker; } + protected doCreatePolygon(definition: PolygonDefinition): L.Polygon { + const { points, title, infoWindow, rawOptions = {} } = definition; + + const polygon = L.polygon(points, { ...rawOptions }).addTo(this.map); + + if (title) { + polygon.bindPopup(title); + } + + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polygon }); + } + + return polygon; + } + protected doCreateInfoWindow({ definition, - marker, + element, }: { - definition: MarkerDefinition['infoWindow']; - marker: L.Marker; + definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; + element: L.Marker | L.Polygon; }): L.Popup { - const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition; + const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; + + element.bindPopup([headerContent, content].filter((x) => x).join('
'), { ...otherOptions, ...rawOptions }); - marker.bindPopup([headerContent, content].filter((x) => x).join('
'), { ...otherOptions, ...rawOptions }); if (definition.opened) { - marker.openPopup(); + element.openPopup(); } - const popup = marker.getPopup(); + const popup = element.getPopup(); if (!popup) { - throw new Error('Unable to get the Popup associated to the Marker, this should not happens.'); + throw new Error('Unable to get the Popup associated with the element.'); } + return popup; } 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 e6aa9276e27..5a51bf5f8a0 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 @@ -36,12 +36,12 @@ describe('LeafletController', () => { beforeEach(() => { container = mountDOM(` -
`); }); diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php index 6931f53abf6..d9ad391ca15 100644 --- a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php +++ b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php @@ -28,20 +28,20 @@ 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')) diff --git a/src/Map/src/Map.php b/src/Map/src/Map.php index d8fdf005e56..3ab240ae1e2 100644 --- a/src/Map/src/Map.php +++ b/src/Map/src/Map.php @@ -30,6 +30,11 @@ public function __construct( * @var array */ private array $markers = [], + + /** + * @var array + */ + private array $polygons = [], ) { } @@ -83,6 +88,13 @@ public function addMarker(Marker $marker): self return $this; } + public function addPolygon(Polygon $polygon): self + { + $this->polygons[] = $polygon; + + return $this; + } + public function toArray(): array { if (!$this->fitBoundsToMarkers) { @@ -101,6 +113,7 @@ public function toArray(): array 'fitBoundsToMarkers' => $this->fitBoundsToMarkers, 'options' => (object) ($this->options?->toArray() ?? []), 'markers' => array_map(static fn (Marker $marker) => $marker->toArray(), $this->markers), + 'polygons' => array_map(static fn (Polygon $polygon) => $polygon->toArray(), $this->polygons), ]; } @@ -109,6 +122,7 @@ public function toArray(): array * center?: array{lat: float, lng: float}, * zoom?: float, * markers?: list, + * polygons?: list, * fitBoundsToMarkers?: bool, * options?: object, * } $map @@ -133,6 +147,12 @@ public static function fromArray(array $map): self } $map['markers'] = array_map(Marker::fromArray(...), $map['markers']); + $map['polygons'] ??= []; + if (!\is_array($map['polygons'])) { + throw new InvalidArgumentException('The "polygons" parameter must be an array.'); + } + $map['polygons'] = array_map(Polygon::fromArray(...), $map['polygons']); + return new self(...$map); } } diff --git a/src/Map/src/Polygon.php b/src/Map/src/Polygon.php new file mode 100644 index 00000000000..5d474346e7d --- /dev/null +++ b/src/Map/src/Polygon.php @@ -0,0 +1,70 @@ + + * + * 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\Exception\InvalidArgumentException; + +/** + * Represents a polygon on a map. + * + * @author [Pierre Svgnt] + */ +final readonly class Polygon +{ + /** + * @param array $extra Extra data, can be used by the developer to store additional information and use them later JavaScript side + */ + public function __construct( + private array $points, + private ?string $title = null, + private ?InfoWindow $infoWindow = null, + private array $extra = [], + ) { + } + + /** + * Convert the polygon to an array representation. + */ + public function toArray(): array + { + return [ + 'points' => array_map(fn (Point $point) => $point->toArray(), $this->points), + 'title' => $this->title, + 'infoWindow' => $this->infoWindow?->toArray(), + 'extra' => (object) $this->extra, + ]; + } + + /** + * @param array{ + * points: array, + * title: string|null, + * infoWindow: array|null, + * extra: object, + * } $polygon + * + * @internal + */ + public static function fromArray(array $polygon): self + { + if (!isset($polygon['points'])) { + throw new InvalidArgumentException('The "points" parameter is required.'); + } + $polygon['points'] = array_map(Point::fromArray(...), $polygon['points']); + + if (isset($polygon['infoWindow'])) { + $polygon['infoWindow'] = InfoWindow::fromArray($polygon['infoWindow']); + } + + return new self(...$polygon); + } +} diff --git a/src/Map/src/Twig/MapRuntime.php b/src/Map/src/Twig/MapRuntime.php index 62e50be86da..cfb47560bd2 100644 --- a/src/Map/src/Twig/MapRuntime.php +++ b/src/Map/src/Twig/MapRuntime.php @@ -14,6 +14,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\Renderer\RendererInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -32,11 +33,13 @@ public function __construct( /** * @param array $attributes * @param array $markers + * @param array $polygons */ public function renderMap( ?Map $map = null, array $attributes = [], ?array $markers = null, + ?array $polygons = null, ?array $center = null, ?float $zoom = null, ): string { @@ -52,6 +55,9 @@ public function renderMap( foreach ($markers ?? [] as $marker) { $map->addMarker(Marker::fromArray($marker)); } + foreach ($polygons ?? [] as $polygons) { + $map->addPolygon(Polygon::fromArray($polygons)); + } if (null !== $center) { $map->center(Point::fromArray($center)); } diff --git a/src/Map/src/Twig/UXMapComponent.php b/src/Map/src/Twig/UXMapComponent.php index 94cb6407e8f..39e362b34b9 100644 --- a/src/Map/src/Twig/UXMapComponent.php +++ b/src/Map/src/Twig/UXMapComponent.php @@ -13,6 +13,7 @@ use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; +use Symfony\UX\Map\Polygon; /** * @author Simon André @@ -29,4 +30,9 @@ final class UXMapComponent * @var Marker[] */ public array $markers; + + /** + * @var Polygon[] + */ + public array $polygons; } diff --git a/src/Map/src/Twig/UXMapComponentListener.php b/src/Map/src/Twig/UXMapComponentListener.php index 3a36e5587da..51034c53b4d 100644 --- a/src/Map/src/Twig/UXMapComponentListener.php +++ b/src/Map/src/Twig/UXMapComponentListener.php @@ -32,7 +32,7 @@ public function onPreCreateForRender(PreCreateForRenderEvent $event): void } $attributes = $event->getInputProps(); - $map = array_intersect_key($attributes, ['markers' => 0, 'center' => 1, 'zoom' => 2]); + $map = array_intersect_key($attributes, ['markers' => 0, 'polygons' => 0, 'center' => 1, 'zoom' => 2]); $attributes = array_diff_key($attributes, $map); $html = $this->mapRuntime->renderMap(...$map, attributes: $attributes); diff --git a/src/Map/tests/MapFactoryTest.php b/src/Map/tests/MapFactoryTest.php index a19e2d7996c..fcff3b0539c 100644 --- a/src/Map/tests/MapFactoryTest.php +++ b/src/Map/tests/MapFactoryTest.php @@ -32,6 +32,13 @@ public function testFromArray(): void $this->assertSame($array['markers'][0]['title'], $markers[0]['title']); $this->assertSame($array['markers'][0]['infoWindow']['headerContent'], $markers[0]['infoWindow']['headerContent']); $this->assertSame($array['markers'][0]['infoWindow']['content'], $markers[0]['infoWindow']['content']); + + $this->assertCount(1, $polygons = $map->toArray()['polygons']); + $this->assertEquals($array['polygons'][0]['points'], $polygons[0]['points']); + $this->assertEquals($array['polygons'][0]['points'], $polygons[0]['points']); + $this->assertSame($array['polygons'][0]['title'], $polygons[0]['title']); + $this->assertSame($array['polygons'][0]['infoWindow']['headerContent'], $polygons[0]['infoWindow']['headerContent']); + $this->assertSame($array['polygons'][0]['infoWindow']['content'], $polygons[0]['infoWindow']['content']); } public function testFromArrayWithInvalidCenter(): void @@ -76,6 +83,30 @@ public function testFromArrayWithInvalidMarker(): void Map::fromArray($array); } + public function testFromArrayWithInvalidPolygons(): void + { + $array = self::createMapArray(); + $array['polygons'] = 'invalid'; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "polygons" parameter must be an array.'); + Map::fromArray($array); + } + + public function testFromArrayWithInvalidPolygon(): void + { + $array = self::createMapArray(); + $array['polygons'] = [ + [ + 'invalid', + ], + ]; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "points" parameter is required.'); + Map::fromArray($array); + } + private static function createMapArray(): array { return [ @@ -97,6 +128,29 @@ private static function createMapArray(): array ], ], ], + 'polygons' => [ + [ + 'points' => [ + [ + 'lat' => 48.858844, + 'lng' => 2.294351, + ], + [ + 'lat' => 48.853, + 'lng' => 2.3499, + ], + [ + 'lat' => 48.8566, + 'lng' => 2.3522, + ], + ], + 'title' => 'Polygon 1', + 'infoWindow' => [ + 'headerContent' => 'Polygon 1', + 'content' => 'Polygon 1', + ], + ], + ], ]; } } diff --git a/src/Map/tests/MapTest.php b/src/Map/tests/MapTest.php index 99a4f1ebc37..95703724466 100644 --- a/src/Map/tests/MapTest.php +++ b/src/Map/tests/MapTest.php @@ -18,6 +18,7 @@ use Symfony\UX\Map\MapOptionsInterface; use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; +use Symfony\UX\Map\Polygon; class MapTest extends TestCase { @@ -55,6 +56,7 @@ public function testZoomAndCenterCanBeOmittedIfFitBoundsToMarkers(): void 'fitBoundsToMarkers' => true, 'options' => $array['options'], 'markers' => [], + 'polygons' => [], ], $array); } @@ -73,6 +75,7 @@ public function testWithMinimumConfiguration(): void 'fitBoundsToMarkers' => false, 'options' => $array['options'], 'markers' => [], + 'polygons' => [], ], $array); } @@ -105,11 +108,36 @@ public function toArray(): array position: new Point(43.2965, 5.3698), title: 'Marseille', infoWindow: new InfoWindow(headerContent: 'Marseille', content: 'Marseille', position: new Point(43.2965, 5.3698), opened: true) - )); + )) + ->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: null, + )) + ->addPolygon(new Polygon( + points: [ + new Point(45.764043, 4.835659), + new Point(45.75, 4.85), + new Point(45.77, 4.82), + ], + title: 'Polygon 2', + infoWindow: new InfoWindow( + headerContent: 'Polygon 2', + content: 'A polygon around Lyon with some additional info.', + position: new Point(45.764, 4.8357), + opened: true, + autoClose: true, + ), + )) + ; $array = $map->toArray(); - self::assertSame([ + self::assertEquals([ 'center' => ['lat' => 48.8566, 'lng' => 2.3522], 'zoom' => 6.0, 'fitBoundsToMarkers' => true, @@ -155,6 +183,35 @@ public function toArray(): array 'extra' => $array['markers'][2]['extra'], ], ], + 'polygons' => [ + [ + 'points' => [ + ['lat' => 48.858844, 'lng' => 2.294351], + ['lat' => 48.853, 'lng' => 2.3499], + ['lat' => 48.8566, 'lng' => 2.3522], + ], + 'title' => 'Polygon 1', + 'infoWindow' => null, + 'extra' => $array['polygons'][0]['extra'], + ], + [ + 'points' => [ + ['lat' => 45.764043, 'lng' => 4.835659], + ['lat' => 45.75, 'lng' => 4.85], + ['lat' => 45.77, 'lng' => 4.82], + ], + 'title' => 'Polygon 2', + 'infoWindow' => [ + 'headerContent' => 'Polygon 2', + 'content' => 'A polygon around Lyon with some additional info.', + 'position' => ['lat' => 45.764, 'lng' => 4.8357], + 'opened' => true, + 'autoClose' => true, + 'extra' => $array['polygons'][1]['infoWindow']['extra'], + ], + 'extra' => $array['polygons'][1]['extra'], + ], + ], ], $array); self::assertSame('roadmap', $array['options']->mapTypeId);