d3 / d3-zoom

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

zoom behavior disables click events on child elements #66

Closed herrvigg closed 8 years ago

herrvigg commented 8 years ago

hello, i encountered some difficulties to migrate some code from d3 API v3 to v4. Most of the issues are fixed but now i'm completely stuck on the new zoom behavior.

My zoom behavior is a bit special : only on X-axis, for both pan (translation) + "zoom". But i don't zoom the images, i rescale the x-axis only. All of this worked well with v3.

I call zoom as usual with svg.call(zoom), it's the top level element. But, when zoom is active, the clickable labels do not respond to "click" events anymore. If i reload everything just by commenting the call(zoom) then it works. You can play with the 2 buttons to see the effect by yourself.

Addendum: sometimes the clicks work with zoom! But then, if you start a pan action from an item, it becomes inactive again and it blocks all the other items as well. There's something wrong going on with the event chain : it works randomly and eventually gets stuck.

The zoom behavior seems to be attached on the svg only, but still it has an effect on other child elements. I tried to remove the zoom behavior from the clickable labels after setting the zoom on the main svg, but it has no effect.

I tried to apply the zoom behavior only on the specific elements (every concerned subitem), but then it becomes a mess. It seems better to keep it on the higher level (svg).

I tried to read more about the events, preventDefault and so on, but let's admit it, it is very hard to understand what is going on and what changed. With d3 v3 i didn't have this problem.

Any suggestion? Help is much welcome. Thank you in advance !

For info here is the code used to create the zoom behavior and the events handlers

// setup zoom allowing only pan+zoom on X
  var zoomPanX = d3.zoom()
    .scaleExtent([1, 9]) // zoom scale x1 to x9
    .translateExtent([[-width/2, 0], [width+width/2, height]]) // pan limits in the viewport (pan offsets = +/-{w/2})
    .on("zoom", zoomed)
    .on("end", zoomended);

  function zoomed() {
    var transform = d3.event.transform;
    // constraint transformation, no translation on Y axis
    transform.y = 0;
    // recompute a new scale with current transformation
    xScale = transform.rescaleX(xScaleBase);
    xAxis.scale(xScale);
    redrawSvg(false); // no transition
  }

  // ...
  // enable click events --- the issue is : selectSerie is not triggered on click anymore
  chart.selectAll("g.legend")
    .on("click", selectSerie);

  // ...
  // activate zoom on chart (svg)
  chart.call(zoomPanX);

[Update 2016-10-29] i removed the sample showing the issue as it was not an independent piece of code and it has been solved with d3v4 meanwhile. You can see the current result in action and it's fun ;)

http://psionic-storm.com/classement-des-heros/

mbostock commented 8 years ago

The difference with v4 is documented here in the release notes:

The zoom behavior now consumes handled events, making it easier to combine with other interactive behaviors such as dragging.

Here “consumes handled events” means calling event.stopImmediatePropagation and where possible event.preventDefault. More details are found in the API Reference’s table of handled events. Note the complication that, due to browser bugs, the mousedown event cannot have its default behavior prevented; see d3/d3-drag#9 for details. (For the implementation, see the calls to nopropagation and noevent in zoom.js and noevent.js.)

The motivation for this change is to avoid accidental concurrent gestures. In v3, the default behavior was to allow concurrent gestures, which often lead to undefined behavior. In v4, the behaviors consume events and thus are exclusive by default. As a result, v4 makes it much easier to combine behaviors; for an example, see Drag & Zoom II. In contrast; the v3 Drag + Zoom example required you to explicitly stop propagation on the source event of a drag manually to prevent a concurrent zoom gesture.

An implication of this change in v4 is that if you apply the zoom behavior to an element, and then register a mousedown event listener on that element, you will never see mousedown events because the zoom behavior will have consumed the event. However, the normal rules of event propagation still apply, so if you register the mousedown event listener before applying the zoom behavior, or if you use a capturing event listener, or you register a non-capturing listener on a descendant element, your mousedown listener will see the event before the zoom behavior and thus have a chance to handle the event. You can then call event.stopImmediatePropagation to prevent the zoom behavior from seeing it, if desired, or you can elect to allow propagation and concurrent interactions. You can also use zoom.filter to control which events the zoom behavior uses to initiate a zoom gesture.

