Building a Store Locator using OpenStreetMap with Leaflet.js and GeoJSON
eZ Platform comes with batteries included for building location-based features for websites and applications. In this article we'll go hands on and create a simple geospatial application using eZ Platform, OpenStreetMap, Leaflet.js and GeoJSON. For insight on web mapping technologies, I recommend reading the article on the state of JavaScript map APIs.
In the coming exercise we will create a simple store locator for Adam's Apple, a fictitious small retail chain selling fresh fruit. The company has 12 retail locations in and around Norwich, but they have ambitions to expand elsewhere in the UK. Based on their needs we've decided to go with OpenStreetMap and Leaflet.js. This will allow Adam's Apple to keep cost down initially while allowing flexibility for growth and advanced features.
The initial requirements for our imaginary business case of Adam's Apple are simple:
- Store locations should be displayed on a map as pins
- Clicking on a store location should open a popup details on a store
- Administrators should be able to modify and add new store locations dynamically
Our project is split in two phases, one for creating the front end and the other for the back end. What glues these together is GeoJSON, a standard format for relaying location data between applications. Our back office is eZ Platform, but due to the standard data exchange format, our implementation is not tightly coupled to any specific technology.
For visuals and UX design we'll keep things minimal as we're focusing on functionality. The front end implementation will be heavily based on the official Leaflet GeoJSON examples.
Getting our hands dirty with the front end
For our front end we have chosen to use Leaflet.js, an abstraction library for the browser. It allows working with multiple different map providers using its own API. This allows flexibility but an added perk is that Leaflet has an ecosystem of plugins that allow adding features such as routing or overlaying data from different sources on to the map.
To keep things simple and straight to the we'll use no build process and no UI library or framework. For a production bound implementation, you should follow your established front end build process and technology stack. And if you're using a UI library like React or Vue.js, you should take a look at the components already available, such as react-leaflet.
First, we'll want to get our base map up and running in the browser. This is straightforward when running Leaflet with OpenStreetMap as can be seen in the HTML file below:
<html> <head> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.5.1/dist/leaflet.css" /> <script src="https://unpkg.com/leaflet@1.5.1/dist/leaflet.js"></script> </head> <body> <div style="width: 100vw; height: 100vh" id="map"></div> <script type="text/javascript"> const map = new L.Map('map', { center: new L.LatLng(52.6313102,1.292898), zoom: 14, maxZoom: 18 }); const osm = new L.TileLayer('http://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'); map.addLayer(osm); </script> </body> </html>
Opening this file in your browser will result in a full window map of Norwich, courtesy of the OpenStreetMap community. The high-level steps needed to get this working are:
- In the head section we load the Leaflet components (JavaScript and CSS) from unpkg, a service that hosts the NPM package repository for ad hoc usage.
- In the body we create a map container and set it to full screen with viewport units.
- Within the script tag we initialize a new map with the default coordinates, as well as default and maximum zoom levels. To make the map visible we also need to create and define a tile layer and add it to be used on our map.
With the base functionality in place, we're ready to move forward. Our key feature, placing pins on a map, is very common use case. We could procedurally add the pins using the API, but there is probably a better way… And there is. The GeoJSON format mentioned in the beginning of the article is something that we can use to accelerate development.
Let's start by considering the following snippet of JavaScript:
let geojsonFeatureCollection = { "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "name": "Adam's Apple Bowthorpe", "amenity": "Fruit Store", "popupContent": `<h1>Adam's Apple Bowthorpe</h1> <p>53 Roker Terrace<br /> LANGTON LONG BLANDFORD<br /> DT11 8BU<br /> tel: 070 8132 5179 </p>` }, "geometry": { "type": "Point", "coordinates": [1.2173105,52.6372672] } } ] };
Above we define a JavaScript object that conforms to the GeoJSON specification. This specific example is a FeatureCollection that contains multiple Features. Each feature contains location data (in our case of type point and coordinates), but also arbitrary properties for name, amenity and popupContent. GeoJSON can be read and interpreted by Leaflet out of the box. For other overlay data formats like GPX or KML you'll need a plugin.
To get started with using GeoJSON on our map, copy the object definition from above and copy it to the end of your JavaScript block. Then place the following snippet underneath it:
L.geoJSON(geojsonFeatureCollection).addTo(map);
This loads the object we've got on hand and adds it to the map. Now if you load the map you should have a single marker on your map. So far so good, but our specification stated that visitors should be able to view store details by clicking on the map. This is another standard function included in Leaflet. To enable this, we'll need to add two things:
- Attach an onEachFeature event to each Feature within the FeatureCollection
- Create a function to display content within the popupContent variable in a popup
Amend the previous function call to match this one:
L.geoJSON(geojsonFeatureCollection, { onEachFeature: onEachFeature }).addTo(map);
Once this is in place consider the following function:
function onEachFeature(feature, layer) { if (feature.properties & feature.properties.popupContent) { layer.bindPopup(feature.properties.popupContent); } }
This function will be fired for each Feature. After some sanity checking it uses the built in Popup functionality included in Leaflet to display whatever is defined in the popupContent property. Once you have made both changes and reload the page, you should now be able to click on the map marker and see the location name, along with address and contact details.
Now we've got our functionality in place, but there's one important thing we need to adjust. Our data is now inline within our program flow, which is not practical for using dynamic data. Thus, we want to load it from an external URL containing just the GeoJSON payload. We don't have our backend API set up yet, so for now we can use a static file to mock our data.
Loading data with async functions & the fetch API
For our map client we will be using a few contemporary JavaScript features: Async functions and Fetch API. They are not guaranteed to work in all browsers (yet), but for our example we are comfortable with it. In production environments your build tooling (such as Webpack Encore) can be configured to support both of these even with older clients.
You might be aware that JavaScript is asynchronous by nature. This can lead to some surprises and quirks, for example when fetching external resources. There are a number of ways to handle asynchronicity in JavaScript code, such as callbacks and promises. Introduced in the ES2016 spec, async functions can be used to write asynchronous code whose syntax resembles synchronous code, making JS more familiar for PHP developers.
First let's wrap all of our JS code from above in a function with the async keyword and call it:
<script type="text/javascript"> initMap(); async function initMap(){ // all of our code from above } </script>
The above won't change how our code runs, but it does allow us to use the await keyword when calling functions that return promises. This allows us to write code that consistently flows from the top to bottom. Next let's replace our statically defined JavaScript object set in geojsonFeatureCollection with a dynamic call to our static data file on ezplatform.com:
let response = await fetch('https://ezplatform.com/misc/adams-apple-locations.json'); let geojsonFeatureCollection = await response.json();
Here we use the native fetch API to get the payload over HTTP, instead of raw XMLHttpRequest or a utility library like Axios. When using the await keyword the logic program flow will seem to be halted until the server responds to it. For the returned response object, we'll call the json function to get the JSON payload from the response. Note that we also need to use the await keyword when calling response.json().
Now our front end is complete, as we've got a simple application loading map data from an external URL. You can see (and poke around) on the code hosted on the JSFiddle below:
Defining our content model
So now with a functional front end we'll move forward and create our dynamic backend. We'll start by installing a fresh copy of eZ Platform. Once that is done, we'll define our and create our content model using the administration interface (Admin -> Content Types -> Content). To keep focus, will settle for three fields (field type in brackets):
- Title (TextLine)
- Location (MapLocation)
- Telephone (TextLine)
The Location field is of most central for our mapping use case. The MapLocation field type is more complex than the TextLine field type, and internally stores the following fields:
- Address
- Latitude
- Longitude
Address is a string that can be anything, there is nothing beyond basic validation for it. Latitude and Longitude fields are floating point fields storing coordinates. For administrators this data is exposed using a UI element using OpenStreetMap:
In the back end the data storage is also abstracted, so developers interact with the field using the same APIs as they would with any of eZ Platform field types that we ship by default. It's worth noting our internal abstractions are not limited to only content storage, but the eZ Platform Search API as well. Our search in turn is abstracted and allows indexing data into a dedicated search engine when using Solr Search Engine Bundle, for example.
In addition to indexing text, we store the location a geospatial object in the search. This enables us to scale to millions of content items with solid performance and allows doing distance-based queries against our content repository. This is useful not only for location focused items but can provide interesting views into content driven use cases such as news. An extensive archive of location tagged articles can give unique insight to a topic.
But back to where we were: The store locator for Adam's Apple. After having the content model in place, create some store locations to the root of your eZ Platform installation, so we can get on with pulling data from eZ Platform for the map component to display.
Building our GeoJSON feed with eZ Platform
eZ Platform ships with a two of different HTTP capable APIs out of the box:
Both of options are general purpose APIs allowing developers to interact with our Content Repository without access to the back end. These are often used to integrate eZ Platform with other applications in a microservices setting, or possibly for building headless CMS implementations where your front end is decoupled from your content API.
We could also use the two to expose our store items to a store locator application written in JavaScript. However, in this case we already built the client application and agreed to use the GeoJSON format our data format. eZ Platform does not ship with GeoJSON generation capabilities out of the box. But we can use the Symfony framework at our disposal to create an endpoint that will expose our stores in GeoJSON with a modest amount a lot of code.
First off, we'll need a controller for our custom endpoint. Create a new file (src/AppBundle/Controller/GeoJsonController.php) and fill it with the following stub:
<?php namespace AppBundle\Controller; use Symfony\Component\HttpFoundation\Response; class GeoJsonController { public function getStoresAction(){ return new Response('Hello world!'); } }
Next, we'll need to define a route to expose our newly created controller. There are many ways of achieving this in Symfony, but we'll use YAML configuration. Append the following snippet to the end of your main routing configuration file (app/config/routing.yml):
_geojson_get_stores: path: /geojson/stores defaults: { _controller: AppBundle:GeoJson:getStores }
With this in place you should now be able to access your development URL (e.g. https://localhost:8000/geojson/stores) and receive see the message "Hello World!". This means that our core functionality is now in place and we're ready to add our business logic. In our case converting data from our eZ Platform content repository to GeoJSON format.
We need to tap into our content repository from our custom controller. To do this we will use the Dependency Injection functionality provided by the Symfony framework. In eZ Platform v2.0 and higher we use Symfony 3.4 which has improvements to DI functionality. To register our controllers as services, append the following to app/configures/services.yml:
AppBundle\Controller\: resource: '../../src/AppBundle/Controller/*' public: true
This doesn't change how our code works, but it enables us to use constructor injection to gain access to services provided by the eZ Platform. In our case we want to use the eZ Platform Search Service. To inject it, add this property and constructor to your controller:
private $searchService; public function __construct(SearchService $searchService) { $this->searchService = $searchService; }
Also remember to add the use statements to the SearchService and Query (we will need this in the next step) to the beginning of the file:
use eZ\Publish\API\Repository\LocationService; use eZ\Publish\API\Repository\Values\Content\Query;
Once everything is in place, we can access the search service from any method within our controller class. In addition, we've got access to the Query namespace, which will allow us to construct queries that can be executed using the search service. Copy the following block of code to your getStoresAction method on your controller class:
$query = new Query(); $criteria = [ new Query\Criterion\ContentTypeIdentifier('store') ]; $query->filter = new Query\Criterion\LogicalAnd($criteria); $result = $this->searchService->findContent( $query );
The above code will create and execute a query into your content repository that fetches all content items with the type of store. Dump the $result variable, for following output:
GeoJsonController.php on line 39: SearchResult {#1979 ▼ +facets: [] +searchHits: array:1 [▼ 0 => SearchHit {#2126 ▼ +valueObject: Content {#2137 ▼ #fields: array:3 [▶] #versionInfo: VersionInfo {#2199 ▶} #contentType: ContentType {#2257 ▶} -internalFields: array:3 [▶] #prioritizedFieldLanguageCode: null } +score: null +index: null +matchedTranslation: "eng-GB" +highlight: null } ] +spellSuggestion: null +time: 0.011630058288574 +timedOut: null +maxScore: null +totalCount: 1 }
The searchHits array is populated with valueObjects that represent individual locations in our content repository. These contain the values that we want to pass to our front end app using GeoJSON. To accelerate development, we will use a PHP GeoJSON library, to construct our data structures. To add this dependency use the Composer dependency manager:
composer req jmikola/geojson
Once Composer has done its magic, you will have the library in your vendor directory and ready to go. The library allows us to create objects conforming to the GeoJSON specification. We've already executed a search that yielded search results, so what we need to do is loop them to generate a feature collection object as shown below:
$features = []; foreach($result->searchHits as $store){ $location = $store->valueObject->getFieldValue('location'); $telephone = $store->valueObject->getFieldValue('telephone'); $popupContent = '<h1>' . $store->valueObject->getName() . '</h1><p>' . $location->address . '<br/>tel: ' . $telephone . '</p>'; $properties = [ 'name' => $store->valueObject->getName(), 'amenity' => 'Fruit Store', 'popupContent' => $popupContent ]; $features[] = new \GeoJson\Feature\Feature(new \GeoJson\Geometry\Point([$location->longitude,$location->latitude]),$properties); } $featureCollection = new \GeoJson\Feature\FeatureCollection($features);
Finally, instead of our controller returning a Response object we can use the JsonResponse convenience class. This behaves very much like the standard Response object, but it automatically encodes an out to JSON as well as sends the appropriate HTTP headers. Import Symfony\Component\HttpFoundation\JsonResponse and return the following:
return new JsonResponse($featureCollection);
If everything went according to plan you should now have valid GeoJSON output under the URL /geojson/stores. Next switch the URL in our JavaScript app to reference this path and voilà! We've got a dynamic backend powering our store locator app running in the browser.
The full code on GitHub, so feel free to clone it from ezplatform-storelocator and try it.
It's a good start, but there's room to improve
While the above results in a fully functional store locator, it's more of a prototype rather than production ready. First off, you'd need quite a bit of spit and polish to make it look and feel nicer. From a technical point of view, I can think of three improvements right off the bat that should be implemented:
- Set up HTTP Caching: To reduce load on your backend you should set up caching for responses. Look no further to Symfony and eZ Platform docs on HTTP caching.
- Optimize Search Query: If you have hundreds or thousands of stores our map will slow down as is. To improve this, you should only load stores relevant to the current screen. You could use the MapLocationDistance search criterion to only return results within a certain radius based on the location and zoom level.
- Load data on events: The app currently gets all of the data on the first page load. You should use zoom, panning, etc. events to load only subsets of data.
The simple application we built is a good example of how you can accelerate development using common standards, and open source libraries from the JavaScript and PHP ecosystems. In my experience the challenge with functionalities such as this, especially in fragmented markets like Europe, comes from managing data rather than technology itself.
Our app is also a testament to the fact that building custom features to your digital service channel does not need to be a gargantuan task. This approach also gives you more flexibility for iterative improvement over a readymade solution, enabling you to develop a service that surpasses your competitors' off-the-shelf tools without breaking the bank.
eZ Platform is now Ibexa DXP
Ibexa DXP was announced in October 2020. It replaces the eZ Platform brand name, but behind the scenes it is an evolution of the technology. Read the Ibexa DXP v3.2 announcement blog post to learn all about our new product family: Ibexa Content, Ibexa Experience and Ibexa Commerce