Skip to content

Commit 87d2c81

Browse files
committed
feat(google-maps): add heatmap support
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 71b7b15 commit 87d2c81

File tree

12 files changed

+542
-7
lines changed

12 files changed

+542
-7
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
<map-traffic-layer *ngIf="isTrafficLayerDisplayed"></map-traffic-layer>
3131
<map-transit-layer *ngIf="isTransitLayerDisplayed"></map-transit-layer>
3232
<map-bicycling-layer *ngIf="isBicyclingLayerDisplayed"></map-bicycling-layer>
33+
<map-heatmap-layer *ngIf="isHeatmapDisplayed"
34+
[data]="heatmapData"
35+
[options]="heatmapOptions"></map-heatmap-layer>
3336
</google-map>
3437

3538
<p><label>Latitude:</label> {{display?.lat}}</p>
@@ -150,4 +153,11 @@
150153
</label>
151154
</div>
152155

156+
<div>
157+
<label for="heatmap-layer-checkbox">
158+
Toggle Heatmap Layer
159+
<input type="checkbox" (click)="toggleHeatmapLayerDisplay()">
160+
</label>
161+
</div>
162+
153163
</div>

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

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

60+
heatmapData = this._getHeatmapData(5, 1);
61+
heatmapOptions = {radius: 50};
62+
isHeatmapDisplayed = false;
63+
6064
isPolygonDisplayed = false;
6165
polygonOptions:
6266
google.maps.PolygonOptions = {paths: POLYGON_PATH, strokeColor: 'grey', strokeOpacity: 0.8};
@@ -190,4 +194,20 @@ export class GoogleMapDemo {
190194
toggleBicyclingLayerDisplay() {
191195
this.isBicyclingLayerDisplayed = !this.isBicyclingLayerDisplayed;
192196
}
197+
198+
toggleHeatmapLayerDisplay() {
199+
this.isHeatmapDisplayed = !this.isHeatmapDisplayed;
200+
}
201+
202+
private _getHeatmapData(offset: number, increment: number) {
203+
const result: google.maps.LatLngLiteral[] = [];
204+
205+
for (let lat = this.center.lat - offset; lat < this.center.lat + offset; lat += increment) {
206+
for (let lng = this.center.lng - offset; lng < this.center.lng + offset; lng += increment) {
207+
result.push({lat, lng});
208+
}
209+
}
210+
211+
return result;
212+
}
193213
}

src/dev-app/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
<script src="systemjs/dist/system.js"></script>
3030
<script src="system-config.js"></script>
3131
<script src="https://www.youtube.com/iframe_api"></script>
32-
<script src="https://maps.googleapis.com/maps/api/js"></script>
32+
<script src="https://maps.googleapis.com/maps/api/js?libraries=visualization"></script>
3333
<script src="https://unpkg.com/@googlemaps/markerclustererplus/dist/index.min.js"></script>
3434
<script>
3535
System.config({

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
@@ -101,10 +110,14 @@ export class GoogleMapsDemoComponent {
101110
- [`MapTrafficLayer`](./map-traffic-layer/README.md)
102111
- [`MapTransitLayer`](./map-transit-layer/README.md)
103112
- [`MapBicyclingLayer`](./map-bicycling-layer/README.md)
113+
- [`MapHeatmapLayer`](./map-heatmap-layer/README.md)
104114

105115
## The Options Input
106116

107-
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:
117+
The Google Maps components implement all of the options for their respective objects from the
118+
Google Maps JavaScript API through an `options` input, but they also have specific inputs for some
119+
of the most common options. For example, the Google Maps component could have its options set either
120+
in with a google.maps.MapOptions object:
108121

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

132-
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.
145+
Not every option has its own input. See the API for each component to see if the option has a
146+
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
@@ -22,6 +22,7 @@ import {MapPolyline} from './map-polyline/map-polyline';
2222
import {MapRectangle} from './map-rectangle/map-rectangle';
2323
import {MapTrafficLayer} from './map-traffic-layer/map-traffic-layer';
2424
import {MapTransitLayer} from './map-transit-layer/map-transit-layer';
25+
import {MapHeatmapLayer} from './map-heatmap-layer/map-heatmap-layer';
2526

2627
const COMPONENTS = [
2728
GoogleMap,
@@ -38,6 +39,7 @@ const COMPONENTS = [
3839
MapRectangle,
3940
MapTrafficLayer,
4041
MapTransitLayer,
42+
MapHeatmapLayer,
4143
];
4244

4345
@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)