Tutorials
advanced
JavaScript

Using geofences for location-based notifications and events

Prerequisite

Familiarity with front-end development concepts including JavaScript and API calls.

A geofence, in the most fundamental form, is a shape with an identifying property. By tracking when a customer or asset crosses a geofence boundary, developers can create trigger workflows such as push notifications, advertisements, or order preparation. A common type of geofence is an isochrone (the distance an individual can reach in a given amount of time).

By using isochrones as a geofence, it is possible to trigger time-based events as users cross into or out of a geofence. For example, in a curbside-pickup use-case, the customer places an order but the items are not picked until the customer crosses the 20-minute threshold.

In this tutorial, you will build an end-to-end pipeline that creates geofences from the Mapbox Isochrone API, processes them into tiles with the Mapbox Tiling Service, and queries the geofences with the Mapbox Tilequery API to trigger a notification. At the end of this tutorial, you will have two scripts that will enable you to automate, scale, and customize your pipeline to any use-case.

Getting started

There are a few resources you’ll need before getting started:

  • An access token from your Mapbox account. You will use an access token to associate a map with your account. Your access token is on the Account page.
  • A tilesets token from your Mapbox account. Using the Mapbox Tiling Service requires a secret (sk) token with the following scopes: tilesets:read, tilesets:write, tilesets:list
  • A text editor Use the text editor of your choice for writing HTML, CSS, and JavaScript.
  • NodeJS: The core script you will be writing is NodeJS. Follow the installation instructions for your platform.
  • Python3.6+: The Mapbox Tilesets CLI requires Python3.6. Follow the installation instructions for your platform.

Create a workspace

Once you have obtained your tokens and installed Node and Python, the next step is to create a place to store your work. From your terminal, run the following commands:

mkdir geofencing
cd geofencing
mkdir prep
touch prep/index.js
touch index.js
npm init -y

This will create a folder called geofencing, a subfolder called prep, and two index.js files. It will also initialize your project. Initialization is necessary to install the Node packages you will need.

Install dependencies

This tutorial requires a few tools to run efficiently.

From your terminal, run the following commands:

npm install -S axios bottleneck csvtojson
  • Axios: Make HTTP calls

  • Bottleneck: Control the amount of API calls you make per minute

  • csvtojson: Convert a CSV file to JSON

Finally, you will need to install the Mapbox Tilesets CLI.

pip install mapbox-tilesets

Download sample data

To simplify this tutorial, we have created a CSV of locations. Download them and place them in the prep folder.

Configure your environment

The last step before starting to build is to set your Tilesets token into your environment.

# Replace "sk.abc token" with your personal token
export MAPBOX_ACCESS_TOKEN= <sk.abc token>

Generate isochrones

To generate the isochrones, we have to ingest the CSV, clean it, submit it to the Mapbox Isochrone API, and write out a GeoJSON. All work in this phase will occur in prep/index.js.

Ingest CSV

Copy and paste the following into prep/index.js.

const csv = require("csvtojson");
const fs = require("fs");
const Bottleneck = require("bottleneck");
const os = require("os");
const axios = require("axios");
const data = "./locations.csv";

// This is your normal Mapbox token - NOT the one for tilesets
// It should look like pk.abc
const apiToken = 'YOUR_MAPBOX_ACCESS_TOKEN'

const baseUrl = "https://api.mapbox.com/isochrone/v1/mapbox/";
const token = apiToken;
const profile = "driving/";

csv()
  .fromFile(data)
  .then((data) => {
      console.log(data)
  })
  .then((features) => {});

This imports all the tools you will need, sets your Mapbox token, and prepares to make calls to the Mapbox Isochrone API. After the setup, it ingests the CSV with csv() and prints the results to the console.

Process the CSV

Now that the raw data is coming through, the next step is to take the row items and process them. Since this data will be eventually passed to the Mapbox Tiling Service, it is important to include a unique identifier. The CSV already includes one, but we will add a test for demonstration purposes.

Update your prep/index.js to the following

const csv = require("csvtojson");
const fs = require("fs");
const Bottleneck = require("bottleneck");
const os = require("os");
const axios = require("axios");
const data = "./prep/locations.csv";

const apiToken = 'YOUR_MAPBOX_ACCESS_TOKEN'

const baseUrl = "https://api.mapbox.com/isochrone/v1/mapbox/";
const token = apiToken;
const profile = "driving/";

csv()
  .fromFile(data)
  .then((data) => {
    const keyCheck = Object.keys(data[0]);
    let output;
    if (!keyCheck.includes("id")) {
      const postProc = data.map((datum, index) => {
        datum.id = index;
        return datum;
      });
      output = postProc;
    } else {
      output = data;
    }
    return output;
  })
  .then((features) => {});

