Hands on

Develop a Cross-Platform Desktop Maps Application with Electron, Vue.js, and HERE

By Nic Raboy | 13 November 2018

Have you ever wanted to build your own desktop application for working with maps, similar to how Apple Maps functions on macOS? How about building a mapping application that works cross-platform and not just for macOS, all while using much more reliable maps? All of this can be accomplished with JavaScript, Electron, and HERE!

I’ve been writing a lot of tutorials around Vue.js and Angular that demonstrate different development strategies for working with maps, whether that be routing, searching, or something else. About a month ago, I even demonstrated using Electron in a tutorial titled, Developing a Cross-Platform Desktop Maps Application with Electron, Angular, and HERE, which focused on Angular and the HERE Places API. This time around I thought it would be a great idea to explore something similar, but using the Vue.js JavaScript framework instead.

We’re going to see how to build a cross-platform desktop application that works for Mac, Windows, and Linux, and uses the HERE Routing API to calculate Isoline routes with Vue.js.

To get an idea of what we want to accomplish, check out the animated image below:

vue-isoline-electron

As you can see in the above image, we have a desktop application with a full screen map. We also have a set of user defined inputs that accept an address and a range in seconds. The point behind Isoline routing is the ability to take a starting set of coordinates and a range to determine the coverage that can be reached.

For example, let’s say you use your work address. You want to buy a house that is within a 15 minute (900 seconds) drive to the office. Rather than randomly checking a potentially large amount of addresses, or using a radius that can be inaccurate due to road coverage and traffic, you can create an Isoline route. This is just one of many possible use cases for Isoline Routing.

Build a Vue.js Application with a HERE Map Component

The first step of this project will involve creating a Vue.js application using the Vue CLI. While the Vue CLI isn’t a requirement of using Vue.js, it will be for this particular project.

With the Vue CLI installed, execute the following command:

vue create demo

The Vue CLI will ask questions as part of the project configuration wizard. This project will use the defaults, so don’t bother with Vuex or similar. Going forward, it is important to note that I’ve already explored much of this content in past tutorials. For example, my tutorial titled, Showing a HERE Map with the Vue.js JavaScript Framework demonstrates how to create a map component with Vue.js. I had also written a tutorial titled, Calculate and Visualize Navigation Routes with HERE, JavaScript, and Vue.js which demonstrates standard waypoint routing with Vue.js.

For these reasons, much of the deeper details will be skipped to get us up to speed with a map component. What we’re going to focus on is Isoline Routing and configuring Electron.

In the project that was created, open the public/index.html file and include the following:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8">
        <meta http-equiv="X-UA-Compatible" content="IE=edge">
        <meta name="viewport" content="width=device-width,initial-scale=1.0">
        <link rel="icon" href="<%= BASE_URL %>favicon.ico">
        <title>HERE with Vue</title>
        <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>
        <noscript>
            <strong>We're sorry but vue-electron doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
        </noscript>
        <div id="app"></div>
        <!-- built files will be auto injected -->
        <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-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>

We’ve imported quite a few JavaScript and CSS libraries, all related to HERE for mapping and location services and Bootstrap for a nice application theme.

With our libraries in place, we can build our map component. The project should already contain a src/components/HelloWorld.vue file. Rename that file to src/components/HereMap.vue and include the following:

<template>
    <div class="here-map">
        <div ref="map" id="map"></div>
    </div>
</template>

<script>
    export default {
        name: "HereMap",
        data() {
            return {
                map: {},
                platform: {},
                router: {},
                geocoder: {},
                directions: [],
                ui: {}
            }
        },
        props: {
            appId: String,
            appCode: String,
            lat: String,
            lng: String
        },
        created() {
            this.platform = new H.service.Platform({
                "app_id": this.appId,
                "app_code": this.appCode
            });
            this.router = this.platform.getRoutingService();
            this.geocoder = this.platform.getGeocodingService();
        },
        mounted() {
            var 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.$refs.map,
                defaultLayers.normal.map,
                {
                    zoom: 10,
                    center: { lng: this.lng, lat: this.lat },
                    pixelRatio: pixelRatio
                }
            );
            let behavior = new H.mapevents.Behavior(new H.mapevents.MapEvents(this.map));
            this.ui = H.ui.UI.createDefault(this.map, defaultLayers);
        },
        methods: { }
    }
</script>

<style scoped></style>

