In the current state of the world π¦ and with many of us in lockdown, I thought it would be a good idea to put down Netflix for a bit, and build a COVID map similar to Hopkins Dashboard.
Our version will be simpler but it's up to you to include more features.
This is what we are going to build β https://codesandbox.io/s/mapbox-covid19-8sni6 β. Thanks to Mapbox's ease of use this is a lot easier than you might think.
This will be a long tutorial but if you have no patience like me... here are all the links you need. You can also scroll to the bottom for an extended list of resources or click π here.
ποΈNOTE
: I will use React because it is my favourite framework/library and scss for writing css.
πLinks:
- Live Demo
- Github Repo
- CodeSandbox(using the access key from Mapbox tutorial lol - might stop working at some point)
- COVID-19 API Data
Tutorial
Let's get started with the tutorial
| You can skip to each step using this menu.
- 1. Initial Setup
- 2. Setup Mapbox
- 3. Add COVID-19 data
- 4. Scale and colorize circles
- 5. Interpolate values to the dataset [2021 Update]
- 6. Add tooltips on hover
- 7. Complete Project
1. Initial Setup
Ideally, you should clone this CodeSandbox which has everything setup, including the css and an empty map initialized.
But if you wish you can also use something like create-react-app:
# Create a new folder using create-react-app and cd into it
npx create-react-app mapbox-covid
cd mapbox-covid
# Packages to use in this tutorial
npm i node-sass mapbox-gl swr country-code-lookup
# Start a local server
npm i && npm start
Go to localhost:3000
Now you're all set with React and all the packages for this tutorial.
Next up: Clean up all the files that come by default, especially do this:
- remove everything from App.js
- remove everything from App.css
- rename App.css to App.scss to use sass
2. Setup Mapbox πΊοΈ
Get an account from https://account.mapbox.com/ and your access token will be in your account dashboard.
To initialize Mapbox you need 4 things:
- Your access token (which you just got)
- DOM container where to render the map
- A styled map to use:
- You could use Mapbox's default
mapbox://styles/mapbox/streets-v11
. - But for this tutorial we will use Le-Shine theme by the talented Nat Slaughter - he works for Apple as a map designer.
- You could use Mapbox's default
- Initial geolocation:
- You can use this tool to find your geolocation values.
- For this, let's use a very zoomed-out view of the world to show the impact of COVID-19.
This is the condensed code for App.js
after putting together π these steps.
import React, { useRef, useEffect } from 'react';
import mapboxgl from 'mapbox-gl';
import useSWR from 'swr'; // React hook to fetch the data
import lookup from 'country-code-lookup'; // npm module to get ISO Code for countries
import './App.scss';
// Mapbox css - needed to make tooltips work later in this article
import 'mapbox-gl/dist/mapbox-gl.css';
mapboxgl.accessToken = 'your-access-token';
function App() {
const mapboxElRef = useRef(null); // DOM element to render map
// Initialize our map
useEffect(() => {
// You can store the map instance with useRef too
const map = new mapboxgl.Map({
container: mapboxElRef.current,
style: 'mapbox://styles/notalemesa/ck8dqwdum09ju1ioj65e3ql3k',
center: [-98, 37], // initial geo location
zoom: 3 // initial zoom
});
// Add navigation controls to the top right of the canvas
map.addControl(new mapboxgl.NavigationControl());
// Add navigation control to center your map on your location
map.addControl(
new mapboxgl.GeolocateControl({
fitBoundsOptions: { maxZoom: 6 }
})
);
}, []);
return (
<div className="App">
<div className="mapContainer">
{/* Assigned Mapbox container */}
<div className="mapBox" ref={mapboxElRef} />
</div>
</div>
);
}
export default App;
- Next, let's add some css to
App.scss
, this will include the css for the tooltip portion of the tutorial.
/* This usually goes in the global but let's keep it here
for the sake of this tutorial */
body {
width: 100vw;
height: 100vh;
margin: 0;
padding: 0;
overflow: hidden;
}
/* Make our map take the full viewport - 100% */
#root,
.App,
.mapContainer,
.mapBox {
width: 100%;
height: 100%;
}
/* Tooltip code */
.mapboxgl-popup {
font-family: 'Baloo Thambi 2', cursive;
font-size: 10px;
padding: 0;
margin: 0;
color: #424242;
}
.mapboxgl-popup-content {
padding: 1rem;
margin: 0;
> * {
margin: 0 0 0.5rem;
padding: 0;
}
p {
border-bottom: 1px solid rgba(black, 0.2);
b {
font-size: 1.6rem;
color: #212121;
padding: 0 5px;
}
}
img {
width: 4rem;
height: 4rem;
}
}
πCheckpoint
: At this point, you should have something like this on your screen:
3. Add COVID-19 data π¨βπ»
We're going to be using this API:
Let's use this API path https://disease.sh/v3/covid-19/jhucsse which returns a list of countries or provinces with COVID-19 stats.
The response looks like this:
[{
"country": "Canada",
"province": "Ontario",
"updatedAt": "2020-03-29 23:13:52",
"stats": { "confirmed": 1355, "deaths": 21, "recovered": 0 },
"coordinates": { "latitude": "51.2538", "longitude": "-85.3232" }
},...]
We will use swr by the skilled Vercel team to fetch the data and convert it to a mapbox geojson formatted data which should look like this:
data: {
type: "FeatureCollection",
features: [{
{
type: "Feature",
geometry: {
type: "Point",
coordinates: ["-85.3232", "51.2538"]
},
// you can add anything you want to the properties object
properties: {
id: 'unique_id'
country: 'Canada',
province: 'Ontario',
cases: 1355,
deaths: 21
}
}
}, ...]
}
ποΈNOTE
: Notice how I'm adding a unique id to each point's properties object which we will use later for the tooltip functionality.
Mapbox works by combining a source and style layers.
The source supplies data to the map and the style layers are in charge of visually representing this data. In our case:
- our source is the
data
object we got in the previous step - our style layer will be a point/circle layer
ποΈNOTE
: You need to reference the source ID on the layer since they go hand in hand.
For example:
// once map load
map.once('load', function () {
// Add our source
map.addSource('points', options);
// Add our layer
map.addLayer({
source: 'points' // source id
});
});
By putting together these concepts your code should look like this by now:
function App() {
const fetcher = (url) =>
fetch(url)
.then((r) => r.json())
.then((data) =>
data.map((point, index) => ({
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [point.coordinates.longitude, point.coordinates.latitude]
},
properties: {
id: index, // unique identifier in this case the index
country: point.country,
province: point.province,
cases: point.stats.confirmed,
deaths: point.stats.deaths
}
}))
);
// Fetching our data with swr package
const { data } = useSWR('https://disease.sh/v3/covid-19/jhucsse', fetcher);
useEffect(() => {
if (data) {
const map = new mapboxgl.Map({
/* ... previous code */
});
// Call this method when the map is loaded
map.once('load', function () {
// Add our SOURCE
// with id "points"
map.addSource('points', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: data
}
});
// Add our layer
map.addLayer({
id: 'circles',
source: 'points', // this should be the id of the source
type: 'circle',
// paint properties
paint: {
'circle-opacity': 0.75,
'circle-stroke-width': 1,
'circle-radius': 4,
'circle-color': '#FFEB3B'
}
});
});
}
}, [data]);
}
πCheckpoint
: If everything went well, you should have something like this:
4. Scale and colorize the points π΄
πBut we have a problem: Every dot is equal and COVID-19 impact in the world is certainly not equal - to fix this let's increase the radius of each circle depending on the number of cases.
For this, let's use something called data-driven-styling. Here is a good tutorial.
In short, this is a way to modify the paint
properties of a layer by using source data.
It looks like this for circle-radius:
"circle-radius": [
"interpolate",
["linear"],
["get", "cases"],
1, 4,
50000, 25,
100000, 50
],
This πprobably looks like some dark magic but it's not, this piece of code is doing the following:
- I will
interpolate
the data which is just a fancy word for mapping one range (amount of cases) to another one (circle-radius). - It will happen linearly.
- We will use the
cases
property in ourdata
object to map it to the paint propertycircle-radius
.
For example:
-
1
active case = radius4
-
50000
active cases = radius25
-
100000
active cases = radius50
Thus, if for instance, we have 75000
cases mapbox will create a radius of 37.5
as a midpoint between 25 and 50.
ποΈNOTE
: You might need to change this range as the virus increases in numbers since sadly 100000 will be the norm and not the upper limit.
π [2021 Update]
This π sadly happened and is addressed on 5. Interpolate values to the dataset
For our tutorial we won't use a fully linear approach, our scale system will have some steps to better represent the data, but the interpolation between these will be linear.
This is how it looks but feel free to tweak it:
paint: {
- "circle-radius": 4,
+ "circle-radius": [
+ "interpolate",
+ ["linear"],
+ ["get", "cases"],
+ 1, 4,
+ 1000, 8,
+ 4000, 10,
+ 8000, 14,
+ 12000, 18,
+ 100000, 40
+ ],
}
ποΈNOTE
: Mapbox will properly scale the circles as you zoom in and out so they fit in the screen.
πCheckpoint
: Now, you should have something like this on your screen:
Next, let's do the same for the circle-color property.
I'm going to use a color palette from colorbrewer2 which has palettes that are made specifically for maps - this is the one I picked π link π.
paint: {
- "circle-color": "#FFEB3B",
+ "circle-color": [
+ "interpolate",
+ ["linear"],
+ ["get", "cases"],
+ 1, '#ffffb2',
+ 5000, '#fed976',
+ 10000, '#feb24c',
+ 25000, '#fd8d3c',
+ 50000, '#fc4e2a',
+ 75000, '#e31a1c',
+ 100000, '#b10026'
+ ],
}
I will also adjust the border width (circle-stroke-width
) to scale from 1 to 1.75:
paint: {
- "circle-stroke-width": 1,
+ "circle-stroke-width": [
+ "interpolate",
+ ["linear"],
+ ["get", "cases"],
+ 1, 1,
+ 100000, 1.75,
+ ],
}
πCheckpoint
: At this point, you should have this nice looking map going on your screen:
5. Interpolate values to the dataset [2021 Update]
When I made this tutorial I thought that COVID numbers will never pass 100000 cases per province or country, turns out I was sadly very mistaken.
In order to future proof our app we need to create a proportional linear scale (interpolation) in order to do this we need to find the min, max and average of the dataset.
const average = data.reduce((total, next) => total + next.properties.cases, 0) / data.length;
const min = Math.min(...data.map((item) => item.properties.cases));
const max = Math.max(...data.map((item) => item.properties.cases));
Circle Radius Update
paint: {
- "circle-radius": { /* Old scale */},
+ "circle-radius": [
+ "interpolate",
+ ["linear"],
+ ["get", "cases"],
+ 1,
+ min,
+ 1000,
+ 8,
+ average / 4,
+ 10,
+ average / 2,
+ 14,
+ average,
+ 18,
+ max,
+ 50
+ ],
}
Circle Color Update
paint: {
- "circle-color": { /* Old scale */},
+ "circle-color": [
+ "interpolate",
+ ["linear"],
+ ["get", "cases"],
+ min,
+ "#ffffb2",
+ max / 32,
+ "#fed976",
+ max / 16,
+ "#feb24c",
+ max / 8,
+ "#fd8d3c",
+ max / 4,
+ "#fc4e2a",
+ max / 2,
+ "#e31a1c",
+ max,
+ "#b10026"
+ ]
}
Circle Stroke Width Update
paint: {
- "circle-stroke-width": { /* Old scale */},
+ "circle-stroke-width": [
+ "interpolate",
+ ["linear"],
+ ["get", "cases"],
+ 1,
+ 1,
+ max,
+ 1.75
+ ],
You can play around with these values to create your own scale
6. Add tooltips on hover π
πNow we have another issue: the map doesn't tell much beyond the perceived perspective of the impact of the virus on each country, to solve this let's add country/province unique data on hover.
Let's add a mouse move and mouse leave listener to the circles
layer and let's do the following steps:
- Toggle the cursor style from pointer to default.
- Create an HTML element to insert into the tooltip, this is the data we will use:
- Country
- Province or State (if it exists)
- Cases
- Deaths
- Mortality Rate (deaths / cases)
- Flag (for this we will use
country-lookup-code
npm package in combination with this very useful repo Country flags)
- Keep track of the id of the country being hovered - this way if the points are too close together we guarantee that the tooltip still switches position.
ποΈNOTE
: If there is enough space in between your points you can use mouseenter
of mousemove
instead which only gets called when entering the layer.
// After your mapbox layer code inside the 'load' event
// Create a mapbox popup
const popup = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false
});
// Variable to hold the active country/province on hover
let lastId;
// Mouse move event
map.on('mousemove', 'circles', (e) => {
// Get the id from the properties
const id = e.features[0].properties.id;
// Only if the id are different we process the tooltip
if (id !== lastId) {
lastId = id;
// Change the pointer type on move move
map.getCanvas().style.cursor = 'pointer';
const { cases, deaths, country, province } = e.features[0].properties;
const coordinates = e.features[0].geometry.coordinates.slice();
// Get all data for the tooltip
const countryISO = lookup.byCountry(country)?.iso2 || lookup.byInternet(country)?.iso2;
const countryFlag = `https://raw.githubusercontent.com/stefangabos/world_countries/master/data/flags/64x64/${countryISO.toLowerCase()}.png`;
const provinceHTML = province !== 'null' ? `<p>Province: <b>${province}</b></p>` : '';
const mortalityRate = ((deaths / cases) * 100).toFixed(2);
const countryFlagHTML = Boolean(countryISO)
? `<img src="${countryFlag}"></img>`
: '';
const HTML = `<p>Country: <b>${country}</b></p>
${provinceHTML}
<p>Cases: <b>${cases}</b></p>
<p>Deaths: <b>${deaths}</b></p>
<p>Mortality Rate: <b>${mortalityRate}%</b></p>
${countryFlagHTML}`;
// Ensure that if the map is zoomed out such that multiple
// copies of the feature are visible, the popup appears
// over the copy being pointed to.
while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) {
coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360;
}
popup.setLngLat(coordinates).setHTML(HTML).addTo(map);
}
});
// Mouse leave event
map.on('mouseleave', 'circles', function () {
// Reset the last Id
lastId = undefined;
map.getCanvas().style.cursor = '';
popup.remove();
});
πCheckpoint
: At this point, you should be done and it should look like this πΎ:
Complete Project
Find the completed code here - CodeSandbox - feel free to insert your access token since that one might not work after a while.
Next Steps
Some ideas to take this further:
- Filtering by country.
- Filter by deaths instead of cases.
- Add a sidebar with some general information, maybe use another API.
- Make the ranges dynamic to the data, instead of hard-coding 100000 as the upper limit, you could fetch the country with the biggest amount of cases and divide by 7 and create a dynamic range.
- Save data to local storage so you don't hit the API that often - for example, you can make the local storage expire every 24 hours.
Resources / References
Leigh Halliday πΊ - YouTube Channel that has many high-quality videos, including some about Mapbox. He also deserves a lot more followers :)
Mapbox Examples - Great collection of Mapbox tutorials
Color Palettes
Color Palette Sequence for maps π
Great Color Palette π
Carto π
Mapbox Links
Gallery of Mapbox themes π
Location Helper π
Data-driven styling tutorial π
Popup on hover tutorial π
COVID-19 Links
Covid API π
Another good API π
COVID-19 awareness
And..... that's it, we're done, stay safe π· and stay home ποΈ.
Now you can go back to Netflix and binge Tiger King π
π.
Credits
Two of my talented teammates at Jam3 with whom I learned a couple of things during a project that used Mapbox.
- Bonnie Pham - bonnichiwa
- Yuri Murenko - ymurenko
Top comments (17)
Hi Alejandro, awesome post! As a lover of the open source community I felt a duty to contribute too, so I made a GraphQL API for currents cases about COVID-19. Repo link: github.com/anajuliabit/covid-graph.... Appreciate stars and contributions :)!
looks great Ana - I tho about making this tutorial with a GraphQL endpoint but it will add even more complexity to an already long post so I decided to keep it "simpler"
Hey Alejandro, really a great post <3. I recently started learning React and this was a really helpful article from the learning aspect. Check out my verssion of the website here (c19-india-map.now.sh).
Also, would love the stars in my github repo ;-) (github.com/pulakchakraborty/c19-in...)! Cheers!
Thanks for the kind words, I'm glad it was useful
Your version is awesome btw, nice color scheme and graph on the side :)
Loved this
mexico provinces not included but thanks, this is really helpfull.
yeah the API I'm using doesn't offer Mexico's provinces but you could find another API that offers that or scrap the data yourself and combine the data
Nice, thanks, I have made Nicolas Cage version:
nicolas-cage-covid-map.netlify.com/
lol, my eyes hurt from looking at that but nice job nonetheless π
Hey...great post! Made me like with react even more!
Also, a quick question...why has the live demo stopped working? It's blank..
Hey Parneet59 thank you, works for me maybe the API was updating when you checked - I have no error handling or anything since I wanted to make this simple
Thank you so much @Alenjandro. I will use this as a practice app.
my pleasure, for sure... make it better :)
awesome ππ , i'll try to add this to my existing app
covidlk.now.sh/
thanks
Thank you, Alejandro.
πππ
Great post, really thanks!
Inspired in your post, I did my version:
react-covid19-tracker.netlify.com/
π€©Looks awesome dude, I love the charts π
I think my only reco is to add a border to circles where there are not many cases, because with this mapbox theme sometimes is hard to notice