This intermediate step examines the keys in each row and stores them in an array. It also sets up a dummy variable to store the post-processed data. If keyCheck does not include an id field, it iterates over every row and adds id equal to the index of the row. This ensures that the data sent to the Mapbox Isochrone API always has an ID.

Create isochrones

The final step is to take the post-processed data and pass it to the Mapbox Isochrone API. To make this simpler to read, you are going to create two helper functions that will allow you to write a single line of code in the second then of your CSV processing.

The first helper function is called isochrone. It will take in data, use it to create Mapbox Isochrone API-compliant URLs, send requests, and process the results.

function isochrone(data) {
  const promiseIsochrones = data.map((place, i) => {
    let coordinates = [place.lng, place.lat];
    let id = place.id;
    let origin = coordinates.join(",");
    let tick = i;
    let request = `${baseUrl}${profile}${origin}?contours_minutes=${minutes}&polygons=true&access_token=${token}`;
  });
}

This function takes in data, iterates over it, and begins to create the variables you need to make your API calls. Before you can run this code, there are two major issues to address.

First, we need to be able to write this data somewhere. The data returned from the Mapbox Isochrone API contains multiple detailed features (one for every ring you specify), so we also want to be cautious about not overloading your machine. To resolve this, we are going to create a writable stream.

Second, remember that Mapbox sets rate limits for all its APIs. If you were to try and create isochrones with native JavaScript iterators, you would likely get rate limited. The function needs to protect you from that scenario.

To resolve the first challenge, create a Writable Stream. This will allow you to write out individual features as they come in from the Mapbox Isochrone API.