Again, this code was discussed in detail in previous tutorials that I wrote. In short, we’re creating an HTML container for the map referenced by a map variable. In the <script> block we are initializing our variables, defining our attribute properties, initializing the HERE platform and services, and then displaying a map using the reference variable in the HTML container.

To use this map component to display a map, open the project’s src/App.vue file and include the following:

<template>
    <div id="app">
        <HereMap
            ref="map"
            appId="APP-ID-HERE"
            appCode="APP-CODE-HERE"
            lat="37.7397"
            lng="-121.4252" />
    </div>
</template>

<script>
    import HereMap from './components/HereMap.vue'

    export default {
        name: 'app',
        components: {
            HereMap
        }
    }
</script>

<style></style>

Notice that we’ve changed the old HelloWorld component to the new HereMap component. We’ve also added the <HereMap> tag to the UI. For this project to work you will need to use your own app id and app code values. These can be found after creating a free HERE developer account.

Before we jump into the core of our material, let’s add the CSS to make everything look nice. Within the project’s src/App.vue file, include the following in the <style> block:

body {
    background-color: #262626;
}

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

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

I am not a CSS or design wizard. I’m confident that you’re much more talented than I am and can come up with a more attractive looking application. My applications are more about function than anything.

Since we have an application that should run in the browser, we are ready to configure it to be deployed as a desktop application with Electron.

Configure Electron for Deployments on Mac, Windows, and Linux

Packaging this application won’t be difficult. In fact, most of what you see here will come directly from the Electron documentation.

Before we create configuration files, let’s install Electron as a development dependency. From the command line, execute the following:

npm install electron --save-dev

With Electron available to us, we can create an Electron configuration file. At the root of your project, create an electron.js file with the following JavaScript code:

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

let win

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

    win.loadFile('dist/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()
    }
})

Minus the comments, I took 99% of the above code from the Electron documentation. What I did change is the HTML file that is loaded. The goal with Electron is to package a production build. When building a Vue.js application, output is placed in the dist directory.

With the configuration file in place, it needs to be linked in the package.json file. Include the following in your package.json file without clearing out what already exists:

// ...
"main": "electron.js",
"scripts": {
    // ...
    "electron": "vue-cli-service build; electron ."
},
// ...

The main property must point to the electron.js file we just created. We are also creating a build script for Electron that first builds the Vue.js project and then launches it with Electron. You can create your own script to bundle the application to be standalone using similar steps.

To run the application, execute the following:

npm run electron

If you run the above command you’ll probably run into several resource errors. Errors exist because of the relative and absolute paths that are a result of the Webpack process. This can be fixed using a Vue.js configuration file.

Create a vue.config.js file at the root of your project with the following:

module.exports = {
    baseUrl: process.env.NODE_ENV === 'production' ? './' : '/'
}

When you build your project, the vue.config.js file will be used and the paths will be updated so that Electron can make sense of them. If you ran the application now, it should show your basic map.

Now that we have a basic Vue.js application with Electron support, we can start giving it some functionality.

Geocoding Addresses to Latitude and Longitude Coordinates with HERE, JavaScript, and Vue.js

We’ll be creating a starting point to our Isoline routes, but that starting point needs to be a latitude and longitude position, not an address that is much more easy to understand. Rather than guessing a bunch of coordinates, we can make use of the HERE Geocoder API for JavaScript and gather positions from the addresses.

In the project’s src/components/HereMap.vue you’ll probably remember a methods object. We need to create a method for geocoding our data. Include the following JavaScript to get the job done:

getCoordinates(query) {
    return new Promise((resolve, reject) => {
        this.geocoder.geocode({ searchText: query }, data => {
            if(data.Response.View[0].Result.length > 0) {
                data = data.Response.View[0].Result.map(location => {
                    return {
                        lat: location.Location.DisplayPosition.Latitude,
                        lng: location.Location.DisplayPosition.Longitude
                    };
                });
                resolve(data);
            } else {
                reject({ "message": "No data found" });
            }
        }, error => {
            reject(error);
        });
    });
},

The HERE Geocoder API does quite a bit with very little, but we’re only interested in coordinate information. For this reason, we’re going to format our own very specific response for the next function to consume. If there was no data found for the information provided or there was an error, the promise will be rejected.

With geocoding in place, Isoline routes can become the focus.

Isoline Routing for Finding Reachable Routes within a Range

Isoline Routing accomplishes a lot with very little. Given a starting location and other minor search criteria, a nice overlay can be created to show the furthest regions that meet the criteria provided. This saves you from having to create X number of requests to the standard routing API.

