Skip to content

Commit

Permalink
feat(google-maps): add heatmap support
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto committed Jan 6, 2021
1 parent 71b7b15 commit c17ec2b
Show file tree
Hide file tree
Showing 12 changed files with 542 additions and 7 deletions.
10 changes: 10 additions & 0 deletions src/dev-app/google-map/google-map-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@
<map-traffic-layer *ngIf="isTrafficLayerDisplayed"></map-traffic-layer>
<map-transit-layer *ngIf="isTransitLayerDisplayed"></map-transit-layer>
<map-bicycling-layer *ngIf="isBicyclingLayerDisplayed"></map-bicycling-layer>
<map-heatmap-layer *ngIf="isHeatmapDisplayed"
[data]="heatmapData"
[options]="heatmapOptions"></map-heatmap-layer>
</google-map>

<p><label>Latitude:</label> {{display?.lat}}</p>
Expand Down Expand Up @@ -150,4 +153,11 @@
</label>
</div>

<div>
<label for="heatmap-layer-checkbox">
Toggle Heatmap Layer
<input type="checkbox" (click)="toggleHeatmapLayerDisplay()">
</label>
</div>

</div>
20 changes: 20 additions & 0 deletions src/dev-app/google-map/google-map-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export class GoogleMapDemo {
polylineOptions:
google.maps.PolylineOptions = {path: POLYLINE_PATH, strokeColor: 'grey', strokeOpacity: 0.8};

heatmapData = this._getHeatmapData(5, 1);
heatmapOptions = {radius: 50};
isHeatmapDisplayed = false;

isPolygonDisplayed = false;
polygonOptions:
google.maps.PolygonOptions = {paths: POLYGON_PATH, strokeColor: 'grey', strokeOpacity: 0.8};
Expand Down Expand Up @@ -190,4 +194,20 @@ export class GoogleMapDemo {
toggleBicyclingLayerDisplay() {
this.isBicyclingLayerDisplayed = !this.isBicyclingLayerDisplayed;
}

toggleHeatmapLayerDisplay() {
this.isHeatmapDisplayed = !this.isHeatmapDisplayed;
}

private _getHeatmapData(offset: number, increment: number) {
const result: google.maps.LatLngLiteral[] = [];

for (let lat = this.center.lat - offset; lat < this.center.lat + offset; lat += increment) {
for (let lng = this.center.lng - offset; lng < this.center.lng + offset; lng += increment) {
result.push({lat, lng});
}
}

return result;
}
}
2 changes: 1 addition & 1 deletion src/dev-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
<script src="systemjs/dist/system.js"></script>
<script src="system-config.js"></script>
<script src="https://www.youtube.com/iframe_api"></script>
<script src="https://maps.googleapis.com/maps/api/js"></script>
<script src="https://maps.googleapis.com/maps/api/js?libraries=visualization"></script>
<script src="https://unpkg.com/@googlemaps/markerclustererplus/dist/index.min.js"></script>
<script>
System.config({
Expand Down
24 changes: 19 additions & 5 deletions src/google-maps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,23 @@ To install, run `npm install @angular/google-maps`.
<!doctype html>
<head>
...
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY">
</script>
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY"></script>
</head>
```

**Note:**
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:

```html
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&libraries=visualization"></script>
```

## Lazy 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.
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
Expand Down Expand Up @@ -101,10 +110,14 @@ export class GoogleMapsDemoComponent {
- [`MapTrafficLayer`](./map-traffic-layer/README.md)
- [`MapTransitLayer`](./map-transit-layer/README.md)
- [`MapBicyclingLayer`](./map-bicycling-layer/README.md)
- [`MapHeatmapLayer`](./map-heatmap-layer/README.md)

## The Options Input

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:
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:

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

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.
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.
2 changes: 2 additions & 0 deletions src/google-maps/google-maps-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {MapPolyline} from './map-polyline/map-polyline';
import {MapRectangle} from './map-rectangle/map-rectangle';
import {MapTrafficLayer} from './map-traffic-layer/map-traffic-layer';
import {MapTransitLayer} from './map-transit-layer/map-transit-layer';
import {MapHeatmapLayer} from './map-heatmap-layer/map-heatmap-layer';

const COMPONENTS = [
GoogleMap,
Expand All @@ -38,6 +39,7 @@ const COMPONENTS = [
MapRectangle,
MapTrafficLayer,
MapTransitLayer,
MapHeatmapLayer,
];

@NgModule({
Expand Down
64 changes: 64 additions & 0 deletions src/google-maps/map-heatmap-layer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# MapHeatmapLayer

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
a heatmap layer on the map when it is a content child of a `GoogleMap` component. Like `GoogleMap`,
this directive offers an `options` input as well as a convenience input for passing in the `data`
that is shown on the heatmap.

## Requirements

In order to render a heatmap, the Google Maps JavaScript API has to be loaded with the
`visualization` library. To load the library, you have to add `&libraries=visualization` to the
script that loads the Google Maps API. E.g.

**Before:**
```html
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY"></script>
```

**After:**
```html
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&libraries=visualization"></script>
```

More information: https://developers.google.com/maps/documentation/javascript/heatmaplayer

## Example

```typescript
// google-map-demo.component.ts
import {Component} from '@angular/core';

@Component({
selector: 'google-map-demo',
templateUrl: 'google-map-demo.html',
})
export class GoogleMapDemo {
center = {lat: 37.774546, lng: -122.433523};
zoom = 12;
heatmapOptions = {radius: 5};
heatmapData = [
{lat: 37.782, lng: -122.447},
{lat: 37.782, lng: -122.445},
{lat: 37.782, lng: -122.443},
{lat: 37.782, lng: -122.441},
{lat: 37.782, lng: -122.439},
{lat: 37.782, lng: -122.437},
{lat: 37.782, lng: -122.435},
{lat: 37.785, lng: -122.447},
{lat: 37.785, lng: -122.445},
{lat: 37.785, lng: -122.443},
{lat: 37.785, lng: -122.441},
{lat: 37.785, lng: -122.439},
{lat: 37.785, lng: -122.437},
{lat: 37.785, lng: -122.435}
];
}
```

```html
<!-- google-map-demo.component.html -->
<google-map height="400px" width="750px" [center]="center" [zoom]="zoom">
<map-heatmap-layer [data]="heatmapData" [options]="heatmapOptions"></map-heatmap-layer>
</google-map>
```
166 changes: 166 additions & 0 deletions src/google-maps/map-heatmap-layer/map-heatmap-layer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import {Component, ViewChild} from '@angular/core';
import {waitForAsync, TestBed} from '@angular/core/testing';

import {DEFAULT_OPTIONS} from '../google-map/google-map';

import {GoogleMapsModule} from '../google-maps-module';
import {
createMapConstructorSpy,
createMapSpy,
createHeatmapLayerConstructorSpy,
createHeatmapLayerSpy,
createLatLngSpy,
createLatLngConstructorSpy
} from '../testing/fake-google-map-utils';
import {HeatmapData, MapHeatmapLayer} from './map-heatmap-layer';

describe('MapHeatmapLayer', () => {
let mapSpy: jasmine.SpyObj<google.maps.Map>;
let latLngSpy: jasmine.SpyObj<google.maps.LatLng>;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [GoogleMapsModule],
declarations: [TestApp],
});
}));

beforeEach(() => {
TestBed.compileComponents();
mapSpy = createMapSpy(DEFAULT_OPTIONS);
latLngSpy = createLatLngSpy();
createMapConstructorSpy(mapSpy).and.callThrough();
createLatLngConstructorSpy(latLngSpy).and.callThrough();
});

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

it('initializes a Google Map heatmap layer', () => {
const heatmapSpy = createHeatmapLayerSpy();
const heatmapConstructorSpy = createHeatmapLayerConstructorSpy(heatmapSpy).and.callThrough();

const fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();

expect(heatmapConstructorSpy).toHaveBeenCalledWith({
data: [],
map: mapSpy,
});
});

it('should throw if the `visualization` library has not been loaded', () => {
createHeatmapLayerConstructorSpy(createHeatmapLayerSpy());
delete (window.google.maps as any).visualization;

expect(() => {
const fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();
}).toThrowError(/Namespace `google.maps.visualization` not found, cannot construct heatmap/);
});

it('sets heatmap inputs', () => {
const options: google.maps.visualization.HeatmapLayerOptions = {
map: mapSpy,
data: [
new google.maps.LatLng(37.782, -122.447),
new google.maps.LatLng(37.782, -122.445),
new google.maps.LatLng(37.782, -122.443)
]
};
const heatmapSpy = createHeatmapLayerSpy();
const heatmapConstructorSpy = createHeatmapLayerConstructorSpy(heatmapSpy).and.callThrough();

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.data = options.data;
fixture.detectChanges();

expect(heatmapConstructorSpy).toHaveBeenCalledWith(options);
});

it('sets heatmap options, ignoring map', () => {
const options: Partial<google.maps.visualization.HeatmapLayerOptions> = {
radius: 5,
dissipating: true
};
const data = [
new google.maps.LatLng(37.782, -122.447),
new google.maps.LatLng(37.782, -122.445),
new google.maps.LatLng(37.782, -122.443)
];
const heatmapSpy = createHeatmapLayerSpy();
const heatmapConstructorSpy = createHeatmapLayerConstructorSpy(heatmapSpy).and.callThrough();

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.data = data;
fixture.componentInstance.options = options;
fixture.detectChanges();

expect(heatmapConstructorSpy).toHaveBeenCalledWith({...options, map: mapSpy, data});
});

it('exposes methods that provide information about the heatmap', () => {
const heatmapSpy = createHeatmapLayerSpy();
createHeatmapLayerConstructorSpy(heatmapSpy).and.callThrough();

const fixture = TestBed.createComponent(TestApp);
fixture.detectChanges();
const heatmap = fixture.componentInstance.heatmap;

heatmapSpy.getData.and.returnValue([] as any);
expect(heatmap.getData()).toEqual([]);
});

it('should update the heatmap data when the input changes', () => {
const heatmapSpy = createHeatmapLayerSpy();
const heatmapConstructorSpy = createHeatmapLayerConstructorSpy(heatmapSpy).and.callThrough();
let data = [
new google.maps.LatLng(1, 2),
new google.maps.LatLng(3, 4),
new google.maps.LatLng(5, 6)
];

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.data = data;
fixture.detectChanges();

expect(heatmapConstructorSpy).toHaveBeenCalledWith(jasmine.objectContaining({data}));
data = [
new google.maps.LatLng(7, 8),
new google.maps.LatLng(9, 10),
new google.maps.LatLng(11, 12)
];
fixture.componentInstance.data = data;
fixture.detectChanges();

expect(heatmapSpy.setData).toHaveBeenCalledWith(data);
});

it('should create a LatLng object if a LatLngLiteral is passed in', () => {
const latLngConstructor = createLatLngConstructorSpy(latLngSpy).and.callThrough();
createHeatmapLayerConstructorSpy(createHeatmapLayerSpy()).and.callThrough();
const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.data = [{lat: 1, lng: 2}, {lat: 3, lng: 4}];
fixture.detectChanges();

expect(latLngConstructor).toHaveBeenCalledWith(1, 2);
expect(latLngConstructor).toHaveBeenCalledWith(3, 4);
expect(latLngConstructor).toHaveBeenCalledTimes(2);
});

});

@Component({
selector: 'test-app',
template: `
<google-map>
<map-heatmap-layer [data]="data" [options]="options">
</map-heatmap-layer>
</google-map>`,
})
class TestApp {
@ViewChild(MapHeatmapLayer) heatmap: MapHeatmapLayer;
options?: Partial<google.maps.visualization.HeatmapLayerOptions>;
data?: HeatmapData;
}
Loading

0 comments on commit c17ec2b

Please sign in to comment.