Problem
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:
- Pan right 10 units
- Shift the domain of the scale 10 units.
That works, but if we pan again:
- Pan right 10 more units
- Shift the domain of the scale an additional 20 units.
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:
- Zoom in by 2x on the center of the graph (zoom transform scale = 2)
- Rescale the scale so that it has half the domain (is twice as detailed)
- Zoom in again by 2x on the center of the graph (zoom transform scale = 4)
- Rescale the scale so that it has one one fourth the domain that it currently has (which is already one half of the original, so we are now zoomed in 8x: 2x4).
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.
Solution
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.