I have a simple chart with time as the X axis. The intended behavior is that while dragging in the graph, the X axis only will pan to show other parts of the data.
For convenience, since my X axis is in a react component, the function that creates my chart sets the X scale, the x axis, and the element it is attached to as this.xScale
, this.xAxis
, and this.gX
, respectively.
If I set this as the content of my zoom method, everything works fine:
this.gX.call(this.xAxis.scale(d3.event.transform.rescaleX(this.xScale)))
The X axis moves smoothly with touch input. However, this doesn't work for me, because later when I update the chart (moving data points in response to the change of the axis), I need this.xAxis
to be changed so the points will map to different locations.
So, I then set the content of my zoom method to this:
this.xScale = d3.event.transform.rescaleX(this.xScale);
this.xAxis = this.xAxis.scale(this.xScale);
this.gX.call(this.xAxis);
As far as I can tell, this should function EXACTLY the same way. However, when I use this code, even without running my updateChart()
function (updating the data points), the X axis scales erratically when panning, way more than normal. My X axis is based on time, so suddenly a time domain from 2014 to 2018 includes the early 1920s.
What am I doing wrong?
When you use scale.rescaleX you are modifying a scale's domain based on a current zoom transform (based on translate and scale).
But, the transform returned from d3.event.transfrom
isn't the change from the previous zoom transform, it represents the cumulative transformation. We want to apply this transform on our original scale as the transform represents the change from the original state. However, you are applying this cumulative transform on a scale that was modified by previous zoom transforms:
this.xScale = d3.event.transform.rescaleX(this.xScale);
Let's work through what this does during a translate event such as panning:
That works, but if we pan again:
Why? Because the zoom transform is keeping track of the zoom state relative to the initial state, but you want to update the scale with only the change in state, not the cumulative change to the zoom transform. Consequently, at this point the domain has shifted 30 units, but the user has only panned 20.
The same thing happens with scale:
At step four, d3.event.transform.k == 4
, and rescaleX is now scaling the scale by a factor of four, it doesn't "know" that the scale has already been scaled by a factor of two.
It gets even worse if we continue to apply zooms, for example if we zoom out from k=4 to k=2, d3.event.transform.k == 2
, we are still zooming in 2x despite trying to zoom out, now we are at 16x: 2x4x2. If instead we zoom in, we get 64x (2x4x8)
This effect is particularly bad on a translate - the zoom even is triggered constantly throughout a pan event, so the scale is cumulatively reapplied on a scale that already has cumulatively applied the zoom transform. A pan can easily trigger dozens of zoom events. In the comparison snippet below, panning just a bit can easily pull you into the 1920s despite a starting domain of 2014-2018.
The easiest way to correct this (and the canonical way) is very similar to the approach you use in your code that works for panning (but not updating):
this.gX.call(this.xAxis.scale(d3.event.transform.rescaleX(this.xScale)))
What are we doing here? We are creating a new scale while keeping the original the same - d3.event.transform.rescaleX(this.xScale)
. We supply the new scale to the axis. But, as you note, when updating the graph you run into problems, xScale
isn't the scale used by the axis, as we now have two disparate scales.
The solution then is to use, what I call, a reference scale and a working scale. The reference scale will be used to update a working scale based on the current zoom transform. The working scale will be used whenever creating/updating axes or points. At the beginning, both scales will probably be the same so we can create the scale as so:
var xScale = d3.scaleLinear().domain(...).range(...) // working
var xScaleReference = xScale.copy(); // reference
We can update or place elements with xScale, as usual.
On zoom, we can update xScale (and the axis) with:
xScale = d3.event.transform.rescaleX(xScaleReference)
xAxis.scale(xScale);
selection.call(xAxis);
Here's a comparison, it has the same domain as you note, but it doesn't take long to get to the 1920s on the upper scale (which uses one scale). The bottom is much more as expected (and makes use of a working and reference scale):
var svg = d3.select("body")
.append("svg")
.attr("width", 400)
.attr("height", 200);
var parseTime = d3.timeParse("%Y")
var start = parseTime("2014");
var end = parseTime("2018");
///////////////////
// Single scale updated by zoom transform:
var a = d3.scaleTime()
.domain([start,end])
.range([20,380])
var aAxis = d3.axisBottom(a).ticks(5);
var aAxisG = svg.append("g")
.attr("transform","translate(0,30)")
.call(aAxis);
/////////////////
// Reference and working scale:
var b = d3.scaleTime()
.domain([start,end])
.range([20,380])
var bReference = b.copy();
var bAxis = d3.axisBottom(b).ticks(5);
var bAxisG = svg.append("g")
.attr("transform","translate(0,80)")
.call(bAxis);
/////////////////
// Zoom:
var zoom = d3.zoom()
.on("zoom", function() {
a = d3.event.transform.rescaleX(a);
b = d3.event.transform.rescaleX(bReference);
aAxisG.call(aAxis.scale(a));
bAxisG.call(bAxis.scale(b));
})
svg.call(zoom);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
We can see the same approach taken with Mike Bostock's examples such as this brush and zoom, where x2 and y2 represent the reference scales and x and y represent the working scales.