Jump to main content
Benjamin Becquet

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.

Heavy work-in-progress :)
Heavy work-in-progress :)

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.

Detail of the Dufour Map.
Detail of the Dufour Map.

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:

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.

Screenshot of buffers as individual layers.
Screenshot of buffers as individual layers.

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.

Screenshot of a geometry generator expression.
Screenshot of a geometry generator expression.

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.

Waterlines function applied to a lake with islets.
Waterlines function applied to a lake with islets.

Update 23 July 2021:

Some interesting resources pointed to me on Twitter after I published this article:


  1. You can see the results on my other site, dedicated to my artworks. ↩︎

  2. Many thanks to Vincent De Oliveira for pointing me to the right term on Twitter. ↩︎

  3. 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. ↩︎