Store Locator
There’s probably a lot of good tutorials on building a store locator out there, but I couldn’t find one matching my client’s requirements 100%.
For context: they’re running a static site built by Hugo that uses forestry as a CMS.
Not exactly rocket science, but they’re normal business users (if such a thing exists). That means that content maintenance should be rather streamlined and convenient - forestry’s pretty sweet in terms of that, especially when it comes to maintaining JSON data in a way that looks and feels more natural to said users than any editor GUI would.
Requirements
- list of stores maintained as JSON (in the CMS)
- search function (within the list of stores)
- map with custom markers
- clustering of markers depending on the map’s zoom level
- “find my location” button
In terms of the visual representation, this store locator was requested as some kind of bulletin board/window looking thing, basically a list of stores with a map next to it that reflects the clicks on the respective list entry.
Introductory Remarks
The approach described in this article is also available as a fully functional Hugo site. The site structure is defined in layouts/_default/baseof.html
- the page content resides in layouts/index.html
, all JavaScript is either pulled in from external (gulp-built) bundles or stored in layouts/partials/js.html
.
Best have a quick look at the repository and its structure now, it’ll make the following explanations easier to understand: GitHub Repository
There’s a live demo on Netlify which can be found here: storelocator.ttntm.me
Location Data
In order to make this work, you’ll need data - stores and their address/location. I’ve used McDonald’s locations in Vienna for the demo site, but anything else would also work.
The raw JSON for one store looks like this then:
{
"shopName": "McDonald's Schwedenplatz",
"shopAddress": "Rotenturmstraße 29",
"shopPLZ": "1010 Vienna",
"shopCountry": "Austria",
"shopLatitude": "48.211787",
"shopLongitude": "16.375875",
"shopActive": true
}
It should all be rather self-explanatory - name, address, postcode/city and country are based on data you’ll either have or easily find. The latitude/longitude can be a bit tricky to obtain, but I found this article helpful in case you’re looking for something similar.
The shopActive
key is simply a toggle: show/hide the respective store in the list/map. Not absolutely necessary, but certainly convenient.
Page Template
Before we’re going to dig into the JavaScript, we’ll need both map and data rendered for our site.
The following code samples are all part of layouts/index.html
.
Rendering the Data
If you’re not familiar with the way Hugo handles (JSON) data files, best head over there for a moment: Hugo Docs - Data Templates
So, we’re basically going to loop (range
) through the data, rendering it as a list. We’re also going to add the necessary information as HTML data-*
attributes to the respective list item:
{{ $shops := .Site.Data.stores }}
<div class="shop-container d-flex flex-column flex-nowrap align-content-start px-md-0">
{{ range sort $shops "shopPLZ" "asc" }}
{{ if .shopActive }}
<div class="sItem flex-grow-0 px-0 py-2 p-md-2" data-name="{{ .shopName }}" data-add="{{ .shopAddress }}" data-plz="{{ .shopPLZ }}" data-cty="{{ .shopCountry }}" data-lat="{{ .shopLatitude }}" data-lon="{{ .shopLongitude }}">
<div class="sItem--offline rounded-lg shadow-sm px-3 py-2">
<h5 class="h6 mb-0">{{ .shopName }}</h5>
<p class="small mt-1 mb-0">{{ .shopAddress }}<br>{{ .shopPLZ }}, {{ .shopCountry }}</p>
</div>
</div>
{{ end }}
{{ end }}
</div>
The HTML data-*
attributes will be helpful as they’re going to supply the necessary data for the search functionality for each sItem
as a whole, making it easy to show/hide the correct elements.
Search Input
Above this list of shops, we’d like to have a search bar:
<div class="p-4">
<div class="input-group shadow-sm mt-3">
<input class="form-control border-0" type="text" id="storefinder" onkeyup="findStore();" placeholder="Area code, i.e. '1010'">
<div class="input-group-append">
<button class="btn btn-secondary bg-white text-secondary border-0 py-0" type="submit">Find</button>
</div>
</div>
<p id="result" class="small text-center mt-3 mb-0"></p>
</div>
Map
Not much to do here, the map is going to fill the remaining col-7
left behind by the list in order for both to be displayed side by side.
<div class="col-12 col-md-7 col-lg-8 map-container order-1 order-md-2 px-0">
<div id="map" style="width:100%;height:100%;"></div>
</div>
JavaScript
Now that we have our content rendered, it’s time to have a look at the actual functionality:
- creating the map
- clustering
- search
- navigating based on search results, i.e. finding the marker that belongs to the clicked store
- reset function
All code samples listed here can be found in layouts/partials/js.html
unless stated otherwise.
Prerequisites
We have some dependencies (other than Bootstrap 4/jQuery) that we have to keep in mind. They’re all included in the src
folder of the repository, so you don’t have to go looking unless you want to change something.
Creating the Map
First off, we’re going to need some definitions:
const items = $('.sItem'); // all shops in the list
const item = $('.sItem--offline'); // each shop
const startZoom = 11; //Define zoom level - 13 = default | 19 = max
const startLat = 48.208726;
const startLon = 16.372644;
Now we’ll create the map and add OpenStreetMap tiles:
var mymap = L.map('map', {scrollWheelZoom: false}).setView([startLat, startLon], startZoom);
// Add tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors | <a href="javascript:resetMap();">Reset map</a>'
}).addTo(mymap);
Take note of the scrollWheelZoom: false
option. It makes sure that the mouse wheel doesn’t get hijacked by the map and instead changes the map’s behavior in a way that makes it necessary to click into it in order to enable mouse wheel zoom:
// zoom options enable/disable
mymap.on('click', () => { mymap.scrollWheelZoom.enable();});
mymap.on('mouseout', () => { mymap.scrollWheelZoom.disable();});
Also note the resetMap()
function packed into the bottom right attribution area of the map. It’s a simple function that resets the map and the store search, we’ll have a look at it further down below.
We’ll also add the “find me” button from leaflet-locatecontrol
to the map:
// add GPS find me button
L.control.locate().addTo(mymap);
Originally, leaflet-locatecontrol
uses Font Awesome which was not suitable for my client. I changed that to a normal Unicode “pin” character in CSS and updated the script accordingly.
.gps-marker::after {
content: "📍";
}
Markers and Clustering
Based on our list of stores, we’re going to create a marker for each one of them with a for
loop. These markers then get added to a Leaflet layer group as required for Leaflet.markercluster
.
// create marker cluster layer
var markers = L.markerClusterGroup();
// iterate stores, add markers to map
for(let i = 0; i < items.length; i++) {
let iLat = items[i].getAttribute('data-lat');
let iLon = items[i].getAttribute('data-lon');
if(!isEmpty(iLat) | !isEmpty(iLon)) {
// get popup info
let name = items[i].getAttribute('data-name');
let ad = items[i].getAttribute('data-add');
let plz = items[i].getAttribute('data-plz');
// create marker with associated popup
markers.addLayer(L.marker([iLat,iLon],{key:iLat+'__'+iLon}).bindPopup("<b>" + name + "</b>" + "<br>" + ad + ", " + plz)); // marker added to cluster layer
// we use an ID made up of iLat and iLon here, so we can find the marker again later
}
}
// add clustered markers to map
mymap.addLayer(markers);
Just like the comment in the code above mentions, there’s an option key
, essentially an ID made up of Latitude and Longitude. We’re going to need that for finding the correct marker when handling the clicks for the stores in the list.
Search Function
As mentioned above, the list of stores should have a search function. We added the respective input above the list of stores in the template, the following findStore()
is going to provide the necessary functionality.
function findStore() {
const searchInput = $('#storefinder');
const hidden = 'sItem--hidden';
const result = $('#result');
let filter = searchInput.val().toUpperCase();
let count = 0; // reset on each function call
for (let i = 0; i < items.length; i++) {
let plz = items[i].getAttribute('data-plz').toUpperCase();
let cty = items[i].getAttribute('data-cty').toUpperCase();
if (plz.toUpperCase().indexOf(filter) > -1) { // check PLZ
items[i].classList.remove(hidden);
count = count + 1;
} else if (cty.toUpperCase().indexOf(filter) > -1) { // PLZ not found, check country
items[i].classList.remove(hidden);
count = count + 1;
} else { // nothing found
items[i].classList.add(hidden);
}
}
result.html(count + ' Shops - <a href="javascript:clearSearch();">Reset</a>'); // print the seartch result
}
This function takes the search input, converts it with toUpperCase()
and checks against the HTML data-*
attributes of the stores in the list. Matches remain shown, everything else gets hidden.
Handle Store Clicks for the Map
Once a search result is clicked, the map should navigate to the marker that belongs to the clicked store and open its popup.
In order to achieve that, we’re going to loop through all the markers currently on the map (and in the markerClusterGroup
), trying to find a match based on the previously generated ID (made up of the store’s Lat and Lon):
// handle item clicks
item.click(function(){
let ct = $(this);
let pt = ct.parent(); // the data-* attributes are with the parent <div>
let pLat = pt.attr('data-lat');
let pLon = pt.attr('data-lon');
let id = pLat + '__' + pLon;
if(!isEmpty(pLat) | !isEmpty(pLon)) {
// find the correct marker
markers.eachLayer(function(layer) {
if(layer.options.key === id) {
mymap.setView([pLat,pLon], 19); // move to the selected item and zoom in
layer.openPopup()
}
});
}
});
Each marker we previously added to markerClusterGroup
(Leaflet layer group
) is considered a separate layer
in terms of Leaflet. That’s why eachLayer()
is used here, checking each layer for its key
and trying to find a match for the clicked store’s ID.
Reset
We’ve got 2 reset functions - one for the search and another one for the map:
// reset map
function resetMap() {
mymap.closePopup();
mymap.setView([startLat, startLon], startZoom);
}
// clear search
function clearSearch() {
document.getElementById('storefinder').value = '';
findStore();
resetMap();
}
clearSearch()
first performs an “empty” search, thus clearing all the hidden items. Don’t know if that’s faster/more efficient than another loop through all items, but it’s certainly less lines.
Not much else to say here, basically just another convenience feature.
Demo
As mentioned above, there’s a live demo on Netlify. It can be found here: storelocator.ttntm.me
The GitHub Repository requires Hugo to build/run the site; after cloning/downloading it, you can either hugo server
or npm run start
in order to view the site on http://localhost:1313
.
All necessary information regarding Hugo installation can be found here: Hugo Docs - Install Hugo
Conclusion
Building this store locator was fun and so was building a demo and writing this up.
Another article on the subject that was of some help can be found here: getbounds.com/blog/leaflet-store-locator/
I hope it helps someone, I spent quite some time reading Leaflet’s documentation and researching on the internet to build this. Just leave a comment below, feedback appreciated.