Skip to content

Commit 9e6920d

Browse files
committed
feature #2044 [Map] Add support for libraries for Google Bridge, inject provider's SDK (L or google) to dispatched events (Kocal)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [Map] Add support for `libraries` for Google Bridge, inject provider's SDK (`L` or `google`) to dispatched events | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes <!-- please update src/**/CHANGELOG.md files --> | Issues | Fix #... <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT <!-- Replace this notice by a description of your feature/bugfix. This will help reviewers and should be a good start for the documentation. Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - For new features, provide some code snippets to help understand usage. - Features and deprecations must be submitted against branch main. - Changelog entry should follow https://symfony.com/doc/current/contributing/code/conventions.html#writing-a-changelog-entry - Never break backward compatibility (see https://symfony.com/bc). --> Follows #2040. This PR gives the developper access to `L` (if using Leaflet) or `google` (if using Google Maps) in dispatched events, so the developper can **fully and freely** customize the map, their markers (before and after creation), and info windows (before and after creation). I've added some use cases/examples in respective documentation, but tell me if a better place fits! Also, please no quick merge this time (even if I like that :D), I really wants some reviews for this PR! cc `@javiereguiluz` 🙏🏻 # Code and screenshots On my personal project, I have a map with a lot of markers. Before UX Map, I was using Google Maps because I've found the "glyph" feature really sexy, but I was not able to use it anymore... until now! ## With Google Maps Code: ```js import {Controller} from "`@hotwired`/stimulus"; export default class extends Controller { connect() { this.element.addEventListener('ux:map:marker:before-create', this._onMarkerBeforeCreate); } disconnect() { this.element.removeEventListener('ux:map:marker:before-create', this._onMarkerBeforeCreate); } _onMarkerBeforeCreate(event) { const { definition, google } = event.detail; const pinElement = new google.maps.marker.PinElement({ glyph: new URL(String(definition.extra['icon_mask_uri'])), // I've filled `extra` parameter from `new Marker()` (PHP) with the icon mask URL glyphColor: "white", }); definition.rawOptions = { content: pinElement.element, } } } ``` Screenshot: <img width="470" alt="Capture d’écran 2024-08-09 à 22 58 19" src="https://github.com/user-attachments/assets/ace5a033-a423-45c5-bd67-0da68dd188c1"> ## With Leaflet A dumb example taken from the website: Code: ```js import {Controller} from "`@hotwired`/stimulus"; export default class extends Controller { connect() { this.element.addEventListener('ux:map:marker:before-create', this._onMarkerBeforeCreate); } disconnect() { this.element.removeEventListener('ux:map:marker:before-create', this._onMarkerBeforeCreate); } _onMarkerBeforeCreate(event) { const { definition, L } = event.detail; const redIcon = L.icon({ iconUrl: 'https://leafletjs.com/examples/custom-icons/leaf-red.png', shadowUrl: 'https://leafletjs.com/examples/custom-icons/leaf-shadow.png', iconSize: [38, 95], // size of the icon shadowSize: [50, 64], // size of the shadow iconAnchor: [22, 94], // point of the icon which will correspond to marker's location shadowAnchor: [4, 62], // the same for the shadow popupAnchor: [-3, -76] // point from which the popup should open relative to the iconAnchor }) definition.rawOptions = { icon: redIcon, } } } ``` Screenshot: <img width="495" alt="Capture d’écran 2024-08-09 à 23 19 23" src="https://github.com/user-attachments/assets/e771c133-794a-4693-bfbb-5a3118f5f7f5"> Commits ------- 2dbb169 [Map] Add support for `libraries` for Google Bridge, inject provider's SDK (`L` or `google`) to dispatched events
2 parents c3e42ab + 2dbb169 commit 9e6920d

16 files changed

+261
-86
lines changed

src/Map/assets/dist/abstract_map_controller.d.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export default abstract class<MapOptions, Map, MarkerOptions, Marker, InfoWindow
3535
protected map: Map;
3636
protected markers: Array<Marker>;
3737
protected infoWindows: Array<InfoWindow>;
38-
initialize(): void;
3938
connect(): void;
4039
protected abstract doCreateMap({ center, zoom, options, }: {
4140
center: Point | null;
@@ -53,5 +52,5 @@ export default abstract class<MapOptions, Map, MarkerOptions, Marker, InfoWindow
5352
marker: Marker;
5453
}): InfoWindow;
5554
protected abstract doFitBoundsToMarkers(): void;
56-
private dispatchEvent;
55+
protected abstract dispatchEvent(name: string, payload: Record<string, unknown>): void;
5756
}

