Skip to content

Commit

Permalink
feat(google-maps): Add MapDirectionsRenderer and MapDirectionsService (
Browse files Browse the repository at this point in the history
…#21736)

* feat(google-maps): Add MapDirectionsRenderer and MapDirectionsService

Add the MapDirectionsService which wraps google.maps.DirectionsService
to calculate routes between two points and the MapDirectionsRenderer
component which allows these routes to be displayed on the Google Map.

* feat(google-maps): Add MapDirectionsRenderer and MapDirectionsService

Update public api guard with new component and service. Make minor
optimizations with MapDirectionsService.

* feat(google-maps): Add MapDirectionsRenderer and MapDirectionsService

Add comments explaining how secret API key is loaded by dev-server.
  • Loading branch information
mbehrlich authored Feb 5, 2021
1 parent 6fee271 commit 9dbf49b
Show file tree
Hide file tree
Showing 15 changed files with 588 additions and 3 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ testem.log
*.log
.ng-dev.user*
.husky/_
/src/dev-app/google-maps-api-key.txt
8 changes: 8 additions & 0 deletions src/dev-app/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ create_system_config(
output_name = "system-config.js",
)

# File group for static files that are listed in the gitignore file that contain
# secrets like API keys.
filegroup(
name = "environment-secret-assets",
srcs = glob(["*-api-key.txt"]),
)

# File group for all static files which are needed to serve the dev-app. These files are
# used in the devserver as runfiles and will be copied into the static web package that can
# be deployed on static hosting services (like firebase).
Expand All @@ -129,6 +136,7 @@ filegroup(
srcs = [
"favicon.ico",
"index.html",
":environment-secret-assets",
":system-config",
":theme",
"//src/dev-app/icon:icon_demo_assets",
Expand Down
9 changes: 9 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-directions-renderer *ngIf="directionsResult"
[directions]="directionsResult"></map-directions-renderer>

</google-map>

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

<div>
<button mat-button (click)="calculateDirections()">
Calculate directions between first two markers
</button>
</div>

</div>
20 changes: 19 additions & 1 deletion src/dev-app/google-map/google-map-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import {Component, ViewChild} from '@angular/core';
import {
MapCircle,
MapDirectionsService,
MapInfoWindow,
MapMarker,
MapPolygon,
Expand Down Expand Up @@ -39,7 +40,7 @@ const CIRCLE_RADIUS = 500000;
@Component({
selector: 'google-map-demo',
templateUrl: 'google-map-demo.html',
styleUrls: ['google-map-demo.css']
styleUrls: ['google-map-demo.css'],
})
export class GoogleMapDemo {
@ViewChild(MapInfoWindow) infoWindow: MapInfoWindow;
Expand Down Expand Up @@ -98,6 +99,10 @@ export class GoogleMapDemo {
markerClustererImagePath =
'https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m';

directionsResult?: google.maps.DirectionsResult;

constructor(private readonly _mapDirectionsService: MapDirectionsService) {}

handleClick(event: google.maps.MapMouseEvent) {
this.markerPositions.push(event.latLng.toJSON());
}
Expand Down Expand Up @@ -190,4 +195,17 @@ export class GoogleMapDemo {
toggleBicyclingLayerDisplay() {
this.isBicyclingLayerDisplayed = !this.isBicyclingLayerDisplayed;
}

calculateDirections() {
if (this.markerPositions.length >= 2) {
const request: google.maps.DirectionsRequest = {
destination: this.markerPositions[1],
origin: this.markerPositions[0],
travelMode: google.maps.TravelMode.DRIVING,
};
this._mapDirectionsService.route(request).subscribe(response => {
this.directionsResult = response.result;
});
}
}
}
20 changes: 19 additions & 1 deletion src/dev-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,31 @@
<body>
<dev-app>Loading...</dev-app>

<!-- This iframe loads the hidden Google Maps API Key. -->
<iframe id="google-maps-api-key"
src="google-maps-api-key.txt"
style="display:none;"
onload="loadGoogleMapsScript()"></iframe>

<script src="core-js-bundle/index.js"></script>
<script src="zone.js/dist/zone.js"></script>
<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://unpkg.com/@googlemaps/markerclustererplus/dist/index.min.js"></script>
<script>
function loadGoogleMapsScript() {
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';
if (googleMapsApiKey !== 'Page not found') {
googleMapsUrl = googleMapsUrl + '?key=' + googleMapsApiKey;
}
googleMapsScript.src = googleMapsUrl;
document.body.appendChild(googleMapsScript);
}
</script>
<script>
System.config({
map: {
Expand Down
1 change: 1 addition & 0 deletions src/google-maps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export class GoogleMapsDemoComponent {
- [`MapTrafficLayer`](./map-traffic-layer/README.md)
- [`MapTransitLayer`](./map-transit-layer/README.md)
- [`MapBicyclingLayer`](./map-bicycling-layer/README.md)
- [`MapDirectionsRenderer`](./map-directions-renderer/README.md)

## The Options Input

Expand Down
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 @@ -12,6 +12,7 @@ import {GoogleMap} from './google-map/google-map';
import {MapBaseLayer} from './map-base-layer';
import {MapBicyclingLayer} from './map-bicycling-layer/map-bicycling-layer';
import {MapCircle} from './map-circle/map-circle';
import {MapDirectionsRenderer} from './map-directions-renderer/map-directions-renderer';
import {MapGroundOverlay} from './map-ground-overlay/map-ground-overlay';
import {MapInfoWindow} from './map-info-window/map-info-window';
import {MapKmlLayer} from './map-kml-layer/map-kml-layer';
Expand All @@ -28,6 +29,7 @@ const COMPONENTS = [
MapBaseLayer,
MapBicyclingLayer,
MapCircle,
MapDirectionsRenderer,
MapGroundOverlay,
MapInfoWindow,
MapKmlLayer,
Expand Down
56 changes: 56 additions & 0 deletions src/google-maps/map-directions-renderer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# MapDirectionsRenderer

The `MapDirectionsRenderer` component wraps the [`google.maps.DirectionsRenderer` class](https://developers.google.com/maps/documentation/javascript/reference/directions#DirectionsRenderer) from the Google Maps JavaScript API. This can easily be used with the `MapDirectionsService` that wraps [`google.maps.DirectionsService`](https://developers.google.com/maps/documentation/javascript/reference/directions#DirectionsService) which is designed to be used with Angular by returning an `Observable` response and works inside the Angular Zone.

The `MapDirectionsService`, like the `google.maps.DirectionsService`, has a single method, `route`. Normally, the `google.maps.DirectionsService` takes two arguments, a `google.maps.DirectionsRequest` and a callback that takes the `google.maps.DirectionsResult` and `google.maps.DirectionsStatus` as arguments. The `MapDirectionsService` route method takes takes the `google.maps.DirectionsRequest` as the single argument, and returns an `Observable` of a `MapDirectionsResponse`, which is an interface defined as follows:

```typescript
export interface MapDirectionsResponse {
status: google.maps.DirectionsStatus;
result?: google.maps.DirectionsResult;
}
```

The most common usecase for the component and class would be to use the `MapDirectionsService` to request a route between two points on the map, and then render them on the map using the `MapDirectionsRenderer`.

## Loading the Library

Using the `MapDirectionsService` requires the Directions API to be enabled in Google Cloud Console on the same project as the one set up for the Google Maps JavaScript API, and requires an API key that has billing enabled. See [here](https://developers.google.com/maps/documentation/javascript/directions#GetStarted) for details.

## Example

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

@Component({
selector: 'google-map-demo',
templateUrl: 'google-map-demo.html',
})
export class GoogleMapDemo {
center: google.maps.LatLngLiteral = {lat: 24, lng: 12};
zoom = 4;

readonly directionsResults$: Observable<google.maps.DirectionsResult|undefined>;

constructor(mapDirectionsService: MapDirectionsService) {
const request: google.maps.DirectionsRequest = {
destination: {lat: 12, lng: 4},
origin: {lat: 13, lng: 5},
travelMode: google.maps.TravelMode.DRIVING,
};
this.directionsResults$ = mapDirectionsService.route(request).pipe(map(response => response.result));
}
}
```

```html
<!-- google-maps-demo.component.html -->
<google-map height="400px"
width="750px"
[center]="center"
[zoom]="zoom">
<map-directions-renderer *ngIf="(directionsResults$ | async) as directionsResults"
[directions]="directionsResults"></map-directions-renderer>
</google-map>
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {Component, ViewChild} from '@angular/core';
import {TestBed, waitForAsync} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {MapDirectionsRenderer} from './map-directions-renderer';
import {DEFAULT_OPTIONS} from '../google-map/google-map';
import {GoogleMapsModule} from '../google-maps-module';
import {
createDirectionsRendererConstructorSpy,
createDirectionsRendererSpy,
createMapConstructorSpy,
createMapSpy
} from '../testing/fake-google-map-utils';

const DEFAULT_DIRECTIONS: google.maps.DirectionsResult = {
geocoded_waypoints: [],
routes: [],
};

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

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

beforeEach(() => {
TestBed.compileComponents();

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

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

it('initializes a Google Maps DirectionsRenderer', () => {
const directionsRendererSpy = createDirectionsRendererSpy({directions: DEFAULT_DIRECTIONS});
const directionsRendererConstructorSpy =
createDirectionsRendererConstructorSpy(directionsRendererSpy).and.callThrough();

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.options = {directions: DEFAULT_DIRECTIONS};
fixture.detectChanges();

expect(directionsRendererConstructorSpy)
.toHaveBeenCalledWith({directions: DEFAULT_DIRECTIONS, map: jasmine.any(Object)});
expect(directionsRendererSpy.setMap).toHaveBeenCalledWith(mapSpy);
});

it('sets directions from directions input', () => {
const directionsRendererSpy = createDirectionsRendererSpy({directions: DEFAULT_DIRECTIONS});
const directionsRendererConstructorSpy =
createDirectionsRendererConstructorSpy(directionsRendererSpy).and.callThrough();

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

expect(directionsRendererConstructorSpy)
.toHaveBeenCalledWith({directions: DEFAULT_DIRECTIONS, map: jasmine.any(Object)});
expect(directionsRendererSpy.setMap).toHaveBeenCalledWith(mapSpy);
});

it('gives precedence to directions over options', () => {
const updatedDirections: google.maps.DirectionsResult = {
geocoded_waypoints: [{partial_match: false, place_id: 'test', types: []}],
routes: [],
};
const directionsRendererSpy = createDirectionsRendererSpy({directions: updatedDirections});
const directionsRendererConstructorSpy =
createDirectionsRendererConstructorSpy(directionsRendererSpy).and.callThrough();

const fixture = TestBed.createComponent(TestApp);
fixture.componentInstance.options = {directions: DEFAULT_DIRECTIONS};
fixture.componentInstance.directions = updatedDirections;
fixture.detectChanges();

expect(directionsRendererConstructorSpy)
.toHaveBeenCalledWith({directions: updatedDirections, map: jasmine.any(Object)});
expect(directionsRendererSpy.setMap).toHaveBeenCalledWith(mapSpy);
});

it('exposes methods that provide information from the DirectionsRenderer', () => {
const directionsRendererSpy = createDirectionsRendererSpy({});
createDirectionsRendererConstructorSpy(directionsRendererSpy).and.callThrough();

const fixture = TestBed.createComponent(TestApp);

const directionsRendererComponent =
fixture.debugElement.query(By.directive(MapDirectionsRenderer))!
.injector.get<MapDirectionsRenderer>(MapDirectionsRenderer);
fixture.detectChanges();

directionsRendererSpy.getDirections.and.returnValue(DEFAULT_DIRECTIONS);
expect(directionsRendererComponent.getDirections()).toBe(DEFAULT_DIRECTIONS);

directionsRendererComponent.getPanel();
expect(directionsRendererSpy.getPanel).toHaveBeenCalled();

directionsRendererSpy.getRouteIndex.and.returnValue(10);
expect(directionsRendererComponent.getRouteIndex()).toBe(10);
});

it('initializes DirectionsRenderer event handlers', () => {
const directionsRendererSpy = createDirectionsRendererSpy({});
createDirectionsRendererConstructorSpy(directionsRendererSpy).and.callThrough();

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

expect(directionsRendererSpy.addListener)
.toHaveBeenCalledWith('directions_changed', jasmine.any(Function));
});
});

@Component({
selector: 'test-app',
template: `<google-map>
<map-directions-renderer [options]="options"
[directions]="directions"
(directionsChanged)="handleDirectionsChanged()">
</map-directions-renderer>
</google-map>`,
})
class TestApp {
@ViewChild(MapDirectionsRenderer) directionsRenderer: MapDirectionsRenderer;
options?: google.maps.DirectionsRendererOptions;
directions?: google.maps.DirectionsResult;

handleDirectionsChanged() {}
}
Loading

0 comments on commit 9dbf49b

Please sign in to comment.