Waterlining in QGIS
After three years of practicing intaglio on various subjects[1], I finally want to try to make maps. I'd like to give the first one a vintage look, reminiscent of maps from the early 19th century. The idea is to apply the techniques used by engravers of old maps before the modern reproduction means. Just to see the amount of work it required. And because I'm crazy, I guess.
For now, I'm still in the planning phase. I have to decide what part of the world I want to map (ok, it will be some part of Scotland), the format, the scale, the typography, the look of everything. My masochism will only be applied to the engraving and printing parts, so I'm using modern data and tools for that. I'm using QGIS to design a map from OpenStreetMap data, to get a digital version as close as possible to what I'll try to engrave.
QGIS is an incredible application. I've used it from time to time at work during the last 10 years. Mostly for quick tasks, like data visualization, projection fixing, assessment of coordinate precision, etc. But I had never used it intensively to design a static map from scratch. Despite the complexity, I learned a lot and it's a delight, something I think I could do for months.
One of the typically vintage features I'd like to have on my map is waterlining[2]. This is this representation of water where seas and lakes are filled with repeated lines, growing from the coasts. You can find this on many maps from the 19th century, for example on the famous and incredible Dufour map of Switzerland.
There is no built-in waterlining style in QGIS, nor a dedicated plugin, so I had to find how to achieve that look.
Buffers
From a geometry point of view, the waterlines can be seen as shrinked or expanded copies of the coastline polygons. This corresponds to buffers, a common operation on spatial data, of course supported in QGIS. It typically takes a geometry and a distance parameter.
Two important things to keep in mind about the distance parameter of buffers in QGIS:
- the sign determines the direction of the buffer, external or internal to the polygons. If your polygons represent the earth continents, or the coastlines of an island, you'll want external buffers, i.e. a positive value. If your polygons represent water areas, like lakes, you'll want internal buffers, i.e. negative values.
- the distance is expressed in 'map units', meaning it depends on the current layer coordinate system. So, if your layers are in WGS-84, the unit will be degrees, which isn't something you want for a buffer. To express buffers in meters, you must ensure your layer is in a meter-based CRS, or reproject it if it isn't.
So, it's possible to create waterlines as a set of new layers by manually applying a buffer operation to the coastline layer polygons, one layer by buffer distance. This can be done with the Vector > Geoprocessing Tools > Buffer
dialog, or with the Geometry by expression
tool from the Processing Toolbox, which uses the buffer()
function explicitely. In any case, you can create as many layers you want and even style them separately.
Geometry generators
Waterlining from layers work, but they are tiresome to create. Moreover, having all these new layers to manage in our project seems a bit overkill. After all, the waterlines are just a style property, not real spatial objects. If you don't need to manipulate each buffer line individually, there is a simpler and cleaner way. It uses the powerful Geometry Generator feature, which does exactly what it says: it computes geometries on-the-fly at the symbology level[3].
As for the individual layers, you can add multiple geometry generators to your coastline layer, one for each buffer distance. More clever, you can generate all the waterlines from a single geometry generator by using the collect_geometries()
function, which returns a single composite geometry object.
From there, it's possible to automate further by using array generators and iterators to create each value for us. For example, to have buffers every 25 meters from 0 to 1000 m:
collect_geometries(
array_foreach(
generate_series(0, -1000, -25),
buffer($geometry, @element)
)
)
With the same principle but using the scale_exp()
function to transform a linear series into an exponential one, we can also obtain buffers with an increasing distance between them, to mimic this common variant of waterlines.
collect_geometries(
array_foreach(
generate_series(0, 20, 1),
buffer($geometry, (scale_exp(@element, 0, 20, 0, 20, 3) * -25))
)
)
Python function
Geometry generators solved most of the problem, except for one detail. You may have noticed that we always had to specify a fixed number of lines. Either by creating layers manually or by generating finite series. This means that we cannot define a style where lines fill up entirely any area.
We can use an arbitrary large number of buffers, but this will result in a lot of useless computations for small areas. Even if 'impossible' inner buffers (when the distance is too big to have a valid polygon) yield null geometries, QGIS still has to compute them. Instead, we could use this property as a stop condition: create smaller and smaller buffers until one of them is empty, meaning we reached the 'center' of the polygon. As far as I understand, you can't write this algorithm as an expression. You need a Python function to implement this logic.
So I created this waterlines
function:
from qgis.core import *
@qgsfunction(args='auto', group='Custom')
def waterlines(inc, start, nb_max, grow_ratio, feature, parent):
"""
Creates repeated buffered polygons for a waterlining effect like on old engraved maps.
Arguments:
- inc: Distance increment between each line, in current "units". Negative for inner lines, positive for outer.
- start: Distance where to draw the first line.
- nb_max: Max number of lines. If -1, fills the entire space.
- grow_ratio: Multiplication factor applied at each step. 1 to get equally spaced lines, >1 for increasing distance each time.
Examples:
- waterlines(-50, 0, -1, 1) -> equally spaced lines, spaced by 50, filling the entire polygons.
- waterlines(50, 0, 10, 1.25) -> 10 lines, growing outside the polygon, spaced by 50 in the first iteration, then growing by a 1.25 factor.
"""
geom = feature.geometry()
if geom.type() != QgsWkbTypes.PolygonGeometry:
return NULL
dist = start
lines = []
nb_loops = 500 if nb_max < 0 else nb_max # use 500 as default max to prevent infinite loops
while nb_loops > 0:
line = geom.buffer(dist, 8)
if line.isNull() or line.isEmpty() or not line.isGeosValid():
break
lines.append(line)
dist = (dist + inc) * grow_ratio
nb_loops -= 1
return QgsGeometry.collectGeometry(lines)
To use this function, you need to add it to QGIS in the custom functions dialog. You can copy the code above directly, or from this Gist which has a better doc formatting.
Then, you can call the function in a Geometry Generator symbol or as a one-shot transformation to a layer, with a simple expression:
waterlines(-25, 0, -1, 1.25)
-- inner waterlines, starting at 0, filling the whole area,
-- spaced by 25 units at first, growing by a 1.25 ratio
With this function, I got the look I wanted for my map and I learned a lot about QGIS. I think waterlining would be a good subject for a fully dedicated plugin, to manage more complex cases (for example, applying a color or width gradient to lines). Maybe I'll give it a try :). In the meantime, I hope the simpler techniques in this article will be useful to you.
Update 23 July 2021:
Some interesting resources pointed to me on Twitter after I published this article:
-
Barry Rowlingson mentioned a clever and simple way to achieve a limited number of waterlines, by using alternating fill styles of increasing thickness.
-
Tyler Morgan-Wall mentioned the
generate_waterline_overlay
shader function to do a similar thing in R (which I think you can link to QGIS). -
Thomas Gratier mentioned the fabulous work of Olivia Vane, which explored in depth the subject of implementing (and animating) waterlines in D3.
-
Topi Tjukanov pointed to its GitHub repo of QGIS styles, which includes a 'qartoon' style evoking dashed waterlines made with simple lines of increasing offsets.
You can see the results on my other site, dedicated to my artworks. ↩︎
Many thanks to Vincent De Oliveira for pointing me to the right term on Twitter. ↩︎
This can get pretty costly for complex maps, as geometries are recomputed each time the map is redrawn. It's possible to use the generator expression to instead export real static layers to avoid that. But the geometry generators are invaluable during the conception phase, as they update in real time. ↩︎