Introduction

Since SVGs are infinitely scalable, it can be useful to add controls to pan and zoom, particularly to maps. Panning and zooming can be achieved by manipulating the viewBox attribute, but that will affect the entire SVG including the controller. For a more targeted effect, you can wrap the elements you want to change in a group with a transform attribute.

In this tutorial, I'll explain now to make a pan and zoom controller that can be easily added to any SVG, to make something like this map.

All the code can be found by clicking the Github link at the top of the page.

The transform element

Panning and zooming can be easily achieved using the transform attributes translate and scale respectively.

To pan left by 10 units, you translate everything 10 units right by wrapping it in a group like this.

<g transform="translate(10 0)">
  All the other elements...
</g>

To zoom in by 25%, you scale everything by 1.25 unit by wrapping it in a group like this:

<g transform="scale(1.25)">
  All the other elements...
</g>

However, this will zoom, centred on the top, left corner. Normally we want to zoom centred on the centre of the screen, which means we need to combine scaling with a translation. And when you start combining scaling and translation transformation, things start getting a bit trickier (because you also need to scale previous translations).

Matrix transformations

Using matrices makes transformations a lot simpler in the long run, because they allow us to easily update our transformation. However, if you're not familiar with matrices, then they can be a bit tricky (and I'd recommend watching 3Blue1Brown's Essence of Linear Algebra playlist. Or you can just skip to the code and trust I know what I'm doing.

The SVG transform matrix is applied like this.

<g transform="matrix(a b c d e f)">
  All the other elements...
</g>

Where a, b, c, d, e and f form the top two lines of a 3 × 3 matrix. The code treats every coordinate in the group as a vector $[x, y, 1]$ and multiplies it by a matrix like this:

$\begin{bmatrix}a & c & e\\b & d & f \\0 & 0 & 1 \end{bmatrix} \begin{bmatrix}x \\y \\ 1 \end{bmatrix}$

The result is that:

  • the new x-coordinate of each element is $a(x) + c(y) + e$
  • the new y-coordinate of each element is $b(x) + d(y) + f$

For example, the result of transform="matrix(1 0 0 1 10 0)" will be:

  • each x-coordinate becomes $1x + 0y + 10$
  • each y-coordinate becomes $0x + 1y + 0$

In other words, we translate the matrix 10 units to the right.

With transform="matrix(1.25 0 0 1.25 0 0)":

  • each x-coordinate becomes $1.25x + 0y + 0$
  • each y-coordinate becomes $0x + 1.25y + 0$

In other words, we scale the matrix by 25%.

Initialising the SVG

Now we can create some functions to pan (translate) and zoom (scale) our map. First wrap all the graphical in a group with and id so we can select it and a transform matrix set as (1 0 0 1 0 0), which is the identity matrix, i.e. it doesn't move any of the elements.

<g id="matrix-group" transform="matrix(1 0 0 1 0 0)">
  All the other elements ...
</g>

For my example, I create a script element inside the SVG so I have a single image file. You can also put the javascript in a separate file.

The code sets up some variables for the group containing the transform matrix, the coordinates for the center of the SVG, and an array of values for the transformation matrix. We could get the transformation matrix values by parsing the map-matrix element, but it's quicker and easier to just set up an array.

<script type="text/javascript"><![CDATA[
    var transformMatrix = [1, 0, 0, 1, 0, 0];
    var svg = document.getElementById('map-svg');
    var viewbox = svg.getAttributeNS(null, "viewBox").split(" ");
    var centerX = parseFloat(viewbox[2]) / 2;
    var centerY = parseFloat(viewbox[3]) / 2;
    var matrixGroup = svg.getElementById("matrix-group");
]]></script>

I've assumed that the SVG has a viewBox element, which I'm parsing to get the center coordinate. If instead your SVG has width and height coordinates then you can get those directly.

The pan function

The pan function takes two variables which determine the distance to pan along the x- and y-coordinates. For example, pan(10,0) pans 10 units right and pan(-20,10) pans 20 units left and 10 units down. The function works by adding the translation factor to the last two values in the transformation matrix.

function pan(dx, dy) {     	
  transformMatrix[4] += dx;
  transformMatrix[5] += dy;
            
  var newMatrix = "matrix(" +  transformMatrix.join(' ') + ")";
  matrixGroup.setAttributeNS(null, "transform", newMatrix);
}

It's a little be hacky to create a new transform by converting an array of values into a string, but it's fine since there should be any other transforms on group wrapping the map. To see the "proper" way to create SVG transforms, see my SVG dragging tutorial.

The zoom function

The zoom function takes a single parameter, which determines the zoom factor. For example zoom(1.25), makes everything 25% larger, so zooms in. zoom(0.8), makes everything 80% of the original size, so zooms out. The function works by multiplying every value in the matrix by the scaling factor, which not only scales all the elements, but scales any previous translations. It then translates the matrix in such a way as to keep the centre in the centre by adding a correction value to the last two values in the transformation matrix.

function zoom(scale) {
  for (var i = 0; i < 4; i++) {
    transformMatrix[i] *= scale;
  }

  transformMatrix[4] += (1 - scale) * centerX;
  transformMatrix[5] += (1 - scale) * centerY;
		        
  var newMatrix = "matrix(" +  transformMatrix.join(' ') + ")";
  matrixGroup.setAttributeNS(null, "transform", newMatrix);
}

Notice that lines 30-31 are the same as lines 17-18, so it's probably worth moving them into a separate function.

The controller

Now we've defined the functions we can create a controller that calls them. Below is a very simple example, but being SVG, you could make something a lot more intricate. Or you can use HTML element outside of the SVG. The important features are the onclick="pan(25,0)", etc. event handlers.

You can choose how large to make the transformation by changing the arguments. I like to scale by 1.25 and 0.8 as they are nice fractions and perfectly cancel each other out.

<path class="button" onclick="pan(0, 25)" d="M25 5 l6 10 a20 35 0 0 0 -12 0z" />
<path class="button" onclick="pan(25, 0)" d="M5 25 l10 -6 a35 20 0 0 0 0 12z" />
<path class="button" onclick="pan(0,-25)" d="M25 45 l6 -10 a20, 35 0 0,1 -12,0z" />
<path class="button" onclick="pan(-25, 0)" d="M45 25 l-10 -6 a35 20 0 0 1 0 12z" />
  
<circle class="button" cx="25" cy="20.5" r="4" onclick="zoom(0.8)"/>
<circle class="button" cx="25" cy="29.5" r="4" onclick="zoom(1.25)"/>

This can then slot into the end of an existing SVG. Make sure it is at the end of the SVG so it floats above the other elements and make sure it's not within the group with the transform attribute, so it remains in place.