Build a geofencing web app using Ember
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 isCheckedIn
we 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.
24 June 2018
by Christian Nwamba