Build a geofencing web app using Next.js
You will need Node and npm or Yarn installed on your machine.
According to Wikipedia, a geo-fence is a virtual perimeter for a real-world geographic area. A geo-fence could be dynamically generated—as in a radius around a point location, or a geo-fence can be a predefined set of boundaries (such as school zones or neighborhood boundaries).
It is quite obvious that geofencing can be very useful for several of real life location-specific applications. With the advent of GPS technology, it becomes very easy to get the position of objects in realtime. These days, almost every smartphone have built-in GPS sensors that can be used to estimate the position of the device using WiFi or Cellular data.
As web technologies advance, a couple of tools are made available to us that can enable us build location-aware applications. One of such tools is the GeolocationAPI which is supported by most of the modern browsers.
With the GeolocationAPI, getting the current position of the device is as simple as running the following code snippet in a browser:
if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(function(position) {
console.log({ lat: position.coords.latitude, lng: position.coords.longitude });
});
}
If you are interested in realtime position updates of the device, you can run the following code snippet on the browser:
if ('geolocation' in navigator) {
navigator.geolocation.watchPosition(function(position) {
console.log({ lat: position.coords.latitude, lng: position.coords.longitude });
});
}
In this tutorial, we’ll build a very simple application with realtime geofencing updates, to list nearby people within a circular region of 1km radius.
In order to make testing our app as simple as possible, we will not be using any geolocation API in this tutorial. To avoid having to send our friends and coworkers out into the city to test our app, we will have a list of 15 fake people and randomly set their positions. We will also update the positions of the people who are online using an interval that runs every 10 seconds.
In a real application, you would use the GeolocationAPI available on the user’s browser to get the approximate position of the user. You can also use a geolocation service such as Google’s Geolocation API without relying on the GPS of the device.
Here is a screenshot of what we will end up building in this tutorial.
Prerequisites
Before you begin, ensure that you have Node and npm or Yarn installed on your machine. Here is a run-down of the core technologies we will be using.
-
Next.js - A framework for building server-side rendered(SSR) React applications with ease. It handles most of the challenges that come with building SSR React apps.
-
Pusher - Pusher is a technology for building apps with varying realtime needs like push notifications and pub/sub messaging. It is the engine behind the realtime geofencing updates.
-
GoogleMaps API - GoogleMaps JavaScript API provides utilities that make it possible to add interactive and customizable maps to web apps. We will use the react-google-maps package to enable us to add GoogleMaps to our React application.
-
React - A very popular JavaScript DOM rendering framework for building scalable web applications using a component-based architecture.
A few other libraries will be used as we will see in a moment. Also ensure that you have Node installed on your machine.
Pusher application
Create a new application on your Pusher Dashboard to get your application credentials. The following credentials are required:
APP_ID
APP_KEY
APP_SECRET
APP_CLUSTER
GoogleMaps application
To use the Maps JavaScript API, you must register your app project on the Google API Console and get a Google API key which you can add to your app. Follow this quick guide to register your Maps app and get your API credentials.
Installing dependencies
Create a new directory for the application and run the following command to install the required dependencies for the app.
# Create a new directory
mkdir realtime-geofencing-app
# cd into the new directory
cd realtime-geofencing-app
# Initiate a new package and install app dependencies
npm init -y
npm install react react-dom next pusher pusher-js react-google-maps
npm install express body-parser morgan cors dotenv axios uuid
npm install --save-dev cross-env npm-run-all
Setting environment variables
Create a .env
file in the root directory of your application and add your application credentials as follows.
PUSHER_APP_ID=YOUR_APP_ID
PUSHER_APP_KEY=YOUR_APP_KEY
PUSHER_APP_SECRET=YOUR_APP_SECRET
PUSHER_APP_CLUSTER=YOUR_APP_CLUSTER
# GOOGLE MAPS API CREDENTIALS
GMAPS_API_KEY=YOUR_GOOGLE_MAPS_API_KEY
Ensure that you use the same variable names as specified in the above snippet. We will refer to them at several points in our code.
Next create a Next.js configuration file named next.config.js
in the root directory of your application with the following content:
/* next.config.js */
const webpack = require('webpack');
require('dotenv').config();
module.exports = {
webpack: config => {
const env = Object.keys(process.env).reduce((acc, curr) => {
acc[`process.env.${curr}`] = JSON.stringify(process.env[curr]);
return acc;
}, {});
config.plugins.push(new webpack.DefinePlugin(env));
return config;
}
};
Since Next.js
uses Webpack in the background for module loading and bundling, we are simply configuring Webpack
to be able to provide the environment variables we have defined and make them available to our React components by accessing the process.env
object.
Getting started
Setting up the server
We will go ahead to setup a simple server using Next.js to wrap an Express application server. We will also load the necessary middlewares for the Express server and then we will configure Pusher using the credentials we added to our environment variables.
Create a server.js
file in the root directory of your application and add the following code snippet to setup the server:
/* server.js */
const cors = require('cors');
const uuid = require('uuid').v4;
const next = require('next');
const Pusher = require('pusher');
const logger = require('morgan');
const express = require('express');
const bodyParser = require('body-parser');
const dotenv = require('dotenv').config();
const dev = process.env.NODE_ENV !== 'production';
const port = process.env.PORT || 3000;
const app = next({ dev });
const handler = app.getRequestHandler();
// Ensure that your pusher credentials are properly set in the .env file
// Using the specified variables
const pusher = new 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,
encrypted: true
});
app.prepare()
.then(() => {
const server = express();
server.use(cors());
server.use(logger('dev'));
server.use(bodyParser.json());
server.use(bodyParser.urlencoded({ extended: true }));
server.get('*', (req, res) => {
return handler(req, res);
});
server.listen(port, err => {
if (err) throw err;
console.log(`> Ready on http://localhost:${port}`);
});
})
.catch(ex => {
console.error(ex.stack);
process.exit(1);
});
Modify npm scripts
Finally, we will modify the "scripts"
section of the package.json
file to look like the following snippet:
/* package.json */
"scripts": {
"dev": "node server.js",
"build": "next build",
"prod:server": "cross-env NODE_ENV=production node server.js",
"start": "npm-run-all -s build prod:server"
}
We have gotten all we need to start building our app components. If you run the command npm run dev
on your terminal now, it will start up the application server on port 3000 if it is available. However, nothing happens on the browser yet, because we have not built any index page component.
Building the server routes
As stated earlier for our app, we have a list of 15 people. We will randomly create a person
object for each person containing the following:
id
- UUID identifier for the personname
- the name of the personposition
- a random{ lat, lng }
position coordinate for the persononline
- the online status of the person
Make the following modifications to the server.js
file.
/* server.js */
app.prepare()
.then(() => {
// server.use() middlewares here ...
const initializePeople = ({ lat, lng }) => {
const randomInRange = num => (width = 0.01) => ((Math.random() * width * 2) + num - width);
const randomLat = randomInRange(lat);
const randomLng = randomInRange(lng);
const people = [ 'Stephanie', 'John', 'Steve', 'Anna', 'Margaret', 'Felix', 'Chris', 'Jamie', 'Rose', 'Bob', 'Vanessa', '9lad', 'Bridget', 'Sebastian', 'Richard' ];
return people.map(name => ({
name,
id: uuid(),
position: { lat: randomLat(0.0075), lng: randomLng(0.02) },
online: false
}));
};
const referencePosition = { lat: 6.4311415, lng: 3.4625833 };
let people = initializePeople(referencePosition);
server.get('/people', (req, res, next) => {
res.json({ status: 'success', people });
});
server.post('/transit/:id', (req, res, next) => {
const id = req.params.id;
const { lat, lng } = req.body;
people.forEach((person, index) => {
if (person.id === id) {
people[index] = { ...person, position: { lat, lng } };
pusher.trigger('map-geofencing', 'transit', {
person: people[index], people
});
}
});
});
server.post('/:presence/:id', (req, res, next) => {
const id = req.params.id;
const presence = req.params.presence;
if (['online', 'offline'].includes(presence)) {
people.forEach((person, index) => {
if (person.id === id) {
return people[index] = { ...person, online: presence === 'online' };
}
});
}
});
// server.get('*') is here ...
})
.catch(ex => {
console.error(ex.stack);
process.exit(1);
});
First, we create the initializePeople()
function, which loops through the list of 15 people and creates a person
object for each of them with random position coordinates based on a reference position. It then returns the collection of person
objects.
Next, we create the people
collection on the server by calling initializePeople()
with a reference position. We then go ahead to define the server routes.
We first define the GET /people
route. Whenever a client makes a GET
request to the /people
endpoint, it gets the current people
collection from the server in the returned response.
On the POST /transit/:id
route, we are fetching the ID of the person from the id
route parameter. We then fetch the person’s current position from req.body
through the help of the body-parser
middleware we added earlier.
Next, we update the person’s position on the people
collection. Then, we trigger a transit
event on the map-geofencing
Pusher channel, passing the updated person
and people
collection. This is important for the realtime behavior of the app.
Finally, we define the POST /:presence/:id
route which accepts two route parameters: presence
and id
. The presence
parameter can be either online
or offline
. We simply set the online status of the person with the given id
parameter to either true
or false
based on the value of presence
.
Building the index page
Next.js
requires that you create the page components of your app in a pages
directory. We will go ahead and create a pages
directory in our app root directory and create a new index.js
file inside it for the index page of our application.
It is considered a good practice to have a layout that can be reused across multiple pages. It gives you a form of boilerplate and saves you from unnecessary repetitions.
Before we add content to the index page, we will build a Layout
component that can be used in our app pages as a boilerplate. Go ahead and create a components
directory in your app root. Create a new Layout.js
file inside the just created components
directory with the following content:
/* components/Layout.js */
import React, { Fragment } from 'react';
import Head from 'next/head';
const Layout = props => (
<Fragment>
<Head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossOrigin="anonymous" />
<title>{props.pageTitle || 'Realtime Geofencing'}</title>
</Head>
{props.children}
</Fragment>
);
export default Layout;
Here, we try not to do so much. We are simply using the next/head
component to add meta information to the <head>
of our pages. We have also added a link to the Bootstrap CDN file to add some default styling to our app. We are also setting the page title dynamically from props and rendering the page contents using {props.children}
.
Now let’s go ahead and add content to the pages/index.js
file we created earlier:
/* pages/index.js */
import React, { Component, Fragment } from 'react';
import axios from 'axios';
import Pusher from 'pusher-js';
import Layout from '../components/Layout';
class IndexPage extends Component {
state = { id: null, people: [] }
endConnection = () => {
this.pusher.disconnect();
axios.post(`/offline/${this.state.id}`);
}
componentWillMount() {
this.pusher = new Pusher(process.env.PUSHER_APP_KEY, {
cluster: process.env.PUSHER_APP_CLUSTER,
encrypted: true
});
this.channel = this.pusher.subscribe('map-geofencing');
}
componentDidMount() {
axios.get('/people').then(({ data }) => {
const { people = [] } = data;
this.setState({ people });
});
window.onbeforeunload = this.endConnection;
}
componentWillUnmount() {
this.endConnection();
}
};
export default () => <IndexPage />
First, we initialize the state with two props namely:
-
id
- UUID used to identify the current user. This is initialized withnull
and is updated when a persona is selected. -
people
- An array of people with their respective position coordinates. This is initialized with an empty array ([]
). It will be populated after we fetch people from the server.
Next, we create the endConnection()
method which terminates the current Pusher connection and also sends an /offline
request to the server for the current user. The endConnection()
method is called before the component is unmounted or before the page is unloaded.
On the componentWillMount()
lifecycle method, we set up a Pusher connection and a channel
subscription to the map-geofencing
channel.
When the component is mounted as seen in the componentDidMount()
lifecycle method, we fetch the people collection from the server by making a GET
HTTP request using axios to the /people
endpoint. We then update the state with the people collection gotten from the response.
We will go ahead and add the render()
method to the IndexPage
component. Make the following additions to the IndexPage
component.
/* pages/index.js */
import ChoosePersona from '../components/ChoosePersona';
class IndexPage extends Component {
// previous methods here ...
personaSelected = id => {
this.setState({ id });
axios.post(`/online/${id}`);
}
render() {
const { id, people } = this.state;
const person = people.find(person => person.id === id) || {};
const peopleOffline = people.filter(person => !person.online);
return (
<Layout pageTitle="Realtime Geofencing">
<main className="container-fluid position-absolute h-100 bg-light">
{
id ? <div className="row position-absolute w-100 h-100"></div>
: <ChoosePersona count={5} people={peopleOffline} onSelected={this.personaSelected} />
}
</main>
</Layout>
);
}
};
First, we import the ChoosePersona
component, which we will create in a moment. This component enables us to activate a selected user.
In the render()
method, we conditionally render the ChoosePersona
component when there is no active user. The ChoosePersona
component will randomly display a maximum of 5 people who are offline, using the count
and people
props. Notice how we filter the people
collection to fetch those who are offline.
We also added a personaSelected()
method which is passed to the ChoosePersona
component via the onSelected
prop, and is triggered when a user persona has been chosen. The method sends an /online
request to the server to activate the selected user.
Choosing a persona
Now we will go ahead and create the ChoosePersona
component we saw in the last section. Create a new ChoosePersona.js
file inside the components
directory and add the following content:
/* components/ChoosePersona.js */
import React from 'react';
const ChoosePersona = props => {
const { people = [], count = 3, onSelected = f => f } = props;
const nameBadgeStyles = {
fontSize: '0.8rem',
height: 40,
borderRadius: 20,
cursor: 'pointer'
};
const choosePersona = id => evt => onSelected(id);
const randomPeople = count => people => {
const selected = [];
let i = 0;
count = Math.max(0, Math.min(count, people.length));
while (i < count) {
const index = Math.floor(Math.random() * people.length);
if (selected.includes(index)) continue;
++i && selected.push(index);
}
return selected.map(index => {
const { id, name } = people[index];
const className = 'd-flex align-items-center text-center text-white bg-secondary font-weight-bold py-2 px-4 mx-1 my-2';
return <span key={index} className={className} style={nameBadgeStyles} title={name} onClick={ choosePersona(id) }>{name}</span>
});
};
return (
<div className="w-100 h-100 px-3 pb-5 d-flex flex-wrap align-items-center align-content-center justify-content-center">
<span className="h3 text-dark text-center py-3 w-100 font-weight-bold">Choose your Persona</span>
{ randomPeople(count)(people) }
</div>
);
};
export default ChoosePersona;
The randomPeople()
function takes a count
as its only argument - which is the number of random personas to pick from the people
collection prop. It then uses .map()
to create an array of <span>
elements for each randomly picked persona and returns the array.
Notice the onClick
event handler for each <span>
. The choosePersona(person)
function is used as the handler. It simply invokes the function passed to the onSelected
prop with the id
of the selected persona as argument.
Finally, we render some random personas based on the count
and people
props passed to the ChoosePersona
component.
If you test the app now in your browser, with npm run dev
, you should see the following screen. Ensure that you hit Ctrl+C
(Windows) or Cmd+C
(Mac) on your command terminal before running npm run dev
to restart the server.
Building the map components
We will go ahead and build the map components. As stated earlier in this tutorial, we will be using the react-google-maps package for easy integration of the GoogleMaps API with our application.
The map component
We will start with building the Map
component. This a wrapper component for the map. Create a new Map.js
file inside the components
directory and add the following content:
/* components/Map.js */
import React, { Fragment, Component } from 'react';
import MapContainer from './MapContainer';
const API_KEY = process.env.GMAPS_API_KEY;
const MAP_URL = `https://maps.googleapis.com/maps/api/js?key=${API_KEY}&v=3.exp&libraries=geometry`;
class Map extends Component {
render() {
const containerStyles = {
height: '100%',
width: '100%',
position: 'relative'
};
return <MapContainer
googleMapURL={MAP_URL}
loadingElement={<div style={containerStyles} />}
containerElement={<div style={containerStyles} />}
mapElement={<div style={containerStyles} />}
{...this.props}
/>
}
};
export default Map;
Here we form the MAP_URL
using the API_KEY
of the GoogleMaps app we created earlier for our application. We also render the MapContainer
passing in the MAP_URL
. The MapContainer
component contains the map and other visual elements such as markers and shape regions.
Notice, how we pass the props
received from the Map
component to the MapContainer
. We will go ahead and create the MapContainer
component.
The map container
Create a new MapContainer.js
file inside the components
directory and add the following content:
/* components/MapContainer.js */
import axios from 'axios';
import React, { Fragment, Component } from 'react';
import { withGoogleMap, withScriptjs, GoogleMap } from 'react-google-maps';
import UserMarker from './UserMarker';
import PersonMarker from './PersonMarker';
class MapContainer extends Component {
withinRegion = (position, radius) => {
const to = new google.maps.LatLng(position.lat, position.lng);
const distance = google.maps.geometry.spherical.computeDistanceBetween;
return point => {
const from = new google.maps.LatLng(point.lat, point.lng);
return distance(from, to) <= radius;
}
}
render() {
const { person: { id, position }, radius, people, channel } = this.props;
return (
<GoogleMap ref={elem => this.map = elem} zoom={15} center={position}>
<Fragment>
{ people.map((person, index) => {
const props = { key: index, radius, person, channel };
const withinRegion = point => (position, radius) => this.withinRegion(position, radius)(point);
return (person.id === id)
? <UserMarker {...props} />
: <PersonMarker user={this.props.person} withinRegion={withinRegion} {...props} />
}) }
</Fragment>
</GoogleMap>
);
}
};
export default withScriptjs(withGoogleMap(MapContainer));
First, we create the withinRegion()
method that enables us determine if a point is within a defined circular region. It takes the center and radius of the region as its arguments, and returns a function. The returned function takes a point as argument and returns if the point is in the region.
In the render()
method, we render the GoogleMap
component passing the position
of the current user as the center
prop. We loop through the people
collection received by the MapComponent
and render different types of makers based on the person.
Notice that we create a ref
to the GoogleMap
component and store it in the this.map
property. This ref
will give us access to the underlying google.maps.Map
instance, which we will need later to update the map properties.
We render the UserMarker
for the currently active user and the PersonMarker
for other people. We also pass the radius
, person
and channel
props to the marker components. The channel
prop contains a reference to the current Pusher channel subscription.
For the PersonMarker
component, we pass in the currently active user to the user
prop. We also pass in an inverted version of the withinRegion()
method to the withinRegion
prop.
Finally, we export the higher-order component withScriptjs(withGoogleMap(MapContainer))
. See the react-google-maps documentation to learn more. We will go ahead and create the UserMarker
and PersonMarker
components.
The user marker
Create a new UserMarker.js
file inside the components
directory and add the following content:
/* components/UserMarker.js */
import React, { Fragment, Component } from 'react';
import { Marker, Circle } from 'react-google-maps';
class UserMarker extends Component {
constructor(props) {
super(props);
const { person: { id = null, position = null }, channel = null } = this.props;
this.id = id;
this.channel = channel;
this.state = { position };
}
componentDidMount() {
this.channel && this.channel.bind('transit', ({ person = {} }) => {
const { id, position } = person;
(id === this.id) && this.setState({ position });
});
}
render() {
const { radius } = this.props;
const { position } = this.state;
const regionOptions = { fillOpacity: 0.1, strokeWidth: 1, strokeOpacity: 0.2 };
const MARKER_SIZE = new google.maps.Size(50, 70);
const MARKER_ICON = 'https://i.imgur.com/Rhv5xQh.png';
return <Fragment>
<Marker position={position} title="You" options={{ icon: { url: MARKER_ICON, scaledSize: MARKER_SIZE } }} />
<Circle center={position} radius={radius} options={regionOptions} />
</Fragment>
}
};
export default UserMarker;
The UserMarker
component stores the position of the current active user in the position
property of the component’s state.
When the component mounts, we bind to the transit
event on the Pusher channel, and update the state with the new position
of the user. We only update the state when the current user’s position changes.
In the render()
method, we render a red marker icon for the currently active user by setting the MARKER_ICON
constant as the marker icon URL. We also render a Circle
region using the user’s current position as center
and the radius
received as prop.
The person marker
Create a new PersonMarker.js
file inside the components
directory and add the following content:
/* components/PersonMarker.js */
import React, { Component } from 'react';
import { Marker } from 'react-google-maps';
const BLACK_MARKER = 'https://i.imgur.com/8dOrls4.png?2';
const GREEN_MARKER = 'https://i.imgur.com/9v6uW8U.png';
class PersonMarker extends Component {
constructor(props) {
super(props);
const {
user: { id: userID, position: userPosition },
person: { id = null, position = null },
channel = null
} = this.props;
this.id = id;
this.userID = userID;
this.channel = channel;
this.state = { position, userPosition };
}
componentDidMount() {
this.channel && this.channel.bind('transit', ({ person = {} }) => {
const { id, position } = person;
(id === this.id) && this.setState({ position });
(id === this.userID) && this.setState({ userPosition: position });
});
}
render() {
const { position, userPosition } = this.state;
const { person: { name }, radius, withinRegion = f => f } = this.props;
const within = !!(withinRegion(position)(userPosition, radius));
const MARKER_SIZE = new google.maps.Size(25, 35);
const MARKER_ICON = within ? GREEN_MARKER : BLACK_MARKER;
return <Marker position={position} title={name} options={{ icon: { url: MARKER_ICON, scaledSize: MARKER_SIZE } }} />
}
};
export default PersonMarker;
The PersonMarker
component stores the position of the person in the position
property of the component’s state and the position of the current active user in the userPosition
property of the state.
When the component mounts, we bind to the transit
event on the Pusher channel, and update the state with the new position of the person or currently active user. We update the state’s position
when the person’s position changes, and the userPosition
when the currently active user’s position changes.
In the render()
method, we use the withinRegion()
method received as prop to check if the person is within the defined circular region of the currently active user. We then conditionally render a green marker icon if the person is within the region, otherwise, we render a black icon.
Displaying nearby friends
Now, we will create a component for displaying a list of nearby people/friends. We will display a green marker icon for people within the current user’s region and a black icon for other people.
Create a new NearbyFriends.js
file inside the components
directory and add the following content:
/* components/NearbyFriends.js */
import React, { Component, Fragment } from 'react';
const BLACK_MARKER = 'https://i.imgur.com/8dOrls4.png?2';
const GREEN_MARKER = 'https://i.imgur.com/9v6uW8U.png';
class NearbyFriends extends Component {
state = { people: [] }
updatePeople = people => this.setState({ people })
render() {
const { people } = this.state;
const { person: { name, id } } = this.props;
const nameBadgeStyles = {
fontSize: '0.8rem',
height: 40,
borderRadius: 20,
cursor: 'pointer'
};
const showPeople = (filterFn, marker) => {
return <Fragment>
{ people.filter(filterFn).map((person, index) => {
if (person.id === id) return null;
return (
<div key={index} className="d-flex border-bottom border-gray w-100 px-4 py-3 font-weight-bold text-secondary align-items-center">
<div className="pl-2" style={{ width: 30, height: 30 }}>
<img src={marker} className="img-fluid" alt="marker" />
</div>
<span className="pl-3">{person.name}</span>
</div>
);
}) }
</Fragment>
};
return id && <Fragment>
<div className="border-bottom border-gray w-100 px-2 d-flex align-items-center bg-white justify-content-between" style={{ height: 90 }}>
<span className="h4 text-dark mb-0 mx-4 font-weight-bold">Nearby Friends</span>
<span className="d-flex align-items-center text-center text-white bg-primary font-weight-bold py-2 px-4 mx-4" style={nameBadgeStyles} title={name}>{name}</span>
</div>
<div className="w-100 d-flex flex-wrap align-items-start align-content-start position-relative" style={{ height: 'calc(100% - 90px)', overflowY: 'auto' }}>
{ showPeople(person => person.within, GREEN_MARKER) }
{ showPeople(person => !person.within, BLACK_MARKER) }
</div>
</Fragment>
}
};
export default NearbyFriends;
We initialize the state with a people
property set to an empty array([]
). We then expose the updatePeople()
method which will make it possible for us to update the people
property of the component’s state.
In the render()
method, we define the showPeople()
method which will filter the people
collection based on a filterFn
and renders the filtered list of people using the given marker
. Notice in the showPeople()
function that we skip rendering the currently active user in the list.
Finally, we render the two lists of people. First, we render the list of the people within the user’s region with a green marker. Then, we render the list of the rest people with a black marker.
Keeping track of nearby friends
Now that we have our rendered list of nearby friends, we need to be able to update the list as the position of either the user or some other person changes.
Currently, our map markers are sensitive to position changes but our list is not. However, the list has an updatePeople()
method that can enable us to update the people in the list based on position changes.
We will go ahead and create a bridge between the map and the list from the parent IndexPage
component.
Completing the index page
Make the following additions to the pages/index.js
file:
/* pages/index.js */
import Map from '../components/Map';
import NearbyFriends from '../components/NearbyFriends';
class IndexPage extends Component {
regionFiltered = people => this.nearby.updatePeople(people)
render() {
const { id, people } = this.state;
const person = people.find(person => person.id === id) || {};
const peopleOffline = people.filter(person => !person.online);
return (
<Layout pageTitle="Realtime Geofencing">
<main className="container-fluid position-absolute h-100 bg-light">
{
id ? <div className="row position-absolute w-100 h-100">
<section className="col-md-9 px-0 border-right border-gray position-relative h-100">
<Map person={person} radius={1000} people={people} channel={this.channel} onRegionFiltered={this.regionFiltered} />
</section>
<section className="col-md-3 position-relative d-flex flex-wrap h-100 align-items-start align-content-between bg-white px-0">
<NearbyFriends ref={elem => this.nearby = elem} person={person} />
</section>
</div>
: <ChoosePersona count={5} people={peopleOffline} onSelected={this.personaSelected} />
}
</main>
</Layout>
);
}
}
Here, we update the render()
method to render the Map
and NearbyFriends
components. You can see that we create a ref
to the NearbyFriends
component, storing it in the this.nearby
component property.
We also add the regionFiltered()
bridge method. This method receives a people
collection as argument. It then calls the updatePeople()
method on the ref
created for the NearbyFriends
component. This makes it possible for us to update the list as we so required.
To complete the bridge, we pass the regionFiltered()
method to the Map
component via the onRegionFiltered
prop. We will go ahead and update the MapContainer
component to handle position changes.
Updating the map container
Make the following additions to the components/MapContainer.js
file:
/* components/MapContainer.js */
class MapContainer extends Component {
analyzeRegion = (position, radius) => people => {
const { onRegionFiltered = f => f } = this.props;
const withinRegion = this.withinRegion(position, radius);
const mappedPeople = people.map(person => {
const { position } = person || {};
const within = withinRegion(position);
return { ...person, within };
});
onRegionFiltered(mappedPeople);
}
componentDidMount() {
const { person: { id, position }, radius, people = [], channel = null } = this.props;
const mapContext = this.map.context['__SECRET_MAP_DO_NOT_USE_OR_YOU_WILL_BE_FIRED'];
const setMapCenter = mapContext.setCenter.bind(mapContext);
let { lat, lng } = position;
channel && channel.bind('transit', ({ person = {}, people }) => {
const { id: $id, position: $position } = person;
const isUser = id === $id;
const center = isUser ? $position : position;
isUser && setMapCenter(center);
this.analyzeRegion(center, radius)(people);
});
this.positionUpdate = setInterval(() => {
lat = lat + Math.random() * 0.001;
lng = lng + Math.random() * 0.001;
axios.post(`/transit/${id}`, { lat, lng });
}, 10000);
this.analyzeRegion(position, radius)(people);
}
componentWillUnmount() {
clearInterval(this.positionUpdate);
}
};
First, we add an analyzeRegion()
method that uses the withinRegion()
method defined earlier to modify the people
collection based on the current user’s position, setting the within
property for each person in the collection. It then calls the onRegionFiltered()
method received as prop to the component, which in turns updates the people
collection on the list as we stated earlier.
We then add the componentDidMount()
lifecycle method. When the component mounts, we get the setMapCenter
method from the underlying google.maps.Map
instance which we will need to update the center of the map.
We then bind to the transit
event on the Pusher channel, and update the center of the map to the new position of the currently active user. We also call the analyzeRegion()
method to update the people on the NearbyFriends
list.
Next, we create an interval that randomly changes the user’s position every 10 seconds and triggers a realtime position update of the user by making a server request to the /transit/:id
endpoint.
Finally, we clear the interval when the component is unmounted.
Bravo. If you made it to this point, then you have successfully built a realtime location-aware application app with geofencing updates using Next.js and Pusher.
Test the app now in your browser, with npm run dev
. Choose a persona and see how the position of the user along with the circular geofence updates in realtime. Your screen should look like the following screenshot.
Ensure that you hit Ctrl+C
(Windows) or Cmd+C
(Mac) on your command terminal before running npm run dev
to restart the server.
Conclusion
In this tutorial, we have been able to build a very simple realtime application with geofencing updates using Next.js, React, GoogleMaps API and Pusher. You can check the source code of this tutorial on GitHub.
Do check the documentation for each technology we used in this project to learn more about other ways of using them. I duly hope that this tutorial is of help to you.
15 May 2018
by Christian Nwamba