Skip to content

feat(google-maps): add heatmap support #21489

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 7, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
"@angular/elements": "^11.1.0",
"@angular/forms": "^11.1.0",
"@angular/platform-browser": "^11.1.0",
"@types/googlemaps": "^3.43.0",
"@types/googlemaps": "^3.43.1",
"@types/youtube": "^0.0.40",
"@webcomponents/custom-elements": "^1.1.0",
"core-js-bundle": "^3.8.2",
Expand Down
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 @@ -33,6 +33,9 @@
<map-directions-renderer *ngIf="directionsResult"
[directions]="directionsResult"></map-directions-renderer>

<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 @@ -153,6 +156,13 @@
</label>
</div>

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

<div>
<button mat-button (click)="calculateDirections()">
Calculate directions between first two markers
Expand Down
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 @@ -58,6 +58,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 @@ -208,4 +212,20 @@ export class GoogleMapDemo {
});
}
}

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;
}
}
4 changes: 2 additions & 2 deletions src/dev-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@
var iframe = document.getElementById('google-maps-api-key');
var googleMapsScript = document.createElement('script');
var googleMapsApiKey = iframe.contentDocument.body.textContent;
var googleMapsUrl = 'https://maps.googleapis.com/maps/api/js';
var googleMapsUrl = 'https://maps.googleapis.com/maps/api/js?libraries=visualization';
if (googleMapsApiKey !== 'Page not found') {
googleMapsUrl = googleMapsUrl + '?key=' + googleMapsApiKey;
googleMapsUrl = googleMapsUrl + '&key=' + googleMapsApiKey;
}
googleMapsScript.src = googleMapsUrl;
document.body.appendChild(googleMapsScript);
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:**
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are several classes that we may want to implement in the future that also require adding separate libraries. Should we make this note more general, to apply to several components, while giving more specific instructions about which library to use in the component-specific readme?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it may be too vague if we're doing it now since there aren't any other components that use this approach. It might be better to do it once we have other components.

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 @@ -102,10 +111,14 @@ export class GoogleMapsDemoComponent {
- [`MapTransitLayer`](./map-transit-layer/README.md)
- [`MapBicyclingLayer`](./map-bicycling-layer/README.md)
- [`MapDirectionsRenderer`](./map-directions-renderer/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 @@ -130,4 +143,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 @@ -23,6 +23,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 @@ -40,6 +41,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