Closed herrvigg closed 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.
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.
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...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.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.Thank you again, your comments are very helpful. :+1:
mousedown
as expected. Good!@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));
}
we can get mouseXY by d3.mouse(theZoomNode)
in handleZoom , then dispatch event or pass to your handler directly .
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");
}
});
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.
@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! 🤗
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
[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/