Hands on

Developing a Cross-Platform Desktop Maps Application with Electron, Angular, and HERE

By Nic Raboy | 21 September 2018

I have a Mac and every once in a while I use the Apple Maps Desktop application. There are potentially a few problems with the Apple Maps application, for example, it only works on Mac and not Windows or Linux. Also, the maps could be using a more reliable and trusted solution like HERE. This is where Electron and the HERE JavaScript APIs can make an impact.

If you’re unfamiliar, Electron is a framework that lets you build cross-platform, native packaged desktop applications using JavaScript and other web frameworks. Combine that with a framework like Angular and HERE for mapping and you’ve got yourself a very powerful toolset.

We’re going to see how to build a desktop mapping application that works on Mac, Linux, and Windows using Electron, Angular, and HERE.

To get an idea of what we want to accomplish, take a look at the following animated image:

here-angular-electron

As you can see, we have a standalone desktop application with a map and a fixed position search box. Searching in the box for points of interest will display markers on the map that can be clicked for address information. The map takes up the full dimensions of the application and can be panned or zoomed.

If you’ve seen some of my other Angular with HERE tutorials, you’ll probably recognize a lot of the functionality. For example, I wrote a tutorial titled, Displaying Places on a HERE Map in an Angular Web Application, which focused on the HERE Places API as well as another tutorial titled, Display HERE Maps within your Angular Web Application, which focused strictly on displaying a map with Angular. We’re more or less going to clean up these previous tutorials and package them as a desktop application with Electron.

Creating a New Angular Web Application for Interacting with Maps

To be successful with this project, you’re going to need the Angular CLI and a free HERE developer account. With those prerequisites met, we need to create a new project by executing the following:

ng new here-electron-demo

The above command will create a new Angular project. Before we get into the mapping and Electron content, we need to make some adjustments in preparation. Open the project’s src/index.html file and make it look like the following:

<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>HERE with Angular</title>
        <base href="./">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link rel="icon" type="image/x-icon" href="favicon.ico">
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
    </head>
    <body>
        <app-root></app-root>
        <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"></script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"></script>
    </body>
</html>

There are a few things to note in the above HTML markup. The first of which is in our <base> tag. Instead of referencing an absolute path, we’ve added a period character to reference a relative path. This is necessary for the Angular project to build in a way that is compatible with Electron. The other things you’ll notice is that we’re including Bootstrap which we’ll use as our theming layer. Bootstrap is not a requirement to be successful, but it will make our project look nice with minimal effort. After all, my design skills are not very good.

With the project created and prepared, we can move along to the next step of configuring Electron.

Configuring a Web Application to be Deployed with Electron

For this particular project, Electron will act as strictly a packaging mechanism for our Angular application. In other scenarios that use native operating system features, Electron acts as more than just a packaging mechanism, but we won’t be using any operating system features here.

From the Angular project, execute the following:

npm install electron --save-dev

The above command will install Electron as a development dependency. Electron does require its own configuration for things like screen size, menu information, etc., and that can be handled in an electron.js file at the root of your project. Create an electron.js file and include the following:

const {app, BrowserWindow} = require('electron')

let win

function createWindow () {
    win = new BrowserWindow({width: 1200, height: 750, resizable: false})

    win.loadFile('dist/demo/index.html')

    win.on('closed', () => {
        win = null
    })
}

app.on('ready', createWindow)

app.on('window-all-closed', () => {
    if (process.platform !== 'darwin') {
        app.quit()
    }
})

app.on('activate', () => {
    if (win === null) {
        createWindow()
    }
})

If you’ve ever used Electron before, the above code might look familiar. Most of it was taken from the official quick start documentation for Electron. However, do take note of the loadFile line. When Electron launches, we want it to load our production-ready Angular application. This will eventually be found in the dist/demo path.

With the configuration in place, we can create our build script. Open the project’s package.json file and include the following:

{
    // ...
    "main": "electron.js",
    "scripts": {
        // ...
        "electron": "npm run build; cp electron.js dist/; ./node_modules/.bin/electron ."
    },
}

