Geolocation with Angular and .NET Core – part 2

In part 2 of this three-part tutorial I will explain how the Angular frontend is designed for the “Locations in Your Pocket” application. The Angular application can be configured to use in-memory fake data so you can run this app without a backend for testing purposes.

All code for the complete project is available from GitHub: https://github.com/LarsBergqvist/loc-poc

The other parts of this tutorial are:

  • Part 1 (an overview of the project)
  • Part 3 (the REST API and database implementation with .NET Core)

Overview

The startup view of the Angular application shows a list of existing locations/places. You can add a new location that by default get your current position from the browser (if you allow it). The position is shown on a map and you can change the position by clicking/tapping on a different place on the map. The longitude and latitude values are also available in editable controls. You can give the location a name and a description.

locpoc_main

About PrimeNG

For getting a quicker start with the look-and-feel of the application, I use PrimeNG as a UI framework. There are other good UI frameworks for Angular but I opted for using PrimeNG as I am well acquainted with it from previous projects.

Testing the default behavior

The default configuration for the application uses in-memory mock data and have the map visualization turned off, so you can try out the frontend without the REST API and without having a Google API key. There are a couple of pre-reqs though:

Get the code and install the dependencies

git clone https://github.com/LarsBergqvist/loc-poc
cd loc-poc/Clients/LocPocAngular
yarn install

Start the application

yarn startSSL

Now you should be able to open the application in a browser with https://localhost:4300 (you will get a warning in the browser about the certificate as it has not been verified by a third-party authority)

There is a set of in-memory locations listed. You can click on an item to open the details where you can modify the properties or delete the location. As the data is stored in-memory, the location item collection will be reset if you refresh your browser.

locpoc_updatenomap

The locations list is rendered differently depending on the screen size. With a large screen you get a table with headers and columns, but with small screens (e.g. from a mobile phone) you get a list of item cards.

locpoc-items-list

In the locations list you can press the New button for creating a new location. This will fetch your current position from the browser (if you allow it). Note that if you run the app from a mobile browser, you need to turn on location access for the web browser app that you are using).

The Angular application structure

Component structure

The application is made up of these Angular components:

  • AppComponent – the main component that aggregates the other components
  • AppBarComponent – The appbar/toolbar at the top of the page
  • LocationsListComponent – Shows a list of the stored location items
  • LocationDetailsComponent – An overlay sidebar that shows the details of a location item. Used when adding a new location and when updating an existing item.
  • MapComponent – Used by LocationDetailsComponent for showing the position in a Google Maps view.
  • ToastComponent – A PrimeNG overlay component for showing notifications to the user (error- and success messages).

locpoc_components

Services

The application has a bunch of custom services:

  • AppConfigService – Holds the application configuration (different settings for the app)
  • GoogleMapsService – Loads the google maps api object when the application is started
  • HttpInterceptorService – A middle-ware service that handles http errors in one single place.
  • LocationsService – Provides access to the Location item collection. There is a real implementation that performs actual http requests to the backend API and a mock version that uses in-memory fake data. What version is used is determined by app-config.json.
  • LoggingService – A simple logging service so that all logging statements are routed to a single implementation
  • MessageBrokerService – Sends async messages to subscribers. This makes it possible to have less code coupling between different parts in the application.

Application configuration

There is a configuration file for the application that by default looks like this:

{
"apiUrl": "https://localhost",
"apiPort": "5001",
"useFakeData": true,
"useMap": false,
"googleAPIKey": "YOUR-API-KEY"
}

view raw
app-config.json
hosted with ❤ by GitHub

  • apiUrl and apiPort – define how to access the backend REST API
  • useFakeData – if set to true, the fake in-memory data is used. If set to false, http requests to the backend is used.
  • useMap – if set to false, no map is shown in the details view. If set to true, a Google Maps view is shown. To be able to use the Google Maps view, a Google API key is required.
  • googleAPIKey – this is the API key to your Google Maps resource. See below.

Activating the map view

Get the Google API key

You need a Google API key to use Google Maps from the application. Using Google Maps is currently free up to a certain amount of calls per month. See https://cloud.google.com/maps-platform/pricing. You can sign-up for the Google Cloud Platform and start using the Google Maps API for free for your testing purposes.

When logged into the Google Cloud Platform, go to the Console and select Credentials in the API & Services menu. Create a new API key:

googleapikey2

Your API key will be available for copying from the API Keys list:

googleapikey

You can restrict the usage of this key to certain domains so you can lock it down to your application Url. This is important if you are publishing your application as the key will be visible in the html or the JavaScript and any user can copy the key and use it elsewhere if it is not restricted to your application.

Modify the application settings

