The nitty gritty of zooming in D3.js

Boston D3.js User Group Meetup

June 30th, 2016
Peter Kerpedjiev (@pkerpedjiev)

Why am I talking about panning and zooming?

  • It's useful
  • It's interesting
  • It ties together a number of key D3 concepts
    • Linear scales
    • Transforms (translate, scale)
    • Events
  • It took me a long time to learn

The key questions in any plot

  • What am I drawing?
    • An SVG circle
    • An SVG line
    • A div
    • A canvas object
  • Where am I drawing it?
    • 50 pixels from the top of the enclosing div
    • At (50px, 100px) in the current coordinate system
    • From 10 to 30 pixels from the right end of the div

Example:

Let's plot 4 points:

[1,1010,1020,5000]


What am I drawing?

Four SVG circles (and an axis)

                    
    var dataPoints = [1,1010,1020,5000];

    var circles = gMain.selectAll('circle')
    .data(dataPoints)
    .enter()
    .append('circle')
    .attr('r', 11)
                    
                    

Where am I drawing them?

Somewhere between 100 and 500 pixels within gMain, proportional to their value.

                    
    // create a scale for the data points
    var xScale = d3.scale.linear()
        .domain([0,5000])
        .range([100,500])

    gMain.selectAll('circle').attr('cx', function(d) { return xScale(d); });
                    
                    

Linear Scales:


            var xScale = d3.scale.linear()
                .domain([0,5000])
                .range([100,500])
            

Map a domain interval to a range interval


So a point with value 0 has a position... 100

And a point with value 2500 has a position... 300

And a point with value 5000 has a position... 500

Problem

Difficult to distinguish the points with values 1010 and 1020.


Where are they plotted?

                        xScale(1010) = 180.8
                        xScale(1020) = 181.6
                    

Less than a pixel apart

Solution

Use D3's zoom behavior to interactively enlarge a portion of the plot. The zoom behavior converts mouse events to two values:

  • A translate
  • A scale

Attempt 1

Zoom in by magnifying the plotting area...


                    
    var gZoom = gMain.append('g')

    var zoom = d3.behavior.zoom()
        .on('zoom', function() {
            gZoom.attr('transform', 'translate(' + zoom.translate()[0] + ',0)
                                     scale(' + zoom.scale() + ')')
        });
    gMain.call(zoom);
                    
                    

Now where are we plotting the circles?

                    
    gZoom.attr('transform', 'translate(' + zoom.translate()[0] + ',0)
                             scale(' + zoom.scale() + ')')
                    
                    

If we assume a zoom scale of 3 and a translation of -100:

                -100 + 3 * xScale(1010) = 442.4
                -100 + 3 * xScale(1020) = 444.8
                    

Geometric Zoom (Scale Everything)

  • Advantages:
    • Simple
    • Can increase the size of small details
  • Disadvantages
    • Leads to oversized glyphs
    • Doesn't resolve overlapping data

Can we do better?

  • Keep circles the same size
  • Increase the distance between them when zooming


This is semantic zooming

What's the difference?

Geometric Zoom


        function zoomed() {
            gZoom.attr('transform', 
            'translate(' + zoom.translate()[0] + ',0)
            scale(' + zoom.scale() + ')')
            }
                

Semantic Zoom


            function zoomed() {
                circles.attr('cx', 
                function(d) { return zoom.translate()[0]  
                + zoom.scale() * xScale(d); });
            }
                

Can we do even better?

Let the zoom behavior change the linear scale:


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

    function zoomed() {
        gAxis.call(xAxis);
        circles.attr('cx', function(d) { return xScale(d); });
    }
            

As compared to:


            function zoomed() {
                circles.attr('cx', 
                function(d) { return zoom.translate()[0]  
                + zoom.scale() * xScale(d); });
            }
                

Example:


            xScale = d3.scale.linear().domain([0,5000]).range([100,500])
            var zoom = d3.behavior.zoom()
                    .x(xScale)
            

            xScale(1010) // 180.8
            

            zoom.scale(3).translate([-100,0]);
            xScale(1010) // 442.4
            

What happened?

Initially, We used a linear scale to convert the point's positions (from 0 to 5000) to positions in our viewport (from 100 to 500 pixels)

Then we attached this scale to the zoom behavior so it would automatically be modified when zooming.


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

Zooming and Linear Scales

Zooming can change the domain of an associated scale:


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

Another way of looking at it

Zooming increases the size of the viewport relative to the data


What about the translate and scale?

The translate and scale are still there but they're used behind the scenes to adjust the xScale's domain.


Question

If we want to zoom to the domain [0,2000], which translate and scale do we need?


Remember that the original xScale had a domain of [0,5000] and a range of [100,500].

Answer

If we want to zoom to the domain [0,2000], which translate and scale do we need?


Let's first calculate the zoom scale:

                new_zoom_scale = old_domain_width / new_domain_width;
                    
                2.5  = 5000 / 2000;
                    

What about the translation?

Answer

If we want to zoom to the domain [0,2000], which translate and scale do we need? We need a scale of 2.5.


What translation do we need?

                point_out = translation + scale * xOrigScale(point_in);
                    
                100 = translation + 2.5 * xOrigScale(0);
                100 = translation + 2.5 * 100;
                100 = translation + 250;
                -150 = translation
                    

Is this correct?

Answer

If we want to zoom to the domain [0,2000], which translate and scale do we need? We need a scale of 2.5 and a translate of -150.

Question

If we want to constrain the visible area to the domain [0,2000], how do we do it?


Remember that the original xScale had a domain of [0,5000] and a range of [100,500].

Answer 1

Limit the translate and scale within the 'zoomed' function.

    var constrainedDomain = [0,2000]

    function zoomed() {
        var min_scale = (origXScale.domain()[1] - origXScale.domain()[0]) / 
                (constrainedDomain[1] - constrainedDomain[0]);

        if (zoom.scale() < min_scale)
            zoom.scale(min_scale);

        var max_translate = xScale.range()[0] - zoom.scale() * origXScale(constrainedDomain[0]) ;
        if (zoom.translate()[0] > max_translate )
            zoom.translate([max_translate, 0]);

        var min_translate = xScale.range()[1] - zoom.scale() * origXScale(constrainedDomain[1]);
        if (zoom.translate()[0] < min_translate )
            zoom.translate([min_translate, 0]);
            

Answer 2

Use D3 v4

        zoom.translateExtent([[0,0],[2000,0]])
            

D3 v4

  • Zoom transform is stored within elements (rather than in the zoom behavior)
  • Lots of convenience functions (invertX, translateBy, scaleBy, scaleTo, rescaleX)
  • Constrain the zooming translation (rather than just the scale)
  • Handy toString function for transforms
  • Better event handling (for combining with dragging)

Summary

  • Linear scale
  • Geometric zoom
  • Semantic zoom
  • Translation, scale
  • Linear scale rescaling

D3 v4 introduces a number of changes but the general idea is the same:

                        x_new = translate_x + scale * x_old
                    

Thanks for listening!

Thanks to Bocoup for hosting!