Skip to content

[Map] Add support for libraries for Google Bridge, inject provider's SDK (L or google) to dispatched events #2044

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Aug 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/Map/assets/dist/abstract_map_controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ export default abstract class<MapOptions, Map, MarkerOptions, Marker, InfoWindow
protected map: Map;
protected markers: Array<Marker>;
protected infoWindows: Array<InfoWindow>;
initialize(): void;
connect(): void;
protected abstract doCreateMap({ center, zoom, options, }: {
center: Point | null;
Expand All @@ -53,5 +52,5 @@ export default abstract class<MapOptions, Map, MarkerOptions, Marker, InfoWindow
marker: Marker;
}): InfoWindow;
protected abstract doFitBoundsToMarkers(): void;
private dispatchEvent;
protected abstract dispatchEvent(name: string, payload: Record<string, unknown>): void;
}
4 changes: 0 additions & 4 deletions src/Map/assets/dist/abstract_map_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ class default_1 extends Controller {
this.markers = [];
this.infoWindows = [];
}
initialize() { }
connect() {
const { center, zoom, options, markers, fitBoundsToMarkers } = this.viewValue;
this.dispatchEvent('pre-connect', { options });
Expand Down Expand Up @@ -35,9 +34,6 @@ class default_1 extends Controller {
this.infoWindows.push(infoWindow);
return infoWindow;
}
dispatchEvent(name, payload = {}) {
this.dispatch(name, { prefix: 'ux:map', detail: payload });
}
}
default_1.values = {
providerOptions: Object,
Expand Down
6 changes: 1 addition & 5 deletions src/Map/assets/src/abstract_map_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,6 @@ export default abstract class<
protected markers: Array<Marker> = [];
protected infoWindows: Array<InfoWindow> = [];

initialize() {}

connect() {
const { center, zoom, options, markers, fitBoundsToMarkers } = this.viewValue;

Expand Down Expand Up @@ -136,7 +134,5 @@ export default abstract class<

protected abstract doFitBoundsToMarkers(): void;

private dispatchEvent(name: string, payload: Record<string, unknown> = {}): void {
this.dispatch(name, { prefix: 'ux:map', detail: payload });
}
protected abstract dispatchEvent(name: string, payload: Record<string, unknown>): void;
}
8 changes: 8 additions & 0 deletions src/Map/assets/test/abstract_map_controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@ import { Application } from '@hotwired/stimulus';
import { getByTestId, waitFor } from '@testing-library/dom';
import { clearDOM, mountDOM } from '@symfony/stimulus-testing';
import AbstractMapController from '../src/abstract_map_controller.ts';
import * as L from 'leaflet';

class MyMapController extends AbstractMapController {
protected dispatchEvent(name: string, payload: Record<string, unknown> = {}): void {
this.dispatch(name, {
prefix: 'ux:map',
detail: payload,
});
}

doCreateMap({ center, zoom, options }) {
return { map: 'map', center, zoom, options };
}
Expand Down
74 changes: 65 additions & 9 deletions src/Map/src/Bridge/Google/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,21 @@ UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default
# With options
UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?version=weekly
UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default?language=fr&region=FR
UX_MAP_DSN=google://GOOGLE_MAPS_API_KEY@default??libraries[]=geometry&libraries[]=places
```

Available options:

| Option | Description | Default |
|------------|------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------|
| `id` | The id of the script tag | `__googleMapsScriptId` |
| `language` | Force language, see [list of supported languages](https://developers.google.com/maps/faq#languagesupport) specified in the browser | The user's preferred language |
| `region` | Unicode region subtag identifiers compatible with [ISO 3166-1](https://en.wikipedia.org/wiki/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 script | `https://maps.googleapis.com/maps/api/js` |
| `version` | The release channels or version numbers | `weekly` |
| Option | Description | Default |
|-------------|------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------|
| `id` | The id of the script tag | `__googleMapsScriptId` |
| `language` | Force language, see [list of supported languages](https://developers.google.com/maps/faq#languagesupport) specified in the browser | The user's preferred language |
| `region` | Unicode region subtag identifiers compatible with [ISO 3166-1](https://en.wikipedia.org/wiki/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 script | `https://maps.googleapis.com/maps/api/js` |
| `version` | The release channels or version numbers | `weekly` |
| `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 |
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should that be in controller.json or something like that ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean?


## Map options

Expand Down Expand Up @@ -78,6 +80,60 @@ $googleOptions = (new GoogleOptions())
// Add the custom options to the map
$map->options($googleOptions);
```
## Use cases

Below are some common or advanced use cases when using a map.

### Customize the marker

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.

Assuming you have a map with a custom controller:
```twig
{{ render_map(map, {'data-controller': 'my-map' }) }}
```

You can create a Stimulus controller to customize the markers before they are created:
```js
// assets/controllers/my_map_controller.js
import {Controller} from "@hotwired/stimulus";

export default class extends Controller
{
connect() {
this.element.addEventListener('ux:map:marker:before-create', this._onMarkerBeforeCreate);
Copy link
Member

@smnandre smnandre Aug 11, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is new: ux:map:marker:before-create ?

Or did i left way too long to remember ? (4 days hahaha)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Always has been here ;)

}

disconnect() {
// Always remove listeners when the controller is disconnected
this.element.removeEventListener('ux:map:marker:before-create', this._onMarkerBeforeCreate);
}

_onMarkerBeforeCreate(event) {
// You can access the marker definition and the google object
// Note: `definition.rawOptions` is the raw options object that will be passed to the `google.maps.Marker` constructor.
const { definition, google } = event.detail;

// 1. To use a custom image for the marker
const beachFlagImg = document.createElement("img");
// Note: instead of using an hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`.
beachFlagImg.src = "https://developers.google.com/maps/documentation/javascript/examples/full/images/beachflag.png";
Comment on lines +118 to +120
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should illustrate this example with a controller "value"

definition.rawOptions = {
content: beachFlagImg
}

// 2. To use a custom glyph for the marker
const pinElement = new google.maps.marker.PinElement({
// Note: instead of using an hardcoded URL, you can use the `extra` parameter from `new Marker()` (PHP) and access it here with `definition.extra`.
glyph: new URL('https://maps.gstatic.com/mapfiles/place_api/icons/v2/museum_pinlet.svg'),
glyphColor: "white",
});
definition.rawOptions = {
content: pinElement.element,
}
}
}
```

## Resources

Expand Down
3 changes: 2 additions & 1 deletion src/Map/src/Bridge/Google/assets/dist/map_controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ export default class extends AbstractMapController<MapOptions, google.maps.Map,
static values: {
providerOptions: ObjectConstructor;
};
providerOptionsValue: Pick<LoaderOptions, 'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version'>;
providerOptionsValue: Pick<LoaderOptions, 'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version' | 'libraries'>;
connect(): Promise<void>;
protected dispatchEvent(name: string, payload?: Record<string, unknown>): void;
protected doCreateMap({ center, zoom, options, }: {
center: Point | null;
zoom: number | null;
Expand Down
38 changes: 28 additions & 10 deletions src/Map/src/Bridge/Google/assets/dist/map_controller.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,50 @@
import AbstractMapController from '@symfony/ux-map/abstract-map-controller';
import { Loader } from '@googlemaps/js-api-loader';

let loader;
let library;
let _google;
class default_1 extends AbstractMapController {
async connect() {
if (!loader) {
loader = new Loader(this.providerOptionsValue);
if (!_google) {
_google = { maps: {} };
let { libraries = [], ...loaderOptions } = this.providerOptionsValue;
const loader = new Loader(loaderOptions);
libraries = ['core', ...libraries.filter((library) => library !== 'core')];
const librariesImplementations = await Promise.all(libraries.map((library) => loader.importLibrary(library)));
librariesImplementations.map((libraryImplementation, index) => {
const library = libraries[index];
if (['marker', 'places', 'geometry', 'journeySharing', 'drawing', 'visualization'].includes(library)) {
_google.maps[library] = libraryImplementation;
}
else {
_google.maps = { ..._google.maps, ...libraryImplementation };
}
});
}
const { Map: _Map, InfoWindow } = await loader.importLibrary('maps');
const { AdvancedMarkerElement } = await loader.importLibrary('marker');
library = { _Map, AdvancedMarkerElement, InfoWindow };
super.connect();
}
dispatchEvent(name, payload = {}) {
this.dispatch(name, {
prefix: 'ux:map',
detail: {
...payload,
google: _google,
},
});
}
doCreateMap({ center, zoom, options, }) {
options.zoomControl = typeof options.zoomControlOptions !== 'undefined';
options.mapTypeControl = typeof options.mapTypeControlOptions !== 'undefined';
options.streetViewControl = typeof options.streetViewControlOptions !== 'undefined';
options.fullscreenControl = typeof options.fullscreenControlOptions !== 'undefined';
return new library._Map(this.element, {
return new _google.maps.Map(this.element, {
...options,
center,
zoom,
});
}
doCreateMarker(definition) {
const { position, title, infoWindow, extra, rawOptions = {}, ...otherOptions } = definition;
const marker = new library.AdvancedMarkerElement({
const marker = new _google.maps.marker.AdvancedMarkerElement({
position,
title,
...otherOptions,
Expand All @@ -40,7 +58,7 @@ class default_1 extends AbstractMapController {
}
doCreateInfoWindow({ definition, marker, }) {
const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition;
const infoWindow = new library.InfoWindow({
const infoWindow = new _google.maps.InfoWindow({
headerContent: this.createTextOrElement(headerContent),
content: this.createTextOrElement(content),
...otherOptions,
Expand Down
55 changes: 39 additions & 16 deletions src/Map/src/Bridge/Google/assets/src/map_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,7 @@ type MapOptions = Pick<
| 'fullscreenControlOptions'
>;

let loader: Loader;
let library: {
_Map: typeof google.maps.Map;
AdvancedMarkerElement: typeof google.maps.marker.AdvancedMarkerElement;
InfoWindow: typeof google.maps.InfoWindow;
};
let _google: typeof google;

export default class extends AbstractMapController<
MapOptions,
Expand All @@ -47,21 +42,49 @@ export default class extends AbstractMapController<

declare providerOptionsValue: Pick<
LoaderOptions,
'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version'
'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version' | 'libraries'
>;

async connect() {
if (!loader) {
loader = new Loader(this.providerOptionsValue);
if (!_google) {
_google = { maps: {} };

let { libraries = [], ...loaderOptions } = this.providerOptionsValue;

const loader = new Loader(loaderOptions);

// We could have used `loader.load()` to correctly load libraries, but this method is deprecated in favor of `loader.importLibrary()`.
// But `loader.importLibrary()` is not a 1-1 replacement for `loader.load()`, we need to re-build the `google.maps` object ourselves,
// see https://github.com/googlemaps/js-api-loader/issues/837 for more information.
libraries = ['core', ...libraries.filter((library) => library !== 'core')]; // Ensure 'core' is loaded first
const librariesImplementations = await Promise.all(
libraries.map((library) => loader.importLibrary(library))
);
librariesImplementations.map((libraryImplementation, index) => {
const library = libraries[index];

// The following libraries are in a sub-namespace
if (['marker', 'places', 'geometry', 'journeySharing', 'drawing', 'visualization'].includes(library)) {
_google.maps[library] = libraryImplementation;
} else {
_google.maps = { ..._google.maps, ...libraryImplementation };
}
});
}

const { Map: _Map, InfoWindow } = await loader.importLibrary('maps');
const { AdvancedMarkerElement } = await loader.importLibrary('marker');
library = { _Map, AdvancedMarkerElement, InfoWindow };

super.connect();
}

protected dispatchEvent(name: string, payload: Record<string, unknown> = {}): void {
this.dispatch(name, {
prefix: 'ux:map',
detail: {
...payload,
google: _google,
},
});
}

protected doCreateMap({
center,
zoom,
Expand All @@ -77,7 +100,7 @@ export default class extends AbstractMapController<
options.streetViewControl = typeof options.streetViewControlOptions !== 'undefined';
options.fullscreenControl = typeof options.fullscreenControlOptions !== 'undefined';

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

const marker = new library.AdvancedMarkerElement({
const marker = new _google.maps.marker.AdvancedMarkerElement({
position,
title,
...otherOptions,
Expand All @@ -116,7 +139,7 @@ export default class extends AbstractMapController<
}): google.maps.InfoWindow {
const { headerContent, content, extra, rawOptions = {}, ...otherOptions } = definition;

const infoWindow = new library.InfoWindow({
const infoWindow = new _google.maps.InfoWindow({
headerContent: this.createTextOrElement(headerContent),
content: this.createTextOrElement(content),
...otherOptions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('GoogleMapsController', () => {
data-testid="map"
data-controller="check google"
style="height&#x3A;&#x20;700px&#x3B;&#x20;margin&#x3A;&#x20;10px"
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;"
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;"
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;"
></div>
`);
Expand Down
Loading