import {
AfterContentInit,
ChangeDetectionStrategy,
Component,
ContentChildren,
ElementRef,
EventEmitter,
Input,
OnChanges,
OnDestroy,
OnInit,
Output,
QueryList,
} from '@angular/core';
import {BehaviorSubject, combineLatest, Observable, Subject} from 'rxjs';
import {map, shareReplay, take, takeUntil} from 'rxjs/operators';
import {MapMarker} from '../map-marker/index';
interface GoogleMapsWindow extends Window {
google?: typeof google;
}
// TODO(mbehrlich): Update this to use original map after updating DefinitelyTyped
/**
* Extends the Google Map interface due to the Definitely Typed implementation
* missing "getClickableIcons".
*/
export interface UpdatedGoogleMap extends google.maps.Map {
getClickableIcons: () => boolean;
}
/** default options set to the Googleplex */
export const DEFAULT_OPTIONS: google.maps.MapOptions = {
center: {lat: 37.421995, lng: -122.084092},
zoom: 17,
};
/** Arbitrary default height for the map element */
export const DEFAULT_HEIGHT = '500px';
/** Arbitrary default width for the map element */
export const DEFAULT_WIDTH = '500px';
/**
* Angular component that renders a Google Map via the Google Maps JavaScript
* API.
* @see https://developers.google.com/maps/documentation/javascript/reference/
*/
@Component({
selector: 'google-map',
changeDetection: ChangeDetectionStrategy.OnPush,
template: '
',
})
export class GoogleMap implements OnChanges, OnInit, AfterContentInit, OnDestroy {
@Input() height = DEFAULT_HEIGHT;
@Input() width = DEFAULT_WIDTH;
@Input()
set center(center: google.maps.LatLngLiteral) {
this._center.next(center);
}
@Input()
set zoom(zoom: number) {
this._zoom.next(zoom);
}
@Input()
set options(options: google.maps.MapOptions) {
this._options.next(options || DEFAULT_OPTIONS);
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.bounds_changed
*/
@Output() boundsChanged = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.center_changed
*/
@Output() centerChanged = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.click
*/
@Output() mapClick = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.dblclick
*/
@Output() mapDblclick = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.drag
*/
@Output() mapDrag = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.dragend
*/
@Output() mapDragend = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.dragstart
*/
@Output() mapDragstart = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.heading_changed
*/
@Output() headingChanged = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.idle
*/
@Output() idle = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.maptypeid_changed
*/
@Output() maptypeidChanged = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.mousemove
*/
@Output() mapMousemove = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.mouseout
*/
@Output() mapMouseout = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.mouseover
*/
@Output() mapMouseover = new EventEmitter();
/**
* See
* developers.google.com/maps/documentation/javascript/reference/map#Map.projection_changed
*/
@Output() projectionChanged = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.rightclick
*/
@Output() mapRightclick = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.tilesloaded
*/
@Output() tilesloaded = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.tilt_changed
*/
@Output() tiltChanged = new EventEmitter();
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.zoom_changed
*/
@Output() zoomChanged = new EventEmitter();
@ContentChildren(MapMarker) _markers: QueryList;
private _mapEl: HTMLElement;
private _googleMap!: UpdatedGoogleMap;
private _googleMapChanges!: Observable;
private readonly _listeners: google.maps.MapsEventListener[] = [];
private readonly _options = new BehaviorSubject(DEFAULT_OPTIONS);
private readonly _center = new BehaviorSubject(undefined);
private readonly _zoom = new BehaviorSubject(undefined);
private readonly _destroy = new Subject();
constructor(private readonly _elementRef: ElementRef) {
const googleMapsWindow: GoogleMapsWindow = window;
if (!googleMapsWindow.google) {
throw Error(
'Namespace google not found, cannot construct embedded google ' +
'map. Please install the Google Maps JavaScript API: ' +
'https://developers.google.com/maps/documentation/javascript/' +
'tutorial#Loading_the_Maps_API');
}
}
ngOnChanges() {
this._setSize();
}
ngOnInit() {
this._mapEl = this._elementRef.nativeElement.querySelector('.map-container')!;
this._setSize();
const combinedOptionsChanges = this._combineOptions();
this._googleMapChanges = this._initializeMap(combinedOptionsChanges);
this._googleMapChanges.subscribe((googleMap: google.maps.Map) => {
this._googleMap = googleMap as UpdatedGoogleMap;
this._initializeEventHandlers();
});
this._watchForOptionsChanges(combinedOptionsChanges);
}
ngAfterContentInit() {
for (const marker of this._markers.toArray()) {
marker._setMap(this._googleMap);
}
this._watchForMarkerChanges();
}
ngOnDestroy() {
this._destroy.next();
this._destroy.complete();
for (let listener of this._listeners) {
listener.remove();
}
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.fitBounds
*/
fitBounds(
bounds: google.maps.LatLngBounds|google.maps.LatLngBoundsLiteral,
padding?: number|google.maps.Padding) {
this._googleMap.fitBounds(bounds, padding);
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.panBy
*/
panBy(x: number, y: number) {
this._googleMap.panBy(x, y);
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.panTo
*/
panTo(latLng: google.maps.LatLng|google.maps.LatLngLiteral) {
this._googleMap.panTo(latLng);
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.panToBounds
*/
panToBounds(
latLngBounds: google.maps.LatLngBounds|google.maps.LatLngBoundsLiteral,
padding?: number|google.maps.Padding) {
this._googleMap.panToBounds(latLngBounds, padding);
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.getBounds
*/
getBounds(): google.maps.LatLngBounds|null {
return this._googleMap.getBounds() || null;
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.getCenter
*/
getCenter(): google.maps.LatLng {
return this._googleMap.getCenter();
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.getClickableIcons
*/
getClickableIcons(): boolean {
return this._googleMap.getClickableIcons();
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.getHeading
*/
getHeading(): number {
return this._googleMap.getHeading();
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.getMapTypeId
*/
getMapTypeId(): google.maps.MapTypeId|string {
return this._googleMap.getMapTypeId();
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.getProjection
*/
getProjection(): google.maps.Projection|null {
return this._googleMap.getProjection();
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.getStreetView
*/
getStreetView(): google.maps.StreetViewPanorama {
return this._googleMap.getStreetView();
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.getTilt
*/
getTilt(): number {
return this._googleMap.getTilt();
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.getZoom
*/
getZoom(): number {
return this._googleMap.getZoom();
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.controls
*/
get controls(): Array> {
return this._googleMap.controls;
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.data
*/
get data(): google.maps.Data {
return this._googleMap.data;
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.mapTypes
*/
get mapTypes(): google.maps.MapTypeRegistry {
return this._googleMap.mapTypes;
}
/**
* See
* https://developers.google.com/maps/documentation/javascript/reference/map#Map.overlayMapTypes
*/
get overlayMapTypes(): google.maps.MVCArray {
return this._googleMap.overlayMapTypes;
}
private _setSize() {
if (this._mapEl) {
this._mapEl.style.height = this.height || DEFAULT_HEIGHT;
this._mapEl.style.width = this.width || DEFAULT_WIDTH;
}
}
/** Combines the center and zoom and the other map options into a single object */
private _combineOptions(): Observable {
return combineLatest(this._options, this._center, this._zoom)
.pipe(map(([options, center, zoom]) => {
const combinedOptions: google.maps.MapOptions = {
...options,
center: center || options.center,
zoom: zoom !== undefined ? zoom : options.zoom,
};
return combinedOptions;
}));
}
private _initializeMap(optionsChanges: Observable):
Observable {
return optionsChanges.pipe(
take(1), map(options => {
return new google.maps.Map(this._mapEl, options);
}),
shareReplay(1));
}
private _watchForOptionsChanges(
optionsChanges: Observable) {
combineLatest(this._googleMapChanges, optionsChanges)
.pipe(takeUntil(this._destroy))
.subscribe(([googleMap, options]) => {
googleMap.setOptions(options);
});
}
private _initializeEventHandlers() {
const eventHandlers = new Map>([
['bounds_changed', this.boundsChanged],
['center_changed', this.centerChanged],
['drag', this.mapDrag],
['dragend', this.mapDragend],
['dragstart', this.mapDragstart],
['heading_changed', this.headingChanged],
['idle', this.idle],
['maptypeid_changed', this.maptypeidChanged],
['projection_changed', this.projectionChanged],
['tilesloaded', this.tilesloaded],
['tilt_changed', this.tiltChanged],
['zoomChanged', this.zoomChanged],
]);
const mouseEventHandlers = new Map>([
['dblclick', this.mapDblclick],
['mousemove', this.mapMousemove],
['mouseout', this.mapMouseout],
['mouseover', this.mapMouseover],
['rightclick', this.mapRightclick],
]);
eventHandlers.forEach((eventHandler: EventEmitter, name: string) => {
if (eventHandler.observers.length > 0) {
this._listeners.push(this._googleMap.addListener(name, () => {
eventHandler.emit();
}));
}
});
mouseEventHandlers.forEach(
(eventHandler: EventEmitter, name: string) => {
if (eventHandler.observers.length > 0) {
this._listeners.push(
this._googleMap.addListener(name, (event: google.maps.MouseEvent) => {
eventHandler.emit(event);
}));
}
});
if (this.mapClick.observers.length > 0) {
this._listeners.push(this._googleMap.addListener(
'click', (event: google.maps.MouseEvent|google.maps.IconMouseEvent) => {
this.mapClick.emit(event);
}));
}
}
private _watchForMarkerChanges() {
combineLatest(this._googleMapChanges, this._markers.changes)
.pipe(takeUntil(this._destroy))
.subscribe(([googleMap, markers]) => {
for (let marker of markers) {
marker._setMap(googleMap);
}
});
}
}