Skip to content

Commit 9dbf49b

Browse files
authored
feat(google-maps): Add MapDirectionsRenderer and MapDirectionsService (#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.
1 parent 6fee271 commit 9dbf49b

File tree

15 files changed

+588
-3
lines changed

15 files changed

+588
-3
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ testem.log
4343
*.log
4444
.ng-dev.user*
4545
.husky/_
46+
/src/dev-app/google-maps-api-key.txt

src/dev-app/BUILD.bazel

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,13 @@ create_system_config(
121121
output_name = "system-config.js",
122122
)
123123

124+
# File group for static files that are listed in the gitignore file that contain
125+
# secrets like API keys.
126+
filegroup(
127+
name = "environment-secret-assets",
128+
srcs = glob(["*-api-key.txt"]),
129+
)
130+
124131
# File group for all static files which are needed to serve the dev-app. These files are
125132
# used in the devserver as runfiles and will be copied into the static web package that can
126133
# be deployed on static hosting services (like firebase).
@@ -129,6 +136,7 @@ filegroup(
129136
srcs = [
130137
"favicon.ico",
131138
"index.html",
139+
":environment-secret-assets",
132140
":system-config",
133141
":theme",
134142
"//src/dev-app/icon:icon_demo_assets",

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

Lines changed: 9 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-directions-renderer *ngIf="directionsResult"
34+
[directions]="directionsResult"></map-directions-renderer>
35+
3336
</google-map>
3437

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

156+
<div>
157+
<button mat-button (click)="calculateDirections()">
158+
Calculate directions between first two markers
159+
</button>
160+
</div>
161+
153162
</div>

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {Component, ViewChild} from '@angular/core';
1010
import {
1111
MapCircle,
12+
MapDirectionsService,
1213
MapInfoWindow,
1314
MapMarker,
1415
MapPolygon,
@@ -39,7 +40,7 @@ const CIRCLE_RADIUS = 500000;
3940
@Component({
4041
selector: 'google-map-demo',
4142
templateUrl: 'google-map-demo.html',
42-
styleUrls: ['google-map-demo.css']
43+
styleUrls: ['google-map-demo.css'],
4344
})
4445
export class GoogleMapDemo {
4546
@ViewChild(MapInfoWindow) infoWindow: MapInfoWindow;
@@ -98,6 +99,10 @@ export class GoogleMapDemo {
9899
markerClustererImagePath =
99100
'https://developers.google.com/maps/documentation/javascript/examples/markerclusterer/m';
100101

102+
directionsResult?: google.maps.DirectionsResult;
103+
104+
constructor(private readonly _mapDirectionsService: MapDirectionsService) {}
105+
101106
handleClick(event: google.maps.MapMouseEvent) {
102107
this.markerPositions.push(event.latLng.toJSON());
103108
}
@@ -190,4 +195,17 @@ export class GoogleMapDemo {
190195
toggleBicyclingLayerDisplay() {
191196
this.isBicyclingLayerDisplayed = !this.isBicyclingLayerDisplayed;
192197
}
198+
199+
calculateDirections() {
200+
if (this.markerPositions.length >= 2) {
201+
const request: google.maps.DirectionsRequest = {
202+
destination: this.markerPositions[1],
203+
origin: this.markerPositions[0],
204+
travelMode: google.maps.TravelMode.DRIVING,
205+
};
206+
this._mapDirectionsService.route(request).subscribe(response => {
207+
this.directionsResult = response.result;
208+
});
209+
}
210+
}
193211
}

src/dev-app/index.html

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,31 @@
2525
<body>
2626
<dev-app>Loading...</dev-app>
2727

28+
<!-- This iframe loads the hidden Google Maps API Key. -->
29+
<iframe id="google-maps-api-key"
30+
src="google-maps-api-key.txt"
31+
style="display:none;"
32+
onload="loadGoogleMapsScript()"></iframe>
33+
2834
<script src="core-js-bundle/index.js"></script>
2935
<script src="zone.js/dist/zone.js"></script>
3036
<script src="systemjs/dist/system.js"></script>
3137
<script src="system-config.js"></script>
3238
<script src="https://www.youtube.com/iframe_api"></script>
33-
<script src="https://maps.googleapis.com/maps/api/js"></script>
3439
<script src="https://unpkg.com/@googlemaps/markerclustererplus/dist/index.min.js"></script>
40+
<script>
41+
function loadGoogleMapsScript() {
42+
var iframe = document.getElementById('google-maps-api-key');
43+
var googleMapsScript = document.createElement('script');
44+
var googleMapsApiKey = iframe.contentDocument.body.textContent;
45+
var googleMapsUrl = 'https://maps.googleapis.com/maps/api/js';
46+
if (googleMapsApiKey !== 'Page not found') {
47+
googleMapsUrl = googleMapsUrl + '?key=' + googleMapsApiKey;
48+
}
49+
googleMapsScript.src = googleMapsUrl;
50+
document.body.appendChild(googleMapsScript);
51+
}
52+
</script>
3553
<script>
3654
System.config({
3755
map: {

src/google-maps/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export class GoogleMapsDemoComponent {
101101
- [`MapTrafficLayer`](./map-traffic-layer/README.md)
102102
- [`MapTransitLayer`](./map-transit-layer/README.md)
103103
- [`MapBicyclingLayer`](./map-bicycling-layer/README.md)
104+
- [`MapDirectionsRenderer`](./map-directions-renderer/README.md)
104105

105106
## The Options Input
106107

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {GoogleMap} from './google-map/google-map';
1212
import {MapBaseLayer} from './map-base-layer';
1313
import {MapBicyclingLayer} from './map-bicycling-layer/map-bicycling-layer';
1414
import {MapCircle} from './map-circle/map-circle';
15+
import {MapDirectionsRenderer} from './map-directions-renderer/map-directions-renderer';
1516
import {MapGroundOverlay} from './map-ground-overlay/map-ground-overlay';
1617
import {MapInfoWindow} from './map-info-window/map-info-window';
1718
import {MapKmlLayer} from './map-kml-layer/map-kml-layer';
@@ -28,6 +29,7 @@ const COMPONENTS = [
2829
MapBaseLayer,
2930
MapBicyclingLayer,
3031
MapCircle,
32+
MapDirectionsRenderer,
3133
MapGroundOverlay,
3234
MapInfoWindow,
3335
MapKmlLayer,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# MapDirectionsRenderer
2+
3+
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.
4+
5+
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:
6+
7+
```typescript
8+
export interface MapDirectionsResponse {
9+
status: google.maps.DirectionsStatus;
10+
result?: google.maps.DirectionsResult;
11+
}
12+
```
13+
14+
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`.
15+
16+
## Loading the Library
17+
18+
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.
19+
20+
## Example
21+
22+
```typescript
23+
// google-maps-demo.component.ts
24+
import {Component} from '@angular/core';
25+
26+
@Component({
27+
selector: 'google-map-demo',
28+
templateUrl: 'google-map-demo.html',
29+
})
30+
export class GoogleMapDemo {
31+
center: google.maps.LatLngLiteral = {lat: 24, lng: 12};
32+
zoom = 4;
33+
34+
readonly directionsResults$: Observable<google.maps.DirectionsResult|undefined>;
35+
36+
constructor(mapDirectionsService: MapDirectionsService) {
37+
const request: google.maps.DirectionsRequest = {
38+
destination: {lat: 12, lng: 4},
39+
origin: {lat: 13, lng: 5},
40+
travelMode: google.maps.TravelMode.DRIVING,
41+
};
42+
this.directionsResults$ = mapDirectionsService.route(request).pipe(map(response => response.result));
43+
}
44+
}
45+
```
46+
47+
```html
48+
<!-- google-maps-demo.component.html -->
49+
<google-map height="400px"
50+
width="750px"
51+
[center]="center"
52+
[zoom]="zoom">
53+
<map-directions-renderer *ngIf="(directionsResults$ | async) as directionsResults"
54+
[directions]="directionsResults"></map-directions-renderer>
55+
</google-map>
56+
```
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {Component, ViewChild} from '@angular/core';
2+
import {TestBed, waitForAsync} from '@angular/core/testing';
3+
import {By} from '@angular/platform-browser';
4+
import {MapDirectionsRenderer} from './map-directions-renderer';
5+
import {DEFAULT_OPTIONS} from '../google-map/google-map';
6+
import {GoogleMapsModule} from '../google-maps-module';
7+
import {
8+
createDirectionsRendererConstructorSpy,
9+
createDirectionsRendererSpy,
10+
createMapConstructorSpy,
11+
createMapSpy
12+
} from '../testing/fake-google-map-utils';
13+
14+
const DEFAULT_DIRECTIONS: google.maps.DirectionsResult = {
15+
geocoded_waypoints: [],
16+
routes: [],
17+
};
18+
19+
describe('MapDirectionsRenderer', () => {
20+
let mapSpy: jasmine.SpyObj<google.maps.Map>;
21+
22+
beforeEach(waitForAsync(() => {
23+
TestBed.configureTestingModule({
24+
imports: [GoogleMapsModule],
25+
declarations: [TestApp],
26+
});
27+
}));
28+
29+
beforeEach(() => {
30+
TestBed.compileComponents();
31+
32+
mapSpy = createMapSpy(DEFAULT_OPTIONS);
33+
createMapConstructorSpy(mapSpy).and.callThrough();
34+
});
35+
36+
afterEach(() => {
37+
(window.google as any) = undefined;
38+
});
39+
40+
it('initializes a Google Maps DirectionsRenderer', () => {
41+
const directionsRendererSpy = createDirectionsRendererSpy({directions: DEFAULT_DIRECTIONS});
42+
const directionsRendererConstructorSpy =
43+
createDirectionsRendererConstructorSpy(directionsRendererSpy).and.callThrough();
44+
45+
const fixture = TestBed.createComponent(TestApp);
46+
fixture.componentInstance.options = {directions: DEFAULT_DIRECTIONS};
47+
fixture.detectChanges();
48+
49+
expect(directionsRendererConstructorSpy)
50+
.toHaveBeenCalledWith({directions: DEFAULT_DIRECTIONS, map: jasmine.any(Object)});
51+
expect(directionsRendererSpy.setMap).toHaveBeenCalledWith(mapSpy);
52+
});
53+
54+
it('sets directions from directions input', () => {
55+
const directionsRendererSpy = createDirectionsRendererSpy({directions: DEFAULT_DIRECTIONS});
56+
const directionsRendererConstructorSpy =
57+
createDirectionsRendererConstructorSpy(directionsRendererSpy).and.callThrough();
58+
59+
const fixture = TestBed.createComponent(TestApp);
60+
fixture.componentInstance.directions = DEFAULT_DIRECTIONS;
61+
fixture.detectChanges();
62+
63+
expect(directionsRendererConstructorSpy)
64+
.toHaveBeenCalledWith({directions: DEFAULT_DIRECTIONS, map: jasmine.any(Object)});
65+
expect(directionsRendererSpy.setMap).toHaveBeenCalledWith(mapSpy);
66+
});
67+
68+
it('gives precedence to directions over options', () => {
69+
const updatedDirections: google.maps.DirectionsResult = {
70+
geocoded_waypoints: [{partial_match: false, place_id: 'test', types: []}],
71+
routes: [],
72+
};
73+
const directionsRendererSpy = createDirectionsRendererSpy({directions: updatedDirections});
74+
const directionsRendererConstructorSpy =
75+
createDirectionsRendererConstructorSpy(directionsRendererSpy).and.callThrough();
76+
77+
const fixture = TestBed.createComponent(TestApp);
78+
fixture.componentInstance.options = {directions: DEFAULT_DIRECTIONS};
79+
fixture.componentInstance.directions = updatedDirections;
80+
fixture.detectChanges();
81+
82+
expect(directionsRendererConstructorSpy)
83+
.toHaveBeenCalledWith({directions: updatedDirections, map: jasmine.any(Object)});
84+
expect(directionsRendererSpy.setMap).toHaveBeenCalledWith(mapSpy);
85+
});
86+
87+
it('exposes methods that provide information from the DirectionsRenderer', () => {
88+
const directionsRendererSpy = createDirectionsRendererSpy({});
89+
createDirectionsRendererConstructorSpy(directionsRendererSpy).and.callThrough();
90+
91+
const fixture = TestBed.createComponent(TestApp);
92+
93+
const directionsRendererComponent =
94+
fixture.debugElement.query(By.directive(MapDirectionsRenderer))!
95+
.injector.get<MapDirectionsRenderer>(MapDirectionsRenderer);
96+
fixture.detectChanges();
97+
98+
directionsRendererSpy.getDirections.and.returnValue(DEFAULT_DIRECTIONS);
99+
expect(directionsRendererComponent.getDirections()).toBe(DEFAULT_DIRECTIONS);
100+
101+
directionsRendererComponent.getPanel();
102+
expect(directionsRendererSpy.getPanel).toHaveBeenCalled();
103+
104+
directionsRendererSpy.getRouteIndex.and.returnValue(10);
105+
expect(directionsRendererComponent.getRouteIndex()).toBe(10);
106+
});
107+
108+
it('initializes DirectionsRenderer event handlers', () => {
109+
const directionsRendererSpy = createDirectionsRendererSpy({});
110+
createDirectionsRendererConstructorSpy(directionsRendererSpy).and.callThrough();
111+
112+
const fixture = TestBed.createComponent(TestApp);
113+
fixture.detectChanges();
114+
115+
expect(directionsRendererSpy.addListener)
116+
.toHaveBeenCalledWith('directions_changed', jasmine.any(Function));
117+
});
118+
});
119+
120+
@Component({
121+
selector: 'test-app',
122+
template: `<google-map>
123+
<map-directions-renderer [options]="options"
124+
[directions]="directions"
125+
(directionsChanged)="handleDirectionsChanged()">
126+
</map-directions-renderer>
127+
</google-map>`,
128+
})
129+
class TestApp {
130+
@ViewChild(MapDirectionsRenderer) directionsRenderer: MapDirectionsRenderer;
131+
options?: google.maps.DirectionsRendererOptions;
132+
directions?: google.maps.DirectionsResult;
133+
134+
handleDirectionsChanged() {}
135+
}

0 commit comments

Comments
 (0)