Building a Covid-19 Map Tracker with React and Mapbox

Michael Chen
6 min readJun 28, 2020

I know I’m a few months late to the party, but I recently started to play around with incorporating maps in my React projects and the Corona is still a very relevant topic, so I decided to build a live Covid-19 map tracker.

The tool I’m using is Mapbox, a developer platform for creating custom maps. It has a collection of map styles to choose from and almost every component is customizable.

It also incorporates very nicely with React using the React Map GL package (by Uber).

npm create-react-app corona-tracker
...
cd corona-tracker

There are only 2 npm packages I used for this project, react-map-gl and dotenv.

npm install --save react-map-gl
npm install dotenv

I created a Map.js component that contains everything map related. This is where I put most of the code for this project.

import React, { useState, useEffect } from "react";
import ReactMapGl, { Marker, Popup, FlyToInterpolator } from "react-map-gl";

ReactMapGL is going to be the map canvas, Marker for the pins on the map, Popup for showing data when a marker is clicked, and FlyToInterpolator is going to allow a smooth transition when centering a marker.

const Map = () => {

const [viewport, setViewport] = useState({
width: "100vw",
height: "80vh",
zoom: 1.25,
maxZoom: 8,
// latitude: < your latitude >,
// longitude: < your longitude >
});
return (
<ReactMapGl
{...viewport}
mapboxApiAccessToken={process.env.REACT_APP_MAPBOX_TOKEN}
onViewportChange={(viewport) => setViewport(viewport)}
mapStyle={process.env.REACT_APP_MAPBOX_MAPSTYLE}
>

</ReactMapGl>
);
};
export default Map;

I set the initial state of the viewport with a zoom of 1.25 and a maxZoom of 8. This range is perfect for globe view to city view. It is also possible to initialize it with latitude and longitude to have default coordinates.

The ReactMapGl component needs a few things to work properly. It requires the viewport state we just created, the mapboxApiAccessToken, andonViewportChange . The api access token is given to you after creating an account. onViewportChange is required if you want to be able to move around in the map, it returns the new coordinates that gets passed into setViewport(). mapStyle is optional but highly recommended if you want to personalize your map. I placed the api access token and mapstyle url in a dotenv file to keep them safe.

Next step is to fetch some data and populate the map. The api I’m fetching from is by TrackCorona, they update the numbers every 20 minutes and has 3 endpoints for countries, provinces/states, and cities.

Responselocation - Name of the country/region
latitude - Latitude of the location
longitude - Longitude of the location
confirmed - Total number of cases confirmed (including those who have recovered or died)
dead - Total number of reported deaths
recovered - Total number of patients that have recovered
updated - Latest time the data was fetched (UTC)

The response from their api looks like that. Very simple to work with.

  const [loading, setLoading] = useState(false);