function isochrone(data) {
  const writeStream = fs.createWriteStream("./prep/isochrones.geojson");
  ...

To resolve the second challenge, combine writeStream with a limiter function. This will keep your data flowing without subjecting you to rate limiting. Your final function will look like this.

function isochrone(data) {
  const writeStream = fs.createWriteStream("./prep/isochrones.geojson");
  const promiseIsochrones = data.map((place, i) => {
    let coordinates = [place.lng, place.lat];
    let id = place.id;
    let origin = coordinates.join(",");
    let tick = i;
    let request = `${baseUrl}${profile}${origin}?contours_minutes=${minutes}&polygons=true&access_token=${token}`;

    return limiter
      .schedule(() => axios.get(request))
      .then((res) => {
        console.log("------getting response-----");
        console.log(`${tick + 1} out of ${data.length}`);
        if (res.status === 200) {
          const json = res.data;
          if (json.features.length > 0) {
            json.features.forEach((feature) => {
              delete feature.properties.fillOpacity;
              delete feature.properties.fill;
              delete feature.properties["fill-opacity"];
              delete feature.properties.fillColor;
              feature.properties.id = id;
              if (feature.properties.contour === 3) {
                feature.properties.index = 2;
              } else {
                feature.properties.index = 1;
              }
              writeStream.write(JSON.stringify(feature) + os.EOL);
            });
          }
        }
      })
      .catch((error) => {
        console.log("-----Isochrone error:-----");
        console.log(error.message);
      });
  });
  writeStream.on("finish", () => {
    console.log("Wrote all data to file");
  });

  Promise.all(promiseIsochrones)
    .then(() => {
      writeStream.end();
    })
    .then(() => {
      console.log("done");
    })
    .catch((error) => {
      console.log(error);
    });
}

The following is a description of the isochrone function:

  • Data comes in. When this happens create a Writable Stream.
  • For every item, build a URL to call the Mapbox Isochrone API.
  • For every URL call, take the result and do the following:

    • Print out some pretty tracking information.
    • Check if the HTTP status was 200.
    • Remove extra properties.
    • Add an ID property.
    • Add an index, based on ring size (larger rings should have smaller index).
    • Write out the feature.
  • Wrap the process in a Limiter to spread API calls out.
  • Send all the API requests (Promise.all).
  • End the stream.

The final step is to write a small helper function, init, to add to your csv processing.

function init(data) {
  isochrone(data);
}

Take this function and paste it into the second then of your csv processor.

csv()
  .fromFile(data)
  .then((data) => {
    const keyCheck = Object.keys(data[0]);
    let output;
    if (!keyCheck.includes("id")) {
      const postProc = data.map((datum, index) => {
        datum.id = index;
        return datum;
      });
      output = postProc;
    } else {
      output = data;
    }
    return output;
  })
  .then((features) => {
    init(features);
  });

At the end of this process, you will have a file called isochrones.geojson in the prep folder

Tile the isochrones

Now that the isochrones have been created, the next step is to tile them. For this, you will use the Mapbox Tilesets CLI, which interacts with the Mapbox Tiling Service.

Create a source

The first step is create a data source that is going to be processed by the Mapbox Tiling Service. The file isochrones.geojson is already in the correct format (line-delimited GeoJSON). To add the source, run the following command:

tilesets add-source username sourceid
  • username is your Mapbox username
  • sourceid is whatever you would like to call the dataset (e.g. isochrones)

This will upload the data to a staging location in preparation for tiling.

Create a recipe

To create a tileset, the Mapbox Tiling Service needs to know how to process your tiles. For that, it needs a tileset recipe. Create a file called recipe.json in the prep folder and paste in the following:

{
  "version": 1,
  "layers": {
    "geofences": {
      "source": "mapbox://tileset-source/username/sourceid",
      "minzoom": 7,
      "maxzoom": 12,
      "features": {
        "simplification": [ "match", [ "zoom" ], 10, 0, 4 ]
      },
      "tiles": {
        "order": "index"
      }
    }
  }
}

This recipe tells the Mapbox Tiling Service to create a layer called geofences from the source username/sourceid. It should have a minzoom of 7 and a maxzoom of 12. Simplify the data, but by zoom 10, the simplification factor should be 0. As the tiles are written, order them based on the properties.index.

The simplification and order settings are important. They make sure you are able to query full-detail isochrones, and that they always stack largest to smallest. This is helpful for easier visualization and consistent Tilequery responses. You can learn more about recipes in the Tilesets recipe reference.

Create and publish a tileset

Run the following commands

tilesets create username.tilesetid --recipe ./prep/recipe.json --name "Geofences"
tilesets publish username.tilesetid

This will begin the tiling process. You can check the status by running tilesets status username.tilesetid. When it returns success, the data is ready to be queried.

Query the isochrones

Now that the isochrones are stored as a tileset, they can be queried with coordinates to determine if they are in or out of the polygon. This process is handled by the Mapbox Tilequery API. The goal of this final step is to take a location, identify the geofence, identify the smallest (nearest) contour, and trigger a notification (e.g. logging to the console). This script (index.js) should be placed in the root of your geofencing folder.

const axios = require("axios");
let state = {
    "orderID":1
}
const orderID = state.orderID;
const keys = Object.keys(state);
const mapboxToken = 'YOUR_MAPBOX_ACCESS_TOKEN'
const geofenceID = 'YOUR TILESET ID'
const lng = -94.72274;
const lat = 38.95396;
const origin = [lng, lat].join(",");

This first step is to set up the geofencing call. The state object is a mock for some kind of database that tracks whether a user has ever tripped a geofence. The reasoning for this is to make sure that you do not send a notification unless the user crosses a geofence for the first time or crosses into a new geofence.

The rest of the variables are dummy coordinates that will return a geofence from the Mapbox Tilequery API (based on the isochrones generated before).

The next step is to create and invoke a geofencing function. Add this below your previous code.

const geofence = async (id,origin,token) => {
  const url = `https://api.mapbox.com/v4/${geofenceID}/tilequery/${origin}.json?access_token=${mapboxToken}`;
  const geofence = await axios(url);
  const features = geofence.data.features;
  console.log(features);
  const contours = features.map((feature) => {
    return feature.properties.contour;
  });
  console.log(contours);
  const nearestContour = Math.min(...contours);

  if (!keys.includes("threshold") || data.Item.threshold > nearestContour) {
    const windowStatus = nearestContour === 20 ? "Inbound" : "Delivery";
    const message = `Order ${orderID} is in the ${windowStatus} window.`;
    console.log(message);
    state.orderID = orderID;
    state.threshold = nearestContour;
    console.log(state);
  }
};

geofence(geofenceID,origin,mapboxToken)

This function takes in the tileset ID, coordinates, and Mapbox token and submits it to the Mapbox Tilequery API. If the coordinates are inside any geofence, it will return a feature and log them out. The next step is to return all the contours for a given point, since the rings are concentric. It uses Math.min(...contours) to find the smallest ring the point is in. Finally, it compares this value to the state object. If it has never entered a geofence or the geofence is new, then send a notification and update the state object.

Finished product

This tutorial guided you through the process of converting CSV data into isochrones, uploading them as a tileset, and querying them to generate a geofence-based notification.

As a next step, we recommend trying this out on your own data. To simplify this process, we've created a small automation script that includes all the code written here today.

Download Finished Code

Next steps

You can learn more about the individual APIs by reading their documentation.

Was this page helpful?