Build a geofencing web app using Ember

  • Christian Nwamba
June 24th, 2018
You will need Node and npm installed on your machine. Some knowledge of JavaScript will be helpful.

Introduction

A geofence is a virtual perimeter for a real-world geographic area. With a geofencing app, we can define a virtual boundary and be notified when users enter and exits the boundary.

In this tutorial, we’ll be building a simple geofencing web application using Ember.js. Below is a sneak peek of what we’ll be building:

Prerequisites

To follow this tutorial, you need both Node and NPM installed on your machine. A basic JavaScript understanding will help you get the most out of this tutorial. If you don’t have Node.js installed, go to https://nodejs.org/ and install the recommended version for your operating system.

Installing Ember.js

Ember, like lots of frameworks out there offers a command line utility used to create, build, serve, and test Ember.js apps and addons. The Ember CLI helps us spin up Ember apps with a single command. Run the following command to install the Ember CLI on your machine:

    $ npm install -g ember-cli

The command above installs the Ember CLI globally on your machine. Once it is done installing, run the following command to create a new Ember app and then move to this new directory:

    $ ember new pusher-geofencing
    $ cd pusher-geofencing

Once in the pusher-geofencing directory, you can serve the app running the following command:

    $ ember s

This command starts up Ember’s built-in “live-reloading” development server on port 4200. You can see the app in your browser by visiting http://localhost:4200.

Pusher account setup

Head over to Pusher and sign up for a free account.

Create a new app by selecting Channels apps on the sidebar and clicking Create Channels app button on the bottom of the sidebar:

Configure an app by providing basic information requested in the form presented. You can also choose the environment you intend to integrate Pusher to be provided with some boilerplate code:

You can retrieve your keys from the App Keys tab:

Google Maps setup

To use the Maps JavaScript API, you must register your app on the Google API Console and get a Google API key, which will be loaded in the app. Follow this quick guide to register your Maps app and get your API credentials.

Application setup

Now that we have our Pusher and Google Maps app keys, let’s install some dependencies and addons. Run the following commands in your terminal:

    $ ember install ember-bootstrap ember-auto-import
    $ npm install pusher pusher-js express body-parser dotenv uuid --save

Add the following styles to your app.css file:

    // app/styles/app.css

    #map {
      height: 42rem;
    }
    .jumbotron {
      height: 100vh;
    }
    .available-user {
      border-radius: 3px;
      padding: 0 0 0 0.3rem;
      background-color: #28a745;
      margin-top: 0.3rem;
    }

Let’s configure our Bootstrap addon to use Bootstrap 4. Run the following command in your terminal:

    $ ember generate ember-bootstrap --bootstrap-version=4

With Bootstrap now set up, let’s replace the code in our application template with the following:

    {{!-- app/templates/application.hbs --}}

    <div class="container-fluid p-0">
      {{outlet}}
    </div>

Lastly, let’s add our Google Maps script to the index.html file. Ensure you replace YOUR_API_KEY with your Google Maps API key:

    <!-- app/index.html -->

    <head>
      ...
      <script src="https://maps.googleapis.com/maps/api/js?key=YOUR_API_KEY&libraries=geometry"></script>
    </head>

Building our server

Usually, your server should live separately from your Ember app, but for convenience sake, we are going to build our server as part of our Ember app. In your root directory, create a node-server folder and create a server.js and .env file in that folder. Add the following code to each file:

    // node-server/server.js

    const express = require('express');
    const bodyParser = require('body-parser');
    const Pusher = require('pusher');
    const uuid = require('uuid').v4;
    require('dotenv').config()

    const app = express();
    app.use(bodyParser.json());
    app.use(bodyParser.urlencoded({ extended: false }));

    // enable cross-origin resource sharing
    app.use(function (req, res, next) {
      res.header("Access-Control-Allow-Origin", "*");
      res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
      next();
    });

    const pusher = new Pusher({ // connect to Pusher
      appId: process.env.PUSHER_APP_ID,
      key: process.env.PUSHER_APP_KEY,
      secret: process.env.PUSHER_APP_SECRET,
      cluster: process.env.PUSHER_APP_CLUSTER,
    });

    app.get('/', function (req, res) { // to test if the server is running
      res.send({ success: true, message: 'server is online' });
    });

    app.post('/check-in', function (req, res) { // route to send user information to Pusher
      let { lat, lng, name, userId } = req.body;
      if (lat && lng && name) {
        if (userId.length == 0) {
          userId = uuid();
        }
        const location = { lat, lng, name, userId };
        pusher.trigger('location', 'checkin', { location });
        res.send({ success: true, userId })
      } else {
        res.status(400).send({ success: false, message: 'text not broadcasted' })
      }
    });

    const port = process.env.PORT || 5000;
    app.listen(port, () => {
      console.log(`server running on port ${port}`);
    });
    // node-server/.env

    // add your Pusher credentials here
    PUSHER_APP_ID="YOUR APP ID"
    PUSHER_APP_KEY="YOUR APP KEY"
    PUSHER_APP_SECRET="YOUR APP SECRET"
    PUSHER_APP_CLUSTER="YOUR APP CLUSTER"

