lwiseman lwiseman - 16 days ago 9
Javascript Question

Responsive D3 zoom behavior

I am using D3 to make a time-focused chart that makes use of the zoomable behavior. I'm following the pattern laid out in Mike Bostock's "Towards Reusable Charts" article, and, in addition, trying to make said chart responsive.

The reusable chart pattern allows me to simply call my chart in a setInterval to handle responsiveness. Some values, like width, are updated each call, others are encapsulated in a closure and set only on initial chart creation. One of the values that needs potential updating every call is the scale's range.

Furthermore, according to https://github.com/mbostock/d3/wiki/Zoom-Behavior, modifying the domain or range of a scale that is being automatically adjusted by a zoom behavior necessitates (re)specifying the scale to the zoom behavior (in addition, the zoom behavior's scale and translate values will be reset).

However, the following is the result I get by respecifying the scale to the zoom behavior whenever I modify the scale's range (and also updating the zoom with the latest scale and translate values):

http://jsfiddle.net/xf3fk8hu/



function test(config) {
var aspectRatio = 10 / 3;
var margin = { top: 0, right: 0, bottom: 30, left: 0 };
var current = new Date();
var xScale = d3.time.scale().domain([d3.time.year.offset(current, -1), current]);
var xAxis = d3.svg.axis().scale(xScale).ticks(5);
var currentScale = 1;
var currentTranslate = [0, 0];
var zoom = d3.behavior.zoom().x(xScale).on('zoom', function() {
currentScale = d3.event.scale;
currentTranslate = d3.event.translate;
d3.select(this.parentNode.parentNode.parentNode).call(result);
});
var result = function(selection) {
selection.each(function(data) {
var outerWidth = $(this).width();
var outerHeight = outerWidth / aspectRatio;
var width = outerWidth - margin.left - margin.right;
var height = outerHeight - margin.top - margin.bottom;
xScale.range([0, width]);
zoom.x(xScale).scale(currentScale).translate(currentTranslate);

var svg = d3.select(this).selectAll('svg').data([data]);
var svgEnter = svg.enter().append('svg');
svg.attr('width', outerWidth).attr('height', outerHeight);
var gEnter = svgEnter.append('g');
var g = svg.select('g').attr('transform', 'translate(' + margin.left + ' ' + margin.top + ')');
gEnter.append('rect').attr('class', 'background').style('fill', '#F4F4F4').call(zoom);
g.select('rect.background').attr('width', width).attr('height', height);

var rectItem = g.selectAll('rect.item').data(function(d) {
return d;
});
rectItem.enter().append('rect').attr('class', 'item').style('fill', '#00F');
rectItem.attr('x', function(d) {
return xScale(d);
}).attr('width', xScale(d3.time.day.offset(xScale.invert(0), 7))).attr('height', height);

gEnter.append('g');
g.select('g').attr('transform', 'translate(0 ' + height + ')').call(xAxis);
});
};
return result;
}

setInterval(function() {
var selection = d3.select('#main').datum(d3.range(5).map(function() {
var current = new Date();
var mean = -d3.time.minute.range(current, d3.time.month.offset(current, 6)).length;
var deviation = d3.time.minute.range(current, d3.time.month.offset(current, 1)).length;
var random = d3.random.normal(mean, deviation);
return function() {
return d3.time.minute.offset(current, random());
};
}()));
var myTest = test();
return function() {
selection.call(myTest);
};
}(), 1000 / 60);

<div id="main"></div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>





I can get very close by trying to minimally respecify the scale to the zoom behavior. The following works until I resize the window, then the zoom focus isn't lined up with the mouse anymore:

http://jsfiddle.net/xf3fk8hu/1/



function test(config) {
var aspectRatio = 10 / 3;
var margin = { top: 0, right: 0, bottom: 30, left: 0 };
var current = new Date();
var xScale = d3.time.scale().domain([d3.time.year.offset(current, -1), current]);
var isZoomControllingScale = false;
var xAxis = d3.svg.axis().scale(xScale).ticks(5);
var currentScale = 1;
var currentTranslate = [0, 0];
var zoom = d3.behavior.zoom().on('zoom', function() {
currentScale = d3.event.scale;
currentTranslate = d3.event.translate;
d3.select(this.parentNode.parentNode.parentNode).call(result);
});
var result = function(selection) {
selection.each(function(data) {
var outerWidth = $(this).width();
var outerHeight = outerWidth / aspectRatio;
var width = outerWidth - margin.left - margin.right;
var height = outerHeight - margin.top - margin.bottom;
xScale.range([0, width]);
if(!isZoomControllingScale) {
isZoomControllingScale = true;
zoom.x(xScale).scale(currentScale).translate(currentTranslate);
}

var svg = d3.select(this).selectAll('svg').data([data]);
var svgEnter = svg.enter().append('svg');
svg.attr('width', outerWidth).attr('height', outerHeight);
var gEnter = svgEnter.append('g');
var g = svg.select('g').attr('transform', 'translate(' + margin.left + ' ' + margin.top + ')');
gEnter.append('rect').attr('class', 'background').style('fill', '#F4F4F4').call(zoom);
g.select('rect.background').attr('width', width).attr('height', height);

var rectItem = g.selectAll('rect.item').data(function(d) {
return d;
});
rectItem.enter().append('rect').attr('class', 'item').style('fill', '#00F');
rectItem.attr('x', function(d) {
return xScale(d);
}).attr('width', xScale(d3.time.day.offset(xScale.invert(0), 7))).attr('height', height);

gEnter.append('g');
g.select('g').attr('transform', 'translate(0 ' + height + ')').call(xAxis);
});
};
return result;
}

