D3-Zoom - das fehlende Handbuch

So zoomen und schwenken Sie Ihre Datenvisualisierungen mit SVG und Canvas

Der beste erste Absatz für einen D3-Zoomartikel wurde bereits geschrieben und sieht folgendermaßen aus:

Das ist gut. In vier Sätzen erfahren Sie genau, was Zoomen ist und was es tut, und - was wahrscheinlich noch wichtiger ist - es beseitigt Ihre Ängste vor dem Zoomen.

Wurde dann alles gesagt? Nun, das hat es nie getan. Es ist immer gut, zahlreiche unterschiedliche Perspektiven zu haben, insbesondere bei Ereignissen, die Ihr wertvolles Bild im gesamten Shop bewegen und nach Ermessen Ihres auslöserfreudigen Benutzers skalieren.

Vor einiger Zeit arbeitete ich an einer ziemlich komplexen Visualisierung mit vielen beweglichen Elementen und einer langen Liste von Interaktionen, einschließlich Zoom und Schwenken, an ihrem anfänglich dunklen Herzen. Das statische Bild selbst war bereits relativ komplex, aber das Hinzufügen von Zoom und Schwenken fühlte sich ein bisschen so an, als würde man das 4 mal 6 Fuß große Lego-Schloss meines Sohnes an einen flüchtenden Wasserbüffel binden.

Das konzeptionelle Problem hierbei ist, dass Zoom und Schwenken unsere Arbeit so grundlegend beeinträchtigen. Sie scheinen einen Großteil unserer handgefertigten Visualisierung zu steuern, die selten eine einzige ganze Sache ist, sondern eine sorgfältige Zusammenstellung von Positionen, Skalen und Achsen. Dies kann bestenfalls verwirrend und im schlimmsten Fall einschüchternd sein.

Nachdem meine Zoom- und Schwenkbewegungen an Selbstvertrauen gewonnen und in einigen anderen Projekten getestet wurden, schien die Zeit reif, sie aufzuschreiben. Vielleicht ist es zu spät und Sie alle haben es vor Jahren geknackt, aber selbst dann könnte es hilfreich sein, eine andere Perspektive zu haben.

Unsere Reise besteht aus drei Teilen:

  1. Ein synchrones Rezept für Zoom und Schwenken
  2. Ein visuelles Bild erstellen
  3. Implementieren von Zoom und Schwenken in SVG und in Canvas

Als Bonus fügen wir programmgesteuerten Zoom hinzu und machen unser Bild hübsch.

Vielleicht sehen Sie sich dort die Bildlaufleiste an und denken, Sie werden das Abendessen verpassen, wenn Sie das alles lesen. Es ist aus einem bestimmten Grund detailliert, aber ich werde es Ihnen leicht machen, zu stöbern und Kirschen zu pflücken, wenn ich auf Abschnitte hinweise, über die Sie springen können, ohne wichtige Dinge zu verpassen. So können Sie diese Fahrt so kurz oder gründlich gestalten, wie Sie möchten, und so oder so etwas daraus machen.

Ein einfaches Zoom- und Schwenkrezept

Dieser erste Teil ist das Rückgrat dieses Beitrags. Es ist ein kurzes Handbuch - nichts weiter als eine Reihe von fünf einfachen Punkten, denen Sie beim Aufbau Ihrer Zoom- und Schwenkereignisse folgen können. In diesem Handbuch erfahren Sie, wie Sie Zoom und Pan in Ihre App integrieren. Die asynchrone und verknotete Welt der Programmierung wird oft durch eine Reihe synchroner und einfacher Schritte unterstützt.

Einigung auf eine Terminologie

Bevor wir uns auf den Weg machen, definieren wir zunächst eine hilfreiche Terminologie:

  • Eine Zoomtransformation ist ein Objekt, das von D3 erzeugt und verwaltet wird. Es ist Ihr wertvollster Besitz im Zoom- und Schwenkkontext und enthält drei Werte: die x- und y- Translation sowie den durch k dargestellten Skalierungsfaktor . Wir werden sehen, wann und wo es sehr bald produziert und verändert wird. So sieht es im Ausgangszustand aus:
  • Darin heißt es: „Der Benutzer hat das Bild noch nicht gezoomt oder geschwenkt. Daher ist der Zoom-Skalierungsfaktor 1 und die x- und y-Translation 0. ”
  • Das Zoomverhalten ist das Ereignissystem, das die Transformationswerte verfolgt und weitergibt. Ein Listener konsumiert (nimmt die Aktionen des Benutzers zur Kenntnis). Nach der Aktivierung wird ein Ereignisobjekt mit Informationen zu diesem Ereignis an eine Handlerfunktion gesendet. Sie schreiben diesen Handler und verwenden die Informationen des Ereignisobjekts. Die wichtigste Information, die Ihr Zoom-Handler erhält, ist die obige Transformation bei jeder Zoom-Aktivität. Was auch immer wir mit den Transformationswerten tun möchten, wir werden es im Zoom-Handler tun. Das mag sich nach viel anhören, aber in seiner einfachsten Form stellen Sie das Zoomverhalten folgendermaßen ein:
var zoom = d3.zoom().on(‘zoom’, zoomed);
  • Die Zoombasis ist das übergeordnete Element, an das der Zoom angehängt oder registriert ist. Es macht zwei Dinge: 1) Es ist die Oberfläche, die alle Bewegungen und Gesten des Benutzers aufnimmt, und 2) es enthält das Transformationsobjekt (das x , das y und den Skalierungsfaktor k ).
  • Die Zoomziele sind alle Elemente, die wir verschieben möchten. Wenn Sie einen Kreis vergrößern und verkleinern möchten, ist dieser Kreis Ihr Zoomziel.

