2
votes

The following code successfully allows my canvas to be panned and zoomed. There is also a button that will reset to the original state. However, after I continue to pan or zoom after hitting the button, it reverts to the translate/scale that was present before the button was pressed.

This is in d3 v7, and I see answers for v3 using d3.behavior.zoom, but I believe that is no longer a property. It seems like there is some internal state stored in the event for "zoom" - is there a way to set that to (x:0,y:0,k:1)? Is there a flaw in my code?

<script src="d3.js"></script>
<script>

var svg_orig = d3.select("svg"),
    width = +svg_orig.attr("width"),
    height = +svg_orig.attr("height");

var svg = svg_orig.append("g");

var zoom = d3.zoom()
              .scaleExtent([.5,5])
        
svg_orig.call(zoom.on("zoom", function (event) {
              svg.attr("transform", function(e){return `translate(${event.transform.x},${event.transform.y}) scale(${event.transform.k})`});
         }))

...

// reset button code attempts
function reset(){
  // svg.attr("transform", d3.zoomIdentity);
  // svg.call(zoom.transform,d3.zoomIdentity.translate(0, 0).scale(1));
  svg.transition().duration(500).attr("transform", d3.zoomIdentity);
}
1
Looks like this problem. But I see you have commented out similar code. svg.call(zoom.transform,d3.zoomIdentity) doesn't work for you in the reset function? - Andrew Reid
@AndrewReid Doesn't work for me. when I click the button to reset() with that, it will center and zoom to original. but the moment i drag or zoom, it will start from the state just before resetting. so if i zoom 5x, then click reset it will go to 1x. but if i then drag the canvas, it goes back to 5x. - user4446237
Sorry, my bad, didn't read closely enough: you need to call the transform on the same element the zoom was originally called on: svg_orig : svg_orig.call(zoom.transform,d3.zoomIdentity). (d3-zoom stores the zoom state on the element it is called). - Andrew Reid
That worked. Are you able to submit this as an answer and fill me in on the behind the scenes of what happens? Why does it work when calling on svg_orig, but jumps to another state with svg? - user4446237
Will do, but a bit later today. - Andrew Reid

1 Answers

1
votes

Let's consider an analogy using d3-drag. A drag is essentially a zoom with a fixed scale/pan only (I seem to recall a discussion about removing d3-drag altogether given the overlap with d3-zoom).

Take following example:

const svg = d3.select("svg");
const datum = {x: 100, y:100}
const drag = d3.drag()
  .on("drag", function(event,d) {
    d3.select(this)
      .attr("cx",d.x = event.x)
      .attr("cy",d.y = event.y);
  })

svg.append("circle")
  .datum(datum)
  .attr("cx",d=>d.x)
  .attr("cy",d=>d.y)
  .attr("r", 10)
  .call(drag);
circle { cursor: pointer; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width=500 height=300></svg>

Drags will be relative to the datum which in essence stores the "drag state". The data and the element, while bound, are still separate: we have to explicitly update the element's visual representation after the data is updated.

Now let's move the circle without updating the drag state:

const svg = d3.select("svg");
const datum = {x: 100, y:100}
const drag = d3.drag()
  .on("drag", function(event,d) {
    d3.select(this)
      .attr("cx",d.x = event.x)
      .attr("cy",d.y = event.y);
  })

svg.append("circle")
  .datum(datum)
  .attr("cx",d=>d.x)
  .attr("cy",d=>d.y)
  .attr("r", 10)
  .call(drag);
 
d3.select("button").on("click", function() {
     d3.select("circle")
       .attr("cx", 300)
       .attr("cy", 50)
  })
circle { cursor: pointer; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<button>Move</button>
<svg width=500 height=300></svg>

If we move the circle with the button we reposition the circle without updating the datum: this results in a jump afterwards when dragging: the drag is relative to the datum, but the feature is no longer positioned according to the datum.

The above snippet positions the circle without updating the drag state, much the same as when you use:

  svg.transition().duration(500).attr("transform", d3.zoomIdentity);

You are changing the transform without updating the zoom state.

The only real difference here between the two is that the zoom state is not stored on the datum - it is stored elsewhere on the element. Something that is fairly easily fixed with d3-drag: just update the datum when positioning the circle:

const svg = d3.select("svg");
const datum = {x: 100, y:100}
const drag = d3.drag()
  .on("drag", function(event,d) {
    d3.select(this)
      .attr("cx",d.x = event.x)
      .attr("cy",d.y = event.y);
  })

svg.append("circle")
  .datum(datum)
  .attr("cx",d=>d.x)
  .attr("cy",d=>d.y)
  .attr("r", 10)
  .call(drag);
 
d3.select("button").on("click", function() {
     d3.select("circle")
       .attr("cx", d=>d.x=300)
       .attr("cy", d=>d.y=50)
  })
circle { cursor: pointer; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<button>Move</button>
<svg width=500 height=300></svg>

With d3-zoom the zoom state is not as accessible. The zoom behavior updates zoom state during zoom events, so we need to trigger a zoom event to update the zoom state. As the zoom event is usually used to update the visual representation of the elements, we also ensure agreement between zoom state and visual representation.

Now you almost had the right approach in the code that is commented out:

 svg.attr("transform", d3.zoomIdentity);

Except you need to trigger a zoom event on the same element you called the zoom behavior originally. Like a drag, a zoom behavior can be called on multiple elements: it is just a behavior and doesn't track any state itself:

const svg = d3.select("svg");
const data = [{x: 100, y:100},{x:200,y:100}]
const zoom = d3.zoom()
  .on("zoom", function(event,d) {
    d3.select(this).select("circle")
      .attr("transform",event.transform)
  })

svg.selectAll(null)
  .data(data)
  .enter()
  .append("g")
  .call(zoom)
  .append("circle")
  .attr("cx",d=>d.x)
  .attr("cy",d=>d.y)
  .attr("r", 10)
  
 
circle { cursor: pointer; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width=500 height=300></svg>

The above snippet uses the same zoom behavior twice, once for each circle (zoom interaction is only with circles themselves, not empty space). Ideally this shows that the behavior is independent of the zoom state. As noted above, the zoom state is stored on the element itself, if the behavior tracked zoom state, we couldn't use it on multiple elements.

So if we use:

 svg.call(zoom)

Followed by:

 svg2.call(zoom.transform,someTransform)

The zoom state stored on svg won't be affected/updated when we apply the zoom transform on svg2 - resulting in a jump when we resume zooming on svg.

The reason the result looks ok at first when using zoom.transform is because the zoom event updates the visual representation of the the target elements here (what you call the zoom on and what you modify in the event handler are of course independent):

const svg = d3.select("svg");
const data = [{x: 100, y:100},{x:200,y:100}]
const zoom = d3.zoom()
  .on("zoom", function(event,d) {
    d3.select(d.target).select("circle")
      .attr("fill", d3.scaleLinear()
          .range(["orange","lightgreen","steelblue"])
          .domain([-250,0,250])(event.transform.x))
      .attr("r",d3.scaleLinear()
         .range([50,10,50])
         .domain([-125,0,125])(event.transform.y))
  })

svg.selectAll(null)
  .data(data)
  .enter()
  .append("g")
  .call(zoom)
  .append("circle")
  .attr("cx",d=>d.x)
  .attr("cy",d=>d.y)
  .attr("r", 20)
  .each(function(d,i,nodes) {
    d.target = nodes[+!i].parentNode;
  })
circle { cursor: pointer; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.0.0/d3.min.js"></script>
<svg width=500 height=300></svg>

Again - zoom interaction only on the circles, drag/pan a circle to interact with the other one