├── CHANGELOG.md ├── LICENSE ├── README.md ├── assets ├── LICENSE ├── README.md ├── dist │ ├── map_controller.d.ts │ └── map_controller.js ├── package.json └── vitest.config.mjs ├── composer.json └── src ├── LeafletOptions.php ├── Option └── TileLayer.php └── Renderer ├── LeafletRenderer.php └── LeafletRendererFactory.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2.26 4 | 5 | - Using `new LeafletOptions(tileLayer: false)` will now disable the default `TileLayer`. 6 | Useful when using a custom tiles layer rendering engine not configurable with `L.tileLayer().addTo(map)` method 7 | (e.g.: [Esri/esri-leaflet-vector](https://github.com/Esri/esri-leaflet-vector)) 8 | 9 | ## 2.25 10 | 11 | - Downgrade PHP requirement from 8.3 to 8.1 12 | 13 | ## 2.20 14 | 15 | ### BC Breaks 16 | 17 | - Renamed importmap entry `@symfony/ux-leaflet-map/map-controller` to `@symfony/ux-leaflet-map`, 18 | you will need to update your importmap. 19 | 20 | ## 2.19 21 | 22 | - Bridge added 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Symfony UX Map: Leaflet 2 | 3 | [Leaflet](https://leafletjs.com/) integration for Symfony UX Map. 4 | 5 | ## Installation 6 | 7 | Install the bridge using Composer and Symfony Flex: 8 | 9 | ```shell 10 | composer require symfony/ux-leaflet-map 11 | ``` 12 | 13 | If you're using WebpackEncore, install your assets and restart Encore (not 14 | needed if you're using AssetMapper): 15 | 16 | ```shell 17 | npm install --force 18 | npm run watch 19 | ``` 20 | 21 | > [!NOTE] 22 | > Alternatively, [@symfony/ux-leaflet-map package](https://www.npmjs.com/package/@symfony/ux-leaflet-map) can be used to install the JavaScript assets without requiring PHP. 23 | 24 | ## DSN example 25 | 26 | ```dotenv 27 | UX_MAP_DSN=leaflet://default 28 | ``` 29 | 30 | ## Map options 31 | 32 | You can use the `LeafletOptions` class to configure your `Map`:: 33 | 34 | ```php 35 | use Symfony\UX\Map\Bridge\Leaflet\LeafletOptions; 36 | use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; 37 | use Symfony\UX\Map\Point; 38 | use Symfony\UX\Map\Map; 39 | 40 | $map = (new Map()) 41 | ->center(new Point(48.8566, 2.3522)) 42 | ->zoom(6); 43 | 44 | $leafletOptions = (new LeafletOptions()) 45 | ->tileLayer(new TileLayer( 46 | url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 47 | attribution: '© OpenStreetMap', 48 | options: [ 49 | 'minZoom' => 5, 50 | 'maxZoom' => 10, 51 | ] 52 | )) 53 | ; 54 | 55 | // Add the custom options to the map 56 | $map->options($leafletOptions); 57 | ``` 58 | 59 | ## Use cases 60 | 61 | Below are some common or advanced use cases when using a map. 62 | 63 | ### Customize the marker 64 | 65 | A common use case is to customize the marker. You can listen to the `ux:map:marker:before-create` event to customize the marker before it is created. 66 | 67 | Assuming you have a map with a custom controller: 68 | ```twig 69 | {{ ux_map(map, {'data-controller': 'my-map' }) }} 70 | ``` 71 | 72 | You can create a Stimulus controller to customize the markers before they are created: 73 | ```js 74 | // assets/controllers/my_map_controller.js 75 | import {Controller} from "@hotwired/stimulus"; 76 | 77 | export default class extends Controller 78 | { 79 | connect() { 80 | this.element.addEventListener('ux:map:marker:before-create', this._onMarkerBeforeCreate); 81 | } 82 | 83 | disconnect() { 84 | // Always remove listeners when the controller is disconnected 85 | this.element.removeEventListener('ux:map:marker:before-create', this._onMarkerBeforeCreate); 86 | } 87 | 88 | _onMarkerBeforeCreate(event) { 89 | // You can access the marker definition and the Leaflet object 90 | // Note: `definition.rawOptions` is the raw options object that will be passed to the `L.marker` constructor. 91 | const { definition, L } = event.detail; 92 | 93 | // Use a custom icon for the marker 94 | const redIcon = L.icon({ 95 | // Note: instead of using a hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`. 96 | iconUrl: 'https://leafletjs.com/examples/custom-icons/leaf-red.png', 97 | shadowUrl: 'https://leafletjs.com/examples/custom-icons/leaf-shadow.png', 98 | iconSize: [38, 95], // size of the icon 99 | shadowSize: [50, 64], // size of the shadow 100 | iconAnchor: [22, 94], // point of the icon which will correspond to marker's location 101 | shadowAnchor: [4, 62], // the same for the shadow 102 | popupAnchor: [-3, -76] // point from which the popup should open relative to the iconAnchor 103 | }) 104 | 105 | definition.rawOptions = { 106 | icon: redIcon, 107 | } 108 | } 109 | } 110 | ``` 111 | 112 | ### Disable the default tile layer 113 | 114 | If you need to use a custom tiles layer rendering engine that is not compatible with the `L.tileLayer().addTo(map)` method 115 | (e.g. e.g.: [Esri/esri-leaflet-vector](https://github.com/Esri/esri-leaflet-vector)), you can disable the default tile layer by passing `tileLayer: false` to the `LeafletOptions`: 116 | 117 | ```php 118 | use Symfony\UX\Map\Bridge\Leaflet\LeafletOptions; 119 | 120 | $leafletOptions = new LeafletOptions(tileLayer: false); 121 | // or 122 | $leafletOptions = (new LeafletOptions()) 123 | ->tileLayer(false); 124 | ``` 125 | 126 | ## Known issues 127 | 128 | ### Unable to find `leaflet/dist/leaflet.min.css` file when using Webpack Encore 129 | 130 | When using Webpack Encore with the Leaflet bridge, you may encounter the following error: 131 | ``` 132 | Module build failed: Module not found: 133 | "./node_modules/.pnpm/file+vendor+symfony+ux-leaflet-map+assets_@hotwired+stimulus@3.0.0_leaflet@1.9.4/node_modules/@symfony/ux-leaflet-map/dist/map_controller.js" contains a reference to the file "leaflet/dist/leaflet.min.css". 134 | This file can not be found, please check it for typos or update it if the file got moved. 135 | 136 | Entrypoint app = runtime.67292354.js 488.0777101a.js app.b75294ae.css app.0975a86d.js 137 | webpack compiled with 1 error 138 |  ELIFECYCLE  Command failed with exit code 1. 139 | ``` 140 | 141 | That's because the Leaflet's Stimulus controller references the `leaflet/dist/leaflet.min.css` file, 142 | which exists on [jsDelivr](https://www.jsdelivr.com/package/npm/leaflet) (used by the Symfony AssetMapper component), 143 | but does not in the [`leaflet` npm package](https://www.npmjs.com/package/leaflet). 144 | The correct path is `leaflet/dist/leaflet.css`, but it is not possible to fix it because it would break compatibility 145 | with the Symfony AssetMapper component. 146 | 147 | As a workaround, you can configure Webpack Encore to add an alias for the `leaflet/dist/leaflet.min.css` file: 148 | ```js 149 | Encore.addAliases({ 150 | 'leaflet/dist/leaflet.min.css': 'leaflet/dist/leaflet.css', 151 | }) 152 | ``` 153 | 154 | ## Resources 155 | 156 | - [Documentation](https://symfony.com/bundles/ux-map/current/index.html) 157 | - [Report issues](https://github.com/symfony/ux/issues) and 158 | [send Pull Requests](https://github.com/symfony/ux/pulls) 159 | in the [main Symfony UX repository](https://github.com/symfony/ux) 160 | -------------------------------------------------------------------------------- /assets/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | # @symfony/ux-leaflet-map 2 | 3 | JavaScript assets of the [symfony/ux-leaflet-map](https://packagist.org/packages/symfony/ux-leaflet-map) PHP package. 4 | 5 | ## Installation 6 | 7 | This npm package is **reserved for advanced users** who want to decouple their JavaScript dependencies from their PHP dependencies (e.g., when building Docker images, running JavaScript-only pipelines, etc.). 8 | 9 | We **strongly recommend not installing this package directly**, but instead install the PHP package [symfony/ux-leaflet-map](https://packagist.org/packages/symfony/ux-leaflet-map) in your Symfony application with [Flex](https://github.com/symfony/flex) enabled. 10 | 11 | If you still want to install this package directly, please make sure its version exactly matches [symfony/ux-leaflet-map](https://packagist.org/packages/symfony/ux-leaflet-map) PHP package version: 12 | ```shell 13 | composer require symfony/ux-leaflet-map:2.23.0 14 | npm add @symfony/ux-leaflet-map@2.23.0 15 | ``` 16 | 17 | **Tip:** Your `package.json` file will be automatically modified by [Flex](https://github.com/symfony/flex) when installing or upgrading a PHP package. To prevent this behavior, ensure to **use at least Flex 1.22.0 or 2.5.0**, and run `composer config extra.symfony.flex.synchronize_package_json false`. 18 | 19 | ## Resources 20 | 21 | - [Documentation](https://github.com/symfony/ux/tree/2.x/src/Map/src/Bridge/Google) 22 | - [Report issues](https://github.com/symfony/ux/issues) and 23 | [send Pull Requests](https://github.com/symfony/ux/pulls) 24 | in the [main Symfony UX repository](https://github.com/symfony/ux) 25 | -------------------------------------------------------------------------------- /assets/dist/map_controller.d.ts: -------------------------------------------------------------------------------- 1 | import AbstractMapController from '@symfony/ux-map'; 2 | import type { Icon, InfoWindowWithoutPositionDefinition, MarkerDefinition, Point, PolygonDefinition, PolylineDefinition } from '@symfony/ux-map'; 3 | import 'leaflet/dist/leaflet.min.css'; 4 | import * as L from 'leaflet'; 5 | import type { MapOptions as LeafletMapOptions, MarkerOptions, PolylineOptions as PolygonOptions, PolylineOptions, PopupOptions } from 'leaflet'; 6 | type MapOptions = Pick & { 7 | tileLayer: { 8 | url: string; 9 | attribution: string; 10 | options: Record; 11 | } | false; 12 | }; 13 | export default class extends AbstractMapController { 14 | map: L.Map; 15 | connect(): void; 16 | centerValueChanged(): void; 17 | zoomValueChanged(): void; 18 | protected dispatchEvent(name: string, payload?: Record): void; 19 | protected doCreateMap({ center, zoom, options, }: { 20 | center: Point | null; 21 | zoom: number | null; 22 | options: MapOptions; 23 | }): L.Map; 24 | protected doCreateMarker({ definition }: { 25 | definition: MarkerDefinition; 26 | }): L.Marker; 27 | protected doRemoveMarker(marker: L.Marker): void; 28 | protected doCreatePolygon({ definition, }: { 29 | definition: PolygonDefinition; 30 | }): L.Polygon; 31 | protected doRemovePolygon(polygon: L.Polygon): void; 32 | protected doCreatePolyline({ definition, }: { 33 | definition: PolylineDefinition; 34 | }): L.Polyline; 35 | protected doRemovePolyline(polyline: L.Polyline): void; 36 | protected doCreateInfoWindow({ definition, element, }: { 37 | definition: InfoWindowWithoutPositionDefinition; 38 | element: L.Marker | L.Polygon | L.Polyline; 39 | }): L.Popup; 40 | protected doCreateIcon({ definition, element, }: { 41 | definition: Icon; 42 | element: L.Marker; 43 | }): void; 44 | protected doFitBoundsToMarkers(): void; 45 | } 46 | export {}; 47 | -------------------------------------------------------------------------------- /assets/dist/map_controller.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus'; 2 | import 'leaflet/dist/leaflet.min.css'; 3 | import * as L from 'leaflet'; 4 | 5 | const IconTypes = { 6 | Url: 'url', 7 | Svg: 'svg', 8 | UxIcon: 'ux-icon', 9 | }; 10 | class default_1 extends Controller { 11 | constructor() { 12 | super(...arguments); 13 | this.markers = new Map(); 14 | this.polygons = new Map(); 15 | this.polylines = new Map(); 16 | this.infoWindows = []; 17 | this.isConnected = false; 18 | } 19 | connect() { 20 | const options = this.optionsValue; 21 | this.dispatchEvent('pre-connect', { options }); 22 | this.createMarker = this.createDrawingFactory('marker', this.markers, this.doCreateMarker.bind(this)); 23 | this.createPolygon = this.createDrawingFactory('polygon', this.polygons, this.doCreatePolygon.bind(this)); 24 | this.createPolyline = this.createDrawingFactory('polyline', this.polylines, this.doCreatePolyline.bind(this)); 25 | this.map = this.doCreateMap({ 26 | center: this.hasCenterValue ? this.centerValue : null, 27 | zoom: this.hasZoomValue ? this.zoomValue : null, 28 | options, 29 | }); 30 | this.markersValue.forEach((definition) => this.createMarker({ definition })); 31 | this.polygonsValue.forEach((definition) => this.createPolygon({ definition })); 32 | this.polylinesValue.forEach((definition) => this.createPolyline({ definition })); 33 | if (this.fitBoundsToMarkersValue) { 34 | this.doFitBoundsToMarkers(); 35 | } 36 | this.dispatchEvent('connect', { 37 | map: this.map, 38 | markers: [...this.markers.values()], 39 | polygons: [...this.polygons.values()], 40 | polylines: [...this.polylines.values()], 41 | infoWindows: this.infoWindows, 42 | }); 43 | this.isConnected = true; 44 | } 45 | createInfoWindow({ definition, element, }) { 46 | this.dispatchEvent('info-window:before-create', { definition, element }); 47 | const infoWindow = this.doCreateInfoWindow({ definition, element }); 48 | this.dispatchEvent('info-window:after-create', { infoWindow, definition, element }); 49 | this.infoWindows.push(infoWindow); 50 | return infoWindow; 51 | } 52 | markersValueChanged() { 53 | if (!this.isConnected) { 54 | return; 55 | } 56 | this.onDrawChanged(this.markers, this.markersValue, this.createMarker, this.doRemoveMarker); 57 | if (this.fitBoundsToMarkersValue) { 58 | this.doFitBoundsToMarkers(); 59 | } 60 | } 61 | polygonsValueChanged() { 62 | if (!this.isConnected) { 63 | return; 64 | } 65 | this.onDrawChanged(this.polygons, this.polygonsValue, this.createPolygon, this.doRemovePolygon); 66 | } 67 | polylinesValueChanged() { 68 | if (!this.isConnected) { 69 | return; 70 | } 71 | this.onDrawChanged(this.polylines, this.polylinesValue, this.createPolyline, this.doRemovePolyline); 72 | } 73 | createDrawingFactory(type, draws, factory) { 74 | const eventBefore = `${type}:before-create`; 75 | const eventAfter = `${type}:after-create`; 76 | return ({ definition }) => { 77 | this.dispatchEvent(eventBefore, { definition }); 78 | const drawing = factory({ definition }); 79 | this.dispatchEvent(eventAfter, { [type]: drawing, definition }); 80 | draws.set(definition['@id'], drawing); 81 | return drawing; 82 | }; 83 | } 84 | onDrawChanged(draws, newDrawDefinitions, factory, remover) { 85 | const idsToRemove = new Set(draws.keys()); 86 | newDrawDefinitions.forEach((definition) => { 87 | idsToRemove.delete(definition['@id']); 88 | }); 89 | idsToRemove.forEach((id) => { 90 | const draw = draws.get(id); 91 | remover(draw); 92 | draws.delete(id); 93 | }); 94 | newDrawDefinitions.forEach((definition) => { 95 | if (!draws.has(definition['@id'])) { 96 | factory({ definition }); 97 | } 98 | }); 99 | } 100 | } 101 | default_1.values = { 102 | providerOptions: Object, 103 | center: Object, 104 | zoom: Number, 105 | fitBoundsToMarkers: Boolean, 106 | markers: Array, 107 | polygons: Array, 108 | polylines: Array, 109 | options: Object, 110 | }; 111 | 112 | class map_controller extends default_1 { 113 | connect() { 114 | L.Marker.prototype.options.icon = L.divIcon({ 115 | html: '', 116 | iconSize: [25, 41], 117 | iconAnchor: [12.5, 41], 118 | popupAnchor: [0, -41], 119 | className: '', 120 | }); 121 | super.connect(); 122 | } 123 | centerValueChanged() { 124 | if (this.map && this.hasCenterValue && this.centerValue && this.hasZoomValue && this.zoomValue) { 125 | this.map.setView(this.centerValue, this.zoomValue); 126 | } 127 | } 128 | zoomValueChanged() { 129 | if (this.map && this.hasZoomValue && this.zoomValue) { 130 | this.map.setZoom(this.zoomValue); 131 | } 132 | } 133 | dispatchEvent(name, payload = {}) { 134 | this.dispatch(name, { 135 | prefix: 'ux:map', 136 | detail: { 137 | ...payload, 138 | L, 139 | }, 140 | }); 141 | } 142 | doCreateMap({ center, zoom, options, }) { 143 | const map = L.map(this.element, { 144 | ...options, 145 | center: center === null ? undefined : center, 146 | zoom: zoom === null ? undefined : zoom, 147 | }); 148 | if (options.tileLayer) { 149 | L.tileLayer(options.tileLayer.url, { 150 | attribution: options.tileLayer.attribution, 151 | ...options.tileLayer.options, 152 | }).addTo(map); 153 | } 154 | return map; 155 | } 156 | doCreateMarker({ definition }) { 157 | const { '@id': _id, position, title, infoWindow, icon, extra, rawOptions = {}, ...otherOptions } = definition; 158 | const marker = L.marker(position, { title: title || undefined, ...otherOptions, ...rawOptions }).addTo(this.map); 159 | if (infoWindow) { 160 | this.createInfoWindow({ definition: infoWindow, element: marker }); 161 | } 162 | if (icon) { 163 | this.doCreateIcon({ definition: icon, element: marker }); 164 | } 165 | return marker; 166 | } 167 | doRemoveMarker(marker) { 168 | marker.remove(); 169 | } 170 | doCreatePolygon({ definition, }) { 171 | const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; 172 | const polygon = L.polygon(points, { ...rawOptions }).addTo(this.map); 173 | if (title) { 174 | polygon.bindPopup(title); 175 | } 176 | if (infoWindow) { 177 | this.createInfoWindow({ definition: infoWindow, element: polygon }); 178 | } 179 | return polygon; 180 | } 181 | doRemovePolygon(polygon) { 182 | polygon.remove(); 183 | } 184 | doCreatePolyline({ definition, }) { 185 | const { '@id': _id, points, title, infoWindow, rawOptions = {} } = definition; 186 | const polyline = L.polyline(points, { ...rawOptions }).addTo(this.map); 187 | if (title) { 188 | polyline.bindPopup(title); 189 | } 190 | if (infoWindow) { 191 | this.createInfoWindow({ definition: infoWindow, element: polyline }); 192 | } 193 | return polyline; 194 | } 195 | doRemovePolyline(polyline) { 196 | polyline.remove(); 197 | } 198 | doCreateInfoWindow({ definition, element, }) { 199 | const { headerContent, content, rawOptions = {}, ...otherOptions } = definition; 200 | element.bindPopup([headerContent, content].filter((x) => x).join('
'), { ...otherOptions, ...rawOptions }); 201 | if (definition.opened) { 202 | element.openPopup(); 203 | } 204 | const popup = element.getPopup(); 205 | if (!popup) { 206 | throw new Error('Unable to get the Popup associated with the element.'); 207 | } 208 | return popup; 209 | } 210 | doCreateIcon({ definition, element, }) { 211 | const { type, width, height } = definition; 212 | let icon; 213 | if (type === IconTypes.Svg) { 214 | icon = L.divIcon({ 215 | html: definition.html, 216 | iconSize: [width, height], 217 | className: '', 218 | }); 219 | } 220 | else if (type === IconTypes.UxIcon) { 221 | icon = L.divIcon({ 222 | html: definition._generated_html, 223 | iconSize: [width, height], 224 | className: '', 225 | }); 226 | } 227 | else if (type === IconTypes.Url) { 228 | icon = L.icon({ 229 | iconUrl: definition.url, 230 | iconSize: [width, height], 231 | className: '', 232 | }); 233 | } 234 | else { 235 | throw new Error(`Unsupported icon type: ${type}.`); 236 | } 237 | element.setIcon(icon); 238 | } 239 | doFitBoundsToMarkers() { 240 | if (this.markers.size === 0) { 241 | return; 242 | } 243 | const bounds = []; 244 | this.markers.forEach((marker) => { 245 | const position = marker.getLatLng(); 246 | bounds.push([position.lat, position.lng]); 247 | }); 248 | this.map.fitBounds(bounds); 249 | } 250 | } 251 | 252 | export { map_controller as default }; 253 | -------------------------------------------------------------------------------- /assets/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@symfony/ux-leaflet-map", 3 | "description": "Leaflet bridge for Symfony UX Map, integrate interactive maps in your Symfony applications", 4 | "license": "MIT", 5 | "version": "2.26.1", 6 | "keywords": [ 7 | "symfony-ux", 8 | "leaflet", 9 | "map" 10 | ], 11 | "homepage": "https://ux.symfony.com/map", 12 | "repository": "https://github.com/symfony/ux-leaflet-map", 13 | "type": "module", 14 | "files": [ 15 | "dist" 16 | ], 17 | "main": "dist/map_controller.js", 18 | "types": "dist/map_controller.d.ts", 19 | "scripts": { 20 | "build": "node ../../../../../../bin/build_package.js .", 21 | "watch": "node ../../../../../../bin/build_package.js . --watch", 22 | "test": "../../../../../../bin/test_package.sh .", 23 | "check": "biome check", 24 | "ci": "biome ci" 25 | }, 26 | "symfony": { 27 | "controllers": { 28 | "map": { 29 | "main": "dist/map_controller.js", 30 | "webpackMode": "lazy", 31 | "fetch": "lazy", 32 | "enabled": true 33 | } 34 | }, 35 | "importmap": { 36 | "@hotwired/stimulus": "^3.0.0", 37 | "leaflet": "^1.9.4", 38 | "@symfony/ux-leaflet-map": "path:%PACKAGE%/dist/map_controller.js" 39 | } 40 | }, 41 | "peerDependencies": { 42 | "@hotwired/stimulus": "^3.0.0", 43 | "leaflet": "^1.9.4" 44 | }, 45 | "peerDependenciesMeta": { 46 | "leaflet": { 47 | "optional": false 48 | } 49 | }, 50 | "devDependencies": { 51 | "@hotwired/stimulus": "^3.0.0", 52 | "@symfony/ux-map": "workspace:*", 53 | "@types/leaflet": "^1.9.12", 54 | "leaflet": "^1.9.4" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /assets/vitest.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, mergeConfig } from 'vitest/config'; 2 | import configShared from '../../../../../../vitest.config.mjs' 3 | 4 | export default mergeConfig( 5 | configShared, 6 | defineConfig({ 7 | resolve: { 8 | alias: { 9 | '@symfony/ux-map': __dirname + '/../../../../assets/src/abstract_map_controller.ts', 10 | 'leaflet/dist/leaflet.min.css': 'leaflet/dist/leaflet.css', 11 | }, 12 | }, 13 | define: { 14 | // Prevent the following error: 15 | // ReferenceError: global is not defined 16 | // ❯ ../../../../../../node_modules/pretty-format/build/plugins/AsymmetricMatcher.js ../../../../../../../../../../node_modules/.vite/deps/@testing-library_dom.js:139:19 17 | // ❯ ../../../../../../node_modules/pretty-format/build/index.js ../../../../../../../../../../node_modules/.vite/deps/@testing-library_dom.js:805:7 18 | // ❯ ../../../../../../../../../../node_modules/.vite/deps/@testing-library_dom.js:13445:36 19 | global: {} 20 | }, 21 | test: { 22 | browser: { 23 | enabled: true, 24 | provider: 'playwright', // or 'webdriverio' 25 | name: 'chromium', // browser name is required 26 | headless: true, 27 | }, 28 | }, 29 | }) 30 | ); 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/ux-leaflet-map", 3 | "type": "symfony-ux-map-bridge", 4 | "description": "Symfony UX Map Leaflet Bridge", 5 | "keywords": ["symfony-ux", "leaflet", "map"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Hugo Alliaume", 11 | "email": "hugo@alliau.me" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1", 20 | "symfony/stimulus-bundle": "^2.18.1", 21 | "symfony/ux-map": "^2.19" 22 | }, 23 | "require-dev": { 24 | "symfony/phpunit-bridge": "^7.2", 25 | "symfony/ux-icons": "^2.18", 26 | "spatie/phpunit-snapshot-assertions": "^4.2.17", 27 | "phpunit/phpunit": "^9.6.22" 28 | }, 29 | "autoload": { 30 | "psr-4": { "Symfony\\UX\\Map\\Bridge\\Leaflet\\": "src/" }, 31 | "exclude-from-classmap": [] 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { "Symfony\\UX\\Map\\Bridge\\Leaflet\\Tests\\": "tests/" } 35 | }, 36 | "minimum-stability": "dev" 37 | } 38 | -------------------------------------------------------------------------------- /src/LeafletOptions.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Map\Bridge\Leaflet; 13 | 14 | use Symfony\UX\Map\Bridge\Leaflet\Option\TileLayer; 15 | use Symfony\UX\Map\MapOptionsInterface; 16 | 17 | /** 18 | * @author Hugo Alliaume 19 | */ 20 | final class LeafletOptions implements MapOptionsInterface 21 | { 22 | public function __construct( 23 | private TileLayer|false $tileLayer = new TileLayer( 24 | url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 25 | attribution: '© OpenStreetMap', 26 | ), 27 | ) { 28 | } 29 | 30 | public function tileLayer(TileLayer|false $tileLayer): self 31 | { 32 | $this->tileLayer = $tileLayer; 33 | 34 | return $this; 35 | } 36 | 37 | /** 38 | * @internal 39 | */ 40 | public static function fromArray(array $array): MapOptionsInterface 41 | { 42 | return new self( 43 | tileLayer: $array['tileLayer'] ? TileLayer::fromArray($array['tileLayer']) : false, 44 | ); 45 | } 46 | 47 | /** 48 | * @internal 49 | */ 50 | public function toArray(): array 51 | { 52 | return [ 53 | 'tileLayer' => $this->tileLayer ? $this->tileLayer->toArray() : false, 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Option/TileLayer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Map\Bridge\Leaflet\Option; 13 | 14 | /** 15 | * Represents a tile layer for a Leaflet map. 16 | * 17 | * @see https://leafletjs.com/reference.html#tilelayer 18 | * 19 | * @author Hugo Alliaume 20 | */ 21 | final class TileLayer 22 | { 23 | /** 24 | * @param array $options 25 | */ 26 | public function __construct( 27 | private readonly string $url, 28 | private readonly string $attribution, 29 | private readonly array $options = [], 30 | ) { 31 | } 32 | 33 | /** 34 | * @internal 35 | */ 36 | public static function fromArray(array $array): self 37 | { 38 | return new self( 39 | url: $array['url'], 40 | attribution: $array['attribution'], 41 | options: $array['options'], 42 | ); 43 | } 44 | 45 | /** 46 | * @internal 47 | */ 48 | public function toArray(): array 49 | { 50 | return [ 51 | 'url' => $this->url, 52 | 'attribution' => $this->attribution, 53 | 'options' => $this->options, 54 | ]; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Renderer/LeafletRenderer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Map\Bridge\Leaflet\Renderer; 13 | 14 | use Symfony\UX\Map\Bridge\Leaflet\LeafletOptions; 15 | use Symfony\UX\Map\MapOptionsInterface; 16 | use Symfony\UX\Map\Renderer\AbstractRenderer; 17 | 18 | /** 19 | * @author Hugo Alliaume 20 | * 21 | * @internal 22 | */ 23 | final class LeafletRenderer extends AbstractRenderer 24 | { 25 | protected function getName(): string 26 | { 27 | return 'leaflet'; 28 | } 29 | 30 | protected function getProviderOptions(): array 31 | { 32 | return []; 33 | } 34 | 35 | protected function getDefaultMapOptions(): MapOptionsInterface 36 | { 37 | return new LeafletOptions(); 38 | } 39 | 40 | public function __toString(): string 41 | { 42 | return 'leaflet://default'; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Renderer/LeafletRendererFactory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\UX\Map\Bridge\Leaflet\Renderer; 13 | 14 | use Symfony\UX\Map\Exception\UnsupportedSchemeException; 15 | use Symfony\UX\Map\Renderer\AbstractRendererFactory; 16 | use Symfony\UX\Map\Renderer\Dsn; 17 | use Symfony\UX\Map\Renderer\RendererFactoryInterface; 18 | use Symfony\UX\Map\Renderer\RendererInterface; 19 | 20 | /** 21 | * @author Hugo Alliaume 22 | */ 23 | final class LeafletRendererFactory extends AbstractRendererFactory implements RendererFactoryInterface 24 | { 25 | public function create(Dsn $dsn): RendererInterface 26 | { 27 | if (!$this->supports($dsn)) { 28 | throw new UnsupportedSchemeException($dsn); 29 | } 30 | 31 | return new LeafletRenderer($this->stimulus, $this->uxIconRenderer); 32 | } 33 | 34 | protected function getSupportedSchemes(): array 35 | { 36 | return ['leaflet']; 37 | } 38 | } 39 | --------------------------------------------------------------------------------