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"