How I Generate Maps on My Static Site With OpenLayers and Zola
✍️ • 🕑 • getzola • openlayers • cartography • technical details
For a long time, I wanted this website to have a “map” feature, where you could look at a nice, dynamic scrolling map of all of the places I visited.
Well, guess what! I’ve had one on the site since 11/11/21. You can find a map of all posts on the map page, or individual maps of posts in each series on any series page.
At first, I wasn’t sure how to get a map set up and working, and even once I got started writing the feature, I was still stuck for a fairly long time before I got it running. It’s still imperfect, and my code can get cleaned up, but it’s probably worth sharing for anyone else that tries to create something similar.
What follows are a bunch of code/implementation details, specifically related to my choices of the Zola static site generator and OpenLayers javascript map library.
Tools
I chose OpenLayers as my map library because it’s free, open source, and a nice Javascript tool that can run in browser. Its website has a lot of examples highlighting its flexible features, which were useful as a reference. (Though, projecting an upside down fish image on a map has limited utility in my case.)
Meanwhile, I’ve been using Zola as a static site generator since the inception of my blog, and I am mostly very happy with its ease of use for facilitating the creation of a lot of pages from a handy markdown format.
Adding Points to Posts
The first step was to figure out a method of embedding GPS coordinates for points into posts that I wanted to include on a map. I had thought about defining some Zola-external JSON files, or doing something convoluted like that, but ultimately I found that I got a lot more flexibility by defining my points in the header of each post.
So, for example, my post on the Black Hills of South Dakota contains three points. Each of them are defined in the header like so:
[[extra.points]]
placename = "Custer State Park"
latitude = 43.74083605
longitude = -103.41821211382863
anchor = "custer-state-park"
“extra” is a section defined by Zola that can be used for containing any additional, non-required, custom information. The double square brackets denote that this is an entry in an array of that name. This simply allows for simpler syntax where key = value on each line, rather than something more tortured with curly braces and the like.
For my purposes, placename, latitude and longitude are the three required attributes needed to create a point on a map. Placename is just the displayed title for the point.
The anchor attribute is optional and is used to relate the point back to a sub-heading within the page. It’s a little brittle, but it saves the end-user time hunting for the relevant section if it is included correctly.
In Zola land, we can take a list of posts (e.g. from a taxonomy, date range, or from all posts), and transform them into a 2-D lists of points via a simple foreach.
{% set pages = pages | filter(attribute="extra.points") %}
{% for page in pages %}
{% set_global all_points = all_points | concat(with=page.extra.points ) %}
{% endfor %}
Yippee!
Basic OpenLayers
My OpenLayers code is mainly derived from the examples on Dynamic Clusters, Overlays and Popups.
I am not a Javascript expert.
Case in point: I spent a while being flummoxed by the need to define a style for my points and the fact that they weren’t getting displayed. It took me far too long to realize that the style I needed to provide was not a style object, but a function that returned a style object. Why Javascript works that way, I don’t know.
HTML Land
Anyhoo, I start out by creating a place for a map over in HTML land:
<figure class="map-box">
{% if map_name %}<h1>{{map_name}}</h1>{% endif %}
<div class="map" id="{{map_id}}"></div>
<div class="ol-popup" id="{{map_id}}_popup">
<a href="javascript:" id="{{map_id}}_popup-closer" class="ol-popup-closer"> </a>
<div id="{{map_id}}_popup-content"></div>
</div>
{% if description %}
<figcaption class="map-box__description">
{{description}}
</figcaption>
{% endif %}
<figcaption class="map-box__disclaimer">
I'm not a cartographer, don't trust me!
</figcaption>
</figure>
The ol-popup is based on the popup tutorial, and is a div that can be populated with information about the point(s) the user has clicked on. The map div gets to contain the actual map.
All the curly brace / curly brace and percentage stuff is just controlled by Zola. I don’t think I’ve ever bothered generating a map with a description, so I’m not sure why I put that in as an option.
Javascript Land
Off in Javascript land, we start out by creating a dictionary that contains information about our points.
Let’s forget about this part for now.
/* Will get populated with info about points */
const featureDict = Object();
From there, we provide pointers to our important elements, via Zola
/* Basic popup elements & methods... */
const container = document.getElementById('{{map_id}}_popup');
const content = document.getElementById('{{map_id}}_popup-content');
const closer = document.getElementById('{{map_id}}_popup-closer');
And, we start to define the necessary objects from the OpenLayers lib…
const overlay = new ol.Overlay({
element: container,
autoPan: true,
autoPanAnimation: {
duration: 250,
},
});
closer.onclick = function () {
overlay.setPosition(undefined);
closer.blur();
return false;
};
const rasterLayer = new ol.layer.Tile({source: new ol.source.OSM()});
OSM here is OpenStreetMap, which I’m using as my map layer because it’s fairly complete and nice.
/* Predefined style for all points */
let pointStyle = new ol.style.Style({
image: new ol.style.Circle({
radius: 7,
fill: new ol.style.Fill({color: 'white'}),
stroke: new ol.style.Stroke({
color: 'black', width: 2
})
}),
text: new ol.style.Text({
font: '14px Calibri,sans-serif',
textBaseline: 'bottom',
fill: new ol.style.Fill({
color: 'rgba(0,0,0,1)'
}),
stroke: new ol.style.Stroke({
color: 'rgba(255,255,255,1)',
width: 3
})
})
});
The above is my style for points, which is basic af, and which I should eventually get around to refining.
It’s a white circle with a black border.
Below is the function that returns a fresh style object when it is called.
/* For labels, etc. to show up, one must create a function that will return
a style, which includes the text for each object in the vector. Yippee! */
let getStyle = function (feature, resolution) {
let size = feature.get('features').length;
/* Could scale like:
pointStyle.getImage().setScale(size, size);*/
if (size > 1)
{
pointStyle.getText().setText(size.toString());
}
else
{
pointStyle.getText().setText("");
}
return pointStyle;
}
At this point in the Javascript, I create my points.
This looks like this for each point:
const feature_{{label_slug}} = new ol.Feature({
geometry: new ol.geom.Point(ol.proj.fromLonLat([{{point.longitude}}, {{point.latitude}}])),
name: '{{label_slug}}',
});
I will hold off on showing my lovely (ha!) method of putting this together from the points in Zola.
Continuing downstream, we put group our points as a vectorSource, which we use to create a clusterSource.
The clusterSource is used to glob together nearby points and it styles them with that getStyle function we defined ages ago…
const vectorSource = new ol.source.Vector({features: points,});
const clusterSource = new ol.source.Cluster({
distance: 20,
minDistance: 1,
source: vectorSource,
});
const vectorLayer = new ol.layer.Vector({source: clusterSource, style: getStyle,});
With all that done, we finally define a map object, and set it up to populate our map div.
Here, we also are defining a “view”, basically what the map should show when it loads.
This too is populated by values generated by Zola, which I will show later.
const myMap = new ol.Map({
controls: ol.control.defaults().extend([new ol.control.FullScreen()]),
target: document.getElementById('{{map_id}}'),
overlays: [overlay],
layers: [rasterLayer, vectorLayer],
view: new ol.View({
center: ol.proj.fromLonLat([{{mid_longitude}}, {{mid_latitude}}]),
zoom: {{zoom_level}}
})
});
Now that our map is created, we add our popup listener and whatnot so that the user gets popups when they click on points or point clusters on the map.
Putting it Together
Generating Our Point Array
We start by generating a list in our Javascript.
/* Create objects for individual points & add to list */
const points = [];
We’re generating this in Zola, so we iterate on our array of points there…
{% for point in points %}
We want each point to have a unique name in case the placename is used by multiple points, so we concatenate its name with a measurement that should ideally not recur for multiple points. Technically, a collision is possible but unlikely.
{% set label_slug_head = point.placename | slugify | lower | replace(from="-", to="_") %}
{% set label_slug_tail = now() | date(format="%6f") %}
{% set label_slug = label_slug_head ~ label_slug_tail %}
We create a new OpenLayers feature for each point and add it to our points array.
const feature_{{label_slug}} = new ol.Feature({
geometry: new ol.geom.Point(ol.proj.fromLonLat([{{point.longitude}}, {{point.latitude}}])),
name: '{{label_slug}}',
});
points.push(feature_{{label_slug}})
We also populate our feature dict with information about each point’s post so that it can be used to populate popups. Url’s/titles are provided to the zola macro in a parallel arrays, so we can get the relevant url/titles from the arrays by checking the current index of our loop.
featureDict["{{label_slug}}"] = {
"placeName": "{{point.placename}}",
{% if point.anchor %}"anchor": "{{point.anchor}}",{% endif %}
{% if urls %}"url": "{{urls | nth(n=loop.index0)}}{% if point.anchor %}#{{point.anchor | slugify}}{% endif %}",{% endif %}
{% if point.anchor and not urls %}"url": "#{{point.anchor | slugify}}",{% endif %}
{% if titles %}"title": "{{titles | nth(n=loop.index0)}}",{% endif %}
};
{% endfor %}
Populating Pop Up
Since the featureDict is in place this is pretty simple.
/* Popup Handler */
myMap.on('singleclick', function (event) {
content.innerHTML = "";
let noHits = true;
let currPointsHTML = "";
/* The feature we're going to hit here is our clusterLayer, so we need to
subsequently get features from our features so we can feature while we feature. */
myMap.forEachFeatureAtPixel(event.pixel, function (feat) {
noHits = false;
let features = feat.get('features');
let pointSize = features.length;
for (let i = 0; i < pointSize; i ++)
{
let key = features[i].get('name');
let featureObj = featureDict[key];
let sawUrl = false;
let newHTML ="<li>"
if (featureObj["title"])
{
newHTML += featureObj["placeName"] + " - ";
}
if ("url" in featureObj)
{
newHTML += "<a href=\"" + featureObj["url"] +"\">";
sawUrl = true;
}
newHTML += "<span>";
if (featureObj["title"])
{
newHTML += featureObj["title"];
}
else
{
newHTML += featureObj["placeName"]
}
newHTML += "</span>";
if (sawUrl)
{
newHTML += "</a>"
}
newHTML += "</li>";
currPointsHTML += newHTML;
}
});
if (!noHits)
{
/* We had content -- open our pop up & populate as follows */
content.innerHTML = "<ul>" + currPointsHTML + "</ul>";
const coordinate = event.coordinate;
overlay.setPosition(coordinate);
}
else
{
/* click was not on a point. Hide pop up! */
overlay.setPosition(undefined);
closer.blur();
}
});
Creating Initial Views
The map is a lot more sensible-looking if it loads centered on the points it contains. I based this off of the Zoom Levels guidelines from OpenStreetMap, with some fudging on what looked good to me.
Also, this is some horrible looking code. Why on Earth I decided this was acceptable is beyond me, but it works.
This block runs soley on the Zola side and is used to populate Javascript. It would probably be nicer if I ran it in Javascript land, simply because there are much nicer ways (I assume) of doing complex math over float arrays.
However, coding this in Zola has the advantage that this only needs to run once during website generation and does not get rerun on a client’s computer every time the page is loaded.
{# dummy initializations #}
{% set mid_latitude = 0 %}
{% set mid_longitude = 0 %}
{% set max_latitude = 0 %}
{% set min_latitude = 0 %}
{% set max_longitude = 0 %}
{% set min_longitude = 0 %}
I start by getting the minimum and maximum latitude & longitudes, so that I can average them to get the map’s centerpoint.
{% for point in points %}
{% if loop.first %}
{% set_global max_longitude = point.longitude %}
{% set_global min_longitude = point.longitude %}
{% set_global max_latitude = point.latitude %}
{% set_global min_latitude = point.latitude %}
{% else %}
{% if point.longitude > max_longitude %}{% set_global max_longitude = point.longitude %}{% endif %}
{% if point.longitude < min_longitude %}{% set_global min_longitude = point.longitude %}{% endif %}
{% if point.latitude > max_latitude %}{% set_global max_latitude = point.latitude %}{% endif %}
{% if point.latitude < min_latitude %}{% set_global min_latitude = point.latitude %}{% endif %}
{% endif %}
{% endfor %}
{% set mid_latitude = max_latitude + min_latitude %}
{% set mid_longitude = max_longitude + min_longitude %}
{% set mid_latitude = mid_latitude / 2 %}
{% set mid_longitude = mid_longitude / 2 %}
And then for zoom level, I have to futz around with something about the number of degrees that should be visible. It’s confusing.
{# Here's an actual chart of how this... works!!!
https://wiki.openstreetmap.org/wiki/Zoom_levels #}
{% set diff_latitude = max_latitude - min_latitude %}
{% set diff_longitude = max_longitude - min_longitude %}
{% if diff_latitude > diff_longitude %}
{% set max_diff = diff_latitude %}
{% else %}
{% set max_diff = diff_longitude %}
{% endif %}
{# We need to do some rough math to approximate our zoom level (1-20) #}
{% set num_degrees = 360 %}
{% for i in range(end=21) %}
{% if max_diff > num_degrees %}
{% set_global zoom_level = i %}
{% break %}
{% elif loop.last %}
{# something went very wrong here #}
{% set_global zoom_level = 0 %}
{% endif %}
{% set_global num_degrees = num_degrees / 2 %}
{% endfor %}
Once all of that is done, we have our map!
I’ve since modularized this a little more, but the solution is basically unchanged.
Thanks for reading!
If you enjoyed this post, you might enjoy these 5 similar posts:
- 2021-04-24 —Towards Lighter Weight Youtube Embeds
- 2021-06-21 —How to Liveblog on a Static Site, Or: A Thoughtless Kludge
- 2021-11-16 —Site Updates & Future Content
- 2021-08-05 —Email List Migration!
- 2023-07-30 —A Look Back at April 2023 - Belatedly A.F.