Within the project’s src/components/HereMap.vue file, we need to create another JavaScript method:

route(start, range) {
    var params = {
        "mode": "fastest;car;traffic:enabled",
        "range": range,
        "rangetype": "time",
        "departure": "now"
    }
    this.map.removeObjects(this.map.getObjects());
    this.getCoordinates(start).then(geocoderResult => {
        params["start"] = geocoderResult[0].lat + "," + geocoderResult[0].lng;
        this.router.calculateIsoline(params, data => {
            if(data.response) {
                var center = new H.geo.Point(data.response.center.latitude, data.response.center.longitude),
                    isolineCoords = data.response.isoline[0].component[0].shape,
                    linestring = new H.geo.LineString(),
                    isolinePolygon,
                    isolineCenter;
                isolineCoords.forEach(coords => {
                    linestring.pushLatLngAlt.apply(linestring, coords.split(','));
                });
                isolinePolygon = new H.map.Polygon(linestring);
                isolineCenter = new H.map.Marker(center);
                this.map.addObjects([isolineCenter, isolinePolygon]);
                this.map.setViewBounds(isolinePolygon.getBounds());
            }
        }, error => {
            console.error(error);
        });
    }, error => {
        console.error(error);
    });
}

In the route method, we define a few parameters. For example, we want the Isoline result to be for car transportation with traffic enabled. We also want it to be based on time, not distance or fuel consumption.

When calculating the route, first we clear the map of any existing data. One the map is cleared, we convert an address to coordinates and use it as the final property in our parameters. The rest of the code was taken directly from the HERE Isoline Routing documentation. Essentially, we’re using the parameters that were previously defined, getting all the Isoline coordinates, and displaying them on the map as polygons that are filled.

With the functions in place, we can configure the final part of our UI. Open the project’s src/App.vue file and include the following:

<template>
    <div id="app">
        <div id="searchbar">
            <form class="form-inline">
                <div class="form-group input-group-sm w-50">
                    <label for="location" class="sr-only">Location...</label>
                    <input type="text" v-model="query" class="form-control w-100" id="location" placeholder="Starting location...">
                </div>
                <div class="form-group input-group-sm mx-sm-3 w-25">
                    <label for="range" class="sr-only">Range</label>
                    <input type="text" v-model="range" class="form-control w-100" id="range" placeholder="Range (seconds)">
                </div>
                <button type="button" v-on:click="$refs.map.route(query, range)" class="btn btn-primary btn-sm w-10">Search</button>
            </form>
        </div>
        <HereMap
        ref="map"
        appId="APP-ID-HERE"
        appCode="APP-CODE-HERE"
        lat="37.7397"
        lng="-121.4252" />
    </div>
</template>

<script>
    import HereMap from './components/HereMap.vue'

    export default {
        name: 'app',
        components: {
            HereMap
        },
        data() {
            return {
                query: "",
                range: ""
            };
        }
    }
</script>

<style>
    body {
        background-color: #262626;
    }

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

    #map {
        position: fixed;
        width: 100%;
        height: 100%;
    }
</style>

In the above code you might be wondering what exactly changed. In reality, we’ve just added a few floating search boxes as seen in the following HTML markup:

<div id="searchbar">
    <form class="form-inline">
        <div class="form-group input-group-sm w-50">
            <label for="location" class="sr-only">Location...</label>
            <input type="text" v-model="query" class="form-control w-100" id="location" placeholder="Starting location...">
        </div>
        <div class="form-group input-group-sm mx-sm-3 w-25">
            <label for="range" class="sr-only">Range</label>
            <input type="text" v-model="range" class="form-control w-100" id="range" placeholder="Range (seconds)">
        </div>
        <button type="button" v-on:click="$refs.map.route(query, range)" class="btn btn-primary btn-sm w-10">Search</button>
    </form>
</div>

Each input field is bound to a variable using the v-model attribute. When we want to search, we grab reference to the map and call its route method, passing each of the bound variables.

If you ran the application for either web or Electron, it should work great.

Conclusion

You just saw how to create a mapping application for Mac, Linux, and Windows using Electron, HERE, and Vue.js. Even though Electron played a very small role in terms of configuration, it was easy to meet our goals of creating an application with Vue.js that did Isoline Routing.

If you’re interested in Isoline Routing with Angular, I had written a tutorial titled, Isoline Routing in an Angular Application using the HERE Routing API which demonstrates that, without the Electron part of course.