Skip to main content

Creating a web interface - Mapbox

In this tutorial, we will look at how to connect the open-sourced react-map-gl port of Mapbox project to the Tracking History API in a browser.

caution

This tutorial assumes that you are already familiar with the fundamentals of ReactJS and Redux.

Source code#

You can find and download the source code for this example here.

Setting up React#

For the sake of simplicity, we are gonna use the create-react-app utility.

Simply start by the following commands:

npx create-react-app my-app --template redux
cd my-app
npm start

We use the --template redux flag to set up a Redux store in our app, since KeplerGL depends on Redux to work properly. You should now have a React application running at http://localhost:3000.

Setting up Mapbox#

In order to get Mapbox to work, you will first need to get a Mapbox token. To get one, you will need to create an account here. We can now add react-map-gl to our application:

npm i --save react-map-gl

In our main App.js file, we can then import at the top

/src/App.js
import MapGL from "react-map-gl";

We can now use the MapGL component. Let start with cleaning up the previous default code.

/src/App.js
import React, { useState } from "react";
import MapGL from "react-map-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import "./App.css";
function App() {
const [viewport, setViewport] = useState({
latitude: 0,
longitude: -100,
zoom: 3,
bearing: 0,
pitch: 0,
});
return (
<div className="App">
<div style={{ height: "500px" }}>
<MapGL
{...viewport}
width="100%"
height="100%"
mapStyle="mapbox://styles/mapbox/outdoors-v11"
onViewportChange={setViewport}
mapboxAccessToken={your_mapbox_token}
/>
</div>
</div>
);
}
export default App;

We now have a Mapbox map instance visible in our app homepage.

Calling the Tracking History endpoint#

Fetching the data#

We can now call Spire Aviation Tracking History endpoint and get data from an aircraft. Let's start by creating a function that does that. We will need to import the redux hook useDispatch, which we will use to dispatch our success action to our aircraftReducer.

