d3 / d3-zoom

Pan and zoom SVG, HTML or Canvas using mouse or touch input.
https://d3js.org/d3-zoom
ISC License
505 stars 143 forks source link

Resizing the window produces an offset when zooming and panning #136

Closed NJRBailey closed 5 years ago

NJRBailey commented 6 years ago

What is happening

We have a chart with a d3.scaleTime as the x-axis, and a d3.scaleBand as the y-axis. We only zoom on the x-axis. When we zoom the chart, there is a 1:1 movement with the mouse cursor. When the window is resized horizontally, the x-axis range is recalculated, so that the chart will shrink or expand to fill the space - however, this results in the zoom having an offset (the mouse moves a longer or shorter distance than the chart when panning, and when zooming, the zoom will focus in on a point to the left or right of the mouse cursor). A block to demonstrate this can be found here: https://bl.ocks.org/NJRBailey/3d555173b13cd1de71f101885f16dba4/442012d6d3de6f6fca81ef1deb605f39878fff29

What should be happening

The zoom should stay 1:1 with the mouse (move the same distance, zoom in/out on the mouse position) after window resize.

How to reproduce

  1. Load block in its own window.
  2. Resize the window - the bug will be more obvious with a larger resize.
  3. Try to zoom on a point - you will see that the chart zooms towards a different point than the mouse is currently at. Also, if you pan the chart, you will see that the chart is moving faster or slower than the mouse cursor (try comparing with one of the axis ticks for reference).

Notes

This is a question that has been asked on Stack Overflow (https://stackoverflow.com/questions/39735367/d3-zoom-behavior-when-window-is-resized) but which has no answers, so I thought I'd post it as an issue here, as I haven't been able to find a solution in about five days of searching and trying.

I think (though I'm not sure at all) that this is caused by the zoom behaviour's transform not updating when the window is resized. For example, if the window is made wider (and the range is updated to fill the new space), the scale and translate will stay the same, but the zoom operations will still act as though the range was spread across the same amount of pixels. However, it isn't possible to change the scale and translate accordingly without also triggering the zoom operations (e.g. if we try to set the zoom scale to a specific number in an attempt to counteract the resize error, by using zoom.scaleTo() or zoom.scaleBy(), or by setting the whole zoom.transform(), it will perform the scaling operation instead of just updating the numbers - this means that the operation will just be carried out with the offset, and doesn't get us anywhere).

nitely commented 5 years ago

I think (though I'm not sure at all) that this is caused by the zoom behaviour's transform not updating when the window is resized.

Yes, this is exactly it. You must update the state yourself. Something like:

function onResize() {
  // start/end is the start date and end date of the current zoom
  var s = [startDate, endDate].map(xScale2)
  // timeline.select(".zoom").call(...) in your case
  svg.select(".zoom").call(zoom.transform, d3.zoomIdentity
  //.translate(paddingLeft, 0))  // uncomment in case the svg has some internal padding (i.e for a yAxis)
     // if there's paddingLeft then this would be ((width - paddingLeft) / (s[1] - s[0]))
    .scale(width / (s[1] - s[0]))
    .translate(-s[0], 0));
}

It's the same as in the brush/zoom example, those two must be kept in sync when one of them change.

However, it isn't possible to change the scale and translate accordingly without also triggering the zoom operations

Yes, It is by replacing the selection.node().__zoom instance. However that's not recommended according to the docs, but it's documented. Otherwise, you may just detach the zoom listener before the transform call and then attach it later again or wrap the scale/translate call in a "lock". Not sure if you actually need to do this in your case.

this means that the operation will just be carried out with the offset, and doesn't get us anywhere).

No, there is no offset, as long as you keep the state in sync.

mbostock commented 5 years ago

@nitely’s answer is correct. This isn’t a problem with the zoom behavior, but inconsistent state created as a side-effect of your resize event listener.