diff --git a/src/Map/.gitattributes b/src/Map/.gitattributes new file mode 100644 index 00000000000..97734d35229 --- /dev/null +++ b/src/Map/.gitattributes @@ -0,0 +1,7 @@ +/.gitattributes export-ignore +/.gitignore export-ignore +/.symfony.bundle.yaml export-ignore +/phpunit.xml.dist export-ignore +/assets/src export-ignore +/assets/test export-ignore +/tests export-ignore diff --git a/src/Map/.gitignore b/src/Map/.gitignore new file mode 100644 index 00000000000..30282084317 --- /dev/null +++ b/src/Map/.gitignore @@ -0,0 +1,4 @@ +vendor +composer.lock +.php_cs.cache +.phpunit.result.cache diff --git a/src/Map/.symfony.bundle.yaml b/src/Map/.symfony.bundle.yaml new file mode 100644 index 00000000000..6d9a74acb76 --- /dev/null +++ b/src/Map/.symfony.bundle.yaml @@ -0,0 +1,3 @@ +branches: ["2.x"] +maintained_branches: ["2.x"] +doc_dir: "doc" diff --git a/src/Map/CHANGELOG.md b/src/Map/CHANGELOG.md new file mode 100644 index 00000000000..0b528238410 --- /dev/null +++ b/src/Map/CHANGELOG.md @@ -0,0 +1,5 @@ +# CHANGELOG + +## 2.19.0 + +- Component added diff --git a/src/Map/LICENSE b/src/Map/LICENSE new file mode 100644 index 00000000000..3ed9f412ce5 --- /dev/null +++ b/src/Map/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Map/README.md b/src/Map/README.md new file mode 100644 index 00000000000..067879fa01b --- /dev/null +++ b/src/Map/README.md @@ -0,0 +1,16 @@ +# Symfony UX Map + +**EXPERIMENTAL** This component is currently experimental and is +likely to change, or even change drastically. + +Symfony UX Map integrates [Symfony Translation](https://symfony.com/doc/current/translation.html) for JavaScript. + +**This repository is a READ-ONLY sub-tree split**. See +https://github.com/symfony/ux to create issues or submit pull requests. + +## Resources + +- [Documentation](https://symfony.com/bundles/ux-map/current/index.html) +- [Report issues](https://github.com/symfony/ux/issues) and + [send Pull Requests](https://github.com/symfony/ux/pulls) + in the [main Symfony UX repository](https://github.com/symfony/ux) diff --git a/src/Map/assets/dist/google_maps_controller.d.ts b/src/Map/assets/dist/google_maps_controller.d.ts new file mode 100644 index 00000000000..afc320e0a86 --- /dev/null +++ b/src/Map/assets/dist/google_maps_controller.d.ts @@ -0,0 +1,57 @@ +/// +import { Controller } from '@hotwired/stimulus'; +type MarkerId = number; +export default class extends Controller { + static values: { + view: ObjectConstructor; + }; + viewValue: { + mapId: string | null; + center: null | { + lat: number; + lng: number; + }; + zoom: number; + gestureHandling: string; + backgroundColor: string; + disableDoubleClickZoom: boolean; + zoomControl: boolean; + zoomControlOptions: google.maps.ZoomControlOptions; + mapTypeControl: boolean; + mapTypeControlOptions: google.maps.MapTypeControlOptions; + streetViewControl: boolean; + streetViewControlOptions: google.maps.StreetViewControlOptions; + fullscreenControl: boolean; + fullscreenControlOptions: google.maps.FullscreenControlOptions; + markers: Array<{ + _id: MarkerId; + position: { + lat: number; + lng: number; + }; + title: string | null; + }>; + infoWindows: Array<{ + headerContent: string | null; + content: string | null; + position: { + lat: number; + lng: number; + }; + opened: boolean; + _markerId: MarkerId | null; + autoClose: boolean; + }>; + fitBoundsToMarkers: boolean; + }; + private loader; + private map; + private markers; + private infoWindows; + initialize(): void; + connect(): Promise; + private createTextOrElement; + private closeInfoWindowsExcept; + private dispatchEvent; +} +export {}; diff --git a/src/Map/assets/dist/google_maps_controller.js b/src/Map/assets/dist/google_maps_controller.js new file mode 100644 index 00000000000..09928d70394 --- /dev/null +++ b/src/Map/assets/dist/google_maps_controller.js @@ -0,0 +1,134 @@ +import { Controller } from '@hotwired/stimulus'; +import { Loader } from '@googlemaps/js-api-loader'; + +class default_1 extends Controller { + constructor() { + super(...arguments); + this.markers = new Map(); + this.infoWindows = []; + } + initialize() { + var _a; + const providerConfig = (_a = window.__symfony_ux_maps.providers) === null || _a === void 0 ? void 0 : _a.google_maps; + if (!providerConfig) { + throw new Error('Google Maps provider configuration is missing, did you forget to call `{{ ux_map_script_tags() }}`?'); + } + const loaderOptions = { + apiKey: providerConfig.key, + }; + this.dispatchEvent('init', { + loaderOptions, + }); + this.loader = new Loader(loaderOptions); + } + async connect() { + const { Map: GoogleMap, InfoWindow } = await this.loader.importLibrary('maps'); + const mapOptions = { + gestureHandling: this.viewValue.gestureHandling, + backgroundColor: this.viewValue.backgroundColor, + disableDoubleClickZoom: this.viewValue.disableDoubleClickZoom, + zoomControl: this.viewValue.zoomControl, + zoomControlOptions: this.viewValue.zoomControlOptions, + mapTypeControl: this.viewValue.mapTypeControl, + mapTypeControlOptions: this.viewValue.mapTypeControlOptions, + streetViewControl: this.viewValue.streetViewControl, + streetViewControlOptions: this.viewValue.streetViewControlOptions, + fullscreenControl: this.viewValue.fullscreenControl, + fullscreenControlOptions: this.viewValue.fullscreenControlOptions, + }; + if (this.viewValue.mapId) { + mapOptions.mapId = this.viewValue.mapId; + } + if (this.viewValue.center) { + mapOptions.center = this.viewValue.center; + } + if (this.viewValue.zoom) { + mapOptions.zoom = this.viewValue.zoom; + } + this.dispatchEvent('pre-connect', { + mapOptions, + }); + this.map = new GoogleMap(this.element, mapOptions); + if (this.viewValue.markers) { + const { AdvancedMarkerElement } = await this.loader.importLibrary('marker'); + this.viewValue.markers.forEach((markerConfiguration) => { + const marker = new AdvancedMarkerElement({ + position: markerConfiguration.position, + title: markerConfiguration.title, + map: this.map, + }); + this.markers.set(markerConfiguration._id, marker); + }); + if (this.viewValue.fitBoundsToMarkers) { + const bounds = new google.maps.LatLngBounds(); + this.markers.forEach((marker) => { + if (!marker.position) { + return; + } + bounds.extend(marker.position); + }); + this.map.fitBounds(bounds); + } + } + this.viewValue.infoWindows.forEach((infoWindowConfiguration) => { + const marker = infoWindowConfiguration._markerId + ? this.markers.get(infoWindowConfiguration._markerId) + : undefined; + const infoWindow = new InfoWindow({ + headerContent: this.createTextOrElement(infoWindowConfiguration.headerContent), + content: this.createTextOrElement(infoWindowConfiguration.content), + position: infoWindowConfiguration.position, + }); + this.infoWindows.push(infoWindow); + if (infoWindowConfiguration.opened) { + infoWindow.open({ + map: this.map, + shouldFocus: false, + anchor: marker, + }); + } + if (marker) { + marker.addListener('click', () => { + if (infoWindowConfiguration.autoClose) { + this.closeInfoWindowsExcept(infoWindow); + } + infoWindow.open({ + map: this.map, + anchor: marker, + }); + }); + } + }); + this.dispatchEvent('connect', { + map: this.map, + markers: this.markers, + infoWindows: this.infoWindows, + }); + } + createTextOrElement(content) { + if (!content) { + return null; + } + if (content.includes('<')) { + const div = document.createElement('div'); + div.innerHTML = content; + return div; + } + return content; + } + closeInfoWindowsExcept(infoWindow) { + this.infoWindows.forEach((otherInfoWindow) => { + if (otherInfoWindow !== infoWindow) { + otherInfoWindow.close(); + } + }); + } + dispatchEvent(name, payload) { + this.dispatch(name, { detail: payload, prefix: 'google-maps' }); + } +} +default_1.values = { + view: Object, +}; + +export { default_1 as default }; diff --git a/src/Map/assets/dist/leaflet_controller.d.ts b/src/Map/assets/dist/leaflet_controller.d.ts new file mode 100644 index 00000000000..edad1bcafac --- /dev/null +++ b/src/Map/assets/dist/leaflet_controller.d.ts @@ -0,0 +1,45 @@ +import { Controller } from '@hotwired/stimulus'; +import 'leaflet/dist/leaflet.min.css'; +import type { MarkerOptions } from 'leaflet'; +type MarkerId = number; +export default class extends Controller { + static values: { + view: ObjectConstructor; + }; + viewValue: { + center: null | { + lat: number; + lng: number; + }; + zoom: number | null; + tileLayer: { + url: string; + attribution: string; + } & Record; + fitBoundsToMarkers: boolean; + markers: Array<{ + _id: MarkerId; + position: { + lat: number; + lng: number; + }; + } & MarkerOptions>; + popups: Array<{ + _markerId: MarkerId | null; + content: string; + position: { + lat: number; + lng: number; + }; + opened: boolean; + autoClose: boolean; + }>; + }; + private map; + private markers; + private popups; + connect(): void; + private setupTileLayer; + private dispatchEvent; +} +export {}; diff --git a/src/Map/assets/dist/leaflet_controller.js b/src/Map/assets/dist/leaflet_controller.js new file mode 100644 index 00000000000..ef3ec84faee --- /dev/null +++ b/src/Map/assets/dist/leaflet_controller.js @@ -0,0 +1,101 @@ +import { Controller } from '@hotwired/stimulus'; +import 'leaflet/dist/leaflet.min.css'; +import L from 'leaflet'; + +/****************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ + +function __rest(s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) + t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) + t[p[i]] = s[p[i]]; + } + return t; +} + +class default_1 extends Controller { + constructor() { + super(...arguments); + this.markers = new Map(); + this.popups = []; + } + connect() { + const mapOptions = { + center: this.viewValue.center || undefined, + zoom: this.viewValue.zoom || undefined, + }; + this.dispatchEvent('pre-connect', { + mapOptions, + }); + this.map = L.map(this.element, mapOptions); + this.setupTileLayer(); + this.viewValue.markers.forEach((markerConfiguration) => { + const { _id, position } = markerConfiguration, options = __rest(markerConfiguration, ["_id", "position"]); + const marker = L.marker(position, options).addTo(this.map); + this.markers.set(_id, marker); + }); + this.viewValue.popups.forEach((popupConfiguration) => { + let popup; + if (popupConfiguration._markerId) { + const marker = this.markers.get(popupConfiguration._markerId); + if (!marker) { + return; + } + marker.bindPopup(popupConfiguration.content, { + autoClose: popupConfiguration.autoClose, + }); + popup = marker.getPopup(); + } + else { + popup = L.popup({ + content: popupConfiguration.content, + autoClose: popupConfiguration.autoClose, + }); + popup.setLatLng(popupConfiguration.position); + } + if (popupConfiguration.opened) { + popup.openOn(this.map); + } + this.popups.push(popup); + }); + if (this.viewValue.fitBoundsToMarkers) { + this.map.fitBounds(Array.from(this.markers.values()).map((marker) => { + const position = marker.getLatLng(); + return [position.lat, position.lng]; + })); + } + this.dispatchEvent('connect', { + map: this.map, + markers: this.markers, + popups: this.popups, + }); + } + setupTileLayer() { + const _a = this.viewValue.tileLayer, { url, attribution } = _a, options = __rest(_a, ["url", "attribution"]); + L.tileLayer(url, Object.assign({ attribution }, options)).addTo(this.map); + } + dispatchEvent(name, payload) { + this.dispatch(name, { detail: payload, prefix: 'leaflet' }); + } +} +default_1.values = { + view: Object, +}; + +export { default_1 as default }; diff --git a/src/Map/assets/package.json b/src/Map/assets/package.json new file mode 100644 index 00000000000..977349804b0 --- /dev/null +++ b/src/Map/assets/package.json @@ -0,0 +1,50 @@ +{ + "name": "@symfony/ux-map", + "description": "Symfony Map for JavaScript", + "license": "MIT", + "version": "1.0.0", + "symfony": { + "controllers": { + "google-maps": { + "main": "dist/google_maps_controller.js", + "webpackMode": "lazy", + "fetch": "lazy", + "enabled": false + }, + "leaflet": { + "main": "dist/leaflet_controller.js", + "webpackMode": "lazy", + "fetch": "lazy", + "enabled": false + } + }, + "importmap": { + "@hotwired/stimulus": "^3.0.0", + "@googlemaps/js-api-loader": "^1.16.6", + "leaflet": "^1.9.4", + "@symfony/ux-map/google-maps": "path:%PACKAGE%/dist/google_maps_controller.js", + "@symfony/ux-map/leaflet": "path:%PACKAGE%/dist/leaflet_controller.js" + } + }, + "peerDependencies": { + "@googlemaps/js-api-loader": "^1.16.6", + "@hotwired/stimulus": "^3.0.0", + "leaflet": "^1.9.4" + }, + "peerDependenciesMeta": { + "@googlemaps/js-api-loader": { + "optional": true + }, + "leaflet": { + "optional": true + } + }, + "devDependencies": { + "@googlemaps/js-api-loader": "^1.16.6", + "@hotwired/stimulus": "^3.0.0", + "@types/google.maps": "^3.55.9", + "@types/leaflet": "^1.9.12", + "happy-dom": "^14.12.3", + "leaflet": "^1.9.4" + } +} diff --git a/src/Map/assets/src/global.d.ts b/src/Map/assets/src/global.d.ts new file mode 100644 index 00000000000..ce8d7d79b19 --- /dev/null +++ b/src/Map/assets/src/global.d.ts @@ -0,0 +1,12 @@ +declare global { + interface Window { + __symfony_ux_maps?: { + providers?: { + google_maps?: { + key: string; + }; + leaflet?: Record; + }; + }; + } +} diff --git a/src/Map/assets/src/google_maps_controller.ts b/src/Map/assets/src/google_maps_controller.ts new file mode 100644 index 00000000000..b29c01d5ba1 --- /dev/null +++ b/src/Map/assets/src/google_maps_controller.ts @@ -0,0 +1,204 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Controller } from '@hotwired/stimulus'; +import type { LoaderOptions } from '@googlemaps/js-api-loader'; +import { Loader } from '@googlemaps/js-api-loader'; + +type MarkerId = number; + +export default class extends Controller { + static values = { + view: Object, + }; + + declare viewValue: { + mapId: string | null; + center: null | { lat: number; lng: number }; + zoom: number; + gestureHandling: string; + backgroundColor: string; + disableDoubleClickZoom: boolean; + zoomControl: boolean; + zoomControlOptions: google.maps.ZoomControlOptions; + mapTypeControl: boolean; + mapTypeControlOptions: google.maps.MapTypeControlOptions; + streetViewControl: boolean; + streetViewControlOptions: google.maps.StreetViewControlOptions; + fullscreenControl: boolean; + fullscreenControlOptions: google.maps.FullscreenControlOptions; + markers: Array<{ + _id: MarkerId; + position: { lat: number; lng: number }; + title: string | null; + }>; + infoWindows: Array<{ + headerContent: string | null; + content: string | null; + position: { lat: number; lng: number }; + opened: boolean; + _markerId: MarkerId | null; + autoClose: boolean; + }>; + fitBoundsToMarkers: boolean; + }; + + private loader: Loader; + private map: google.maps.Map; + private markers = new Map(); + private infoWindows: Array = []; + + initialize() { + const providerConfig = window.__symfony_ux_maps.providers?.google_maps; + if (!providerConfig) { + throw new Error( + 'Google Maps provider configuration is missing, did you forget to call `{{ ux_map_script_tags() }}`?' + ); + } + + const loaderOptions: LoaderOptions = { + apiKey: providerConfig.key, + }; + + this.dispatchEvent('init', { + loaderOptions, + }); + + this.loader = new Loader(loaderOptions); + } + + async connect() { + const { Map: GoogleMap, InfoWindow } = await this.loader.importLibrary('maps'); + + const mapOptions: google.maps.MapOptions = { + gestureHandling: this.viewValue.gestureHandling, + backgroundColor: this.viewValue.backgroundColor, + disableDoubleClickZoom: this.viewValue.disableDoubleClickZoom, + zoomControl: this.viewValue.zoomControl, + zoomControlOptions: this.viewValue.zoomControlOptions, + mapTypeControl: this.viewValue.mapTypeControl, + mapTypeControlOptions: this.viewValue.mapTypeControlOptions, + streetViewControl: this.viewValue.streetViewControl, + streetViewControlOptions: this.viewValue.streetViewControlOptions, + fullscreenControl: this.viewValue.fullscreenControl, + fullscreenControlOptions: this.viewValue.fullscreenControlOptions, + }; + + if (this.viewValue.mapId) { + mapOptions.mapId = this.viewValue.mapId; + } + + if (this.viewValue.center) { + mapOptions.center = this.viewValue.center; + } + + if (this.viewValue.zoom) { + mapOptions.zoom = this.viewValue.zoom; + } + + this.dispatchEvent('pre-connect', { + mapOptions, + }); + + this.map = new GoogleMap(this.element, mapOptions); + + if (this.viewValue.markers) { + const { AdvancedMarkerElement } = await this.loader.importLibrary('marker'); + + this.viewValue.markers.forEach((markerConfiguration) => { + const marker = new AdvancedMarkerElement({ + position: markerConfiguration.position, + title: markerConfiguration.title, + map: this.map, + }); + + this.markers.set(markerConfiguration._id, marker); + }); + + if (this.viewValue.fitBoundsToMarkers) { + const bounds = new google.maps.LatLngBounds(); + this.markers.forEach((marker) => { + if (!marker.position) { + return; + } + + bounds.extend(marker.position); + }); + this.map.fitBounds(bounds); + } + } + + this.viewValue.infoWindows.forEach((infoWindowConfiguration) => { + const marker = infoWindowConfiguration._markerId + ? this.markers.get(infoWindowConfiguration._markerId) + : undefined; + + const infoWindow = new InfoWindow({ + headerContent: this.createTextOrElement(infoWindowConfiguration.headerContent), + content: this.createTextOrElement(infoWindowConfiguration.content), + position: infoWindowConfiguration.position, + }); + + this.infoWindows.push(infoWindow); + + if (infoWindowConfiguration.opened) { + infoWindow.open({ + map: this.map, + shouldFocus: false, + anchor: marker, + }); + } + + if (marker) { + marker.addListener('click', () => { + if (infoWindowConfiguration.autoClose) { + this.closeInfoWindowsExcept(infoWindow); + } + + infoWindow.open({ + map: this.map, + anchor: marker, + }); + }); + } + }); + + this.dispatchEvent('connect', { + map: this.map, + markers: this.markers, + infoWindows: this.infoWindows, + }); + } + + private createTextOrElement(content: string | null): string | HTMLElement | null { + if (!content) { + return null; + } + + if (content.includes('<') /* we assume it's HTML if it includes "<" */) { + const div = document.createElement('div'); + div.innerHTML = content; + return div; + } + + return content; + } + + private closeInfoWindowsExcept(infoWindow: google.maps.InfoWindow) { + this.infoWindows.forEach((otherInfoWindow) => { + if (otherInfoWindow !== infoWindow) { + otherInfoWindow.close(); + } + }); + } + + private dispatchEvent(name: string, payload: any) { + this.dispatch(name, { detail: payload, prefix: 'google-maps' }); + } +} diff --git a/src/Map/assets/src/leaflet_controller.ts b/src/Map/assets/src/leaflet_controller.ts new file mode 100644 index 00000000000..24cd699f0bf --- /dev/null +++ b/src/Map/assets/src/leaflet_controller.ts @@ -0,0 +1,124 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import { Controller } from '@hotwired/stimulus'; +import 'leaflet/dist/leaflet.min.css'; +import type { Map as LeafletMap, MapOptions, Marker, MarkerOptions, Popup } from 'leaflet'; +import L from 'leaflet'; + +type MarkerId = number; + +export default class extends Controller { + static values = { + view: Object, + }; + + declare viewValue: { + center: null | { lat: number; lng: number }; + zoom: number | null; + tileLayer: { url: string; attribution: string } & Record; + fitBoundsToMarkers: boolean; + markers: Array< + { + _id: MarkerId; + position: { lat: number; lng: number }; + } & MarkerOptions + >; + popups: Array<{ + _markerId: MarkerId | null; + content: string; + position: { lat: number; lng: number }; + opened: boolean; + autoClose: boolean; + }>; + }; + + private map: LeafletMap; + private markers = new Map(); + private popups: Array = []; + + connect() { + const mapOptions: MapOptions = { + center: this.viewValue.center || undefined, + zoom: this.viewValue.zoom || undefined, + }; + + this.dispatchEvent('pre-connect', { + mapOptions, + }); + + this.map = L.map(this.element, mapOptions); + + this.setupTileLayer(); + + this.viewValue.markers.forEach((markerConfiguration) => { + const { _id, position, ...options } = markerConfiguration; + const marker = L.marker(position, options).addTo(this.map); + + this.markers.set(_id, marker); + }); + + this.viewValue.popups.forEach((popupConfiguration) => { + let popup: Popup; + if (popupConfiguration._markerId) { + const marker = this.markers.get(popupConfiguration._markerId); + if (!marker) { + return; + } + marker.bindPopup(popupConfiguration.content, { + autoClose: popupConfiguration.autoClose, + }); + popup = marker.getPopup()!; + } else { + popup = L.popup({ + content: popupConfiguration.content, + autoClose: popupConfiguration.autoClose, + }); + + popup.setLatLng(popupConfiguration.position); + } + + if (popupConfiguration.opened) { + popup.openOn(this.map); + } + + this.popups.push(popup); + }); + + if (this.viewValue.fitBoundsToMarkers) { + this.map.fitBounds( + Array.from(this.markers.values()).map((marker) => { + const position = marker.getLatLng(); + return [position.lat, position.lng]; + }) + ); + } + + this.dispatchEvent('connect', { + map: this.map, + markers: this.markers, + popups: this.popups, + }); + } + + private setupTileLayer() { + const { url, attribution, ...options } = this.viewValue.tileLayer; + + L.tileLayer(url, { + attribution, + ...options, + }).addTo(this.map); + } + + private dispatchEvent(name: string, payload: any) { + this.dispatch(name, { detail: payload, prefix: 'leaflet' }); + } +} diff --git a/src/Map/assets/test/google_maps_controller.test.ts b/src/Map/assets/test/google_maps_controller.test.ts new file mode 100644 index 00000000000..527b6af5007 --- /dev/null +++ b/src/Map/assets/test/google_maps_controller.test.ts @@ -0,0 +1,68 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Application, Controller } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import GoogleMapsController from '../src/google_maps_controller'; + +// Controller used to check the actual controller was properly booted +class CheckController extends Controller { + connect() { + this.element.addEventListener('google-maps:pre-connect', (event) => { + this.element.classList.add('pre-connected'); + }); + + this.element.addEventListener('google-maps:connect', (event) => { + this.element.classList.add('connected'); + }); + } +} + +const startStimulus = () => { + const application = Application.start(); + application.register('check', CheckController); + application.register('google-maps', GoogleMapsController); +}; + +describe('GoogleMapsController', () => { + let container; + + beforeEach(() => { + container = mountDOM(` +
+ `); + }); + + afterEach(() => { + clearDOM(); + }); + + it('connect' , async () => { + window.__symfony_ux_maps = { + providers: { + google_maps: { + key: '', + }, + }, + } + + const div = getByTestId(container, 'map'); + expect(div).not.toHaveClass('pre-connected'); + expect(div).not.toHaveClass('connected'); + + startStimulus(); + await waitFor(() => expect(div).toHaveClass('pre-connected')); + await waitFor(() => expect(div).toHaveClass('connected')); + }); +}); diff --git a/src/Map/assets/test/leaflet_controller.test.ts b/src/Map/assets/test/leaflet_controller.test.ts new file mode 100644 index 00000000000..7562397a6c7 --- /dev/null +++ b/src/Map/assets/test/leaflet_controller.test.ts @@ -0,0 +1,66 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { Application, Controller } from '@hotwired/stimulus'; +import { getByTestId, waitFor } from '@testing-library/dom'; +import { clearDOM, mountDOM } from '@symfony/stimulus-testing'; +import LeafletController from '../src/leaflet_controller'; + +// Controller used to check the actual controller was properly booted +class CheckController extends Controller { + connect() { + this.element.addEventListener('leaflet:pre-connect', (event) => { + this.element.classList.add('pre-connected'); + }); + + this.element.addEventListener('leaflet:connect', (event) => { + this.element.classList.add('connected'); + }); + } +} + +const startStimulus = () => { + const application = Application.start(); + application.register('check', CheckController); + application.register('leaflet', LeafletController); +}; + +describe('LeafletController', () => { + let container; + + beforeEach(() => { + container = mountDOM(` +
+ `); + }); + + afterEach(() => { + clearDOM(); + }); + + it('connect' , async () => { + window.__symfony_ux_maps = { + providers: { + leaflet: {}, + }, + } + + const div = getByTestId(container, 'map'); + expect(div).not.toHaveClass('pre-connected'); + expect(div).not.toHaveClass('connected'); + + startStimulus(); + await waitFor(() => expect(div).toHaveClass('pre-connected')); + await waitFor(() => expect(div).toHaveClass('connected')); + }); +}); diff --git a/src/Map/assets/vitest.config.js b/src/Map/assets/vitest.config.js new file mode 100644 index 00000000000..4b46e02732e --- /dev/null +++ b/src/Map/assets/vitest.config.js @@ -0,0 +1,17 @@ +import { defineConfig, mergeConfig } from 'vitest/config'; +import configShared from '../../../vitest.config.js' + +export default mergeConfig( + configShared, + defineConfig({ + resolve: { + alias: { + 'leaflet/dist/leaflet.min.css': require.resolve('leaflet/dist/leaflet.css'), + }, + }, + test: { + // We need a browser(-like) environment to run the tests + environment: 'happy-dom', + }, + }) +); diff --git a/src/Map/composer.json b/src/Map/composer.json new file mode 100644 index 00000000000..a782ae1c98c --- /dev/null +++ b/src/Map/composer.json @@ -0,0 +1,48 @@ +{ + "name": "symfony/ux-map", + "type": "symfony-bundle", + "description": "Easily embed interactive maps in your Symfony application", + "keywords": [ + "symfony-ux" + ], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Hugo Alliaume", + "email": "hugo@alliau.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "autoload": { + "psr-4": { + "Symfony\\UX\\Map\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Symfony\\UX\\Map\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.1", + "symfony/stimulus-bundle": "^2.18.12" + }, + "require-dev": { + "symfony/asset-mapper": "^6.4|^7.0", + "symfony/framework-bundle": "^5.4|^6.0|^7.0", + "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", + "symfony/twig-bundle": "^5.4|^6.0|^7.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0" + }, + "extra": { + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, + "minimum-stability": "dev" +} diff --git a/src/Map/config/asset_mapper.php b/src/Map/config/asset_mapper.php new file mode 100644 index 00000000000..233ca11e4b5 --- /dev/null +++ b/src/Map/config/asset_mapper.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +/* + * @author Hugo Alliaume + */ + +use Symfony\UX\Map\AssetMapper\ImportMap\Compiler\LeafletReplaceImagesAssetCompiler; +use Symfony\UX\Map\AssetMapper\ImportMap\Resolver\LeafletPackageResolver; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('ux_map.asset_mapper.leaflet_replace_images_compiler', LeafletReplaceImagesAssetCompiler::class) + ->args([ + service('logger'), + ]) + ->tag('asset_mapper.compiler') + ->tag('monolog.logger', ['channel' => 'asset_mapper']) + + ->set('ux_map.asset_mapper.importmap.resolver.leaflet_package_resolver', LeafletPackageResolver::class) + ->args([ + service('.inner'), + service('http_client')->nullOnInvalid(), + ]) + ->decorate('asset_mapper.importmap.resolver') + ; +}; diff --git a/src/Map/config/services.php b/src/Map/config/services.php new file mode 100644 index 00000000000..e2a64bddf96 --- /dev/null +++ b/src/Map/config/services.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +/* + * @author Hugo Alliaume + */ + +use Symfony\UX\Map\Configuration\Configuration; +use Symfony\UX\Map\Factory\MapFactory; +use Symfony\UX\Map\Factory\MapFactoryInterface; +use Symfony\UX\Map\Registry\MapRegistry; +use Symfony\UX\Map\Registry\MapRegistryInterface; +use Symfony\UX\Map\Twig\MapExtension; +use Symfony\UX\Map\Twig\MapRuntime; + +return static function (ContainerConfigurator $container): void { + $container->services() + ->set('ux_map.configuration', Configuration::class) + ->args([ + param('ux_map.config.providers'), + param('ux_map.config.maps'), + ]) + + ->set('ux_map.map_factory', MapFactory::class) + ->args([ + tagged_locator('ux_map.map_factory', 'name'), + service('ux_map.configuration'), + service('ux_map.map_registry'), + ]) + ->alias(MapFactoryInterface::class, 'ux_map.map_factory') + + ->set('ux_map.google_maps.map_factory', \Symfony\UX\Map\Provider\GoogleMaps\MapFactory::class) + ->tag('ux_map.map_factory', ['name' => 'google_maps']) + + ->set('ux_map.leaflet.map_factory', \Symfony\UX\Map\Provider\Leaflet\MapFactory::class) + ->tag('ux_map.map_factory', ['name' => 'leaflet']) + + ->set('ux_map.map_registry', MapRegistry::class) + ->alias(MapRegistryInterface::class, 'ux_map.map_registry') + + ->set('ux_map.twig_extension', MapExtension::class) + ->tag('twig.extension') + + ->set('ux_map.twig_runtime', MapRuntime::class) + ->args([ + service('stimulus.helper'), + service('ux_map.map_registry'), + service('ux_map.configuration'), + ]) + ->tag('twig.runtime') + ; +}; diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst new file mode 100644 index 00000000000..53a9c9ea9fb --- /dev/null +++ b/src/Map/doc/index.rst @@ -0,0 +1,382 @@ +Symfony UX Map +============== + +**EXPERIMENTAL** This component is currently experimental and is likely +to change, or even change drastically. + +Symfony UX Map is a Symfony bundle integrating interactive Maps in +Symfony applications. It is part of `the Symfony UX initiative`_. + +Installation +------------ + +.. caution:: + + Before you start, make sure you have `StimulusBundle configured in your app`_. + +Install the bundle using Composer and Symfony Flex: + +.. code-block:: terminal + + $ composer require symfony/ux-map + +If you're using WebpackEncore, install your assets and restart Encore (not +needed if you're using AssetMapper): + +.. code-block:: terminal + + $ npm install --force + $ npm run watch + + # or use yarn + $ yarn install --force + $ yarn watch + +After installing the bundle, ensure the line ``{{ ux_map_script_tags() }}`` is present in your Twig template, e.g.: + +.. code-block:: twig + + {% block javascripts %} + {% block importmap %}{{ importmap('app') }}{% endblock %} + {{ ux_map_script_tags() }} + {% endblock %} + +Usage +----- + +Configuration +~~~~~~~~~~~~~ + +Configuration is done in your ``config/packages/ux_map.yaml`` file, where you can define the providers and maps you want to use. + +Providers are the services that will be used to render the maps. +They can be configured with options that are specific to the provider, like the API key for Google Maps: + +.. code-block:: yaml + + ux_map: + providers: + google_maps: + provider: google_maps + options: + key: '%env(GOOGLE_MAPS_API_KEY)%' + + leaflet: + provider: leaflet + +Maps are the actual maps that will be rendered. +They are configured with the provider they will use, and can have options that are specific to the map, +like the center and zoom level: + +.. code-block:: yaml + + ux_map: + maps: + google_maps_map_1: + provider: google_maps + options: + center: [48.8566, 2.3522] + zoom: 12 + + leaflet_map: + provider: leaflet + +.. note:: + + Even if it is possible to render several maps with different providers, + it will not be possible to render two maps with two providers of the same type + but with a different configuration, since they will conflict. + + +Google Maps +~~~~~~~~~~~ + +To use Google Maps on your application, you need to enable the Google Maps controller in your ``assets/controllers.json``: + +.. code-block:: json + + { + "controllers": { + "@symfony/ux-map": { + "google-maps": { + "enabled": true, + "fetch": "lazy" + }, + "leaflet": { + "enabled": false, + "fetch": "lazy" + } + }, + }, + "entrypoints": [] + } + + +Then, you need to configure a new provider and a new map, in your ``config/packages/ux_map.yaml``: + +.. code-block:: yaml + + ux_map: + providers: + google_maps: + provider: google_maps + options: + key: '%env(GOOGLE_MAPS_API_KEY)%' + + maps: + # With the default options + google_maps_map_1: + provider: google_maps + + # With all supported options + google_maps_map_2: + provider: google_maps + options: + mapId: 'DEMO_MAP_ID' + center: [48.8566, 2.3522] + zoom: 12 + gestureHandling: auto + backgroundColor: '#f8f9fa' + enableDoubleClickZoom: true + zoomControl: true + mapTypeControl: true + streetViewControl: true + fullscreenControl: true + fitBoundsToMarkers: true + +Then, you must create the Map instance in your PHP code (e.g. in a controller):: + + namespace App\Controller; + + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\Routing\Attribute\Route; + use Symfony\UX\Map\Factory\MapFactoryInterface; + use Symfony\UX\Map\LatLng; + use Symfony\UX\Map\Provider\GoogleMaps; + + final class ContactController extends AbstractController + { + #[Route('/contact')] + public function __invoke(MapFactoryInterface $mapFactory): Response + { + // 1. The map is created with the factory, you must pass the map name you defined in the configuration (here 'google_maps_map_1'), + // you can also pass the map options as a second argument. + + /** @var GoogleMaps\Map $map */ + $map = $mapFactory->createMap('google_maps_map_1'); + + // 2. The map can be programmatically configured with a fluent API, you can change the center, zoom, configure controls, etc... + $map + ->setMapId("2b2d73ba4b8c7b41") + ->setCenter(new LatLng(46.903354, 1.888334)) + ->setZoom(6) + ->enableFitBoundsToMarkers() + ->enableStreetViewControl(false) + ->enableMapTypeControl(false) + ->setFullscreenControlOptions(new GoogleMaps\FullscreenControlOptions( + position: GoogleMaps\ControlPosition::BLOCK_START_INLINE_START, + )) + ->setZoomControlOptions(new GoogleMaps\ZoomControlOptions( + position: GoogleMaps\ControlPosition::BLOCK_START_INLINE_END, + )); + + // 3. You can add also add markers + $map + ->addMarker($paris = new GoogleMaps\Marker(position: new LatLng(48.8566, 2.3522), title: 'Paris')) + ->addMarker($lyon = new GoogleMaps\Marker(position: new LatLng(45.7640, 4.8357), title: 'Lyon')) + ->addMarker(new GoogleMaps\Marker(position: new LatLng(43.2965, 5.3698), title: 'Marseille')); + + // 4. You can also add info windows to the markers or to a position + $map + ->addInfoWindow(new GoogleMaps\InfoWindow( + headerContent: 'Paris', + content: "Capital of France, is a major European city and a world center for art, fashion, gastronomy and culture.", + marker: $paris, // Attach the info window to the marker, when the marker is clicked, the info window will open + opened: true, // Open the info window by default + )) + ->addInfoWindow(new GoogleMaps\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.', + marker: $lyon + )) + ->addInfoWindow(new GoogleMaps\InfoWindow( + headerContent: 'Strasbourg', + content: "The French town of Alsace is home to the European Parliament and the Council of Europe.", + position: new LatLng(48.5846, 7.7507), // Attach the info window to a position, not to a marker + )); + ; + + // 4. Finally, you must inject the map in your template to render it + return $this->render('contact/index.html.twig', [ + 'map' => $map, + ]); + } + } + +Finally, you can render the map in your Twig template: + +.. code-block:: twig + + {{ render_map(map) }} + + {# or with custom attributes #} + {{ render_map(map, { 'data-controller': 'my-map', style: 'height: 300px' }) }} + +If everything went well, you should see a map with markers and info windows in your page. + +Leaflet +~~~~~~~ + +To use Google Maps on your application, you need to enable the Leaflet controller in your ``assets/controllers.json``: + +.. code-block:: json + + { + "controllers": { + "@symfony/ux-map": { + "google-maps": { + "enabled": false, + "fetch": "lazy" + }, + "leaflet": { + "enabled": true, + "fetch": "lazy" + } + }, + }, + "entrypoints": [] + } + + +Then, you need to configure a new provider and a new map, in your ``config/packages/ux_map.yaml``: + +.. code-block:: yaml + + ux_map: + providers: + leaflet: + provider: leaflet + + maps: + # With the default options + leaflet_map_1: + provider: google_maps + + # With all supported options + leaflet_map_2: + provider: google_maps + options: + center: [48.8566, 2.3522] + zoom: 12 + tileLayer: + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png' + attribution: '© OpenStreetMap contributors' + options: + maxZoom: 19 + fitBoundsToMarkers: true + +Then, you must create the Map instance in your PHP code (e.g. in a controller):: + + namespace App\Controller; + + use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + use Symfony\Component\HttpFoundation\Response; + use Symfony\Component\Routing\Attribute\Route; + use Symfony\UX\Map\Factory\MapFactoryInterface; + use Symfony\UX\Map\LatLng; + use Symfony\UX\Map\Provider\Leaflet; + + final class ContactController extends AbstractController + { + #[Route('/contact')] + public function __invoke(MapFactoryInterface $mapFactory): Response + { + // 1. The map is created with the factory, you must pass the map name you defined in the configuration (here 'leaflet_map_1'), + // you can also pass the map options as a second argument. + + /** @var Leaflet\Map $map */ + $map = $mapFactory->createMap('leaflet_map_1'); + + // 2. The map can be programmatically configured with a fluent API, you can change the center, zoom, configure controls, etc... + $map + ->setCenter(new LatLng(46.903354, 1.888334)) + ->setZoom(6) + ->enableFitBoundsToMarkers(); + + // 3. You can add also add markers + $map + ->addMarker($paris = new Leaflet\Marker(position: new LatLng(48.8566, 2.3522), title: 'Paris')) + ->addMarker($lyon = new Leaflet\Marker(position: new LatLng(45.7640, 4.8357), title: 'Lyon')) + ->addMarker(new Leaflet\Marker(position: new LatLng(43.2965, 5.3698), title: 'Marseille')); + + // 4. You can also add popups to the markers or to a position + $map + ->addInfoWindow(new Leaflet\Popup( + content: "Paris, capital of France, is a major European city and a world center for art, fashion, gastronomy and culture.", + marker: $paris, // Attach the info window to the marker, when the marker is clicked, the info window will open + opened: true, // Open the info window by default + )) + ->addInfoWindow(new Leaflet\Popup( + content: 'Lyon, French town in the historic Rhône-Alpes region, located at the junction of the Rhône and Saône rivers.', + marker: $lyon + )) + ->addInfoWindow(new Leaflet\Popup( + content: "Strasbourg, French town of Alsace is home to the European Parliament and the Council of Europe.", + position: new LatLng(48.5846, 7.7507), // Attach the info window to a position, not to a marker + )); + ; + + // 4. Finally, you must inject the map in your template to render it + return $this->render('contact/index.html.twig', [ + 'map' => $map, + ]); + } + } + +Finally, you can render the map in your Twig template: + +.. code-block:: twig + + {{ render_map(map) }} + + {# or with custom attributes #} + {{ render_map(map, { 'data-controller': 'my-map', style: 'height: 300px' }) }} + +If everything went well, you should see a map with markers and popups in your page. + +.. _using-with-asset-mapper: + +Using with AssetMapper +---------------------- + +Using this library with AssetMapper is possible. + +When installing with AssetMapper, Flex will add a few new items to your ``importmap.php``:: + + '@symfony/ux-map/google-maps' => [ + 'path' => '@symfony/ux-map/google_maps_controller.js', + ], + '@symfony/ux-map/leaflet' => [ + 'path' => '@symfony/ux-map/leaflet_controller.js', + ], + '@googlemaps/js-api-loader' => [ + 'version' => '1.16.6', + ], + 'leaflet' => [ + 'version' => '1.9.4', + ], + 'leaflet/dist/leaflet.min.css' => [ + 'version' => '1.9.4', + 'type' => 'css', + ], + +Backward Compatibility promise +------------------------------ + +This bundle aims at following the same Backward Compatibility promise as +the Symfony framework: +https://symfony.com/doc/current/contributing/code/bc.html + +.. _`the Symfony UX initiative`: https://symfony.com/ux +.. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html diff --git a/src/Map/phpunit.xml.dist b/src/Map/phpunit.xml.dist new file mode 100644 index 00000000000..56e43f7c99d --- /dev/null +++ b/src/Map/phpunit.xml.dist @@ -0,0 +1,26 @@ + + + + + + ./src + + + + + + + + + + + tests + + + diff --git a/src/Map/src/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompiler.php b/src/Map/src/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompiler.php new file mode 100644 index 00000000000..cc2a979bccd --- /dev/null +++ b/src/Map/src/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompiler.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\AssetMapper\ImportMap\Compiler; + +use Psr\Log\LoggerInterface; +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface; +use Symfony\Component\AssetMapper\Exception\RuntimeException; +use Symfony\Component\AssetMapper\MappedAsset; +use Symfony\Component\Filesystem\Path; + +/** + * Replaces the image paths in the Leaflet library JavaScript code with their public path. + */ +final class LeafletReplaceImagesAssetCompiler implements AssetCompilerInterface +{ + /** + * https://regex101.com/r/n3fSEN/1. + */ + public const ASSETS_PATTERN = '/"(?P[^"]+?\.png)"/'; + + public function __construct( + private readonly ?LoggerInterface $logger = null, + ) { + } + + public function supports(MappedAsset $asset): bool + { + return str_ends_with($asset->sourcePath, 'vendor/leaflet/leaflet.index.js'); + } + + public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string + { + return preg_replace_callback(self::ASSETS_PATTERN, function ($matches) use ($asset, $assetMapper) { + try { + $resolvedSourcePath = Path::join(\dirname($asset->sourcePath), 'dist', 'images', $matches['asset']); + } catch (RuntimeException $e) { + $this->logger?->warning(sprintf('Error processing import in "%s": ', $asset->sourcePath).$e->getMessage()); + + return $matches[0]; + } + + $dependentAsset = $assetMapper->getAssetFromSourcePath($resolvedSourcePath); + + if (null === $dependentAsset) { + return $matches[0]; + } + + $asset->addDependency($dependentAsset); + $relativePath = $dependentAsset->publicPath; + + return "\"$relativePath\""; + }, $content); + } +} diff --git a/src/Map/src/AssetMapper/ImportMap/Resolver/LeafletPackageResolver.php b/src/Map/src/AssetMapper/ImportMap/Resolver/LeafletPackageResolver.php new file mode 100644 index 00000000000..3df8c1344d2 --- /dev/null +++ b/src/Map/src/AssetMapper/ImportMap/Resolver/LeafletPackageResolver.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\AssetMapper\ImportMap\Resolver; + +use Symfony\Component\AssetMapper\ImportMap\Resolver\PackageResolverInterface; +use Symfony\Component\Filesystem\Path; +use Symfony\Component\HttpClient\HttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\UX\Map\AssetMapper\ImportMap\Compiler\LeafletReplaceImagesAssetCompiler; + +/** + * PackageResolver decorator for Leaflet. + * + * Some files mentioned in Leaflet's JavaScript code could not be detected as extra files by the actual PackageResolver. + * Without this decorator, in the following code, the .png files won't be detected as extra files: + * + * // ... + * $i.extend({options:{iconUrl:"marker-icon.png",iconRetinaUrl:"marker-icon-2x.png",shadowUrl:"marker-shadow.png", + * // ... + */ +class LeafletPackageResolver implements PackageResolverInterface +{ + public function __construct( + private PackageResolverInterface $inner, + private ?HttpClientInterface $httpClient, + ) { + } + + public function resolvePackages(array $packagesToRequire): array + { + return $this->inner->resolvePackages($packagesToRequire); + } + + public function downloadPackages(array $importMapEntries, ?callable $progressCallback = null): array + { + $contents = $this->inner->downloadPackages($importMapEntries, $progressCallback); + + if (isset($contents['leaflet'])) { + $this->httpClient ??= HttpClient::create(); + $responses = []; + + preg_match_all(LeafletReplaceImagesAssetCompiler::ASSETS_PATTERN, $contents['leaflet']['content'], $leafletAssets); + + foreach ($leafletAssets['asset'] as $leafletAsset) { + $distPath = Path::join('dist', 'images', $leafletAsset); + $responses[] = $this->httpClient->request( + 'GET', + sprintf('https://cdn.jsdelivr.net/npm/leaflet@%s/%s', $importMapEntries['leaflet']->version, $distPath), + ['user_data' => ['dist_path' => $distPath]] + ); + } + + foreach ($responses as $response) { + $distPath = $response->getInfo('user_data')['dist_path']; + $contents['leaflet']['extraFiles'][$distPath] = $response->getContent(); + } + } + + return $contents; + } +} diff --git a/src/Map/src/Configuration/Configuration.php b/src/Map/src/Configuration/Configuration.php new file mode 100644 index 00000000000..fc084271609 --- /dev/null +++ b/src/Map/src/Configuration/Configuration.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Configuration; + +use Symfony\UX\Map\Exception\ConflictingMapProvidersOnSamePageException; +use Symfony\UX\Map\Exception\MapNotFoundException; +use Symfony\UX\Map\Exception\ProviderNotFoundException; + +final class Configuration +{ + /** @var array */ + private readonly array $maps; + + /** + * @param array> $providersConfig + * @param array> $mapsConfig + */ + public function __construct( + array $providersConfig, + array $mapsConfig, + ) { + $providers = []; + foreach ($providersConfig as $providerName => $providerConfig) { + $providers[$providerName] = new Provider( + $providerName, + $providerConfig['provider'], + $providerConfig['options'] ?? [], + ); + } + + $maps = []; + foreach ($mapsConfig as $mapName => $mapConfig) { + $maps[$mapName] = new Map( + $mapName, + $mapConfig['options'] ?? [], + $providers[$mapConfig['provider']] ?? throw new ProviderNotFoundException($mapConfig['provider']), + ); + } + $this->maps = $maps; + } + + public function getMap(string $mapName): Map + { + return $this->maps[$mapName] ?? throw new MapNotFoundException($mapName); + } + + /** + * @param array $mapNames + * + * @throws ConflictingMapProvidersOnSamePageException if providers conflict with each other + */ + public function validateSimultaneousMapsUsage(array $mapNames): void + { + $usedProviders = []; + + foreach ($mapNames as $mapName) { + $map = $this->getMap($mapName); + + if (!\in_array($map->provider, $usedProviders, true)) { + $usedProviders[] = $map->provider; + } + } + + foreach ($usedProviders as $provider) { + $similarProviders = array_filter($usedProviders, fn (Provider $usedProvider) => $provider->provider === $usedProvider->provider && $provider !== $usedProvider); + + if ($similarProviders) { + throw new ConflictingMapProvidersOnSamePageException($provider->name, array_map(fn (Provider $similarProvider) => $similarProvider->name, $similarProviders)); + } + } + } +} diff --git a/src/Map/src/Configuration/Map.php b/src/Map/src/Configuration/Map.php new file mode 100644 index 00000000000..bc40625303a --- /dev/null +++ b/src/Map/src/Configuration/Map.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Configuration; + +final class Map +{ + /** + * @param array $options + */ + public function __construct( + public readonly string $name, + public readonly array $options, + public readonly Provider $provider, + ) { + } +} diff --git a/src/Map/src/Configuration/Provider.php b/src/Map/src/Configuration/Provider.php new file mode 100644 index 00000000000..097032cee92 --- /dev/null +++ b/src/Map/src/Configuration/Provider.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Configuration; + +final class Provider +{ + /** + * @param array $options + */ + public function __construct( + public readonly string $name, + public readonly string $provider, + public readonly array $options, + ) { + } +} diff --git a/src/Map/src/DependencyInjection/Configuration.php b/src/Map/src/DependencyInjection/Configuration.php new file mode 100644 index 00000000000..f304b51f6dd --- /dev/null +++ b/src/Map/src/DependencyInjection/Configuration.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\DependencyInjection; + +use Symfony\Component\Config\Definition\Builder\TreeBuilder; +use Symfony\Component\Config\Definition\ConfigurationInterface; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; + +/** + * @author Hugo Alliaume + * + * @experimental + */ +class Configuration implements ConfigurationInterface +{ + private const PROVIDERS = [ + 'google_maps' => [ + 'available_options' => [ + 'mapId', + 'center', + 'zoom', + 'gestureHandling', + 'backgroundColor', + 'enableDoubleClickZoom', + 'zoomControl', + 'mapTypeControl', + 'streetViewControl', + 'fullscreenControl', + 'fitBoundsToMarkers', + ], + ], + 'leaflet' => [ + 'available_options' => [ + 'center', + 'zoom', + 'tileLayer', + 'fitBoundsToMarkers', + ], + ], + ]; + + public function getConfigTreeBuilder(): TreeBuilder + { + $treeBuilder = new TreeBuilder('ux_map'); + $rootNode = $treeBuilder->getRootNode(); + $rootNode + ->children() + ->arrayNode('providers') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->defaultValue([]) + ->arrayPrototype() + ->children() + ->scalarNode('provider') + ->isRequired() + ->validate() + ->ifNotInArray(array_keys(self::PROVIDERS)) + ->thenInvalid('The provider %s is not supported.') + ->end() + ->end() + ->arrayNode('options') + ->normalizeKeys(false) + ->defaultValue([]) + ->prototype('variable')->end() + ->end() + ->end() + ->validate() + ->ifTrue(function ($v) { return 'google_maps' === $v['provider'] && !isset($v['options']['key']); }) + ->thenInvalid('The "key" option is required for the "google_maps" provider.') + ->end() + ->end() + ->end() + + ->arrayNode('maps') + ->normalizeKeys(false) + ->useAttributeAsKey('name') + ->defaultValue([]) + ->arrayPrototype() + ->children() + ->scalarNode('provider')->isRequired()->end() + ->arrayNode('options') + ->normalizeKeys(false) + ->defaultValue([]) + ->prototype('variable') + ->end() + ->end() + ->end() + ->end() + ->end() + ->end() + + ->beforeNormalization() + ->always(function ($v) { + // Validate that the provider exists + foreach ($v['maps'] ?? [] as $mapName => $map) { + if (!isset($v['providers'][$map['provider']])) { + throw new InvalidArgumentException(sprintf('The provider "%s" for the map "%s" is not found, has it been correctly registered?', $map['provider'], $mapName)); + } + } + + foreach ($v['maps'] ?? [] as $map) { + $this->validateMapOptions($map, $v['providers'][$map['provider']]); + } + + return $v; + }) + ->end() + ; + + return $treeBuilder; + } + + private function validateMapOptions(array $map, array $provider): void + { + $availableOptions = self::PROVIDERS[$provider['provider']]['available_options'] ?? []; + $userOptions = array_keys($map['options'] ?? []); + $invalidOptions = array_diff($userOptions, $availableOptions); + + foreach ($invalidOptions as $invalidOption) { + $alternatives = []; + foreach ($availableOptions as $availableOption) { + $lev = levenshtein($invalidOption, $availableOption); + if ($lev <= \strlen($invalidOption) / 3 || str_contains($availableOption, $invalidOption)) { + $alternatives[] = $availableOption; + } + } + + if ($alternatives) { + throw new InvalidArgumentException(sprintf('The option "%s" is not supported for the provider "%s". Did you mean "%s"?', $invalidOption, $provider['provider'], implode('", "', $alternatives))); + } else { + throw new InvalidArgumentException(sprintf('The option "%s" is not supported for the provider "%s". Known options are "%s".', $invalidOption, $provider['provider'], implode('", "', $availableOptions))); + } + } + } +} diff --git a/src/Map/src/DependencyInjection/UXMapExtension.php b/src/Map/src/DependencyInjection/UXMapExtension.php new file mode 100644 index 00000000000..b88b8951c91 --- /dev/null +++ b/src/Map/src/DependencyInjection/UXMapExtension.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\DependencyInjection; + +use Symfony\Component\AssetMapper\AssetMapperInterface; +use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; +use Symfony\Component\HttpKernel\DependencyInjection\Extension; + +/** + * @author Hugo Alliaume + * + * @internal + * + * @experimental + */ +class UXMapExtension extends Extension implements PrependExtensionInterface +{ + public function load(array $configs, ContainerBuilder $container) + { + $configuration = new Configuration(); + $config = $this->processConfiguration($configuration, $configs); + + $loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config'))); + $loader->load('services.php'); + + if ($this->isAssetMapperAvailable($container)) { + $loader->load('asset_mapper.php'); + } + + $container->setParameter('ux_map.config.providers', $config['providers']); + $container->setParameter('ux_map.config.maps', $config['maps']); + } + + public function prepend(ContainerBuilder $container) + { + if (!$this->isAssetMapperAvailable($container)) { + return; + } + + $container->prependExtensionConfig('framework', [ + 'asset_mapper' => [ + 'paths' => [ + __DIR__.'/../../assets/dist' => '@symfony/ux-map', + ], + ], + ]); + } + + private function isAssetMapperAvailable(ContainerBuilder $container): bool + { + if (!interface_exists(AssetMapperInterface::class)) { + return false; + } + + // check that FrameworkBundle 6.3 or higher is installed + $bundlesMetadata = $container->getParameter('kernel.bundles_metadata'); + if (!isset($bundlesMetadata['FrameworkBundle'])) { + return false; + } + + return is_file($bundlesMetadata['FrameworkBundle']['path'].'/Resources/config/asset_mapper.php'); + } +} diff --git a/src/Map/src/Exception/ConflictingMapProvidersOnSamePageException.php b/src/Map/src/Exception/ConflictingMapProvidersOnSamePageException.php new file mode 100644 index 00000000000..ed3a53d0ced --- /dev/null +++ b/src/Map/src/Exception/ConflictingMapProvidersOnSamePageException.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +class ConflictingMapProvidersOnSamePageException extends RuntimeException implements Exception +{ + public function __construct(string $providerName, array $similarProvidersName) + { + parent::__construct(sprintf( + 'You cannot use the "%s" map provider on the same page as the following map providers: "%s", as their configuration will conflicts with each-other.', + $providerName, + implode('", "', $similarProvidersName) + )); + } +} diff --git a/src/Map/src/Exception/Exception.php b/src/Map/src/Exception/Exception.php new file mode 100644 index 00000000000..ac3ca93a93d --- /dev/null +++ b/src/Map/src/Exception/Exception.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +interface Exception extends \Throwable +{ +} diff --git a/src/Map/src/Exception/InvalidArgumentException.php b/src/Map/src/Exception/InvalidArgumentException.php new file mode 100644 index 00000000000..5529fa5f467 --- /dev/null +++ b/src/Map/src/Exception/InvalidArgumentException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +class InvalidArgumentException extends \InvalidArgumentException implements Exception +{ +} diff --git a/src/Map/src/Exception/MapNotFoundException.php b/src/Map/src/Exception/MapNotFoundException.php new file mode 100644 index 00000000000..ed3a54ec406 --- /dev/null +++ b/src/Map/src/Exception/MapNotFoundException.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +class MapNotFoundException extends \InvalidArgumentException implements Exception +{ + public function __construct(string $name) + { + parent::__construct(sprintf('Map "%s" is not found, has it been correctly configured?', $name)); + } +} diff --git a/src/Map/src/Exception/ProviderNotFoundException.php b/src/Map/src/Exception/ProviderNotFoundException.php new file mode 100644 index 00000000000..f00e94f3440 --- /dev/null +++ b/src/Map/src/Exception/ProviderNotFoundException.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +class ProviderNotFoundException extends \InvalidArgumentException implements Exception +{ + public function __construct(string $name) + { + parent::__construct(sprintf('Provider "%s" is not found, has it been correctly configured?', $name)); + } +} diff --git a/src/Map/src/Exception/RuntimeException.php b/src/Map/src/Exception/RuntimeException.php new file mode 100644 index 00000000000..69f94e14f06 --- /dev/null +++ b/src/Map/src/Exception/RuntimeException.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Exception; + +class RuntimeException extends \RuntimeException implements Exception +{ +} diff --git a/src/Map/src/Factory/MapFactory.php b/src/Map/src/Factory/MapFactory.php new file mode 100644 index 00000000000..ac045b395e7 --- /dev/null +++ b/src/Map/src/Factory/MapFactory.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Factory; + +use Psr\Container\ContainerInterface; +use Symfony\UX\Map\Configuration\Configuration; +use Symfony\UX\Map\Exception\ProviderNotFoundException; +use Symfony\UX\Map\MapInterface; +use Symfony\UX\Map\Registry\MapRegistryInterface; + +/** + * Creates a map based on the configuration, and registers it in the map registry. + */ +final class MapFactory implements MapFactoryInterface +{ + public function __construct( + private ContainerInterface $mapFactories, + private Configuration $configuration, + private MapRegistryInterface $mapRegistry, + ) { + } + + public function createMap(string $name, array $options = []): MapInterface + { + $mapConfig = $this->configuration->getMap($name); + + if (!$this->mapFactories->has($mapConfig->provider->provider)) { + throw new ProviderNotFoundException($mapConfig->provider->provider); + } + + $mapFactory = $this->mapFactories->get($mapConfig->provider->provider); + $map = $mapFactory->createMap($name, $options + $mapConfig->options); + + $this->mapRegistry->register($map); + + return $map; + } +} diff --git a/src/Map/src/Factory/MapFactoryInterface.php b/src/Map/src/Factory/MapFactoryInterface.php new file mode 100644 index 00000000000..e452cf52b9b --- /dev/null +++ b/src/Map/src/Factory/MapFactoryInterface.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Factory; + +use Symfony\UX\Map\MapInterface; + +interface MapFactoryInterface +{ + /** + * @param array $options + */ + public function createMap(string $name, array $options = []): MapInterface; +} diff --git a/src/Map/src/LatLng.php b/src/Map/src/LatLng.php new file mode 100644 index 00000000000..e47b17b42d4 --- /dev/null +++ b/src/Map/src/LatLng.php @@ -0,0 +1,46 @@ + + * + * 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 geographical point. + */ +final class LatLng +{ + public function __construct( + public readonly float $latitude, + public readonly float $longitude, + ) { + if ($latitude < -90 || $latitude > 90) { + throw new InvalidArgumentException(sprintf('Latitude must be between -90 and 90 degrees, "%s" given.', $latitude)); + } + + if ($longitude < -180 || $longitude > 180) { + throw new InvalidArgumentException(sprintf('Longitude must be between -180 and 180 degrees, "%s" given.', $longitude)); + } + } + + /** + * @return array{latitude: float, longitude: float} + */ + public function createView(): array + { + return [ + 'lat' => $this->latitude, + 'lng' => $this->longitude, + ]; + } +} diff --git a/src/Map/src/MapInterface.php b/src/Map/src/MapInterface.php new file mode 100644 index 00000000000..a9ac1450243 --- /dev/null +++ b/src/Map/src/MapInterface.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +interface MapInterface +{ + public static function getMainDataController(): string; + + public function getName(): string; + + public function setAttributes(array $attributes): self; + + public function getDataController(): ?string; + + /** + * @return array + */ + public function getAttributes(): array; + + /** + * @return array + */ + public function createView(): array; +} diff --git a/src/Map/src/MapTrait.php b/src/Map/src/MapTrait.php new file mode 100644 index 00000000000..e1c80a3b4ca --- /dev/null +++ b/src/Map/src/MapTrait.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map; + +trait MapTrait +{ + private string $name; + + /** + * @var array + */ + private $attributes = []; + + public function getName(): string + { + return $this->name; + } + + public function setAttributes(array $attributes): self + { + $this->attributes = $attributes; + + return $this; + } + + public function getDataController(): ?string + { + return $this->attributes['data-controller'] ?? null; + } + + public function getAttributes(): array + { + return $this->attributes; + } +} diff --git a/src/Map/src/Provider/GoogleMaps/ControlPosition.php b/src/Map/src/Provider/GoogleMaps/ControlPosition.php new file mode 100644 index 00000000000..5904a005efa --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/ControlPosition.php @@ -0,0 +1,160 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +/** + * Identifiers used to specify the placement of controls on the map. + * Controls are positioned relative to other controls in the same layout position. + * Controls that are added first are positioned closer to the edge of the map. + * Usage of "logical values" (see https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values) is recommended + * in order to be able to automatically support both left-to-right (LTR) and right-to-left (RTL) layout contexts. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#ControlPosition + */ +enum ControlPosition: int +{ + /** + * Equivalent to {@see self::BOTTOM_CENTER} in both LTR and RTL. + */ + case BLOCK_END_INLINE_CENTER = 24; + + /** + * Equivalent to {@see self::BOTTOM_RIGHT} in LTR, or {@see self::BOTTOM_LEFT} in RTL. + */ + case BLOCK_END_INLINE_END = 25; + + /** + * Equivalent to {@see self::BOTTOM_LEFT} in LTR, or {@see self::BOTTOM_RIGHT} in RTL. + */ + case BLOCK_END_INLINE_START = 23; + + /** + * Equivalent to {@see self::TOP_CENTER} in both LTR and RTL. + */ + case BLOCK_START_INLINE_CENTER = 15; + + /** + * Equivalent to {@see self::TOP_RIGHT} in LTR, or {@see self::TOP_LEFT} in RTL. + */ + case BLOCK_START_INLINE_END = 16; + + /** + * Equivalent to {@see self::TOP_LEFT} in LTR, or {@see self::TOP_RIGHT} in RTL. + */ + case BLOCK_START_INLINE_START = 14; + + /** + * Elements are positioned in the center of the bottom row. Consider using + * {@see self::BLOCK_END_INLINE_CENTER} instead. + */ + case BOTTOM_CENTER = 11; + + /** + * Elements are positioned in the bottom left and flow towards the middle. + * Elements are positioned to the right of the Google logo. Consider using + * {@see self::BLOCK_END_INLINE_START} instead. + */ + case BOTTOM_LEFT = 10; + + /** + * Elements are positioned in the bottom right and flow towards the middle. + * Elements are positioned to the left of the copyrights. Consider using + * {@see self::BLOCK_END_INLINE_END} instead. + */ + case BOTTOM_RIGHT = 12; + + /** + * Equivalent to {@see self::RIGHT_CENTER} in LTR, or {@see self::LEFT_CENTER} in RTL. + */ + case INLINE_END_BLOCK_CENTER = 21; + + /** + * Equivalent to {@see self::RIGHT_BOTTOM} in LTR, or {@see self::LEFT_BOTTOM} in RTL. + */ + case INLINE_END_BLOCK_END = 22; + + /** + * Equivalent to {@see self::RIGHT_TOP} in LTR, or {@see self::LEFT_TOP} in RTL. + */ + case INLINE_END_BLOCK_START = 20; + + /** + * Equivalent to {@see self::LEFT_CENTER} in LTR, or {@see self::RIGHT_CENTER} in RTL. + */ + case INLINE_START_BLOCK_CENTER = 17; + + /** + * Equivalent to {@see self::LEFT_BOTTOM} in LTR, or {@see self::RIGHT_BOTTOM} in RTL. + */ + case INLINE_START_BLOCK_END = 19; + + /** + * Equivalent to {@see self::LEFT_TOP} in LTR, or {@see self::RIGHT_TOP} in RTL. + */ + case INLINE_START_BLOCK_START = 18; + + /** + * Elements are positioned on the left, above bottom-left elements, and flow + * upwards. Consider using {@see self::INLINE_START_BLOCK_END} instead. + */ + case LEFT_BOTTOM = 6; + + /** + * Elements are positioned in the center of the left side. Consider using + * {@see self::INLINE_START_BLOCK_CENTER} instead. + */ + case LEFT_CENTER = 4; + + /** + * Elements are positioned on the left, below top-left elements, and flow + * downwards. Consider using {@see self::INLINE_START_BLOCK_START} instead. + */ + case LEFT_TOP = 5; + + /** + * Elements are positioned on the right, above bottom-right elements, and + * flow upwards. Consider using {@see self::INLINE_END_BLOCK_END} instead. + */ + case RIGHT_BOTTOM = 9; + + /** + * Elements are positioned in the center of the right side. Consider using + * {@see self::INLINE_END_BLOCK_CENTER} instead. + */ + case RIGHT_CENTER = 8; + + /** + * Elements are positioned on the right, below top-right elements, and flow + * downwards. Consider using {@see self::INLINE_END_BLOCK_START} instead. + */ + case RIGHT_TOP = 7; + + /** + * Elements are positioned in the center of the top row. Consider using + * {@see self::BLOCK_START_INLINE_CENTER} instead. + */ + case TOP_CENTER = 2; + + /** + * Elements are positioned in the top left and flow towards the middle. + * Consider using {@see self::BLOCK_START_INLINE_START} instead. + */ + case TOP_LEFT = 1; + + /** + * Elements are positioned in the top right and flow towards the middle. + * Consider using {@see self::BLOCK_START_INLINE_END} instead. + */ + case TOP_RIGHT = 3; +} diff --git a/src/Map/src/Provider/GoogleMaps/FullscreenControlOptions.php b/src/Map/src/Provider/GoogleMaps/FullscreenControlOptions.php new file mode 100644 index 00000000000..e740e0319c9 --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/FullscreenControlOptions.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +/** + * Options for the rendering of the fullscreen control. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#FullscreenControlOptions + */ +final class FullscreenControlOptions +{ + public function __construct( + public ControlPosition $position = ControlPosition::INLINE_END_BLOCK_START, + ) { + } + + public function createView(): array + { + return [ + 'position' => $this->position->value, + ]; + } +} diff --git a/src/Map/src/Provider/GoogleMaps/GestureHandling.php b/src/Map/src/Provider/GoogleMaps/GestureHandling.php new file mode 100644 index 00000000000..177b72a2d40 --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/GestureHandling.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +/** + * This setting controls how the API handles gestures on the map. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/map#MapOptions.gestureHandling + */ +enum GestureHandling: string +{ + /** + * Scroll events and one-finger touch gestures scroll the page, and do not zoom or pan the map. + * Two-finger touch gestures pan and zoom the map. + * Scroll events with a ctrl key or ⌘ key pressed zoom the map. + * In this mode the map cooperates with the page. + */ + case Cooperative = 'cooperative'; + + /** + * All touch gestures and scroll events pan or zoom the map. + */ + case Greedy = 'greedy'; + + /** + * The map cannot be panned or zoomed by user gestures. + */ + case None = 'none'; + + /** + * (default) Gesture handling is either cooperative or greedy, depending on whether the page is scrollable or in an iframe. + */ + case Auto = 'auto'; +} diff --git a/src/Map/src/Provider/GoogleMaps/InfoWindow.php b/src/Map/src/Provider/GoogleMaps/InfoWindow.php new file mode 100644 index 00000000000..6c398e232d0 --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/InfoWindow.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\LatLng; + +/** + * Represents an info window on a Google Maps map. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/info-window + */ +final class InfoWindow +{ + private readonly LatLng $position; + + public function __construct( + private readonly ?string $headerContent = null, + private readonly ?string $content = null, + ?LatLng $position = null, + private readonly ?Marker $marker = null, + private readonly bool $opened = false, + private readonly bool $autoClose = true, + ) { + if (null === $position && null === $marker) { + throw new InvalidArgumentException(sprintf('An "%s" must be associated with a position or a marker.', __CLASS__)); + } + + $this->position = $this->marker?->getPosition() ?? $position; + } + + public function createView(): array + { + return [ + 'headerContent' => $this->headerContent, + 'content' => $this->content, + 'position' => $this->position->createView(), + 'opened' => $this->opened, + 'autoClose' => $this->autoClose, + '_markerId' => $this->marker?->getId(), + ]; + } +} diff --git a/src/Map/src/Provider/GoogleMaps/Map.php b/src/Map/src/Provider/GoogleMaps/Map.php new file mode 100644 index 00000000000..2a8b387c202 --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/Map.php @@ -0,0 +1,203 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +use Symfony\UX\Map\LatLng; +use Symfony\UX\Map\MapInterface; +use Symfony\UX\Map\MapTrait; + +/** + * Represents a Google Maps map. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/map + */ +final class Map implements MapInterface +{ + use MapTrait; + + public static function getMainDataController(): string + { + return '@symfony/ux-map/google_maps'; + } + + /** + * @param array $markers + * @param array $infoWindows + */ + public function __construct( + private string $name, + // Google Maps specific options + private ?string $mapId = null, + private ?LatLng $center = null, + private ?float $zoom = null, + private GestureHandling $gestureHandling = GestureHandling::Auto, + private ?string $backgroundColor = null, + private bool $disableDoubleClickZoom = false, + private bool $zoomControl = true, + private ZoomControlOptions $zoomControlOptions = new ZoomControlOptions(), + private bool $mapTypeControl = true, + private MapTypeControlOptions $mapTypeControlOptions = new MapTypeControlOptions(), + private bool $streetViewControl = true, + private StreetViewControlOptions $streetViewControlOptions = new StreetViewControlOptions(), + private bool $fullscreenControl = true, + private FullscreenControlOptions $fullscreenControlOptions = new FullscreenControlOptions(), + // Custom options + private bool $fitBoundsToMarkers = false, + private array $markers = [], + private array $infoWindows = [], + ) { + } + + public function setMapId(string $mapId): self + { + $this->mapId = $mapId; + + return $this; + } + + public function setCenter(LatLng $center): self + { + $this->center = $center; + + return $this; + } + + public function setZoom(float $zoom): self + { + $this->zoom = $zoom; + + return $this; + } + + public function setGestureHandling(GestureHandling $gestureHandling): self + { + $this->gestureHandling = $gestureHandling; + + return $this; + } + + public function setBackgroundColor(string $backgroundColor): self + { + $this->backgroundColor = $backgroundColor; + + return $this; + } + + public function enableDoubleClickZoom(bool $enable = true): self + { + $this->disableDoubleClickZoom = !$enable; + + return $this; + } + + public function enableZoomControl(bool $enable = true): self + { + $this->zoomControl = $enable; + + return $this; + } + + public function setZoomControlOptions(ZoomControlOptions $zoomControlOptions): self + { + $this->zoomControlOptions = $zoomControlOptions; + + return $this; + } + + public function enableMapTypeControl(bool $enable = true): self + { + $this->mapTypeControl = $enable; + + return $this; + } + + public function setMapTypeControlOptions(MapTypeControlOptions $mapTypeControlOptions): self + { + $this->mapTypeControlOptions = $mapTypeControlOptions; + + return $this; + } + + public function enableStreetViewControl(bool $enable = true): self + { + $this->streetViewControl = $enable; + + return $this; + } + + public function setStreetViewControlOptions(StreetViewControlOptions $streetViewControlOptions): self + { + $this->streetViewControlOptions = $streetViewControlOptions; + + return $this; + } + + public function enableFullscreenControl(bool $enable = true): self + { + $this->fullscreenControl = $enable; + + return $this; + } + + public function setFullscreenControlOptions(FullscreenControlOptions $fullscreenControlOptions): self + { + $this->fullscreenControlOptions = $fullscreenControlOptions; + + return $this; + } + + public function enableFitBoundsToMarkers(bool $enable = true): self + { + $this->fitBoundsToMarkers = $enable; + + return $this; + } + + public function addMarker(Marker $marker): self + { + $this->markers[] = $marker; + + return $this; + } + + public function addInfoWindow(InfoWindow $infoWindow): self + { + $this->infoWindows[] = $infoWindow; + + return $this; + } + + public function createView(): array + { + return [ + 'mapId' => $this->mapId, + 'center' => $this->center?->createView(), + 'zoom' => $this->zoom, + 'gestureHandling' => $this->gestureHandling->value, + 'backgroundColor' => $this->backgroundColor, + 'disableDoubleClickZoom' => $this->disableDoubleClickZoom, + 'zoomControl' => $this->zoomControl, + 'zoomControlOptions' => $this->zoomControlOptions->createView(), + 'mapTypeControl' => $this->mapTypeControl, + 'mapTypeControlOptions' => $this->mapTypeControlOptions->createView(), + 'streetViewControl' => $this->streetViewControl, + 'streetViewControlOptions' => $this->streetViewControlOptions->createView(), + 'fullscreenControl' => $this->fullscreenControl, + 'fullscreenControlOptions' => $this->fullscreenControlOptions->createView(), + 'fitBoundsToMarkers' => $this->fitBoundsToMarkers, + 'markers' => array_map(fn (Marker $marker) => $marker->createView(), $this->markers), + 'infoWindows' => array_map(fn (InfoWindow $infoWindow) => $infoWindow->createView(), $this->infoWindows), + ]; + } +} diff --git a/src/Map/src/Provider/GoogleMaps/MapFactory.php b/src/Map/src/Provider/GoogleMaps/MapFactory.php new file mode 100644 index 00000000000..376cc8c090e --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/MapFactory.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +use Symfony\UX\Map\Factory\MapFactoryInterface; +use Symfony\UX\Map\LatLng; +use Symfony\UX\Map\MapInterface; + +/** + * Create a Google Maps map, with options. + */ +final class MapFactory implements MapFactoryInterface +{ + /** + * @param array{ + * mapId?: string, + * center?: array{float, float}, + * zoom?: numeric, + * gestureHandling?: bool, + * backgroundColor?: string, + * enableDoubleClickZoom?: bool, + * zoomControl?: bool, + * mapTypeControl?: bool, + * streetViewControl?: bool, + * fullscreenControl?: bool, + * fitBoundsToMarkers?: bool, + * } $options + */ + public function createMap(string $name, array $options = []): MapInterface + { + $map = new Map($name); + + if ($options['mapId'] ?? null) { + $map->setMapId($options['mapId']); + } + if ($options['center'] ?? null) { + $map->setCenter(new LatLng($options['center'][0], $options['center'][1])); + } + if ($options['zoom'] ?? null) { + $map->setZoom($options['zoom']); + } + if ($options['gestureHandling'] ?? null) { + $map->setGestureHandling(GestureHandling::from($options['gestureHandling'])); + } + if ($options['backgroundColor'] ?? null) { + $map->setBackgroundColor($options['backgroundColor']); + } + if ($options['enableDoubleClickZoom'] ?? null) { + $map->enableDoubleClickZoom($options['enableDoubleClickZoom']); + } + if ($options['zoomControl'] ?? null) { + $map->enableZoomControl($options['zoomControl']); + } + if ($options['mapTypeControl'] ?? null) { + $map->enableMapTypeControl($options['mapTypeControl']); + } + if ($options['streetViewControl'] ?? null) { + $map->enableStreetViewControl($options['streetViewControl']); + } + if ($options['fullscreenControl'] ?? null) { + $map->enableFullscreenControl($options['fullscreenControl']); + } + if ($options['fitBoundsToMarkers'] ?? null) { + $map->enableFitBoundsToMarkers($options['fitBoundsToMarkers']); + } + + return $map; + } +} diff --git a/src/Map/src/Provider/GoogleMaps/MapTypeControlOptions.php b/src/Map/src/Provider/GoogleMaps/MapTypeControlOptions.php new file mode 100644 index 00000000000..e3a2063b6da --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/MapTypeControlOptions.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +/** + * Options for the rendering of the map type control. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#MapTypeControlOptions + */ +final class MapTypeControlOptions +{ + /** + * @param array $mapTypeIds + */ + public function __construct( + public array $mapTypeIds = [], + public ControlPosition $position = ControlPosition::BLOCK_START_INLINE_START, + public MapTypeControlStyle $style = MapTypeControlStyle::DEFAULT, + ) { + } + + public function createView(): array + { + return [ + 'mapTypeIds' => array_map( + fn (MapTypeId|string $mapTypeId) => $mapTypeId instanceof MapTypeId ? $mapTypeId->value : $mapTypeId, + $this->mapTypeIds + ), + 'position' => $this->position->value, + 'style' => $this->style->value, + ]; + } +} diff --git a/src/Map/src/Provider/GoogleMaps/MapTypeControlStyle.php b/src/Map/src/Provider/GoogleMaps/MapTypeControlStyle.php new file mode 100644 index 00000000000..11fb810de3f --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/MapTypeControlStyle.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +/** + * Identifiers for common MapTypesControls. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#MapTypeControlStyle + */ +enum MapTypeControlStyle: int +{ + /** + * Uses the default map type control. When the DEFAULT control is shown, it will vary according to window size and other factors. + * The DEFAULT control may change in future versions of the API. + */ + case DEFAULT = 0; + + /** + * A dropdown menu for the screen realestate conscious. + */ + case DROPDOWN_MENU = 2; + + /** + * The standard horizontal radio buttons bar. + */ + case HORIZONTAL_BAR = 1; +} diff --git a/src/Map/src/Provider/GoogleMaps/MapTypeId.php b/src/Map/src/Provider/GoogleMaps/MapTypeId.php new file mode 100644 index 00000000000..7ec592b9fba --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/MapTypeId.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +/** + * Identifiers for common MapTypes. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/map#MapTypeId + */ +enum MapTypeId: string +{ + /** + * This map type displays a transparent layer of major streets on satellite images. + */ + case HYBRID = 'hybrid'; + + /** + * This map type displays a normal street map. + */ + case ROADMAP = 'roadmap'; + + /** + * This map type displays satellite images. + */ + case SATELLITE = 'satellite'; + + /** + * This map type displays maps with physical features such as terrain and vegetation. + */ + case TERRAIN = 'terrain'; +} diff --git a/src/Map/src/Provider/GoogleMaps/Marker.php b/src/Map/src/Provider/GoogleMaps/Marker.php new file mode 100644 index 00000000000..a8792a49545 --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/Marker.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\Provider\GoogleMaps; + +use Symfony\UX\Map\LatLng; + +/** + * Represents a marker on a Google Maps map. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/advanced-markers + */ +final class Marker +{ + private int $id; + + public function __construct( + private readonly LatLng $position, + private readonly ?string $title = null, + ) { + $this->id = spl_object_id($this); + } + + public function getId(): int + { + return $this->id; + } + + public function getPosition(): LatLng + { + return $this->position; + } + + public function createView(): array + { + return [ + '_id' => $this->id, + 'position' => $this->position->createView(), + 'title' => $this->title, + ]; + } +} diff --git a/src/Map/src/Provider/GoogleMaps/StreetViewControlOptions.php b/src/Map/src/Provider/GoogleMaps/StreetViewControlOptions.php new file mode 100644 index 00000000000..71ec54ee1ed --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/StreetViewControlOptions.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +/** + * Options for the rendering of the Street View pegman control on the map. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#StreetViewControlOptions + */ +final class StreetViewControlOptions +{ + public function __construct( + public ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, + ) { + } + + public function createView(): array + { + return [ + 'position' => $this->position->value, + ]; + } +} diff --git a/src/Map/src/Provider/GoogleMaps/ZoomControlOptions.php b/src/Map/src/Provider/GoogleMaps/ZoomControlOptions.php new file mode 100644 index 00000000000..0f79634ab1c --- /dev/null +++ b/src/Map/src/Provider/GoogleMaps/ZoomControlOptions.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\GoogleMaps; + +/** + * Options for the rendering of the zoom control. + * + * @see https://developers.google.com/maps/documentation/javascript/reference/control#ZoomControlOptions + */ +final class ZoomControlOptions +{ + public function __construct( + public ControlPosition $position = ControlPosition::INLINE_END_BLOCK_END, + ) { + } + + public function createView(): array + { + return [ + 'position' => $this->position->value, + ]; + } +} diff --git a/src/Map/src/Provider/Leaflet/Map.php b/src/Map/src/Provider/Leaflet/Map.php new file mode 100644 index 00000000000..23b9a69e99b --- /dev/null +++ b/src/Map/src/Provider/Leaflet/Map.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\Leaflet; + +use Symfony\UX\Map\LatLng; +use Symfony\UX\Map\MapInterface; +use Symfony\UX\Map\MapTrait; + +/** + * Represents a Leaflet map. + * + * @see https://leafletjs.com/reference.html#map + */ +final class Map implements MapInterface +{ + use MapTrait; + + public static function getMainDataController(): string + { + return '@symfony/ux-map/leaflet'; + } + + /** + * @param array $markers + * @param array $popups + */ + public function __construct( + private string $name, + // Leaflet options + private TileLayer $tileLayer, + private ?LatLng $center = null, + private ?float $zoom = null, + // Custom options + private bool $fitBoundsToMarkers = false, + private array $markers = [], + private array $popups = [], + ) { + } + + public function setCenter(?LatLng $center): self + { + $this->center = $center; + + return $this; + } + + public function setZoom(?float $zoom): self + { + $this->zoom = $zoom; + + return $this; + } + + public function setTileLayer(TileLayer $tileLayer): self + { + $this->tileLayer = $tileLayer; + + return $this; + } + + public function enableFitBoundsToMarkers(bool $enable = true): self + { + $this->fitBoundsToMarkers = $enable; + + return $this; + } + + public function addMarker(Marker $marker): self + { + $this->markers[] = $marker; + + return $this; + } + + public function addPopup(Popup $popup): self + { + $this->popups[] = $popup; + + return $this; + } + + public function createView(): array + { + return [ + 'center' => $this->center?->createView(), + 'zoom' => $this->zoom, + 'tileLayer' => $this->tileLayer?->createView(), + 'fitBoundsToMarkers' => $this->fitBoundsToMarkers, + 'markers' => array_map(fn (Marker $marker) => $marker->createView(), $this->markers), + 'popups' => array_map(fn (Popup $popup) => $popup->createView(), $this->popups), + ]; + } +} diff --git a/src/Map/src/Provider/Leaflet/MapFactory.php b/src/Map/src/Provider/Leaflet/MapFactory.php new file mode 100644 index 00000000000..1508342095a --- /dev/null +++ b/src/Map/src/Provider/Leaflet/MapFactory.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\Leaflet; + +use Symfony\UX\Map\Factory\MapFactoryInterface; +use Symfony\UX\Map\LatLng; +use Symfony\UX\Map\MapInterface; + +/** + * Creates a Leaflet map, with options. + */ +final class MapFactory implements MapFactoryInterface +{ + /** + * @param array{ + * center?: array{float, float}, + * zoom?: numeric, + * tileLayer?: array{ url?: string, attribution?: string, options?: array }, + * fitBoundsToMarkers?: bool, + * } $options + */ + public function createMap(string $name, array $options = []): MapInterface + { + $tileLayer = array_merge([ + 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + 'attribution' => '© OpenStreetMap', + 'options' => [], + ], $options['tileLayer'] ?? []); + + $map = new Map( + $name, + tileLayer: new TileLayer($tileLayer['url'], $tileLayer['attribution'], $tileLayer['options']) + ); + + if ($options['center'] ?? null) { + $map->setCenter(new LatLng($options['center'][0], $options['center'][1])); + } + if ($options['zoom'] ?? null) { + $map->setZoom($options['zoom']); + } + if ($options['fitBoundsToMarkers'] ?? null) { + $map->enableFitBoundsToMarkers($options['fitBoundsToMarkers']); + } + + return $map; + } +} diff --git a/src/Map/src/Provider/Leaflet/Marker.php b/src/Map/src/Provider/Leaflet/Marker.php new file mode 100644 index 00000000000..08363fd857b --- /dev/null +++ b/src/Map/src/Provider/Leaflet/Marker.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\Leaflet; + +use Symfony\UX\Map\LatLng; + +/** + * Represents a marker on a Leaflet map. + * + * @see https://leafletjs.com/reference.html#marker + */ +final class Marker +{ + private int $id; + + public function __construct( + private readonly LatLng $position, + private readonly string $title = '', + private readonly bool $riseOnHover = false, + private readonly int $riseOffset = 250, + private readonly bool $draggable = false, + ) { + $this->id = spl_object_id($this); + } + + public function getId(): int + { + return $this->id; + } + + public function getPosition(): LatLng + { + return $this->position; + } + + public function createView(): array + { + return [ + '_id' => $this->id, + 'position' => $this->position->createView(), + 'title' => $this->title, + 'riseOnHover' => $this->riseOnHover, + 'riseOffset' => $this->riseOffset, + 'draggable' => $this->draggable, + ]; + } +} diff --git a/src/Map/src/Provider/Leaflet/Popup.php b/src/Map/src/Provider/Leaflet/Popup.php new file mode 100644 index 00000000000..892ecbbf989 --- /dev/null +++ b/src/Map/src/Provider/Leaflet/Popup.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\Provider\Leaflet; + +use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\LatLng; + +/** + * Represents a popup on a Leaflet map. + * + * @see https://leafletjs.com/reference.html#popup + */ +final class Popup +{ + private readonly LatLng $position; + + public function __construct( + private readonly string $content, + ?LatLng $position = null, + private readonly ?Marker $marker = null, + private readonly bool $opened = false, + private readonly bool $autoClose = true, + ) { + if (null === $position && null === $marker) { + throw new InvalidArgumentException(sprintf('An "%s" must be associated with a position or a marker.', __CLASS__)); + } + + $this->position = $this->marker?->getPosition() ?? $position; + } + + public function createView(): array + { + return [ + '_markerId' => $this->marker?->getId(), + 'content' => $this->content, + 'position' => $this->position->createView(), + 'opened' => $this->opened, + 'autoClose' => $this->autoClose, + ]; + } +} diff --git a/src/Map/src/Provider/Leaflet/TileLayer.php b/src/Map/src/Provider/Leaflet/TileLayer.php new file mode 100644 index 00000000000..4f6b16c34b4 --- /dev/null +++ b/src/Map/src/Provider/Leaflet/TileLayer.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Provider\Leaflet; + +/** + * Represents a tile layer for a Leaflet map. + * + * @see https://leafletjs.com/reference.html#tilelayer + */ +final class TileLayer +{ + public function __construct( + public string $url, + public string $attribution, + public array $options = [], + ) { + } + + public function createView(): array + { + return [ + ...$this->options, + 'url' => $this->url, + 'attribution' => $this->attribution, + ]; + } +} diff --git a/src/Map/src/Registry/MapRegistry.php b/src/Map/src/Registry/MapRegistry.php new file mode 100644 index 00000000000..e1385feae0b --- /dev/null +++ b/src/Map/src/Registry/MapRegistry.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Registry; + +use Symfony\Contracts\Service\ResetInterface; +use Symfony\UX\Map\MapInterface; + +final class MapRegistry implements MapRegistryInterface, ResetInterface +{ + /** + * @var array + */ + private array $maps = []; + + public function register(MapInterface $map): void + { + $this->maps[] = $map; + } + + public function all(): array + { + return $this->maps; + } + + public function reset(): void + { + $this->maps = []; + } +} diff --git a/src/Map/src/Registry/MapRegistryInterface.php b/src/Map/src/Registry/MapRegistryInterface.php new file mode 100644 index 00000000000..547e66eea53 --- /dev/null +++ b/src/Map/src/Registry/MapRegistryInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Registry; + +use Symfony\UX\Map\MapInterface; + +interface MapRegistryInterface +{ + public function register(MapInterface $map): void; + + /** + * @return iterable + */ + public function all(): array; +} diff --git a/src/Map/src/Twig/MapExtension.php b/src/Map/src/Twig/MapExtension.php new file mode 100644 index 00000000000..0cb30eaad66 --- /dev/null +++ b/src/Map/src/Twig/MapExtension.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Twig; + +use Twig\Extension\AbstractExtension; +use Twig\TwigFunction; + +final class MapExtension extends AbstractExtension +{ + public function getFunctions(): iterable + { + yield new TwigFunction('ux_map_script_tags', [MapRuntime::class, 'renderScriptTags'], ['is_safe' => ['html']]); + yield new TwigFunction('render_map', [MapRuntime::class, 'renderMap'], ['is_safe' => ['html']]); + } +} diff --git a/src/Map/src/Twig/MapRuntime.php b/src/Map/src/Twig/MapRuntime.php new file mode 100644 index 00000000000..4f2d23b72ea --- /dev/null +++ b/src/Map/src/Twig/MapRuntime.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Twig; + +use Symfony\UX\Map\Configuration\Configuration; +use Symfony\UX\Map\MapInterface; +use Symfony\UX\Map\Registry\MapRegistryInterface; +use Symfony\UX\StimulusBundle\Helper\StimulusHelper; +use Twig\Extension\RuntimeExtensionInterface; + +final class MapRuntime implements RuntimeExtensionInterface +{ + public function __construct( + private readonly StimulusHelper $stimulus, + private readonly MapRegistryInterface $mapRegistry, + private readonly Configuration $configuration, + ) { + } + + public function renderScriptTags(): string + { + if (!$maps = $this->mapRegistry->all()) { + return ''; + } + + $this->configuration->validateSimultaneousMapsUsage(array_map(fn (MapInterface $map) => $map->getName(), $maps)); + + $scriptTags = []; + + $jsConfig = ['providers' => []]; + foreach ($maps as $map) { + $mapConfig = $this->configuration->getMap($map->getName()); + + $jsConfig['providers'][$mapConfig->provider->provider] = (object) $mapConfig->provider->options; + } + $scriptTags[] = sprintf('', json_encode($jsConfig, flags: \JSON_THROW_ON_ERROR)); + + return implode("\n", $scriptTags); + } + + public function renderMap(MapInterface $map, array $attributes = []): string + { + $map->setAttributes($attributes + $map->getAttributes()); + + $controllers = []; + if ($map->getDataController()) { + $controllers[$map->getDataController()] = []; + } + $controllers[$map::getMainDataController()] = ['view' => $map->createView()]; + + $stimulusAttributes = $this->stimulus->createStimulusAttributes(); + foreach ($controllers as $name => $controllerValues) { + $stimulusAttributes->addController($name, $controllerValues); + } + + foreach ($map->getAttributes() as $name => $value) { + if ('data-controller' === $name) { + continue; + } + + if (true === $value) { + $stimulusAttributes->addAttribute($name, $name); + } elseif (false !== $value) { + $stimulusAttributes->addAttribute($name, $value); + } + } + + return sprintf('
', $stimulusAttributes); + } +} diff --git a/src/Map/src/UXMapBundle.php b/src/Map/src/UXMapBundle.php new file mode 100644 index 00000000000..263a89165ff --- /dev/null +++ b/src/Map/src/UXMapBundle.php @@ -0,0 +1,29 @@ + + * + * 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\Component\HttpKernel\Bundle\Bundle; + +/** + * @author Hugo Alliaume + * + * @final + * + * @experimental + */ +class UXMapBundle extends Bundle +{ + public function getPath(): string + { + return \dirname(__DIR__); + } +} diff --git a/src/Map/tests/ConfigurationTest.php b/src/Map/tests/ConfigurationTest.php new file mode 100644 index 00000000000..ffb81c09e83 --- /dev/null +++ b/src/Map/tests/ConfigurationTest.php @@ -0,0 +1,65 @@ + + * + * 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\Configuration\Configuration; +use Symfony\UX\Map\Exception\MapNotFoundException; + +final class ConfigurationTest extends TestCase +{ + private Configuration $configuration; + + protected function setUp(): void + { + $this->configuration = new Configuration( + [ + 'google_maps_provider' => [ + 'provider' => 'google_maps', + 'options' => [ + 'key' => 'my_key', + ], + ], + ], + [ + 'google_maps_map_1' => [ + 'provider' => 'google_maps_provider', + 'options' => [ + 'map_id' => 'DEMO_MAP_ID', + ], + ], + ] + ); + } + + public function testGetMap(): void + { + $config = $this->configuration->getMap('google_maps_map_1'); + + self::assertSame('google_maps_map_1', $config->name); + self::assertEquals(['map_id' => 'DEMO_MAP_ID'], $config->options); + + self::assertSame('google_maps', $config->provider->provider); + self::assertSame('google_maps_provider', $config->provider->name); + self::assertEquals(['key' => 'my_key'], $config->provider->options); + } + + public function testGetMapConfigWithUnknownMap(): void + { + $this->expectException(MapNotFoundException::class); + $this->expectExceptionMessage('Map "unknown_map" is not found, has it been correctly configured?'); + + $this->configuration->getMap('unknown_map'); + } +} diff --git a/src/Map/tests/Factory/MapFactoryTest.php b/src/Map/tests/Factory/MapFactoryTest.php new file mode 100644 index 00000000000..75ac65e74e5 --- /dev/null +++ b/src/Map/tests/Factory/MapFactoryTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Factory; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ServiceLocator; +use Symfony\UX\Map\Configuration\Configuration; +use Symfony\UX\Map\Exception\MapNotFoundException; +use Symfony\UX\Map\Factory\MapFactory; +use Symfony\UX\Map\Factory\MapFactoryInterface; +use Symfony\UX\Map\MapInterface; +use Symfony\UX\Map\MapTrait; +use Symfony\UX\Map\Registry\MapRegistry; + +final class MapFactoryTest extends TestCase +{ + private MapFactoryInterface $fakeFactory; + private MapFactory $mapFactory; + private MapRegistry $mapRegistry; + + protected function setUp(): void + { + $this->fakeFactory = new class() implements MapFactoryInterface { + public string $passedName = ''; + public array $passedOptions = []; + + public function createMap(string $name, array $options = []): MapInterface + { + $this->passedName = $name; + $this->passedOptions = $options; + + return new class() implements MapInterface { + use MapTrait; + + public static function getMainDataController(): string + { + throw new \BadMethodCallException('Not implemented'); + } + + public function createView(): array + { + throw new \BadMethodCallException('Not implemented'); + } + }; + } + }; + + $this->mapFactory = new MapFactory( + new ServiceLocator([ + 'leaflet' => fn () => $this->fakeFactory, + 'google_maps' => fn () => $this->fakeFactory, + ]), + new Configuration([ + 'leaflet' => ['provider' => 'leaflet'], + 'google_maps' => ['provider' => 'google_maps'], + ], [ + 'map_1' => [ + 'provider' => 'leaflet', + ], + 'map_2' => [ + 'provider' => 'google_maps', + ], + 'map_3' => [ + 'provider' => 'google_maps', + 'options' => [ + 'mapId' => 'abcdefgh1234567890', + ], + ], + ]), + $this->mapRegistry = new MapRegistry(), + ); + + self::assertEmpty($this->mapRegistry->all()); + } + + public function testCreateMap(): void + { + $map = $this->mapFactory->createMap('map_1'); + + self::assertSame('map_1', $this->fakeFactory->passedName); + self::assertSame([], $this->fakeFactory->passedOptions); + self::assertContains($map, $this->mapRegistry->all()); + } + + public function testCreateMapWithCustomOptions(): void + { + $map = $this->mapFactory->createMap('map_3', ['zoom' => 10]); + + self::assertSame('map_3', $this->fakeFactory->passedName); + self::assertSame(['zoom' => 10, 'mapId' => 'abcdefgh1234567890'], $this->fakeFactory->passedOptions); + self::assertContains($map, $this->mapRegistry->all()); + } + + public function testCreateMapWithInvalidMap(): void + { + $this->expectException(MapNotFoundException::class); + $this->expectExceptionMessage('Map "foo_bar" is not found, has it been correctly configured?'); + + $this->mapFactory->createMap('foo_bar'); + } +} diff --git a/src/Map/tests/Kernel/AppKernelTrait.php b/src/Map/tests/Kernel/AppKernelTrait.php new file mode 100644 index 00000000000..593759b5f12 --- /dev/null +++ b/src/Map/tests/Kernel/AppKernelTrait.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Kernel; + +/** + * @author Hugo Alliaume + * + * @internal + */ +trait AppKernelTrait +{ + public function getCacheDir(): string + { + return $this->createTmpDir('cache'); + } + + public function getLogDir(): string + { + return $this->createTmpDir('logs'); + } + + private function createTmpDir(string $type): string + { + $dir = sys_get_temp_dir().'/map_bundle/'.uniqid($type.'_', true); + + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + + return $dir; + } +} diff --git a/src/Map/tests/Kernel/FrameworkAppKernel.php b/src/Map/tests/Kernel/FrameworkAppKernel.php new file mode 100644 index 00000000000..241b0aa5ddc --- /dev/null +++ b/src/Map/tests/Kernel/FrameworkAppKernel.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Kernel; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\Map\UXMapBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; + +/** + * @author Hugo Alliaume + * + * @internal + */ +class FrameworkAppKernel extends Kernel +{ + use AppKernelTrait; + + public function registerBundles(): iterable + { + return [new FrameworkBundle(), new StimulusBundle(), new UXMapBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); + }); + } +} diff --git a/src/Map/tests/Kernel/TwigAppKernel.php b/src/Map/tests/Kernel/TwigAppKernel.php new file mode 100644 index 00000000000..efcebb6c750 --- /dev/null +++ b/src/Map/tests/Kernel/TwigAppKernel.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Kernel; + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\Map\UXMapBundle; +use Symfony\UX\StimulusBundle\StimulusBundle; + +/** + * @author Hugo Alliaume + * + * @internal + */ +class TwigAppKernel extends Kernel +{ + use AppKernelTrait; + + public function registerBundles(): iterable + { + return [new FrameworkBundle(), new StimulusBundle(), new TwigBundle(), new UXMapBundle()]; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', ['secret' => '$ecret', 'test' => true, 'http_method_override' => false]); + $container->loadFromExtension('twig', ['default_path' => __DIR__.'/templates', 'strict_variables' => true, 'exception_controller' => null]); + + $container->setAlias('test.ux_map.map_factory', 'ux_map.map_factory')->setPublic(true); + }); + } +} diff --git a/src/Map/tests/LatLngTest.php b/src/Map/tests/LatLngTest.php new file mode 100644 index 00000000000..6c4e94f89df --- /dev/null +++ b/src/Map/tests/LatLngTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\LatLng; + +class LatLngTest extends TestCase +{ + public static function provideInvalidLatLng(): iterable + { + yield [91, 0, 'Latitude must be between -90 and 90 degrees, "91" given.']; + yield [-91, 0, 'Latitude must be between -90 and 90 degrees, "-91" given.']; + yield [0, 181, 'Longitude must be between -180 and 180 degrees, "181" given.']; + yield [0, -181, 'Longitude must be between -180 and 180 degrees, "-181" given.']; + } + + /** + * @dataProvider provideInvalidLatLng + */ + public function testInvalidLatLng(float $latitude, float $longitude, string $expectedExceptionMessage): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + new LatLng($latitude, $longitude); + } + + public function testLatLng(): void + { + $latLng = new LatLng(48.8566, 2.3522); + + self::assertSame(48.8566, $latLng->latitude); + self::assertSame(2.3522, $latLng->longitude); + } + + public function testCreateView(): void + { + $latLng = new LatLng(48.8566, 2.3533); + + self::assertSame(['lat' => 48.8566, 'lng' => 2.3533], $latLng->createView()); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/ControlPositionTest.php b/src/Map/tests/Provider/GoogleMaps/ControlPositionTest.php new file mode 100644 index 00000000000..0b528e19009 --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/ControlPositionTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\GoogleMaps\ControlPosition; + +class ControlPositionTest extends TestCase +{ + public function testEnumValues(): void + { + self::assertSame(24, ControlPosition::BLOCK_END_INLINE_CENTER->value); + self::assertSame(25, ControlPosition::BLOCK_END_INLINE_END->value); + self::assertSame(23, ControlPosition::BLOCK_END_INLINE_START->value); + self::assertSame(15, ControlPosition::BLOCK_START_INLINE_CENTER->value); + self::assertSame(16, ControlPosition::BLOCK_START_INLINE_END->value); + self::assertSame(14, ControlPosition::BLOCK_START_INLINE_START->value); + self::assertSame(11, ControlPosition::BOTTOM_CENTER->value); + self::assertSame(10, ControlPosition::BOTTOM_LEFT->value); + self::assertSame(12, ControlPosition::BOTTOM_RIGHT->value); + self::assertSame(21, ControlPosition::INLINE_END_BLOCK_CENTER->value); + self::assertSame(22, ControlPosition::INLINE_END_BLOCK_END->value); + self::assertSame(20, ControlPosition::INLINE_END_BLOCK_START->value); + self::assertSame(17, ControlPosition::INLINE_START_BLOCK_CENTER->value); + self::assertSame(19, ControlPosition::INLINE_START_BLOCK_END->value); + self::assertSame(18, ControlPosition::INLINE_START_BLOCK_START->value); + self::assertSame(6, ControlPosition::LEFT_BOTTOM->value); + self::assertSame(4, ControlPosition::LEFT_CENTER->value); + self::assertSame(5, ControlPosition::LEFT_TOP->value); + self::assertSame(9, ControlPosition::RIGHT_BOTTOM->value); + self::assertSame(8, ControlPosition::RIGHT_CENTER->value); + self::assertSame(7, ControlPosition::RIGHT_TOP->value); + self::assertSame(2, ControlPosition::TOP_CENTER->value); + self::assertSame(1, ControlPosition::TOP_LEFT->value); + self::assertSame(3, ControlPosition::TOP_RIGHT->value); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/FullscreenControlOptionsTest.php b/src/Map/tests/Provider/GoogleMaps/FullscreenControlOptionsTest.php new file mode 100644 index 00000000000..ed770864d99 --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/FullscreenControlOptionsTest.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\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\GoogleMaps\ControlPosition; +use Symfony\UX\Map\Provider\GoogleMaps\FullscreenControlOptions; + +class FullscreenControlOptionsTest extends TestCase +{ + public function testCreateView(): void + { + $options = new FullscreenControlOptions( + position: ControlPosition::BLOCK_END_INLINE_CENTER + ); + + self::assertSame([ + 'position' => ControlPosition::BLOCK_END_INLINE_CENTER->value, + ], $options->createView()); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/GestureHandlingTest.php b/src/Map/tests/Provider/GoogleMaps/GestureHandlingTest.php new file mode 100644 index 00000000000..02894d17d1f --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/GestureHandlingTest.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\GoogleMaps\GestureHandling; + +class GestureHandlingTest extends TestCase +{ + public function testEnumValues(): void + { + self::assertSame('cooperative', GestureHandling::Cooperative->value); + self::assertSame('greedy', GestureHandling::Greedy->value); + self::assertSame('none', GestureHandling::None->value); + self::assertSame('auto', GestureHandling::Auto->value); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/InfoWindowTest.php b/src/Map/tests/Provider/GoogleMaps/InfoWindowTest.php new file mode 100644 index 00000000000..ad050a8dcd6 --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/InfoWindowTest.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\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\LatLng; +use Symfony\UX\Map\Provider\GoogleMaps\InfoWindow; +use Symfony\UX\Map\Provider\GoogleMaps\Marker; + +class InfoWindowTest extends TestCase +{ + public function testConstructShouldThrowIfNoPositionOrMarkerIsPassed(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('An "Symfony\UX\Map\Provider\GoogleMaps\InfoWindow" must be associated with a position or a marker.'); + + new InfoWindow(); + } + + public function testMarkerPositionShouldTakePrecedenceOverPosition(): void + { + $infoWindow = new InfoWindow( + marker: new Marker(position: new LatLng(1, 2)), + position: new LatLng(3, 4) + ); + + $position = \Closure::bind(fn () => $infoWindow->position, $infoWindow, InfoWindow::class)(); + + self::assertEquals(new LatLng(1, 2), $position); + } + + public function testPositionFallbackToPositionIfNoMarkerIsPassed(): void + { + $infoWindow = new InfoWindow( + position: new LatLng(3, 4) + ); + + $position = \Closure::bind(fn () => $infoWindow->position, $infoWindow, InfoWindow::class)(); + + self::assertEquals(new LatLng(3, 4), $position); + } + + public function testCreateView(): void + { + $infoWindow = new InfoWindow( + position: new LatLng(3, 4) + ); + + self::assertEquals([ + 'headerContent' => null, + 'content' => null, + 'position' => ['lat' => 3, 'lng' => 4], + 'opened' => false, + 'autoClose' => true, + '_markerId' => null, + ], $infoWindow->createView()); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/MapFactoryTest.php b/src/Map/tests/Provider/GoogleMaps/MapFactoryTest.php new file mode 100644 index 00000000000..75d61b565b3 --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/MapFactoryTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\GoogleMaps\GestureHandling; +use Symfony\UX\Map\Provider\GoogleMaps\Map; +use Symfony\UX\Map\Provider\GoogleMaps\MapFactory; + +class MapFactoryTest extends TestCase +{ + public function testCreate(): void + { + $mapFactory = new MapFactory(); + + $map = $mapFactory->createMap('map_name'); + + self::assertInstanceOf(Map::class, $map); + self::assertSame('map_name', $map->getName()); + } + + public function testCreateWithCustomOptions(): void + { + $mapFactory = new MapFactory(); + + $map = $mapFactory->createMap('map_name', [ + 'mapId' => 'DEMO_MAP_ID', + 'center' => [37.7749, -122.4194], + 'zoom' => 3, + 'gestureHandling' => 'auto', + 'backgroundColor' => '#f8f9fa', + 'enableDoubleClickZoom' => true, + 'zoomControl' => true, + 'mapTypeControl' => true, + 'streetViewControl' => true, + 'fullscreenControl' => true, + 'fitBoundsToMarkers' => true, + ]); + + self::assertInstanceOf(Map::class, $map); + self::assertSame('map_name', $map->getName()); + + $view = $map->createView(); + + self::assertSame('DEMO_MAP_ID', $view['mapId']); + self::assertSame(['lat' => 37.7749, 'lng' => -122.4194], $view['center']); + self::assertSame(3.0, $view['zoom']); + self::assertSame(GestureHandling::Auto->value, $view['gestureHandling']); + self::assertSame('#f8f9fa', $view['backgroundColor']); + self::assertFalse($view['disableDoubleClickZoom']); + self::assertTrue($view['zoomControl']); + self::assertTrue($view['mapTypeControl']); + self::assertTrue($view['streetViewControl']); + self::assertTrue($view['fullscreenControl']); + self::assertTrue($view['fitBoundsToMarkers']); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/MapTest.php b/src/Map/tests/Provider/GoogleMaps/MapTest.php new file mode 100644 index 00000000000..c974c5a0a49 --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/MapTest.php @@ -0,0 +1,201 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\LatLng; +use Symfony\UX\Map\Provider\GoogleMaps\ControlPosition; +use Symfony\UX\Map\Provider\GoogleMaps\FullscreenControlOptions; +use Symfony\UX\Map\Provider\GoogleMaps\GestureHandling; +use Symfony\UX\Map\Provider\GoogleMaps\InfoWindow; +use Symfony\UX\Map\Provider\GoogleMaps\Map; +use Symfony\UX\Map\Provider\GoogleMaps\MapTypeControlOptions; +use Symfony\UX\Map\Provider\GoogleMaps\MapTypeControlStyle; +use Symfony\UX\Map\Provider\GoogleMaps\MapTypeId; +use Symfony\UX\Map\Provider\GoogleMaps\Marker; +use Symfony\UX\Map\Provider\GoogleMaps\StreetViewControlOptions; +use Symfony\UX\Map\Provider\GoogleMaps\ZoomControlOptions; + +class MapTest extends TestCase +{ + public function testCreateViewWithDefaultOptions(): void + { + $map = new Map('map_name'); + + self::assertEquals([ + 'mapId' => null, + 'center' => null, + 'zoom' => null, + '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, + ], + 'fitBoundsToMarkers' => false, + 'markers' => [], + 'infoWindows' => [], + ], $map->createView()); + } + + public function testCreateViewWithCustomOptions(): void + { + $map = (new Map( + name: 'map_name', + )) + ->setCenter(new LatLng(48.8566, 2.3522)) + ->setZoom(12) + ->addMarker($paris = new Marker(position: new LatLng(48.8566, 2.3522), title: 'Paris')) + ->addMarker($lyon = new Marker(position: new LatLng(45.7640, 4.8357), title: 'Lyon')) + ->addMarker($marseille = new Marker(position: new LatLng(43.2965, 5.3698), title: 'Marseille')) + ->addInfoWindow(new InfoWindow( + headerContent: 'Paris', + content: "Capitale de la France, est une grande ville européenne et un centre mondial de l'art, de la mode, de la gastronomie et de la culture.", + marker: $paris, + )) + ->addInfoWindow(new InfoWindow( + headerContent: 'Lyon', + content: 'Ville française de la région historique Rhône-Alpes, se trouve à la jonction du Rhône et de la Saône.', + marker: $lyon + )) + ->addInfoWindow(new InfoWindow( + headerContent: 'Marseille', + content: 'Ville portuaire du sud de la France, est une ville cosmopolite qui a été un centre d\'échanges commerciaux et culturels depuis sa fondation par les Grecs vers 600 av. J.-C.', + marker: $marseille, + )) + ->addInfoWindow(new InfoWindow( + headerContent: 'Strasbourg', + content: 'Ville française située dans le Grand Est, est également le siège du Parlement européen.', + position: new LatLng(48.5734, 7.7521), + opened: true, + )) + ->setMapId('2b2d73ba4b8c7b41') + ->setGestureHandling(GestureHandling::Greedy) + ->setBackgroundColor('#f0f0f0') + ->enableDoubleClickZoom(false) + ->enableZoomControl(false) + ->setZoomControlOptions(new ZoomControlOptions( + position: ControlPosition::BLOCK_END_INLINE_END + )) + ->enableMapTypeControl(false) + ->setMapTypeControlOptions(new MapTypeControlOptions( + mapTypeIds: ['roadmap', 'satellite', MapTypeId::TERRAIN], + position: ControlPosition::BLOCK_END_INLINE_START, + style: MapTypeControlStyle::HORIZONTAL_BAR, + )) + ->enableStreetViewControl(false) + ->setStreetViewControlOptions(new StreetViewControlOptions( + position: ControlPosition::BLOCK_END_INLINE_START, + )) + ->enableFullscreenControl(false) + ->setFullscreenControlOptions(new FullscreenControlOptions( + position: ControlPosition::BLOCK_END_INLINE_START, + )) + ->enableFitBoundsToMarkers(false); + + self::assertEquals([ + 'mapId' => '2b2d73ba4b8c7b41', + 'center' => ['lat' => 48.8566, 'lng' => 2.3522], + 'zoom' => 12.0, + 'gestureHandling' => 'greedy', + 'backgroundColor' => '#f0f0f0', + 'disableDoubleClickZoom' => true, + 'zoomControl' => false, + 'zoomControlOptions' => [ + 'position' => ControlPosition::BLOCK_END_INLINE_END->value, + ], + 'mapTypeControl' => false, + 'mapTypeControlOptions' => [ + 'mapTypeIds' => ['roadmap', 'satellite', 'terrain'], + 'position' => ControlPosition::BLOCK_END_INLINE_START->value, + 'style' => 1, + ], + 'streetViewControl' => false, + 'streetViewControlOptions' => [ + 'position' => ControlPosition::BLOCK_END_INLINE_START->value, + ], + 'fullscreenControl' => false, + 'fullscreenControlOptions' => [ + 'position' => ControlPosition::BLOCK_END_INLINE_START->value, + ], + 'fitBoundsToMarkers' => false, + 'markers' => [ + [ + '_id' => $paris->getId(), + 'position' => ['lat' => 48.8566, 'lng' => 2.3522], + 'title' => 'Paris', + ], + [ + '_id' => $lyon->getId(), + 'position' => ['lat' => 45.764, 'lng' => 4.8357], + 'title' => 'Lyon', + ], + [ + '_id' => $marseille->getId(), + 'position' => ['lat' => 43.2965, 'lng' => 5.3698], + 'title' => 'Marseille', + ], + ], + 'infoWindows' => [ + [ + '_markerId' => $paris->getId(), + 'headerContent' => 'Paris', + 'content' => "Capitale de la France, est une grande ville européenne et un centre mondial de l'art, de la mode, de la gastronomie et de la culture.", + 'position' => ['lat' => 48.8566, 'lng' => 2.3522], + 'opened' => false, + 'autoClose' => true, + ], + [ + '_markerId' => $lyon->getId(), + 'headerContent' => 'Lyon', + 'content' => 'Ville française de la région historique Rhône-Alpes, se trouve à la jonction du Rhône et de la Saône.', + 'position' => ['lat' => 45.764, 'lng' => 4.8357], + 'opened' => false, + 'autoClose' => true, + ], + [ + '_markerId' => $marseille->getId(), + 'headerContent' => 'Marseille', + 'content' => 'Ville portuaire du sud de la France, est une ville cosmopolite qui a été un centre d\'échanges commerciaux et culturels depuis sa fondation par les Grecs vers 600 av. J.-C.', + 'position' => ['lat' => 43.2965, 'lng' => 5.3698], + 'opened' => false, + 'autoClose' => true, + ], + [ + '_markerId' => null, + 'headerContent' => 'Strasbourg', + 'content' => 'Ville française située dans le Grand Est, est également le siège du Parlement européen.', + 'position' => ['lat' => 48.5734, 'lng' => 7.7521], + 'opened' => true, + 'autoClose' => true, + ], + ], + ], $map->createView()); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/MapTypeControlOptionsTest.php b/src/Map/tests/Provider/GoogleMaps/MapTypeControlOptionsTest.php new file mode 100644 index 00000000000..cdf6062f463 --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/MapTypeControlOptionsTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\GoogleMaps\ControlPosition; +use Symfony\UX\Map\Provider\GoogleMaps\MapTypeControlOptions; +use Symfony\UX\Map\Provider\GoogleMaps\MapTypeControlStyle; +use Symfony\UX\Map\Provider\GoogleMaps\MapTypeId; + +class MapTypeControlOptionsTest extends TestCase +{ + public function testCreateView(): void + { + $options = new MapTypeControlOptions( + mapTypeIds: [MapTypeId::SATELLITE, 'hybrid'], + position: ControlPosition::BLOCK_END_INLINE_END, + style: MapTypeControlStyle::HORIZONTAL_BAR, + ); + + self::assertSame([ + 'mapTypeIds' => ['satellite', 'hybrid'], + 'position' => ControlPosition::BLOCK_END_INLINE_END->value, + 'style' => MapTypeControlStyle::HORIZONTAL_BAR->value, + ], $options->createView()); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/MapTypeControlStyleTest.php b/src/Map/tests/Provider/GoogleMaps/MapTypeControlStyleTest.php new file mode 100644 index 00000000000..78abf3ad7f8 --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/MapTypeControlStyleTest.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\GoogleMaps\MapTypeControlStyle; + +class MapTypeControlStyleTest extends TestCase +{ + public function testEnumValues(): void + { + self::assertSame(0, MapTypeControlStyle::DEFAULT->value); + self::assertSame(2, MapTypeControlStyle::DROPDOWN_MENU->value); + self::assertSame(1, MapTypeControlStyle::HORIZONTAL_BAR->value); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/MapTypeIdTest.php b/src/Map/tests/Provider/GoogleMaps/MapTypeIdTest.php new file mode 100644 index 00000000000..130eea13754 --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/MapTypeIdTest.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\GoogleMaps\MapTypeId; + +class MapTypeIdTest extends TestCase +{ + public function testEnumValues(): void + { + self::assertSame('hybrid', MapTypeId::HYBRID->value); + self::assertSame('roadmap', MapTypeId::ROADMAP->value); + self::assertSame('satellite', MapTypeId::SATELLITE->value); + self::assertSame('terrain', MapTypeId::TERRAIN->value); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/MarkerTest.php b/src/Map/tests/Provider/GoogleMaps/MarkerTest.php new file mode 100644 index 00000000000..2da87edabdf --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/MarkerTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\LatLng; +use Symfony\UX\Map\Provider\GoogleMaps\Marker; + +class MarkerTest extends TestCase +{ + public function testCreateView(): void + { + $marker = new Marker( + new LatLng(48.8566, 2.3522), + 'Paris' + ); + + self::assertSame([ + '_id' => $marker->getId(), + 'position' => [ + 'lat' => 48.8566, + 'lng' => 2.3522, + ], + 'title' => 'Paris', + ], $marker->createView()); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/StreetViewControlOptionsTest.php b/src/Map/tests/Provider/GoogleMaps/StreetViewControlOptionsTest.php new file mode 100644 index 00000000000..d71a5367805 --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/StreetViewControlOptionsTest.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\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\GoogleMaps\ControlPosition; +use Symfony\UX\Map\Provider\GoogleMaps\StreetViewControlOptions; + +class StreetViewControlOptionsTest extends TestCase +{ + public function testCreateView(): void + { + $options = new StreetViewControlOptions( + position: ControlPosition::INLINE_END_BLOCK_CENTER + ); + + self::assertSame([ + 'position' => ControlPosition::INLINE_END_BLOCK_CENTER->value, + ], $options->createView()); + } +} diff --git a/src/Map/tests/Provider/GoogleMaps/ZoomControlOptionsTest.php b/src/Map/tests/Provider/GoogleMaps/ZoomControlOptionsTest.php new file mode 100644 index 00000000000..cf808c08bdb --- /dev/null +++ b/src/Map/tests/Provider/GoogleMaps/ZoomControlOptionsTest.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\Tests\Provider\GoogleMaps; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\GoogleMaps\ControlPosition; +use Symfony\UX\Map\Provider\GoogleMaps\ZoomControlOptions; + +class ZoomControlOptionsTest extends TestCase +{ + public function testCreateView(): void + { + $options = new ZoomControlOptions( + position: ControlPosition::BOTTOM_CENTER, + ); + + self::assertSame([ + 'position' => ControlPosition::BOTTOM_CENTER->value, + ], $options->createView()); + } +} diff --git a/src/Map/tests/Provider/Leaflet/MapFactoryTest.php b/src/Map/tests/Provider/Leaflet/MapFactoryTest.php new file mode 100644 index 00000000000..17de970b05e --- /dev/null +++ b/src/Map/tests/Provider/Leaflet/MapFactoryTest.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\Tests\Provider\Leaflet; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\Leaflet\Map; +use Symfony\UX\Map\Provider\Leaflet\MapFactory; + +class MapFactoryTest extends TestCase +{ + public function testCreate(): void + { + $mapFactory = new MapFactory(); + + $map = $mapFactory->createMap('map_name'); + + self::assertInstanceOf(Map::class, $map); + self::assertSame('map_name', $map->getName()); + + $view = $map->createView(); + self::assertNull($view['center']); + self::assertNull($view['zoom']); + self::assertFalse($view['fitBoundsToMarkers']); + self::assertEquals([ + 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + 'attribution' => '© OpenStreetMap', + ], $view['tileLayer']); + } + + public function testCreateWithCustomOptions(): void + { + $mapFactory = new MapFactory(); + + $map = $mapFactory->createMap('map_name', [ + 'center' => [37.7749, -122.4194], + 'zoom' => 3, + 'tileLayer' => [ + 'url' => 'https://foobar.org/{z}/{x}/{y}.png', + 'attribution' => '© OpenStreetMap contributors (kudos)', + 'options' => [ + 'maxZoom' => 19, + ], + ], + 'fitBoundsToMarkers' => true, + ]); + + self::assertInstanceOf(Map::class, $map); + self::assertSame('map_name', $map->getName()); + + $view = $map->createView(); + self::assertSame(['lat' => 37.7749, 'lng' => -122.4194], $view['center']); + self::assertSame(3.0, $view['zoom']); + self::assertTrue($view['fitBoundsToMarkers']); + + self::assertEquals([ + 'url' => 'https://foobar.org/{z}/{x}/{y}.png', + 'attribution' => '© OpenStreetMap contributors (kudos)', + 'maxZoom' => 19, + ], $view['tileLayer']); + } +} diff --git a/src/Map/tests/Provider/Leaflet/MapTest.php b/src/Map/tests/Provider/Leaflet/MapTest.php new file mode 100644 index 00000000000..e365f632f2e --- /dev/null +++ b/src/Map/tests/Provider/Leaflet/MapTest.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Provider\Leaflet; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\LatLng; +use Symfony\UX\Map\Provider\Leaflet; +use Symfony\UX\Map\Provider\Leaflet\Map; +use Symfony\UX\Map\Provider\Leaflet\Marker; +use Symfony\UX\Map\Provider\Leaflet\TileLayer; + +class MapTest extends TestCase +{ + public function testCreateViewWithDefaultOptions(): void + { + $map = new Map( + 'map_name', + new TileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', '© OpenStreetMap') + ); + + self::assertEquals([ + 'center' => null, + 'zoom' => null, + 'fitBoundsToMarkers' => false, + 'markers' => [], + 'popups' => [], + 'tileLayer' => [ + 'url' => 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + 'attribution' => '© OpenStreetMap', + ], + ], $map->createView()); + } + + public function testCreateViewWithCustomOptions(): void + { + $map = (new Map( + name: 'map_name', + tileLayer: new TileLayer( + url: 'https://foobar.org/{z}/{x}/{y}.png', + attribution: '© OpenStreetMap contributors (kudos)', + options: ['maxZoom' => 19] + ) + )) + ->setCenter(new LatLng(48.8566, 2.3522)) + ->setZoom(12) + ->addMarker($paris = new Marker(position: new LatLng(48.8566, 2.3522), title: 'Paris')) + ->addMarker($lyon = new Marker(position: new LatLng(45.7640, 4.8357), title: 'Lyon')) + ->addMarker($marseille = new Marker(position: new LatLng(43.2965, 5.3698), title: 'Marseille')) + ->addPopup(new Leaflet\Popup( + content: "Paris, capitale de la France, est une grande ville européenne et un centre mondial de l'art, de la mode, de la gastronomie et de la culture.", + marker: $paris, + )) + ->addPopup(new Leaflet\Popup( + content: 'Lyon, ville française de la région historique Rhône-Alpes, se trouve à la jonction du Rhône et de la Saône.', + marker: $lyon + )) + ->addPopup(new Leaflet\Popup( + content: 'Marseille, ville portuaire du sud de la France, est une ville cosmopolite qui a été un centre d\'échanges commerciaux et culturels depuis sa fondation par les Grecs vers 600 av. J.-C.', + marker: $marseille, + )) + ->addPopup(new Leaflet\Popup( + content: 'Strasbourg, ville française située dans le Grand Est, est également le siège du Parlement européen.', + position: new LatLng(48.5734, 7.7521), + opened: true, + )) + ->enableFitBoundsToMarkers(false); + + self::assertEquals([ + 'center' => ['lat' => 48.8566, 'lng' => 2.3522], + 'zoom' => 12.0, + 'fitBoundsToMarkers' => false, + 'tileLayer' => [ + 'url' => 'https://foobar.org/{z}/{x}/{y}.png', + 'attribution' => '© OpenStreetMap contributors (kudos)', + 'maxZoom' => 19, + ], + 'markers' => [ + [ + '_id' => $paris->getId(), + 'position' => ['lat' => 48.8566, 'lng' => 2.3522], + 'title' => 'Paris', + 'riseOnHover' => false, + 'riseOffset' => 250, + 'draggable' => false, + ], + [ + '_id' => $lyon->getId(), + 'position' => ['lat' => 45.764, 'lng' => 4.8357], + 'title' => 'Lyon', + 'riseOnHover' => false, + 'riseOffset' => 250, + 'draggable' => false, + ], + [ + '_id' => $marseille->getId(), + 'position' => ['lat' => 43.2965, 'lng' => 5.3698], + 'title' => 'Marseille', + 'riseOnHover' => false, + 'riseOffset' => 250, + 'draggable' => false, + ], + ], + 'popups' => [ + [ + '_markerId' => $paris->getId(), + 'content' => "Paris, capitale de la France, est une grande ville européenne et un centre mondial de l'art, de la mode, de la gastronomie et de la culture.", + 'position' => ['lat' => 48.8566, 'lng' => 2.3522], + 'opened' => false, + 'autoClose' => true, + ], + [ + '_markerId' => $lyon->getId(), + 'content' => 'Lyon, ville française de la région historique Rhône-Alpes, se trouve à la jonction du Rhône et de la Saône.', + 'position' => ['lat' => 45.764, 'lng' => 4.8357], + 'opened' => false, + 'autoClose' => true, + ], + [ + '_markerId' => $marseille->getId(), + 'content' => 'Marseille, ville portuaire du sud de la France, est une ville cosmopolite qui a été un centre d\'échanges commerciaux et culturels depuis sa fondation par les Grecs vers 600 av. J.-C.', + 'position' => ['lat' => 43.2965, 'lng' => 5.3698], + 'opened' => false, + 'autoClose' => true, + ], + [ + '_markerId' => null, + 'content' => 'Strasbourg, ville française située dans le Grand Est, est également le siège du Parlement européen.', + 'position' => ['lat' => 48.5734, 'lng' => 7.7521], + 'opened' => true, + 'autoClose' => true, + ], + ], + ], $map->createView()); + } +} diff --git a/src/Map/tests/Provider/Leaflet/MarkerTest.php b/src/Map/tests/Provider/Leaflet/MarkerTest.php new file mode 100644 index 00000000000..160850587d4 --- /dev/null +++ b/src/Map/tests/Provider/Leaflet/MarkerTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Provider\Leaflet; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\LatLng; +use Symfony\UX\Map\Provider\Leaflet\Marker; + +class MarkerTest extends TestCase +{ + public function testCreateView(): void + { + $marker = new Marker( + new LatLng(48.8566, 2.3522), + 'Paris' + ); + + self::assertSame([ + '_id' => $marker->getId(), + 'position' => [ + 'lat' => 48.8566, + 'lng' => 2.3522, + ], + 'title' => 'Paris', + 'riseOnHover' => false, + 'riseOffset' => 250, + 'draggable' => false, + ], $marker->createView()); + } +} diff --git a/src/Map/tests/Provider/Leaflet/PopupTest.php b/src/Map/tests/Provider/Leaflet/PopupTest.php new file mode 100644 index 00000000000..03bb397279e --- /dev/null +++ b/src/Map/tests/Provider/Leaflet/PopupTest.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\Tests\Provider\Leaflet; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Exception\InvalidArgumentException; +use Symfony\UX\Map\LatLng; +use Symfony\UX\Map\Provider\Leaflet\Marker; +use Symfony\UX\Map\Provider\Leaflet\Popup; + +class PopupTest extends TestCase +{ + public function testConstructShouldThrowIfNoPositionOrMarkerIsPassed(): void + { + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('An "Symfony\UX\Map\Provider\Leaflet\Popup" must be associated with a position or a marker.'); + + new Popup(content: 'Hello!'); + } + + public function testMarkerPositionShouldTakePrecedenceOverPosition(): void + { + $Popup = new Popup( + content: 'Hello!', + marker: new Marker(position: new LatLng(1, 2)), + position: new LatLng(3, 4) + ); + + $position = \Closure::bind(fn () => $Popup->position, $Popup, Popup::class)(); + + self::assertEquals(new LatLng(1, 2), $position); + } + + public function testPositionFallbackToPositionIfNoMarkerIsPassed(): void + { + $Popup = new Popup( + content: 'Hello!', + position: new LatLng(3, 4) + ); + + $position = \Closure::bind(fn () => $Popup->position, $Popup, Popup::class)(); + + self::assertEquals(new LatLng(3, 4), $position); + } + + public function testCreateView(): void + { + $Popup = new Popup( + content: 'Hello!', + position: new LatLng(3, 4) + ); + + self::assertEquals([ + 'content' => 'Hello!', + 'position' => ['lat' => 3, 'lng' => 4], + 'opened' => false, + 'autoClose' => true, + '_markerId' => null, + ], $Popup->createView()); + } +} diff --git a/src/Map/tests/Registry/MapRegistryTest.php b/src/Map/tests/Registry/MapRegistryTest.php new file mode 100644 index 00000000000..589515fdcdf --- /dev/null +++ b/src/Map/tests/Registry/MapRegistryTest.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Map\Tests\Registry; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Map\Provider\GoogleMaps; +use Symfony\UX\Map\Registry\MapRegistry; + +final class MapRegistryTest extends TestCase +{ + public function testBehavior(): void + { + $mapRegistry = new MapRegistry(); + self::assertEmpty($mapRegistry->all()); + + $mapRegistry->register($map1 = new GoogleMaps\Map('my_map')); + self::assertContains($map1, $mapRegistry->all()); + + $mapRegistry->register($map2 = new GoogleMaps\Map('my_map')); + self::assertContains($map2, $mapRegistry->all()); + + $mapRegistry->reset(); + self::assertEmpty($mapRegistry->all()); + } +} diff --git a/src/Map/tests/TwigTest.php b/src/Map/tests/TwigTest.php new file mode 100644 index 00000000000..34df090ebeb --- /dev/null +++ b/src/Map/tests/TwigTest.php @@ -0,0 +1,149 @@ + + * + * 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\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\KernelInterface; +use Symfony\UX\Map\Exception\ConflictingMapProvidersOnSamePageException; +use Symfony\UX\Map\Tests\Kernel\TwigAppKernel; + +final class TwigTest extends KernelTestCase +{ + protected static function createKernel(array $options = []): KernelInterface + { + return new class('test', true) extends TwigAppKernel { + public function registerContainerConfiguration(LoaderInterface $loader) + { + parent::registerContainerConfiguration($loader); + + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('ux_map', [ + 'providers' => [ + 'google_maps' => [ + 'provider' => 'google_maps', + 'options' => [ + 'key' => 'GOOGLE_MAPS_API_KEY', + ], + ], + 'google_maps_2' => [ + 'provider' => 'google_maps', + 'options' => [ + 'key' => 'GOOGLE_MAPS_API_KEY_2', + ], + ], + 'leaflet' => [ + 'provider' => 'leaflet', + ], + ], + 'maps' => [ + 'google_maps_map_1' => [ + 'provider' => 'google_maps', + ], + 'google_maps_map_2' => [ + 'provider' => 'google_maps', + ], + 'google_maps_with_another_google_maps_provider' => [ + 'provider' => 'google_maps_2', + ], + 'leaflet_map_1' => [ + 'provider' => 'leaflet', + ], + ], + ]); + }); + } + }; + } + + protected function setUp(): void + { + self::bootKernel(); + } + + public function testRenderScriptTagsShouldOutputNothingIfNoMapsHaveBeenCreated(): void + { + $twig = self::getContainer()->get('twig'); + $template = $twig->createTemplate('{{ ux_map_script_tags() }}'); + + self::assertEmpty($template->render()); + } + + public function testRenderScriptTagsShouldOutputTheScriptTagsForTheMaps(): void + { + $twig = self::getContainer()->get('twig'); + $mapFactory = self::getContainer()->get('ux_map.map_factory'); + + $mapFactory->createMap('google_maps_map_1'); + + self::assertStringContainsString( + '', + $twig->createTemplate('{{ ux_map_script_tags() }}')->render() + ); + + $mapFactory->createMap('google_maps_map_2'); + + self::assertStringContainsString( + '', + $twig->createTemplate('{{ ux_map_script_tags() }}')->render() + ); + + $mapFactory->createMap('leaflet_map_1'); + + self::assertStringContainsString( + '', + $twig->createTemplate('{{ ux_map_script_tags() }}')->render() + ); + } + + public function testRenderScriptTagsShouldFailIfWeHaveTheSameKindOfProviderOnThePage(): void + { + $twig = self::getContainer()->get('twig'); + $mapFactory = self::getContainer()->get('ux_map.map_factory'); + + $mapFactory->createMap('google_maps_map_1'); + $mapFactory->createMap('google_maps_with_another_google_maps_provider'); + + try { + $twig->createTemplate('{{ ux_map_script_tags() }}')->render(); + self::assertTrue(false, 'This should not be reached.'); + } catch (\Twig\Error\RuntimeError $e) { + self::assertInstanceOf(ConflictingMapProvidersOnSamePageException::class, $e->getPrevious()); + self::assertSame('You cannot use the "google_maps" map provider on the same page as the following map providers: "google_maps_2", as their configuration will conflicts with each-other.', $e->getPrevious()->getMessage()); + } + } + + public function testRenderMap(): void + { + $twig = self::getContainer()->get('twig'); + $mapFactory = self::getContainer()->get('ux_map.map_factory'); + + $map = $mapFactory->createMap('google_maps_map_1'); + + self::assertStringContainsString( + '
', + $twig->createTemplate('{{ render_map(map) }}')->render([ + 'map' => $map, + ]) + ); + + self::assertStringContainsString( + '
', + $twig->createTemplate('{{ render_map(map, { "data-controller": "my-map", "class": "foo" }) }}')->render([ + 'map' => $map, + ]) + ); + } +} diff --git a/src/Map/tests/UxMapBundleTest.php b/src/Map/tests/UxMapBundleTest.php new file mode 100644 index 00000000000..c7876986223 --- /dev/null +++ b/src/Map/tests/UxMapBundleTest.php @@ -0,0 +1,109 @@ + + * + * 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\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; +use Symfony\UX\Map\Tests\Kernel\FrameworkAppKernel; +use Symfony\UX\Map\Tests\Kernel\TwigAppKernel; + +class UxMapBundleTest extends TestCase +{ + public static function provideKernels() + { + yield 'framework' => [new FrameworkAppKernel('test', true)]; + yield 'twig' => [new TwigAppKernel('test', true)]; + } + + /** + * @dataProvider provideKernels + */ + public function testBootKernel(Kernel $kernel) + { + $kernel->boot(); + self::assertArrayHasKey('UXMapBundle', $kernel->getBundles()); + } + + public function testMinimalConfigurationShouldNotThrow() + { + self::expectNotToPerformAssertions(); + + $kernel = $this->createAndConfigureKernel(uxMapConfiguration: []); + $kernel->boot(); + } + + public function testValidateSupportedProvider() + { + $this->expectException(\Symfony\Component\Config\Definition\Exception\InvalidConfigurationException::class); + $this->expectExceptionMessage('The provider "foo" is not supported.'); + + $kernel = $this->createAndConfigureKernel(uxMapConfiguration: [ + 'providers' => [ + 'foo' => [ + 'provider' => 'foo', + ], + ], + ]); + $kernel->boot(); + } + + public function testGoogleProviderValidation() + { + $this->expectException(\Symfony\Component\Config\Definition\Exception\InvalidConfigurationException::class); + $this->expectExceptionMessage('Invalid configuration for path "ux_map.providers.google_maps": The "key" option is required for the "google_maps" provider.'); + + $kernel = $this->createAndConfigureKernel(uxMapConfiguration: [ + 'providers' => [ + 'google_maps' => [ + 'provider' => 'google_maps', + ], + ], + ]); + $kernel->boot(); + } + + public function testMapProviderValidation() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('The provider "google_map" for the map "google_map_1" is not found, has it been correctly registered?'); + + $kernel = $this->createAndConfigureKernel(uxMapConfiguration: [ + 'maps' => [ + 'google_map_1' => [ + 'provider' => 'google_map', + ], + ], + ]); + $kernel->boot(); + } + + private function createAndConfigureKernel(array $uxMapConfiguration) + { + return new class('test', true, $uxMapConfiguration) extends TwigAppKernel { + public function __construct(string $environment, bool $debug, private array $uxMapConfiguration) + { + parent::__construct($environment, $debug); + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + parent::registerContainerConfiguration($loader); + + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('ux_map', $this->uxMapConfiguration); + }); + } + }; + } +} diff --git a/yarn.lock b/yarn.lock index 6e57d4e20f5..17f64ceaaa2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2122,6 +2122,13 @@ dependencies: tslib "^2.4.0" +"@googlemaps/js-api-loader@^1.16.6": + version "1.16.6" + resolved "https://registry.yarnpkg.com/@googlemaps/js-api-loader/-/js-api-loader-1.16.6.tgz#c89970c94b55796d51746c092f0e52953994a171" + integrity sha512-V8p5W9DbPQx74jWUmyYJOerhiB4C+MHekaO0ZRmc6lrOYrvY7+syLhzOWpp55kqSPeNb+qbC2h8i69aLIX6krQ== + dependencies: + fast-deep-equal "^3.1.3" + "@hotwired/stimulus@^3.0.0": version "3.2.1" resolved "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.1.tgz" @@ -2844,6 +2851,16 @@ resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== +"@types/geojson@*": + version "7946.0.14" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.14.tgz#319b63ad6df705ee2a65a73ef042c8271e696613" + integrity sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg== + +"@types/google.maps@^3.55.9": + version "3.55.9" + resolved "https://registry.yarnpkg.com/@types/google.maps/-/google.maps-3.55.9.tgz#3bbe1d044d9b999392a359fb37b0de2545ac53c4" + integrity sha512-phaOMtezbT3NaXPKiI3m0OosUS7Nly0auw3Be5s/CgMWLVoDAUP1Yb/Ld0TRoRp8ibrlT4VqM5kmzfvUA0UNLQ== + "@types/graceful-fs@^4.1.2": version "4.1.6" resolved "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz" @@ -2883,6 +2900,13 @@ resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz" integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== +"@types/leaflet@^1.9.12": + version "1.9.12" + resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.9.12.tgz#a6626a0b3fba36fd34723d6e95b22e8024781ad6" + integrity sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg== + dependencies: + "@types/geojson" "*" + "@types/node-fetch@^2.6.2": version "2.6.2" resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.2.tgz" @@ -4528,6 +4552,11 @@ entities@^2.0.0: resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + error-ex@^1.3.1: version "1.3.2" resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz" @@ -5304,6 +5333,15 @@ gzip-size@^6.0.0: dependencies: duplexer "^0.1.2" +happy-dom@^14.12.3: + version "14.12.3" + resolved "https://registry.yarnpkg.com/happy-dom/-/happy-dom-14.12.3.tgz#1b5892c670461fd1db041bee690981c22d3d521f" + integrity sha512-vsYlEs3E9gLwA1Hp+w3qzu+RUDFf4VTT8cyKqVICoZ2k7WM++Qyd2LwzyTi5bqMJFiIC/vNpTDYuxdreENRK/g== + dependencies: + entities "^4.5.0" + webidl-conversions "^7.0.0" + whatwg-mimetype "^3.0.0" + has-ansi@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz" @@ -6543,6 +6581,11 @@ kleur@^4.1.3, kleur@^4.1.5: resolved "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz" integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== +leaflet@^1.9.4: + version "1.9.4" + resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" + integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA== + leven@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz" @@ -9154,6 +9197,11 @@ webidl-conversions@^6.1.0: resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz" integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w== +webidl-conversions@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" + integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== + whatwg-encoding@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz" @@ -9166,6 +9214,11 @@ whatwg-mimetype@^2.3.0: resolved "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz" integrity sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g== +whatwg-mimetype@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz#5fa1a7623867ff1af6ca3dc72ad6b8a4208beba7" + integrity sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q== + whatwg-url@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz"