diff --git a/src/Providers/Maps/Google/Constants/Constants.ts b/src/Providers/Maps/Google/Constants/Constants.ts index ef72dd8e..41e6a0b1 100644 --- a/src/Providers/Maps/Google/Constants/Constants.ts +++ b/src/Providers/Maps/Google/Constants/Constants.ts @@ -1,4 +1,7 @@ namespace Provider.Maps.Google.Constants { + // Regular expression to validate if a string is a set of coordinates. Accepts "12.300,-8.220" and "12.300, -8.220". + export const coordinateValidator = /^-{0,1}\d*\.{0,1}\d*,( )?-{0,1}\d*\.{0,1}\d*$/; + // Name of the Google Maps Version in the LocalStorage export const googleMapsLocalStorageVersionKey = 'gmVersion'; @@ -27,14 +30,22 @@ namespace Provider.Maps.Google.Constants { /** URL for GoogleMapsApis */ /************************** */ export const googleMapsApiURL = 'https://maps.googleapis.com/maps/api'; + // URL for GoogleMaps API to make use of the routes API + export const googleMapsRoutesApiURL = 'https://routes.googleapis.com/directions/v2:computeRoutes'; // URL for GoogleMaps API to make use of the Google Map export const googleMapsApiMap = `${googleMapsApiURL}/js`; // URL for GoogleMaps API to make use of the Google StaticMap export const googleMapsApiStaticMap = `${googleMapsApiURL}/staticmap`; + + /****************************** */ + /** Options for GoogleMapsApis */ + /****************************** */ + // When using Google Maps Routes API, these are the options that we want to retrieve. + export const GoogleMapsRouteOptions = 'routes.duration,routes.distanceMeters,routes.polyline.encodedPolyline'; // In order to use the drawingTools we need to add it into the libraries via the URL = drawing // In order to use the heatmap we need to add it into the libraries via the URL = visualization // In order to use the searchplaces we need to add it into the libraries via the URL = places (in case the Map is the first to import the scripts) - export const GoogleMapsLibraries = 'drawing,visualization,places,marker'; + export const GoogleMapsLibraries = 'drawing,visualization,places,marker,geometry'; // Used to check if modules are available on cycles of 100ms */ export const checkGoogleMapsLibrariesMaxAttempts = 25; // Version of the Google Maps to be loaded. diff --git a/src/Providers/Maps/Google/Features/Directions.ts b/src/Providers/Maps/Google/Features/Directions.ts index eb1fc3f4..29bd5436 100644 --- a/src/Providers/Maps/Google/Features/Directions.ts +++ b/src/Providers/Maps/Google/Features/Directions.ts @@ -6,107 +6,255 @@ namespace Provider.Maps.Google.Feature { OSFramework.Maps.Interface.IBuilder, OSFramework.Maps.Interface.IDisposable { - private _directionsRenderer: google.maps.DirectionsRenderer; - private _directionsService: google.maps.DirectionsService; + private _currRouteDistance: number; + private _currRouteLegs: Types.RoutesResponseLegStep[]; + private _currRouteTime: number; private _isEnabled: boolean; private _map: OSMap.IMapGoogle; + private _retriveLegsFromRoute: boolean; + private _routeRenderer: Helper.RouteRenderer; constructor(map: OSMap.IMapGoogle) { this._map = map; this._isEnabled = false; + this._currRouteDistance = 0; + this._currRouteTime = 0; + this._currRouteLegs = []; + this._retriveLegsFromRoute = false; + this._routeRenderer = new Helper.RouteRenderer(map); + } + + private get _fieldMask(): string { + return `${Constants.GoogleMapsRouteOptions}${this._retriveLegsFromRoute ? ',routes.legs.steps' : ''}`; + } + + /** Converts the travel mode input into a valid Google Maps travel mode. */ + private _convertTravelMode(travelModeInput: string): string { + let travelMode = Types.TravelModes.DRIVING; + switch (travelModeInput) { + case 'DRIVING': + travelMode = Types.TravelModes.DRIVING; + break; + case 'BICYCLING': + travelMode = Types.TravelModes.BICYCLING; + break; + case 'WALKING': + travelMode = Types.TravelModes.WALKING; + break; + default: + OSFramework.Maps.Helper.LogWarningMessage( + `The Google Maps API for Routes does not support ${travelModeInput} directions. Using DRIVING mode instead.` + ); + break; + } + return travelMode; + } + + /** Creates a waypoint for the directions request. */ + private _createWaypoint(coordinates: string): Types.Waypoint { + const isCoordinates = Helper.TypeChecker.IsValidCoordinates(coordinates); + const local = isCoordinates + ? { latLng: Helper.Conversions.GetCoordinatesFromString(coordinates) } + : undefined; + const address = isCoordinates ? undefined : coordinates; + return { + location: local, + address: address, + via: true, + }; + } + + /** Builds the request body for the Google Maps Directions API. */ + private _getRoutesRequestBody( + directionOptions: OSFramework.Maps.OSStructures.Directions.Options + ): Types.RoutesRequestBody { + const isOriginCoordinate = Helper.TypeChecker.IsValidCoordinates(directionOptions.originRoute); + const isDestinationCoordinate = Helper.TypeChecker.IsValidCoordinates(directionOptions.destinationRoute); + + const requestBody: Types.RoutesRequestBody = { + origin: { + location: isOriginCoordinate + ? { + latLng: Helper.Conversions.GetCoordinatesFromString(directionOptions.originRoute), + } + : undefined, + address: isOriginCoordinate ? undefined : directionOptions.originRoute, + }, + destination: { + location: isDestinationCoordinate + ? { + latLng: Helper.Conversions.GetCoordinatesFromString(directionOptions.destinationRoute), + } + : undefined, + address: isDestinationCoordinate ? undefined : directionOptions.destinationRoute, + }, + intermediates: this._waypointsCleanup(directionOptions.waypoints), + travelMode: this._convertTravelMode(directionOptions.travelMode), + routingPreference: directionOptions.travelMode === 'DRIVING' ? 'TRAFFIC_UNAWARE' : undefined, + routeModifiers: { + avoidTolls: directionOptions.exclude.avoidTolls, + avoidHighways: directionOptions.exclude.avoidHighways, + avoidFerries: directionOptions.exclude.avoidFerries, + }, + languageCode: (this._map.config as Configuration.OSMap.GoogleMapConfig).localization.language, + units: 'METRIC', + }; + + return requestBody; + } + + /** Sets the route in the map based on the response from the Google Maps Routes API. */ + private _setRouteInMap(response: Types.RoutesResponse): OSFramework.Maps.OSStructures.ReturnMessage { + if (response.routes.length > 0) { + const firstRoute = response.routes[0]; + this._currRouteTime = parseInt(firstRoute.duration); + this._currRouteDistance = firstRoute.distanceMeters; + + this._currRouteLegs = this._retriveLegsFromRoute ? firstRoute.legs[0].steps : undefined; + + const result = this._routeRenderer.renderRoute(firstRoute.polyline.encodedPolyline); + + return result; + } else { + this._currRouteTime = 0; + this._currRouteDistance = 0; + + return { + code: OSFramework.Maps.Enum.ErrorCodes.LIB_FailedSetDirections, + message: 'No routes found for the provided origin, destination and waypoints.', + }; + } } /** Makes sure all waypoints from a list of locations (string) gets converted into a list of {location, stopover}. */ - private _waypointsCleanup(waypoints: string[]): google.maps.DirectionsWaypoint[] { + private _waypointsCleanup(waypoints: string[]): Types.Waypoint[] { return waypoints.reduce((acc, curr) => { - acc.push({ location: curr, stopover: true }); + const waypoint: Types.Waypoint = this._createWaypoint(curr); + acc.push(waypoint); return acc; - }, []); + }, [] as Types.Waypoint[]); + } + + /** + * Sets a value indicating whether the legs from the route should be retrieved. + * + * @memberof Directions + */ + public set retrieveLegsFromRoute(value: boolean) { + if (value) { + OSFramework.Maps.Helper.LogWarningMessage( + 'By requesting the legs from the route, you will be retrieving a lot of data. This may cause higher costs of usage in the Google Maps API. Use it wisely.' + ); + } + this._retriveLegsFromRoute = value; } public get isEnabled(): boolean { return this._isEnabled; } - public build(): void { - this._directionsRenderer = new google.maps.DirectionsRenderer(); - this._directionsService = new google.maps.DirectionsService(); - this._directionsRenderer.setMap(this._map.provider); + /** + * Builds the Directions feature. + * + * @memberof Directions + */ + public build(): void { this.setState(this._isEnabled); } + + /** + * Disposes the Directions feature. + * + * @memberof Directions + */ public dispose(): void { this.setState(false); - this._directionsService = undefined; - this._directionsRenderer = undefined; + this._map = undefined; + this._routeRenderer.dispose(); + this._routeRenderer = undefined; } + + /** + * Gets all the legs from the current route. + * + * @return {*} {Array} + * @memberof Directions + */ public getLegsFromDirection(): Array { // If the Map has the directions disabled return 0 (meters) if (this._isEnabled === false) return []; + if (!this._currRouteLegs || this._currRouteLegs.length === 0) return []; - const legs = this._directionsRenderer - .getDirections() - .routes[0].legs.reduce( - ( - acc: Array, - curr: google.maps.DirectionsLeg - ) => { - // For each leg, push an object containing the origin (coords), distination (coords), distance (in meters) and duration (in seconds) - acc.push({ - origin: curr.start_location.toJSON(), - destination: curr.end_location.toJSON(), - distance: curr.distance.value, - duration: curr.duration.value, - }); - return acc; - }, - [] - ); + const legs = this._currRouteLegs.reduce( + ( + acc: Array, + curr: Types.RoutesResponseLegStep + ) => { + acc.push({ + origin: JSON.parse(JSON.stringify(curr.startLocation.latLng)), + destination: JSON.parse(JSON.stringify(curr.endLocation.latLng)), + distance: curr.distanceMeters, + duration: parseInt(curr.staticDuration) || 0, + }); + return acc; + }, + [] + ); return legs; } + + /** + * Gets the total distance in meters from the current route. + * + * @return {*} {Promise} + * @memberof Directions + */ public getTotalDistanceFromDirection(): Promise { return new Promise((resolve) => { // If no route has been set before requesting the distance return 0 (meters) if (this._isEnabled === false) resolve(0); - const distance = this._directionsRenderer.getDirections().routes[0].legs.reduce((acc, curr) => { - // For each leg, sum the distance values (in meters) - acc += curr.distance.value; - return acc; - }, 0); - - resolve(distance); + resolve(this._currRouteDistance); }); } + + /** + * Gets the total duration in seconds from the current route. + * + * @return {*} {Promise} + * @memberof Directions + */ public getTotalDurationFromDirection(): Promise { return new Promise((resolve) => { // If no route has been set before requesting the duration return 0 (meters) if (this._isEnabled === false) resolve(0); - const duration = this._directionsRenderer.getDirections().routes[0].legs.reduce((acc, curr) => { - // For each leg, sum the duration values (in seconds) - acc += curr.duration.value; - return acc; - }, 0); - - resolve(duration); + resolve(this._currRouteTime); }); } + + /** + * Removes the current route from the map. + * + * @return {*} {OSFramework.Maps.OSStructures.ReturnMessage} + * @memberof Directions + */ public removeRoute(): OSFramework.Maps.OSStructures.ReturnMessage { this.setState(false); - if (this._directionsRenderer.getMap() === null) { - return { - isSuccess: true, - }; - } else { - return { - code: OSFramework.Maps.Enum.ErrorCodes.API_FailedRemoveDirections, - }; - } + + return { + isSuccess: true, + }; } /** * SetPlugin for GoogleMaps provider is not needed. + * + * @param {string} providerName + * @param {string} apiKey + * @return {*} {OSFramework.Maps.OSStructures.ReturnMessage} + * @memberof Directions */ public setPlugin( // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -121,52 +269,83 @@ namespace Provider.Maps.Google.Feature { return; } + /** + * Sets the route in the map based on the provided direction options. + * + * @param {OSFramework.Maps.OSStructures.Directions.Options} directionOptions + * @return {*} {Promise} + * @memberof Directions + */ public setRoute( directionOptions: OSFramework.Maps.OSStructures.Directions.Options ): Promise { - const waypts: google.maps.DirectionsWaypoint[] = this._waypointsCleanup(directionOptions.waypoints); - return ( - this._directionsService - .route( - { - origin: { - query: directionOptions.originRoute, - }, - destination: { - query: directionOptions.destinationRoute, - }, - waypoints: waypts, - optimizeWaypoints: directionOptions.optimizeWaypoints, - travelMode: directionOptions.travelMode as google.maps.TravelMode, - avoidTolls: directionOptions.exclude.avoidTolls, - avoidHighways: directionOptions.exclude.avoidHighways, - avoidFerries: directionOptions.exclude.avoidFerries, - }, - (response, status) => { - if (status === 'OK') { - this._directionsRenderer.setDirections(response); - } + const routeSetPromise = new Promise((resolve, reject) => { + // Fetch the Google Maps Routes API to get the route based on the provided options + fetch(Constants.googleMapsRoutesApiURL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Goog-Api-Key': this._map.config.apiKey, + 'X-Goog-FieldMask': this._fieldMask, + }, + body: JSON.stringify(this._getRoutesRequestBody(directionOptions)), + }) + .then((response) => { + if (response.status === 200) { + response + .json() + .then((responseJSON) => { + const result = this._setRouteInMap(responseJSON); + + if (result.isSuccess) { + this.setState(true); + resolve(result); + } else { + this.setState(false); + reject(result); + } + }) + // Else, we want to return the reason + .catch((reason: string) => { + this.setState(false); + reject({ + code: OSFramework.Maps.Enum.ErrorCodes.LIB_FailedSetDirections, + message: `${reason}`, + }); + }); + } else { + reject({ + code: OSFramework.Maps.Enum.ErrorCodes.LIB_FailedSetDirections, + message: response.statusText, + }); } - ) - // If the previous request returns status OK, then we want to return success - .then(() => { - this.setState(true); - return { - isSuccess: true, - }; }) // Else, we want to return the reason .catch((reason: string) => { this.setState(false); - return { + reject({ code: OSFramework.Maps.Enum.ErrorCodes.LIB_FailedSetDirections, message: `${reason}`, - }; - }) - ); + }); + }); + }); + + return routeSetPromise; } + + /** + * Sets the state of the Directions feature. + * + * @param {boolean} value + * @memberof Directions + */ public setState(value: boolean): void { - this._directionsRenderer.setMap(value === true ? this._map.provider : null); + if (!value) { + this._routeRenderer.removeRoute(); + this._currRouteLegs = []; + this._currRouteDistance = 0; + this._currRouteTime = 0; + } this._isEnabled = value; } } diff --git a/src/Providers/Maps/Google/Helper/Conversions.ts b/src/Providers/Maps/Google/Helper/Conversions.ts index 93b59743..85e855fb 100644 --- a/src/Providers/Maps/Google/Helper/Conversions.ts +++ b/src/Providers/Maps/Google/Helper/Conversions.ts @@ -42,26 +42,14 @@ namespace Provider.Maps.Google.Helper.Conversions { console.warn( 'Invalid location. Using the default location -> 55 Thomson Pl 2nd floor, Boston, MA 02210, United States' ); - return new Promise((resolve) => { - resolve(OSFramework.Maps.Helper.Constants.defaultMapCenter); - }); + return Promise.resolve(OSFramework.Maps.Helper.Constants.defaultMapCenter); } - // Regex that validates if string is a set of coordinates - // Accepts "12.300,-8.220" and "12.300, -8.220" - const regexValidator = /^-{0,1}\d*\.{0,1}\d*,( )?-{0,1}\d*\.{0,1}\d*$/; // If the provided location is a set of coordinates - if (regexValidator.test(location)) { - let latitude: number; - let longitude: number; + if (Helper.TypeChecker.IsValidCoordinates(location)) { // split the coordinates into latitude and longitude if (location.indexOf(',') > -1) { - latitude = parseFloat(location.split(',')[0].replace(' ', '')); - longitude = parseFloat(location.split(',')[1].replace(' ', '')); - - return new Promise((resolve) => { - resolve({ lat: latitude, lng: longitude }); - }); + return Promise.resolve(GetCoordinatesFromString(location)); } else { // Try to get the address via the googleMapsAPIGeocode return googleMapsApiGeocode(location); @@ -73,6 +61,27 @@ namespace Provider.Maps.Google.Helper.Conversions { } } + /** + * Converts a string with coordinates into a google.maps.LatLngLiteral object. + * + * @export + * @param {string} coordinates + * @return {*} {google.maps.LatLngLiteral} + */ + export function GetCoordinatesFromString(coordinates: string): google.maps.LatLngLiteral { + let latitude: number; + let longitude: number; + // split the coordinates into latitude and longitude + if (coordinates.indexOf(',') > -1) { + latitude = parseFloat(coordinates.split(',')[0].replace(' ', '')); + longitude = parseFloat(coordinates.split(',')[1].replace(' ', '')); + + return { lat: latitude, lng: longitude }; + } else { + return { lat: 0, lng: 0 }; + } + } + /** * Get the value of a coordinate, if it is a function, call it and get the value * diff --git a/src/Providers/Maps/Google/Helper/RouteRenderer.ts b/src/Providers/Maps/Google/Helper/RouteRenderer.ts new file mode 100644 index 00000000..cca69c13 --- /dev/null +++ b/src/Providers/Maps/Google/Helper/RouteRenderer.ts @@ -0,0 +1,97 @@ +namespace Provider.Maps.Google.Helper { + export class RouteRenderer implements OSFramework.Maps.Interface.IDisposable { + private _endMarker: google.maps.marker.AdvancedMarkerElement; + private _isRouteRendered: boolean; + private _map: OSMap.IMapGoogle; + private _pathPolyline: google.maps.Polyline; + private _startMarker: google.maps.marker.AdvancedMarkerElement; + + constructor(map: OSMap.IMapGoogle) { + this._map = map; + this._isRouteRendered = false; + } + + private _buildMarker(position: google.maps.LatLng, label: string): google.maps.marker.AdvancedMarkerElement { + // The pin element is the colored teardrop shape. + const pin = new google.maps.marker.PinElement({ + background: '#EA4335', // Red background + borderColor: '#D6352D', // Dark red border + glyphColor: '#FFFFFF', // White letter + scale: 1, + }); + + // Create the advanced marker. + const marker = new google.maps.marker.AdvancedMarkerElement({ + position: { + lat: Helper.Conversions.GetCoordinateValue(position.lat()), + lng: Helper.Conversions.GetCoordinateValue(position.lng()), + }, + map: this._map.provider, + content: pin.element, + }); + + pin.glyph = label; + + return marker; + } + + public dispose(): void { + this.removeRoute(); + this._map = undefined; + this._startMarker = undefined; + this._endMarker = undefined; + this._pathPolyline = undefined; + this._isRouteRendered = false; + } + + public removeRoute(): void { + if (this._pathPolyline) { + this._pathPolyline.setMap(null); + this._pathPolyline = undefined; + } + if (this._startMarker) { + this._startMarker.map = undefined; + this._startMarker = undefined; + } + if (this._endMarker) { + this._endMarker.map = null; + this._endMarker = undefined; + } + this._isRouteRendered = false; + } + + public renderRoute(encodedPolyline: string): OSFramework.Maps.OSStructures.ReturnMessage { + this._isRouteRendered && this.removeRoute(); + + if (encodedPolyline) { + const bounds = new google.maps.LatLngBounds(); + const routePath = google.maps.geometry.encoding.decodePath(encodedPolyline); + + this._startMarker = this._buildMarker(routePath[0], 'A'); + bounds.extend(routePath[0]); + + this._pathPolyline = new google.maps.Polyline({ + path: routePath, + strokeColor: '#4285F470', // A solid red color + strokeWeight: 6, + map: this._map.provider, + }); + this._endMarker = this._buildMarker(routePath[routePath.length - 1], 'B'); + bounds.extend(routePath[routePath.length - 1]); + + this._map.provider.fitBounds(bounds); + + this._isRouteRendered = true; + + return { + isSuccess: true, + }; + } else { + return { + code: OSFramework.Maps.Enum.ErrorCodes.LIB_FailedSetDirections, + message: 'Encoded polyline is empty or undefined.', + }; + } + } + } +} diff --git a/src/Providers/Maps/Google/Helper/TypeChecker.ts b/src/Providers/Maps/Google/Helper/TypeChecker.ts index a69812ae..94bb29cf 100644 --- a/src/Providers/Maps/Google/Helper/TypeChecker.ts +++ b/src/Providers/Maps/Google/Helper/TypeChecker.ts @@ -1,6 +1,26 @@ // eslint-disable-next-line @typescript-eslint/no-unused-vars namespace Provider.Maps.Google.Helper.TypeChecker { + /** + * Validates if a marker is an AdvancedMarkerElement + * + * @export + * @param {unknown} marker Marker to validate + * @return {*} {boolean} True if the marker is an AdvancedMarkerElement, false otherwise + * + */ export function IsAdvancedMarker(marker: unknown): boolean { return marker instanceof google.maps.marker.AdvancedMarkerElement; } + + /** + * Validates if a string is a set of coordinates + * + * @export + * @param {string} coordinates Coordinates to validate + * @return {*} {boolean} True if the coordinates are valid, false otherwise + * + */ + export function IsValidCoordinates(coordinates: string): boolean { + return Constants.coordinateValidator.test(coordinates); + } } diff --git a/src/Providers/Maps/Google/Types/Routes.d.ts b/src/Providers/Maps/Google/Types/Routes.d.ts new file mode 100644 index 00000000..ba35283c --- /dev/null +++ b/src/Providers/Maps/Google/Types/Routes.d.ts @@ -0,0 +1,101 @@ +namespace Provider.Maps.Google.Types { + export interface LocationPoint { + location?: { + latLng: google.maps.LatLngLiteral, + }, + address?: string + } + + // Waypoint interface for the Google Maps Routes API + export interface Waypoint { + via: boolean, + vehicleStopover?: boolean, + sideOfRoad?: boolean, + + // Union field location_type can be only one of the following: + location?: { + latLng: google.maps.LatLngLiteral, + heading?: integer + }, + placeId?: string, + address?: string + // End of list of possible types for union field location_type. + } + + // Request body for the Google Maps Routes API + export interface RoutesRequestBody{ + origin: LocationPoint, + destination: LocationPoint, + intermediates: LocationPoint[], + travelMode: string, + routingPreference?: "TRAFFIC_UNAWARE" | "TRAFFIC_AWARE" | "TRAFFIC_AWARE_OPTIMAL", + computeAlternativeRoutes?: boolean, + routeModifiers?: { + avoidTolls: boolean, + avoidHighways: boolean, + avoidFerries: boolean + }, + languageCode?: string, + units?: string, + } + + // Response body for the Google Maps Routes API + // This is a simplified version of the response, only including the fields we need. + export interface RoutesResponse { + routes: [ + { + distanceMeters: number, + duration: string, + polyline: { + encodedPolyline: string + }, + legs: [{ + steps: [RoutesResponseLegStep] + }] + } + ] + } + + // Each step in the leg of the route + export interface RoutesResponseLegStep { + distanceMeters: number, + staticDuration: string, + polyline: { + encodedPolyline: string + }, + startLocation: { + latLng: { + latitude: number, + longitude: number + } + }, + endLocation: { + latLng: { + latitude: number, + longitude: number + } + }, + navigationInstruction: { + maneuver: string, + instructions: string + }, + localizedValues: { + distance: { + text: string + }, + + staticDuration: { + text: string + } + }, + travelMode: string + }; + + // Enum for the travel modes supported by the Google Maps Routes API + export const enum TravelModes { + "DRIVING" = "DRIVE", + "BICYCLING" = "BICYCLE", + "WALKING" = "WALK", + "TWO_WHEELER" = "TWO_WHEELER" + } +} \ No newline at end of file