Skip to content

Commit

Permalink
feat(google-maps): add support for dynamic library loading API
Browse files Browse the repository at this point in the history
Currently the `@angular/google-maps` module is implemented on top of the legacy `<script>` API which is problematic, because:
1. It loads all of the JavaScript up-front, even if there are no maps on the page.
2. It requires the user to ensure that the API is fully loaded before the `<google-map>` component starts rendering. Lazy-loading in this scenario is error-prone and loading the API up-front leads to a lot of unused JavaScript being loaded.

These changes add support for the [Dynamic Library Import API](https://developers.google.com/maps/documentation/javascript/load-maps-js-api#dynamic-library-import) which requires a tiny script to be added up-front which then allows the `<google-map>` to load the chunks of the API it needs on-demand. The legacy API is still supported, but is no longer recommended.

All the components also now have `initialized` outputs to make it easier to know when the underlying Maps classes have been created and can be interacted with.
  • Loading branch information
crisbeto committed Dec 19, 2023
1 parent 22b5ebc commit da3bcc6
Show file tree
Hide file tree
Showing 41 changed files with 970 additions and 785 deletions.
18 changes: 4 additions & 14 deletions src/dev-app/google-map/google-map-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export class GoogleMapDemo {
};

isGroundOverlayDisplayed = false;
hasLoaded: boolean;
hasLoaded = false;
groundOverlayImages = [
{
title: 'Red logo',
Expand Down Expand Up @@ -284,24 +284,14 @@ export class GoogleMapDemo {
}

private _loadApi() {
this.hasLoaded = !!window.google?.maps;

if (this.hasLoaded) {
return;
}

if (!apiLoadingPromise) {
// Key can be set through the `GOOGLE_MAPS_KEY` environment variable.
const apiKey: string | undefined = (window as any).GOOGLE_MAPS_KEY;

apiLoadingPromise = Promise.all([
this._loadScript(
`https://maps.googleapis.com/maps/api/js?libraries=visualization${
apiKey ? `&key=${apiKey}` : ''
}`,
),
this._loadScript('https://unpkg.com/@googlemaps/markerclustererplus/dist/index.min.js'),
]);
apiLoadingPromise = this._loadScript(
'https://unpkg.com/@googlemaps/markerclustererplus/dist/index.min.js',
);
}

apiLoadingPromise.then(
Expand Down
6 changes: 6 additions & 0 deletions src/dev-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,11 @@
<dev-app>Loading...</dev-app>
<script src="zone.js/bundles/zone.umd.js"></script>
<script src="bundles/dev-app/main.js" type="module"></script>
<script>
(g=>{var h,a,k,p="The Google Maps JavaScript API",c="google",l="importLibrary",q="__ib__",m=document,b=window;b=b[c]||(b[c]={});var d=b.maps||(b.maps={}),r=new Set,e=new URLSearchParams,u=()=>h||(h=new Promise(async(f,n)=>{await (a=m.createElement("script"));e.set("libraries",[...r]+"");for(k in g)e.set(k.replace(/[A-Z]/g,t=>"_"+t[0].toLowerCase()),g[k]);e.set("callback",c+".maps."+q);a.src=`https://maps.${c}apis.com/maps/api/js?`+e;d[q]=f;a.onerror=()=>h=n(Error(p+" could not load."));a.nonce=m.querySelector("script[nonce]")?.nonce||"";m.head.append(a)}));d[l]?console.warn(p+" only loads once. Ignoring:",g):d[l]=(f,...n)=>r.add(f)&&u().then(()=>d[l](f,...n))})({
v: "weekly",
key: window.GOOGLE_MAPS_KEY || 'invalid'
});
</script>
</body>
</html>
68 changes: 15 additions & 53 deletions src/google-maps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,63 +14,25 @@ Follow [these steps](https://developers.google.com/maps/gmp-get-started) to get

## Loading the API

The API can be loaded when the component is actually used by using the Angular HttpClient jsonp
method to make sure that the component doesn't load until after the API has loaded.

```typescript
// google-maps-demo.module.ts

import { NgModule } from '@angular/core';
import { provideHttpClient, withJsonpSupport } from '@angular/common/http';

import { GoogleMapsDemoComponent } from './google-maps-demo.component';

@NgModule({
imports: [GoogleMapsDemoComponent],
providers: [provideHttpClient(withJsonpSupport())]
})
export class GoogleMapsDemoModule {}


// google-maps-demo.component.ts

import { Component } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { GoogleMap } from '@angular/google-maps';
import { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';

@Component({
selector: 'google-maps-demo',
templateUrl: './google-maps-demo.component.html',
standalone: true,
imports: [GoogleMap]
})
export class GoogleMapsDemoComponent {
apiLoaded: Observable<boolean>;

constructor(httpClient: HttpClient) {
// If you're using the `<map-heatmap-layer>` directive, you also have to include the `visualization` library
// when loading the Google Maps API. To do so, you can add `&libraries=visualization` to the script URL:
// https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&libraries=visualization

this.apiLoaded = httpClient.jsonp('https://maps.googleapis.com/maps/api/js?key=YOUR_KEY_HERE', 'callback')
.pipe(
map(() => true),
catchError(() => of(false)),
);
}
}
```
Include the [Dynamic Library Import script](https://developers.google.com/maps/documentation/javascript/load-maps-js-api#dynamic-library-import) in the `index.html` of your app. When a Google Map is being rendered, it'll use the Dynamic Import API to load the necessary JavaScript automatically.

```html
<!-- google-maps-demo.component.html -->

@if (apiLoaded | async) {
<google-map />
}
<!-- index.html -->
<!DOCTYPE html>
<body>
...
<script>
(g=>{var h,a,k,p="The Google Maps JavaScript API",c="google",l="importLibrary",q="__ib__",m=document,b=window;b=b[c]||(b[c]={});var d=b.maps||(b.maps={}),r=new Set,e=new URLSearchParams,u=()=>h||(h=new Promise(async(f,n)=>{await (a=m.createElement("script"));e.set("libraries",[...r]+"");for(k in g)e.set(k.replace(/[A-Z]/g,t=>"_"+t[0].toLowerCase()),g[k]);e.set("callback",c+".maps."+q);a.src=`https://maps.${c}apis.com/maps/api/js?`+e;d[q]=f;a.onerror=()=>h=n(Error(p+" could not load."));a.nonce=m.querySelector("script[nonce]")?.nonce||"";m.head.append(a)}));d[l]?console.warn(p+" only loads once. Ignoring:",g):d[l]=(f,...n)=>r.add(f)&&u().then(()=>d[l](f,...n))})({
v: "weekly",
key: YOUR_API_KEY_GOES_HERE
});
</script>
</body>
</html>
```
**Note:** the component also supports loading the API using the [legacy script tag](https://developers.google.com/maps/documentation/javascript/load-maps-js-api#use-legacy-tag), however it isn't recommended because it requires all of the Google Maps JavaScript to be loaded up-front, even if it isn't used.
## Components
- [`GoogleMap`](./google-map/README.md)
Expand Down
22 changes: 11 additions & 11 deletions src/google-maps/google-map/google-map.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('GoogleMap', () => {
it('sets center and zoom of the map', () => {
const options = {center: {lat: 3, lng: 5}, zoom: 7, mapTypeId: DEFAULT_OPTIONS.mapTypeId};
mapSpy = createMapSpy(options);
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
mapConstructorSpy = createMapConstructorSpy(mapSpy);

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.center = options.center;
Expand All @@ -142,7 +142,7 @@ describe('GoogleMap', () => {
mapTypeId: DEFAULT_OPTIONS.mapTypeId,
};
mapSpy = createMapSpy(options);
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
mapConstructorSpy = createMapConstructorSpy(mapSpy);

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.options = options;
Expand All @@ -160,7 +160,7 @@ describe('GoogleMap', () => {
it('should set a default center if the custom options do not provide one', () => {
const options = {zoom: 7};
mapSpy = createMapSpy(options);
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
mapConstructorSpy = createMapConstructorSpy(mapSpy);

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.options = options;
Expand All @@ -172,7 +172,7 @@ describe('GoogleMap', () => {
it('should set a default zoom level if the custom options do not provide one', () => {
const options = {};
mapSpy = createMapSpy(options);
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
mapConstructorSpy = createMapConstructorSpy(mapSpy);

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.options = options;
Expand All @@ -184,7 +184,7 @@ describe('GoogleMap', () => {
it('should not set a default zoom level if the custom options provide "zoom: 0"', () => {
const options = {zoom: 0};
mapSpy = createMapSpy(options);
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
mapConstructorSpy = createMapConstructorSpy(mapSpy);

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.options = options;
Expand Down Expand Up @@ -216,7 +216,7 @@ describe('GoogleMap', () => {

it('exposes methods that change the configuration of the Google Map', () => {
mapSpy = createMapSpy(DEFAULT_OPTIONS);
createMapConstructorSpy(mapSpy).and.callThrough();
createMapConstructorSpy(mapSpy);

const fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();
Expand All @@ -238,7 +238,7 @@ describe('GoogleMap', () => {

it('exposes methods that get information about the Google Map', () => {
mapSpy = createMapSpy(DEFAULT_OPTIONS);
createMapConstructorSpy(mapSpy).and.callThrough();
createMapConstructorSpy(mapSpy);

const fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();
Expand Down Expand Up @@ -275,7 +275,7 @@ describe('GoogleMap', () => {

it('initializes event handlers that are set on the map', () => {
mapSpy = createMapSpy(DEFAULT_OPTIONS);
createMapConstructorSpy(mapSpy).and.callThrough();
createMapConstructorSpy(mapSpy);

const addSpy = mapSpy.addListener;
const fixture = TestBed.createComponent(TestApp);
Expand Down Expand Up @@ -303,7 +303,7 @@ describe('GoogleMap', () => {

it('should be able to add an event listener after init', () => {
mapSpy = createMapSpy(DEFAULT_OPTIONS);
createMapConstructorSpy(mapSpy).and.callThrough();
createMapConstructorSpy(mapSpy);

const addSpy = mapSpy.addListener;
const fixture = TestBed.createComponent(TestApp);
Expand All @@ -321,7 +321,7 @@ describe('GoogleMap', () => {

it('should set the map type', () => {
mapSpy = createMapSpy(DEFAULT_OPTIONS);
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
mapConstructorSpy = createMapConstructorSpy(mapSpy);

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.mapTypeId = 'terrain' as unknown as google.maps.MapTypeId;
Expand All @@ -341,7 +341,7 @@ describe('GoogleMap', () => {
it('sets mapTypeId through the options', () => {
const options = {mapTypeId: 'satellite'};
mapSpy = createMapSpy(options);
mapConstructorSpy = createMapConstructorSpy(mapSpy).and.callThrough();
mapConstructorSpy = createMapConstructorSpy(mapSpy);
const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.options = options;
fixture.detectChanges();
Expand Down
18 changes: 14 additions & 4 deletions src/google-maps/google-map/google-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
import {isPlatformBrowser} from '@angular/common';
import {Observable} from 'rxjs';
import {MapEventManager} from '../map-event-manager';
import {take} from 'rxjs/operators';

interface GoogleMapsWindow extends Window {
gm_authFailure?: () => void;
Expand Down Expand Up @@ -307,15 +308,19 @@ export class GoogleMap implements OnChanges, OnInit, OnDestroy {
// Create the object outside the zone so its events don't trigger change detection.
// We'll bring it back in inside the `MapEventManager` only for the events that the
// user has subscribed to.
this._ngZone.runOutsideAngular(() => {
this.googleMap = new google.maps.Map(this._mapEl, this._combineOptions());
this._ngZone.runOutsideAngular(async () => {
const mapConstructor =
google.maps.Map ||
((await google.maps.importLibrary('maps')) as google.maps.MapsLibrary).Map;
this.googleMap = new mapConstructor(this._mapEl, this._combineOptions());
this._eventManager.setTarget(this.googleMap);
this.mapInitialized.emit(this.googleMap);
});
this._eventManager.setTarget(this.googleMap);
this.mapInitialized.emit(this.googleMap);
}
}

ngOnDestroy() {
this.mapInitialized.complete();
this._eventManager.destroy();

if (this._isBrowser) {
Expand Down Expand Up @@ -483,6 +488,11 @@ export class GoogleMap implements OnChanges, OnInit, OnDestroy {
return this.googleMap.overlayMapTypes;
}

/** Returns a promise that resolves when the map has been initialized. */
async _resolveMap(): Promise<google.maps.Map> {
return this.googleMap || this.mapInitialized.pipe(take(1)).toPromise();
}

private _setSize() {
if (this._mapEl) {
const styles = this._mapEl.style;
Expand Down
23 changes: 7 additions & 16 deletions src/google-maps/map-base-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,21 @@ export class MapBaseLayer implements OnInit, OnDestroy {
protected readonly _ngZone: NgZone,
) {}

ngOnInit() {
async ngOnInit() {
if (this._map._isBrowser) {
this._ngZone.runOutsideAngular(() => {
this._initializeObject();
this._ngZone.runOutsideAngular(async () => {
const map = await this._map._resolveMap();
await this._initializeObject();
this._setMap(map);
});
this._assertInitialized();
this._setMap();
}
}

ngOnDestroy() {
this._unsetMap();
}

private _assertInitialized() {
if (!this._map.googleMap) {
throw Error(
'Cannot access Google Map information before the API has been initialized. ' +
'Please wait for the API to load before trying to interact with it.',
);
}
}

protected _initializeObject() {}
protected _setMap() {}
protected async _initializeObject() {}
protected _setMap(_map: google.maps.Map) {}
protected _unsetMap() {}
}
13 changes: 6 additions & 7 deletions src/google-maps/map-bicycling-layer/map-bicycling-layer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Component} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {TestBed, fakeAsync, flush} from '@angular/core/testing';

import {DEFAULT_OPTIONS, GoogleMap} from '../google-map/google-map';
import {
Expand All @@ -16,24 +16,23 @@ describe('MapBicyclingLayer', () => {

beforeEach(() => {
mapSpy = createMapSpy(DEFAULT_OPTIONS);
createMapConstructorSpy(mapSpy).and.callThrough();
createMapConstructorSpy(mapSpy);
});

afterEach(() => {
(window.google as any) = undefined;
});

it('initializes a Google Map Bicycling Layer', () => {
it('initializes a Google Map Bicycling Layer', fakeAsync(() => {
const bicyclingLayerSpy = createBicyclingLayerSpy();
const bicyclingLayerConstructorSpy =
createBicyclingLayerConstructorSpy(bicyclingLayerSpy).and.callThrough();

const bicyclingLayerConstructorSpy = createBicyclingLayerConstructorSpy(bicyclingLayerSpy);
const fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();
flush();

expect(bicyclingLayerConstructorSpy).toHaveBeenCalled();
expect(bicyclingLayerSpy.setMap).toHaveBeenCalledWith(mapSpy);
});
}));
});

@Component({
Expand Down
22 changes: 14 additions & 8 deletions src/google-maps/map-bicycling-layer/map-bicycling-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// Workaround for: https://github.com/bazelbuild/rules_nodejs/issues/1265
/// <reference types="google.maps" />

import {Directive} from '@angular/core';
import {Directive, EventEmitter, Output} from '@angular/core';

import {MapBaseLayer} from '../map-base-layer';

Expand All @@ -31,19 +31,25 @@ export class MapBicyclingLayer extends MapBaseLayer {
*/
bicyclingLayer?: google.maps.BicyclingLayer;

protected override _initializeObject() {
this.bicyclingLayer = new google.maps.BicyclingLayer();
/** Event emitted when the bicycling layer is initialized. */
@Output() readonly bicyclingLayerInitialized: EventEmitter<google.maps.BicyclingLayer> =
new EventEmitter<google.maps.BicyclingLayer>();

protected override async _initializeObject() {
const layerConstructor =
google.maps.BicyclingLayer ||
((await google.maps.importLibrary('maps')) as google.maps.MapsLibrary).BicyclingLayer;
this.bicyclingLayer = new layerConstructor();
this.bicyclingLayerInitialized.emit(this.bicyclingLayer);
}

protected override _setMap() {
protected override _setMap(map: google.maps.Map) {
this._assertLayerInitialized();
this.bicyclingLayer.setMap(this._map.googleMap!);
this.bicyclingLayer.setMap(map);
}

protected override _unsetMap() {
if (this.bicyclingLayer) {
this.bicyclingLayer.setMap(null);
}
this.bicyclingLayer?.setMap(null);
}

private _assertLayerInitialized(): asserts this is {bicyclingLayer: google.maps.BicyclingLayer} {
Expand Down
Loading

0 comments on commit da3bcc6

Please sign in to comment.