This tutorial shows you how to sort store locations based on their distance from a geocoded point, using Mapbox GL JS, the Mapbox GL Geocoder plugin, and Turf.js.
This guide extends the map created in the Build a store locator using Mapbox GL JS tutorial. If you haven't completed that tutorial yet, you can do so before starting this project, or you can download the starter code in the prerequisites section of this tutorial.
To complete this tutorial, you will need:
To set up this project follow these steps:
starter-code
zip file.
index.html
file and an img
folder that contains the custom marker you'll be using to show store locations.index.html
file in a text editor.mapboxgl.accessToken
to your default public token in the code.
mapboxgl.accessToken
will be assigned to YOUR_MAPBOX_ACCESS_TOKEN
when you first paste the code. You want to replace this value with your default public token which will start with pk
.pk
) from the left side of your dashboard and replace YOUR_MAPBOX_ACCESS_TOKEN
in the code with it.Next, add the Mapbox GL Geocoder plug-in and Turf.js library links to your HTML file:
Copy the following code and paste it inside your <head>
tags after your links to mapbox-gl.js
and mapbox-gl.css
.
{/* Geocoder plugin */}
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.1-dev/mapbox-gl-geocoder.min.js"></script>
<link
rel="stylesheet"
href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.1-dev/mapbox-gl-geocoder.css"
type="text/css"
/>
{/* Turf.js plugin */}
<script src="https://npmcdn.com/@turf/turf/turf.min.js"></script>
Add the geocoder control to your JavaScript code using the constructor new mapboxgl.Geocoder
.
In this case, you'll limit the search results to the Washington DC area using the bbox
parameter. There are several other parameters you can specify. You can read more about the available parameters in the documentation on GitHub.
To add the controller, follow these steps:
<script>
tags, inside your map.on('load', () => { ... })
after map.addSource()
.const geocoder = new MapboxGeocoder({
accessToken: mapboxgl.accessToken, // Set the access token
mapboxgl: mapboxgl, // Set the mapbox-gl instance
marker: true, // Use the geocoder's default marker style
bbox: [-77.210763, 38.803367, -76.853675, 39.052643] // Set the bounding box coordinates
});
map.addControl(geocoder, 'top-left');
</style>
tag..mapboxgl-ctrl-geocoder {
border: 0;
border-radius: 0;
position: relative;
top: 0;
width: 800px;
margin-top: 0;
}
.mapboxgl-ctrl-geocoder > div {
min-width: 100%;
margin-left: 0;
}
Notice what happens when you search for an address using the geocoding form you have created. The map flies to the location you specified and adds a marker at the matching location.
Next, you will calculate the distance between the searched location and the stores, add the results to your GeoJSON
data, and sort the store listings by distance from the searched point.
map.addSource()
function
}
) of your map.on()
functionsearchResult
.geocoder.on('result', (event) => {
const searchResult = event.result.geometry;
// Code for the next step will go here
});
geocoder.on('result', () => {...});
function.
GeoJSON
(remember, you stored these in the stores
variable earlier), define a new property for each object called distance
, and set the value of that property to the distance between the coordinates stored in the searchResult
and the coordinates of each store location.turf.distance()
method, which accepts three arguments: from, to, options
.GeoJSON
, the distance
property is applied or will be updated each time a new geocoder result is selected.const options = { units: 'miles' };
for (const store of stores.features) {
store.properties.distance = turf.distance(
searchResult,
store.geometry,
options
);
}
// Code for the next step will go here
geocoder.on('result', () => {...});
function.
distance
value for each store location, you can use it to sort the list of stores by distance.stores
array by the distance
property you added earlier.stores.features.sort((a, b) => {
if (a.properties.distance > b.properties.distance) {
return 1;
}
if (a.properties.distance < b.properties.distance) {
return -1;
}
return 0; // a must be equal to b
});
// Code for the next step will go here
div
with id listings
.const listings = document.getElementById('listings');
while (listings.firstChild) {
listings.removeChild(listings.firstChild);
}
buildLocationList(stores);
link.addEventListener()
function within the buildLocationList()
function.
buildLocationList()
function. You will need to find and change this function to check if there is a distance
property, and if there is, add the value of that property to each listing.if (store.properties.distance) {
const roundedDistance = Math.round(store.properties.distance * 100) / 100;
details.innerHTML += `<div><strong>${roundedDistance} miles away</strong></div>`;
}
If you refresh your browser page now and do a search in the geocoder, you will see that the restaurant list in the sidebar reorders itself so that the closest restaurant to the query point is on top. To make this listing stand out, we will apply a CSS class to it in the next step.
To clarify that the first item in the reordered list is the restaurant closest to the query point, you will highlight its address in the listing by adding and applying the active
CSS class.
Add the following code to geocoder.on('result', function(){...})
before the final }
.
const activeListing = document.getElementById(
`listing-${stores.features[0].properties.id}`
);
activeListing.classList.add('active');
Save your changes.
Now when you refresh the page in your browser and search for a location, the top result in the restaurant listings will change color from dark green to light green.
Finally, when you search for a location, you can change the view to include both the location that was searched and the closest store to show more context. You can do this with map.fitBounds()
.
</script>
tag.
getBbox
that takes the coordinates of the search result and store closest to it and creates a bounding box based on those coordinates.function getBbox(sortedStores, storeIdentifier, searchResult) {
const lats = [
sortedStores.features[storeIdentifier].geometry.coordinates[1],
searchResult.coordinates[1]
];
const lons = [
sortedStores.features[storeIdentifier].geometry.coordinates[0],
searchResult.coordinates[0]
];
const sortedLons = lons.sort((a, b) => {
if (a > b) {
return 1;
}
if (a.distance < b.distance) {
return -1;
}
return 0;
});
const sortedLats = lats.sort((a, b) => {
if (a > b) {
return 1;
}
if (a.distance < b.distance) {
return -1;
}
return 0;
});
return [
[sortedLons[0], sortedLats[0]],
[sortedLons[1], sortedLats[1]]
];
}
geocoder.on('result', () => {...})
function before the closing curly brace (}
)
bbox
using the getBbox()
function you created in the last step.const bbox = getBbox(stores, 0, searchResult);
map.fitBounds(bbox, {
padding: 100
});
createPopUp(stores.features[0]);
You have created a store locator with geocoding and spatial analysis.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Demo: Sort stores by distance</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link
href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400,700"
rel="stylesheet"
/>
<!-- Mapbox GL JS -->
<script src="https://api.tiles.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.js"></script>
<link
href="https://api.tiles.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.css"
rel="stylesheet"
/>
<!-- Geocoder plugin -->
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.1-dev/mapbox-gl-geocoder.min.js"></script>
<link
rel="stylesheet"
href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.1-dev/mapbox-gl-geocoder.css"
type="text/css"
/>
<!-- Turf.js plugin -->
<script src="https://npmcdn.com/@turf/turf/turf.min.js"></script>
<style>
body {
color: #404040;
font:
400 15px/22px 'Source Sans Pro',
'Helvetica Neue',
sans-serif;
margin: 0;
padding: 0;
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
.sidebar {
position: absolute;
width: 33.3333%;
height: 100%;
top: 0;
left: 0;
overflow: hidden;
border-right: 1px solid rgb(0 0 0 / 25%);
}
.pad2 {
padding: 20px;
}
.map {
position: absolute;
left: 33.3333%;
width: 66.6666%;
top: 0;
bottom: 0;
}
h1 {
font-size: 22px;
margin: 0;
font-weight: 400;
line-height: 20px;
padding: 20px 2px;
}
a {
color: #404040;
text-decoration: none;
}
a:hover {
color: #101010;
}
.heading {
background: #fff;
border-bottom: 1px solid #eee;
min-height: 60px;
line-height: 60px;
padding: 0 10px;
background-color: #00853e;
color: #fff;
}
.listings {
height: 100%;
overflow: auto;
padding-bottom: 60px;
}
.listings .item {
border-bottom: 1px solid #eee;
padding: 10px;
text-decoration: none;
}
.listings .item:last-child {
border-bottom: none;
}
.listings .item .title {
display: block;
color: #00853e;
font-weight: 700;
}
.listings .item .title small {
font-weight: 400;
}
.listings .item.active .title,
.listings .item .title:hover {
color: #8cc63f;
}
.listings .item.active {
background-color: #f8f8f8;
}
::-webkit-scrollbar {
width: 3px;
height: 3px;
border-left: 0;
background: rgb(0 0 0 / 10%);
}
::-webkit-scrollbar-track {
background: none;
}
::-webkit-scrollbar-thumb {
background: #00853e;
border-radius: 0;
}
.marker {
border: none;
cursor: pointer;
height: 56px;
width: 56px;
background-image: url('https://docs.mapbox.com/demos/gl-store-locator/marker.png');
}
/* Marker tweaks */
.mapboxgl-popup {
padding-bottom: 50px;
}
.mapboxgl-popup-close-button {
display: none;
}
.mapboxgl-popup-content {
font:
400 15px/22px 'Source Sans Pro',
'Helvetica Neue',
sans-serif;
padding: 0;
width: 180px;
}
.mapboxgl-popup-content h3 {
background: #91c949;
color: #fff;
margin: -15px 0 0;
padding: 10px;
border-radius: 3px 3px 0 0;
font-weight: 700;
}
.mapboxgl-popup-content h4 {
margin: 0;
padding: 10px;
font-weight: 400;
}
.mapboxgl-popup-content div {
padding: 10px;
}
.mapboxgl-popup-anchor-top > .mapboxgl-popup-content {
margin-top: 15px;
}
.mapboxgl-popup-anchor-top > .mapboxgl-popup-tip {
border-bottom-color: #91c949;
}
.mapboxgl-ctrl-geocoder {
border-radius: 0;
position: relative;
top: 0;
width: 800px;
margin-top: 0;
border: 0;
}
.mapboxgl-ctrl-geocoder > div {
min-width: 100%;
margin-left: 0;
}
</style>
</head>
<body>
<div class="sidebar">
<div class="heading">
<h1>Our locations</h1>
</div>
<div id="listings" class="listings"></div>
</div>
<div id="map" class="map"></div>
<script>
mapboxgl.accessToken = '{{MAPBOX_ACCESS_TOKEN}}';
/**
* Add the map to the page
*/
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/light-v11',
center: [-77.034084142948, 38.909671288923],
zoom: 13,
scrollZoom: false
});
const stores = {
'type': 'FeatureCollection',
'features': [
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-77.034084142948, 38.909671288923]
},
'properties': {
'phoneFormatted': '(202) 234-7336',
'phone': '2022347336',
'address': '1471 P St NW',
'city': 'Washington DC',
'country': 'United States',
'crossStreet': 'at 15th St NW',
'postalCode': '20005',
'state': 'D.C.'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-77.049766, 38.900772]
},
'properties': {
'phoneFormatted': '(202) 507-8357',
'phone': '2025078357',
'address': '2221 I St NW',
'city': 'Washington DC',
'country': 'United States',
'crossStreet': 'at 22nd St NW',
'postalCode': '20037',
'state': 'D.C.'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-77.043929, 38.910525]
},
'properties': {
'phoneFormatted': '(202) 387-9338',
'phone': '2023879338',
'address': '1512 Connecticut Ave NW',
'city': 'Washington DC',
'country': 'United States',
'crossStreet': 'at Dupont Circle',
'postalCode': '20036',
'state': 'D.C.'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-77.0672, 38.90516896]
},
'properties': {
'phoneFormatted': '(202) 337-9338',
'phone': '2023379338',
'address': '3333 M St NW',
'city': 'Washington DC',
'country': 'United States',
'crossStreet': 'at 34th St NW',
'postalCode': '20007',
'state': 'D.C.'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-77.002583742142, 38.887041080933]
},
'properties': {
'phoneFormatted': '(202) 547-9338',
'phone': '2025479338',
'address': '221 Pennsylvania Ave SE',
'city': 'Washington DC',
'country': 'United States',
'crossStreet': 'btwn 2nd & 3rd Sts. SE',
'postalCode': '20003',
'state': 'D.C.'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-76.933492720127, 38.99225245786]
},
'properties': {
'address': '8204 Baltimore Ave',
'city': 'College Park',
'country': 'United States',
'postalCode': '20740',
'state': 'MD'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-77.097083330154, 38.980979]
},
'properties': {
'phoneFormatted': '(301) 654-7336',
'phone': '3016547336',
'address': '4831 Bethesda Ave',
'cc': 'US',
'city': 'Bethesda',
'country': 'United States',
'postalCode': '20814',
'state': 'MD'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-77.359425054188, 38.958058116661]
},
'properties': {
'phoneFormatted': '(571) 203-0082',
'phone': '5712030082',
'address': '11935 Democracy Dr',
'city': 'Reston',
'country': 'United States',
'crossStreet': 'btw Explorer & Library',
'postalCode': '20190',
'state': 'VA'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-77.10853099823, 38.880100922392]
},
'properties': {
'phoneFormatted': '(703) 522-2016',
'phone': '7035222016',
'address': '4075 Wilson Blvd',
'city': 'Arlington',
'country': 'United States',
'crossStreet': 'at N Randolph St.',
'postalCode': '22203',
'state': 'VA'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-75.28784, 40.008008]
},
'properties': {
'phoneFormatted': '(610) 642-9400',
'phone': '6106429400',
'address': '68 Coulter Ave',
'city': 'Ardmore',
'country': 'United States',
'postalCode': '19003',
'state': 'PA'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-75.20121216774, 39.954030175164]
},
'properties': {
'phoneFormatted': '(215) 386-1365',
'phone': '2153861365',
'address': '3925 Walnut St',
'city': 'Philadelphia',
'country': 'United States',
'postalCode': '19104',
'state': 'PA'
}
},
{
'type': 'Feature',
'geometry': {
'type': 'Point',
'coordinates': [-77.043959498405, 38.903883387232]
},
'properties': {
'phoneFormatted': '(202) 331-3355',
'phone': '2023313355',
'address': '1901 L St. NW',
'city': 'Washington DC',
'country': 'United States',
'crossStreet': 'at 19th St',
'postalCode': '20036',
'state': 'D.C.'
}
}
]
};
/**
* Assign a unique id to each store. You'll use this `id`
* later to associate each point on the map with a listing
* in the sidebar.
*/
stores.features.forEach((store, i) => {
store.properties.id = i;
});
/**
* Wait until the map loads to make changes to the map.
*/
map.on('load', () => {
/**
* This is where your '.addLayer()' used to be, instead
* add only the source without styling a layer
*/
map.addSource('places', {
'type': 'geojson',
'data': stores
});
/**
* Create a new MapboxGeocoder instance.
*/
const geocoder = new MapboxGeocoder({
accessToken: mapboxgl.accessToken,
mapboxgl: mapboxgl,
marker: true,
bbox: [-77.210763, 38.803367, -76.853675, 39.052643]
});
/**
* Add all the things to the page:
* - The location listings on the side of the page
* - The search box (MapboxGeocoder) onto the map
* - The markers onto the map
*/
buildLocationList(stores);
map.addControl(geocoder, 'top-left');
addMarkers();
/**
* Listen for when a geocoder result is returned. When one is returned:
* - Calculate distances
* - Sort stores by distance
* - Rebuild the listings
* - Adjust the map camera
* - Open a popup for the closest store
* - Highlight the listing for the closest store.
*/
geocoder.on('result', (event) => {
/* Get the coordinate of the search result */
const searchResult = event.result.geometry;
/**
* Calculate distances:
* For each store, use turf.disance to calculate the distance
* in miles between the searchResult and the store. Assign the
* calculated value to a property called `distance`.
*/
const options = { units: 'miles' };
for (const store of stores.features) {
store.properties.distance = turf.distance(
searchResult,
store.geometry,
options
);
}
/**
* Sort stores by distance from closest to the `searchResult`
* to furthest.
*/
stores.features.sort((a, b) => {
if (a.properties.distance > b.properties.distance) {
return 1;
}
if (a.properties.distance < b.properties.distance) {
return -1;
}
return 0; // a must be equal to b
});
/**
* Rebuild the listings:
* Remove the existing listings and build the location
* list again using the newly sorted stores.
*/
const listings = document.getElementById('listings');
while (listings.firstChild) {
listings.removeChild(listings.firstChild);
}
buildLocationList(stores);
/* Open a popup for the closest store. */
createPopUp(stores.features[0]);
/** Highlight the listing for the closest store. */
const activeListing = document.getElementById(
`listing-${stores.features[0].properties.id}`
);
activeListing.classList.add('active');
/**
* Adjust the map camera:
* Get a bbox that contains both the geocoder result and
* the closest store. Fit the bounds to that bbox.
*/
const bbox = getBbox(stores, 0, searchResult);
map.fitBounds(bbox, {
padding: 100
});
});
});
/**
* Using the coordinates (lng, lat) for
* (1) the search result and
* (2) the closest store
* construct a bbox that will contain both points
*/
function getBbox(sortedStores, storeIdentifier, searchResult) {
const lats = [
sortedStores.features[storeIdentifier].geometry.coordinates[1],
searchResult.coordinates[1]
];
const lons = [
sortedStores.features[storeIdentifier].geometry.coordinates[0],
searchResult.coordinates[0]
];
const sortedLons = lons.sort((a, b) => {
if (a > b) {
return 1;
}
if (a.distance < b.distance) {
return -1;
}
return 0;
});
const sortedLats = lats.sort((a, b) => {
if (a > b) {
return 1;
}
if (a.distance < b.distance) {
return -1;
}
return 0;
});
return [
[sortedLons[0], sortedLats[0]],
[sortedLons[1], sortedLats[1]]
];
}
/**
* Add a marker to the map for every store listing.
**/
function addMarkers() {
/* For each feature in the GeoJSON object above: */
for (const marker of stores.features) {
/* Create a div element for the marker. */
const el = document.createElement('div');
/* Assign a unique `id` to the marker. */
el.id = `marker-${marker.properties.id}`;
/* Assign the `marker` class to each marker for styling. */
el.className = 'marker';
/**
* Create a marker using the div element
* defined above and add it to the map.
**/
new mapboxgl.Marker(el, { offset: [0, -23] })
.setLngLat(marker.geometry.coordinates)
.addTo(map);
/**
* Listen to the element and when it is clicked, do three things:
* 1. Fly to the point
* 2. Close all other popups and display popup for clicked store
* 3. Highlight listing in sidebar (and remove highlight for all other listings)
**/
el.addEventListener('click', (e) => {
flyToStore(marker);
createPopUp(marker);
const activeItem = document.getElementsByClassName('active');
e.stopPropagation();
if (activeItem[0]) {
activeItem[0].classList.remove('active');
}
const listing = document.getElementById(
`listing-${marker.properties.id}`
);
listing.classList.add('active');
});
}
}
/**
* Add a listing for each store to the sidebar.
**/
function buildLocationList(stores) {
for (const store of stores.features) {
/* Add a new listing section to the sidebar. */
const listings = document.getElementById('listings');
const listing = listings.appendChild(document.createElement('div'));
/* Assign a unique `id` to the listing. */
listing.id = `listing-${store.properties.id}`;
/* Assign the `item` class to each listing for styling. */
listing.className = 'item';
/* Add the link to the individual listing created above. */
const link = listing.appendChild(document.createElement('a'));
link.href = '#';
link.className = 'title';
link.id = `link-${store.properties.id}`;
link.innerHTML = ``;
/* Add details to the individual listing. */
const details = listing.appendChild(document.createElement('div'));
details.innerHTML = ``;
if (store.properties.phone) {
details.innerHTML += ``;
}
if (store.properties.distance) {
const roundedDistance =
Math.round(store.properties.distance * 100) / 100;
details.innerHTML += ``;
}
/**
* Listen to the element and when it is clicked, do four things:
* 1. Update the `currentFeature` to the store associated with the clicked link
* 2. Fly to the point
* 3. Close all other popups and display popup for clicked store
* 4. Highlight listing in sidebar (and remove highlight for all other listings)
**/
link.addEventListener('click', function () {
for (const feature of stores.features) {
if (this.id === `link-${feature.properties.id}`) {
flyToStore(feature);
createPopUp(feature);
}
}
const activeItem = document.getElementsByClassName('active');
if (activeItem[0]) {
activeItem[0].classList.remove('active');
}
this.parentNode.classList.add('active');
});
}
}
/**
* Use Mapbox GL JS's `flyTo` to move the camera smoothly
* a given center point.
**/
function flyToStore(currentFeature) {
map.flyTo({
center: currentFeature.geometry.coordinates,
zoom: 15
});
}
/**
* Create a Mapbox GL JS `Popup`.
**/
function createPopUp(currentFeature) {
const popUps = document.getElementsByClassName('mapboxgl-popup');
if (popUps[0]) popUps[0].remove();
const popup = new mapboxgl.Popup({ closeOnClick: false })
.setLngLat(currentFeature.geometry.coordinates)
.setHTML(
`<h3>Sweetgreen</h3><h4>${currentFeature.properties.address}</h4>`
)
.addTo(map);
}
</script>
</body>
</html>
After this guide, you should have everything you need to create your own store locator. You can complete the Create a custom style tutorial to create a branded map style or use Cartogram, a drag and drop tool, to create a custom style from your logo in minutes. To do more with Mapbox GL JS, explore the Mapbox GL JS API reference and examples