Building a store locator based on Leaflet

Published 15.03.2020, 9 minute read

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 on top of forestry.io CMS and Hugo that gets deployed by Netlify.

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: leaflet-store-locator.netlify.app

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.

  • Leaflet 1.2.0
  • Leaflet.markercluster 1.4.1: GitHub
  • leaflet-locatecontrol 0.70: GitHub

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: '&copy; <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: leaflet-store-locator.netlify.app

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.

Leave a comment

Confidential, will not be shared with anyone or published here.

Related Posts

jQuery Multiselect Filter

An article about building a portfolio filter capable of multiselect with jQuery. Also includes a working CodePen example.

Published 12.12.2018, 7 minute read

"Static" Comments with Gulp, Hugo & Netlify

Probably the easiest way of adding comments to your Hugo site.

Published 15.12.2019, 9 minute read