src/Map/assets/dist/abstract_map_controller.js

-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ class default_1 extends Controller {
66
this.markers = [];
77
this.infoWindows = [];
88
}
9-
initialize() { }
109
connect() {
1110
const { center, zoom, options, markers, fitBoundsToMarkers } = this.viewValue;
1211
this.dispatchEvent('pre-connect', { options });
@@ -35,9 +34,6 @@ class default_1 extends Controller {
3534
this.infoWindows.push(infoWindow);
3635
return infoWindow;
3736
}
38-
dispatchEvent(name, payload = {}) {
39-
this.dispatch(name, { prefix: 'ux:map', detail: payload });
40-
}
4137
}
4238
default_1.values = {
4339
providerOptions: Object,

src/Map/assets/src/abstract_map_controller.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,6 @@ export default abstract class<
6666
protected markers: Array<Marker> = [];
6767
protected infoWindows: Array<InfoWindow> = [];
6868

69-
initialize() {}
70-
7169
connect() {
7270
const { center, zoom, options, markers, fitBoundsToMarkers } = this.viewValue;
7371

@@ -136,7 +134,5 @@ export default abstract class<
136134

137135
protected abstract doFitBoundsToMarkers(): void;
138136

139-
private dispatchEvent(name: string, payload: Record<string, unknown> = {}): void {
140-
this.dispatch(name, { prefix: 'ux:map', detail: payload });
141-
}
137+
protected abstract dispatchEvent(name: string, payload: Record<string, unknown>): void;
142138
}

src/Map/assets/test/abstract_map_controller.test.ts

+8
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@ import { Application } from '@hotwired/stimulus';
22
import { getByTestId, waitFor } from '@testing-library/dom';
33
import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
44
import AbstractMapController from '../src/abstract_map_controller.ts';
5+
import * as L from 'leaflet';
56

67
class MyMapController extends AbstractMapController {
8+
protected dispatchEvent(name: string, payload: Record<string, unknown> = {}): void {
9+
this.dispatch(name, {
10+
prefix: 'ux:map',
11+
detail: payload,
12+
});
13+
}
14+
715
doCreateMap({ center, zoom, options }) {
816
return { map: 'map', center, zoom, options };
917
}

src/Map/src/Bridge/Google/README.md

+65-9
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,21 @@ UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default
1010
# With options
1111
UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?version=weekly
1212
UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?language=fr&region=FR
13+
UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default??libraries[]=geometry&libraries[]=places
1314
```
1415

1516
Available options:
1617

17-
| Option | Description | Default |
18-
|------------|------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------|
19-
| `id` | The id of the script tag | `__googleMapsScriptId` |
20-
| `language` | Force language, see [list of supported languages](https://developers.google.com/maps/faq#languagesupport) specified in the browser | The user's preferred language |
21-
| `region` | Unicode region subtag identifiers compatible with [ISO 3166-1](https://en.wikipedia.org/wiki/ISO_3166-1) | |
22-
| `nonce` | Use a cryptographic nonce attribute | |
23-
| `retries` | The number of script load retries | 3 |
24-
| `url` | Custom url to load the Google Maps API script | `https://maps.googleapis.com/maps/api/js` |
25-
| `version` | The release channels or version numbers | `weekly` |
18+
| Option | Description | Default |
19+
|-------------|------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------|
20+
| `id` | The id of the script tag | `__googleMapsScriptId` |
21+
| `language` | Force language, see [list of supported languages](https://developers.google.com/maps/faq#languagesupport) specified in the browser | The user's preferred language |
22+
| `region` | Unicode region subtag identifiers compatible with [ISO 3166-1](https://en.wikipedia.org/wiki/ISO_3166-1) | |
23+
| `nonce` | Use a cryptographic nonce attribute | |
24+
| `retries` | The number of script load retries | 3 |
25+
| `url` | Custom url to load the Google Maps API script | `https://maps.googleapis.com/maps/api/js` |
26+
| `version` | The release channels or version numbers | `weekly` |
27+
| `libraries` | The additional libraries to load, see [list of supported libraries](https://googlemaps.github.io/js-api-loader/types/Library.html) | `['maps', 'marker']`, those two libraries are always loaded |
2628

2729
## Map options
2830

@@ -78,6 +80,60 @@ $googleOptions = (new GoogleOptions())
7880
// Add the custom options to the map
7981
$map->options($googleOptions);
8082
```
83+
## Use cases
84+
85+
Below are some common or advanced use cases when using a map.
86+
87+
### Customize the marker
88+
89+
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.
90+
91+
Assuming you have a map with a custom controller:
92+
```twig
93+
{{ render_map(map, {'data-controller': 'my-map' }) }}
94+
```
95+
96+
You can create a Stimulus controller to customize the markers before they are created:
97+
```js
98+
// assets/controllers/my_map_controller.js
99+
import {Controller} from "@hotwired/stimulus";
100+
101+
export default class extends Controller
102+
{
103+
connect() {
104+
this.element.addEventListener('ux:map:marker:before-create', this._onMarkerBeforeCreate);
105+
}
106+
107+
disconnect() {
108+
// Always remove listeners when the controller is disconnected
109+
this.element.removeEventListener('ux:map:marker:before-create', this._onMarkerBeforeCreate);
110+
}
111+
112+
_onMarkerBeforeCreate(event) {
113+
// You can access the marker definition and the google object
114+
// Note: `definition.rawOptions` is the raw options object that will be passed to the `google.maps.Marker` constructor.
115+
const { definition, google } = event.detail;
116+
117+
// 1. To use a custom image for the marker
118+
const beachFlagImg = document.createElement("img");
119+
// Note: instead of using an hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`.
120+
beachFlagImg.src = "https://developers.google.com/maps/documentation/javascript/examples/full/images/beachflag.png";
121+
definition.rawOptions = {
122+
content: beachFlagImg
123+
}
124+
125+
// 2. To use a custom glyph for the marker
126+
const pinElement = new google.maps.marker.PinElement({
127+
// Note: instead of using an hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`.
128+
glyph: new URL('https://maps.gstatic.com/mapfiles/place_api/icons/v2/museum_pinlet.svg'),
129+
glyphColor: "white",
130+
});
131+
definition.rawOptions = {
132+
content: pinElement.element,
133+
}
134+
}
135+
}
136+
```
81137

82138
## Resources
83139

src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ export default class extends AbstractMapController<MapOptions, google.maps.Map,
77
static values: {
88
providerOptions: ObjectConstructor;
99
};
10-
providerOptionsValue: Pick<LoaderOptions, 'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version'>;
10+
providerOptionsValue: Pick<LoaderOptions, 'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version' | 'libraries'>;
1111
connect(): Promise<void>;
12+
protected dispatchEvent(name: string, payload?: Record<string, unknown>): void;
1213
protected doCreateMap({ center, zoom, options, }: {
1314
center: Point | null;
1415
zoom: number | null;

src/Map/src/Bridge/Google/assets/dist/map_controller.js

+28-10
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,50 @@
11
import AbstractMapController from '@symfony/ux-map/abstract-map-controller';
22
import { Loader } from '@googlemaps/js-api-loader';
33

4-
let loader;
5-
let library;
4+
let _google;
65
class default_1 extends AbstractMapController {
76
async connect() {
8-
if (!loader) {
9-
loader = new Loader(this.providerOptionsValue);
7+
if (!_google) {
8+
_google = { maps: {} };
9+
let { libraries = [], ...loaderOptions } = this.providerOptionsValue;
10+
const loader = new Loader(loaderOptions);
11+
libraries = ['core', ...libraries.filter((library) => library !== 'core')];
12+
const librariesImplementations = await Promise.all(libraries.map((library) => loader.importLibrary(library)));
13+
librariesImplementations.map((libraryImplementation, index) => {
14+
const library = libraries[index];
15+
if (['marker', 'places', 'geometry', 'journeySharing', 'drawing', 'visualization'].includes(library)) {
16+
_google.maps[library] = libraryImplementation;
17+
}
18+
else {
19+
_google.maps = { ..._google.maps, ...libraryImplementation };
20+
}
21+
});
1022
}
11-
const { Map: _Map, InfoWindow } = await loader.importLibrary('maps');
12-
const { AdvancedMarkerElement } = await loader.importLibrary('marker');
13-
library = { _Map, AdvancedMarkerElement, InfoWindow };
1423
super.connect();
1524
}
25+
dispatchEvent(name, payload = {}) {
26+
this.dispatch(name, {
27+
prefix: 'ux:map',
28+
detail: {
29+
...payload,
30+
google: _google,
31+
},
32+
});
33+
}
1634
doCreateMap({ center, zoom, options, }) {
1735
options.zoomControl = typeof options.zoomControlOptions !== 'undefined';
1836
options.mapTypeControl = typeof options.mapTypeControlOptions !== 'undefined';
1937
options.streetViewControl = typeof options.streetViewControlOptions !== 'undefined';
2038
options.fullscreenControl = typeof options.fullscreenControlOptions !== 'undefined';
21-
return new library._Map(this.element, {
39+
return new _google.maps.Map(this.element, {
2240
...options,
2341
center,
2442
zoom,
2543
});
2644
}
2745
doCreateMarker(definition) {
2846
const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition;
29-
const marker = new library.AdvancedMarkerElement({
47+
const marker = new _google.maps.marker.AdvancedMarkerElement({
3048
position,
3149
title,
3250
...otherOptions,
@@ -40,7 +58,7 @@ class default_1 extends AbstractMapController {
4058
}
4159
doCreateInfoWindow({ definition, marker, }) {
4260
const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition;
43-
const infoWindow = new library.InfoWindow({
61+
const infoWindow = new _google.maps.InfoWindow({
4462
headerContent: this.createTextOrElement(headerContent),
4563
content: this.createTextOrElement(content),
4664
...otherOptions,

src/Map/src/Bridge/Google/assets/src/map_controller.ts

+39-16
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,7 @@ type MapOptions = Pick<
2828
| 'fullscreenControlOptions'
2929
>;
3030

31-
let loader: Loader;
32-
let library: {
33-
_Map: typeof google.maps.Map;
34-
AdvancedMarkerElement: typeof google.maps.marker.AdvancedMarkerElement;
35-
InfoWindow: typeof google.maps.InfoWindow;
36-
};
31+
let _google: typeof google;
3732

3833
export default class extends AbstractMapController<
3934
MapOptions,
@@ -47,21 +42,49 @@ export default class extends AbstractMapController<
4742

4843
declare providerOptionsValue: Pick<
4944
LoaderOptions,
50-
'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version'
45+
'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version' | 'libraries'
5146
>;
5247

5348
async connect() {
54-
if (!loader) {
55-
loader = new Loader(this.providerOptionsValue);
49+
if (!_google) {
50+
_google = { maps: {} };
51+
52+
let { libraries = [], ...loaderOptions } = this.providerOptionsValue;
53+
54+
const loader = new Loader(loaderOptions);
55+
56+
// We could have used `loader.load()` to correctly load libraries, but this method is deprecated in favor of `loader.importLibrary()`.
57+
// But `loader.importLibrary()` is not a 1-1 replacement for `loader.load()`, we need to re-build the `google.maps` object ourselves,
58+
// see https://github.com/googlemaps/js-api-loader/issues/837 for more information.
59+
libraries = ['core', ...libraries.filter((library) => library !== 'core')]; // Ensure 'core' is loaded first
60+
const librariesImplementations = await Promise.all(
61+
libraries.map((library) => loader.importLibrary(library))
62+
);
63+
librariesImplementations.map((libraryImplementation, index) => {
64+
const library = libraries[index];
65+
66+
// The following libraries are in a sub-namespace
67+
if (['marker', 'places', 'geometry', 'journeySharing', 'drawing', 'visualization'].includes(library)) {
68+
_google.maps[library] = libraryImplementation;
69+
} else {
70+
_google.maps = { ..._google.maps, ...libraryImplementation };
71+
}
72+
});
5673
}
5774

58-
const { Map: _Map, InfoWindow } = await loader.importLibrary('maps');
59-
const { AdvancedMarkerElement } = await loader.importLibrary('marker');
60-
library = { _Map, AdvancedMarkerElement, InfoWindow };
61-
6275
super.connect();
6376
}
6477

78+
protected dispatchEvent(name: string, payload: Record<string, unknown> = {}): void {
79+
this.dispatch(name, {
80+
prefix: 'ux:map',
81+
detail: {
82+
...payload,
83+
google: _google,
84+
},
85+
});
86+
}
87+
6588
protected doCreateMap({
6689
center,
6790
zoom,
@@ -77,7 +100,7 @@ export default class extends AbstractMapController<
77100
options.streetViewControl = typeof options.streetViewControlOptions !== 'undefined';
78101
options.fullscreenControl = typeof options.fullscreenControlOptions !== 'undefined';
79102

80-
return new library._Map(this.element, {
103+
return new _google.maps.Map(this.element, {
81104
...options,
82105
center,
83106
zoom,
@@ -89,7 +112,7 @@ export default class extends AbstractMapController<
89112
): google.maps.marker.AdvancedMarkerElement {
90113
const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition;
91114

92-
const marker = new library.AdvancedMarkerElement({
115+
const marker = new _google.maps.marker.AdvancedMarkerElement({
93116
position,
94117
title,
95118
...otherOptions,
@@ -116,7 +139,7 @@ export default class extends AbstractMapController<
116139
}): google.maps.InfoWindow {
117140
const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition;
118141

119-
const infoWindow = new library.InfoWindow({
142+
const infoWindow = new _google.maps.InfoWindow({
120143
headerContent: this.createTextOrElement(headerContent),
121144
content: this.createTextOrElement(content),
122145
...otherOptions,

src/Map/src/Bridge/Google/assets/test/map_controller.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('GoogleMapsController', () => {
4040
data-testid="map"
4141
data-controller="check google"
4242
style="height&#x3A;&#x20;700px&#x3B;&#x20;margin&#x3A;&#x20;10px"
43-
data-google-provider-options-value="&#x7B;&quot;language&quot;&#x3A;&quot;fr&quot;,&quot;region&quot;&#x3A;&quot;FR&quot;,&quot;retries&quot;&#x3A;10,&quot;version&quot;&#x3A;&quot;weekly&quot;,&quot;apiKey&quot;&#x3A;&quot;&quot;&#x7D;"
43+
data-google-provider-options-value="&#x7B;&quot;version&quot;&#x3A;&quot;weekly&quot;,&quot;libraries&quot;&#x3A;&#x5B;&quot;maps&quot;,&quot;marker&quot;&#x5D;,&quot;apiKey&quot;&#x3A;&quot;&quot;&#x7D;"
4444
data-google-view-value="&#x7B;&quot;center&quot;&#x3A;&#x7B;&quot;lat&quot;&#x3A;48.8566,&quot;lng&quot;&#x3A;2.3522&#x7D;,&quot;zoom&quot;&#x3A;4,&quot;fitBoundsToMarkers&quot;&#x3A;true,&quot;options&quot;&#x3A;&#x7B;&quot;mapId&quot;&#x3A;&quot;YOUR_MAP_ID&quot;,&quot;gestureHandling&quot;&#x3A;&quot;auto&quot;,&quot;backgroundColor&quot;&#x3A;null,&quot;disableDoubleClickZoom&quot;&#x3A;false,&quot;zoomControl&quot;&#x3A;true,&quot;zoomControlOptions&quot;&#x3A;&#x7B;&quot;position&quot;&#x3A;22&#x7D;,&quot;mapTypeControl&quot;&#x3A;true,&quot;mapTypeControlOptions&quot;&#x3A;&#x7B;&quot;mapTypeIds&quot;&#x3A;&#x5B;&#x5D;,&quot;position&quot;&#x3A;14,&quot;style&quot;&#x3A;0&#x7D;,&quot;streetViewControl&quot;&#x3A;true,&quot;streetViewControlOptions&quot;&#x3A;&#x7B;&quot;position&quot;&#x3A;22&#x7D;,&quot;fullscreenControl&quot;&#x3A;true,&quot;fullscreenControlOptions&quot;&#x3A;&#x7B;&quot;position&quot;&#x3A;20&#x7D;&#x7D;,&quot;markers&quot;&#x3A;&#x5B;&#x7B;&quot;position&quot;&#x3A;&#x7B;&quot;lat&quot;&#x3A;48.8566,&quot;lng&quot;&#x3A;2.3522&#x7D;,&quot;title&quot;&#x3A;&quot;Paris&quot;,&quot;infoWindow&quot;&#x3A;null&#x7D;,&#x7B;&quot;position&quot;&#x3A;&#x7B;&quot;lat&quot;&#x3A;45.764,&quot;lng&quot;&#x3A;4.8357&#x7D;,&quot;title&quot;&#x3A;&quot;Lyon&quot;,&quot;infoWindow&quot;&#x3A;&#x7B;&quot;headerContent&quot;&#x3A;&quot;&lt;b&gt;Lyon&lt;&#x5C;&#x2F;b&gt;&quot;,&quot;content&quot;&#x3A;&quot;The&#x20;French&#x20;town&#x20;in&#x20;the&#x20;historic&#x20;Rh&#x5C;u00f4ne-Alpes&#x20;region,&#x20;located&#x20;at&#x20;the&#x20;junction&#x20;of&#x20;the&#x20;Rh&#x5C;u00f4ne&#x20;and&#x20;Sa&#x5C;u00f4ne&#x20;rivers.&quot;,&quot;position&quot;&#x3A;null,&quot;opened&quot;&#x3A;false,&quot;autoClose&quot;&#x3A;true&#x7D;&#x7D;&#x5D;&#x7D;"
4545
></div>
4646
`);

0 commit comments

Comments
 (0)