const [markers, setMarkers] = useState([]);
const [countries, setCountries] = useState([]);
const [provinces, setProvinces] = useState([]);
const fetchCountries = async () => {
try {
setLoading(true);
const res = await fetch("https://www.trackcorona.live/api/countries");
const data = await res.json();
setLoading(false);
setCountries(data.data);
setMarkers(countries);
} catch (error) {
console.log(error);
}
};
const fetchProvinces = async () => {
try {
setLoading(true);
const res = await fetch("https://www.trackcorona.live/api/provinces");
const data = await res.json();
setLoading(false);
setProvinces(data.data);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
// fetch from api on app load
fetchCountries();
fetchProvinces();
}, []);
return (
<ReactMapGl
{...viewport}
mapboxApiAccessToken={process.env.REACT_APP_MAPBOX_TOKEN}
onViewportChange={(viewport) => setViewport(viewport)}
mapStyle={process.env.REACT_APP_MAPBOX_MAPSTYLE}
>
<span className="zoom">Zoom: {viewport.zoom.toFixed(2)}</span>
{loading ? <img className="loading" src={loadingSvg} alt="loading" /> : null}
{markers.map((marker) => (
<Marker
key={marker.location + marker.country_code}
latitude={marker.latitude}
longitude={marker.longitude}
>
placeholder
</Marker>
))}

</ReactMapGl>

When the component mounts, it fetches the data for countries and provinces, and setMarkers to the countries. I wanted it to show the markers for the countries first because that’s the default view for the map. I want to setMarkers(provinces) whenever the user zooms in past a certain threshold.

Next I mapped over the markers and passed them into the Marker component given by react-map-gl. The latitude and longitude attributes are necessary to plot each marker at its intended position.

Instead of having placeholder for each marker, I replaced it with a button with an image of a marker.

import mapMarker from "../assets/mapMarker.svg"...
const [selectedMarker, setSelectedMarker] = useState(null);
...
{markers.map((marker) => (
<Marker
key={marker.location + marker.country_code}
latitude={marker.latitude}
longitude={marker.longitude}
>
<button
className="marker"
onClick={() => {
setSelectedMarker(marker);
setViewport({
...viewport,
latitude: marker.latitude,
longitude: marker.longitude,
zoom: viewport.zoom < 3 ? 2.8 : 5,
transitionInterpolator: new FlyToInterpolator({ speed: 1.6 }),
transitionDuration: "auto",
});
}}
>
<img src={mapMarker} alt={`map marker for ${marker.location}`} />
</button>
{/* <img src={corona} alt="corona beer" /> */}
</Marker>
))}

When a marker is clicked, it becomes the selected marker, and sets the viewport to zoom into it. transitionInterpolator and transitionDuration provides a smooth animation to that marker, without them, we get a teleporting effect when markers are clicked. How far the viewport zooms depends on the current zoom level, if it’s between 1 and 3, it means we are still zooming in on countries, over 3 means we are zooming in on provinces and states.

The next feature I implemented was the zoom transition from countries to provinces.

useEffect(() => {
if (viewport.zoom < 3) {
setMarkers(countries);
} else {
setMarkers(provinces);
}
}, [viewport.zoom]);

Since we already fetched the provinces on component mount, we can just replace the markers for countries with provinces if the zoom is greater than 3.

The final part for this project is implementing a popup that shows the statistics for each marker location.

{selectedMarker ? (
<Popup
latitude={selectedMarker.latitude}
longitude={selectedMarker.longitude}
onClose={() => setSelectedMarker(null)}
>
<div className="popup">
<h3>{selectedMarker.location}</h3>
<p>
Confirmed: <span className="numbers">{selectedMarker.confirmed}</span>
</p>
{selectedMarker.recovered ? (
<p>
Recovered: <span className="numbers">{selectedMarker.recovered}</span>
</p>
) : null}
<p>
Dead: <span className="numbers">{selectedMarker.dead}</span>
</p>
</div>
</Popup>
) : null}

If a marker is selected, it displays a pop up with the stats of that location.

The finished component looks like this:

import React, { useState, useEffect } from "react";
import ReactMapGl, { Marker, Popup, FlyToInterpolator } from "react-map-gl";
import mapMarker from "../assets/mapMarker.svg";
import loadingSvg from "../assets/loading.svg";
const Map = () => {
// initial map configs
const [viewport, setViewport] = useState({
width: "100vw",
height: "80vh",
zoom: 1.25,
maxZoom: 8,
});
const [loading, setLoading] = useState(false);
const [markers, setMarkers] = useState([]);
const [countries, setCountries] = useState([]);
const [provinces, setProvinces] = useState([]);
const [selectedMarker, setSelectedMarker] = useState(null);
const fetchCountries = async () => {
try {
setLoading(true);
const res = await fetch("https://www.trackcorona.live/api/countries");
const data = await res.json();
setLoading(false);
setCountries(data.data);
setMarkers(countries);
} catch (error) {
console.log(error);
}
};
const fetchProvinces = async () => {
try {
setLoading(true);
const res = await fetch("https://www.trackcorona.live/api/provinces");
const data = await res.json();
setLoading(false);
setProvinces(data.data);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
// fetch from api on app load
fetchCountries();
fetchProvinces();
// escape key closes popup
const listener = (event) => {
if (event.key === "Escape") setSelectedMarker(null);
};
window.addEventListener("keydown", listener);
return () => {
window.removeEventListener("keydown", listener);
};
}, []);
// different sets of markers on zoom change
useEffect(() => {
if (viewport.zoom < 3) {
setMarkers(countries);
} else {
setMarkers(provinces);
}
}, [viewport.zoom, countries, provinces]);
// change marker size depending on zoom level
const markerSize = () => {
if (viewport.zoom < 3) {
return "small-marker";
} else {
return "med-marker";
}
};
return (
<ReactMapGl
{...viewport}
mapboxApiAccessToken={process.env.REACT_APP_MAPBOX_TOKEN}
onViewportChange={(viewport) => setViewport(viewport)}
mapStyle={process.env.REACT_APP_MAPBOX_MAPSTYLE}
>
<span className="zoom">Zoom: {viewport.zoom.toFixed(2)}</span>
{loading ? <img className="loading" src={loadingSvg} alt="loading" /> : null}
{markers.map((marker) => (
<Marker
key={marker.location + marker.country_code}
latitude={marker.latitude}
longitude={marker.longitude}
>
<button
className={markerSize()}
onClick={() => {
setSelectedMarker(marker);
setViewport({
...viewport,
latitude: marker.latitude,
longitude: marker.longitude,
zoom: viewport.zoom < 3 ? 2.8 : 5,
transitionInterpolator: new FlyToInterpolator({ speed: 1.6 }),
transitionDuration: "auto",
});
}}
>
<img src={mapMarker} alt={`map marker for ${marker.location}`} />
</button>
</Marker>
))}
{selectedMarker ? (
<Popup
latitude={selectedMarker.latitude}
longitude={selectedMarker.longitude}
onClose={() => setSelectedMarker(null)}
>
<div className="popup">
<h3>{selectedMarker.location}</h3>
<p>
Confirmed: <span className="numbers">{selectedMarker.confirmed}</span>
</p>
{selectedMarker.recovered ? (
<p>
Recovered: <span className="numbers">{selectedMarker.recovered}</span>
</p>
) : null}
<p>
Dead: <span className="numbers">{selectedMarker.dead}</span>
</p>
</div>
</Popup>
) : null}
</ReactMapGl>
);
};
export default Map;

https://github.com/michaelcheny/rona-tracker

--

--