diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md index 3883fe057d4..693f7147a46 100644 --- a/src/Map/CHANGELOG.md +++ b/src/Map/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.22 + +- Add `Polyline` support + ## 2.20 - Deprecate `render_map` Twig function (will be removed in 2.21). Use diff --git a/src/Map/assets/dist/abstract_map_controller.d.ts b/src/Map/assets/dist/abstract_map_controller.d.ts index f7f9ffd8096..12a297d844a 100644 --- a/src/Map/assets/dist/abstract_map_controller.d.ts +++ b/src/Map/assets/dist/abstract_map_controller.d.ts @@ -3,12 +3,13 @@ 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>; + polylines: Array>; options: Options; }; export type MarkerDefinition = { @@ -25,6 +26,13 @@ export type PolygonDefinition = { rawOptions?: PolygonOptions; extra: Record; }; +export type PolylineDefinition = { + infoWindow?: Omit, 'position'>; + points: Array; + title: string | null; + rawOptions?: PolylineOptions; + extra: Record; +}; export type InfoWindowDefinition = { headerContent: string | null; content: string | null; @@ -34,16 +42,17 @@ 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; + protected polylines: Array; connect(): void; protected abstract doCreateMap({ center, zoom, options, }: { center: Point | null; @@ -52,18 +61,29 @@ export default abstract class): Marker; createPolygon(definition: PolygonDefinition): Polygon; + createPolyline(definition: PolylineDefinition): Polyline; protected abstract doCreateMarker(definition: MarkerDefinition): Marker; protected abstract doCreatePolygon(definition: PolygonDefinition): Polygon; - protected createInfoWindow({ definition, element, }: { - definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; - element: Marker | Polygon; + protected abstract doCreatePolyline(definition: PolylineDefinition): Polyline; + protected abstract createInfoWindow(args: { + definition: MarkerDefinition['infoWindow']; + element: Marker; + } | { + definition: PolygonDefinition['infoWindow']; + element: Polygon; + } | { + definition: PolylineDefinition['infoWindow']; + element: Polyline; }): InfoWindow; - protected abstract doCreateInfoWindow({ definition, element, }: { + protected abstract doCreateInfoWindow(args: { definition: MarkerDefinition['infoWindow']; element: Marker; } | { definition: PolygonDefinition['infoWindow']; element: Polygon; + } | { + definition: PolylineDefinition['infoWindow']; + element: Polyline; }): 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 83cc772e76a..d93c21da3dc 100644 --- a/src/Map/assets/dist/abstract_map_controller.js +++ b/src/Map/assets/dist/abstract_map_controller.js @@ -6,13 +6,15 @@ class default_1 extends Controller { this.markers = []; this.infoWindows = []; this.polygons = []; + this.polylines = []; } connect() { - const { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; + const { center, zoom, options, markers, polygons, polylines, 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)); + polylines.forEach((polyline) => this.createPolyline(polyline)); if (fitBoundsToMarkers) { this.doFitBoundsToMarkers(); } @@ -20,6 +22,7 @@ class default_1 extends Controller { map: this.map, markers: this.markers, polygons: this.polygons, + polylines: this.polylines, infoWindows: this.infoWindows, }); } @@ -37,7 +40,15 @@ class default_1 extends Controller { this.polygons.push(polygon); return polygon; } - createInfoWindow({ definition, element, }) { + createPolyline(definition) { + this.dispatchEvent('polyline:before-create', { definition }); + const polyline = this.doCreatePolyline(definition); + this.dispatchEvent('polyline:after-create', { polyline }); + this.polylines.push(polyline); + return polyline; + } + createInfoWindow(args) { + const { definition, element } = args; this.dispatchEvent('info-window:before-create', { definition, element }); const infoWindow = this.doCreateInfoWindow({ definition, element }); this.dispatchEvent('info-window:after-create', { infoWindow, element }); diff --git a/src/Map/assets/src/abstract_map_controller.ts b/src/Map/assets/src/abstract_map_controller.ts index bae763cc529..e08fd87486b 100644 --- a/src/Map/assets/src/abstract_map_controller.ts +++ b/src/Map/assets/src/abstract_map_controller.ts @@ -2,12 +2,13 @@ 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>; + polylines: Array>; options: Options; }; @@ -36,6 +37,14 @@ export type PolygonDefinition = { extra: Record; }; +export type PolylineDefinition = { + infoWindow?: Omit, 'position'>; + points: Array; + title: string | null; + rawOptions?: PolylineOptions; + extra: Record; +}; + export type InfoWindowDefinition = { headerContent: string | null; content: string | null; @@ -65,21 +74,24 @@ export default abstract class< InfoWindow, PolygonOptions, Polygon, + PolylineOptions, + Polyline, > 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 = []; + protected polylines: Array = []; connect() { - const { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; + const { center, zoom, options, markers, polygons, polylines, fitBoundsToMarkers } = this.viewValue; this.dispatchEvent('pre-connect', { options }); @@ -89,6 +101,8 @@ export default abstract class< polygons.forEach((polygon) => this.createPolygon(polygon)); + polylines.forEach((polyline) => this.createPolyline(polyline)); + if (fitBoundsToMarkers) { this.doFitBoundsToMarkers(); } @@ -97,6 +111,7 @@ export default abstract class< map: this.map, markers: this.markers, polygons: this.polygons, + polylines: this.polylines, infoWindows: this.infoWindows, }); } @@ -129,18 +144,34 @@ export default abstract class< return polygon; } + createPolyline(definition: PolylineDefinition): Polyline { + this.dispatchEvent('polyline:before-create', { definition }); + const polyline = this.doCreatePolyline(definition); + this.dispatchEvent('polyline:after-create', { polyline }); + this.polylines.push(polyline); + return polyline; + } + protected abstract doCreateMarker(definition: MarkerDefinition): Marker; protected abstract doCreatePolygon(definition: PolygonDefinition): Polygon; - - protected createInfoWindow({ - definition, - element, - }: { - definition: - | MarkerDefinition['infoWindow'] - | PolygonDefinition['infoWindow']; - element: Marker | Polygon; - }): InfoWindow { + protected abstract doCreatePolyline(definition: PolylineDefinition): Polyline; + + protected abstract createInfoWindow( + args: + | { + definition: MarkerDefinition['infoWindow']; + element: Marker; + } + | { + definition: PolygonDefinition['infoWindow']; + element: Polygon; + } + | { + definition: PolylineDefinition['infoWindow']; + element: Polyline; + } + ): InfoWindow { + const { definition, element } = args; this.dispatchEvent('info-window:before-create', { definition, element }); const infoWindow = this.doCreateInfoWindow({ definition, element }); this.dispatchEvent('info-window:after-create', { infoWindow, element }); @@ -150,18 +181,21 @@ export default abstract class< return infoWindow; } - protected abstract doCreateInfoWindow({ - definition, - element, - }: - | { - definition: MarkerDefinition['infoWindow']; - element: Marker; - } - | { - definition: PolygonDefinition['infoWindow']; - element: Polygon; - }): InfoWindow; + protected abstract doCreateInfoWindow( + args: + | { + definition: MarkerDefinition['infoWindow']; + element: Marker; + } + | { + definition: PolygonDefinition['infoWindow']; + element: Polygon; + } + | { + definition: PolylineDefinition['infoWindow']; + element: Polyline; + } + ): 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 c9e0e38aeba..b1a20a713b7 100644 --- a/src/Map/assets/test/abstract_map_controller.test.ts +++ b/src/Map/assets/test/abstract_map_controller.test.ts @@ -35,6 +35,15 @@ class MyMapController extends AbstractMapController { return polygon; } + doCreatePolyline(definition) { + const polyline = { polyline: 'polyline', title: definition.title }; + + if (definition.infoWindow) { + this.createInfoWindow({ definition: definition.infoWindow, element: polyline }); + } + return polyline; + } + doCreateInfoWindow({ definition, element }) { if (element.marker) { return { infoWindow: 'infoWindow', headerContent: definition.headerContent, marker: element.title }; @@ -42,6 +51,9 @@ class MyMapController extends AbstractMapController { if (element.polygon) { return { infoWindow: 'infoWindow', headerContent: definition.headerContent, polygon: element.title }; } + if (element.polyline) { + return { infoWindow: 'infoWindow', headerContent: definition.headerContent, polyline: element.title }; + } } doFitBoundsToMarkers() { @@ -113,6 +125,32 @@ describe('AbstractMapController', () => { "autoClose": true } } + ], + "polylines": [ + { + "coordinates": [ + { "lat": 48.858844, "lng": 2.294351 }, + { "lat": 48.853, "lng": 2.3499 }, + { "lat": 48.8566, "lng": 2.3522 } + ], + "title": "Polyline 1", + "infoWindow": null + }, + { + "coordinates": [ + { "lat": 45.764043, "lng": 4.835659 }, + { "lat": 45.750000, "lng": 4.850000 }, + { "lat": 45.770000, "lng": 4.820000 } + ], + "title": "Polyline 2", + "infoWindow": { + "headerContent": "Polyline 2", + "content": "A polyline around Lyon with some additional info.", + "position": null, + "opened": false, + "autoClose": true + } + } ] }'> @@ -123,7 +161,7 @@ describe('AbstractMapController', () => { clearDOM(); }); - it('connect and create map, marker, polygon and info window', async () => { + it('connect and create map, marker, polygon, polyline and info window', async () => { const div = getByTestId(container, 'map'); expect(div).not.toHaveClass('connected'); @@ -140,6 +178,10 @@ describe('AbstractMapController', () => { { polygon: 'polygon', title: 'Polygon 1' }, { polygon: 'polygon', title: 'Polygon 2' }, ]); + expect(controller.polylines).toEqual([ + { polyline: 'polyline', title: 'Polyline 1' }, + { polyline: 'polyline', title: 'Polyline 2' }, + ]); expect(controller.infoWindows).toEqual([ { headerContent: 'Lyon', @@ -151,6 +193,11 @@ describe('AbstractMapController', () => { infoWindow: 'infoWindow', polygon: 'Polygon 2', }, + { + headerContent: 'Polyline 2', + infoWindow: 'infoWindow', + polyline: 'Polyline 2', + }, ]); }); }); diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst index c6d49cfdc7d..3ecbf47914c 100644 --- a/src/Map/doc/index.rst +++ b/src/Map/doc/index.rst @@ -82,7 +82,7 @@ You can set the center and zoom of the map using the ``center()`` and ``zoom()`` use Symfony\UX\Map\Map; use Symfony\UX\Map\Point; - + $myMap // Explicitly set the center and zoom ->center(new Point(46.903354, 1.888334)) @@ -130,6 +130,7 @@ You can add markers to a map using the ``addMarker()`` method:: ) ; + Add Polygons ~~~~~~~~~~~~ @@ -147,6 +148,23 @@ You can also add Polygons, which represents an area enclosed by a series of ``Po ), )); +Add Polylines +~~~~~~~~~~~~~ + +You can add Polylines, which represents a path made by a series of `Point` instances + $myMap->addPolyline(new Polyline( + 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: 'A line passing through Paris, Lyon, Marseille, Bordeaux', + ), + )); + + Render a map ------------ 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..727c4e216f9 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, PolygonDefinition } from '@symfony/ux-map'; +import type { Point, MarkerDefinition, PolygonDefinition, PolylineDefinition } 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; }; @@ -16,9 +16,10 @@ export default class extends AbstractMapController): google.maps.marker.AdvancedMarkerElement; protected doCreatePolygon(definition: PolygonDefinition): google.maps.Polygon; + protected doCreatePolyline(definition: PolylineDefinition): google.maps.Polyline; protected doCreateInfoWindow({ definition, element, }: { - definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; - element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon; + definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow'] | PolylineDefinition['infoWindow']; + element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon | google.maps.Polyline; }): 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 30fbe283118..a2bee153b3b 100644 --- a/src/Map/src/Bridge/Google/assets/dist/map_controller.js +++ b/src/Map/src/Bridge/Google/assets/dist/map_controller.js @@ -7,13 +7,15 @@ let default_1$1 = class default_1 extends Controller { this.markers = []; this.infoWindows = []; this.polygons = []; + this.polylines = []; } connect() { - const { center, zoom, options, markers, polygons, fitBoundsToMarkers } = this.viewValue; + const { center, zoom, options, markers, polygons, polylines, 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)); + polylines.forEach((polyline) => this.createPolyline(polyline)); if (fitBoundsToMarkers) { this.doFitBoundsToMarkers(); } @@ -21,6 +23,7 @@ let default_1$1 = class default_1 extends Controller { map: this.map, markers: this.markers, polygons: this.polygons, + polylines: this.polylines, infoWindows: this.infoWindows, }); } @@ -38,7 +41,15 @@ let default_1$1 = class default_1 extends Controller { this.polygons.push(polygon); return polygon; } - createInfoWindow({ definition, element, }) { + createPolyline(definition) { + this.dispatchEvent('polyline:before-create', { definition }); + const polyline = this.doCreatePolyline(definition); + this.dispatchEvent('polyline:after-create', { polyline }); + this.polylines.push(polyline); + return polyline; + } + createInfoWindow(args) { + const { definition, element } = args; this.dispatchEvent('info-window:before-create', { definition, element }); const infoWindow = this.doCreateInfoWindow({ definition, element }); this.dispatchEvent('info-window:after-create', { infoWindow, element }); @@ -121,6 +132,21 @@ class default_1 extends default_1$1 { } return polygon; } + doCreatePolyline(definition) { + const { points, title, infoWindow, rawOptions = {} } = definition; + const polyline = new _google.maps.Polyline({ + ...rawOptions, + path: points, + map: this.map, + }); + if (title) { + polyline.set('title', title); + } + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polyline }); + } + return polyline; + } doCreateInfoWindow({ definition, element, }) { const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition; const infoWindow = new _google.maps.InfoWindow({ @@ -140,7 +166,7 @@ class default_1 extends default_1$1 { infoWindow.open({ map: this.map, anchor: element }); } } - else if (element instanceof google.maps.Polygon) { + else if (element instanceof google.maps.Polygon || element instanceof google.maps.Polyline) { element.addListener('click', (event) => { if (definition.autoClose) { this.closeInfoWindowsExcept(infoWindow); 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..9e5f8762d6f 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, PolygonDefinition } from '@symfony/ux-map'; +import type { Point, MarkerDefinition, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; import type { LoaderOptions } from '@googlemaps/js-api-loader'; import { Loader } from '@googlemaps/js-api-loader'; @@ -38,7 +38,9 @@ export default class extends AbstractMapController< google.maps.InfoWindowOptions, google.maps.InfoWindow, google.maps.PolygonOptions, - google.maps.Polygon + google.maps.Polygon, + google.maps.PolylineOptions, + google.maps.Polyline > { static values = { providerOptions: Object, @@ -153,6 +155,28 @@ export default class extends AbstractMapController< return polygon; } + protected doCreatePolyline( + definition: PolylineDefinition + ): google.maps.Polyline { + const { points, title, infoWindow, rawOptions = {} } = definition; + + const polyline = new _google.maps.Polyline({ + ...rawOptions, + path: points, + map: this.map, + }); + + if (title) { + polyline.set('title', title); + } + + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polyline }); + } + + return polyline; + } + protected doCreateInfoWindow({ definition, element, @@ -162,8 +186,9 @@ export default class extends AbstractMapController< google.maps.marker.AdvancedMarkerElementOptions, google.maps.InfoWindowOptions >['infoWindow'] - | PolygonDefinition['infoWindow']; - element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon; + | PolygonDefinition['infoWindow'] + | PolylineDefinition['infoWindow']; + element: google.maps.marker.AdvancedMarkerElement | google.maps.Polygon | google.maps.Polyline; }): google.maps.InfoWindow { const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition; @@ -185,7 +210,7 @@ export default class extends AbstractMapController< if (definition.opened) { infoWindow.open({ map: this.map, anchor: element }); } - } else if (element instanceof google.maps.Polygon) { + } else if (element instanceof google.maps.Polygon || element instanceof google.maps.Polyline) { element.addListener('click', (event: any) => { if (definition.autoClose) { this.closeInfoWindowsExcept(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 f1b08abba5c..ec688f28c4a 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}}],"polygons":[]}" + 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":[]}polylines":[]}" > `); }); diff --git a/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php b/src/Map/src/Bridge/Google/tests/GoogleRendererTest.php index e688d0c89f3..5a3b167b267 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 6b32a8df45b..f5dd9682125 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, PolygonDefinition } from '@symfony/ux-map'; +import type { Point, MarkerDefinition, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; -import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions, PolygonOptions } from 'leaflet'; +import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions, PolygonOptions, PolylineOptions } 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, }: { @@ -20,9 +20,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, - }); - } - createMarker(definition) { - this.dispatchEvent('marker:before-create', { definition }); - const marker = this.doCreateMarker(definition); - this.dispatchEvent('marker:after-create', { marker }); - this.markers.push(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); - 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; - } -} -default_1.values = { - providerOptions: Object, - view: Object, -}; - -class map_controller extends default_1 { +class map_controller extends AbstractMapController { connect() { L.Marker.prototype.options.icon = L.divIcon({ html: '', @@ -103,6 +53,17 @@ class map_controller extends default_1 { } return polygon; } + doCreatePolyline(definition) { + const { points, title, infoWindow, rawOptions = {} } = definition; + const polyline = L.polyline(points, { ...rawOptions }).addTo(this.map); + if (title) { + polyline.bindPopup(title); + } + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polyline }); + } + return polyline; + } doCreateInfoWindow({ definition, element, }) { const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; element.bindPopup([headerContent, content].filter((x) => x).join('
'), { ...otherOptions, ...rawOptions }); 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..5a18bbb1491 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,14 @@ import AbstractMapController from '@symfony/ux-map'; -import type { Point, MarkerDefinition, PolygonDefinition } from '@symfony/ux-map'; +import type { Point, MarkerDefinition, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; import 'leaflet/dist/leaflet.min.css'; import * as L from 'leaflet'; -import type { MapOptions as LeafletMapOptions, MarkerOptions, PopupOptions, PolygonOptions } from 'leaflet'; +import type { + MapOptions as LeafletMapOptions, + MarkerOptions, + PopupOptions, + PolygonOptions, + PolylineOptions, +} from 'leaflet'; type MapOptions = Pick & { tileLayer: { url: string; attribution: string; options: Record }; @@ -16,7 +22,9 @@ export default class extends AbstractMapController< PopupOptions, typeof L.Popup, PolygonOptions, - typeof L.Polygon + typeof L.Polygon, + PolylineOptions, + typeof L.Polyline > { connect(): void { L.Marker.prototype.options.icon = L.divIcon({ @@ -87,12 +95,28 @@ export default class extends AbstractMapController< return polygon; } + protected doCreatePolyline(definition: PolylineDefinition): L.Polyline { + const { points, title, infoWindow, rawOptions = {} } = definition; + + const polyline = L.polyline(points, { ...rawOptions }).addTo(this.map); + + if (title) { + polyline.bindPopup(title); + } + + if (infoWindow) { + this.createInfoWindow({ definition: infoWindow, element: polyline }); + } + + return polyline; + } + protected doCreateInfoWindow({ definition, element, }: { - definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow']; - element: L.Marker | L.Polygon; + definition: MarkerDefinition['infoWindow'] | PolygonDefinition['infoWindow'] | PolylineDefinition['infoWindow']; + element: L.Marker | L.Polygon | L.Polyline; }): L.Popup { const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; 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..2fb368467e8 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 @@ -41,7 +41,7 @@ describe('LeafletController', () => { data-controller="check leaflet" style="height: 700px; margin: 10px" data-leaflet-provider-options-value="{}" - data-leaflet-view-value="{"center":{"lat":48.8566,"lng":2.3522},"zoom":4,"fitBoundsToMarkers":true,"options":{"tileLayer":{"url":"https:\/\/tile.openstreetmap.org\/{z}\/{x}\/{y}.png","attribution":"\u00a9 <a href=\"https:\/\/www.openstreetmap.org\/copyright\">OpenStreetMap<\/a>","options":{}}},"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":[]}" + data-leaflet-view-value="{"center":{"lat":48.8566,"lng":2.3522},"zoom":4,"fitBoundsToMarkers":true,"options":{"tileLayer":{"url":"https:\/\/tile.openstreetmap.org\/{z}\/{x}\/{y}.png","attribution":"\u00a9 <a href=\"https:\/\/www.openstreetmap.org\/copyright\">OpenStreetMap<\/a>","options":{}}},"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":[]}polylines":[]}" > `); }); diff --git a/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php b/src/Map/src/Bridge/Leaflet/tests/LeafletRendererTest.php index a23f103ec53..e59d27838b5 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 3ab240ae1e2..49e360776f6 100644 --- a/src/Map/src/Map.php +++ b/src/Map/src/Map.php @@ -35,8 +35,12 @@ public function __construct( * @var array */ private array $polygons = [], - ) { - } + + /** + * @var array + */ + private array $polylines = [], + ) {} public function getRendererName(): ?string { @@ -94,6 +98,12 @@ public function addPolygon(Polygon $polygon): self return $this; } + public function addPolyline(Polyline $polyline): self + { + $this->polylines[] = $polyline; + + return $this; + } public function toArray(): array { @@ -112,8 +122,9 @@ public function toArray(): array 'zoom' => $this->zoom, '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), + 'markers' => array_map(static fn(Marker $marker) => $marker->toArray(), $this->markers), + 'polygons' => array_map(static fn(Polygon $polygon) => $polygon->toArray(), $this->polygons), + 'polylines' => array_map(static fn(Polyline $polyline) => $polyline->toArray(), $this->polylines), ]; } @@ -123,6 +134,7 @@ public function toArray(): array * zoom?: float, * markers?: list, * polygons?: list, + * polylines?: list, * fitBoundsToMarkers?: bool, * options?: object, * } $map @@ -153,6 +165,12 @@ public static function fromArray(array $map): self } $map['polygons'] = array_map(Polygon::fromArray(...), $map['polygons']); + $map['polylines'] ??= []; + if (!\is_array($map['polylines'])) { + throw new InvalidArgumentException('The "polylines" parameter must be an array.'); + } + $map['polylines'] = array_map(Polygon::fromArray(...), $map['polylines']); + return new self(...$map); } } diff --git a/src/Map/src/Polygon.php b/src/Map/src/Polygon.php index 5d474346e7d..2339aff4ebb 100644 --- a/src/Map/src/Polygon.php +++ b/src/Map/src/Polygon.php @@ -33,6 +33,12 @@ public function __construct( /** * Convert the polygon to an array representation. + * @return array{ + * points: array, + * title: string|null, + * infoWindow: array|null, + * extra: object, + * } */ public function toArray(): array { diff --git a/src/Map/src/Polyline.php b/src/Map/src/Polyline.php new file mode 100644 index 00000000000..345441bc387 --- /dev/null +++ b/src/Map/src/Polyline.php @@ -0,0 +1,76 @@ + + * + * 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 polyline on a map. + * + * @author [Sylvain Blondeau] + */ +final readonly class Polyline +{ + /** + * @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 polyline to an array representation. + * @return array{ + * points: array, + * title: string|null, + * infoWindow: array|null, + * extra: object, + * } + */ + 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, + * } $polyline + * + * @internal + */ + public static function fromArray(array $polyline): self + { + if (!isset($polyline['points'])) { + throw new InvalidArgumentException('The "points" parameter is required.'); + } + $polyline['points'] = array_map(Point::fromArray(...), $polyline['points']); + + if (isset($polyline['infoWindow'])) { + $polyline['infoWindow'] = InfoWindow::fromArray($polyline['infoWindow']); + } + + return new self(...$polyline); + } +} diff --git a/src/Map/src/Twig/MapRuntime.php b/src/Map/src/Twig/MapRuntime.php index 8b9a1ca2036..6dca2c53b4e 100644 --- a/src/Map/src/Twig/MapRuntime.php +++ b/src/Map/src/Twig/MapRuntime.php @@ -15,6 +15,7 @@ use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; use Symfony\UX\Map\Polygon; +use Symfony\UX\Map\Polyline; use Symfony\UX\Map\Renderer\RendererInterface; use Twig\Extension\RuntimeExtensionInterface; @@ -34,12 +35,14 @@ public function __construct( * @param array $attributes * @param array $markers * @param array $polygons + * @param array $polylines */ public function renderMap( ?Map $map = null, array $attributes = [], ?array $markers = null, ?array $polygons = null, + ?array $polylines = null, ?array $center = null, ?float $zoom = null, ): string { @@ -58,6 +61,9 @@ public function renderMap( foreach ($polygons ?? [] as $polygons) { $map->addPolygon(Polygon::fromArray($polygons)); } + foreach ($polylines ?? [] as $polylines) { + $map->addPolyline(Polyline::fromArray($polylines)); + } if (null !== $center) { $map->center(Point::fromArray($center)); } @@ -70,7 +76,7 @@ public function renderMap( public function render(array $args = []): string { - $map = array_intersect_key($args, ['map' => 0, 'markers' => 0, 'polygons' => 0, 'center' => 1, 'zoom' => 2]); + $map = array_intersect_key($args, ['map' => 0, 'markers' => 1, 'polygons' => 2, 'polylines' => 3, 'center' => 4, 'zoom' => 5]); $attributes = array_diff_key($args, $map); return $this->renderMap(...$map, attributes: $attributes); diff --git a/src/Map/src/Twig/UXMapComponent.php b/src/Map/src/Twig/UXMapComponent.php index 39e362b34b9..a8801ad780d 100644 --- a/src/Map/src/Twig/UXMapComponent.php +++ b/src/Map/src/Twig/UXMapComponent.php @@ -14,6 +14,7 @@ use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; use Symfony\UX\Map\Polygon; +use Symfony\UX\Map\Polyline; /** * @author Simon André @@ -35,4 +36,8 @@ final class UXMapComponent * @var Polygon[] */ public array $polygons; + /** + * @var Polyline[] + */ + public array $polylines; } diff --git a/src/Map/tests/MapFactoryTest.php b/src/Map/tests/MapFactoryTest.php index fcff3b0539c..907c0fb72db 100644 --- a/src/Map/tests/MapFactoryTest.php +++ b/src/Map/tests/MapFactoryTest.php @@ -39,6 +39,13 @@ public function testFromArray(): void $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']); + + $this->assertCount(1, $polylines = $map->toArray()['polylines']); + $this->assertEquals($array['polylines'][0]['points'], $polylines[0]['points']); + $this->assertEquals($array['polylines'][0]['points'], $polylines[0]['points']); + $this->assertSame($array['polylines'][0]['title'], $polylines[0]['title']); + $this->assertSame($array['polylines'][0]['infoWindow']['headerContent'], $polylines[0]['infoWindow']['headerContent']); + $this->assertSame($array['polylines'][0]['infoWindow']['content'], $polylines[0]['infoWindow']['content']); } public function testFromArrayWithInvalidCenter(): void @@ -107,6 +114,30 @@ public function testFromArrayWithInvalidPolygon(): void Map::fromArray($array); } + public function testFromArrayWithInvalidPolylines(): void + { + $array = self::createMapArray(); + $array['polylines'] = 'invalid'; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "polylines" parameter must be an array.'); + Map::fromArray($array); + } + + public function testFromArrayWithInvalidPolyline(): void + { + $array = self::createMapArray(); + $array['polylines'] = [ + [ + 'invalid', + ], + ]; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The "points" parameter is required.'); + Map::fromArray($array); + } + private static function createMapArray(): array { return [ @@ -151,6 +182,29 @@ private static function createMapArray(): array ], ], ], + 'polylines' => [ + [ + 'points' => [ + [ + 'lat' => 48.858844, + 'lng' => 2.294351, + ], + [ + 'lat' => 48.853, + 'lng' => 2.3499, + ], + [ + 'lat' => 48.8566, + 'lng' => 2.3522, + ], + ], + 'title' => 'Polyline 1', + 'infoWindow' => [ + 'headerContent' => 'Polyline 1', + 'content' => 'Polyline 1', + ], + ], + ], ]; } } diff --git a/src/Map/tests/MapTest.php b/src/Map/tests/MapTest.php index 95703724466..afb400737dd 100644 --- a/src/Map/tests/MapTest.php +++ b/src/Map/tests/MapTest.php @@ -19,6 +19,7 @@ use Symfony\UX\Map\Marker; use Symfony\UX\Map\Point; use Symfony\UX\Map\Polygon; +use Symfony\UX\Map\Polyline; class MapTest extends TestCase { @@ -57,6 +58,7 @@ public function testZoomAndCenterCanBeOmittedIfFitBoundsToMarkers(): void 'options' => $array['options'], 'markers' => [], 'polygons' => [], + 'polylines' => [], ], $array); } @@ -76,6 +78,7 @@ public function testWithMinimumConfiguration(): void 'options' => $array['options'], 'markers' => [], 'polygons' => [], + 'polylines' => [], ], $array); } @@ -133,6 +136,30 @@ public function toArray(): array autoClose: true, ), )) + ->addPolyline(new Polyline( + points: [ + new Point(48.858844, 2.294351), + new Point(48.853, 2.3499), + new Point(48.8566, 2.3522), + ], + title: 'Polyline 1', + infoWindow: null, + )) + ->addPolyline(new Polyline( + points: [ + new Point(45.764043, 4.835659), + new Point(45.75, 4.85), + new Point(45.77, 4.82), + ], + title: 'Polyline 2', + infoWindow: new InfoWindow( + headerContent: 'Polyline 2', + content: 'A polyline around Lyon with some additional info.', + position: new Point(45.764, 4.8357), + opened: true, + autoClose: true, + ), + )) ; $array = $map->toArray(); @@ -212,6 +239,35 @@ public function toArray(): array 'extra' => $array['polygons'][1]['extra'], ], ], + 'polylines' => [ + [ + 'points' => [ + ['lat' => 48.858844, 'lng' => 2.294351], + ['lat' => 48.853, 'lng' => 2.3499], + ['lat' => 48.8566, 'lng' => 2.3522], + ], + 'title' => 'Polyline 1', + 'infoWindow' => null, + 'extra' => $array['polylines'][0]['extra'], + ], + [ + 'points' => [ + ['lat' => 45.764043, 'lng' => 4.835659], + ['lat' => 45.75, 'lng' => 4.85], + ['lat' => 45.77, 'lng' => 4.82], + ], + 'title' => 'Polyline 2', + 'infoWindow' => [ + 'headerContent' => 'Polyline 2', + 'content' => 'A polyline around Lyon with some additional info.', + 'position' => ['lat' => 45.764, 'lng' => 4.8357], + 'opened' => true, + 'autoClose' => true, + 'extra' => $array['polylines'][1]['infoWindow']['extra'], + ], + 'extra' => $array['polylines'][1]['extra'], + ], + ], ], $array); self::assertSame('roadmap', $array['options']->mapTypeId);