This is a demo of using an Ably data stream to place an animating marker on a map in real time. It shows usage of both Mapbox and Google maps
First configure TokenRequests
by creating an .env
file in the root of the web folder and add your Ably API key as below:
ABLY_API_KEY=yourably:apikeyhere
If you are running the app for the first time, then install the dependencies, in the web folder run:
npm install
Also you will need to make sure you build the SDK before you can run the demo app, in the root directory of this repository run:
npm install
npm run build
Then to run the web demos, in the example app folder run:
npm run start
This is an Express app which serves an index page, and /mapbox and /google routes for the Mapbox and Google maps demos respectively.
Each of these routes, maps to a HTML file in /views/
- google.html and mapbox.html respectively.
These two examples are useful for comparing and contrasting the different SDK calls required to use either mapping solution.
We've split up the files required into the following locations:
/web/views/ - HTML files
/web/public/ - client side assets
/web/public/Google - Google maps implementation
/web/public/Mapbox - Mapbox maps implementation
Each HTML file references its own JavaScript module that executes when the page loads. This example uses ES Modules to reference the other scripts using import
statements, without having to use a bundler, or Webpack, or any other build tools.
The Mapbox and Google Maps script.js
modules are very similar, and do the following:
- If the URL does not already contain an API key for the relevant map provider prompts the user using
window.prompt
to enter their own. - Instantiates the Mapbox/Google Maps SDK.
- Create an initial location to draw the world map at.
- Find an HTML element to display the map in.
- Create an instance of the respective mapping SDk.
- Implement a factory function which is responsible for creating a map marker (in a format that the active mapping SDK understands).
- Implements functions to add listeners to zoom in/out events on the given map.
- Create an instance of a class called RiderConnection, and connects to it.
- Wires up the UI.
For example, here is the Google script.js
:
(async function() {
const position = new Coordinate(0, 0); // Create start location
const mapElement = document.getElementById("map"); // Find mapping element
const map = new google.maps.Map(mapElement, { center: position, zoom: 3 }); // Initilise GMaps SDK
// Create function that RiderConnection will use to increase resolution when a user zooms in to the map
function createZoomListener(cb) {
map.addListener('zoom_changed', () => {
cb(map.zoom);
});
}
// Create `createMarker` factory function that creates Google Maps Marker
function createMarker(coordinate) {
return new GoogleMapsMarker(map, coordinate);
}
// Connect to Ably using our `RiderConnection` class
const riderConnection = new RiderConnection(createMarker, createZoomListener, map.zoom);
await riderConnection.connect();
// Wire up UI buttons
bindUi(riderConnection);
})();
and the very similar Mapbox script.js
:
(async function() {
const position = new Coordinate(0, 0);
const mapElement = "map";
const map = new mapboxgl.Map({ center: position.toGeoJson(), zoom: 15, container: mapElement, style: 'mapbox://styles/mapbox/streets-v11' });
function createZoomListener(cb) {
map.on('zoom', () => {
cb(map.getZoom());
})
};
function createMarker(coordinate) {
return new MapBoxMarker(map, coordinate);
}
const riderConnection = new RiderConnection(createMarker, createZoomListener, map.getZoom());
await riderConnection.connect();
bindUi(riderConnection);
})();
The only places that these two code samples differ, are the createMarker
factory functions (which create either a GoogleMapsMarker
or a MapBoxMarker
), the implementation of createZoomListener
, and where we call either google.maps.Map
or mapboxgl.Map
to construct the map.
The logic that deals with creating and managing map markers differs between each platform, it is provider specific, so there are two completely distinct implementations in public/Google/GoogleMapsMarker.js and public/Mapbox/MapBoxMarker.js.
These map markers act as adapters
- the rest of the code can interacts with the map markers in a map-platform-agnostic manner.
This approach keeps the display logic out of the Ably code.
Each of the PROVIDERMapMarker.js classes, implements the same set of functions:
export class FooMapsMarker {
getCurrentCoordinate() { ... }
updatePosition(targetCoordinate) { ... }
focus() { ... }
}
These functions will be called by RiderConnection
to get current marker locations, update marker positions, and focus on markers. By keeping the factory functions in the provider specific script.js
files, the RiderConnection
code doesn't need to know anything about our mapping libraries.
The RiderConnection class is the heart of the application - it connects to the Ably channel dedicated to Rider GPS coordinate updates, and processes the messages as they arrive.
An instance of this class is constructed in script.js
and handles most of the application logic.
This class has to:
- Connect to Ably using the Ably Asset Subscriber SDK.
- Processes messages from Ably.
- Create an instance of a
Vehicle
whenever a rider is detected. - Track whether the driver is online or not.
- Change the requested resolution to an appropriate level depending on how far the user has zoomed in to the map.
The constructor is defined in script.js:
export class RiderConnection {
constructor(createMapSpecificMarker, createMapSpecificZoomListener, initialZoomLevel) {
this.createMapSpecificMarker = createMapSpecificMarker;
this.createMapSpecificZoomListener = createMapSpecificZoomListener;
this.hiRes = initialZoomLevel > 14;
this.subscriber = new Subscriber({
ablyOptions: { authUrl: '/api/createTokenRequest' },
onEnhancedLocationUpdate: (message) => {
this.processMessage(message);
},
onStatusUpdate: (status) => {
this.statusUpdateCallback(status);
},
resolution: this.hiRes ? lowResolution : highResolution,
});
createMapSpecificZoomListener((zoom) => {
if (zoom > zoomThreshold && !this.hiRes) {
this.hiRes = true;
this.subscriber.sendChangeRequest(highResolution);
} else if (zoom <= zoomThreshold && this.hiRes) {
this.hiRes = false;
this.subscriber.sendChangeRequest(lowResolution);
}
});
this.shouldSnap = false;
}
This constructor takes a three parameters:
- the
createMapSpecificMarker
function - that is defined ascreateMarker
in thescript.js
file - and sets up some defaults. - the
createMapSpecificZoomListener
function - which is defined ascreateZoomListener
in thescript.js
file. - the initial zoom level of the map.
Callbacks are provided to the Ably Subscriber constructor to define behaviour for when the drivers status and location are updated.
In order to connect to Ably, a connect
function is defined inside the script.js
file:
async connect(channelId) {
// First, make sure that if we're already connected to a driver we disconnect from it
if (this.subscriber.assetConnection) {
await this.subscriber.stop();
}
// Now, use the Asset Tracking SDK to connect to the channelId provided in the call to connect
// This defaults to 'ivan', one of our test channel names.
this.subscriber.start(channelId || 'ivan');
}
As its name would suggest, processMessage
processes incoming messages.
processMessage(message) {
const locationCoordinate = Coordinate.fromMessage(message);
// The code expects that the incoming message contains a `riderId` in order to do so -`default-id` is used if that property is not found on the inbound message (for the sake of this demo).
const riderId = locationCoordinate.id ?? 'default-id';
// When a rider is first detected, a (provider specific) instance of the map marker is created, by calling the factory function provided to the constructor, and is given the coordinates from the message. Then a new instance of the `Vehicle` class is created:
if (!this.rider) {
const marker = this.createMapSpecificMarker(locationCoordinate);
this.rider = new Vehicle(riderId, true, marker);
marker.focus();
}
// The `rider.move` function on the `Vehicle` instance will handle all of the animation and movement of riders across the map UI.
this.rider.move(locationCoordinate, this.shouldSnap);
}
Next, the onStatusUpdate()
function is defined at the bottom of the Vehicle.js
class.
Finally onViewerCountUpdated()
is a function provided to allow the script.js
module to subscribe to the event raised when the Asset Tracking SDK notifies that the asset status has changed:
onStatusUpdate(callbackFunction) {
this.statusUpdateCallback = callbackFunction;
}
RiderConnection
subscribes to, and manages the state of the application. While it calls rider.move()
every time a new location is detected, it doesn't actually have any code for creating, moving or animating map markers. All of that logic lives inside the Vehicle.js
class.
Vehicle.js looks like this:
export class Vehicle {
get position() { ... }
async move(destinationCoordinate, snapToLocation = false) { ... }
async animate() { ... }
}
and is responsible for encapsulating the map marker animations. It uses turf.js to plot a course between its current position and a destination location each time move()
is called, and most of its work is done inside the animate function. When a Vehicle
is created, its constructor calls a function called animate()
.
The animate()
function uses the browser's requestAnimationFrame() API to repeatedly call itself, based on the refresh rate of your browser and your display.
async animate() {
if (this.moveBuffer.length === 0) {
window.requestAnimationFrame(() => { this.animate(); });
return;
}
const targetCoordinate = this.moveBuffer.shift();
this.marker.updatePosition(targetCoordinate);
if (this.movementsSinceLastFocused >= this.numberOfMovementsToFocusAfter) {
this.movementsSinceLastFocused = 0;
this.marker.focus();
}
window.requestAnimationFrame(() => { this.animate(); });
}
It does a few things:
- It reads data from the
this.moveBuffer
. - If there's a location in the moveBuffer, it calls
this.marker.updatePosition()
with the target location as a parameter. - If the number of calls to animate is greater than or equal to the
this.numberOfMovementsToFocusAfter
- a configuration setting that defaults to 100, it pans the map center to the current vehicle location (and then resetsthis.movementsSinceLastFocused
to zero).
The marker adapters are responsible for actually updating the position of the marker on the users screen - for example, the GoogleMapsMarker
does this:
updatePosition(targetCoordinate) {
this.marker.setPosition(targetCoordinate);
this.current = targetCoordinate;
...
}
using the Google Maps SDK
setPosition
function to move the marker, while the MapBoxMarker
does this:
updatePosition(targetCoordinate) {
this.marker.setLngLat(targetCoordinate);
this.el.setAttribute('compass-direction', targetCoordinate.compassDirection);
}
using the Mapbox SDK
setLngLat()
function to move the marker.
This animation loop runs over and over, processing any movements that are in the internal this.moveBuffer
array. If the array is empty, it does nothing.
In order to animate the marker, the this.moveBuffer
needs to be filled with coordinates to move between, this is done in the implementation of the move()
function.
First, to support the "animation on/off" setting in the UI, there is a snapToLocation
check at the start of the move function. If snapToLocation
is enabled, then this.moveBuffer
is replaced with the final destination of the marker, and the rest of the animation logic is skipped.
This will "jump" the map marker to that location as soon as the animate()
function is next called.
async move(destinationCoordinate, snapToLocation = false) {
this.movementsSinceLastFocused++;
if (snapToLocation) {
this.moveBuffer = [ destinationCoordinate ];
return;
}
If the marker needs to animate, the this.moveBuffer
is reset, and then the current starting location is retrieved from the this.marker
instance. The Google/Mapbox Marker classes will make sure that this marker is returned in a format that the animation logic expects - the start position is then stored in a variable called currentCoordinate
.
this.moveBuffer = [];
const currentCoordinate = this.marker.getCurrentCoordinate();
Next, using turf.js, a path is calculated between currentCoordinate
and the destinationCoordinate
that was passed as an argument to move()
when a message was received by the Ably channel
.
var path = turf.lineString([ currentCoordinate.toGeoJson(), destinationCoordinate.toGeoJson() ]);
var pathLength = turf.length(path, { units: 'miles' });
This line is then split into 30 steps, each one of these steps representing a single movement in the animation.
Each computed step is added to the this.moveBuffer
.
var numSteps = 30; // This is the FPS
for (let step = 0; step <= numSteps; step++) {
const curDistance = step / numSteps * pathLength;
const targetLocation = turf.along(path, curDistance, { units: "miles" });
const targetCoordinate = Coordinate.fromGeoJson(targetLocation.geometry.coordinates, destinationCoordinate.bearing);
this.moveBuffer.push(targetCoordinate);
}
}
The result is that every time the animate()
function gets called (which on most monitors will be either every 16ms or 33ms), the marker will move the next step down the line.
In order to keep this demo as close to a real world application as possible,it uses Ably tokenAuthentication
to handle the Ably API key
. This works by providing the client side Ably SDK
with a url that it can call to get a valid access token.
In RiderConnection.js
there's a line that looks like this:
this.ably = new Ably.Realtime.Promise({ authUrl: "/api/createTokenRequest" });
The url "/api/createTokenRequest" - calls a route mapped in the Express
app found in /web/server.js
const Ably = require('ably/promises');
require('dotenv').config();
const client = new Ably.Realtime(process.env.ABLY_API_KEY);
app.get("/api/createTokenRequest", async (request, response) => {
const tokenRequestData = await client.auth.createTokenRequest({
clientId: 'ably-client-side-api-calls-demo'
});
response.send(tokenRequestData);
});
This block of code reads an API key stored in a .env
file called ABLY_API_KEY
, which creates an instance of the Ably SDK on the server side, and uses it to send a token to the frontend that can be used without leaking the Ably credentials to anyone with a browser.
This is the recommended way to handle your Ably API key security in real-world applications.
You can read more about this in the docs.
Inside Coordinates.js
there is a surprisingly large amount of code which formats different JavaScript objects.
This is because each of the libraries used in this sample - Mapbox, GoogleMaps, and turf.js, along with the Ably messages - have a subtly different way of expressing "a latitude and longitude". Sometimes they use "lat", sometimes "latitude", or "long" and "lon", or even as a two value array of [lng,lat].
Coordinate.js
deals with the differences in these formats, converting between them without peppering the code with code copying and renaming properties.
There are calls to these functions throughout the code - converting coordinates and bearings from one direction to another.
export class Coordinate {
get latitude() { ... }
get longitude() { ... }
get compassDirection() { ... }
toGeoJson() { ... }
static fromGeoJson(coords, bearing = 0) { ... }
static fromLngLat(lngLatObj, bearing = 0) { ... }
static fromMessage(messageData) { ... }
static bearingToCompass(bearing) { ... }
}
Ui.js
is called from both of the script.js
files - this is the code that handles wiring up changes to the values of the animation toggle switch (which is implemented with an HTML checkbox), and handling the button press to change rider channels when a channel name is typed into the HTML text input.
In a larger application, the UI would probably be taken care of by a UI framework like React or Vue. In order to keep this demo legible and simple,the click handlers are bound by hand using getElementById
, and have some code in Ui.js
to update the "Driver is online/offline" message on the screen.
export function bindUi(riderConnectionInstance) {
var queryParams = new URLSearchParams(window.location.search);
const channelIdTextBox = document.getElementById("channelID");
const animationCheckbox = document.getElementById("animation");
const subscriberCount = document.getElementById("subscriberCount");
if (!channelIdTextBox) {
throw new Error("Where has the UI gone? Cannot continue. Can't find ChannelID");
}
animationCheckbox.addEventListener("change", (cbEvent) => {
riderConnectionInstance.shouldSnap = !cbEvent.target.checked;
});
if (queryParams.has("channel")) {
const channelId = queryParams.get("channel");
channelIdTextBox.value = channelId;
riderConnectionInstance.connect(channelId);
}
riderConnectionInstance.onStatusUpdate((status) => { updateDriverStatus(status); });
updateDriverStatus(riderConnectionInstance.driverStatus);
}
function updateDriverStatus(status) {
const driverPresent = "Driver is online";
const noDrivers = "Driver is offline";
const message = status ? driverPresent : noDrivers;
subscriberCount.innerHTML = message;
}