/src/App.js
...
import { useDispatch } from "react-redux";
function App() {
const dispatch = useDispatch();
...
function getHistoryData(icao, start, end) {
fetch(
`https://api.airsafe.spire.com/v2/targets/history?icao_address=${icao}&start=${start}&end=${end}`,
{
headers: {
Authorization: `Bearer your_token`,
},
}
)
.then(async (response) => {
if (response.status === 401) {
alert("Unauthorized");
}
response.text().then((r) => {
dispatch({ type: "SET_AIRCRAFT_DATA", data: parseResponse(r) });
});
})
.catch((e) => {
alert("An error occured while calling the endpoint");
});
}
...

We can now use a useEffect hook, calling the API once on component mount:

import { useEffect } from "react"
...
useEffect(() => {
getHistoryData(
"398568",
"2021-07-27T22:00:00.000Z",
"2021-07-28T22:00:00.000Z"
);
}, []);
...
info

If you feel like going the extra mile, you should directly create a form to select the ICAO address and dates you are interested in with a submission button that triggers the call to the API.

Here, we will call the API, filtering data for the ICAO address 398568, for a one day period.

Parsing the data#

The response of our query to the Tracking History needs to be parsed. It is a set of JSON objects, each separated by a new line. To parse that, we will use the split function and then loop through each line, parsing the JSON inside it:

/src/App.js
function parseResponse(response) {
const results = [];
response.split("\n").forEach((value) => {
if (value) {
try {
const { target } = JSON.parse(value);
results.push(target);
} catch (error) {
console.error(value);
}
}
});
return results;
}

Storing the data in Redux#

We can now create our Redux store where we will store the parsed results. Let's start by creating a new folder named src/reducers, with a aircraftReducer.js file in it.

/src/reducers/aircraftReducer.js
export default function aircraftReducer(state = null, action) {
switch (action.type) {
case "SET_AIRCRAFT_DATA":
return action.data;
default:
return state;
}
}

We can also update our store.js file to take this new reducer into account:

src/app/store.js
import { combineReducers } from "redux";
import { configureStore } from "@reduxjs/toolkit";
import aircraftReducer from "../reducers/aircraftReducer";
export const store = configureStore({
reducer: combineReducers({ aircraft: aircraftReducer }),
});

Plotting the data#

In order to plot the data, we first need to select it from the redux store. Let's add a useSelector hook in order to select our data.

import { useDispatch, useSelector } from "react-redux";
function App() {
const data = useSelector((state) => state.aircraft);
const dispatch = useDispatch();
...

Now, let's create a new state variable, called plot, that we will use for storing the aircraft plotted data.

We will also need to create a useEffect hook, that will listen to changes on our data state value, and generate/set the associated plot.

function App() {
const data = useSelector((state) => state.aircraft);
const dispatch = useDispatch();
const [plot, setPlot] = useState(null);
...
useEffect(() => {
if (data) {
setPlot({
type: "FeatureCollection",
features: data.map((point) => ({
type: "Feature",
properties: {
...point,
},
geometry: {
type: "Point",
coordinates: [point.longitude, point.latitude],
},
})),
});
}
}, [data]);
...

Finally, we need to add the Mapbox <Layer> that will display the GeoJSON plot:

/src/App.js
import { Layer, Source } from "react-map-gl";
...
return (
<div className="App">
<div style={{ height: "500px" }}>
<MapGL
{...viewport}
width="100%"
height="100%"
mapStyle="mapbox://styles/mapbox/outdoors-v11"
onViewportChange={setViewport}
mapboxAccessToken={your_mapbox_token}
>
{plot && (
<Source type="geojson" data={plot}>
<Layer {...pointLayer} />
</Source>
)}
</MapGL>
</div>
</div>
);

We also need to add the <Layer> settings:

const pointLayer = {
type: "circle",
id: "circle",
paint: {
"circle-radius": 2,
"circle-color": "#007cbf",
},
};

We now have a plot visible on our map! One final thing we can add is the centering of the map where the aircraft plot is upon loading. We will simply update the viewport of our map after receiving the new data.

/src/App.js
import React, { useState, useEffect } from "react";
...
function App() {
useEffect(() => {
if (data) {
setPlot({
type: "FeatureCollection",
features: data.map((point) => ({
type: "Feature",
properties: {
...point,
},
geometry: {
type: "Point",
coordinates: [point.longitude, point.latitude],
},
})),
});
setViewport({
latitude: data[0].latitude,
longitude: data[0].longitude,
zoom: 3,
bearing: 0,
pitch: 0,
});
}
}, [data]);
...

Clickable points#

Now, let's see how we can do to have our points clickable, so we can get details upon a specific point when clicking it. We will add 3 props to our <MapGL> component:

/src/App.js
const [clickedPoint, setClickedPoint] = useState(null);
...
return (
<div className="App">
<div style={{ height: "500px" }}>
<MapGL
{...viewport}
width="100%"
height="100%"
mapStyle="mapbox://styles/mapbox/outdoors-v11"
onViewportChange={setViewport}
mapboxAccessToken={your_mapbox_token}
clickRadius={1}
interactiveLayerIds={["circle"]}
onClick={(point) => {
if (point?.features[0])
setClickedPoint(point?.features[0].properties);
}}
>
{plot && (
<Source type="geojson" data={plot}>
<Layer {...pointLayer} />
</Source>
)}
</MapGL>
</div>
{clickedPoint && (
<div style={{ marginTop: "10px" }}>
<div>
<b>ICAO Address</b>: {clickedPoint.icao_address}
</div>
<div>
<b>Altitude</b>: {clickedPoint.altitude_baro} ft.
</div>
<div>
<b>Longitude</b>: {clickedPoint.longitude}
</div>
<div>
<b>Latitude</b>: {clickedPoint.latitude}
</div>
</div>
)}
</div>
);
...

Let's break down those newly added props:

  • We added interactiveLayerIds, which describes which layers will have interactivity, and thus click events. If you remember, we added a pointLayer earlier in our code, containing the property id. This id is what is used to identify the interactiveLayerIds.
  • We also added the clickRadius to set how close the mouse needs to be from a point for the click event to be dispatched.
  • We also added our onClick handler, which receives a function as a parameter. The function has one parameter, which is a click event. In that even we can look for features, from which we will pick the first one. Thanks to our setPlot effect, we have the properties set in that feature.

By adding a new state variable clickedPoint, we can have the clicked point updated and displayed in our interface.

Animate the data#

Let's now look at how we could animate our aircraft over its course. For that, we will need to add:

  • A toggle button to have the static path or the animation mode
  • The new <Marker> component used for our animation, containing the svg of our aircraft.
  • Two new state variables, mode and animationIndex. mode will be used to toggle between modes, and animationIndex will contain the current index in the data array from which we create our animation.
/src/App.js
import { Marker } from "react-map-gl";
...
const [mode, setMode] = useState("plot");
const [animationIndex, setAnimationIndex] = useState(0);
...
return (
<div className="App">
<div style={{ height: "500px" }}>
<MapGL
{...viewport}
width="100%"
height="100%"
mapStyle="mapbox://styles/mapbox/outdoors-v11"
onViewportChange={setViewport}
mapboxAccessToken={your_mapbox_token}
clickRadius={1}
// Note that we do not add the interactive layer when the layer is not present
interactiveLayerIds={mode === "plot" && plot ? ["circle"] : null}
onClick={(point) => {
if (point?.features[0])
setClickedPoint(point?.features[0].properties);
}}
>
{mode === "plot" && plot && (
<Source type="geojson" data={plot}>
<Layer {...pointLayer} />
</Source>
)}
{mode === "animation" && data && (
<Marker
key={`marker`}
latitude={data[animationIndex].latitude}
longitude={data[animationIndex].longitude}
offsetLeft={-10}
offsetTop={-10}
>
<svg
fill="none"
height={20}
style={{
transformOrigin: "center",
transform: `rotate(${data[animationIndex].heading}deg)`,
}}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
>
<path
d="M7.052 9.416c-.314-.055-.58-.11-.593-.123-.022-.022.127-.857.153-.857.025 0 .905.682.991.768.087.087.112.22.053.28-.018.017-.29-.013-.604-.068zm2.667-2.23c-.07-.029-.77-.36-1.553-.735a94.104 94.104 0 00-1.457-.69c-.018-.005-.032-.431-.032-.948v-.94l.154.12c.085.067.197.165.25.217.093.093.097.093.131.001.03-.08.055-.097.158-.112a.443.443 0 01.215.026c.083.04.093.07.094.312l.002.267.342.285.342.284.054-.08c.063-.095.256-.132.363-.07.053.032.072.106.077.306l.008.265.47.386c.26.213.495.425.524.472.057.095.074.6.022.653-.02.019-.093.01-.164-.019zM6.073 9.85c-.097 0-.157-.173-.281-.816a27.339 27.339 0 01-.161-.889c-.037-.272-.042-5.76-.006-6.09.042-.385.133-.646.275-.788.063-.063.141-.112.173-.108.178.022.332.247.416.613.055.239.084 5.859.033 6.325-.052.463-.3 1.638-.36 1.697-.03.03-.07.056-.089.056zm-.941-.439c-.368.064-.604.085-.632.057-.024-.025-.03-.093-.014-.152.027-.098.079-.145.535-.494.277-.212.511-.386.52-.386.008 0 .052.197.096.437l.081.437-.586.101zM3.93 6.481c-.843.401-1.557.734-1.586.74-.083.02-.1-.022-.111-.265-.016-.364.008-.399.568-.863l.482-.399.001-.238c.003-.275.07-.371.255-.365.092.003.138.026.183.093l.06.09.34-.278.34-.279.007-.268c.004-.147.026-.286.05-.31.05-.05.328-.056.376-.007a.24.24 0 01.048.097c.011.054.053.032.271-.143l.258-.206.003.935c.002.514 0 .936-.004.937l-1.54.73z"
fill="#0B0C10"
/>
</svg>
</Marker>
)}
</MapGL>
</div>
<button
style={{ marginTop: "10px" }}
onClick={() => {
setClickedPoint(null);
setAnimationIndex(0);
setMode(mode === "plot" ? "animation" : "plot");
}}
>
{mode === "plot" ? "Animation mode" : "Plot mode"}
</button>
{clickedPoint && (
<div style={{ marginTop: "10px" }}>
<div>
<b>ICAO Address</b>: {clickedPoint.icao_address}
</div>
<div>
<b>Altitude</b>: {clickedPoint.altitude_baro} ft.
</div>
<div>
<b>Longitude</b>: {clickedPoint.longitude}
</div>
<div>
<b>Latitude</b>: {clickedPoint.latitude}
</div>
</div>
)}
</div>
);
...

Now, we need to create the actual animation, so we will loop through our data by increasing a data index in an interval callback. For that, we will create a new custom hook named useInterval, in /src/hooks/useInterval.jsx.

/src/hooks/useInterval.jsx
import React, { useEffect, useRef } from "react";
export default function useInterval(callback, delay) {
const savedCallback = useRef();
// Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the interval.
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}

We can now use our useInterval hook to animate our aircraft!

/src/App.jsx
import useInterval from "./hooks/useInterval";
...
useInterval(
() => {
if (data[animationIndex + 1] && mode === "animation") {
setAnimationIndex((prevIndex) => prevIndex + 1);
}
},
mode === "animation" && data && data[animationIndex + 1] ? 10 : null
);
...

If we switch over to the Animation mode, the aircraft position will change every 10ms on our map.

Final result#

Next steps

We could improve that example by plotting the aircraft route in the same layer as our animation. We could also improve the design of the page, adding loading etc ...