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:
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 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.
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:
Once this is in place consider the following function:
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. Download the dummy data: https://ezplatform.com/misc/adams-apple-locations.json
Loading data with async functions & the fetch API
First let’s wrap all of our JS code from above in a function with the async keyword and call it:
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)
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.
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:
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):
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:
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:
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:
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:
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:
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:
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:
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:
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.
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.