diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 96a0c126b02..402d4bca253 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -89,6 +89,8 @@ jobs:
dependency-version: 'highest'
component: ${{ fromJson(needs.tests-php-components.outputs.components )}}
exclude:
+ - component: Map # does not support PHP 8.1
+ php-version: '8.1'
- component: Swup # has no tests
- component: Turbo # has its own workflow (test-turbo.yml)
- component: Typed # has no tests
diff --git a/src/Map/.gitattributes b/src/Map/.gitattributes
index 97734d35229..35c1f46ae5d 100644
--- a/src/Map/.gitattributes
+++ b/src/Map/.gitattributes
@@ -4,4 +4,5 @@
/phpunit.xml.dist export-ignore
/assets/src export-ignore
/assets/test export-ignore
+/assets/vitest.config.js export-ignore
/tests export-ignore
diff --git a/src/Map/.gitignore b/src/Map/.gitignore
index 30282084317..50b321e33a2 100644
--- a/src/Map/.gitignore
+++ b/src/Map/.gitignore
@@ -1,4 +1,3 @@
vendor
composer.lock
-.php_cs.cache
.phpunit.result.cache
diff --git a/src/Map/LICENSE b/src/Map/LICENSE
index 3ed9f412ce5..e374a5c8339 100644
--- a/src/Map/LICENSE
+++ b/src/Map/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2023-present Fabien Potencier
+Copyright (c) 2024-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
diff --git a/src/Map/README.md b/src/Map/README.md
index 067879fa01b..443684f5d84 100644
--- a/src/Map/README.md
+++ b/src/Map/README.md
@@ -3,7 +3,7 @@
**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.
+Symfony UX Map integrates interactive Maps in Symfony applications, like Leaflet or GoogleMaps.
**This repository is a READ-ONLY sub-tree split**. See
https://github.com/symfony/ux to create issues or submit pull requests.
diff --git a/src/Map/assets/dist/google_maps_controller.d.ts b/src/Map/assets/dist/google_maps_controller.d.ts
index afc320e0a86..dcdada40bd8 100644
--- a/src/Map/assets/dist/google_maps_controller.d.ts
+++ b/src/Map/assets/dist/google_maps_controller.d.ts
@@ -1,55 +1,22 @@
///
import { Controller } from '@hotwired/stimulus';
-type MarkerId = number;
+import type { MapView } from './map';
+type GoogleMapsOptions = Pick;
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;
- };
+ viewValue: MapView;
private loader;
private map;
private markers;
private infoWindows;
initialize(): void;
connect(): Promise;
+ private createMarkers;
+ private createMarker;
+ private createInfoWindows;
+ private createInfoWindow;
private createTextOrElement;
private closeInfoWindowsExcept;
private dispatchEvent;
diff --git a/src/Map/assets/dist/google_maps_controller.js b/src/Map/assets/dist/google_maps_controller.js
index 09928d70394..33774d7c48d 100644
--- a/src/Map/assets/dist/google_maps_controller.js
+++ b/src/Map/assets/dist/google_maps_controller.js
@@ -4,106 +4,94 @@ import { Loader } from '@googlemaps/js-api-loader';
class default_1 extends Controller {
constructor() {
super(...arguments);
- this.markers = new Map();
+ this.markers = [];
this.infoWindows = [];
}
initialize() {
- var _a;
- const providerConfig = (_a = window.__symfony_ux_maps.providers) === null || _a === void 0 ? void 0 : _a.google_maps;
+ var _a, _b;
+ const providerConfig = (_b = (_a = window.__symfony_ux_maps) === null || _a === void 0 ? void 0 : _a.providers) === null || _b === void 0 ? void 0 : _b['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,
- };
+ const loaderOptions = providerConfig;
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,
+ const { Map: GoogleMap } = await this.loader.importLibrary('maps');
+ const { center, zoom, fitBoundsToMarkers, options, markers, infoWindows } = this.viewValue;
+ this.dispatchEvent('pre-connect', { options });
+ this.map = new GoogleMap(this.element, Object.assign(Object.assign({}, options), { center,
+ zoom }));
+ this.createMarkers(markers, fitBoundsToMarkers);
+ this.createInfoWindows(infoWindows);
+ this.dispatchEvent('connect', {
+ map: this.map,
+ markers: this.markers,
+ infoWindows: this.infoWindows,
});
- 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);
+ }
+ createMarkers(markers, fitBoundsToMarkers) {
+ markers.forEach((definition) => this.createMarker(definition));
+ if (this.markers.length > 0 && fitBoundsToMarkers) {
+ const bounds = new google.maps.LatLngBounds();
+ this.markers.forEach((marker) => {
+ if (!marker.position) {
+ return;
+ }
+ bounds.extend(marker.position);
});
- 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.map.fitBounds(bounds);
+ }
+ }
+ async createMarker(definition) {
+ const { AdvancedMarkerElement } = await this.loader.importLibrary('marker');
+ const options = {
+ position: definition.position,
+ title: definition.title,
+ };
+ this.dispatchEvent('marker:before-create', { options });
+ const marker = new AdvancedMarkerElement(Object.assign(Object.assign({}, options), { map: this.map }));
+ if (definition.infoWindow) {
+ this.createInfoWindow(definition.infoWindow, marker);
}
- 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.dispatchEvent('marker:after-create', { marker });
+ this.markers.push(marker);
+ }
+ createInfoWindows(infoWindows) {
+ infoWindows.forEach((definition) => this.createInfoWindow(definition));
+ }
+ async createInfoWindow(definition, marker) {
+ const { InfoWindow } = await this.loader.importLibrary('maps');
+ const options = {
+ headerContent: this.createTextOrElement(definition.headerContent),
+ content: this.createTextOrElement(definition.content),
+ position: definition.position,
+ };
+ this.dispatchEvent('info-window:before-create', { options });
+ const infoWindow = new InfoWindow(options);
+ this.infoWindows.push(infoWindow);
+ if (definition.opened) {
+ infoWindow.open({
+ map: this.map,
+ shouldFocus: false,
+ anchor: marker,
});
- this.infoWindows.push(infoWindow);
- if (infoWindowConfiguration.opened) {
+ }
+ if (marker) {
+ marker.addListener('click', () => {
+ if (definition.autoClose) {
+ this.closeInfoWindowsExcept(infoWindow);
+ }
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,
- });
+ });
+ }
+ this.dispatchEvent('info-window:after-create', { infoWindow });
}
createTextOrElement(content) {
if (!content) {
diff --git a/src/Map/assets/dist/leaflet_controller.d.ts b/src/Map/assets/dist/leaflet_controller.d.ts
index edad1bcafac..e4717cb27f0 100644
--- a/src/Map/assets/dist/leaflet_controller.d.ts
+++ b/src/Map/assets/dist/leaflet_controller.d.ts
@@ -1,45 +1,29 @@
import { Controller } from '@hotwired/stimulus';
import 'leaflet/dist/leaflet.min.css';
-import type { MarkerOptions } from 'leaflet';
-type MarkerId = number;
+import type { MapOptions } from 'leaflet';
+import type { MapView } from './map';
+type LeafletOptions = Pick;
+type AdditionalOptions = {
+ tileLayer: {
+ url: string;
+ attribution: string;
+ options: Record;
+ };
+};
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;
- }>;
- };
+ viewValue: MapView;
private map;
private markers;
- private popups;
+ private infoWindows;
connect(): void;
- private setupTileLayer;
+ private createTileLayer;
+ private createMarkers;
+ private createMarker;
+ private createInfoWindows;
+ private createInfoWindow;
private dispatchEvent;
}
export {};
diff --git a/src/Map/assets/dist/leaflet_controller.js b/src/Map/assets/dist/leaflet_controller.js
index ef3ec84faee..7237a585970 100644
--- a/src/Map/assets/dist/leaflet_controller.js
+++ b/src/Map/assets/dist/leaflet_controller.js
@@ -32,63 +32,72 @@ function __rest(s, e) {
class default_1 extends Controller {
constructor() {
super(...arguments);
- this.markers = new Map();
- this.popups = [];
+ this.markers = [];
+ this.infoWindows = [];
}
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);
+ const { center, zoom, fitBoundsToMarkers, options, markers, infoWindows } = this.viewValue;
+ this.dispatchEvent('pre-connect', { options });
+ const _a = this.viewValue.options, { tileLayer } = _a, mapOptions = __rest(_a, ["tileLayer"]);
+ this.map = L.map(this.element, Object.assign(Object.assign({}, mapOptions), { center,
+ zoom }));
+ this.createTileLayer(tileLayer);
+ this.createMarkers(markers, fitBoundsToMarkers);
+ this.createInfoWindows(infoWindows);
+ this.dispatchEvent('connect', {
+ map: this.map,
+ markers: this.markers,
+ infoWindows: this.infoWindows,
});
- if (this.viewValue.fitBoundsToMarkers) {
+ }
+ createTileLayer(definition) {
+ const { url, attribution, options } = definition;
+ L.tileLayer(url, Object.assign({ attribution }, options)).addTo(this.map);
+ }
+ createMarkers(markers, fitBoundsToMarkers) {
+ markers.forEach((definition) => this.createMarker(definition));
+ if (fitBoundsToMarkers && this.markers.length > 0) {
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);
+ createMarker(definition) {
+ const { position } = definition, options = __rest(definition, ["position"]);
+ this.dispatchEvent('marker:before-create', { options });
+ const marker = L.marker(position, options).addTo(this.map);
+ if (definition.infoWindow) {
+ this.createInfoWindow(definition.infoWindow, marker);
+ }
+ this.dispatchEvent('marker:after-create', { marker });
+ this.markers.push(marker);
+ }
+ createInfoWindows(infoWindows) {
+ infoWindows.forEach((definition) => this.createInfoWindow(definition));
+ }
+ createInfoWindow(definition, marker) {
+ let infoWindow;
+ const options = Object.assign({}, definition);
+ this.dispatchEvent('info-window:before-create', { options });
+ const { headerContent, content, position } = options, otherOptions = __rest(options, ["headerContent", "content", "position"]);
+ if (marker) {
+ marker.bindPopup(headerContent + '
' + content, otherOptions);
+ if (definition.opened) {
+ marker.openPopup();
+ }
+ infoWindow = marker.getPopup();
+ }
+ else {
+ infoWindow = L.popup(otherOptions)
+ .setContent(headerContent + '
' + content)
+ .setLatLng(position);
+ if (definition.opened) {
+ infoWindow.openOn(this.map);
+ }
+ }
+ this.infoWindows.push(infoWindow);
+ this.dispatchEvent('info-window:after-create', { infoWindow });
}
dispatchEvent(name, payload) {
this.dispatch(name, { detail: payload, prefix: 'leaflet' });
diff --git a/src/Map/assets/package.json b/src/Map/assets/package.json
index 977349804b0..14f939319eb 100644
--- a/src/Map/assets/package.json
+++ b/src/Map/assets/package.json
@@ -1,6 +1,6 @@
{
"name": "@symfony/ux-map",
- "description": "Symfony Map for JavaScript",
+ "description": "Integrates interactive maps in your Symfony applications",
"license": "MIT",
"version": "1.0.0",
"symfony": {
diff --git a/src/Map/assets/src/global.d.ts b/src/Map/assets/src/global.d.ts
index ce8d7d79b19..2a0838fbe90 100644
--- a/src/Map/assets/src/global.d.ts
+++ b/src/Map/assets/src/global.d.ts
@@ -1,10 +1,10 @@
+import type { LoaderOptions } from '@googlemaps/js-api-loader';
+
declare global {
interface Window {
__symfony_ux_maps?: {
providers?: {
- google_maps?: {
- key: string;
- };
+ 'google-maps'?: LoaderOptions;
leaflet?: Record;
};
};
diff --git a/src/Map/assets/src/google_maps_controller.ts b/src/Map/assets/src/google_maps_controller.ts
index b29c01d5ba1..7c11c4b7c0b 100644
--- a/src/Map/assets/src/google_maps_controller.ts
+++ b/src/Map/assets/src/google_maps_controller.ts
@@ -8,63 +8,46 @@
*/
import { Controller } from '@hotwired/stimulus';
-import type { LoaderOptions } from '@googlemaps/js-api-loader';
import { Loader } from '@googlemaps/js-api-loader';
-
-type MarkerId = number;
+import type { InfoWindowDefinition, MapView, MarkerDefinition } from './map';
+
+type GoogleMapsOptions = Pick<
+ google.maps.MapOptions,
+ | 'mapId'
+ | 'gestureHandling'
+ | 'backgroundColor'
+ | 'disableDoubleClickZoom'
+ | 'zoomControl'
+ | 'zoomControlOptions'
+ | 'mapTypeControl'
+ | 'mapTypeControlOptions'
+ | 'streetViewControl'
+ | 'streetViewControlOptions'
+ | 'fullscreenControl'
+ | 'fullscreenControlOptions'
+>;
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;
- };
+ declare viewValue: MapView;
private loader: Loader;
private map: google.maps.Map;
- private markers = new Map();
+ private markers: Array = [];
private infoWindows: Array = [];
initialize() {
- const providerConfig = window.__symfony_ux_maps.providers?.google_maps;
+ 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,
- };
+ const loaderOptions = providerConfig;
this.dispatchEvent('init', {
loaderOptions,
@@ -74,106 +57,115 @@ export default class extends Controller {
}
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,
- };
+ const { Map: GoogleMap } = await this.loader.importLibrary('maps');
+ const { center, zoom, fitBoundsToMarkers, options, markers, infoWindows } = this.viewValue;
- if (this.viewValue.mapId) {
- mapOptions.mapId = this.viewValue.mapId;
- }
+ this.dispatchEvent('pre-connect', { options });
- if (this.viewValue.center) {
- mapOptions.center = this.viewValue.center;
- }
+ this.map = new GoogleMap(this.element, {
+ ...options,
+ center,
+ zoom,
+ });
+ this.createMarkers(markers, fitBoundsToMarkers);
+ this.createInfoWindows(infoWindows);
+
+ this.dispatchEvent('connect', {
+ map: this.map,
+ markers: this.markers,
+ infoWindows: this.infoWindows,
+ });
+ }
+
+ private createMarkers(markers: Array, fitBoundsToMarkers: boolean) {
+ markers.forEach((definition) => this.createMarker(definition));
- if (this.viewValue.zoom) {
- mapOptions.zoom = this.viewValue.zoom;
+ if (this.markers.length > 0 && fitBoundsToMarkers) {
+ const bounds = new google.maps.LatLngBounds();
+ this.markers.forEach((marker) => {
+ if (!marker.position) {
+ return;
+ }
+
+ bounds.extend(marker.position);
+ });
+ this.map.fitBounds(bounds);
}
+ }
- this.dispatchEvent('pre-connect', {
- mapOptions,
+ private async createMarker(definition: MarkerDefinition) {
+ // Load the marker library on demand. Doing it twice won't make another HTTP request.
+ const { AdvancedMarkerElement } = await this.loader.importLibrary('marker');
+
+ const options = {
+ position: definition.position,
+ title: definition.title,
+ };
+ this.dispatchEvent('marker:before-create', { options });
+
+ const marker = new AdvancedMarkerElement({
+ ...options,
+ map: this.map,
});
- this.map = new GoogleMap(this.element, mapOptions);
+ if (definition.infoWindow) {
+ this.createInfoWindow(definition.infoWindow, marker);
+ }
- if (this.viewValue.markers) {
- const { AdvancedMarkerElement } = await this.loader.importLibrary('marker');
+ this.dispatchEvent('marker:after-create', { marker });
- this.viewValue.markers.forEach((markerConfiguration) => {
- const marker = new AdvancedMarkerElement({
- position: markerConfiguration.position,
- title: markerConfiguration.title,
- map: this.map,
- });
+ this.markers.push(marker);
+ }
- this.markers.set(markerConfiguration._id, marker);
- });
+ private createInfoWindows(infoWindows: Array) {
+ infoWindows.forEach((definition) => this.createInfoWindow(definition));
+ }
- if (this.viewValue.fitBoundsToMarkers) {
- const bounds = new google.maps.LatLngBounds();
- this.markers.forEach((marker) => {
- if (!marker.position) {
- return;
- }
+ private async createInfoWindow(definition: InfoWindowDefinition): Promise;
+ private async createInfoWindow(
+ definition: MarkerDefinition['infoWindow'],
+ marker: google.maps.marker.AdvancedMarkerElement
+ ): Promise;
+ private async createInfoWindow(
+ definition: InfoWindowDefinition,
+ marker?: google.maps.marker.AdvancedMarkerElement
+ ): Promise {
+ // Load the marker library on demand. Doing it twice won't make another HTTP request.
+ const { InfoWindow } = await this.loader.importLibrary('maps');
+ const options = {
+ headerContent: this.createTextOrElement(definition.headerContent),
+ content: this.createTextOrElement(definition.content),
+ position: definition.position,
+ };
- bounds.extend(marker.position);
- });
- this.map.fitBounds(bounds);
- }
- }
+ this.dispatchEvent('info-window:before-create', { options });
+
+ const infoWindow = new InfoWindow(options);
- this.viewValue.infoWindows.forEach((infoWindowConfiguration) => {
- const marker = infoWindowConfiguration._markerId
- ? this.markers.get(infoWindowConfiguration._markerId)
- : undefined;
+ this.infoWindows.push(infoWindow);
- const infoWindow = new InfoWindow({
- headerContent: this.createTextOrElement(infoWindowConfiguration.headerContent),
- content: this.createTextOrElement(infoWindowConfiguration.content),
- position: infoWindowConfiguration.position,
+ if (definition.opened) {
+ infoWindow.open({
+ map: this.map,
+ shouldFocus: false,
+ anchor: marker,
});
+ }
- this.infoWindows.push(infoWindow);
+ if (marker) {
+ marker.addListener('click', () => {
+ if (definition.autoClose) {
+ this.closeInfoWindowsExcept(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,
- });
+ this.dispatchEvent('info-window:after-create', { infoWindow });
}
private createTextOrElement(content: string | null): string | HTMLElement | null {
@@ -181,7 +173,8 @@ export default class extends Controller {
return null;
}
- if (content.includes('<') /* we assume it's HTML if it includes "<" */) {
+ // we assume it's HTML if it includes "<"
+ if (content.includes('<')) {
const div = document.createElement('div');
div.innerHTML = content;
return div;
diff --git a/src/Map/assets/src/leaflet_controller.ts b/src/Map/assets/src/leaflet_controller.ts
index 24cd699f0bf..8bbb4c2db42 100644
--- a/src/Map/assets/src/leaflet_controller.ts
+++ b/src/Map/assets/src/leaflet_controller.ts
@@ -11,111 +11,122 @@
import { Controller } from '@hotwired/stimulus';
import 'leaflet/dist/leaflet.min.css';
-import type { Map as LeafletMap, MapOptions, Marker, MarkerOptions, Popup } from 'leaflet';
+import type { Map as LeafletMap, MapOptions, Marker, Popup } from 'leaflet';
import L from 'leaflet';
+import type { InfoWindowDefinition, MapView, MarkerDefinition } from './map';
-type MarkerId = number;
+type LeafletOptions = Pick;
+type AdditionalOptions = {
+ tileLayer: { url: string; attribution: string; options: Record };
+};
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;
- }>;
- };
+ declare viewValue: MapView;
private map: LeafletMap;
- private markers = new Map();
- private popups: Array = [];
+ private markers: Array = [];
+ private infoWindows: Array = [];
connect() {
- const mapOptions: MapOptions = {
- center: this.viewValue.center || undefined,
- zoom: this.viewValue.zoom || undefined,
- };
-
- this.dispatchEvent('pre-connect', {
- mapOptions,
- });
+ const { center, zoom, fitBoundsToMarkers, options, markers, infoWindows } = this.viewValue;
- this.map = L.map(this.element, mapOptions);
+ this.dispatchEvent('pre-connect', { options });
- this.setupTileLayer();
+ const { tileLayer, ...mapOptions } = this.viewValue.options;
- this.viewValue.markers.forEach((markerConfiguration) => {
- const { _id, position, ...options } = markerConfiguration;
- const marker = L.marker(position, options).addTo(this.map);
+ this.map = L.map(this.element, {
+ ...mapOptions,
+ center,
+ zoom,
+ });
+ this.createTileLayer(tileLayer);
+ this.createMarkers(markers, fitBoundsToMarkers);
+ this.createInfoWindows(infoWindows);
- this.markers.set(_id, marker);
+ this.dispatchEvent('connect', {
+ map: this.map,
+ markers: this.markers,
+ infoWindows: this.infoWindows,
});
+ }
- 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);
- }
+ private createTileLayer(definition: AdditionalOptions['tileLayer']) {
+ const { url, attribution, options } = definition;
- if (popupConfiguration.opened) {
- popup.openOn(this.map);
- }
+ L.tileLayer(url, {
+ attribution,
+ ...options,
+ }).addTo(this.map);
+ }
- this.popups.push(popup);
- });
+ private createMarkers(markers: Array, fitBoundsToMarkers: boolean) {
+ markers.forEach((definition) => this.createMarker(definition));
- if (this.viewValue.fitBoundsToMarkers) {
+ if (fitBoundsToMarkers && this.markers.length > 0) {
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 createMarker(definition: MarkerDefinition) {
+ const { position, ...options } = definition;
+
+ this.dispatchEvent('marker:before-create', { options });
+
+ const marker = L.marker(position, options).addTo(this.map);
+
+ if (definition.infoWindow) {
+ this.createInfoWindow(definition.infoWindow, marker);
+ }
+
+ this.dispatchEvent('marker:after-create', { marker });
+
+ this.markers.push(marker);
}
- private setupTileLayer() {
- const { url, attribution, ...options } = this.viewValue.tileLayer;
+ private createInfoWindows(infoWindows: Array) {
+ infoWindows.forEach((definition) => this.createInfoWindow(definition));
+ }
- L.tileLayer(url, {
- attribution,
- ...options,
- }).addTo(this.map);
+ private createInfoWindow(definition: InfoWindowDefinition): void;
+ private createInfoWindow(definition: MarkerDefinition['infoWindow'], marker: Marker): void;
+ private createInfoWindow(definition: InfoWindowDefinition, marker?: Marker): void {
+ let infoWindow: Popup;
+ const options = { ...definition };
+
+ this.dispatchEvent('info-window:before-create', { options });
+
+ const { headerContent, content, position, ...otherOptions } = options;
+
+ if (marker) {
+ marker.bindPopup(headerContent + '
' + content, otherOptions);
+ if (definition.opened) {
+ marker.openPopup();
+ }
+
+ infoWindow = marker.getPopup()!;
+ } else {
+ infoWindow = L.popup(otherOptions)
+ .setContent(headerContent + '
' + content)
+ .setLatLng(position);
+
+ if (definition.opened) {
+ infoWindow.openOn(this.map);
+ }
+ }
+
+ this.infoWindows.push(infoWindow);
+
+ this.dispatchEvent('info-window:after-create', { infoWindow });
}
private dispatchEvent(name: string, payload: any) {
diff --git a/src/Map/assets/src/map.d.ts b/src/Map/assets/src/map.d.ts
new file mode 100644
index 00000000000..98df8cddcad
--- /dev/null
+++ b/src/Map/assets/src/map.d.ts
@@ -0,0 +1,24 @@
+type LatLng = { lat: number; lng: number };
+
+export type MapView = {
+ center: LatLng;
+ zoom: number;
+ fitBoundsToMarkers: boolean;
+ options: Options;
+ markers: Array;
+ infoWindows: Array;
+};
+
+export type MarkerDefinition = {
+ position: LatLng;
+ title: string | null;
+ infoWindow?: Omit;
+};
+
+export type InfoWindowDefinition = {
+ headerContent: string | null;
+ content: string | null;
+ position: LatLng;
+ opened: boolean;
+ autoClose: boolean;
+};
diff --git a/src/Map/assets/test/google_maps_controller.test.ts b/src/Map/assets/test/google_maps_controller.test.ts
index 527b6af5007..efe6b9ef1ee 100644
--- a/src/Map/assets/test/google_maps_controller.test.ts
+++ b/src/Map/assets/test/google_maps_controller.test.ts
@@ -39,7 +39,7 @@ describe('GoogleMapsController', () => {
`);
});
@@ -51,7 +51,7 @@ describe('GoogleMapsController', () => {
it('connect' , async () => {
window.__symfony_ux_maps = {
providers: {
- google_maps: {
+ ['google-maps']: {
key: '',
},
},
diff --git a/src/Map/assets/test/leaflet_controller.test.ts b/src/Map/assets/test/leaflet_controller.test.ts
index 7562397a6c7..3812a995f57 100644
--- a/src/Map/assets/test/leaflet_controller.test.ts
+++ b/src/Map/assets/test/leaflet_controller.test.ts
@@ -36,10 +36,10 @@ describe('LeafletController', () => {
beforeEach(() => {
container = mountDOM(`
-
`);
});
diff --git a/src/Map/composer.json b/src/Map/composer.json
index a782ae1c98c..ace65bf6ec3 100644
--- a/src/Map/composer.json
+++ b/src/Map/composer.json
@@ -3,7 +3,10 @@
"type": "symfony-bundle",
"description": "Easily embed interactive maps in your Symfony application",
"keywords": [
- "symfony-ux"
+ "symfony-ux",
+ "map",
+ "markers",
+ "maps"
],
"homepage": "https://symfony.com",
"license": "MIT",
@@ -28,15 +31,15 @@
}
},
"require": {
- "php": ">=8.1",
+ "php": ">=8.3",
"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"
+ "symfony/framework-bundle": "^6.4|^7.0",
+ "symfony/phpunit-bridge": "^6.4|^7.0",
+ "symfony/twig-bundle": "^6.4|^7.0",
+ "symfony/var-dumper": "^6.4|^7.0"
},
"extra": {
"thanks": {
diff --git a/src/Map/config/asset_mapper.php b/src/Map/config/asset_mapper.php
index 233ca11e4b5..7ccdee78973 100644
--- a/src/Map/config/asset_mapper.php
+++ b/src/Map/config/asset_mapper.php
@@ -11,13 +11,12 @@
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;
+/*
+ * @author Hugo Alliaume
+ */
return static function (ContainerConfigurator $container): void {
$container->services()
->set('ux_map.asset_mapper.leaflet_replace_images_compiler', LeafletReplaceImagesAssetCompiler::class)
diff --git a/src/Map/config/services.php b/src/Map/config/services.php
index e2a64bddf96..a6a5ad3f6c7 100644
--- a/src/Map/config/services.php
+++ b/src/Map/config/services.php
@@ -11,42 +11,39 @@
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\MapFactory;
+use Symfony\UX\Map\MapRegistry;
+use Symfony\UX\Map\Provider\GoogleMaps\GoogleMapsProviderFactory;
+use Symfony\UX\Map\Provider\Leaflet\LeafletProviderFactory;
+use Symfony\UX\Map\Provider\Provider;
+use Symfony\UX\Map\Provider\ProviderInterface;
use Symfony\UX\Map\Twig\MapExtension;
use Symfony\UX\Map\Twig\MapRuntime;
+/*
+ * @author Hugo Alliaume
+ */
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.default_provider'),
service('ux_map.map_registry'),
])
- ->alias(MapFactoryInterface::class, 'ux_map.map_factory')
+ ->alias(MapFactory::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.default_provider', ProviderInterface::class)
+ ->factory([service('ux_map.provider'), 'fromString'])
+ ->args([
+ abstract_arg('provider configuration'),
+ ])
- ->set('ux_map.leaflet.map_factory', \Symfony\UX\Map\Provider\Leaflet\MapFactory::class)
- ->tag('ux_map.map_factory', ['name' => 'leaflet'])
+ ->set('ux_map.provider', Provider::class)
+ ->args([
+ tagged_iterator('ux_map.provider_factory', indexAttribute: 'name'),
+ ])
->set('ux_map.map_registry', MapRegistry::class)
- ->alias(MapRegistryInterface::class, 'ux_map.map_registry')
->set('ux_map.twig_extension', MapExtension::class)
->tag('twig.extension')
@@ -55,8 +52,13 @@
->args([
service('stimulus.helper'),
service('ux_map.map_registry'),
- service('ux_map.configuration'),
])
->tag('twig.runtime')
+
+ ->set('ux_map.google.provider_factory', GoogleMapsProviderFactory::class)
+ ->tag('ux_map.provider_factory', ['name' => 'google'])
+
+ ->set('ux_map.leaflet.provider_factory', LeafletProviderFactory::class)
+ ->tag('ux_map.provider_factory', ['name' => 'leaflet'])
;
};
diff --git a/src/Map/doc/index.rst b/src/Map/doc/index.rst
index 53a9c9ea9fb..0d361ba5907 100644
--- a/src/Map/doc/index.rst
+++ b/src/Map/doc/index.rst
@@ -41,206 +41,109 @@ After installing the bundle, ensure the line ``{{ ux_map_script_tags() }}`` is p
{{ 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:
+Configuration is done in your ``config/packages/ux_map.yaml`` file:
.. code-block:: yaml
+ # config/packages/ux_map.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": []
- }
+ provider: '%env(UX_MAP_DSN)%'
+The ``UX_MAP_DSN`` environment variable should contain the provider DSN to use, e.g. ``google-maps://`` or ``leaflet://``.
+See :ref:`Map providers` for more information on the available providers.
-Then, you need to configure a new provider and a new map, in your ``config/packages/ux_map.yaml``:
+Usage
+-----
-.. code-block:: yaml
+Creating and rendering
+~~~~~~~~~~~~~~~~~~~~~~
- 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)::
+To create a map, you must use the ``MapFactory`` service.
+This service allows you to create a new ``Map`` instance by using the default configured provider::
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\InfoWindow;
use Symfony\UX\Map\LatLng;
- use Symfony\UX\Map\Provider\GoogleMaps;
+ use Symfony\UX\Map\MapFactory;
+ use Symfony\UX\Map\Marker;
final class ContactController extends AbstractController
{
#[Route('/contact')]
- public function __invoke(MapFactoryInterface $mapFactory): Response
+ public function __invoke(MapFactory $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
+ // 1. Create a new map instance
+ $myMap = $mapFactory->createMap();
+
+ // 2. The map can be programmatically configured with a fluent API, you can change the center, zoom, and other options specific to the provider
+ $myMap
+ ->center(new LatLng(46.903354, 1.888334))
+ ->zoom(6)
+ ->fitBoundsToMarkers()
+ ;
+
+ // 3. You can also add markers
+ $myMap
+ ->addMarker(new Marker(
+ position: new LatLng(48.8566, 2.3522),
+ title: 'Paris'
))
- ->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
+ ->addMarker(new Marker(
+ position: new LatLng(45.7640, 4.8357),
+ title: 'Lyon',
+ // With an info window
+ infoWindow: new 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.'
+ )
));
- ;
-
- // 4. Finally, you must inject the map in your template to render it
+
+ // 4. and then, you must inject the map in your template to render it
return $this->render('contact/index.html.twig', [
- 'map' => $map,
+ 'my_map' => $myMap,
]);
}
}
-Finally, you can render the map in your Twig template:
+To render a map in your Twig template, use the ``render_map`` Twig function, e.g.:
.. code-block:: twig
- {{ render_map(map) }}
+ {{ render_map(my_map) }}
{# or with custom attributes #}
- {{ render_map(map, { 'data-controller': 'my-map', style: 'height: 300px' }) }}
+ {{ render_map(my_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.
+Map providers
+-------------
Leaflet
~~~~~~~
-To use Google Maps on your application, you need to enable the Leaflet controller in your ``assets/controllers.json``:
+You can use `Leaflet`_ as the default provider by configuring the ``UX_MAP_DSN`` environment variable like this:
+
+.. code-block:: env
+
+ # .env
+ UX_MAP_DSN=leaflet://default
-.. code-block:: json
+Then, enable the Leaflet`_ Stimulus controller in your ``assets/controllers.json``:
+
+.. code-block:: diff
{
"controllers": {
"@symfony/ux-map": {
- "google-maps": {
- "enabled": false,
- "fetch": "lazy"
- },
"leaflet": {
- "enabled": true,
+ - "enabled": false,
+ + "enabled": true,
"fetch": "lazy"
}
},
@@ -248,103 +151,87 @@ To use Google Maps on your application, you need to enable the Leaflet controlle
"entrypoints": []
}
+You can configure the map with specific Leaflet options by passing a ``LeafletOptions`` instance to the ``options`` method::
+
+ use Symfony\UX\Map\Provider\Leaflet\LeafletOptions;
+ use Symfony\UX\Map\Provider\Leaflet\Option as LeafletOption;
+
+ $map
+ ->options(new LeafletOptions(
+ tileLayer: new LeafletOption\TileLayer(
+ url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
+ attribution: '© OpenStreetMap contributors',
+ options: [
+ 'maxZoom' => 19,
+ ]
+ ),
+ ));
+
+Google Maps
+~~~~~~~~~~~
-Then, you need to configure a new provider and a new map, in your ``config/packages/ux_map.yaml``:
+You can use `Google Maps`_ as the default provider by configuring the ``UX_MAP_DSN`` environment variable like this:
-.. code-block:: yaml
+.. code-block:: env
- 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,
- ]);
- }
- }
+ # .env
+ UX_MAP_DSN=google-maps://GOOGLE_MAPS_API_KEY@default
+ UX_MAP_DSN=google-maps://GOOGLE_MAPS_API_KEY@default?version=weekly
+ UX_MAP_DSN=google-maps://GOOGLE_MAPS_API_KEY@default?language=fr®ion=FR
-Finally, you can render the map in your Twig template:
+The provider has a number of options:
-.. code-block:: twig
+====================== ====================================== ===================================
+ Option Description Default
+====================== ====================================== ===================================
+``id`` The id of the script tag __googleMapsScriptId
+``language`` Force language, see The user's preferred language
+ `list of supported languages`_ specified in the browser
+``region`` Unicode region subtag identifiers
+ compatible with `ISO 3166-1`_
+``nonce`` Use a cryptographic nonce attribute
+``retries`` The number of script load retries 3
+``url`` Custom url to load the Google Maps API https://maps.googleapis.com/maps/api/js
+ script
+``version`` The release channels or version weekly
+ numbers
+====================== ====================================== ===================================
- {{ render_map(map) }}
-
- {# or with custom attributes #}
- {{ render_map(map, { 'data-controller': 'my-map', style: 'height: 300px' }) }}
+Then, enable the `Google Maps`_ Stimulus controller in your ``assets/controllers.json``:
-If everything went well, you should see a map with markers and popups in your page.
+.. code-block:: diff
+
+ {
+ "controllers": {
+ "@symfony/ux-map": {
+ "google-maps": {
+ - "enabled": false,
+ + "enabled": true,
+ "fetch": "lazy"
+ }
+ },
+ },
+ "entrypoints": []
+ }
+You can configure the map with specific Google Maps options by passing a ``GoogleOptions`` instance to the ``options``::
+
+ use Symfony\UX\Map\Provider\GoogleMaps\GoogleMapsOptions;
+ use Symfony\UX\Map\Provider\GoogleMaps\Option as GoogleMapsOption;
+
+ $map
+ ->options(new GoogleMapsOptions(
+ mapId: '2b2d73ba4b8c7b41', // Enable markers and Google Maps Cloud Styles (https://developers.google.com/maps/documentation/cloud-customization)
+ zoomControlOptions: new GoogleMapsOption\ZoomControlOptions(
+ position: GoogleMapsOption\ControlPosition::BLOCK_START_INLINE_END,
+ ),
+ mapTypeControl: false,
+ streetViewControl: false,
+ fullscreenControlOptions: new GoogleMapsOption\FullscreenControlOptions(
+ position: GoogleMapsOption\ControlPosition::BLOCK_START_INLINE_START,
+ ),
+ ));
+
.. _using-with-asset-mapper:
Using with AssetMapper
@@ -380,3 +267,7 @@ 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
+.. _`Leaflet`: https://leafletjs.com
+.. _`Google Maps`: https://developers.google.com/maps/documentation/javascript/overview
+.. _`ISO 3166-1`: https://en.wikipedia.org/wiki/ISO_3166-1
+.. _`list of supported languages`: https://developers.google.com/maps/faq#languagesupport
diff --git a/src/Map/src/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompiler.php b/src/Map/src/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompiler.php
index cc2a979bccd..f7823dfef4b 100644
--- a/src/Map/src/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompiler.php
+++ b/src/Map/src/AssetMapper/ImportMap/Compiler/LeafletReplaceImagesAssetCompiler.php
@@ -1,7 +1,5 @@
*/
final class LeafletReplaceImagesAssetCompiler implements AssetCompilerInterface
{
@@ -46,7 +48,7 @@ public function compile(string $content, MappedAsset $asset, AssetMapperInterfac
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());
+ $this->logger?->warning(\sprintf('Error processing import in "%s": ', $asset->sourcePath).$e->getMessage());
return $matches[0];
}
diff --git a/src/Map/src/AssetMapper/ImportMap/Resolver/LeafletPackageResolver.php b/src/Map/src/AssetMapper/ImportMap/Resolver/LeafletPackageResolver.php
index 3df8c1344d2..4ecb4ba85b9 100644
--- a/src/Map/src/AssetMapper/ImportMap/Resolver/LeafletPackageResolver.php
+++ b/src/Map/src/AssetMapper/ImportMap/Resolver/LeafletPackageResolver.php
@@ -1,7 +1,5 @@
*/
class LeafletPackageResolver implements PackageResolverInterface
{
@@ -56,7 +58,7 @@ public function downloadPackages(array $importMapEntries, ?callable $progressCal
$distPath = Path::join('dist', 'images', $leafletAsset);
$responses[] = $this->httpClient->request(
'GET',
- sprintf('https://cdn.jsdelivr.net/npm/leaflet@%s/%s', $importMapEntries['leaflet']->version, $distPath),
+ \sprintf('https://cdn.jsdelivr.net/npm/leaflet@%s/%s', $importMapEntries['leaflet']->version, $distPath),
['user_data' => ['dist_path' => $distPath]]
);
}
diff --git a/src/Map/src/Configuration/Configuration.php b/src/Map/src/Configuration/Configuration.php
deleted file mode 100644
index fc084271609..00000000000
--- a/src/Map/src/Configuration/Configuration.php
+++ /dev/null
@@ -1,83 +0,0 @@
-
- *
- * 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
deleted file mode 100644
index bc40625303a..00000000000
--- a/src/Map/src/Configuration/Map.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- *
- * 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
deleted file mode 100644
index 097032cee92..00000000000
--- a/src/Map/src/Configuration/Provider.php
+++ /dev/null
@@ -1,27 +0,0 @@
-
- *
- * 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
deleted file mode 100644
index f304b51f6dd..00000000000
--- a/src/Map/src/DependencyInjection/Configuration.php
+++ /dev/null
@@ -1,144 +0,0 @@
-
- *
- * 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
index b88b8951c91..42502f87e02 100644
--- a/src/Map/src/DependencyInjection/UXMapExtension.php
+++ b/src/Map/src/DependencyInjection/UXMapExtension.php
@@ -12,6 +12,8 @@
namespace Symfony\UX\Map\DependencyInjection;
use Symfony\Component\AssetMapper\AssetMapperInterface;
+use Symfony\Component\Config\Definition\Builder\TreeBuilder;
+use Symfony\Component\Config\Definition\ConfigurationInterface;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
@@ -20,17 +22,12 @@
/**
* @author Hugo Alliaume
- *
- * @internal
- *
- * @experimental
*/
-class UXMapExtension extends Extension implements PrependExtensionInterface
+class UXMapExtension extends Extension implements ConfigurationInterface, PrependExtensionInterface
{
- public function load(array $configs, ContainerBuilder $container)
+ public function load(array $configs, ContainerBuilder $container): void
{
- $configuration = new Configuration();
- $config = $this->processConfiguration($configuration, $configs);
+ $config = $this->processConfiguration($this, $configs);
$loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/../config')));
$loader->load('services.php');
@@ -39,11 +36,24 @@ public function load(array $configs, ContainerBuilder $container)
$loader->load('asset_mapper.php');
}
- $container->setParameter('ux_map.config.providers', $config['providers']);
- $container->setParameter('ux_map.config.maps', $config['maps']);
+ $container->getDefinition('ux_map.default_provider')
+ ->setArgument(0, $config['provider']);
+ }
+
+ public function getConfigTreeBuilder(): TreeBuilder
+ {
+ $treeBuilder = new TreeBuilder('ux_map');
+ $rootNode = $treeBuilder->getRootNode();
+ $rootNode
+ ->children()
+ ->scalarNode('provider')->isRequired()->end()
+ ->end()
+ ;
+
+ return $treeBuilder;
}
- public function prepend(ContainerBuilder $container)
+ public function prepend(ContainerBuilder $container): void
{
if (!$this->isAssetMapperAvailable($container)) {
return;
diff --git a/src/Map/src/Exception/ConflictingMapProvidersOnSamePageException.php b/src/Map/src/Exception/ConflictingMapProvidersOnSamePageException.php
deleted file mode 100644
index ed3a53d0ced..00000000000
--- a/src/Map/src/Exception/ConflictingMapProvidersOnSamePageException.php
+++ /dev/null
@@ -1,26 +0,0 @@
-
- *
- * 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
index ac3ca93a93d..82e977ac9ea 100644
--- a/src/Map/src/Exception/Exception.php
+++ b/src/Map/src/Exception/Exception.php
@@ -1,7 +1,5 @@
+ */
interface Exception extends \Throwable
{
}
diff --git a/src/Map/src/Exception/MapNotFoundException.php b/src/Map/src/Exception/IncompleteDsnException.php
similarity index 50%
rename from src/Map/src/Exception/MapNotFoundException.php
rename to src/Map/src/Exception/IncompleteDsnException.php
index ed3a54ec406..a12d01a1a27 100644
--- a/src/Map/src/Exception/MapNotFoundException.php
+++ b/src/Map/src/Exception/IncompleteDsnException.php
@@ -1,7 +1,5 @@
+ */
+final class IncompleteDsnException extends InvalidArgumentException
{
- 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/InvalidArgumentException.php b/src/Map/src/Exception/InvalidArgumentException.php
index 5529fa5f467..aa280857e7b 100644
--- a/src/Map/src/Exception/InvalidArgumentException.php
+++ b/src/Map/src/Exception/InvalidArgumentException.php
@@ -1,7 +1,5 @@
+ */
class InvalidArgumentException extends \InvalidArgumentException implements Exception
{
}
diff --git a/src/Map/src/Exception/ProviderNotFoundException.php b/src/Map/src/Exception/ProviderNotFoundException.php
deleted file mode 100644
index f00e94f3440..00000000000
--- a/src/Map/src/Exception/ProviderNotFoundException.php
+++ /dev/null
@@ -1,22 +0,0 @@
-
- *
- * 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
index 69f94e14f06..ec2b5ef8b14 100644
--- a/src/Map/src/Exception/RuntimeException.php
+++ b/src/Map/src/Exception/RuntimeException.php
@@ -1,7 +1,5 @@
+ */
class RuntimeException extends \RuntimeException implements Exception
{
}
diff --git a/src/Map/src/Exception/UnsupportedSchemeException.php b/src/Map/src/Exception/UnsupportedSchemeException.php
new file mode 100644
index 00000000000..5df69483a2b
--- /dev/null
+++ b/src/Map/src/Exception/UnsupportedSchemeException.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\Exception;
+
+use Symfony\UX\Map\Provider\Dsn;
+
+/**
+ * @author Hugo Alliaume
+ */
+class UnsupportedSchemeException extends InvalidArgumentException
+{
+ public function __construct(Dsn $dsn, ?\Throwable $previous = null)
+ {
+ parent::__construct(
+ \sprintf('The provider "%s" is not supported.', $dsn->getScheme()),
+ 0,
+ $previous
+ );
+ }
+}
diff --git a/src/Map/src/Factory/MapFactory.php b/src/Map/src/Factory/MapFactory.php
deleted file mode 100644
index ac045b395e7..00000000000
--- a/src/Map/src/Factory/MapFactory.php
+++ /dev/null
@@ -1,49 +0,0 @@
-
- *
- * 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/InfoWindow.php b/src/Map/src/InfoWindow.php
new file mode 100644
index 00000000000..1ea1b4cddd8
--- /dev/null
+++ b/src/Map/src/InfoWindow.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;
+
+/**
+ * Represents an information window that can be displayed on a map.
+ *
+ * @author Hugo Alliaume
+ */
+final readonly class InfoWindow
+{
+ public function __construct(
+ private ?string $headerContent = null,
+ private ?string $content = null,
+ private ?LatLng $position = null,
+ private bool $opened = false,
+ private bool $autoClose = true,
+ ) {
+ }
+
+ public function toArray(): array
+ {
+ return [
+ 'headerContent' => $this->headerContent,
+ 'content' => $this->content,
+ 'position' => $this->position?->toArray(),
+ 'opened' => $this->opened,
+ 'autoClose' => $this->autoClose,
+ ];
+ }
+}
diff --git a/src/Map/src/LatLng.php b/src/Map/src/LatLng.php
index e47b17b42d4..573b1c1b757 100644
--- a/src/Map/src/LatLng.php
+++ b/src/Map/src/LatLng.php
@@ -1,7 +1,5 @@
*/
-final class LatLng
+final readonly class LatLng
{
public function __construct(
- public readonly float $latitude,
- public readonly float $longitude,
+ public float $latitude,
+ public float $longitude,
) {
if ($latitude < -90 || $latitude > 90) {
- throw new InvalidArgumentException(sprintf('Latitude must be between -90 and 90 degrees, "%s" given.', $latitude));
+ 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));
+ throw new InvalidArgumentException(\sprintf('Longitude must be between -180 and 180 degrees, "%s" given.', $longitude));
}
}
/**
- * @return array{latitude: float, longitude: float}
+ * @return array{lat: float, lng: float}
*/
- public function createView(): array
+ public function toArray(): array
{
return [
'lat' => $this->latitude,
diff --git a/src/Map/src/Map.php b/src/Map/src/Map.php
new file mode 100644
index 00000000000..9063900352d
--- /dev/null
+++ b/src/Map/src/Map.php
@@ -0,0 +1,134 @@
+
+ *
+ * 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;
+use Symfony\UX\Map\Provider\ProviderInterface;
+
+/**
+ * Represents a map.
+ *
+ * @author Hugo Alliaume
+ */
+final class Map
+{
+ /**
+ * @var array
+ */
+ private $attributes = [];
+
+ public function __construct(
+ private readonly ProviderInterface $provider,
+ private MapOptionsInterface $options,
+ private ?LatLng $center = null,
+ private ?float $zoom = null,
+ private bool $fitBoundsToMarkers = false,
+ /**
+ * @var array
+ */
+ private array $markers = [],
+ /**
+ * @var array
+ */
+ private array $infoWindows = [],
+ ) {
+ }
+
+ public function center(LatLng $center): self
+ {
+ $this->center = $center;
+
+ return $this;
+ }
+
+ public function zoom(float $zoom): self
+ {
+ $this->zoom = $zoom;
+
+ return $this;
+ }
+
+ public function fitBoundsToMarkers(bool $enable = true): self
+ {
+ $this->fitBoundsToMarkers = $enable;
+
+ return $this;
+ }
+
+ public function getProvider(): ProviderInterface
+ {
+ return $this->provider;
+ }
+
+ public function options(MapOptionsInterface $options): self
+ {
+ $this->options = $options;
+
+ 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 setAttributes(array $attributes): self
+ {
+ $this->attributes = $attributes;
+
+ return $this;
+ }
+
+ public function getMainDataController(): string
+ {
+ return $this->provider->getStimulusDataController();
+ }
+
+ public function getDataController(): ?string
+ {
+ return $this->attributes['data-controller'] ?? null;
+ }
+
+ public function getAttributes(): array
+ {
+ return $this->attributes;
+ }
+
+ public function toArray(): array
+ {
+ if (null === $this->center) {
+ throw new InvalidArgumentException('The center of the map must be set.');
+ }
+
+ if (null === $this->zoom) {
+ throw new InvalidArgumentException('The zoom of the map must be set.');
+ }
+
+ return [
+ 'center' => $this->center->toArray(),
+ 'zoom' => $this->zoom,
+ 'fitBoundsToMarkers' => $this->fitBoundsToMarkers,
+ 'options' => $this->options->toArray(),
+ 'markers' => array_map(static fn (Marker $marker) => $marker->toArray(), $this->markers),
+ 'infoWindows' => array_map(static fn (InfoWindow $infoWindow) => $infoWindow->toArray(), $this->infoWindows),
+ ];
+ }
+}
diff --git a/src/Map/src/MapFactory.php b/src/Map/src/MapFactory.php
new file mode 100644
index 00000000000..039bdc3251c
--- /dev/null
+++ b/src/Map/src/MapFactory.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;
+
+use Symfony\UX\Map\Provider\ProviderInterface;
+
+/**
+ * Creates a map based on the configuration, and registers it in the map registry.
+ *
+ * @internal
+ *
+ * @author Hugo Alliaume
+ */
+final readonly class MapFactory
+{
+ public function __construct(
+ private ProviderInterface $defaultProvider,
+ private MapRegistry $mapRegistry,
+ ) {
+ }
+
+ public function createMap(?ProviderInterface $provider = null): Map
+ {
+ $provider ??= $this->defaultProvider;
+ $map = new Map($provider, $provider::getDefaultMapOptions());
+
+ $this->mapRegistry->register($map);
+
+ return $map;
+ }
+}
diff --git a/src/Map/src/MapInterface.php b/src/Map/src/MapInterface.php
deleted file mode 100644
index a9ac1450243..00000000000
--- a/src/Map/src/MapInterface.php
+++ /dev/null
@@ -1,35 +0,0 @@
-
- *
- * 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/Factory/MapFactoryInterface.php b/src/Map/src/MapOptionsInterface.php
similarity index 51%
rename from src/Map/src/Factory/MapFactoryInterface.php
rename to src/Map/src/MapOptionsInterface.php
index e452cf52b9b..de7b1e20211 100644
--- a/src/Map/src/Factory/MapFactoryInterface.php
+++ b/src/Map/src/MapOptionsInterface.php
@@ -1,7 +1,5 @@
+ */
+interface MapOptionsInterface
{
/**
- * @param array $options
+ * @return array
*/
- public function createMap(string $name, array $options = []): MapInterface;
+ public function toArray(): array;
}
diff --git a/src/Map/src/Registry/MapRegistry.php b/src/Map/src/MapRegistry.php
similarity index 67%
rename from src/Map/src/Registry/MapRegistry.php
rename to src/Map/src/MapRegistry.php
index e1385feae0b..d7fad4d4f89 100644
--- a/src/Map/src/Registry/MapRegistry.php
+++ b/src/Map/src/MapRegistry.php
@@ -1,7 +1,5 @@
+ */
+final class MapRegistry implements ResetInterface
{
/**
- * @var array
+ * @var array