Darüber hinaus möchten wir möglicherweise zwischen zwei Zoomtypen unterscheiden. Sie werden viel klarer, wenn wir zu unseren Beispielen übergehen, aber es wird hilfreich sein, sie zuerst auf oberster Ebene zu definieren:

  • Geometrischer Zoom (oder grafischer Zoom ) bedeutet, dass Elemente ohne Differenzierung nur vergrößert oder verkleinert werden. Alle ihre Eigenschaften werden vergrößert oder verkleinert. Stellen Sie sich vor, Sie verschieben oder skalieren das Koordinatensystem der jeweiligen Elemente. Alles darauf wird skaliert und wahllos verschoben. Der geometrische Zoom kommt unserer realen Erfahrung am nächsten. Wenn wir auf ein Haus zugehen, erscheint jeder Aspekt des Hauses bei jedem Schritt größer. Wenn wir eine Achse skalieren, werden alle Teile davon größer oder kleiner - die Linien, der Domänenpfad, die Beschriftungen. Zum Beispiel würde eine um einen Skalierungsfaktor von 2 vergrößerte 14px-Achsenbeschriftung 14 × 2 = 28px groß erscheinen.
  • Semantischer Zoom (oder nicht geometrischer Zoom ) bedeutet, dass wir die Eigenschaft jedes einzelnen Elements während des Zooms steuern. Wenn wir beispielsweise eine Achse mit Beschriftungen der Größe 14px haben und die Achse semantisch vergrößern, können wir den Beschriftungen befehlen, ihre ursprüngliche Größe für jeden Skalierungsfaktor beizubehalten. Die Linien könnten größer und dünner werden und die Achse würde entsprechend dem Zoom neu positioniert, aber unsere Beschriftung würde 14 Pixel groß bleiben.

Wir werden im Folgenden nicht darauf eingehen, aber der echte semantische Zoom kann noch weiter gehen. Damit können wir nicht nur die Eigenschaften des Elements steuern, sondern auch die Darstellung unseres Elements in Abhängigkeit von der Zoomstufe. Google Maps zeigt beispielsweise Länder beim Verkleinern, Bundesstaaten oder Verwaltungsbezirke beim mittleren Vergrößern und kleinere Städte beim Vergrößern.

Zoomen und schwenken Sie in 5 Schritten

Dafür sind wir jetzt gut gerüstet. Hier ist unser Zoom und Schwenken in fünf einfachen Schritten:

1. Erstellen Sie zuerst Ihr statisches Bild

Um in ein Bild zu zoomen, benötigen Sie ein Bild.

2. Identifizieren Sie Ihre Zoombasis und Ihre Zoomziele

Nehmen Sie ein Blatt Papier, benennen Sie ein Element, das zuhört (die Zoombasis ), und schreiben Sie eine Liste der Elemente auf, die sich bewegen sollen (die Zoomziele ).

  • Choose your zoom base element first. Determine which DOM element you want to use for your zoom base. You can attach the zoom to an svg, g, rect or any other element that your mouse has access to. Note here, that g elements can only register events where they have children with a fill. So, if you have a large g element with a circle of radius 1, your zoom gestures will only work on that tiny circle. It is often best to set-up a dedicated SVG rectangle (rect) with fill but 0 opacity and pointer-events set to all to register the zoom listener on. You might have to unset pointer events of ascendant elements.
  • Identify your zoom target elements and write them down. Remember, the zoom targets are the elements you want to move. Make a list of all zoom target elements.
  • For each target, identify if you want to use geometric or semantic zoom.
  • Note it down. Here’s an example table you might end up with:

3. Set up the zoom behaviour

Now you want to set up the behaviour that will make the listener listen.

  • Create the zoom behaviour with at least:
var zoom = d3.zoom().on(‘zoom’, zoomed);
  • Check out the D3 API reference for d3.zoom() for helper methods like scaleExtent and translateExtent.
  • Call the zoom behaviour on your base element like:
zoomBaseElement.call(zoom)

Note, you don’t have to call your zoom base zoomBaseElement of course.

4. Write the handler

This is where the zoom and pan will happen. The handler’s single most precious possession will be the transform object updating x, y, and k continuously when the user wheels or drags. You will apply these to your zoom targets.

  • The first thing you want to do is capture the transform object passed into the handler by the listener at every user interaction (wheel or mouse):
var transform = d3.event.transform;
  • Now that you have your zoom and pan parameters (tx, ty, k), you can do whatever you want with it...
  • If you only want to administer geometric zoom, you just call:
zoomTargetElement .attr(‘transform’, ‘translate(‘ + transform.x + ‘, ‘ + transform.y + ‘) scale(‘ + transform.k + ‘)’);

or simpler:

zoomTargetElement.attr(‘transform’, transform.toString());

… Was genau das gleiche ist. Dies setzt voraus, dass Sie alle Transformationswerte anwenden möchten. Sie können sich natürlich auch nur auf tx , ty oder die Skala k konzentrieren .

  • Wenn Sie semantischen Zoom wünschen , müssen Sie neu skalieren.
  • Angenommen, alle Ihre Datenwerte haben eine Skala durchlaufen, um von Daten in den Bildschirmbereich übersetzt zu werden, ändert sich diese Übersetzung beim Zoomen. Wenn Ihr Datenpunkt x = 10 vor dem Zoomen in den Pixelraum 50 übersetzt wurde, wird er durch den Zoom an einen anderen Punkt verschoben.
  • Wenn Sie das x um 5 übersetzen und um 2 skalieren, lautet die neue Position:
x2 = x1 × k + tx

x2 = 50 × 2 + 5 = 105

  • Luckily, you don’t have to (nor should you) produce these calculations yourself, but you can rescale your scale on each zoom and apply it to the target properties you want to change. These include axes or circles or rects or whatever target shapes and components you have.
  • With a scale called xScale, you can use the sugar function .rescaleX() and apply it like so:
var updatedScale = transform.rescaleX(xScale);
  • Now you can use updatedScale in your zoomed function for all the elements you want to update. For example an axis:
xAxis.scale(updatedScale); gAxis.call(xAxis);
  • or a set of circle x positions:
circles.attr(‘cx’. function(d) { return updatedScale(d.value); })

5. Do you need to programmatically move your target into a position?

  • Calculate/determine the position and the scale
  • Determine the new positions tx and ty and the new scale k in D3’s own transform producing function by saying:
