mirror of
https://github.com/symfony/ux-google-map.git
synced 2026-03-23 23:42:13 +01:00
476 lines
15 KiB
TypeScript
476 lines
15 KiB
TypeScript
/*
|
|
* This file is part of the Symfony package.
|
|
*
|
|
* (c) Fabien Potencier <fabien@symfony.com>
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
/// <reference types="google.maps" />
|
|
|
|
import type { LoaderOptions } from '@googlemaps/js-api-loader';
|
|
import { Loader } from '@googlemaps/js-api-loader';
|
|
import type {
|
|
CircleDefinition,
|
|
Icon,
|
|
InfoWindowDefinition,
|
|
MapDefinition,
|
|
MarkerDefinition,
|
|
PolygonDefinition,
|
|
PolylineDefinition,
|
|
RectangleDefinition,
|
|
} from '@symfony/ux-map';
|
|
import AbstractMapController, { IconTypes } from '@symfony/ux-map';
|
|
|
|
type MapOptions = Pick<
|
|
google.maps.MapOptions,
|
|
| 'mapId'
|
|
| 'gestureHandling'
|
|
| 'backgroundColor'
|
|
| 'disableDoubleClickZoom'
|
|
| 'zoomControl'
|
|
| 'zoomControlOptions'
|
|
| 'mapTypeControl'
|
|
| 'mapTypeControlOptions'
|
|
| 'streetViewControl'
|
|
| 'streetViewControlOptions'
|
|
| 'fullscreenControl'
|
|
| 'fullscreenControlOptions'
|
|
>;
|
|
|
|
let _google: typeof google;
|
|
|
|
// Loading the Google Maps API is an asynchronous operation, so we need to track the loading state to prevent race conditions.
|
|
let _loading = false;
|
|
let _loaded = false;
|
|
let _onLoadedCallbacks: Array<() => void> = [];
|
|
|
|
const parser = new DOMParser();
|
|
|
|
export default class extends AbstractMapController<
|
|
MapOptions,
|
|
google.maps.MapOptions,
|
|
google.maps.Map,
|
|
google.maps.marker.AdvancedMarkerElementOptions,
|
|
google.maps.marker.AdvancedMarkerElement,
|
|
google.maps.InfoWindowOptions,
|
|
google.maps.InfoWindow,
|
|
google.maps.PolygonOptions,
|
|
google.maps.Polygon,
|
|
google.maps.PolylineOptions,
|
|
google.maps.Polyline,
|
|
google.maps.CircleOptions,
|
|
google.maps.Circle,
|
|
google.maps.RectangleOptions,
|
|
google.maps.Rectangle
|
|
> {
|
|
declare providerOptionsValue: Pick<
|
|
LoaderOptions,
|
|
'apiKey' | 'id' | 'language' | 'region' | 'nonce' | 'retries' | 'url' | 'version' | 'libraries'
|
|
>;
|
|
|
|
declare map: google.maps.Map;
|
|
|
|
async connect() {
|
|
const onLoaded = () => super.connect();
|
|
|
|
if (_loaded) {
|
|
onLoaded();
|
|
return;
|
|
}
|
|
|
|
if (_loading) {
|
|
_onLoadedCallbacks.push(onLoaded);
|
|
return;
|
|
}
|
|
|
|
_loading = true;
|
|
_google = { maps: {} as typeof 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.forEach((libraryImplementation, index) => {
|
|
if (typeof libraryImplementation !== 'object' || libraryImplementation === null) {
|
|
return;
|
|
}
|
|
|
|
const library = libraries[index];
|
|
|
|
// The following libraries are in a sub-namespace
|
|
if (['marker', 'places', 'geometry', 'journeySharing', 'drawing', 'visualization'].includes(library)) {
|
|
// @ts-expect-error
|
|
_google.maps[library] = libraryImplementation as any;
|
|
} else {
|
|
_google.maps = { ..._google.maps, ...libraryImplementation };
|
|
}
|
|
});
|
|
|
|
_loading = false;
|
|
_loaded = true;
|
|
onLoaded();
|
|
_onLoadedCallbacks.forEach((callback) => {
|
|
callback();
|
|
});
|
|
_onLoadedCallbacks = [];
|
|
}
|
|
|
|
public centerValueChanged(): void {
|
|
if (this.map && this.hasCenterValue && this.centerValue) {
|
|
this.map.setCenter(this.centerValue);
|
|
}
|
|
}
|
|
|
|
public zoomValueChanged(): void {
|
|
if (this.map && this.hasZoomValue && this.zoomValue) {
|
|
this.map.setZoom(this.zoomValue);
|
|
}
|
|
}
|
|
|
|
public minZoomValueChanged(): void {
|
|
if (this.map && this.hasMinZoomValue && this.minZoomValue) {
|
|
this.map.setOptions({ minZoom: this.minZoomValue });
|
|
}
|
|
}
|
|
|
|
public maxZoomValueChanged(): void {
|
|
if (this.map && this.hasMaxZoomValue && this.maxZoomValue) {
|
|
this.map.setOptions({ maxZoom: this.maxZoomValue });
|
|
}
|
|
}
|
|
|
|
protected dispatchEvent(name: string, payload: Record<string, unknown> = {}): void {
|
|
payload.google = _google;
|
|
this.dispatch(name, {
|
|
prefix: 'ux:map',
|
|
detail: payload,
|
|
});
|
|
}
|
|
|
|
protected doCreateMap({
|
|
definition,
|
|
}: {
|
|
definition: MapDefinition<MapOptions, google.maps.MapOptions>;
|
|
}): google.maps.Map {
|
|
const { center, zoom, minZoom, maxZoom, options, bridgeOptions = {} } = definition;
|
|
|
|
// We assume the following control options are enabled if their options are set
|
|
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 _google.maps.Map(this.element, {
|
|
center,
|
|
zoom,
|
|
minZoom,
|
|
maxZoom,
|
|
...options,
|
|
...bridgeOptions,
|
|
});
|
|
}
|
|
|
|
protected doCreateMarker({
|
|
definition,
|
|
}: {
|
|
definition: MarkerDefinition<google.maps.marker.AdvancedMarkerElementOptions, google.maps.InfoWindowOptions>;
|
|
}): google.maps.marker.AdvancedMarkerElement {
|
|
const { '@id': _id, position, title, infoWindow, icon, rawOptions = {}, bridgeOptions = {} } = definition;
|
|
|
|
const marker = new _google.maps.marker.AdvancedMarkerElement({
|
|
position,
|
|
title,
|
|
map: this.map,
|
|
...rawOptions,
|
|
...bridgeOptions,
|
|
});
|
|
|
|
if (infoWindow) {
|
|
this.createInfoWindow({ definition: infoWindow, element: marker });
|
|
}
|
|
|
|
if (icon) {
|
|
if (Object.prototype.hasOwnProperty.call(bridgeOptions, 'content')) {
|
|
console.warn(
|
|
'[Symfony UX Map] Defining "bridgeOptions.content" for a marker with a custom icon is not supported and will be ignored.'
|
|
);
|
|
} else if (Object.prototype.hasOwnProperty.call(rawOptions, 'content')) {
|
|
console.warn(
|
|
'[Symfony UX Map] Defining "rawOptions.content" for a marker with a custom icon is not supported and will be ignored.'
|
|
);
|
|
}
|
|
|
|
this.doCreateIcon({ definition: icon, element: marker });
|
|
}
|
|
|
|
return marker;
|
|
}
|
|
|
|
protected doRemoveMarker(marker: google.maps.marker.AdvancedMarkerElement): void {
|
|
marker.map = null;
|
|
}
|
|
|
|
protected doCreatePolygon({
|
|
definition,
|
|
}: {
|
|
definition: PolygonDefinition<google.maps.PolygonOptions, google.maps.InfoWindowOptions>;
|
|
}): google.maps.Polygon {
|
|
const { '@id': _id, points, title, infoWindow, rawOptions = {}, bridgeOptions = {} } = definition;
|
|
|
|
const polygon = new _google.maps.Polygon({
|
|
paths: points,
|
|
map: this.map,
|
|
...rawOptions,
|
|
...bridgeOptions,
|
|
});
|
|
|
|
/**
|
|
* @deprecated since Symfony UX Map 2.29, will be removed in 3.0
|
|
*/
|
|
if (title) {
|
|
polygon.set('title', title);
|
|
}
|
|
|
|
if (infoWindow) {
|
|
this.createInfoWindow({ definition: infoWindow, element: polygon });
|
|
}
|
|
|
|
return polygon;
|
|
}
|
|
|
|
protected doRemovePolygon(polygon: google.maps.Polygon) {
|
|
polygon.setMap(null);
|
|
}
|
|
|
|
protected doCreatePolyline({
|
|
definition,
|
|
}: {
|
|
definition: PolylineDefinition<google.maps.PolylineOptions, google.maps.InfoWindowOptions>;
|
|
}): google.maps.Polyline {
|
|
const { '@id': _id, points, title, infoWindow, rawOptions = {}, bridgeOptions = {} } = definition;
|
|
|
|
const polyline = new _google.maps.Polyline({
|
|
path: points,
|
|
map: this.map,
|
|
...rawOptions,
|
|
...bridgeOptions,
|
|
});
|
|
|
|
/**
|
|
* @deprecated since Symfony UX Map 2.29, will be removed in 3.0
|
|
*/
|
|
if (title) {
|
|
polyline.set('title', title);
|
|
}
|
|
|
|
if (infoWindow) {
|
|
this.createInfoWindow({ definition: infoWindow, element: polyline });
|
|
}
|
|
|
|
return polyline;
|
|
}
|
|
|
|
protected doRemovePolyline(polyline: google.maps.Polyline): void {
|
|
polyline.setMap(null);
|
|
}
|
|
|
|
protected doCreateCircle({
|
|
definition,
|
|
}: {
|
|
definition: CircleDefinition<google.maps.CircleOptions, google.maps.InfoWindowOptions>;
|
|
}): google.maps.Circle {
|
|
const { '@id': _id, center, radius, title, infoWindow, rawOptions = {}, bridgeOptions = {} } = definition;
|
|
|
|
const circle = new _google.maps.Circle({
|
|
center,
|
|
radius,
|
|
map: this.map,
|
|
...rawOptions,
|
|
...bridgeOptions,
|
|
});
|
|
|
|
/**
|
|
* @deprecated since Symfony UX Map 2.29, will be removed in 3.0
|
|
*/
|
|
if (title) {
|
|
circle.set('title', title);
|
|
}
|
|
|
|
if (infoWindow) {
|
|
this.createInfoWindow({ definition: infoWindow, element: circle });
|
|
}
|
|
|
|
return circle;
|
|
}
|
|
|
|
protected doRemoveCircle(circle: google.maps.Circle): void {
|
|
circle.setMap(null);
|
|
}
|
|
|
|
protected doCreateRectangle({
|
|
definition,
|
|
}: {
|
|
definition: RectangleDefinition<google.maps.RectangleOptions, google.maps.InfoWindowOptions>;
|
|
}): google.maps.Rectangle {
|
|
const { northEast, southWest, title, infoWindow, rawOptions = {}, bridgeOptions = {} } = definition;
|
|
|
|
const rectangle = new _google.maps.Rectangle({
|
|
bounds: new _google.maps.LatLngBounds(southWest, northEast),
|
|
map: this.map,
|
|
...rawOptions,
|
|
...bridgeOptions,
|
|
});
|
|
|
|
/**
|
|
* @deprecated since Symfony UX Map 2.29, will be removed in 3.0
|
|
*/
|
|
if (title) {
|
|
rectangle.set('title', title);
|
|
}
|
|
|
|
if (infoWindow) {
|
|
this.createInfoWindow({ definition: infoWindow, element: rectangle });
|
|
}
|
|
|
|
return rectangle;
|
|
}
|
|
|
|
protected doRemoveRectangle(rectangle: google.maps.Rectangle): void {
|
|
rectangle.setMap(null);
|
|
}
|
|
|
|
protected doCreateInfoWindow({
|
|
definition,
|
|
element,
|
|
}: {
|
|
definition: Omit<InfoWindowDefinition<google.maps.InfoWindowOptions>, 'position'>;
|
|
element:
|
|
| google.maps.marker.AdvancedMarkerElement
|
|
| google.maps.Polygon
|
|
| google.maps.Polyline
|
|
| google.maps.Circle
|
|
| google.maps.Rectangle;
|
|
}): google.maps.InfoWindow {
|
|
const { headerContent, content, opened, autoClose, rawOptions = {}, bridgeOptions = {} } = definition;
|
|
|
|
let position: google.maps.LatLng | null = null;
|
|
if (element instanceof google.maps.Circle) {
|
|
position = element.getCenter();
|
|
} else if (element instanceof google.maps.Rectangle) {
|
|
position = element.getBounds()?.getCenter() || null;
|
|
} else if (element instanceof google.maps.Polygon || element instanceof google.maps.Polyline) {
|
|
// Note: We do not compute the center of Polygons or Polylines here, since shapes can be complex.
|
|
// Instead, listen to the `ux:map:polygon:before-create` or `ux:map:polyline:before-create` events to set the position manually.
|
|
// ```js
|
|
// const bounds = new google.maps.LatLngBounds();
|
|
// element.getPath().forEach((latLng) => bounds.extend(latLng));
|
|
// event.definition.infoWindow.bridgeOptions.position = bounds.getCenter();
|
|
// ```
|
|
}
|
|
|
|
const infoWindowOptions: google.maps.InfoWindowOptions = {
|
|
headerContent: this.createTextOrElement(headerContent),
|
|
content: this.createTextOrElement(content),
|
|
position,
|
|
...rawOptions,
|
|
...bridgeOptions,
|
|
};
|
|
|
|
const infoWindow = new _google.maps.InfoWindow(infoWindowOptions);
|
|
|
|
element.addListener('click', (event: google.maps.MapMouseEvent) => {
|
|
if (autoClose) {
|
|
this.closeInfoWindowsExcept(infoWindow);
|
|
}
|
|
|
|
// Don't override the position if it was already set (e.g. through "rawOptions")
|
|
if (infoWindowOptions.position === null) {
|
|
infoWindow.setPosition(event.latLng);
|
|
}
|
|
|
|
infoWindow.open({ map: this.map, anchor: element });
|
|
});
|
|
|
|
if (opened) {
|
|
if (autoClose) {
|
|
this.closeInfoWindowsExcept(infoWindow);
|
|
}
|
|
|
|
infoWindow.open({ map: this.map, anchor: element });
|
|
}
|
|
|
|
return infoWindow;
|
|
}
|
|
|
|
protected doFitBoundsToMarkers(): void {
|
|
if (this.markers.size === 0) {
|
|
return;
|
|
}
|
|
|
|
const bounds = new google.maps.LatLngBounds();
|
|
this.markers.forEach((marker) => {
|
|
if (!marker.position) {
|
|
return;
|
|
}
|
|
|
|
bounds.extend(marker.position);
|
|
});
|
|
|
|
this.map.fitBounds(bounds);
|
|
}
|
|
|
|
private createTextOrElement(content: string | null): string | HTMLElement | null {
|
|
if (!content) {
|
|
return null;
|
|
}
|
|
|
|
// we assume it's HTML if it includes "<"
|
|
if (content.includes('<')) {
|
|
const div = document.createElement('div');
|
|
div.innerHTML = content;
|
|
return div;
|
|
}
|
|
|
|
return content;
|
|
}
|
|
|
|
protected doCreateIcon({
|
|
definition,
|
|
element,
|
|
}: {
|
|
definition: Icon;
|
|
element: google.maps.marker.AdvancedMarkerElement;
|
|
}): void {
|
|
const { type, width, height } = definition;
|
|
|
|
if (type === IconTypes.Svg) {
|
|
element.content = parser.parseFromString(definition.html, 'image/svg+xml').documentElement;
|
|
} else if (type === IconTypes.UxIcon) {
|
|
element.content = parser.parseFromString(definition._generated_html, 'image/svg+xml').documentElement;
|
|
} else if (type === IconTypes.Url) {
|
|
const icon = document.createElement('img');
|
|
icon.width = width;
|
|
icon.height = height;
|
|
icon.src = definition.url;
|
|
element.content = icon;
|
|
} else {
|
|
throw new Error(`Unsupported icon type: ${type}.`);
|
|
}
|
|
}
|
|
|
|
private closeInfoWindowsExcept(infoWindow: google.maps.InfoWindow) {
|
|
this.infoWindows.forEach((otherInfoWindow) => {
|
|
if (otherInfoWindow !== infoWindow) {
|
|
otherInfoWindow.close();
|
|
}
|
|
});
|
|
}
|
|
}
|