Notice that we have added a main property as well as an electron script. The script will build our Angular project, copy the electron.js file, and launch the project as an Electron application. This will not build the Electron application as a deployable binary, but it could easily be adjusted following the Electron documentation.

At any time, if you wish to run the project, execute the following:

npm run electron

Now that the project is ready and Electron is ready, we can start the development of our project. From this point on, we’ll be more or less revisiting content from previous tutorials. However, we will be cleaning it up and making it a bit more fancy from a UX and UI perspective.

Developing an Interactive Map Component with Angular

Since we’ll be using a map in our application, we should probably build a dedicated component for it with Angular. From the Angular CLI, execute the following to create a here-map component:

ng g component here-map

The above command will create a CSS, TypeScript, and HTML file for our component. However, we’re going to focus on strictly the TypeScript file for logic and the HTML file for UI.

Open the project’s src/app/here-map/here-map.component.html file and include the following HTML markup:

<div #map id="map"></div>

Notice that we only have a <div> tag here. This represents a placeholder for our map. Because we need to interact with the DOM directly, we need to create a reference variable in Angular, as noted by the #map attribute. The id attribute will be used for our stylesheet information later in the tutorial.

As of right now, we don’t have any of the HERE libraries available in our project. Open the project’s src/index.html file and include the following:

<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <title>HERE with Angular</title>
        <base href="./">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link rel="icon" type="image/x-icon" href="favicon.ico">
        <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css">
        <link rel="stylesheet" type="text/css" href="https://js.api.here.com/v3/3.0/mapsjs-ui.css?dp-version=1533195059">
    </head>
    <body>
        <app-root></app-root>
        <script src="https://code.jquery.com/jquery-3.3.1.slim.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js"></script>
        <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"></script>
        <script src="https://js.api.here.com/v3/3.0/mapsjs-core.js" type="text/javascript" charset="utf-8"></script>
        <script src="https://js.api.here.com/v3/3.0/mapsjs-service.js" type="text/javascript" charset="utf-8"></script>
        <script src="https://js.api.here.com/v3/3.0/mapsjs-places.js" type="text/javascript" charset="utf-8"></script>
        <script src="https://js.api.here.com/v3/3.0/mapsjs-ui.js" type="text/javascript"></script>
        <script src="https://js.api.here.com/v3/3.0/mapsjs-mapevents.js" type="text/javascript"></script>
    </body>
</html>

As you can see, we’ve just added more JavaScript and CSS libraries to our project. These libraries are for using HERE Maps as well as other HERE related services in our application.

With HERE available to us, we can continue editing our here-map component. Open the project’s src/app/here-map/here-map.component.ts file and include the following:

import {
    Component,
    OnInit,
    ViewChild,
    ElementRef,
    Input
} from '@angular/core';

declare var H: any;

@Component({
    selector: 'here-map',
    templateUrl: './here-map.component.html',
    styleUrls: ['./here-map.component.css']
})
export class HereMapComponent implements OnInit {

    @ViewChild("map")
    public mapElement: ElementRef;

    @Input()
    public appId: any;

    @Input()
    public appCode: any;

    @Input()
    public lat: any;

    @Input()
    public lng: any;

    @Input()
    public zoom: any;

    private platform: any;
    private map: any;
    private ui: any;
    private search: any;

    public constructor() { }

    public ngOnInit() {
        this.platform = new H.service.Platform({
            "app_id": this.appId,
            "app_code": this.appCode
        });
        this.search = new H.places.Search(this.platform.getPlacesService());
    }

    public ngAfterViewInit() {
        let pixelRatio = window.devicePixelRatio || 1;
        let defaultLayers = this.platform.createDefaultLayers({
            tileSize: pixelRatio === 1 ? 256 : 512,
            ppi: pixelRatio === 1 ? undefined : 320
        });
        this.map = new H.Map(
            this.mapElement.nativeElement,
            defaultLayers.normal.map,
            {
                zoom: this.zoom,
                center: { lat: this.lat, lng: this.lng },
                pixelRatio: pixelRatio
            }
        );
        let behavior = new H.mapevents.Behavior(new H.mapevents.MapEvents(this.map));
        this.ui = H.ui.UI.createDefault(this.map, defaultLayers);
    }

}