var t = d3.zoomIdentity.translateBy(tx, ty).scale(k);
  • Store the object in the zoom base AND propagate the changes by calling your first zoom handler, which will move the targets with:
zoomBaseElement.call(zoom.transform, t);
  • Now enable user triggered zoom with:
zoomBaseElement.call(zoom)

Here you are. I think they call this an executive summary. However, we have only superficially touched on the key concepts and haven’t even mentioned different renderers. Let’s add some flesh to the bones with a real life example.

What we make

Here’s the Guinea pig we shall build in passing:

It’s a visualisation of our solar system’s planets showing their distance from the Sun. Zooming will come in handy to allow an overview, and panning conveys some feel for distance. In addition, all orbs are pink!

Oh, and you don’t really have to, but if you want, you can follow along. Go here for all commented code. Alternatively, you can just play around with the app step by step. I’ll drop a link whenever we progress.

Building our static visual

As with nearly all visualisations, data is our starting point. So, here it is in its entirety:

We have 8 planets, 1 star called Sun, and Pluto, which is not actually a planet anymore but still in here for romantic reasons. We also have each planet’s distance from the sun and their radii. That’s all we need. But in order to turn it into this:

…we need to write some code.

Please note: this post is about zooming rather than about building a static visualization of our solar system. Nevertheless, I will run through the code to give you a round-trip of this app. However, if you’re only here for the zoom, please feel free to browse through this section and quickly move on to the first zoom-building step called Identifying our zoom base and zoom targets (mind you, it might be worth reading the Calculating the Dimensions section in a moment).

Let’s start with the sparse HTML:

Measuring our planets' distances

That’s it. We have a headline with a link and a span to give it an appropriately pink bottom border and a container div for our vis. Now we’ll move swiftly on to the JavaScript and bypass the CSS, which is not invited until the end of this post…

The first thing we do is to load in the data:

d3.csv('planets.csv', row, function(error, data) { if (error) throw error; make(data); 
}); 
function row(d) { return { planet: d.planet, distance: +d.distance, radius: +d.radius }; }

We’re loading in our planets.csv piping it through the row() function which makes sure our numbers are indeed numbers. Then we call the make() function, which will be the home of all further code.

The make() function does the following:

  1. It sets the dimensions of our visual
  2. It builds an svg as well as a zoom surface
  3. It calculates our scales
  4. It builds our axis
  5. It builds the planets

Let’s start with setting our visual’s dimensions.

Calculating the dimensions ^

The margin and the height calculations are straightforward:

var margin = { top: window.innerHeight * 0.3, left: 50, bottom: window.innerHeight * 0.4, right: 50 }; 
var height = window.innerHeight - margin.top - margin.bottom;

We want the svg element to cover our entire screen. So our height will be the window.innerHeight subtracting some margins. We define the top and bottom margin in respect to the window.innerHeight to keep them relative to each other.

On to the width, which needs just a little more thought:

var maxDist = d3.max(data, function(d) { return d.distance; }); 
var mapScale = 1/10e4; 
// The full width of all planets var chartWidth = maxDist * mapScale; 
// svg width will only be as large as screen var screenWidth = window.innerWidth - margin.left - margin.right;

The gist of our width calculation is that we want two widths. One for the chart and one for the svg. What’s the difference? Well, the chart will be very wide, because it needs to fit all our planets on it. The svg, however, doesn’t need to be very wide. The svg’s task is to show us the planets that fit on our browser window. An svg of the window’s dimensions is henceforth enough. It’ll look like this:

Note that this is only possible using the zoom behaviour. If we wanted to allow the user to see all planets without the zoom and pan magic, we would need to have an svg as wide as the chart. As a result, the browser would give us scroll bars our users could use to move to the right or left — like in the marvelous If the Moon were only 1 Pixel visual.

However, using D3 zoom, the zoom transform object we will initialize will keep track of our gestures: how far we “scrolled” to the right, left, and along the z-axis virtually piercing through the screen following our line of sight.

Based on the transform, we can re-position our elements. And if they happen to be within screen coordinates, they get displayed on our base svg. No harm done if not, they just won’t get shown.

As such, our svg’s width will get the screenWidth which is just the window.innerWidth minus the margins. How wide will our chartWidth, the base for all planets, be? We will scale down the distance between the two furthest apart orbs (the Sun and Pluto, that is) with our mapScale by 10e4, or 1:10,000. When Pluto is 5,913,000,000 km away from the Sun in real space, it will be 59,130 pixels away from the centre of the Sun in our visual.

That wasn’t too bad. Onwards!

Building out the base

First, we build our svg base: a margin-transformed g element dangling off an svg element:

var svg = d3.select('#vis') .append('svg') .attr('width', screenWidth + margin.left + margin.right) .attr('height', height + margin.top + margin.bottom) .append('g') .attr('class', 'chart') .attr('transform', 'translate(' + margin.left + ', ' + margin.top + ')');

Then we overlay it with a rect element that we’ll use as our zoom base. This rect will listen to all mouse events and gestures, and as such we’ll boldly call it listenerRect:

var listenerRect = svg .append('rect') .attr('class', 'listener-rect') .attr('x', 0) .attr('y', -margin.top) .attr('width', screenWidth) .attr('height', height) .style('opacity', 0);

Important to note here that our zoom base is at the same spot as the zoom targets — the elements we want to zoom. We will attach our zoom base listenerRect to the svg (which in fact is the margin translated g.chart element as you can see just one code block above), which also be the home of our planet circles we’ll draw later.

Scales next.

Setting up our scales

We are mapping two measures to screen coordinates: distance and radius. As such we need two scales. Here’s the first, mapping our planet’s radii in km to screen radii:

var rExtent = d3.extent(data, function(d) { return d.radius; }); 
var rScale = d3.scaleLinear() .domain([0, rExtent[1]]) .range([3, height/2 * 0.9]);

First, we get the radius scale. We calculate the domain and map these values to a range of 3px to a little less than half our window’s height, keeping the measures relative to the window.

Our second scale is the distance scale:

var xScale = d3.scaleLinear() .domain([0, maxDist]) .range([0, chartWidth]);