To activate the map you have to set the useMap property to true and set your Google API key as the googleAPIKey property in app-config.json. See example below (with faked apiKey);

{
"apiUrl": "https://localhost",
"apiPort": "5001",
"useFakeData": true,
"useMap": true,
"googleAPIKey": "ABzaDuAQbFxSalDaWfIAB7DEZAh730AFGGGlpg"
}

view raw
app-config.json
hosted with ❤ by GitHub

Implementation details

The geolocation API

The browser’s geolocation API is used for fetching the current position. When the user presses the New button, the LocationDetailsComponent gets a message from the MessageBrokerService. It creates a new empty Location object, shows the dialog in AddNewMode and requests to get the current position from the browser asynchronously.

async ngOnInit() {
const messages = this.messageBroker.getMessage();
messages.pipe(filter(message => message instanceof AddNewLocationMessage))
.subscribe((message: AddNewLocationMessage) => {
if (message) {
this.createDefaultLocation();
this.editMode = LocationEditMode.AddNew;
this.isVisible = true;
this.setCurrentPosition();
}
});
}
private async setCurrentPosition() {
navigator.geolocation.getCurrentPosition(position => {
this.location = {
Id: this.location.Id,
Name: this.location.Name,
Description: this.location.Description,
Latitude: position.coords.latitude,
Longitude: position.coords.longitude
};
}, error => {
this.logging.logError(error.message);
this.messageBroker.sendMessage(new ErrorOccurredMessage(error.message));
});
}

The user will get prompted to allow the app to access the location information:

locpoc-allowaccess

When a position arrives from the browser, a new location object is created with the new position (so that the property binding works correctly with sub-components) and the latitude/longitude input fields are updated as well as the location marker in the map.

APP_INITIALIZER: Read configuration and load the google maps api

The google maps api object needs to be available globally on the page for you to be able to use the Google Maps JavaScript API. The standard way of achieving this is to provide a <script>-tag in index.html with https://maps.googleapis.com/maps/api + your API key. This does not fit Angular and configuration files well, so I’m using the load-google-maps-api package to load the google api object with code.

However, the google maps api object needs to be loaded during application initialization. I also need to load app-config.json during app init (so I know if maps should be used and can get the googleAPIKey from the configuration). This can be achieved by using an APP_INITIALIZER provider in app.module.ts:

export function appConfigInit(configService: AppConfigService,
googleMapService: GoogleMapsService, logging: LoggingService) {
// Load the configuration and init google api if maps should be used
return () => {
return new Promise((resolve) => {
configService.load().then(() => {
if (configService.useMap) {
logging.logInfo('Use map');
googleMapService.load(configService.googleAPIKey).then(() => {
resolve();
});
} else {
logging.logInfo('no map');
resolve();
}
});
});
};
}
@NgModule({
declarations: [
AppComponent,
LocationsListComponent,
AppBarComponent,
LocationDetailsComponent,
MapComponent,
NumberRangeValidator
],
providers: [
AppConfigService,
GoogleMapsService,
LoggingService,
{
provide: APP_INITIALIZER,
useFactory: appConfigInit,
multi: true,
deps: [AppConfigService, GoogleMapsService, LoggingService]
},

view raw
app.module.ts
hosted with ❤ by GitHub

During app initialization, a function appConfigInit is called. The init sequence is not continued until the returned promise from the function is resolved. First, the configService loads app-config.json. If the config specified that we should use maps, the google maps api object is loaded with the specified API key. After the google api object has finished loading, the promise is resolved and the init sequence continues.

The GoogleMapsService looks like this:

import { Injectable } from '@angular/core';
import * as loadGoogleMapsApi from 'load-google-maps-api';
@Injectable()
class GoogleMapsService {
constructor() { }
load(googleAPIKey: string): any {
return loadGoogleMapsApi({ key: googleAPIKey });
}
}
export { GoogleMapsService };

Use PrimeNG’s GMap and the google map object

I use PrimeNG’s Gmap component for displaying the google map view. Notice that it is only displayed if we have specified “useMap”: true in app-config.json.

<div *ngIf="useMap">
<p-gmap *ngIf="options.center" [options]="options" [overlays]="overlays" [style]="{'width':'100%','height':'320px'}"
(onMapReady)="onMapReady($event)" (onMapClick)="handleMapClick($event)"></p-gmap>
</div>

view raw
map.component.html
hosted with ❤ by GitHub

The GMap component requires a global google maps api object (as described in the APP_INITIALIZER section above). For my needs I have to access google.maps directly as well. For this to work with TypeScript I need to declare google in the component file:

import { Component, Input, OnInit } from '@angular/core';
import { Location } from '../models/location';
import { AppConfigService } from '../services/app-config.service';
import { MessageBrokerService } from '../services/message-broker.service';
import { NewMarkerFromMapMessage } from '../messages/new-marker-from-map.message';
declare var google: any;
@Component({
selector: 'app-map',
templateUrl: './map.component.html'
})
export class MapComponent implements OnInit {
options: any;
overlays: any;
map: any;
readonly defaultPosition = { lat: 51.477847, lng: 0.0 };
readonly defaultZoom = 15;
@Input('location')
set value(loc: Location) {
if (loc && this.map) {
// Work with the google map object directly as modifying gmap's options
// will not update the map
this.map.setCenter({ lat: loc.Latitude, lng: loc.Longitude });
this.overlays = [
new google.maps.Marker({ position: { lat: loc.Latitude, lng: loc.Longitude }, title: loc.Name })
];
}
}
constructor(private readonly appConfigService: AppConfigService,
private readonly messageBroker: MessageBrokerService) {
}
onMapReady(event: any) {
if (event.map) {
this.map = event.map;
}
}
handleMapClick(event) {
this.messageBroker.sendMessage(new NewMarkerFromMapMessage(event.latLng.lat(), event.latLng.lng()));
}
ngOnInit() {
this.options = {
center: this.defaultPosition,
zoom: this.defaultZoom
};
}
get useMap(): boolean {
return this.appConfigService.useMap;
}
}

view raw
map.component.ts
hosted with ❤ by GitHub

There is an onHandleMapReady() event handler that saves the map object when the intial map is ready. The location to use is set via an Input()-property (from the parent component). It uses the map object to center the view (according to the new location) and creates a new overlay collection that contains a marker for the new position.

Selecting what repository to use

The app-config.json file specifies if to use mock/fake data or real data from an external service. There are two different LocationService-implementations that implements the same interface. Which one to use is decided by a provider in app.module.ts. The provider has a factory method that is called when an implementation should be injected:

export function locationsServiceFactory() {
return (http: HttpClient, configService: AppConfigService, logging: LoggingService): LocationsService => {
if (configService.useFakeData) {
logging.logInfo('use mock service');
return new LocationsServiceMock();
} else {
logging.logInfo('use real service');
return new LocationsServiceImpl(http, configService);
}
};
}

view raw
app.module.ts
hosted with ❤ by GitHub

HttpInterceptorService

I use a HTTP_INTERCEPTOR provider for hooking into the http requests and provide error handling in one single place:

{
provide: HTTP_INTERCEPTORS,
useClass: HttpInterceptorService,
multi: true,
deps: [MessageBrokerService, LoggingService]
},

view raw
app.module.ts
hosted with ❤ by GitHub

The HttpInterceptorService depends on the MessageBrokerService and the LoggingService so that the errors are logged and can be sent as a notification to the user:

import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { ErrorOccurredMessage } from '../messages/error-occurred.message';
import { MessageBrokerService } from './message-broker.service';
import { LoggingService } from './logging-service';
@Injectable({
providedIn: 'root'
})
export class HttpInterceptorService implements HttpInterceptor {
constructor(private readonly messageService: MessageBrokerService,
private readonly logging: LoggingService) { }
intercept(req: HttpRequest<any>, next: HttpHandler):
Observable<HttpEvent<any>> {
return next.handle(req)
.pipe(
catchError((error) => this.handleError(error, this.messageService))
);
}
private handleError(error: HttpErrorResponse, messageService: MessageBrokerService) {
if (error && (typeof error.error === 'string')) {
this.logging.logError('err: ' + error.error);
messageService.sendMessage(new ErrorOccurredMessage(error.error));
} else if (error) {
if (error.status === 0) {
this.logging.logError('Connection error.');
messageService.sendMessage(new ErrorOccurredMessage('Connection error.'));
} else {
this.logging.logError('An error has occurred.');
messageService.sendMessage(new ErrorOccurredMessage('An error has occurred.'));
}
}
return throwError(error);
}
}

Certificate

The browser’s geolocation APIs only works properly if the application is hosted with SSL/https. Thus you need a certificate for the Angular application.

I have provided a self-signed certificate for testing purposes in the git repo, but you can create your own with e.g. OpenSSL. There is configuration file available that helps you do this (from this article about serving Angular over https locally). In the loc-poc/Clients/LocPocAngular folder, run:

cd certificates
openssl req -new -x509 -newkey rsa:2048 -sha256 -nodes -keyout localhost.key -days 3560 -out localhost.crt -config certificate.cnf

The self-signed certificate will give a warning in the browser as it is not verified by a third-party, but it is ok for testing purposes.

Next step

With the Angular application up-and-running, the next step is to setup the backend API so that we can persist the location items in a database. This will be covered in Part 3.

 

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s