The click event is a special case. In general, it is not recommended to combine clicking and zooming on the same element because the interpretation of a sequence of input events is ambiguous: should a sequence of mousedown, mousemove*, mouseup be interpreted as a click, or as a panning gesture? Often the desired answer depends on whether the mouse moved between mousedown and mouseup: we only want a click if the mouse has not moved. This is implemented in nodrag.js, and note where g.moved is set to true in zoom.js.

Unfortunately, the definition of “moved” is also ambiguous. The d3-drag and d3-zoom behaviors consider the mouse to have moved if any mousemove event is received between mousedown and mouseup. If you have a particularly sensitive mouse, it can be difficult to click because the a mousemove event is inadvertently dispatched and the click is instead interpreted as a (small) panning gesture. Also, there may be a browser or platform bug (#65) where a mousemove event is dispatched even if the mouse did not move at all.

When #65 (and d3/d3-drag#28) is fixed, the zoom behavior will be more robust at distinguishing between input event sequences that are clicks and those that are small panning gestures. If desired, you can also avoid the ambiguity by stopping propagation on mousedown on your clickable elements, such that you can only pan by clicking on the background of the chart.

herrvigg commented 8 years ago

Thank you sir! Many thanks for the time you spent answering this with so many details. It was really useful, much appreciated!

The good news : with this i found a solution! But there are a few mysterious points and it might be useful to other developers. Also, maybe a guideline should be written on how to disable zoom on specific sub elements. Though it works for me, i'm not sure my solution is the best for all.

  1. Changes with d3 v4.2.8 From yesterday, the example i gave you to illustrate the problem suddenly worked! I couldn't reproduce the problem, that was a bit crazy... But then i noticed that you delivered a new version 4.2.8 a few hours before! So, the problem of concurrent events that used to happen in 4.2.6 and 4.2.7 does not happen in 4.2.8 anymore. This new version should fix an issue about "interrupting transition events", that i don't use here. My observation is that with 4.2.8, the zoom doesn't eat the event so the click events on sub elements also triggers (both zoom and click)! So, something else changed. But considering what you wrote, i need a more reliable solution so i went back to 4.2.6 just to validate the correction as follows.
  2. Capturing events to disable zoom on sub elements (partially) The solution i found is to intercept the mousedown events as you suggested. This might help others, so here it is: chart.selectAll(".activable").on("mousedown", function() { d3.event.stopImmediatePropagation(); }) // where activable is a CSS class for all clickable sub elements But, strangely, this works regardless of the order! If i call chart.call(zoomPanX) before or after this, it's the same, which is weird as i would expect the zoom to be disabled only if i capture the mousedown events before. So it works, but i can't really understand what's going on with the events...
  3. How to completely disable the zoom on a sub element? With this solution, the effect is that the mousedown does not trigger the pan. The click event is then correctly triggered. But, when i release the button, the pan becomes effective from the point i first clicked! It's a bit hard to describe, but technically speaking, the stopImmediatePropagation on mousedown did not prevent completely the pan, and this regardless of the order of the callbacks. Logically, i thought that mouseup event could be used the same way, but it had no effect. In my case, it's not really annoying as it still works, but i suggest a guideline should be given on how to properly disable zoom on a sub element. Maybe there is something missing internally for doing this in an easier way.
mbostock commented 8 years ago
  1. The only change in 4.2.8 was fixing the reporting of transition interrupt events, and your code looks like it is using transitions, so perhaps it is related. (Also, the zoom behavior will initiate a transition by default on double-click to zoom in.)
  2. It works regardless of order because the activable elements (chart.selectAll(".activable")) are descendants of the chart element (chart), and per the browser event propagation rules I linked previously, non-capturing listeners on descendant elements will receive events before non-capturing listeners on ancestor elements. The only time that order matters if you register the same type of listener (capturing or non-capturing) on the same element.
  3. Sorry, but not sure I understand. If you prevent the zoom behavior from seeing the initial mousedown event, it won’t be listening for any subsequent mousemove and mouseup events, so I can’t see how anything would happen on mouseup.
herrvigg commented 8 years ago

Thank you again, your comments are very helpful. :+1:

  1. To be sure, i removed all transitions and it is the same : with 4.2.8 the click events fire correctly although the zoom is active. I can't explain it.
  2. That wasn't clear to me but now it is. Many things to be aware of, not easy to grasp it all if not coding such handlers at "low level".
  3. Sorry for me, I don't manage to reproduce it and i may have misinterpreted the real effect of the click action as it now triggers correctly. The pan now seems totally disabled when intercepting mousedown as expected. Good!
pbeshai commented 7 years ago

@mbostock said:

If desired, you can also avoid the ambiguity by stopping propagation on mousedown on your clickable elements, such that you can only pan by clicking on the background of the chart.

I was curious about using this approach since I want people to be able to click points but also to be able to pan after dragging far enough. In my solution, the SVG is set up as follows:

<svg>
  <g>
    ... everything else ...
  </g>
</svg>

The zoom handler is attached to the <svg> node itself, so we listen for mouse events on the <g> tag to intercept before it reaches the zoom listener. In the code below this.svg refers to the d3-selected <svg> tag and this.svg.select('g') the d3-selected <g> tag.

The basic idea is to only propagate a mousedown event to the zoom listener if the mouse has moved far enough away from the mousedown point. In this example, the mouse must move 5 pixels (mouseMoveThreshold) before the zoom mousedown handler is called. Here's the code:

// setup special mouse handling to enable click instead of pan unless the mouse
// moves mouseMoveThreshold many pixels
let mouseDowned = false;
let mouseDownCoords;
let mouseDownEvent;

// Number of pixels a mouse needs to move before a drag is registered when
// the mousedown takes place on the highlight circle.
const mouseMoveThreshold = 5;

// note: it is important select a node that covers the whole mousedownable area
// but is beneath the node where the zoom is attached. In this case
// we have <svg><g>...</g></svg> and we attach the zoom to svg and this
// listener to the g.
this.svg.select('g')
  .on('mousedown.highlight', () => {
    mouseDowned = true;

    // create a copy of the mousedown event to propagate later if needed
    mouseDownEvent = new MouseEvent(d3.event.type, d3.event);

    // keep coords handy to check distance to see if mouse moved far enough
    mouseDownCoords = [mouseDownEvent.x, mouseDownEvent.y];

    // do not propagate this mousedown to zoom immediately
    // we will fake it with the mouseDownEvent if the mouse moves far enough.
    d3.event.stopPropagation();
  });

this.svg.on('mouseup.highlight click.highlight', () => {
  mouseDowned = false;
});

this.svg.on('mousemove.highlight', () => {
  // skip tracking mouse move if we didn't mousedown on the highlight item
  if (!mouseDowned) {
    return;
  }

  const mouse = [d3.event.x, d3.event.y];
  const distance = lineLength(mouse, mouseDownCoords);

  // we have reached a big enough distance to pass over to the zoom handler
  if (distance > mouseMoveThreshold) {
    mouseDowned = false;

    // dispatch original mouse down event to the zoom handler
    // note that here the mousedown event is dispatched directly on the
    // zoom element (this.svg), which bypasses our mousedown listener above.
    this.svg.node().dispatchEvent(mouseDownEvent);
  }
});

// setup zoom controls
this.zoom = d3.zoom()
  .scaleExtent([1, 10]) // min and max zoom scale amounts
  .translateExtent([[0, 0], [width, height]])
  .on('zoom', this.handleZoom);

this.svg.call(this.zoom);

// helper to compute length of a line (distance between two points)
function lineLength(a, b) {
  return Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2));
}
houfeng0923 commented 7 years ago