We map the data extent to the full chartWidth. If you mapped it to the screenWidth, all the planets would stand on their feet:

We could correct this by using a tighter radius scale, but we would like them to stretch out initially and then allow the user to zoom in or out.

Drawing the axis

We’ll be using a normal D3 axis component to build the axis. However, as you can see in the image above, we will stagger the labels so they don’t overlap.

First, we build out the axis component:

var xAxis = d3.axisBottom(xScale) .tickSizeOuter(0) .tickPadding(10) .tickValues(data.map(function(el) { return el.distance; })) .tickFormat(function(d, i) { return data[i].planet + ' ' + d3.format(',')(d) + ' km'; });

We determine the exact number of tick mark labels by passing an array of the planets’ distance values to .tickValues():

[0, 58000000, 108000000, 150000000, 228000000, 778000000, 1429000000, 2871000000, 4504000000, 5913000000]

The axis will now only draw tick labels for these values. We use .tickFormat() to specify what the label will say. In our case, it’ll be sun> .

Now we produce the axis’ g base and unleash the component on it:

var xAxisDraw = svg.insert('g', ':first-child') .attr('class', 'x axis') .call(xAxis);

Like our listenerRect, the axis becomes a child of our g.chart element we labelled svg. Why insert it? We want our zoom base to be on top of all other elements dangling off the svg so it can consume all the events. Looking at the DOM it should be the last child of svg. To achieve this, we’ll insert the axis — and soon the planets — before listenerRect.

Moving on to our axis labels. By default, all labels will be drawn on the same y level. But we want them staggered, so we need to write some code to achieve the steps. This is the stagger voodoo we apply:

// Move the axis-labels and -lines down 
var labelHeight = xAxisDraw.select('text').node().getBBox().height; 
xAxisDraw.attr('transform', 'translate(0, ' + (height + labelHeight * data.length) + ')'); 
// Position the axis text 
xAxisDraw.selectAll('text') .attr('y', function(d, i) { return -(i * labelHeight + labelHeight); }) .attr('dx', '-0.15em') .attr('dy', '1.15em') .style('text-anchor', 'start');

Don’t feel obliged to follow me down this rabbit hole — in short, we move them all down by # of labels × their label height. Then we move each label up by their height × their index. As a result, the Sun, for example, won’t move up as it’ll be lifted by 0 × labelHeight = 0, but Mercury (the next planet to the Sun) will move up by 1 × labelHeight and so on.

The tick line needs a little more attention, as we have to cater for its y1 and y2 value:

// Draw the axis lines 
xAxisDraw.selectAll('line') .attr('y1', function(d, i) { return -(i * labelHeight + labelHeight); }) .attr('y2', function(d, i) { return -(i * labelHeight + labelHeight + from axis-y 0 // ^ this label’s start position (data.length-1-i) * labelHeight + // ^ the distance from the start position // to the bottom of the chart area height); // ^ the height });

Good news. We can now draw our planets in (nearly) a single D3 chain:

var gPlanets = svg .insert('g', '.listener-rect') .attr('class', 'planet-group');
var planets = gPlanets.selectAll('.planet') .data(data) .enter().append('circle') .attr('class', 'planet') .attr('id', function(d) { return d.planet; }) .attr('cx', function(d) { return xScale(d.distance); }) .attr('cy', 0) .attr('r', function(d) { d.scaledRadius = rScale(d.radius); return d.scaledRadius; });

First, we create a group for all our planets and make sure the listenerRect also covers these planets by inserting our g.planet-group before the rect.listener-rect. Then we join and enter() the data to our as yet virtual .planet's, which will manifest as circles with the respectively scaled distances as x positions and rScaled radii. So there:

Original text


Great! We have our visual. Now let’s get to the zoom…

Identifying our zoom base and zoom targets ^

It’s often a wise idea to start by thinking about what you want to do before a head first code plunge. Before setting up our zoom, let’s identify what and how we want to zoom and pan. We ask 3 questions:

  1. What will be our zoom base — the “sensor element” that we’ll use for the zoom?
  2. What will be our zoom targets — the elements that we will move?
  3. What type of zoom do we want for each element — geometric or semantic zoom?

Identifying our zoom base

Let’s choose our zoom base element first. You can attach the zoom to an svg, g, rect or any other element that your mouse has access to. Note here that g elements can only register events where they have children with a set fill property. So, if you have a large g element with a circle of radius 1, your zoom gestures will only work on that tiny circle.

As such, it’s often wise to set up a dedicated rect with fill, but 0 opacity. You have to make sure that the zoom base can consume all events. So, it should either be on top of all other elements, or its pointer-events should be set to all while all other elements’ pointer-events are set to none.

In fact, we already totally decided to set up an extra rect element to listen to events. We wisely cached it in the listenerRect variable, which we can refer to upon set-up. Done.

Identifying our zoom targets

Now let’s identify our target elements and write them down. Which elements do we want to move when we zoom and pan? Let’s make a list:

  • The planets
  • The axis and all their elements (tick lines and tick text only; we’re not showing the axis path).

Now we know our zoom base and our targets, we want to make sure they share the same coordinate system at the initial zoom state — when no zoom or pan has happened yet. That’s why we attached the zoom base and targets (planets, axis) to the same g above.

This is going really well!

Identifying the type of zoom

Lastly, let’s decide how we want to zoom them — geometrically or semantically? First of all, this distinction only makes sense for zooming, not panning. We’ve defined it above, but for the purpose of redundant completeness, let’s repeat that Geometric zoom is simple: all elements are just being scaled up or down uniformly. Semantic zoom is a little more elaborate, as you can decide what you want to scale up or down.

In our case, we might want to scale up the size of the planets, but keep the line width at 4px. For that we would need semantic zoom. For our educational purposes, let’s implement both types! Why not?

Setting up the zoom

For any zoom we decide to implement, we will need to set it up first. You’ll probably agree that it couldn’t be less complex:

var zoom = d3.zoom() .on('zoom', zoomed);

Calling d3.zoom() will return an object and a function. As with many parts of the D3 API, the object allows us to configure the variables we use in the function. So what we do up there is configure the use of the d3.zoom() function with a single method: .on() attaches a handler function called zoomed. zoomed will be called every time we zoom. This is where we’ll make the elements move.

We have two other zoom cycle events to trigger a function, start and end. It should be relatively easy to guess when they would trigger the callback.

We store the returned function in the creatively named variable zoom. Next, we can use this function as zoom(t>) or, as it’s more commonly done in D3 ;.call(zoom) like so:

listenerRect.call(zoom);

That’s great, but what does that mean? It means that the listenerRect is now the official home of our zoom. Our zoom base! At this very moment, it has two things dangling off it: the .on() event and the zoom transform. If we console.dir(d3.select(‘#listener-rect’).node()) and check our attributes, we’ll find these two D3 properties at the very bottom of the list:

The __on object holds our listener information, and the __zoom object is a transform object holding the 3 values we discussed in the beginning of this pot: the x and y translation when we zoom and pan, and the scale factor k changing upon zoom.

You can always come to your zoom base — the listenerRect for us — to query the current transform values. However, you don’t need to do so very often, as the transform will be handily accessible in the event object from within our zoomed handler function. Right. For the love of our lives — let’s finally zoom.

Geometric zoom with SVG

We have our static visual. We’ve set up the zoom. We’ve attached it to the zoom base. Let’s finally decide which type of zoom we’re going for. Here’s the thing: axes should be zoomed semantically, you decide for the other elements. Going back to our zoom targets, let’s decree this here in a table on a piece of parchment:

Now, let’s write the zoom handler:

function zoomed() { 
 var transform = d3.event.transform; 
 gPlanets.attr('transform', transform.toString()); 
}

We’re not quite done yet, but this is the simplest zoom possible and will already move our planets. We cache the transform object that dangles off the d3.event object which gets passed in on every zoom and pan move in the variable transform. Then we move our planets by just updating the transform attribute of our circles.

transform.toString() is just a convenience method the transform object gives us. It saves us from having to type out the transform attribute’s value. For the identity transform { k: 1, x: 0, y: 0 } it returns the string "translate(0, 0) scale(1)"

How will this look?

Very good! The planets are moving — the rest is not. We need to do 3 things to improve this:

  1. Let’s prohibit the planets from moving to the right (there’s no planet left of the sun, so it would be futile).
  2. Let’s also prohibit the planets from moving up and down.
  3. Move the scales.

1 and 2 are simple; we just manipulate the transform object before we use it like so:

function zoomed() { 
 var transform = d3.event.transform; transform.x = Math.min(0, transform.x); transform.y = 0; 
 gPlanets.attr('transform', transform.toString()); 
}

As a result, x is never higher than 0, and therefore we can’t move the thing to the right. Also, y will always be 0. The result does what we expect:

Next, let’s make the axis move semantically. Our axis consists of labels and lines. We choose semantic over geometric zoom, as we only want to change their position on zoom — not the label size or the line width.

The main positioning engine behind the axis’ elements — the thing that makes the labels and lines move — is the scale. And what does the scale do? The scale maps our data values to the width of our svg element. If we want to change a scale with D3, we usually update the scale’s domain and/or range. But as rescaling axes is such a common activity for D3 zoom, we have the rescaleX() and rescaleY() methods dangling off the transform object. It updates the mapping for us according to the zoom. Perfect syntactic sugar we can use to create an updated scale:

var xScaleNew = transform.rescaleX(xScale);

The next section is called Semantic Zoom with SVG and will carelessly open the hood of this rescaleX() method in much more detail. But for now, let's just use xScaleNew trustingly like so:

xAxis.scale(xScaleNew); xAxisDraw.call(xAxis);

We update the scale of our xAxis and redraw the axis with our new axis component. The last thing we need to do to the axis is stagger our labels and lines again, as we’ve done above.

// Stagger the axis-labels xAxisDraw.selectAll('text') .attr('y', function(d, i) { return -(i * labelHeight + labelHeight); }) 
// Stagger the axis-lines xAxisDraw.selectAll('line') .attr('y1', function(d, i) { return -(i * labelHeight + labelHeight); }) .attr('y2', function(d, i) { return -(i * labelHeight + labelHeight + (data.length-1-i) * labelHeight + height); });

Remember, all of this happens in our zoomed handler.

It works:

Semantic zoom with SVG

This headline comes a bit late. We have semantically zoomed our axis already. But now let’s also apply it to our planets and dive into the rescaling process. Here’s our updated prep table:

Semantic zoom of circles

First of all, why would we want to use semantic zoom on the planets? I guess the above gif demonstrates the semantic need pretty well. As the planets get smaller, their outline is nearly impossible to see. With semantic zoom, we will have control over which element properties change or remain. In our case, zoom should change the position as well as the size of our planets, but the width of the outline stroke should stay constant at 4px.

What we do is simple:

function zoomed() { 
 var transform = d3.event.transform; 
 transform.x = Math.min(0, transform.x); 
 var xScaleNew = transform.rescaleX(xScale); 
 planets .attr('cx', function(d) { return xScaleNew(d.distance); }) .attr('r', function(d) { return d.scaledRadius * transform.k; }); 
 // Zoom and pan the axis here (…) 
}

First, we remove our geometric planet zoom. Then we grab our planets and, instead of transforming them, we specifically only access their cx and the r attributes. The x position will be re-calculated with the updated xScaleNew and the radius just needs to be multiplied by the scale factor. No translation necessary here.

And that’s it:

However far we zoom in or out, our stroke remains at 4px allowing us to actually see our planets even if they’re fully zoomed out.

Understanding zoom rescale

Semantic zoom requires us to zoom and pan properties selectively. Our semantic planet zoom above only changed the cx and the r attribute, while keeping the stroke width at 4px. To specifically change cx, we needed to update our scale — the main positioning engine of our visualisation — so that it positions our elements according to the new transform.

As said, D3 offers the convenience methods rescaleX() and rescaleY() to update scales according to the transform. Of course it’s perfectly fine to use these methods without knowing the inner workings, so please feel free to jump straight to the next section. But if you’re curious about how exactly the rescale happens, stay with me. There'll be images in color, too.

We’ll use a real simple example. Let’s assume we only look at the x-dimension, and we want to map a data space that covers a domain from 0 to 100 to a 1000 pixel wide screen. As such we have a data domain of [0, 100] we want to map to a width range of [0, 1000]. Our scale would look like this:

var xScale = d3.scaleLinear() .domain([0, 100]) .range([0, 1000]);

Let’s also assume that we have a single circle with the data value 20, which would be mapped to the pixel value 200:

Easy. Now we zoom in so that our scale factor k will be 2. No translation, just zoom. As a result, our circle would move according to our zoom transform formula we started this post with: tx + x × k, which would result in 0 + 200 × 2 = 400:

Note, we’ve also scaled up its radius by 2. All good so far? Great.

In this case, we could just do our transform calculation for the circle. But it’s much simpler, more convenient, and more consistent to continue to use our scale. However, we need to update it, as our data value 10 shouldn’t scale to 100px anymore but to 200px!

How do we do this? As we’ve done above, we just pass our xScale to the transform.rescaleX() function. This returns the respectivley updated newXScale, which we use on the circle’s data value to determine the cx position:

var newXScale = transform.rescaleX(xScale); 
circle.attr(‘cx’, function(d) { return d.dataValue; }); // note: d.dataValue is from a fictitious dataset

But what exactly does this rescale do? Let’s look at the source code first before considering its logic. A rescale under the hood looks like so:

function rescaleX(x) { 
 var range = x.range().map(transform.invertX, transform), 
 domain = range.map(x.invert, x); 
 return x.copy().domain(domain); 
}

As you can see in the last line, it will return the original scale BUT with an updated domain. The range will remain as is. If you asked me before I looked at this code, I would’ve guessed D3 would update the range and keep the domain as is. Much more direct. But it’s the other way around. This makes sense, as the pixel range is a more static concept. In our case, 1000 is the width of the screen — that won’t change upon zoom.

The (small) downside is that the new domain calculation is slightly more involved than a new range calculation would be. There are 4 steps involved in calculating the new domain at each zoom and pan move:

  1. We first take the range of our original scale. In our example that would be [0, 1000].
  2. We then apply the inverse transform to it, which will return [0, 500].
  3. Next we will use the scale’s .invert() method to find the data value associated with the range values 0 and 500, which will be [0 and 50] in our case.
  4. Finally, we override the current x-scale domain with this new domain and return it.

But why? Let’s consider this conceptually…

First, we calculate a new range by taking the inverse of our transform function for the x value. By now we know the zoom transform function for x is tx + x × k. Its inverse is (x — tx) / k.

If you never came across inverse functions, they are just the opposite — the reverse of their main function. If you had f(x) = 3 × x then the inverse is g(y) = y/3. Plugging 2 into the main function f(x) returns 6 — plugging this 6 into the inverse function g(y) returns 2 again. It reverses the process of the main function.

Why do we take the inverse on our range? We want to adjust the domain, but keep the range at [0, 1000]. The easiest way to get the updated domain is to first calculate updated range extent values (min and max) in order to derive the new domain extent values from them.

Let’s play this through with a single value. Let’s take our maximum range value of 1000. Our current scale maps the maximum data value of 100 to the maximum range value of 1000 pixels.

What’s the max range value when we scale by 2? Scaling by 2 means we’re zooming in. So, our current max range value of 1000 will move to 2000 (0 + 1000 × 2). However, we would like to know the new pixel point that moves to the edge of our screen when we zoom. The previous point that was at 1000 and is now at 2000 is no help to us as it’s beyond the screen area now. So, which point is at the edge of our window after we zoomed? Which point is our new maximum range value?

In order to get that point, we don’t ask: where does our current max range value of 1000 zoom to? We ask, where does the new max range value come from! Logically, this is the opposite or the INVERSE question. Accordingly, we apply the inverse zoom transformation: (x — tx) / k. We plug in our previous max range value of 1000px, our tx of 0 and scale k of 2 to get: (1000–0) / 2 = 500.

We can now say that our new maximum range value would come from the pixel position 500.

Why did we do this again? Isn’t this all a bit silly as we want to keep the range at [0, 1000] anyway? Yes. And no. It’s not silly, because we don’t use this new maximum range value in a new range input for our scale. We just use it to find our new maximum data domain value.

We take our original scale that mapped a data value of 0 to 0 pixel, a data value of 100 to 1000 pixel and all in-between values accordingly. Now we ask which data value maps to the pixel value of 500? For this simple case we can use our brain, or — much better — we use the .invert() method of our original x-scale. xScale.invert(500) will return 50 as probably expected.

Let’s remember here that we still have our original range of [0, 1000]. All the range calculations we have done were only done in order to get to the new domain. Our new x-scale still maps the data value 0 to pixel 0, but now maps the new maximum data domain value of 50 to the loyally standing maximum range value of 1000.

Likewise, our circle center x value still has the data value of 10, which now doesn’t map to 100 but to 200. We successfully zoomed in, we did.

Well done! Now, onwards to Canvas. Same game — different board…

Geometric zoom with Canvas ^

We only have 10 circles on our site. However, there are of course a great many more orbs out there to visualize. Visualizing more than 1000 of them might get you into render performance troubles, which you can attempt to cure with Canvas.

Unlike SVG, Canvas produces a single bitmap of your drawing. 1000 planets on your screen will be drawn to a single DOM element, the canvas. In SVG 1000 planets will produce 1000 circle elements that the browser has to maintain, which affects performance. There’s a list of Canvas resources in the sources section below if you want to know more, but don’t worry, you don’t need a Canvas degree to follow along.

We will change very little in our app. As a quick reminder, here are the main steps we’ve folowed to get here:

  1. Load data
  2. Calculate the dimensions of our visual
  3. Build the SVG base and the listener rectangle
  4. Calculate the scales
  5. Define and draw the axis
  6. Build the SVG visual
  7. Zoom

We’ll change points 3, 6 and 7 above and leave the rest unchanged. In fact, we won’t produce a pure Canvas drawing, but rather will draw the planets in Canvas and keep the axes in SVG. This is called Mixed-mode rendering, and is really clever if you have axes to draw. Drawing axes is wonderfully solved by D3 in SVG but can be a pain in Canvas. (Elijah Meeks dedicates a good section to Mixed-mode rendering in chapter 11 of his book D3js in Action)

Adding a canvas base

As with SVG, we need a base to draw on. For Canvas we need two things, the canvas element and its drawing context — the tools we can use to draw on the canvas. Below our svg base we add the following Canvas base snippet:

var canvas = d3.select('#vis').append('canvas') .attr('width', screenWidth + margin.left + margin.right) .attr('height', height + margin.top + margin.bottom); 
var context = canvas.node().getContext('2d');

It’s often wise to skip the margin convention for Canvas (we don’t have a g we can move around). But, especially when drawing SVG axes, we want to cling on to our margins.

We also want to overlay our canvas element perfectly over our svg element and its children, the planet g and the listenerRect. To achieve this, we need to give it the same size as the svg element and position the canvas absolute on top of the svg. Here’s our CSS:

canvas { position: absolute; top: 0; left: 0; pointer-events: none; }

Notice that we also remove all pointer-events from our canvas so that the listenerRect receives all gestures. As a result, we have quite a few layers:

The g now only holds our axis, which we can view through our svg. The canvas will display our planets, but only the section in green above (the other planets are drawn here for completeness but will initially be invisible). The top level is the listenerRect consuming all pointer events and informing our zoom and pan.

Drawing the planet circles in Canvas

We remove the logic that built out the SVG planets, and instead draw our Canvas circles. We will draw it in a single function. Let me first show you the code of this Canvas drawing function before running you through it. Here we go:

function drawGeometricCircles(data, transform) {

We pass our data and the transform. If we only wanted to build a static visual we wouldn’t need to worry about the transform, but zooming is very much our mission!

 context.clearRect(0, 0, screenWidth + margin.left + margin.right, height + margin.top + margin.bottom);

Next, we access our Canvas context (we cached in the context variable) and run a method called .clearRect. You can surely guess what it does — it clears the canvas. We pass it the canvas dimensions, which will clear the canvas every time we call this function.

This is what we do with Canvas. Unlike in SVG where we have manifest nodes in the DOM for our circles, we only have a pixel image on our canvas. Instead of moving around a DOM node, we just remove the image we drew earlier and draw a new image with elements in slightly different positions. That’s Canvas for you.

 context.save();

Then we .save() the default and unchanged context, and we .restore() it in a moment after all drawing is done. This way we secure not only a blank canvas slate, but also a blank context slate whenever we draw a new planet.

 context.lineWidth = 4; context.strokeStyle = 'deeppink'; context.fillStyle = 'white';

Next, we define our painting brushes. We want a line width of 4px, we want a stroke color of deeppink and a fill of white. These aesthetic properties will apply to everything we draw after we set them. Until we change them.

 context.translate(transform.x + margin.left, margin.top); context.scale(transform.k, transform.k);

These next two lines are the geometric zoom. We translate and scale the entire image we draw by the respective transform values.

 for (var i = 0; i < data.length; i++) {
 context.beginPath(); context.arc(xScale(data[i].distance), 0, rScale(data[i].radius), 0, 2 * Math.PI, false); context.stroke(); context.fill(); 
 context.fill(); 
 } 
 context.restore(); 
}

Finally, we draw the circles. If you haven’t seen much of Canvas yet, this might look a little raw. And indeed, D3 internalizes this loop through the elements for us by joining the data to selections that we can subsequently access, position, and style.

With Canvas, we do this ourselves. We loop through the data, start a path, draw the path as a circle with the context.arc() method, and finally stroke and fill the path.

The rest is a piece of code. We just need to call it right here, and then with our data and the identity, transform, which is simply { k: 1, x: 0, y: 0 }:

drawGeometricCircles(data, d3.zoomIdentity);

Whenever we zoom, we replace the code that moved our SVG planets with this:

drawGeometricCircles(data, transform);

I’ll spare you the gif as it looks exactly like what we’ve seen above with geometric SVG zoom. But the working implementation with code is just a click away!

Semantic zoom with Canvas

Let’s celebrate our geometric zoom feat by getting rid of it. In fact, to achieve semantic instead of geometric zoom, we will just rename and change our draw function. We will call it appropriately drawSemanticCircles().

Changing from geometric to semantic zoom in Canvas requires the same high-level actions. Instead of translating and scaling the planet’s coordinate system, we will change the planet’s positions and radius according to the transforms.

drawSemanticCircles() will clear our canvas and then draw all circles with drawCircle():

function drawSemanticCircles(data, transform) { 
 context.clearRect(0, 0, screenWidth + margin.left + margin.right, height + margin.top + margin.bottom); 
 for (var i = 0; i < data.length; i++) { drawCircle(data[i], transform); } 
}

drawCircle() will be run for each data element, taking the data element and the current transform:

function drawCircle(elem, transform) { 
 var x = (transform.x + transform.k * xScale(elem.distance)) + margin.left; var y = margin.top; var r = transform.k * rScale(elem.radius); 
 context.lineWidth = 4; context.strokeStyle = 'deeppink'; context.fillStyle = 'white'; 
 context.beginPath(); context.arc(x, y, r, 0, 2 * Math.PI); context.stroke(); context.fill(); 
}

We first determine the x and the y positions as well as the radius r. Then we define the styles for our circles. Lastly, we draw our galactic spheres as arcs. And that’s it…

Great! We’ve covered the two types of zoom in two renderers. On to the bonus tracks: programmatic zoom and making our galaxy pretty…

Programmatic zoom

It’s often helpful to move our visuals into a certain position. You can let a user center a map, move a long bar chart to the beginning, or zoom in and out of the solar system.

We have neither a map nor a bar chart, so let’s programmatically zoom out and back into our planets upon load. We go back to SVG for this, as we don’t really need Canvas here. Because of its lower level, I’d recommend using Canvas only if you need it or speak it like your mother tongue. As we only have 10 circles to move around here, we don’t need it.

Here’s what we want to achieve:

We start with a heavily zoomed-in visual at a zoom scale of 20. We then zoom out to our minimum zoom, so all planets fit comfortably on the page. Lastly, we zoom back in to our default zoom scale of 1.

To achieve this, we bolt on the programmatic logic to the bottom of our make() function where all our app code lives. We start by zooming in to a scale factor of 20 without panning:

var initialTransform = d3.zoomIdentity.scale(20); listenerRect.call(zoom.transform, initialTransform);

d3.zoomIdentity returns the identity transform we have already encountered a few times. We change the transform scale to 20 and cache it in initialTransform. Then we use the zoom.transform() function. This function is obviously different from our transform object, but it directly manipulates it. We use it here with D3's own .call() method we encountered above. The selection we call zoom.transform() on will be its first argument. It will be our zoom base listenerRect, home to our current transform object. The second argument has to be a new transform object. It will replace the current transform on that node.

The cherry on top is that instead of passing our zoom base as a simple selection, we can pass it as a transition. Remember (or note) that transitions are just derived selections, so passing in listenerRect.transition() will in fact transition our visual from one transform to the other.

But so far, we’ve just snapped our visual to a scale of 20. Let’s kick off the transition. First to a scale of minZoom we have defined earlier, then to a scale of 1. Here’s what we do:

// Trigger programmatic zoom progZoom()

Let’s write it. It won’t take any arguments:

function progZoom() {

We first define the transform for the minZoom we want to zoom to first:

var zoomOutTransform = d3.zoomIdentity.scale(minZoom);

In the following lines, we turn our listenerRect into a transition and call zoomTransform() again. Using .call() we pass in the transition we just built as a first argument and zoomOutTransform — the minZoom transform we just saved:

listenerRect .transition() .duration(5000) .call(zoom.transform, zoomOutTransform) .on('end', zoomToNormal)

At the end of the zoom we call a function called zoomToNormal. It does exactly what we just have done, apart from transition-zooming to an identity transform:

function zoomToNormal() { listenerRect .transition() .duration(3000) .ease(d3.easeQuadInOut) .call(zoom.transform, d3.zoomIdentity) }

Apart from zooming to a different transform, we’re also setting a different duration as well as a different easing function.

}

And that was our first bonus track. On to track two…

Making our visual pretty

It’s wise to get your visuals right in black and white first (pink and white in our case). But in the end, a lick of paint can’t hurt. In order to get here…

…we only need to change a few things, of which the planet’s glow is probably the most elaborate. Let’s look at the rest first:

We’ll add a dark blue background with a radial gradient moving into the dark blue from a slightly lighter one. It’s one line in our body CSS:

body { font-family: Avenir, sans-serif; sans-serif; font-size: 0.75rem; margin: 0; background: radial-gradient(#091C33, #091426); }

We change the text and line colour to a grey off-white (#ddd), and instead of the solid lines, we render dashed lines with wide gaps:

.tick line, .lines { stroke: #ddd; stroke-width: 0.5; shape-rendering: crispEdges; stroke-dasharray: 1,5; }

Lastly, we fill the planets with our favourite deeppink and add the glow. The glow is an SVG filter we apply to each planet. I won’t go into detail here, but you can find the code commented right here. In short, we thicken the planets a little bit before feathering them with some Gaussian blur. We fill the blur deeppink and marvel at the resulting glow. The filter gets an id of #soft-glow, which our planets can reference with the filter attribute:

var planets = gPlanets.selectAll('.planet') .data(data) .enter().append('circle') .attr('class', 'planet') // (…) .attr('filter', 'url(#soft-glow)');

And that’s it!

We’ve come a long way, and hopefully you now understand D3 zoom a little better. We’ve looked into a short recipe you can follow before and during wiring your visual up with any zooming and panning. We then applied this blueprint to a real project with pink orbs, playing through geometric and semantic zoom rendering in SVG as well as Canvas. As a bonus, we looked at programmatic zoom and finally made its subtly pink face even more pink. What fun!

Two more things that might help: a quick note on updating your zoom from D3 v3 to v4, and a list of sources.

Updating zoom from v3 to v4

In 2016 (as in many generations ago) D3 v4 superseded v3 with some great but breaking changes. Some conceptual changes including the zoom behaviour kept devs up at night (including myself). The changes are consistent and sensible, but are worth a few extra notes that might help you find sleep:

  • As with v3, zoom in v4 is just about the x and y translation and the scale — the transform parameters. That is, of course, brutally simplifying complexity, but it’s a mantra you should try out when gridlocked.
  • The transform parameters are stored with the zoom base in v4, while they were stored with the behavior in v3. The behavior now just passes the transform on to the targets. This is good to know when we want to retrieve the transform outside of the zoom handler.
  • The v3 behaviour rescaled your scale automatically. In v4 you need to rescale your scale in the zoom function manually, and update all scale-based shapes and components. This is a little more work, but significantly less magic and a clearer separation of concerns.

Sources ^ ^

There is no abundance of D3 (v4) zoom related posts and tutorials out there. The lack thereof was one reason to write this tutorial. However, there are a few zoom gems as well as some helpful further Canvas related material you can have a look at:

Article additions:

  • GitHub repo for all code we went through in this article
  • All the steps we took above as working apps with code

Zoom tutorials:

  • Zoom explained by Empty Pipes
  • Zoom explained by Puzzlr
  • Zoom with React and D3

Zoom tech:

  • Mike Bostock’s zoom examples
  • Geometric vs Semantic Zoom
  • D3 v4 Zoom API Referene

Canvas:

  • D3 and Canvas (shameless self-reference)
  • More D3 and Canvas
  • And even more D3 and Canvas

I truly hope you enjoyed reading this. Please clap if you want to spread the word, follow me on Twitter and do say hello to either just say hello or tell me about other ways to zoom.

Knowledge is partial and we’re all here to learn…

Originally published at www.datamake.io.