title | datePublished | cuid | slug | cover | tags |
---|---|---|---|---|---|
How to Build a Map with React |
Mon Oct 17 2022 23:33:27 GMT+0000 (Coordinated Universal Time) |
cl9dex4t6000609mbf30c3cki |
how-to-build-a-map-with-react |
javascript, reactjs, beginners |
Objectives π§
we'll be building a Restaurant Locator using **Leaflet **to display the different locations on a map.
How?
- We'll be using a
create-react-app
starter template - The super-popular mapping library called Leaflet (say goodbye to
iframes
π!) - We'll be saving map data in a format called GeoJSON format
Mapping applications are in essence like a JAMstack app, they have:
- JavaScript (for running the mamping library)
- API (accessing the map API for imagery)
- Markup (the final output of our app will be HTML)
In this course, we'll be a building restaurant locator πͺ complete with popups, delivery radius and the ability to add a marker for your current location.
π€ Curious about JAMStack, learn more about JAMStack here.
we'll add a map to our app using Mapbox πͺ
π€ Mapbox documentation
We will also add our first map marker (the little pin that you can find in Google maps, for example)
π€ Mapping is hard. If you're interested in maps and want to know more about the struggles of representing Earth in a 2D map, check out the 6-min video Why all world maps are wrong by Vox. Super-quick summary of the struggle: The surface of a sphere cannot be represented as a plane without some form of distortion.
π€ or if you are a West Wing fan (guilty!) You can watch this Why are we changing maps? clip instead.
This consists of two exercises:
- adding the two dependencies to our project
- adding a map to the project
The two main components of a React Leaflet map will be:
- A
<Map/>
component - A
<TileLayer />
component
We'll do most of our work in the following two files:
App.js
App.css
π€ Leaflet is the leading open-source JavaScript library for mobile-friendly interactive maps (weighing just about 38 KB of JS)
Run:
npm install leaflet react-leaflet # npm
OR
yarn add leaflet react-leaflet # Yarn
to add the two dependencies to our project
π Note: if you are coding along you should be installing the two dependencies in: 02 - Adding Your First React Leaflet Map to a New React Application
directory
π Note: React-Leaflet provides an abstraction of Leaflet as React components (For example: Map
, Marker
, Popup
, TileLayer
). In other words, even though we'll be using React, you'll still need the base Leaflet library to go along with React-Leaflet.
Time to write some code!
Inside App.js
we'll be importing the two components we mentioned earlier!
import {Map, TileLayer} from 'react-leaflet'
Replace the h1
with the <Map>
component. We'll add some props next (the coordinates and the zoom level).
Remember, the map won't display until we add a <TileLayer>
component, which is responsible for all the map imagery.
Your code should look similar to this:
// coordinates for Washington city, but you can add your own
<Map center={[38.907132, -77.036546]} zoom={12}>
<TileLayer
// we'll be using OpenStreetMap (we'll change this in the later lessons)
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
// don't forget to attribute them!
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
/>
</Map>
π€ You can read more about Zoom levels here
π Note that the <Map>
component creates its own <div>
container for the map, it does not get attached to an existing node.
At this point the map loads - but it looks broken π’. Let's fix that!
Start by importing the Leaflet CSS:
import "leaflet/dist/leaflet.css";
Last thing is giving the map wrapepr div
a height and a width (like with img
elements.)
.leaflet-container {
width: 100%;
height: 100%;
}
Remove the extra padding from the main
element to give the map more space to shine!
main {
padding: 0;
}
Time to customize our map with Mapbox Studio πͺ!
To do that, we'll need an API Key.
To generate a custom map, you'll need the following 3 things:
- A map style (from Mapbox Studio)
- The ID of that map style
- API Key (saved in an
env
variable is best)
π€ Did you know that while Mapbox.com is a commercial platform, most of their projects are open source? In Mapbox you are essentially paying for data hosting, servers and API access.
π€ List of Mapbox' Open Source Projects
Create a free Mapbox account.
π€ Mapbox is a type of GIS (Geographic Information System)
Once logged in, go to studio.mapbox.com (you can also select Studio from the dropdown menu - click on your Account icon).
Click New Style and select your preferred map options (colors, styles).
In Mapbox account dashboard (account.mapbox.com) click Create a Token.
Give it a name and leave the checked options as they are.
REACT_APP_MAPBOX_API_KEY = "[API Key]";
REACT_APP_MAPBOX_USERID = "[Mapbox User ID]";
REACT_APP_MAPBOX_STYLEID = "[Mapbox Map Style ID]";
The Mapbox GET
request requires a couple of variables:
https://api.mapbox.com/styles/v1/{username}/{style_id}/tiles/{tilesize}/{z}/{x}/{y}{@2x}
Let's swap those placeholders with our information:
- Replace
{username}
with your Mapbox account name (make sure to remove the curly braces too!) - Replace
{style_id}
with your custom map id (from Mapbox Studio) - Replace
{tilesize}
with 256. - Remove the curly brackets around
{@2x}
- Finally, append your access_token which you'll source from you .env file:
?access_token=${process.env.REACT_APP_MAPBOX_API_KEY}
Copy the URL endpoint and replace the url
prop inside the component(in App.js
)
Add Mapbox to the attribution:
attribution =
'Map data © <a href="https://www.openstreetmap.org/">OpenStreetMap</a> contributors, <a href="https://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA</a>, Imagery Β© <a href="https://www.mapbox.com/">Mapbox</a>';
π Restart your server (with yarn develop
) and marvel at your customized map!
Move the username
and style_id
to .env.shared
file as well.
The final url endpoint should like this:
{https://api.mapbox.com/styles/v1/${process.env.REACT_APP_MAPBOX_USERID}/${process.env.REACT_APP_MAPBOX_STYLEID}/tiles/256/{z}/{x}/{y}@2x?access_token=${process.env.REACT_APP_MAPBOX_API_KEY}
}
π To make the endpoint a little bit easier to read, you can extract the variables like this:
const MAPBOX_API_KEY = process.env.REACT_APP_MAPBOX_API_KEY;
const MAPBOX_USERID = process.env.REACT_APP_MAPBOX_USERID;
const MAPBOX_STYLEID = process.env.REACT_APP_MAPBOX_STYLEID;
Then your url
endpoint shoud look like this:
url={`https://api.mapbox.com/styles/v1/${MAPBOX_USERID}/${MAPBOX_STYLEID}/tiles/256/{z}/{x}/{y}@2x?access_token=${MAPBOX_API_KEY}`}
Time to get creative! Create a new style in Mapbox studio.
Replace the style_id
in the .env.shared
file, restart the server and test out the new map.
Time to add our first location to the map! For that, we'll need latitude and longitudeof that location.
π€ A quick refresher, what are latitude and longitude?(and is it possible to spell those two words correctly?π )
Then we'll add the marker - that's the pin that marks the location on the map.
We'll also add a popup with more information.
π Both <Marker>
and <Popup>
components conveniently ship with react-leaflet library.
To get the coordinates of a specific location, find the location on Google maps, right-click, select What's here and copy the coordinates.
For example: 38.891652, -74.026070
First, import the two components to our App.js
.
Our react-leaflet imports should like this:
import { Map, TileLayer, Marker, Popup } from "react-leaflet";
After the TileLayer component, but still inside the Map component, add the Marker with the position prop and the coordinates that you copied in the previous lesson.
<Marker position={[38.888369, -77.019900]} />
Let's fix the data:image/p
console error from the last video, with a workaround.
Import useEffect
React hook:
import React, {useEffect } from "react";
Add the following (before the return
statement):
useEffect(() => {
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: require("leaflet/dist/images/marker-icon-2x.png"),
iconUrl: require("leaflet/dist/images/marker-icon.png"),
shadowUrl: require("leaflet/dist/images/marker-shadow.png"),
});
}, []);
This is a code snippet copied from a GitHub issue - you're not supposed to just come up with the solution yourself.
Since we are using Leaflet here directly (the L from above), we'll have to import it to our file as well:
import L from 'leaflet';
The marker should now be there!
Ok, so the marker now neatly shows on the map, but what does the marker stand for?
We've already imported the Popup component in one of the previous videos. Nest the Popup component inside the Marker, add some descriptive text and voila!
<Marker position={[38.888369, -77.0199]}>
<Popup>Smithsonian National Air and Space Museum</Popup>
</Marker>
π Click on the marker to test that the popup works.
Practice time!
Add a second location by repeating the steps from before (Google maps -> What's here -> copy coordinates).
Then add another set of Marker + Popup components.
π Start by copying our first duo, then replaced the required information (mainly the coordinates, and the popup text)
In this we'll cover:
- managing leaflet state with React hooks
- What is GeoJSON?
- how to add more data to our popups!
Time for some React Hooks!
We'll be using useRef
to access the Leaflet API directly.
We'll fire up the ref
with useEffect
- timing it after the component was rendered!
We'll also be creating a new marker instance!
Then a quick debugging session demonstrating how the state can get out of sync (between Leaflet and the app).
Import useRef
along with your other React imports.
Define it as:
const mapRef = useRef();
And then apply the ref prop to the Map component:
ref={mapRef}
Sweet!
We want to access our Map component via the ref
prop with useEffect
.
useEffect(() => {
console.log(mapRef.current);
}, [mapRef]);
Test that it works!
π Note: this will be the second useEffect
in our App.js.
Let's do some destructuring and take what we need from our ref
.
const { current = {} } = mapRef;
const { leafletElement: map } = current;
We also want to exit (return
) if a map doesn't exist.
if ( !map ) return;
Now for the tricky bit!
Create a new marker instance and copy the existing coordinates (the one from the existing Marker component).
For example: const marker = L.marker([38.888369, -77.019900])
Add this marker to the map:
marker.addTo(map);
And let's not forget the popup:
marker.bindPopup("Smithsonian National Air and Space Museum");
Now let's comment on the <Marker />
component and initialize it inside the useEffect
hook.
The final useEffect
hook should look like this:
useEffect(() => {
const { current = {} } = mapRef;
const { leafletElement: map } = current;
if (!map) return;
map.eachLayer((layer = {}) => {
const { options } = layer;
const { name } = options;
if (name !== "Mapbox") {
map.removeLayer(layer);
}
});
const marker = L.marker([38.888369, -77.0199]);
marker.bindPopup("Smithsonian National Air and Space Museum");
marker.addTo(map);
}, [mapRef]);
π We can now also remove the Marker and Popup Leaflet imports since we won't be using them.
A quick reminder to comment out (or delete) the initial Marker/Popup combo:
<Marker position={[38.888369, -77.0199]}>
<Popup>Smithsonian National Air and Space Museum</Popup>
</Marker>
π Instead, we'll be using the ones we created programmatically in the previous video.
Let's reinforce the process of creating a marker + popup programmatically.
π You can have as many markers on a map, but make sure you save them under different variable names.
You'll need to do three things:
- Initiate a new
L.marker
- bind a new popup
- add both to the map
Like so:
const markerExample2 = L.marker([38.123123, -77.123123]);
marker.bindPopup("This is my super cool marker");
marker.addTo(map);
Instead of adding coordinates manually, we want to load them using a GeoJSON file.
What is GeoJSON? It's a JSON document with a specific structure.
This is an example GeoJSON object:
var geojsonFeature = {
type: "Feature",
properties: {
name: "Coors Field",
amenity: "Baseball Stadium",
popupContent: "This is where the Rockies play!",
},
geometry: {
type: "Point",
coordinates: [-104.99404, 39.75621],
},
};
We'll be using GeoJSON.io to generate aGeoJSON document.
π€ You can read more about GeoJSON here
GeoJSON is a JSON document intended specifically for handling map data.
π€ Specifically: "GeoJSON supports the following geometry types: Point, LineString, Polygon, MultiPoint, MultiLineString, and MultiPolygon. Geometric objects with additional properties are Feature objects. Sets of features are contained by FeatureCollection objects."
π Note GeoJSON is supposed to be an object, and not an array of objects.
π€ A handy GeoJSON linter
You can generate a GeoJSON object on geojson.io by clicking on the marker icon and placing it on the map. This will create a new FeatureCollection
, its features, including the coordinates and other data.
For example:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {},
"geometry": {
"type": "Point",
"coordinates": [
-93.2330274581909,
32.69994010385839
]
}
}
]
}
π If we add another marker, we'll be ading another feature to the FeatureCollection.
Instead of placing a marker on the map, we can also create a GeoJSON object by inputting the coordinates manually.
π Some mapping interfaces put longitude first and latitude last, others swap the two - make sure to learn which order is correct!
Our Launch app is still rather dumb, let's fix that by loading GeoJSON data, instead of inputting the coordinates manually.
In the src
directory, create a new data folder. Inside create a locations.json
file.
Copy the data from geojson.io to the locations
file and then import the locations
file to App.js
.
Like so:
import locations from './data/locations.json';
Clean-up time! π§Ή
Remove the three lines defining and adding the marker data (in App.js
).
Replace them with GeoJson locations data - which the Leaflet library (L
) will intuitively know how to handle.
const geoJson = new L.GeoJSON(locations);
But we still need to add it to our map:
geoJson.addTo(map);
π Note: we'll be adding the popup data in one of the following videos.
For extra practice, let's add another location to the GeoJSON object.
Remember, there are two ways of doing it:
- search for a location on GeoJSON.io and drag a marker to the location (to get all the data)
- copy an existing
feature
and replace the latitude and longitude
In this lesson we'll learn:
- how to add custom data to our GeoJSON object
- how to add popups to our GeoJSON markers
- how to add our GeoJSON data to the popups
We will also do some CSS styling to make the map feel truly ours! π
Let's add some custom properties to our GeoJSON object.
Add a new properties
property and store the custom values in an object, like so:
"properties": {
"name": "DC Pizza",
"delivery": true,
"phone": "(202) 331-1800",
"website": "http://www.dcpizzaonline.com/",
"tags": [
"Pizza",
"Wings",
"Sandwiches",
"Salads"
]
}
π Make sure to add the same properties object (with different values. of course) to all the locations.
Time to add popups to our locations.
We'll be looping through the features (locations) in our object with a onEachFeature
function.
As a first step, let's try to console.log
the name of each restaurant:
const geoJson = new L.GeoJSON(locations, {
onEachFeature: (feature = {}, layer) => {
const { properties = {} } = feature;
const { name } = properties;
console.log("name: ", name);
},
});
Once that's working, we can add it the to the popup, by adding the following three lines to the geoJSON function:
const popup = L.popup();
popup.setContent(name);
layer.bindPopup(popup);
Let's destructure the rest of the information from our data object:
const { name, delivery, tags, phone, website } = properties;
In the setContent
function we are able to display HTML (and not just a simple variable, like name
that we did previously).
We need to wrap the HTML inside a template literal (the backticks ``).
Let's add the following html:
const html =
`<div">
<h3>${name}</h3>
<ul>
<li>
${tags.join(", ")}
</li>
<li>
<strong>Delivery:</strong> ${delivery ? "Yes" : "No"}
</li>
<li>
<strong>Phone:</strong> ${phone}
</li>
<li>
<strong>Website:</strong> <a href="${website}">${website}</a>
</li>
</ul>
</div>
`;
Our popups can be styled just like any other HTML element. Let's start by adding classes and then add styling to the App.css
file.
Below is the example styling by Colby.
.restaurant-popup h3 {
font-size: 1.4em;
margin-bottom: 0.4em;
}
.restaurant-popup ul {
padding: 0;
list-style: none;
margin: 0;
}
.restaurant-popup li {
margin-bottom: 0.4em;
}
.restaurant-popup li:last-child {
margin-bottom: 0;
}
We can add some additional styling by overriding the default styling from the default leaflet-popup
class.
The sky is the limit! π
Let's practice our GeoJSON wrangling by adding a new attribute of vegan: true
for vegan-friendly locations.
First, let's destructure the vegan
attribute from the properties
object.
Then inside the HTML popup, we can add another li
with:
<li>
<strong>Vegan Friendly:</strong> ${vegan ? 'Yes' : 'No'}
</li>
In this group, we'll:
- add restaurant delivery zones (with a delivery radius)
- replace the default markers (the location pins) with custom images
- use Geolocation API to find locations closeby
To indicate a delivery radius, we'll need to add it (as a number in meters) to our ever-growing GeoJSON object.
We'll also add the shading on the radius to make it more prominent. We'll make sure to only show the radius of the location that we are currently hovering over - otherwise, chaos!
We'll also learn how to add some custom styling to the delivery radius.
Add a deliveryRadius
property to the GeoJSON object.
π It makes sense to add deliveryRadius
to every object that offers delivery
.
Destructure it the properties
attribute.
Test that everything works with a trusty console.log
:
if (deliveryRadius) {
console.log(deliveryRadius);
}
Let's add a circle to signify our delivery radius (but only for locations that support delivery).
First, we'll need to destructure geometry
from feature as well as coordinates
from geometry.
Like so:
const { properties = {}, geometry = {} } = feature;
const { coordinates } = geometry;
Add the circle to the map with the following code:
let deliveryZoneCircle;
if (deliveryRadius) {
// π make sure to add reverse since the coordinates in GeoJSOn are stored backwards to what Leaflet expects
deliveryZoneCircle = L.circle(coordinates.reverse(), {
radius: deliveryRadius,
});
// Don't forget to add the circle to the map.
deliveryZoneCircle.addTo(map);
}
The overlapping delivery circles are a bit confusing - imagine if we added more locations to the map!
Let's only display the border radius (not to be confused with CSS border-radius
π
) when hovering over the marker (on mouseover
).
Similarly, let's Remove the radius when we're done hovering (on mouseout
)
layer.on("mouseover", () => {
if (deliveryZoneCircle) {
deliveryZoneCircle.addTo(map);
}
});
layer.on("mouseout", () => {
if (deliveryZoneCircle) {
deliveryZoneCircle.removeFrom(map);
}
});
We can change the color of the delivery radius circle by adding the color
option to the radius:
deliveryZoneCircle = L.circle(coordinates.reverse(), {
radius: deliveryRadius,
color: "red",
});
Time to spice up our GeoJSON configuration!
We'll be:
- adding markers programmatically (will I ever learn how to spell this word correctly?! π )
- swapping the default marker images for our own
- we'll need to add a custom shadow as well (to get that nice 3D effect)
We'll be replacing the default marker with utensils-marker
found in the shared assets folder.
First, let's add a new property to our GeoJSON object (inside App.js
) called pointToLayer
.
Initialize the markers with:
pointToLayer: (feature, latlng) => {
return L.marker(latlng);
},
We'll add a custom marker by adding an options object to pointToLayer
.
First import the custom marker image at the top of the file:
import utensilsIcon from './assets/shared/utensils-marker.png';
Now let's add it to the options object:
pointToLayer: (feature, latlng) => {
return L.marker(latlng, {
icon: new L.Icon({
iconUrl: utensilsIcon,
// size in pixels
iconSize: [26, 26],
// readjust the popup to be centered around the icon π₯Ό
popupAnchor: [0, -15],
})
});
},
π€ You can read more about Leaflet's custom icons here
Let's add that fancy marker shadow!
Import the Leaflet shadow from:
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
Then add the two shadow configurations to the icon options object:
shadowUrl: markerShadow,
// if you're using a different icon, you'll probably have to play around with the values a bit to get it right
shadowAnchor: [13, 28],
Time to replace our custom icon with a Leaflet's L.divIcon
return L.marker(latlng, {
icon: L.divIcon(),
});
We can add the preferred HTML structure straight to the icon's options object:
return L.marker(latlng, {
icon: L.divIcon({
html: "<div class="my-class">HIHIHI</div>",
}),
});
And styling as well! (Apply styling to the .my-class
CSS class)
π Check out Leaflet's map examples for inspiration
Time to improve the user experience! π¦
From now on, we want users to add new markers to the map with a click of a button.
To make things smoother, we want to be able to check the user's location - so we can display results relevant to their geographic area.
We also want a new marker when the location is found.
The browsers won't be able to pinpoint the exact location, so we'll adjust our calculation to accept a margin of error.
Lastly, to avoid any performance issues down the line, we want to clean up all event handlers that have already been used.
In the starter code for lesson 10 you'll find a new button for Setting the location to the National Geographic Museum. Let's copy it!
Then add the marker, using the code we're already familiar with:
const marker = L.marker(locationNationalGeographic);
marker.addTo(map);
Once the marker has been added, we want to center it on the screen. Add the following code to achieve that:
map.setView(locationNationalGeographic);
We'll add a new button for Finding the user's location with a new onClick
function.
<button onClick={handleOnFindLocation}>Find My Location</button>
Next, let's define the click handler:
function handleOnFindLocation() {
const { current = {} } = mapRef;
const { leafletElement: map } = current;
map.locate({
setView: true,
});
}
π You'll have to click "allow" when prompted by the browser.
We've located the user's location, but how do we automatically add a marker to it?
We can listen for when the locate
function was fired (using the useEffect
hook)
map.on('locationfound', handleOnLocationFound);
Now let's define handleOnLocationFound
:
function handleOnFindLocation() {
const { current = {} } = mapRef;
const { leafletElement: map } = current;
map.locate({
setView: true,
});
}
A new marker will automatically be added when clicking the Find My Location button.
π€ You can find other Map events here.
If we console.log
the event
inside handleOnLocationFound
, you'll notice the accuracy
property (the radius in meters).
Let's demonstrate this accuracy radius with a circle:
Inside handleOnLocationFound
add:
const radius = event.accuracy;
const circle = L.circle(latlng, {
radius,
// use any color, but best if it's different from the color of delivery zones
color: "#26c6da",
});
// don't forget to add any new shapes to the map!
circle.addTo(map);
Clean-up time! π§Ή
Inside the locationfound
event handler add:
return () => {
map.off("locationfound", handleOnLocationFound);
};
Aaaaaaaaaaaaaaaaaaaaaand, you did it! πππΎππ