we can get mouseXY by d3.mouse(theZoomNode) in handleZoom , then dispatch event or pass to your handler directly .

woodz- commented 7 years ago

I would like to hook in on @pbeshai's preliminary citation.

If desired, you can also avoid the ambiguity...

What it might make it a bit easier, I am not having clickable objects, just hovering ones. I have a world map and like to show country details via tool tips while still maintaining zoom & pan by d3js v4. I am struggling with the exclusive thing where I can make the labels to show on countries but not pan & zoom on countries the same time. pan & zoom on the water (the background) is always possible, but not enough. Are there any directions with examples to solve this in way to have the ability to pan & zoom & show labels on countries and still to pan & zoom on the water? The relevant code parts are below (will provide full function if required):

//enable pan and zoom
  svg.append("rect")
    .attr("width", width)
    .attr("height", height)
    .style("fill", "none")
    .style("pointer-events", "all")
    .call(d3.zoom()
      .scaleExtent([1 / 2, 4])
      .on("zoom", zoomed));

  function zoomed() {
     g.attr("transform", d3.event.transform);
  }

//do responsive and projection stuff here

        d3.json("https://unpkg.com/world-atlas@1/world/50m.json", function ready(error, world) {
          if (error) throw error;    
        var country = g.selectAll('.country')
          .data(topojson.feature(world, world.objects.countries).features)
          .enter().append("g")
            .attr("class", "country");

        var tooltip = d3.select("body")
          .append("div")
          .style("position", "absolute")
          .style("z-index", "10")
          .style("visibility", "hidden")
          .style("background", "#eaf4fd")
          .style("padding", ".1em")
          .text("a simple tooltip");

        var wDat = #{fgView.worldPltdat}; //server side data injection

        wdArr = Object.keys(wDat).map(function ( key ) { return wDat[key]; });

        var dmMax = d3.max(wdArr, function(d) { return d; });
        var dmMin = d3.min(wdArr, function(d) { return d; });
        var colorScale = d3.scaleQuantile()
          .domain([dmMin, dmMax])
          .range(colors);

        country.append('path')
          .attr('d', path)  // re-project path data
          .attr("id", function(d) { return d.id; })
          .style("fill", function(d) {var cs = colorScale(wDat[i18nic.numericToAlpha3(d.id)]); return cs != null ? cs : 'oldlace';})
          .style("stroke", "#666")
          .style("stroke-width", ".015em")
          .on("mouseover", function(d){
            showCoLbl(d);
          })
          .on("mousemove", function(d) {
            showCoLbl(d);
          })
          .on("mouseout",  function(d) {
            tooltip.style("visibility", "hidden");
          });

        function showCoLbl(d) {
          tooltip.text(i18nic.numericToAlpha3(d.id) + " | " + Math.round(wDat[i18nic.numericToAlpha3(d.id)]) + " | " + i18nic.getName(d.id, "de"));
          tooltip.style("visibility", "visible")
            .style("top", (d3.event.pageY-10)+"px")
            .style("left",(d3.event.pageX+10)+"px");
        }
      });
namwkim commented 7 years ago

I am curious why "if (!filter.apply(this, arguments)) return;" is not called on "touchmove" e and "touchend", etc. It is currently only called on "touchstart".

I am thinking this 'filter' function can be used to control the event propagation. Maybe I am missing something.

mbostock commented 7 years ago

@namwkim The zoom.filter function only applies to events that can initiate a zoom gesture; it does not apply to events once the zoom gesture has already initiated. If you want to prevent other events from reaching the zoom behavior, you’ll need to capture them before they reach the zoom behavior and then call event.stopPropagation or event.stopImmediatePropagation.

If you have a question about D3’s behavior and want to discuss it with other users, also consider the d3-js Google Group or joining the d3-js Slack.

Thank you! 🤗