setInterval(function() {
var selection = d3.select('#main').datum(d3.range(5).map(function() {
var current = new Date();
var mean = -d3.time.minute.range(current, d3.time.month.offset(current, 6)).length;
var deviation = d3.time.minute.range(current, d3.time.month.offset(current, 1)).length;
var random = d3.random.normal(mean, deviation);
return function() {
return d3.time.minute.offset(current, random());
};
}()));
var myTest = test();
return function() {
selection.call(myTest);
};
}(), 1000 / 60);

<div id="main"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>





How can use the zoom behavior in a responsive and reusable way?

Answer

I solved the problem of the zoom focus not lining up with the mouse after resizing, by respecifying the scale to the zoom behavior only after detecting a change in width. However, there was still a problem with the new width causing an erroneous translation during resize. I found the answer to that at d3 Preserve scale/translate after resetting range, which leads me to a solution:

http://jsfiddle.net/xf3fk8hu/5/

function test(config) {
    var aspectRatio = 10 / 3;
    var margin = { top: 0, right: 0, bottom: 30, left: 0 };
    var current = new Date();
    var xScale = d3.time.scale();
    var xAxis = d3.svg.axis().scale(xScale).ticks(5);
    var zoom = d3.behavior.zoom().x(xScale).on('zoom', function() {
        currentScale = d3.event.scale;
        currentTranslate = d3.event.translate;
        d3.select(this.parentNode.parentNode.parentNode).call(result);
    });
    var currentScale = zoom.scale();
    var currentTranslate = zoom.translate();
    var oldWidth;
    var result = function(selection) {
        selection.each(function(data) {
            var outerWidth = $(this).width();
            var outerHeight = outerWidth / aspectRatio;
            var width = outerWidth - margin.left - margin.right;
            var height = outerHeight - margin.top - margin.bottom;
            if(oldWidth !== width) {
                if(oldWidth === undefined) oldWidth = width;
                currentTranslate[0] *= width / oldWidth;
                xScale.domain([d3.time.year.offset(current, -1), current]).range([0, width]);
                zoom.x(xScale).scale(currentScale).translate(currentTranslate);
            }
            oldWidth = width;

            var svg = d3.select(this).selectAll('svg').data([data]);
            var svgEnter = svg.enter().append('svg');
            svg.attr('width', outerWidth).attr('height', outerHeight);
            var gEnter = svgEnter.append('g');
            var g = svg.select('g').attr('transform', 'translate(' + margin.left + ' ' + margin.top + ')');
            gEnter.append('rect').attr('class', 'background').style('fill', '#F4F4F4').call(zoom);
            g.select('rect.background').attr('width', width).attr('height', height);

            var rectItem = g.selectAll('rect.item').data(function(d) {
                return d;
            });
            rectItem.enter().append('rect').attr('class', 'item').style('fill', '#00F');
            rectItem.attr('x', function(d) {
                return xScale(d);
            }).attr('width', xScale(d3.time.day.offset(xScale.invert(0), 7))).attr('height', height);

            gEnter.append('g');
            g.select('g').attr('transform', 'translate(0 ' + height + ')').call(xAxis);
        });
    };
    return result;
}

setInterval(function() {
    var selection = d3.select('#main').datum(d3.range(5).map(function() {
        var current = new Date();
        var mean = -d3.time.minute.range(current, d3.time.month.offset(current, 6)).length;
        var deviation = d3.time.minute.range(current, d3.time.month.offset(current, 1)).length;
        var random = d3.random.normal(mean, deviation);
        return function() {
            return d3.time.minute.offset(current, random());
        };
    }()));
    var myTest = test();
    return function() {
    	selection.call(myTest);
    };
}(), 1000 / 60);
<div id="main"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>