Skip to content

Commit 09581ee

Browse files
authored
feat(google-maps): add heatmap support (#21489)
Adds support for rendering heatmaps on the `google-map` component using the `map-heatmap-layer` directive. The directive is mostly a direct wrapper around the `google.maps.visualization.HeatmapLayer` class, except for the fact that it also accepts a `LatLngLiteral`, whereas the Google Maps class only accepts `LatLng` objects. I decided to add some logic to convert them automatically, because creating `LatLng` requires the Maps API to have been loaded which can lead to race conditions if it's being loaded lazily.
1 parent 52f39bc commit 09581ee

File tree

15 files changed

+542
-14
lines changed

15 files changed

+542
-14
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"@angular/elements": "^11.1.0",
6060
"@angular/forms": "^11.1.0",
6161
"@angular/platform-browser": "^11.1.0",
62-
"@types/googlemaps": "^3.43.0",
62+
"@types/googlemaps": "^3.43.1",
6363
"@types/youtube": "^0.0.40",
6464
"@webcomponents/custom-elements": "^1.1.0",
6565
"core-js-bundle": "^3.8.2",

src/dev-app/google-map/google-map-demo.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333
<map-directions-renderer *ngIf="directionsResult"
3434
[directions]="directionsResult"></map-directions-renderer>
3535

36+
<map-heatmap-layer *ngIf="isHeatmapDisplayed"
37+
[data]="heatmapData"
38+
[options]="heatmapOptions"></map-heatmap-layer>
3639
</google-map>
3740

3841
<p><label>Latitude:</label> {{display?.lat}}</p>
@@ -153,6 +156,13 @@
153156
</label>
154157
</div>
155158

159+
<div>
160+
<label for="heatmap-layer-checkbox">
161+
Toggle Heatmap Layer
162+
<input type="checkbox" (click)="toggleHeatmapLayerDisplay()">
163+
</label>
164+
</div>
165+
156166
<div>
157167
<button mat-button (click)="calculateDirections()">
158168
Calculate directions between first two markers

src/dev-app/google-map/google-map-demo.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ export class GoogleMapDemo {
5858
polylineOptions:
5959
google.maps.PolylineOptions = {path: POLYLINE_PATH, strokeColor: 'grey', strokeOpacity: 0.8};
6060

61+
heatmapData = this._getHeatmapData(5, 1);
62+
heatmapOptions = {radius: 50};
63+
isHeatmapDisplayed = false;
64+
6165
isPolygonDisplayed = false;
6266
polygonOptions:
6367
google.maps.PolygonOptions = {paths: POLYGON_PATH, strokeColor: 'grey', strokeOpacity: 0.8};
@@ -208,4 +212,20 @@ export class GoogleMapDemo {
208212
});
209213
}
210214
}
215+
216+
toggleHeatmapLayerDisplay() {
217+
this.isHeatmapDisplayed = !this.isHeatmapDisplayed;
218+
}
219+
220+
private _getHeatmapData(offset: number, increment: number) {
221+
const result: google.maps.LatLngLiteral[] = [];
222+
223+
for (let lat = this.center.lat - offset; lat < this.center.lat + offset; lat += increment) {
224+
for (let lng = this.center.lng - offset; lng < this.center.lng + offset; lng += increment) {
225+
result.push({lat, lng});
226+
}
227+
}
228+
229+
return result;
230+
}
211231
}

src/dev-app/index.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@
4242
var iframe = document.getElementById('google-maps-api-key');
4343
var googleMapsScript = document.createElement('script');
4444
var googleMapsApiKey = iframe.contentDocument.body.textContent;
45-
var googleMapsUrl = 'https://maps.googleapis.com/maps/api/js';
45+
var googleMapsUrl = 'https://maps.googleapis.com/maps/api/js?libraries=visualization';
4646
if (googleMapsApiKey !== 'Page not found') {
47-
googleMapsUrl = googleMapsUrl + '?key=' + googleMapsApiKey;
47+
googleMapsUrl = googleMapsUrl + '&key=' + googleMapsApiKey;
4848
}
4949
googleMapsScript.src = googleMapsUrl;
5050
document.body.appendChild(googleMapsScript);

