diff --git a/packages/core/map-types.ts b/packages/core/map-types.ts index 57a1ed8f5..76e3393a0 100644 --- a/packages/core/map-types.ts +++ b/packages/core/map-types.ts @@ -6,9 +6,18 @@ export { DataMouseEvent, LatLngBounds, LatLngBoundsLiteral, + LatLng, LatLngLiteral, PolyMouseEvent, MarkerLabel, + Geocoder, + GeocoderAddressComponent, + GeocoderComponentRestrictions, + GeocoderGeometry, + GeocoderLocationType, + GeocoderRequest, + GeocoderResult, + GeocoderStatus, } from './services/google-maps-types'; /** diff --git a/packages/core/services.ts b/packages/core/services.ts index ce65d367a..8d383d915 100644 --- a/packages/core/services.ts +++ b/packages/core/services.ts @@ -1,6 +1,7 @@ export { CircleManager } from './services/managers/circle-manager'; export { DataLayerManager } from './services/managers/data-layer-manager'; export { FitBoundsAccessor, FitBoundsDetails } from './services/fit-bounds'; +export { AgmGeocoder } from './services/geocoder-service'; export { GoogleMapsAPIWrapper } from './services/google-maps-api-wrapper'; export { GoogleMapsScriptProtocol, diff --git a/packages/core/services/geocoder-service.spec.ts b/packages/core/services/geocoder-service.spec.ts new file mode 100644 index 000000000..0eaa5a5d2 --- /dev/null +++ b/packages/core/services/geocoder-service.spec.ts @@ -0,0 +1,155 @@ +import { discardPeriodicTasks, fakeAsync, TestBed, tick } from '@angular/core/testing'; + +import { AgmGeocoder } from './geocoder-service'; +import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; + +describe('GeocoderService', () => { + let loader: MapsAPILoader; + let geocoderService: AgmGeocoder; + let geocoderConstructs: number; + let geocodeMock: jest.Mock; + + beforeEach(fakeAsync(() => { + loader = { + load: jest.fn().mockReturnValue(Promise.resolve()), + }; + + geocoderConstructs = 0; + geocodeMock = jest.fn(); + + (window as any).google = { + maps: { + Geocoder: class Geocoder { + geocode: jest.Mock = geocodeMock; + + constructor() { + geocoderConstructs += 1; + } + }, + }, + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: MapsAPILoader, useValue: loader }, + AgmGeocoder, + ], + }); + + geocoderService = TestBed.get(AgmGeocoder); + tick(); + })); + + it('should wait for the load event', () => { + expect(loader.load).toHaveBeenCalledTimes(1); + expect(geocoderConstructs).toEqual(1); + }); + + it('should emit a geocode result', fakeAsync(() => { + const success = jest.fn(); + const geocodeRequest = { + address: 'Mountain View, California, United States', + }; + const geocodeExampleResponse = { + 'results': [ + { + 'address_components': [ + { + 'long_name': '1600', + 'short_name': '1600', + 'types': ['street_number'], + }, + { + 'long_name': 'Amphitheatre Parkway', + 'short_name': 'Amphitheatre Pkwy', + 'types': ['route'], + }, + { + 'long_name': 'Mountain View', + 'short_name': 'Mountain View', + 'types': ['locality', 'political'], + }, + { + 'long_name': 'Santa Clara County', + 'short_name': 'Santa Clara County', + 'types': ['administrative_area_level_2', 'political'], + }, + { + 'long_name': 'California', + 'short_name': 'CA', + 'types': ['administrative_area_level_1', 'political'], + }, + { + 'long_name': 'United States', + 'short_name': 'US', + 'types': ['country', 'political'], + }, + { + 'long_name': '94043', + 'short_name': '94043', + 'types': ['postal_code'], + }, + ], + 'formatted_address': '1600 Amphitheatre Pkwy, Mountain View, CA 94043, USA', + 'geometry': { + 'location': { + 'lat': 37.4267861, + 'lng': -122.0806032, + }, + 'location_type': 'ROOFTOP', + 'viewport': { + 'northeast': { + 'lat': 37.4281350802915, + 'lng': -122.0792542197085, + }, + 'southwest': { + 'lat': 37.4254371197085, + 'lng': -122.0819521802915, + }, + }, + }, + 'place_id': 'ChIJtYuu0V25j4ARwu5e4wwRYgE', + 'plus_code': { + 'compound_code': 'CWC8+R3 Mountain View, California, United States', + 'global_code': '849VCWC8+R3', + }, + 'types': ['street_address'], + }, + ], + 'status': 'OK', + }; + + geocodeMock.mockImplementation((_geocodeRequest, callback) => callback(geocodeExampleResponse, 'OK')); + + geocoderService.geocode(geocodeRequest).subscribe(success); + + tick(); + + expect(success).toHaveBeenCalledTimes(1); + expect(success).toHaveBeenCalledWith(geocodeExampleResponse); + expect(geocodeMock).toHaveBeenCalledTimes(1); + + discardPeriodicTasks(); + })); + + it('should catch error if Google does not return a OK result', fakeAsync(() => { + const success = jest.fn(); + const catchFn = jest.fn(); + const geocodeRequest = { + address: 'Mountain View, California, United States', + }; + + geocodeMock.mockImplementation((geocodeRequest, callback) => callback(geocodeRequest, 'INVALID_REQUEST')); + + geocoderService.geocode(geocodeRequest).subscribe(success, catchFn); + + tick(); + + expect(success).toHaveBeenCalledTimes(0); + expect(catchFn).toHaveBeenCalledTimes(1); + expect(catchFn).toHaveBeenCalledWith('INVALID_REQUEST'); + expect(geocodeMock).toHaveBeenCalledTimes(1); + + discardPeriodicTasks(); + })); +}); diff --git a/packages/core/services/geocoder-service.ts b/packages/core/services/geocoder-service.ts new file mode 100644 index 000000000..c3ed68585 --- /dev/null +++ b/packages/core/services/geocoder-service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@angular/core'; +import { bindCallback, ConnectableObservable, Observable, of, ReplaySubject, throwError } from 'rxjs'; +import { map, multicast, switchMap } from 'rxjs/operators'; +import { Geocoder, GeocoderRequest, GeocoderResult, GeocoderStatus } from './google-maps-types'; +import { MapsAPILoader } from './maps-api-loader/maps-api-loader'; + +declare var google: any; + +@Injectable({ providedIn: 'root' }) +export class AgmGeocoder { + protected readonly geocoder$: Observable; + + constructor(loader: MapsAPILoader) { + const connectableGeocoder$ = new Observable(subscriber => { + loader.load().then(() => subscriber.next()); + }) + .pipe( + map(() => this._createGeocoder()), + multicast(new ReplaySubject(1)), + ) as ConnectableObservable; + + connectableGeocoder$.connect(); // ignore the subscription + // since we will remain subscribed till application exits + + this.geocoder$ = connectableGeocoder$; + } + + geocode(request: GeocoderRequest): Observable { + return this.geocoder$.pipe( + switchMap((geocoder) => this._getGoogleResults(geocoder, request)) + ); + } + + private _getGoogleResults(geocoder: Geocoder, request: GeocoderRequest): Observable { + const geocodeObservable = bindCallback(geocoder.geocode); + return geocodeObservable(request).pipe( + switchMap(([results, status]) => { + if (status === GeocoderStatus.OK) { + return of(results); + } + + return throwError(status); + }) + ); + } + + private _createGeocoder(): Geocoder { + return new google.maps.Geocoder() as Geocoder; + } +} diff --git a/packages/core/services/google-maps-types.ts b/packages/core/services/google-maps-types.ts index 20bbc22b9..b27b4157e 100644 --- a/packages/core/services/google-maps-types.ts +++ b/packages/core/services/google-maps-types.ts @@ -634,3 +634,65 @@ export interface MapRestriction { latLngBounds: LatLngBounds | LatLngBoundsLiteral; strictBounds?: boolean; } + +export interface Geocoder { + geocode: (request: GeocoderRequest, googleCallback: (results: GeocoderResult[], status: GeocoderStatus) => void) => void; +} + +export interface GeocoderAddressComponent { + long_name: string; + short_name: string; + types: string[]; +} + +/** Options for restricting the geocoder results */ +export interface GeocoderComponentRestrictions { + administrativeArea?: string; + country?: string; + locality?: string; + postalCode?: string; + route?: string; +} + +export interface GeocoderGeometry { + bounds: LatLngBounds; + location: LatLng; + location_type: GeocoderLocationType; + viewport: LatLngBounds; +} + +export enum GeocoderLocationType { + APPROXIMATE = 'APPROXIMATE', + GEOMETRIC_CENTER = 'GEOMETRIC_CENTER', + RANGE_INTERPOLATED = 'RANGE_INTERPOLATED', + ROOFTOP = 'ROOFTOP', +} + +export interface GeocoderRequest { + address?: string; + bounds?: LatLngBounds | LatLngBoundsLiteral; + componentRestrictions?: GeocoderComponentRestrictions; + location?: LatLng | LatLngLiteral; + placeId?: string; + region?: string; +} + +export interface GeocoderResult { + address_components: GeocoderAddressComponent[]; + formatted_address: string; + geometry: GeocoderGeometry; + partial_match: boolean; + place_id: string; + postcode_localities: string[]; + types: string[]; +} + +export enum GeocoderStatus { + ERROR = 'ERROR', + INVALID_REQUEST = 'INVALID_REQUEST', + OK = 'OK', + OVER_QUERY_LIMIT = 'OVER_QUERY_LIMIT', + REQUEST_DENIED = 'REQUEST_DENIED', + UNKNOWN_ERROR = 'UNKNOWN_ERROR', + ZERO_RESULTS = 'ZERO_RESULTS', +}