In the server.js file, we created a simple server with a /check-in route which sends user location data via a location channel to Pusher.

To run this server, open the root directory of the project in a new terminal window, and run the following command:

    $ cd node-server
    $ node server.js

If you’re using version control, remember to ignore your .env file.

Creating the home view

Our geofencing app will have two basic pages: one for users to check in and the other for the admin to view users within range.

In Ember, when we want to make a new page that can be visited using a URL, we generate a "route" using Ember CLI. To generate an index route, run the following command in your terminal:

    $ ember g route index

The above command generates three files:

  • A route handler, located in app/routes/index.js, which sets up what should happen when that route is loaded.
  • A route template, located in app/``templates``/index.hbs, which is where we display the actual content for the page.
  • Lastly, a route test file located in tests/unit/routes/about-test.js, which is used to test the route.

In the index template, add the following code:

    {{!-- app/templates/index.hbs --}}

    {{index-view}}

In the index template, we’re simply rendering the index-view component which we’ll create next. The index-view component will contain the code for the home view. Go ahead and run the following command in your terminal to create the index-view component:

    $ ember g component index-view

As with generating a route, the command above generates a template file, a JavaScript component source file and a file for testing the component. Note that every Ember controller name must be separated by a hyphen.

Add the following code the component’s template file:

    {{!-- app/templates/components/index-view.hbs --}}

    <div class="jumbotron jumbotron-fluid text-center align-middle">
      {{#if isCheckedIn}}{{!-- run this block if the user is checked in --}}
      <h4>You're checked in</h4>
      {{else}} {{!-- run this block if the user is not checked in --}}
      <h4>Welcome to Pusher Geofencer</h4>
      <div class="col-4 mt-5 offset-4">
        {{input value=name class="form-control" placeholder="Enter your name" autofocus=true}}
        <button{{action "checkin"}} class="btn btn-success mt-5">Check in</button>
      </div>
      {{/if}}
    </div>

In the code we added above, we have a handlebars conditional statement. If the user isCheckedInwe display some text. When they’re not checked in, we display an input field and a button that triggers the checkin action in the component JavaScript source file when clicked.

Let’s add the functionality in the component’s JavaScript source file:

    // app/components/index-view.js

    import Component from '@ember/component';
    import { run } from '@ember/runloop';
    import $ from 'jquery';

    export default Component.extend({
      name: '', // user's name
      isCheckedIn: false, // check if the user is checked in
      userId: '', // user's userId
      // component actions
      actions: {
        // action that is run when the button is clicked
        checkin() {
          if (this.name.length > 0) { // if there is a name
            if ('geolocation' in navigator) {
              navigator.geolocation.watchPosition((position) => { // get user location
                const { latitude, longitude } = position.coords;
                const userDetail = { lat: latitude, lng: longitude, name: this.name, userId: this.userId };
                $.ajax({ // send user data via an AJAX call
                  url: 'http://localhost:5000/check-in',
                  type: 'post',
                  data: userDetail
                }).then(response => {
                  run(() => {
                    this.set('userId', response.userId);
                  });
                })
              }, null, { enableHighAccuracy: true });
              this.set('isCheckedIn', true); // set isCheckedIn to true
            }
          } else {
            alert('Enter a name') // if there's no name show this alert
          }
        }
      }
    });

In the code above, we have a checkin action which is called when the check in button is clicked. The action gets the user’s location using the Geolocation API’s watchPosition method and sends it together with the user’s name to the server.

If you visit the app in the browser, you should be able to enter a name and check in after granting location permission.

Creating the admin view

Now that our users can check in and their location is being broadcast by Pusher on the server, it’s time for us to render our map and display the users that are within our range.

Let’s create our admin route and a display-maps component. Run the following code in your terminal:

    $ ember g route admin
    $ ember g component display-maps

Let’s render the display-maps component in the admin template file:

    {{!-- app/templates/admin.hbs --}}

    {{display-maps}}

We’ll also add our admin view markup to the display-maps component

    {{!-- app/templates/components/display-maps.hbs --}}

    <div class="row">
      <div class="col-10 p-0">
        <div id="map"></div>
      </div>
      <div class="col-2 bg-dark">
        <h5 class="text-center py-3 text-white">Users within range</h5>
        <div class="users"></div>
      </div>
    </div>

Next, we’ll generate a service for implementing our map. A service is an Ember object that lives for the duration of the application and can be made available in different parts of your application. It helps us abstract the logic for creating and updating our map and is a singleton, which means there is only one instance of the service object in the browser. To create a maps service, run the following command in your terminal:

    $ ember g service maps

Add the following code to the generated maps.js file:

    // app/services/maps.js

    import Service from '@ember/service';
    import $ from 'jquery';

    const google = window.google;
    let targetLocation;
    const rangeRadius = 500;

    export default Service.extend({

      // function to create admin's map
      createAdminMap(adminLocation) {
        targetLocation = adminLocation;
        this.createMapElement([]) // call the create map function passing empty user locations
      },

      // function to create our map
      createMapElement(usersLocation) {
        const element = document.querySelector('#map');
        let map = new google.maps.Map(element, { zoom: 16, center: targetLocation }); // generate a map
        // The marker, positioned at center
        this.addMarker(targetLocation, map) // add marker fot the target location
        usersLocation.forEach(location => { // loop through the location of available users
          // add markers for other available users to the map
          this.addMarker(location, map, true)
        })

        new google.maps.Circle({ // add the circle on the map
          strokeColor: '#FF0000',
          strokeOpacity: 0.2,
          strokeWeight: 1,
          fillColor: '#FF0000',
          fillOpacity: 0.1,
          map: map,
          center: targetLocation,
          radius: rangeRadius
        });
      },

      // function to add a marker on the map
      addMarker(userLocation, map, icon = false) {
        if (icon) {
          icon = 'http://maps.google.com/mapfiles/ms/icons/green-dot.png'
        } else {
          icon = ""
        }

        let parsedUserLocation = {
          lat: parseFloat(userLocation.lat), // parse the location string to a float
          lng: parseFloat(userLocation.lng),
          name: userLocation.name,
          userId: userLocation.userId
        }

        new google.maps.Marker({ position: parsedUserLocation, map, icon });
        this.addUserWithinRange(parsedUserLocation); // add users to the sidebar
      },

      // function to add/remove users within range
      addUserWithinRange(userLocation) {
        if (userLocation.name) {
          let userDistance = this.locationDistance(userLocation); // check the distance between the user and the target location
          let existingUser = $('div').find(`[data-id="${userLocation.userId}"]`); // find the user on the page via the data-id attribute
          if (userDistance < rangeRadius) { // if the user is within the range
            if (!existingUser[0]) { // if the user is not already displayed on the page
              let div = document.createElement('div'); // create a div element
              div.className = 'available-user';
              div.dataset.id = userLocation.userId;
              let span = document.createElement('span'); // create a span element
              span.className = 'text-white';
              let username = `@${userLocation.name}`
              span.append(username);
              div.append(span);
              const usersDiv = document.querySelector('.users');
              usersDiv.append(div); // add the user to the page
            }
          } else {
            existingUser.remove(); // remove the user from the page is they're out of range
          }
        }
      },

      // function to calculate the distance between our target location and the user's location
      locationDistance(userLocation) {
        const point1 = new google.maps.LatLng(targetLocation.lat, targetLocation.lng);
        const point2 = new google.maps.LatLng(userLocation.lat, userLocation.lng);
        const distance = google.maps.geometry.spherical.computeDistanceBetween(point1, point2);
        return distance;
      }
    });

In our maps service, we have four functions:

  • The createAdminMap function for creating the map showing the target location
  • The createMapElement function for creating our map.
  • The addMarker function for adding markers to our map.
  • The addUserWithinRange function for adding and removing users from the sidebar on the admin page.
  • The locationDistance function for calculating if the user is within our target range.

In the createAdminMap function, we accept our admin’s location and call the createMapElement function. The createMapElement function generates a map using the Google Maps Map object and insert it to the div with the ID of map on our page. The function also accepts an array of users location and for each user, we add a marker for their location on the map.

The locationDistance function calculates the difference between the user’s location and the target location and passes the data to the adUserWithinRange function which either adds or removes the user’s name from the page based on whether or not they’re within range.

Now that we’ve written the code for building with our map, let’s use it in the display-maps component:

    // app/components/display-maps.js

    import Component from '@ember/component';
    import { inject as service } from '@ember/service';
    import Pusher from 'pusher-js';

    export default Component.extend({
      allUsers: [].map(user => { // all users array
        return user;
      }),
      maps: service('maps'),

      init() {
        this._super(...arguments);
        let pusher = new Pusher('YOUR_APP_KEY', { // instantiate new Pusher client
          cluster: 'CLUSTER',
          encrypted: true
        });
        let users = this.get('allUsers'); // save the allUsers array to a variable
        const channel = pusher.subscribe('location'); // subscribe Pusher client to location channel
        channel.bind('checkin', data => {
          if (users.length == 0) { // if the allUsers array is empty
            users.pushObject(data.location) // add new data to users array
          } else { // if the allUsers array is not empty
            // check if user already exists before pushing
            const userIndex = this.userExists(users, data.location, 0)
            if (userIndex === false) { // if user was not found, means its a new user
              users.pushObject(data.location) // push the users info to the allUsers array
            } else {
              // replace the users previous object with new one if they exists
              users[userIndex] = data.location;
            }
          }
          this.get('maps').createMapElement(users); // create the map
        });
      },

      // Ember's didInsertElement life cycle hook
      didInsertElement() {
        this._super(...arguments);
        this.getAdminLocation(); // get the admins location
      },

      // recursive function to check if a user already exixts
      userExists(users, user, index) {
        if (index == users.length) {
          return false;
        }
        if (users[index].userId === user.userId) {
          return index;
        } else {
          return this.userExists(users, user, index + 1);
        }
      },

      // function to get admin's location
      getAdminLocation() {
        if ('geolocation' in navigator) {
          navigator.geolocation.getCurrentPosition((position) => { // get admin's location
            const { latitude, longitude } = position.coords;
            const adminLocation = { lat: latitude, lng: longitude };
            this.get('maps').createAdminMap(adminLocation); // call the createAdmin map from our service
          }, null, { enableHighAccuracy: true });
        }
      }
    });

In the code snippet above, we have an array of allUsers and we inject our maps service into the component by calling maps: service('maps'). In the didInsertElement lifecycle hook, we call the getAdminLocation function which gets the admin’s location and calls the createAdminMap from our map service to create the admin’s map showing the target location.

In the init function which is called when the component is initialized, we create our Pusher client and subscribe it to the location channel.

When there is a new checkin event, we call the userExists function to see if the user already exists in our allUsers array. We then add or update the user’s info based on whether or not they exist in the allUsers array. After all this is done, we call the createMapElement from our maps service and pass it our array of users to be rendered on the page. Remember to add your Pusher key and cluster.

Bringing it all together

At this point, restart your development server, ensure your Node server is running and open the admin view(http://localhost:4200/admin) in a second tab. Enter a name in the home view then check in, you should see your name popup with your location showing on the map.

Conclusion

In this post, we have successfully created a realtime geofencing application in Ember. I hope you found this tutorial helpful and would love to apply the knowledge gained here to easily set up your own application using Pusher in an Ember application. You can find the source code for the demo app on GitHub.

  • Channels

© 2018 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.