src/google-maps/README.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,23 @@ To install, run `npm install @angular/google-maps`.
1919
<!doctype html>
2020
<head>
2121
...
22-
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY">
23-
</script>
22+
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY"></script>
2423
</head>
2524
```
2625

26+
**Note:**
27+
If you're using the `<map-heatmap-layer>` directive, you also have to include the `visualization`
28+
library when loading the Google Maps API. To do so, you can add `&libraries=visualization` to the
29+
script URL:
30+
31+
```html
32+
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&libraries=visualization"></script>
33+
```
34+
2735
## Lazy Loading the API
2836

29-
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.
37+
The API can be loaded when the component is actually used by using the Angular HttpClient jsonp
38+
method to make sure that the component doesn't load until after the API has loaded.
3039

3140
```typescript
3241
// google-maps-demo.module.ts
@@ -102,10 +111,14 @@ export class GoogleMapsDemoComponent {
102111
- [`MapTransitLayer`](./map-transit-layer/README.md)
103112
- [`MapBicyclingLayer`](./map-bicycling-layer/README.md)
104113
- [`MapDirectionsRenderer`](./map-directions-renderer/README.md)
114+
- [`MapHeatmapLayer`](./map-heatmap-layer/README.md)
105115

106116
## The Options Input
107117

108-
The Google Maps components implement all of the options for their respective objects from the Google Maps JavaScript API through an `options` input, but they also have specific inputs for some of the most common options. For example, the Google Maps component could have its options set either in with a google.maps.MapOptions object:
118+
The Google Maps components implement all of the options for their respective objects from the
119+
Google Maps JavaScript API through an `options` input, but they also have specific inputs for some
120+
of the most common options. For example, the Google Maps component could have its options set either
121+
in with a google.maps.MapOptions object:
109122

110123
```html
111124
<google-map [options]="options"></google-map>
@@ -130,4 +143,5 @@ center: google.maps.LatLngLiteral = {lat: 40, lng: -20};
130143
zoom = 4;
131144
```
132145

133-
Not every option has its own input. See the API for each component to see if the option has a dedicated input or if it should be set in the options input.
146+
Not every option has its own input. See the API for each component to see if the option has a
147+
dedicated input or if it should be set in the options input.

src/google-maps/google-maps-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {MapPolyline} from './map-polyline/map-polyline';
2323
import {MapRectangle} from './map-rectangle/map-rectangle';
2424
import {MapTrafficLayer} from './map-traffic-layer/map-traffic-layer';
2525
import {MapTransitLayer} from './map-transit-layer/map-transit-layer';
26+
import {MapHeatmapLayer} from './map-heatmap-layer/map-heatmap-layer';
2627

2728
const COMPONENTS = [
2829
GoogleMap,
@@ -40,6 +41,7 @@ const COMPONENTS = [
4041
MapRectangle,
4142
MapTrafficLayer,
4243
MapTransitLayer,
44+
MapHeatmapLayer,
4345
];
4446

4547
@NgModule({
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# MapHeatmapLayer
2+
3+
The `MapHeatmapLayer` directive wraps the [`google.maps.visualization.HeatmapLayer` class](https://developers.google.com/maps/documentation/javascript/reference/visualization#HeatmapLayer) from the Google Maps Visualization JavaScript API. It displays
4+
a heatmap layer on the map when it is a content child of a `GoogleMap` component. Like `GoogleMap`,
5+
this directive offers an `options` input as well as a convenience input for passing in the `data`
6+
that is shown on the heatmap.
7+
8+
## Requirements
9+
10+
In order to render a heatmap, the Google Maps JavaScript API has to be loaded with the
11+
`visualization` library. To load the library, you have to add `&libraries=visualization` to the
12+
script that loads the Google Maps API. E.g.
13+
14+
**Before:**
15+
```html
16+
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY"></script>
17+
```
18+
19+
**After:**
20+
```html
21+
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&libraries=visualization"></script>
22+
```
23+
24+
More information: https://developers.google.com/maps/documentation/javascript/heatmaplayer
25+
26+
## Example
27+
28+
```typescript
29+
// google-map-demo.component.ts
30+
import {Component} from '@angular/core';
31+
32+
@Component({
33+
selector: 'google-map-demo',
34+
templateUrl: 'google-map-demo.html',
35+
})
36+
export class GoogleMapDemo {
37+
center = {lat: 37.774546, lng: -122.433523};
38+
zoom = 12;
39+
heatmapOptions = {radius: 5};
40+
heatmapData = [
41+
{lat: 37.782, lng: -122.447},
42+
{lat: 37.782, lng: -122.445},
43+
{lat: 37.782, lng: -122.443},
44+
{lat: 37.782, lng: -122.441},
45+
{lat: 37.782, lng: -122.439},
46+
{lat: 37.782, lng: -122.437},
47+
{lat: 37.782, lng: -122.435},
48+
{lat: 37.785, lng: -122.447},
49+
{lat: 37.785, lng: -122.445},
50+
{lat: 37.785, lng: -122.443},
51+
{lat: 37.785, lng: -122.441},
52+
{lat: 37.785, lng: -122.439},
53+
{lat: 37.785, lng: -122.437},
54+
{lat: 37.785, lng: -122.435}
55+
];
56+
}
57+
```
58+
59+
```html
60+
<!-- google-map-demo.component.html -->
61+
<google-map height="400px" width="750px" [center]="center" [zoom]="zoom">
62+
<map-heatmap-layer [data]="heatmapData" [options]="heatmapOptions"></map-heatmap-layer>
63+
</google-map>
64+
```
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import {Component, ViewChild} from '@angular/core';
2+
import {waitForAsync, TestBed} from '@angular/core/testing';
3+
4+
import {DEFAULT_OPTIONS} from '../google-map/google-map';
5+
6+
import {GoogleMapsModule} from '../google-maps-module';
7+
import {
8+
createMapConstructorSpy,
9+
createMapSpy,
10+
createHeatmapLayerConstructorSpy,
11+
createHeatmapLayerSpy,
12+
createLatLngSpy,
13+
createLatLngConstructorSpy
14+
} from '../testing/fake-google-map-utils';
15+
import {HeatmapData, MapHeatmapLayer} from './map-heatmap-layer';
16+
17+
describe('MapHeatmapLayer', () => {
18+
let mapSpy: jasmine.SpyObj<google.maps.Map>;
19+
let latLngSpy: jasmine.SpyObj<google.maps.LatLng>;
20+
21+
beforeEach(waitForAsync(() => {
22+
TestBed.configureTestingModule({
23+
imports: [GoogleMapsModule],
24+
declarations: [TestApp],
25+
});
26+
}));
27+
28+
beforeEach(() => {
29+
TestBed.compileComponents();
30+
mapSpy = createMapSpy(DEFAULT_OPTIONS);
31+
latLngSpy = createLatLngSpy();
32+
createMapConstructorSpy(mapSpy).and.callThrough();
33+
createLatLngConstructorSpy(latLngSpy).and.callThrough();
34+
});
35+
36+
afterEach(() => {
37+
(window.google as any) = undefined;
38+
});
39+
40+
it('initializes a Google Map heatmap layer', () => {
41+
const heatmapSpy = createHeatmapLayerSpy();
42+
const heatmapConstructorSpy = createHeatmapLayerConstructorSpy(heatmapSpy).and.callThrough();
43+
44+
const fixture = TestBed.createComponent(TestApp);
45+
fixture.detectChanges();
46+
47+
expect(heatmapConstructorSpy).toHaveBeenCalledWith({
48+
data: [],
49+
map: mapSpy,
50+
});
51+
});
52+
53+
it('should throw if the `visualization` library has not been loaded', () => {
54+
createHeatmapLayerConstructorSpy(createHeatmapLayerSpy());
55+
delete (window.google.maps as any).visualization;
56+
57+
expect(() => {
58+
const fixture = TestBed.createComponent(TestApp);
59+
fixture.detectChanges();
60+
}).toThrowError(/Namespace `google.maps.visualization` not found, cannot construct heatmap/);
61+
});
62+
63+
it('sets heatmap inputs', () => {
64+
const options: google.maps.visualization.HeatmapLayerOptions = {
65+
map: mapSpy,
66+
data: [
67+
new google.maps.LatLng(37.782, -122.447),
68+
new google.maps.LatLng(37.782, -122.445),
69+
new google.maps.LatLng(37.782, -122.443)
70+
]
71+
};
72+
const heatmapSpy = createHeatmapLayerSpy();
73+
const heatmapConstructorSpy = createHeatmapLayerConstructorSpy(heatmapSpy).and.callThrough();
74+
75+
const fixture = TestBed.createComponent(TestApp);
76+
fixture.componentInstance.data = options.data;
77+
fixture.detectChanges();
78+
79+
expect(heatmapConstructorSpy).toHaveBeenCalledWith(options);
80+
});
81+
82+
it('sets heatmap options, ignoring map', () => {
83+
const options: Partial<google.maps.visualization.HeatmapLayerOptions> = {
84+
radius: 5,
85+
dissipating: true
86+
};
87+
const data = [
88+
new google.maps.LatLng(37.782, -122.447),
89+
new google.maps.LatLng(37.782, -122.445),
90+
new google.maps.LatLng(37.782, -122.443)
91+
];
92+
const heatmapSpy = createHeatmapLayerSpy();
93+
const heatmapConstructorSpy = createHeatmapLayerConstructorSpy(heatmapSpy).and.callThrough();
94+
95+
const fixture = TestBed.createComponent(TestApp);
96+
fixture.componentInstance.data = data;
97+
fixture.componentInstance.options = options;
98+
fixture.detectChanges();
99+
100+
expect(heatmapConstructorSpy).toHaveBeenCalledWith({...options, map: mapSpy, data});
101+
});
102+
103+
it('exposes methods that provide information about the heatmap', () => {
104+
const heatmapSpy = createHeatmapLayerSpy();
105+
createHeatmapLayerConstructorSpy(heatmapSpy).and.callThrough();
106+
107+
const fixture = TestBed.createComponent(TestApp);
108+
fixture.detectChanges();
109+
const heatmap = fixture.componentInstance.heatmap;
110+
111+
heatmapSpy.getData.and.returnValue([] as any);
112+
expect(heatmap.getData()).toEqual([]);
113+
});
114+
115+
it('should update the heatmap data when the input changes', () => {
116+
const heatmapSpy = createHeatmapLayerSpy();
117+
const heatmapConstructorSpy = createHeatmapLayerConstructorSpy(heatmapSpy).and.callThrough();
118+
let data = [
119+
new google.maps.LatLng(1, 2),
120+
new google.maps.LatLng(3, 4),
121+
new google.maps.LatLng(5, 6)
122+
];
123+
124+
const fixture = TestBed.createComponent(TestApp);
125+
fixture.componentInstance.data = data;
126+
fixture.detectChanges();
127+
128+
expect(heatmapConstructorSpy).toHaveBeenCalledWith(jasmine.objectContaining({data}));
129+
data = [
130+
new google.maps.LatLng(7, 8),
131+
new google.maps.LatLng(9, 10),
132+
new google.maps.LatLng(11, 12)
133+
];
134+
fixture.componentInstance.data = data;
135+
fixture.detectChanges();
136+
137+
expect(heatmapSpy.setData).toHaveBeenCalledWith(data);
138+
});
139+
140+
it('should create a LatLng object if a LatLngLiteral is passed in', () => {
141+
const latLngConstructor = createLatLngConstructorSpy(latLngSpy).and.callThrough();
142+
createHeatmapLayerConstructorSpy(createHeatmapLayerSpy()).and.callThrough();
143+
const fixture = TestBed.createComponent(TestApp);
144+
fixture.componentInstance.data = [{lat: 1, lng: 2}, {lat: 3, lng: 4}];
145+
fixture.detectChanges();
146+
147+
expect(latLngConstructor).toHaveBeenCalledWith(1, 2);
148+
expect(latLngConstructor).toHaveBeenCalledWith(3, 4);
149+
expect(latLngConstructor).toHaveBeenCalledTimes(2);
150+
});
151+
152+
});
153+
154+
@Component({
155+
selector: 'test-app',
156+
template: `
157+
<google-map>
158+
<map-heatmap-layer [data]="data" [options]="options">
159+
</map-heatmap-layer>
160+
</google-map>`,
161+
})
162+
class TestApp {
163+
@ViewChild(MapHeatmapLayer) heatmap: MapHeatmapLayer;
164+
options?: Partial<google.maps.visualization.HeatmapLayerOptions>;
165+
data?: HeatmapData;
166+
}

0 commit comments

Comments
 (0)