Skip to content

Commit

Permalink
feature #2044 [Map] Add support for libraries for Google Bridge, in…
Browse files Browse the repository at this point in the history
…ject 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
  • Loading branch information
kbond committed Aug 12, 2024
2 parents c3e42ab + 2dbb169 commit 9e6920d
Show file tree
Hide file tree
Showing 16 changed files with 261 additions and 86 deletions.
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 |

## 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);
}

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";
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

0 comments on commit 9e6920d

Please sign in to comment.