So what is happening in the above code? Since we won’t be working with any official type definitions for TypeScript, we’re going to declare our HERE library using the following line:

declare var H: any;

This prevents any TypeScript related errors at build time. Inside the HereMapComponent class, we have a @ViewChild and several @Input attributes. These variables are how we bind to the DOM element and the elements HTML attributes. You can include as little or many attributes as you want as long as our @ViewChild is there and referencing the same name as we defined, in this case #map.

Inside the constructor method we can load our HERE platform using the app id and app code values found in the HERE Developer Portal. We can also initialize our search variable to be used later.

Because we’re working with a DOM element, we need to wait until after the view has rendered before we try to work with it. In the ngAfterViewInit method we have the following:

public ngAfterViewInit() {
    let pixelRatio = window.devicePixelRatio || 1;
    let defaultLayers = this.platform.createDefaultLayers({
        tileSize: pixelRatio === 1 ? 256 : 512,
        ppi: pixelRatio === 1 ? undefined : 320
    });
    this.map = new H.Map(
        this.mapElement.nativeElement,
        defaultLayers.normal.map,
        {
            zoom: this.zoom,
            center: { lat: this.lat, lng: this.lng },
            pixelRatio: pixelRatio
        }
    );
    let behavior = new H.mapevents.Behavior(new H.mapevents.MapEvents(this.map));
    this.ui = H.ui.UI.createDefault(this.map, defaultLayers);
}

To create a pixel perfect map, we get the pixel density from the browser or Electron container and create our layers based on that information. The map is then loaded from our DOM element and the latitude and longitude is centered on based on the user provided information in the HTML attributes. To make the map interactive, we add a new behavior and initialize the UI.

To see what we have in action, we can open the project’s src/app/app.component.html file and add the following:

<here-map
    #map
    appId="APP-ID-HERE"
    appCode="APP-CODE-HERE"
    lat="37.7397"
    lng="-121.4252"
    zoom="13">
</here-map>

The above component has attributes that match what we have as @Input in the logic file. You’ll notice that we also have a #map attribute, like what we had in the other HTML file. This is a coincidence and it isn’t technically necessary for this step. Removing it will still leave you with a map. Renaming it also is fine too. It will be relevant for when we want to search for places.

From a stylesheet perspective, to get a full screen map, add the following to your project’s src/styles.css file:

#map {
    position: fixed;
    width: 100%;
    height: 100%;
}

Almost everything that you’ve seen so far was discussed in my previous article titled, Display HERE Maps within your Angular Web Application. For more clarity and depth, I recommend checking out that previous tutorial.

Leveraging the HERE Places API for Points of Interest Searching

Now that we’ve got an interactive map, we can give it some more useful functionality. We’re going to implement a points of interest search that displays markers on the map.

Open the project’s src/app/here-map/here-map.component.ts file and include the following function to our HereMapComponent class:

private dropMarker(coordinates: any, data: any) {
    let marker = new H.map.Marker(coordinates);
    marker.setData("<p>" + data.title + "<br>" + data.vicinity + "</p>");
    marker.addEventListener('tap', event => {
        let bubble =  new H.ui.InfoBubble(event.target.getPosition(), {
            content: event.target.getData()
        });
        this.ui.addBubble(bubble);
    }, false);
    this.map.addObject(marker);
}

When latitude and longitude coordinate information and generic data is provided, we can create a clickable marker. The marker will be placed at the coordinate position and the data will be presented in an InfoBubble when the marker is clicked. In this case, we are expecting the generic data to be a point of interest title and address.

Now that we have a mechanism for adding markers, we can search for places to actually drop the markers. Within the HereMapComponent class, add another function:

public places(query: string) {
    this.map.removeObjects(this.map.getObjects());
    this.search.request({ "q": query, "at": this.lat + "," + this.lng }, {}, data => {
        for(let i = 0; i < data.results.items.length; i++) {
            this.dropMarker({ "lat": data.results.items[i].position[0], "lng": data.results.items[i].position[1] }, data.results.items[i]);
        }
    }, error => {
        console.error(error);
    });
}

When the places method is called, the query string is used to search for points of interest using the HERE Places API. The results from the API include coordinate information and other data which includes a title for the point of interest and its address. This information is passed into our dropMarker function so it can be displayed.

Before we can effectively use these functions, we need to enable forms in Angular. Open the project’s src/app/app.module.ts file and include the following:

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { FormsModule } from "@angular/forms";

import { AppComponent } from './app.component';
import { HereMapComponent } from './here-map/here-map.component';

@NgModule({
    declarations: [
        AppComponent,
        HereMapComponent
    ],
    imports: [
        BrowserModule,
        FormsModule
    ],
    providers: [],
    bootstrap: [AppComponent]
})
export class AppModule { }

Notice we’ve imported the FormsModule which is disabled by default. By enabling it, we can bind form data to components, which is critical for searching for places.

Head back into your project’s src/app/app.component.html file and include the following:

<div id="searchbar">
    <div class="input-group input-group-sm w-100">
        <input type="text" [(ngModel)]="query" (keydown.enter)="map.places(query)" class="form-control" placeholder="Points of Interest..." aria-label="Points of Interest" aria-describedby="button-addon2">
        <div class="input-group-append">
            <button (click)="map.places(query)" class="btn btn-primary" type="button" id="button-addon2">Search</button>
        </div>
    </div>
</div>

<here-map
    #map
    appId="APP-ID-HERE"
    appCode="APP-CODE-HERE"
    lat="37.7397"
    lng="-121.4252"
    zoom="13">
</here-map>

Now instead of only having a map, we have a search form as well. The search form uses Bootstrap and the <input> field will eventually be bound to variables in our logic file. It will be bound to a query variable as well as to our places method in the map component. We can call the places method because of the #map we placed on our <here-map> tag.

In addition to having a keydown event, we are also adding a button that accomplishes the same thing.

Open the project’s src/app/app.component.ts file and include the following TypeScript logic:

import { Component } from '@angular/core';

@Component({
    selector: 'app-root',
    templateUrl: './app.component.html',
    styleUrls: ['./app.component.css']
})
export class AppComponent {

    public query: string;

    public constructor() {
        this.query = "";
    }

}

All we’re doing in the TypeScript file is defining the query variable that we’re using in our HTML file. This variable is bound and is used when calling the places method of our map.

To bring things to a close, we can add the rest of our stylesheet information. Within the projects src/styles.css file, append the following:

.H_ib_body {
    font-size: 0.8em;
    white-space: nowrap;
    line-height: 1.5em;
}

.H_ib_body p {
    margin: 0;
    padding: 10px;
}

#searchbar {
    position: fixed;
    z-index: 1;
    top: 10px;
    left: 50%;
    transform: translateX(-50%);
    background-color: #262626;
    padding: 15px;
    width: 50%;
    border-radius: 5px;
}

If you run the application, it should look like what I had previously demonstrated in the animated image. Feel free to put your design talents to good use and make it even better.

You can find more information on using the HERE Places API with Angular in a previous tutorial I wrote titled, Displaying Places on a HERE Map in an Angular Web Application.

Conclusion

You just saw how to build a cross-platform desktop application using Angular, Electron and HERE, to display and use maps for Mac, Linux, and Windows. Being able to build a desktop mapping application is useful because you can enhance the user experience that you’d find on the web and then compete with Apple.

As I previously mentioned, a lot of this tutorial was taken from my other Angular tutorials. To make this Electron application more useful, you could add routing as demonstrated in my tutorial titled, Transportation Routing and Directions in an Angular Application with the HERE Routing API.